Add AQMM task evidence and review notes workflow
This commit is contained in:
@@ -32,9 +32,28 @@ CLOSED_TASK_STATUSES = {"completed", "not_applicable", "cancelled"}
|
||||
|
||||
TASK_COMMENT_TYPES = [
|
||||
("internal_note", "Internal Note"),
|
||||
("staff_work_note", "Staff Work Note"),
|
||||
("client_clarification", "Client Clarification"),
|
||||
("consultant_clarification", "Consultant Clarification"),
|
||||
("manager_review_note", "Manager Review Note"),
|
||||
("partner_review_note", "Partner Review Note"),
|
||||
("review_partner_review_note", "Review Partner Review Note"),
|
||||
("rework_note", "Rework Note"),
|
||||
]
|
||||
|
||||
AQMM_REVIEW_STATUSES = [
|
||||
("not_required", "Not Required"),
|
||||
("pending", "Pending"),
|
||||
("reviewed", "Reviewed"),
|
||||
("rework_required", "Rework Required"),
|
||||
]
|
||||
|
||||
AQMM_EVIDENCE_STATUSES = [
|
||||
("not_required", "Not Required"),
|
||||
("pending", "Pending"),
|
||||
("uploaded", "Uploaded"),
|
||||
("accepted", "Accepted"),
|
||||
("rejected", "Rejected / Insufficient"),
|
||||
]
|
||||
|
||||
TASK_COMMENT_VISIBILITIES = [
|
||||
@@ -243,34 +262,114 @@ def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceS
|
||||
|
||||
|
||||
|
||||
|
||||
def _latest_task_documents(db: Session, task_id: int) -> list[EngagementDocument]:
|
||||
return db.execute(
|
||||
select(EngagementDocument).where(
|
||||
EngagementDocument.task_instance_id == int(task_id),
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def _task_documents_for_aqmm(db: Session, task: ClientServiceTaskInstance) -> list[EngagementDocument]:
|
||||
docs = _latest_task_documents(db, task.id)
|
||||
if getattr(task, "is_aqmm_task", False):
|
||||
return [doc for doc in docs if getattr(doc, "evidence_status", "not_required") != "rejected"]
|
||||
return docs
|
||||
|
||||
def _task_has_evidence(db: Session, task_id: int) -> bool:
|
||||
return bool(
|
||||
db.execute(
|
||||
select(EngagementDocument.id).where(
|
||||
EngagementDocument.task_instance_id == int(task_id),
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
EngagementDocument.evidence_status != "rejected",
|
||||
)
|
||||
).first()
|
||||
)
|
||||
|
||||
|
||||
def _evidence_status_for_task(db: Session, task: ClientServiceTaskInstance) -> str:
|
||||
if not getattr(task, "is_aqmm_task", False) or not getattr(task, "aqmm_evidence_required", False):
|
||||
return "not_required"
|
||||
docs = _latest_task_documents(db, task.id)
|
||||
if not docs:
|
||||
return "pending"
|
||||
if any(getattr(doc, "evidence_status", "uploaded") == "accepted" for doc in docs):
|
||||
return "accepted"
|
||||
if any(getattr(doc, "evidence_status", "uploaded") in {"uploaded", "pending_review"} for doc in docs):
|
||||
return "uploaded"
|
||||
return "pending"
|
||||
|
||||
|
||||
def _required_review_issue(task: ClientServiceTaskInstance) -> str | None:
|
||||
if getattr(task, "aqmm_manager_review_required", False) and task.manager_review_status != "reviewed":
|
||||
return "Manager review pending" if task.manager_review_status != "rework_required" else "Manager requested rework"
|
||||
if getattr(task, "aqmm_partner_review_required", False) and task.partner_review_status != "reviewed":
|
||||
return "Partner review pending" if task.partner_review_status != "rework_required" else "Partner requested rework"
|
||||
if getattr(task, "aqmm_review_partner_required", False) and task.review_partner_review_status != "reviewed":
|
||||
return "Review partner review pending" if task.review_partner_review_status != "rework_required" else "Review partner requested rework"
|
||||
return None
|
||||
|
||||
|
||||
def recalculate_task_aqmm_status(db: Session, task: ClientServiceTaskInstance) -> None:
|
||||
if not getattr(task, "is_aqmm_task", False):
|
||||
task.evidence_status = "not_required"
|
||||
task.aqmm_status = "not_required"
|
||||
task.aqmm_review_status = "not_required"
|
||||
task.aqmm_completed_at_utc = None
|
||||
return
|
||||
|
||||
task.evidence_status = _evidence_status_for_task(db, task)
|
||||
review_issue = _required_review_issue(task)
|
||||
if review_issue:
|
||||
task.aqmm_review_status = "rework_required" if "rework" in review_issue.lower() else "pending_review"
|
||||
else:
|
||||
task.aqmm_review_status = "reviewed" if (
|
||||
task.aqmm_manager_review_required or task.aqmm_partner_review_required or task.aqmm_review_partner_required
|
||||
) else "not_required"
|
||||
|
||||
issue = _aqmm_task_issue(db, task)
|
||||
if issue:
|
||||
if "rework" in issue.lower():
|
||||
task.aqmm_status = "rework_required"
|
||||
elif "evidence" in issue.lower():
|
||||
task.aqmm_status = "pending_evidence"
|
||||
elif "review" in issue.lower():
|
||||
task.aqmm_status = "pending_review"
|
||||
else:
|
||||
task.aqmm_status = "pending"
|
||||
task.aqmm_completed_at_utc = None
|
||||
else:
|
||||
task.aqmm_status = "completed"
|
||||
if not task.aqmm_completed_at_utc:
|
||||
task.aqmm_completed_at_utc = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
|
||||
def _aqmm_task_issue(db: Session, task: ClientServiceTaskInstance) -> str | None:
|
||||
"""Return blocking reason for one AQMM-tagged task, or None if it passes.
|
||||
|
||||
This deliberately reuses the existing task status and task document upload
|
||||
workflow. Review flags are captured for reporting and future review-specific
|
||||
workflow; until dedicated manager/partner review statuses are added, the
|
||||
task must at least be completed and evidence uploaded wherever marked.
|
||||
The task itself is the AQMM evidence container. For AQMM tasks, task
|
||||
documents, staff notes and manager/partner/review-partner reviews attached
|
||||
to this task are the quality evidence trail.
|
||||
"""
|
||||
if not getattr(task, "is_aqmm_task", False):
|
||||
return None
|
||||
if getattr(task, "rework_status", "none") == "open":
|
||||
return "Rework is open"
|
||||
if getattr(task, "aqmm_mandatory", False) and task.status != "completed":
|
||||
return "Task not completed"
|
||||
if getattr(task, "aqmm_evidence_required", False) and not _task_has_evidence(db, task.id):
|
||||
return "Evidence not uploaded"
|
||||
review_issue = _required_review_issue(task)
|
||||
if review_issue:
|
||||
return review_issue
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def list_aqmm_quality_tasks(db: Session, *, subscription_id: int) -> list[dict]:
|
||||
tasks = db.execute(
|
||||
select(ClientServiceTaskInstance)
|
||||
@@ -285,7 +384,15 @@ def list_aqmm_quality_tasks(db: Session, *, subscription_id: int) -> list[dict]:
|
||||
for task in tasks:
|
||||
has_evidence = _task_has_evidence(db, task.id)
|
||||
issue = _aqmm_task_issue(db, task)
|
||||
rows.append({"task": task, "has_evidence": has_evidence, "issue": issue, "passes": issue is None})
|
||||
recalculate_task_aqmm_status(db, task)
|
||||
rows.append({
|
||||
"task": task,
|
||||
"has_evidence": has_evidence,
|
||||
"evidence_status": getattr(task, "evidence_status", "not_required"),
|
||||
"review_status": getattr(task, "aqmm_review_status", "not_required"),
|
||||
"issue": issue,
|
||||
"passes": issue is None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
@@ -297,10 +404,14 @@ def aqmm_task_summary_for_subscription(db: Session, *, subscription_id: int) ->
|
||||
mandatory_done = sum(1 for r in rows if getattr(r["task"], "aqmm_mandatory", False) and r["passes"])
|
||||
evidence_required = sum(1 for r in rows if getattr(r["task"], "aqmm_evidence_required", False))
|
||||
evidence_missing = sum(1 for r in rows if getattr(r["task"], "aqmm_evidence_required", False) and not r["has_evidence"])
|
||||
manager_review_pending = sum(1 for r in rows if getattr(r["task"], "aqmm_manager_review_required", False) and r["task"].manager_review_status != "reviewed")
|
||||
partner_review_pending = sum(1 for r in rows if getattr(r["task"], "aqmm_partner_review_required", False) and r["task"].partner_review_status != "reviewed")
|
||||
review_partner_review_pending = sum(1 for r in rows if getattr(r["task"], "aqmm_review_partner_required", False) and r["task"].review_partner_review_status != "reviewed")
|
||||
rework_open = sum(1 for r in rows if getattr(r["task"], "rework_status", "none") == "open")
|
||||
blockers = [r for r in rows if getattr(r["task"], "aqmm_blocks_final_release", False) and not r["passes"]]
|
||||
status = "not_required"
|
||||
if total:
|
||||
status = "completed" if mandatory_done == mandatory and evidence_missing == 0 else "in_progress"
|
||||
status = "completed" if mandatory_done == mandatory and evidence_missing == 0 and manager_review_pending == 0 and partner_review_pending == 0 and review_partner_review_pending == 0 and rework_open == 0 else "in_progress"
|
||||
return {
|
||||
"rows": rows,
|
||||
"total": total,
|
||||
@@ -309,11 +420,16 @@ def aqmm_task_summary_for_subscription(db: Session, *, subscription_id: int) ->
|
||||
"mandatory_done": mandatory_done,
|
||||
"evidence_required": evidence_required,
|
||||
"evidence_missing": evidence_missing,
|
||||
"manager_review_pending": manager_review_pending,
|
||||
"partner_review_pending": partner_review_pending,
|
||||
"review_partner_review_pending": review_partner_review_pending,
|
||||
"rework_open": rework_open,
|
||||
"blockers": blockers,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def assert_aqmm_quality_tasks_complete(db: Session, *, subscription_id: int) -> None:
|
||||
summary = aqmm_task_summary_for_subscription(db, subscription_id=subscription_id)
|
||||
if summary["blockers"]:
|
||||
@@ -324,6 +440,15 @@ def assert_aqmm_quality_tasks_complete(db: Session, *, subscription_id: int) ->
|
||||
raise ValueError("AQMM mandatory quality checklist tasks are pending.")
|
||||
if summary["evidence_missing"]:
|
||||
raise ValueError("AQMM evidence upload is pending for one or more quality checklist tasks.")
|
||||
if summary["manager_review_pending"]:
|
||||
raise ValueError("AQMM manager review is pending for one or more quality checklist tasks.")
|
||||
if summary["partner_review_pending"]:
|
||||
raise ValueError("AQMM partner review is pending for one or more quality checklist tasks.")
|
||||
if summary["review_partner_review_pending"]:
|
||||
raise ValueError("AQMM review partner review is pending for one or more quality checklist tasks.")
|
||||
if summary["rework_open"]:
|
||||
raise ValueError("AQMM rework is open for one or more quality checklist tasks.")
|
||||
|
||||
|
||||
def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance:
|
||||
target_date = getattr(task, "internal_target_date", None)
|
||||
@@ -567,6 +692,115 @@ def get_tasks_for_bulk_update(
|
||||
|
||||
|
||||
|
||||
|
||||
def _append_system_task_comment(
|
||||
db: Session,
|
||||
*,
|
||||
task: ClientServiceTaskInstance,
|
||||
comment_type: str,
|
||||
message: str,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
if not (message or "").strip():
|
||||
return
|
||||
db.add(
|
||||
ServiceTaskComment(
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
subscription_id=task.subscription_id,
|
||||
task_instance_id=task.id,
|
||||
comment_type=_normalise_comment_type(comment_type),
|
||||
visibility="internal",
|
||||
message=message.strip(),
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def submit_task_for_review(db: Session, *, task: ClientServiceTaskInstance, note: str, user_id: int) -> None:
|
||||
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
|
||||
return
|
||||
if getattr(task, "is_aqmm_task", False) and getattr(task, "aqmm_evidence_required", False) and not _task_has_evidence(db, task.id):
|
||||
raise ValueError("Evidence is required before submitting this AQMM task for review.")
|
||||
now = datetime.now(timezone.utc)
|
||||
task.submitted_for_review_by_user_id = user_id
|
||||
task.submitted_for_review_at_utc = now
|
||||
if getattr(task, "aqmm_manager_review_required", False) and task.manager_review_status != "reviewed":
|
||||
task.manager_review_status = "pending"
|
||||
if getattr(task, "aqmm_partner_review_required", False) and task.partner_review_status != "reviewed":
|
||||
task.partner_review_status = "pending"
|
||||
if getattr(task, "aqmm_review_partner_required", False) and task.review_partner_review_status != "reviewed":
|
||||
task.review_partner_review_status = "pending"
|
||||
if task.status == "pending":
|
||||
task.status = "in_progress"
|
||||
if getattr(task, "rework_status", "none") == "open":
|
||||
task.rework_status = "resolved"
|
||||
task.rework_resolved_at_utc = now
|
||||
task.updated_by_user_id = user_id
|
||||
_append_system_task_comment(db, task=task, comment_type="staff_work_note", message=note or "Submitted for review.", user_id=user_id)
|
||||
recalculate_task_aqmm_status(db, task)
|
||||
|
||||
|
||||
def apply_task_review(
|
||||
db: Session,
|
||||
*,
|
||||
task: ClientServiceTaskInstance,
|
||||
review_level: str,
|
||||
decision: str,
|
||||
note: str,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
|
||||
return
|
||||
clean_decision = (decision or "reviewed").strip().lower()
|
||||
if clean_decision not in {"reviewed", "rework_required"}:
|
||||
clean_decision = "reviewed"
|
||||
now = datetime.now(timezone.utc)
|
||||
clean_note = (note or "").strip()
|
||||
if clean_decision == "rework_required" and not clean_note:
|
||||
raise ValueError("Rework reason is required.")
|
||||
|
||||
if review_level == "manager":
|
||||
task.manager_review_status = clean_decision
|
||||
task.manager_review_note = clean_note or task.manager_review_note
|
||||
task.manager_reviewed_by_user_id = user_id
|
||||
task.manager_reviewed_at_utc = now
|
||||
comment_type = "manager_review_note"
|
||||
elif review_level == "partner":
|
||||
task.partner_review_status = clean_decision
|
||||
task.partner_review_note = clean_note or task.partner_review_note
|
||||
task.partner_reviewed_by_user_id = user_id
|
||||
task.partner_reviewed_at_utc = now
|
||||
comment_type = "partner_review_note"
|
||||
elif review_level == "review_partner":
|
||||
task.review_partner_review_status = clean_decision
|
||||
task.review_partner_review_note = clean_note or task.review_partner_review_note
|
||||
task.review_partner_reviewed_by_user_id = user_id
|
||||
task.review_partner_reviewed_at_utc = now
|
||||
comment_type = "review_partner_review_note"
|
||||
else:
|
||||
raise ValueError("Invalid review level.")
|
||||
|
||||
if clean_decision == "rework_required":
|
||||
task.rework_status = "open"
|
||||
task.rework_reason = clean_note
|
||||
task.rework_requested_by_user_id = user_id
|
||||
task.rework_requested_at_utc = now
|
||||
task.rework_resolved_at_utc = None
|
||||
task.status = "blocked"
|
||||
comment_type = "rework_note"
|
||||
else:
|
||||
if getattr(task, "rework_status", "none") == "open":
|
||||
task.rework_status = "resolved"
|
||||
task.rework_resolved_at_utc = now
|
||||
if getattr(task, "is_aqmm_task", False) and _required_review_issue(task) is None and (not getattr(task, "aqmm_evidence_required", False) or _task_has_evidence(db, task.id)):
|
||||
task.status = "completed"
|
||||
if not task.completed_at_utc:
|
||||
task.completed_at_utc = now
|
||||
task.updated_by_user_id = user_id
|
||||
_append_system_task_comment(db, task=task, comment_type=comment_type, message=clean_note or clean_decision.replace("_", " ").title(), user_id=user_id)
|
||||
recalculate_task_aqmm_status(db, task)
|
||||
|
||||
def list_task_comments(db: Session, *, tenant_id: int, task_id: int) -> list[ServiceTaskComment]:
|
||||
return db.execute(
|
||||
select(ServiceTaskComment)
|
||||
|
||||
@@ -611,6 +611,35 @@ class ClientServiceTaskInstance(CommonBase):
|
||||
aqmm_review_status: Mapped[str] = mapped_column(String(40), default="not_required", nullable=False, index=True)
|
||||
aqmm_completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Phase 4 - AQMM task evidence and review notes.
|
||||
# For AQMM-tagged tasks, documents, work notes and review notes on this
|
||||
# task become the AQMM evidence trail. Normal non-AQMM tasks continue to use
|
||||
# the same fields as optional operational notes.
|
||||
evidence_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True)
|
||||
submitted_for_review_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
submitted_for_review_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
manager_review_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True)
|
||||
manager_review_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
manager_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
manager_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
partner_review_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True)
|
||||
partner_review_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
partner_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
partner_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
review_partner_review_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True)
|
||||
review_partner_review_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
review_partner_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
review_partner_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
rework_status: Mapped[str] = mapped_column(String(30), nullable=False, default="none", index=True)
|
||||
rework_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
rework_requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
rework_requested_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
rework_resolved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
started_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
@@ -219,6 +220,8 @@ def save_uploaded_task_document(
|
||||
user,
|
||||
existing_document_id: int | None = None,
|
||||
udin_required: bool | str | None = None,
|
||||
evidence_type: str | None = None,
|
||||
evidence_description: str | None = None,
|
||||
) -> EngagementDocument:
|
||||
engagement = task.subscription or db.get(ClientServiceSubscription, task.subscription_id)
|
||||
if engagement is None:
|
||||
@@ -241,5 +244,39 @@ def save_uploaded_task_document(
|
||||
)
|
||||
doc.task_instance_id = task.id
|
||||
doc.document_requirement_id = requirement.id if requirement else None
|
||||
if getattr(task, "is_aqmm_task", False):
|
||||
doc.is_aqmm_evidence = True
|
||||
doc.evidence_status = "uploaded"
|
||||
doc.evidence_type = (evidence_type or getattr(requirement, "document_type", None) or document_type or "GENERAL").strip()[:80]
|
||||
doc.evidence_description = (evidence_description or description or remarks or "").strip() or None
|
||||
elif not getattr(doc, "is_aqmm_evidence", False):
|
||||
doc.evidence_status = getattr(doc, "evidence_status", None) or "not_required"
|
||||
db.flush()
|
||||
return doc
|
||||
|
||||
|
||||
|
||||
def update_task_document_evidence_review(
|
||||
db: Session,
|
||||
*,
|
||||
document: EngagementDocument,
|
||||
evidence_status: str,
|
||||
evidence_type: str | None,
|
||||
evidence_description: str | None,
|
||||
evidence_review_note: str | None,
|
||||
user,
|
||||
) -> EngagementDocument:
|
||||
allowed = {"not_required", "uploaded", "accepted", "rejected"}
|
||||
status = (evidence_status or "uploaded").strip().lower()
|
||||
if status not in allowed:
|
||||
status = "uploaded"
|
||||
document.is_aqmm_evidence = bool(status != "not_required")
|
||||
document.evidence_status = status
|
||||
document.evidence_type = (evidence_type or "").strip()[:80] or None
|
||||
document.evidence_description = (evidence_description or "").strip() or None
|
||||
document.evidence_review_note = (evidence_review_note or "").strip() or None
|
||||
document.evidence_reviewed_by_user_id = getattr(user, "id", None)
|
||||
document.evidence_reviewed_at_utc = datetime.now(timezone.utc)
|
||||
document.updated_by_user_id = getattr(user, "id", None)
|
||||
db.flush()
|
||||
return document
|
||||
|
||||
@@ -24,6 +24,36 @@
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">This task is due today based on the internal target date.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if task.is_aqmm_task %}
|
||||
<div class="mt-4 rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-indigo-900">AQMM / Quality Evidence Task</div>
|
||||
<p class="mt-1 text-xs text-indigo-700">Documents, work notes and review notes recorded against this task are treated as AQMM evidence.</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-white px-3 py-1 text-xs font-medium text-indigo-700">{{ task.aqmm_status.replace('_',' ').title() }}</span>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 text-xs md:grid-cols-4">
|
||||
<div class="rounded-xl bg-white p-3"><div class="text-slate-500">Evidence</div><div class="mt-1 font-semibold text-slate-900">{{ task.evidence_status.replace('_',' ').title() }}</div></div>
|
||||
<div class="rounded-xl bg-white p-3"><div class="text-slate-500">Manager Review</div><div class="mt-1 font-semibold text-slate-900">{{ task.manager_review_status.replace('_',' ').title() }}</div></div>
|
||||
<div class="rounded-xl bg-white p-3"><div class="text-slate-500">Partner Review</div><div class="mt-1 font-semibold text-slate-900">{{ task.partner_review_status.replace('_',' ').title() }}</div></div>
|
||||
<div class="rounded-xl bg-white p-3"><div class="text-slate-500">Review Partner</div><div class="mt-1 font-semibold text-slate-900">{{ task.review_partner_review_status.replace('_',' ').title() }}</div></div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
{% if task.aqmm_mandatory %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">Mandatory</span>{% endif %}
|
||||
{% if task.aqmm_evidence_required %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">Evidence Required</span>{% endif %}
|
||||
{% if task.aqmm_manager_review_required %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">Manager Review</span>{% endif %}
|
||||
{% if task.aqmm_partner_review_required %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">Partner Review</span>{% endif %}
|
||||
{% if task.aqmm_review_partner_required %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">Review Partner Review</span>{% endif %}
|
||||
{% if task.aqmm_blocks_final_release %}<span class="rounded-full bg-red-100 px-2 py-1 text-red-800">Blocks Final Release</span>{% endif %}
|
||||
{% if task.aqmm_reference %}<span class="rounded-full bg-white px-2 py-1 text-slate-700">{{ task.aqmm_reference }}</span>{% endif %}
|
||||
</div>
|
||||
{% if task.rework_status == 'open' %}
|
||||
<div class="mt-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">Rework required: {{ task.rework_reason or '-' }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div>
|
||||
@@ -59,6 +89,7 @@
|
||||
<div class="md:col-span-2 flex items-center justify-between gap-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="is_active" {% if task.is_active %}checked{% endif %} {% if not can_manage_fields %}disabled{% endif %}> Active</label>
|
||||
<div class="flex gap-3">
|
||||
<a href="/documents/tasks/{{ task.id }}" class="rounded-xl border border-indigo-300 px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50">Task Evidence</a>
|
||||
<a href="/services/work-tracker" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
{% if can_edit %}<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button>{% endif %}
|
||||
</div>
|
||||
@@ -66,6 +97,61 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if task.is_aqmm_task %}
|
||||
<div class="mx-auto mt-6 max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">AQMM Submit & Review</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Use this task as the AQMM evidence container. Upload documents first where evidence is required, then submit for review.</p>
|
||||
</div>
|
||||
<a href="/documents/tasks/{{ task.id }}" class="rounded-xl border border-indigo-300 px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50">Open Evidence</a>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('submitted') %}<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Task submitted for review.</div>{% endif %}
|
||||
{% if request.query_params.get('reviewed') %}<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Review action saved.</div>{% endif %}
|
||||
{% if request.query_params.get('review_error') %}<div class="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">Review action failed. Check evidence requirement or rework note.</div>{% endif %}
|
||||
|
||||
{% if can_submit_review %}
|
||||
<form method="post" action="/services/work-tracker/tasks/{{ task.id }}/submit-review" class="mt-5 rounded-2xl border border-slate-200 p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Staff Work Note / Submission Note</label>
|
||||
<textarea name="note" rows="3" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Summarise work performed and evidence uploaded."></textarea>
|
||||
<div class="mt-3 flex justify-end"><button class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">Submit for Review</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-3">
|
||||
{% if task.aqmm_manager_review_required and can_manager_review %}
|
||||
<form method="post" action="/services/work-tracker/tasks/{{ task.id }}/review/manager" class="rounded-2xl border border-slate-200 p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="text-sm font-semibold text-slate-900">Manager Review</div>
|
||||
<select name="decision" class="mt-3 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="reviewed">Reviewed</option><option value="rework_required">Rework Required</option></select>
|
||||
<textarea name="note" rows="3" class="mt-3 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Review note / rework reason"></textarea>
|
||||
<button class="mt-3 rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if task.aqmm_partner_review_required and can_partner_review %}
|
||||
<form method="post" action="/services/work-tracker/tasks/{{ task.id }}/review/partner" class="rounded-2xl border border-slate-200 p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="text-sm font-semibold text-slate-900">Partner Review</div>
|
||||
<select name="decision" class="mt-3 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="reviewed">Reviewed</option><option value="rework_required">Rework Required</option></select>
|
||||
<textarea name="note" rows="3" class="mt-3 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Review note / rework reason"></textarea>
|
||||
<button class="mt-3 rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if task.aqmm_review_partner_required and can_review_partner_review %}
|
||||
<form method="post" action="/services/work-tracker/tasks/{{ task.id }}/review/review_partner" class="rounded-2xl border border-slate-200 p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="text-sm font-semibold text-slate-900">Review Partner Review</div>
|
||||
<select name="decision" class="mt-3 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="reviewed">Reviewed</option><option value="rework_required">Rework Required</option></select>
|
||||
<textarea name="note" rows="3" class="mt-3 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Review note / rework reason"></textarea>
|
||||
<button class="mt-3 rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mx-auto mt-6 max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.modules.services.execution import (
|
||||
TASK_STATUSES,
|
||||
add_task_comment,
|
||||
apply_bulk_task_update,
|
||||
apply_task_review,
|
||||
apply_task_update,
|
||||
dashboard_stats,
|
||||
generate_tasks_for_subscription,
|
||||
@@ -29,6 +30,8 @@ from app.modules.services.execution import (
|
||||
list_subscription_execution_payload,
|
||||
list_tasks_payload,
|
||||
parse_date_value,
|
||||
recalculate_task_aqmm_status,
|
||||
submit_task_for_review,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/services/work-tracker", tags=["services-work-tracker-ui"])
|
||||
@@ -119,10 +122,10 @@ def _can_update_task_status(db, user, task) -> bool:
|
||||
|
||||
def _allowed_comment_type_codes(db, user) -> set[str]:
|
||||
if _can_bulk_manage_tasks(db, user):
|
||||
return {"internal_note", "client_clarification", "consultant_clarification", "partner_review_note"}
|
||||
return {"internal_note", "client_clarification", "consultant_clarification", "manager_review_note", "partner_review_note", "review_partner_review_note", "rework_note"}
|
||||
if _is_staff(db, user):
|
||||
# Staff may communicate through consultants for clients where consultant is the communication channel.
|
||||
return {"internal_note", "client_clarification", "consultant_clarification"}
|
||||
return {"internal_note", "staff_work_note", "client_clarification", "consultant_clarification"}
|
||||
return set()
|
||||
|
||||
|
||||
@@ -132,6 +135,32 @@ def _can_add_task_comment(db, user, task) -> bool:
|
||||
return _can_update_task_status(db, user, task)
|
||||
|
||||
|
||||
def _is_manager_role(db, user) -> bool:
|
||||
roles = _role_names(db, user)
|
||||
return bool({"Manager", "Branch Manager", "Firm Admin"}.intersection(roles))
|
||||
|
||||
|
||||
def _can_submit_task_for_review(db, user, task) -> bool:
|
||||
return _can_update_task_status(db, user, task)
|
||||
|
||||
|
||||
def _can_manager_review_task(db, user, task) -> bool:
|
||||
return _can_bulk_manage_tasks(db, user) or (_is_manager_role(db, user) and _has_perm(db, user, "service_tasks.edit"))
|
||||
|
||||
|
||||
def _can_partner_review_task(db, user, task) -> bool:
|
||||
return _can_bulk_manage_tasks(db, user)
|
||||
|
||||
|
||||
def _can_review_partner_review_task(db, user, task) -> bool:
|
||||
subscription = getattr(task, "subscription", None)
|
||||
if not subscription:
|
||||
return False
|
||||
if _is_firm_admin(db, user):
|
||||
return _has_perm(db, user, "service_tasks.edit")
|
||||
return _is_partner(db, user) and int(getattr(subscription, "review_partner_user_id", 0) or 0) == int(user.id)
|
||||
|
||||
|
||||
def _comment_type_options_for_user(db, user):
|
||||
allowed = _allowed_comment_type_codes(db, user)
|
||||
return [(code, label) for code, label in TASK_COMMENT_TYPES if code in allowed]
|
||||
@@ -406,6 +435,10 @@ def task_edit_page(request: Request, task_id: int):
|
||||
comment_type_options=_comment_type_options_for_user(db, user),
|
||||
can_add_comment=_can_add_task_comment(db, user, task),
|
||||
can_edit=_can_update_task_status(db, user, task),
|
||||
can_submit_review=_can_submit_task_for_review(db, user, task),
|
||||
can_manager_review=_can_manager_review_task(db, user, task),
|
||||
can_partner_review=_can_partner_review_task(db, user, task),
|
||||
can_review_partner_review=_can_review_partner_review_task(db, user, task),
|
||||
can_manage_fields=_can_bulk_manage_tasks(db, user),
|
||||
can_reassign=_can_assign_staff(db, user),
|
||||
)
|
||||
@@ -481,12 +514,100 @@ def task_edit_submit(
|
||||
is_active=resolved_is_active,
|
||||
user_id=user.id,
|
||||
)
|
||||
recalculate_task_aqmm_status(db, task)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/submit-review")
|
||||
def task_submit_review(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
note: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
task = get_task(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||
partner_user_id=_partner_visibility_user_id(db, user),
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
if not _can_submit_task_for_review(db, user, task):
|
||||
return _redirect_denied()
|
||||
try:
|
||||
submit_task_for_review(db, task=task, note=note, user_id=user.id)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/services/work-tracker/tasks/{task_id}/edit?submitted=1", status_code=303)
|
||||
except ValueError:
|
||||
db.rollback()
|
||||
return RedirectResponse(url=f"/services/work-tracker/tasks/{task_id}/edit?review_error=1", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/review/{review_level}")
|
||||
def task_review_submit(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
review_level: str,
|
||||
decision: str = Form("reviewed"),
|
||||
note: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
task = get_task(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=None,
|
||||
partner_user_id=_partner_visibility_user_id(db, user),
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
allowed = False
|
||||
if review_level == "manager":
|
||||
allowed = _can_manager_review_task(db, user, task)
|
||||
elif review_level == "partner":
|
||||
allowed = _can_partner_review_task(db, user, task)
|
||||
elif review_level == "review_partner":
|
||||
allowed = _can_review_partner_review_task(db, user, task)
|
||||
if not allowed:
|
||||
return _redirect_denied()
|
||||
try:
|
||||
apply_task_review(db, task=task, review_level=review_level, decision=decision, note=note, user_id=user.id)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/services/work-tracker/tasks/{task_id}/edit?reviewed=1", status_code=303)
|
||||
except ValueError:
|
||||
db.rollback()
|
||||
return RedirectResponse(url=f"/services/work-tracker/tasks/{task_id}/edit?review_error=1", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/comments")
|
||||
def task_comment_submit(
|
||||
request: Request,
|
||||
|
||||
Reference in New Issue
Block a user