Files
arrr-erp/app/modules/partners/ui.py
T

759 lines
35 KiB
Python

from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, timezone
from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.db.common import CommonSessionLocal
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.documents.models import EngagementDocument
from app.modules.clients.models import Client, ClientBranch, ClientBusinessUnit
from app.modules.registrations.models import ClientRegistration, RegistrationType
from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
ServiceCatalogue,
ServiceTaskComment,
)
router = APIRouter(prefix="/partner", tags=["partner-workspace-ui"])
REVIEW_ACTIONS = {
"approve": ("completed", "partner_review", "partner_review"),
"send_rework": ("pending", "partner_review_note", "partner_review"),
"clarification": ("blocked", "client_clarification", "internal"),
}
def _redirect_login():
return RedirectResponse(url="/login", status_code=303)
def _redirect_denied():
from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _is_partner_user(db: Session, current_user) -> bool:
roles = set(get_user_roles(db, current_user.id))
return bool(roles.intersection({"Partner", "Firm Admin", "System Admin"}))
def _base_ctx(request: Request, db: Session, current_user, **ctx):
base = {
"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),
"task_statuses": TASK_STATUSES,
"task_priorities": TASK_PRIORITIES,
}
base.update(ctx)
return base
def _render(request: Request, template_name: str, db: Session, current_user, **ctx):
return templates.TemplateResponse(template_name, _base_ctx(request, db, current_user, **ctx))
def _active_tenant_branch(request: Request, current_user, roles: set[str]) -> tuple[int, int | None]:
tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id
branch_id = request.session.get("active_branch_id")
if "System Admin" not in roles:
tenant_id = current_user.tenant_id
if not roles.intersection({"System Admin", "Firm Admin"}):
branch_id = current_user.branch_id
if branch_id in (0, "0", "", None):
branch_id = None
return int(tenant_id), int(branch_id) if branch_id is not None else None
def _active_financial_year(request: Request) -> str | None:
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
value = (value or "").strip()
return value or None
def _subscription_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None):
stmt = stmt.where(ClientServiceSubscription.tenant_id == tenant_id, ClientServiceSubscription.is_active.is_(True))
if branch_id is not None:
stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id)
if financial_year:
stmt = stmt.where(ClientServiceSubscription.financial_year == financial_year.strip())
if not roles.intersection({"System Admin", "Firm Admin"}):
stmt = stmt.where(
or_(
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
ClientServiceSubscription.review_partner_user_id == current_user.id,
)
)
return stmt
def _task_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None):
stmt = stmt.where(ClientServiceTaskInstance.tenant_id == tenant_id, ClientServiceTaskInstance.is_active.is_(True))
if branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id)
if financial_year:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
if not roles.intersection({"System Admin", "Firm Admin"}):
stmt = stmt.where(
ClientServiceTaskInstance.subscription.has(
or_(
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
ClientServiceSubscription.review_partner_user_id == current_user.id,
)
)
)
return stmt
def _task_status_label(task: ClientServiceTaskInstance) -> str:
return dict(TASK_STATUSES).get(getattr(task, "status", ""), (getattr(task, "status", "") or "-").replace("_", " ").title())
def _task_priority_label(task: ClientServiceTaskInstance) -> str:
return dict(TASK_PRIORITIES).get(getattr(task, "priority", ""), (getattr(task, "priority", "") or "normal").replace("_", " ").title())
def _engagement_label(subscription: ClientServiceSubscription | None, task: ClientServiceTaskInstance | None = None) -> str:
catalogue = getattr(subscription, "catalogue", None) if subscription else getattr(task, "catalogue", None)
service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) or "Engagement"
fy = getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None)
return f"{service_name} · FY {fy}" if fy else str(service_name)
def _decorate_task(task: ClientServiceTaskInstance, today: date) -> ClientServiceTaskInstance:
client = getattr(task, "client", None)
assignee = getattr(task, "assigned_to", None)
subscription = getattr(task, "subscription", None)
target = getattr(task, "internal_target_date", None)
status = (task.status or "pending").strip().lower()
is_closed = status in CLOSED_TASK_STATUSES
task.status_label = _task_status_label(task)
task.priority_label = _task_priority_label(task)
task.client_display = getattr(client, "client_name", None) or "Unlinked Client"
task.client_code_display = getattr(client, "client_code", None) or ""
task.assignee_display = getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned"
task.engagement_label = _engagement_label(subscription, task)
task.is_overdue = bool(target and target < today and not is_closed)
task.is_due_today = bool(target and target == today and not is_closed)
task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)])
return task
def _task_bucket(task: ClientServiceTaskInstance, current_user) -> str:
status = (task.status or "pending").strip().lower()
subscription = getattr(task, "subscription", None)
uid = int(getattr(current_user, "id", 0) or 0)
is_engagement_partner = uid and uid == int(getattr(subscription, "assigned_partner_user_id", 0) or 0)
is_review_partner = uid and uid == int(getattr(subscription, "review_partner_user_id", 0) or 0)
normal_role = (getattr(task, "normal_review_role", None) or "").strip().lower()
normal_status = (getattr(task, "normal_review_status", None) or "not_required").strip().lower()
normal_partner_review = (
is_engagement_partner
and getattr(task, "normal_review_required", False)
and normal_role in {"partner", "manager_or_partner"}
)
aqmm_partner_review = is_engagement_partner and getattr(task, "aqmm_partner_review_required", False)
aqmm_review_partner_review = is_review_partner and getattr(task, "aqmm_review_partner_required", False)
if status == "blocked" and getattr(task, "rework_status", "none") != "open":
return "clarification_required"
if (
(normal_partner_review and normal_status == "rework_required")
or (aqmm_partner_review and getattr(task, "partner_review_status", "not_required") == "rework_required")
or (aqmm_review_partner_review and getattr(task, "review_partner_review_status", "not_required") == "rework_required")
or getattr(task, "rework_status", "none") == "open"
):
return "rework_sent"
if (
(normal_partner_review and normal_status == "pending")
or (aqmm_partner_review and getattr(task, "partner_review_status", "not_required") == "pending")
or (aqmm_review_partner_review and getattr(task, "review_partner_review_status", "not_required") == "pending")
):
return "pending_review"
if status in CLOSED_TASK_STATUSES:
return "completed"
return "approved"
def _normalise_name(value: str | None) -> str:
return " ".join((value or "").split()).casefold()
def _partner_scope_display_map(
db: Session,
*,
tenant_id: int,
subscriptions: list[ClientServiceSubscription],
) -> dict[int, dict[str, str]]:
"""Resolve business/trade identity for the Partner Review Board.
Business/trade name is always primary. The legal client name is shown only
when it is genuinely different, preserving the engagement's existing scope.
"""
subscriptions = [row for row in subscriptions if row is not None]
if not subscriptions:
return {}
business_ids = {int(row.business_unit_id) for row in subscriptions if getattr(row, "business_unit_id", None)}
branch_ids = {int(row.client_branch_id) for row in subscriptions if getattr(row, "client_branch_id", None)}
registration_ids = {int(row.registration_id) for row in subscriptions if getattr(row, "registration_id", None)}
businesses: dict[int, ClientBusinessUnit] = {}
if business_ids:
businesses = {
row.id: row for row in db.execute(
select(ClientBusinessUnit).where(
ClientBusinessUnit.tenant_id == tenant_id,
ClientBusinessUnit.id.in_(business_ids),
)
).scalars().all()
}
branches: dict[int, ClientBranch] = {}
if branch_ids:
branches = {
row.id: row for row in db.execute(
select(ClientBranch).where(
ClientBranch.tenant_id == tenant_id,
ClientBranch.id.in_(branch_ids),
)
).scalars().all()
}
registrations: dict[int, ClientRegistration] = {}
registration_types: dict[int, RegistrationType] = {}
if registration_ids:
for registration, registration_type in db.execute(
select(ClientRegistration, RegistrationType)
.join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id)
.where(
ClientRegistration.tenant_id == tenant_id,
ClientRegistration.id.in_(registration_ids),
)
).all():
registrations[registration.id] = registration
registration_types[registration.id] = registration_type
extra_business_ids = {
int(row.business_unit_id) for row in registrations.values()
if row.business_unit_id and row.business_unit_id not in businesses
}
if extra_business_ids:
businesses.update({
row.id: row for row in db.execute(
select(ClientBusinessUnit).where(
ClientBusinessUnit.tenant_id == tenant_id,
ClientBusinessUnit.id.in_(extra_business_ids),
)
).scalars().all()
})
extra_branch_ids = {
int(row.client_branch_id) for row in registrations.values()
if row.client_branch_id and row.client_branch_id not in branches
}
if extra_branch_ids:
branches.update({
row.id: row for row in db.execute(
select(ClientBranch).where(
ClientBranch.tenant_id == tenant_id,
ClientBranch.id.in_(extra_branch_ids),
)
).scalars().all()
})
result: dict[int, dict[str, str]] = {}
for subscription in subscriptions:
client = getattr(subscription, "client", None)
client_name = (getattr(client, "client_name", None) or "").strip()
client_trade_name = (getattr(client, "trade_name", None) or "").strip()
primary = client_trade_name or client_name or "Unlinked Client"
secondary_client = ""
scope_context = ""
scope_type = (getattr(subscription, "scope_type", None) or "client").strip().lower()
if scope_type == "registration":
registration = registrations.get(getattr(subscription, "registration_id", None))
registration_type = registration_types.get(getattr(subscription, "registration_id", None))
if registration:
business = businesses.get(getattr(registration, "business_unit_id", None) or getattr(subscription, "business_unit_id", None))
branch = branches.get(getattr(registration, "client_branch_id", None) or getattr(subscription, "client_branch_id", None))
business_trade = ((getattr(business, "trade_name", None) or "").strip() if business else "")
business_name = ((getattr(business, "business_name", None) or "").strip() if business else "")
branch_name = ((getattr(branch, "branch_name", None) or "").strip() if branch else "")
primary = (
(getattr(registration, "trade_name", None) or "").strip()
or business_trade or business_name or branch_name
or (getattr(registration, "legal_name", None) or "").strip()
or client_trade_name or client_name or "Unlinked Client"
)
reg_code = ((getattr(registration_type, "code", None) or "").strip() if registration_type else "")
reg_no = (getattr(registration, "registration_number", None) or "").strip()
scope_context = " · ".join(part for part in [reg_code, reg_no] if part)
elif scope_type == "business_unit":
business = businesses.get(getattr(subscription, "business_unit_id", None))
if business:
primary = ((getattr(business, "trade_name", None) or "").strip()
or (getattr(business, "business_name", None) or "").strip()
or primary)
scope_context = (getattr(business, "business_code", None) or "").strip()
elif scope_type == "client_branch":
branch = branches.get(getattr(subscription, "client_branch_id", None))
business = businesses.get(getattr(branch, "business_unit_id", None) if branch else getattr(subscription, "business_unit_id", None))
business_display = ""
if business:
business_display = ((getattr(business, "trade_name", None) or "").strip()
or (getattr(business, "business_name", None) or "").strip())
if branch:
primary = (getattr(branch, "branch_name", None) or "").strip() or business_display or primary
scope_context = (getattr(branch, "branch_code", None) or "").strip()
elif business_display:
primary = business_display
if client_name and _normalise_name(client_name) != _normalise_name(primary):
secondary_client = client_name
result[int(subscription.id)] = {
"primary": primary,
"client_name": client_name,
"secondary_client": secondary_client,
"scope_context": scope_context,
}
return result
def _due_bucket_matches(due: date | None, bucket: str, today: date) -> bool:
bucket = (bucket or "").strip().lower()
if not bucket or bucket == "all":
return True
if bucket == "none":
return due is None
if due is None:
return False
delta = (due - today).days
if bucket == "overdue":
return delta < 0
if bucket == "today":
return delta == 0
if bucket == "next_7":
return 0 <= delta <= 7
if bucket == "next_15":
return 0 <= delta <= 15
if bucket == "this_month":
return due.year == today.year and due.month == today.month
return True
def _due_status(due: date | None, today: date) -> tuple[str, str]:
if due is None:
return "none", "No due date"
delta = (due - today).days
if delta < 0:
days = abs(delta)
return "overdue", f"Overdue by {days} day{'s' if days != 1 else ''}"
if delta == 0:
return "today", "Due today"
if delta <= 7:
return "soon", f"Due in {delta} day{'s' if delta != 1 else ''}"
return "future", f"Due in {delta} days"
def build_partner_payload(
db: Session,
request: Request,
current_user,
*,
q: str = "",
financial_year_filter: str | None = None,
period: str = "",
service_id: int | None = None,
due_bucket: str = "",
assignee_id: int | None = None,
priority: str = "",
) -> dict[str, Any]:
roles = set(get_user_roles(db, current_user.id))
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
active_financial_year = _active_financial_year(request)
selected_fy = (financial_year_filter or "").strip()
if not selected_fy:
selected_fy = active_financial_year or ""
financial_year = None if selected_fy.lower() == "all" else (selected_fy or None)
today = date.today()
# Filter choices are drawn from the same partner/tenant/branch scope, without
# narrowing by FY so the board can move between years without changing the
# global application FY context.
option_stmt = select(ClientServiceSubscription).options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
)
option_stmt = _subscription_scope_filter(option_stmt, tenant_id, branch_id, current_user, roles, financial_year=None)
option_engagements = db.execute(option_stmt).scalars().all()
financial_year_options = sorted({(row.financial_year or "").strip() for row in option_engagements if (row.financial_year or "").strip()}, reverse=True)
period_options = sorted({(row.period_label or "").strip() for row in option_engagements if (row.period_label or "").strip()})
service_map = {}
for row in option_engagements:
catalogue = getattr(row, "catalogue", None)
if catalogue:
service_map[int(catalogue.id)] = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) or f"Service #{catalogue.id}"
service_options = [{"id": key, "name": service_map[key]} for key in sorted(service_map, key=lambda x: str(service_map[x]).casefold())]
task_stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
)
)
task_stmt = _task_scope_filter(task_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
if period.strip():
task_stmt = task_stmt.where(ClientServiceTaskInstance.subscription.has(ClientServiceSubscription.period_label == period.strip()))
if service_id:
task_stmt = task_stmt.where(ClientServiceTaskInstance.service_catalogue_id == int(service_id))
if assignee_id:
task_stmt = task_stmt.where(ClientServiceTaskInstance.assigned_to_user_id == int(assignee_id))
if priority.strip():
task_stmt = task_stmt.where(ClientServiceTaskInstance.priority == priority.strip())
tasks = db.execute(
task_stmt.order_by(
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.priority.desc(),
ClientServiceTaskInstance.id.desc(),
)
).scalars().all()
subscriptions_by_id = {
int(task.subscription_id): task.subscription
for task in tasks
if getattr(task, "subscription_id", None) and getattr(task, "subscription", None)
}
scope_display_map = _partner_scope_display_map(db, tenant_id=tenant_id, subscriptions=list(subscriptions_by_id.values()))
# Team choices are limited to people actually present in the current scoped
# result set before search/due-date filtering.
team_map: dict[int, str] = {}
for task in tasks:
assignee = getattr(task, "assigned_to", None)
if assignee and getattr(assignee, "id", None):
team_map[int(assignee.id)] = getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or f"User #{assignee.id}"
team_options = [{"id": key, "name": team_map[key]} for key in sorted(team_map, key=lambda x: str(team_map[x]).casefold())]
filtered_tasks: list[ClientServiceTaskInstance] = []
q_norm = _normalise_name(q)
for task in tasks:
_decorate_task(task, today)
subscription = getattr(task, "subscription", None)
display = scope_display_map.get(int(task.subscription_id), {}) if getattr(task, "subscription_id", None) else {}
task.business_display = display.get("primary") or task.client_display
task.legal_client_display = display.get("secondary_client") or ""
task.scope_context_display = display.get("scope_context") or ""
task.period_label_display = (getattr(subscription, "period_label", None) or "").strip() or "Annual / Full year"
task.financial_year_display = (getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) or "").strip()
task.engagement_due_date = getattr(subscription, "current_due_date", None)
task.engagement_original_due_date = getattr(subscription, "original_due_date", None)
task.engagement_start_date = getattr(subscription, "start_date", None)
task.engagement_end_date = getattr(subscription, "end_date", None)
task.due_status_code, task.due_status_label = _due_status(task.engagement_due_date, today)
if not _due_bucket_matches(task.engagement_due_date, due_bucket, today):
continue
if q_norm:
searchable = " ".join([
task.business_display or "",
task.legal_client_display or "",
task.client_display or "",
getattr(task, "task_name", None) or "",
getattr(task, "description", None) or "",
task.engagement_label or "",
task.period_label_display or "",
task.scope_context_display or "",
])
if q_norm not in _normalise_name(searchable):
continue
filtered_tasks.append(task)
tasks = filtered_tasks
columns = [
{"code": "pending_review", "label": "Pending Review", "hint": "Completed tasks waiting for partner review", "tasks": []},
{"code": "clarification_required", "label": "Clarification Required", "hint": "Blocked tasks needing partner attention", "tasks": []},
{"code": "rework_sent", "label": "Rework Sent", "hint": "Pending/reopened after review notes", "tasks": []},
{"code": "approved", "label": "In Progress", "hint": "Work currently moving with the team", "tasks": []},
{"code": "completed", "label": "Completed", "hint": "Closed tasks", "tasks": []},
]
lookup = {c["code"]: c for c in columns}
summary = {"total": len(tasks), "pending_review": 0, "blocked": 0, "overdue": 0, "due_today": 0, "completed": 0, "clients": set(), "engagements": set()}
for task in tasks:
status = (task.status or "pending").lower()
if getattr(task, "client_id", None):
summary["clients"].add(task.client_id)
if getattr(task, "subscription_id", None):
summary["engagements"].add(task.subscription_id)
bucket = _task_bucket(task, current_user)
if bucket == "pending_review":
summary["pending_review"] += 1
if status == "blocked":
summary["blocked"] += 1
if task.due_status_code == "overdue":
summary["overdue"] += 1
if task.due_status_code == "today":
summary["due_today"] += 1
if status in CLOSED_TASK_STATUSES:
summary["completed"] += 1
lookup[bucket]["tasks"].append(task)
summary["clients"] = len(summary["clients"])
summary["engagements"] = len(summary["engagements"])
for col in columns:
col["count"] = len(col["tasks"])
engagement_stmt = (
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
)
)
engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
engagements = db.execute(
engagement_stmt.order_by(ClientServiceSubscription.current_due_date.is_(None), ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.desc()).limit(25)
).scalars().all()
task_counts_by_subscription: dict[int, dict[str, int]] = defaultdict(lambda: {"total": 0, "completed": 0, "open": 0})
for task in tasks:
bucket = task_counts_by_subscription[int(task.subscription_id)]
bucket["total"] += 1
if (task.status or "").lower() in CLOSED_TASK_STATUSES:
bucket["completed"] += 1
else:
bucket["open"] += 1
for engagement in engagements:
engagement.display_label = _engagement_label(engagement)
engagement.client_display = getattr(getattr(engagement, "client", None), "client_name", None) or "Unlinked Client"
engagement.task_counts = task_counts_by_subscription.get(int(engagement.id), {"total": 0, "completed": 0, "open": 0})
due = getattr(engagement, "current_due_date", None)
engagement.is_overdue = bool(due and due < today and (engagement.status or "").lower() not in {"completed", "closed", "locked"})
return {
"summary": summary,
"columns": columns,
"engagements": engagements,
"q": q,
"today": today,
"financial_year": financial_year,
"active_financial_year": active_financial_year,
"filters": {
"financial_year": selected_fy or "all",
"period": period or "",
"service_id": int(service_id) if service_id else None,
"due_bucket": due_bucket or "",
"assignee_id": int(assignee_id) if assignee_id else None,
"priority": priority or "",
},
"filter_options": {
"financial_years": financial_year_options,
"periods": period_options,
"services": service_options,
"teams": team_options,
},
}
def _get_partner_task_or_redirect(db: Session, request: Request, current_user, task_id: int) -> ClientServiceTaskInstance | None:
roles = set(get_user_roles(db, current_user.id))
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
financial_year = _active_financial_year(request)
stmt = select(ClientServiceTaskInstance).options(selectinload(ClientServiceTaskInstance.subscription)).where(ClientServiceTaskInstance.id == int(task_id))
stmt = _task_scope_filter(stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
return db.execute(stmt).scalar_one_or_none()
@router.get("/dashboard")
def partner_dashboard(request: Request, q: str = ""):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return _redirect_login()
if not _is_partner_user(db, current_user):
return _redirect_denied()
payload = build_partner_payload(db, request, current_user, q=q)
return _render(request, "modules/partners/templates/partners/dashboard.html", db, current_user, title="Partner Workspace", payload=payload, q=q, errors=[])
finally:
db.close()
@router.get("/reviews")
def partner_review_board(
request: Request,
q: str = "",
fy: str = "",
period: str = "",
service_id: str = "",
due: str = "",
assignee_id: str = "",
priority: str = "",
page_size: int = 10,
):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return _redirect_login()
if not _is_partner_user(db, current_user):
return _redirect_denied()
page_size = page_size if page_size in {10, 25, 50} else 10
service_id_value = int(service_id) if str(service_id).strip().isdigit() else None
assignee_id_value = int(assignee_id) if str(assignee_id).strip().isdigit() else None
payload = build_partner_payload(
db, request, current_user, q=q, financial_year_filter=fy, period=period,
service_id=service_id_value, due_bucket=due, assignee_id=assignee_id_value, priority=priority,
)
preserved = {
"q": q.strip(),
"fy": payload["filters"]["financial_year"],
"period": period.strip(),
"service_id": service_id_value or "",
"due": due.strip(),
"assignee_id": assignee_id_value or "",
"priority": priority.strip(),
"page_size": page_size,
}
filter_query = urlencode({k: v for k, v in preserved.items() if v not in (None, "")})
return _render(
request, "modules/partners/templates/partners/review_board.html", db, current_user,
title="Partner Review Board", payload=payload, q=q, page_size=page_size,
filter_query=filter_query, errors=[]
)
finally:
db.close()
@router.get("/clients")
def partner_clients(request: Request, q: str = ""):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return _redirect_login()
if not _is_partner_user(db, current_user):
return _redirect_denied()
payload = build_partner_payload(db, request, current_user, q=q)
return _render(request, "modules/partners/templates/partners/clients.html", db, current_user, title="My Client Portfolio", payload=payload, q=q, errors=[])
finally:
db.close()
@router.get("/engagements/{engagement_id}")
def partner_engagement_detail(request: Request, engagement_id: int):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return _redirect_login()
if not _is_partner_user(db, current_user):
return _redirect_denied()
roles = set(get_user_roles(db, current_user.id))
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
financial_year = _active_financial_year(request)
engagement_stmt = select(ClientServiceSubscription).options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
).where(ClientServiceSubscription.id == int(engagement_id))
engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
engagement = db.execute(engagement_stmt).scalar_one_or_none()
if not engagement:
return _redirect_denied()
task_stmt = select(ClientServiceTaskInstance).options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
).where(ClientServiceTaskInstance.subscription_id == engagement.id, ClientServiceTaskInstance.is_active.is_(True))
tasks = db.execute(task_stmt.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())).scalars().all()
today = date.today()
for task in tasks:
_decorate_task(task, today)
documents = db.execute(
select(EngagementDocument)
.where(EngagementDocument.engagement_id == engagement.id, EngagementDocument.is_deleted.is_(False))
.order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc())
).scalars().all()
engagement.display_label = _engagement_label(engagement)
return _render(request, "modules/partners/templates/partners/engagement_detail.html", db, current_user, title="Partner Engagement Review", engagement=engagement, tasks=tasks, documents=documents, errors=[], financial_year=financial_year)
finally:
db.close()
@router.post("/tasks/{task_id}/review")
def partner_review_task(request: Request, task_id: int, action: str = Form(...), message: str = Form(""), csrf_token: str = Form(...)):
db = CommonSessionLocal()
try:
validate_csrf(request, csrf_token)
current_user = get_current_user(request, db=db)
if not current_user:
return _redirect_login()
if not _is_partner_user(db, current_user):
return _redirect_denied()
task = _get_partner_task_or_redirect(db, request, current_user, task_id)
if not task:
return _redirect_denied()
new_status, comment_type, visibility = REVIEW_ACTIONS.get(action, REVIEW_ACTIONS["approve"])
task.status = new_status
task.updated_by_user_id = current_user.id
task.updated_at_utc = datetime.now(timezone.utc)
note = (message or "").strip()
if not note:
note = {
"approve": "Approved by partner.",
"send_rework": "Sent back for rework by partner.",
"clarification": "Clarification requested by partner.",
}.get(action, "Partner review updated.")
db.add(ServiceTaskComment(
tenant_id=task.tenant_id,
branch_id=task.branch_id,
subscription_id=task.subscription_id,
task_instance_id=task.id,
comment_type=comment_type,
visibility=visibility,
message=note,
created_by_user_id=current_user.id,
))
db.commit()
return RedirectResponse(url=f"/partner/engagements/{task.subscription_id}", status_code=303)
finally:
db.close()