Squashed 'pm/' content from commit 91f3271

git-subtree-dir: pm
git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
This commit is contained in:
MMD
2026-10-07 15:14:20 +03:30
commit 934b74ebbd
87 changed files with 23922 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""PM application package."""
+296
View File
@@ -0,0 +1,296 @@
"""Central authorization (RBAC) — roles, permissions and per-project scoping.
Everything authorization-related lives in this module so that adding a role is
one entry in `ROLE_PERMISSIONS` (plus a nav entry on the frontend), not a hunt
through the routers.
Two independent questions, answered separately:
* **Role → permissions** (`Perm`, `ROLE_PERMISSIONS`) decides *which operations*
a user may call at all.
* **Project scope** decides *which projects* those operations may touch. A role
listed in `SCOPED_ROLES` is limited to the rows in `project_assignments`
(an empty set means "no project at all"); every other role is unrestricted.
Routers must go through the dependencies and helpers below — hiding a button in
the UI is a convenience, never the enforcement.
"""
from __future__ import annotations
import enum
import logging
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from fastapi import Depends
from sqlalchemy import Select, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db import get_db
from app.errors import ForbiddenError, NotFoundError
from app.models import PMUser, ProjectAssignment, Task
from app.security import get_current_user
logger = logging.getLogger("pm.authz")
# --- roles --------------------------------------------------------------------
class Role(str, enum.Enum):
project_manager = "project_manager"
project_supervisor = "project_supervisor"
ROLE_LABELS: dict[str, str] = {
Role.project_manager.value: "مدیر پروژه",
Role.project_supervisor.value: "سرپرست پروژه",
}
# Users created before RBAC existed keep their previous (full) access.
DEFAULT_ROLE = Role.project_manager
# Roles whose access is limited to their assigned projects.
SCOPED_ROLES: frozenset[Role] = frozenset({Role.project_supervisor})
# --- permissions --------------------------------------------------------------
class Perm(str, enum.Enum):
"""One value per authorizable operation — keep it coarse and stable."""
dashboard_view = "dashboard.view"
projects_view = "projects.view"
projects_create = "projects.create"
projects_update = "projects.update"
tasks_view = "tasks.view"
tasks_create = "tasks.create"
tasks_update = "tasks.update"
tasks_delete = "tasks.delete"
tasks_progress = "tasks.progress"
tasks_dates = "tasks.dates"
qc_view = "qc.view"
# executing QC: recording answers and raising/resolving defects
qc_execute = "qc.execute"
# QC structure: global templates + per-task definitions
qc_define = "qc.define"
materials_view = "materials.view"
materials_manage = "materials.manage"
users_manage = "users.manage"
files_upload = "files.upload"
# Project manager: full access to everything, across every project.
_MANAGER_PERMISSIONS: frozenset[str] = frozenset(p.value for p in Perm)
# Project supervisor: executes the assigned projects — reads the plan, records
# progress and QC results, needs the material list; never edits the QC
# structure, templates, procurement or integration settings.
_SUPERVISOR_PERMISSIONS: frozenset[str] = frozenset(
{
Perm.dashboard_view.value,
Perm.projects_view.value,
Perm.tasks_view.value,
Perm.tasks_progress.value,
Perm.qc_view.value,
Perm.qc_execute.value,
Perm.materials_view.value,
Perm.files_upload.value,
}
)
ROLE_PERMISSIONS: dict[Role, frozenset[str]] = {
Role.project_manager: _MANAGER_PERMISSIONS,
Role.project_supervisor: _SUPERVISOR_PERMISSIONS,
}
def permissions_for(role: Role) -> frozenset[str]:
return ROLE_PERMISSIONS.get(role, frozenset())
def role_of(user: PMUser) -> Role:
try:
return Role(user.role)
except ValueError:
logger.warning(
"unknown role %r on user %s — falling back to %s", user.role, user.username, DEFAULT_ROLE.value
)
return DEFAULT_ROLE
# --- request context ----------------------------------------------------------
@dataclass(frozen=True)
class AuthContext:
"""Who is calling, what they may do, and which projects they may touch.
`project_uuids` — native projects (`projects.id`).
None for an unscoped role (project manager). For a scoped role a set — possibly empty — means "only these".
"""
user: PMUser
role: Role
project_uuids: frozenset[uuid.UUID] | None = None
@property
def permissions(self) -> frozenset[str]:
return permissions_for(self.role)
@property
def role_label(self) -> str:
return ROLE_LABELS.get(self.role.value, self.role.value)
@property
def all_projects(self) -> bool:
return self.project_uuids is None
def can(self, permission: Perm | str) -> bool:
value = permission.value if isinstance(permission, Perm) else permission
return value in self.permissions
def require(self, permission: Perm | str) -> None:
if not self.can(permission):
raise ForbiddenError(f"نقش «{self.role_label}» به این عملیات دسترسی ندارد")
def allows_project(self, project_id) -> bool:
"""Check whether the user is allowed to access the specified project."""
if self.project_uuids is None:
return True
try:
pid = uuid.UUID(str(project_id))
except (ValueError, AttributeError):
return False
return pid in self.project_uuids
def ensure_project(self, project_id) -> None:
"""Raise 403 when the project is outside this user's scope."""
if not self.allows_project(project_id):
raise ForbiddenError("این پروژه به شما اختصاص داده نشده است")
allows_native_project = allows_project
ensure_native_project = ensure_project
def scope(self, stmt: Select, column) -> Select:
"""Restrict a query over project UUIDs."""
if self.project_uuids is None:
return stmt
return stmt.where(column.in_(self.project_uuids))
scope_native = scope
@property
def scoped_project_count(self) -> int:
"""How many projects a scoped user may see (0 when nothing is assigned)."""
return len(self.project_uuids or ())
def as_dict(self) -> dict:
return {
"role": self.role.value,
"role_label": self.role_label,
"permissions": sorted(self.permissions),
"scope": {
"all_projects": self.all_projects,
"project_count": None if self.all_projects else self.scoped_project_count,
},
}
async def assigned_scope(db: AsyncSession, user_id) -> frozenset[uuid.UUID]:
"""Load assigned project UUIDs for a scoped user."""
rows = (
await db.execute(
select(ProjectAssignment.project_id).where(
ProjectAssignment.user_id == user_id
)
)
).scalars().all()
assigned = set(r for r in rows if r is not None)
# Also resolve projects assigned via projects.pm_user_id or user_project_members in the shared DB
pm_user = await db.get(PMUser, user_id)
if pm_user and pm_user.username:
erp_rows = (
await db.execute(
text("""
SELECT p.id FROM projects p JOIN users u ON p.pm_user_id = u.id WHERE u.username = :username
UNION
SELECT upm.project_id FROM user_project_members upm JOIN users u ON upm.user_id = u.id WHERE u.username = :username
"""),
{"username": pm_user.username},
)
).scalars().all()
for r in erp_rows:
if r:
try:
assigned.add(uuid.UUID(str(r)))
except (ValueError, TypeError):
pass
return frozenset(assigned)
async def build_context(db: AsyncSession, user: PMUser) -> AuthContext:
"""Resolve role + project scope for a user."""
role = role_of(user)
if role not in SCOPED_ROLES:
return AuthContext(user=user, role=role)
native = await assigned_scope(db, user.id)
return AuthContext(user=user, role=role, project_uuids=native)
async def get_auth_context(
user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
) -> AuthContext:
return await build_context(db, user)
def require_permission(permission: Perm) -> Callable[..., Awaitable[AuthContext]]:
"""Dependency: authenticated **and** allowed to perform `permission`."""
async def _guard(ctx: AuthContext = Depends(get_auth_context)) -> AuthContext:
ctx.require(permission)
return ctx
return _guard
# --- data-access guards -------------------------------------------------------
async def resolve_task(db: AsyncSession, task_ref: str | uuid.UUID) -> Task | None:
"""Find a native task by UUID."""
task_ref = task_ref.strip() if isinstance(task_ref, str) else task_ref
try:
task_uuid = uuid.UUID(str(task_ref))
except (ValueError, AttributeError):
return None
return await db.scalar(select(Task).where(Task.id == task_uuid))
async def scoped_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> Task:
"""Load a native task: 404 unknown, 403 out of scope."""
task = await resolve_task(db, task_ref)
if task is None:
raise NotFoundError("تسک یافت نشد")
ctx.ensure_project(task.project_id)
return task
scoped_native_task = scoped_task
resolve_native_task = resolve_task
# --- development role switching ----------------------------------------------
def dev_switch_enabled() -> bool:
return bool(settings.DEV_ROLE_SWITCH)
def dev_login_usernames() -> list[str]:
"""Demo accounts the dev switcher may log in as (never arbitrary users)."""
return [name for name in settings.dev_demo_usernames() if name]
+184
View File
@@ -0,0 +1,184 @@
"""Casdoor SSO for PM — one identity source for the whole suite.
The Casdoor JWT issued by the ERP login flow is accepted on every PM endpoint:
- The token is verified against Casdoor's JWKS (same as the ERP backend):
signature (RS256 family), issuer = ``PM_CASDOOR_ISSUER`` (the browser-facing
origin Casdoor advertises — ``casdoor.origin`` in the compose env) and
audience = ``PM_CASDOOR_AUDIENCE`` (the inventory-app client).
- The caller's system-wide role lives in the ERP (``users.role``) and is
resolved by calling ``GET {ERP_BASE_URL}/api/v1/users/me`` with the SAME
Bearer token (the ERP verifies the identical JWT). Result is cached briefly
per casdoor id — one upstream call per role change window, not per request.
- Mapping to the PM world (single source of truth stays the ERP):
admin -> project_manager (full PM access)
pm (مدیر پروژه) -> project_manager (full PM access)
supervisor -> project_supervisor (project-scoped, as today)
procurement_manager -> 403 — مدیر تدارکات به پنل PM دسترسی ندارد
- The PMUser row is auto-provisioned / role-synced by username on first use,
so تخصیص سرپرست‌ها به پروژه‌ها (ProjectAssignment) بلافاصله بعد از اولین
ورود قابل انجام است.
Legacy HMAC session tokens (POST /auth/login, dev role switcher, standalone
:8083 deployments) keep working: ``get_current_user`` dispatches on token
shape (two dot-separated segments = legacy, three = Casdoor JWT).
"""
import logging
import time
import httpx
import jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.errors import AuthError, ForbiddenError, UpstreamError
from app.models import PMUser
# نقش‌های PM به‌صورت متن (PMUser.role ستون متنی است) — import از authz اینجا
# چرخه‌ی import می‌سازد (authz → security → casdoor_auth → authz)
_PM_PROJECT_MANAGER = "project_manager"
_PM_PROJECT_SUPERVISOR = "project_supervisor"
logger = logging.getLogger("pm.casdoor")
_JWKS_CACHE: dict | None = None
# casdoor_id -> (erp_role, expiry monotonic seconds)
_ROLE_CACHE: dict[str, tuple[str, float]] = {}
_ROLE_CACHE_TTL_SECONDS = 30
def _jwks_url() -> str:
return f"{settings.ERP_CASDOOR_ENDPOINT.rstrip('/')}/.well-known/jwks"
async def _get_jwks() -> dict:
global _JWKS_CACHE
if _JWKS_CACHE is not None:
return _JWKS_CACHE
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(_jwks_url())
resp.raise_for_status()
_JWKS_CACHE = resp.json()
except httpx.HTTPError as exc:
logger.error("Casdoor JWKS fetch failed (%s): %s", _jwks_url(), exc)
raise UpstreamError("سرویس احراز هویت (Casdoor) در دسترس نیست") from exc
return _JWKS_CACHE
def _key_from_jwk(jwk: dict):
kty = jwk.get("kty")
if kty == "RSA":
return jwt.algorithms.RSAAlgorithm.from_jwk(jwk)
if kty == "EC":
return jwt.algorithms.ECAlgorithm.from_jwk(jwk)
return jwk
async def verify_casdoor_token(token: str) -> dict:
"""Validate the Casdoor JWT and return its claims (AuthError on failure)."""
try:
kid = jwt.get_unverified_header(token).get("kid")
except jwt.PyJWTError as exc:
logger.warning("JWT unreadable: %s", exc)
raise AuthError("توکن نامعتبر است") from exc
jwks = await _get_jwks()
entry = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
if entry is None:
logger.warning("JWT kid %r not found in JWKS", kid)
raise AuthError("امضای توکن نامعتبر است")
key = _key_from_jwk(entry)
try:
return jwt.decode(
token,
key,
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"],
issuer=settings.CASDOOR_ISSUER,
audience=settings.CASDOOR_AUDIENCE,
)
except jwt.PyJWTError as exc:
logger.warning("JWT verification failed: %s", exc)
raise AuthError("توکن نامعتبر یا منقضی شده است") from exc
async def _erp_identity_for_token(token: str, casdoor_id: str) -> dict:
"""نقش + پنل‌های کاربر از ERP (users/me) — کش کوتاه."""
cached = _ROLE_CACHE.get(casdoor_id)
if cached and cached[1] > time.monotonic():
return cached[0]
url = f"{settings.ERP_BASE_URL.rstrip('/')}/api/v1/users/me"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as exc:
logger.error("ERP /users/me unreachable (%s): %s", url, exc)
raise UpstreamError("سامانه تدارکات (ERP) برای تعیین دسترسی در دسترس نیست") from exc
if resp.status_code == 401:
raise AuthError("توکن نامعتبر یا منقضی شده است")
if resp.status_code != 200:
logger.error("ERP /users/me failed (%s): HTTP %s", url, resp.status_code)
raise UpstreamError("تعیین دسترسی کاربر از ERP ناموفق بود")
data = resp.json().get("data") or {}
identity = {"role": data.get("role"), "panels": data.get("panels") or {}}
_ROLE_CACHE[casdoor_id] = (identity, time.monotonic() + _ROLE_CACHE_TTL_SECONDS)
return identity
# سطح پنل PM -> نقش PM: full = همه بخش‌ها با اختیار کامل تغییر؛
# limited = پاسخ QC + درصد پیشرفت + مشاهده جزئیات (محدود به پروژه‌های تخصیصی).
# مدیر تدارکات به PM دسترسی ندارد.
async def casdoor_user_from_token(token: str, db: AsyncSession) -> PMUser:
"""Verify the JWT, resolve the system role from ERP and return the
provisioned PMUser (role synced). 403 for roles without PM access."""
claims = await verify_casdoor_token(token)
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
username = str(claims.get("name") or claims.get("preferred_username") or "")
if not casdoor_id or not username:
raise AuthError("توکن فاقد شناسه کاربر است")
identity = await _erp_identity_for_token(token, casdoor_id)
panels = identity.get("panels") or {}
erp_role = identity.get("role")
pm_level = panels.get("pm")
if erp_role == "admin":
pm_role = _PM_PROJECT_MANAGER
elif pm_level == "full":
pm_role = _PM_PROJECT_MANAGER
elif pm_level == "limited":
pm_role = _PM_PROJECT_SUPERVISOR
elif not panels and erp_role in ("admin", "pm"): # میراث — دوره مهاجرت فقط در صورت عدم تعریف پنل‌ها
pm_role = _PM_PROJECT_MANAGER
elif not panels and erp_role == "supervisor": # میراث — فقط در صورت عدم تعریف پنل‌ها
pm_role = _PM_PROJECT_SUPERVISOR
else:
raise ForbiddenError("به پنل مدیریت پروژه دسترسی ندارید")
user = await db.scalar(select(PMUser).where(PMUser.username == username))
if user is None:
display_name = str(
claims.get("displayName") or claims.get("firstName") or username
)
user = PMUser(
username=username,
password_hash="", # SSO identities never authenticate locally
display_name=display_name,
role=pm_role,
)
db.add(user)
await db.commit()
await db.refresh(user)
elif user.role != pm_role:
# نقش سراسری عوض شده — با PM همگام می‌شود
user.role = pm_role
await db.commit()
await db.refresh(user)
return user
def is_casdoor_token(token: str) -> bool:
"""Legacy session tokens are ``body.signature`` (one dot); Casdoor JWTs
have the standard three header/payload/signature segments."""
return token.count(".") == 2
+49
View File
@@ -0,0 +1,49 @@
"""PM settings — env prefix PM_ (mirrors the ERP PROCUREMENT_ pattern)."""
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PM_", env_file=".env", extra="ignore")
DATABASE_URL: str = "postgresql+asyncpg://pm:pm@localhost:5432/pm"
SESSION_SECRET: str = "change-me"
SESSION_TTL_HOURS: int = 24 * 14
ADMIN_USER: str = "admin"
ADMIN_PASSWORD: str = "admin"
# Development role switching — the dashboard shows a role picker so the two
# roles can be tried without separate logins. Set PM_DEV_ROLE_SWITCH=false
# (production) to disable the dev endpoints entirely.
DEV_ROLE_SWITCH: bool = False
DEV_SUPERVISOR_USER: str = "supervisor"
DEV_SUPERVISOR_PASSWORD: str = "pm123456"
DEV_SUPERVISOR2_USER: str = "supervisor2"
DEV_SUPERVISOR2_PASSWORD: str = "pm123456"
def dev_demo_usernames(self) -> list[str]:
"""Accounts the dev role switcher may log in as (manager + supervisors)."""
return [self.ADMIN_USER, self.DEV_SUPERVISOR_USER, self.DEV_SUPERVISOR2_USER]
FILES_DIR: Path = Path("/data/files")
# ERP (procurement/inventory) integration — read-only service account
ERP_BASE_URL: str = "http://procurement-api:8000"
ERP_CASDOOR_ENDPOINT: str = "http://casdoor:8000"
ERP_CASDOOR_CLIENT_ID: str = "inventory-app-client"
ERP_CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
ERP_CASDOOR_ORGANIZATION: str = "inventory"
ERP_SERVICE_USERNAME: str = "pm-service"
ERP_SERVICE_PASSWORD: str = ""
ERP_CACHE_TTL_SECONDS: int = 120
# Casdoor SSO — PM accepts the ERP-issued JWT as its own session.
# ISSUER = the browser-facing origin Casdoor advertises (casdoor `origin`),
# not the in-network endpoint; AUDIENCE = the inventory-app client id.
CASDOOR_ISSUER: str = "http://localhost:8080"
CASDOOR_AUDIENCE: str = "inventory-app-client"
settings = Settings()
+20
View File
@@ -0,0 +1,20 @@
"""Async SQLAlchemy engine/session for PM database."""
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
try:
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True, pool_size=5, max_overflow=5)
except Exception:
# Fallback for testing environments or when asyncpg driver is not installed on host
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
+151
View File
@@ -0,0 +1,151 @@
"""Development-only demo accounts for the role switcher on the dashboard.
While the two roles are being built, the «امروز» dashboard shows a role picker
so nobody has to juggle logins. This module creates those demo users and gives
the demo supervisors a project scope, so the picker is immediately useful —
the enforcement itself is the real thing (see app/authz.py).
Everything here is inert unless `PM_DEV_ROLE_SWITCH` is enabled; the endpoints
in routers/auth.py return 404 in that case. For production set the flag to
false and manage users through `PUT /users/{id}/projects`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import Role, dev_switch_enabled
from app.config import settings
from app.models import PMUser, Project, ProjectAssignment
from app.security import hash_password
logger = logging.getLogger("pm.dev")
@dataclass(frozen=True)
class DemoAccount:
username: str
password: str
display_name: str
role: Role
share: tuple[int, int] | None = None
def demo_accounts() -> list[DemoAccount]:
total = len(settings.dev_demo_usernames())
return [
DemoAccount(settings.ADMIN_USER, settings.ADMIN_PASSWORD, "مدیر سیستم", Role.project_manager),
DemoAccount(
settings.DEV_SUPERVISOR_USER,
settings.DEV_SUPERVISOR_PASSWORD,
"سرپرست پروژه الف",
Role.project_supervisor,
share=(1, 2),
),
DemoAccount(
settings.DEV_SUPERVISOR2_USER,
settings.DEV_SUPERVISOR2_PASSWORD,
"سرپرست پروژه ب",
Role.project_supervisor,
share=(2, 2),
),
][:total]
def _share_of(items: list, share: tuple[int, int]) -> list:
"""Deterministic, non-overlapping slice: part N of M (1-based)."""
part, of = share
size = -(-len(items) // of)
start = (part - 1) * size
return items[start : start + size]
def demo_account(username: str) -> DemoAccount | None:
for account in demo_accounts():
if account.username == username:
return account
return None
async def ensure_demo_accounts(db: AsyncSession) -> None:
if not dev_switch_enabled():
# در پروداکشن، حساب‌های نمایشی توسعه نباید فعال باشند
demo_names = [settings.DEV_SUPERVISOR_USER, settings.DEV_SUPERVISOR2_USER]
demo_users = (
await db.execute(select(PMUser).where(PMUser.username.in_(demo_names)))
).scalars().all()
for u in demo_users:
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == u.id))
await db.delete(u)
logger.info("cleaned up demo account %s for production", u.username)
admin_u = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
if admin_u is None:
db.add(
PMUser(
username=settings.ADMIN_USER,
password_hash=hash_password(settings.ADMIN_PASSWORD),
display_name="مدیر سیستم",
role=Role.project_manager.value,
)
)
logger.info("seeded admin account in production mode")
else:
# همیشه رمز عبور را از تنظیمات به‌روز کن تا لاگین همیشه کار کند
admin_u.password_hash = hash_password(settings.ADMIN_PASSWORD)
admin_u.role = Role.project_manager.value
logger.info("refreshed admin password hash in production mode")
await db.commit()
return
for account in demo_accounts():
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
db.add(
PMUser(
username=account.username,
password_hash=hash_password(account.password),
display_name=account.display_name,
role=account.role.value,
)
)
logger.info("seeded %s account %s", account.role.value, account.username)
else:
user.role = account.role.value
user.display_name = account.display_name
await db.commit()
async def ensure_demo_assignments(db: AsyncSession) -> None:
if not dev_switch_enabled():
return
native_ids = [p for p in (await db.execute(select(Project.id).order_by(Project.name))).scalars().all()]
if not native_ids:
return
for account in demo_accounts():
if account.share is None:
continue
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
continue
existing = await db.scalar(
select(func.count()).where(ProjectAssignment.user_id == user.id)
)
if existing:
continue
mine = _share_of(native_ids, account.share)
for project_uuid in mine:
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
logger.info(
"assigned %d demo projects to %s",
len(mine),
account.username,
)
await db.commit()
async def clear_assignments(db: AsyncSession, user_id) -> None:
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == user_id))
+53
View File
@@ -0,0 +1,53 @@
"""PM error envelope — same contract as the ERP: {"error": {code, message, field}}."""
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AppError(Exception):
status_code = 400
code = "BAD_REQUEST"
def __init__(self, message: str, field: str | None = None, code: str | None = None):
super().__init__(message)
self.message = message
self.field = field
if code:
self.code = code
@property
def body(self) -> dict:
error = {"code": self.code, "message": self.message}
if self.field is not None:
error["field"] = self.field
return {"error": error}
class AuthError(AppError):
status_code, code = 401, "UNAUTHORIZED"
class ValidationError(AppError):
status_code, code = 400, "VALIDATION_ERROR"
class ForbiddenError(AppError):
status_code, code = 403, "FORBIDDEN"
class NotFoundError(AppError):
status_code, code = 404, "NOT_FOUND"
class ConflictError(AppError):
status_code, code = 409, "CONFLICT"
class UpstreamError(AppError):
"""Upstream service unreachable / errored."""
status_code, code = 503, "UPSTREAM_UNAVAILABLE"
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def _app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=exc.body)
+1
View File
@@ -0,0 +1 @@
"""Integration connectors (ERP/Inventory/Procurement). Phase-2 lives here."""
+160
View File
@@ -0,0 +1,160 @@
"""ERP (procurement/inventory) read-only connector.
Auth: Casdoor password grant with a dedicated service account (no changes to
the ERP codebase). The token is cached until expiry. ERP stays the source of
truth for physical stock; PM only READS it (reservations/purchase requests are
PM-owned operational overlays).
"""
import asyncio
import logging
import time
from typing import Any
import httpx
from app.config import settings
from app.errors import UpstreamError
logger = logging.getLogger("pm.erp")
class ERPClient:
def __init__(self):
self._token: str | None = None
self._token_exp: float = 0
self._lock = asyncio.Lock()
async def _ensure_token(self) -> str:
async with self._lock:
if self._token and time.time() < self._token_exp - 30:
return self._token
usernames = [
settings.ERP_SERVICE_USERNAME,
f"{settings.ERP_CASDOOR_ORGANIZATION}/{settings.ERP_SERVICE_USERNAME}",
]
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=12) as client:
for username in usernames:
try:
resp = await client.post(
f"{settings.ERP_CASDOOR_ENDPOINT}/api/login/oauth/access_token",
data={
"grant_type": "password",
"client_id": settings.ERP_CASDOOR_CLIENT_ID,
"client_secret": settings.ERP_CASDOOR_CLIENT_SECRET,
"username": username,
"password": settings.ERP_SERVICE_PASSWORD,
},
)
resp.raise_for_status()
data = resp.json()
token = data.get("access_token")
if token:
self._token = token
self._token_exp = time.time() + int(data.get("expires_in") or 3600)
return token
except Exception as exc: # noqa: BLE001
last_error = exc
logger.warning("ERP token grant failed for %s: %s", username, exc)
raise UpstreamError(f"احراز هویت سرویس با ERP/Casdoor ناموفق بود: {last_error}")
async def get(self, path: str, params: dict | None = None) -> Any:
token = await self._ensure_token()
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{settings.ERP_BASE_URL}{path}",
params=params,
headers={"Authorization": f"Bearer {token}"},
)
except httpx.HTTPError as exc:
raise UpstreamError(f"ERP در دسترس نیست: {exc.__class__.__name__}") from exc
if resp.status_code == 401:
self._token = None
raise UpstreamError("توکن سرویس ERP نامعتبر شد")
if resp.status_code >= 400:
logger.warning("ERP GET %s → %s %s", path, resp.status_code, resp.text[:200])
raise UpstreamError(f"خطای ERP ({resp.status_code})")
if not resp.content:
return {}
return resp.json()
# --- domain reads (normalize to PM-friendly dicts) -----------------------
async def materials(self) -> list[dict]:
"""Global material catalog: [{id, name, unit_id, unit_name}]"""
materials: list[dict] = []
page = 1
while True:
data = await self.get(f"/api/v1/materials?page_size=100&page={page}")
elements = data.get("data", []) if isinstance(data, dict) else []
materials.extend(elements)
meta = data.get("meta", {}) if isinstance(data, dict) else {}
if not elements or page * 100 >= int(meta.get("total") or len(materials)):
break
page += 1
unit_names: dict[int, str] = {}
try:
units = await self.get("/api/v1/units?page_size=100")
for u in units.get("data", []):
unit_names[u.get("id")] = u.get("name_fa") or u.get("name") or u.get("code") or ""
except Exception: # noqa: BLE001 — unit names are cosmetic
pass
return [
{
"id": m.get("id"),
"name": m.get("name") or "",
"unit_id": m.get("canonical_unit_id"),
"unit_name": unit_names.get(m.get("canonical_unit_id"), ""),
}
for m in materials
if m.get("id") and (m.get("status") or "active") == "active"
]
async def projects(self) -> list[dict]:
"""ERP projects for the mapping picker: [{id, name}]"""
data = await self.get("/api/v1/projects?page_size=100")
elements = data.get("data", []) if isinstance(data, dict) else []
return [{"id": p.get("id"), "name": p.get("name") or ""} for p in elements if p.get("id")]
async def warehouses(self, erp_project_id: str) -> list[dict]:
data = await self.get("/api/v1/inventory/warehouses")
elements = data.get("data", data) if isinstance(data, dict) else data
if isinstance(elements, dict):
elements = elements.get("data", [])
return [w for w in (elements or []) if w.get("project_id") == erp_project_id]
async def inventory_rows(self) -> list[dict]:
"""Normalized stock rows: [{warehouse_id, material_id, quantity}].
ERP returns nested objects (warehouse: {...}, material: {...}) plus a
numeric `quantity_number` — flatten to the three fields PM needs.
"""
rows: list[dict] = []
page = 1
while True:
data = await self.get(f"/api/v1/inventory?page_size=100&page={page}")
elements = data.get("data", []) if isinstance(data, dict) else []
for r in elements:
warehouse = r.get("warehouse") or {}
material = r.get("material") or {}
qty = r.get("quantity_number")
if qty is None:
qty = r.get("quantity")
rows.append(
{
"warehouse_id": warehouse.get("id"),
"material_id": material.get("id"),
"quantity": float(qty or 0),
}
)
meta = data.get("meta", {}) if isinstance(data, dict) else {}
if not elements or page * 100 >= int(meta.get("total") or len(rows)):
break
page += 1
return rows
erp_client = ERPClient()
+79
View File
@@ -0,0 +1,79 @@
"""PM — Project Management operational layer. FastAPI entrypoint."""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy import select
from app.config import settings
from app.db import SessionFactory, engine
from app.dev_accounts import ensure_demo_accounts, ensure_demo_assignments
from app.errors import install_error_handlers
from app.models import Base, PMUser
from app.routers import auth, dashboard, files, projects, qc, tasks, users
from app.security import hash_password
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
async def _ensure_admin() -> None:
"""Seed the local admin user (role: project_manager) from env on first boot."""
async with SessionFactory() as db:
existing = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
if existing is None:
db.add(
PMUser(
username=settings.ADMIN_USER,
password_hash=hash_password(settings.ADMIN_PASSWORD),
display_name="مدیر سیستم",
role="project_manager",
)
)
await db.commit()
logging.getLogger("pm.boot").info("seeded admin user %s", settings.ADMIN_USER)
# dev role switcher: demo accounts for the dashboard picker
await ensure_demo_accounts(db)
@asynccontextmanager
async def lifespan(_: FastAPI):
if settings.DEV_ROLE_SWITCH:
logging.getLogger("pm.boot").warning(
"PM_DEV_ROLE_SWITCH is ON — /auth/dev-login issues tokens without a password. "
"Set PM_DEV_ROLE_SWITCH=false for production."
)
async with engine.begin() as conn:
from app.schema_sync import drop_legacy, ensure_schema
await drop_legacy(conn)
await conn.run_sync(Base.metadata.create_all)
await ensure_schema(conn)
await _ensure_admin()
asyncio.create_task(_init_demo_assignments())
yield
async def _init_demo_assignments() -> None:
await asyncio.sleep(1)
try:
async with SessionFactory() as db:
await ensure_demo_assignments(db)
except Exception: # noqa: BLE001
logging.getLogger("pm.boot").exception("demo assignment failed")
app = FastAPI(title="PM — Construction Project Management Platform", version="1.0.0", lifespan=lifespan)
API_V1_PREFIX = "/api/v1"
for module in (auth, users, dashboard, projects, tasks, qc, files):
app.include_router(module.router, prefix=API_V1_PREFIX)
@app.get("/health")
async def health():
return {"status": "ok", "service": "pm-api"}
install_error_handlers(app)
+446
View File
@@ -0,0 +1,446 @@
"""PM data model — Native Construction Project Management Platform.
- Project and Task entities are owned directly by PM with hierarchical WBS,
CPM scheduling, dependencies, and milestones.
- QC domain (templates, definitions, inspections, results, defects) is PM-owned.
- Material requirements and reservations integrate directly with ERP stock.
"""
import enum
import uuid
from datetime import date, datetime
from sqlalchemy import (
JSON,
Boolean,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
def _uuid() -> uuid.UUID:
return uuid.uuid4()
# --- Native PM Platform Models -----------------------------------------------
class ProjectStatus(str, enum.Enum):
PLANNED = "PLANNED"
ACTIVE = "ACTIVE"
ON_HOLD = "ON_HOLD"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class TaskStatus(str, enum.Enum):
TODO = "TODO"
IN_PROGRESS = "IN_PROGRESS"
BLOCKED = "BLOCKED"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class TaskPriority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class TaskRelationType(str, enum.Enum):
FS = "FS" # Finish-to-Start (پایان به شروع)
SS = "SS" # Start-to-Start (شروع به شروع)
FF = "FF" # Finish-to-Finish (پایان به پایان)
SF = "SF" # Start-to-Finish (شروع به پایان)
class Project(Base):
"""Native Construction Project entity owned directly by PM."""
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String(255), index=True)
description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(String(255), default="")
project_manager: Mapped[str] = mapped_column(String(255), default="")
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(
String(32),
default=ProjectStatus.ACTIVE.value,
server_default=ProjectStatus.ACTIVE.value,
index=True,
)
progress: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
members: Mapped[list["ProjectMember"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
tasks: Mapped[list["Task"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
relations: Mapped[list["TaskRelation"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
class ProjectMember(Base):
"""Team members assigned to a project with designated roles."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_member"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
)
role: Mapped[str] = mapped_column(String(32), default="site_engineer")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project"] = relationship(back_populates="members")
user: Mapped["PMUser"] = relationship(lazy="selectin")
class Task(Base):
"""Hierarchical Construction Task / Activity entity owned directly by PM.
Supports arbitrary depth: Project -> Phase -> Activity -> Subtask through self-referencing `parent_id`.
"""
__tablename__ = "tasks"
__table_args__ = (
Index("ix_tasks_project", "project_id"),
Index("ix_tasks_parent", "parent_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(
String(32),
default=TaskStatus.TODO.value,
server_default=TaskStatus.TODO.value,
index=True,
)
priority: Mapped[str] = mapped_column(
String(32),
default=TaskPriority.MEDIUM.value,
server_default=TaskPriority.MEDIUM.value,
)
assignee_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="SET NULL"), nullable=True
)
assignee_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
duration: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
qc_status: Mapped[str] = mapped_column(
String(32),
default="none",
server_default="none",
comment="none|pre_qc_pending|pre_qc_failed|ready|exec_qc_pending|in_progress|qc_failed_rework|final_passed",
)
human_resources: Mapped[list[dict]] = mapped_column(
JSON, default=list, server_default="[]"
)
materials: Mapped[list[dict]] = mapped_column(
JSON, default=list, server_default="[]"
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
project: Mapped["Project"] = relationship(back_populates="tasks", lazy="selectin")
parent: Mapped["Task | None"] = relationship(
back_populates="children", remote_side=[id], lazy="selectin"
)
children: Mapped[list["Task"]] = relationship(
back_populates="parent", cascade="all, delete-orphan", lazy="selectin"
)
assignee: Mapped["PMUser | None"] = relationship(lazy="selectin")
outgoing_relations: Mapped[list["TaskRelation"]] = relationship(
foreign_keys="[TaskRelation.from_task_id]",
back_populates="from_task",
cascade="all, delete-orphan",
)
incoming_relations: Mapped[list["TaskRelation"]] = relationship(
foreign_keys="[TaskRelation.to_task_id]",
back_populates="to_task",
cascade="all, delete-orphan",
)
progress_logs: Mapped[list["TaskProgressLog"]] = relationship(
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
)
class TaskProgressLog(Base):
"""ثبت گزارش پیشرفت مقطعی/روزانه وظیفه به همراه مصرف واقعی نیروی انسانی و مصالح."""
__tablename__ = "task_progress_logs"
__table_args__ = (
Index("ix_task_progress_logs_task", "task_id"),
Index("ix_task_progress_logs_created", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
progress_before: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
progress_after: Mapped[int] = mapped_column(Integer)
human_resources_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
materials_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
description: Mapped[str] = mapped_column(Text, default="", server_default="")
logged_by: Mapped[str] = mapped_column(String(255), default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
task: Mapped["Task"] = relationship(back_populates="progress_logs")
class TaskRelation(Base):
"""Dependency / precedence between two tasks in a project (FS, SS, FF, SF + lag)."""
__tablename__ = "task_relations"
__table_args__ = (
UniqueConstraint("from_task_id", "to_task_id", name="uq_task_relations_pair"),
Index("ix_task_relations_project", "project_id"),
Index("ix_task_relations_from", "from_task_id"),
Index("ix_task_relations_to", "to_task_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
from_task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
to_task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
relation_type: Mapped[str] = mapped_column(
String(8), default=TaskRelationType.FS.value, server_default=TaskRelationType.FS.value
)
lag_days: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project"] = relationship(back_populates="relations")
from_task: Mapped["Task"] = relationship(
foreign_keys=[from_task_id], back_populates="outgoing_relations"
)
to_task: Mapped["Task"] = relationship(
foreign_keys=[to_task_id], back_populates="incoming_relations"
)
# --- PM-owned QC domain ------------------------------------------------------
class QCQuestionPhase(str, enum.Enum):
"""مرحله‌ای که یک سوال QC به آن تعلق دارد.
pre = پیش از اجرا (آمادگی شروع کار)
during = هنگام اجرا
after = پس از اجرا
"""
pre = "pre"
during = "during"
after = "after"
class QCAnswer(str, enum.Enum):
pass_ = "pass"
fail = "fail"
na = "na"
class QCTemplate(Base):
__tablename__ = "qc_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String(255))
activity_type: Mapped[str] = mapped_column(String(64), default="", index=True)
description: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
questions: Mapped[list["QCQuestion"]] = relationship(
back_populates="template", cascade="all, delete-orphan", order_by="QCQuestion.sort_order"
)
class QCQuestion(Base):
__tablename__ = "qc_questions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("qc_templates.id", ondelete="CASCADE"),
index=True,
)
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
text: Mapped[str] = mapped_column(String(512))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
required: Mapped[bool] = mapped_column(Boolean, default=True)
template: Mapped[QCTemplate] = relationship(back_populates="questions")
class TaskQCDefinition(Base):
__tablename__ = "task_qc_definitions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(255), default="")
# provenance only — deleting/editing the template never affects this copy
source_template_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("qc_templates.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
questions: Mapped[list["TaskQCQuestion"]] = relationship(
back_populates="definition",
cascade="all, delete-orphan",
order_by="TaskQCQuestion.sort_order",
)
class TaskQCQuestion(Base):
__tablename__ = "task_qc_questions"
__table_args__ = (Index("ix_task_qc_q_def", "definition_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
definition_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_definitions.id", ondelete="CASCADE")
)
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
text: Mapped[str] = mapped_column(String(512))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
required: Mapped[bool] = mapped_column(Boolean, default=True)
definition: Mapped[TaskQCDefinition] = relationship(back_populates="questions")
results: Mapped[list["TaskQCResult"]] = relationship(
back_populates="question", cascade="all, delete-orphan", order_by="TaskQCResult.evaluated_at"
)
class TaskQCResult(Base):
"""Evaluation of ONE task-specific question — append-only history.
The current answer is the latest row for the question; keeping history is
what makes rework → re-inspection → pass traceable.
"""
__tablename__ = "task_qc_results"
__table_args__ = (Index("ix_task_qc_res_q", "question_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
question_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="CASCADE")
)
answer: Mapped[QCAnswer] = mapped_column(Enum(QCAnswer, name="qc_answer"))
comment: Mapped[str] = mapped_column(Text, default="")
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
evaluated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
evaluated_by: Mapped[str] = mapped_column(String(255), default="")
question: Mapped[TaskQCQuestion] = relationship(back_populates="results")
class Defect(Base):
"""عیب ثبت‌شده روی یک تسک (اختیاری متصل به سؤال QC مردود)."""
__tablename__ = "defects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
question_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
severity: Mapped[str] = mapped_column(String(16), default="medium", server_default="medium")
status: Mapped[str] = mapped_column(String(16), default="open", server_default="open")
corrective_action: Mapped[str] = mapped_column(Text, default="")
resolution_notes: Mapped[str] = mapped_column(Text, default="")
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Auth & access control ------------------------------------------------------
class PMUser(Base):
__tablename__ = "pm_users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
display_name: Mapped[str] = mapped_column(String(255), default="")
# Values live in app/authz.py (Role); stored as text so new roles need no
# Postgres enum migration.
role: Mapped[str] = mapped_column(
String(32),
default="project_manager",
server_default="project_manager",
comment="project_manager|project_supervisor",
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ProjectAssignment(Base):
"""Projects a scoped role may access (e.g. a supervisor's assigned projects).
Unscoped roles (project_manager) see every project and need no rows here;
scoped roles are limited to exactly the rows present for them. Source of
truth for the scoping done in app/authz.py.
"""
__tablename__ = "project_assignments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+276
View File
@@ -0,0 +1,276 @@
"""QC domain service — the single source of truth for the QC business rules.
Concepts (kept strictly separate):
* QCTemplate / QCQuestion → global, reusable definition (a starting point)
* TaskQCDefinition/…Question → task-specific COPY the user customizes freely
* TaskQCResult → the evaluation (append-only history) done during execution
Rules enforced here (and reused by routers/tasks.py + the dashboard):
* A task cannot start (progress > 0) while required PRE-EXECUTION QC is unresolved/failed.
* A task cannot reach 100% while ANY required QC question (pre/during/after) is unresolved.
* `na` ("مطرح نیست") counts as satisfied for gating purposes.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
QCAnswer,
QCQuestionPhase,
Task,
TaskQCDefinition,
TaskQCQuestion,
TaskQCResult,
)
PRE = QCQuestionPhase.pre.value
SATISFIED = {QCAnswer.pass_, QCAnswer.na}
# --- status vocabulary --------------------------------------------------------
QC_STATUS_NONE = "none"
QC_STATUS_PRE_PENDING = "pre_qc_pending"
QC_STATUS_PRE_FAILED = "pre_qc_failed"
QC_STATUS_READY = "ready"
QC_STATUS_EXEC_PENDING = "exec_qc_pending"
QC_STATUS_IN_PROGRESS = "in_progress"
QC_STATUS_REWORK = "qc_failed_rework"
QC_STATUS_PASSED = "final_passed"
@dataclass
class PhaseSummary:
phase: str
total: int = 0
passed: int = 0
failed: int = 0
pending: int = 0
na: int = 0
required_total: int = 0
required_unresolved: int = 0
@property
def status(self) -> str:
if self.total == 0:
return "none"
if self.failed:
return "failed"
if self.pending:
return "pending"
return "passed"
def as_dict(self) -> dict:
return {
"total": self.total,
"passed": self.passed,
"failed": self.failed,
"pending": self.pending,
"na": self.na,
"required_total": self.required_total,
"required_unresolved": self.required_unresolved,
"status": self.status,
}
@dataclass
class QCEvaluation:
"""Latest answer of one task QC question."""
question: TaskQCQuestion
latest: TaskQCResult | None
@property
def answer(self) -> str | None:
return self.latest.answer.value if self.latest else None
@property
def satisfied(self) -> bool:
return self.latest is not None and self.latest.answer in SATISFIED
@dataclass
class TaskQCState:
items: list[QCEvaluation] = field(default_factory=list)
@property
def pre(self) -> PhaseSummary:
return _summarize([i for i in self.items if i.question.phase == QCQuestionPhase.pre], "pre")
@property
def exec_(self) -> PhaseSummary:
return _summarize([i for i in self.items if i.question.phase != QCQuestionPhase.pre], "exec")
@property
def all_(self) -> PhaseSummary:
return _summarize(self.items, "all")
@property
def can_start(self) -> bool:
return self.pre.required_unresolved == 0 and self.pre.failed == 0
@property
def can_complete(self) -> bool:
return self.all_.required_unresolved == 0 and self.all_.failed == 0
def block_reasons(self, requested_percentage: int | None = None) -> list[str]:
reasons: list[str] = []
if requested_percentage is not None and requested_percentage > 0 and not self.can_start:
if self.pre.failed:
reasons.append("کنترل کیفیت پیش از اجرا مردود شده است (ایرادات را برطرف کنید)")
elif self.pre.required_unresolved:
reasons.append(
f"{self.pre.required_unresolved} سؤال کنترل کیفیت پیش از اجرا پاسخ داده نشده است"
)
if requested_percentage == 100 and not self.can_complete:
if self.all_.failed:
reasons.append("تسک دارای کنترل کیفیت مردود است و قابل بستن نیست")
elif self.all_.required_unresolved:
reasons.append(
f"{self.all_.required_unresolved} سؤال کنترل کیفیت الزامی هنوز ارزیابی نشده‌اند"
)
return reasons
def status(self, progress: int) -> str:
if not self.items:
return QC_STATUS_NONE
if self.pre.failed:
return QC_STATUS_PRE_FAILED
if self.pre.total > 0 and not self.can_start:
return QC_STATUS_PRE_PENDING
if progress == 0:
return QC_STATUS_READY
if self.exec_.failed:
return QC_STATUS_REWORK
if progress >= 100:
return QC_STATUS_PASSED if self.can_complete else QC_STATUS_EXEC_PENDING
return QC_STATUS_IN_PROGRESS
def _summarize(items: list[QCEvaluation], phase_name: str) -> PhaseSummary:
summary = PhaseSummary(phase=phase_name, total=len(items))
for item in items:
# تمام سؤالات QC نیازمند تعیین تکلیف هستند (قبول، مردود یا مطرح نیست)
summary.required_total += 1
if not item.satisfied:
summary.required_unresolved += 1
ans = item.answer
if ans == QCAnswer.pass_.value:
summary.passed += 1
elif ans == QCAnswer.fail.value:
summary.failed += 1
elif ans is None:
summary.pending += 1
else:
summary.na += 1
return summary
async def load_definitions(db: AsyncSession, task_id: Any) -> list[TaskQCDefinition]:
"""Definitions with questions + their full result history eagerly loaded."""
try:
u = uuid.UUID(str(task_id))
except (ValueError, AttributeError):
return []
stmt = (
select(TaskQCDefinition)
.options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results))
.where(TaskQCDefinition.task_id == u)
.order_by(TaskQCDefinition.created_at)
)
rows = (await db.execute(stmt)).scalars().all()
return list(rows)
def _latest(results: list[TaskQCResult]) -> TaskQCResult | None:
if not results:
return None
return max(results, key=lambda r: (r.evaluated_at, r.id))
def build_state(definitions: list[TaskQCDefinition]) -> TaskQCState:
evaluations: list[QCEvaluation] = []
for d in definitions:
for q in d.questions:
evaluations.append(QCEvaluation(question=q, latest=_latest(q.results)))
return TaskQCState(items=evaluations)
async def task_qc_state(db: AsyncSession, task_id: Any) -> TaskQCState:
return build_state(await load_definitions(db, task_id))
def question_dict(evaluation: QCEvaluation) -> dict:
question = evaluation.question
latest = evaluation.latest
return {
"id": str(question.id),
"definition_id": str(question.definition_id),
"text": question.text,
"phase": question.phase.value,
"sort_order": question.sort_order,
"required": question.required,
"result": (
{
"answer": latest.answer.value,
"comment": latest.comment,
"photo_url": f"/api/v1/files/{latest.photo_path}" if latest.photo_path else None,
"photo_id": latest.photo_path,
"evaluated_at": latest.evaluated_at.isoformat() if latest.evaluated_at else None,
"evaluated_by": latest.evaluated_by,
}
if latest
else None
),
"history_count": len(question.results),
}
def definition_dict(definition: TaskQCDefinition, state: TaskQCState) -> dict:
mine = [i for i in state.items if i.question.definition_id == definition.id]
summary = _summarize(mine, "definition")
return {
"id": str(definition.id),
"task_id": str(definition.task_id),
"name": definition.name,
"source_template_id": str(definition.source_template_id) if definition.source_template_id else None,
"created_at": definition.created_at.isoformat() if definition.created_at else None,
"questions": [question_dict(i) for i in mine],
"summary": summary.as_dict(),
}
def state_dict(state: TaskQCState, progress: int, requested_percentage: int | None = None) -> dict:
return {
"pre": state.pre.as_dict(),
"exec": state.exec_.as_dict(),
"all": state.all_.as_dict(),
"status": state.status(progress),
"can_start": state.can_start,
"can_complete": state.can_complete,
"block_reasons": state.block_reasons(requested_percentage),
}
async def recompute_task_qc_status(db: AsyncSession, task_id: Any) -> str | None:
"""Store the derived QC status on the task (dashboard/analytics read it)."""
db.expire_all()
state = await task_qc_state(db, task_id)
try:
native_id = uuid.UUID(str(task_id))
except (ValueError, AttributeError):
return None
task = await db.scalar(select(Task).where(Task.id == native_id))
if task is None:
return None
task.qc_status = state.status(task.progress or 0)
return task.qc_status
+1
View File
@@ -0,0 +1 @@
"""PM API routers."""
+98
View File
@@ -0,0 +1,98 @@
"""Local auth + role/scope introspection.
`POST /auth/login` is the real entry point; `GET /auth/me` re-reads the caller's
role/permissions/scope (used after a role or assignment change, and by the
frontend to self-heal a session stored before RBAC existed). The `/auth/dev-*`
endpoints implement the dashboard role switcher and only exist while
`PM_DEV_ROLE_SWITCH` is enabled.
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import build_context, dev_login_usernames, dev_switch_enabled
from app.db import get_db
from app.dev_accounts import demo_account, ensure_demo_assignments
from app.errors import AuthError, NotFoundError
from app.models import PMUser, ProjectAssignment
from app.security import authenticate, create_token, get_current_user
from app.serializers import user_payload
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginIn(BaseModel):
username: str
password: str
@router.post("/login")
async def login(payload: LoginIn, db: AsyncSession = Depends(get_db)):
user = await authenticate(db, payload.username, payload.password)
if user is None:
raise AuthError("نام کاربری یا رمز عبور اشتباه است")
ctx = await build_context(db, user)
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
@router.get("/me")
async def me(user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return {"data": user_payload(await build_context(db, user))}
# --- development role switcher -------------------------------------------------
def _require_dev_switch() -> None:
if not dev_switch_enabled():
raise NotFoundError("تغییر نقش فقط در فاز توسعه فعال است")
@router.get("/dev-roles")
async def dev_roles(db: AsyncSession = Depends(get_db)):
"""Demo identities offered by the dashboard role picker (dev only)."""
_require_dev_switch()
# projects only exist after the first pull — self-heal the demo scope here
await ensure_demo_assignments(db)
data = []
for username in dev_login_usernames():
account = demo_account(username)
if account is None:
continue
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
continue
projects = await db.scalar(
select(func.count()).where(ProjectAssignment.user_id == user.id)
)
data.append(
{
"username": user.username,
"display_name": user.display_name,
"role": account.role.value,
"project_count": projects or 0,
"all_projects": account.share is None,
}
)
return {"data": data}
class DevLoginIn(BaseModel):
username: str
@router.post("/dev-login")
async def dev_login(payload: DevLoginIn, db: AsyncSession = Depends(get_db)):
"""Issue a token for a demo account — dev role switching without a password.
Restricted to the configured demo usernames, never arbitrary users.
"""
_require_dev_switch()
if payload.username not in dev_login_usernames():
raise AuthError("این حساب در فهرست حساب‌های توسعه نیست")
user = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
if user is None:
raise AuthError("حساب نمایشی ساخته نشده است؛ سرور را دوباره راه‌اندازی کنید")
ctx = await build_context(db, user)
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
+113
View File
@@ -0,0 +1,113 @@
"""Today dashboard.
Both roles land here, but they need different things from it: the project
manager gets a management overview (every project, with its counters), the
supervisor gets an operational view of the activities in the projects assigned
to them. The data is the same query either way — only scoped by role and
presented differently (see frontend TodayPage).
"""
from datetime import date, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import AuthContext, Perm, require_permission
from app.db import get_db
from app.models import Project, Task
from app.serializers import _iso, native_task_dict
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
view_dashboard = require_permission(Perm.dashboard_view)
async def _native_project_overview(db: AsyncSession, ctx: AuthContext) -> list[dict]:
"""کاشی‌های مدیریتی پروژه‌های بومی برای داشبورد مدیر پروژه."""
proj_stmt = ctx.scope_native(select(Project).order_by(Project.name), Project.id)
projects = (await db.execute(proj_stmt)).scalars().all()
tasks = (await db.execute(ctx.scope_native(select(Task), Task.project_id))).scalars().all()
today_date = date.today()
by_project: dict = {}
for t in tasks:
by_project.setdefault(t.project_id, []).append(t)
rows: list[dict] = []
for p in projects:
mine = by_project.get(p.id, [])
open_tasks = [t for t in mine if t.status not in ("COMPLETED", "CANCELLED")]
stats = {
"total": len(mine),
"active": len(open_tasks),
"awaiting_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")),
"failed_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_failed", "qc_failed_rework")),
"overdue": sum(1 for t in open_tasks if t.planned_end and t.planned_end < today_date),
}
rows.append(
{
"id": str(p.id),
"name": p.name,
"identifier": str(p.id),
"active": p.status == "ACTIVE",
"stats": stats,
}
)
return rows
@router.get("/today")
async def today(ctx: AuthContext = Depends(view_dashboard), db: AsyncSession = Depends(get_db)):
native_stmt = ctx.scope_native(
select(Task).options(selectinload(Task.project)), Task.project_id
)
native_tasks = (await db.execute(native_stmt)).scalars().all()
today_date = date.today()
week_end = today_date + timedelta(days=7)
def is_task_open(t: Task) -> bool:
return t.status != "COMPLETED" and t.status != "CANCELLED"
overdue = [t for t in native_tasks if is_task_open(t) and t.planned_end and t.planned_end < today_date]
due_today = [t for t in native_tasks if is_task_open(t) and t.planned_end == today_date]
upcoming = [
t for t in native_tasks if is_task_open(t) and t.planned_end and today_date < t.planned_end <= week_end
]
awaiting_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")]
failed_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_failed", "qc_failed_rework")]
blocked = [t for t in native_tasks if is_task_open(t) and t.status == "BLOCKED"]
def _native_row(t: Task) -> dict:
p_name = t.project.name if t.project else ""
d = native_task_dict(t, project_name=p_name)
d["subject"] = t.title
d["percentage_done"] = t.progress
d["start_date"] = _iso(t.planned_start)
d["due_date"] = _iso(t.planned_end)
d["is_closed"] = t.status == "COMPLETED"
return d
problems = len(overdue) + len(failed_qc)
warnings = len(awaiting_qc) + len(blocked)
ok = max(len(native_tasks) - problems - warnings, 0)
data = {
"role": ctx.role.value,
"role_label": ctx.role_label,
"scope": ctx.as_dict()["scope"],
"summary": {"problems": problems, "warnings": warnings, "ok": ok},
"sections": {
"overdue": [_native_row(t) for t in sorted(overdue, key=lambda x: x.planned_end or date.max)],
"due_today": [_native_row(t) for t in due_today],
"awaiting_qc": [_native_row(t) for t in awaiting_qc],
"failed_qc": [_native_row(t) for t in failed_qc],
"blocked": [_native_row(t) for t in blocked],
"upcoming": [_native_row(t) for t in sorted(upcoming, key=lambda x: x.planned_end or date.max)[:10]],
},
}
# نمای مدیریتی پروژه‌ها فقط برای نقش بدون محدودیت (مدیر پروژه)
if ctx.all_projects:
data["projects"] = await _native_project_overview(db, ctx)
return {"data": data}
+54
View File
@@ -0,0 +1,54 @@
"""Defect photos — upload to PM volume, serve (LAN-internal, unauthenticated)."""
import uuid
from fastapi import APIRouter, Depends, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import AuthContext, Perm, require_permission
from app.config import settings
from app.db import get_db
from app.errors import ValidationError
router = APIRouter(prefix="/files", tags=["files"])
ALLOWED = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
MAX_SIZE = 10 * 1024 * 1024
# photos are attached to QC defects, so both roles may upload
upload_file = require_permission(Perm.files_upload)
@router.post("")
async def upload(
file: UploadFile,
_: AuthContext = Depends(upload_file),
db: AsyncSession = Depends(get_db),
):
_ = db
ext = ALLOWED.get(file.content_type or "")
if ext is None:
raise ValidationError("فقط تصویر jpg/png/webp پذیرفته میشود", field="file")
data = await file.read()
if len(data) > MAX_SIZE:
raise ValidationError("حجم فایل بیش از حد مجاز است (۱۰ مگابایت)", field="file")
settings.FILES_DIR.mkdir(parents=True, exist_ok=True)
file_id = uuid.uuid4().hex
path = settings.FILES_DIR / f"{file_id}{ext}"
path.write_bytes(data)
return {"data": {"id": file_id, "url": f"/api/v1/files/{file_id}", "filename": file.filename}}
@router.get("/{file_id}")
async def serve(file_id: str, db: AsyncSession = Depends(get_db)):
_ = db
if not file_id.isalnum():
raise ValidationError("شناسه فایل نامعتبر است")
for ext in ALLOWED.values():
path = settings.FILES_DIR / f"{file_id}{ext}"
if path.exists():
media = next(k for k, v in ALLOWED.items() if v == ext)
return FileResponse(path, media_type=media)
from app.errors import NotFoundError
raise NotFoundError("فایل یافت نشد")
+601
View File
@@ -0,0 +1,601 @@
"""Projects (Native PM Construction Platform Projects).
Supports:
- Project list with real-time statistics and scoping.
- Creation, editing, archiving of native projects.
- Project team members management.
- Unique project code and UUID resolution.
"""
from __future__ import annotations
import uuid
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import AuthContext, Perm, require_permission
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import (
Project,
ProjectMember,
ProjectStatus,
Task,
TaskRelation,
TaskRelationType,
)
from app.scheduling_service import (
calculate_schedule,
check_for_cycle,
rollup_parent_tasks,
sync_project_lifecycle_dates,
)
from app.serializers import _iso, project_dict, project_member_dict, task_relation_dict
from app.utils.date_service import date_service
from app.utils.jalali import to_jalali_str
router = APIRouter(prefix="/projects", tags=["projects"])
ACTIVE_QC = ("pre_qc_pending", "exec_qc_pending", "ready")
FAILED_QC = ("pre_qc_failed", "qc_failed_rework")
view_projects = require_permission(Perm.projects_view)
manage_projects = require_permission(Perm.projects_create)
class ProjectCreateSchema(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str = ""
location: str = ""
project_manager: str = ""
planned_start: str | None = None
planned_end: str | None = None
status: str = ProjectStatus.ACTIVE.value
class ProjectUpdateSchema(BaseModel):
name: str | None = None
description: str | None = None
location: str | None = None
project_manager: str | None = None
planned_start: str | None = None
planned_end: str | None = None
actual_start: str | None = None
actual_end: str | None = None
status: str | None = None
progress: float | None = None
class ProjectMemberCreateSchema(BaseModel):
user_id: str
role: str = "site_engineer"
class ErpMappingSchema(BaseModel):
erp_project_id: str | None = None
class TaskRelationCreateSchema(BaseModel):
from_task_id: str
to_task_id: str
relation_type: str = "FS"
lag_days: int = 0
class TaskRelationUpdateSchema(BaseModel):
"""Edit an existing dependency. The endpoints (from/to tasks) are immutable —
changing them is a different relation, so it must be deleted and recreated."""
relation_type: str | None = None
lag_days: int | None = None
async def _resolve_project(db: AsyncSession, ctx: AuthContext, project_id_or_op: str | int) -> Project:
"""Find a project by UUID and enforce scope."""
query = select(Project).options(
selectinload(Project.members).selectinload(ProjectMember.user)
)
try:
val_uuid = uuid.UUID(str(project_id_or_op))
stmt = query.where(Project.id == val_uuid)
except ValueError:
raise NotFoundError("شناسه پروژه نامعتبر است")
project = (await db.execute(stmt)).scalar_one_or_none()
if not project:
raise NotFoundError("پروژه مورد نظر یافت نشد")
ctx.ensure_native_project(project.id)
return project
async def native_project_stats(db: AsyncSession, project_id: uuid.UUID) -> dict:
"""Task counters of one native project."""
today = date_service.today()
rows = (
await db.execute(
select(Task.qc_status, Task.planned_end, Task.status, func.count())
.where(Task.project_id == project_id)
.group_by(Task.qc_status, Task.planned_end, Task.status)
)
).all()
total = sum(r[3] for r in rows)
completed = sum(r[3] for r in rows if r[2] == "COMPLETED")
in_progress = sum(r[3] for r in rows if r[2] == "IN_PROGRESS")
blocked = sum(r[3] for r in rows if r[2] == "BLOCKED")
awaiting = sum(r[3] for r in rows if r[0] in ACTIVE_QC)
failed = sum(r[3] for r in rows if r[0] in FAILED_QC)
overdue = sum(r[3] for r in rows if r[2] != "COMPLETED" and r[1] and r[1] < today)
return {
"total": total,
"completed": completed,
"in_progress": in_progress,
"blocked": blocked,
"awaiting_qc": awaiting,
"failed_qc": failed,
"overdue": overdue,
}
@router.get("")
async def list_projects(
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""List all projects visible to the user, with stats and team counts."""
stmt = select(Project).options(selectinload(Project.members)).order_by(Project.created_at.desc())
# Apply project scoping for supervisor / scoped roles (native projects = UUID)
stmt = ctx.scope_native(stmt, Project.id)
projects = (await db.execute(stmt)).scalars().all()
results = []
for p in projects:
stats = await native_project_stats(db, p.id)
results.append(project_dict(p, member_count=len(p.members), task_stats=stats))
return {"data": results}
@router.post("")
async def create_project(
payload: ProjectCreateSchema,
ctx: AuthContext = Depends(manage_projects),
db: AsyncSession = Depends(get_db),
):
"""Create a new native project."""
p_start = date_service.parse(payload.planned_start) if payload.planned_start else None
p_end = date_service.parse(payload.planned_end) if payload.planned_end else None
if p_start and p_end and p_end < p_start:
raise ValidationError("تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد", field="planned_end")
project = Project(
name=payload.name.strip(),
description=payload.description.strip(),
location=payload.location.strip() if payload.location else "",
project_manager=payload.project_manager.strip() if payload.project_manager else ctx.user.display_name,
planned_start=p_start,
planned_end=p_end,
status=payload.status.upper(),
)
db.add(project)
await db.flush()
# Automatically add the creator as a project manager member
creator_member = ProjectMember(
project_id=project.id,
user_id=ctx.user.id,
role="project_manager",
)
db.add(creator_member)
await db.commit()
await db.refresh(project)
stats = {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "overdue": 0}
res = project_dict(project, member_count=1, task_stats=stats)
res["identifier"] = str(project.id)
return {"data": res}
@router.get("/{id}")
async def get_project(
id: str,
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""Get project details, member list, and overall stats."""
project = await _resolve_project(db, ctx, id)
stats = await native_project_stats(db, project.id)
data = project_dict(project, member_count=len(project.members), task_stats=stats)
data["identifier"] = str(project.id)
data["members"] = [project_member_dict(m) for m in project.members]
return {"data": data}
@router.patch("/{id}")
async def update_project(
id: str,
payload: ProjectUpdateSchema,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Update project details, status, or dates."""
project = await _resolve_project(db, ctx, id)
if payload.name is not None:
project.name = payload.name.strip()
if payload.description is not None:
project.description = payload.description.strip()
if payload.location is not None:
project.location = payload.location.strip()
if payload.project_manager is not None:
project.project_manager = payload.project_manager.strip()
if payload.status is not None:
project.status = payload.status.upper()
if payload.progress is not None:
project.progress = max(0.0, min(100.0, payload.progress))
if payload.planned_start is not None:
project.planned_start = date_service.parse(payload.planned_start)
if payload.planned_end is not None:
project.planned_end = date_service.parse(payload.planned_end)
if payload.actual_start is not None:
project.actual_start = date_service.parse(payload.actual_start)
if payload.actual_end is not None:
project.actual_end = date_service.parse(payload.actual_end)
if project.planned_start and project.planned_end and project.planned_end < project.planned_start:
raise ValidationError("تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد", field="planned_end")
await db.commit()
await db.refresh(project)
stats = await native_project_stats(db, project.id)
return {"data": project_dict(project, member_count=len(project.members), task_stats=stats)}
@router.post("/{id}/members")
async def add_project_member(
id: str,
payload: ProjectMemberCreateSchema,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Add a team member to the project."""
project = await _resolve_project(db, ctx, id)
target_user_id = uuid.UUID(payload.user_id)
# Check if already a member
existing = await db.scalar(
select(ProjectMember).where(
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
)
)
if existing:
existing.role = payload.role
else:
member = ProjectMember(
project_id=project.id,
user_id=target_user_id,
role=payload.role,
)
db.add(member)
await db.commit()
# Return updated members list
await db.refresh(project)
project = await _resolve_project(db, ctx, project.id)
return {"data": [project_member_dict(m) for m in project.members]}
@router.delete("/{id}/members/{user_id}")
async def remove_project_member(
id: str,
user_id: str,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Remove a team member from the project."""
project = await _resolve_project(db, ctx, id)
target_user_id = uuid.UUID(user_id)
member = await db.scalar(
select(ProjectMember).where(
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
)
)
if member:
await db.delete(member)
await db.commit()
project = await _resolve_project(db, ctx, project.id)
return {"data": [project_member_dict(m) for m in project.members]}
@router.patch("/{id}/erp-mapping")
async def map_erp_project(
id: str,
payload: ErpMappingSchema,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
project = await _resolve_project(db, ctx, id)
return {"data": {"id": str(project.id)}}
# --- Relations & Dependencies ---
@router.get("/{id}/relations")
async def list_project_relations(
id: str,
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""List all task dependencies in a project."""
project = await _resolve_project(db, ctx, id)
stmt = (
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(TaskRelation.project_id == project.id)
)
relations = (await db.execute(stmt)).scalars().all()
return {"data": [task_relation_dict(r) for r in relations]}
@router.post("/{id}/relations")
async def create_task_relation(
id: str,
payload: TaskRelationCreateSchema,
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
db: AsyncSession = Depends(get_db),
):
"""Create a new task dependency (FS, SS, FF, SF + lag). Enforces cycle check."""
project = await _resolve_project(db, ctx, id)
from_id = uuid.UUID(payload.from_task_id)
to_id = uuid.UUID(payload.to_task_id)
if from_id == to_id:
raise ValidationError("یک وظیفه نمی‌تواند پیش‌نیاز خودش باشد")
# Verify tasks belong to this project
from_task = await db.get(Task, from_id)
to_task = await db.get(Task, to_id)
if not from_task or from_task.project_id != project.id:
raise ValidationError("وظیفه پیش‌نیاز در این پروژه یافت نشد")
if not to_task or to_task.project_id != project.id:
raise ValidationError("وظیفه وابسته در این پروژه یافت نشد")
# Load existing relations to verify DAG
existing_rels = (
await db.execute(select(TaskRelation.from_task_id, TaskRelation.to_task_id).where(TaskRelation.project_id == project.id))
).all()
existing_pairs = [(r[0], r[1]) for r in existing_rels]
# Check for duplicate
if (from_id, to_id) in existing_pairs:
raise ValidationError("این وابستگی قبلاً ثبت شده است")
# Cycle check
if check_for_cycle(existing_pairs, from_id, to_id):
raise ValidationError("این وابستگی باعث ایجاد چرخه (حلقه تکرار) در ساختار زمان‌بندی می‌شود")
rtype = payload.relation_type.upper()
if rtype not in ("FS", "SS", "FF", "SF"):
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
relation = TaskRelation(
project_id=project.id,
from_task_id=from_id,
to_task_id=to_id,
relation_type=rtype,
lag_days=payload.lag_days,
)
db.add(relation)
await db.commit()
# Recalculate schedule cascade automatically
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
# Update tasks with new early dates if changed
for t in all_tasks:
if t.id in sched_result["tasks_schedule"]:
info = sched_result["tasks_schedule"][t.id]
t.planned_start = info["planned_start"]
t.planned_end = info["planned_end"]
t.duration = info["duration"]
await db.commit()
await db.refresh(relation)
return {"data": task_relation_dict(relation, from_task.title, to_task.title)}
@router.patch("/{id}/relations/{relation_id}")
async def update_task_relation(
id: str,
relation_id: str,
payload: TaskRelationUpdateSchema,
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
db: AsyncSession = Depends(get_db),
):
"""Edit a dependency's type and/or lag, then re-run the schedule cascade."""
project = await _resolve_project(db, ctx, id)
rel = await db.scalar(
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(TaskRelation.id == uuid.UUID(relation_id), TaskRelation.project_id == project.id)
)
if rel is None:
raise NotFoundError("وابستگی یافت نشد")
if payload.relation_type is not None:
rtype = payload.relation_type.upper()
if rtype not in ("FS", "SS", "FF", "SF"):
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
rel.relation_type = rtype
if payload.lag_days is not None:
rel.lag_days = payload.lag_days
await db.commit()
# Recalculate the schedule so the edit takes effect immediately
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
for t in all_tasks:
if t.id in sched_result["tasks_schedule"]:
info = sched_result["tasks_schedule"][t.id]
t.planned_start = info["planned_start"]
t.planned_end = info["planned_end"]
t.duration = info["duration"]
await db.commit()
await db.refresh(rel)
return {"data": task_relation_dict(rel, rel.from_task.title, rel.to_task.title)}
@router.delete("/{id}/relations/{relation_id}")
async def delete_task_relation(
id: str,
relation_id: str,
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
db: AsyncSession = Depends(get_db),
):
"""Delete a task dependency."""
project = await _resolve_project(db, ctx, id)
rel_uuid = uuid.UUID(relation_id)
rel = await db.scalar(
select(TaskRelation).where(TaskRelation.id == rel_uuid, TaskRelation.project_id == project.id)
)
if rel:
await db.delete(rel)
await db.commit()
return {"data": {"deleted": True}}
# --- Interactive Gantt & Topological Scheduling ---
@router.get("/{id}/gantt")
async def get_project_gantt(
id: str,
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""Return full Gantt chart dataset with CPM calculations, relations, and critical path."""
project = await _resolve_project(db, ctx, id)
# 1. Fetch tasks, relations, milestones
tasks = (
await db.execute(
select(Task)
.options(selectinload(Task.children))
.where(Task.project_id == project.id)
.order_by(Task.planned_start.asc().nulls_last(), Task.created_at.asc())
)
).scalars().all()
relations = (
await db.execute(
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(TaskRelation.project_id == project.id)
)
).scalars().all()
# 2. Run CPM scheduling analysis
sched = calculate_schedule(tasks, relations, project.planned_start)
sched_info = sched["tasks_schedule"]
crit_set = set(sched["critical_path"])
# 3. Format tasks
formatted_tasks = []
for t in tasks:
t_sched = sched_info.get(t.id, {})
start_dt = t_sched.get("planned_start") or t.planned_start
end_dt = t_sched.get("planned_end") or t.planned_end
formatted_tasks.append({
"id": str(t.id),
"title": t.title,
"subject": t.title,
"parent_id": str(t.parent_id) if t.parent_id else None,
"status": t.status,
"priority": t.priority,
"planned_start": _iso(start_dt),
"planned_end": _iso(end_dt),
"planned_start_jalali": to_jalali_str(start_dt),
"planned_end_jalali": to_jalali_str(end_dt),
"duration": t_sched.get("duration") or t.duration or 1,
"progress": t.progress or 0,
"is_critical": t.id in crit_set,
"float_days": t_sched.get("float_days", 0),
"qc_status": t.qc_status or "none",
"assignee_name": t.assignee_name or (t.assignee.display_name if getattr(t, "assignee", None) else None),
})
return {
"data": {
"project": {
"id": str(project.id),
"identifier": str(project.id),
"name": project.name,
"planned_start": _iso(project.planned_start),
"planned_end": _iso(project.planned_end),
"planned_start_jalali": to_jalali_str(project.planned_start),
"planned_end_jalali": to_jalali_str(project.planned_end),
"progress": project.progress,
},
"tasks": formatted_tasks,
"relations": [task_relation_dict(r) for r in relations],
"critical_path": [str(tid) for tid in sched["critical_path"]],
"project_finish": _iso(sched.get("project_finish")),
"project_finish_jalali": to_jalali_str(sched.get("project_finish")),
}
}
@router.post("/{id}/reschedule")
async def reschedule_project(
id: str,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Trigger full topological forward-pass recalculation and persist cascaded dates."""
project = await _resolve_project(db, ctx, id)
tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
sched = calculate_schedule(tasks, relations, project.planned_start)
sched_info = sched["tasks_schedule"]
updated_count = 0
for t in tasks:
if t.id in sched_info:
info = sched_info[t.id]
t.planned_start = info["planned_start"]
t.planned_end = info["planned_end"]
t.duration = info["duration"]
updated_count += 1
# Roll up summary tasks
rollups = rollup_parent_tasks(tasks)
for pid, rdata in rollups.items():
parent_task = await db.get(Task, pid)
if parent_task:
if rdata["planned_start"]:
parent_task.planned_start = rdata["planned_start"]
if rdata["planned_end"]:
parent_task.planned_end = rdata["planned_end"]
if rdata.get("duration"):
parent_task.duration = rdata["duration"]
await sync_project_lifecycle_dates(db, project.id)
await db.commit()
return {"data": {"recalculated": True, "tasks_updated": updated_count}}
+729
View File
@@ -0,0 +1,729 @@
"""QC module.
Two clearly separated halves:
**تعریف (Definition)** — what must eventually be checked
* `QCTemplate` + `QCQuestion` : global, reusable, editable
* `TaskQCDefinition`/`TaskQCQuestion`: a task-specific COPY the user customizes
**اجرا (Execution)** — the actual evaluation during the project
* `TaskQCResult` : append-only answers (pass / fail / na) + comment
* `Defect` : problems raised on a task, with corrective action
Applying a template to a task deep-copies its questions, so customizing a task
never mutates the global template.
Access split (app/authz.py):
* `qc.define` — templates + the per-task QC structure: project manager only
* `qc.execute` — recording answers and defects, on assigned projects
* `qc.view` — reading status/readiness, on assigned projects
"""
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import (
AuthContext,
Perm,
require_permission,
scoped_task,
)
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import (
Defect,
QCAnswer,
QCQuestion,
QCQuestionPhase,
QCTemplate,
Task,
TaskQCDefinition,
TaskQCQuestion,
TaskQCResult,
)
from app.qc_service import (
PRE,
build_state,
definition_dict,
load_definitions,
question_dict,
recompute_task_qc_status,
state_dict,
task_qc_state,
)
from app.serializers import _iso
router = APIRouter(prefix="/qc", tags=["qc"])
define_qc = require_permission(Perm.qc_define)
execute_qc = require_permission(Perm.qc_execute)
view_qc = require_permission(Perm.qc_view)
PHASE_LABELS = {PRE: "پیش از اجرا", "during": "هنگام اجرا", "after": "پس از اجرا"}
def _phase(value: str) -> QCQuestionPhase:
try:
return QCQuestionPhase(value)
except ValueError as exc:
raise ValidationError("مرحله باید pre، during یا after باشد", field="phase") from exc
def _answer(value: str) -> QCAnswer:
try:
return QCAnswer(value)
except ValueError as exc:
raise ValidationError("پاسخ باید pass، fail یا na باشد", field="answer") from exc
@dataclass
class QCTaskRef:
"""A task as the QC module sees it."""
id: uuid.UUID
subject: str
progress: int
status: str
@property
def key(self):
return self.id
async def _qc_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> QCTaskRef:
"""Resolve the task a QC screen is about, enforcing the caller's scope."""
native = await scoped_task(db, ctx, task_ref)
return QCTaskRef(
id=native.id,
subject=native.title or "",
progress=native.progress or 0,
status=native.status or "",
)
async def _ensure_definition_scope(db: AsyncSession, ctx: AuthContext, definition: TaskQCDefinition) -> None:
native = await db.get(Task, definition.task_id)
if native is not None:
ctx.ensure_project(native.project_id)
return
raise NotFoundError("تعریف QC یافت نشد")
async def _definition(db: AsyncSession, definition_id: uuid.UUID) -> TaskQCDefinition:
row = await db.scalar(
select(TaskQCDefinition)
.options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results))
.where(TaskQCDefinition.id == definition_id)
)
if row is None:
raise NotFoundError("تعریف QC یافت نشد")
return row
async def _scoped_definition(db: AsyncSession, ctx: AuthContext, definition_id: uuid.UUID) -> TaskQCDefinition:
definition = await _definition(db, definition_id)
await _ensure_definition_scope(db, ctx, definition)
return definition
async def _question(db: AsyncSession, question_id: uuid.UUID) -> TaskQCQuestion:
row = await db.scalar(
select(TaskQCQuestion)
.options(selectinload(TaskQCQuestion.results))
.where(TaskQCQuestion.id == question_id)
)
if row is None:
raise NotFoundError("سؤال QC یافت نشد")
return row
async def _scoped_definition_question(
db: AsyncSession, ctx: AuthContext, question_id: uuid.UUID
) -> tuple[TaskQCQuestion, TaskQCDefinition]:
question = await _question(db, question_id)
definition = await db.get(TaskQCDefinition, question.definition_id)
if definition is None:
raise NotFoundError("تعریف QC یافت نشد")
await _ensure_definition_scope(db, ctx, definition)
return question, definition
# =============================================================================
# Global QC templates
# =============================================================================
def _template_dict(template: QCTemplate) -> dict:
return {
"id": str(template.id),
"name": template.name,
"activity_type": template.activity_type,
"description": template.description,
"questions": [
{
"id": str(q.id),
"phase": q.phase.value,
"text": q.text,
"sort_order": q.sort_order,
"required": q.required,
}
for q in template.questions
],
}
async def _template(db: AsyncSession, template_id: uuid.UUID) -> QCTemplate:
row = await db.scalar(
select(QCTemplate)
.options(selectinload(QCTemplate.questions))
.where(QCTemplate.id == template_id)
)
if row is None:
raise NotFoundError("قالب QC یافت نشد")
return row
@router.get("/templates")
async def list_templates(activity_type: str | None = None, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
stmt = select(QCTemplate).options(selectinload(QCTemplate.questions)).order_by(QCTemplate.name)
if activity_type:
stmt = stmt.where(QCTemplate.activity_type == activity_type)
templates = (await db.execute(stmt)).scalars().all()
return {"data": [_template_dict(t) for t in templates]}
@router.get("/templates/{template_id}")
async def get_template(template_id: uuid.UUID, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
return {"data": _template_dict(await _template(db, template_id))}
class QuestionIn(BaseModel):
phase: str
text: str
required: bool = True
sort_order: int = 0
class TemplateIn(BaseModel):
name: str
activity_type: str = ""
description: str = ""
questions: list[QuestionIn] = []
@field_validator("name")
@classmethod
def _name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("نام قالب الزامی است")
return v.strip()
@router.post("/templates")
async def create_template(payload: TemplateIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
template = QCTemplate(
name=payload.name,
activity_type=payload.activity_type,
description=payload.description,
)
db.add(template)
await db.flush()
for index, q in enumerate(payload.questions):
db.add(
QCQuestion(
template_id=template.id,
phase=_phase(q.phase),
text=q.text.strip(),
sort_order=q.sort_order if q.sort_order else index,
required=q.required,
)
)
await db.commit()
return {"data": _template_dict(await _template(db, template.id))}
class TemplatePatch(BaseModel):
name: str | None = None
activity_type: str | None = None
description: str | None = None
@router.patch("/templates/{template_id}")
async def patch_template(
template_id: uuid.UUID, payload: TemplatePatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
template = await _template(db, template_id)
if payload.name is not None:
if not payload.name.strip():
raise ValidationError("نام قالب نمی‌تواند خالی باشد", field="name")
template.name = payload.name.strip()
if payload.activity_type is not None:
template.activity_type = payload.activity_type
if payload.description is not None:
template.description = payload.description
await db.commit()
return {"data": _template_dict(template)}
@router.delete("/templates/{template_id}")
async def delete_template(template_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
template = await _template(db, template_id)
await db.delete(template)
await db.commit()
return {"data": {"deleted": True}}
@router.post("/templates/{template_id}/questions")
async def add_template_question(
template_id: uuid.UUID, payload: QuestionIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
template = await _template(db, template_id)
question = QCQuestion(
template_id=template.id,
phase=_phase(payload.phase),
text=payload.text.strip(),
sort_order=payload.sort_order,
required=payload.required,
)
db.add(question)
await db.commit()
return {"data": _template_dict(await _template(db, template_id))}
class QuestionPatch(BaseModel):
text: str | None = None
phase: str | None = None
required: bool | None = None
sort_order: int | None = None
@router.patch("/questions/{question_id}")
async def patch_question(
question_id: uuid.UUID, payload: QuestionPatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
question = await db.get(QCQuestion, question_id)
if question is None:
raise NotFoundError("سؤال QC یافت نشد")
if payload.text is not None:
if not payload.text.strip():
raise ValidationError("متن سؤال نمی‌تواند خالی باشد", field="text")
question.text = payload.text.strip()
if payload.phase is not None:
question.phase = _phase(payload.phase)
if payload.required is not None:
question.required = payload.required
if payload.sort_order is not None:
question.sort_order = payload.sort_order
await db.commit()
return {"data": {"id": str(question.id)}}
@router.delete("/questions/{question_id}")
async def delete_question(question_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
question = await db.get(QCQuestion, question_id)
if question is None:
raise NotFoundError("سؤال QC یافت نشد")
await db.delete(question)
await db.commit()
return {"data": {"deleted": True}}
# =============================================================================
# Task QC Definitions
# =============================================================================
class DefinitionIn(BaseModel):
name: str = ""
template_id: str | None = None
@router.get("/tasks/{task_ref}")
async def task_qc(
task_ref: str, ctx: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)
):
"""Everything the QC screen needs: definitions + questions + results + readiness."""
task = await _qc_task(db, ctx, task_ref)
definitions = await load_definitions(db, task.key)
state = build_state(definitions)
return {
"data": {
"task": {
"id": str(task.id),
"subject": task.subject,
"percentage_done": task.progress,
"status": task.status,
},
"definitions": [definition_dict(d, state) for d in definitions],
"readiness": state_dict(state, progress=task.progress),
}
}
@router.post("/tasks/{task_ref}/definition")
async def create_definition(
task_ref: str,
payload: DefinitionIn,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
task = await _qc_task(db, ctx, task_ref)
template: QCTemplate | None = None
if payload.template_id:
try:
template_uuid = uuid.UUID(payload.template_id)
except ValueError as exc:
raise ValidationError("شناسه قالب نامعتبر است", field="template_id") from exc
template = await _template(db, template_uuid)
definition = TaskQCDefinition(
task_id=task.id,
name=payload.name.strip() or (template.name if template else "QC تسک"),
source_template_id=template.id if template else None,
)
db.add(definition)
await db.flush()
if template:
for index, question in enumerate(template.questions):
db.add(
TaskQCQuestion(
definition_id=definition.id,
phase=question.phase,
text=question.text,
sort_order=index,
required=question.required,
)
)
await db.commit()
await recompute_task_qc_status(db, task.key)
await db.commit()
return {"data": {"id": str(definition.id)}}
class DefinitionPatch(BaseModel):
name: str
@router.patch("/definitions/{definition_id}")
async def patch_definition(
definition_id: uuid.UUID,
payload: DefinitionPatch,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
definition = await _scoped_definition(db, ctx, definition_id)
if not payload.name.strip():
raise ValidationError("نام تعریف الزامی است", field="name")
definition.name = payload.name.strip()
await db.commit()
return {"data": {"id": str(definition.id), "name": definition.name}}
@router.delete("/definitions/{definition_id}")
async def delete_definition(
definition_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
definition = await _scoped_definition(db, ctx, definition_id)
task_key = definition.task_id
await db.delete(definition)
await db.commit()
await recompute_task_qc_status(db, task_key)
await db.commit()
return {"data": {"deleted": True}}
class TaskQuestionIn(BaseModel):
text: str
phase: str
required: bool = True
sort_order: int = 0
@router.post("/definitions/{definition_id}/questions")
async def add_task_question(
definition_id: uuid.UUID,
payload: TaskQuestionIn,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
definition = await _scoped_definition(db, ctx, definition_id)
question = TaskQCQuestion(
definition_id=definition.id,
phase=_phase(payload.phase),
text=payload.text.strip(),
sort_order=payload.sort_order,
required=payload.required,
)
db.add(question)
definition.updated_at = datetime.now(timezone.utc)
await db.commit()
await recompute_task_qc_status(db, definition.task_id)
await db.commit()
return {"data": {"id": str(question.id)}}
@router.patch("/task-questions/{question_id}")
async def patch_task_question(
question_id: uuid.UUID,
payload: QuestionPatch,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
values = payload.model_dump(exclude_unset=True)
if "text" in values:
if not values["text"].strip():
raise ValidationError("متن سؤال نمی‌تواند خالی باشد", field="text")
question.text = values["text"].strip()
values.pop("text")
if "phase" in values:
question.phase = _phase(values.pop("phase"))
for key, value in values.items():
setattr(question, key, value)
definition.updated_at = datetime.now(timezone.utc)
await db.commit()
await recompute_task_qc_status(db, definition.task_id)
await db.commit()
return {"data": {"id": str(question.id)}}
@router.delete("/task-questions/{question_id}")
async def delete_task_question(
question_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
task_key = definition.task_id
await db.delete(question)
await db.commit()
await recompute_task_qc_status(db, task_key)
await db.commit()
return {"data": {"deleted": True}}
# =============================================================================
# Execution / evaluation
# =============================================================================
class ResultIn(BaseModel):
answer: str | None = None
comment: str = ""
photo_id: str | None = None
@router.put("/task-questions/{question_id}/result")
async def evaluate_question(
question_id: uuid.UUID,
payload: ResultIn,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
# اگر پاسخ خالی یا null باشد، انتخاب قبلی لغو شده و سؤال به حالت بدون پاسخ برمی‌گردد
if not payload.answer or payload.answer.strip().lower() in ("none", "null", "cancel", "clear", "pending"):
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
user = ctx.user
answer = _answer(payload.answer)
existing_latest = await db.scalar(
select(TaskQCResult)
.where(TaskQCResult.question_id == question.id)
.order_by(TaskQCResult.evaluated_at.desc(), TaskQCResult.id.desc())
)
photo_path = payload.photo_id
if photo_path is None and existing_latest and existing_latest.answer == answer:
photo_path = existing_latest.photo_path
elif photo_path == "":
photo_path = None
db.add(
TaskQCResult(
question_id=question.id,
answer=answer,
comment=payload.comment.strip(),
photo_path=photo_path,
evaluated_by=user.display_name or user.username,
)
)
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": answer.value,
"photo_url": f"/api/v1/files/{photo_path}" if photo_path else None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
@router.delete("/task-questions/{question_id}/result")
async def delete_question_result(
question_id: uuid.UUID,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
async def _progress(db: AsyncSession, task_id: uuid.UUID) -> int:
task = await db.scalar(select(Task).where(Task.id == task_id))
return (task.progress or 0) if task else 0
# =============================================================================
# Defects
# =============================================================================
def _defect_dict(defect: Defect) -> dict:
return {
"id": str(defect.id),
"task_id": str(defect.task_id),
"question_id": str(defect.question_id) if defect.question_id else None,
"title": defect.title,
"description": defect.description,
"severity": defect.severity,
"status": defect.status,
"corrective_action": defect.corrective_action,
"resolution_notes": defect.resolution_notes,
"photo_url": f"/api/v1/files/{defect.photo_path}" if defect.photo_path else None,
"created_at": _iso(defect.created_at),
"resolved_at": _iso(defect.resolved_at),
}
class DefectIn(BaseModel):
task_ref: str
question_id: str | None = None
title: str
description: str = ""
severity: str = "medium"
corrective_action: str = ""
photo_id: str | None = None
@field_validator("title")
@classmethod
def _title(cls, v: str) -> str:
if not v.strip():
raise ValueError("عنوان عیب الزامی است")
return v.strip()
@router.post("/defects")
async def create_defect(
payload: DefectIn, ctx: AuthContext = Depends(execute_qc), db: AsyncSession = Depends(get_db)
):
task = await _qc_task(db, ctx, payload.task_ref)
question_id = None
if payload.question_id:
try:
question_id = uuid.UUID(payload.question_id)
except ValueError as exc:
raise ValidationError("شناسه سؤال نامعتبر است", field="question_id") from exc
defect = Defect(
task_id=task.id,
question_id=question_id,
title=payload.title,
description=payload.description,
severity=payload.severity if payload.severity in ("low", "medium", "high") else "medium",
corrective_action=payload.corrective_action,
photo_path=payload.photo_id,
)
db.add(defect)
await db.commit()
return {"data": _defect_dict(defect)}
class ResolveIn(BaseModel):
resolution_notes: str = ""
@router.post("/defects/{defect_id}/resolve")
async def resolve_defect(
defect_id: uuid.UUID,
payload: ResolveIn,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
defect = await db.get(Defect, defect_id)
if defect is None:
raise NotFoundError("عیب یافت نشد")
await scoped_task(db, ctx, defect.task_id)
if defect.status == "resolved":
raise ValidationError("این عیب قبلاً رفع شده است")
defect.status = "resolved"
defect.resolution_notes = payload.resolution_notes.strip()
defect.resolved_at = datetime.now(timezone.utc)
await db.commit()
return {"data": _defect_dict(defect)}
@router.get("/defects")
async def list_defects(
task_id: uuid.UUID | None = None,
status: str | None = None,
ctx: AuthContext = Depends(view_qc),
db: AsyncSession = Depends(get_db),
):
query = select(Defect).join(Task, Task.id == Defect.task_id)
if task_id:
query = query.where(Defect.task_id == task_id)
if status:
query = query.where(Defect.status == status)
query = ctx.scope(query, Task.project_id)
query = query.order_by(Defect.created_at.desc()).limit(150)
rows = (await db.execute(query)).scalars().all()
items = []
for d in rows:
task = await db.get(Task, d.task_id)
di = _defect_dict(d)
di["task_title"] = task.title if task else ""
items.append(di)
return {"data": items}
async def defects_for_task(db: AsyncSession, task_id: uuid.UUID) -> list[Defect]:
rows = (
await db.execute(select(Defect).where(Defect.task_id == task_id).order_by(Defect.created_at.desc()))
).scalars().all()
return list(rows)
+834
View File
@@ -0,0 +1,834 @@
"""Tasks: Native PM Construction Platform Tasks & Activities.
Supports:
- Hierarchical tasks (Project -> Phase -> Activity -> Subtask) via self-referencing `parent_id`.
- Dates, duration, status, priorities, progress.
- Full dependency relations & scheduling.
"""
from __future__ import annotations
import uuid
from datetime import date, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import AuthContext, Perm, require_permission
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import Project, ProjectMember, Task, TaskPriority, TaskProgressLog, TaskRelation, TaskStatus
from app.qc_service import build_state, definition_dict, load_definitions, state_dict
from app.routers.qc import _defect_dict, defects_for_task
from app.serializers import _iso, native_task_dict, task_relation_dict
from app.utils.date_service import date_service
from app.scheduling_service import sync_project_lifecycle_dates
router = APIRouter(prefix="/tasks", tags=["tasks"])
view_tasks = require_permission(Perm.tasks_view)
manage_tasks = require_permission(Perm.tasks_create)
update_tasks = require_permission(Perm.tasks_update)
progress_tasks = require_permission(Perm.tasks_progress)
class HumanResourceItem(BaseModel):
role: str = Field(..., min_length=1)
count: int = Field(1, ge=1)
class MaterialItem(BaseModel):
name: str = Field(..., min_length=1)
unit: str = Field("", max_length=64)
specifications: list[str] = Field(default_factory=list)
count: float = Field(1.0, ge=0)
class TaskCreateSchema(BaseModel):
project_id: str
parent_id: str | None = None
title: str = Field(..., min_length=1, max_length=512)
description: str = ""
status: str = TaskStatus.TODO.value
priority: str = TaskPriority.MEDIUM.value
assignee_id: str | None = None
planned_start: str | None = None
planned_end: str | None = None
duration: int | None = None
human_resources: list[HumanResourceItem] = Field(default_factory=list)
materials: list[MaterialItem] = Field(default_factory=list)
class TaskUpdateSchema(BaseModel):
title: str | None = None
description: str | None = None
status: str | None = None
priority: str | None = None
assignee: str | None = None
assignee_id: str | None = None
parent_id: str | None = None
planned_start: str | None = None
planned_end: str | None = None
duration: int | None = None
duration_days: int | None = None
human_resources: list[HumanResourceItem] | None = None
materials: list[MaterialItem] | None = None
class HumanResourceConsumption(BaseModel):
role: str = ""
planned_count: int | None = None
actual_count: int = 1
note: str = ""
class MaterialConsumption(BaseModel):
name: str = ""
unit: str = ""
amount: float = 0
allocated_amount: float | None = None
previous_consumed: float | None = None
total_consumed: float | None = None
remaining_amount: float | None = None
is_over_allocated: bool = False
over_allocated_amount: float = 0.0
note: str = ""
class TaskProgressSchema(BaseModel):
percentage: int = Field(..., ge=0, le=100)
human_resources_used: list[HumanResourceConsumption] = Field(default_factory=list)
materials_used: list[MaterialConsumption] = Field(default_factory=list)
description: str = ""
class TaskDatesSchema(BaseModel):
start_date: str | None = None
due_date: str | None = None
async def _resolve_task(db: AsyncSession, ctx: AuthContext, task_id_or_op: str | int | uuid.UUID) -> Task:
"""Find a native task by UUID, enforcing the caller's scope."""
query = (
select(Task)
.options(
selectinload(Task.project),
selectinload(Task.assignee),
selectinload(Task.children),
selectinload(Task.parent),
selectinload(Task.progress_logs),
)
)
try:
val_uuid = uuid.UUID(str(task_id_or_op))
stmt = query.where(Task.id == val_uuid)
except ValueError:
raise NotFoundError("تسک یافت نشد")
task = (await db.execute(stmt)).scalar_one_or_none()
if not task:
raise NotFoundError("تسک یافت نشد")
ctx.ensure_native_project(task.project_id)
return task
async def _validate_task_dates_and_hierarchy(
db: AsyncSession,
task_id: uuid.UUID | None,
planned_start: date | None,
planned_end: date | None,
duration: int | None,
parent_id: uuid.UUID | None,
actual_start: date | None = None,
actual_end: date | None = None,
) -> None:
# 0. اجباری بودن تاریخ شروع و تاریخ پایان
if not planned_start:
raise ValidationError("تاریخ شروع وظیفه الزامی است", field="planned_start")
if not planned_end:
raise ValidationError("تاریخ پایان وظیفه الزامی است", field="planned_end")
# 1. Start date must not be later than end date
if planned_start and planned_end and planned_start > planned_end:
raise ValidationError(
f"تاریخ شروع یک وظیفه ({date_service.to_jalali(planned_start)}) نمی‌تواند بعد از تاریخ اتمام آن ({date_service.to_jalali(planned_end)}) باشد",
field="planned_start",
)
if actual_start and actual_end and actual_start > actual_end:
raise ValidationError(
f"تاریخ شروع واقعی ({date_service.to_jalali(actual_start)}) نمی‌تواند بعد از تاریخ پایان واقعی ({date_service.to_jalali(actual_end)}) باشد",
field="actual_start",
)
# 2. Parent vs Child constraint: Parent must envelop child
if parent_id:
parent = await db.scalar(select(Task).where(Task.id == parent_id))
if parent:
if parent.planned_start and planned_start and planned_start < parent.planned_start:
raise ValidationError(
f"تاریخ شروع وظیفه فرزند ({date_service.to_jalali(planned_start)}) نمی‌تواند قبل از تاریخ شروع وظیفه والد ({date_service.to_jalali(parent.planned_start)}) باشد",
field="planned_start",
)
if parent.planned_end and planned_end and planned_end > parent.planned_end:
raise ValidationError(
f"تاریخ پایان وظیفه فرزند ({date_service.to_jalali(planned_end)}) نمی‌تواند بعد از تاریخ پایان وظیفه والد ({date_service.to_jalali(parent.planned_end)}) باشد",
field="planned_end",
)
if parent.duration and duration and duration > parent.duration:
raise ValidationError(
f"مدت زمان وظیفه والد ({parent.duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند ({duration} روز) باشد",
field="duration",
)
# 3. If this task is a parent, its time span/duration must be >= all its children
if task_id:
children_stmt = select(Task).where(Task.parent_id == task_id)
children = (await db.execute(children_stmt)).scalars().all()
for c in children:
if planned_start and c.planned_start and planned_start > c.planned_start:
raise ValidationError(
f"تاریخ شروع وظیفه والد ({date_service.to_jalali(planned_start)}) نمی‌تواند بعد از تاریخ شروع وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_start)}) باشد",
field="planned_start",
)
if planned_end and c.planned_end and planned_end < c.planned_end:
raise ValidationError(
f"تاریخ پایان وظیفه والد ({date_service.to_jalali(planned_end)}) نمی‌تواند قبل از تاریخ پایان وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_end)}) باشد",
field="planned_end",
)
if duration and c.duration and duration < c.duration:
raise ValidationError(
f"مدت زمان وظیفه والد ({duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند «{c.title}» ({c.duration} روز) باشد",
field="duration",
)
@router.get("")
async def list_tasks(
project_id: str | None = None,
parent_id: str | None = None,
search: str | None = None,
status: str | None = None,
qc_status: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(300, ge=1, le=1000),
ctx: AuthContext = Depends(view_tasks),
db: AsyncSession = Depends(get_db),
):
"""List native tasks with flexible filtering."""
stmt = (
select(Task)
.options(
selectinload(Task.project),
selectinload(Task.assignee),
selectinload(Task.children),
)
.order_by(Task.created_at.asc())
)
# Filter by project
if project_id is not None:
try:
p_uuid = uuid.UUID(project_id)
stmt = stmt.where(Task.project_id == p_uuid)
except ValueError:
pass
# Project scoping for non-managers — native projects are keyed by UUID
stmt = ctx.scope_native(stmt, Task.project_id)
if parent_id is not None:
if parent_id.lower() in ("null", "root", "none"):
stmt = stmt.where(Task.parent_id.is_(None))
else:
stmt = stmt.where(Task.parent_id == uuid.UUID(parent_id))
if search:
stmt = stmt.where(Task.title.ilike(f"%{search.strip()}%"))
if status:
stmt = stmt.where(Task.status == status.upper())
if qc_status:
stmt = stmt.where(Task.qc_status == qc_status)
total = (await db.scalar(select(func.count()).select_from(stmt.subquery()))) or 0
tasks = (await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars().all()
# Count children in memory from loaded tasks to prevent lazy-load I/O
child_counts: dict[uuid.UUID, int] = {}
for t in tasks:
if t.parent_id:
child_counts[t.parent_id] = child_counts.get(t.parent_id, 0) + 1
results = []
for t in tasks:
p_name = t.project.name if t.project else None
c_count = child_counts.get(t.id, 0)
d = native_task_dict(t, project_name=p_name, children_count=c_count)
d["subject"] = t.title
d["percentage_done"] = t.progress
d["start_date"] = _iso(t.planned_start)
d["due_date"] = _iso(t.planned_end)
d["is_closed"] = t.status == TaskStatus.COMPLETED.value
results.append(d)
return {"data": results, "meta": {"total": total}}
@router.post("")
async def create_task(
payload: TaskCreateSchema,
ctx: AuthContext = Depends(manage_tasks),
db: AsyncSession = Depends(get_db),
):
"""Create a new native task / subtask."""
# Resolve project
try:
p_uuid = uuid.UUID(payload.project_id)
project = await db.scalar(select(Project).where(Project.id == p_uuid))
except ValueError:
project = None
if not project:
raise NotFoundError("پروژه یافت نشد")
# Resolve parent task if specified
parent_uuid = None
if payload.parent_id:
parent_task = await _resolve_task(db, ctx, payload.parent_id)
parent_uuid = parent_task.id
p_start = date_service.parse(payload.planned_start)
p_end = date_service.parse(payload.planned_end)
duration = payload.duration or 0
if p_start and p_end:
duration = date_service.calculate_duration(p_start, p_end)
elif p_start and duration:
p_end = date_service.compute_end_date(p_start, duration)
elif p_end and duration:
p_start = p_end - timedelta(days=duration)
await _validate_task_dates_and_hierarchy(
db=db,
task_id=None,
planned_start=p_start,
planned_end=p_end,
duration=duration,
parent_id=parent_uuid,
)
assignee_uuid = None
assignee_name = None
if payload.assignee_id:
try:
assignee_uuid = uuid.UUID(payload.assignee_id)
except ValueError:
assignee_name = payload.assignee_id
task = Task(
project_id=project.id,
parent_id=parent_uuid,
title=payload.title.strip(),
description=payload.description.strip(),
status=payload.status.upper(),
priority=payload.priority.lower(),
assignee_id=assignee_uuid,
assignee_name=assignee_name,
planned_start=p_start,
planned_end=p_end,
duration=duration,
progress=0,
human_resources=[hr.model_dump() for hr in payload.human_resources],
materials=[m.model_dump() for m in payload.materials],
)
db.add(task)
await db.flush()
await sync_project_lifecycle_dates(db, project.id)
await db.commit()
await db.refresh(task)
d = native_task_dict(task, project_name=project.name)
d["subject"] = task.title
d["percentage_done"] = task.progress
return {"data": d}
@router.get("/{id}")
async def get_task_detail(
id: str,
ctx: AuthContext = Depends(view_tasks),
db: AsyncSession = Depends(get_db),
):
"""Get full task details, including hierarchy, QC state, and defects."""
task = await _resolve_task(db, ctx, id)
# QC definitions and execution state
definitions = await load_definitions(db, task.id)
state = build_state(definitions)
# Defects
defects = await defects_for_task(db, task.id)
children_data = []
for c in task.children:
c_dict = native_task_dict(c, project_name=task.project.name if task.project else None)
c_dict["subject"] = c.title
c_dict["percentage_done"] = c.progress
children_data.append(c_dict)
data = native_task_dict(
task,
project_name=task.project.name if task.project else None,
children_count=len(task.children),
)
data["subject"] = task.title
data["percentage_done"] = task.progress
data["start_date"] = _iso(task.planned_start)
data["due_date"] = _iso(task.planned_end)
data["start_date_jalali"] = date_service.to_jalali(task.planned_start)
data["due_date_jalali"] = date_service.to_jalali(task.planned_end)
data["is_closed"] = task.status == TaskStatus.COMPLETED.value
# Load task relations (predecessors and successors)
rel_stmt = (
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(
or_(
TaskRelation.from_task_id == task.id,
TaskRelation.to_task_id == task.id,
)
)
)
task_rels = (await db.execute(rel_stmt)).scalars().all()
incoming = []
outgoing = []
for r in task_rels:
rd = task_relation_dict(r)
if r.to_task_id == task.id:
# incoming: predecessor (from_task) -> current task
incoming.append({
**rd,
"id": str(r.id),
"op_wp_id": str(r.from_task_id),
"task_id": str(r.from_task_id),
"subject": r.from_task.title if r.from_task else "",
"relation_type": r.relation_type,
"lag_days": r.lag_days,
})
if r.from_task_id == task.id:
# outgoing: current task -> successor (to_task)
outgoing.append({
**rd,
"id": str(r.id),
"op_wp_id": str(r.to_task_id),
"task_id": str(r.to_task_id),
"subject": r.to_task.title if r.to_task else "",
"relation_type": r.relation_type,
"lag_days": r.lag_days,
})
data["relations"] = {"blocks": outgoing, "blocked_by": incoming}
data["all_relations"] = [task_relation_dict(r) for r in task_rels]
data["children"] = children_data
data["parent"] = (
{"id": str(task.parent.id), "title": task.parent.title} if task.parent else None
)
data["qc"] = state_dict(state, progress=task.progress)
data["qc_definitions"] = [definition_dict(d, state) for d in definitions]
data["defects"] = [_defect_dict(d) for d in defects]
data["open_defects"] = sum(1 for d in defects if d.status != "resolved")
return {"data": data}
@router.patch("/{id}")
async def update_task(
id: str,
payload: TaskUpdateSchema,
ctx: AuthContext = Depends(update_tasks),
db: AsyncSession = Depends(get_db),
):
"""Update task title, description, status, priority, or dates."""
task = await _resolve_task(db, ctx, id)
if payload.title is not None:
task.title = payload.title.strip()
if payload.description is not None:
task.description = payload.description.strip()
if payload.status is not None:
task.status = payload.status.upper()
if payload.priority is not None:
task.priority = payload.priority.lower()
if payload.assignee is not None:
task.assignee_name = payload.assignee.strip() or None
if payload.assignee_id is not None:
try:
task.assignee_id = uuid.UUID(payload.assignee_id)
except ValueError:
task.assignee_name = payload.assignee_id
if payload.parent_id is not None:
clean_p = payload.parent_id.strip().lower()
if not clean_p or clean_p in ("none", "null", "root"):
task.parent_id = None
else:
parent = await _resolve_task(db, ctx, payload.parent_id)
if parent.id == task.id:
raise ValidationError("تسک نمی‌تواند والد خودش باشد")
# Prevent cycle: ensure task is not in ancestors of parent
check = parent
while check:
if check.id == task.id:
raise ValidationError("نمی‌توان یک زیرتسک را به عنوان والد انتخاب کرد (وابستگی چرخه‌ای)")
if check.parent_id:
check = await db.scalar(select(Task).where(Task.id == check.parent_id))
else:
break
task.parent_id = parent.id
p_start = task.planned_start
p_end = task.planned_end
a_start = task.actual_start
a_end = task.actual_end
dur = task.duration
if payload.planned_start is not None:
p_start = date_service.parse(payload.planned_start)
if payload.planned_end is not None:
p_end = date_service.parse(payload.planned_end)
dur_input = payload.duration if payload.duration is not None else payload.duration_days
if dur_input is not None:
dur = max(0, dur_input)
if p_start:
p_end = date_service.compute_end_date(p_start, dur)
elif p_start and p_end:
dur = date_service.calculate_duration(p_start, p_end)
await _validate_task_dates_and_hierarchy(
db=db,
task_id=task.id,
planned_start=p_start,
planned_end=p_end,
duration=dur,
parent_id=task.parent_id,
actual_start=a_start,
actual_end=a_end,
)
task.planned_start = p_start
task.planned_end = p_end
task.duration = dur
if payload.human_resources is not None:
task.human_resources = [hr.model_dump() for hr in payload.human_resources]
if payload.materials is not None:
task.materials = [m.model_dump() for m in payload.materials]
await sync_project_lifecycle_dates(db, task.project_id)
await db.commit()
task = await _resolve_task(db, ctx, task.id)
return {"data": native_task_dict(task, project_name=task.project.name if task.project else None)}
@router.patch("/{id}/progress")
async def update_task_progress(
id: str,
payload: TaskProgressSchema,
ctx: AuthContext = Depends(progress_tasks),
db: AsyncSession = Depends(get_db),
):
"""Update task progress (0-100%) with strict server-side QC validation rules."""
task = await _resolve_task(db, ctx, id)
percentage = payload.percentage
# 1. Evaluate QC business rules
definitions = await load_definitions(db, task.id)
state = build_state(definitions)
# Server-side Invariant Rule 1: pre-QC must be satisfied if progress > 0
if percentage > 0 and not state.can_start:
unresolved = state.pre.required_unresolved
msg = (
f"بازرسی پیش از اجرا تأیید نشده است ({unresolved} سؤال باقی‌مانده)"
if unresolved > 0
else "بازرسی پیش از اجرا مردود شده است و نیاز به رفع و بازرسی مجدد دارد"
)
raise ValidationError(msg, field="percentage")
# Server-side Invariant Rule 2: all required QC must be satisfied if progress >= 100
if percentage >= 100 and not state.can_complete:
unresolved = state.all_.required_unresolved
failed = state.all_.failed
raise ValidationError(
f"تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست ({unresolved} سؤال باقی‌مانده، {failed} مورد مردود)",
field="percentage",
)
# 2. Update task progress and lifecycle dates
progress_before = task.progress or 0
task.progress = percentage
if percentage > 0:
if not task.actual_start:
task.actual_start = date_service.today()
if task.status == TaskStatus.TODO.value:
task.status = TaskStatus.IN_PROGRESS.value
else:
# percentage == 0: reset actual dates
task.actual_start = None
task.actual_end = None
task.status = TaskStatus.TODO.value
if percentage >= 100:
task.actual_end = date_service.today()
task.status = TaskStatus.COMPLETED.value
elif 0 < percentage < 100:
task.actual_end = None
if task.status == TaskStatus.COMPLETED.value:
task.status = TaskStatus.IN_PROGRESS.value
# 3. Log consumption of human resources and materials
hr_list = [h.model_dump() for h in payload.human_resources_used if h.role.strip()]
# Build allocation lookup from task.materials
alloc_map = {}
for item in (task.materials or []):
if isinstance(item, dict):
m_name = (item.get("name") or "").strip().lower()
if m_name:
alloc_map[m_name] = item
# Calculate previously consumed amounts from prior logs
past_consumed_map: dict[str, float] = {}
for plog in (task.progress_logs or []):
for pmat in (plog.materials_used or []):
if isinstance(pmat, dict):
p_name = (pmat.get("name") or "").strip().lower()
if p_name:
past_consumed_map[p_name] = past_consumed_map.get(p_name, 0.0) + float(pmat.get("amount") or 0.0)
mat_list = []
for m in payload.materials_used:
name = m.name.strip()
if not name:
continue
key = name.lower()
matched = alloc_map.get(key)
# Base allocation: explicitly passed or from task.materials
if m.allocated_amount is not None:
alloc_amt = float(m.allocated_amount)
elif matched:
alloc_amt = float(matched.get("count") or 0.0)
else:
alloc_amt = 0.0
unit = m.unit.strip() or (matched.get("unit", "") if matched else "")
amount = float(m.amount or 0.0)
prev_consumed = past_consumed_map.get(key, 0.0)
tot_consumed = prev_consumed + amount
rem_amount = max(0.0, alloc_amt - tot_consumed) if alloc_amt > 0 else 0.0
is_over = (tot_consumed > alloc_amt) if alloc_amt > 0 else (tot_consumed > 0)
over_amt = max(0.0, tot_consumed - alloc_amt) if alloc_amt > 0 else tot_consumed
mat_list.append({
"name": name,
"unit": unit,
"amount": amount,
"allocated_amount": alloc_amt,
"previous_consumed": prev_consumed,
"total_consumed": tot_consumed,
"remaining_amount": rem_amount,
"is_over_allocated": is_over,
"over_allocated_amount": over_amt,
"note": m.note.strip() if m.note else "",
})
log = TaskProgressLog(
task_id=task.id,
progress_before=progress_before,
progress_after=percentage,
human_resources_used=hr_list,
materials_used=mat_list,
description=payload.description.strip(),
logged_by=ctx.user.display_name or ctx.user.username,
)
db.add(log)
await sync_project_lifecycle_dates(db, task.project_id)
await db.commit()
await db.refresh(task)
return {
"data": {
"id": str(task.id),
"progress": task.progress,
"status": task.status,
"qc_status": task.qc_status,
"actual_start": _iso(task.actual_start),
"actual_end": _iso(task.actual_end),
"progress_log": {
"id": str(log.id),
"progress_before": log.progress_before,
"progress_after": log.progress_after,
"human_resources_used": log.human_resources_used,
"materials_used": log.materials_used,
"description": log.description,
"logged_by": log.logged_by,
"created_at": _iso(log.created_at),
},
}
}
@router.get("/{id}/material-audit")
async def get_task_material_audit(
id: str,
ctx: AuthContext = Depends(view_tasks),
db: AsyncSession = Depends(get_db),
):
"""استعلام مقایسه‌ای مصالح تخصیص‌یافته در برابر کل مصارف واقعی و گزارش اضافه مصرف."""
task = await _resolve_task(db, ctx, id)
alloc_map = {}
for m in (task.materials or []):
if isinstance(m, dict):
name = (m.get("name") or "").strip()
if name:
alloc_map[name.lower()] = {
"name": name,
"unit": m.get("unit") or "",
"allocated_amount": float(m.get("count") or 0.0),
"specifications": m.get("specifications") or [],
}
consumption_map: dict[str, dict] = {}
for log in (task.progress_logs or []):
for mu in (log.materials_used or []):
if isinstance(mu, dict):
mname = (mu.get("name") or "").strip()
if not mname:
continue
key = mname.lower()
m_amt = float(mu.get("amount") or 0.0)
m_unit = mu.get("unit") or ""
if key not in consumption_map:
consumption_map[key] = {
"name": mname,
"unit": m_unit,
"total_consumed": 0.0,
}
consumption_map[key]["total_consumed"] += m_amt
all_keys = set(alloc_map.keys()) | set(consumption_map.keys())
audit_items = []
total_over_allocated_count = 0
for key in sorted(all_keys):
alloc = alloc_map.get(key)
consumed = consumption_map.get(key)
name = alloc["name"] if alloc else consumed["name"]
unit = alloc["unit"] if alloc else consumed["unit"]
alloc_amt = alloc["allocated_amount"] if alloc else 0.0
tot_cons = consumed["total_consumed"] if consumed else 0.0
rem_amt = max(0.0, alloc_amt - tot_cons) if alloc_amt > 0 else 0.0
is_over = (tot_cons > alloc_amt) if alloc_amt > 0 else (tot_cons > 0)
over_amt = max(0.0, tot_cons - alloc_amt) if alloc_amt > 0 else tot_cons
if is_over:
total_over_allocated_count += 1
audit_items.append({
"name": name,
"unit": unit,
"allocated_amount": alloc_amt,
"total_consumed": tot_cons,
"remaining_amount": rem_amt,
"is_over_allocated": is_over,
"over_allocated_amount": over_amt,
"specifications": alloc["specifications"] if alloc else [],
})
return {
"data": {
"task_id": str(task.id),
"task_title": task.title,
"has_over_allocation": total_over_allocated_count > 0,
"over_allocated_items_count": total_over_allocated_count,
"items": audit_items,
}
}
@router.patch("/{id}/dates")
async def update_task_dates(
id: str,
payload: TaskDatesSchema,
ctx: AuthContext = Depends(update_tasks),
db: AsyncSession = Depends(get_db),
):
"""Update task dates (dual Jalali/ISO compatible)."""
task = await _resolve_task(db, ctx, id)
p_start = task.planned_start
p_end = task.planned_end
if payload.start_date is not None:
p_start = date_service.parse(payload.start_date)
if payload.due_date is not None:
p_end = date_service.parse(payload.due_date)
dur = task.duration
if p_start and p_end:
dur = date_service.calculate_duration(p_start, p_end)
await _validate_task_dates_and_hierarchy(
db=db,
task_id=task.id,
planned_start=p_start,
planned_end=p_end,
duration=dur,
parent_id=task.parent_id,
actual_start=task.actual_start,
actual_end=task.actual_end,
)
task.planned_start = p_start
task.planned_end = p_end
task.duration = dur
await sync_project_lifecycle_dates(db, task.project_id)
await db.commit()
task = await _resolve_task(db, ctx, task.id)
return {
"data": {
"id": str(task.id),
"start_date": _iso(task.planned_start),
"due_date": _iso(task.planned_end),
"start_date_jalali": date_service.to_jalali(task.planned_start),
"due_date_jalali": date_service.to_jalali(task.planned_end),
"duration": task.duration,
}
}
@router.delete("/{id}")
async def delete_task(
id: str,
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
db: AsyncSession = Depends(get_db),
):
"""Delete a task and its children."""
task = await _resolve_task(db, ctx, id)
project_id = task.project_id
await db.delete(task)
await db.flush()
await sync_project_lifecycle_dates(db, project_id)
await db.commit()
return {"data": {"deleted": True, "id": id}}
+186
View File
@@ -0,0 +1,186 @@
"""User and project-assignment administration (role `project_manager` only).
Creating the accounts that hold a scoped role and deciding *which* projects the
scoped role may touch is what makes the RBAC in app/authz.py usable; nothing
here changes an operational record.
"""
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import ROLE_LABELS, AuthContext, Perm, Role, require_permission, role_of
from app.db import get_db
from app.dev_accounts import clear_assignments
from app.errors import NotFoundError, ValidationError
from app.models import PMUser, Project, ProjectAssignment
from app.security import hash_password
from app.serializers import _iso
router = APIRouter(prefix="/users", tags=["users"])
manage = require_permission(Perm.users_manage)
def _role_value(value: str) -> str:
try:
return Role(value).value
except ValueError as exc:
raise ValidationError(f"نقش نامعتبر است: {value}", field="role") from exc
async def _assigned_project_ids(db: AsyncSession, user_id) -> list[str]:
"""Projects assigned to a user, as native UUIDs."""
rows = (
await db.execute(
select(ProjectAssignment.project_id).where(
ProjectAssignment.user_id == user_id
)
)
).scalars().all()
return sorted(str(pid) for pid in rows if pid is not None)
async def _user_dict(db: AsyncSession, user: PMUser) -> dict:
role = role_of(user)
return {
"id": str(user.id),
"username": user.username,
"display_name": user.display_name,
"role": role.value,
"role_label": ROLE_LABELS.get(role.value, role.value),
"scoped": role in (Role.project_supervisor,),
"project_ids": await _assigned_project_ids(db, user.id),
"created_at": _iso(user.created_at),
}
@router.get("")
async def list_users(ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
users = (await db.execute(select(PMUser).order_by(PMUser.username))).scalars().all()
return {"data": [await _user_dict(db, u) for u in users]}
class UserIn(BaseModel):
username: str
password: str
display_name: str = ""
role: str = Role.project_supervisor.value
@field_validator("username")
@classmethod
def _username(cls, v: str) -> str:
if not v.strip():
raise ValueError("نام کاربری الزامی است")
return v.strip()
@field_validator("password")
@classmethod
def _password(cls, v: str) -> str:
if len(v) < 6:
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
return v
@router.post("")
async def create_user(payload: UserIn, ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
existing = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
if existing is not None:
raise ValidationError("این نام کاربری قبلاً ثبت شده است", field="username")
user = PMUser(
username=payload.username,
password_hash=hash_password(payload.password),
display_name=payload.display_name.strip(),
role=_role_value(payload.role),
)
db.add(user)
await db.commit()
return {"data": await _user_dict(db, user)}
class UserPatch(BaseModel):
display_name: str | None = None
role: str | None = None
password: str | None = None
@field_validator("password")
@classmethod
def _password(cls, v: str | None) -> str | None:
if v is not None and len(v) < 6:
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
return v
@router.patch("/{user_id}")
async def patch_user(
user_id: uuid.UUID,
payload: UserPatch,
ctx: AuthContext = Depends(manage),
db: AsyncSession = Depends(get_db),
):
user = await db.get(PMUser, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if payload.display_name is not None:
user.display_name = payload.display_name.strip()
if payload.password:
user.password_hash = hash_password(payload.password)
if payload.role is not None:
new_role = _role_value(payload.role)
# never let a manager remove their own manager role (lock-out guard)
if user.id == ctx.user.id and new_role != Role.project_manager.value:
raise ValidationError("نقش مدیر فعلی خود را نمی‌توانید تغییر دهید", field="role")
user.role = new_role
await db.commit()
return {"data": await _user_dict(db, user)}
class ProjectsIn(BaseModel):
"""Native project UUIDs (the live world). Legacy integer ids still accepted."""
project_ids: list[str] = []
@router.put("/{user_id}/projects")
async def set_user_projects(
user_id: uuid.UUID,
payload: ProjectsIn,
ctx: AuthContext = Depends(manage),
db: AsyncSession = Depends(get_db),
):
"""Replace a user's assigned projects (the scope a supervisor is limited to)."""
user = await db.get(PMUser, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
native_ids: list[uuid.UUID] = []
legacy_ids: list[int] = []
unknown: list[str] = []
for raw in dict.fromkeys(payload.project_ids):
raw = str(raw).strip()
parsed = None
try:
parsed = uuid.UUID(raw)
except ValueError:
parsed = None
if parsed is not None:
if await db.get(Project, parsed) is None:
unknown.append(raw)
else:
native_ids.append(parsed)
else:
unknown.append(raw)
if unknown:
raise ValidationError(
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
)
await clear_assignments(db, user.id)
for project_uuid in native_ids:
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
await db.commit()
return {"data": await _user_dict(db, user)}
+298
View File
@@ -0,0 +1,298 @@
"""Topological Scheduling Engine for Construction Projects.
Features:
- Cycle Detection (DAG validation)
- Forward Pass (Early Start / Early Finish propagation based on FS, SS, FF, SF + lag)
- Backward Pass (Late Start / Late Finish & Float calculation)
- Critical Path Method (CPM) identification
- Summary task rollup (parent tasks envelop child start/end and progress)
"""
from collections import defaultdict, deque
from datetime import date, timedelta
import uuid
from app.utils.date_service import date_service
class CycleError(Exception):
"""Raised when a dependency would introduce a cycle in the project graph."""
pass
def check_for_cycle(
existing_edges: list[tuple[uuid.UUID, uuid.UUID]],
new_from: uuid.UUID,
new_to: uuid.UUID,
) -> bool:
"""Return True if adding (new_from -> new_to) creates a cycle in the directed graph."""
if new_from == new_to:
return True
adj: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list)
for u, v in existing_edges:
adj[u].append(v)
adj[new_from].append(new_to)
visited: set[uuid.UUID] = set()
rec_stack: set[uuid.UUID] = set()
def dfs(node: uuid.UUID) -> bool:
visited.add(node)
rec_stack.add(node)
for neighbor in adj.get(node, []):
if neighbor not in visited:
if dfs(neighbor):
return True
elif neighbor in rec_stack:
return True
rec_stack.remove(node)
return False
all_nodes = set(adj.keys())
for node in all_nodes:
if node not in visited:
if dfs(node):
return True
return False
def calculate_schedule(
tasks: list,
relations: list,
project_start: date | None = None,
) -> dict:
"""Perform CPM forward and backward pass on tasks according to relations.
Returns dict with:
- tasks_schedule: {task_id: {"planned_start": date, "planned_end": date, "duration": int, "is_critical": bool, "float_days": int}}
- critical_path: list of task_ids
"""
if not tasks:
return {"tasks_schedule": {}, "critical_path": []}
task_map = {t.id: t for t in tasks}
in_degree: dict[uuid.UUID, int] = {t.id: 0 for t in tasks}
adj: dict[uuid.UUID, list] = defaultdict(list)
reverse_adj: dict[uuid.UUID, list] = defaultdict(list)
for rel in relations:
if rel.from_task_id in task_map and rel.to_task_id in task_map:
adj[rel.from_task_id].append(rel)
reverse_adj[rel.to_task_id].append(rel)
in_degree[rel.to_task_id] += 1
# 1. Topological Sort via Kahn's algorithm
queue = deque([tid for tid, deg in in_degree.items() if deg == 0])
topo_order: list[uuid.UUID] = []
while queue:
u = queue.popleft()
topo_order.append(u)
for rel in adj[u]:
v = rel.to_task_id
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
if len(topo_order) < len(tasks):
# Fallback if a cycle somehow exists: process in default order
topo_order = [t.id for t in tasks]
# Baseline anchor date
base_date = project_start or date_service.today()
# 2. Forward Pass (Calculate Early Start ES and Early Finish EF)
es: dict[uuid.UUID, date] = {}
ef: dict[uuid.UUID, date] = {}
durations: dict[uuid.UUID, int] = {}
for tid in topo_order:
t = task_map[tid]
# مدت = فاصلهٔ روزها؛ صفر یعنی شروع و پایان همان روز
duration = max(t.duration or 0, 0)
durations[tid] = duration
# Default start from current task planned_start or project base
initial_start = t.planned_start or base_date
incoming = reverse_adj.get(tid, [])
if not incoming:
es[tid] = initial_start
else:
cand_starts = [initial_start]
for rel in incoming:
p_id = rel.from_task_id
p_ef = ef.get(p_id, initial_start)
p_es = es.get(p_id, initial_start)
lag = rel.lag_days or 0
rtype = (rel.relation_type or "FS").upper()
if rtype == "FS":
# Successor starts after Predecessor finishes + lag
cand_starts.append(p_ef + timedelta(days=lag))
elif rtype == "SS":
# Successor starts after Predecessor starts + lag
cand_starts.append(p_es + timedelta(days=lag))
elif rtype == "FF":
# Successor finishes after Predecessor finishes + lag -> adjust start
needed_ef = p_ef + timedelta(days=lag)
cand_starts.append(needed_ef - timedelta(days=duration))
elif rtype == "SF":
# Successor finishes after Predecessor starts + lag
needed_ef = p_es + timedelta(days=lag)
cand_starts.append(needed_ef - timedelta(days=duration))
es[tid] = max(cand_starts)
ef[tid] = es[tid] + timedelta(days=duration)
# 3. Backward Pass (Calculate Late Start LS, Late Finish LF, Float)
project_max_ef = max(ef.values()) if ef else base_date
lf: dict[uuid.UUID, date] = {}
ls: dict[uuid.UUID, date] = {}
for tid in reversed(topo_order):
duration = durations[tid]
outgoing = adj.get(tid, [])
if not outgoing:
lf[tid] = project_max_ef
else:
cand_finishes = []
for rel in outgoing:
s_id = rel.to_task_id
s_ls = ls.get(s_id, project_max_ef)
s_lf = lf.get(s_id, project_max_ef)
lag = rel.lag_days or 0
rtype = (rel.relation_type or "FS").upper()
if rtype == "FS":
cand_finishes.append(s_ls - timedelta(days=lag))
elif rtype == "SS":
needed_s_start = s_ls - timedelta(days=lag)
cand_finishes.append(needed_s_start + timedelta(days=duration))
elif rtype == "FF":
cand_finishes.append(s_lf - timedelta(days=lag))
elif rtype == "SF":
needed_s_finish = s_lf - timedelta(days=lag)
cand_finishes.append(needed_s_finish + timedelta(days=duration))
lf[tid] = min(cand_finishes) if cand_finishes else project_max_ef
ls[tid] = lf[tid] - timedelta(days=duration)
# 4. Critical Path Identification (Total Float == 0)
critical_path: list[uuid.UUID] = []
tasks_schedule: dict[uuid.UUID, dict] = {}
for tid in topo_order:
t_es = es[tid]
t_ef = ef[tid]
t_ls = ls.get(tid, t_es)
t_lf = lf.get(tid, t_ef)
float_days = (t_ls - t_es).days
is_critical = float_days <= 0
if is_critical:
critical_path.append(tid)
tasks_schedule[tid] = {
"planned_start": t_es,
"planned_end": t_ef,
"duration": durations[tid],
"float_days": max(float_days, 0),
"is_critical": is_critical,
}
return {
"tasks_schedule": tasks_schedule,
"critical_path": critical_path,
"project_finish": project_max_ef,
}
def rollup_parent_tasks(tasks: list) -> dict[uuid.UUID, dict]:
"""Roll up dates and progress from children to parent tasks."""
parent_map: dict[uuid.UUID, list] = defaultdict(list)
task_map = {t.id: t for t in tasks}
for t in tasks:
if t.parent_id and t.parent_id in task_map:
parent_map[t.parent_id].append(t)
rollups: dict[uuid.UUID, dict] = {}
for pid, children in parent_map.items():
child_starts = [c.planned_start for c in children if c.planned_start]
child_ends = [c.planned_end for c in children if c.planned_end]
child_progs = [c.progress or 0 for c in children]
p_start = min(child_starts) if child_starts else None
p_end = max(child_ends) if child_ends else None
p_dur = None
if p_start and p_end:
p_dur = date_service.calculate_duration(p_start, p_end)
rollups[pid] = {
"planned_start": p_start,
"planned_end": p_end,
"duration": p_dur,
"progress": round(sum(child_progs) / len(child_progs)) if child_progs else 0,
}
return rollups
async def sync_project_lifecycle_dates(db, project_id: uuid.UUID):
"""Synchronize Project planned_start, planned_end, actual_start, actual_end, and progress from its tasks.
Business rules:
- planned_start: planned_start of the first task (earliest planned_start)
- planned_end: planned_end of the last task (latest planned_end)
- actual_start: actual_start of the first task (or earliest actual_start across tasks)
- actual_end: actual_end of the last task
"""
from sqlalchemy import select
from app.models import Project, Task
project = await db.get(Project, project_id)
if not project:
return None
tasks_result = await db.execute(select(Task).where(Task.project_id == project_id))
tasks = tasks_result.scalars().all()
if not tasks:
project.planned_start = None
project.planned_end = None
project.actual_start = None
project.actual_end = None
project.progress = 0.0
return project
valid_starts = [t for t in tasks if t.planned_start is not None]
valid_ends = [t for t in tasks if t.planned_end is not None]
if valid_starts:
first_task = min(valid_starts, key=lambda t: (t.planned_start, t.created_at))
project.planned_start = first_task.planned_start
all_actual_starts = [t.actual_start for t in tasks if t.actual_start is not None]
project.actual_start = first_task.actual_start or (min(all_actual_starts) if all_actual_starts else None)
else:
project.planned_start = None
all_actual_starts = [t.actual_start for t in tasks if t.actual_start is not None]
project.actual_start = min(all_actual_starts) if all_actual_starts else None
if valid_ends:
last_task = max(valid_ends, key=lambda t: (t.planned_end, t.created_at))
project.planned_end = last_task.planned_end
project.actual_end = last_task.actual_end
else:
project.planned_end = None
project.actual_end = None
progs = [t.progress or 0 for t in tasks]
if progs:
project.progress = round(sum(progs) / len(progs), 1)
return project
+142
View File
@@ -0,0 +1,142 @@
"""Lightweight schema sync for additive/structural changes.
`Base.metadata.create_all` creates NEW tables but never alters existing ones,
so this module applies the missing pieces at startup:
1. `drop_legacy()` — tables whose shape changed or whose feature was removed
(including all legacy cache & sync tables).
2. `ensure_schema()` — additive columns and new enum values.
"""
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection
logger = logging.getLogger("pm.schema")
# Tables dropped on startup (idempotent):
# - legacy cache & sync tables completely purged
# - legacy inspection / activity template tables
LEGACY_TABLES: list[str] = [
"material_requirements",
"reservations",
"milestones",
"purchase_requests",
"inspection_results",
"inspections",
"activity_template_materials",
"activity_templates",
"projects_cache",
"task_refs",
"sync_events",
"integration_logs",
"webhook_deliveries",
"sync_state",
]
LEGACY_COLUMNS: list[tuple[str, str]] = [
("projects", "client"),
("projects", "code"),
("projects", "erp_project_id"),
("tasks", "planned_progress"),
("projects", "legacy_op_project_id"),
("tasks", "legacy_op_wp_id"),
("task_qc_definitions", "task_op_wp_id"),
("defects", "task_op_wp_id"),
("project_assignments", "op_project_id"),
("tasks", "equipment"),
]
ADDITIVE_COLUMNS: list[tuple[str, str, str]] = [
# (table, column, type)
# RBAC: users that predate roles keep their previous (manager) access
("pm_users", "role", "VARCHAR(32) NOT NULL DEFAULT 'project_manager'"),
# Native PM Platform linking
("task_qc_definitions", "task_id", "UUID"),
("defects", "task_id", "UUID"),
("project_assignments", "project_id", "UUID"),
("tasks", "human_resources", "JSONB DEFAULT '[]'"),
("tasks", "materials", "JSONB DEFAULT '[]'"),
("task_qc_results", "photo_path", "VARCHAR(512)"),
]
ENUM_VALUES: list[tuple[str, str]] = [
# (enum type, value) — e.g. the pre-execution QC stage
("qc_phase", "pre"),
]
async def drop_legacy(conn: AsyncConnection) -> None:
# Migrate any existing equipment data to materials before dropping the equipment column
try:
check = await conn.execute(
text("SELECT 1 FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'equipment'")
)
if check.scalar():
await conn.execute(
text("""
UPDATE tasks
SET materials = COALESCE(materials, '[]'::jsonb) || (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'name', elem->>'name',
'unit', COALESCE(elem->>'unit', 'دستگاه'),
'specifications', COALESCE(elem->'specifications', '[]'::jsonb),
'count', COALESCE((elem->>'count')::numeric, 1)
)
), '[]'::jsonb)
FROM jsonb_array_elements(equipment) AS elem
WHERE elem->>'name' IS NOT NULL AND elem->>'name' != ''
)
WHERE equipment IS NOT NULL AND jsonb_array_length(equipment) > 0;
""")
)
logger.info("Migrated equipment data into materials in tasks table.")
except Exception as exc: # noqa: BLE001
logger.warning("equipment migration skipped: %s", exc)
for table in LEGACY_TABLES:
try:
await conn.execute(text(f"DROP TABLE IF EXISTS {table} CASCADE"))
except Exception as exc: # noqa: BLE001 — never block startup on this
logger.warning("legacy drop skipped %s: %s", table, exc)
for table, column in LEGACY_COLUMNS:
try:
await conn.execute(text(f"ALTER TABLE IF EXISTS {table} DROP COLUMN IF EXISTS {column} CASCADE"))
except Exception as exc: # noqa: BLE001
logger.warning("legacy column drop skipped %s.%s: %s", table, column, exc)
async def ensure_schema(conn: AsyncConnection) -> None:
for enum_type, value in ENUM_VALUES:
try:
await conn.execute(text(f"ALTER TYPE {enum_type} ADD VALUE IF NOT EXISTS '{value}'"))
except Exception as exc: # noqa: BLE001
logger.warning("enum sync skipped %s.%s: %s", enum_type, value, exc)
for table, column, coltype in ADDITIVE_COLUMNS:
try:
await conn.execute(text(f"ALTER TABLE IF EXISTS {table} ADD COLUMN IF NOT EXISTS {column} {coltype}"))
except Exception as exc: # noqa: BLE001
logger.warning("schema sync skipped %s.%s: %s", table, column, exc)
try:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS task_progress_logs (
id UUID PRIMARY KEY,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
progress_before INTEGER DEFAULT 0,
progress_after INTEGER NOT NULL,
human_resources_used JSONB DEFAULT '[]',
materials_used JSONB DEFAULT '[]',
description TEXT DEFAULT '',
logged_by VARCHAR(255) DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
"""))
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_progress_logs_task ON task_progress_logs (task_id)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_progress_logs_created ON task_progress_logs (created_at)"))
except Exception as exc: # noqa: BLE001
logger.warning("task_progress_logs table sync skipped: %s", exc)
+89
View File
@@ -0,0 +1,89 @@
"""Local auth — scrypt password hashing + HMAC-signed session tokens.
Deliberately simple for the MVP (single role). Casdoor SSO can be added later
the same way the ERP does it.
"""
import base64
import hashlib
import hmac
import json
import os
import time
import uuid
from fastapi import Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.casdoor_auth import casdoor_user_from_token, is_casdoor_token
from app.config import settings
from app.db import get_db
from app.errors import AuthError
from app.models import PMUser
def hash_password(password: str) -> str:
salt = os.urandom(16)
dk = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
return f"scrypt${salt.hex()}${dk.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
_, salt_hex, dk_hex = stored.split("$")
dk = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt_hex), n=2**14, r=8, p=1)
return hmac.compare_digest(dk.hex(), dk_hex)
except Exception:
return False
def _sign(payload: bytes) -> str:
return hmac.new(settings.SESSION_SECRET.encode(), payload, hashlib.sha256).hexdigest()
def create_token(user_id: uuid.UUID) -> str:
payload = json.dumps(
{"uid": str(user_id), "exp": int(time.time()) + settings.SESSION_TTL_HOURS * 3600}
).encode()
body = base64.urlsafe_b64encode(payload).rstrip(b"=")
return f"{body.decode()}.{_sign(body)}"
def verify_token(token: str) -> uuid.UUID:
try:
body_b64, sig = token.split(".", 1)
body = body_b64.encode()
if not hmac.compare_digest(_sign(body), sig):
raise ValueError("bad signature")
payload = json.loads(base64.urlsafe_b64decode(body_b64 + "=" * (-len(body_b64) % 4)))
if payload.get("exp", 0) < time.time():
raise ValueError("expired")
return uuid.UUID(payload["uid"])
except AuthError:
raise
except Exception as exc:
raise AuthError("نشست نامعتبر یا منقضی شده است") from exc
async def get_current_user(
request: Request, db: AsyncSession = Depends(get_db)
) -> PMUser:
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
raise AuthError("احراز هویت لازم است")
token = auth.split(" ", 1)[1].strip()
if is_casdoor_token(token):
# SSO — the same Casdoor JWT the ERP issued (one login for the suite)
return await casdoor_user_from_token(token, db)
user_id = verify_token(token)
user = await db.get(PMUser, user_id)
if user is None:
raise AuthError("کاربر یافت نشد")
return user
async def authenticate(db: AsyncSession, username: str, password: str) -> PMUser | None:
user = await db.scalar(select(PMUser).where(PMUser.username == username.strip()))
if user is None or not verify_password(password, user.password_hash):
return None
return user
+158
View File
@@ -0,0 +1,158 @@
"""Response serializers — the {"data": ...} envelope lives in the routers."""
from datetime import date, datetime
from app.utils.jalali import to_jalali_str
def _iso(value: datetime | date | None) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return value.isoformat()
def user_payload(ctx) -> dict:
"""The authenticated user as the frontend sees them: identity, role and the
permissions/scope the UI derives its navigation from (see frontend
src/lib/roles.ts). The API enforces the same values server-side."""
user = ctx.user
return {
"id": str(user.id),
"username": user.username,
"display_name": user.display_name,
**ctx.as_dict(),
}
def project_dict(project, member_count: int = 0, task_stats: dict | None = None) -> dict:
return {
"id": str(project.id),
"name": project.name,
"description": project.description or "",
"location": project.location or "",
"project_manager": project.project_manager or "",
"planned_start": _iso(project.planned_start),
"planned_end": _iso(project.planned_end),
"actual_start": _iso(project.actual_start),
"actual_end": _iso(project.actual_end),
"planned_start_jalali": to_jalali_str(project.planned_start),
"planned_end_jalali": to_jalali_str(project.planned_end),
"actual_start_jalali": to_jalali_str(project.actual_start),
"actual_end_jalali": to_jalali_str(project.actual_end),
"status": project.status,
"progress": round(project.progress or 0.0, 1),
"member_count": member_count,
"stats": task_stats or {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "overdue": 0},
"created_at": _iso(project.created_at),
"updated_at": _iso(project.updated_at),
}
def project_member_dict(member) -> dict:
username = ""
display_name = ""
try:
from sqlalchemy.inspection import inspect
insp = inspect(member)
if "user" in insp.attrs and insp.attrs.user.loaded and member.user:
username = member.user.username
display_name = member.user.display_name
except Exception:
pass
return {
"id": str(member.id),
"project_id": str(member.project_id),
"user_id": str(member.user_id),
"username": username,
"display_name": display_name,
"role": member.role,
"created_at": _iso(member.created_at),
}
def native_task_dict(task, project_name: str | None = None, children_count: int = 0) -> dict:
assignee_name = task.assignee_name
if not assignee_name:
try:
from sqlalchemy.inspection import inspect
insp = inspect(task)
if "assignee" in insp.attrs and "assignee" not in insp.unloaded and task.assignee:
assignee_name = task.assignee.display_name
except Exception:
pass
progress_logs_data = []
try:
from sqlalchemy.inspection import inspect
insp = inspect(task)
if "progress_logs" in insp.attrs and "progress_logs" not in insp.unloaded and task.progress_logs:
progress_logs_data = [
{
"id": str(log.id),
"progress_before": log.progress_before,
"progress_after": log.progress_after,
"human_resources_used": log.human_resources_used or [],
"materials_used": log.materials_used or [],
"description": log.description or "",
"logged_by": log.logged_by or "",
"created_at": _iso(log.created_at),
}
for log in task.progress_logs
]
except Exception:
pass
return {
"id": str(task.id),
"project_id": str(task.project_id),
"parent_id": str(task.parent_id) if task.parent_id else None,
"title": task.title,
"description": task.description or "",
"status": task.status,
"priority": task.priority,
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
"assignee_name": assignee_name,
"planned_start": _iso(task.planned_start),
"planned_end": _iso(task.planned_end),
"actual_start": _iso(task.actual_start),
"actual_end": _iso(task.actual_end),
"planned_start_jalali": to_jalali_str(task.planned_start),
"planned_end_jalali": to_jalali_str(task.planned_end),
"actual_start_jalali": to_jalali_str(task.actual_start),
"actual_end_jalali": to_jalali_str(task.actual_end),
"duration": task.duration or 1,
"progress": task.progress or 0,
"qc_status": task.qc_status or "none",
"project_name": project_name,
"human_resources": getattr(task, "human_resources", None) or [],
"materials": getattr(task, "materials", None) or [],
"progress_logs": progress_logs_data,
"created_at": _iso(task.created_at),
"updated_at": _iso(task.updated_at),
}
def task_relation_dict(rel, from_title: str = "", to_title: str = "") -> dict:
from_title_val = from_title
to_title_val = to_title
try:
from sqlalchemy.inspection import inspect
insp = inspect(rel)
if not from_title_val and "from_task" in insp.attrs and insp.attrs.from_task.loaded and rel.from_task:
from_title_val = rel.from_task.title
if not to_title_val and "to_task" in insp.attrs and insp.attrs.to_task.loaded and rel.to_task:
to_title_val = rel.to_task.title
except Exception:
pass
return {
"id": str(rel.id),
"project_id": str(rel.project_id),
"from_task_id": str(rel.from_task_id),
"to_task_id": str(rel.to_task_id),
"from_task_title": from_title_val,
"to_task_title": to_title_val,
"relation_type": rel.relation_type,
"lag_days": rel.lag_days,
"created_at": _iso(rel.created_at),
}
+1
View File
@@ -0,0 +1 @@
"""Shared utilities (Jalali dates, ...)."""
+97
View File
@@ -0,0 +1,97 @@
"""Unified DateService — single source of truth for date handling across PM.
Design invariants:
- Internally: Gregorian datetime.date (UTC-safe, no timezone shift).
- API input/output: ISO 8601 (YYYY-MM-DD) or Jalali (YYYY/MM/DD) when convenient.
- UI: Jalali (Shamsi) display with English/Persian digit support.
"""
from __future__ import annotations
import datetime as _dt
from typing import Any
from app.utils.jalali import (
days_in_month,
gregorian_to_jalali,
is_leap_jalali,
jalali_to_gregorian,
parse_flexible_date,
to_jalali_str,
)
class DateService:
@staticmethod
def parse(value: Any) -> _dt.date | None:
"""Parse ISO, Jalali, or datetime into a standard Gregorian date."""
if value is None:
return None
if isinstance(value, _dt.date) and not isinstance(value, _dt.datetime):
return value
if isinstance(value, _dt.datetime):
return value.date()
return parse_flexible_date(str(value))
@staticmethod
def to_iso(value: _dt.date | None) -> str | None:
"""Format date as ISO 8601 string (YYYY-MM-DD)."""
if value is None:
return None
return value.isoformat()
@staticmethod
def today() -> _dt.date:
"""Return today's Gregorian date."""
return _dt.date.today()
@staticmethod
def to_jalali(value: _dt.date | None, sep: str = "/") -> str | None:
"""Format date as Jalali string (YYYY/MM/DD)."""
return to_jalali_str(value, sep=sep)
@staticmethod
def calculate_duration(start_date: _dt.date | None, end_date: _dt.date | None) -> int:
"""Duration in days = the GAP between the two dates (end date not counted).
This is the convention the UI uses, so the number a user types is the
number displayed back to them:
16 → 20 Mordad = 4 days
start == end = 0 days (same day, no span)
Returns 0 when either date is missing or the range is inverted.
"""
if not start_date or not end_date:
return 0
diff = (end_date - start_date).days
return max(0, diff)
@staticmethod
def compute_end_date(start_date: _dt.date | None, duration_days: int) -> _dt.date | None:
"""End date for a start date and a gap-based duration.
Exclusive, the exact inverse of calculate_duration:
compute_end_date(d, calculate_duration(d, e)) == e
calculate_duration(d, compute_end_date(d, n)) == n
A duration of 0 (or 1) means the task starts and ends the same day.
"""
if not start_date:
return None
if duration_days <= 0:
return start_date
return start_date + _dt.timedelta(days=duration_days)
@staticmethod
def today() -> _dt.date:
"""Return current Gregorian date."""
return _dt.date.today()
@staticmethod
def today_jalali(sep: str = "/") -> str:
"""Return today as Jalali string."""
return to_jalali_str(_dt.date.today(), sep=sep) or ""
date_service = DateService()
+195
View File
@@ -0,0 +1,195 @@
"""Jalali (Shamsi) ↔ Gregorian conversion — the ONLY date-conversion module.
Ported from the well-known `jalaali-js` algorithm (MIT), dependency-free.
Design notes:
- Conversions work on plain (year, month, day) integers — no timezone or
clock involvement at all, which is what makes date-only fields safe
(no off-by-one-day bugs).
- PM stores standard ISO dates (Gregorian) and converts only
at the boundaries: parsing user input (Jalali → ISO) and rendering responses
(`*_jalali` convenience fields). The frontend has its own mirrored copy of
this algorithm in `frontend/src/lib/jalali.ts` for pickers/display.
"""
from __future__ import annotations
import datetime as _dt
import re
_BREAKS = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210,
1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178,
]
def _div(a: int, b: int) -> int:
"""Truncating integer division (matches the JS reference implementation)."""
q = abs(a) // abs(b)
return q if (a >= 0) == (b >= 0) else -q
def _mod(a: int, b: int) -> int:
"""Remainder with the sign of the dividend (JS `%` semantics)."""
return a - _div(a, b) * b
def _jal_cal(jy: int, without_leap: bool = False) -> tuple[int, int, int]:
"""(leap, gy, march) for a Jalali year."""
bl = len(_BREAKS)
gy = jy + 621
leap_j = -14
jp = _BREAKS[0]
jump = 0
if jy < jp or jy >= _BREAKS[bl - 1]:
raise ValueError(f"سال جلالی نامعتبر: {jy}")
for i in range(1, bl):
jm = _BREAKS[i]
jump = jm - jp
if jy < jm:
break
leap_j += _div(jump, 33) * 8 + _div(_mod(jump, 33), 4)
jp = jm
n = jy - jp
leap_j += _div(n, 33) * 8 + _div(_mod(n, 33) + 3, 4)
if _mod(jump, 33) == 4 and jump - n == 4:
leap_j += 1
leap_g = _div(gy, 4) - _div((_div(gy, 100) + 1) * 3, 4) - 150
march = 20 + leap_j - leap_g
leap = 0
if not without_leap:
if jump - n < 6:
n = n - jump + _div(jump + 4, 33) * 33
leap = _mod(_mod(n + 1, 33) - 1, 4)
if leap == -1:
leap = 4
return leap, gy, march
def _g2d(gy: int, gm: int, gd: int) -> int:
"""Gregorian date → Julian Day Number."""
d = (
_div((gy + _div(gm - 8, 6) + 100100) * 1461, 4)
+ _div(153 * _mod(gm + 9, 12) + 2, 5)
+ gd
- 34840408
)
d = d - _div(_div(gy + 100100 + _div(gm - 8, 6), 100) * 3, 4) + 752
return d
def _d2g(jdn: int) -> tuple[int, int, int]:
"""Julian Day Number → Gregorian date."""
j = 4 * jdn + 139361631
j = j + _div(_div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908
i = _div(_mod(j, 1461), 4) * 5 + 308
gd = _div(_mod(i, 153), 5) + 1
gm = _mod(_div(i, 153), 12) + 1
gy = _div(j, 1461) - 100100 + _div(8 - gm, 6)
return gy, gm, gd
def _j2d(jy: int, jm: int, jd: int) -> int:
"""Jalali date → Julian Day Number."""
_, gy, march = _jal_cal(jy, True)
return _g2d(gy, 3, march) + (jm - 1) * 31 - _div(jm, 7) * (jm - 7) + jd - 1
def _d2j(jdn: int) -> tuple[int, int, int]:
"""Julian Day Number → Jalali date."""
gy, _, _ = _d2g(jdn)
jy = gy - 621
leap, _, march = _jal_cal(jy, False)
jdn1f = _g2d(gy, 3, march)
k = jdn - jdn1f
if k >= 0:
if k <= 185:
return jy, 1 + _div(k, 31), _mod(k, 31) + 1
k -= 186
else:
jy -= 1
k += 179
if leap == 1:
k += 1
return jy, 7 + _div(k, 30), _mod(k, 30) + 1
# --- public API ---------------------------------------------------------------
def gregorian_to_jalali(value: _dt.date) -> tuple[int, int, int]:
"""date(2026, 9, 14) → (1405, 6, 23)"""
return _d2j(_g2d(value.year, value.month, value.day))
def is_leap_jalali(jy: int) -> bool:
return _jal_cal(jy, False)[0] == 0
def days_in_month(jy: int, jm: int) -> int:
if jm <= 6:
return 31
if jm <= 11:
return 30
return 30 if is_leap_jalali(jy) else 29
def jalali_to_gregorian(jy: int, jm: int, jd: int) -> _dt.date:
"""(1405, 6, 23) → date(2026, 9, 14)"""
if not 1 <= jm <= 12:
raise ValueError(f"ماه جلالی نامعتبر است: {jm}")
if not 1 <= jd <= days_in_month(jy, jm):
raise ValueError(f"روز جلالی نامعتبر است: {jy}/{jm:02d}/{jd:02d}")
gy, gm, gd = _d2g(_j2d(jy, jm, jd))
return _dt.date(gy, gm, gd)
def to_jalali_str(value: _dt.date | None, sep: str = "/") -> str | None:
"""date → «1405/06/23» (Latin digits, zero-padded)."""
if value is None:
return None
jy, jm, jd = gregorian_to_jalali(value)
return f"{jy:04d}{sep}{jm:02d}{sep}{jd:02d}"
_ISO_RE = re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})$")
_JALALI_RE = re.compile(r"^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$")
_JALALI_COMPACT_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})$")
# Gregorian years PM can plausibly see; anything lower is treated as Jalali.
_GREGORIAN_MIN_YEAR = 1700
def parse_flexible_date(value: str | None) -> _dt.date | None:
"""Accept ISO (`2026-09-14`), Jalali (`1405/06/23`, `1405-6-23`) or Jalali
compact (`14050623`) and return a Gregorian date.
Disambiguation: a 4-digit year >= 1700 is Gregorian, otherwise Jalali.
ISO datetime strings (`2026-09-14T10:00:00Z`) are truncated to the date.
"""
if value is None:
return None
text = str(value).strip()
if not text:
return None
# ISO datetime → date part
if "T" in text:
text = text.split("T", 1)[0]
match = _ISO_RE.match(text)
if match:
year, month, day = (int(g) for g in match.groups())
if year >= _GREGORIAN_MIN_YEAR:
return _dt.date(year, month, day)
match = _JALALI_RE.match(text) or _JALALI_COMPACT_RE.match(text)
if match:
jy, jm, jd = (int(g) for g in match.groups())
if jy >= _GREGORIAN_MIN_YEAR: # e.g. 2026/09/14 written with slashes
return _dt.date(jy, jm, jd)
return jalali_to_gregorian(jy, jm, jd)
try:
return _dt.date.fromisoformat(text)
except ValueError as exc:
raise ValueError(f"قالب تاریخ نامعتبر است: {value}") from exc