chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM, branch feature/project-management-platform. Update via: git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
This commit is contained in:
@@ -0,0 +1,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)
|
||||
Reference in New Issue
Block a user