Files
ERP/pm/backend/app/routers/projects.py
T
MMD 2747f9d4bb 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
2026-10-07 15:14:20 +03:30

602 lines
21 KiB
Python

"""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}}