diff --git a/app/api/routers/essentials.py b/app/api/routers/essentials.py index 7279ce0..2aab585 100644 --- a/app/api/routers/essentials.py +++ b/app/api/routers/essentials.py @@ -1,7 +1,7 @@ """Essentials API router — CRUD for Essentials nested under a Proposal. Endpoints are scoped to a project and proposal: - /projects/{project_id}/proposals/{proposal_id}/essentials + /projects/{project_code}/proposals/{proposal_code}/essentials Only open Proposals allow Essential mutations. """ @@ -26,7 +26,7 @@ from app.services.activity import log_activity from app.services.essential_code import generate_essential_code router = APIRouter( - prefix="/projects/{project_id}/proposals/{proposal_id}/essentials", + prefix="/projects/{project_code}/proposals/{proposal_code}/essentials", tags=["Essentials"], ) @@ -35,53 +35,27 @@ router = APIRouter( # Helpers # --------------------------------------------------------------------------- -def _find_project(db: Session, identifier: str): - """Look up project by numeric id or project_code.""" - try: - pid = int(identifier) - p = db.query(models.Project).filter(models.Project.id == pid).first() - if p: - return p - except (ValueError, TypeError): - pass +def _find_project(db: Session, project_code: str): + """Look up project by project_code.""" return db.query(models.Project).filter( - models.Project.project_code == str(identifier) + models.Project.project_code == str(project_code) ).first() -def _find_proposal(db: Session, identifier: str, project_id: int) -> Proposal | None: - """Look up proposal by numeric id or propose_code within a project.""" - try: - pid = int(identifier) - q = db.query(Proposal).filter(Proposal.id == pid, Proposal.project_id == project_id) - p = q.first() - if p: - return p - except (ValueError, TypeError): - pass +def _find_proposal(db: Session, proposal_code: str, project_id: int) -> Proposal | None: + """Look up proposal by propose_code within a project.""" return ( db.query(Proposal) - .filter(Proposal.propose_code == str(identifier), Proposal.project_id == project_id) + .filter(Proposal.propose_code == str(proposal_code), Proposal.project_id == project_id) .first() ) -def _find_essential(db: Session, identifier: str, proposal_id: int) -> Essential | None: - """Look up essential by numeric id or essential_code within a proposal.""" - try: - eid = int(identifier) - e = ( - db.query(Essential) - .filter(Essential.id == eid, Essential.proposal_id == proposal_id) - .first() - ) - if e: - return e - except (ValueError, TypeError): - pass +def _find_essential(db: Session, essential_code: str, proposal_id: int) -> Essential | None: + """Look up essential by essential_code within a proposal.""" return ( db.query(Essential) - .filter(Essential.essential_code == str(identifier), Essential.proposal_id == proposal_id) + .filter(Essential.essential_code == str(essential_code), Essential.proposal_id == proposal_id) .first() ) @@ -108,12 +82,11 @@ def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool: return False -def _serialize_essential(e: Essential) -> dict: +def _serialize_essential(e: Essential, proposal_code: str | None) -> dict: """Return a dict matching EssentialResponse.""" return { - "id": e.id, "essential_code": e.essential_code, - "proposal_id": e.proposal_id, + "proposal_code": proposal_code, "type": e.type.value if hasattr(e.type, "value") else e.type, "title": e.title, "description": e.description, @@ -129,18 +102,18 @@ def _serialize_essential(e: Essential) -> dict: @router.get("", response_model=List[EssentialResponse]) def list_essentials( - project_id: str, - proposal_id: str, + project_code: str, + proposal_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """List all Essentials under a Proposal.""" - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") check_project_role(db, current_user.id, project.id, min_role="viewer") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") @@ -150,24 +123,24 @@ def list_essentials( .order_by(Essential.id.asc()) .all() ) - return [_serialize_essential(e) for e in essentials] + return [_serialize_essential(e, proposal.propose_code) for e in essentials] @router.post("", response_model=EssentialResponse, status_code=status.HTTP_201_CREATED) def create_essential( - project_id: str, - proposal_id: str, + project_code: str, + proposal_code: str, body: EssentialCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Create a new Essential under an open Proposal.""" - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") check_project_role(db, current_user.id, project.id, min_role="dev") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") @@ -196,50 +169,50 @@ def create_essential( details={"title": essential.title, "type": body.type.value, "proposal_id": proposal.id}, ) - return _serialize_essential(essential) + return _serialize_essential(essential, proposal.propose_code) @router.get("/{essential_id}", response_model=EssentialResponse) def get_essential( - project_id: str, - proposal_id: str, - essential_id: str, + project_code: str, + proposal_code: str, + essential_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - """Get a single Essential by id or essential_code.""" - project = _find_project(db, project_id) + """Get a single Essential by essential_code.""" + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") check_project_role(db, current_user.id, project.id, min_role="viewer") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") - essential = _find_essential(db, essential_id, proposal.id) + essential = _find_essential(db, essential_code, proposal.id) if not essential: raise HTTPException(status_code=404, detail="Essential not found") - return _serialize_essential(essential) + return _serialize_essential(essential, proposal.propose_code) @router.patch("/{essential_id}", response_model=EssentialResponse) def update_essential( - project_id: str, - proposal_id: str, - essential_id: str, + project_code: str, + proposal_code: str, + essential_code: str, body: EssentialUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Update an Essential (only on open Proposals).""" - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") check_project_role(db, current_user.id, project.id, min_role="dev") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") @@ -248,7 +221,7 @@ def update_essential( if not _can_edit_proposal(db, current_user.id, proposal): raise HTTPException(status_code=403, detail="Permission denied") - essential = _find_essential(db, essential_id, proposal.id) + essential = _find_essential(db, essential_code, proposal.id) if not essential: raise HTTPException(status_code=404, detail="Essential not found") @@ -265,24 +238,24 @@ def update_essential( details=data, ) - return _serialize_essential(essential) + return _serialize_essential(essential, proposal.propose_code) @router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_essential( - project_id: str, - proposal_id: str, - essential_id: str, + project_code: str, + proposal_code: str, + essential_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Delete an Essential (only on open Proposals).""" - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") check_project_role(db, current_user.id, project.id, min_role="dev") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") @@ -291,7 +264,7 @@ def delete_essential( if not _can_edit_proposal(db, current_user.id, proposal): raise HTTPException(status_code=403, detail="Permission denied") - essential = _find_essential(db, essential_id, proposal.id) + essential = _find_essential(db, essential_code, proposal.id) if not essential: raise HTTPException(status_code=404, detail="Essential not found") diff --git a/app/api/routers/meetings.py b/app/api/routers/meetings.py index 27b13bd..95a5541 100644 --- a/app/api/routers/meetings.py +++ b/app/api/routers/meetings.py @@ -18,15 +18,8 @@ router = APIRouter(tags=["Meetings"]) # ---- helpers ---- -def _find_meeting_by_id_or_code(db: Session, identifier: str) -> Meeting | None: - try: - mid = int(identifier) - meeting = db.query(Meeting).filter(Meeting.id == mid).first() - if meeting: - return meeting - except (ValueError, TypeError): - pass - return db.query(Meeting).filter(Meeting.meeting_code == str(identifier)).first() +def _find_meeting_by_code(db: Session, meeting_code: str) -> Meeting | None: + return db.query(Meeting).filter(Meeting.meeting_code == str(meeting_code)).first() def _resolve_project_id(db: Session, project_code: str | None) -> int | None: @@ -64,16 +57,13 @@ def _serialize_meeting(db: Session, meeting: Meeting) -> dict: project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first() milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first() return { - "id": meeting.id, "code": meeting.meeting_code, "meeting_code": meeting.meeting_code, "title": meeting.title, "description": meeting.description, "status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status, "priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority, - "project_id": meeting.project_id, "project_code": project.project_code if project else None, - "milestone_id": meeting.milestone_id, "milestone_code": milestone.milestone_code if milestone else None, "reporter_id": meeting.reporter_id, "meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None, @@ -155,6 +145,7 @@ def create_meeting( @router.get("/meetings") def list_meetings( project: str = None, + project_code: str = None, status_value: str = Query(None, alias="status"), order_by: str = None, page: int = 1, @@ -163,8 +154,9 @@ def list_meetings( ): query = db.query(Meeting) - if project: - project_id = _resolve_project_id(db, project) + effective_project = project_code or project + if effective_project: + project_id = _resolve_project_id(db, effective_project) if project_id: query = query.filter(Meeting.project_id == project_id) @@ -197,9 +189,9 @@ def list_meetings( } -@router.get("/meetings/{meeting_id}") -def get_meeting(meeting_id: str, db: Session = Depends(get_db)): - meeting = _find_meeting_by_id_or_code(db, meeting_id) +@router.get("/meetings/{meeting_code}") +def get_meeting(meeting_code: str, db: Session = Depends(get_db)): + meeting = _find_meeting_by_code(db, meeting_code) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") return _serialize_meeting(db, meeting) @@ -213,14 +205,14 @@ class MeetingUpdateBody(BaseModel): duration_minutes: Optional[int] = None -@router.patch("/meetings/{meeting_id}") +@router.patch("/meetings/{meeting_code}") def update_meeting( - meeting_id: str, + meeting_code: str, body: MeetingUpdateBody, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - meeting = _find_meeting_by_id_or_code(db, meeting_id) + meeting = _find_meeting_by_code(db, meeting_code) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") check_project_role(db, current_user.id, meeting.project_id, min_role="dev") @@ -248,13 +240,13 @@ def update_meeting( return _serialize_meeting(db, meeting) -@router.delete("/meetings/{meeting_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/meetings/{meeting_code}", status_code=status.HTTP_204_NO_CONTENT) def delete_meeting( - meeting_id: str, + meeting_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - meeting = _find_meeting_by_id_or_code(db, meeting_id) + meeting = _find_meeting_by_code(db, meeting_code) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") check_project_role(db, current_user.id, meeting.project_id, min_role="dev") @@ -265,13 +257,13 @@ def delete_meeting( # ---- Attend ---- -@router.post("/meetings/{meeting_id}/attend") +@router.post("/meetings/{meeting_code}/attend") def attend_meeting( - meeting_id: str, + meeting_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - meeting = _find_meeting_by_id_or_code(db, meeting_id) + meeting = _find_meeting_by_code(db, meeting_code) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") check_project_role(db, current_user.id, meeting.project_id, min_role="viewer") diff --git a/app/api/routers/milestone_actions.py b/app/api/routers/milestone_actions.py index cbe0f49..1c69fb2 100644 --- a/app/api/routers/milestone_actions.py +++ b/app/api/routers/milestone_actions.py @@ -20,7 +20,7 @@ from app.services.activity import log_activity from app.services.dependency_check import check_milestone_deps router = APIRouter( - prefix="/projects/{project_id}/milestones/{milestone_id}/actions", + prefix="/projects/{project_code}/milestones/{milestone_code}/actions", tags=["Milestone Actions"], ) @@ -29,10 +29,18 @@ router = APIRouter( # Helpers # --------------------------------------------------------------------------- -def _get_milestone_or_404(db: Session, project_id: int, milestone_id: int) -> Milestone: +def _resolve_project_or_404(db: Session, project_code: str): + project = db.query(models.Project).filter(models.Project.project_code == project_code).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +def _get_milestone_or_404(db: Session, project_code: str, milestone_code: str) -> Milestone: + project = _resolve_project_or_404(db, project_code) ms = ( db.query(Milestone) - .filter(Milestone.id == milestone_id, Milestone.project_id == project_id) + .filter(Milestone.milestone_code == milestone_code, Milestone.project_id == project.id) .first() ) if not ms: @@ -59,8 +67,8 @@ class CloseBody(BaseModel): @router.get("/preflight", status_code=200) def preflight_milestone_actions( - project_id: int, - milestone_id: int, + project_code: str, + milestone_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): @@ -69,8 +77,9 @@ def preflight_milestone_actions( The frontend uses this to decide whether to *disable* buttons and what hint text to show. This endpoint never mutates data. """ - check_project_role(db, current_user.id, project_id, min_role="viewer") - ms = _get_milestone_or_404(db, project_id, milestone_id) + project = _resolve_project_or_404(db, project_code) + check_project_role(db, current_user.id, project.id, min_role="viewer") + ms = _get_milestone_or_404(db, project_code, milestone_code) ms_status = _ms_status_value(ms) result: dict = {"status": ms_status, "freeze": None, "start": None} @@ -80,7 +89,7 @@ def preflight_milestone_actions( release_tasks = ( db.query(Task) .filter( - Task.milestone_id == milestone_id, + Task.milestone_id == ms.id, Task.task_type == "maintenance", Task.task_subtype == "release", ) @@ -118,8 +127,8 @@ def preflight_milestone_actions( @router.post("/freeze", status_code=200) def freeze_milestone( - project_id: int, - milestone_id: int, + project_code: str, + milestone_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): @@ -130,10 +139,11 @@ def freeze_milestone( - Milestone must have **exactly one** maintenance task with subtype ``release``. - Caller must have ``freeze milestone`` permission. """ - check_project_role(db, current_user.id, project_id, min_role="mgr") - check_permission(db, current_user.id, project_id, "milestone.freeze") + project = _resolve_project_or_404(db, project_code) + check_project_role(db, current_user.id, project.id, min_role="mgr") + check_permission(db, current_user.id, project.id, "milestone.freeze") - ms = _get_milestone_or_404(db, project_id, milestone_id) + ms = _get_milestone_or_404(db, project_code, milestone_code) if _ms_status_value(ms) != "open": raise HTTPException( @@ -145,7 +155,7 @@ def freeze_milestone( release_tasks = ( db.query(Task) .filter( - Task.milestone_id == milestone_id, + Task.milestone_id == ms.id, Task.task_type == "maintenance", Task.task_subtype == "release", ) @@ -184,8 +194,8 @@ def freeze_milestone( @router.post("/start", status_code=200) def start_milestone( - project_id: int, - milestone_id: int, + project_code: str, + milestone_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): @@ -196,10 +206,11 @@ def start_milestone( - All milestone dependencies must be completed. - Caller must have ``start milestone`` permission. """ - check_project_role(db, current_user.id, project_id, min_role="mgr") - check_permission(db, current_user.id, project_id, "milestone.start") + project = _resolve_project_or_404(db, project_code) + check_project_role(db, current_user.id, project.id, min_role="mgr") + check_permission(db, current_user.id, project.id, "milestone.start") - ms = _get_milestone_or_404(db, project_id, milestone_id) + ms = _get_milestone_or_404(db, project_code, milestone_code) if _ms_status_value(ms) != "freeze": raise HTTPException( @@ -240,8 +251,8 @@ def start_milestone( @router.post("/close", status_code=200) def close_milestone( - project_id: int, - milestone_id: int, + project_code: str, + milestone_code: str, body: CloseBody = CloseBody(), db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), @@ -252,10 +263,11 @@ def close_milestone( - Milestone must be in ``open``, ``freeze``, or ``undergoing`` status. - Caller must have ``close milestone`` permission. """ - check_project_role(db, current_user.id, project_id, min_role="mgr") - check_permission(db, current_user.id, project_id, "milestone.close") + project = _resolve_project_or_404(db, project_code) + check_project_role(db, current_user.id, project.id, min_role="mgr") + check_permission(db, current_user.id, project.id, "milestone.close") - ms = _get_milestone_or_404(db, project_id, milestone_id) + ms = _get_milestone_or_404(db, project_code, milestone_code) current = _ms_status_value(ms) allowed_from = {"open", "freeze", "undergoing"} diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py index 1b4c973..3eeafd7 100644 --- a/app/api/routers/milestones.py +++ b/app/api/routers/milestones.py @@ -48,10 +48,10 @@ def _find_milestone(db, identifier, project_id: int = None) -> Milestone | None: return q.first() -def _serialize_milestone(milestone): - """Serialize milestone with JSON fields and code.""" +def _serialize_milestone(db, milestone): + """Serialize milestone with JSON fields and code-first identifiers.""" + project = db.query(models.Project).filter(models.Project.id == milestone.project_id).first() return { - "id": milestone.id, "title": milestone.title, "description": milestone.description, "status": milestone.status.value if hasattr(milestone.status, 'value') else milestone.status, @@ -59,9 +59,9 @@ def _serialize_milestone(milestone): "planned_release_date": milestone.planned_release_date, "depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [], "depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [], - "project_id": milestone.project_id, "milestone_code": milestone.milestone_code, "code": milestone.milestone_code, + "project_code": project.project_code if project else None, "created_by_id": milestone.created_by_id, "started_at": milestone.started_at, "created_at": milestone.created_at, @@ -76,7 +76,7 @@ def list_milestones(project_id: str, db: Session = Depends(get_db), current_user raise HTTPException(status_code=404, detail="Project not found") check_project_role(db, current_user.id, project.id, min_role="viewer") milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all() - return [_serialize_milestone(m) for m in milestones] + return [_serialize_milestone(db, m) for m in milestones] @router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED) @@ -101,7 +101,7 @@ def create_milestone(project_id: str, milestone: schemas.MilestoneCreate, db: Se db.add(db_milestone) db.commit() db.refresh(db_milestone) - return _serialize_milestone(db_milestone) + return _serialize_milestone(db, db_milestone) @router.get("/{milestone_id}", response_model=schemas.MilestoneResponse) @@ -113,7 +113,7 @@ def get_milestone(project_id: str, milestone_id: str, db: Session = Depends(get_ milestone = _find_milestone(db, milestone_id, project.id) if not milestone: raise HTTPException(status_code=404, detail="Milestone not found") - return _serialize_milestone(milestone) + return _serialize_milestone(db, milestone) @router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse) @@ -163,7 +163,7 @@ def update_milestone(project_id: str, milestone_id: str, milestone: schemas.Mile setattr(db_milestone, key, value) db.commit() db.refresh(db_milestone) - return _serialize_milestone(db_milestone) + return _serialize_milestone(db, db_milestone) @router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index f201610..0040d3e 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -149,18 +149,19 @@ def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), @router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"]) -def list_milestones(project_id: str = None, status_filter: str = None, db: Session = Depends(get_db)): +def list_milestones(project_id: str = None, project_code: str = None, status_filter: str = None, db: Session = Depends(get_db)): query = db.query(MilestoneModel) - if project_id: + effective_project = project_code or project_id + if effective_project: # Resolve project_id by numeric id or project_code resolved_project = None try: - pid = int(project_id) + pid = int(effective_project) resolved_project = db.query(models.Project).filter(models.Project.id == pid).first() except (ValueError, TypeError): pass if not resolved_project: - resolved_project = db.query(models.Project).filter(models.Project.project_code == project_id).first() + resolved_project = db.query(models.Project).filter(models.Project.project_code == effective_project).first() if not resolved_project: raise HTTPException(status_code=404, detail="Project not found") query = query.filter(MilestoneModel.project_id == resolved_project.id) @@ -428,14 +429,21 @@ def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)): # ============ Milestone-scoped Tasks ============ @router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"]) -def list_milestone_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)): +def list_milestone_tasks(project_code: str, milestone_id: str, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.project_code == project_code).first() if not project: raise HTTPException(status_code=404, detail="Project not found") + milestone = db.query(MilestoneModel).filter( + MilestoneModel.milestone_code == milestone_id, + MilestoneModel.project_id == project.id, + ).first() + if not milestone: + raise HTTPException(status_code=404, detail="Milestone not found") + tasks = db.query(Task).filter( Task.project_id == project.id, - Task.milestone_id == milestone_id + Task.milestone_id == milestone.id ).all() return [{ @@ -459,12 +467,12 @@ def list_milestone_tasks(project_code: str, milestone_id: int, db: Session = Dep @router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Tasks"]) -def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): +def create_milestone_task(project_code: str, milestone_id: str, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): project = db.query(models.Project).filter(models.Project.project_code == project_code).first() if not project: raise HTTPException(status_code=404, detail="Project not found") - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first() if not ms: raise HTTPException(status_code=404, detail="Milestone not found") @@ -491,7 +499,7 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, task_type=task_data.get("task_type", "issue"), # P7.1: default changed from 'task' to 'issue' task_subtype=task_data.get("task_subtype"), project_id=project.id, - milestone_id=milestone_id, + milestone_id=ms.id, reporter_id=current_user.id, task_code=task_code, estimated_effort=task_data.get("estimated_effort"), @@ -503,10 +511,10 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, db.refresh(task) return { - "id": task.id, "title": task.title, "description": task.description, "task_code": task.task_code, + "code": task.task_code, "status": task.status.value, "priority": task.priority.value, "created_at": task.created_at, @@ -516,15 +524,8 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, # ============ Supports ============ -def _find_support_by_id_or_code(db: Session, identifier: str) -> Support | None: - try: - support_id = int(identifier) - support = db.query(Support).filter(Support.id == support_id).first() - if support: - return support - except (TypeError, ValueError): - pass - return db.query(Support).filter(Support.support_code == str(identifier)).first() +def _find_support_by_code(db: Session, support_code: str) -> Support | None: + return db.query(Support).filter(Support.support_code == str(support_code)).first() @@ -536,16 +537,13 @@ def _serialize_support(db: Session, support: Support) -> dict: assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first() return { - "id": support.id, "code": support.support_code, "support_code": support.support_code, "title": support.title, "description": support.description, "status": support.status.value if hasattr(support.status, "value") else support.status, "priority": support.priority.value if hasattr(support.priority, "value") else support.priority, - "project_id": support.project_id, "project_code": project.project_code if project else None, - "milestone_id": support.milestone_id, "milestone_code": milestone.milestone_code if milestone else None, "reporter_id": support.reporter_id, "assignee_id": support.assignee_id, @@ -585,26 +583,30 @@ def list_all_supports( @router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"]) -def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)): +def list_supports(project_code: str, milestone_id: str, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.project_code == project_code).first() if not project: raise HTTPException(status_code=404, detail="Project not found") + milestone = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first() + if not milestone: + raise HTTPException(status_code=404, detail="Milestone not found") + supports = db.query(Support).filter( Support.project_id == project.id, - Support.milestone_id == milestone_id + Support.milestone_id == milestone.id ).all() return [_serialize_support(db, s) for s in supports] @router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"]) -def create_support(project_code: str, milestone_id: int, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): +def create_support(project_code: str, milestone_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): project = db.query(models.Project).filter(models.Project.project_code == project_code).first() if not project: raise HTTPException(status_code=404, detail="Project not found") - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first() if not ms: raise HTTPException(status_code=404, detail="Milestone not found") @@ -612,7 +614,7 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db: raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing") milestone_code = ms.milestone_code or f"m{ms.id}" - max_support = db.query(Support).filter(Support.milestone_id == milestone_id).order_by(Support.id.desc()).first() + max_support = db.query(Support).filter(Support.milestone_id == ms.id).order_by(Support.id.desc()).first() next_num = (max_support.id + 1) if max_support else 1 support_code = f"{milestone_code}:S{next_num:05x}" @@ -622,7 +624,7 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db: status=SupportStatus.OPEN, priority=SupportPriority.MEDIUM, project_id=project.id, - milestone_id=milestone_id, + milestone_id=ms.id, reporter_id=current_user.id, support_code=support_code, ) @@ -632,18 +634,18 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db: return _serialize_support(db, support) -@router.get("/supports/{support_id}", tags=["Supports"]) -def get_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): - support = _find_support_by_id_or_code(db, support_id) +@router.get("/supports/{support_code}", tags=["Supports"]) +def get_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_code(db, support_code) if not support: raise HTTPException(status_code=404, detail="Support not found") check_project_role(db, current_user.id, support.project_id, min_role="viewer") return _serialize_support(db, support) -@router.patch("/supports/{support_id}", tags=["Supports"]) -def update_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): - support = _find_support_by_id_or_code(db, support_id) +@router.patch("/supports/{support_code}", tags=["Supports"]) +def update_support(support_code: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_code(db, support_code) if not support: raise HTTPException(status_code=404, detail="Support not found") check_project_role(db, current_user.id, support.project_id, min_role="dev") @@ -668,9 +670,9 @@ def update_support(support_id: str, support_data: dict, db: Session = Depends(ge return _serialize_support(db, support) -@router.delete("/supports/{support_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"]) -def delete_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): - support = _find_support_by_id_or_code(db, support_id) +@router.delete("/supports/{support_code}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"]) +def delete_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_code(db, support_code) if not support: raise HTTPException(status_code=404, detail="Support not found") check_project_role(db, current_user.id, support.project_id, min_role="dev") @@ -679,9 +681,9 @@ def delete_support(support_id: str, db: Session = Depends(get_db), current_user: return None -@router.post("/supports/{support_id}/take", tags=["Supports"]) -def take_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): - support = _find_support_by_id_or_code(db, support_id) +@router.post("/supports/{support_code}/take", tags=["Supports"]) +def take_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_code(db, support_code) if not support: raise HTTPException(status_code=404, detail="Support not found") check_project_role(db, current_user.id, support.project_id, min_role="dev") @@ -697,9 +699,9 @@ def take_support(support_id: str, db: Session = Depends(get_db), current_user: m return _serialize_support(db, support) -@router.post("/supports/{support_id}/transition", tags=["Supports"]) -def transition_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): - support = _find_support_by_id_or_code(db, support_id) +@router.post("/supports/{support_code}/transition", tags=["Supports"]) +def transition_support(support_code: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_code(db, support_code) if not support: raise HTTPException(status_code=404, detail="Support not found") check_project_role(db, current_user.id, support.project_id, min_role="dev") @@ -717,20 +719,25 @@ def transition_support(support_id: str, support_data: dict, db: Session = Depend # ============ Meetings ============ @router.get("/meetings/{project_code}/{milestone_id}", tags=["Meetings"]) -def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(get_db)): +def list_meetings(project_code: str, milestone_id: str, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.project_code == project_code).first() if not project: raise HTTPException(status_code=404, detail="Project not found") + milestone = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first() + if not milestone: + raise HTTPException(status_code=404, detail="Milestone not found") + meetings = db.query(Meeting).filter( Meeting.project_id == project.id, - Meeting.milestone_id == milestone_id + Meeting.milestone_id == milestone.id ).all() return [{ - "id": m.id, "title": m.title, "description": m.description, + "meeting_code": m.meeting_code, + "code": m.meeting_code, "status": m.status.value, "priority": m.priority.value, "scheduled_at": m.scheduled_at, @@ -740,12 +747,12 @@ def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(ge @router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Meetings"]) -def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): +def create_meeting(project_code: str, milestone_id: str, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): project = db.query(models.Project).filter(models.Project.project_code == project_code).first() if not project: raise HTTPException(status_code=404, detail="Project not found") - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first() if not ms: raise HTTPException(status_code=404, detail="Milestone not found") @@ -753,7 +760,7 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing") milestone_code = ms.milestone_code or f"m{ms.id}" - max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).order_by(Meeting.id.desc()).first() + max_meeting = db.query(Meeting).filter(Meeting.milestone_id == ms.id).order_by(Meeting.id.desc()).first() next_num = (max_meeting.id + 1) if max_meeting else 1 meeting_code = f"{milestone_code}:M{next_num:05x}" @@ -770,7 +777,7 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: status=MeetingStatus.SCHEDULED, priority=MeetingPriority.MEDIUM, project_id=project.id, - milestone_id=milestone_id, + milestone_id=ms.id, reporter_id=current_user.id, meeting_code=meeting_code, scheduled_at=scheduled_at, @@ -779,4 +786,14 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: db.add(meeting) db.commit() db.refresh(meeting) - return meeting + return { + "meeting_code": meeting.meeting_code, + "code": meeting.meeting_code, + "title": meeting.title, + "description": meeting.description, + "status": meeting.status.value, + "priority": meeting.priority.value, + "scheduled_at": meeting.scheduled_at, + "duration_minutes": meeting.duration_minutes, + "created_at": meeting.created_at, + } diff --git a/app/api/routers/proposals.py b/app/api/routers/proposals.py index b3ed736..c2dcad1 100644 --- a/app/api/routers/proposals.py +++ b/app/api/routers/proposals.py @@ -19,15 +19,14 @@ from app.models.task import Task, TaskStatus, TaskPriority from app.schemas import schemas from app.services.activity import log_activity -router = APIRouter(prefix="/projects/{project_id}/proposals", tags=["Proposals"]) +router = APIRouter(prefix="/projects/{project_code}/proposals", tags=["Proposals"]) -def _serialize_essential(e: Essential) -> dict: +def _serialize_essential(e: Essential, proposal_code: str | None) -> dict: """Serialize an Essential for embedding in Proposal detail.""" return { - "id": e.id, "essential_code": e.essential_code, - "proposal_id": e.proposal_id, + "proposal_code": proposal_code, "type": e.type.value if hasattr(e.type, "value") else e.type, "title": e.title, "description": e.description, @@ -41,14 +40,14 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials: """Serialize proposal with created_by_username.""" creator = db.query(models.User).filter(models.User.id == proposal.created_by_id).first() if proposal.created_by_id else None code = proposal.propose_code # DB column; also exposed as proposal_code + project = db.query(models.Project).filter(models.Project.id == proposal.project_id).first() result = { - "id": proposal.id, "title": proposal.title, "description": proposal.description, "proposal_code": code, # preferred name "propose_code": code, # backward compat "status": proposal.status.value if hasattr(proposal.status, "value") else proposal.status, - "project_id": proposal.project_id, + "project_code": project.project_code if project else None, "created_by_id": proposal.created_by_id, "created_by_username": creator.username if creator else None, "feat_task_id": proposal.feat_task_id, # DEPRECATED (BE-PR-010): read-only for legacy rows. Clients should use generated_tasks. @@ -62,7 +61,7 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials: .order_by(Essential.id.asc()) .all() ) - result["essentials"] = [_serialize_essential(e) for e in essentials] + result["essentials"] = [_serialize_essential(e, code) for e in essentials] # BE-PR-008: include tasks generated from this Proposal via Accept gen_tasks = ( @@ -71,46 +70,34 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials: .order_by(Task.id.asc()) .all() ) + def _lookup_essential_code(essential_id: int | None) -> str | None: + if not essential_id: + return None + essential = db.query(Essential).filter(Essential.id == essential_id).first() + return essential.essential_code if essential else None + result["generated_tasks"] = [ { - "task_id": t.id, "task_code": t.task_code, "task_type": t.task_type or "story", "task_subtype": t.task_subtype, "title": t.title, "status": t.status.value if hasattr(t.status, "value") else t.status, - "source_essential_id": t.source_essential_id, + "source_essential_code": _lookup_essential_code(t.source_essential_id), } for t in gen_tasks ] return result -def _find_project(db, identifier): - """Look up project by numeric id or project_code.""" - try: - pid = int(identifier) - p = db.query(models.Project).filter(models.Project.id == pid).first() - if p: - return p - except (ValueError, TypeError): - pass - return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first() +def _find_project(db, project_code: str): + """Look up project by project_code.""" + return db.query(models.Project).filter(models.Project.project_code == str(project_code)).first() -def _find_proposal(db, identifier, project_id: int = None) -> Proposal | None: - """Look up proposal by numeric id or propose_code.""" - try: - pid = int(identifier) - q = db.query(Proposal).filter(Proposal.id == pid) - if project_id: - q = q.filter(Proposal.project_id == project_id) - p = q.first() - if p: - return p - except (ValueError, TypeError): - pass - q = db.query(Proposal).filter(Proposal.propose_code == str(identifier)) +def _find_proposal(db, proposal_code: str, project_id: int = None) -> Proposal | None: + """Look up proposal by propose_code.""" + q = db.query(Proposal).filter(Proposal.propose_code == str(proposal_code)) if project_id: q = q.filter(Proposal.project_id == project_id) return q.first() @@ -147,11 +134,11 @@ def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool: @router.get("", response_model=List[schemas.ProposalResponse]) def list_proposals( - project_id: str, + project_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") check_project_role(db, current_user.id, project.id, min_role="viewer") @@ -166,12 +153,12 @@ def list_proposals( @router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED) def create_proposal( - project_id: str, + project_code: str, proposal_in: schemas.ProposalCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") check_project_role(db, current_user.id, project.id, min_role="dev") @@ -197,17 +184,17 @@ def create_proposal( @router.get("/{proposal_id}", response_model=schemas.ProposalDetailResponse) def get_proposal( - project_id: str, - proposal_id: str, + project_code: str, + proposal_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Get a single Proposal with its Essentials list embedded.""" - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") check_project_role(db, current_user.id, project.id, min_role="viewer") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") return _serialize_proposal(db, proposal, include_essentials=True) @@ -215,16 +202,16 @@ def get_proposal( @router.patch("/{proposal_id}", response_model=schemas.ProposalResponse) def update_proposal( - project_id: str, - proposal_id: str, + project_code: str, + proposal_code: str, proposal_in: schemas.ProposalUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") @@ -253,13 +240,13 @@ def update_proposal( # ---- Actions ---- class AcceptRequest(schemas.BaseModel): - milestone_id: int + milestone_code: str @router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse) def accept_proposal( - project_id: str, - proposal_id: str, + project_code: str, + proposal_code: str, body: AcceptRequest, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), @@ -274,10 +261,10 @@ def accept_proposal( All tasks are created in a single transaction. The Proposal must have at least one Essential to be accepted. """ - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") @@ -289,7 +276,7 @@ def accept_proposal( # Validate milestone milestone = db.query(Milestone).filter( - Milestone.id == body.milestone_id, + Milestone.milestone_code == body.milestone_code, Milestone.project_id == project.id, ).first() if not milestone: @@ -355,12 +342,10 @@ def accept_proposal( db.flush() # materialise task.id generated_tasks.append({ - "task_id": task.id, "task_code": task_code, "task_type": "story", "task_subtype": task_subtype, "title": essential.title, - "essential_id": essential.id, "essential_code": essential.essential_code, }) next_num = task.id + 1 # use real id for next code to stay consistent @@ -372,9 +357,9 @@ def accept_proposal( db.refresh(proposal) log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={ - "milestone_id": milestone.id, + "milestone_code": milestone.milestone_code, "generated_tasks": [ - {"task_id": t["task_id"], "task_code": t["task_code"], "essential_id": t["essential_id"]} + {"task_code": t["task_code"], "essential_code": t["essential_code"]} for t in generated_tasks ], }) @@ -390,17 +375,17 @@ class RejectRequest(schemas.BaseModel): @router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse) def reject_proposal( - project_id: str, - proposal_id: str, + project_code: str, + proposal_code: str, body: RejectRequest | None = None, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Reject a proposal.""" - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") @@ -423,16 +408,16 @@ def reject_proposal( @router.post("/{proposal_id}/reopen", response_model=schemas.ProposalResponse) def reopen_proposal( - project_id: str, - proposal_id: str, + project_code: str, + proposal_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Reopen a rejected proposal back to open.""" - project = _find_project(db, project_id) + project = _find_project(db, project_code) if not project: raise HTTPException(status_code=404, detail="Project not found") - proposal = _find_proposal(db, proposal_id, project.id) + proposal = _find_proposal(db, proposal_code, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") diff --git a/app/api/routers/proposes.py b/app/api/routers/proposes.py index 2d9d42f..be2fac3 100644 --- a/app/api/routers/proposes.py +++ b/app/api/routers/proposes.py @@ -28,83 +28,83 @@ from app.api.rbac import check_project_role, check_permission, is_global_admin from app.services.activity import log_activity # Legacy router — same logic, old URL prefix -router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes (legacy)"]) +router = APIRouter(prefix="/projects/{project_code}/proposes", tags=["Proposes (legacy)"]) @router.get("", response_model=List[schemas.ProposalResponse]) def list_proposes( - project_id: str, + project_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): from app.api.routers.proposals import list_proposals - return list_proposals(project_id=project_id, db=db, current_user=current_user) + return list_proposals(project_code=project_code, db=db, current_user=current_user) @router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED) def create_propose( - project_id: str, + project_code: str, proposal_in: schemas.ProposalCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): from app.api.routers.proposals import create_proposal - return create_proposal(project_id=project_id, proposal_in=proposal_in, db=db, current_user=current_user) + return create_proposal(project_code=project_code, proposal_in=proposal_in, db=db, current_user=current_user) @router.get("/{propose_id}", response_model=schemas.ProposalResponse) def get_propose( - project_id: str, + project_code: str, propose_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): from app.api.routers.proposals import get_proposal - return get_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user) + return get_proposal(project_code=project_code, proposal_code=propose_id, db=db, current_user=current_user) @router.patch("/{propose_id}", response_model=schemas.ProposalResponse) def update_propose( - project_id: str, + project_code: str, propose_id: str, proposal_in: schemas.ProposalUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): from app.api.routers.proposals import update_proposal - return update_proposal(project_id=project_id, proposal_id=propose_id, proposal_in=proposal_in, db=db, current_user=current_user) + return update_proposal(project_code=project_code, proposal_code=propose_id, proposal_in=proposal_in, db=db, current_user=current_user) @router.post("/{propose_id}/accept", response_model=schemas.ProposalResponse) def accept_propose( - project_id: str, + project_code: str, propose_id: str, body: AcceptRequest, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): from app.api.routers.proposals import accept_proposal - return accept_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user) + return accept_proposal(project_code=project_code, proposal_code=propose_id, body=body, db=db, current_user=current_user) @router.post("/{propose_id}/reject", response_model=schemas.ProposalResponse) def reject_propose( - project_id: str, + project_code: str, propose_id: str, body: RejectRequest | None = None, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): from app.api.routers.proposals import reject_proposal - return reject_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user) + return reject_proposal(project_code=project_code, proposal_code=propose_id, body=body, db=db, current_user=current_user) @router.post("/{propose_id}/reopen", response_model=schemas.ProposalResponse) def reopen_propose( - project_id: str, + project_code: str, propose_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): from app.api.routers.proposals import reopen_proposal - return reopen_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user) + return reopen_proposal(project_code=project_code, proposal_code=propose_id, db=db, current_user=current_user) diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index f2c654d..6eeb36b 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -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) diff --git a/app/main.py b/app/main.py index 3dd9b9d..45ef1a9 100644 --- a/app/main.py +++ b/app/main.py @@ -140,6 +140,8 @@ def _migrate_schema(): if not result.fetchone(): db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL")) db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)")) + else: + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_project_code ON projects (project_code)")) # projects.owner_name result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")) @@ -173,6 +175,8 @@ def _migrate_schema(): if not result.fetchone(): db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL")) _ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id") + if _has_column(db, "tasks", "task_code"): + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_task_code ON tasks (task_code)")) # milestones creator field result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'")) @@ -202,6 +206,8 @@ def _migrate_schema(): # --- Milestone status enum migration (old -> new) --- if _has_table(db, "milestones"): + if _has_column(db, "milestones", "milestone_code"): + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_milestones_milestone_code ON milestones (milestone_code)")) # Alter enum column to accept new values db.execute(text( "ALTER TABLE milestones MODIFY COLUMN status " @@ -257,6 +263,18 @@ def _migrate_schema(): if _has_table(db, "server_states") and not _has_column(db, "server_states", "plugin_version"): db.execute(text("ALTER TABLE server_states ADD COLUMN plugin_version VARCHAR(64) NULL")) + if _has_table(db, "meetings") and _has_column(db, "meetings", "meeting_code"): + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_meetings_meeting_code ON meetings (meeting_code)")) + + if _has_table(db, "supports") and _has_column(db, "supports", "support_code"): + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_supports_support_code ON supports (support_code)")) + + if _has_table(db, "proposes") and _has_column(db, "proposes", "propose_code"): + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_proposes_propose_code ON proposes (propose_code)")) + + if _has_table(db, "essentials") and _has_column(db, "essentials", "essential_code"): + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_essentials_essential_code ON essentials (essential_code)")) + # --- server_states nginx telemetry for generic monitor client --- if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"): db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_installed BOOLEAN NULL")) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 6bd1443..ef8e8e1 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -43,9 +43,7 @@ class TaskBase(BaseModel): class TaskCreate(TaskBase): - project_id: Optional[int] = None project_code: Optional[str] = None - milestone_id: Optional[int] = None milestone_code: Optional[str] = None reporter_id: Optional[int] = None assignee_id: Optional[int] = None @@ -75,15 +73,12 @@ class TaskUpdate(BaseModel): class TaskResponse(TaskBase): - id: int status: TaskStatusEnum task_code: Optional[str] = None code: Optional[str] = None type: Optional[str] = None due_date: Optional[datetime] = None - project_id: int project_code: Optional[str] = None - milestone_id: int milestone_code: Optional[str] = None reporter_id: int assignee_id: Optional[int] = None @@ -94,8 +89,8 @@ class TaskResponse(TaskBase): positions: Optional[str] = None pending_matters: Optional[str] = None # BE-PR-008: Proposal Accept tracking - source_proposal_id: Optional[int] = None - source_essential_id: Optional[int] = None + source_proposal_code: Optional[str] = None + source_essential_code: Optional[str] = None created_at: datetime updated_at: Optional[datetime] = None @@ -259,9 +254,9 @@ class MilestoneUpdate(BaseModel): class MilestoneResponse(MilestoneBase): - id: int milestone_code: Optional[str] = None - project_id: int + code: Optional[str] = None + project_code: Optional[str] = None created_by_id: Optional[int] = None started_at: Optional[datetime] = None created_at: datetime @@ -285,7 +280,7 @@ class ProposalBase(BaseModel): class ProposalCreate(ProposalBase): - project_id: Optional[int] = None + pass class ProposalUpdate(BaseModel): @@ -294,11 +289,10 @@ class ProposalUpdate(BaseModel): class ProposalResponse(ProposalBase): - id: int proposal_code: Optional[str] = None # preferred name propose_code: Optional[str] = None # backward compat alias (same value) status: ProposalStatusEnum - project_id: int + project_code: Optional[str] = None created_by_id: Optional[int] = None created_by_username: Optional[str] = None feat_task_id: Optional[str] = None # DEPRECATED (BE-PR-010): legacy field, read-only. Use generated_tasks instead. @@ -340,9 +334,8 @@ class EssentialUpdate(BaseModel): class EssentialResponse(EssentialBase): - id: int essential_code: str - proposal_id: int + proposal_code: Optional[str] = None created_by_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] = None @@ -353,13 +346,12 @@ class EssentialResponse(EssentialBase): class GeneratedTaskBrief(BaseModel): """Brief info about a story task generated from Proposal Accept.""" - task_id: int task_code: Optional[str] = None task_type: str task_subtype: Optional[str] = None title: str status: Optional[str] = None - source_essential_id: Optional[int] = None + source_essential_code: Optional[str] = None class ProposalDetailResponse(ProposalResponse): @@ -374,12 +366,10 @@ class ProposalDetailResponse(ProposalResponse): class GeneratedTaskSummary(BaseModel): """Brief summary of a task generated from a Proposal Essential.""" - task_id: int task_code: str task_type: str task_subtype: str title: str - essential_id: int essential_code: str