Add AQMM quality tags to existing service task checklist

This commit is contained in:
A R R R Associates
2026-07-07 11:30:59 +05:30
parent 05c882304b
commit 05690a220c
11 changed files with 448 additions and 5 deletions
@@ -0,0 +1,125 @@
"""phase 3.1 aqmm quality tags on existing service tasks
Revision ID: 20260625_phase_3_1_aqmm_task_quality_tags
Revises: 20260624_phase_3_aqmm_engagement_level_rework
Create Date: 2026-06-25
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260625_phase_3_1_aqmm_task_quality_tags"
down_revision = "20260624_phase_3_aqmm_engagement_level_rework"
branch_labels = None
depends_on = None
AQMM_TEMPLATE_COLUMNS = [
sa.Column("is_aqmm_task", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("aqmm_mandatory", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("aqmm_evidence_required", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("aqmm_manager_review_required", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("aqmm_partner_review_required", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("aqmm_review_partner_required", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("aqmm_blocks_final_release", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("aqmm_reference", sa.String(length=120), nullable=True),
]
AQMM_INSTANCE_COLUMNS = AQMM_TEMPLATE_COLUMNS + [
sa.Column("aqmm_status", sa.String(length=40), nullable=False, server_default="not_required"),
sa.Column("aqmm_review_status", sa.String(length=40), nullable=False, server_default="not_required"),
sa.Column("aqmm_completed_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()
for table in ["service_default_task_templates", "firm_service_task_templates"]:
if _has_table(bind, table):
for column in AQMM_TEMPLATE_COLUMNS:
_add_column_once(bind, table, column.copy())
if _has_table(bind, "client_service_task_instances"):
for column in AQMM_INSTANCE_COLUMNS:
_add_column_once(bind, "client_service_task_instances", column.copy())
# Backfill existing generated tasks from their firm task templates.
op.execute("""
UPDATE client_service_task_instances csti
SET is_aqmm_task = COALESCE(fst.is_aqmm_task, FALSE),
aqmm_mandatory = COALESCE(fst.aqmm_mandatory, FALSE),
aqmm_evidence_required = COALESCE(fst.aqmm_evidence_required, FALSE),
aqmm_manager_review_required = COALESCE(fst.aqmm_manager_review_required, FALSE),
aqmm_partner_review_required = COALESCE(fst.aqmm_partner_review_required, FALSE),
aqmm_review_partner_required = COALESCE(fst.aqmm_review_partner_required, FALSE),
aqmm_blocks_final_release = COALESCE(fst.aqmm_blocks_final_release, FALSE),
aqmm_reference = fst.aqmm_reference,
aqmm_status = CASE WHEN COALESCE(fst.is_aqmm_task, FALSE) THEN 'pending' ELSE 'not_required' END,
aqmm_review_status = CASE
WHEN COALESCE(fst.aqmm_manager_review_required, FALSE)
OR COALESCE(fst.aqmm_partner_review_required, FALSE)
OR COALESCE(fst.aqmm_review_partner_required, FALSE)
THEN 'pending_review'
ELSE 'not_required'
END
FROM firm_service_task_templates fst
WHERE csti.firm_task_template_id = fst.id
""")
def downgrade() -> None:
bind = op.get_bind()
for table in ["client_service_task_instances"]:
if _has_table(bind, table):
for col in [
"aqmm_completed_at_utc",
"aqmm_review_status",
"aqmm_status",
"aqmm_reference",
"aqmm_blocks_final_release",
"aqmm_review_partner_required",
"aqmm_partner_review_required",
"aqmm_manager_review_required",
"aqmm_evidence_required",
"aqmm_mandatory",
"is_aqmm_task",
]:
_drop_column_once(bind, table, col)
for table in ["firm_service_task_templates", "service_default_task_templates"]:
if _has_table(bind, table):
for col in [
"aqmm_reference",
"aqmm_blocks_final_release",
"aqmm_review_partner_required",
"aqmm_partner_review_required",
"aqmm_manager_review_required",
"aqmm_evidence_required",
"aqmm_mandatory",
"is_aqmm_task",
]:
_drop_column_once(bind, table, col)
+32
View File
@@ -73,6 +73,14 @@ DEFAULT_TASK_COLUMNS = [
"default_role_name",
"is_mandatory",
"requires_review",
"is_aqmm_task",
"aqmm_mandatory",
"aqmm_evidence_required",
"aqmm_manager_review_required",
"aqmm_partner_review_required",
"aqmm_review_partner_required",
"aqmm_blocks_final_release",
"aqmm_reference",
"is_active",
"description",
]
@@ -84,6 +92,14 @@ FIRM_TASK_COLUMNS = [
"default_role_name",
"is_mandatory",
"requires_review",
"is_aqmm_task",
"aqmm_mandatory",
"aqmm_evidence_required",
"aqmm_manager_review_required",
"aqmm_partner_review_required",
"aqmm_review_partner_required",
"aqmm_blocks_final_release",
"aqmm_reference",
"is_active",
"description",
]
@@ -1010,6 +1026,14 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
task.default_role_name = _clean(_cell(row, headers, "default_role_name")) or None
task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True)
task.requires_review = _bool(_cell(row, headers, "requires_review"), False)
task.is_aqmm_task = _bool(_cell(row, headers, "is_aqmm_task"), False)
task.aqmm_mandatory = _bool(_cell(row, headers, "aqmm_mandatory"), False)
task.aqmm_evidence_required = _bool(_cell(row, headers, "aqmm_evidence_required"), False)
task.aqmm_manager_review_required = _bool(_cell(row, headers, "aqmm_manager_review_required"), False)
task.aqmm_partner_review_required = _bool(_cell(row, headers, "aqmm_partner_review_required"), False)
task.aqmm_review_partner_required = _bool(_cell(row, headers, "aqmm_review_partner_required"), False)
task.aqmm_blocks_final_release = _bool(_cell(row, headers, "aqmm_blocks_final_release"), False)
task.aqmm_reference = _clean(_cell(row, headers, "aqmm_reference")) or None
task.is_active = _bool(_cell(row, headers, "is_active"), True)
except Exception as exc:
errors.append({"row": row_no, "message": str(exc)})
@@ -1067,6 +1091,14 @@ def import_firm_task_templates(db: Session, *, current_user, tenant_id: int, fil
task.default_role_name = _clean(_cell(row, headers, "default_role_name")) or None
task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True)
task.requires_review = _bool(_cell(row, headers, "requires_review"), False)
task.is_aqmm_task = _bool(_cell(row, headers, "is_aqmm_task"), False)
task.aqmm_mandatory = _bool(_cell(row, headers, "aqmm_mandatory"), False)
task.aqmm_evidence_required = _bool(_cell(row, headers, "aqmm_evidence_required"), False)
task.aqmm_manager_review_required = _bool(_cell(row, headers, "aqmm_manager_review_required"), False)
task.aqmm_partner_review_required = _bool(_cell(row, headers, "aqmm_partner_review_required"), False)
task.aqmm_review_partner_required = _bool(_cell(row, headers, "aqmm_review_partner_required"), False)
task.aqmm_blocks_final_release = _bool(_cell(row, headers, "aqmm_blocks_final_release"), False)
task.aqmm_reference = _clean(_cell(row, headers, "aqmm_reference")) or None
task.is_active = _bool(_cell(row, headers, "is_active"), True)
task.updated_by_user_id = current_user.id
except Exception as exc:
+3
View File
@@ -10,6 +10,7 @@ from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
from app.modules.services.execution import aqmm_task_summary_for_subscription
from app.modules.clients.models import Client
from app.modules.services.client_services import (
SUBSCRIPTION_STATUSES,
@@ -398,6 +399,7 @@ def subscription_detail(request: Request, subscription_id: int):
my_pending_declarations = [d for d in declarations if d.requested_user_id == user.id and d.status == "pending"]
kyc_verification = get_latest_engagement_kyc_verification(db, subscription_id=row.id)
engagement_letter = get_current_engagement_letter(db, subscription_id=row.id)
aqmm_task_summary = aqmm_task_summary_for_subscription(db, subscription_id=row.id)
tasks = db.execute(
select(ClientServiceTaskInstance)
@@ -417,6 +419,7 @@ def subscription_detail(request: Request, subscription_id: int):
my_pending_declarations=my_pending_declarations,
kyc_verification=kyc_verification,
engagement_letter=engagement_letter,
aqmm_task_summary=aqmm_task_summary,
can_manage=_can_manage_client_services(db, user),
)
finally:
+94
View File
@@ -6,6 +6,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.modules.clients.models import Client
from app.modules.documents.models import EngagementDocument
from app.modules.core.iam.models import User
from app.modules.services.client_services import quality_required_for_engagement, QUALITY_APPROVED
from app.modules.services.models import (
@@ -222,6 +223,16 @@ def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceS
internal_target_date=_default_internal_target_date(subscription, template),
status="pending",
priority="normal",
is_aqmm_task=getattr(template, "is_aqmm_task", False),
aqmm_mandatory=getattr(template, "aqmm_mandatory", False),
aqmm_evidence_required=getattr(template, "aqmm_evidence_required", False),
aqmm_manager_review_required=getattr(template, "aqmm_manager_review_required", False),
aqmm_partner_review_required=getattr(template, "aqmm_partner_review_required", False),
aqmm_review_partner_required=getattr(template, "aqmm_review_partner_required", False),
aqmm_blocks_final_release=getattr(template, "aqmm_blocks_final_release", False),
aqmm_reference=getattr(template, "aqmm_reference", None),
aqmm_status="pending" if getattr(template, "is_aqmm_task", False) else "not_required",
aqmm_review_status="pending_review" if (getattr(template, "aqmm_manager_review_required", False) or getattr(template, "aqmm_partner_review_required", False) or getattr(template, "aqmm_review_partner_required", False)) else "not_required",
is_active=True,
created_by_user_id=user_id,
updated_by_user_id=user_id,
@@ -231,6 +242,89 @@ def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceS
return created
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),
)
).first()
)
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.
"""
if not getattr(task, "is_aqmm_task", False):
return None
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"
return None
def list_aqmm_quality_tasks(db: Session, *, subscription_id: int) -> list[dict]:
tasks = db.execute(
select(ClientServiceTaskInstance)
.where(
ClientServiceTaskInstance.subscription_id == int(subscription_id),
ClientServiceTaskInstance.is_active.is_(True),
ClientServiceTaskInstance.is_aqmm_task.is_(True),
)
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
).scalars().all()
rows: 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})
return rows
def aqmm_task_summary_for_subscription(db: Session, *, subscription_id: int) -> dict:
rows = list_aqmm_quality_tasks(db, subscription_id=subscription_id)
total = len(rows)
completed = sum(1 for r in rows if r["task"].status == "completed")
mandatory = sum(1 for r in rows if getattr(r["task"], "aqmm_mandatory", False))
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"])
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"
return {
"rows": rows,
"total": total,
"completed": completed,
"mandatory": mandatory,
"mandatory_done": mandatory_done,
"evidence_required": evidence_required,
"evidence_missing": evidence_missing,
"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"]:
first = summary["blockers"][0]
task = first["task"]
raise ValueError(f"AQMM final release blocked: {task.task_name} - {first['issue']}")
if summary["mandatory"] and summary["mandatory_done"] < summary["mandatory"]:
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.")
def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance:
target_date = getattr(task, "internal_target_date", None)
subscription = getattr(task, "subscription", None)
+39
View File
@@ -111,6 +111,19 @@ class ServiceDefaultTaskTemplate(CommonBase):
default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# AQMM task tags. These flags allow the existing service checklist to become
# the engagement quality checklist for assurance engagements, without creating
# a separate duplicate AQMM checklist module.
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
aqmm_mandatory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_evidence_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_manager_review_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_partner_review_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_review_partner_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_blocks_final_release: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_reference: Mapped[str | None] = mapped_column(String(120), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
catalogue = relationship("ServiceCatalogue", back_populates="default_task_templates")
@@ -166,6 +179,17 @@ class FirmServiceTaskTemplate(CommonBase):
default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# AQMM task tags copied into generated ClientServiceTaskInstance rows.
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
aqmm_mandatory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_evidence_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_manager_review_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_partner_review_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_review_partner_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_blocks_final_release: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_reference: Mapped[str | None] = mapped_column(String(120), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
@@ -572,6 +596,21 @@ class ClientServiceTaskInstance(CommonBase):
internal_target_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
# Copied from FirmServiceTaskTemplate at generation time. Existing task
# completion/evidence upload flow is reused to calculate AQMM quality status.
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
aqmm_mandatory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_evidence_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_manager_review_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_partner_review_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_review_partner_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_blocks_final_release: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
aqmm_reference: Mapped[str | None] = mapped_column(String(120), nullable=True)
aqmm_status: Mapped[str] = mapped_column(String(40), default="not_required", nullable=False, index=True)
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)
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -47,6 +47,24 @@
<textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ task.description or '' }}</textarea>
</div>
<div class="md:col-span-2 rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
<div class="text-sm font-semibold text-indigo-950">AQMM / Quality Control Tagging</div>
<p class="mt-1 text-xs text-indigo-800">Use these only for tasks that should become part of the assurance engagement quality checklist. Non-assurance engagements will continue using the normal task flow.</p>
<div class="mt-3 grid gap-3 md:grid-cols-2 text-sm text-slate-700">
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_aqmm_task" {% if task.is_aqmm_task %}checked{% endif %}> Part of AQMM / quality checklist</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_mandatory" {% if task.aqmm_mandatory %}checked{% endif %}> Mandatory for AQMM completion</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_evidence_required" {% if task.aqmm_evidence_required %}checked{% endif %}> Evidence upload required</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_manager_review_required" {% if task.aqmm_manager_review_required %}checked{% endif %}> Manager review required</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_partner_review_required" {% if task.aqmm_partner_review_required %}checked{% endif %}> Partner review required</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_review_partner_required" {% if task.aqmm_review_partner_required %}checked{% endif %}> Review partner review required</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_blocks_final_release" {% if task.aqmm_blocks_final_release %}checked{% endif %}> Block final release until completed</label>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-indigo-800">AQMM Reference / Clause</label>
<input type="text" name="aqmm_reference" value="{{ task.aqmm_reference or '' }}" class="w-full rounded-xl border border-indigo-200 px-3 py-2 text-sm" placeholder="e.g. Acceptance / Planning / Review">
</div>
</div>
</div>
<div class="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
To deactivate this system default task, untick <strong>Active</strong> and save. Existing firm-copied tasks will not be automatically changed.
</div>
@@ -19,7 +19,7 @@
<div class="mt-1 text-xs text-slate-500">
Role: {{ task.default_role_name or '-' }} ·
Mandatory: {{ 'Yes' if task.is_mandatory else 'No' }} ·
Review: {{ 'Yes' if task.requires_review else 'No' }}
Review: {{ 'Yes' if task.requires_review else 'No' }} · AQMM: {{ 'Yes' if task.is_aqmm_task else 'No' }}
</div>
</div>
<div class="flex items-center gap-3">
@@ -46,6 +46,19 @@
<div><label class="mb-2 block text-sm font-medium text-slate-700">Sequence No</label><input type="number" min="1" name="sequence_no" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_mandatory" checked> Mandatory</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="requires_review"> Review</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" checked> Active</label></div>
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea></div>
<div class="md:col-span-2 rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
<div class="text-sm font-semibold text-indigo-950">AQMM / Quality Control Tagging</div>
<div class="mt-3 grid gap-2 md:grid-cols-4 text-xs text-slate-700">
<label class="inline-flex items-center gap-1"><input type="checkbox" name="is_aqmm_task"> Part of AQMM</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_mandatory"> Mandatory</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_evidence_required"> Evidence required</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_manager_review_required"> Manager review</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_partner_review_required"> Partner review</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_review_partner_required"> Review partner review</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_blocks_final_release"> Blocks final release</label>
<input name="aqmm_reference" placeholder="AQMM reference / clause" class="rounded-lg border border-indigo-200 px-2 py-1 text-xs">
</div>
</div>
<div class="md:col-span-2 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Add Default Task</button></div>
</form>
</section>
@@ -45,6 +45,41 @@
</div>
{% endif %}
<div class="mt-5 rounded-2xl border border-white bg-white p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h4 class="text-sm font-semibold text-slate-900">AQMM Quality Checklist Tasks</h4>
<p class="mt-1 text-xs text-slate-500">These are your existing service tasks tagged as part of AQMM. No separate checklist is created.</p>
</div>
<div class="grid grid-cols-2 gap-2 text-xs md:grid-cols-4">
<span class="rounded-xl bg-slate-100 px-3 py-2">Total: <strong>{{ aqmm_task_summary.total if aqmm_task_summary else 0 }}</strong></span>
<span class="rounded-xl bg-emerald-100 px-3 py-2 text-emerald-800">Completed: <strong>{{ aqmm_task_summary.completed if aqmm_task_summary else 0 }}</strong></span>
<span class="rounded-xl bg-amber-100 px-3 py-2 text-amber-800">Evidence Missing: <strong>{{ aqmm_task_summary.evidence_missing if aqmm_task_summary else 0 }}</strong></span>
<span class="rounded-xl bg-purple-100 px-3 py-2 text-purple-800">Final Blockers: <strong>{{ aqmm_task_summary.blockers|length if aqmm_task_summary else 0 }}</strong></span>
</div>
</div>
<div class="mt-4 overflow-hidden rounded-xl border border-slate-200">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50"><tr><th class="px-3 py-2 text-left">Task</th><th class="px-3 py-2 text-left">AQMM Flags</th><th class="px-3 py-2 text-left">Evidence</th><th class="px-3 py-2 text-left">Status</th><th class="px-3 py-2 text-right">Action</th></tr></thead>
<tbody class="divide-y divide-slate-100">
{% for item in aqmm_task_summary.rows if aqmm_task_summary %}
{% set task = item.task %}
<tr>
<td class="px-3 py-2"><div class="font-medium text-slate-900">{{ task.sequence_no }}. {{ task.task_name }}</div>{% if task.aqmm_reference %}<div class="text-xs text-slate-500">{{ task.aqmm_reference }}</div>{% endif %}</td>
<td class="px-3 py-2 text-xs"><div class="flex flex-wrap gap-1">{% if task.aqmm_mandatory %}<span class="rounded-full bg-rose-100 px-2 py-1 text-rose-800">Mandatory</span>{% endif %}{% if task.aqmm_evidence_required %}<span class="rounded-full bg-sky-100 px-2 py-1 text-sky-800">Evidence</span>{% endif %}{% if task.aqmm_manager_review_required %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-800">Manager</span>{% endif %}{% if task.aqmm_partner_review_required %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">Partner</span>{% endif %}{% if task.aqmm_review_partner_required %}<span class="rounded-full bg-purple-100 px-2 py-1 text-purple-800">Review Partner</span>{% endif %}{% if task.aqmm_blocks_final_release %}<span class="rounded-full bg-slate-900 px-2 py-1 text-white">Final Gate</span>{% endif %}</div></td>
<td class="px-3 py-2">{{ 'Uploaded' if item.has_evidence else ('Required' if task.aqmm_evidence_required else '-') }}</td>
<td class="px-3 py-2"><span class="rounded-full px-2 py-1 text-xs font-medium {{ 'bg-emerald-100 text-emerald-800' if item.passes else 'bg-amber-100 text-amber-800' }}">{{ 'OK' if item.passes else item.issue }}</span><div class="mt-1 text-xs text-slate-500">Task: {{ task.status|replace('_',' ')|title }}</div></td>
<td class="px-3 py-2 text-right"><a href="/documents/tasks/{{ task.id }}" class="text-xs font-medium text-brand-700 hover:underline">Open Evidence</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="px-3 py-6 text-center text-sm text-slate-500">No AQMM-tagged task generated yet. Mark selected service tasks as AQMM and generate work tracker tasks.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if my_pending_declarations %}
<div class="mt-5 rounded-2xl border border-white bg-white p-4">
<h4 class="text-sm font-semibold text-slate-900">My Pending Declarations</h4>
@@ -83,7 +118,7 @@
{% for task in tasks or [] %}
<tr>
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ task.sequence_no }}</td>
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ task.task_name }}</div>{% if task.description %}<div class="text-xs text-slate-500">{{ task.description }}</div>{% endif %}</td>
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ task.task_name }}</div>{% if task.description %}<div class="text-xs text-slate-500">{{ task.description }}</div>{% endif %}{% if task.is_aqmm_task %}<div class="mt-1 flex flex-wrap gap-1 text-[11px]"><span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">AQMM</span>{% if task.aqmm_mandatory %}<span class="rounded-full bg-rose-100 px-2 py-1 text-rose-800">Mandatory</span>{% endif %}{% if task.aqmm_evidence_required %}<span class="rounded-full bg-sky-100 px-2 py-1 text-sky-800">Evidence</span>{% endif %}</div>{% endif %}</td>
<td class="px-4 py-3 text-sm text-slate-700">{{ task.status.replace('_',' ').title() }}</td>
<td class="px-4 py-3 text-right"><a href="/documents/tasks/{{ task.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open Task Documents</a></td>
</tr>
@@ -47,6 +47,24 @@
<textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ task.description or '' }}</textarea>
</div>
<div class="md:col-span-2 rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
<div class="text-sm font-semibold text-indigo-950">AQMM / Quality Control Tagging</div>
<p class="mt-1 text-xs text-indigo-800">Use these only for tasks that should become part of the assurance engagement quality checklist. Non-assurance engagements will continue using the normal task flow.</p>
<div class="mt-3 grid gap-3 md:grid-cols-2 text-sm text-slate-700">
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_aqmm_task" {% if task.is_aqmm_task %}checked{% endif %}> Part of AQMM / quality checklist</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_mandatory" {% if task.aqmm_mandatory %}checked{% endif %}> Mandatory for AQMM completion</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_evidence_required" {% if task.aqmm_evidence_required %}checked{% endif %}> Evidence upload required</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_manager_review_required" {% if task.aqmm_manager_review_required %}checked{% endif %}> Manager review required</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_partner_review_required" {% if task.aqmm_partner_review_required %}checked{% endif %}> Partner review required</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_review_partner_required" {% if task.aqmm_review_partner_required %}checked{% endif %}> Review partner review required</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="aqmm_blocks_final_release" {% if task.aqmm_blocks_final_release %}checked{% endif %}> Block final release until completed</label>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-indigo-800">AQMM Reference / Clause</label>
<input type="text" name="aqmm_reference" value="{{ task.aqmm_reference or '' }}" class="w-full rounded-xl border border-indigo-200 px-3 py-2 text-sm" placeholder="e.g. Acceptance / Planning / Review">
</div>
</div>
</div>
<div class="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
To deactivate this task, untick <strong>Active</strong> and save. No hard delete is used.
</div>
@@ -74,6 +74,20 @@
<label class="inline-flex items-center gap-1"><input type="checkbox" name="is_active" checked> Active</label>
</div>
</div>
<div class="lg:col-span-12 rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
<div class="text-sm font-semibold text-indigo-950">AQMM / Quality Control Tagging</div>
<p class="mt-1 text-xs text-indigo-800">Tick only the service checklist tasks that should form part of assurance engagement quality evidence.</p>
<div class="mt-3 grid gap-2 md:grid-cols-4 text-xs text-slate-700">
<label class="inline-flex items-center gap-1"><input type="checkbox" name="is_aqmm_task"> Part of AQMM</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_mandatory"> Mandatory</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_evidence_required"> Evidence required</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_manager_review_required"> Manager review</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_partner_review_required"> Partner review</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_review_partner_required"> Review partner review</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="aqmm_blocks_final_release"> Blocks final release</label>
<input name="aqmm_reference" placeholder="AQMM reference / clause" class="rounded-lg border border-indigo-200 px-2 py-1 text-xs">
</div>
</div>
<div class="lg:col-span-10">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Description</label>
<textarea name="description" rows="2" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional instructions for staff or reviewer"></textarea>
@@ -115,6 +129,10 @@
<div class="flex flex-wrap gap-1">
{% if task.is_mandatory %}<span class="rounded-full bg-slate-100 px-2 py-1">Mandatory</span>{% endif %}
{% if task.requires_review %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-800">Review</span>{% endif %}
{% if task.is_aqmm_task %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">AQMM</span>{% endif %}
{% if task.aqmm_mandatory %}<span class="rounded-full bg-rose-100 px-2 py-1 text-rose-800">AQMM Mandatory</span>{% endif %}
{% if task.aqmm_evidence_required %}<span class="rounded-full bg-sky-100 px-2 py-1 text-sky-800">Evidence</span>{% endif %}
{% if task.aqmm_blocks_final_release %}<span class="rounded-full bg-purple-100 px-2 py-1 text-purple-800">Final Gate</span>{% endif %}
<span class="rounded-full px-2 py-1 {{ 'bg-emerald-100 text-emerald-800' if task.is_active else 'bg-slate-100 text-slate-500' }}">{{ 'Active' if task.is_active else 'Inactive' }}</span>
</div>
</td>
+51 -3
View File
@@ -814,7 +814,7 @@ def task_templates_detail(request: Request, catalogue_id: int):
@router.post('/templates/{catalogue_id}/tasks/new')
def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), is_active: str | None = Form(None), csrf_token: str = Form(...)):
def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
@@ -839,6 +839,14 @@ def task_template_create_submit(request: Request, catalogue_id: int, task_name:
sequence_no=sequence_no or next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id),
is_mandatory=is_mandatory is not None,
requires_review=requires_review is not None,
is_aqmm_task=is_aqmm_task is not None,
aqmm_mandatory=aqmm_mandatory is not None,
aqmm_evidence_required=aqmm_evidence_required is not None,
aqmm_manager_review_required=aqmm_manager_review_required is not None,
aqmm_partner_review_required=aqmm_partner_review_required is not None,
aqmm_review_partner_required=aqmm_review_partner_required is not None,
aqmm_blocks_final_release=aqmm_blocks_final_release is not None,
aqmm_reference=aqmm_reference.strip() or None,
is_active=is_active is not None,
created_by_user_id=user.id,
updated_by_user_id=user.id,
@@ -888,7 +896,7 @@ def default_templates_detail(request: Request, catalogue_id: int):
@router.post('/catalogue/{catalogue_id}/defaults/new')
def default_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), is_active: str | None = Form(None), csrf_token: str = Form(...)):
def default_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
@@ -901,7 +909,7 @@ def default_template_create_submit(request: Request, catalogue_id: int, task_nam
catalogue = get_catalogue(db, catalogue_id)
if not catalogue:
return RedirectResponse(url='/services/defaults', status_code=303)
row = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue_id, task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, sequence_no=sequence_no or next_default_task_sequence(db, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, is_active=is_active is not None)
row = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue_id, task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, sequence_no=sequence_no or next_default_task_sequence(db, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, is_aqmm_task=is_aqmm_task is not None, aqmm_mandatory=aqmm_mandatory is not None, aqmm_evidence_required=aqmm_evidence_required is not None, aqmm_manager_review_required=aqmm_manager_review_required is not None, aqmm_partner_review_required=aqmm_partner_review_required is not None, aqmm_review_partner_required=aqmm_review_partner_required is not None, aqmm_blocks_final_release=aqmm_blocks_final_release is not None, aqmm_reference=aqmm_reference.strip() or None, is_active=is_active is not None)
db.add(row); db.commit()
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
finally:
@@ -940,6 +948,14 @@ def copy_defaults_to_firm(request: Request, catalogue_id: int, csrf_token: str =
default_role_name=d.default_role_name,
is_mandatory=d.is_mandatory,
requires_review=d.requires_review,
is_aqmm_task=getattr(d, "is_aqmm_task", False),
aqmm_mandatory=getattr(d, "aqmm_mandatory", False),
aqmm_evidence_required=getattr(d, "aqmm_evidence_required", False),
aqmm_manager_review_required=getattr(d, "aqmm_manager_review_required", False),
aqmm_partner_review_required=getattr(d, "aqmm_partner_review_required", False),
aqmm_review_partner_required=getattr(d, "aqmm_review_partner_required", False),
aqmm_blocks_final_release=getattr(d, "aqmm_blocks_final_release", False),
aqmm_reference=getattr(d, "aqmm_reference", None),
is_active=d.is_active,
created_by_user_id=user.id,
updated_by_user_id=user.id,
@@ -1136,6 +1152,14 @@ def firm_task_template_edit_submit(
sequence_no: int = Form(1),
is_mandatory: str | None = Form(None),
requires_review: str | None = Form(None),
is_aqmm_task: str | None = Form(None),
aqmm_mandatory: str | None = Form(None),
aqmm_evidence_required: str | None = Form(None),
aqmm_manager_review_required: str | None = Form(None),
aqmm_partner_review_required: str | None = Form(None),
aqmm_review_partner_required: str | None = Form(None),
aqmm_blocks_final_release: str | None = Form(None),
aqmm_reference: str = Form(''),
is_active: str | None = Form(None),
csrf_token: str = Form(...),
):
@@ -1166,6 +1190,14 @@ def firm_task_template_edit_submit(
task.sequence_no = sequence_no
task.is_mandatory = is_mandatory is not None
task.requires_review = requires_review is not None
task.is_aqmm_task = is_aqmm_task is not None
task.aqmm_mandatory = aqmm_mandatory is not None
task.aqmm_evidence_required = aqmm_evidence_required is not None
task.aqmm_manager_review_required = aqmm_manager_review_required is not None
task.aqmm_partner_review_required = aqmm_partner_review_required is not None
task.aqmm_review_partner_required = aqmm_review_partner_required is not None
task.aqmm_blocks_final_release = aqmm_blocks_final_release is not None
task.aqmm_reference = aqmm_reference.strip() or None
task.is_active = is_active is not None
task.updated_by_user_id = user.id
@@ -1227,6 +1259,14 @@ def default_task_template_edit_submit(
sequence_no: int = Form(1),
is_mandatory: str | None = Form(None),
requires_review: str | None = Form(None),
is_aqmm_task: str | None = Form(None),
aqmm_mandatory: str | None = Form(None),
aqmm_evidence_required: str | None = Form(None),
aqmm_manager_review_required: str | None = Form(None),
aqmm_partner_review_required: str | None = Form(None),
aqmm_review_partner_required: str | None = Form(None),
aqmm_blocks_final_release: str | None = Form(None),
aqmm_reference: str = Form(''),
is_active: str | None = Form(None),
csrf_token: str = Form(...),
):
@@ -1259,6 +1299,14 @@ def default_task_template_edit_submit(
task.sequence_no = sequence_no
task.is_mandatory = is_mandatory is not None
task.requires_review = requires_review is not None
task.is_aqmm_task = is_aqmm_task is not None
task.aqmm_mandatory = aqmm_mandatory is not None
task.aqmm_evidence_required = aqmm_evidence_required is not None
task.aqmm_manager_review_required = aqmm_manager_review_required is not None
task.aqmm_partner_review_required = aqmm_partner_review_required is not None
task.aqmm_review_partner_required = aqmm_review_partner_required is not None
task.aqmm_blocks_final_release = aqmm_blocks_final_release is not None
task.aqmm_reference = aqmm_reference.strip() or None
task.is_active = is_active is not None
db.commit()