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:
@@ -0,0 +1 @@
|
||||
"""PM API routers."""
|
||||
@@ -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)}}
|
||||
@@ -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}
|
||||
@@ -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("فایل یافت نشد")
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
@@ -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}}
|
||||
@@ -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)}
|
||||
Reference in New Issue
Block a user