Compare commits
2 Commits
f5bf480c76
...
ae353afbed
| Author | SHA1 | Date | |
|---|---|---|---|
| ae353afbed | |||
| 58d3ca6ad0 |
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
18
app/main.py
18
app/main.py
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user