"""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 Only open Proposals allow Essential mutations. """ from typing import List from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from app.core.config import get_db from app.api.deps import get_current_user_or_apikey from app.api.rbac import check_project_role, is_global_admin from app.models import models from app.models.proposal import Proposal, ProposalStatus from app.models.essential import Essential from app.schemas.schemas import ( EssentialCreate, EssentialUpdate, EssentialResponse, ) 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", tags=["Essentials"], ) # --------------------------------------------------------------------------- # 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 return db.query(models.Project).filter( models.Project.project_code == str(identifier) ).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 return ( db.query(Proposal) .filter(Proposal.propose_code == str(identifier), 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 return ( db.query(Essential) .filter(Essential.essential_code == str(identifier), Essential.proposal_id == proposal_id) .first() ) def _require_open_proposal(proposal: Proposal) -> None: """Raise 400 if the proposal is not in open status.""" s = proposal.status.value if hasattr(proposal.status, "value") else proposal.status if s != "open": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Essentials can only be modified on open proposals", ) def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool: """Only creator, project owner, or global admin may mutate Essentials.""" if is_global_admin(db, user_id): return True if proposal.created_by_id == user_id: return True project = db.query(models.Project).filter(models.Project.id == proposal.project_id).first() if project and project.owner_id == user_id: return True return False def _serialize_essential(e: Essential) -> dict: """Return a dict matching EssentialResponse.""" return { "id": e.id, "essential_code": e.essential_code, "proposal_id": e.proposal_id, "type": e.type.value if hasattr(e.type, "value") else e.type, "title": e.title, "description": e.description, "created_by_id": e.created_by_id, "created_at": e.created_at, "updated_at": e.updated_at, } # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("", response_model=List[EssentialResponse]) def list_essentials( project_id: str, proposal_id: 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) 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) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") essentials = ( db.query(Essential) .filter(Essential.proposal_id == proposal.id) .order_by(Essential.id.asc()) .all() ) return [_serialize_essential(e) for e in essentials] @router.post("", response_model=EssentialResponse, status_code=status.HTTP_201_CREATED) def create_essential( project_id: str, proposal_id: 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) 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) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") _require_open_proposal(proposal) if not _can_edit_proposal(db, current_user.id, proposal): raise HTTPException(status_code=403, detail="Permission denied") code = generate_essential_code(db, proposal) essential = Essential( essential_code=code, proposal_id=proposal.id, type=body.type, title=body.title, description=body.description, created_by_id=current_user.id, ) db.add(essential) db.commit() db.refresh(essential) log_activity( db, "create", "essential", essential.id, user_id=current_user.id, details={"title": essential.title, "type": body.type.value, "proposal_id": proposal.id}, ) return _serialize_essential(essential) @router.get("/{essential_id}", response_model=EssentialResponse) def get_essential( project_id: str, proposal_id: str, essential_id: 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) 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) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") essential = _find_essential(db, essential_id, proposal.id) if not essential: raise HTTPException(status_code=404, detail="Essential not found") return _serialize_essential(essential) @router.patch("/{essential_id}", response_model=EssentialResponse) def update_essential( project_id: str, proposal_id: str, essential_id: 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) 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) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") _require_open_proposal(proposal) 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) if not essential: raise HTTPException(status_code=404, detail="Essential not found") data = body.model_dump(exclude_unset=True) for key, value in data.items(): setattr(essential, key, value) db.commit() db.refresh(essential) log_activity( db, "update", "essential", essential.id, user_id=current_user.id, details=data, ) return _serialize_essential(essential) @router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_essential( project_id: str, proposal_id: str, essential_id: 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) 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) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") _require_open_proposal(proposal) 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) if not essential: raise HTTPException(status_code=404, detail="Essential not found") essential_data = { "title": essential.title, "type": essential.type.value if hasattr(essential.type, "value") else essential.type, "proposal_id": proposal.id, } db.delete(essential) db.commit() log_activity( db, "delete", "essential", essential.id, user_id=current_user.id, details=essential_data, )