Compare commits

...

2 Commits

Author SHA1 Message Date
ae353afbed feat: switch backend indexing to code-first identifiers 2026-04-03 16:25:11 +00:00
58d3ca6ad0 fix: allow api key auth for account creation 2026-04-03 13:45:36 +00:00
11 changed files with 356 additions and 379 deletions

View File

@@ -1,7 +1,7 @@
"""Essentials API router — CRUD for Essentials nested under a Proposal. """Essentials API router — CRUD for Essentials nested under a Proposal.
Endpoints are scoped to a project and 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. 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 from app.services.essential_code import generate_essential_code
router = APIRouter( router = APIRouter(
prefix="/projects/{project_id}/proposals/{proposal_id}/essentials", prefix="/projects/{project_code}/proposals/{proposal_code}/essentials",
tags=["Essentials"], tags=["Essentials"],
) )
@@ -35,53 +35,27 @@ router = APIRouter(
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _find_project(db: Session, identifier: str): def _find_project(db: Session, project_code: str):
"""Look up project by numeric id or project_code.""" """Look up project by 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( return db.query(models.Project).filter(
models.Project.project_code == str(identifier) models.Project.project_code == str(project_code)
).first() ).first()
def _find_proposal(db: Session, identifier: str, project_id: int) -> Proposal | None: def _find_proposal(db: Session, proposal_code: str, project_id: int) -> Proposal | None:
"""Look up proposal by numeric id or propose_code within a project.""" """Look up proposal by 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
return ( return (
db.query(Proposal) 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() .first()
) )
def _find_essential(db: Session, identifier: str, proposal_id: int) -> Essential | None: def _find_essential(db: Session, essential_code: str, proposal_id: int) -> Essential | None:
"""Look up essential by numeric id or essential_code within a proposal.""" """Look up essential by 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
return ( return (
db.query(Essential) 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() .first()
) )
@@ -108,12 +82,11 @@ def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool:
return False return False
def _serialize_essential(e: Essential) -> dict: def _serialize_essential(e: Essential, proposal_code: str | None) -> dict:
"""Return a dict matching EssentialResponse.""" """Return a dict matching EssentialResponse."""
return { return {
"id": e.id,
"essential_code": e.essential_code, "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, "type": e.type.value if hasattr(e.type, "value") else e.type,
"title": e.title, "title": e.title,
"description": e.description, "description": e.description,
@@ -129,18 +102,18 @@ def _serialize_essential(e: Essential) -> dict:
@router.get("", response_model=List[EssentialResponse]) @router.get("", response_model=List[EssentialResponse])
def list_essentials( def list_essentials(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""List all Essentials under a Proposal.""" """List all Essentials under a Proposal."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -150,24 +123,24 @@ def list_essentials(
.order_by(Essential.id.asc()) .order_by(Essential.id.asc())
.all() .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) @router.post("", response_model=EssentialResponse, status_code=status.HTTP_201_CREATED)
def create_essential( def create_essential(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
body: EssentialCreate, body: EssentialCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Create a new Essential under an open Proposal.""" """Create a new Essential under an open Proposal."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") 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}, 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) @router.get("/{essential_id}", response_model=EssentialResponse)
def get_essential( def get_essential(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
essential_id: str, essential_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Get a single Essential by id or essential_code.""" """Get a single Essential by essential_code."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") 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: if not essential:
raise HTTPException(status_code=404, detail="Essential not found") 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) @router.patch("/{essential_id}", response_model=EssentialResponse)
def update_essential( def update_essential(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
essential_id: str, essential_code: str,
body: EssentialUpdate, body: EssentialUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Update an Essential (only on open Proposals).""" """Update an Essential (only on open Proposals)."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") 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): if not _can_edit_proposal(db, current_user.id, proposal):
raise HTTPException(status_code=403, detail="Permission denied") 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: if not essential:
raise HTTPException(status_code=404, detail="Essential not found") raise HTTPException(status_code=404, detail="Essential not found")
@@ -265,24 +238,24 @@ def update_essential(
details=data, details=data,
) )
return _serialize_essential(essential) return _serialize_essential(essential, proposal.propose_code)
@router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_essential( def delete_essential(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
essential_id: str, essential_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Delete an Essential (only on open Proposals).""" """Delete an Essential (only on open Proposals)."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") 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): if not _can_edit_proposal(db, current_user.id, proposal):
raise HTTPException(status_code=403, detail="Permission denied") 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: if not essential:
raise HTTPException(status_code=404, detail="Essential not found") raise HTTPException(status_code=404, detail="Essential not found")

View File

@@ -18,15 +18,8 @@ router = APIRouter(tags=["Meetings"])
# ---- helpers ---- # ---- helpers ----
def _find_meeting_by_id_or_code(db: Session, identifier: str) -> Meeting | None: def _find_meeting_by_code(db: Session, meeting_code: str) -> Meeting | None:
try: return db.query(Meeting).filter(Meeting.meeting_code == str(meeting_code)).first()
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 _resolve_project_id(db: Session, project_code: str | None) -> int | None: 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() project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first()
milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first() milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first()
return { return {
"id": meeting.id,
"code": meeting.meeting_code, "code": meeting.meeting_code,
"meeting_code": meeting.meeting_code, "meeting_code": meeting.meeting_code,
"title": meeting.title, "title": meeting.title,
"description": meeting.description, "description": meeting.description,
"status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status, "status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status,
"priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority, "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, "project_code": project.project_code if project else None,
"milestone_id": meeting.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None, "milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": meeting.reporter_id, "reporter_id": meeting.reporter_id,
"meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None, "meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
@@ -155,6 +145,7 @@ def create_meeting(
@router.get("/meetings") @router.get("/meetings")
def list_meetings( def list_meetings(
project: str = None, project: str = None,
project_code: str = None,
status_value: str = Query(None, alias="status"), status_value: str = Query(None, alias="status"),
order_by: str = None, order_by: str = None,
page: int = 1, page: int = 1,
@@ -163,8 +154,9 @@ def list_meetings(
): ):
query = db.query(Meeting) query = db.query(Meeting)
if project: effective_project = project_code or project
project_id = _resolve_project_id(db, project) if effective_project:
project_id = _resolve_project_id(db, effective_project)
if project_id: if project_id:
query = query.filter(Meeting.project_id == project_id) query = query.filter(Meeting.project_id == project_id)
@@ -197,9 +189,9 @@ def list_meetings(
} }
@router.get("/meetings/{meeting_id}") @router.get("/meetings/{meeting_code}")
def get_meeting(meeting_id: str, db: Session = Depends(get_db)): def get_meeting(meeting_code: str, db: Session = Depends(get_db)):
meeting = _find_meeting_by_id_or_code(db, meeting_id) meeting = _find_meeting_by_code(db, meeting_code)
if not meeting: if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found") raise HTTPException(status_code=404, detail="Meeting not found")
return _serialize_meeting(db, meeting) return _serialize_meeting(db, meeting)
@@ -213,14 +205,14 @@ class MeetingUpdateBody(BaseModel):
duration_minutes: Optional[int] = None duration_minutes: Optional[int] = None
@router.patch("/meetings/{meeting_id}") @router.patch("/meetings/{meeting_code}")
def update_meeting( def update_meeting(
meeting_id: str, meeting_code: str,
body: MeetingUpdateBody, body: MeetingUpdateBody,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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: if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found") raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="dev") 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) 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( def delete_meeting(
meeting_id: str, meeting_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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: if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found") raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="dev") check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
@@ -265,13 +257,13 @@ def delete_meeting(
# ---- Attend ---- # ---- Attend ----
@router.post("/meetings/{meeting_id}/attend") @router.post("/meetings/{meeting_code}/attend")
def attend_meeting( def attend_meeting(
meeting_id: str, meeting_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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: if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found") raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="viewer") check_project_role(db, current_user.id, meeting.project_id, min_role="viewer")

View File

@@ -20,7 +20,7 @@ from app.services.activity import log_activity
from app.services.dependency_check import check_milestone_deps from app.services.dependency_check import check_milestone_deps
router = APIRouter( router = APIRouter(
prefix="/projects/{project_id}/milestones/{milestone_id}/actions", prefix="/projects/{project_code}/milestones/{milestone_code}/actions",
tags=["Milestone Actions"], tags=["Milestone Actions"],
) )
@@ -29,10 +29,18 @@ router = APIRouter(
# Helpers # 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 = ( ms = (
db.query(Milestone) 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() .first()
) )
if not ms: if not ms:
@@ -59,8 +67,8 @@ class CloseBody(BaseModel):
@router.get("/preflight", status_code=200) @router.get("/preflight", status_code=200)
def preflight_milestone_actions( def preflight_milestone_actions(
project_id: int, project_code: str,
milestone_id: int, milestone_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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 The frontend uses this to decide whether to *disable* buttons and what
hint text to show. This endpoint never mutates data. hint text to show. This endpoint never mutates data.
""" """
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _resolve_project_or_404(db, project_code)
ms = _get_milestone_or_404(db, project_id, milestone_id) 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) ms_status = _ms_status_value(ms)
result: dict = {"status": ms_status, "freeze": None, "start": None} result: dict = {"status": ms_status, "freeze": None, "start": None}
@@ -80,7 +89,7 @@ def preflight_milestone_actions(
release_tasks = ( release_tasks = (
db.query(Task) db.query(Task)
.filter( .filter(
Task.milestone_id == milestone_id, Task.milestone_id == ms.id,
Task.task_type == "maintenance", Task.task_type == "maintenance",
Task.task_subtype == "release", Task.task_subtype == "release",
) )
@@ -118,8 +127,8 @@ def preflight_milestone_actions(
@router.post("/freeze", status_code=200) @router.post("/freeze", status_code=200)
def freeze_milestone( def freeze_milestone(
project_id: int, project_code: str,
milestone_id: int, milestone_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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``. - Milestone must have **exactly one** maintenance task with subtype ``release``.
- Caller must have ``freeze milestone`` permission. - Caller must have ``freeze milestone`` permission.
""" """
check_project_role(db, current_user.id, project_id, min_role="mgr") project = _resolve_project_or_404(db, project_code)
check_permission(db, current_user.id, project_id, "milestone.freeze") 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": if _ms_status_value(ms) != "open":
raise HTTPException( raise HTTPException(
@@ -145,7 +155,7 @@ def freeze_milestone(
release_tasks = ( release_tasks = (
db.query(Task) db.query(Task)
.filter( .filter(
Task.milestone_id == milestone_id, Task.milestone_id == ms.id,
Task.task_type == "maintenance", Task.task_type == "maintenance",
Task.task_subtype == "release", Task.task_subtype == "release",
) )
@@ -184,8 +194,8 @@ def freeze_milestone(
@router.post("/start", status_code=200) @router.post("/start", status_code=200)
def start_milestone( def start_milestone(
project_id: int, project_code: str,
milestone_id: int, milestone_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
@@ -196,10 +206,11 @@ def start_milestone(
- All milestone dependencies must be completed. - All milestone dependencies must be completed.
- Caller must have ``start milestone`` permission. - Caller must have ``start milestone`` permission.
""" """
check_project_role(db, current_user.id, project_id, min_role="mgr") project = _resolve_project_or_404(db, project_code)
check_permission(db, current_user.id, project_id, "milestone.start") 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": if _ms_status_value(ms) != "freeze":
raise HTTPException( raise HTTPException(
@@ -240,8 +251,8 @@ def start_milestone(
@router.post("/close", status_code=200) @router.post("/close", status_code=200)
def close_milestone( def close_milestone(
project_id: int, project_code: str,
milestone_id: int, milestone_code: str,
body: CloseBody = CloseBody(), body: CloseBody = CloseBody(),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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. - Milestone must be in ``open``, ``freeze``, or ``undergoing`` status.
- Caller must have ``close milestone`` permission. - Caller must have ``close milestone`` permission.
""" """
check_project_role(db, current_user.id, project_id, min_role="mgr") project = _resolve_project_or_404(db, project_code)
check_permission(db, current_user.id, project_id, "milestone.close") 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) current = _ms_status_value(ms)
allowed_from = {"open", "freeze", "undergoing"} allowed_from = {"open", "freeze", "undergoing"}

View File

@@ -48,10 +48,10 @@ def _find_milestone(db, identifier, project_id: int = None) -> Milestone | None:
return q.first() return q.first()
def _serialize_milestone(milestone): def _serialize_milestone(db, milestone):
"""Serialize milestone with JSON fields and code.""" """Serialize milestone with JSON fields and code-first identifiers."""
project = db.query(models.Project).filter(models.Project.id == milestone.project_id).first()
return { return {
"id": milestone.id,
"title": milestone.title, "title": milestone.title,
"description": milestone.description, "description": milestone.description,
"status": milestone.status.value if hasattr(milestone.status, 'value') else milestone.status, "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, "planned_release_date": milestone.planned_release_date,
"depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [], "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 [], "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, "milestone_code": milestone.milestone_code,
"code": milestone.milestone_code, "code": milestone.milestone_code,
"project_code": project.project_code if project else None,
"created_by_id": milestone.created_by_id, "created_by_id": milestone.created_by_id,
"started_at": milestone.started_at, "started_at": milestone.started_at,
"created_at": milestone.created_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") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") check_project_role(db, current_user.id, project.id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all() 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) @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.add(db_milestone)
db.commit() db.commit()
db.refresh(db_milestone) db.refresh(db_milestone)
return _serialize_milestone(db_milestone) return _serialize_milestone(db, db_milestone)
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse) @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) milestone = _find_milestone(db, milestone_id, project.id)
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") 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) @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) setattr(db_milestone, key, value)
db.commit() db.commit()
db.refresh(db_milestone) 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) @router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -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"]) @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) 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 # Resolve project_id by numeric id or project_code
resolved_project = None resolved_project = None
try: try:
pid = int(project_id) pid = int(effective_project)
resolved_project = db.query(models.Project).filter(models.Project.id == pid).first() resolved_project = db.query(models.Project).filter(models.Project.id == pid).first()
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if not resolved_project: 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: if not resolved_project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
query = query.filter(MilestoneModel.project_id == resolved_project.id) 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 ============ # ============ Milestone-scoped Tasks ============
@router.get("/tasks/{project_code}/{milestone_id}", tags=["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() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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( tasks = db.query(Task).filter(
Task.project_id == project.id, Task.project_id == project.id,
Task.milestone_id == milestone_id Task.milestone_id == milestone.id
).all() ).all()
return [{ 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"]) @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() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") 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_type=task_data.get("task_type", "issue"), # P7.1: default changed from 'task' to 'issue'
task_subtype=task_data.get("task_subtype"), task_subtype=task_data.get("task_subtype"),
project_id=project.id, project_id=project.id,
milestone_id=milestone_id, milestone_id=ms.id,
reporter_id=current_user.id, reporter_id=current_user.id,
task_code=task_code, task_code=task_code,
estimated_effort=task_data.get("estimated_effort"), 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) db.refresh(task)
return { return {
"id": task.id,
"title": task.title, "title": task.title,
"description": task.description, "description": task.description,
"task_code": task.task_code, "task_code": task.task_code,
"code": task.task_code,
"status": task.status.value, "status": task.status.value,
"priority": task.priority.value, "priority": task.priority.value,
"created_at": task.created_at, "created_at": task.created_at,
@@ -516,15 +524,8 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
# ============ Supports ============ # ============ Supports ============
def _find_support_by_id_or_code(db: Session, identifier: str) -> Support | None: def _find_support_by_code(db: Session, support_code: str) -> Support | None:
try: return db.query(Support).filter(Support.support_code == str(support_code)).first()
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()
@@ -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() assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first()
return { return {
"id": support.id,
"code": support.support_code, "code": support.support_code,
"support_code": support.support_code, "support_code": support.support_code,
"title": support.title, "title": support.title,
"description": support.description, "description": support.description,
"status": support.status.value if hasattr(support.status, "value") else support.status, "status": support.status.value if hasattr(support.status, "value") else support.status,
"priority": support.priority.value if hasattr(support.priority, "value") else support.priority, "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, "project_code": project.project_code if project else None,
"milestone_id": support.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None, "milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": support.reporter_id, "reporter_id": support.reporter_id,
"assignee_id": support.assignee_id, "assignee_id": support.assignee_id,
@@ -585,26 +583,30 @@ def list_all_supports(
@router.get("/supports/{project_code}/{milestone_id}", tags=["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() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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( supports = db.query(Support).filter(
Support.project_id == project.id, Support.project_id == project.id,
Support.milestone_id == milestone_id Support.milestone_id == milestone.id
).all() ).all()
return [_serialize_support(db, s) for s in supports] return [_serialize_support(db, s) for s in supports]
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["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() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") 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") 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}" 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 next_num = (max_support.id + 1) if max_support else 1
support_code = f"{milestone_code}:S{next_num:05x}" 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, status=SupportStatus.OPEN,
priority=SupportPriority.MEDIUM, priority=SupportPriority.MEDIUM,
project_id=project.id, project_id=project.id,
milestone_id=milestone_id, milestone_id=ms.id,
reporter_id=current_user.id, reporter_id=current_user.id,
support_code=support_code, 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) return _serialize_support(db, support)
@router.get("/supports/{support_id}", tags=["Supports"]) @router.get("/supports/{support_code}", tags=["Supports"])
def get_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): 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_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="viewer") check_project_role(db, current_user.id, support.project_id, min_role="viewer")
return _serialize_support(db, support) return _serialize_support(db, support)
@router.patch("/supports/{support_id}", tags=["Supports"]) @router.patch("/supports/{support_code}", 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)): 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_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev") 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) return _serialize_support(db, support)
@router.delete("/supports/{support_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"]) @router.delete("/supports/{support_code}", 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)): 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_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev") 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 return None
@router.post("/supports/{support_id}/take", tags=["Supports"]) @router.post("/supports/{support_code}/take", tags=["Supports"])
def take_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): 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_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev") 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) return _serialize_support(db, support)
@router.post("/supports/{support_id}/transition", tags=["Supports"]) @router.post("/supports/{support_code}/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)): 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_id_or_code(db, support_id) support = _find_support_by_code(db, support_code)
if not support: if not support:
raise HTTPException(status_code=404, detail="Support not found") raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev") 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 ============ # ============ Meetings ============
@router.get("/meetings/{project_code}/{milestone_id}", tags=["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() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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( meetings = db.query(Meeting).filter(
Meeting.project_id == project.id, Meeting.project_id == project.id,
Meeting.milestone_id == milestone_id Meeting.milestone_id == milestone.id
).all() ).all()
return [{ return [{
"id": m.id,
"title": m.title, "title": m.title,
"description": m.description, "description": m.description,
"meeting_code": m.meeting_code,
"code": m.meeting_code,
"status": m.status.value, "status": m.status.value,
"priority": m.priority.value, "priority": m.priority.value,
"scheduled_at": m.scheduled_at, "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"]) @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() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") 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") 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}" 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 next_num = (max_meeting.id + 1) if max_meeting else 1
meeting_code = f"{milestone_code}:M{next_num:05x}" 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, status=MeetingStatus.SCHEDULED,
priority=MeetingPriority.MEDIUM, priority=MeetingPriority.MEDIUM,
project_id=project.id, project_id=project.id,
milestone_id=milestone_id, milestone_id=ms.id,
reporter_id=current_user.id, reporter_id=current_user.id,
meeting_code=meeting_code, meeting_code=meeting_code,
scheduled_at=scheduled_at, 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.add(meeting)
db.commit() db.commit()
db.refresh(meeting) 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,
}

View File

@@ -19,15 +19,14 @@ from app.models.task import Task, TaskStatus, TaskPriority
from app.schemas import schemas from app.schemas import schemas
from app.services.activity import log_activity 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.""" """Serialize an Essential for embedding in Proposal detail."""
return { return {
"id": e.id,
"essential_code": e.essential_code, "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, "type": e.type.value if hasattr(e.type, "value") else e.type,
"title": e.title, "title": e.title,
"description": e.description, "description": e.description,
@@ -41,14 +40,14 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
"""Serialize proposal with created_by_username.""" """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 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 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 = { result = {
"id": proposal.id,
"title": proposal.title, "title": proposal.title,
"description": proposal.description, "description": proposal.description,
"proposal_code": code, # preferred name "proposal_code": code, # preferred name
"propose_code": code, # backward compat "propose_code": code, # backward compat
"status": proposal.status.value if hasattr(proposal.status, "value") else proposal.status, "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_id": proposal.created_by_id,
"created_by_username": creator.username if creator else None, "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. "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()) .order_by(Essential.id.asc())
.all() .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 # BE-PR-008: include tasks generated from this Proposal via Accept
gen_tasks = ( gen_tasks = (
@@ -71,46 +70,34 @@ def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials:
.order_by(Task.id.asc()) .order_by(Task.id.asc())
.all() .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"] = [ result["generated_tasks"] = [
{ {
"task_id": t.id,
"task_code": t.task_code, "task_code": t.task_code,
"task_type": t.task_type or "story", "task_type": t.task_type or "story",
"task_subtype": t.task_subtype, "task_subtype": t.task_subtype,
"title": t.title, "title": t.title,
"status": t.status.value if hasattr(t.status, "value") else t.status, "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 for t in gen_tasks
] ]
return result return result
def _find_project(db, identifier): def _find_project(db, project_code: str):
"""Look up project by numeric id or project_code.""" """Look up project by project_code."""
try: return db.query(models.Project).filter(models.Project.project_code == str(project_code)).first()
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_proposal(db, identifier, project_id: int = None) -> Proposal | None: def _find_proposal(db, proposal_code: str, project_id: int = None) -> Proposal | None:
"""Look up proposal by numeric id or propose_code.""" """Look up proposal by propose_code."""
try: q = db.query(Proposal).filter(Proposal.propose_code == str(proposal_code))
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))
if project_id: if project_id:
q = q.filter(Proposal.project_id == project_id) q = q.filter(Proposal.project_id == project_id)
return q.first() 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]) @router.get("", response_model=List[schemas.ProposalResponse])
def list_proposals( def list_proposals(
project_id: str, project_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") 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) @router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
def create_proposal( def create_proposal(
project_id: str, project_code: str,
proposal_in: schemas.ProposalCreate, proposal_in: schemas.ProposalCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev") 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) @router.get("/{proposal_id}", response_model=schemas.ProposalDetailResponse)
def get_proposal( def get_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Get a single Proposal with its Essentials list embedded.""" """Get a single Proposal with its Essentials list embedded."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
return _serialize_proposal(db, proposal, include_essentials=True) return _serialize_proposal(db, proposal, include_essentials=True)
@@ -215,16 +202,16 @@ def get_proposal(
@router.patch("/{proposal_id}", response_model=schemas.ProposalResponse) @router.patch("/{proposal_id}", response_model=schemas.ProposalResponse)
def update_proposal( def update_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
proposal_in: schemas.ProposalUpdate, proposal_in: schemas.ProposalUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -253,13 +240,13 @@ def update_proposal(
# ---- Actions ---- # ---- Actions ----
class AcceptRequest(schemas.BaseModel): class AcceptRequest(schemas.BaseModel):
milestone_id: int milestone_code: str
@router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse) @router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse)
def accept_proposal( def accept_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
body: AcceptRequest, body: AcceptRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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 All tasks are created in a single transaction. The Proposal must have at
least one Essential to be accepted. least one Essential to be accepted.
""" """
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")
@@ -289,7 +276,7 @@ def accept_proposal(
# Validate milestone # Validate milestone
milestone = db.query(Milestone).filter( milestone = db.query(Milestone).filter(
Milestone.id == body.milestone_id, Milestone.milestone_code == body.milestone_code,
Milestone.project_id == project.id, Milestone.project_id == project.id,
).first() ).first()
if not milestone: if not milestone:
@@ -355,12 +342,10 @@ def accept_proposal(
db.flush() # materialise task.id db.flush() # materialise task.id
generated_tasks.append({ generated_tasks.append({
"task_id": task.id,
"task_code": task_code, "task_code": task_code,
"task_type": "story", "task_type": "story",
"task_subtype": task_subtype, "task_subtype": task_subtype,
"title": essential.title, "title": essential.title,
"essential_id": essential.id,
"essential_code": essential.essential_code, "essential_code": essential.essential_code,
}) })
next_num = task.id + 1 # use real id for next code to stay consistent next_num = task.id + 1 # use real id for next code to stay consistent
@@ -372,9 +357,9 @@ def accept_proposal(
db.refresh(proposal) db.refresh(proposal)
log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={ log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={
"milestone_id": milestone.id, "milestone_code": milestone.milestone_code,
"generated_tasks": [ "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 for t in generated_tasks
], ],
}) })
@@ -390,17 +375,17 @@ class RejectRequest(schemas.BaseModel):
@router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse) @router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse)
def reject_proposal( def reject_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
body: RejectRequest | None = None, body: RejectRequest | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reject a proposal.""" """Reject a proposal."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") 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) @router.post("/{proposal_id}/reopen", response_model=schemas.ProposalResponse)
def reopen_proposal( def reopen_proposal(
project_id: str, project_code: str,
proposal_id: str, proposal_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reopen a rejected proposal back to open.""" """Reopen a rejected proposal back to open."""
project = _find_project(db, project_id) project = _find_project(db, project_code)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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: if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found") raise HTTPException(status_code=404, detail="Proposal not found")

View File

@@ -28,83 +28,83 @@ from app.api.rbac import check_project_role, check_permission, is_global_admin
from app.services.activity import log_activity from app.services.activity import log_activity
# Legacy router — same logic, old URL prefix # 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]) @router.get("", response_model=List[schemas.ProposalResponse])
def list_proposes( def list_proposes(
project_id: str, project_code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import list_proposals 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) @router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
def create_propose( def create_propose(
project_id: str, project_code: str,
proposal_in: schemas.ProposalCreate, proposal_in: schemas.ProposalCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import create_proposal 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) @router.get("/{propose_id}", response_model=schemas.ProposalResponse)
def get_propose( def get_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import get_proposal 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) @router.patch("/{propose_id}", response_model=schemas.ProposalResponse)
def update_propose( def update_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
proposal_in: schemas.ProposalUpdate, proposal_in: schemas.ProposalUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import update_proposal 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) @router.post("/{propose_id}/accept", response_model=schemas.ProposalResponse)
def accept_propose( def accept_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
body: AcceptRequest, body: AcceptRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import accept_proposal 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) @router.post("/{propose_id}/reject", response_model=schemas.ProposalResponse)
def reject_propose( def reject_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
body: RejectRequest | None = None, body: RejectRequest | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import reject_proposal 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) @router.post("/{propose_id}/reopen", response_model=schemas.ProposalResponse)
def reopen_propose( def reopen_propose(
project_id: str, project_code: str,
propose_id: str, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
from app.api.routers.proposals import reopen_proposal 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)

View File

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

View File

@@ -7,7 +7,7 @@ from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_password_hash from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
from app.core.config import get_db from app.core.config import get_db
from app.models import models from app.models import models
from app.models.agent import Agent from app.models.agent import Agent
@@ -57,7 +57,7 @@ def _has_global_permission(db: Session, user: models.User, permission_name: str)
def require_account_creator( def require_account_creator(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
if current_user.is_admin or _has_global_permission(db, current_user, "account.create"): if current_user.is_admin or _has_global_permission(db, current_user, "account.create"):
return current_user return current_user

View File

@@ -140,6 +140,8 @@ def _migrate_schema():
if not result.fetchone(): if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL")) 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)")) 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 # projects.owner_name
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")) result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
@@ -173,6 +175,8 @@ def _migrate_schema():
if not result.fetchone(): if not result.fetchone():
db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL")) 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") _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 # milestones creator field
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'")) 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) --- # --- Milestone status enum migration (old -> new) ---
if _has_table(db, "milestones"): 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 # Alter enum column to accept new values
db.execute(text( db.execute(text(
"ALTER TABLE milestones MODIFY COLUMN status " "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"): 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")) 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 --- # --- server_states nginx telemetry for generic monitor client ---
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"): 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")) db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_installed BOOLEAN NULL"))

View File

@@ -43,9 +43,7 @@ class TaskBase(BaseModel):
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
project_id: Optional[int] = None
project_code: Optional[str] = None project_code: Optional[str] = None
milestone_id: Optional[int] = None
milestone_code: Optional[str] = None milestone_code: Optional[str] = None
reporter_id: Optional[int] = None reporter_id: Optional[int] = None
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
@@ -75,15 +73,12 @@ class TaskUpdate(BaseModel):
class TaskResponse(TaskBase): class TaskResponse(TaskBase):
id: int
status: TaskStatusEnum status: TaskStatusEnum
task_code: Optional[str] = None task_code: Optional[str] = None
code: Optional[str] = None code: Optional[str] = None
type: Optional[str] = None type: Optional[str] = None
due_date: Optional[datetime] = None due_date: Optional[datetime] = None
project_id: int
project_code: Optional[str] = None project_code: Optional[str] = None
milestone_id: int
milestone_code: Optional[str] = None milestone_code: Optional[str] = None
reporter_id: int reporter_id: int
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
@@ -94,8 +89,8 @@ class TaskResponse(TaskBase):
positions: Optional[str] = None positions: Optional[str] = None
pending_matters: Optional[str] = None pending_matters: Optional[str] = None
# BE-PR-008: Proposal Accept tracking # BE-PR-008: Proposal Accept tracking
source_proposal_id: Optional[int] = None source_proposal_code: Optional[str] = None
source_essential_id: Optional[int] = None source_essential_code: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@@ -259,9 +254,9 @@ class MilestoneUpdate(BaseModel):
class MilestoneResponse(MilestoneBase): class MilestoneResponse(MilestoneBase):
id: int
milestone_code: Optional[str] = None milestone_code: Optional[str] = None
project_id: int code: Optional[str] = None
project_code: Optional[str] = None
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
started_at: Optional[datetime] = None started_at: Optional[datetime] = None
created_at: datetime created_at: datetime
@@ -285,7 +280,7 @@ class ProposalBase(BaseModel):
class ProposalCreate(ProposalBase): class ProposalCreate(ProposalBase):
project_id: Optional[int] = None pass
class ProposalUpdate(BaseModel): class ProposalUpdate(BaseModel):
@@ -294,11 +289,10 @@ class ProposalUpdate(BaseModel):
class ProposalResponse(ProposalBase): class ProposalResponse(ProposalBase):
id: int
proposal_code: Optional[str] = None # preferred name proposal_code: Optional[str] = None # preferred name
propose_code: Optional[str] = None # backward compat alias (same value) propose_code: Optional[str] = None # backward compat alias (same value)
status: ProposalStatusEnum status: ProposalStatusEnum
project_id: int project_code: Optional[str] = None
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
created_by_username: Optional[str] = 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. 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): class EssentialResponse(EssentialBase):
id: int
essential_code: str essential_code: str
proposal_id: int proposal_code: Optional[str] = None
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@@ -353,13 +346,12 @@ class EssentialResponse(EssentialBase):
class GeneratedTaskBrief(BaseModel): class GeneratedTaskBrief(BaseModel):
"""Brief info about a story task generated from Proposal Accept.""" """Brief info about a story task generated from Proposal Accept."""
task_id: int
task_code: Optional[str] = None task_code: Optional[str] = None
task_type: str task_type: str
task_subtype: Optional[str] = None task_subtype: Optional[str] = None
title: str title: str
status: Optional[str] = None status: Optional[str] = None
source_essential_id: Optional[int] = None source_essential_code: Optional[str] = None
class ProposalDetailResponse(ProposalResponse): class ProposalDetailResponse(ProposalResponse):
@@ -374,12 +366,10 @@ class ProposalDetailResponse(ProposalResponse):
class GeneratedTaskSummary(BaseModel): class GeneratedTaskSummary(BaseModel):
"""Brief summary of a task generated from a Proposal Essential.""" """Brief summary of a task generated from a Proposal Essential."""
task_id: int
task_code: str task_code: str
task_type: str task_type: str
task_subtype: str task_subtype: str
title: str title: str
essential_id: int
essential_code: str essential_code: str