chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)

The PM panel (PmPanelPage -> iframe /pm/) needs the project-management
platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM,
branch feature/project-management-platform. Update via:
git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
This commit is contained in:
MMD
2026-10-07 15:14:20 +03:30
87 changed files with 23922 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""PM API routers."""
+98
View File
@@ -0,0 +1,98 @@
"""Local auth + role/scope introspection.
`POST /auth/login` is the real entry point; `GET /auth/me` re-reads the caller's
role/permissions/scope (used after a role or assignment change, and by the
frontend to self-heal a session stored before RBAC existed). The `/auth/dev-*`
endpoints implement the dashboard role switcher and only exist while
`PM_DEV_ROLE_SWITCH` is enabled.
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import build_context, dev_login_usernames, dev_switch_enabled
from app.db import get_db
from app.dev_accounts import demo_account, ensure_demo_assignments
from app.errors import AuthError, NotFoundError
from app.models import PMUser, ProjectAssignment
from app.security import authenticate, create_token, get_current_user
from app.serializers import user_payload
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginIn(BaseModel):
username: str
password: str
@router.post("/login")
async def login(payload: LoginIn, db: AsyncSession = Depends(get_db)):
user = await authenticate(db, payload.username, payload.password)
if user is None:
raise AuthError("نام کاربری یا رمز عبور اشتباه است")
ctx = await build_context(db, user)
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
@router.get("/me")
async def me(user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return {"data": user_payload(await build_context(db, user))}
# --- development role switcher -------------------------------------------------
def _require_dev_switch() -> None:
if not dev_switch_enabled():
raise NotFoundError("تغییر نقش فقط در فاز توسعه فعال است")
@router.get("/dev-roles")
async def dev_roles(db: AsyncSession = Depends(get_db)):
"""Demo identities offered by the dashboard role picker (dev only)."""
_require_dev_switch()
# projects only exist after the first pull — self-heal the demo scope here
await ensure_demo_assignments(db)
data = []
for username in dev_login_usernames():
account = demo_account(username)
if account is None:
continue
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
continue
projects = await db.scalar(
select(func.count()).where(ProjectAssignment.user_id == user.id)
)
data.append(
{
"username": user.username,
"display_name": user.display_name,
"role": account.role.value,
"project_count": projects or 0,
"all_projects": account.share is None,
}
)
return {"data": data}
class DevLoginIn(BaseModel):
username: str
@router.post("/dev-login")
async def dev_login(payload: DevLoginIn, db: AsyncSession = Depends(get_db)):
"""Issue a token for a demo account — dev role switching without a password.
Restricted to the configured demo usernames, never arbitrary users.
"""
_require_dev_switch()
if payload.username not in dev_login_usernames():
raise AuthError("این حساب در فهرست حساب‌های توسعه نیست")
user = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
if user is None:
raise AuthError("حساب نمایشی ساخته نشده است؛ سرور را دوباره راه‌اندازی کنید")
ctx = await build_context(db, user)
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
+113
View File
@@ -0,0 +1,113 @@
"""Today dashboard.
Both roles land here, but they need different things from it: the project
manager gets a management overview (every project, with its counters), the
supervisor gets an operational view of the activities in the projects assigned
to them. The data is the same query either way — only scoped by role and
presented differently (see frontend TodayPage).
"""
from datetime import date, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import AuthContext, Perm, require_permission
from app.db import get_db
from app.models import Project, Task
from app.serializers import _iso, native_task_dict
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
view_dashboard = require_permission(Perm.dashboard_view)
async def _native_project_overview(db: AsyncSession, ctx: AuthContext) -> list[dict]:
"""کاشی‌های مدیریتی پروژه‌های بومی برای داشبورد مدیر پروژه."""
proj_stmt = ctx.scope_native(select(Project).order_by(Project.name), Project.id)
projects = (await db.execute(proj_stmt)).scalars().all()
tasks = (await db.execute(ctx.scope_native(select(Task), Task.project_id))).scalars().all()
today_date = date.today()
by_project: dict = {}
for t in tasks:
by_project.setdefault(t.project_id, []).append(t)
rows: list[dict] = []
for p in projects:
mine = by_project.get(p.id, [])
open_tasks = [t for t in mine if t.status not in ("COMPLETED", "CANCELLED")]
stats = {
"total": len(mine),
"active": len(open_tasks),
"awaiting_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")),
"failed_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_failed", "qc_failed_rework")),
"overdue": sum(1 for t in open_tasks if t.planned_end and t.planned_end < today_date),
}
rows.append(
{
"id": str(p.id),
"name": p.name,
"identifier": str(p.id),
"active": p.status == "ACTIVE",
"stats": stats,
}
)
return rows
@router.get("/today")
async def today(ctx: AuthContext = Depends(view_dashboard), db: AsyncSession = Depends(get_db)):
native_stmt = ctx.scope_native(
select(Task).options(selectinload(Task.project)), Task.project_id
)
native_tasks = (await db.execute(native_stmt)).scalars().all()
today_date = date.today()
week_end = today_date + timedelta(days=7)
def is_task_open(t: Task) -> bool:
return t.status != "COMPLETED" and t.status != "CANCELLED"
overdue = [t for t in native_tasks if is_task_open(t) and t.planned_end and t.planned_end < today_date]
due_today = [t for t in native_tasks if is_task_open(t) and t.planned_end == today_date]
upcoming = [
t for t in native_tasks if is_task_open(t) and t.planned_end and today_date < t.planned_end <= week_end
]
awaiting_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")]
failed_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_failed", "qc_failed_rework")]
blocked = [t for t in native_tasks if is_task_open(t) and t.status == "BLOCKED"]
def _native_row(t: Task) -> dict:
p_name = t.project.name if t.project else ""
d = native_task_dict(t, project_name=p_name)
d["subject"] = t.title
d["percentage_done"] = t.progress
d["start_date"] = _iso(t.planned_start)
d["due_date"] = _iso(t.planned_end)
d["is_closed"] = t.status == "COMPLETED"
return d
problems = len(overdue) + len(failed_qc)
warnings = len(awaiting_qc) + len(blocked)
ok = max(len(native_tasks) - problems - warnings, 0)
data = {
"role": ctx.role.value,
"role_label": ctx.role_label,
"scope": ctx.as_dict()["scope"],
"summary": {"problems": problems, "warnings": warnings, "ok": ok},
"sections": {
"overdue": [_native_row(t) for t in sorted(overdue, key=lambda x: x.planned_end or date.max)],
"due_today": [_native_row(t) for t in due_today],
"awaiting_qc": [_native_row(t) for t in awaiting_qc],
"failed_qc": [_native_row(t) for t in failed_qc],
"blocked": [_native_row(t) for t in blocked],
"upcoming": [_native_row(t) for t in sorted(upcoming, key=lambda x: x.planned_end or date.max)[:10]],
},
}
# نمای مدیریتی پروژه‌ها فقط برای نقش بدون محدودیت (مدیر پروژه)
if ctx.all_projects:
data["projects"] = await _native_project_overview(db, ctx)
return {"data": data}
+54
View File
@@ -0,0 +1,54 @@
"""Defect photos — upload to PM volume, serve (LAN-internal, unauthenticated)."""
import uuid
from fastapi import APIRouter, Depends, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import AuthContext, Perm, require_permission
from app.config import settings
from app.db import get_db
from app.errors import ValidationError
router = APIRouter(prefix="/files", tags=["files"])
ALLOWED = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
MAX_SIZE = 10 * 1024 * 1024
# photos are attached to QC defects, so both roles may upload
upload_file = require_permission(Perm.files_upload)
@router.post("")
async def upload(
file: UploadFile,
_: AuthContext = Depends(upload_file),
db: AsyncSession = Depends(get_db),
):
_ = db
ext = ALLOWED.get(file.content_type or "")
if ext is None:
raise ValidationError("فقط تصویر jpg/png/webp پذیرفته میشود", field="file")
data = await file.read()
if len(data) > MAX_SIZE:
raise ValidationError("حجم فایل بیش از حد مجاز است (۱۰ مگابایت)", field="file")
settings.FILES_DIR.mkdir(parents=True, exist_ok=True)
file_id = uuid.uuid4().hex
path = settings.FILES_DIR / f"{file_id}{ext}"
path.write_bytes(data)
return {"data": {"id": file_id, "url": f"/api/v1/files/{file_id}", "filename": file.filename}}
@router.get("/{file_id}")
async def serve(file_id: str, db: AsyncSession = Depends(get_db)):
_ = db
if not file_id.isalnum():
raise ValidationError("شناسه فایل نامعتبر است")
for ext in ALLOWED.values():
path = settings.FILES_DIR / f"{file_id}{ext}"
if path.exists():
media = next(k for k, v in ALLOWED.items() if v == ext)
return FileResponse(path, media_type=media)
from app.errors import NotFoundError
raise NotFoundError("فایل یافت نشد")
+601
View File
@@ -0,0 +1,601 @@
"""Projects (Native PM Construction Platform Projects).
Supports:
- Project list with real-time statistics and scoping.
- Creation, editing, archiving of native projects.
- Project team members management.
- Unique project code and UUID resolution.
"""
from __future__ import annotations
import uuid
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import AuthContext, Perm, require_permission
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import (
Project,
ProjectMember,
ProjectStatus,
Task,
TaskRelation,
TaskRelationType,
)
from app.scheduling_service import (
calculate_schedule,
check_for_cycle,
rollup_parent_tasks,
sync_project_lifecycle_dates,
)
from app.serializers import _iso, project_dict, project_member_dict, task_relation_dict
from app.utils.date_service import date_service
from app.utils.jalali import to_jalali_str
router = APIRouter(prefix="/projects", tags=["projects"])
ACTIVE_QC = ("pre_qc_pending", "exec_qc_pending", "ready")
FAILED_QC = ("pre_qc_failed", "qc_failed_rework")
view_projects = require_permission(Perm.projects_view)
manage_projects = require_permission(Perm.projects_create)
class ProjectCreateSchema(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str = ""
location: str = ""
project_manager: str = ""
planned_start: str | None = None
planned_end: str | None = None
status: str = ProjectStatus.ACTIVE.value
class ProjectUpdateSchema(BaseModel):
name: str | None = None
description: str | None = None
location: str | None = None
project_manager: str | None = None
planned_start: str | None = None
planned_end: str | None = None
actual_start: str | None = None
actual_end: str | None = None
status: str | None = None
progress: float | None = None
class ProjectMemberCreateSchema(BaseModel):
user_id: str
role: str = "site_engineer"
class ErpMappingSchema(BaseModel):
erp_project_id: str | None = None
class TaskRelationCreateSchema(BaseModel):
from_task_id: str
to_task_id: str
relation_type: str = "FS"
lag_days: int = 0
class TaskRelationUpdateSchema(BaseModel):
"""Edit an existing dependency. The endpoints (from/to tasks) are immutable —
changing them is a different relation, so it must be deleted and recreated."""
relation_type: str | None = None
lag_days: int | None = None
async def _resolve_project(db: AsyncSession, ctx: AuthContext, project_id_or_op: str | int) -> Project:
"""Find a project by UUID and enforce scope."""
query = select(Project).options(
selectinload(Project.members).selectinload(ProjectMember.user)
)
try:
val_uuid = uuid.UUID(str(project_id_or_op))
stmt = query.where(Project.id == val_uuid)
except ValueError:
raise NotFoundError("شناسه پروژه نامعتبر است")
project = (await db.execute(stmt)).scalar_one_or_none()
if not project:
raise NotFoundError("پروژه مورد نظر یافت نشد")
ctx.ensure_native_project(project.id)
return project
async def native_project_stats(db: AsyncSession, project_id: uuid.UUID) -> dict:
"""Task counters of one native project."""
today = date_service.today()
rows = (
await db.execute(
select(Task.qc_status, Task.planned_end, Task.status, func.count())
.where(Task.project_id == project_id)
.group_by(Task.qc_status, Task.planned_end, Task.status)
)
).all()
total = sum(r[3] for r in rows)
completed = sum(r[3] for r in rows if r[2] == "COMPLETED")
in_progress = sum(r[3] for r in rows if r[2] == "IN_PROGRESS")
blocked = sum(r[3] for r in rows if r[2] == "BLOCKED")
awaiting = sum(r[3] for r in rows if r[0] in ACTIVE_QC)
failed = sum(r[3] for r in rows if r[0] in FAILED_QC)
overdue = sum(r[3] for r in rows if r[2] != "COMPLETED" and r[1] and r[1] < today)
return {
"total": total,
"completed": completed,
"in_progress": in_progress,
"blocked": blocked,
"awaiting_qc": awaiting,
"failed_qc": failed,
"overdue": overdue,
}
@router.get("")
async def list_projects(
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""List all projects visible to the user, with stats and team counts."""
stmt = select(Project).options(selectinload(Project.members)).order_by(Project.created_at.desc())
# Apply project scoping for supervisor / scoped roles (native projects = UUID)
stmt = ctx.scope_native(stmt, Project.id)
projects = (await db.execute(stmt)).scalars().all()
results = []
for p in projects:
stats = await native_project_stats(db, p.id)
results.append(project_dict(p, member_count=len(p.members), task_stats=stats))
return {"data": results}
@router.post("")
async def create_project(
payload: ProjectCreateSchema,
ctx: AuthContext = Depends(manage_projects),
db: AsyncSession = Depends(get_db),
):
"""Create a new native project."""
p_start = date_service.parse(payload.planned_start) if payload.planned_start else None
p_end = date_service.parse(payload.planned_end) if payload.planned_end else None
if p_start and p_end and p_end < p_start:
raise ValidationError("تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد", field="planned_end")
project = Project(
name=payload.name.strip(),
description=payload.description.strip(),
location=payload.location.strip() if payload.location else "",
project_manager=payload.project_manager.strip() if payload.project_manager else ctx.user.display_name,
planned_start=p_start,
planned_end=p_end,
status=payload.status.upper(),
)
db.add(project)
await db.flush()
# Automatically add the creator as a project manager member
creator_member = ProjectMember(
project_id=project.id,
user_id=ctx.user.id,
role="project_manager",
)
db.add(creator_member)
await db.commit()
await db.refresh(project)
stats = {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "overdue": 0}
res = project_dict(project, member_count=1, task_stats=stats)
res["identifier"] = str(project.id)
return {"data": res}
@router.get("/{id}")
async def get_project(
id: str,
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""Get project details, member list, and overall stats."""
project = await _resolve_project(db, ctx, id)
stats = await native_project_stats(db, project.id)
data = project_dict(project, member_count=len(project.members), task_stats=stats)
data["identifier"] = str(project.id)
data["members"] = [project_member_dict(m) for m in project.members]
return {"data": data}
@router.patch("/{id}")
async def update_project(
id: str,
payload: ProjectUpdateSchema,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Update project details, status, or dates."""
project = await _resolve_project(db, ctx, id)
if payload.name is not None:
project.name = payload.name.strip()
if payload.description is not None:
project.description = payload.description.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.status is not None:
project.status = payload.status.upper()
if payload.progress is not None:
project.progress = max(0.0, min(100.0, payload.progress))
if payload.planned_start is not None:
project.planned_start = date_service.parse(payload.planned_start)
if payload.planned_end is not None:
project.planned_end = date_service.parse(payload.planned_end)
if payload.actual_start is not None:
project.actual_start = date_service.parse(payload.actual_start)
if payload.actual_end is not None:
project.actual_end = date_service.parse(payload.actual_end)
if project.planned_start and project.planned_end and project.planned_end < project.planned_start:
raise ValidationError("تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد", field="planned_end")
await db.commit()
await db.refresh(project)
stats = await native_project_stats(db, project.id)
return {"data": project_dict(project, member_count=len(project.members), task_stats=stats)}
@router.post("/{id}/members")
async def add_project_member(
id: str,
payload: ProjectMemberCreateSchema,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Add a team member to the project."""
project = await _resolve_project(db, ctx, id)
target_user_id = uuid.UUID(payload.user_id)
# Check if already a member
existing = await db.scalar(
select(ProjectMember).where(
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
)
)
if existing:
existing.role = payload.role
else:
member = ProjectMember(
project_id=project.id,
user_id=target_user_id,
role=payload.role,
)
db.add(member)
await db.commit()
# Return updated members list
await db.refresh(project)
project = await _resolve_project(db, ctx, project.id)
return {"data": [project_member_dict(m) for m in project.members]}
@router.delete("/{id}/members/{user_id}")
async def remove_project_member(
id: str,
user_id: str,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Remove a team member from the project."""
project = await _resolve_project(db, ctx, id)
target_user_id = uuid.UUID(user_id)
member = await db.scalar(
select(ProjectMember).where(
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
)
)
if member:
await db.delete(member)
await db.commit()
project = await _resolve_project(db, ctx, project.id)
return {"data": [project_member_dict(m) for m in project.members]}
@router.patch("/{id}/erp-mapping")
async def map_erp_project(
id: str,
payload: ErpMappingSchema,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
project = await _resolve_project(db, ctx, id)
return {"data": {"id": str(project.id)}}
# --- Relations & Dependencies ---
@router.get("/{id}/relations")
async def list_project_relations(
id: str,
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""List all task dependencies in a project."""
project = await _resolve_project(db, ctx, id)
stmt = (
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(TaskRelation.project_id == project.id)
)
relations = (await db.execute(stmt)).scalars().all()
return {"data": [task_relation_dict(r) for r in relations]}
@router.post("/{id}/relations")
async def create_task_relation(
id: str,
payload: TaskRelationCreateSchema,
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
db: AsyncSession = Depends(get_db),
):
"""Create a new task dependency (FS, SS, FF, SF + lag). Enforces cycle check."""
project = await _resolve_project(db, ctx, id)
from_id = uuid.UUID(payload.from_task_id)
to_id = uuid.UUID(payload.to_task_id)
if from_id == to_id:
raise ValidationError("یک وظیفه نمی‌تواند پیش‌نیاز خودش باشد")
# Verify tasks belong to this project
from_task = await db.get(Task, from_id)
to_task = await db.get(Task, to_id)
if not from_task or from_task.project_id != project.id:
raise ValidationError("وظیفه پیش‌نیاز در این پروژه یافت نشد")
if not to_task or to_task.project_id != project.id:
raise ValidationError("وظیفه وابسته در این پروژه یافت نشد")
# Load existing relations to verify DAG
existing_rels = (
await db.execute(select(TaskRelation.from_task_id, TaskRelation.to_task_id).where(TaskRelation.project_id == project.id))
).all()
existing_pairs = [(r[0], r[1]) for r in existing_rels]
# Check for duplicate
if (from_id, to_id) in existing_pairs:
raise ValidationError("این وابستگی قبلاً ثبت شده است")
# Cycle check
if check_for_cycle(existing_pairs, from_id, to_id):
raise ValidationError("این وابستگی باعث ایجاد چرخه (حلقه تکرار) در ساختار زمان‌بندی می‌شود")
rtype = payload.relation_type.upper()
if rtype not in ("FS", "SS", "FF", "SF"):
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
relation = TaskRelation(
project_id=project.id,
from_task_id=from_id,
to_task_id=to_id,
relation_type=rtype,
lag_days=payload.lag_days,
)
db.add(relation)
await db.commit()
# Recalculate schedule cascade automatically
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
# Update tasks with new early dates if changed
for t in all_tasks:
if t.id in sched_result["tasks_schedule"]:
info = sched_result["tasks_schedule"][t.id]
t.planned_start = info["planned_start"]
t.planned_end = info["planned_end"]
t.duration = info["duration"]
await db.commit()
await db.refresh(relation)
return {"data": task_relation_dict(relation, from_task.title, to_task.title)}
@router.patch("/{id}/relations/{relation_id}")
async def update_task_relation(
id: str,
relation_id: str,
payload: TaskRelationUpdateSchema,
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
db: AsyncSession = Depends(get_db),
):
"""Edit a dependency's type and/or lag, then re-run the schedule cascade."""
project = await _resolve_project(db, ctx, id)
rel = await db.scalar(
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(TaskRelation.id == uuid.UUID(relation_id), TaskRelation.project_id == project.id)
)
if rel is None:
raise NotFoundError("وابستگی یافت نشد")
if payload.relation_type is not None:
rtype = payload.relation_type.upper()
if rtype not in ("FS", "SS", "FF", "SF"):
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
rel.relation_type = rtype
if payload.lag_days is not None:
rel.lag_days = payload.lag_days
await db.commit()
# Recalculate the schedule so the edit takes effect immediately
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
for t in all_tasks:
if t.id in sched_result["tasks_schedule"]:
info = sched_result["tasks_schedule"][t.id]
t.planned_start = info["planned_start"]
t.planned_end = info["planned_end"]
t.duration = info["duration"]
await db.commit()
await db.refresh(rel)
return {"data": task_relation_dict(rel, rel.from_task.title, rel.to_task.title)}
@router.delete("/{id}/relations/{relation_id}")
async def delete_task_relation(
id: str,
relation_id: str,
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
db: AsyncSession = Depends(get_db),
):
"""Delete a task dependency."""
project = await _resolve_project(db, ctx, id)
rel_uuid = uuid.UUID(relation_id)
rel = await db.scalar(
select(TaskRelation).where(TaskRelation.id == rel_uuid, TaskRelation.project_id == project.id)
)
if rel:
await db.delete(rel)
await db.commit()
return {"data": {"deleted": True}}
# --- Interactive Gantt & Topological Scheduling ---
@router.get("/{id}/gantt")
async def get_project_gantt(
id: str,
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""Return full Gantt chart dataset with CPM calculations, relations, and critical path."""
project = await _resolve_project(db, ctx, id)
# 1. Fetch tasks, relations, milestones
tasks = (
await db.execute(
select(Task)
.options(selectinload(Task.children))
.where(Task.project_id == project.id)
.order_by(Task.planned_start.asc().nulls_last(), Task.created_at.asc())
)
).scalars().all()
relations = (
await db.execute(
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(TaskRelation.project_id == project.id)
)
).scalars().all()
# 2. Run CPM scheduling analysis
sched = calculate_schedule(tasks, relations, project.planned_start)
sched_info = sched["tasks_schedule"]
crit_set = set(sched["critical_path"])
# 3. Format tasks
formatted_tasks = []
for t in tasks:
t_sched = sched_info.get(t.id, {})
start_dt = t_sched.get("planned_start") or t.planned_start
end_dt = t_sched.get("planned_end") or t.planned_end
formatted_tasks.append({
"id": str(t.id),
"title": t.title,
"subject": t.title,
"parent_id": str(t.parent_id) if t.parent_id else None,
"status": t.status,
"priority": t.priority,
"planned_start": _iso(start_dt),
"planned_end": _iso(end_dt),
"planned_start_jalali": to_jalali_str(start_dt),
"planned_end_jalali": to_jalali_str(end_dt),
"duration": t_sched.get("duration") or t.duration or 1,
"progress": t.progress or 0,
"is_critical": t.id in crit_set,
"float_days": t_sched.get("float_days", 0),
"qc_status": t.qc_status or "none",
"assignee_name": t.assignee_name or (t.assignee.display_name if getattr(t, "assignee", None) else None),
})
return {
"data": {
"project": {
"id": str(project.id),
"identifier": str(project.id),
"name": project.name,
"planned_start": _iso(project.planned_start),
"planned_end": _iso(project.planned_end),
"planned_start_jalali": to_jalali_str(project.planned_start),
"planned_end_jalali": to_jalali_str(project.planned_end),
"progress": project.progress,
},
"tasks": formatted_tasks,
"relations": [task_relation_dict(r) for r in relations],
"critical_path": [str(tid) for tid in sched["critical_path"]],
"project_finish": _iso(sched.get("project_finish")),
"project_finish_jalali": to_jalali_str(sched.get("project_finish")),
}
}
@router.post("/{id}/reschedule")
async def reschedule_project(
id: str,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Trigger full topological forward-pass recalculation and persist cascaded dates."""
project = await _resolve_project(db, ctx, id)
tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
sched = calculate_schedule(tasks, relations, project.planned_start)
sched_info = sched["tasks_schedule"]
updated_count = 0
for t in tasks:
if t.id in sched_info:
info = sched_info[t.id]
t.planned_start = info["planned_start"]
t.planned_end = info["planned_end"]
t.duration = info["duration"]
updated_count += 1
# Roll up summary tasks
rollups = rollup_parent_tasks(tasks)
for pid, rdata in rollups.items():
parent_task = await db.get(Task, pid)
if parent_task:
if rdata["planned_start"]:
parent_task.planned_start = rdata["planned_start"]
if rdata["planned_end"]:
parent_task.planned_end = rdata["planned_end"]
if rdata.get("duration"):
parent_task.duration = rdata["duration"]
await sync_project_lifecycle_dates(db, project.id)
await db.commit()
return {"data": {"recalculated": True, "tasks_updated": updated_count}}
+729
View File
@@ -0,0 +1,729 @@
"""QC module.
Two clearly separated halves:
**تعریف (Definition)** — what must eventually be checked
* `QCTemplate` + `QCQuestion` : global, reusable, editable
* `TaskQCDefinition`/`TaskQCQuestion`: a task-specific COPY the user customizes
**اجرا (Execution)** — the actual evaluation during the project
* `TaskQCResult` : append-only answers (pass / fail / na) + comment
* `Defect` : problems raised on a task, with corrective action
Applying a template to a task deep-copies its questions, so customizing a task
never mutates the global template.
Access split (app/authz.py):
* `qc.define` — templates + the per-task QC structure: project manager only
* `qc.execute` — recording answers and defects, on assigned projects
* `qc.view` — reading status/readiness, on assigned projects
"""
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import (
AuthContext,
Perm,
require_permission,
scoped_task,
)
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import (
Defect,
QCAnswer,
QCQuestion,
QCQuestionPhase,
QCTemplate,
Task,
TaskQCDefinition,
TaskQCQuestion,
TaskQCResult,
)
from app.qc_service import (
PRE,
build_state,
definition_dict,
load_definitions,
question_dict,
recompute_task_qc_status,
state_dict,
task_qc_state,
)
from app.serializers import _iso
router = APIRouter(prefix="/qc", tags=["qc"])
define_qc = require_permission(Perm.qc_define)
execute_qc = require_permission(Perm.qc_execute)
view_qc = require_permission(Perm.qc_view)
PHASE_LABELS = {PRE: "پیش از اجرا", "during": "هنگام اجرا", "after": "پس از اجرا"}
def _phase(value: str) -> QCQuestionPhase:
try:
return QCQuestionPhase(value)
except ValueError as exc:
raise ValidationError("مرحله باید pre، during یا after باشد", field="phase") from exc
def _answer(value: str) -> QCAnswer:
try:
return QCAnswer(value)
except ValueError as exc:
raise ValidationError("پاسخ باید pass، fail یا na باشد", field="answer") from exc
@dataclass
class QCTaskRef:
"""A task as the QC module sees it."""
id: uuid.UUID
subject: str
progress: int
status: str
@property
def key(self):
return self.id
async def _qc_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> QCTaskRef:
"""Resolve the task a QC screen is about, enforcing the caller's scope."""
native = await scoped_task(db, ctx, task_ref)
return QCTaskRef(
id=native.id,
subject=native.title or "",
progress=native.progress or 0,
status=native.status or "",
)
async def _ensure_definition_scope(db: AsyncSession, ctx: AuthContext, definition: TaskQCDefinition) -> None:
native = await db.get(Task, definition.task_id)
if native is not None:
ctx.ensure_project(native.project_id)
return
raise NotFoundError("تعریف QC یافت نشد")
async def _definition(db: AsyncSession, definition_id: uuid.UUID) -> TaskQCDefinition:
row = await db.scalar(
select(TaskQCDefinition)
.options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results))
.where(TaskQCDefinition.id == definition_id)
)
if row is None:
raise NotFoundError("تعریف QC یافت نشد")
return row
async def _scoped_definition(db: AsyncSession, ctx: AuthContext, definition_id: uuid.UUID) -> TaskQCDefinition:
definition = await _definition(db, definition_id)
await _ensure_definition_scope(db, ctx, definition)
return definition
async def _question(db: AsyncSession, question_id: uuid.UUID) -> TaskQCQuestion:
row = await db.scalar(
select(TaskQCQuestion)
.options(selectinload(TaskQCQuestion.results))
.where(TaskQCQuestion.id == question_id)
)
if row is None:
raise NotFoundError("سؤال QC یافت نشد")
return row
async def _scoped_definition_question(
db: AsyncSession, ctx: AuthContext, question_id: uuid.UUID
) -> tuple[TaskQCQuestion, TaskQCDefinition]:
question = await _question(db, question_id)
definition = await db.get(TaskQCDefinition, question.definition_id)
if definition is None:
raise NotFoundError("تعریف QC یافت نشد")
await _ensure_definition_scope(db, ctx, definition)
return question, definition
# =============================================================================
# Global QC templates
# =============================================================================
def _template_dict(template: QCTemplate) -> dict:
return {
"id": str(template.id),
"name": template.name,
"activity_type": template.activity_type,
"description": template.description,
"questions": [
{
"id": str(q.id),
"phase": q.phase.value,
"text": q.text,
"sort_order": q.sort_order,
"required": q.required,
}
for q in template.questions
],
}
async def _template(db: AsyncSession, template_id: uuid.UUID) -> QCTemplate:
row = await db.scalar(
select(QCTemplate)
.options(selectinload(QCTemplate.questions))
.where(QCTemplate.id == template_id)
)
if row is None:
raise NotFoundError("قالب QC یافت نشد")
return row
@router.get("/templates")
async def list_templates(activity_type: str | None = None, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
stmt = select(QCTemplate).options(selectinload(QCTemplate.questions)).order_by(QCTemplate.name)
if activity_type:
stmt = stmt.where(QCTemplate.activity_type == activity_type)
templates = (await db.execute(stmt)).scalars().all()
return {"data": [_template_dict(t) for t in templates]}
@router.get("/templates/{template_id}")
async def get_template(template_id: uuid.UUID, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
return {"data": _template_dict(await _template(db, template_id))}
class QuestionIn(BaseModel):
phase: str
text: str
required: bool = True
sort_order: int = 0
class TemplateIn(BaseModel):
name: str
activity_type: str = ""
description: str = ""
questions: list[QuestionIn] = []
@field_validator("name")
@classmethod
def _name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("نام قالب الزامی است")
return v.strip()
@router.post("/templates")
async def create_template(payload: TemplateIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
template = QCTemplate(
name=payload.name,
activity_type=payload.activity_type,
description=payload.description,
)
db.add(template)
await db.flush()
for index, q in enumerate(payload.questions):
db.add(
QCQuestion(
template_id=template.id,
phase=_phase(q.phase),
text=q.text.strip(),
sort_order=q.sort_order if q.sort_order else index,
required=q.required,
)
)
await db.commit()
return {"data": _template_dict(await _template(db, template.id))}
class TemplatePatch(BaseModel):
name: str | None = None
activity_type: str | None = None
description: str | None = None
@router.patch("/templates/{template_id}")
async def patch_template(
template_id: uuid.UUID, payload: TemplatePatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
template = await _template(db, template_id)
if payload.name is not None:
if not payload.name.strip():
raise ValidationError("نام قالب نمی‌تواند خالی باشد", field="name")
template.name = payload.name.strip()
if payload.activity_type is not None:
template.activity_type = payload.activity_type
if payload.description is not None:
template.description = payload.description
await db.commit()
return {"data": _template_dict(template)}
@router.delete("/templates/{template_id}")
async def delete_template(template_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
template = await _template(db, template_id)
await db.delete(template)
await db.commit()
return {"data": {"deleted": True}}
@router.post("/templates/{template_id}/questions")
async def add_template_question(
template_id: uuid.UUID, payload: QuestionIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
template = await _template(db, template_id)
question = QCQuestion(
template_id=template.id,
phase=_phase(payload.phase),
text=payload.text.strip(),
sort_order=payload.sort_order,
required=payload.required,
)
db.add(question)
await db.commit()
return {"data": _template_dict(await _template(db, template_id))}
class QuestionPatch(BaseModel):
text: str | None = None
phase: str | None = None
required: bool | None = None
sort_order: int | None = None
@router.patch("/questions/{question_id}")
async def patch_question(
question_id: uuid.UUID, payload: QuestionPatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
question = await db.get(QCQuestion, question_id)
if question is None:
raise NotFoundError("سؤال QC یافت نشد")
if payload.text is not None:
if not payload.text.strip():
raise ValidationError("متن سؤال نمی‌تواند خالی باشد", field="text")
question.text = payload.text.strip()
if payload.phase is not None:
question.phase = _phase(payload.phase)
if payload.required is not None:
question.required = payload.required
if payload.sort_order is not None:
question.sort_order = payload.sort_order
await db.commit()
return {"data": {"id": str(question.id)}}
@router.delete("/questions/{question_id}")
async def delete_question(question_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
question = await db.get(QCQuestion, question_id)
if question is None:
raise NotFoundError("سؤال QC یافت نشد")
await db.delete(question)
await db.commit()
return {"data": {"deleted": True}}
# =============================================================================
# Task QC Definitions
# =============================================================================
class DefinitionIn(BaseModel):
name: str = ""
template_id: str | None = None
@router.get("/tasks/{task_ref}")
async def task_qc(
task_ref: str, ctx: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)
):
"""Everything the QC screen needs: definitions + questions + results + readiness."""
task = await _qc_task(db, ctx, task_ref)
definitions = await load_definitions(db, task.key)
state = build_state(definitions)
return {
"data": {
"task": {
"id": str(task.id),
"subject": task.subject,
"percentage_done": task.progress,
"status": task.status,
},
"definitions": [definition_dict(d, state) for d in definitions],
"readiness": state_dict(state, progress=task.progress),
}
}
@router.post("/tasks/{task_ref}/definition")
async def create_definition(
task_ref: str,
payload: DefinitionIn,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
task = await _qc_task(db, ctx, task_ref)
template: QCTemplate | None = None
if payload.template_id:
try:
template_uuid = uuid.UUID(payload.template_id)
except ValueError as exc:
raise ValidationError("شناسه قالب نامعتبر است", field="template_id") from exc
template = await _template(db, template_uuid)
definition = TaskQCDefinition(
task_id=task.id,
name=payload.name.strip() or (template.name if template else "QC تسک"),
source_template_id=template.id if template else None,
)
db.add(definition)
await db.flush()
if template:
for index, question in enumerate(template.questions):
db.add(
TaskQCQuestion(
definition_id=definition.id,
phase=question.phase,
text=question.text,
sort_order=index,
required=question.required,
)
)
await db.commit()
await recompute_task_qc_status(db, task.key)
await db.commit()
return {"data": {"id": str(definition.id)}}
class DefinitionPatch(BaseModel):
name: str
@router.patch("/definitions/{definition_id}")
async def patch_definition(
definition_id: uuid.UUID,
payload: DefinitionPatch,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
definition = await _scoped_definition(db, ctx, definition_id)
if not payload.name.strip():
raise ValidationError("نام تعریف الزامی است", field="name")
definition.name = payload.name.strip()
await db.commit()
return {"data": {"id": str(definition.id), "name": definition.name}}
@router.delete("/definitions/{definition_id}")
async def delete_definition(
definition_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
definition = await _scoped_definition(db, ctx, definition_id)
task_key = definition.task_id
await db.delete(definition)
await db.commit()
await recompute_task_qc_status(db, task_key)
await db.commit()
return {"data": {"deleted": True}}
class TaskQuestionIn(BaseModel):
text: str
phase: str
required: bool = True
sort_order: int = 0
@router.post("/definitions/{definition_id}/questions")
async def add_task_question(
definition_id: uuid.UUID,
payload: TaskQuestionIn,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
definition = await _scoped_definition(db, ctx, definition_id)
question = TaskQCQuestion(
definition_id=definition.id,
phase=_phase(payload.phase),
text=payload.text.strip(),
sort_order=payload.sort_order,
required=payload.required,
)
db.add(question)
definition.updated_at = datetime.now(timezone.utc)
await db.commit()
await recompute_task_qc_status(db, definition.task_id)
await db.commit()
return {"data": {"id": str(question.id)}}
@router.patch("/task-questions/{question_id}")
async def patch_task_question(
question_id: uuid.UUID,
payload: QuestionPatch,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
values = payload.model_dump(exclude_unset=True)
if "text" in values:
if not values["text"].strip():
raise ValidationError("متن سؤال نمی‌تواند خالی باشد", field="text")
question.text = values["text"].strip()
values.pop("text")
if "phase" in values:
question.phase = _phase(values.pop("phase"))
for key, value in values.items():
setattr(question, key, value)
definition.updated_at = datetime.now(timezone.utc)
await db.commit()
await recompute_task_qc_status(db, definition.task_id)
await db.commit()
return {"data": {"id": str(question.id)}}
@router.delete("/task-questions/{question_id}")
async def delete_task_question(
question_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
task_key = definition.task_id
await db.delete(question)
await db.commit()
await recompute_task_qc_status(db, task_key)
await db.commit()
return {"data": {"deleted": True}}
# =============================================================================
# Execution / evaluation
# =============================================================================
class ResultIn(BaseModel):
answer: str | None = None
comment: str = ""
photo_id: str | None = None
@router.put("/task-questions/{question_id}/result")
async def evaluate_question(
question_id: uuid.UUID,
payload: ResultIn,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
# اگر پاسخ خالی یا null باشد، انتخاب قبلی لغو شده و سؤال به حالت بدون پاسخ برمی‌گردد
if not payload.answer or payload.answer.strip().lower() in ("none", "null", "cancel", "clear", "pending"):
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
user = ctx.user
answer = _answer(payload.answer)
existing_latest = await db.scalar(
select(TaskQCResult)
.where(TaskQCResult.question_id == question.id)
.order_by(TaskQCResult.evaluated_at.desc(), TaskQCResult.id.desc())
)
photo_path = payload.photo_id
if photo_path is None and existing_latest and existing_latest.answer == answer:
photo_path = existing_latest.photo_path
elif photo_path == "":
photo_path = None
db.add(
TaskQCResult(
question_id=question.id,
answer=answer,
comment=payload.comment.strip(),
photo_path=photo_path,
evaluated_by=user.display_name or user.username,
)
)
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": answer.value,
"photo_url": f"/api/v1/files/{photo_path}" if photo_path else None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
@router.delete("/task-questions/{question_id}/result")
async def delete_question_result(
question_id: uuid.UUID,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
async def _progress(db: AsyncSession, task_id: uuid.UUID) -> int:
task = await db.scalar(select(Task).where(Task.id == task_id))
return (task.progress or 0) if task else 0
# =============================================================================
# Defects
# =============================================================================
def _defect_dict(defect: Defect) -> dict:
return {
"id": str(defect.id),
"task_id": str(defect.task_id),
"question_id": str(defect.question_id) if defect.question_id else None,
"title": defect.title,
"description": defect.description,
"severity": defect.severity,
"status": defect.status,
"corrective_action": defect.corrective_action,
"resolution_notes": defect.resolution_notes,
"photo_url": f"/api/v1/files/{defect.photo_path}" if defect.photo_path else None,
"created_at": _iso(defect.created_at),
"resolved_at": _iso(defect.resolved_at),
}
class DefectIn(BaseModel):
task_ref: str
question_id: str | None = None
title: str
description: str = ""
severity: str = "medium"
corrective_action: str = ""
photo_id: str | None = None
@field_validator("title")
@classmethod
def _title(cls, v: str) -> str:
if not v.strip():
raise ValueError("عنوان عیب الزامی است")
return v.strip()
@router.post("/defects")
async def create_defect(
payload: DefectIn, ctx: AuthContext = Depends(execute_qc), db: AsyncSession = Depends(get_db)
):
task = await _qc_task(db, ctx, payload.task_ref)
question_id = None
if payload.question_id:
try:
question_id = uuid.UUID(payload.question_id)
except ValueError as exc:
raise ValidationError("شناسه سؤال نامعتبر است", field="question_id") from exc
defect = Defect(
task_id=task.id,
question_id=question_id,
title=payload.title,
description=payload.description,
severity=payload.severity if payload.severity in ("low", "medium", "high") else "medium",
corrective_action=payload.corrective_action,
photo_path=payload.photo_id,
)
db.add(defect)
await db.commit()
return {"data": _defect_dict(defect)}
class ResolveIn(BaseModel):
resolution_notes: str = ""
@router.post("/defects/{defect_id}/resolve")
async def resolve_defect(
defect_id: uuid.UUID,
payload: ResolveIn,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
defect = await db.get(Defect, defect_id)
if defect is None:
raise NotFoundError("عیب یافت نشد")
await scoped_task(db, ctx, defect.task_id)
if defect.status == "resolved":
raise ValidationError("این عیب قبلاً رفع شده است")
defect.status = "resolved"
defect.resolution_notes = payload.resolution_notes.strip()
defect.resolved_at = datetime.now(timezone.utc)
await db.commit()
return {"data": _defect_dict(defect)}
@router.get("/defects")
async def list_defects(
task_id: uuid.UUID | None = None,
status: str | None = None,
ctx: AuthContext = Depends(view_qc),
db: AsyncSession = Depends(get_db),
):
query = select(Defect).join(Task, Task.id == Defect.task_id)
if task_id:
query = query.where(Defect.task_id == task_id)
if status:
query = query.where(Defect.status == status)
query = ctx.scope(query, Task.project_id)
query = query.order_by(Defect.created_at.desc()).limit(150)
rows = (await db.execute(query)).scalars().all()
items = []
for d in rows:
task = await db.get(Task, d.task_id)
di = _defect_dict(d)
di["task_title"] = task.title if task else ""
items.append(di)
return {"data": items}
async def defects_for_task(db: AsyncSession, task_id: uuid.UUID) -> list[Defect]:
rows = (
await db.execute(select(Defect).where(Defect.task_id == task_id).order_by(Defect.created_at.desc()))
).scalars().all()
return list(rows)
+834
View File
@@ -0,0 +1,834 @@
"""Tasks: Native PM Construction Platform Tasks & Activities.
Supports:
- Hierarchical tasks (Project -> Phase -> Activity -> Subtask) via self-referencing `parent_id`.
- Dates, duration, status, priorities, progress.
- Full dependency relations & scheduling.
"""
from __future__ import annotations
import uuid
from datetime import date, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import AuthContext, Perm, require_permission
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import Project, ProjectMember, Task, TaskPriority, TaskProgressLog, TaskRelation, TaskStatus
from app.qc_service import build_state, definition_dict, load_definitions, state_dict
from app.routers.qc import _defect_dict, defects_for_task
from app.serializers import _iso, native_task_dict, task_relation_dict
from app.utils.date_service import date_service
from app.scheduling_service import sync_project_lifecycle_dates
router = APIRouter(prefix="/tasks", tags=["tasks"])
view_tasks = require_permission(Perm.tasks_view)
manage_tasks = require_permission(Perm.tasks_create)
update_tasks = require_permission(Perm.tasks_update)
progress_tasks = require_permission(Perm.tasks_progress)
class HumanResourceItem(BaseModel):
role: str = Field(..., min_length=1)
count: int = Field(1, ge=1)
class MaterialItem(BaseModel):
name: str = Field(..., min_length=1)
unit: str = Field("", max_length=64)
specifications: list[str] = Field(default_factory=list)
count: float = Field(1.0, ge=0)
class TaskCreateSchema(BaseModel):
project_id: str
parent_id: str | None = None
title: str = Field(..., min_length=1, max_length=512)
description: str = ""
status: str = TaskStatus.TODO.value
priority: str = TaskPriority.MEDIUM.value
assignee_id: str | None = None
planned_start: str | None = None
planned_end: str | None = None
duration: int | None = None
human_resources: list[HumanResourceItem] = Field(default_factory=list)
materials: list[MaterialItem] = Field(default_factory=list)
class TaskUpdateSchema(BaseModel):
title: str | None = None
description: str | None = None
status: str | None = None
priority: str | None = None
assignee: str | None = None
assignee_id: str | None = None
parent_id: str | None = None
planned_start: str | None = None
planned_end: str | None = None
duration: int | None = None
duration_days: int | None = None
human_resources: list[HumanResourceItem] | None = None
materials: list[MaterialItem] | None = None
class HumanResourceConsumption(BaseModel):
role: str = ""
planned_count: int | None = None
actual_count: int = 1
note: str = ""
class MaterialConsumption(BaseModel):
name: str = ""
unit: str = ""
amount: float = 0
allocated_amount: float | None = None
previous_consumed: float | None = None
total_consumed: float | None = None
remaining_amount: float | None = None
is_over_allocated: bool = False
over_allocated_amount: float = 0.0
note: str = ""
class TaskProgressSchema(BaseModel):
percentage: int = Field(..., ge=0, le=100)
human_resources_used: list[HumanResourceConsumption] = Field(default_factory=list)
materials_used: list[MaterialConsumption] = Field(default_factory=list)
description: str = ""
class TaskDatesSchema(BaseModel):
start_date: str | None = None
due_date: str | None = None
async def _resolve_task(db: AsyncSession, ctx: AuthContext, task_id_or_op: str | int | uuid.UUID) -> Task:
"""Find a native task by UUID, enforcing the caller's scope."""
query = (
select(Task)
.options(
selectinload(Task.project),
selectinload(Task.assignee),
selectinload(Task.children),
selectinload(Task.parent),
selectinload(Task.progress_logs),
)
)
try:
val_uuid = uuid.UUID(str(task_id_or_op))
stmt = query.where(Task.id == val_uuid)
except ValueError:
raise NotFoundError("تسک یافت نشد")
task = (await db.execute(stmt)).scalar_one_or_none()
if not task:
raise NotFoundError("تسک یافت نشد")
ctx.ensure_native_project(task.project_id)
return task
async def _validate_task_dates_and_hierarchy(
db: AsyncSession,
task_id: uuid.UUID | None,
planned_start: date | None,
planned_end: date | None,
duration: int | None,
parent_id: uuid.UUID | None,
actual_start: date | None = None,
actual_end: date | None = None,
) -> None:
# 0. اجباری بودن تاریخ شروع و تاریخ پایان
if not planned_start:
raise ValidationError("تاریخ شروع وظیفه الزامی است", field="planned_start")
if not planned_end:
raise ValidationError("تاریخ پایان وظیفه الزامی است", field="planned_end")
# 1. Start date must not be later than end date
if planned_start and planned_end and planned_start > planned_end:
raise ValidationError(
f"تاریخ شروع یک وظیفه ({date_service.to_jalali(planned_start)}) نمی‌تواند بعد از تاریخ اتمام آن ({date_service.to_jalali(planned_end)}) باشد",
field="planned_start",
)
if actual_start and actual_end and actual_start > actual_end:
raise ValidationError(
f"تاریخ شروع واقعی ({date_service.to_jalali(actual_start)}) نمی‌تواند بعد از تاریخ پایان واقعی ({date_service.to_jalali(actual_end)}) باشد",
field="actual_start",
)
# 2. Parent vs Child constraint: Parent must envelop child
if parent_id:
parent = await db.scalar(select(Task).where(Task.id == parent_id))
if parent:
if parent.planned_start and planned_start and planned_start < parent.planned_start:
raise ValidationError(
f"تاریخ شروع وظیفه فرزند ({date_service.to_jalali(planned_start)}) نمی‌تواند قبل از تاریخ شروع وظیفه والد ({date_service.to_jalali(parent.planned_start)}) باشد",
field="planned_start",
)
if parent.planned_end and planned_end and planned_end > parent.planned_end:
raise ValidationError(
f"تاریخ پایان وظیفه فرزند ({date_service.to_jalali(planned_end)}) نمی‌تواند بعد از تاریخ پایان وظیفه والد ({date_service.to_jalali(parent.planned_end)}) باشد",
field="planned_end",
)
if parent.duration and duration and duration > parent.duration:
raise ValidationError(
f"مدت زمان وظیفه والد ({parent.duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند ({duration} روز) باشد",
field="duration",
)
# 3. If this task is a parent, its time span/duration must be >= all its children
if task_id:
children_stmt = select(Task).where(Task.parent_id == task_id)
children = (await db.execute(children_stmt)).scalars().all()
for c in children:
if planned_start and c.planned_start and planned_start > c.planned_start:
raise ValidationError(
f"تاریخ شروع وظیفه والد ({date_service.to_jalali(planned_start)}) نمی‌تواند بعد از تاریخ شروع وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_start)}) باشد",
field="planned_start",
)
if planned_end and c.planned_end and planned_end < c.planned_end:
raise ValidationError(
f"تاریخ پایان وظیفه والد ({date_service.to_jalali(planned_end)}) نمی‌تواند قبل از تاریخ پایان وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_end)}) باشد",
field="planned_end",
)
if duration and c.duration and duration < c.duration:
raise ValidationError(
f"مدت زمان وظیفه والد ({duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند «{c.title}» ({c.duration} روز) باشد",
field="duration",
)
@router.get("")
async def list_tasks(
project_id: str | None = None,
parent_id: str | None = None,
search: str | None = None,
status: str | None = None,
qc_status: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(300, ge=1, le=1000),
ctx: AuthContext = Depends(view_tasks),
db: AsyncSession = Depends(get_db),
):
"""List native tasks with flexible filtering."""
stmt = (
select(Task)
.options(
selectinload(Task.project),
selectinload(Task.assignee),
selectinload(Task.children),
)
.order_by(Task.created_at.asc())
)
# Filter by project
if project_id is not None:
try:
p_uuid = uuid.UUID(project_id)
stmt = stmt.where(Task.project_id == p_uuid)
except ValueError:
pass
# Project scoping for non-managers — native projects are keyed by UUID
stmt = ctx.scope_native(stmt, Task.project_id)
if parent_id is not None:
if parent_id.lower() in ("null", "root", "none"):
stmt = stmt.where(Task.parent_id.is_(None))
else:
stmt = stmt.where(Task.parent_id == uuid.UUID(parent_id))
if search:
stmt = stmt.where(Task.title.ilike(f"%{search.strip()}%"))
if status:
stmt = stmt.where(Task.status == status.upper())
if qc_status:
stmt = stmt.where(Task.qc_status == qc_status)
total = (await db.scalar(select(func.count()).select_from(stmt.subquery()))) or 0
tasks = (await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars().all()
# Count children in memory from loaded tasks to prevent lazy-load I/O
child_counts: dict[uuid.UUID, int] = {}
for t in tasks:
if t.parent_id:
child_counts[t.parent_id] = child_counts.get(t.parent_id, 0) + 1
results = []
for t in tasks:
p_name = t.project.name if t.project else None
c_count = child_counts.get(t.id, 0)
d = native_task_dict(t, project_name=p_name, children_count=c_count)
d["subject"] = t.title
d["percentage_done"] = t.progress
d["start_date"] = _iso(t.planned_start)
d["due_date"] = _iso(t.planned_end)
d["is_closed"] = t.status == TaskStatus.COMPLETED.value
results.append(d)
return {"data": results, "meta": {"total": total}}
@router.post("")
async def create_task(
payload: TaskCreateSchema,
ctx: AuthContext = Depends(manage_tasks),
db: AsyncSession = Depends(get_db),
):
"""Create a new native task / subtask."""
# Resolve project
try:
p_uuid = uuid.UUID(payload.project_id)
project = await db.scalar(select(Project).where(Project.id == p_uuid))
except ValueError:
project = None
if not project:
raise NotFoundError("پروژه یافت نشد")
# Resolve parent task if specified
parent_uuid = None
if payload.parent_id:
parent_task = await _resolve_task(db, ctx, payload.parent_id)
parent_uuid = parent_task.id
p_start = date_service.parse(payload.planned_start)
p_end = date_service.parse(payload.planned_end)
duration = payload.duration or 0
if p_start and p_end:
duration = date_service.calculate_duration(p_start, p_end)
elif p_start and duration:
p_end = date_service.compute_end_date(p_start, duration)
elif p_end and duration:
p_start = p_end - timedelta(days=duration)
await _validate_task_dates_and_hierarchy(
db=db,
task_id=None,
planned_start=p_start,
planned_end=p_end,
duration=duration,
parent_id=parent_uuid,
)
assignee_uuid = None
assignee_name = None
if payload.assignee_id:
try:
assignee_uuid = uuid.UUID(payload.assignee_id)
except ValueError:
assignee_name = payload.assignee_id
task = Task(
project_id=project.id,
parent_id=parent_uuid,
title=payload.title.strip(),
description=payload.description.strip(),
status=payload.status.upper(),
priority=payload.priority.lower(),
assignee_id=assignee_uuid,
assignee_name=assignee_name,
planned_start=p_start,
planned_end=p_end,
duration=duration,
progress=0,
human_resources=[hr.model_dump() for hr in payload.human_resources],
materials=[m.model_dump() for m in payload.materials],
)
db.add(task)
await db.flush()
await sync_project_lifecycle_dates(db, project.id)
await db.commit()
await db.refresh(task)
d = native_task_dict(task, project_name=project.name)
d["subject"] = task.title
d["percentage_done"] = task.progress
return {"data": d}
@router.get("/{id}")
async def get_task_detail(
id: str,
ctx: AuthContext = Depends(view_tasks),
db: AsyncSession = Depends(get_db),
):
"""Get full task details, including hierarchy, QC state, and defects."""
task = await _resolve_task(db, ctx, id)
# QC definitions and execution state
definitions = await load_definitions(db, task.id)
state = build_state(definitions)
# Defects
defects = await defects_for_task(db, task.id)
children_data = []
for c in task.children:
c_dict = native_task_dict(c, project_name=task.project.name if task.project else None)
c_dict["subject"] = c.title
c_dict["percentage_done"] = c.progress
children_data.append(c_dict)
data = native_task_dict(
task,
project_name=task.project.name if task.project else None,
children_count=len(task.children),
)
data["subject"] = task.title
data["percentage_done"] = task.progress
data["start_date"] = _iso(task.planned_start)
data["due_date"] = _iso(task.planned_end)
data["start_date_jalali"] = date_service.to_jalali(task.planned_start)
data["due_date_jalali"] = date_service.to_jalali(task.planned_end)
data["is_closed"] = task.status == TaskStatus.COMPLETED.value
# Load task relations (predecessors and successors)
rel_stmt = (
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(
or_(
TaskRelation.from_task_id == task.id,
TaskRelation.to_task_id == task.id,
)
)
)
task_rels = (await db.execute(rel_stmt)).scalars().all()
incoming = []
outgoing = []
for r in task_rels:
rd = task_relation_dict(r)
if r.to_task_id == task.id:
# incoming: predecessor (from_task) -> current task
incoming.append({
**rd,
"id": str(r.id),
"op_wp_id": str(r.from_task_id),
"task_id": str(r.from_task_id),
"subject": r.from_task.title if r.from_task else "",
"relation_type": r.relation_type,
"lag_days": r.lag_days,
})
if r.from_task_id == task.id:
# outgoing: current task -> successor (to_task)
outgoing.append({
**rd,
"id": str(r.id),
"op_wp_id": str(r.to_task_id),
"task_id": str(r.to_task_id),
"subject": r.to_task.title if r.to_task else "",
"relation_type": r.relation_type,
"lag_days": r.lag_days,
})
data["relations"] = {"blocks": outgoing, "blocked_by": incoming}
data["all_relations"] = [task_relation_dict(r) for r in task_rels]
data["children"] = children_data
data["parent"] = (
{"id": str(task.parent.id), "title": task.parent.title} if task.parent else None
)
data["qc"] = state_dict(state, progress=task.progress)
data["qc_definitions"] = [definition_dict(d, state) for d in definitions]
data["defects"] = [_defect_dict(d) for d in defects]
data["open_defects"] = sum(1 for d in defects if d.status != "resolved")
return {"data": data}
@router.patch("/{id}")
async def update_task(
id: str,
payload: TaskUpdateSchema,
ctx: AuthContext = Depends(update_tasks),
db: AsyncSession = Depends(get_db),
):
"""Update task title, description, status, priority, or dates."""
task = await _resolve_task(db, ctx, id)
if payload.title is not None:
task.title = payload.title.strip()
if payload.description is not None:
task.description = payload.description.strip()
if payload.status is not None:
task.status = payload.status.upper()
if payload.priority is not None:
task.priority = payload.priority.lower()
if payload.assignee is not None:
task.assignee_name = payload.assignee.strip() or None
if payload.assignee_id is not None:
try:
task.assignee_id = uuid.UUID(payload.assignee_id)
except ValueError:
task.assignee_name = payload.assignee_id
if payload.parent_id is not None:
clean_p = payload.parent_id.strip().lower()
if not clean_p or clean_p in ("none", "null", "root"):
task.parent_id = None
else:
parent = await _resolve_task(db, ctx, payload.parent_id)
if parent.id == task.id:
raise ValidationError("تسک نمی‌تواند والد خودش باشد")
# Prevent cycle: ensure task is not in ancestors of parent
check = parent
while check:
if check.id == task.id:
raise ValidationError("نمی‌توان یک زیرتسک را به عنوان والد انتخاب کرد (وابستگی چرخه‌ای)")
if check.parent_id:
check = await db.scalar(select(Task).where(Task.id == check.parent_id))
else:
break
task.parent_id = parent.id
p_start = task.planned_start
p_end = task.planned_end
a_start = task.actual_start
a_end = task.actual_end
dur = task.duration
if payload.planned_start is not None:
p_start = date_service.parse(payload.planned_start)
if payload.planned_end is not None:
p_end = date_service.parse(payload.planned_end)
dur_input = payload.duration if payload.duration is not None else payload.duration_days
if dur_input is not None:
dur = max(0, dur_input)
if p_start:
p_end = date_service.compute_end_date(p_start, dur)
elif p_start and p_end:
dur = date_service.calculate_duration(p_start, p_end)
await _validate_task_dates_and_hierarchy(
db=db,
task_id=task.id,
planned_start=p_start,
planned_end=p_end,
duration=dur,
parent_id=task.parent_id,
actual_start=a_start,
actual_end=a_end,
)
task.planned_start = p_start
task.planned_end = p_end
task.duration = dur
if payload.human_resources is not None:
task.human_resources = [hr.model_dump() for hr in payload.human_resources]
if payload.materials is not None:
task.materials = [m.model_dump() for m in payload.materials]
await sync_project_lifecycle_dates(db, task.project_id)
await db.commit()
task = await _resolve_task(db, ctx, task.id)
return {"data": native_task_dict(task, project_name=task.project.name if task.project else None)}
@router.patch("/{id}/progress")
async def update_task_progress(
id: str,
payload: TaskProgressSchema,
ctx: AuthContext = Depends(progress_tasks),
db: AsyncSession = Depends(get_db),
):
"""Update task progress (0-100%) with strict server-side QC validation rules."""
task = await _resolve_task(db, ctx, id)
percentage = payload.percentage
# 1. Evaluate QC business rules
definitions = await load_definitions(db, task.id)
state = build_state(definitions)
# Server-side Invariant Rule 1: pre-QC must be satisfied if progress > 0
if percentage > 0 and not state.can_start:
unresolved = state.pre.required_unresolved
msg = (
f"بازرسی پیش از اجرا تأیید نشده است ({unresolved} سؤال باقی‌مانده)"
if unresolved > 0
else "بازرسی پیش از اجرا مردود شده است و نیاز به رفع و بازرسی مجدد دارد"
)
raise ValidationError(msg, field="percentage")
# Server-side Invariant Rule 2: all required QC must be satisfied if progress >= 100
if percentage >= 100 and not state.can_complete:
unresolved = state.all_.required_unresolved
failed = state.all_.failed
raise ValidationError(
f"تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست ({unresolved} سؤال باقی‌مانده، {failed} مورد مردود)",
field="percentage",
)
# 2. Update task progress and lifecycle dates
progress_before = task.progress or 0
task.progress = percentage
if percentage > 0:
if not task.actual_start:
task.actual_start = date_service.today()
if task.status == TaskStatus.TODO.value:
task.status = TaskStatus.IN_PROGRESS.value
else:
# percentage == 0: reset actual dates
task.actual_start = None
task.actual_end = None
task.status = TaskStatus.TODO.value
if percentage >= 100:
task.actual_end = date_service.today()
task.status = TaskStatus.COMPLETED.value
elif 0 < percentage < 100:
task.actual_end = None
if task.status == TaskStatus.COMPLETED.value:
task.status = TaskStatus.IN_PROGRESS.value
# 3. Log consumption of human resources and materials
hr_list = [h.model_dump() for h in payload.human_resources_used if h.role.strip()]
# Build allocation lookup from task.materials
alloc_map = {}
for item in (task.materials or []):
if isinstance(item, dict):
m_name = (item.get("name") or "").strip().lower()
if m_name:
alloc_map[m_name] = item
# Calculate previously consumed amounts from prior logs
past_consumed_map: dict[str, float] = {}
for plog in (task.progress_logs or []):
for pmat in (plog.materials_used or []):
if isinstance(pmat, dict):
p_name = (pmat.get("name") or "").strip().lower()
if p_name:
past_consumed_map[p_name] = past_consumed_map.get(p_name, 0.0) + float(pmat.get("amount") or 0.0)
mat_list = []
for m in payload.materials_used:
name = m.name.strip()
if not name:
continue
key = name.lower()
matched = alloc_map.get(key)
# Base allocation: explicitly passed or from task.materials
if m.allocated_amount is not None:
alloc_amt = float(m.allocated_amount)
elif matched:
alloc_amt = float(matched.get("count") or 0.0)
else:
alloc_amt = 0.0
unit = m.unit.strip() or (matched.get("unit", "") if matched else "")
amount = float(m.amount or 0.0)
prev_consumed = past_consumed_map.get(key, 0.0)
tot_consumed = prev_consumed + amount
rem_amount = max(0.0, alloc_amt - tot_consumed) if alloc_amt > 0 else 0.0
is_over = (tot_consumed > alloc_amt) if alloc_amt > 0 else (tot_consumed > 0)
over_amt = max(0.0, tot_consumed - alloc_amt) if alloc_amt > 0 else tot_consumed
mat_list.append({
"name": name,
"unit": unit,
"amount": amount,
"allocated_amount": alloc_amt,
"previous_consumed": prev_consumed,
"total_consumed": tot_consumed,
"remaining_amount": rem_amount,
"is_over_allocated": is_over,
"over_allocated_amount": over_amt,
"note": m.note.strip() if m.note else "",
})
log = TaskProgressLog(
task_id=task.id,
progress_before=progress_before,
progress_after=percentage,
human_resources_used=hr_list,
materials_used=mat_list,
description=payload.description.strip(),
logged_by=ctx.user.display_name or ctx.user.username,
)
db.add(log)
await sync_project_lifecycle_dates(db, task.project_id)
await db.commit()
await db.refresh(task)
return {
"data": {
"id": str(task.id),
"progress": task.progress,
"status": task.status,
"qc_status": task.qc_status,
"actual_start": _iso(task.actual_start),
"actual_end": _iso(task.actual_end),
"progress_log": {
"id": str(log.id),
"progress_before": log.progress_before,
"progress_after": log.progress_after,
"human_resources_used": log.human_resources_used,
"materials_used": log.materials_used,
"description": log.description,
"logged_by": log.logged_by,
"created_at": _iso(log.created_at),
},
}
}
@router.get("/{id}/material-audit")
async def get_task_material_audit(
id: str,
ctx: AuthContext = Depends(view_tasks),
db: AsyncSession = Depends(get_db),
):
"""استعلام مقایسه‌ای مصالح تخصیص‌یافته در برابر کل مصارف واقعی و گزارش اضافه مصرف."""
task = await _resolve_task(db, ctx, id)
alloc_map = {}
for m in (task.materials or []):
if isinstance(m, dict):
name = (m.get("name") or "").strip()
if name:
alloc_map[name.lower()] = {
"name": name,
"unit": m.get("unit") or "",
"allocated_amount": float(m.get("count") or 0.0),
"specifications": m.get("specifications") or [],
}
consumption_map: dict[str, dict] = {}
for log in (task.progress_logs or []):
for mu in (log.materials_used or []):
if isinstance(mu, dict):
mname = (mu.get("name") or "").strip()
if not mname:
continue
key = mname.lower()
m_amt = float(mu.get("amount") or 0.0)
m_unit = mu.get("unit") or ""
if key not in consumption_map:
consumption_map[key] = {
"name": mname,
"unit": m_unit,
"total_consumed": 0.0,
}
consumption_map[key]["total_consumed"] += m_amt
all_keys = set(alloc_map.keys()) | set(consumption_map.keys())
audit_items = []
total_over_allocated_count = 0
for key in sorted(all_keys):
alloc = alloc_map.get(key)
consumed = consumption_map.get(key)
name = alloc["name"] if alloc else consumed["name"]
unit = alloc["unit"] if alloc else consumed["unit"]
alloc_amt = alloc["allocated_amount"] if alloc else 0.0
tot_cons = consumed["total_consumed"] if consumed else 0.0
rem_amt = max(0.0, alloc_amt - tot_cons) if alloc_amt > 0 else 0.0
is_over = (tot_cons > alloc_amt) if alloc_amt > 0 else (tot_cons > 0)
over_amt = max(0.0, tot_cons - alloc_amt) if alloc_amt > 0 else tot_cons
if is_over:
total_over_allocated_count += 1
audit_items.append({
"name": name,
"unit": unit,
"allocated_amount": alloc_amt,
"total_consumed": tot_cons,
"remaining_amount": rem_amt,
"is_over_allocated": is_over,
"over_allocated_amount": over_amt,
"specifications": alloc["specifications"] if alloc else [],
})
return {
"data": {
"task_id": str(task.id),
"task_title": task.title,
"has_over_allocation": total_over_allocated_count > 0,
"over_allocated_items_count": total_over_allocated_count,
"items": audit_items,
}
}
@router.patch("/{id}/dates")
async def update_task_dates(
id: str,
payload: TaskDatesSchema,
ctx: AuthContext = Depends(update_tasks),
db: AsyncSession = Depends(get_db),
):
"""Update task dates (dual Jalali/ISO compatible)."""
task = await _resolve_task(db, ctx, id)
p_start = task.planned_start
p_end = task.planned_end
if payload.start_date is not None:
p_start = date_service.parse(payload.start_date)
if payload.due_date is not None:
p_end = date_service.parse(payload.due_date)
dur = task.duration
if p_start and p_end:
dur = date_service.calculate_duration(p_start, p_end)
await _validate_task_dates_and_hierarchy(
db=db,
task_id=task.id,
planned_start=p_start,
planned_end=p_end,
duration=dur,
parent_id=task.parent_id,
actual_start=task.actual_start,
actual_end=task.actual_end,
)
task.planned_start = p_start
task.planned_end = p_end
task.duration = dur
await sync_project_lifecycle_dates(db, task.project_id)
await db.commit()
task = await _resolve_task(db, ctx, task.id)
return {
"data": {
"id": str(task.id),
"start_date": _iso(task.planned_start),
"due_date": _iso(task.planned_end),
"start_date_jalali": date_service.to_jalali(task.planned_start),
"due_date_jalali": date_service.to_jalali(task.planned_end),
"duration": task.duration,
}
}
@router.delete("/{id}")
async def delete_task(
id: str,
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
db: AsyncSession = Depends(get_db),
):
"""Delete a task and its children."""
task = await _resolve_task(db, ctx, id)
project_id = task.project_id
await db.delete(task)
await db.flush()
await sync_project_lifecycle_dates(db, project_id)
await db.commit()
return {"data": {"deleted": True, "id": id}}
+186
View File
@@ -0,0 +1,186 @@
"""User and project-assignment administration (role `project_manager` only).
Creating the accounts that hold a scoped role and deciding *which* projects the
scoped role may touch is what makes the RBAC in app/authz.py usable; nothing
here changes an operational record.
"""
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import ROLE_LABELS, AuthContext, Perm, Role, require_permission, role_of
from app.db import get_db
from app.dev_accounts import clear_assignments
from app.errors import NotFoundError, ValidationError
from app.models import PMUser, Project, ProjectAssignment
from app.security import hash_password
from app.serializers import _iso
router = APIRouter(prefix="/users", tags=["users"])
manage = require_permission(Perm.users_manage)
def _role_value(value: str) -> str:
try:
return Role(value).value
except ValueError as exc:
raise ValidationError(f"نقش نامعتبر است: {value}", field="role") from exc
async def _assigned_project_ids(db: AsyncSession, user_id) -> list[str]:
"""Projects assigned to a user, as native UUIDs."""
rows = (
await db.execute(
select(ProjectAssignment.project_id).where(
ProjectAssignment.user_id == user_id
)
)
).scalars().all()
return sorted(str(pid) for pid in rows if pid is not None)
async def _user_dict(db: AsyncSession, user: PMUser) -> dict:
role = role_of(user)
return {
"id": str(user.id),
"username": user.username,
"display_name": user.display_name,
"role": role.value,
"role_label": ROLE_LABELS.get(role.value, role.value),
"scoped": role in (Role.project_supervisor,),
"project_ids": await _assigned_project_ids(db, user.id),
"created_at": _iso(user.created_at),
}
@router.get("")
async def list_users(ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
users = (await db.execute(select(PMUser).order_by(PMUser.username))).scalars().all()
return {"data": [await _user_dict(db, u) for u in users]}
class UserIn(BaseModel):
username: str
password: str
display_name: str = ""
role: str = Role.project_supervisor.value
@field_validator("username")
@classmethod
def _username(cls, v: str) -> str:
if not v.strip():
raise ValueError("نام کاربری الزامی است")
return v.strip()
@field_validator("password")
@classmethod
def _password(cls, v: str) -> str:
if len(v) < 6:
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
return v
@router.post("")
async def create_user(payload: UserIn, ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
existing = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
if existing is not None:
raise ValidationError("این نام کاربری قبلاً ثبت شده است", field="username")
user = PMUser(
username=payload.username,
password_hash=hash_password(payload.password),
display_name=payload.display_name.strip(),
role=_role_value(payload.role),
)
db.add(user)
await db.commit()
return {"data": await _user_dict(db, user)}
class UserPatch(BaseModel):
display_name: str | None = None
role: str | None = None
password: str | None = None
@field_validator("password")
@classmethod
def _password(cls, v: str | None) -> str | None:
if v is not None and len(v) < 6:
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
return v
@router.patch("/{user_id}")
async def patch_user(
user_id: uuid.UUID,
payload: UserPatch,
ctx: AuthContext = Depends(manage),
db: AsyncSession = Depends(get_db),
):
user = await db.get(PMUser, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if payload.display_name is not None:
user.display_name = payload.display_name.strip()
if payload.password:
user.password_hash = hash_password(payload.password)
if payload.role is not None:
new_role = _role_value(payload.role)
# never let a manager remove their own manager role (lock-out guard)
if user.id == ctx.user.id and new_role != Role.project_manager.value:
raise ValidationError("نقش مدیر فعلی خود را نمی‌توانید تغییر دهید", field="role")
user.role = new_role
await db.commit()
return {"data": await _user_dict(db, user)}
class ProjectsIn(BaseModel):
"""Native project UUIDs (the live world). Legacy integer ids still accepted."""
project_ids: list[str] = []
@router.put("/{user_id}/projects")
async def set_user_projects(
user_id: uuid.UUID,
payload: ProjectsIn,
ctx: AuthContext = Depends(manage),
db: AsyncSession = Depends(get_db),
):
"""Replace a user's assigned projects (the scope a supervisor is limited to)."""
user = await db.get(PMUser, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
native_ids: list[uuid.UUID] = []
legacy_ids: list[int] = []
unknown: list[str] = []
for raw in dict.fromkeys(payload.project_ids):
raw = str(raw).strip()
parsed = None
try:
parsed = uuid.UUID(raw)
except ValueError:
parsed = None
if parsed is not None:
if await db.get(Project, parsed) is None:
unknown.append(raw)
else:
native_ids.append(parsed)
else:
unknown.append(raw)
if unknown:
raise ValidationError(
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
)
await clear_assignments(db, user.id)
for project_uuid in native_ids:
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
await db.commit()
return {"data": await _user_dict(db, user)}