Add peer review evidence export and restrict AQMM access
This commit is contained in:
@@ -68,11 +68,14 @@ def build_scope(db, request, user) -> AqmmScope:
|
|||||||
|
|
||||||
|
|
||||||
def can_view_aqmm_dashboard(db, user) -> bool:
|
def can_view_aqmm_dashboard(db, user) -> bool:
|
||||||
|
"""AQMM dashboard is intentionally restricted to Firm Admin and Partner roles.
|
||||||
|
|
||||||
|
Managers/staff may still complete assigned work and review actions through
|
||||||
|
Work Tracker, but the consolidated AQMM monitoring dashboard is a leadership
|
||||||
|
view and must not be exposed through broad service/client permissions.
|
||||||
|
"""
|
||||||
roles = set(get_user_roles(db, user.id))
|
roles = set(get_user_roles(db, user.id))
|
||||||
permissions = set(get_user_permissions(db, user.id))
|
return bool(roles.intersection({"Firm Admin", "Partner"}))
|
||||||
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 = ""):
|
def _subscription_filters(scope: AqmmScope, financial_year: str = "", q: str = ""):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<p class="text-sm text-slate-500">Assurance engagement quality status, AQMM tasks, evidence, reviews and closure readiness.</p>
|
<p class="text-sm text-slate-500">Assurance engagement quality status, AQMM tasks, evidence, reviews and closure readiness.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/peer-review/evidence?financial_year={{ financial_year or '' }}&q={{ q or '' }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Peer Review Export</a>
|
||||||
<a href="/aqmm/dashboard/export.csv?financial_year={{ financial_year or '' }}&q={{ q or '' }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Export CSV</a>
|
<a href="/aqmm/dashboard/export.csv?financial_year={{ financial_year or '' }}&q={{ q or '' }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Export CSV</a>
|
||||||
<a href="/services/engagements" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Open Engagements</a>
|
<a href="/services/engagements" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Open Engagements</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Peer review evidence export module."""
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
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_roles
|
||||||
|
from app.modules.documents.models import EngagementDocument, EngagementDocumentVersion
|
||||||
|
from app.modules.services.models import (
|
||||||
|
ClientServiceSubscription,
|
||||||
|
ClientServiceTaskInstance,
|
||||||
|
EngagementClosureChecklist,
|
||||||
|
EngagementKycVerification,
|
||||||
|
EngagementLetter,
|
||||||
|
EngagementQualityDeclaration,
|
||||||
|
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 PeerReviewScope:
|
||||||
|
tenant_id: int | None
|
||||||
|
branch_id: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_peer_review_export(db, user) -> bool:
|
||||||
|
"""Peer review export is restricted to Firm Admin and Partner roles only."""
|
||||||
|
roles = set(get_user_roles(db, user.id))
|
||||||
|
return bool(roles.intersection({"Firm Admin", "Partner"}))
|
||||||
|
|
||||||
|
|
||||||
|
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) -> int | None:
|
||||||
|
value = request.session.get("active_branch_id") or getattr(user, "branch_id", None)
|
||||||
|
return int(value) if value not in (None, "", 0, "0") else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_scope(request, user) -> PeerReviewScope:
|
||||||
|
return PeerReviewScope(tenant_id=_active_tenant_id(request, user), branch_id=_active_branch_id(request, user))
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_filters(scope: PeerReviewScope, 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 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 list_peer_review_engagements(db, request, user, *, financial_year: str = "", q: str = "") -> dict[str, Any]:
|
||||||
|
financial_year = _clean(financial_year)
|
||||||
|
q = _clean(q)
|
||||||
|
scope = build_scope(request, user)
|
||||||
|
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.financial_year.desc(), ClientServiceSubscription.updated_at_utc.desc(), ClientServiceSubscription.id.desc())
|
||||||
|
.limit(200)
|
||||||
|
).all()
|
||||||
|
return {"financial_year": financial_year, "q": q, "scope": scope, "engagements": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def get_subscription_for_export(db, request, user, subscription_id: int) -> ClientServiceSubscription | None:
|
||||||
|
scope = build_scope(request, user)
|
||||||
|
filters = [ClientServiceSubscription.id == subscription_id, 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)
|
||||||
|
return db.execute(
|
||||||
|
select(ClientServiceSubscription)
|
||||||
|
.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_(*filters))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_blockers(task: ClientServiceTaskInstance) -> list[str]:
|
||||||
|
blockers: list[str] = []
|
||||||
|
if task.aqmm_evidence_required and (task.evidence_status or "pending") not in EVIDENCE_OK_STATUSES:
|
||||||
|
blockers.append("Evidence pending")
|
||||||
|
if task.aqmm_manager_review_required and (task.manager_review_status or "pending") not in REVIEWED_STATUSES:
|
||||||
|
blockers.append("Manager review pending")
|
||||||
|
if task.aqmm_partner_review_required and (task.partner_review_status or "pending") not in REVIEWED_STATUSES:
|
||||||
|
blockers.append("Partner review pending")
|
||||||
|
if task.aqmm_review_partner_required and (task.review_partner_review_status or "pending") not in REVIEWED_STATUSES:
|
||||||
|
blockers.append("Review partner review pending")
|
||||||
|
if (task.rework_status or "").lower() in {"requested", "pending", "rework_required"}:
|
||||||
|
blockers.append("Rework pending")
|
||||||
|
return blockers
|
||||||
|
|
||||||
|
|
||||||
|
def build_peer_review_evidence(db, request, user, subscription_id: int) -> dict[str, Any]:
|
||||||
|
subscription = get_subscription_for_export(db, request, user, subscription_id)
|
||||||
|
if not subscription:
|
||||||
|
raise ValueError("Assurance engagement not found or not accessible")
|
||||||
|
|
||||||
|
tasks = db.execute(
|
||||||
|
select(ClientServiceTaskInstance)
|
||||||
|
.where(ClientServiceTaskInstance.subscription_id == subscription.id)
|
||||||
|
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
documents = db.execute(
|
||||||
|
select(EngagementDocument)
|
||||||
|
.options(joinedload(EngagementDocument.versions))
|
||||||
|
.where(EngagementDocument.engagement_id == subscription.id, EngagementDocument.is_deleted.is_(False))
|
||||||
|
.order_by(EngagementDocument.is_aqmm_evidence.desc(), EngagementDocument.document_type.asc(), EngagementDocument.id.asc())
|
||||||
|
).scalars().unique().all()
|
||||||
|
|
||||||
|
declarations = db.execute(
|
||||||
|
select(EngagementQualityDeclaration)
|
||||||
|
.options(joinedload(EngagementQualityDeclaration.requested_user), joinedload(EngagementQualityDeclaration.created_by))
|
||||||
|
.where(EngagementQualityDeclaration.subscription_id == subscription.id)
|
||||||
|
.order_by(EngagementQualityDeclaration.declaration_type.asc(), EngagementQualityDeclaration.requested_user_id.asc())
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
kyc = db.execute(
|
||||||
|
select(EngagementKycVerification)
|
||||||
|
.options(joinedload(EngagementKycVerification.verified_by), joinedload(EngagementKycVerification.created_by))
|
||||||
|
.where(EngagementKycVerification.subscription_id == subscription.id)
|
||||||
|
.order_by(EngagementKycVerification.updated_at_utc.desc())
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
letters = db.execute(
|
||||||
|
select(EngagementLetter)
|
||||||
|
.options(
|
||||||
|
joinedload(EngagementLetter.partner_approved_by),
|
||||||
|
joinedload(EngagementLetter.client_accepted_by),
|
||||||
|
joinedload(EngagementLetter.manual_verified_by),
|
||||||
|
joinedload(EngagementLetter.created_by),
|
||||||
|
)
|
||||||
|
.where(EngagementLetter.subscription_id == subscription.id)
|
||||||
|
.order_by(EngagementLetter.version_no.desc(), EngagementLetter.updated_at_utc.desc())
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
closure = db.execute(
|
||||||
|
select(EngagementClosureChecklist)
|
||||||
|
.options(joinedload(EngagementClosureChecklist.closure_approved_by), joinedload(EngagementClosureChecklist.reopened_by))
|
||||||
|
.where(EngagementClosureChecklist.subscription_id == subscription.id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
aqmm_tasks = [t for t in tasks if getattr(t, "is_aqmm_task", False)]
|
||||||
|
blockers = []
|
||||||
|
if subscription.quality_workflow_required and (subscription.quality_workflow_status or "pending") not in APPROVED_STATUSES:
|
||||||
|
blockers.append("AQMM acceptance pending")
|
||||||
|
for task in aqmm_tasks:
|
||||||
|
for b in _task_blockers(task):
|
||||||
|
blockers.append(f"{task.task_name}: {b}")
|
||||||
|
if closure and (closure.closure_status or "not_started") not in CLOSURE_CLOSED_STATUSES:
|
||||||
|
blockers.append("Closure not completed")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now(timezone.utc),
|
||||||
|
"subscription": subscription,
|
||||||
|
"tasks": tasks,
|
||||||
|
"aqmm_tasks": aqmm_tasks,
|
||||||
|
"documents": documents,
|
||||||
|
"declarations": declarations,
|
||||||
|
"kyc": kyc,
|
||||||
|
"letters": letters,
|
||||||
|
"closure": closure,
|
||||||
|
"blockers": blockers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _writerow_dict(writer, row: dict[str, Any], headers: list[str]) -> None:
|
||||||
|
writer.writerow([row.get(h, "") for h in headers])
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(value) -> str:
|
||||||
|
return value.isoformat() if value else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _user_label(user) -> str:
|
||||||
|
if not user:
|
||||||
|
return ""
|
||||||
|
return getattr(user, "full_name", None) or getattr(user, "email", None) or str(getattr(user, "id", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_csv(headers: list[str], rows: list[dict[str, Any]]) -> bytes:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(headers)
|
||||||
|
for row in rows:
|
||||||
|
_writerow_dict(writer, row, headers)
|
||||||
|
return buf.getvalue().encode("utf-8-sig")
|
||||||
|
|
||||||
|
|
||||||
|
def build_peer_review_zip(db, request, user, subscription_id: int) -> bytes:
|
||||||
|
payload = build_peer_review_evidence(db, request, user, subscription_id)
|
||||||
|
sub = payload["subscription"]
|
||||||
|
client_name = sub.client.client_name if sub.client else ""
|
||||||
|
service_name = sub.catalogue.service_name if sub.catalogue else ""
|
||||||
|
|
||||||
|
summary_headers = ["field", "value"]
|
||||||
|
summary_rows = [
|
||||||
|
{"field": "Generated At UTC", "value": _dt(payload["generated_at"])},
|
||||||
|
{"field": "Client", "value": client_name},
|
||||||
|
{"field": "Service", "value": service_name},
|
||||||
|
{"field": "Financial Year", "value": sub.financial_year or ""},
|
||||||
|
{"field": "Engagement Type", "value": sub.engagement_type or ""},
|
||||||
|
{"field": "Engagement Status", "value": sub.status or ""},
|
||||||
|
{"field": "AQMM Required", "value": "Yes" if sub.quality_workflow_required else "No"},
|
||||||
|
{"field": "AQMM Status", "value": sub.quality_workflow_status or ""},
|
||||||
|
{"field": "Assigned Partner", "value": _user_label(sub.assigned_partner)},
|
||||||
|
{"field": "Assigned Manager", "value": _user_label(sub.assigned_manager)},
|
||||||
|
{"field": "Assigned Staff", "value": _user_label(sub.assigned_staff)},
|
||||||
|
{"field": "Review Partner", "value": _user_label(sub.review_partner)},
|
||||||
|
{"field": "Blockers", "value": "; ".join(payload["blockers"])},
|
||||||
|
]
|
||||||
|
|
||||||
|
task_headers = [
|
||||||
|
"sequence_no", "task_name", "status", "is_aqmm_task", "aqmm_mandatory", "evidence_required", "evidence_status",
|
||||||
|
"manager_review_required", "manager_review_status", "manager_review_note", "partner_review_required", "partner_review_status", "partner_review_note",
|
||||||
|
"review_partner_required", "review_partner_review_status", "review_partner_review_note", "rework_status", "rework_reason", "staff_work_note", "aqmm_reference", "blockers",
|
||||||
|
]
|
||||||
|
task_rows = []
|
||||||
|
for t in payload["tasks"]:
|
||||||
|
task_rows.append({
|
||||||
|
"sequence_no": t.sequence_no,
|
||||||
|
"task_name": t.task_name,
|
||||||
|
"status": t.status or "",
|
||||||
|
"is_aqmm_task": "Yes" if t.is_aqmm_task else "No",
|
||||||
|
"aqmm_mandatory": "Yes" if t.aqmm_mandatory else "No",
|
||||||
|
"evidence_required": "Yes" if t.aqmm_evidence_required else "No",
|
||||||
|
"evidence_status": t.evidence_status or "",
|
||||||
|
"manager_review_required": "Yes" if t.aqmm_manager_review_required else "No",
|
||||||
|
"manager_review_status": t.manager_review_status or "",
|
||||||
|
"manager_review_note": t.manager_review_note or "",
|
||||||
|
"partner_review_required": "Yes" if t.aqmm_partner_review_required else "No",
|
||||||
|
"partner_review_status": t.partner_review_status or "",
|
||||||
|
"partner_review_note": t.partner_review_note or "",
|
||||||
|
"review_partner_required": "Yes" if t.aqmm_review_partner_required else "No",
|
||||||
|
"review_partner_review_status": t.review_partner_review_status or "",
|
||||||
|
"review_partner_review_note": t.review_partner_review_note or "",
|
||||||
|
"rework_status": t.rework_status or "",
|
||||||
|
"rework_reason": t.rework_reason or "",
|
||||||
|
"staff_work_note": t.staff_work_note or "",
|
||||||
|
"aqmm_reference": t.aqmm_reference or "",
|
||||||
|
"blockers": "; ".join(_task_blockers(t)),
|
||||||
|
})
|
||||||
|
|
||||||
|
document_headers = [
|
||||||
|
"document_code", "title", "document_type", "task_instance_id", "is_aqmm_evidence", "evidence_status", "evidence_type", "evidence_description",
|
||||||
|
"final_release_status", "udin_required", "udin_status", "udin_number", "version_no", "original_filename", "file_hash_sha256", "local_relative_path", "uploaded_at_utc",
|
||||||
|
]
|
||||||
|
document_rows = []
|
||||||
|
for d in payload["documents"]:
|
||||||
|
versions = list(d.versions or [])
|
||||||
|
if not versions:
|
||||||
|
document_rows.append({
|
||||||
|
"document_code": d.document_code, "title": d.title, "document_type": d.document_type, "task_instance_id": d.task_instance_id or "",
|
||||||
|
"is_aqmm_evidence": "Yes" if d.is_aqmm_evidence else "No", "evidence_status": d.evidence_status or "", "evidence_type": d.evidence_type or "",
|
||||||
|
"evidence_description": d.evidence_description or "", "final_release_status": d.final_release_status or "", "udin_required": "Yes" if d.udin_required else "No",
|
||||||
|
"udin_status": d.udin_status or "", "udin_number": d.udin_number or "", "version_no": "", "original_filename": "", "file_hash_sha256": "", "local_relative_path": "", "uploaded_at_utc": "",
|
||||||
|
})
|
||||||
|
for v in versions:
|
||||||
|
document_rows.append({
|
||||||
|
"document_code": d.document_code, "title": d.title, "document_type": d.document_type, "task_instance_id": d.task_instance_id or "",
|
||||||
|
"is_aqmm_evidence": "Yes" if d.is_aqmm_evidence else "No", "evidence_status": d.evidence_status or "", "evidence_type": d.evidence_type or "",
|
||||||
|
"evidence_description": d.evidence_description or "", "final_release_status": d.final_release_status or "", "udin_required": "Yes" if d.udin_required else "No",
|
||||||
|
"udin_status": d.udin_status or "", "udin_number": d.udin_number or "", "version_no": v.version_no, "original_filename": v.original_filename,
|
||||||
|
"file_hash_sha256": v.file_hash_sha256, "local_relative_path": v.local_relative_path, "uploaded_at_utc": _dt(v.uploaded_at_utc),
|
||||||
|
})
|
||||||
|
|
||||||
|
declaration_headers = ["type", "requested_user", "requested_role", "status", "response_notes", "responded_at_utc", "ip_address"]
|
||||||
|
declaration_rows = [
|
||||||
|
{
|
||||||
|
"type": d.declaration_type,
|
||||||
|
"requested_user": _user_label(d.requested_user),
|
||||||
|
"requested_role": d.requested_role or "",
|
||||||
|
"status": d.status or "",
|
||||||
|
"response_notes": d.response_notes or "",
|
||||||
|
"responded_at_utc": _dt(d.responded_at_utc),
|
||||||
|
"ip_address": d.ip_address or "",
|
||||||
|
}
|
||||||
|
for d in payload["declarations"]
|
||||||
|
]
|
||||||
|
|
||||||
|
kyc_headers = ["status", "source", "verification_notes", "verified_by", "verified_at_utc"]
|
||||||
|
kyc_rows = [
|
||||||
|
{"status": k.status or "", "source": k.source or "", "verification_notes": k.verification_notes or "", "verified_by": _user_label(k.verified_by), "verified_at_utc": _dt(k.verified_at_utc)}
|
||||||
|
for k in payload["kyc"]
|
||||||
|
]
|
||||||
|
|
||||||
|
letter_headers = ["title", "version_no", "status", "acceptance_mode", "partner_approved_by", "partner_approved_at_utc", "client_accepted_by", "client_accepted_at_utc", "manual_verified_by", "manual_verified_at_utc", "pdf_sha256", "manual_upload_path"]
|
||||||
|
letter_rows = [
|
||||||
|
{
|
||||||
|
"title": l.title, "version_no": l.version_no, "status": l.status or "", "acceptance_mode": l.acceptance_mode or "",
|
||||||
|
"partner_approved_by": _user_label(l.partner_approved_by), "partner_approved_at_utc": _dt(l.partner_approved_at_utc),
|
||||||
|
"client_accepted_by": _user_label(l.client_accepted_by), "client_accepted_at_utc": _dt(l.client_accepted_at_utc),
|
||||||
|
"manual_verified_by": _user_label(l.manual_verified_by), "manual_verified_at_utc": _dt(l.manual_verified_at_utc),
|
||||||
|
"pdf_sha256": l.pdf_sha256 or "", "manual_upload_path": l.manual_upload_path or "",
|
||||||
|
}
|
||||||
|
for l in payload["letters"]
|
||||||
|
]
|
||||||
|
|
||||||
|
closure_headers = ["closure_status", "aqmm_acceptance_completed", "aqmm_tasks_completed", "evidence_review_completed", "final_documents_released", "udin_completed", "normal_tasks_completed", "deliverables_sent_to_client", "billing_reviewed", "open_points_closed", "client_communication_completed", "closure_note", "closure_block_reason", "closure_approved_by", "closure_approved_at_utc"]
|
||||||
|
c = payload["closure"]
|
||||||
|
closure_rows = []
|
||||||
|
if c:
|
||||||
|
closure_rows.append({
|
||||||
|
"closure_status": c.closure_status or "",
|
||||||
|
"aqmm_acceptance_completed": "Yes" if c.aqmm_acceptance_completed else "No",
|
||||||
|
"aqmm_tasks_completed": "Yes" if c.aqmm_tasks_completed else "No",
|
||||||
|
"evidence_review_completed": "Yes" if c.evidence_review_completed else "No",
|
||||||
|
"final_documents_released": "Yes" if c.final_documents_released else "No",
|
||||||
|
"udin_completed": "Yes" if c.udin_completed else "No",
|
||||||
|
"normal_tasks_completed": "Yes" if c.normal_tasks_completed else "No",
|
||||||
|
"deliverables_sent_to_client": "Yes" if c.deliverables_sent_to_client else "No",
|
||||||
|
"billing_reviewed": "Yes" if c.billing_reviewed else "No",
|
||||||
|
"open_points_closed": "Yes" if c.open_points_closed else "No",
|
||||||
|
"client_communication_completed": "Yes" if c.client_communication_completed else "No",
|
||||||
|
"closure_note": c.closure_note or "",
|
||||||
|
"closure_block_reason": c.closure_block_reason or "",
|
||||||
|
"closure_approved_by": _user_label(c.closure_approved_by),
|
||||||
|
"closure_approved_at_utc": _dt(c.closure_approved_at_utc),
|
||||||
|
})
|
||||||
|
|
||||||
|
readme = f"""Peer Review Evidence Export\nGenerated At UTC: {_dt(payload['generated_at'])}\nClient: {client_name}\nService: {service_name}\nFinancial Year: {sub.financial_year or ''}\n\nFiles included:\n- 01_engagement_summary.csv\n- 02_aqmm_and_task_review_evidence.csv\n- 03_document_and_udin_evidence.csv\n- 04_independence_conflict_declarations.csv\n- 05_kyc_verification.csv\n- 06_engagement_letter_evidence.csv\n- 07_closure_checklist.csv\n\nNote: This export contains the peer review evidence index, statuses, notes, hashes and storage paths. Physical documents remain in the ERP document vault and can be opened from the engagement/task documents screens.\n"""
|
||||||
|
|
||||||
|
bio = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(bio, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("README.txt", readme)
|
||||||
|
zf.writestr("01_engagement_summary.csv", _make_csv(summary_headers, summary_rows))
|
||||||
|
zf.writestr("02_aqmm_and_task_review_evidence.csv", _make_csv(task_headers, task_rows))
|
||||||
|
zf.writestr("03_document_and_udin_evidence.csv", _make_csv(document_headers, document_rows))
|
||||||
|
zf.writestr("04_independence_conflict_declarations.csv", _make_csv(declaration_headers, declaration_rows))
|
||||||
|
zf.writestr("05_kyc_verification.csv", _make_csv(kyc_headers, kyc_rows))
|
||||||
|
zf.writestr("06_engagement_letter_evidence.csv", _make_csv(letter_headers, letter_rows))
|
||||||
|
zf.writestr("07_closure_checklist.csv", _make_csv(closure_headers, closure_rows))
|
||||||
|
return bio.getvalue()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">Peer Review Evidence Preview</h2>
|
||||||
|
<p class="text-sm text-slate-500">{{ subscription.client.client_name if subscription.client else '-' }} · {{ subscription.catalogue.service_name if subscription.catalogue else '-' }} · FY {{ subscription.financial_year or '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/peer-review/evidence" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||||
|
<a href="/peer-review/evidence/{{ subscription.id }}/export.zip" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Export ZIP</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if blockers %}
|
||||||
|
<section class="rounded-2xl border border-amber-200 bg-amber-50 p-4">
|
||||||
|
<h3 class="text-sm font-semibold text-amber-900">Open Blockers</h3>
|
||||||
|
<ul class="mt-2 list-disc pl-5 text-sm text-amber-900">
|
||||||
|
{% for b in blockers %}<li>{{ b }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">AQMM Status</p><p class="mt-2 text-lg font-semibold text-slate-900">{{ (subscription.quality_workflow_status or '-')|replace('_',' ')|title }}</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">AQMM Tasks</p><p class="mt-2 text-lg font-semibold text-slate-900">{{ aqmm_tasks|length }}</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Documents</p><p class="mt-2 text-lg font-semibold text-slate-900">{{ documents|length }}</p></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="border-b border-slate-100 px-5 py-4"><h3 class="text-sm font-semibold text-slate-900">AQMM Task Evidence and Reviews</h3></div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200">
|
||||||
|
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Task</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Evidence</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Manager</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Partner</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Review Partner</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for t in aqmm_tasks %}
|
||||||
|
<tr><td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ t.sequence_no }}. {{ t.task_name }}</div><div class="text-xs text-slate-500">{{ t.aqmm_reference or '' }}</div></td><td class="px-4 py-3 text-sm">{{ (t.evidence_status or '-')|replace('_',' ')|title }}</td><td class="px-4 py-3 text-sm">{{ (t.manager_review_status or '-')|replace('_',' ')|title }}</td><td class="px-4 py-3 text-sm">{{ (t.partner_review_status or '-')|replace('_',' ')|title }}</td><td class="px-4 py-3 text-sm">{{ (t.review_partner_review_status or '-')|replace('_',' ')|title }}</td></tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="px-4 py-6 text-center text-sm text-slate-500">No AQMM-tagged tasks found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="border-b border-slate-100 px-5 py-4"><h3 class="text-sm font-semibold text-slate-900">Documents / UDIN Evidence</h3></div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200">
|
||||||
|
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Document</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">AQMM Evidence</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Release</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">UDIN</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for d in documents %}
|
||||||
|
<tr><td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ d.title }}</div><div class="text-xs text-slate-500">{{ d.document_code }} · {{ d.document_type }}</div></td><td class="px-4 py-3 text-sm">{{ 'Yes' if d.is_aqmm_evidence else 'No' }} · {{ (d.evidence_status or '-')|replace('_',' ')|title }}</td><td class="px-4 py-3 text-sm">{{ (d.final_release_status or '-')|replace('_',' ')|title }}</td><td class="px-4 py-3 text-sm">{{ d.udin_number or (d.udin_status or '-') }}</td></tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="px-4 py-6 text-center text-sm text-slate-500">No documents found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">Peer Review Evidence Export</h2>
|
||||||
|
<p class="text-sm text-slate-500">Export assurance engagement evidence from AQMM acceptance, AQMM-tagged tasks, task documents, review notes, UDIN and closure checklist.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/aqmm/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">AQMM Dashboard</a>
|
||||||
|
<a href="/services/engagements" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Open Engagements</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="flex flex-wrap items-end gap-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</label>
|
||||||
|
<input type="text" name="financial_year" value="{{ financial_year or '' }}" placeholder="2025-26" class="w-36 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
|
||||||
|
<input type="text" name="q" value="{{ q or '' }}" placeholder="Client or service" class="w-72 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Filter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="border-b border-slate-100 px-5 py-4">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">Assurance Engagements</h3>
|
||||||
|
<p class="text-sm text-slate-500">Only Partner and Firm Admin can access this page and download evidence exports.</p>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client / Service</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">FY</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">AQMM</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Closure</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for row, closure in engagements %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<div class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</div>
|
||||||
|
<div class="text-xs text-slate-500">{{ row.catalogue.service_name if row.catalogue else '-' }}</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">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 '-') }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-slate-700">{{ row.financial_year or '-' }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-slate-700">
|
||||||
|
<div>{{ (row.quality_workflow_status or 'not_required')|replace('_',' ')|title }}</div>
|
||||||
|
<div class="text-xs text-slate-500">KYC: {{ (row.quality_kyc_status or '-')|replace('_',' ')|title }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-slate-700">{{ (closure.closure_status if closure else 'not_started')|replace('_',' ')|title }}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-sm">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<a href="/peer-review/evidence/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Preview</a>
|
||||||
|
<a href="/peer-review/evidence/{{ row.id }}/export.zip" class="rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-700">Export ZIP</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No assurance engagements found for the selected filter.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||||
|
|
||||||
|
from app.core.db.common import CommonSessionLocal
|
||||||
|
from app.core.http_responses import ui_access_denied
|
||||||
|
from app.core.security.csrf import get_or_create_csrf_token
|
||||||
|
from app.core.security.session_auth import get_current_user
|
||||||
|
from app.core.templating import templates
|
||||||
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
|
from app.modules.peer_review_export.service import (
|
||||||
|
build_peer_review_evidence,
|
||||||
|
build_peer_review_zip,
|
||||||
|
can_view_peer_review_export,
|
||||||
|
list_peer_review_engagements,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/peer-review", tags=["peer-review-evidence-export-ui"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(request: Request, db, user, **extra):
|
||||||
|
base = {
|
||||||
|
"request": request,
|
||||||
|
"current_user": user,
|
||||||
|
"current_user_roles": get_user_roles(db, user.id),
|
||||||
|
"current_user_permissions": get_user_permissions(db, user.id),
|
||||||
|
"csrf_token": get_or_create_csrf_token(request),
|
||||||
|
"title": "Peer Review Evidence Export",
|
||||||
|
}
|
||||||
|
base.update(extra)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(value: str) -> str:
|
||||||
|
value = re.sub(r"[^A-Za-z0-9._-]+", "_", str(value or "").strip())
|
||||||
|
value = value.strip("._")
|
||||||
|
return value or "peer_review_evidence"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/evidence")
|
||||||
|
def evidence_index(request: Request, financial_year: str = "", q: str = ""):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not can_view_peer_review_export(db, user):
|
||||||
|
return ui_access_denied()
|
||||||
|
payload = list_peer_review_engagements(db, request, user, financial_year=financial_year, q=q)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/peer_review_export/templates/peer_review_export/index.html",
|
||||||
|
_ctx(request, db, user, **payload),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/evidence/{subscription_id}")
|
||||||
|
def evidence_detail(request: Request, subscription_id: int):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not can_view_peer_review_export(db, user):
|
||||||
|
return ui_access_denied()
|
||||||
|
try:
|
||||||
|
payload = build_peer_review_evidence(db, request, user, subscription_id)
|
||||||
|
except ValueError:
|
||||||
|
return ui_access_denied()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/peer_review_export/templates/peer_review_export/detail.html",
|
||||||
|
_ctx(request, db, user, **payload),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/evidence/{subscription_id}/export.zip")
|
||||||
|
def evidence_export_zip(request: Request, subscription_id: int):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not can_view_peer_review_export(db, user):
|
||||||
|
return ui_access_denied()
|
||||||
|
try:
|
||||||
|
payload = build_peer_review_evidence(db, request, user, subscription_id)
|
||||||
|
data = build_peer_review_zip(db, request, user, subscription_id)
|
||||||
|
except ValueError:
|
||||||
|
return ui_access_denied()
|
||||||
|
sub = payload["subscription"]
|
||||||
|
client_name = sub.client.client_name if sub.client else "client"
|
||||||
|
service_name = sub.catalogue.service_name if sub.catalogue else "service"
|
||||||
|
filename = _safe_filename(f"peer_review_{client_name}_{service_name}_{sub.financial_year or 'fy'}_{sub.id}.zip")
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([data]),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -30,6 +30,7 @@ from app.ui.routes.auth import router as auth_router
|
|||||||
from app.modules.workspace_navigation.ui import router as workspace_navigation_router
|
from app.modules.workspace_navigation.ui import router as workspace_navigation_router
|
||||||
from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_router
|
from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_router
|
||||||
from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router
|
from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router
|
||||||
|
from app.modules.peer_review_export.ui import router as peer_review_export_router
|
||||||
|
|
||||||
|
|
||||||
def mount_ui(app: FastAPI) -> None:
|
def mount_ui(app: FastAPI) -> None:
|
||||||
@@ -46,6 +47,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(audit_ui_router)
|
app.include_router(audit_ui_router)
|
||||||
app.include_router(services_ui_router)
|
app.include_router(services_ui_router)
|
||||||
app.include_router(aqmm_dashboard_router)
|
app.include_router(aqmm_dashboard_router)
|
||||||
|
app.include_router(peer_review_export_router)
|
||||||
app.include_router(work_tracker_ui_router)
|
app.include_router(work_tracker_ui_router)
|
||||||
app.include_router(billing_ui_router)
|
app.include_router(billing_ui_router)
|
||||||
app.include_router(platform_billing_ui_router)
|
app.include_router(platform_billing_ui_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user