diff --git a/app/api/routers/essentials.py b/app/api/routers/essentials.py new file mode 100644 index 0000000..7279ce0 --- /dev/null +++ b/app/api/routers/essentials.py @@ -0,0 +1,311 @@ +"""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, + ) diff --git a/app/api/routers/proposals.py b/app/api/routers/proposals.py index 8ff7cd2..5c5056f 100644 --- a/app/api/routers/proposals.py +++ b/app/api/routers/proposals.py @@ -13,6 +13,7 @@ from app.api.deps import get_current_user_or_apikey from app.api.rbac import check_project_role, check_permission, is_global_admin from app.models import models from app.models.proposal import Proposal, ProposalStatus +from app.models.essential import Essential from app.models.milestone import Milestone, MilestoneStatus from app.models.task import Task, TaskStatus, TaskPriority from app.schemas import schemas @@ -21,11 +22,26 @@ from app.services.activity import log_activity router = APIRouter(prefix="/projects/{project_id}/proposals", tags=["Proposals"]) -def _serialize_proposal(db: Session, proposal: Proposal) -> dict: +def _serialize_essential(e: Essential) -> dict: + """Serialize an Essential for embedding in Proposal detail.""" + 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, + } + + +def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials: bool = False) -> dict: """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 - return { + result = { "id": proposal.id, "title": proposal.title, "description": proposal.description, @@ -39,6 +55,15 @@ def _serialize_proposal(db: Session, proposal: Proposal) -> dict: "created_at": proposal.created_at, "updated_at": proposal.updated_at, } + if include_essentials: + essentials = ( + db.query(Essential) + .filter(Essential.proposal_id == proposal.id) + .order_by(Essential.id.asc()) + .all() + ) + result["essentials"] = [_serialize_essential(e) for e in essentials] + return result def _find_project(db, identifier): @@ -150,13 +175,14 @@ def create_proposal( return _serialize_proposal(db, proposal) -@router.get("/{proposal_id}", response_model=schemas.ProposalResponse) +@router.get("/{proposal_id}", response_model=schemas.ProposalDetailResponse) def get_proposal( project_id: str, proposal_id: 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) if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -164,7 +190,7 @@ def get_proposal( proposal = _find_proposal(db, proposal_id, project.id) if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") - return _serialize_proposal(db, proposal) + return _serialize_proposal(db, proposal, include_essentials=True) @router.patch("/{proposal_id}", response_model=schemas.ProposalResponse) diff --git a/app/main.py b/app/main.py index 698fbe4..b2fd104 100644 --- a/app/main.py +++ b/app/main.py @@ -61,6 +61,7 @@ from app.api.routers.proposals import router as proposals_router from app.api.routers.proposes import router as proposes_router # legacy compat from app.api.routers.milestone_actions import router as milestone_actions_router from app.api.routers.meetings import router as meetings_router +from app.api.routers.essentials import router as essentials_router app.include_router(auth_router) app.include_router(tasks_router) @@ -76,6 +77,7 @@ app.include_router(proposals_router) app.include_router(proposes_router) # legacy compat app.include_router(milestone_actions_router) app.include_router(meetings_router) +app.include_router(essentials_router) # Auto schema migration for lightweight deployments