diff --git a/app/modules/partner_dashboard/service.py b/app/modules/partner_dashboard/service.py
index 722b4e5..382656c 100644
--- a/app/modules/partner_dashboard/service.py
+++ b/app/modules/partner_dashboard/service.py
@@ -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"])
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/review.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/review.html
index 89281f3..2dcaed4 100644
--- a/app/modules/partner_dashboard/templates/partner_dashboard/partials/review.html
+++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/review.html
@@ -1,6 +1,50 @@
-
-
Partner Review Queue Completed work awaiting partner approval, rework decision or final sign-off.
Open Review Board
-
- {% for item in review_queue %}
{{ item.task_name }} {{ item.client_name }} · {{ item.service_name }}
Review Prepared by: {{ item.assigned_to }} · Due: {{ item.due_date or '-' }}
{% else %}
No completed tasks waiting for partner review.
{% endfor %}
+
+
+
+
+
Partner Engagement Review Queue
+
Engagement-level Partner and Review Partner review, significant exceptions and final-release blockers.
+
+
Open Existing Review Board
+
+
+
+ {% for item in partner_engagement_review_queue %}
+
+
+
+
+
{{ item.client_name }}
+ {{ item.financial_year }}
+ {% if item.release_blockers %}{{ item.release_blockers }} release blocker{{ '' if item.release_blockers == 1 else 's' }} {% endif %}
+ {% if item.exception_count %}{{ item.exception_count }} exception{{ '' if item.exception_count == 1 else 's' }} {% endif %}
+
+
{{ item.service_name }}
+
+ Partner pending: {{ item.pending_partner }}
+ Review Partner pending: {{ item.pending_review_partner }}
+ Rework: {{ item.rework_count }}
+ {{ item.sla.label }}
+
+
+
+
+
+ {% else %}
+
No engagements are awaiting Partner or Review Partner action.
+ {% endfor %}
+
+
+
+
+
Existing Task-Level Review Queue
+
Retained for compatibility with the existing Partner workflow.
+
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partner_review_workspace.html b/app/modules/partner_dashboard/templates/partner_dashboard/partner_review_workspace.html
new file mode 100644
index 0000000..b9b5e33
--- /dev/null
+++ b/app/modules/partner_dashboard/templates/partner_dashboard/partner_review_workspace.html
@@ -0,0 +1,93 @@
+{% extends "ui/templates/base/layout.html" %}
+{% block content %}
+{% set active = workspace.active_task %}
+
+
+
+
+
+
Partner Engagement Review & Release
+ {% if workspace.is_locked %}Locked {% endif %}
+
+
{{ workspace.client_name }} · {{ workspace.service_name }} · {{ workspace.financial_year }}
+
+ Partner pending: {{ workspace.pending_partner_count }}
+ Review Partner pending: {{ workspace.pending_review_partner_count }}
+ Exceptions: {{ workspace.exception_count }}
+ Release blockers: {{ workspace.release_blocker_count }}
+ {{ workspace.sla.label }}
+
+
+
+
+
+
+
Weighted Progress
{{ workspace.weighted_progress.progress_percent }}%
+
AQMM Status
{{ workspace.aqmm.status.replace('_',' ').title() }}
{{ workspace.aqmm.mandatory_done }}/{{ workspace.aqmm.mandatory }} mandatory controls passed
+
Release Readiness
{{ 'Ready for final document release' if workspace.release_ready else 'Blocked / pending controls' }}
+
SLA & Ageing
{{ workspace.sla.label }}
Age: {{ workspace.sla.age_days }} days{% if workspace.sla.days_overdue %} · {{ workspace.sla.days_overdue }} overdue{% endif %}
+
+
+
+ {% for member in workspace.team %}
{{ member.role }}
{{ member.name }}
{% endfor %}
+
+
+
+ {% if request.query_params.get('reviewed') %}
Review decision saved.
{% endif %}
+ {% if request.query_params.get('review_error') %}
The review could not be saved. Check readiness, review note, assignment and lock status.
{% endif %}
+
+
+
+ {% for category in workspace.categories %}
+
+ {{ category.name }} {{ category.reviewed }} reviewed · {{ category.pending }} pending · {{ category.rework }} rework
{{ category.progress_percent }}%
+
+
+
+ {% endfor %}
+
+
+
+ {% if active %}
+ {{ active.category }}
{{ active.task_name }} {{ active.description or 'No additional procedure description.' }}
Assigned: {{ active.assigned_to }} {% if active.review_level %}{{ active.review_level.replace('_',' ').title() }} {% endif %}
+
+
+
Staff Response & Conclusion
Response {{ active.checklist_response or active.checklist_text_response or active.checklist_number_response or active.checklist_date_response or '-' }}
Checklist remarks {{ active.checklist_remarks or '-' }}
Work remarks {{ active.work_remarks or '-' }}
+
Manager Conclusion
Status {{ active.manager_review_status.replace('_',' ').title() }}
Note {{ active.manager_review_note or '-' }} Evidence {{ active.evidence_count }} file{{ '' if active.evidence_count == 1 else 's' }} · Open Evidence
+
+
+ {% if active.rework_reason %}Open/previous rework: {{ active.rework_reason }}
{% endif %}
+ {% if active.checklist_response == 'NO' %}Exception response: The staff response is NO. Review the remarks, evidence and reporting consequence before approval.
{% endif %}
+
+ Communication Timeline {% for comment in active.comments %}
{{ comment.comment_type.replace('_',' ').title() }} · {{ comment.created_at_utc }}
{{ comment.message }}
{% else %}
No communication recorded.
{% endfor %}
+
+ {% if active.can_review and not active.is_locked and not workspace.is_locked %}
+
+ {% else %}
+ {% 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 %}
+ {% endif %}
+ {% else %}No tasks are available in this engagement.
{% endif %}
+
+
+
+{% endblock %}
diff --git a/app/modules/partner_dashboard/ui.py b/app/modules/partner_dashboard/ui.py
index 1330d60..b99495d 100644
--- a/app/modules/partner_dashboard/ui.py
+++ b/app/modules/partner_dashboard/ui.py
@@ -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()