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