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
730 lines
24 KiB
Python
730 lines
24 KiB
Python
"""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)
|