diff --git a/app/modules/aqmm_dashboard/__init__.py b/app/modules/aqmm_dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/aqmm_dashboard/service.py b/app/modules/aqmm_dashboard/service.py new file mode 100644 index 0000000..ef230d9 --- /dev/null +++ b/app/modules/aqmm_dashboard/service.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import joinedload + +from app.modules.clients.models import Client +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.services.models import ( + ClientServiceSubscription, + ClientServiceTaskInstance, + EngagementClosureChecklist, + ServiceCatalogue, +) + + +ASSURANCE_TYPE = "assurance" +APPROVED_STATUSES = {"approved", "completed", "complete"} +REVIEWED_STATUSES = {"reviewed", "approved", "completed", "complete", "not_required"} +EVIDENCE_OK_STATUSES = {"accepted", "approved", "reviewed", "uploaded", "not_required"} +CLOSURE_CLOSED_STATUSES = {"closed", "completed"} + + +@dataclass(frozen=True) +class AqmmScope: + tenant_id: int | None + branch_id: int | None + own_user_id: int | None + + +def _clean(value: Any) -> str: + return str(value or "").strip() + + +def _active_tenant_id(request, user) -> int | None: + value = ( + request.session.get("active_tenant_id") + or request.session.get("selected_tenant_id") + or request.session.get("tenant_id") + or getattr(user, "tenant_id", None) + ) + return int(value) if value not in (None, "", 0, "0") else None + + +def _active_branch_id(request, user, permissions: set[str]) -> int | None: + value = request.session.get("active_branch_id") + if value in (None, "", 0, "0"): + if "clients.cross_branch" in permissions or "services.cross_branch" in permissions: + return None + branch_id = getattr(user, "branch_id", None) + return int(branch_id) if branch_id not in (None, "", 0, "0") else None + return int(value) + + +def build_scope(db, request, user) -> AqmmScope: + roles = set(get_user_roles(db, user.id)) + permissions = set(get_user_permissions(db, user.id)) + own_user_id = None + if "clients.view.own_only" in permissions and "Firm Admin" not in roles: + own_user_id = int(user.id) + return AqmmScope( + tenant_id=_active_tenant_id(request, user), + branch_id=_active_branch_id(request, user, permissions), + own_user_id=own_user_id, + ) + + +def can_view_aqmm_dashboard(db, user) -> bool: + roles = set(get_user_roles(db, user.id)) + permissions = set(get_user_permissions(db, user.id)) + if roles.intersection({"System Admin", "Firm Admin", "Partner", "Manager", "Branch Manager"}): + return True + return bool(permissions.intersection({"services.view", "clients.view", "audit.view"})) + + +def _subscription_filters(scope: AqmmScope, financial_year: str = "", q: str = ""): + filters = [ClientServiceSubscription.engagement_type == ASSURANCE_TYPE] + if scope.tenant_id: + filters.append(ClientServiceSubscription.tenant_id == scope.tenant_id) + if scope.branch_id: + filters.append(ClientServiceSubscription.branch_id == scope.branch_id) + if financial_year: + filters.append(ClientServiceSubscription.financial_year == financial_year) + if scope.own_user_id: + filters.append( + or_( + ClientServiceSubscription.assigned_partner_user_id == scope.own_user_id, + ClientServiceSubscription.assigned_manager_user_id == scope.own_user_id, + ClientServiceSubscription.assigned_staff_user_id == scope.own_user_id, + ClientServiceSubscription.review_partner_user_id == scope.own_user_id, + Client.partner_id == scope.own_user_id, + ) + ) + if q: + like = f"%{q.lower()}%" + filters.append( + or_( + func.lower(Client.client_name).like(like), + func.lower(Client.client_code).like(like), + func.lower(ServiceCatalogue.service_name).like(like), + func.lower(ServiceCatalogue.service_code).like(like), + ) + ) + return filters + + +def _count(db, stmt) -> int: + return int(db.execute(stmt).scalar_one() or 0) + + +def _subscription_id_subquery(scope: AqmmScope, financial_year: str = "", q: str = ""): + return ( + select(ClientServiceSubscription.id) + .join(Client, Client.id == ClientServiceSubscription.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id) + .where(and_(*_subscription_filters(scope, financial_year, q))) + .subquery() + ) + + +def _task_count(db, subq, *extra_filters) -> int: + return _count( + db, + select(func.count(ClientServiceTaskInstance.id)).where( + ClientServiceTaskInstance.subscription_id.in_(select(subq.c.id)), + ClientServiceTaskInstance.is_aqmm_task.is_(True), + *extra_filters, + ), + ) + + +def _subscription_task_counts(db, subscription_id: int) -> dict[str, int]: + base = [ClientServiceTaskInstance.subscription_id == subscription_id, ClientServiceTaskInstance.is_aqmm_task.is_(True)] + total = _count(db, select(func.count(ClientServiceTaskInstance.id)).where(*base)) + mandatory = _count(db, select(func.count(ClientServiceTaskInstance.id)).where(*base, ClientServiceTaskInstance.aqmm_mandatory.is_(True))) + evidence_pending = _count( + db, + select(func.count(ClientServiceTaskInstance.id)).where( + *base, + ClientServiceTaskInstance.aqmm_evidence_required.is_(True), + ClientServiceTaskInstance.evidence_status.notin_(list(EVIDENCE_OK_STATUSES)), + ), + ) + manager_pending = _count( + db, + select(func.count(ClientServiceTaskInstance.id)).where( + *base, + ClientServiceTaskInstance.aqmm_manager_review_required.is_(True), + ClientServiceTaskInstance.manager_review_status.notin_(list(REVIEWED_STATUSES)), + ), + ) + partner_pending = _count( + db, + select(func.count(ClientServiceTaskInstance.id)).where( + *base, + ClientServiceTaskInstance.aqmm_partner_review_required.is_(True), + ClientServiceTaskInstance.partner_review_status.notin_(list(REVIEWED_STATUSES)), + ), + ) + review_partner_pending = _count( + db, + select(func.count(ClientServiceTaskInstance.id)).where( + *base, + ClientServiceTaskInstance.aqmm_review_partner_required.is_(True), + ClientServiceTaskInstance.review_partner_review_status.notin_(list(REVIEWED_STATUSES)), + ), + ) + completed = _count( + db, + select(func.count(ClientServiceTaskInstance.id)).where( + *base, + or_( + ClientServiceTaskInstance.aqmm_status.in_(["completed", "complete", "approved"]), + ClientServiceTaskInstance.status.in_(["completed", "complete", "done"]), + ), + ), + ) + return { + "total": total, + "mandatory": mandatory, + "completed": completed, + "evidence_pending": evidence_pending, + "manager_pending": manager_pending, + "partner_pending": partner_pending, + "review_partner_pending": review_partner_pending, + } + + +def build_aqmm_dashboard_payload(db, request, user, *, financial_year: str = "", q: str = "") -> dict[str, Any]: + financial_year = _clean(financial_year) + q = _clean(q) + scope = build_scope(db, request, user) + subq = _subscription_id_subquery(scope, financial_year, q) + + total_assurance = _count(db, select(func.count()).select_from(subq)) + aqmm_approved = _count( + db, + select(func.count(ClientServiceSubscription.id)).where( + ClientServiceSubscription.id.in_(select(subq.c.id)), + ClientServiceSubscription.quality_workflow_status.in_(list(APPROVED_STATUSES)), + ), + ) + aqmm_pending = max(total_assurance - aqmm_approved, 0) + + kyc_pending = _count( + db, + select(func.count(ClientServiceSubscription.id)).where( + ClientServiceSubscription.id.in_(select(subq.c.id)), + ClientServiceSubscription.quality_kyc_status.notin_(list(APPROVED_STATUSES) + ["not_required"]), + ), + ) + declaration_pending = _count( + db, + select(func.count(ClientServiceSubscription.id)).where( + ClientServiceSubscription.id.in_(select(subq.c.id)), + or_( + ClientServiceSubscription.quality_independence_status.notin_(list(APPROVED_STATUSES) + ["not_required"]), + ClientServiceSubscription.quality_conflict_status.notin_(list(APPROVED_STATUSES) + ["not_required"]), + ), + ), + ) + letter_pending = _count( + db, + select(func.count(ClientServiceSubscription.id)).where( + ClientServiceSubscription.id.in_(select(subq.c.id)), + ClientServiceSubscription.quality_engagement_letter_status.notin_(list(APPROVED_STATUSES) + ["not_required"]), + ), + ) + + aqmm_tasks_total = _task_count(db, subq) + evidence_pending = _task_count( + db, + subq, + ClientServiceTaskInstance.aqmm_evidence_required.is_(True), + ClientServiceTaskInstance.evidence_status.notin_(list(EVIDENCE_OK_STATUSES)), + ) + manager_review_pending = _task_count( + db, + subq, + ClientServiceTaskInstance.aqmm_manager_review_required.is_(True), + ClientServiceTaskInstance.manager_review_status.notin_(list(REVIEWED_STATUSES)), + ) + partner_review_pending = _task_count( + db, + subq, + ClientServiceTaskInstance.aqmm_partner_review_required.is_(True), + ClientServiceTaskInstance.partner_review_status.notin_(list(REVIEWED_STATUSES)), + ) + review_partner_pending = _task_count( + db, + subq, + ClientServiceTaskInstance.aqmm_review_partner_required.is_(True), + ClientServiceTaskInstance.review_partner_review_status.notin_(list(REVIEWED_STATUSES)), + ) + rework_pending = _task_count( + db, + subq, + ClientServiceTaskInstance.rework_status.in_(["requested", "pending", "rework_required"]), + ) + + closure_closed = _count( + db, + select(func.count(EngagementClosureChecklist.id)).where( + EngagementClosureChecklist.subscription_id.in_(select(subq.c.id)), + EngagementClosureChecklist.closure_status.in_(list(CLOSURE_CLOSED_STATUSES)), + ), + ) + closure_pending = max(total_assurance - closure_closed, 0) + + engagement_rows = db.execute( + select(ClientServiceSubscription, EngagementClosureChecklist) + .join(Client, Client.id == ClientServiceSubscription.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id) + .outerjoin(EngagementClosureChecklist, EngagementClosureChecklist.subscription_id == ClientServiceSubscription.id) + .options( + joinedload(ClientServiceSubscription.client), + joinedload(ClientServiceSubscription.catalogue), + joinedload(ClientServiceSubscription.assigned_partner), + joinedload(ClientServiceSubscription.assigned_manager), + joinedload(ClientServiceSubscription.assigned_staff), + joinedload(ClientServiceSubscription.review_partner), + ) + .where(and_(*_subscription_filters(scope, financial_year, q))) + .order_by(ClientServiceSubscription.updated_at_utc.desc(), ClientServiceSubscription.id.desc()) + .limit(50) + ).all() + + engagements = [] + for subscription, closure in engagement_rows: + counts = _subscription_task_counts(db, subscription.id) + blockers = [] + if subscription.quality_workflow_status not in APPROVED_STATUSES: + blockers.append("AQMM acceptance pending") + if counts["evidence_pending"]: + blockers.append(f"Evidence pending: {counts['evidence_pending']}") + if counts["manager_pending"]: + blockers.append(f"Manager review pending: {counts['manager_pending']}") + if counts["partner_pending"]: + blockers.append(f"Partner review pending: {counts['partner_pending']}") + if counts["review_partner_pending"]: + blockers.append(f"Review partner review pending: {counts['review_partner_pending']}") + if closure and closure.closure_status not in CLOSURE_CLOSED_STATUSES: + blockers.append("Closure pending") + engagements.append({ + "row": subscription, + "closure": closure, + "counts": counts, + "blockers": blockers, + }) + + pending_tasks = db.execute( + select(ClientServiceTaskInstance) + .options(joinedload(ClientServiceTaskInstance.subscription).joinedload(ClientServiceSubscription.client), joinedload(ClientServiceTaskInstance.subscription).joinedload(ClientServiceSubscription.catalogue)) + .where( + ClientServiceTaskInstance.subscription_id.in_(select(subq.c.id)), + ClientServiceTaskInstance.is_aqmm_task.is_(True), + or_( + and_(ClientServiceTaskInstance.aqmm_evidence_required.is_(True), ClientServiceTaskInstance.evidence_status.notin_(list(EVIDENCE_OK_STATUSES))), + and_(ClientServiceTaskInstance.aqmm_manager_review_required.is_(True), ClientServiceTaskInstance.manager_review_status.notin_(list(REVIEWED_STATUSES))), + and_(ClientServiceTaskInstance.aqmm_partner_review_required.is_(True), ClientServiceTaskInstance.partner_review_status.notin_(list(REVIEWED_STATUSES))), + and_(ClientServiceTaskInstance.aqmm_review_partner_required.is_(True), ClientServiceTaskInstance.review_partner_review_status.notin_(list(REVIEWED_STATUSES))), + ClientServiceTaskInstance.rework_status.in_(["requested", "pending", "rework_required"]), + ), + ) + .order_by(ClientServiceTaskInstance.internal_target_date.asc().nulls_last(), ClientServiceTaskInstance.sequence_no.asc()) + .limit(50) + ).scalars().all() + + return { + "financial_year": financial_year, + "q": q, + "scope": scope, + "kpis": { + "total_assurance": total_assurance, + "aqmm_approved": aqmm_approved, + "aqmm_pending": aqmm_pending, + "declaration_pending": declaration_pending, + "kyc_pending": kyc_pending, + "engagement_letter_pending": letter_pending, + "aqmm_tasks_total": aqmm_tasks_total, + "evidence_pending": evidence_pending, + "manager_review_pending": manager_review_pending, + "partner_review_pending": partner_review_pending, + "review_partner_pending": review_partner_pending, + "rework_pending": rework_pending, + "closure_pending": closure_pending, + "closure_closed": closure_closed, + }, + "engagements": engagements, + "pending_tasks": pending_tasks, + } diff --git a/app/modules/aqmm_dashboard/templates/aqmm_dashboard/dashboard.html b/app/modules/aqmm_dashboard/templates/aqmm_dashboard/dashboard.html new file mode 100644 index 0000000..6fec2ce --- /dev/null +++ b/app/modules/aqmm_dashboard/templates/aqmm_dashboard/dashboard.html @@ -0,0 +1,143 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
Assurance engagement quality status, AQMM tasks, evidence, reviews and closure readiness.
+Assurance Engagements
{{ kpis.total_assurance }}
Within selected scope
AQMM Approved
{{ kpis.aqmm_approved }}
Pending: {{ kpis.aqmm_pending }}
Evidence Pending
{{ kpis.evidence_pending }}
AQMM-tagged task evidence
Closure Pending
{{ kpis.closure_pending }}
Closed: {{ kpis.closure_closed }}
Declarations Pending
{{ kpis.declaration_pending }}
KYC Pending
{{ kpis.kyc_pending }}
Engagement Letter Pending
{{ kpis.engagement_letter_pending }}
Rework Pending
{{ kpis.rework_pending }}
AQMM Tasks
{{ kpis.aqmm_tasks_total }}
Manager Review Pending
{{ kpis.manager_review_pending }}
Partner Review Pending
{{ kpis.partner_review_pending }}
Review Partner Pending
{{ kpis.review_partner_pending }}
Shows only assurance engagements in the selected tenant, branch and financial year.
+| Client / Service | +AQMM Acceptance | +Checklist Tasks | +Pending Reviews | +Closure | +Blockers | +Action | +
|---|---|---|---|---|---|---|
|
+ {{ row.client.client_name if row.client else '-' }}
+ {{ row.catalogue.service_name if row.catalogue else '-' }} · FY {{ row.financial_year or '-' }}
+ Partner: {{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}
+ |
+
+ Status: {{ (row.quality_workflow_status or 'not_required')|replace('_',' ')|title }}
+ KYC: {{ (row.quality_kyc_status or '-')|replace('_',' ')|title }}
+ Independence: {{ (row.quality_independence_status or '-')|replace('_',' ')|title }}
+ Conflict: {{ (row.quality_conflict_status or '-')|replace('_',' ')|title }}
+ Letter: {{ (row.quality_engagement_letter_status or '-')|replace('_',' ')|title }}
+ |
+
+ Total: {{ counts.total }}
+ Mandatory: {{ counts.mandatory }}
+ Completed: {{ counts.completed }}
+ Evidence pending: {{ counts.evidence_pending }}
+ |
+
+ Manager: {{ counts.manager_pending }}
+ Partner: {{ counts.partner_pending }}
+ Review Partner: {{ counts.review_partner_pending }}
+ |
+ + {{ (closure.closure_status if closure else 'not_started')|replace('_',' ')|title }} + | +
+ {% if item.blockers %}
+
|
+ Open | +
| No assurance engagements found. | ||||||
Tasks shown here are already part of your existing service checklist and are tagged as AQMM tasks.
+| Task | Client / Engagement | Evidence | Review Status | Action |
|---|---|---|---|---|
{{ task.task_name }} Seq {{ task.sequence_no }}{% if task.aqmm_reference %} · {{ task.aqmm_reference }}{% endif %} |
+ {{ task.subscription.client.client_name if task.subscription and task.subscription.client else '-' }} {{ task.subscription.catalogue.service_name if task.subscription and task.subscription.catalogue else '-' }} |
+ Required: {{ 'Yes' if task.aqmm_evidence_required else 'No' }} Status: {{ (task.evidence_status or '-')|replace('_',' ')|title }} |
+ Manager: {{ (task.manager_review_status or '-')|replace('_',' ')|title }} Partner: {{ (task.partner_review_status or '-')|replace('_',' ')|title }} Review Partner: {{ (task.review_partner_review_status or '-')|replace('_',' ')|title }} {% if task.rework_status not in ['none','not_required',''] %}Rework: {{ task.rework_status|replace('_',' ')|title }} {% endif %} |
+ Evidence | +
| No pending AQMM task evidence or reviews. | ||||