feat: switch backend indexing to code-first identifiers

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

View File

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

View File

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

View File

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

View File

@@ -48,10 +48,10 @@ def _find_milestone(db, identifier, project_id: int = None) -> Milestone | None:
return q.first()
def _serialize_milestone(milestone):
"""Serialize milestone with JSON fields and code."""
def _serialize_milestone(db, milestone):
"""Serialize milestone with JSON fields and code-first identifiers."""
project = db.query(models.Project).filter(models.Project.id == milestone.project_id).first()
return {
"id": milestone.id,
"title": milestone.title,
"description": milestone.description,
"status": milestone.status.value if hasattr(milestone.status, 'value') else milestone.status,
@@ -59,9 +59,9 @@ def _serialize_milestone(milestone):
"planned_release_date": milestone.planned_release_date,
"depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [],
"depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
"project_id": milestone.project_id,
"milestone_code": milestone.milestone_code,
"code": milestone.milestone_code,
"project_code": project.project_code if project else None,
"created_by_id": milestone.created_by_id,
"started_at": milestone.started_at,
"created_at": milestone.created_at,
@@ -76,7 +76,7 @@ def list_milestones(project_id: str, db: Session = Depends(get_db), current_user
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all()
return [_serialize_milestone(m) for m in milestones]
return [_serialize_milestone(db, m) for m in milestones]
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED)
@@ -101,7 +101,7 @@ def create_milestone(project_id: str, milestone: schemas.MilestoneCreate, db: Se
db.add(db_milestone)
db.commit()
db.refresh(db_milestone)
return _serialize_milestone(db_milestone)
return _serialize_milestone(db, db_milestone)
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
@@ -113,7 +113,7 @@ def get_milestone(project_id: str, milestone_id: str, db: Session = Depends(get_
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
return _serialize_milestone(milestone)
return _serialize_milestone(db, milestone)
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
@@ -163,7 +163,7 @@ def update_milestone(project_id: str, milestone_id: str, milestone: schemas.Mile
setattr(db_milestone, key, value)
db.commit()
db.refresh(db_milestone)
return _serialize_milestone(db_milestone)
return _serialize_milestone(db, db_milestone)
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)

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"])
def list_milestones(project_id: str = None, status_filter: str = None, db: Session = Depends(get_db)):
def list_milestones(project_id: str = None, project_code: str = None, status_filter: str = None, db: Session = Depends(get_db)):
query = db.query(MilestoneModel)
if project_id:
effective_project = project_code or project_id
if effective_project:
# Resolve project_id by numeric id or project_code
resolved_project = None
try:
pid = int(project_id)
pid = int(effective_project)
resolved_project = db.query(models.Project).filter(models.Project.id == pid).first()
except (ValueError, TypeError):
pass
if not resolved_project:
resolved_project = db.query(models.Project).filter(models.Project.project_code == project_id).first()
resolved_project = db.query(models.Project).filter(models.Project.project_code == effective_project).first()
if not resolved_project:
raise HTTPException(status_code=404, detail="Project not found")
query = query.filter(MilestoneModel.project_id == resolved_project.id)
@@ -428,14 +429,21 @@ def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)):
# ============ Milestone-scoped Tasks ============
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
def list_milestone_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
def list_milestone_tasks(project_code: str, milestone_id: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
milestone = db.query(MilestoneModel).filter(
MilestoneModel.milestone_code == milestone_id,
MilestoneModel.project_id == project.id,
).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
tasks = db.query(Task).filter(
Task.project_id == project.id,
Task.milestone_id == milestone_id
Task.milestone_id == milestone.id
).all()
return [{
@@ -459,12 +467,12 @@ def list_milestone_tasks(project_code: str, milestone_id: int, db: Session = Dep
@router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Tasks"])
def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
def create_milestone_task(project_code: str, milestone_id: str, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
@@ -491,7 +499,7 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
task_type=task_data.get("task_type", "issue"), # P7.1: default changed from 'task' to 'issue'
task_subtype=task_data.get("task_subtype"),
project_id=project.id,
milestone_id=milestone_id,
milestone_id=ms.id,
reporter_id=current_user.id,
task_code=task_code,
estimated_effort=task_data.get("estimated_effort"),
@@ -503,10 +511,10 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
db.refresh(task)
return {
"id": task.id,
"title": task.title,
"description": task.description,
"task_code": task.task_code,
"code": task.task_code,
"status": task.status.value,
"priority": task.priority.value,
"created_at": task.created_at,
@@ -516,15 +524,8 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
# ============ Supports ============
def _find_support_by_id_or_code(db: Session, identifier: str) -> Support | None:
try:
support_id = int(identifier)
support = db.query(Support).filter(Support.id == support_id).first()
if support:
return support
except (TypeError, ValueError):
pass
return db.query(Support).filter(Support.support_code == str(identifier)).first()
def _find_support_by_code(db: Session, support_code: str) -> Support | None:
return db.query(Support).filter(Support.support_code == str(support_code)).first()
@@ -536,16 +537,13 @@ def _serialize_support(db: Session, support: Support) -> dict:
assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first()
return {
"id": support.id,
"code": support.support_code,
"support_code": support.support_code,
"title": support.title,
"description": support.description,
"status": support.status.value if hasattr(support.status, "value") else support.status,
"priority": support.priority.value if hasattr(support.priority, "value") else support.priority,
"project_id": support.project_id,
"project_code": project.project_code if project else None,
"milestone_id": support.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": support.reporter_id,
"assignee_id": support.assignee_id,
@@ -585,26 +583,30 @@ def list_all_supports(
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
def list_supports(project_code: str, milestone_id: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
milestone = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
supports = db.query(Support).filter(
Support.project_id == project.id,
Support.milestone_id == milestone_id
Support.milestone_id == milestone.id
).all()
return [_serialize_support(db, s) for s in supports]
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"])
def create_support(project_code: str, milestone_id: int, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
def create_support(project_code: str, milestone_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
@@ -612,7 +614,7 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
milestone_code = ms.milestone_code or f"m{ms.id}"
max_support = db.query(Support).filter(Support.milestone_id == milestone_id).order_by(Support.id.desc()).first()
max_support = db.query(Support).filter(Support.milestone_id == ms.id).order_by(Support.id.desc()).first()
next_num = (max_support.id + 1) if max_support else 1
support_code = f"{milestone_code}:S{next_num:05x}"
@@ -622,7 +624,7 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
status=SupportStatus.OPEN,
priority=SupportPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone_id,
milestone_id=ms.id,
reporter_id=current_user.id,
support_code=support_code,
)
@@ -632,18 +634,18 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
return _serialize_support(db, support)
@router.get("/supports/{support_id}", tags=["Supports"])
def get_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
@router.get("/supports/{support_code}", tags=["Supports"])
def get_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="viewer")
return _serialize_support(db, support)
@router.patch("/supports/{support_id}", tags=["Supports"])
def update_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
@router.patch("/supports/{support_code}", tags=["Supports"])
def update_support(support_code: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev")
@@ -668,9 +670,9 @@ def update_support(support_id: str, support_data: dict, db: Session = Depends(ge
return _serialize_support(db, support)
@router.delete("/supports/{support_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"])
def delete_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
@router.delete("/supports/{support_code}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"])
def delete_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev")
@@ -679,9 +681,9 @@ def delete_support(support_id: str, db: Session = Depends(get_db), current_user:
return None
@router.post("/supports/{support_id}/take", tags=["Supports"])
def take_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
@router.post("/supports/{support_code}/take", tags=["Supports"])
def take_support(support_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev")
@@ -697,9 +699,9 @@ def take_support(support_id: str, db: Session = Depends(get_db), current_user: m
return _serialize_support(db, support)
@router.post("/supports/{support_id}/transition", tags=["Supports"])
def transition_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
@router.post("/supports/{support_code}/transition", tags=["Supports"])
def transition_support(support_code: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_code(db, support_code)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev")
@@ -717,20 +719,25 @@ def transition_support(support_id: str, support_data: dict, db: Session = Depend
# ============ Meetings ============
@router.get("/meetings/{project_code}/{milestone_id}", tags=["Meetings"])
def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
def list_meetings(project_code: str, milestone_id: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
milestone = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
meetings = db.query(Meeting).filter(
Meeting.project_id == project.id,
Meeting.milestone_id == milestone_id
Meeting.milestone_id == milestone.id
).all()
return [{
"id": m.id,
"title": m.title,
"description": m.description,
"meeting_code": m.meeting_code,
"code": m.meeting_code,
"status": m.status.value,
"priority": m.priority.value,
"scheduled_at": m.scheduled_at,
@@ -740,12 +747,12 @@ def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(ge
@router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Meetings"])
def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
def create_meeting(project_code: str, milestone_id: str, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
@@ -753,7 +760,7 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
milestone_code = ms.milestone_code or f"m{ms.id}"
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).order_by(Meeting.id.desc()).first()
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == ms.id).order_by(Meeting.id.desc()).first()
next_num = (max_meeting.id + 1) if max_meeting else 1
meeting_code = f"{milestone_code}:M{next_num:05x}"
@@ -770,7 +777,7 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
status=MeetingStatus.SCHEDULED,
priority=MeetingPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone_id,
milestone_id=ms.id,
reporter_id=current_user.id,
meeting_code=meeting_code,
scheduled_at=scheduled_at,
@@ -779,4 +786,14 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
db.add(meeting)
db.commit()
db.refresh(meeting)
return meeting
return {
"meeting_code": meeting.meeting_code,
"code": meeting.meeting_code,
"title": meeting.title,
"description": meeting.description,
"status": meeting.status.value,
"priority": meeting.priority.value,
"scheduled_at": meeting.scheduled_at,
"duration_minutes": meeting.duration_minutes,
"created_at": meeting.created_at,
}

View File

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

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

View File

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

View File

@@ -140,6 +140,8 @@ def _migrate_schema():
if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
else:
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_project_code ON projects (project_code)"))
# projects.owner_name
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
@@ -173,6 +175,8 @@ def _migrate_schema():
if not result.fetchone():
db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL"))
_ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id")
if _has_column(db, "tasks", "task_code"):
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_task_code ON tasks (task_code)"))
# milestones creator field
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'"))
@@ -202,6 +206,8 @@ def _migrate_schema():
# --- Milestone status enum migration (old -> new) ---
if _has_table(db, "milestones"):
if _has_column(db, "milestones", "milestone_code"):
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_milestones_milestone_code ON milestones (milestone_code)"))
# Alter enum column to accept new values
db.execute(text(
"ALTER TABLE milestones MODIFY COLUMN status "
@@ -257,6 +263,18 @@ def _migrate_schema():
if _has_table(db, "server_states") and not _has_column(db, "server_states", "plugin_version"):
db.execute(text("ALTER TABLE server_states ADD COLUMN plugin_version VARCHAR(64) NULL"))
if _has_table(db, "meetings") and _has_column(db, "meetings", "meeting_code"):
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_meetings_meeting_code ON meetings (meeting_code)"))
if _has_table(db, "supports") and _has_column(db, "supports", "support_code"):
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_supports_support_code ON supports (support_code)"))
if _has_table(db, "proposes") and _has_column(db, "proposes", "propose_code"):
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_proposes_propose_code ON proposes (propose_code)"))
if _has_table(db, "essentials") and _has_column(db, "essentials", "essential_code"):
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_essentials_essential_code ON essentials (essential_code)"))
# --- server_states nginx telemetry for generic monitor client ---
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"):
db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_installed BOOLEAN NULL"))

View File

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