From bca72ab2d960a20e51db4f654058d77c8607656d Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Mon, 20 Jul 2026 13:11:11 +0530 Subject: [PATCH] Add partner engagement review and release workspace phase 5B --- app/modules/partner_dashboard/service.py | 286 +++++++++++++++++- .../partner_dashboard/partials/review.html | 52 +++- .../partner_review_workspace.html | 93 ++++++ app/modules/partner_dashboard/ui.py | 101 ++++++- 4 files changed, 523 insertions(+), 9 deletions(-) create mode 100644 app/modules/partner_dashboard/templates/partner_dashboard/partner_review_workspace.html 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 }} +
+
+
+
Weighted progress{{ item.weighted_progress.progress_percent }}%
+
+ Start / Continue Partner Review +
+
+
+ {% else %} +
No engagements are awaiting Partner or Review Partner action.
+ {% endfor %} +
+
+ +
+

Existing Task-Level Review Queue

+

Retained for compatibility with the existing Partner workflow.

+
+ {% 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 in the legacy task queue.
{% endfor %} +
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 %} + +
+ + +
+ {% 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 %} +
+ + +
+ +
Close Workspace
+
+ {% 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()