Improve partner review board filters business display due dates and pagination

This commit is contained in:
A R R R Associates
2026-08-21 13:42:19 +05:30
parent 92f06f333a
commit 208808da6a
2 changed files with 490 additions and 176 deletions
+344 -21
View File
@@ -3,6 +3,7 @@ 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
@@ -15,7 +16,8 @@ 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
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,
@@ -189,17 +191,237 @@ def _task_bucket(task: ClientServiceTaskInstance, current_user) -> str:
return "approved"
def build_partner_payload(db: Session, request: Request, current_user, *, q: str = "") -> dict[str, Any]:
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)
financial_year = _active_financial_year(request)
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),
@@ -209,16 +431,15 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str
)
)
task_stmt = _task_scope_filter(task_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
if q.strip():
like = f"%{q.strip()}%"
task_stmt = task_stmt.where(
or_(
ClientServiceTaskInstance.task_name.ilike(like),
ClientServiceTaskInstance.description.ilike(like),
ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))),
ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))),
)
)
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),
@@ -228,6 +449,57 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str
)
).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": []},
@@ -239,7 +511,6 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str
summary = {"total": len(tasks), "pending_review": 0, "blocked": 0, "overdue": 0, "due_today": 0, "completed": 0, "clients": set(), "engagements": set()}
for task in tasks:
_decorate_task(task, today)
status = (task.status or "pending").lower()
if getattr(task, "client_id", None):
summary["clients"].add(task.client_id)
@@ -250,9 +521,9 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str
summary["pending_review"] += 1
if status == "blocked":
summary["blocked"] += 1
if getattr(task, "is_overdue", False):
if task.due_status_code == "overdue":
summary["overdue"] += 1
if getattr(task, "is_due_today", False):
if task.due_status_code == "today":
summary["due_today"] += 1
if status in CLOSED_TASK_STATUSES:
summary["completed"] += 1
@@ -293,8 +564,29 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str
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}
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))
@@ -321,7 +613,17 @@ def partner_dashboard(request: Request, q: str = ""):
@router.get("/reviews")
def partner_review_board(request: Request, q: str = ""):
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)
@@ -329,8 +631,29 @@ def partner_review_board(request: Request, q: str = ""):
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/review_board.html", db, current_user, title="Partner Review Board", payload=payload, q=q, errors=[])
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()