Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /srv/pm
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY tools ./tools
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1 @@
|
||||
"""PM application package."""
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration connectors (ERP/Inventory/Procurement). Phase-2 lives here."""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""PM API routers."""
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Local auth + role/scope introspection.
|
||||
|
||||
`POST /auth/login` is the real entry point; `GET /auth/me` re-reads the caller's
|
||||
role/permissions/scope (used after a role or assignment change, and by the
|
||||
frontend to self-heal a session stored before RBAC existed). The `/auth/dev-*`
|
||||
endpoints implement the dashboard role switcher and only exist while
|
||||
`PM_DEV_ROLE_SWITCH` is enabled.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.authz import build_context, dev_login_usernames, dev_switch_enabled
|
||||
from app.db import get_db
|
||||
from app.dev_accounts import demo_account, ensure_demo_assignments
|
||||
from app.errors import AuthError, NotFoundError
|
||||
from app.models import PMUser, ProjectAssignment
|
||||
from app.security import authenticate, create_token, get_current_user
|
||||
from app.serializers import user_payload
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(payload: LoginIn, db: AsyncSession = Depends(get_db)):
|
||||
user = await authenticate(db, payload.username, payload.password)
|
||||
if user is None:
|
||||
raise AuthError("نام کاربری یا رمز عبور اشتباه است")
|
||||
ctx = await build_context(db, user)
|
||||
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return {"data": user_payload(await build_context(db, user))}
|
||||
|
||||
|
||||
# --- development role switcher -------------------------------------------------
|
||||
|
||||
|
||||
def _require_dev_switch() -> None:
|
||||
if not dev_switch_enabled():
|
||||
raise NotFoundError("تغییر نقش فقط در فاز توسعه فعال است")
|
||||
|
||||
|
||||
@router.get("/dev-roles")
|
||||
async def dev_roles(db: AsyncSession = Depends(get_db)):
|
||||
"""Demo identities offered by the dashboard role picker (dev only)."""
|
||||
_require_dev_switch()
|
||||
# projects only exist after the first pull — self-heal the demo scope here
|
||||
await ensure_demo_assignments(db)
|
||||
data = []
|
||||
for username in dev_login_usernames():
|
||||
account = demo_account(username)
|
||||
if account is None:
|
||||
continue
|
||||
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
|
||||
if user is None:
|
||||
continue
|
||||
projects = await db.scalar(
|
||||
select(func.count()).where(ProjectAssignment.user_id == user.id)
|
||||
)
|
||||
data.append(
|
||||
{
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"role": account.role.value,
|
||||
"project_count": projects or 0,
|
||||
"all_projects": account.share is None,
|
||||
}
|
||||
)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
class DevLoginIn(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@router.post("/dev-login")
|
||||
async def dev_login(payload: DevLoginIn, db: AsyncSession = Depends(get_db)):
|
||||
"""Issue a token for a demo account — dev role switching without a password.
|
||||
|
||||
Restricted to the configured demo usernames, never arbitrary users.
|
||||
"""
|
||||
_require_dev_switch()
|
||||
if payload.username not in dev_login_usernames():
|
||||
raise AuthError("این حساب در فهرست حسابهای توسعه نیست")
|
||||
user = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
|
||||
if user is None:
|
||||
raise AuthError("حساب نمایشی ساخته نشده است؛ سرور را دوباره راهاندازی کنید")
|
||||
ctx = await build_context(db, user)
|
||||
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Today dashboard.
|
||||
|
||||
Both roles land here, but they need different things from it: the project
|
||||
manager gets a management overview (every project, with its counters), the
|
||||
supervisor gets an operational view of the activities in the projects assigned
|
||||
to them. The data is the same query either way — only scoped by role and
|
||||
presented differently (see frontend TodayPage).
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.authz import AuthContext, Perm, require_permission
|
||||
from app.db import get_db
|
||||
from app.models import Project, Task
|
||||
from app.serializers import _iso, native_task_dict
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
view_dashboard = require_permission(Perm.dashboard_view)
|
||||
|
||||
|
||||
async def _native_project_overview(db: AsyncSession, ctx: AuthContext) -> list[dict]:
|
||||
"""کاشیهای مدیریتی پروژههای بومی برای داشبورد مدیر پروژه."""
|
||||
proj_stmt = ctx.scope_native(select(Project).order_by(Project.name), Project.id)
|
||||
projects = (await db.execute(proj_stmt)).scalars().all()
|
||||
|
||||
tasks = (await db.execute(ctx.scope_native(select(Task), Task.project_id))).scalars().all()
|
||||
today_date = date.today()
|
||||
by_project: dict = {}
|
||||
for t in tasks:
|
||||
by_project.setdefault(t.project_id, []).append(t)
|
||||
|
||||
rows: list[dict] = []
|
||||
for p in projects:
|
||||
mine = by_project.get(p.id, [])
|
||||
open_tasks = [t for t in mine if t.status not in ("COMPLETED", "CANCELLED")]
|
||||
stats = {
|
||||
"total": len(mine),
|
||||
"active": len(open_tasks),
|
||||
"awaiting_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")),
|
||||
"failed_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_failed", "qc_failed_rework")),
|
||||
"overdue": sum(1 for t in open_tasks if t.planned_end and t.planned_end < today_date),
|
||||
}
|
||||
rows.append(
|
||||
{
|
||||
"id": str(p.id),
|
||||
"name": p.name,
|
||||
"identifier": str(p.id),
|
||||
"active": p.status == "ACTIVE",
|
||||
"stats": stats,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/today")
|
||||
async def today(ctx: AuthContext = Depends(view_dashboard), db: AsyncSession = Depends(get_db)):
|
||||
native_stmt = ctx.scope_native(
|
||||
select(Task).options(selectinload(Task.project)), Task.project_id
|
||||
)
|
||||
native_tasks = (await db.execute(native_stmt)).scalars().all()
|
||||
|
||||
today_date = date.today()
|
||||
week_end = today_date + timedelta(days=7)
|
||||
|
||||
def is_task_open(t: Task) -> bool:
|
||||
return t.status != "COMPLETED" and t.status != "CANCELLED"
|
||||
|
||||
overdue = [t for t in native_tasks if is_task_open(t) and t.planned_end and t.planned_end < today_date]
|
||||
due_today = [t for t in native_tasks if is_task_open(t) and t.planned_end == today_date]
|
||||
upcoming = [
|
||||
t for t in native_tasks if is_task_open(t) and t.planned_end and today_date < t.planned_end <= week_end
|
||||
]
|
||||
awaiting_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")]
|
||||
failed_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_failed", "qc_failed_rework")]
|
||||
blocked = [t for t in native_tasks if is_task_open(t) and t.status == "BLOCKED"]
|
||||
|
||||
def _native_row(t: Task) -> dict:
|
||||
p_name = t.project.name if t.project else ""
|
||||
d = native_task_dict(t, project_name=p_name)
|
||||
d["subject"] = t.title
|
||||
d["percentage_done"] = t.progress
|
||||
d["start_date"] = _iso(t.planned_start)
|
||||
d["due_date"] = _iso(t.planned_end)
|
||||
d["is_closed"] = t.status == "COMPLETED"
|
||||
return d
|
||||
|
||||
problems = len(overdue) + len(failed_qc)
|
||||
warnings = len(awaiting_qc) + len(blocked)
|
||||
ok = max(len(native_tasks) - problems - warnings, 0)
|
||||
|
||||
data = {
|
||||
"role": ctx.role.value,
|
||||
"role_label": ctx.role_label,
|
||||
"scope": ctx.as_dict()["scope"],
|
||||
"summary": {"problems": problems, "warnings": warnings, "ok": ok},
|
||||
"sections": {
|
||||
"overdue": [_native_row(t) for t in sorted(overdue, key=lambda x: x.planned_end or date.max)],
|
||||
"due_today": [_native_row(t) for t in due_today],
|
||||
"awaiting_qc": [_native_row(t) for t in awaiting_qc],
|
||||
"failed_qc": [_native_row(t) for t in failed_qc],
|
||||
"blocked": [_native_row(t) for t in blocked],
|
||||
"upcoming": [_native_row(t) for t in sorted(upcoming, key=lambda x: x.planned_end or date.max)[:10]],
|
||||
},
|
||||
}
|
||||
# نمای مدیریتی پروژهها فقط برای نقش بدون محدودیت (مدیر پروژه)
|
||||
if ctx.all_projects:
|
||||
data["projects"] = await _native_project_overview(db, ctx)
|
||||
return {"data": data}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Defect photos — upload to PM volume, serve (LAN-internal, unauthenticated)."""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.authz import AuthContext, Perm, require_permission
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.errors import ValidationError
|
||||
|
||||
router = APIRouter(prefix="/files", tags=["files"])
|
||||
|
||||
ALLOWED = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
# photos are attached to QC defects, so both roles may upload
|
||||
upload_file = require_permission(Perm.files_upload)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def upload(
|
||||
file: UploadFile,
|
||||
_: AuthContext = Depends(upload_file),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = db
|
||||
ext = ALLOWED.get(file.content_type or "")
|
||||
if ext is None:
|
||||
raise ValidationError("فقط تصویر jpg/png/webp پذیرفته میشود", field="file")
|
||||
data = await file.read()
|
||||
if len(data) > MAX_SIZE:
|
||||
raise ValidationError("حجم فایل بیش از حد مجاز است (۱۰ مگابایت)", field="file")
|
||||
settings.FILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file_id = uuid.uuid4().hex
|
||||
path = settings.FILES_DIR / f"{file_id}{ext}"
|
||||
path.write_bytes(data)
|
||||
return {"data": {"id": file_id, "url": f"/api/v1/files/{file_id}", "filename": file.filename}}
|
||||
|
||||
|
||||
@router.get("/{file_id}")
|
||||
async def serve(file_id: str, db: AsyncSession = Depends(get_db)):
|
||||
_ = db
|
||||
if not file_id.isalnum():
|
||||
raise ValidationError("شناسه فایل نامعتبر است")
|
||||
for ext in ALLOWED.values():
|
||||
path = settings.FILES_DIR / f"{file_id}{ext}"
|
||||
if path.exists():
|
||||
media = next(k for k, v in ALLOWED.items() if v == ext)
|
||||
return FileResponse(path, media_type=media)
|
||||
from app.errors import NotFoundError
|
||||
|
||||
raise NotFoundError("فایل یافت نشد")
|
||||
@@ -0,0 +1,601 @@
|
||||
"""Projects (Native PM Construction Platform Projects).
|
||||
|
||||
Supports:
|
||||
- Project list with real-time statistics and scoping.
|
||||
- Creation, editing, archiving of native projects.
|
||||
- Project team members management.
|
||||
- Unique project code and UUID resolution.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.authz import AuthContext, Perm, require_permission
|
||||
from app.db import get_db
|
||||
from app.errors import NotFoundError, ValidationError
|
||||
from app.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectStatus,
|
||||
Task,
|
||||
TaskRelation,
|
||||
TaskRelationType,
|
||||
)
|
||||
from app.scheduling_service import (
|
||||
calculate_schedule,
|
||||
check_for_cycle,
|
||||
rollup_parent_tasks,
|
||||
sync_project_lifecycle_dates,
|
||||
)
|
||||
from app.serializers import _iso, project_dict, project_member_dict, task_relation_dict
|
||||
from app.utils.date_service import date_service
|
||||
from app.utils.jalali import to_jalali_str
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
ACTIVE_QC = ("pre_qc_pending", "exec_qc_pending", "ready")
|
||||
FAILED_QC = ("pre_qc_failed", "qc_failed_rework")
|
||||
|
||||
view_projects = require_permission(Perm.projects_view)
|
||||
manage_projects = require_permission(Perm.projects_create)
|
||||
|
||||
|
||||
class ProjectCreateSchema(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
location: str = ""
|
||||
project_manager: str = ""
|
||||
planned_start: str | None = None
|
||||
planned_end: str | None = None
|
||||
status: str = ProjectStatus.ACTIVE.value
|
||||
|
||||
|
||||
class ProjectUpdateSchema(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
project_manager: str | None = None
|
||||
planned_start: str | None = None
|
||||
planned_end: str | None = None
|
||||
actual_start: str | None = None
|
||||
actual_end: str | None = None
|
||||
status: str | None = None
|
||||
progress: float | None = None
|
||||
|
||||
|
||||
class ProjectMemberCreateSchema(BaseModel):
|
||||
user_id: str
|
||||
role: str = "site_engineer"
|
||||
|
||||
|
||||
class ErpMappingSchema(BaseModel):
|
||||
erp_project_id: str | None = None
|
||||
|
||||
|
||||
class TaskRelationCreateSchema(BaseModel):
|
||||
from_task_id: str
|
||||
to_task_id: str
|
||||
relation_type: str = "FS"
|
||||
lag_days: int = 0
|
||||
|
||||
|
||||
class TaskRelationUpdateSchema(BaseModel):
|
||||
"""Edit an existing dependency. The endpoints (from/to tasks) are immutable —
|
||||
changing them is a different relation, so it must be deleted and recreated."""
|
||||
|
||||
relation_type: str | None = None
|
||||
lag_days: int | None = None
|
||||
|
||||
|
||||
async def _resolve_project(db: AsyncSession, ctx: AuthContext, project_id_or_op: str | int) -> Project:
|
||||
"""Find a project by UUID and enforce scope."""
|
||||
query = select(Project).options(
|
||||
selectinload(Project.members).selectinload(ProjectMember.user)
|
||||
)
|
||||
|
||||
try:
|
||||
val_uuid = uuid.UUID(str(project_id_or_op))
|
||||
stmt = query.where(Project.id == val_uuid)
|
||||
except ValueError:
|
||||
raise NotFoundError("شناسه پروژه نامعتبر است")
|
||||
|
||||
project = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not project:
|
||||
raise NotFoundError("پروژه مورد نظر یافت نشد")
|
||||
ctx.ensure_native_project(project.id)
|
||||
return project
|
||||
|
||||
|
||||
async def native_project_stats(db: AsyncSession, project_id: uuid.UUID) -> dict:
|
||||
"""Task counters of one native project."""
|
||||
today = date_service.today()
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Task.qc_status, Task.planned_end, Task.status, func.count())
|
||||
.where(Task.project_id == project_id)
|
||||
.group_by(Task.qc_status, Task.planned_end, Task.status)
|
||||
)
|
||||
).all()
|
||||
|
||||
total = sum(r[3] for r in rows)
|
||||
completed = sum(r[3] for r in rows if r[2] == "COMPLETED")
|
||||
in_progress = sum(r[3] for r in rows if r[2] == "IN_PROGRESS")
|
||||
blocked = sum(r[3] for r in rows if r[2] == "BLOCKED")
|
||||
awaiting = sum(r[3] for r in rows if r[0] in ACTIVE_QC)
|
||||
failed = sum(r[3] for r in rows if r[0] in FAILED_QC)
|
||||
overdue = sum(r[3] for r in rows if r[2] != "COMPLETED" and r[1] and r[1] < today)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"in_progress": in_progress,
|
||||
"blocked": blocked,
|
||||
"awaiting_qc": awaiting,
|
||||
"failed_qc": failed,
|
||||
"overdue": overdue,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(
|
||||
ctx: AuthContext = Depends(view_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all projects visible to the user, with stats and team counts."""
|
||||
stmt = select(Project).options(selectinload(Project.members)).order_by(Project.created_at.desc())
|
||||
|
||||
# Apply project scoping for supervisor / scoped roles (native projects = UUID)
|
||||
stmt = ctx.scope_native(stmt, Project.id)
|
||||
|
||||
projects = (await db.execute(stmt)).scalars().all()
|
||||
results = []
|
||||
for p in projects:
|
||||
stats = await native_project_stats(db, p.id)
|
||||
results.append(project_dict(p, member_count=len(p.members), task_stats=stats))
|
||||
|
||||
return {"data": results}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_project(
|
||||
payload: ProjectCreateSchema,
|
||||
ctx: AuthContext = Depends(manage_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new native project."""
|
||||
p_start = date_service.parse(payload.planned_start) if payload.planned_start else None
|
||||
p_end = date_service.parse(payload.planned_end) if payload.planned_end else None
|
||||
if p_start and p_end and p_end < p_start:
|
||||
raise ValidationError("تاریخ پایان نمیتواند قبل از تاریخ شروع باشد", field="planned_end")
|
||||
|
||||
project = Project(
|
||||
name=payload.name.strip(),
|
||||
description=payload.description.strip(),
|
||||
location=payload.location.strip() if payload.location else "",
|
||||
project_manager=payload.project_manager.strip() if payload.project_manager else ctx.user.display_name,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
status=payload.status.upper(),
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
# Automatically add the creator as a project manager member
|
||||
creator_member = ProjectMember(
|
||||
project_id=project.id,
|
||||
user_id=ctx.user.id,
|
||||
role="project_manager",
|
||||
)
|
||||
db.add(creator_member)
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
stats = {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "overdue": 0}
|
||||
res = project_dict(project, member_count=1, task_stats=stats)
|
||||
res["identifier"] = str(project.id)
|
||||
return {"data": res}
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_project(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get project details, member list, and overall stats."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
stats = await native_project_stats(db, project.id)
|
||||
|
||||
data = project_dict(project, member_count=len(project.members), task_stats=stats)
|
||||
data["identifier"] = str(project.id)
|
||||
data["members"] = [project_member_dict(m) for m in project.members]
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.patch("/{id}")
|
||||
async def update_project(
|
||||
id: str,
|
||||
payload: ProjectUpdateSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update project details, status, or dates."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
|
||||
if payload.name is not None:
|
||||
project.name = payload.name.strip()
|
||||
if payload.description is not None:
|
||||
project.description = payload.description.strip()
|
||||
if payload.location is not None:
|
||||
project.location = payload.location.strip()
|
||||
if payload.project_manager is not None:
|
||||
project.project_manager = payload.project_manager.strip()
|
||||
if payload.status is not None:
|
||||
project.status = payload.status.upper()
|
||||
if payload.progress is not None:
|
||||
project.progress = max(0.0, min(100.0, payload.progress))
|
||||
|
||||
if payload.planned_start is not None:
|
||||
project.planned_start = date_service.parse(payload.planned_start)
|
||||
if payload.planned_end is not None:
|
||||
project.planned_end = date_service.parse(payload.planned_end)
|
||||
if payload.actual_start is not None:
|
||||
project.actual_start = date_service.parse(payload.actual_start)
|
||||
if payload.actual_end is not None:
|
||||
project.actual_end = date_service.parse(payload.actual_end)
|
||||
|
||||
if project.planned_start and project.planned_end and project.planned_end < project.planned_start:
|
||||
raise ValidationError("تاریخ پایان نمیتواند قبل از تاریخ شروع باشد", field="planned_end")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
stats = await native_project_stats(db, project.id)
|
||||
return {"data": project_dict(project, member_count=len(project.members), task_stats=stats)}
|
||||
|
||||
|
||||
@router.post("/{id}/members")
|
||||
async def add_project_member(
|
||||
id: str,
|
||||
payload: ProjectMemberCreateSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a team member to the project."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
target_user_id = uuid.UUID(payload.user_id)
|
||||
|
||||
# Check if already a member
|
||||
existing = await db.scalar(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
existing.role = payload.role
|
||||
else:
|
||||
member = ProjectMember(
|
||||
project_id=project.id,
|
||||
user_id=target_user_id,
|
||||
role=payload.role,
|
||||
)
|
||||
db.add(member)
|
||||
|
||||
await db.commit()
|
||||
# Return updated members list
|
||||
await db.refresh(project)
|
||||
project = await _resolve_project(db, ctx, project.id)
|
||||
return {"data": [project_member_dict(m) for m in project.members]}
|
||||
|
||||
|
||||
@router.delete("/{id}/members/{user_id}")
|
||||
async def remove_project_member(
|
||||
id: str,
|
||||
user_id: str,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove a team member from the project."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
target_user_id = uuid.UUID(user_id)
|
||||
|
||||
member = await db.scalar(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
|
||||
)
|
||||
)
|
||||
if member:
|
||||
await db.delete(member)
|
||||
await db.commit()
|
||||
|
||||
project = await _resolve_project(db, ctx, project.id)
|
||||
return {"data": [project_member_dict(m) for m in project.members]}
|
||||
|
||||
|
||||
@router.patch("/{id}/erp-mapping")
|
||||
async def map_erp_project(
|
||||
id: str,
|
||||
payload: ErpMappingSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
return {"data": {"id": str(project.id)}}
|
||||
|
||||
|
||||
# --- Relations & Dependencies ---
|
||||
|
||||
@router.get("/{id}/relations")
|
||||
async def list_project_relations(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all task dependencies in a project."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
stmt = (
|
||||
select(TaskRelation)
|
||||
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
|
||||
.where(TaskRelation.project_id == project.id)
|
||||
)
|
||||
relations = (await db.execute(stmt)).scalars().all()
|
||||
return {"data": [task_relation_dict(r) for r in relations]}
|
||||
|
||||
|
||||
@router.post("/{id}/relations")
|
||||
async def create_task_relation(
|
||||
id: str,
|
||||
payload: TaskRelationCreateSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new task dependency (FS, SS, FF, SF + lag). Enforces cycle check."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
from_id = uuid.UUID(payload.from_task_id)
|
||||
to_id = uuid.UUID(payload.to_task_id)
|
||||
|
||||
if from_id == to_id:
|
||||
raise ValidationError("یک وظیفه نمیتواند پیشنیاز خودش باشد")
|
||||
|
||||
# Verify tasks belong to this project
|
||||
from_task = await db.get(Task, from_id)
|
||||
to_task = await db.get(Task, to_id)
|
||||
if not from_task or from_task.project_id != project.id:
|
||||
raise ValidationError("وظیفه پیشنیاز در این پروژه یافت نشد")
|
||||
if not to_task or to_task.project_id != project.id:
|
||||
raise ValidationError("وظیفه وابسته در این پروژه یافت نشد")
|
||||
|
||||
# Load existing relations to verify DAG
|
||||
existing_rels = (
|
||||
await db.execute(select(TaskRelation.from_task_id, TaskRelation.to_task_id).where(TaskRelation.project_id == project.id))
|
||||
).all()
|
||||
existing_pairs = [(r[0], r[1]) for r in existing_rels]
|
||||
|
||||
# Check for duplicate
|
||||
if (from_id, to_id) in existing_pairs:
|
||||
raise ValidationError("این وابستگی قبلاً ثبت شده است")
|
||||
|
||||
# Cycle check
|
||||
if check_for_cycle(existing_pairs, from_id, to_id):
|
||||
raise ValidationError("این وابستگی باعث ایجاد چرخه (حلقه تکرار) در ساختار زمانبندی میشود")
|
||||
|
||||
rtype = payload.relation_type.upper()
|
||||
if rtype not in ("FS", "SS", "FF", "SF"):
|
||||
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
|
||||
|
||||
relation = TaskRelation(
|
||||
project_id=project.id,
|
||||
from_task_id=from_id,
|
||||
to_task_id=to_id,
|
||||
relation_type=rtype,
|
||||
lag_days=payload.lag_days,
|
||||
)
|
||||
db.add(relation)
|
||||
await db.commit()
|
||||
|
||||
# Recalculate schedule cascade automatically
|
||||
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
|
||||
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
|
||||
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
|
||||
|
||||
# Update tasks with new early dates if changed
|
||||
for t in all_tasks:
|
||||
if t.id in sched_result["tasks_schedule"]:
|
||||
info = sched_result["tasks_schedule"][t.id]
|
||||
t.planned_start = info["planned_start"]
|
||||
t.planned_end = info["planned_end"]
|
||||
t.duration = info["duration"]
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(relation)
|
||||
return {"data": task_relation_dict(relation, from_task.title, to_task.title)}
|
||||
|
||||
|
||||
@router.patch("/{id}/relations/{relation_id}")
|
||||
async def update_task_relation(
|
||||
id: str,
|
||||
relation_id: str,
|
||||
payload: TaskRelationUpdateSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Edit a dependency's type and/or lag, then re-run the schedule cascade."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
rel = await db.scalar(
|
||||
select(TaskRelation)
|
||||
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
|
||||
.where(TaskRelation.id == uuid.UUID(relation_id), TaskRelation.project_id == project.id)
|
||||
)
|
||||
if rel is None:
|
||||
raise NotFoundError("وابستگی یافت نشد")
|
||||
|
||||
if payload.relation_type is not None:
|
||||
rtype = payload.relation_type.upper()
|
||||
if rtype not in ("FS", "SS", "FF", "SF"):
|
||||
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
|
||||
rel.relation_type = rtype
|
||||
if payload.lag_days is not None:
|
||||
rel.lag_days = payload.lag_days
|
||||
await db.commit()
|
||||
|
||||
# Recalculate the schedule so the edit takes effect immediately
|
||||
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
|
||||
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
|
||||
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
|
||||
for t in all_tasks:
|
||||
if t.id in sched_result["tasks_schedule"]:
|
||||
info = sched_result["tasks_schedule"][t.id]
|
||||
t.planned_start = info["planned_start"]
|
||||
t.planned_end = info["planned_end"]
|
||||
t.duration = info["duration"]
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(rel)
|
||||
return {"data": task_relation_dict(rel, rel.from_task.title, rel.to_task.title)}
|
||||
|
||||
|
||||
@router.delete("/{id}/relations/{relation_id}")
|
||||
async def delete_task_relation(
|
||||
id: str,
|
||||
relation_id: str,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a task dependency."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
rel_uuid = uuid.UUID(relation_id)
|
||||
rel = await db.scalar(
|
||||
select(TaskRelation).where(TaskRelation.id == rel_uuid, TaskRelation.project_id == project.id)
|
||||
)
|
||||
if rel:
|
||||
await db.delete(rel)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
# --- Interactive Gantt & Topological Scheduling ---
|
||||
|
||||
@router.get("/{id}/gantt")
|
||||
async def get_project_gantt(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return full Gantt chart dataset with CPM calculations, relations, and critical path."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
|
||||
# 1. Fetch tasks, relations, milestones
|
||||
tasks = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.options(selectinload(Task.children))
|
||||
.where(Task.project_id == project.id)
|
||||
.order_by(Task.planned_start.asc().nulls_last(), Task.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
relations = (
|
||||
await db.execute(
|
||||
select(TaskRelation)
|
||||
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
|
||||
.where(TaskRelation.project_id == project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# 2. Run CPM scheduling analysis
|
||||
sched = calculate_schedule(tasks, relations, project.planned_start)
|
||||
sched_info = sched["tasks_schedule"]
|
||||
crit_set = set(sched["critical_path"])
|
||||
|
||||
# 3. Format tasks
|
||||
formatted_tasks = []
|
||||
for t in tasks:
|
||||
t_sched = sched_info.get(t.id, {})
|
||||
start_dt = t_sched.get("planned_start") or t.planned_start
|
||||
end_dt = t_sched.get("planned_end") or t.planned_end
|
||||
formatted_tasks.append({
|
||||
"id": str(t.id),
|
||||
"title": t.title,
|
||||
"subject": t.title,
|
||||
"parent_id": str(t.parent_id) if t.parent_id else None,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"planned_start": _iso(start_dt),
|
||||
"planned_end": _iso(end_dt),
|
||||
"planned_start_jalali": to_jalali_str(start_dt),
|
||||
"planned_end_jalali": to_jalali_str(end_dt),
|
||||
"duration": t_sched.get("duration") or t.duration or 1,
|
||||
"progress": t.progress or 0,
|
||||
"is_critical": t.id in crit_set,
|
||||
"float_days": t_sched.get("float_days", 0),
|
||||
"qc_status": t.qc_status or "none",
|
||||
"assignee_name": t.assignee_name or (t.assignee.display_name if getattr(t, "assignee", None) else None),
|
||||
})
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"project": {
|
||||
"id": str(project.id),
|
||||
"identifier": str(project.id),
|
||||
"name": project.name,
|
||||
"planned_start": _iso(project.planned_start),
|
||||
"planned_end": _iso(project.planned_end),
|
||||
"planned_start_jalali": to_jalali_str(project.planned_start),
|
||||
"planned_end_jalali": to_jalali_str(project.planned_end),
|
||||
"progress": project.progress,
|
||||
},
|
||||
"tasks": formatted_tasks,
|
||||
"relations": [task_relation_dict(r) for r in relations],
|
||||
"critical_path": [str(tid) for tid in sched["critical_path"]],
|
||||
"project_finish": _iso(sched.get("project_finish")),
|
||||
"project_finish_jalali": to_jalali_str(sched.get("project_finish")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{id}/reschedule")
|
||||
async def reschedule_project(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Trigger full topological forward-pass recalculation and persist cascaded dates."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
|
||||
tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
|
||||
relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
|
||||
|
||||
sched = calculate_schedule(tasks, relations, project.planned_start)
|
||||
sched_info = sched["tasks_schedule"]
|
||||
|
||||
updated_count = 0
|
||||
for t in tasks:
|
||||
if t.id in sched_info:
|
||||
info = sched_info[t.id]
|
||||
t.planned_start = info["planned_start"]
|
||||
t.planned_end = info["planned_end"]
|
||||
t.duration = info["duration"]
|
||||
updated_count += 1
|
||||
|
||||
# Roll up summary tasks
|
||||
rollups = rollup_parent_tasks(tasks)
|
||||
for pid, rdata in rollups.items():
|
||||
parent_task = await db.get(Task, pid)
|
||||
if parent_task:
|
||||
if rdata["planned_start"]:
|
||||
parent_task.planned_start = rdata["planned_start"]
|
||||
if rdata["planned_end"]:
|
||||
parent_task.planned_end = rdata["planned_end"]
|
||||
if rdata.get("duration"):
|
||||
parent_task.duration = rdata["duration"]
|
||||
|
||||
await sync_project_lifecycle_dates(db, project.id)
|
||||
await db.commit()
|
||||
return {"data": {"recalculated": True, "tasks_updated": updated_count}}
|
||||
@@ -0,0 +1,729 @@
|
||||
"""QC module.
|
||||
|
||||
Two clearly separated halves:
|
||||
|
||||
**تعریف (Definition)** — what must eventually be checked
|
||||
* `QCTemplate` + `QCQuestion` : global, reusable, editable
|
||||
* `TaskQCDefinition`/`TaskQCQuestion`: a task-specific COPY the user customizes
|
||||
|
||||
**اجرا (Execution)** — the actual evaluation during the project
|
||||
* `TaskQCResult` : append-only answers (pass / fail / na) + comment
|
||||
* `Defect` : problems raised on a task, with corrective action
|
||||
|
||||
Applying a template to a task deep-copies its questions, so customizing a task
|
||||
never mutates the global template.
|
||||
|
||||
Access split (app/authz.py):
|
||||
* `qc.define` — templates + the per-task QC structure: project manager only
|
||||
* `qc.execute` — recording answers and defects, on assigned projects
|
||||
* `qc.view` — reading status/readiness, on assigned projects
|
||||
"""
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.authz import (
|
||||
AuthContext,
|
||||
Perm,
|
||||
require_permission,
|
||||
scoped_task,
|
||||
)
|
||||
from app.db import get_db
|
||||
from app.errors import NotFoundError, ValidationError
|
||||
from app.models import (
|
||||
Defect,
|
||||
QCAnswer,
|
||||
QCQuestion,
|
||||
QCQuestionPhase,
|
||||
QCTemplate,
|
||||
Task,
|
||||
TaskQCDefinition,
|
||||
TaskQCQuestion,
|
||||
TaskQCResult,
|
||||
)
|
||||
from app.qc_service import (
|
||||
PRE,
|
||||
build_state,
|
||||
definition_dict,
|
||||
load_definitions,
|
||||
question_dict,
|
||||
recompute_task_qc_status,
|
||||
state_dict,
|
||||
task_qc_state,
|
||||
)
|
||||
from app.serializers import _iso
|
||||
|
||||
router = APIRouter(prefix="/qc", tags=["qc"])
|
||||
|
||||
define_qc = require_permission(Perm.qc_define)
|
||||
execute_qc = require_permission(Perm.qc_execute)
|
||||
view_qc = require_permission(Perm.qc_view)
|
||||
|
||||
PHASE_LABELS = {PRE: "پیش از اجرا", "during": "هنگام اجرا", "after": "پس از اجرا"}
|
||||
|
||||
|
||||
def _phase(value: str) -> QCQuestionPhase:
|
||||
try:
|
||||
return QCQuestionPhase(value)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("مرحله باید pre، during یا after باشد", field="phase") from exc
|
||||
|
||||
|
||||
def _answer(value: str) -> QCAnswer:
|
||||
try:
|
||||
return QCAnswer(value)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("پاسخ باید pass، fail یا na باشد", field="answer") from exc
|
||||
|
||||
|
||||
@dataclass
|
||||
class QCTaskRef:
|
||||
"""A task as the QC module sees it."""
|
||||
|
||||
id: uuid.UUID
|
||||
subject: str
|
||||
progress: int
|
||||
status: str
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return self.id
|
||||
|
||||
|
||||
async def _qc_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> QCTaskRef:
|
||||
"""Resolve the task a QC screen is about, enforcing the caller's scope."""
|
||||
native = await scoped_task(db, ctx, task_ref)
|
||||
return QCTaskRef(
|
||||
id=native.id,
|
||||
subject=native.title or "",
|
||||
progress=native.progress or 0,
|
||||
status=native.status or "",
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_definition_scope(db: AsyncSession, ctx: AuthContext, definition: TaskQCDefinition) -> None:
|
||||
native = await db.get(Task, definition.task_id)
|
||||
if native is not None:
|
||||
ctx.ensure_project(native.project_id)
|
||||
return
|
||||
raise NotFoundError("تعریف QC یافت نشد")
|
||||
|
||||
|
||||
async def _definition(db: AsyncSession, definition_id: uuid.UUID) -> TaskQCDefinition:
|
||||
row = await db.scalar(
|
||||
select(TaskQCDefinition)
|
||||
.options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results))
|
||||
.where(TaskQCDefinition.id == definition_id)
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("تعریف QC یافت نشد")
|
||||
return row
|
||||
|
||||
|
||||
async def _scoped_definition(db: AsyncSession, ctx: AuthContext, definition_id: uuid.UUID) -> TaskQCDefinition:
|
||||
definition = await _definition(db, definition_id)
|
||||
await _ensure_definition_scope(db, ctx, definition)
|
||||
return definition
|
||||
|
||||
|
||||
async def _question(db: AsyncSession, question_id: uuid.UUID) -> TaskQCQuestion:
|
||||
row = await db.scalar(
|
||||
select(TaskQCQuestion)
|
||||
.options(selectinload(TaskQCQuestion.results))
|
||||
.where(TaskQCQuestion.id == question_id)
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("سؤال QC یافت نشد")
|
||||
return row
|
||||
|
||||
|
||||
async def _scoped_definition_question(
|
||||
db: AsyncSession, ctx: AuthContext, question_id: uuid.UUID
|
||||
) -> tuple[TaskQCQuestion, TaskQCDefinition]:
|
||||
question = await _question(db, question_id)
|
||||
definition = await db.get(TaskQCDefinition, question.definition_id)
|
||||
if definition is None:
|
||||
raise NotFoundError("تعریف QC یافت نشد")
|
||||
await _ensure_definition_scope(db, ctx, definition)
|
||||
return question, definition
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Global QC templates
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _template_dict(template: QCTemplate) -> dict:
|
||||
return {
|
||||
"id": str(template.id),
|
||||
"name": template.name,
|
||||
"activity_type": template.activity_type,
|
||||
"description": template.description,
|
||||
"questions": [
|
||||
{
|
||||
"id": str(q.id),
|
||||
"phase": q.phase.value,
|
||||
"text": q.text,
|
||||
"sort_order": q.sort_order,
|
||||
"required": q.required,
|
||||
}
|
||||
for q in template.questions
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _template(db: AsyncSession, template_id: uuid.UUID) -> QCTemplate:
|
||||
row = await db.scalar(
|
||||
select(QCTemplate)
|
||||
.options(selectinload(QCTemplate.questions))
|
||||
.where(QCTemplate.id == template_id)
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("قالب QC یافت نشد")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(activity_type: str | None = None, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(QCTemplate).options(selectinload(QCTemplate.questions)).order_by(QCTemplate.name)
|
||||
if activity_type:
|
||||
stmt = stmt.where(QCTemplate.activity_type == activity_type)
|
||||
templates = (await db.execute(stmt)).scalars().all()
|
||||
return {"data": [_template_dict(t) for t in templates]}
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
async def get_template(template_id: uuid.UUID, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
|
||||
return {"data": _template_dict(await _template(db, template_id))}
|
||||
|
||||
|
||||
class QuestionIn(BaseModel):
|
||||
phase: str
|
||||
text: str
|
||||
required: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class TemplateIn(BaseModel):
|
||||
name: str
|
||||
activity_type: str = ""
|
||||
description: str = ""
|
||||
questions: list[QuestionIn] = []
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _name_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("نام قالب الزامی است")
|
||||
return v.strip()
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(payload: TemplateIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
|
||||
template = QCTemplate(
|
||||
name=payload.name,
|
||||
activity_type=payload.activity_type,
|
||||
description=payload.description,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
for index, q in enumerate(payload.questions):
|
||||
db.add(
|
||||
QCQuestion(
|
||||
template_id=template.id,
|
||||
phase=_phase(q.phase),
|
||||
text=q.text.strip(),
|
||||
sort_order=q.sort_order if q.sort_order else index,
|
||||
required=q.required,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {"data": _template_dict(await _template(db, template.id))}
|
||||
|
||||
|
||||
class TemplatePatch(BaseModel):
|
||||
name: str | None = None
|
||||
activity_type: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}")
|
||||
async def patch_template(
|
||||
template_id: uuid.UUID, payload: TemplatePatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
template = await _template(db, template_id)
|
||||
if payload.name is not None:
|
||||
if not payload.name.strip():
|
||||
raise ValidationError("نام قالب نمیتواند خالی باشد", field="name")
|
||||
template.name = payload.name.strip()
|
||||
if payload.activity_type is not None:
|
||||
template.activity_type = payload.activity_type
|
||||
if payload.description is not None:
|
||||
template.description = payload.description
|
||||
await db.commit()
|
||||
return {"data": _template_dict(template)}
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def delete_template(template_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
|
||||
template = await _template(db, template_id)
|
||||
await db.delete(template)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/questions")
|
||||
async def add_template_question(
|
||||
template_id: uuid.UUID, payload: QuestionIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
template = await _template(db, template_id)
|
||||
question = QCQuestion(
|
||||
template_id=template.id,
|
||||
phase=_phase(payload.phase),
|
||||
text=payload.text.strip(),
|
||||
sort_order=payload.sort_order,
|
||||
required=payload.required,
|
||||
)
|
||||
db.add(question)
|
||||
await db.commit()
|
||||
return {"data": _template_dict(await _template(db, template_id))}
|
||||
|
||||
|
||||
class QuestionPatch(BaseModel):
|
||||
text: str | None = None
|
||||
phase: str | None = None
|
||||
required: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
@router.patch("/questions/{question_id}")
|
||||
async def patch_question(
|
||||
question_id: uuid.UUID, payload: QuestionPatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
question = await db.get(QCQuestion, question_id)
|
||||
if question is None:
|
||||
raise NotFoundError("سؤال QC یافت نشد")
|
||||
if payload.text is not None:
|
||||
if not payload.text.strip():
|
||||
raise ValidationError("متن سؤال نمیتواند خالی باشد", field="text")
|
||||
question.text = payload.text.strip()
|
||||
if payload.phase is not None:
|
||||
question.phase = _phase(payload.phase)
|
||||
if payload.required is not None:
|
||||
question.required = payload.required
|
||||
if payload.sort_order is not None:
|
||||
question.sort_order = payload.sort_order
|
||||
await db.commit()
|
||||
return {"data": {"id": str(question.id)}}
|
||||
|
||||
|
||||
@router.delete("/questions/{question_id}")
|
||||
async def delete_question(question_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
|
||||
question = await db.get(QCQuestion, question_id)
|
||||
if question is None:
|
||||
raise NotFoundError("سؤال QC یافت نشد")
|
||||
await db.delete(question)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task QC Definitions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DefinitionIn(BaseModel):
|
||||
name: str = ""
|
||||
template_id: str | None = None
|
||||
|
||||
|
||||
@router.get("/tasks/{task_ref}")
|
||||
async def task_qc(
|
||||
task_ref: str, ctx: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Everything the QC screen needs: definitions + questions + results + readiness."""
|
||||
task = await _qc_task(db, ctx, task_ref)
|
||||
definitions = await load_definitions(db, task.key)
|
||||
state = build_state(definitions)
|
||||
return {
|
||||
"data": {
|
||||
"task": {
|
||||
"id": str(task.id),
|
||||
"subject": task.subject,
|
||||
"percentage_done": task.progress,
|
||||
"status": task.status,
|
||||
},
|
||||
"definitions": [definition_dict(d, state) for d in definitions],
|
||||
"readiness": state_dict(state, progress=task.progress),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_ref}/definition")
|
||||
async def create_definition(
|
||||
task_ref: str,
|
||||
payload: DefinitionIn,
|
||||
ctx: AuthContext = Depends(define_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await _qc_task(db, ctx, task_ref)
|
||||
template: QCTemplate | None = None
|
||||
if payload.template_id:
|
||||
try:
|
||||
template_uuid = uuid.UUID(payload.template_id)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("شناسه قالب نامعتبر است", field="template_id") from exc
|
||||
template = await _template(db, template_uuid)
|
||||
|
||||
definition = TaskQCDefinition(
|
||||
task_id=task.id,
|
||||
name=payload.name.strip() or (template.name if template else "QC تسک"),
|
||||
source_template_id=template.id if template else None,
|
||||
)
|
||||
db.add(definition)
|
||||
await db.flush()
|
||||
|
||||
if template:
|
||||
for index, question in enumerate(template.questions):
|
||||
db.add(
|
||||
TaskQCQuestion(
|
||||
definition_id=definition.id,
|
||||
phase=question.phase,
|
||||
text=question.text,
|
||||
sort_order=index,
|
||||
required=question.required,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, task.key)
|
||||
await db.commit()
|
||||
return {"data": {"id": str(definition.id)}}
|
||||
|
||||
|
||||
class DefinitionPatch(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.patch("/definitions/{definition_id}")
|
||||
async def patch_definition(
|
||||
definition_id: uuid.UUID,
|
||||
payload: DefinitionPatch,
|
||||
ctx: AuthContext = Depends(define_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
definition = await _scoped_definition(db, ctx, definition_id)
|
||||
if not payload.name.strip():
|
||||
raise ValidationError("نام تعریف الزامی است", field="name")
|
||||
definition.name = payload.name.strip()
|
||||
await db.commit()
|
||||
return {"data": {"id": str(definition.id), "name": definition.name}}
|
||||
|
||||
|
||||
@router.delete("/definitions/{definition_id}")
|
||||
async def delete_definition(
|
||||
definition_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
definition = await _scoped_definition(db, ctx, definition_id)
|
||||
task_key = definition.task_id
|
||||
await db.delete(definition)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, task_key)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
class TaskQuestionIn(BaseModel):
|
||||
text: str
|
||||
phase: str
|
||||
required: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
@router.post("/definitions/{definition_id}/questions")
|
||||
async def add_task_question(
|
||||
definition_id: uuid.UUID,
|
||||
payload: TaskQuestionIn,
|
||||
ctx: AuthContext = Depends(define_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
definition = await _scoped_definition(db, ctx, definition_id)
|
||||
question = TaskQCQuestion(
|
||||
definition_id=definition.id,
|
||||
phase=_phase(payload.phase),
|
||||
text=payload.text.strip(),
|
||||
sort_order=payload.sort_order,
|
||||
required=payload.required,
|
||||
)
|
||||
db.add(question)
|
||||
definition.updated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
return {"data": {"id": str(question.id)}}
|
||||
|
||||
|
||||
@router.patch("/task-questions/{question_id}")
|
||||
async def patch_task_question(
|
||||
question_id: uuid.UUID,
|
||||
payload: QuestionPatch,
|
||||
ctx: AuthContext = Depends(define_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
question, definition = await _scoped_definition_question(db, ctx, question_id)
|
||||
values = payload.model_dump(exclude_unset=True)
|
||||
if "text" in values:
|
||||
if not values["text"].strip():
|
||||
raise ValidationError("متن سؤال نمیتواند خالی باشد", field="text")
|
||||
question.text = values["text"].strip()
|
||||
values.pop("text")
|
||||
if "phase" in values:
|
||||
question.phase = _phase(values.pop("phase"))
|
||||
for key, value in values.items():
|
||||
setattr(question, key, value)
|
||||
definition.updated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
return {"data": {"id": str(question.id)}}
|
||||
|
||||
|
||||
@router.delete("/task-questions/{question_id}")
|
||||
async def delete_task_question(
|
||||
question_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
question, definition = await _scoped_definition_question(db, ctx, question_id)
|
||||
task_key = definition.task_id
|
||||
await db.delete(question)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, task_key)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Execution / evaluation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ResultIn(BaseModel):
|
||||
answer: str | None = None
|
||||
comment: str = ""
|
||||
photo_id: str | None = None
|
||||
|
||||
|
||||
@router.put("/task-questions/{question_id}/result")
|
||||
async def evaluate_question(
|
||||
question_id: uuid.UUID,
|
||||
payload: ResultIn,
|
||||
ctx: AuthContext = Depends(execute_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
question, definition = await _scoped_definition_question(db, ctx, question_id)
|
||||
|
||||
# اگر پاسخ خالی یا null باشد، انتخاب قبلی لغو شده و سؤال به حالت بدون پاسخ برمیگردد
|
||||
if not payload.answer or payload.answer.strip().lower() in ("none", "null", "cancel", "clear", "pending"):
|
||||
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
|
||||
await db.commit()
|
||||
status = await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
state = await task_qc_state(db, definition.task_id)
|
||||
return {
|
||||
"data": {
|
||||
"question_id": str(question.id),
|
||||
"answer": None,
|
||||
"task_qc_status": status,
|
||||
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
|
||||
}
|
||||
}
|
||||
|
||||
user = ctx.user
|
||||
answer = _answer(payload.answer)
|
||||
|
||||
existing_latest = await db.scalar(
|
||||
select(TaskQCResult)
|
||||
.where(TaskQCResult.question_id == question.id)
|
||||
.order_by(TaskQCResult.evaluated_at.desc(), TaskQCResult.id.desc())
|
||||
)
|
||||
photo_path = payload.photo_id
|
||||
if photo_path is None and existing_latest and existing_latest.answer == answer:
|
||||
photo_path = existing_latest.photo_path
|
||||
elif photo_path == "":
|
||||
photo_path = None
|
||||
|
||||
db.add(
|
||||
TaskQCResult(
|
||||
question_id=question.id,
|
||||
answer=answer,
|
||||
comment=payload.comment.strip(),
|
||||
photo_path=photo_path,
|
||||
evaluated_by=user.display_name or user.username,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
status = await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
state = await task_qc_state(db, definition.task_id)
|
||||
return {
|
||||
"data": {
|
||||
"question_id": str(question.id),
|
||||
"answer": answer.value,
|
||||
"photo_url": f"/api/v1/files/{photo_path}" if photo_path else None,
|
||||
"task_qc_status": status,
|
||||
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/task-questions/{question_id}/result")
|
||||
async def delete_question_result(
|
||||
question_id: uuid.UUID,
|
||||
ctx: AuthContext = Depends(execute_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
question, definition = await _scoped_definition_question(db, ctx, question_id)
|
||||
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
|
||||
await db.commit()
|
||||
status = await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
state = await task_qc_state(db, definition.task_id)
|
||||
return {
|
||||
"data": {
|
||||
"question_id": str(question.id),
|
||||
"answer": None,
|
||||
"task_qc_status": status,
|
||||
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def _progress(db: AsyncSession, task_id: uuid.UUID) -> int:
|
||||
task = await db.scalar(select(Task).where(Task.id == task_id))
|
||||
return (task.progress or 0) if task else 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Defects
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _defect_dict(defect: Defect) -> dict:
|
||||
return {
|
||||
"id": str(defect.id),
|
||||
"task_id": str(defect.task_id),
|
||||
"question_id": str(defect.question_id) if defect.question_id else None,
|
||||
"title": defect.title,
|
||||
"description": defect.description,
|
||||
"severity": defect.severity,
|
||||
"status": defect.status,
|
||||
"corrective_action": defect.corrective_action,
|
||||
"resolution_notes": defect.resolution_notes,
|
||||
"photo_url": f"/api/v1/files/{defect.photo_path}" if defect.photo_path else None,
|
||||
"created_at": _iso(defect.created_at),
|
||||
"resolved_at": _iso(defect.resolved_at),
|
||||
}
|
||||
|
||||
|
||||
class DefectIn(BaseModel):
|
||||
task_ref: str
|
||||
question_id: str | None = None
|
||||
title: str
|
||||
description: str = ""
|
||||
severity: str = "medium"
|
||||
corrective_action: str = ""
|
||||
photo_id: str | None = None
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def _title(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("عنوان عیب الزامی است")
|
||||
return v.strip()
|
||||
|
||||
|
||||
@router.post("/defects")
|
||||
async def create_defect(
|
||||
payload: DefectIn, ctx: AuthContext = Depends(execute_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
task = await _qc_task(db, ctx, payload.task_ref)
|
||||
question_id = None
|
||||
if payload.question_id:
|
||||
try:
|
||||
question_id = uuid.UUID(payload.question_id)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("شناسه سؤال نامعتبر است", field="question_id") from exc
|
||||
defect = Defect(
|
||||
task_id=task.id,
|
||||
question_id=question_id,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
severity=payload.severity if payload.severity in ("low", "medium", "high") else "medium",
|
||||
corrective_action=payload.corrective_action,
|
||||
photo_path=payload.photo_id,
|
||||
)
|
||||
db.add(defect)
|
||||
await db.commit()
|
||||
return {"data": _defect_dict(defect)}
|
||||
|
||||
|
||||
class ResolveIn(BaseModel):
|
||||
resolution_notes: str = ""
|
||||
|
||||
|
||||
@router.post("/defects/{defect_id}/resolve")
|
||||
async def resolve_defect(
|
||||
defect_id: uuid.UUID,
|
||||
payload: ResolveIn,
|
||||
ctx: AuthContext = Depends(execute_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
defect = await db.get(Defect, defect_id)
|
||||
if defect is None:
|
||||
raise NotFoundError("عیب یافت نشد")
|
||||
await scoped_task(db, ctx, defect.task_id)
|
||||
if defect.status == "resolved":
|
||||
raise ValidationError("این عیب قبلاً رفع شده است")
|
||||
defect.status = "resolved"
|
||||
defect.resolution_notes = payload.resolution_notes.strip()
|
||||
defect.resolved_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"data": _defect_dict(defect)}
|
||||
|
||||
|
||||
@router.get("/defects")
|
||||
async def list_defects(
|
||||
task_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
ctx: AuthContext = Depends(view_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Defect).join(Task, Task.id == Defect.task_id)
|
||||
if task_id:
|
||||
query = query.where(Defect.task_id == task_id)
|
||||
if status:
|
||||
query = query.where(Defect.status == status)
|
||||
query = ctx.scope(query, Task.project_id)
|
||||
query = query.order_by(Defect.created_at.desc()).limit(150)
|
||||
rows = (await db.execute(query)).scalars().all()
|
||||
|
||||
items = []
|
||||
for d in rows:
|
||||
task = await db.get(Task, d.task_id)
|
||||
di = _defect_dict(d)
|
||||
di["task_title"] = task.title if task else ""
|
||||
items.append(di)
|
||||
return {"data": items}
|
||||
|
||||
|
||||
async def defects_for_task(db: AsyncSession, task_id: uuid.UUID) -> list[Defect]:
|
||||
rows = (
|
||||
await db.execute(select(Defect).where(Defect.task_id == task_id).order_by(Defect.created_at.desc()))
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
@@ -0,0 +1,834 @@
|
||||
"""Tasks: Native PM Construction Platform Tasks & Activities.
|
||||
|
||||
Supports:
|
||||
- Hierarchical tasks (Project -> Phase -> Activity -> Subtask) via self-referencing `parent_id`.
|
||||
- Dates, duration, status, priorities, progress.
|
||||
- Full dependency relations & scheduling.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.authz import AuthContext, Perm, require_permission
|
||||
from app.db import get_db
|
||||
from app.errors import NotFoundError, ValidationError
|
||||
from app.models import Project, ProjectMember, Task, TaskPriority, TaskProgressLog, TaskRelation, TaskStatus
|
||||
from app.qc_service import build_state, definition_dict, load_definitions, state_dict
|
||||
from app.routers.qc import _defect_dict, defects_for_task
|
||||
from app.serializers import _iso, native_task_dict, task_relation_dict
|
||||
from app.utils.date_service import date_service
|
||||
from app.scheduling_service import sync_project_lifecycle_dates
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
view_tasks = require_permission(Perm.tasks_view)
|
||||
manage_tasks = require_permission(Perm.tasks_create)
|
||||
update_tasks = require_permission(Perm.tasks_update)
|
||||
progress_tasks = require_permission(Perm.tasks_progress)
|
||||
|
||||
|
||||
class HumanResourceItem(BaseModel):
|
||||
role: str = Field(..., min_length=1)
|
||||
count: int = Field(1, ge=1)
|
||||
|
||||
|
||||
class MaterialItem(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
unit: str = Field("", max_length=64)
|
||||
specifications: list[str] = Field(default_factory=list)
|
||||
count: float = Field(1.0, ge=0)
|
||||
|
||||
|
||||
class TaskCreateSchema(BaseModel):
|
||||
project_id: str
|
||||
parent_id: str | None = None
|
||||
title: str = Field(..., min_length=1, max_length=512)
|
||||
description: str = ""
|
||||
status: str = TaskStatus.TODO.value
|
||||
priority: str = TaskPriority.MEDIUM.value
|
||||
assignee_id: str | None = None
|
||||
planned_start: str | None = None
|
||||
planned_end: str | None = None
|
||||
duration: int | None = None
|
||||
human_resources: list[HumanResourceItem] = Field(default_factory=list)
|
||||
materials: list[MaterialItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskUpdateSchema(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
priority: str | None = None
|
||||
assignee: str | None = None
|
||||
assignee_id: str | None = None
|
||||
parent_id: str | None = None
|
||||
planned_start: str | None = None
|
||||
planned_end: str | None = None
|
||||
duration: int | None = None
|
||||
duration_days: int | None = None
|
||||
human_resources: list[HumanResourceItem] | None = None
|
||||
materials: list[MaterialItem] | None = None
|
||||
|
||||
|
||||
class HumanResourceConsumption(BaseModel):
|
||||
role: str = ""
|
||||
planned_count: int | None = None
|
||||
actual_count: int = 1
|
||||
note: str = ""
|
||||
|
||||
|
||||
class MaterialConsumption(BaseModel):
|
||||
name: str = ""
|
||||
unit: str = ""
|
||||
amount: float = 0
|
||||
allocated_amount: float | None = None
|
||||
previous_consumed: float | None = None
|
||||
total_consumed: float | None = None
|
||||
remaining_amount: float | None = None
|
||||
is_over_allocated: bool = False
|
||||
over_allocated_amount: float = 0.0
|
||||
note: str = ""
|
||||
|
||||
|
||||
class TaskProgressSchema(BaseModel):
|
||||
percentage: int = Field(..., ge=0, le=100)
|
||||
human_resources_used: list[HumanResourceConsumption] = Field(default_factory=list)
|
||||
materials_used: list[MaterialConsumption] = Field(default_factory=list)
|
||||
description: str = ""
|
||||
|
||||
|
||||
class TaskDatesSchema(BaseModel):
|
||||
start_date: str | None = None
|
||||
due_date: str | None = None
|
||||
|
||||
|
||||
async def _resolve_task(db: AsyncSession, ctx: AuthContext, task_id_or_op: str | int | uuid.UUID) -> Task:
|
||||
"""Find a native task by UUID, enforcing the caller's scope."""
|
||||
query = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project),
|
||||
selectinload(Task.assignee),
|
||||
selectinload(Task.children),
|
||||
selectinload(Task.parent),
|
||||
selectinload(Task.progress_logs),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
val_uuid = uuid.UUID(str(task_id_or_op))
|
||||
stmt = query.where(Task.id == val_uuid)
|
||||
except ValueError:
|
||||
raise NotFoundError("تسک یافت نشد")
|
||||
|
||||
task = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not task:
|
||||
raise NotFoundError("تسک یافت نشد")
|
||||
ctx.ensure_native_project(task.project_id)
|
||||
return task
|
||||
|
||||
|
||||
async def _validate_task_dates_and_hierarchy(
|
||||
db: AsyncSession,
|
||||
task_id: uuid.UUID | None,
|
||||
planned_start: date | None,
|
||||
planned_end: date | None,
|
||||
duration: int | None,
|
||||
parent_id: uuid.UUID | None,
|
||||
actual_start: date | None = None,
|
||||
actual_end: date | None = None,
|
||||
) -> None:
|
||||
# 0. اجباری بودن تاریخ شروع و تاریخ پایان
|
||||
if not planned_start:
|
||||
raise ValidationError("تاریخ شروع وظیفه الزامی است", field="planned_start")
|
||||
if not planned_end:
|
||||
raise ValidationError("تاریخ پایان وظیفه الزامی است", field="planned_end")
|
||||
|
||||
# 1. Start date must not be later than end date
|
||||
if planned_start and planned_end and planned_start > planned_end:
|
||||
raise ValidationError(
|
||||
f"تاریخ شروع یک وظیفه ({date_service.to_jalali(planned_start)}) نمیتواند بعد از تاریخ اتمام آن ({date_service.to_jalali(planned_end)}) باشد",
|
||||
field="planned_start",
|
||||
)
|
||||
|
||||
if actual_start and actual_end and actual_start > actual_end:
|
||||
raise ValidationError(
|
||||
f"تاریخ شروع واقعی ({date_service.to_jalali(actual_start)}) نمیتواند بعد از تاریخ پایان واقعی ({date_service.to_jalali(actual_end)}) باشد",
|
||||
field="actual_start",
|
||||
)
|
||||
|
||||
# 2. Parent vs Child constraint: Parent must envelop child
|
||||
if parent_id:
|
||||
parent = await db.scalar(select(Task).where(Task.id == parent_id))
|
||||
if parent:
|
||||
if parent.planned_start and planned_start and planned_start < parent.planned_start:
|
||||
raise ValidationError(
|
||||
f"تاریخ شروع وظیفه فرزند ({date_service.to_jalali(planned_start)}) نمیتواند قبل از تاریخ شروع وظیفه والد ({date_service.to_jalali(parent.planned_start)}) باشد",
|
||||
field="planned_start",
|
||||
)
|
||||
if parent.planned_end and planned_end and planned_end > parent.planned_end:
|
||||
raise ValidationError(
|
||||
f"تاریخ پایان وظیفه فرزند ({date_service.to_jalali(planned_end)}) نمیتواند بعد از تاریخ پایان وظیفه والد ({date_service.to_jalali(parent.planned_end)}) باشد",
|
||||
field="planned_end",
|
||||
)
|
||||
if parent.duration and duration and duration > parent.duration:
|
||||
raise ValidationError(
|
||||
f"مدت زمان وظیفه والد ({parent.duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند ({duration} روز) باشد",
|
||||
field="duration",
|
||||
)
|
||||
|
||||
# 3. If this task is a parent, its time span/duration must be >= all its children
|
||||
if task_id:
|
||||
children_stmt = select(Task).where(Task.parent_id == task_id)
|
||||
children = (await db.execute(children_stmt)).scalars().all()
|
||||
for c in children:
|
||||
if planned_start and c.planned_start and planned_start > c.planned_start:
|
||||
raise ValidationError(
|
||||
f"تاریخ شروع وظیفه والد ({date_service.to_jalali(planned_start)}) نمیتواند بعد از تاریخ شروع وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_start)}) باشد",
|
||||
field="planned_start",
|
||||
)
|
||||
if planned_end and c.planned_end and planned_end < c.planned_end:
|
||||
raise ValidationError(
|
||||
f"تاریخ پایان وظیفه والد ({date_service.to_jalali(planned_end)}) نمیتواند قبل از تاریخ پایان وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_end)}) باشد",
|
||||
field="planned_end",
|
||||
)
|
||||
if duration and c.duration and duration < c.duration:
|
||||
raise ValidationError(
|
||||
f"مدت زمان وظیفه والد ({duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند «{c.title}» ({c.duration} روز) باشد",
|
||||
field="duration",
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tasks(
|
||||
project_id: str | None = None,
|
||||
parent_id: str | None = None,
|
||||
search: str | None = None,
|
||||
status: str | None = None,
|
||||
qc_status: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(300, ge=1, le=1000),
|
||||
ctx: AuthContext = Depends(view_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List native tasks with flexible filtering."""
|
||||
stmt = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project),
|
||||
selectinload(Task.assignee),
|
||||
selectinload(Task.children),
|
||||
)
|
||||
.order_by(Task.created_at.asc())
|
||||
)
|
||||
|
||||
# Filter by project
|
||||
if project_id is not None:
|
||||
try:
|
||||
p_uuid = uuid.UUID(project_id)
|
||||
stmt = stmt.where(Task.project_id == p_uuid)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Project scoping for non-managers — native projects are keyed by UUID
|
||||
stmt = ctx.scope_native(stmt, Task.project_id)
|
||||
|
||||
if parent_id is not None:
|
||||
if parent_id.lower() in ("null", "root", "none"):
|
||||
stmt = stmt.where(Task.parent_id.is_(None))
|
||||
else:
|
||||
stmt = stmt.where(Task.parent_id == uuid.UUID(parent_id))
|
||||
|
||||
if search:
|
||||
stmt = stmt.where(Task.title.ilike(f"%{search.strip()}%"))
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(Task.status == status.upper())
|
||||
|
||||
if qc_status:
|
||||
stmt = stmt.where(Task.qc_status == qc_status)
|
||||
|
||||
total = (await db.scalar(select(func.count()).select_from(stmt.subquery()))) or 0
|
||||
tasks = (await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars().all()
|
||||
|
||||
# Count children in memory from loaded tasks to prevent lazy-load I/O
|
||||
child_counts: dict[uuid.UUID, int] = {}
|
||||
for t in tasks:
|
||||
if t.parent_id:
|
||||
child_counts[t.parent_id] = child_counts.get(t.parent_id, 0) + 1
|
||||
|
||||
results = []
|
||||
for t in tasks:
|
||||
p_name = t.project.name if t.project else None
|
||||
c_count = child_counts.get(t.id, 0)
|
||||
d = native_task_dict(t, project_name=p_name, children_count=c_count)
|
||||
d["subject"] = t.title
|
||||
d["percentage_done"] = t.progress
|
||||
d["start_date"] = _iso(t.planned_start)
|
||||
d["due_date"] = _iso(t.planned_end)
|
||||
d["is_closed"] = t.status == TaskStatus.COMPLETED.value
|
||||
results.append(d)
|
||||
|
||||
return {"data": results, "meta": {"total": total}}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_task(
|
||||
payload: TaskCreateSchema,
|
||||
ctx: AuthContext = Depends(manage_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new native task / subtask."""
|
||||
# Resolve project
|
||||
try:
|
||||
p_uuid = uuid.UUID(payload.project_id)
|
||||
project = await db.scalar(select(Project).where(Project.id == p_uuid))
|
||||
except ValueError:
|
||||
project = None
|
||||
|
||||
if not project:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
|
||||
# Resolve parent task if specified
|
||||
parent_uuid = None
|
||||
if payload.parent_id:
|
||||
parent_task = await _resolve_task(db, ctx, payload.parent_id)
|
||||
parent_uuid = parent_task.id
|
||||
|
||||
p_start = date_service.parse(payload.planned_start)
|
||||
p_end = date_service.parse(payload.planned_end)
|
||||
duration = payload.duration or 0
|
||||
|
||||
if p_start and p_end:
|
||||
duration = date_service.calculate_duration(p_start, p_end)
|
||||
elif p_start and duration:
|
||||
p_end = date_service.compute_end_date(p_start, duration)
|
||||
elif p_end and duration:
|
||||
p_start = p_end - timedelta(days=duration)
|
||||
|
||||
await _validate_task_dates_and_hierarchy(
|
||||
db=db,
|
||||
task_id=None,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
duration=duration,
|
||||
parent_id=parent_uuid,
|
||||
)
|
||||
|
||||
assignee_uuid = None
|
||||
assignee_name = None
|
||||
if payload.assignee_id:
|
||||
try:
|
||||
assignee_uuid = uuid.UUID(payload.assignee_id)
|
||||
except ValueError:
|
||||
assignee_name = payload.assignee_id
|
||||
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
parent_id=parent_uuid,
|
||||
title=payload.title.strip(),
|
||||
description=payload.description.strip(),
|
||||
status=payload.status.upper(),
|
||||
priority=payload.priority.lower(),
|
||||
assignee_id=assignee_uuid,
|
||||
assignee_name=assignee_name,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
duration=duration,
|
||||
progress=0,
|
||||
human_resources=[hr.model_dump() for hr in payload.human_resources],
|
||||
materials=[m.model_dump() for m in payload.materials],
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
await sync_project_lifecycle_dates(db, project.id)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
d = native_task_dict(task, project_name=project.name)
|
||||
d["subject"] = task.title
|
||||
d["percentage_done"] = task.progress
|
||||
return {"data": d}
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_task_detail(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get full task details, including hierarchy, QC state, and defects."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
|
||||
# QC definitions and execution state
|
||||
definitions = await load_definitions(db, task.id)
|
||||
state = build_state(definitions)
|
||||
|
||||
# Defects
|
||||
defects = await defects_for_task(db, task.id)
|
||||
|
||||
children_data = []
|
||||
for c in task.children:
|
||||
c_dict = native_task_dict(c, project_name=task.project.name if task.project else None)
|
||||
c_dict["subject"] = c.title
|
||||
c_dict["percentage_done"] = c.progress
|
||||
children_data.append(c_dict)
|
||||
|
||||
data = native_task_dict(
|
||||
task,
|
||||
project_name=task.project.name if task.project else None,
|
||||
children_count=len(task.children),
|
||||
)
|
||||
data["subject"] = task.title
|
||||
data["percentage_done"] = task.progress
|
||||
data["start_date"] = _iso(task.planned_start)
|
||||
data["due_date"] = _iso(task.planned_end)
|
||||
data["start_date_jalali"] = date_service.to_jalali(task.planned_start)
|
||||
data["due_date_jalali"] = date_service.to_jalali(task.planned_end)
|
||||
data["is_closed"] = task.status == TaskStatus.COMPLETED.value
|
||||
|
||||
# Load task relations (predecessors and successors)
|
||||
rel_stmt = (
|
||||
select(TaskRelation)
|
||||
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
|
||||
.where(
|
||||
or_(
|
||||
TaskRelation.from_task_id == task.id,
|
||||
TaskRelation.to_task_id == task.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task_rels = (await db.execute(rel_stmt)).scalars().all()
|
||||
incoming = []
|
||||
outgoing = []
|
||||
for r in task_rels:
|
||||
rd = task_relation_dict(r)
|
||||
if r.to_task_id == task.id:
|
||||
# incoming: predecessor (from_task) -> current task
|
||||
incoming.append({
|
||||
**rd,
|
||||
"id": str(r.id),
|
||||
"op_wp_id": str(r.from_task_id),
|
||||
"task_id": str(r.from_task_id),
|
||||
"subject": r.from_task.title if r.from_task else "",
|
||||
"relation_type": r.relation_type,
|
||||
"lag_days": r.lag_days,
|
||||
})
|
||||
if r.from_task_id == task.id:
|
||||
# outgoing: current task -> successor (to_task)
|
||||
outgoing.append({
|
||||
**rd,
|
||||
"id": str(r.id),
|
||||
"op_wp_id": str(r.to_task_id),
|
||||
"task_id": str(r.to_task_id),
|
||||
"subject": r.to_task.title if r.to_task else "",
|
||||
"relation_type": r.relation_type,
|
||||
"lag_days": r.lag_days,
|
||||
})
|
||||
data["relations"] = {"blocks": outgoing, "blocked_by": incoming}
|
||||
data["all_relations"] = [task_relation_dict(r) for r in task_rels]
|
||||
data["children"] = children_data
|
||||
data["parent"] = (
|
||||
{"id": str(task.parent.id), "title": task.parent.title} if task.parent else None
|
||||
)
|
||||
data["qc"] = state_dict(state, progress=task.progress)
|
||||
data["qc_definitions"] = [definition_dict(d, state) for d in definitions]
|
||||
data["defects"] = [_defect_dict(d) for d in defects]
|
||||
data["open_defects"] = sum(1 for d in defects if d.status != "resolved")
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.patch("/{id}")
|
||||
async def update_task(
|
||||
id: str,
|
||||
payload: TaskUpdateSchema,
|
||||
ctx: AuthContext = Depends(update_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update task title, description, status, priority, or dates."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
|
||||
if payload.title is not None:
|
||||
task.title = payload.title.strip()
|
||||
if payload.description is not None:
|
||||
task.description = payload.description.strip()
|
||||
if payload.status is not None:
|
||||
task.status = payload.status.upper()
|
||||
if payload.priority is not None:
|
||||
task.priority = payload.priority.lower()
|
||||
if payload.assignee is not None:
|
||||
task.assignee_name = payload.assignee.strip() or None
|
||||
if payload.assignee_id is not None:
|
||||
try:
|
||||
task.assignee_id = uuid.UUID(payload.assignee_id)
|
||||
except ValueError:
|
||||
task.assignee_name = payload.assignee_id
|
||||
if payload.parent_id is not None:
|
||||
clean_p = payload.parent_id.strip().lower()
|
||||
if not clean_p or clean_p in ("none", "null", "root"):
|
||||
task.parent_id = None
|
||||
else:
|
||||
parent = await _resolve_task(db, ctx, payload.parent_id)
|
||||
if parent.id == task.id:
|
||||
raise ValidationError("تسک نمیتواند والد خودش باشد")
|
||||
# Prevent cycle: ensure task is not in ancestors of parent
|
||||
check = parent
|
||||
while check:
|
||||
if check.id == task.id:
|
||||
raise ValidationError("نمیتوان یک زیرتسک را به عنوان والد انتخاب کرد (وابستگی چرخهای)")
|
||||
if check.parent_id:
|
||||
check = await db.scalar(select(Task).where(Task.id == check.parent_id))
|
||||
else:
|
||||
break
|
||||
task.parent_id = parent.id
|
||||
|
||||
p_start = task.planned_start
|
||||
p_end = task.planned_end
|
||||
a_start = task.actual_start
|
||||
a_end = task.actual_end
|
||||
dur = task.duration
|
||||
|
||||
if payload.planned_start is not None:
|
||||
p_start = date_service.parse(payload.planned_start)
|
||||
if payload.planned_end is not None:
|
||||
p_end = date_service.parse(payload.planned_end)
|
||||
|
||||
dur_input = payload.duration if payload.duration is not None else payload.duration_days
|
||||
if dur_input is not None:
|
||||
dur = max(0, dur_input)
|
||||
if p_start:
|
||||
p_end = date_service.compute_end_date(p_start, dur)
|
||||
elif p_start and p_end:
|
||||
dur = date_service.calculate_duration(p_start, p_end)
|
||||
|
||||
await _validate_task_dates_and_hierarchy(
|
||||
db=db,
|
||||
task_id=task.id,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
duration=dur,
|
||||
parent_id=task.parent_id,
|
||||
actual_start=a_start,
|
||||
actual_end=a_end,
|
||||
)
|
||||
|
||||
task.planned_start = p_start
|
||||
task.planned_end = p_end
|
||||
task.duration = dur
|
||||
|
||||
if payload.human_resources is not None:
|
||||
task.human_resources = [hr.model_dump() for hr in payload.human_resources]
|
||||
if payload.materials is not None:
|
||||
task.materials = [m.model_dump() for m in payload.materials]
|
||||
|
||||
await sync_project_lifecycle_dates(db, task.project_id)
|
||||
await db.commit()
|
||||
task = await _resolve_task(db, ctx, task.id)
|
||||
return {"data": native_task_dict(task, project_name=task.project.name if task.project else None)}
|
||||
|
||||
|
||||
@router.patch("/{id}/progress")
|
||||
async def update_task_progress(
|
||||
id: str,
|
||||
payload: TaskProgressSchema,
|
||||
ctx: AuthContext = Depends(progress_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update task progress (0-100%) with strict server-side QC validation rules."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
percentage = payload.percentage
|
||||
|
||||
# 1. Evaluate QC business rules
|
||||
definitions = await load_definitions(db, task.id)
|
||||
state = build_state(definitions)
|
||||
|
||||
# Server-side Invariant Rule 1: pre-QC must be satisfied if progress > 0
|
||||
if percentage > 0 and not state.can_start:
|
||||
unresolved = state.pre.required_unresolved
|
||||
msg = (
|
||||
f"بازرسی پیش از اجرا تأیید نشده است ({unresolved} سؤال باقیمانده)"
|
||||
if unresolved > 0
|
||||
else "بازرسی پیش از اجرا مردود شده است و نیاز به رفع و بازرسی مجدد دارد"
|
||||
)
|
||||
raise ValidationError(msg, field="percentage")
|
||||
|
||||
# Server-side Invariant Rule 2: all required QC must be satisfied if progress >= 100
|
||||
if percentage >= 100 and not state.can_complete:
|
||||
unresolved = state.all_.required_unresolved
|
||||
failed = state.all_.failed
|
||||
raise ValidationError(
|
||||
f"تا زمانی که همه بازرسیهای الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست ({unresolved} سؤال باقیمانده، {failed} مورد مردود)",
|
||||
field="percentage",
|
||||
)
|
||||
|
||||
# 2. Update task progress and lifecycle dates
|
||||
progress_before = task.progress or 0
|
||||
task.progress = percentage
|
||||
if percentage > 0:
|
||||
if not task.actual_start:
|
||||
task.actual_start = date_service.today()
|
||||
if task.status == TaskStatus.TODO.value:
|
||||
task.status = TaskStatus.IN_PROGRESS.value
|
||||
else:
|
||||
# percentage == 0: reset actual dates
|
||||
task.actual_start = None
|
||||
task.actual_end = None
|
||||
task.status = TaskStatus.TODO.value
|
||||
|
||||
if percentage >= 100:
|
||||
task.actual_end = date_service.today()
|
||||
task.status = TaskStatus.COMPLETED.value
|
||||
elif 0 < percentage < 100:
|
||||
task.actual_end = None
|
||||
if task.status == TaskStatus.COMPLETED.value:
|
||||
task.status = TaskStatus.IN_PROGRESS.value
|
||||
|
||||
# 3. Log consumption of human resources and materials
|
||||
hr_list = [h.model_dump() for h in payload.human_resources_used if h.role.strip()]
|
||||
|
||||
# Build allocation lookup from task.materials
|
||||
alloc_map = {}
|
||||
for item in (task.materials or []):
|
||||
if isinstance(item, dict):
|
||||
m_name = (item.get("name") or "").strip().lower()
|
||||
if m_name:
|
||||
alloc_map[m_name] = item
|
||||
|
||||
# Calculate previously consumed amounts from prior logs
|
||||
past_consumed_map: dict[str, float] = {}
|
||||
for plog in (task.progress_logs or []):
|
||||
for pmat in (plog.materials_used or []):
|
||||
if isinstance(pmat, dict):
|
||||
p_name = (pmat.get("name") or "").strip().lower()
|
||||
if p_name:
|
||||
past_consumed_map[p_name] = past_consumed_map.get(p_name, 0.0) + float(pmat.get("amount") or 0.0)
|
||||
|
||||
mat_list = []
|
||||
for m in payload.materials_used:
|
||||
name = m.name.strip()
|
||||
if not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
matched = alloc_map.get(key)
|
||||
|
||||
# Base allocation: explicitly passed or from task.materials
|
||||
if m.allocated_amount is not None:
|
||||
alloc_amt = float(m.allocated_amount)
|
||||
elif matched:
|
||||
alloc_amt = float(matched.get("count") or 0.0)
|
||||
else:
|
||||
alloc_amt = 0.0
|
||||
|
||||
unit = m.unit.strip() or (matched.get("unit", "") if matched else "")
|
||||
amount = float(m.amount or 0.0)
|
||||
prev_consumed = past_consumed_map.get(key, 0.0)
|
||||
tot_consumed = prev_consumed + amount
|
||||
rem_amount = max(0.0, alloc_amt - tot_consumed) if alloc_amt > 0 else 0.0
|
||||
is_over = (tot_consumed > alloc_amt) if alloc_amt > 0 else (tot_consumed > 0)
|
||||
over_amt = max(0.0, tot_consumed - alloc_amt) if alloc_amt > 0 else tot_consumed
|
||||
|
||||
mat_list.append({
|
||||
"name": name,
|
||||
"unit": unit,
|
||||
"amount": amount,
|
||||
"allocated_amount": alloc_amt,
|
||||
"previous_consumed": prev_consumed,
|
||||
"total_consumed": tot_consumed,
|
||||
"remaining_amount": rem_amount,
|
||||
"is_over_allocated": is_over,
|
||||
"over_allocated_amount": over_amt,
|
||||
"note": m.note.strip() if m.note else "",
|
||||
})
|
||||
|
||||
log = TaskProgressLog(
|
||||
task_id=task.id,
|
||||
progress_before=progress_before,
|
||||
progress_after=percentage,
|
||||
human_resources_used=hr_list,
|
||||
materials_used=mat_list,
|
||||
description=payload.description.strip(),
|
||||
logged_by=ctx.user.display_name or ctx.user.username,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await sync_project_lifecycle_dates(db, task.project_id)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"id": str(task.id),
|
||||
"progress": task.progress,
|
||||
"status": task.status,
|
||||
"qc_status": task.qc_status,
|
||||
"actual_start": _iso(task.actual_start),
|
||||
"actual_end": _iso(task.actual_end),
|
||||
"progress_log": {
|
||||
"id": str(log.id),
|
||||
"progress_before": log.progress_before,
|
||||
"progress_after": log.progress_after,
|
||||
"human_resources_used": log.human_resources_used,
|
||||
"materials_used": log.materials_used,
|
||||
"description": log.description,
|
||||
"logged_by": log.logged_by,
|
||||
"created_at": _iso(log.created_at),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{id}/material-audit")
|
||||
async def get_task_material_audit(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""استعلام مقایسهای مصالح تخصیصیافته در برابر کل مصارف واقعی و گزارش اضافه مصرف."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
|
||||
alloc_map = {}
|
||||
for m in (task.materials or []):
|
||||
if isinstance(m, dict):
|
||||
name = (m.get("name") or "").strip()
|
||||
if name:
|
||||
alloc_map[name.lower()] = {
|
||||
"name": name,
|
||||
"unit": m.get("unit") or "",
|
||||
"allocated_amount": float(m.get("count") or 0.0),
|
||||
"specifications": m.get("specifications") or [],
|
||||
}
|
||||
|
||||
consumption_map: dict[str, dict] = {}
|
||||
for log in (task.progress_logs or []):
|
||||
for mu in (log.materials_used or []):
|
||||
if isinstance(mu, dict):
|
||||
mname = (mu.get("name") or "").strip()
|
||||
if not mname:
|
||||
continue
|
||||
key = mname.lower()
|
||||
m_amt = float(mu.get("amount") or 0.0)
|
||||
m_unit = mu.get("unit") or ""
|
||||
if key not in consumption_map:
|
||||
consumption_map[key] = {
|
||||
"name": mname,
|
||||
"unit": m_unit,
|
||||
"total_consumed": 0.0,
|
||||
}
|
||||
consumption_map[key]["total_consumed"] += m_amt
|
||||
|
||||
all_keys = set(alloc_map.keys()) | set(consumption_map.keys())
|
||||
audit_items = []
|
||||
total_over_allocated_count = 0
|
||||
|
||||
for key in sorted(all_keys):
|
||||
alloc = alloc_map.get(key)
|
||||
consumed = consumption_map.get(key)
|
||||
|
||||
name = alloc["name"] if alloc else consumed["name"]
|
||||
unit = alloc["unit"] if alloc else consumed["unit"]
|
||||
alloc_amt = alloc["allocated_amount"] if alloc else 0.0
|
||||
tot_cons = consumed["total_consumed"] if consumed else 0.0
|
||||
rem_amt = max(0.0, alloc_amt - tot_cons) if alloc_amt > 0 else 0.0
|
||||
is_over = (tot_cons > alloc_amt) if alloc_amt > 0 else (tot_cons > 0)
|
||||
over_amt = max(0.0, tot_cons - alloc_amt) if alloc_amt > 0 else tot_cons
|
||||
|
||||
if is_over:
|
||||
total_over_allocated_count += 1
|
||||
|
||||
audit_items.append({
|
||||
"name": name,
|
||||
"unit": unit,
|
||||
"allocated_amount": alloc_amt,
|
||||
"total_consumed": tot_cons,
|
||||
"remaining_amount": rem_amt,
|
||||
"is_over_allocated": is_over,
|
||||
"over_allocated_amount": over_amt,
|
||||
"specifications": alloc["specifications"] if alloc else [],
|
||||
})
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"task_id": str(task.id),
|
||||
"task_title": task.title,
|
||||
"has_over_allocation": total_over_allocated_count > 0,
|
||||
"over_allocated_items_count": total_over_allocated_count,
|
||||
"items": audit_items,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{id}/dates")
|
||||
async def update_task_dates(
|
||||
id: str,
|
||||
payload: TaskDatesSchema,
|
||||
ctx: AuthContext = Depends(update_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update task dates (dual Jalali/ISO compatible)."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
|
||||
p_start = task.planned_start
|
||||
p_end = task.planned_end
|
||||
|
||||
if payload.start_date is not None:
|
||||
p_start = date_service.parse(payload.start_date)
|
||||
if payload.due_date is not None:
|
||||
p_end = date_service.parse(payload.due_date)
|
||||
|
||||
dur = task.duration
|
||||
if p_start and p_end:
|
||||
dur = date_service.calculate_duration(p_start, p_end)
|
||||
|
||||
await _validate_task_dates_and_hierarchy(
|
||||
db=db,
|
||||
task_id=task.id,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
duration=dur,
|
||||
parent_id=task.parent_id,
|
||||
actual_start=task.actual_start,
|
||||
actual_end=task.actual_end,
|
||||
)
|
||||
|
||||
task.planned_start = p_start
|
||||
task.planned_end = p_end
|
||||
task.duration = dur
|
||||
|
||||
await sync_project_lifecycle_dates(db, task.project_id)
|
||||
await db.commit()
|
||||
task = await _resolve_task(db, ctx, task.id)
|
||||
return {
|
||||
"data": {
|
||||
"id": str(task.id),
|
||||
"start_date": _iso(task.planned_start),
|
||||
"due_date": _iso(task.planned_end),
|
||||
"start_date_jalali": date_service.to_jalali(task.planned_start),
|
||||
"due_date_jalali": date_service.to_jalali(task.planned_end),
|
||||
"duration": task.duration,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_task(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a task and its children."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
project_id = task.project_id
|
||||
await db.delete(task)
|
||||
await db.flush()
|
||||
await sync_project_lifecycle_dates(db, project_id)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True, "id": id}}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""User and project-assignment administration (role `project_manager` only).
|
||||
|
||||
Creating the accounts that hold a scoped role and deciding *which* projects the
|
||||
scoped role may touch is what makes the RBAC in app/authz.py usable; nothing
|
||||
here changes an operational record.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.authz import ROLE_LABELS, AuthContext, Perm, Role, require_permission, role_of
|
||||
from app.db import get_db
|
||||
from app.dev_accounts import clear_assignments
|
||||
from app.errors import NotFoundError, ValidationError
|
||||
from app.models import PMUser, Project, ProjectAssignment
|
||||
from app.security import hash_password
|
||||
from app.serializers import _iso
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
manage = require_permission(Perm.users_manage)
|
||||
|
||||
|
||||
def _role_value(value: str) -> str:
|
||||
try:
|
||||
return Role(value).value
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"نقش نامعتبر است: {value}", field="role") from exc
|
||||
|
||||
|
||||
async def _assigned_project_ids(db: AsyncSession, user_id) -> list[str]:
|
||||
"""Projects assigned to a user, as native UUIDs."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ProjectAssignment.project_id).where(
|
||||
ProjectAssignment.user_id == user_id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
return sorted(str(pid) for pid in rows if pid is not None)
|
||||
|
||||
|
||||
async def _user_dict(db: AsyncSession, user: PMUser) -> dict:
|
||||
role = role_of(user)
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"role": role.value,
|
||||
"role_label": ROLE_LABELS.get(role.value, role.value),
|
||||
"scoped": role in (Role.project_supervisor,),
|
||||
"project_ids": await _assigned_project_ids(db, user.id),
|
||||
"created_at": _iso(user.created_at),
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
|
||||
users = (await db.execute(select(PMUser).order_by(PMUser.username))).scalars().all()
|
||||
return {"data": [await _user_dict(db, u) for u in users]}
|
||||
|
||||
|
||||
class UserIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
display_name: str = ""
|
||||
role: str = Role.project_supervisor.value
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def _username(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("نام کاربری الزامی است")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def _password(cls, v: str) -> str:
|
||||
if len(v) < 6:
|
||||
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
|
||||
return v
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_user(payload: UserIn, ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
|
||||
if existing is not None:
|
||||
raise ValidationError("این نام کاربری قبلاً ثبت شده است", field="username")
|
||||
user = PMUser(
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
display_name=payload.display_name.strip(),
|
||||
role=_role_value(payload.role),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return {"data": await _user_dict(db, user)}
|
||||
|
||||
|
||||
class UserPatch(BaseModel):
|
||||
display_name: str | None = None
|
||||
role: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def _password(cls, v: str | None) -> str | None:
|
||||
if v is not None and len(v) < 6:
|
||||
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
|
||||
return v
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
async def patch_user(
|
||||
user_id: uuid.UUID,
|
||||
payload: UserPatch,
|
||||
ctx: AuthContext = Depends(manage),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await db.get(PMUser, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
if payload.display_name is not None:
|
||||
user.display_name = payload.display_name.strip()
|
||||
if payload.password:
|
||||
user.password_hash = hash_password(payload.password)
|
||||
if payload.role is not None:
|
||||
new_role = _role_value(payload.role)
|
||||
# never let a manager remove their own manager role (lock-out guard)
|
||||
if user.id == ctx.user.id and new_role != Role.project_manager.value:
|
||||
raise ValidationError("نقش مدیر فعلی خود را نمیتوانید تغییر دهید", field="role")
|
||||
user.role = new_role
|
||||
await db.commit()
|
||||
return {"data": await _user_dict(db, user)}
|
||||
|
||||
|
||||
class ProjectsIn(BaseModel):
|
||||
"""Native project UUIDs (the live world). Legacy integer ids still accepted."""
|
||||
|
||||
project_ids: list[str] = []
|
||||
|
||||
|
||||
@router.put("/{user_id}/projects")
|
||||
async def set_user_projects(
|
||||
user_id: uuid.UUID,
|
||||
payload: ProjectsIn,
|
||||
ctx: AuthContext = Depends(manage),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Replace a user's assigned projects (the scope a supervisor is limited to)."""
|
||||
user = await db.get(PMUser, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
|
||||
native_ids: list[uuid.UUID] = []
|
||||
legacy_ids: list[int] = []
|
||||
unknown: list[str] = []
|
||||
for raw in dict.fromkeys(payload.project_ids):
|
||||
raw = str(raw).strip()
|
||||
parsed = None
|
||||
try:
|
||||
parsed = uuid.UUID(raw)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
|
||||
if parsed is not None:
|
||||
if await db.get(Project, parsed) is None:
|
||||
unknown.append(raw)
|
||||
else:
|
||||
native_ids.append(parsed)
|
||||
else:
|
||||
unknown.append(raw)
|
||||
|
||||
if unknown:
|
||||
raise ValidationError(
|
||||
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
|
||||
)
|
||||
|
||||
await clear_assignments(db, user.id)
|
||||
for project_uuid in native_ids:
|
||||
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
|
||||
await db.commit()
|
||||
return {"data": await _user_dict(db, user)}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared utilities (Jalali dates, ...)."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
httpx==0.28.1
|
||||
python-multipart==0.0.20
|
||||
alembic==1.14.0
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.24.0
|
||||
aiosqlite==0.20.0
|
||||
|
||||
pyjwt[crypto]>=2.8.0
|
||||
@@ -0,0 +1,94 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Base, PMUser, Project, Task
|
||||
from app.security import create_token, hash_password
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gantt_and_relations_api():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
admin = PMUser(
|
||||
username="admin",
|
||||
password_hash=hash_password("admin123"),
|
||||
display_name="مدیر ارشد",
|
||||
role="project_manager",
|
||||
)
|
||||
session.add(admin)
|
||||
await session.flush()
|
||||
|
||||
project = Project(name="پروژه تست گانت", description="تست")
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
|
||||
task1 = Task(project_id=project.id, title="فونداسیون", duration=5)
|
||||
task2 = Task(project_id=project.id, title="اسکلت بتنی", duration=10)
|
||||
session.add_all([task1, task2])
|
||||
await session.commit()
|
||||
|
||||
admin_id = admin.id
|
||||
p_id = str(project.id)
|
||||
t1_id = str(task1.id)
|
||||
t2_id = str(task2.id)
|
||||
|
||||
token = create_token(admin_id)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async def override_get_db():
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
try:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test", headers=headers) as client:
|
||||
# 1. Add relation: task1 -> task2 (FS + 2 days lag)
|
||||
rel_res = await client.post(
|
||||
f"/api/v1/projects/{p_id}/relations",
|
||||
json={"from_task_id": t1_id, "to_task_id": t2_id, "relation_type": "FS", "lag_days": 2},
|
||||
)
|
||||
assert rel_res.status_code == 200
|
||||
rel_data = rel_res.json()["data"]
|
||||
assert rel_data["relation_type"] == "FS"
|
||||
assert rel_data["lag_days"] == 2
|
||||
rel_id = rel_data["id"]
|
||||
|
||||
# 2. Add reverse relation: task2 -> task1 (should detect cycle and reject with 400)
|
||||
cycle_res = await client.post(
|
||||
f"/api/v1/projects/{p_id}/relations",
|
||||
json={"from_task_id": t2_id, "to_task_id": t1_id, "relation_type": "FS"},
|
||||
)
|
||||
assert cycle_res.status_code == 400
|
||||
assert "چرخه" in cycle_res.json()["error"]["message"]
|
||||
|
||||
# 3. Query Gantt dataset
|
||||
gantt_res = await client.get(f"/api/v1/projects/{p_id}/gantt")
|
||||
assert gantt_res.status_code == 200
|
||||
gantt_data = gantt_res.json()["data"]
|
||||
assert len(gantt_data["tasks"]) == 2
|
||||
assert len(gantt_data["relations"]) == 1
|
||||
assert len(gantt_data["critical_path"]) >= 1
|
||||
|
||||
# 4. Delete relation
|
||||
del_rel = await client.delete(f"/api/v1/projects/{p_id}/relations/{rel_id}")
|
||||
assert del_rel.status_code == 200
|
||||
assert del_rel.json()["data"]["deleted"] is True
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,207 @@
|
||||
"""API tests for native Projects and Tasks endpoints."""
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Base, PMUser, Project, ProjectStatus, Task
|
||||
from app.security import create_token, hash_password
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_project_and_task_api():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
# Seed admin user
|
||||
async with async_session() as session:
|
||||
admin = PMUser(
|
||||
username="admin",
|
||||
password_hash=hash_password("admin123"),
|
||||
display_name="مدیر ارشد",
|
||||
role="project_manager",
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
admin_id = admin.id
|
||||
|
||||
token = create_token(admin_id)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def override_get_db():
|
||||
async with async_session() as s:
|
||||
yield s
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# 1. Create a native project
|
||||
resp = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={
|
||||
"name": "پروژه بیمارستان",
|
||||
"description": "احداث فاز ۲ بیمارستان",
|
||||
"location": "تهران",
|
||||
"status": "ACTIVE",
|
||||
"planned_start": "1405/01/01",
|
||||
"planned_end": "1405/12/29",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
proj_data = resp.json()["data"]
|
||||
project_id = proj_data["id"]
|
||||
assert proj_data["name"] == "پروژه بیمارستان"
|
||||
assert proj_data["status"] == "ACTIVE"
|
||||
|
||||
# 2. List projects
|
||||
resp = await client.get("/api/v1/projects", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
projects_list = resp.json()["data"]
|
||||
assert len(projects_list) == 1
|
||||
assert projects_list[0]["id"] == project_id
|
||||
|
||||
# 3. Create a root task (Phase)
|
||||
resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "فاز ۱: اسکلتبندی",
|
||||
"priority": "high",
|
||||
"planned_start": "1405/01/01",
|
||||
"duration": 60,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
phase_task = resp.json()["data"]
|
||||
phase_id = phase_task["id"]
|
||||
assert phase_task["title"] == "فاز ۱: اسکلتبندی"
|
||||
|
||||
# 4. Create a child task (Subtask)
|
||||
resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"parent_id": phase_id,
|
||||
"title": "فعالیت ۱.۱: آرماتوربندی ستونها",
|
||||
"priority": "urgent",
|
||||
"planned_start": "1405/01/05",
|
||||
"duration": 15,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
subtask = resp.json()["data"]
|
||||
assert subtask["parent_id"] == phase_id
|
||||
|
||||
# 5. Get Phase task detail and verify children
|
||||
resp = await client.get(f"/api/v1/tasks/{phase_id}", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
detail = resp.json()["data"]
|
||||
assert len(detail["children"]) == 1
|
||||
assert detail["children"][0]["id"] == subtask["id"]
|
||||
|
||||
# 6. Update task progress with QC invariant rule
|
||||
# Since no QC questions exist for this task, progress can increase directly
|
||||
resp = await client.patch(
|
||||
f"/api/v1/tasks/{subtask['id']}/progress",
|
||||
json={"percentage": 50},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["progress"] == 50
|
||||
assert resp.json()["data"]["status"] == "IN_PROGRESS"
|
||||
|
||||
# 7. Verify mandatory start/end dates: task without dates must be rejected
|
||||
resp_no_dates = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "وظیفه بدون تاریخ",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp_no_dates.status_code in (400, 422)
|
||||
|
||||
# 8. Verify project creation with simplified fields (no code, client, dates required)
|
||||
resp_simple_proj = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={
|
||||
"name": "پروژه ساده نوساز",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp_simple_proj.status_code == 200
|
||||
simple_data = resp_simple_proj.json()["data"]
|
||||
assert simple_data["name"] == "پروژه ساده نوساز"
|
||||
assert "id" in simple_data
|
||||
|
||||
# 9. Verify task creation and update with Human Resources and Materials (including equipment)
|
||||
resp_resources_task = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "بتنریزی فونداسیون با منابع کامل",
|
||||
"planned_start": "1405/01/10",
|
||||
"duration": 5,
|
||||
"human_resources": [
|
||||
{"role": "سرکارگر بتنریزی", "count": 1},
|
||||
{"role": "کارگر ساده", "count": 6},
|
||||
],
|
||||
"materials": [
|
||||
{
|
||||
"name": "بتن آماده عیار ۳۵۰",
|
||||
"unit": "متر مکعب",
|
||||
"specifications": ["تیپ ۲", "اسلامپ ۸"],
|
||||
"count": 150.5,
|
||||
},
|
||||
{
|
||||
"name": "پمپ بتن دکلی",
|
||||
"unit": "دستگاه",
|
||||
"specifications": ["دکل ۳۶ متری"],
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp_resources_task.status_code == 200
|
||||
res_data = resp_resources_task.json()["data"]
|
||||
assert len(res_data["human_resources"]) == 2
|
||||
assert res_data["human_resources"][0]["role"] == "سرکارگر بتنریزی"
|
||||
assert len(res_data["materials"]) == 2
|
||||
assert res_data["materials"][0]["unit"] == "متر مکعب"
|
||||
assert res_data["materials"][0]["specifications"] == ["تیپ ۲", "اسلامپ ۸"]
|
||||
assert res_data["materials"][1]["name"] == "پمپ بتن دکلی"
|
||||
|
||||
# Update resources via PATCH
|
||||
task_id = res_data["id"]
|
||||
patch_resp = await client.patch(
|
||||
f"/api/v1/tasks/{task_id}",
|
||||
json={
|
||||
"human_resources": [
|
||||
{"role": "سرکارگر بتنریزی", "count": 2},
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert patch_resp.status_code == 200
|
||||
patched_data = patch_resp.json()["data"]
|
||||
assert len(patched_data["human_resources"]) == 1
|
||||
assert patched_data["human_resources"][0]["count"] == 2
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Unit tests for unified DateService."""
|
||||
from datetime import date
|
||||
import pytest
|
||||
|
||||
from app.utils.date_service import date_service
|
||||
|
||||
|
||||
def test_date_service_parse_iso():
|
||||
d = date_service.parse("2026-09-16")
|
||||
assert d == date(2026, 9, 16)
|
||||
|
||||
|
||||
def test_date_service_parse_jalali():
|
||||
# 1405/06/25 -> 2026-09-16
|
||||
d = date_service.parse("1405/06/25")
|
||||
assert d == date(2026, 9, 16)
|
||||
|
||||
|
||||
def test_date_service_to_jalali():
|
||||
d = date(2026, 9, 16)
|
||||
assert date_service.to_jalali(d) == "1405/06/25"
|
||||
|
||||
|
||||
def test_date_service_duration_calculation():
|
||||
d1 = date(2026, 9, 10)
|
||||
d2 = date(2026, 9, 15)
|
||||
# gap between the dates: the end date is not counted
|
||||
assert date_service.calculate_duration(d1, d2) == 5
|
||||
assert date_service.calculate_duration(d1, d1) == 0
|
||||
assert date_service.calculate_duration(None, d2) == 0
|
||||
# inverted range is not a duration
|
||||
assert date_service.calculate_duration(d2, d1) == 0
|
||||
|
||||
|
||||
def test_date_service_compute_end_date():
|
||||
d1 = date(2026, 9, 10)
|
||||
# 0 days means start and end are the same day
|
||||
assert date_service.compute_end_date(d1, 0) == d1
|
||||
# 5 days is the gap, so the end date is 5 days later
|
||||
assert date_service.compute_end_date(d1, 5) == date(2026, 9, 15)
|
||||
|
||||
|
||||
def test_duration_and_end_date_are_inverses():
|
||||
"""The pair must round-trip, otherwise saved values drift on reload."""
|
||||
d1 = date(2026, 9, 10)
|
||||
for gap in (0, 1, 4, 5, 30):
|
||||
end = date_service.compute_end_date(d1, gap)
|
||||
assert date_service.calculate_duration(d1, end) == gap
|
||||
@@ -0,0 +1,155 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Base, PMUser
|
||||
from app.security import create_token, hash_password
|
||||
from app.utils.date_service import date_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_automated_lifecycle_dates():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
admin = PMUser(
|
||||
username="admin",
|
||||
password_hash=hash_password("admin123"),
|
||||
display_name="مدیر ارشد",
|
||||
role="project_manager",
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
admin_id = admin.id
|
||||
|
||||
token = create_token(admin_id)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async def override_get_db():
|
||||
async with async_session() as s:
|
||||
yield s
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# 1. Create a project
|
||||
p_res = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "پروژه بیمارستان البرز"},
|
||||
headers=headers,
|
||||
)
|
||||
assert p_res.status_code == 200
|
||||
project_id = p_res.json()["data"]["id"]
|
||||
assert p_res.json()["data"]["planned_start"] is None
|
||||
assert p_res.json()["data"]["planned_end"] is None
|
||||
|
||||
# 2. Create Task 1 (earliest: 2026-10-01 to 2026-10-10)
|
||||
t1_res = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "گودبرداری و خاکبرداری",
|
||||
"planned_start": "2026-10-01",
|
||||
"planned_end": "2026-10-10",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert t1_res.status_code == 200
|
||||
t1_id = t1_res.json()["data"]["id"]
|
||||
assert t1_res.json()["data"]["actual_start"] is None
|
||||
assert t1_res.json()["data"]["actual_end"] is None
|
||||
|
||||
# Verify project planned dates matched Task 1
|
||||
p_get1 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
|
||||
assert p_get1.json()["data"]["planned_start"] == "2026-10-01"
|
||||
assert p_get1.json()["data"]["planned_end"] == "2026-10-10"
|
||||
assert p_get1.json()["data"]["actual_start"] is None
|
||||
assert p_get1.json()["data"]["actual_end"] is None
|
||||
|
||||
# 3. Create Task 2 (later: 2026-10-11 to 2026-10-25)
|
||||
t2_res = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "اجرای فونداسیون",
|
||||
"planned_start": "2026-10-11",
|
||||
"planned_end": "2026-10-25",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert t2_res.status_code == 200
|
||||
t2_id = t2_res.json()["data"]["id"]
|
||||
|
||||
# Verify project planned dates span from Task 1 (first) to Task 2 (last)
|
||||
p_get2 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
|
||||
assert p_get2.json()["data"]["planned_start"] == "2026-10-01"
|
||||
assert p_get2.json()["data"]["planned_end"] == "2026-10-25"
|
||||
|
||||
# 4. Progress on Task 1 moves from 0 to 25%
|
||||
today_iso = date_service.today().isoformat()
|
||||
prog1_res = await client.patch(
|
||||
f"/api/v1/tasks/{t1_id}/progress",
|
||||
json={"percentage": 25},
|
||||
headers=headers,
|
||||
)
|
||||
assert prog1_res.status_code == 200
|
||||
assert prog1_res.json()["data"]["actual_start"] == today_iso
|
||||
assert prog1_res.json()["data"]["actual_end"] is None
|
||||
|
||||
# Check project dates: project.actual_start is now set, actual_end is None
|
||||
p_get3 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
|
||||
assert p_get3.json()["data"]["actual_start"] == today_iso
|
||||
assert p_get3.json()["data"]["actual_end"] is None
|
||||
|
||||
# 5. Complete Task 1 (100%)
|
||||
prog1_100 = await client.patch(
|
||||
f"/api/v1/tasks/{t1_id}/progress",
|
||||
json={"percentage": 100},
|
||||
headers=headers,
|
||||
)
|
||||
assert prog1_100.status_code == 200
|
||||
assert prog1_100.json()["data"]["actual_end"] == today_iso
|
||||
|
||||
# Project actual_end should still be None because Task 2 (the last task) hasn't finished
|
||||
p_get4 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
|
||||
assert p_get4.json()["data"]["actual_end"] is None
|
||||
|
||||
# 6. Complete Task 2 (last task) (100%)
|
||||
prog2_100 = await client.patch(
|
||||
f"/api/v1/tasks/{t2_id}/progress",
|
||||
json={"percentage": 100},
|
||||
headers=headers,
|
||||
)
|
||||
assert prog2_100.status_code == 200
|
||||
assert prog2_100.json()["data"]["actual_end"] == today_iso
|
||||
|
||||
# Now project actual_end should match Task 2's actual_end
|
||||
p_get5 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
|
||||
assert p_get5.json()["data"]["actual_end"] == today_iso
|
||||
assert p_get5.json()["data"]["progress"] == 100.0
|
||||
|
||||
# 7. Test reset to 0% on Task 2: actual_end and actual_start are cleared
|
||||
prog2_0 = await client.patch(
|
||||
f"/api/v1/tasks/{t2_id}/progress",
|
||||
json={"percentage": 0},
|
||||
headers=headers,
|
||||
)
|
||||
assert prog2_0.status_code == 200
|
||||
assert prog2_0.json()["data"]["actual_start"] is None
|
||||
assert prog2_0.json()["data"]["actual_end"] is None
|
||||
|
||||
p_get6 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
|
||||
assert p_get6.json()["data"]["actual_end"] is None
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Integration tests for native Projects, Tasks hierarchy, and QC gating invariants."""
|
||||
import uuid
|
||||
from datetime import date
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import pytest_asyncio
|
||||
|
||||
from app.models import (
|
||||
Base,
|
||||
PMUser,
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectStatus,
|
||||
QCAnswer,
|
||||
QCQuestionPhase,
|
||||
Task,
|
||||
TaskPriority,
|
||||
TaskQCDefinition,
|
||||
TaskQCQuestion,
|
||||
TaskQCResult,
|
||||
TaskStatus,
|
||||
)
|
||||
from app.qc_service import build_state, load_definitions
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_db():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_and_members_creation(async_db: AsyncSession):
|
||||
# 1. Create a user
|
||||
user = PMUser(
|
||||
username="manager1",
|
||||
password_hash="fake",
|
||||
display_name="مهندس مدیر",
|
||||
role="project_manager",
|
||||
)
|
||||
async_db.add(user)
|
||||
await async_db.commit()
|
||||
|
||||
# 2. Create a project
|
||||
project = Project(
|
||||
name="پروژه برج بهارستان",
|
||||
description="ساخت مجتمع ۲۰ طبقه",
|
||||
location="تهران",
|
||||
project_manager="مهندس مدیر",
|
||||
status=ProjectStatus.ACTIVE.value,
|
||||
planned_start=date(2026, 9, 1),
|
||||
planned_end=date(2027, 9, 1),
|
||||
)
|
||||
async_db.add(project)
|
||||
await async_db.flush()
|
||||
|
||||
# 3. Add a project member
|
||||
member = ProjectMember(
|
||||
project_id=project.id,
|
||||
user_id=user.id,
|
||||
role="project_manager",
|
||||
)
|
||||
async_db.add(member)
|
||||
await async_db.commit()
|
||||
|
||||
# Verify project
|
||||
loaded = await async_db.scalar(select(Project).where(Project.id == project.id))
|
||||
assert loaded is not None
|
||||
assert loaded.name == "پروژه برج بهارستان"
|
||||
assert loaded.status == "ACTIVE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hierarchical_tasks(async_db: AsyncSession):
|
||||
# Setup project
|
||||
project = Project(name="پروژه تست ساختار شکست")
|
||||
async_db.add(project)
|
||||
await async_db.commit()
|
||||
|
||||
# 1. Phase 1 (Root Task)
|
||||
phase1 = Task(
|
||||
project_id=project.id,
|
||||
parent_id=None,
|
||||
title="فاز ۱: اسکلت فلزی",
|
||||
status=TaskStatus.TODO.value,
|
||||
)
|
||||
async_db.add(phase1)
|
||||
await async_db.flush()
|
||||
|
||||
# 2. Activity (Subtask under Phase 1)
|
||||
activity1 = Task(
|
||||
project_id=project.id,
|
||||
parent_id=phase1.id,
|
||||
title="فعالیت ۱.۱: نصب ستونهای طبقه اول",
|
||||
status=TaskStatus.TODO.value,
|
||||
)
|
||||
async_db.add(activity1)
|
||||
await async_db.flush()
|
||||
|
||||
# 3. Subtask under Activity
|
||||
subtask1 = Task(
|
||||
project_id=project.id,
|
||||
parent_id=activity1.id,
|
||||
title="زیرتسک ۱.۱.۱: بولتگذاری و تراز پایه",
|
||||
status=TaskStatus.TODO.value,
|
||||
)
|
||||
async_db.add(subtask1)
|
||||
await async_db.commit()
|
||||
|
||||
# Query hierarchy
|
||||
root = await async_db.scalar(select(Task).where(Task.id == phase1.id))
|
||||
assert root is not None
|
||||
assert root.parent_id is None
|
||||
|
||||
leaf = await async_db.scalar(select(Task).where(Task.id == subtask1.id))
|
||||
assert leaf is not None
|
||||
assert leaf.parent_id == activity1.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qc_gating_invariants(async_db: AsyncSession):
|
||||
project = Project(name="پروژه تست کیفیت")
|
||||
async_db.add(project)
|
||||
await async_db.commit()
|
||||
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
title="بتنریزی فونداسیون",
|
||||
status=TaskStatus.TODO.value,
|
||||
)
|
||||
async_db.add(task)
|
||||
await async_db.flush()
|
||||
|
||||
# Create task QC definition with 1 pre-execution question and 1 execution question
|
||||
qc_def = TaskQCDefinition(task_id=task.id, name="چکلیست بتنریزی")
|
||||
async_db.add(qc_def)
|
||||
await async_db.flush()
|
||||
|
||||
q_pre = TaskQCQuestion(
|
||||
definition_id=qc_def.id,
|
||||
phase=QCQuestionPhase.pre,
|
||||
text="آیا آرماتوربندی و قالببندی بازرسی و تأیید شد؟",
|
||||
required=True,
|
||||
sort_order=1,
|
||||
)
|
||||
q_exec = TaskQCQuestion(
|
||||
definition_id=qc_def.id,
|
||||
phase=QCQuestionPhase.during,
|
||||
text="آیا اسلامپ بتن در محدوده استاندارد است؟",
|
||||
required=True,
|
||||
sort_order=2,
|
||||
)
|
||||
async_db.add_all([q_pre, q_exec])
|
||||
await async_db.commit()
|
||||
|
||||
task_id = task.id
|
||||
|
||||
# Invariant Rule 1 check: Before answering pre-QC, can_start must be FALSE
|
||||
defs = await load_definitions(async_db, task_id)
|
||||
state = build_state(defs)
|
||||
assert state.can_start is False
|
||||
assert state.can_complete is False
|
||||
|
||||
# Now pass the pre-QC question
|
||||
res_pre = TaskQCResult(question_id=q_pre.id, question=q_pre, answer=QCAnswer.pass_)
|
||||
async_db.add(res_pre)
|
||||
await async_db.commit()
|
||||
|
||||
# Load fresh definitions in new query
|
||||
defs = await load_definitions(async_db, task_id)
|
||||
state = build_state(defs)
|
||||
# can_start is now TRUE! Progress can become > 0
|
||||
assert state.can_start is True
|
||||
# But can_complete is still FALSE because execution question is not yet answered!
|
||||
assert state.can_complete is False
|
||||
|
||||
# Now pass the execution question as well
|
||||
res_exec = TaskQCResult(question_id=q_exec.id, question=q_exec, answer=QCAnswer.pass_)
|
||||
async_db.add(res_exec)
|
||||
await async_db.commit()
|
||||
|
||||
defs = await load_definitions(async_db, task_id)
|
||||
state = build_state(defs)
|
||||
# Both can_start and can_complete are now TRUE!
|
||||
assert state.can_start is True
|
||||
assert state.can_complete is True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.scheduling_service import calculate_schedule, check_for_cycle
|
||||
|
||||
|
||||
def test_cycle_detection():
|
||||
id_a = uuid.uuid4()
|
||||
id_b = uuid.uuid4()
|
||||
id_c = uuid.uuid4()
|
||||
|
||||
edges = [
|
||||
(id_a, id_b),
|
||||
(id_b, id_c),
|
||||
]
|
||||
|
||||
# Adding C -> A should detect cycle
|
||||
assert check_for_cycle(edges, id_c, id_a) is True
|
||||
|
||||
# Adding A -> A (self-loop) should detect cycle
|
||||
assert check_for_cycle(edges, id_a, id_a) is True
|
||||
|
||||
# Adding C -> D (new node) should not detect cycle
|
||||
id_d = uuid.uuid4()
|
||||
assert check_for_cycle(edges, id_c, id_d) is False
|
||||
|
||||
|
||||
def test_cpm_forward_and_backward_pass():
|
||||
id_a = uuid.uuid4()
|
||||
id_b = uuid.uuid4()
|
||||
id_c = uuid.uuid4()
|
||||
|
||||
t_a = SimpleNamespace(id=id_a, duration=3, planned_start=date(2026, 1, 1), parent_id=None, progress=0)
|
||||
t_b = SimpleNamespace(id=id_b, duration=4, planned_start=None, parent_id=None, progress=0)
|
||||
t_c = SimpleNamespace(id=id_c, duration=2, planned_start=None, parent_id=None, progress=0)
|
||||
|
||||
# A (3d) -[FS + 1 lag]-> B (4d) -[FS + 0 lag]-> C (2d)
|
||||
rel_ab = SimpleNamespace(from_task_id=id_a, to_task_id=id_b, relation_type="FS", lag_days=1)
|
||||
rel_bc = SimpleNamespace(from_task_id=id_b, to_task_id=id_c, relation_type="FS", lag_days=0)
|
||||
|
||||
res = calculate_schedule([t_a, t_b, t_c], [rel_ab, rel_bc], project_start=date(2026, 1, 1))
|
||||
|
||||
sched = res["tasks_schedule"]
|
||||
|
||||
# Task A: 2026-01-01, duration 3 is a gap -> ends 2026-01-04
|
||||
assert sched[id_a]["planned_start"] == date(2026, 1, 1)
|
||||
assert sched[id_a]["planned_end"] == date(2026, 1, 4)
|
||||
|
||||
# Task B starts right after A finishes (Jan 4) plus 1 lag day = Jan 5
|
||||
assert sched[id_b]["planned_start"] == date(2026, 1, 5)
|
||||
# B: duration 4 gap -> Jan 5 + 4 = Jan 9
|
||||
assert sched[id_b]["planned_end"] == date(2026, 1, 9)
|
||||
|
||||
# Task C starts Jan 9, duration 2 gap -> ends Jan 11
|
||||
assert sched[id_c]["planned_start"] == date(2026, 1, 9)
|
||||
assert sched[id_c]["planned_end"] == date(2026, 1, 11)
|
||||
|
||||
# All three are on critical path
|
||||
assert id_a in res["critical_path"]
|
||||
assert id_b in res["critical_path"]
|
||||
assert id_c in res["critical_path"]
|
||||
@@ -0,0 +1 @@
|
||||
"""PM backend tools (seed etc.)."""
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Seed PM with the 8 standard construction QC templates.
|
||||
|
||||
Each template now carries three stages:
|
||||
* pre — آمادگی شروع کار (readiness; a task cannot start until these pass)
|
||||
* during — کنترل حین اجرا
|
||||
* after — کنترل نهایی
|
||||
|
||||
Idempotent — run any time: python -m tools.seed
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import SessionFactory, engine
|
||||
from app.models import Base, QCQuestion, QCQuestionPhase, QCTemplate
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("pm.seed")
|
||||
|
||||
# (activity_key, name, pre checklist, during checklist, after checklist)
|
||||
TEMPLATES: list[tuple[str, str, list[str], list[str], list[str]]] = [
|
||||
(
|
||||
"concrete",
|
||||
"بتنریزی",
|
||||
[
|
||||
"آرماتوربندی و قالببندی تأیید شده است؟",
|
||||
"سطح قالب تمیز و روغنکاری شده است؟",
|
||||
"بتن سفارششده و پمپ/ویبره هماهنگ شده است؟",
|
||||
"دسترسی و مسیر تخلیه بتن آماده است؟",
|
||||
],
|
||||
[
|
||||
"مشخصات بتن (عیار/روانی) مطابق طراحی است؟",
|
||||
"ویبره بدون جداشدگی انجام شد؟",
|
||||
"ضخامت و تراز حین اجرا کنترل شد؟",
|
||||
],
|
||||
[
|
||||
"سطح یکنواخت و بدون جداشدگی است؟",
|
||||
"ترک مویی یا ریزش لبه وجود ندارد؟",
|
||||
"عملآوری (کیورینگ) طبق برنامه انجام شد؟",
|
||||
],
|
||||
),
|
||||
(
|
||||
"plaster",
|
||||
"گچکاری",
|
||||
[
|
||||
"سطح آمادهسازی (شستشو/چکشکاری) انجام شده؟",
|
||||
"داربست/چهارپایه سالم آماده است؟",
|
||||
"مصالح (گچ/پودر) و آب در دسترس است؟",
|
||||
],
|
||||
[
|
||||
"ضخامت گچ طبق مشخصات است؟",
|
||||
"شاقول و تراز رعایت شده؟",
|
||||
"درزها و اتصالات کنترل شد؟",
|
||||
],
|
||||
[
|
||||
"سطح یکنواخت و بدون موج است؟",
|
||||
"ترک ندارد؟",
|
||||
"گوشهها و لبهها سالم هستند؟",
|
||||
],
|
||||
),
|
||||
(
|
||||
"rebar",
|
||||
"آرماتوربندی",
|
||||
[
|
||||
"نقشههای اجرایی و لیست برش آماده است؟",
|
||||
"میلگرد مورد نیاز در کارگاه موجود است؟",
|
||||
"سطح کارگاه و مسیر دسترسی آماده است؟",
|
||||
],
|
||||
[
|
||||
"قطر و تعداد میلگرد طبق نقشه است؟",
|
||||
"فاصله خاموتها رعایت شده؟",
|
||||
"پوشش بتن (کاور) مناسب است؟",
|
||||
],
|
||||
[
|
||||
"اتصالات (بست/جوش/درزگیری) مطابق آییننامه است؟",
|
||||
"آرماتور اضافی و برادهها جمع شده؟",
|
||||
"آماده بتنریزی است؟",
|
||||
],
|
||||
),
|
||||
(
|
||||
"formwork",
|
||||
"قالببندی",
|
||||
[
|
||||
"نقشه قالببندی و ابعاد مشخص است؟",
|
||||
"قالب و شمعبندی سالم و کافی موجود است؟",
|
||||
"روغن قالب آماده است؟",
|
||||
],
|
||||
[
|
||||
"ابعاد و تراز طبق نقشه است؟",
|
||||
"شمعبندی و مهاربندی کافی است؟",
|
||||
"درزهای قالب آببند است؟",
|
||||
],
|
||||
["قالب بدون تغییر شکل است؟", "سطح تماس مناسب بتنریزی است؟"],
|
||||
),
|
||||
(
|
||||
"blockwork",
|
||||
"تیغهچینی",
|
||||
[
|
||||
"آجر/بلوک و ملات در پای کار موجود است؟",
|
||||
"خطاندازی و نقشه چیدمان انجام شده؟",
|
||||
"سطح زیرکار آماده و تمیز است؟",
|
||||
],
|
||||
[
|
||||
"خط و تراز دیوار تنظیم شده؟",
|
||||
"مقاومت ملات مناسب است؟",
|
||||
"بندکشی و فاصله درزها صحیح است؟",
|
||||
],
|
||||
[
|
||||
"سطح دیوار صاف و شاقول است؟",
|
||||
"درزها پر و یکنواخت است؟",
|
||||
"نازککاری بعدی بدون اصلاح ممکن است؟",
|
||||
],
|
||||
),
|
||||
(
|
||||
"isogam",
|
||||
"ایزوگام",
|
||||
[
|
||||
"سطح بام خشک و تمیز است؟",
|
||||
"پرایمر و مشعل آماده است؟",
|
||||
"هواشناسی برای اجرا مناسب است؟",
|
||||
],
|
||||
[
|
||||
"پرایمر اجرا شده؟",
|
||||
"همپوشانی نوارها حداقل ۱۰ سانتیمتر است؟",
|
||||
"حرارت شعله مناسب و بدون سوختگی است؟",
|
||||
],
|
||||
["چکه و تورفتگی ندارد؟", "لبهها و اتصالات کاملاً چسبیده؟", "آزمون آب (باران مصنوعی) پاس شد؟"],
|
||||
),
|
||||
(
|
||||
"tiling",
|
||||
"کاشیکاری",
|
||||
[
|
||||
"زیرکار تراز، خشک و آماده است؟",
|
||||
"کاشی/دوغاب/چسب در پای کار موجود است؟",
|
||||
"نقشه چیدمان و بندبندی مشخص شده است؟",
|
||||
],
|
||||
[
|
||||
"دوغاب و ملات مناسب است؟",
|
||||
"بندبندی یکنواخت است؟",
|
||||
"کاشیهای شکسته کنار گذاشته شده؟",
|
||||
],
|
||||
["صدای توخالی ندارد؟", "تراز و پلمپ رعایت شده؟", "بندها تمیز و همرنگ است؟"],
|
||||
),
|
||||
(
|
||||
"mep",
|
||||
"تأسیسات",
|
||||
[
|
||||
"نقشههای مکانیکال/الکتریکال آماده است؟",
|
||||
"مقطع و اتصالات پیشبینیشده موجود است؟",
|
||||
"مسیر و برشهای مورد نیاز هماهنگ شده؟",
|
||||
],
|
||||
[
|
||||
"مسیر لولهکشی طبق نقشه است؟",
|
||||
"آزمون فشار حین اجرا انجام شد؟",
|
||||
"ایزولاسیون لولهها اجرا شده؟",
|
||||
],
|
||||
["آزمون نهایی سیستم انجام شد؟", "برچسبگذاری و شناسایی کامل است؟", "تحویل به ناظر ثبت شد؟"],
|
||||
),
|
||||
(
|
||||
"excavation",
|
||||
"گودبرداری و سازه نگهبان",
|
||||
[
|
||||
"نقشههای مصوب سازه نگهبان و مجوزهای گودبرداری اخذ شده است؟",
|
||||
"وضعیت ابنیه و تأسیسات همجوار و چاههای مجاور بررسی و صورتجلسه شده است؟",
|
||||
"ایستگاههای پایش و مانیتورینگ تغییرشکل دیوارهها نصب شده است؟",
|
||||
"ماشینآلات و پمپهای زهکشی و هدایت آب سطحی مستقر است؟",
|
||||
],
|
||||
[
|
||||
"خاکبرداری مرحلهای طبق ترازهای مصوب طرح سازه نگهبان انجام میشود؟",
|
||||
"المانهای سازه نگهبان (سولجر، نیلینگ، استرات و مهارها) بهموقع اجرا شدند؟",
|
||||
"رفتارنگاری روزانه دیوارهها و ساختمانهای مجاور ثبت و کنترل میشود؟",
|
||||
],
|
||||
[
|
||||
"تراز نهایی کف گود و رقوم ارتفاعی فونداسیون کنترل و تأیید شد؟",
|
||||
"دیوارهها و ترانشههای گود کاملاً پایدار تحویل شدند؟",
|
||||
"صورتجلسه تحویل بستر پی به آزمایشگاه مکانیک خاک و دستگاه نظارت تنظیم شد؟",
|
||||
],
|
||||
),
|
||||
(
|
||||
"steel_structure",
|
||||
"اسکلت فولادی و اتصالات جوشی/پیچی",
|
||||
[
|
||||
"نقشههای شاپدراویینگ (Shop Drawings) و دستورکار اتصالات ابلاغ شده است؟",
|
||||
"گواهیهای فنی متریال فولادی، الکترودها و پیچهای پرمقاومت بررسی و تأیید شد؟",
|
||||
"صلاحیت جوشکاران (کارت کد) و کالیبراسیون تجهیزات جوش کنترل گردید؟",
|
||||
"آکسبندی، تراز بیسپلیتها و استحکام انکربولتها بررسی شد؟",
|
||||
],
|
||||
[
|
||||
"رواداری شاقولی ستونها و تراز پلها و تیرها در حین برپایی کنترل شد؟",
|
||||
"سایز، بعد، طول و توالی پاسهای جوشکاری طبق WPS اجرا گردید؟",
|
||||
"گشتاور پیچهای پرمقاومت اصطکاکی با ترکمتر کالیبره کنترل شد؟",
|
||||
],
|
||||
[
|
||||
"نتایج آزمونهای غیرمخرب جوش (VT/PT/UT/MT) مطابق ضوابط اخذ و تأیید شد؟",
|
||||
"تمیزکاری سرباره جوش، پاششها و سندبلاست موضعی انجام گرفت؟",
|
||||
"پوشش ضدزنگ و رنگآمیزی حفاظتی طبق ضخامت مشخصات فنی اجرا شد؟",
|
||||
],
|
||||
),
|
||||
(
|
||||
"joist_slab",
|
||||
"اجرای سقف تیرچه بلوک و دال بتنی",
|
||||
[
|
||||
"شمعبندی، پایههای اطمینان و مهاربندی زیر سقف با تراکم استاندارد اجرا شد؟",
|
||||
"تیرچهها سالم، بدون ترکخوردگی و با خیز منفی مناسب نصب شدهاند؟",
|
||||
"مسیرهای عبور لولههای تأسیسات برقی و مکانیکی جاگذاری و تثبیت شدند؟",
|
||||
],
|
||||
[
|
||||
"فاصله تیرچهها، جانمایی کلاف میانی (تایبیم) و میلگردهای ممان منفی کنترل شد؟",
|
||||
"آرماتورهای حرارتی با پوشش کاور و همپوشانی مجاز نصب گردیدند؟",
|
||||
"بلوکها/یونولیتهای سقفی سالم و کاملاً جذب بدون درز چیده شدند؟",
|
||||
],
|
||||
[
|
||||
"ضخامت بتن دال روی تیرچهها در تمام نقاط کنترل و تأیید شد؟",
|
||||
"خیز سقف پس از بتنریزی و برداشتن شمعها در حد مجاز آییننامه است؟",
|
||||
"کیورینگ و مرطوب نگهداشتن دال بتنی طبق برنامه زمانبندی انجام گرفت؟",
|
||||
],
|
||||
),
|
||||
(
|
||||
"stone_facade",
|
||||
"سنگکاری نما و کف",
|
||||
[
|
||||
"شاسیکشی فلزی و زیرسازی نما طبق جزئیات اجرایی تراز و شاقول است؟",
|
||||
"سنگها سالم، همرنگ و بدون رگههای سست و ترک انتخاب شدهاند؟",
|
||||
"کلیه سنگهای نما شیارزنی شده و دارای اسکوپ سیمی یا مکانیکی مطمئن هستند؟",
|
||||
],
|
||||
[
|
||||
"ملات ماسه سیمان پشت سنگ پرعیار و بهصورت یکنواخت بدون پوکی پر میشود؟",
|
||||
"شاقول، تراز و ریسمانکشی بندهای افقی و قائم به طور مداوم کنترل میگردد؟",
|
||||
"درزهای انبساط حرارتی نما در فواصل پیشبینیشده اجرا شد؟",
|
||||
],
|
||||
[
|
||||
"سطح سنگکاری شاقول، گونیا، تخت و بدون پلهشدن لبههاست؟",
|
||||
"بندکشی توپر، صاف و با ملات ضدرطوبت همرنگ اجرا شد؟",
|
||||
"تست چسبندگی سنگها و شستشوی نهایی سطح با موفقیت انجام گرفت؟",
|
||||
],
|
||||
),
|
||||
(
|
||||
"doors_windows",
|
||||
"نصب درب و پنجرههای دوجداره",
|
||||
[
|
||||
"ابعاد و شاقولی سابفریمها (فریمهای انتظار) کنترل و رنگ ضدزنگ خورده است؟",
|
||||
"پروفیلها، یراقآلات و شیشههای دوجداره سالم و استاندارد تخلیه شدهاند؟",
|
||||
],
|
||||
[
|
||||
"شاقول و تراز بودن فریمها در سه محور و فواصل پیچهای بولت کنترل شد؟",
|
||||
"تزریق فوم پلیاورتان برای هوابندی و عایق صوتی دور فریم انجام گرفت؟",
|
||||
"درزگیری محیط خارجی پنجره با چسب سیلیکون خنثی آنتییووی (UV) اجرا شد؟",
|
||||
],
|
||||
[
|
||||
"رگلاژ یراقآلات و روانی بازشوها کنترل و قفلها به درستی عمل میکنند؟",
|
||||
"تست عدم نفوذ آب باران و باد انجام و تأیید گردید؟",
|
||||
"پوششهای محافظ و چسبهای سطحی پاکسازی و فریمها تمیز شدند؟",
|
||||
],
|
||||
),
|
||||
(
|
||||
"painting",
|
||||
"نقاشی و رنگآمیزی ساختمان",
|
||||
[
|
||||
"سطح گچکاری کاملاً خشک، بدون رطوبت، پوکی یا شوره است؟",
|
||||
"روغن زیرکار (پرایمر نفوذگر) متناسب با نوع رنگ زیرین اعمال گردیده است؟",
|
||||
"کاورکشی و حفاظت از کف، پنجرهها و چهارچوبها انجام شده است؟",
|
||||
],
|
||||
[
|
||||
"بتونهکاری سراسری، لکهگیری و سنبادهزنی یکدست با نور موضعی انجام شد؟",
|
||||
"تعداد لایههای رنگ و فاصله زمانی کافی برای خشکشدن لایهها رعایت گردید؟",
|
||||
],
|
||||
[
|
||||
"سطح نهایی صاف، یکدست و بدون اثر قلممو، شره و سایهروشن است؟",
|
||||
"تطابق کد رنگ اجرا شده با شید رنگ مصوب کارفرما تأیید شد؟",
|
||||
"تمیزکاری نهایی محیط از پاشش ذرات رنگ انجام گردید؟",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with SessionFactory() as db:
|
||||
for key, name, pre, during, after in TEMPLATES:
|
||||
existing = await db.scalar(select(QCTemplate).where(QCTemplate.name == name))
|
||||
if existing is not None:
|
||||
continue
|
||||
template = QCTemplate(name=name, activity_type=key, description=f"چکلیست کنترل کیفیت {name}")
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
for phase, items in (
|
||||
(QCQuestionPhase.pre, pre),
|
||||
(QCQuestionPhase.during, during),
|
||||
(QCQuestionPhase.after, after),
|
||||
):
|
||||
for index, text in enumerate(items):
|
||||
db.add(
|
||||
QCQuestion(template_id=template.id, phase=phase, text=text, sort_order=index)
|
||||
)
|
||||
log.info("QC template seeded: %s (pre=%d during=%d after=%d)", name, len(pre), len(during), len(after))
|
||||
|
||||
await db.commit()
|
||||
log.info("seed done")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Bootstrap the PM→ERP service account (one-off, idempotent).
|
||||
|
||||
1. Creates Casdoor user `pm-service` (org `inventory`) via the Casdoor admin API.
|
||||
2. Performs a password grant so ERP auto-provisions the users row.
|
||||
3. Promotes that row to role `admin` (read-only across all projects —
|
||||
the ERP inventory router lets admin read without a project scope).
|
||||
|
||||
Usage (from the pm-api container):
|
||||
PM_ERP_SERVICE_PASSWORD=... python -m tools.seed_erp_service
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("pm.seed_erp")
|
||||
|
||||
ORG = settings.ERP_CASDOOR_ORGANIZATION
|
||||
USERNAME = settings.ERP_SERVICE_USERNAME
|
||||
PASSWORD = settings.ERP_SERVICE_PASSWORD or os.environ.get("PM_ERP_SERVICE_PASSWORD", "pm-service-2026")
|
||||
EMAIL = f"{USERNAME}@sample.ir"
|
||||
ERP_DSN = "postgresql://postgres:postgres@procurement-postgres:5432/procurement"
|
||||
|
||||
|
||||
def _post(url: str, body: dict, headers: dict | None = None) -> dict:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
method="POST",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def casdoor_admin_cookie() -> str:
|
||||
resp = urllib.request.urlopen(urllib.request.Request(
|
||||
f"{settings.ERP_CASDOOR_ENDPOINT}/api/login",
|
||||
data=json.dumps({
|
||||
"type": "login", "organization": "built-in",
|
||||
"username": "admin", "password": "123", "application": "app-built-in",
|
||||
}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
), timeout=15)
|
||||
data = json.loads(resp.read())
|
||||
if data.get("status") != "ok":
|
||||
raise SystemExit(f"casdoor admin login failed: {data}")
|
||||
return (resp.headers.get("Set-Cookie") or "").split(";")[0]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1) casdoor user (idempotent — casdoor rejects duplicates, that's fine)
|
||||
cookie = casdoor_admin_cookie()
|
||||
for path, body in [
|
||||
("/api/add-user", {
|
||||
"owner": ORG, "name": USERNAME, "displayName": "PM Service",
|
||||
"firstName": "PM", "lastName": "Service", "email": EMAIL,
|
||||
"password": PASSWORD, "passwordType": "plain", "type": "normal-user",
|
||||
}),
|
||||
(f"/api/update-user?id={ORG}/{USERNAME}&columns=password_type", {"passwordType": "plain"}),
|
||||
]:
|
||||
try:
|
||||
result = _post(f"{settings.ERP_CASDOOR_ENDPOINT}{path}", body, {"Cookie": cookie})
|
||||
log.info("%s → %s", path.split("?")[0], result.get("msg", result.get("status")))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("%s failed: %s", path.split("?")[0], exc)
|
||||
|
||||
# 2) password grant (PM never verifies this token — it just forwards it)
|
||||
grant = _post(f"{settings.ERP_CASDOOR_ENDPOINT}/api/login/oauth/access_token", {
|
||||
"grant_type": "password",
|
||||
"client_id": settings.ERP_CASDOOR_CLIENT_ID,
|
||||
"client_secret": settings.ERP_CASDOOR_CLIENT_SECRET,
|
||||
"username": USERNAME,
|
||||
"password": PASSWORD,
|
||||
})
|
||||
token = grant.get("access_token")
|
||||
if not token:
|
||||
raise SystemExit(f"password grant failed: {grant}")
|
||||
claims = json.loads(base64.urlsafe_b64decode(token.split(".")[1] + "=" * (-len(token.split(".")[1]) % 4)))
|
||||
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
|
||||
log.info("grant ok, casdoor_id=%s", casdoor_id[:8])
|
||||
|
||||
# trigger ERP auto-provisioning
|
||||
me_req = urllib.request.Request(
|
||||
f"{settings.ERP_BASE_URL}/api/v1/users/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(me_req, timeout=15).read()
|
||||
log.info("ERP provisioning call ok")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("provisioning call failed (row may already exist): %s", exc)
|
||||
|
||||
# 3) promote in ERP DB (direct SQL — this service account is PM infrastructure)
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(ERP_DSN)
|
||||
try:
|
||||
updated = await conn.execute(
|
||||
"UPDATE users SET role='admin' WHERE casdoor_id=$1", casdoor_id
|
||||
)
|
||||
log.info("ERP role promotion: %s", updated)
|
||||
finally:
|
||||
await conn.close()
|
||||
log.info("service account ready: %s / ***", USERNAME)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user