Improve partner review board filters business display due dates and pagination
This commit is contained in:
+344
-21
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user