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 %}