feat: switch backend indexing to code-first identifiers

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

View File

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