Add AQMM task evidence and review notes workflow
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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