Add partner engagement review and release workspace phase 5B

This commit is contained in:
A R R R Associates
2026-07-20 13:11:11 +05:30
parent 4c81659156
commit bca72ab2d9
4 changed files with 523 additions and 9 deletions
+284 -2
View File
@@ -12,8 +12,18 @@ from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.employees.models import Employee
from app.modules.services.execution import CLOSED_TASK_STATUSES
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue
from app.modules.services.execution import (
CLOSED_TASK_STATUSES,
aqmm_task_summary_for_subscription,
closure_readiness_for_subscription,
)
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
ServiceCatalogue,
ServiceTaskComment,
)
from app.modules.employees.service import _engagement_sla, _engagement_team, _weighted_progress
try:
from app.modules.billing.models import BillingInvoice
@@ -319,6 +329,7 @@ def build_partner_dashboard_payload(db: Session, request, current_user) -> dict[
"overdue": overdue_rows[:25],
"client_pending": client_pending_rows[:25],
"review_queue": review_rows[:50],
"partner_engagement_review_queue": _partner_review_queue_rows(db, request, current_user),
"clients": clients,
"staff_rows": staff,
"billing": billing,
@@ -347,3 +358,274 @@ def _wizard_cards() -> list[dict[str, str]]:
{"title": "Documents", "desc": "Open branch and engagement documents.", "href": "/documents"},
{"title": "Billing", "desc": "Raise invoices and track collection follow-up.", "href": "/billing"},
]
# -----------------------------------------------------------------------------
# Phase 5B - Partner engagement review and release workspace
# -----------------------------------------------------------------------------
def _display_user(user) -> str:
return getattr(user, "full_name", None) or getattr(user, "email", None) or "Not assigned"
def _partner_task_category(task: ClientServiceTaskInstance) -> str:
return (getattr(task, "task_category", None) or "General Workflow").strip() or "General Workflow"
def _partner_review_level_for_task(task: ClientServiceTaskInstance, subscription: ClientServiceSubscription, current_user) -> str | None:
"""Return the review level the current partner is authorised to perform.
Review Partner work is kept separate from Engagement Partner work. System and
Firm Admin users may inspect the workspace, but review decisions are selected
from the engagement assignments rather than from a new permission model.
"""
uid = int(getattr(current_user, "id", 0) or 0)
is_review_partner = uid and uid == int(getattr(subscription, "review_partner_user_id", 0) or 0)
is_engagement_partner = uid and uid == int(getattr(subscription, "assigned_partner_user_id", 0) or 0)
if is_review_partner and getattr(task, "aqmm_review_partner_required", False):
return "review_partner"
if is_engagement_partner and getattr(task, "aqmm_partner_review_required", False):
return "partner"
return None
def _partner_review_state(task: ClientServiceTaskInstance, review_level: str | None) -> str:
if getattr(task, "rework_status", "none") == "open":
return "rework"
if review_level == "review_partner":
status = getattr(task, "review_partner_review_status", "not_required")
elif review_level == "partner":
status = getattr(task, "partner_review_status", "not_required")
else:
return "read_only"
if status == "reviewed":
return "reviewed"
if status == "rework_required":
return "rework"
# Partner review must follow any required Manager review.
if getattr(task, "aqmm_manager_review_required", False) and getattr(task, "manager_review_status", "not_required") != "reviewed":
return "not_ready"
# Review Partner review must follow required Engagement Partner review.
if review_level == "review_partner" and getattr(task, "aqmm_partner_review_required", False) and getattr(task, "partner_review_status", "not_required") != "reviewed":
return "not_ready"
if (getattr(task, "status", "pending") or "pending") not in CLOSED_TASK_STATUSES and getattr(task, "submitted_for_review_at_utc", None) is None:
return "not_ready"
return "pending_review"
def _partner_task_payload(task: ClientServiceTaskInstance, subscription: ClientServiceSubscription, current_user) -> dict[str, Any]:
review_level = _partner_review_level_for_task(task, subscription, current_user)
review_state = _partner_review_state(task, review_level)
comments = [c for c in (getattr(task, "comments", None) or []) if not getattr(c, "is_deleted", False)]
documents = [d for d in (getattr(task, "documents", None) or []) if not getattr(d, "is_deleted", False)]
assignee = getattr(task, "assigned_to", None)
return {
"id": task.id,
"task_name": task.task_name,
"description": task.description,
"sequence_no": task.sequence_no,
"category": _partner_task_category(task),
"status": task.status or "pending",
"status_label": (task.status or "pending").replace("_", " ").title(),
"assigned_to": _display_user(assignee),
"response_type": getattr(task, "response_type", "NONE") or "NONE",
"checklist_response": getattr(task, "checklist_response", None),
"checklist_text_response": getattr(task, "checklist_text_response", None),
"checklist_number_response": getattr(task, "checklist_number_response", None),
"checklist_date_response": getattr(task, "checklist_date_response", None),
"checklist_remarks": getattr(task, "checklist_remarks", None),
"work_remarks": getattr(task, "remarks", None),
"manager_review_status": getattr(task, "manager_review_status", "not_required"),
"manager_review_note": getattr(task, "manager_review_note", None),
"partner_review_status": getattr(task, "partner_review_status", "not_required"),
"partner_review_note": getattr(task, "partner_review_note", None),
"review_partner_review_status": getattr(task, "review_partner_review_status", "not_required"),
"review_partner_review_note": getattr(task, "review_partner_review_note", None),
"rework_status": getattr(task, "rework_status", "none"),
"rework_reason": getattr(task, "rework_reason", None),
"is_aqmm_task": bool(getattr(task, "is_aqmm_task", False)),
"aqmm_reference": getattr(task, "aqmm_reference", None),
"aqmm_status": getattr(task, "aqmm_status", "not_required"),
"blocks_final_release": bool(getattr(task, "aqmm_blocks_final_release", False)),
"partner_review_required": bool(getattr(task, "aqmm_partner_review_required", False)),
"review_partner_required": bool(getattr(task, "aqmm_review_partner_required", False)),
"review_level": review_level,
"review_state": review_state,
"can_review": review_level is not None and review_state in {"pending_review", "reviewed", "rework"},
"is_exception": getattr(task, "checklist_response", None) == "NO" or getattr(task, "rework_status", "none") == "open",
"evidence_count": len(documents),
"comments": comments,
"is_locked": bool(getattr(task, "is_locked", False) or getattr(subscription, "is_locked", False)),
}
def _partner_review_queue_rows(db: Session, request, current_user, *, limit: int = 80) -> list[dict[str, Any]]:
roles = set(get_user_role_names(db, current_user.id))
scope = _active_scope(db, request, current_user, roles)
fy = _financial_year(request)
stmt = (
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceSubscription.review_partner),
)
)
stmt = _subscription_scope(stmt, scope["tenant_id"], scope["branch_id"], current_user, roles, fy)
if "System Admin" not in roles and "Firm Admin" not in roles:
stmt = stmt.where(or_(
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
ClientServiceSubscription.review_partner_user_id == current_user.id,
))
subscriptions = list(db.execute(stmt.order_by(ClientServiceSubscription.current_due_date.is_(None), ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.desc()).limit(limit)).scalars().all())
rows: list[dict[str, Any]] = []
for sub in subscriptions:
task_stmt = select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.subscription_id == sub.id,
ClientServiceTaskInstance.is_active.is_(True),
)
tasks = list(db.execute(task_stmt.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())).scalars().all())
pending_partner = sum(1 for t in tasks if getattr(t, "aqmm_partner_review_required", False) and getattr(t, "partner_review_status", "not_required") != "reviewed")
pending_review_partner = sum(1 for t in tasks if getattr(t, "aqmm_review_partner_required", False) and getattr(t, "review_partner_review_status", "not_required") != "reviewed")
rework = sum(1 for t in tasks if getattr(t, "rework_status", "none") == "open")
blockers = sum(1 for t in tasks if getattr(t, "aqmm_blocks_final_release", False) and getattr(t, "aqmm_status", "not_required") != "completed")
exceptions = sum(1 for t in tasks if getattr(t, "checklist_response", None) == "NO")
if not any((pending_partner, pending_review_partner, rework, blockers, exceptions)):
continue
weighted = _weighted_progress(tasks)
sla = _engagement_sla(sub, tasks)
rows.append({
"subscription_id": sub.id,
"client_name": getattr(getattr(sub, "client", None), "client_name", None) or "Unlinked Client",
"service_name": getattr(getattr(sub, "catalogue", None), "service_name", None) or "Service",
"financial_year": sub.financial_year or "-",
"pending_partner": pending_partner,
"pending_review_partner": pending_review_partner,
"rework_count": rework,
"release_blockers": blockers,
"exception_count": exceptions,
"weighted_progress": weighted,
"sla": sla,
"href": f"/partner/reviews/engagements/{sub.id}",
})
rows.sort(key=lambda r: (r["release_blockers"], r["rework_count"], r["pending_partner"] + r["pending_review_partner"]), reverse=True)
return rows
def get_partner_review_workspace(db: Session, request, current_user, *, subscription_id: int, active_task_id: int | None = None) -> dict[str, Any] | None:
roles = set(get_user_role_names(db, current_user.id))
scope = _active_scope(db, request, current_user, roles)
fy = _financial_year(request)
stmt = (
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceSubscription.review_partner),
)
.where(ClientServiceSubscription.id == int(subscription_id))
)
stmt = _subscription_scope(stmt, scope["tenant_id"], scope["branch_id"], current_user, roles, fy)
subscription = db.execute(stmt).scalar_one_or_none()
if not subscription:
return None
if not roles.intersection({"System Admin", "Firm Admin"}) and int(current_user.id) not in {
int(getattr(subscription, "assigned_partner_user_id", 0) or 0),
int(getattr(subscription, "review_partner_user_id", 0) or 0),
}:
return None
task_stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
selectinload(ClientServiceTaskInstance.documents),
)
.where(
ClientServiceTaskInstance.subscription_id == subscription.id,
ClientServiceTaskInstance.is_active.is_(True),
)
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
)
tasks = list(db.execute(task_stmt).scalars().all())
task_rows = [_partner_task_payload(task, subscription, current_user) for task in tasks]
categories_by_name: dict[str, dict[str, Any]] = {}
for row in task_rows:
cat = categories_by_name.setdefault(row["category"], {
"name": row["category"], "tasks": [], "total": 0, "pending": 0,
"reviewed": 0, "rework": 0, "exceptions": 0, "blockers": 0,
})
cat["tasks"].append(row)
cat["total"] += 1
if row["review_state"] == "pending_review": cat["pending"] += 1
if row["review_state"] == "reviewed": cat["reviewed"] += 1
if row["review_state"] == "rework": cat["rework"] += 1
if row["is_exception"]: cat["exceptions"] += 1
if row["blocks_final_release"] and row["aqmm_status"] != "completed": cat["blockers"] += 1
categories = list(categories_by_name.values())
for cat in categories:
denominator = cat["pending"] + cat["reviewed"] + cat["rework"]
cat["progress_percent"] = int(round((cat["reviewed"] / denominator) * 100)) if denominator else 100
actionable = [r for r in task_rows if r["can_review"]]
active = next((r for r in task_rows if active_task_id and r["id"] == int(active_task_id)), None)
if active is None:
active = next((r for r in actionable if r["review_state"] == "pending_review"), None)
if active is None:
active = next((r for r in actionable if r["review_state"] in {"rework", "reviewed"}), None)
if active is None and task_rows:
active = task_rows[0]
weighted = _weighted_progress(tasks)
sla = _engagement_sla(subscription, tasks)
team = _engagement_team(subscription)
aqmm = aqmm_task_summary_for_subscription(db, subscription_id=subscription.id)
closure = closure_readiness_for_subscription(db, subscription=subscription)
release_ready = bool(
closure["normal_tasks_completed"]
and closure["aqmm_acceptance_completed"]
and closure["aqmm_tasks_completed"]
and closure["evidence_review_completed"]
and closure["udin_completed"]
)
return {
"subscription": subscription,
"client_name": getattr(getattr(subscription, "client", None), "client_name", None) or "Unlinked Client",
"service_name": getattr(getattr(subscription, "catalogue", None), "service_name", None) or "Service",
"financial_year": subscription.financial_year or "-",
"categories": categories,
"tasks": task_rows,
"active_task": active,
"weighted_progress": weighted,
"sla": sla,
"team": team,
"aqmm": aqmm,
"closure": closure,
"release_ready": release_ready,
"is_locked": bool(getattr(subscription, "is_locked", False)),
"pending_partner_count": sum(1 for r in task_rows if r["review_level"] == "partner" and r["review_state"] == "pending_review"),
"pending_review_partner_count": sum(1 for r in task_rows if r["review_level"] == "review_partner" and r["review_state"] == "pending_review"),
"rework_count": sum(1 for r in task_rows if r["review_state"] == "rework"),
"exception_count": sum(1 for r in task_rows if r["is_exception"]),
"release_blocker_count": len(aqmm.get("blockers") or []),
"documents_href": f"/documents/engagements/{subscription.id}",
}
def get_next_partner_review_task_id(workspace: dict[str, Any], current_task_id: int) -> int | None:
rows = [r for r in workspace.get("tasks", []) if r.get("can_review") and r.get("review_state") == "pending_review"]
if not rows:
return None
for index, row in enumerate(rows):
if int(row["id"]) == int(current_task_id):
return int(rows[index + 1]["id"]) if index + 1 < len(rows) else None
return int(rows[0]["id"])
@@ -1,6 +1,50 @@
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="flex items-center justify-between"><div><h2 class="text-lg font-semibold text-slate-900">Partner Review Queue</h2><p class="text-sm text-slate-500">Completed work awaiting partner approval, rework decision or final sign-off.</p></div><a href="/partner/reviews" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open Review Board</a></div>
<div class="space-y-6">
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-lg font-semibold text-slate-900">Partner Engagement Review Queue</h2>
<p class="text-sm text-slate-500">Engagement-level Partner and Review Partner review, significant exceptions and final-release blockers.</p>
</div>
<a href="/partner/reviews" class="rounded-2xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Existing Review Board</a>
</div>
<div class="mt-5 space-y-4">
{% for item in partner_engagement_review_queue %}
<article class="rounded-3xl border border-slate-200 p-5">
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold text-slate-900">{{ item.client_name }}</h3>
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ item.financial_year }}</span>
{% if item.release_blockers %}<span class="rounded-full bg-rose-100 px-2.5 py-1 text-xs font-semibold text-rose-700">{{ item.release_blockers }} release blocker{{ '' if item.release_blockers == 1 else 's' }}</span>{% endif %}
{% if item.exception_count %}<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">{{ item.exception_count }} exception{{ '' if item.exception_count == 1 else 's' }}</span>{% endif %}
</div>
<p class="mt-1 text-sm text-slate-500">{{ item.service_name }}</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-indigo-50 px-2.5 py-1 font-semibold text-indigo-700">Partner pending: {{ item.pending_partner }}</span>
<span class="rounded-full bg-purple-50 px-2.5 py-1 font-semibold text-purple-700">Review Partner pending: {{ item.pending_review_partner }}</span>
<span class="rounded-full bg-rose-50 px-2.5 py-1 font-semibold text-rose-700">Rework: {{ item.rework_count }}</span>
<span class="rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-700">{{ item.sla.label }}</span>
</div>
</div>
<div class="w-full xl:w-72">
<div class="flex items-center justify-between text-xs text-slate-500"><span>Weighted progress</span><span>{{ item.weighted_progress.progress_percent }}%</span></div>
<div class="mt-2 h-2 rounded-full bg-slate-100"><div class="h-2 rounded-full bg-brand-600" style="width: {{ item.weighted_progress.progress_percent }}%"></div></div>
<a href="{{ item.href }}" class="mt-4 inline-flex w-full justify-center rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Start / Continue Partner Review</a>
</div>
</div>
</article>
{% else %}
<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">No engagements are awaiting Partner or Review Partner action.</div>
{% endfor %}
</div>
</div>
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Existing Task-Level Review Queue</h2>
<p class="text-sm text-slate-500">Retained for compatibility with the existing Partner workflow.</p>
<div class="mt-5 grid gap-4 lg:grid-cols-2">
{% for item in review_queue %}<a href="{{ item.href }}" class="rounded-3xl border border-slate-200 p-5 hover:bg-slate-50"><div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ item.task_name }}</h3><p class="mt-1 text-xs text-slate-500">{{ item.client_name }} · {{ item.service_name }}</p></div><span class="rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">Review</span></div><div class="mt-3 text-xs text-slate-500">Prepared by: {{ item.assigned_to }} · Due: {{ item.due_date or '-' }}</div></a>{% else %}<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 lg:col-span-2">No completed tasks waiting for partner review.</div>{% endfor %}
{% for item in review_queue %}<a href="{{ item.href }}" class="rounded-3xl border border-slate-200 p-5 hover:bg-slate-50"><div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ item.task_name }}</h3><p class="mt-1 text-xs text-slate-500">{{ item.client_name }} · {{ item.service_name }}</p></div><span class="rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">Review</span></div><div class="mt-3 text-xs text-slate-500">Prepared by: {{ item.assigned_to }} · Due: {{ item.due_date or '-' }}</div></a>{% else %}<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 lg:col-span-2">No completed tasks waiting in the legacy task queue.</div>{% endfor %}
</div>
</div>
</div>
@@ -0,0 +1,93 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% set active = workspace.active_task %}
<div class="space-y-6">
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<h1 class="text-2xl font-semibold text-slate-900">Partner Engagement Review &amp; Release</h1>
{% if workspace.is_locked %}<span class="rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold text-white">Locked</span>{% endif %}
</div>
<p class="mt-1 text-sm text-slate-500">{{ workspace.client_name }} · {{ workspace.service_name }} · {{ workspace.financial_year }}</p>
<div class="mt-4 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-indigo-50 px-3 py-1.5 font-semibold text-indigo-700">Partner pending: {{ workspace.pending_partner_count }}</span>
<span class="rounded-full bg-purple-50 px-3 py-1.5 font-semibold text-purple-700">Review Partner pending: {{ workspace.pending_review_partner_count }}</span>
<span class="rounded-full bg-amber-50 px-3 py-1.5 font-semibold text-amber-700">Exceptions: {{ workspace.exception_count }}</span>
<span class="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700">Release blockers: {{ workspace.release_blocker_count }}</span>
<span class="rounded-full bg-slate-100 px-3 py-1.5 font-semibold text-slate-700">{{ workspace.sla.label }}</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ workspace.documents_href }}" class="rounded-2xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white">Final Documents / UDIN</a>
<a href="/partner/dashboard?tab=review" class="rounded-2xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700">Close Workspace</a>
</div>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase tracking-wide text-slate-500">Weighted Progress</div><div class="mt-2 text-2xl font-semibold">{{ workspace.weighted_progress.progress_percent }}%</div><div class="mt-2 h-2 rounded-full bg-white"><div class="h-2 rounded-full bg-brand-600" style="width: {{ workspace.weighted_progress.progress_percent }}%"></div></div></div>
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase tracking-wide text-slate-500">AQMM Status</div><div class="mt-2 text-lg font-semibold">{{ workspace.aqmm.status.replace('_',' ').title() }}</div><div class="mt-1 text-xs text-slate-500">{{ workspace.aqmm.mandatory_done }}/{{ workspace.aqmm.mandatory }} mandatory controls passed</div></div>
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase tracking-wide text-slate-500">Release Readiness</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if workspace.release_ready else 'text-amber-700' }}">{{ 'Ready for final document release' if workspace.release_ready else 'Blocked / pending controls' }}</div></div>
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase tracking-wide text-slate-500">SLA &amp; Ageing</div><div class="mt-2 text-lg font-semibold">{{ workspace.sla.label }}</div><div class="mt-1 text-xs text-slate-500">Age: {{ workspace.sla.age_days }} days{% if workspace.sla.days_overdue %} · {{ workspace.sla.days_overdue }} overdue{% endif %}</div></div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{% for member in workspace.team %}<div class="rounded-2xl border border-slate-200 p-3"><div class="text-xs text-slate-500">{{ member.role }}</div><div class="mt-1 text-sm font-semibold text-slate-900">{{ member.name }}</div></div>{% endfor %}
</div>
</div>
{% if request.query_params.get('reviewed') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Review decision saved.</div>{% endif %}
{% if request.query_params.get('review_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">The review could not be saved. Check readiness, review note, assignment and lock status.</div>{% endif %}
<div class="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
<aside class="space-y-4">
{% for category in workspace.categories %}
<section class="rounded-3xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="flex items-start justify-between gap-3"><div><h2 class="font-semibold text-slate-900">{{ category.name }}</h2><p class="mt-1 text-xs text-slate-500">{{ category.reviewed }} reviewed · {{ category.pending }} pending · {{ category.rework }} rework</p></div><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ category.progress_percent }}%</span></div>
<div class="mt-3 h-1.5 rounded-full bg-slate-100"><div class="h-1.5 rounded-full bg-brand-600" style="width: {{ category.progress_percent }}%"></div></div>
<div class="mt-4 space-y-2">
{% for task in category.tasks %}
<a href="/partner/reviews/engagements/{{ workspace.subscription.id }}?task_id={{ task.id }}" class="block rounded-2xl border p-3 {{ 'border-brand-400 bg-brand-50' if active and active.id == task.id else 'border-slate-200 hover:bg-slate-50' }}">
<div class="flex items-start justify-between gap-2"><div class="text-sm font-semibold text-slate-900">{{ task.task_name }}</div>{% if task.blocks_final_release %}<span class="rounded-full bg-slate-900 px-2 py-0.5 text-[10px] font-semibold text-white">Final Gate</span>{% endif %}</div>
<div class="mt-2 flex flex-wrap gap-1 text-[10px]">
<span class="rounded-full bg-slate-100 px-2 py-1">{{ task.status_label }}</span>
<span class="rounded-full px-2 py-1 {{ 'bg-indigo-100 text-indigo-700' if task.review_state == 'pending_review' else ('bg-emerald-100 text-emerald-700' if task.review_state == 'reviewed' else ('bg-rose-100 text-rose-700' if task.review_state == 'rework' else 'bg-slate-100 text-slate-600')) }}">{{ task.review_state.replace('_',' ').title() }}</span>
{% if task.is_exception %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-700">Exception</span>{% endif %}
</div>
</a>
{% endfor %}
</div>
</section>
{% endfor %}
</aside>
<main class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
{% if active %}
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"><div><div class="text-xs font-semibold uppercase tracking-wide text-brand-700">{{ active.category }}</div><h2 class="mt-1 text-xl font-semibold text-slate-900">{{ active.task_name }}</h2><p class="mt-2 text-sm text-slate-500">{{ active.description or 'No additional procedure description.' }}</p></div><div class="flex flex-wrap gap-2 text-xs"><span class="rounded-full bg-slate-100 px-3 py-1.5">Assigned: {{ active.assigned_to }}</span>{% if active.review_level %}<span class="rounded-full bg-indigo-100 px-3 py-1.5 font-semibold text-indigo-700">{{ active.review_level.replace('_',' ').title() }}</span>{% endif %}</div></div>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<section class="rounded-2xl border border-slate-200 p-4"><h3 class="font-semibold text-slate-900">Staff Response &amp; Conclusion</h3><dl class="mt-3 space-y-2 text-sm"><div><dt class="text-slate-500">Response</dt><dd class="font-semibold">{{ active.checklist_response or active.checklist_text_response or active.checklist_number_response or active.checklist_date_response or '-' }}</dd></div><div><dt class="text-slate-500">Checklist remarks</dt><dd class="whitespace-pre-wrap">{{ active.checklist_remarks or '-' }}</dd></div><div><dt class="text-slate-500">Work remarks</dt><dd class="whitespace-pre-wrap">{{ active.work_remarks or '-' }}</dd></div></dl></section>
<section class="rounded-2xl border border-slate-200 p-4"><h3 class="font-semibold text-slate-900">Manager Conclusion</h3><dl class="mt-3 space-y-2 text-sm"><div><dt class="text-slate-500">Status</dt><dd class="font-semibold">{{ active.manager_review_status.replace('_',' ').title() }}</dd></div><div><dt class="text-slate-500">Note</dt><dd class="whitespace-pre-wrap">{{ active.manager_review_note or '-' }}</dd></div><div><dt class="text-slate-500">Evidence</dt><dd>{{ active.evidence_count }} file{{ '' if active.evidence_count == 1 else 's' }} · <a class="font-semibold text-brand-700" href="/documents/tasks/{{ active.id }}">Open Evidence</a></dd></div></dl></section>
</div>
{% if active.rework_reason %}<div class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800"><strong>Open/previous rework:</strong> {{ active.rework_reason }}</div>{% endif %}
{% if active.checklist_response == 'NO' %}<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800"><strong>Exception response:</strong> The staff response is NO. Review the remarks, evidence and reporting consequence before approval.</div>{% endif %}
<section class="mt-6 rounded-2xl border border-slate-200 p-4"><h3 class="font-semibold text-slate-900">Communication Timeline</h3><div class="mt-3 space-y-3">{% for comment in active.comments %}<div class="rounded-xl bg-slate-50 p-3 text-sm"><div class="text-xs text-slate-500">{{ comment.comment_type.replace('_',' ').title() }} · {{ comment.created_at_utc }}</div><div class="mt-1 whitespace-pre-wrap">{{ comment.message }}</div></div>{% else %}<div class="text-sm text-slate-500">No communication recorded.</div>{% endfor %}</div></section>
{% if active.can_review and not active.is_locked and not workspace.is_locked %}
<form method="post" action="/partner/reviews/engagements/{{ workspace.subscription.id }}/tasks/{{ active.id }}" class="mt-6 rounded-2xl border border-slate-200 p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="text-sm font-semibold text-slate-800">Partner conclusion</label>
<div class="mt-3 grid gap-3 sm:grid-cols-2"><label class="rounded-2xl border border-slate-200 p-3"><input type="radio" name="decision" value="reviewed" checked> <span class="ml-2 font-semibold">Approve / Reviewed</span></label><label class="rounded-2xl border border-slate-200 p-3"><input type="radio" name="decision" value="rework_required"> <span class="ml-2 font-semibold">Return for Rework</span></label></div>
<textarea name="review_note" rows="4" placeholder="Partner conclusion, clarification or mandatory rework reason" class="mt-3 w-full rounded-2xl border border-slate-300 px-3 py-2 text-sm">{{ active.partner_review_note if active.review_level == 'partner' else active.review_partner_review_note or '' }}</textarea>
<div class="mt-4 flex flex-wrap gap-2"><button name="workflow_action" value="save" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Save Review</button><button name="workflow_action" value="save_next" class="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Save &amp; Next Review</button><a href="/partner/dashboard?tab=review" class="rounded-2xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700">Close Workspace</a></div>
</form>
{% else %}
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">{% if workspace.is_locked or active.is_locked %}This engagement or task is locked and cannot be reviewed.{% elif active.review_state == 'not_ready' %}This task is visible for context but is not ready for your review. Required execution or prior review is pending.{% else %}This task is read-only for your current engagement role.{% endif %}</div>
{% endif %}
{% else %}<div class="py-16 text-center text-sm text-slate-500">No tasks are available in this engagement.</div>{% endif %}
</main>
</div>
</div>
{% endblock %}
+98 -3
View File
@@ -1,15 +1,21 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
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.csrf import get_or_create_csrf_token, validate_csrf
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.partner_dashboard.service import build_partner_dashboard_payload, can_access_partner_dashboard
from app.modules.partner_dashboard.service import (
build_partner_dashboard_payload,
can_access_partner_dashboard,
get_next_partner_review_task_id,
get_partner_review_workspace,
)
from app.modules.services.execution import apply_task_review
router = APIRouter(prefix="/partner", tags=["partner-dashboard-v2-ui"])
@@ -73,3 +79,92 @@ def dashboard_tab(request: Request, tab_name: str):
)
finally:
db.close()
@router.get("/reviews/engagements/{subscription_id}")
def partner_review_workspace(request: Request, subscription_id: int, task_id: int | None = None):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
if not can_access_partner_dashboard(db, current_user):
return ui_access_denied()
workspace = get_partner_review_workspace(db, request, current_user, subscription_id=subscription_id, active_task_id=task_id)
if not workspace:
return RedirectResponse(url="/partner/dashboard?tab=review", status_code=303)
return templates.TemplateResponse(
"modules/partner_dashboard/templates/partner_dashboard/partner_review_workspace.html",
{
"request": request,
"current_user": current_user,
"current_user_roles": get_user_roles(db, current_user.id),
"current_user_permissions": get_user_permissions(db, current_user.id),
"csrf_token": get_or_create_csrf_token(request),
"title": "Partner Engagement Review & Release",
"workspace": workspace,
},
)
finally:
db.close()
@router.post("/reviews/engagements/{subscription_id}/tasks/{task_id}")
def partner_review_task_submit(
request: Request,
subscription_id: int,
task_id: int,
decision: str = Form("reviewed"),
review_note: str = Form(""),
workflow_action: str = Form("save"),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
if not can_access_partner_dashboard(db, current_user):
return ui_access_denied()
workspace = get_partner_review_workspace(db, request, current_user, subscription_id=subscription_id, active_task_id=task_id)
if not workspace:
return RedirectResponse(url="/partner/dashboard?tab=review", status_code=303)
active = workspace.get("active_task") or {}
if int(active.get("id") or 0) != int(task_id):
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?review_error=task", status_code=303)
if active.get("is_locked") or workspace.get("is_locked"):
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&review_error=locked", status_code=303)
if not active.get("can_review") or active.get("review_level") not in {"partner", "review_partner"}:
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&review_error=not_ready", status_code=303)
from sqlalchemy import select
from app.modules.services.models import ClientServiceTaskInstance
task = db.execute(select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.id == int(task_id),
ClientServiceTaskInstance.subscription_id == int(subscription_id),
)).scalar_one_or_none()
if not task:
return RedirectResponse(url="/partner/dashboard?tab=review", status_code=303)
try:
apply_task_review(
db,
task=task,
review_level=active["review_level"],
decision=decision,
note=review_note,
user_id=current_user.id,
)
db.commit()
except ValueError:
db.rollback()
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&review_error=validation", status_code=303)
if workflow_action == "save_next":
refreshed = get_partner_review_workspace(db, request, current_user, subscription_id=subscription_id)
next_id = get_next_partner_review_task_id(refreshed, task_id) if refreshed else None
if next_id:
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={next_id}&reviewed=1", status_code=303)
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&reviewed=1", status_code=303)
finally:
db.close()