feat: switch backend indexing to code-first identifiers

This commit is contained in:
2026-04-03 16:25:11 +00:00
parent 58d3ca6ad0
commit ae353afbed
10 changed files with 354 additions and 377 deletions

View File

@@ -10,6 +10,8 @@ from app.core.config import get_db
from app.models import models
from app.models.task import Task, TaskStatus, TaskPriority
from app.models.milestone import Milestone
from app.models.proposal import Proposal
from app.models.essential import Essential
from app.schemas import schemas
from app.services.webhook import fire_webhooks_sync
from app.models.notification import Notification as NotificationModel
@@ -21,14 +23,9 @@ from app.services.dependency_check import check_task_deps
router = APIRouter(tags=["Tasks"])
def _resolve_task(db: Session, identifier: str) -> Task:
"""Resolve a task by numeric id or task_code string.
Raises 404 if not found."""
try:
task_id = int(identifier)
task = db.query(Task).filter(Task.id == task_id).first()
except (ValueError, TypeError):
task = db.query(Task).filter(Task.task_code == identifier).first()
def _resolve_task(db: Session, task_code: str) -> Task:
"""Resolve a task by task_code string. Raises 404 if not found."""
task = db.query(Task).filter(Task.task_code == task_code).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@@ -118,9 +115,7 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
return n
def _resolve_project_id(db: Session, project_id: int | None, project_code: str | None) -> int | None:
if project_id:
return project_id
def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
if not project_code:
return None
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
@@ -129,40 +124,36 @@ def _resolve_project_id(db: Session, project_id: int | None, project_code: str |
return project.id
def _resolve_milestone(db: Session, milestone_id: int | None, milestone_code: str | None, project_id: int | None) -> Milestone | None:
if milestone_id:
query = db.query(Milestone).filter(Milestone.id == milestone_id)
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
elif milestone_code:
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
else:
def _resolve_milestone(db: Session, milestone_code: str | None, project_id: int | None) -> Milestone | None:
if not milestone_code:
return None
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
return milestone
def _find_task_by_id_or_code(db: Session, identifier: str) -> Task | None:
try:
task_id = int(identifier)
task = db.query(Task).filter(Task.id == task_id).first()
if task:
return task
except ValueError:
pass
return db.query(Task).filter(Task.task_code == identifier).first()
def _find_task_by_code(db: Session, task_code: str) -> Task | None:
return db.query(Task).filter(Task.task_code == task_code).first()
def _serialize_task(db: Session, task: Task) -> dict:
payload = schemas.TaskResponse.model_validate(task).model_dump(mode="json")
project = db.query(models.Project).filter(models.Project.id == task.project_id).first()
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
proposal_code = None
essential_code = None
if task.source_proposal_id:
proposal = db.query(Proposal).filter(Proposal.id == task.source_proposal_id).first()
proposal_code = proposal.propose_code if proposal else None
if task.source_essential_id:
essential = db.query(Essential).filter(Essential.id == task.source_essential_id).first()
essential_code = essential.essential_code if essential else None
assignee = None
if task.assignee_id:
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
@@ -174,6 +165,8 @@ def _serialize_task(db: Session, task: Task) -> dict:
"milestone_code": milestone.milestone_code if milestone else None,
"taken_by": assignee.username if assignee else None,
"due_date": None,
"source_proposal_code": proposal_code,
"source_essential_code": essential_code,
})
return payload
@@ -191,8 +184,8 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
else:
data.pop("type", None)
data["project_id"] = _resolve_project_id(db, data.get("project_id"), data.pop("project_code", None))
milestone = _resolve_milestone(db, data.get("milestone_id"), data.pop("milestone_code", None), data.get("project_id"))
data["project_id"] = _resolve_project_id(db, data.pop("project_code", None))
milestone = _resolve_milestone(db, data.pop("milestone_code", None), data.get("project_id"))
if milestone:
data["milestone_id"] = milestone.id
data["project_id"] = milestone.project_id
@@ -201,17 +194,12 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
data["created_by_id"] = current_user.id
if not data.get("project_id"):
raise HTTPException(status_code=400, detail="project_id or project_code is required")
raise HTTPException(status_code=400, detail="project_code is required")
if not data.get("milestone_id"):
raise HTTPException(status_code=400, detail="milestone_id or milestone_code is required")
raise HTTPException(status_code=400, detail="milestone_code is required")
check_project_role(db, current_user.id, data["project_id"], min_role="dev")
if not milestone:
milestone = db.query(Milestone).filter(
Milestone.id == data["milestone_id"],
Milestone.project_id == data["project_id"],
).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
@@ -237,7 +225,7 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
bg.add_task(
fire_webhooks_sync,
event,
{"task_id": db_task.id, "title": db_task.title, "type": db_task.task_type, "status": db_task.status.value},
{"task_code": db_task.task_code, "title": db_task.title, "type": db_task.task_type, "status": db_task.status.value},
db_task.project_id,
db,
)
@@ -247,22 +235,22 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
@router.get("/tasks")
def list_tasks(
project_id: int = None, task_status: str = None, task_type: str = None, task_subtype: str = None,
task_status: str = None, task_type: str = None, task_subtype: str = None,
assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50,
project: str = None, milestone: str = None, status_value: str = Query(None, alias="status"), taken_by: str = None,
project_code: str = None, milestone_code: str = None, status_value: str = Query(None, alias="status"), taken_by: str = None,
order_by: str = None,
db: Session = Depends(get_db)
):
query = db.query(Task)
resolved_project_id = _resolve_project_id(db, project_id, project)
resolved_project_id = _resolve_project_id(db, project_code)
if resolved_project_id:
query = query.filter(Task.project_id == resolved_project_id)
if milestone:
milestone_obj = _resolve_milestone(db, None, milestone, resolved_project_id)
if milestone_code:
milestone_obj = _resolve_milestone(db, milestone_code, resolved_project_id)
query = query.filter(Task.milestone_id == milestone_obj.id)
effective_status = status_value or task_status
@@ -316,14 +304,14 @@ def list_tasks(
@router.get("/tasks/search", response_model=List[schemas.TaskResponse])
def search_tasks_alias(
q: str,
project: str = None,
project_code: str = None,
status: str = None,
db: Session = Depends(get_db),
):
query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q))
)
resolved_project_id = _resolve_project_id(db, None, project)
resolved_project_id = _resolve_project_id(db, project_code)
if resolved_project_id:
query = query.filter(Task.project_id == resolved_project_id)
if status:
@@ -332,15 +320,15 @@ def search_tasks_alias(
return [_serialize_task(db, i) for i in items]
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
def get_task(task_id: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
@router.get("/tasks/{task_code}", response_model=schemas.TaskResponse)
def get_task(task_code: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_code)
return _serialize_task(db, task)
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse)
def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = _resolve_task(db, task_id)
@router.patch("/tasks/{task_code}", response_model=schemas.TaskResponse)
def update_task(task_code: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = _resolve_task(db, task_code)
# P5.7: status-based edit restrictions
current_status = task.status.value if hasattr(task.status, 'value') else task.status
@@ -437,9 +425,9 @@ def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Dep
return _serialize_task(db, task)
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = _resolve_task(db, task_id)
@router.delete("/tasks/{task_code}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = _resolve_task(db, task_code)
check_project_role(db, current_user.id, task.project_id, min_role="mgr")
log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
db.delete(task)
@@ -454,9 +442,9 @@ class TransitionBody(BaseModel):
comment: Optional[str] = None
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
@router.post("/tasks/{task_code}/transition", response_model=schemas.TaskResponse)
def transition_task(
task_id: str,
task_code: str,
bg: BackgroundTasks,
new_status: str | None = None,
body: TransitionBody = None,
@@ -467,7 +455,7 @@ def transition_task(
valid_statuses = [s.value for s in TaskStatus]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
task = _resolve_task(db, task_id)
task = _resolve_task(db, task_code)
old_status = task.status.value if hasattr(task.status, 'value') else task.status
# P5.1: enforce state-machine
@@ -547,18 +535,18 @@ def transition_task(
event = "task.closed" if new_status == "closed" else "task.updated"
bg.add_task(fire_webhooks_sync, event,
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
{"task_code": task.task_code, "title": task.title, "old_status": old_status, "new_status": new_status},
task.project_id, db)
return _serialize_task(db, task)
@router.post("/tasks/{task_id}/take", response_model=schemas.TaskResponse)
@router.post("/tasks/{task_code}/take", response_model=schemas.TaskResponse)
def take_task(
task_id: str,
task_code: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
task = _find_task_by_id_or_code(db, task_id)
task = _find_task_by_code(db, task_code)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
@@ -577,7 +565,7 @@ def take_task(
db,
current_user.id,
"task.assigned",
f"Task {task.task_code or task.id} assigned to you",
f"Task {task.task_code} assigned to you",
f"'{task.title}' has been assigned to you.",
"task",
task.id,
@@ -587,9 +575,9 @@ def take_task(
# ---- Assignment ----
@router.post("/tasks/{task_id}/assign")
def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
@router.post("/tasks/{task_code}/assign")
def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db)):
task = _resolve_task(db, task_code)
user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -597,33 +585,33 @@ def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)):
db.commit()
db.refresh(task)
_notify_user(db, assignee_id, "task.assigned",
f"Task #{task.id} assigned to you",
f"Task {task.task_code} assigned to you",
f"'{task.title}' has been assigned to you.", "task", task.id)
return {"task_id": task.id, "assignee_id": assignee_id, "title": task.title}
return {"task_code": task.task_code, "assignee_id": assignee_id, "title": task.title}
# ---- Tags ----
@router.post("/tasks/{task_id}/tags")
def add_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
@router.post("/tasks/{task_code}/tags")
def add_tag(task_code: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_code)
current = set(task.tags.split(",")) if task.tags else set()
current.add(tag.strip())
current.discard("")
task.tags = ",".join(sorted(current))
db.commit()
return {"task_id": task_id, "tags": list(current)}
return {"task_code": task.task_code, "tags": list(current)}
@router.delete("/tasks/{task_id}/tags")
def remove_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
@router.delete("/tasks/{task_code}/tags")
def remove_tag(task_code: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_code)
current = set(task.tags.split(",")) if task.tags else set()
current.discard(tag.strip())
current.discard("")
task.tags = ",".join(sorted(current)) if current else None
db.commit()
return {"task_id": task_id, "tags": list(current)}
return {"task_code": task.task_code, "tags": list(current)}
@router.get("/tags")
@@ -643,12 +631,12 @@ def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
# ---- Batch ----
class BatchAssign(BaseModel):
task_ids: List[int]
task_codes: List[str]
assignee_id: int
class BatchTransitionBody(BaseModel):
task_ids: List[int]
task_codes: List[str]
new_status: str
comment: Optional[str] = None
@@ -665,17 +653,17 @@ def batch_transition(
raise HTTPException(status_code=400, detail="Invalid status")
updated = []
skipped = []
for task_id in data.task_ids:
task = db.query(Task).filter(Task.id == task_id).first()
for task_code in data.task_codes:
task = db.query(Task).filter(Task.task_code == task_code).first()
if not task:
skipped.append({"id": task_id, "title": None, "old": None,
skipped.append({"task_code": task_code, "title": None, "old": None,
"reason": "Task not found"})
continue
old_status = task.status.value if hasattr(task.status, 'value') else task.status
# P5.1: state-machine check
allowed = VALID_TRANSITIONS.get(old_status, set())
if data.new_status not in allowed:
skipped.append({"id": task.id, "title": task.title, "old": old_status,
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": f"Cannot transition from '{old_status}' to '{data.new_status}'"})
continue
@@ -685,23 +673,23 @@ def batch_transition(
if milestone:
ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status
if ms_status != "undergoing":
skipped.append({"id": task.id, "title": task.title, "old": old_status,
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": f"Milestone is '{ms_status}', must be 'undergoing'"})
continue
dep_result = check_task_deps(db, task.depend_on)
if not dep_result.ok:
skipped.append({"id": task.id, "title": task.title, "old": old_status,
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": dep_result.reason})
continue
# P5.3: open → undergoing requires assignee == current_user
if old_status == "open" and data.new_status == "undergoing":
if not task.assignee_id:
skipped.append({"id": task.id, "title": task.title, "old": old_status,
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "Assignee must be set before starting"})
continue
if current_user.id != task.assignee_id:
skipped.append({"id": task.id, "title": task.title, "old": old_status,
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "Only the assigned user can start this task"})
continue
@@ -709,11 +697,11 @@ def batch_transition(
if old_status == "undergoing" and data.new_status == "completed":
comment_text = data.comment
if not comment_text or not comment_text.strip():
skipped.append({"id": task.id, "title": task.title, "old": old_status,
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "A completion comment is required"})
continue
if task.assignee_id and current_user.id != task.assignee_id:
skipped.append({"id": task.id, "title": task.title, "old": old_status,
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "Only the assigned user can complete this task"})
continue
@@ -722,7 +710,7 @@ def batch_transition(
try:
check_permission(db, current_user.id, task.project_id, "task.close")
except HTTPException:
skipped.append({"id": task.id, "title": task.title, "old": old_status,
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": "Missing 'task.close' permission"})
continue
@@ -732,7 +720,7 @@ def batch_transition(
try:
check_permission(db, current_user.id, task.project_id, perm)
except HTTPException:
skipped.append({"id": task.id, "title": task.title, "old": old_status,
skipped.append({"task_code": task.task_code, "title": task.title, "old": old_status,
"reason": f"Missing '{perm}' permission"})
continue
task.finished_on = None
@@ -742,7 +730,7 @@ def batch_transition(
if data.new_status in ("closed", "completed") and not task.finished_on:
task.finished_on = datetime.utcnow()
task.status = data.new_status
updated.append({"id": task.id, "title": task.title, "old": old_status, "new": data.new_status})
updated.append({"task_code": task.task_code, "title": task.title, "old": old_status, "new": data.new_status})
# Activity log per task
log_activity(db, f"task.transition.{data.new_status}", "task", task.id, current_user.id,
@@ -762,7 +750,7 @@ def batch_transition(
# P3.5: auto-complete milestone for any completed task
for u in updated:
if u["new"] == "completed":
t = db.query(Task).filter(Task.id == u["id"]).first()
t = db.query(Task).filter(Task.task_code == u["task_code"]).first()
if t:
from app.api.routers.milestone_actions import try_auto_complete_milestone
try_auto_complete_milestone(db, t, user_id=current_user.id)
@@ -782,25 +770,27 @@ def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
if not user:
raise HTTPException(status_code=404, detail="Assignee not found")
updated = []
for task_id in data.task_ids:
task = db.query(Task).filter(Task.id == task_id).first()
for task_code in data.task_codes:
task = db.query(Task).filter(Task.task_code == task_code).first()
if task:
task.assignee_id = data.assignee_id
updated.append(task_id)
updated.append(task.task_code)
db.commit()
return {"updated": len(updated), "task_ids": updated, "assignee_id": data.assignee_id}
return {"updated": len(updated), "task_codes": updated, "assignee_id": data.assignee_id}
# ---- Search ----
@router.get("/search/tasks")
def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int = 50,
def search_tasks(q: str, project_code: str = None, page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)):
query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q))
)
if project_id:
query = query.filter(Task.project_id == project_id)
if project_code:
project_id = _resolve_project_id(db, project_code)
if project_id:
query = query.filter(Task.project_id == project_id)
total = query.count()
page = max(1, page)
page_size = min(max(1, page_size), 200)