"""Projects CRUD + per-project stats.""" from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import Project, Quote, User from app.schemas.procurement import ProjectCreate, ProjectUpdate from app.services.quote_service import project_stats from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_date, to_iso router = APIRouter(prefix="/projects", tags=["projects"]) _procurement_writer = require_procurement_writer() async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: """JWT caller when present — used for RBAC on PM assignment (PATCH).""" return await get_optional_user(request, db) async def _pm_map(db: AsyncSession, projects: list[Project]) -> dict: """project_id -> pm brief (v2.6: the single PM of the project).""" pm_ids = {p.pm_user_id for p in projects if p.pm_user_id} if not pm_ids: return {} users = (await db.execute(select(User).where(User.id.in_(pm_ids)))).scalars().all() return {u.id: u for u in users} async def project_to_dict(db: AsyncSession, project: Project) -> dict: d = { "id": str(project.id), "name": project.name, "location": project.location or "", "project_manager": project.project_manager or "", "description": project.description or "", "start_date": to_date(project.start_date), "status": project.status or "ACTIVE", "progress": float(project.progress) if project.progress is not None else 0.0, "pm_user_id": str(project.pm_user_id) if project.pm_user_id else None, "created_at": to_iso(project.created_at), } if project.pm_user_id: pm = await db.get(User, project.pm_user_id) if pm: d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id elif project.project_manager: d["pm_name"] = project.project_manager return d @router.get("") async def list_projects( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), q: str | None = None, sort: str | None = None, db: AsyncSession = Depends(get_db), ): stmt = select(Project) if q: stmt = stmt.where( or_( Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%"), Project.location.ilike(f"%{q}%"), Project.project_manager.ilike(f"%{q}%"), ) ) stmt = sort_query(stmt, Project, sort) items, meta = await paginate(stmt, page, page_size, db) pm_map = await _pm_map(db, items) data = [] for p in items: d = { "id": str(p.id), "name": p.name, "location": p.location or "", "project_manager": p.project_manager or "", "description": p.description or "", "start_date": to_date(p.start_date), "status": p.status or "ACTIVE", "progress": float(p.progress) if p.progress is not None else 0.0, "pm_user_id": str(p.pm_user_id) if p.pm_user_id else None, "created_at": to_iso(p.created_at), } pm = pm_map.get(p.pm_user_id) if pm: d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id elif p.project_manager: d["pm_name"] = p.project_manager data.append(d) return {"data": data, "meta": meta} @router.post("", status_code=201) async def create_project( payload: ProjectCreate, db: AsyncSession = Depends(get_db), _auth=Depends(_procurement_writer), ): project = Project( name=payload.name.strip(), location=payload.location.strip() if payload.location else "", project_manager=payload.project_manager.strip() if payload.project_manager else "", description=payload.description.strip() if payload.description else "", start_date=payload.start_date, planned_start=payload.start_date, status="ACTIVE", ) db.add(project) await db.flush() return {"data": await project_to_dict(db, project)} @router.get("/{project_id}") async def get_project(project_id, db: AsyncSession = Depends(get_db)): project = await db.get(Project, project_id) if project is None: raise NotFoundError("پروژه یافت نشد") return {"data": await project_to_dict(db, project)} @router.patch("/{project_id}") async def update_project( project_id, payload: ProjectUpdate, request: Request, ctx: UserContext | None = Depends(_optional_ctx), db: AsyncSession = Depends(get_db), _auth=Depends(_procurement_writer), ): project = await db.get(Project, project_id) if project is None: raise NotFoundError("پروژه یافت نشد") if payload.name is not None: project.name = payload.name.strip() if payload.location is not None: project.location = payload.location.strip() if payload.project_manager is not None: project.project_manager = payload.project_manager.strip() if payload.description is not None: project.description = payload.description.strip() if payload.start_date is not None: project.start_date = payload.start_date project.planned_start = payload.start_date if payload.status is not None: project.status = payload.status # PM assignment (1:N): one PM per project, a PM may own many projects. # Admin / procurement manager only (same rule as the Organization panel). if "pm_user_id" in payload.model_fields_set: role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower() if role not in ("admin", "procurement_manager", "pm"): raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است") if payload.pm_user_id is None: project.pm_user_id = None else: pm_user = await db.get(User, payload.pm_user_id) if pm_user is None: raise NotFoundError("کاربر یافت نشد", field="pm_user_id") if pm_user.role not in ("supervisor", "pm"): raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id") project.pm_user_id = pm_user.id if not project.project_manager: project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip() await db.flush() return {"data": await project_to_dict(db, project)} @router.delete("/{project_id}") async def delete_project( project_id, db: AsyncSession = Depends(get_db), _auth=Depends(_procurement_writer), ): project = await db.get(Project, project_id) if project is None: raise NotFoundError("پروژه یافت نشد") # Recommendation (P2-24): block instead of silently unlinking quotes (SET NULL). linked = await db.scalar(select(func.count()).select_from(Quote).where(Quote.project_id == project_id)) if linked: raise ConflictError("این پروژه دارای پیشنهاد است و قابل حذف نیست") await db.delete(project) await db.flush() return {"data": {"id": str(project_id), "deleted": True}} @router.get("/{project_id}/stats") async def get_project_stats(project_id, db: AsyncSession = Depends(get_db)): return {"data": await project_stats(db, project_id)}