790 lines
37 KiB
Python
790 lines
37 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,
|
|
synchronise_approved_engagement_acceptance_tasks,
|
|
)
|
|
from app.modules.services.engagement_resources import build_engagement_resource_context
|
|
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()
|
|
|
|
# Keep the Review Board in step with the engagement-level AQMM workflow.
|
|
# Older generated task rows can retain their original AQMM gate flags even
|
|
# after acceptance is approved; reconcile only those duplicated setup rows
|
|
# before task buckets/counts are built.
|
|
repaired_setup_rows = 0
|
|
for subscription in option_engagements:
|
|
repaired_setup_rows += synchronise_approved_engagement_acceptance_tasks(
|
|
db,
|
|
subscription=subscription,
|
|
actor_user_id=int(current_user.id),
|
|
)
|
|
if repaired_setup_rows:
|
|
db.commit()
|
|
|
|
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()
|
|
|
|
repaired_setup_rows = synchronise_approved_engagement_acceptance_tasks(
|
|
db,
|
|
subscription=engagement,
|
|
actor_user_id=int(current_user.id),
|
|
)
|
|
if repaired_setup_rows:
|
|
db.commit()
|
|
|
|
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)
|
|
.options(selectinload(EngagementDocument.versions))
|
|
.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)
|
|
resources = build_engagement_resource_context(db, engagement, tasks, documents)
|
|
return _render(request, "modules/partners/templates/partners/engagement_detail.html", db, current_user, title="Partner Engagement Review", engagement=engagement, tasks=tasks, documents=documents, resources=resources, 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()
|