diff --git a/alembic/versions/20260626_phase_4_aqmm_task_evidence_review_notes.py b/alembic/versions/20260626_phase_4_aqmm_task_evidence_review_notes.py new file mode 100644 index 0000000..a035955 --- /dev/null +++ b/alembic/versions/20260626_phase_4_aqmm_task_evidence_review_notes.py @@ -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) diff --git a/app/modules/documents/models.py b/app/modules/documents/models.py index 3849511..1079521 100644 --- a/app/modules/documents/models.py +++ b/app/modules/documents/models.py @@ -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) diff --git a/app/modules/documents/services.py b/app/modules/documents/services.py index b13ea12..c8472de 100644 --- a/app/modules/documents/services.py +++ b/app/modules/documents/services.py @@ -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) diff --git a/app/modules/documents/templates/documents/task_documents.html b/app/modules/documents/templates/documents/task_documents.html index df22edb..04911a1 100644 --- a/app/modules/documents/templates/documents/task_documents.html +++ b/app/modules/documents/templates/documents/task_documents.html @@ -17,6 +17,7 @@ {% if request.query_params.get('uploaded') %}
Task document uploaded successfully.
{% endif %} + {% if request.query_params.get('evidence_reviewed') %}
Evidence review status updated.
{% endif %} {% if request.query_params.get('udin_saved') %}
UDIN details saved successfully.
{% endif %} {% if request.query_params.get('released') %}
Document released as final.
{% endif %} {% if request.query_params.get('error') %}
Upload/action failed. Please verify file, requirement, UDIN and permission.
{% endif %} @@ -27,6 +28,24 @@
Uploaded Documents
{{ documents|length }}
+ {% if task.is_aqmm_task %} +
+
+
+

AQMM Evidence Container

+

This task is marked as part of AQMM. Documents uploaded here, together with staff notes and review notes, become AQMM evidence.

+
+ {{ task.aqmm_status.replace('_',' ').title() }} +
+
+
Evidence Required
{{ 'Yes' if task.aqmm_evidence_required else 'No' }}
+
Evidence Status
{{ task.evidence_status.replace('_',' ').title() }}
+
Review Status
{{ task.aqmm_review_status.replace('_',' ').title() }}
+
Blocks Final Release
{{ 'Yes' if task.aqmm_blocks_final_release else 'No' }}
+
+
+ {% endif %} + {% if can_upload %}

Upload Task Document

@@ -63,6 +82,16 @@ + {% if task.is_aqmm_task %} +
+
AQMM Evidence Details
+

This upload will be automatically marked as AQMM evidence because the task is part of AQMM.

+
+ + +
+
+ {% endif %}
@@ -104,13 +133,23 @@

All Task Documents

- + {% for doc in documents %} {% set latest = doc.versions[0] if doc.versions else None %} + - {% else %}{% endfor %} + {% else %}{% endfor %}
DocumentLatest VersionUDIN / ReleaseAction
DocumentLatest VersionAQMM EvidenceUDIN / ReleaseAction
{{ doc.title }}
{{ doc.document_type.replace('_',' ').title() }}{% if doc.document_requirement %} · {{ doc.document_requirement.document_name }}{% endif %}
{% if latest %}v{{ latest.version_no }} · {{ latest.original_filename }}{% else %}-{% endif %} + {% if doc.is_aqmm_evidence %} +
AQMM Evidence
+
Status: {{ (doc.evidence_status or 'uploaded').replace('_',' ').title() }}
+ {% if doc.evidence_type %}
{{ doc.evidence_type }}
{% endif %} + {% if doc.evidence_review_note %}
{{ doc.evidence_review_note }}
{% endif %} + {% elif task.is_aqmm_task %} + Not marked + {% else %}-{% endif %} +
{{ (doc.final_release_status or 'draft').replace('_',' ').title() }}
UDIN: {{ (doc.udin_status or 'not_required').replace('_',' ').title() }}
@@ -119,6 +158,23 @@
{% if latest %}Download{% endif %} + {% if task.is_aqmm_task and can_upload %} +
+ Evidence Review +
+ + + + + + +
+
+ {% endif %} {% if 'udin.manage' in current_user_permissions %}
UDIN / Release @@ -149,7 +205,7 @@ {% endif %}
No task documents uploaded yet.
No task documents uploaded yet.
diff --git a/app/modules/documents/ui.py b/app/modules/documents/ui.py index ce767d0..c220287 100644 --- a/app/modules/documents/ui.py +++ b/app/modules/documents/ui.py @@ -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() diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py index ff751cf..a8614f0 100644 --- a/app/modules/services/execution.py +++ b/app/modules/services/execution.py @@ -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) diff --git a/app/modules/services/models.py b/app/modules/services/models.py index 547682e..d6ec03c 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -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) diff --git a/app/modules/services/task_documents.py b/app/modules/services/task_documents.py index a2301d5..5935e71 100644 --- a/app/modules/services/task_documents.py +++ b/app/modules/services/task_documents.py @@ -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 diff --git a/app/modules/services/templates/services/work_tracker/task_form.html b/app/modules/services/templates/services/work_tracker/task_form.html index ad783d3..4af9809 100644 --- a/app/modules/services/templates/services/work_tracker/task_form.html +++ b/app/modules/services/templates/services/work_tracker/task_form.html @@ -24,6 +24,36 @@
This task is due today based on the internal target date.
{% endif %} + {% if task.is_aqmm_task %} +
+
+
+
AQMM / Quality Evidence Task
+

Documents, work notes and review notes recorded against this task are treated as AQMM evidence.

+
+ {{ task.aqmm_status.replace('_',' ').title() }} +
+
+
Evidence
{{ task.evidence_status.replace('_',' ').title() }}
+
Manager Review
{{ task.manager_review_status.replace('_',' ').title() }}
+
Partner Review
{{ task.partner_review_status.replace('_',' ').title() }}
+
Review Partner
{{ task.review_partner_review_status.replace('_',' ').title() }}
+
+
+ {% if task.aqmm_mandatory %}Mandatory{% endif %} + {% if task.aqmm_evidence_required %}Evidence Required{% endif %} + {% if task.aqmm_manager_review_required %}Manager Review{% endif %} + {% if task.aqmm_partner_review_required %}Partner Review{% endif %} + {% if task.aqmm_review_partner_required %}Review Partner Review{% endif %} + {% if task.aqmm_blocks_final_release %}Blocks Final Release{% endif %} + {% if task.aqmm_reference %}{{ task.aqmm_reference }}{% endif %} +
+ {% if task.rework_status == 'open' %} +
Rework required: {{ task.rework_reason or '-' }}
+ {% endif %} +
+ {% endif %} +
@@ -59,6 +89,7 @@
+ Task Evidence Cancel {% if can_edit %}{% endif %}
@@ -66,6 +97,61 @@
+{% if task.is_aqmm_task %} +
+
+
+

AQMM Submit & Review

+

Use this task as the AQMM evidence container. Upload documents first where evidence is required, then submit for review.

+
+ Open Evidence +
+ + {% if request.query_params.get('submitted') %}
Task submitted for review.
{% endif %} + {% if request.query_params.get('reviewed') %}
Review action saved.
{% endif %} + {% if request.query_params.get('review_error') %}
Review action failed. Check evidence requirement or rework note.
{% endif %} + + {% if can_submit_review %} +
+ + + +
+
+ {% endif %} + +
+ {% if task.aqmm_manager_review_required and can_manager_review %} +
+ +
Manager Review
+ + + +
+ {% endif %} + {% if task.aqmm_partner_review_required and can_partner_review %} +
+ +
Partner Review
+ + + +
+ {% endif %} + {% if task.aqmm_review_partner_required and can_review_partner_review %} +
+ +
Review Partner Review
+ + + +
+ {% endif %} +
+
+{% endif %} +
diff --git a/app/modules/services/work_tracker_ui.py b/app/modules/services/work_tracker_ui.py index e095509..e0d4f29 100644 --- a/app/modules/services/work_tracker_ui.py +++ b/app/modules/services/work_tracker_ui.py @@ -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,