Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly
Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)
Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
152 lines
5.9 KiB
Python
152 lines
5.9 KiB
Python
"""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
|
|
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"])
|
|
|
|
|
|
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,
|
|
"description": project.description,
|
|
"start_date": to_date(project.start_date),
|
|
"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
|
|
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}%")))
|
|
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,
|
|
"description": p.description,
|
|
"start_date": to_date(p.start_date),
|
|
"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
|
|
data.append(d)
|
|
return {"data": data, "meta": meta}
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
|
project = Project(
|
|
name=payload.name,
|
|
description=payload.description,
|
|
start_date=payload.start_date,
|
|
)
|
|
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),
|
|
):
|
|
project = await db.get(Project, project_id)
|
|
if project is None:
|
|
raise NotFoundError("پروژه یافت نشد")
|
|
if payload.name is not None:
|
|
project.name = payload.name
|
|
if payload.description is not None:
|
|
project.description = payload.description
|
|
if payload.start_date is not None:
|
|
project.start_date = payload.start_date
|
|
# 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"):
|
|
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 != "pm":
|
|
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
|
|
project.pm_user_id = pm_user.id
|
|
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)):
|
|
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)}
|