BE-PR-006: Add Essential CRUD API under Proposals

- New router: /projects/{project_id}/proposals/{proposal_id}/essentials
  - GET (list), POST (create), GET/{id}, PATCH/{id}, DELETE/{id}
- All mutations restricted to open proposals only
- Permission: creator, project owner, or global admin
- Registered essentials router in main.py
- Updated GET /proposals/{id} to return ProposalDetailResponse with
  embedded essentials list
- Activity logging on all CRUD operations
This commit is contained in:
zhi
2026-03-30 07:16:30 +00:00
parent 8d2d467bd8
commit 431f4abe5a
3 changed files with 343 additions and 4 deletions

View File

@@ -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,
)

View File

@@ -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.api.rbac import check_project_role, check_permission, is_global_admin
from app.models import models from app.models import models
from app.models.proposal import Proposal, ProposalStatus from app.models.proposal import Proposal, ProposalStatus
from app.models.essential import Essential
from app.models.milestone import Milestone, MilestoneStatus from app.models.milestone import Milestone, MilestoneStatus
from app.models.task import Task, TaskStatus, TaskPriority from app.models.task import Task, TaskStatus, TaskPriority
from app.schemas import schemas 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"]) 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.""" """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
return { result = {
"id": proposal.id, "id": proposal.id,
"title": proposal.title, "title": proposal.title,
"description": proposal.description, "description": proposal.description,
@@ -39,6 +55,15 @@ def _serialize_proposal(db: Session, proposal: Proposal) -> dict:
"created_at": proposal.created_at, "created_at": proposal.created_at,
"updated_at": proposal.updated_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): def _find_project(db, identifier):
@@ -150,13 +175,14 @@ def create_proposal(
return _serialize_proposal(db, 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( def get_proposal(
project_id: str, project_id: str,
proposal_id: str, proposal_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),
): ):
"""Get a single Proposal with its Essentials list embedded."""
project = _find_project(db, project_id) project = _find_project(db, project_id)
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
@@ -164,7 +190,7 @@ def get_proposal(
proposal = _find_proposal(db, proposal_id, project.id) proposal = _find_proposal(db, proposal_id, 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) return _serialize_proposal(db, proposal, include_essentials=True)
@router.patch("/{proposal_id}", response_model=schemas.ProposalResponse) @router.patch("/{proposal_id}", response_model=schemas.ProposalResponse)

View File

@@ -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.proposes import router as proposes_router # legacy compat
from app.api.routers.milestone_actions import router as milestone_actions_router 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.meetings import router as meetings_router
from app.api.routers.essentials import router as essentials_router
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(tasks_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(proposes_router) # legacy compat
app.include_router(milestone_actions_router) app.include_router(milestone_actions_router)
app.include_router(meetings_router) app.include_router(meetings_router)
app.include_router(essentials_router)
# Auto schema migration for lightweight deployments # Auto schema migration for lightweight deployments