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