Add AQMM task evidence and review notes workflow

This commit is contained in:
A R R R Associates
2026-07-07 12:03:07 +05:30
parent 05690a220c
commit 91b1811eb8
10 changed files with 816 additions and 11 deletions
+240 -6
View File
@@ -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)
+29
View File
@@ -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)
+37
View File
@@ -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>
+123 -2
View File
@@ -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,