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