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
@@ -0,0 +1,151 @@
"""phase 4 aqmm task evidence and review notes
Revision ID: 20260626_phase_4_aqmm_task_evidence_review_notes
Revises: 20260625_phase_3_1_aqmm_task_quality_tags
Create Date: 2026-06-26
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260626_phase_4_aqmm_task_evidence_review_notes"
down_revision = "20260625_phase_3_1_aqmm_task_quality_tags"
branch_labels = None
depends_on = None
TASK_COLUMNS = [
sa.Column("evidence_status", sa.String(length=30), nullable=False, server_default="not_required"),
sa.Column("submitted_for_review_by_user_id", sa.Integer(), nullable=True),
sa.Column("submitted_for_review_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("manager_review_status", sa.String(length=30), nullable=False, server_default="not_required"),
sa.Column("manager_review_note", sa.Text(), nullable=True),
sa.Column("manager_reviewed_by_user_id", sa.Integer(), nullable=True),
sa.Column("manager_reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("partner_review_status", sa.String(length=30), nullable=False, server_default="not_required"),
sa.Column("partner_review_note", sa.Text(), nullable=True),
sa.Column("partner_reviewed_by_user_id", sa.Integer(), nullable=True),
sa.Column("partner_reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("review_partner_review_status", sa.String(length=30), nullable=False, server_default="not_required"),
sa.Column("review_partner_review_note", sa.Text(), nullable=True),
sa.Column("review_partner_reviewed_by_user_id", sa.Integer(), nullable=True),
sa.Column("review_partner_reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("rework_status", sa.String(length=30), nullable=False, server_default="none"),
sa.Column("rework_reason", sa.Text(), nullable=True),
sa.Column("rework_requested_by_user_id", sa.Integer(), nullable=True),
sa.Column("rework_requested_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("rework_resolved_at_utc", sa.DateTime(timezone=True), nullable=True),
]
DOCUMENT_COLUMNS = [
sa.Column("is_aqmm_evidence", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("evidence_type", sa.String(length=80), nullable=True),
sa.Column("evidence_description", sa.Text(), nullable=True),
sa.Column("evidence_status", sa.String(length=30), nullable=False, server_default="not_required"),
sa.Column("evidence_review_note", sa.Text(), nullable=True),
sa.Column("evidence_reviewed_by_user_id", sa.Integer(), nullable=True),
sa.Column("evidence_reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
]
def _has_table(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _has_column(bind, table_name: str, column_name: str) -> bool:
if not _has_table(bind, table_name):
return False
return column_name in {c["name"] for c in sa.inspect(bind).get_columns(table_name)}
def _add_column_once(bind, table: str, column: sa.Column) -> None:
if not _has_column(bind, table, column.name):
with op.batch_alter_table(table) as batch:
batch.add_column(column)
def _drop_column_once(bind, table: str, column_name: str) -> None:
if _has_column(bind, table, column_name):
with op.batch_alter_table(table) as batch:
batch.drop_column(column_name)
def upgrade() -> None:
bind = op.get_bind()
if _has_table(bind, "client_service_task_instances"):
for column in TASK_COLUMNS:
_add_column_once(bind, "client_service_task_instances", column.copy())
op.execute("""
UPDATE client_service_task_instances
SET evidence_status = CASE
WHEN COALESCE(is_aqmm_task, FALSE) = FALSE THEN 'not_required'
WHEN COALESCE(aqmm_evidence_required, FALSE) = FALSE THEN 'not_required'
WHEN EXISTS (
SELECT 1 FROM engagement_documents ed
WHERE ed.task_instance_id = client_service_task_instances.id
AND COALESCE(ed.is_deleted, FALSE) = FALSE
) THEN 'uploaded'
ELSE 'pending'
END,
manager_review_status = CASE WHEN COALESCE(aqmm_manager_review_required, FALSE) THEN 'pending' ELSE 'not_required' END,
partner_review_status = CASE WHEN COALESCE(aqmm_partner_review_required, FALSE) THEN 'pending' ELSE 'not_required' END,
review_partner_review_status = CASE WHEN COALESCE(aqmm_review_partner_required, FALSE) THEN 'pending' ELSE 'not_required' END
WHERE COALESCE(is_aqmm_task, FALSE) = TRUE
""")
if _has_table(bind, "engagement_documents"):
for column in DOCUMENT_COLUMNS:
_add_column_once(bind, "engagement_documents", column.copy())
op.execute("""
UPDATE engagement_documents ed
SET is_aqmm_evidence = TRUE,
evidence_status = 'uploaded',
evidence_type = COALESCE(ed.document_type, 'GENERAL')
WHERE ed.task_instance_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM client_service_task_instances t
WHERE t.id = ed.task_instance_id
AND COALESCE(t.is_aqmm_task, FALSE) = TRUE
)
""")
def downgrade() -> None:
bind = op.get_bind()
if _has_table(bind, "engagement_documents"):
for col in [
"evidence_reviewed_at_utc",
"evidence_reviewed_by_user_id",
"evidence_review_note",
"evidence_status",
"evidence_description",
"evidence_type",
"is_aqmm_evidence",
]:
_drop_column_once(bind, "engagement_documents", col)
if _has_table(bind, "client_service_task_instances"):
for col in [
"rework_resolved_at_utc",
"rework_requested_at_utc",
"rework_requested_by_user_id",
"rework_reason",
"rework_status",
"review_partner_reviewed_at_utc",
"review_partner_reviewed_by_user_id",
"review_partner_review_note",
"review_partner_review_status",
"partner_reviewed_at_utc",
"partner_reviewed_by_user_id",
"partner_review_note",
"partner_review_status",
"manager_reviewed_at_utc",
"manager_reviewed_by_user_id",
"manager_review_note",
"manager_review_status",
"submitted_for_review_at_utc",
"submitted_for_review_by_user_id",
"evidence_status",
]:
_drop_column_once(bind, "client_service_task_instances", col)
+26
View File
@@ -62,6 +62,19 @@ class EngagementDocument(CommonBase):
released_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
release_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Phase 4 - AQMM evidence metadata.
# If this document is uploaded against a task marked as AQMM, it is
# automatically treated as AQMM evidence. These fields store review status
# without creating a duplicate AQMM document vault.
is_aqmm_evidence: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
evidence_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
evidence_description: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True)
evidence_review_note: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
evidence_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
deleted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deleted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
@@ -291,6 +304,19 @@ class PermanentClientDocument(CommonBase):
released_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
release_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Phase 4 - AQMM evidence metadata.
# If this document is uploaded against a task marked as AQMM, it is
# automatically treated as AQMM evidence. These fields store review status
# without creating a duplicate AQMM document vault.
is_aqmm_evidence: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
evidence_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
evidence_description: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True)
evidence_review_note: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
evidence_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
deleted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deleted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+11
View File
@@ -527,6 +527,17 @@ def release_document_final(
raise ValueError("UDIN is required before final release for this document.")
if document.udin_required and document.udin_status != "generated":
raise ValueError("UDIN status must be Generated before final release.")
# Phase 4 AQMM final release gate. Assurance engagements can release final
# documents only after the AQMM-tagged task evidence/review trail is complete.
engagement = document.engagement or db.get(ClientServiceSubscription, document.engagement_id)
if engagement is not None:
from app.modules.services.client_services import quality_required_for_engagement
from app.modules.services.execution import assert_aqmm_quality_tasks_complete
if quality_required_for_engagement(getattr(engagement, "engagement_type", None)):
assert_aqmm_quality_tasks_complete(db, subscription_id=int(engagement.id))
document.final_release_status = "released"
document.status = "final"
document.released_by_user_id = getattr(user, "id", None)
@@ -17,6 +17,7 @@
</div>
{% if request.query_params.get('uploaded') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Task document uploaded successfully.</div>{% endif %}
{% if request.query_params.get('evidence_reviewed') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Evidence review status updated.</div>{% endif %}
{% if request.query_params.get('udin_saved') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">UDIN details saved successfully.</div>{% endif %}
{% if request.query_params.get('released') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Document released as final.</div>{% endif %}
{% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Upload/action failed. Please verify file, requirement, UDIN and permission.</div>{% endif %}
@@ -27,6 +28,24 @@
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Uploaded Documents</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ documents|length }}</div></div>
</section>
{% if task.is_aqmm_task %}
<section class="rounded-2xl border border-indigo-200 bg-indigo-50 p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-indigo-900">AQMM Evidence Container</h3>
<p class="mt-1 text-sm text-indigo-700">This task is marked as part of AQMM. Documents uploaded here, together with staff notes and review notes, become 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 Required</div><div class="mt-1 font-semibold text-slate-900">{{ 'Yes' if task.aqmm_evidence_required else 'No' }}</div></div>
<div class="rounded-xl bg-white p-3"><div class="text-slate-500">Evidence Status</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">Review Status</div><div class="mt-1 font-semibold text-slate-900">{{ task.aqmm_review_status.replace('_',' ').title() }}</div></div>
<div class="rounded-xl bg-white p-3"><div class="text-slate-500">Blocks Final Release</div><div class="mt-1 font-semibold text-slate-900">{{ 'Yes' if task.aqmm_blocks_final_release else 'No' }}</div></div>
</div>
</section>
{% endif %}
{% if can_upload %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h3 class="text-sm font-semibold text-slate-900">Upload Task Document</h3>
@@ -63,6 +82,16 @@
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">File</label>
<input type="file" name="file" required class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
{% if task.is_aqmm_task %}
<div class="md:col-span-2 rounded-xl border border-indigo-200 bg-indigo-50 p-3 text-sm text-indigo-900">
<div class="font-semibold">AQMM Evidence Details</div>
<p class="mt-1 text-xs text-indigo-800">This upload will be automatically marked as AQMM evidence because the task is part of AQMM.</p>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<input type="text" name="evidence_type" placeholder="Evidence type, e.g. Working Paper / Confirmation" class="rounded-xl border border-indigo-200 px-3 py-2 text-sm">
<input type="text" name="evidence_description" placeholder="Evidence description" class="rounded-xl border border-indigo-200 px-3 py-2 text-sm">
</div>
</div>
{% endif %}
<div class="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
<label class="inline-flex items-center gap-2 font-semibold">
<input type="checkbox" name="udin_required" value="1" class="rounded border-amber-300">
@@ -96,7 +125,7 @@
<td class="px-4 py-3 text-sm">{% if item.is_uploaded %}<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs text-emerald-800">Uploaded</span>{% elif req.is_mandatory %}<span class="rounded-full bg-red-100 px-2 py-1 text-xs text-red-800">Pending Mandatory</span>{% else %}<span class="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600">Optional Pending</span>{% endif %}</td>
<td class="px-4 py-3 text-xs text-slate-600">{% for doc in item.documents %}<div><a href="/documents/{{ doc.id }}/download" class="text-brand-700 hover:underline">{{ doc.title }}</a> · v{{ doc.current_version_no }}</div>{% else %}-{% endfor %}</td>
</tr>
{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">No document requirements configured for this task template.</td></tr>{% endfor %}
{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No document requirements configured for this task template.</td></tr>{% endfor %}
</tbody>
</table>
</section>
@@ -104,13 +133,23 @@
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-100 px-5 py-4"><h3 class="text-sm font-semibold text-slate-900">All Task Documents</h3></div>
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Document</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Latest Version</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">UDIN / Release</th><th class="px-4 py-3 text-right text-xs font-semibold uppercase text-slate-500">Action</th></tr></thead>
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Document</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Latest Version</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">AQMM Evidence</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">UDIN / Release</th><th class="px-4 py-3 text-right text-xs font-semibold uppercase text-slate-500">Action</th></tr></thead>
<tbody class="divide-y divide-slate-100">
{% for doc in documents %}
{% set latest = doc.versions[0] if doc.versions else None %}
<tr>
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ doc.title }}</div><div class="text-xs text-slate-500">{{ doc.document_type.replace('_',' ').title() }}{% if doc.document_requirement %} · {{ doc.document_requirement.document_name }}{% endif %}</div></td>
<td class="px-4 py-3 text-xs text-slate-600">{% if latest %}v{{ latest.version_no }} · {{ latest.original_filename }}{% else %}-{% endif %}</td>
<td class="px-4 py-3 text-xs text-slate-600 align-top">
{% if doc.is_aqmm_evidence %}
<div><span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">AQMM Evidence</span></div>
<div class="mt-2">Status: <span class="font-semibold">{{ (doc.evidence_status or 'uploaded').replace('_',' ').title() }}</span></div>
{% if doc.evidence_type %}<div class="mt-1">{{ doc.evidence_type }}</div>{% endif %}
{% if doc.evidence_review_note %}<div class="mt-1 text-slate-500">{{ doc.evidence_review_note }}</div>{% endif %}
{% elif task.is_aqmm_task %}
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">Not marked</span>
{% else %}-{% endif %}
</td>
<td class="px-4 py-3 text-xs text-slate-600">
<div><span class="rounded-full px-2 py-1 {{ 'bg-emerald-100 text-emerald-800' if doc.final_release_status == 'released' else 'bg-slate-100 text-slate-700' }}">{{ (doc.final_release_status or 'draft').replace('_',' ').title() }}</span></div>
<div class="mt-2">UDIN: <span class="font-semibold">{{ (doc.udin_status or 'not_required').replace('_',' ').title() }}</span></div>
@@ -119,6 +158,23 @@
</td>
<td class="px-4 py-3 text-right text-sm align-top">
{% if latest %}<a href="/documents/{{ doc.id }}/download" class="text-brand-700 hover:underline">Download</a>{% endif %}
{% if task.is_aqmm_task and can_upload %}
<details class="mt-3 text-left">
<summary class="cursor-pointer text-sm font-semibold text-indigo-700">Evidence Review</summary>
<form method="post" action="/documents/tasks/{{ task.id }}/documents/{{ doc.id }}/evidence-review" class="mt-3 grid gap-2 rounded-xl border border-indigo-100 bg-indigo-50 p-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="evidence_status" class="rounded-lg border border-indigo-200 px-2 py-1 text-xs">
{% for status_code, status_label in [('uploaded','Uploaded'),('accepted','Accepted'),('rejected','Rejected / Insufficient'),('not_required','Not Required')] %}
<option value="{{ status_code }}" {% if doc.evidence_status == status_code %}selected{% endif %}>{{ status_label }}</option>
{% endfor %}
</select>
<input type="text" name="evidence_type" value="{{ doc.evidence_type or doc.document_type }}" placeholder="Evidence type" class="rounded-lg border border-indigo-200 px-2 py-1 text-xs">
<input type="text" name="evidence_description" value="{{ doc.evidence_description or '' }}" placeholder="Evidence description" class="rounded-lg border border-indigo-200 px-2 py-1 text-xs">
<input type="text" name="evidence_review_note" value="{{ doc.evidence_review_note or '' }}" placeholder="Evidence review note" class="rounded-lg border border-indigo-200 px-2 py-1 text-xs">
<button class="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white">Save Evidence Review</button>
</form>
</details>
{% endif %}
{% if 'udin.manage' in current_user_permissions %}
<details class="mt-3 text-left">
<summary class="cursor-pointer text-sm font-semibold text-brand-700">UDIN / Release</summary>
@@ -149,7 +205,7 @@
{% endif %}
</td>
</tr>
{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">No task documents uploaded yet.</td></tr>{% endfor %}
{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No task documents uploaded yet.</td></tr>{% endfor %}
</tbody>
</table>
</section>
+54
View File
@@ -87,7 +87,9 @@ from app.modules.services.task_documents import (
list_task_document_requirements,
requirement_upload_status,
save_uploaded_task_document,
update_task_document_evidence_review,
)
from app.modules.services.execution import recalculate_task_aqmm_status
router = APIRouter(prefix="/documents", tags=["documents-ui"])
logger = logging.getLogger("audit_storage_agent.documents_ui")
@@ -205,6 +207,8 @@ def upload_engagement_document(
remarks: str | None = Form(None),
existing_document_id: str | None = Form(None),
udin_required: str | None = Form(None),
evidence_type: str | None = Form(None),
evidence_description: str | None = Form(None),
file: UploadFile = File(...),
csrf_token: str = Form(...),
):
@@ -237,7 +241,10 @@ def upload_engagement_document(
user=user,
existing_document_id=int(existing_document_id) if existing_document_id else None,
udin_required=_bool_from_form(udin_required),
evidence_type=evidence_type,
evidence_description=evidence_description,
)
recalculate_task_aqmm_status(db, task)
log_document_access(db, action="upload", result="success", user=user, request=request, document=doc)
db.commit()
except Exception as exc:
@@ -308,6 +315,8 @@ def upload_task_document(
remarks: str | None = Form(None),
existing_document_id: str | None = Form(None),
udin_required: str | None = Form(None),
evidence_type: str | None = Form(None),
evidence_description: str | None = Form(None),
file: UploadFile = File(...),
csrf_token: str = Form(...),
):
@@ -347,7 +356,10 @@ def upload_task_document(
user=user,
existing_document_id=int(existing_document_id) if existing_document_id else None,
udin_required=_bool_from_form(udin_required),
evidence_type=evidence_type,
evidence_description=evidence_description,
)
recalculate_task_aqmm_status(db, task)
log_document_access(db, action="task_upload", result="success", user=user, request=request, document=doc)
db.commit()
except Exception as exc:
@@ -363,6 +375,48 @@ def upload_task_document(
finally:
db.close()
@router.post("/tasks/{task_id}/documents/{document_id}/evidence-review")
def update_task_document_evidence(
request: Request,
task_id: int,
document_id: int,
evidence_status: str = Form("uploaded"),
evidence_type: str | None = Form(None),
evidence_description: str | None = Form(None),
evidence_review_note: str | None = Form(None),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "documents.upload")
if response:
return response
scope = build_document_scope(request, db, user)
task = get_task_with_subscription(db, task_id)
engagement = task.subscription if task else None
if not task or not engagement or not user_can_upload_to_engagement(db, user, engagement, scope):
return _redirect_denied()
document = get_document(db, document_id)
if not document or int(document.task_instance_id or 0) != int(task.id):
return RedirectResponse(url=f"/documents/tasks/{task_id}?error=invalid_document", status_code=303)
update_task_document_evidence_review(
db,
document=document,
evidence_status=evidence_status,
evidence_type=evidence_type,
evidence_description=evidence_description,
evidence_review_note=evidence_review_note,
user=user,
)
recalculate_task_aqmm_status(db, task)
log_document_access(db, action="task_evidence_review", result="success", user=user, request=request, document=document)
db.commit()
return RedirectResponse(url=f"/documents/tasks/{task_id}?evidence_reviewed=1", status_code=303)
finally:
db.close()
@router.get("/udin-register")
def udin_register(request: Request, financial_year: str = "", status: str = "", q: str = ""):
db = CommonSessionLocal()
+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,