Compare commits
1 Commits
8d2d467bd8
...
431f4abe5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 431f4abe5a |
311
app/api/routers/essentials.py
Normal file
311
app/api/routers/essentials.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user