285 lines
9.4 KiB
Python
285 lines
9.4 KiB
Python
"""Essentials API router — CRUD for Essentials nested under a Proposal.
|
|
|
|
Endpoints are scoped to a project and proposal:
|
|
/projects/{project_code}/proposals/{proposal_code}/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_code}/proposals/{proposal_code}/essentials",
|
|
tags=["Essentials"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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(project_code)
|
|
).first()
|
|
|
|
|
|
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(proposal_code), Proposal.project_id == project_id)
|
|
.first()
|
|
)
|
|
|
|
|
|
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(essential_code), 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, proposal_code: str | None) -> dict:
|
|
"""Return a dict matching EssentialResponse."""
|
|
return {
|
|
"essential_code": e.essential_code,
|
|
"proposal_code": proposal_code,
|
|
"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_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_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_code, 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, proposal.propose_code) for e in essentials]
|
|
|
|
|
|
@router.post("", response_model=EssentialResponse, status_code=status.HTTP_201_CREATED)
|
|
def create_essential(
|
|
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_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_code, 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, proposal.propose_code)
|
|
|
|
|
|
@router.get("/{essential_id}", response_model=EssentialResponse)
|
|
def get_essential(
|
|
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 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_code, project.id)
|
|
if not proposal:
|
|
raise HTTPException(status_code=404, detail="Proposal not found")
|
|
|
|
essential = _find_essential(db, essential_code, proposal.id)
|
|
if not essential:
|
|
raise HTTPException(status_code=404, detail="Essential not found")
|
|
|
|
return _serialize_essential(essential, proposal.propose_code)
|
|
|
|
|
|
@router.patch("/{essential_id}", response_model=EssentialResponse)
|
|
def update_essential(
|
|
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_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_code, 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_code, 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, proposal.propose_code)
|
|
|
|
|
|
@router.delete("/{essential_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_essential(
|
|
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_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_code, 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_code, 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,
|
|
)
|