Backend: 7 nouveaux endpoints (advance, assign, revoke, create-vote-session), services enrichis avec creation de sessions de vote, assignation de mandataire et revocation. 35 nouveaux tests (104 total). Frontend: store mandates, page cadrage decisions, detail mandats, composants DecisionWorkflow, DecisionCadrage, DecisionCard, MandateTimeline, MandateCard. Documentation mise a jour. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
208 lines
7.1 KiB
Python
208 lines
7.1 KiB
Python
"""Decisions router: CRUD for decision processes and their steps."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.database import get_db
|
|
from app.models.decision import Decision, DecisionStep
|
|
from app.models.user import DuniterIdentity
|
|
from app.schemas.decision import (
|
|
DecisionAdvanceOut,
|
|
DecisionCreate,
|
|
DecisionOut,
|
|
DecisionStepCreate,
|
|
DecisionStepOut,
|
|
DecisionUpdate,
|
|
)
|
|
from app.schemas.vote import VoteSessionOut
|
|
from app.services.auth_service import get_current_identity
|
|
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
|
"""Fetch a decision by ID with its steps eagerly loaded, or raise 404."""
|
|
result = await db.execute(
|
|
select(Decision)
|
|
.options(selectinload(Decision.steps))
|
|
.where(Decision.id == decision_id)
|
|
)
|
|
decision = result.scalar_one_or_none()
|
|
if decision is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
|
|
return decision
|
|
|
|
|
|
# ── Decision routes ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/", response_model=list[DecisionOut])
|
|
async def list_decisions(
|
|
db: AsyncSession = Depends(get_db),
|
|
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
|
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
|
skip: int = Query(default=0, ge=0),
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
) -> list[DecisionOut]:
|
|
"""List all decisions with optional filters."""
|
|
stmt = select(Decision).options(selectinload(Decision.steps))
|
|
|
|
if decision_type is not None:
|
|
stmt = stmt.where(Decision.decision_type == decision_type)
|
|
if status_filter is not None:
|
|
stmt = stmt.where(Decision.status == status_filter)
|
|
|
|
stmt = stmt.order_by(Decision.created_at.desc()).offset(skip).limit(limit)
|
|
result = await db.execute(stmt)
|
|
decisions = result.scalars().unique().all()
|
|
|
|
return [DecisionOut.model_validate(d) for d in decisions]
|
|
|
|
|
|
@router.post("/", response_model=DecisionOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_decision(
|
|
payload: DecisionCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
identity: DuniterIdentity = Depends(get_current_identity),
|
|
) -> DecisionOut:
|
|
"""Create a new decision process."""
|
|
decision = Decision(
|
|
**payload.model_dump(),
|
|
created_by_id=identity.id,
|
|
)
|
|
db.add(decision)
|
|
await db.commit()
|
|
await db.refresh(decision)
|
|
|
|
# Reload with steps (empty at creation)
|
|
decision = await _get_decision(db, decision.id)
|
|
return DecisionOut.model_validate(decision)
|
|
|
|
|
|
@router.get("/{id}", response_model=DecisionOut)
|
|
async def get_decision(
|
|
id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> DecisionOut:
|
|
"""Get a single decision with all its steps."""
|
|
decision = await _get_decision(db, id)
|
|
return DecisionOut.model_validate(decision)
|
|
|
|
|
|
@router.put("/{id}", response_model=DecisionOut)
|
|
async def update_decision(
|
|
id: uuid.UUID,
|
|
payload: DecisionUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
identity: DuniterIdentity = Depends(get_current_identity),
|
|
) -> DecisionOut:
|
|
"""Update a decision's metadata (title, description, status, protocol)."""
|
|
decision = await _get_decision(db, id)
|
|
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(decision, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(decision)
|
|
|
|
# Reload with steps
|
|
decision = await _get_decision(db, decision.id)
|
|
return DecisionOut.model_validate(decision)
|
|
|
|
|
|
# ── Decision Step routes ────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/{id}/steps", response_model=DecisionStepOut, status_code=status.HTTP_201_CREATED)
|
|
async def add_step(
|
|
id: uuid.UUID,
|
|
payload: DecisionStepCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
identity: DuniterIdentity = Depends(get_current_identity),
|
|
) -> DecisionStepOut:
|
|
"""Add a step to a decision process."""
|
|
# Verify decision exists
|
|
decision = await _get_decision(db, id)
|
|
|
|
step = DecisionStep(
|
|
decision_id=decision.id,
|
|
**payload.model_dump(),
|
|
)
|
|
db.add(step)
|
|
await db.commit()
|
|
await db.refresh(step)
|
|
|
|
return DecisionStepOut.model_validate(step)
|
|
|
|
|
|
# ── Workflow routes ────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/{id}/advance", response_model=DecisionAdvanceOut)
|
|
async def advance_decision_endpoint(
|
|
id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
identity: DuniterIdentity = Depends(get_current_identity),
|
|
) -> DecisionAdvanceOut:
|
|
"""Advance a decision to its next step or status."""
|
|
try:
|
|
decision = await advance_decision(id, db)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
|
|
# Reload with steps for complete output
|
|
decision = await _get_decision(db, decision.id)
|
|
data = DecisionOut.model_validate(decision).model_dump()
|
|
data["message"] = f"Decision avancee au statut : {decision.status}"
|
|
return DecisionAdvanceOut(**data)
|
|
|
|
|
|
@router.post(
|
|
"/{id}/steps/{step_id}/create-vote-session",
|
|
response_model=VoteSessionOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_vote_session_for_step_endpoint(
|
|
id: uuid.UUID,
|
|
step_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
identity: DuniterIdentity = Depends(get_current_identity),
|
|
) -> VoteSessionOut:
|
|
"""Create a vote session linked to a decision step."""
|
|
try:
|
|
session = await create_vote_session_for_step(id, step_id, db)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
|
|
return VoteSessionOut.model_validate(session)
|
|
|
|
|
|
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_decision(
|
|
id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
identity: DuniterIdentity = Depends(get_current_identity),
|
|
) -> None:
|
|
"""Delete a decision (only if in draft status)."""
|
|
decision = await _get_decision(db, id)
|
|
|
|
if decision.status != "draft":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Seules les decisions en brouillon peuvent etre supprimees",
|
|
)
|
|
|
|
await db.delete(decision)
|
|
await db.commit()
|