Add partner operations dashboard v1

This commit is contained in:
A R R R Associates
2026-07-04 19:53:01 +05:30
parent 6e7355ffbf
commit 830c62c33d
13 changed files with 606 additions and 1 deletions
+349
View File
@@ -0,0 +1,349 @@
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.modules.clients.models import Client
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.employees.models import Employee
from app.modules.services.execution import CLOSED_TASK_STATUSES
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue
try:
from app.modules.billing.models import BillingInvoice
except Exception: # pragma: no cover
BillingInvoice = None
PARTNER_ROLES = {"Partner", "System Admin"}
def _count(db: Session, stmt) -> int:
return int(db.execute(stmt).scalar() or 0)
def _money(value: Any) -> Decimal:
return Decimal(str(value or "0"))
def get_user_role_names(db: Session, user_id: int) -> list[str]:
return list(
db.execute(
select(Role.name)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == int(user_id), Role.is_active.is_(True))
.order_by(Role.name.asc())
).scalars().all()
)
def can_access_partner_dashboard(db: Session, current_user) -> bool:
roles = set(get_user_role_names(db, current_user.id))
return bool(roles.intersection(PARTNER_ROLES))
def _active_scope(db: Session, request, current_user, roles: set[str]) -> dict[str, Any]:
tenant_id = request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None)
branch_id = request.session.get("active_branch_id") or getattr(current_user, "branch_id", None)
if "System Admin" not in roles:
tenant_id = getattr(current_user, "tenant_id", None)
# Partner operations are branch-centric. For normal partner users, do not allow
# accidental all-branch view unless the branch context is genuinely missing.
if "System Admin" not in roles:
branch_id = getattr(current_user, "branch_id", None) or branch_id
if branch_id in (None, "", 0, "0"):
branch_id = None
tenant = db.get(Tenant, int(tenant_id)) if tenant_id else None
branch = db.get(Branch, int(branch_id)) if branch_id else None
return {
"tenant_id": int(tenant_id) if tenant_id else None,
"branch_id": int(branch_id) if branch_id else None,
"tenant": tenant,
"branch": branch,
}
def _financial_year(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 _task_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None = None):
if tenant_id:
stmt = stmt.where(ClientServiceTaskInstance.tenant_id == tenant_id)
stmt = stmt.where(ClientServiceTaskInstance.is_active.is_(True))
if branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id)
if fy:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == fy)
if "Partner" in roles and "System Admin" not in roles and branch_id is None:
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 _subscription_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None = None):
if tenant_id:
stmt = stmt.where(ClientServiceSubscription.tenant_id == tenant_id)
stmt = stmt.where(ClientServiceSubscription.is_active.is_(True))
if branch_id is not None:
stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id)
if fy:
stmt = stmt.where(ClientServiceSubscription.financial_year == fy)
if "Partner" in roles and "System Admin" not in roles and branch_id is None:
stmt = stmt.where(
or_(
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
ClientServiceSubscription.review_partner_user_id == current_user.id,
)
)
return stmt
def _client_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str]):
if tenant_id:
stmt = stmt.where(Client.tenant_id == tenant_id)
stmt = stmt.where(Client.is_active.is_(True))
if branch_id is not None:
stmt = stmt.where(Client.branch_id == branch_id)
elif "Partner" in roles and "System Admin" not in roles:
stmt = stmt.where(
or_(
Client.partner_id == current_user.id,
Client.default_review_partner_user_id == current_user.id,
)
)
return stmt
def _is_open_status(status: str | None) -> bool:
return (status or "pending").strip().lower() not in CLOSED_TASK_STATUSES
def _task_label(task: ClientServiceTaskInstance) -> dict[str, Any]:
client = getattr(task, "client", None)
catalogue = getattr(task, "catalogue", None)
assignee = getattr(task, "assigned_to", None)
status = (task.status or "pending").replace("_", " ").title()
return {
"id": task.id,
"subscription_id": task.subscription_id,
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
"client_code": getattr(client, "client_code", None) or "",
"service_name": getattr(catalogue, "service_name", None) or "Service",
"task_name": task.task_name,
"period": task.financial_year or "-",
"due_date": task.internal_target_date,
"status": task.status or "pending",
"status_label": status,
"priority": task.priority or "normal",
"assigned_to": getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned",
"is_overdue": bool(task.internal_target_date and task.internal_target_date < date.today() and _is_open_status(task.status)),
"href": f"/work/engagements/{task.subscription_id}" if task.subscription_id else "/partner/reviews",
}
def _load_tasks(db: Session, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None) -> list[ClientServiceTaskInstance]:
stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
)
)
stmt = _task_scope(stmt, tenant_id, branch_id, current_user, roles, fy)
stmt = stmt.order_by(
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.priority.desc(),
ClientServiceTaskInstance.id.desc(),
).limit(300)
return list(db.execute(stmt).scalars().all())
def _load_clients(db: Session, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str]) -> list[dict[str, Any]]:
stmt = _client_scope(select(Client), tenant_id, branch_id, current_user, roles)
clients = db.execute(stmt.order_by(Client.client_name.asc()).limit(100)).scalars().all()
out: list[dict[str, Any]] = []
for client in clients:
task_count = _count(db, _task_scope(select(func.count(ClientServiceTaskInstance.id)), tenant_id, branch_id, current_user, roles).where(ClientServiceTaskInstance.client_id == client.id))
overdue_count = _count(
db,
_task_scope(select(func.count(ClientServiceTaskInstance.id)), tenant_id, branch_id, current_user, roles)
.where(ClientServiceTaskInstance.client_id == client.id, ClientServiceTaskInstance.internal_target_date < date.today(), ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES))),
)
service_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles).where(ClientServiceSubscription.client_id == client.id))
out.append({
"id": client.id,
"client_name": client.client_name,
"client_code": client.client_code,
"pan": client.pan,
"gstin": client.gstin,
"email": client.email,
"mobile": client.mobile,
"service_count": service_count,
"task_count": task_count,
"overdue_count": overdue_count,
"href": f"/clients/{client.id}/edit",
})
return out
def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks: list[ClientServiceTaskInstance]) -> list[dict[str, Any]]:
if not tenant_id:
return []
stmt = select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True))
if branch_id is not None:
stmt = stmt.where(User.branch_id == branch_id)
users = db.execute(stmt.order_by(User.full_name.asc(), User.email.asc()).limit(100)).scalars().all()
by_user: dict[int, dict[str, int]] = {}
for task in tasks:
uid = getattr(task, "assigned_to_user_id", None)
if not uid:
continue
bucket = by_user.setdefault(int(uid), {"active": 0, "overdue": 0, "review": 0, "client_pending": 0})
if _is_open_status(task.status):
bucket["active"] += 1
if task.internal_target_date and task.internal_target_date < date.today() and _is_open_status(task.status):
bucket["overdue"] += 1
if (task.status or "").lower() == "completed":
bucket["review"] += 1
if (task.status or "").lower() == "blocked":
bucket["client_pending"] += 1
rows: list[dict[str, Any]] = []
for user in users:
stats = by_user.get(int(user.id), {"active": 0, "overdue": 0, "review": 0, "client_pending": 0})
total = stats["active"] + stats["review"]
rows.append({
"id": user.id,
"name": user.full_name or user.email,
"email": user.email,
"designation": user.designation,
"active": stats["active"],
"overdue": stats["overdue"],
"review": stats["review"],
"client_pending": stats["client_pending"],
"load_status": "Heavy" if total >= 40 else ("Balanced" if total >= 10 else "Light"),
})
rows.sort(key=lambda r: (r["active"] + r["review"], r["overdue"]), reverse=True)
return rows[:25]
def _billing_rows(db: Session, tenant_id: int | None, branch_id: int | None, fy: str | None) -> dict[str, Any]:
if BillingInvoice is None or not tenant_id:
return {"available": False, "invoices": [], "invoice_count": 0, "draft_count": 0, "outstanding": Decimal("0")}
stmt = select(BillingInvoice).where(BillingInvoice.tenant_id == tenant_id)
if branch_id is not None:
stmt = stmt.where(BillingInvoice.branch_id == branch_id)
if fy:
stmt = stmt.where(BillingInvoice.financial_year == fy)
invoices = list(db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc()).limit(25)).scalars().all())
outstanding = sum((_money(getattr(inv, "balance_amount", 0)) for inv in invoices), Decimal("0"))
return {
"available": True,
"invoices": invoices,
"invoice_count": len(invoices),
"draft_count": len([i for i in invoices if (getattr(i, "status", "") or "").upper() == "DRAFT"]),
"outstanding": outstanding,
}
def build_partner_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
roles = set(get_user_role_names(db, current_user.id))
scope = _active_scope(db, request, current_user, roles)
tenant_id = scope["tenant_id"]
branch_id = scope["branch_id"]
fy = _financial_year(request)
today = date.today()
next_week = today + timedelta(days=7)
tasks = _load_tasks(db, tenant_id, branch_id, current_user, roles, fy)
task_rows = [_task_label(t) for t in tasks]
open_rows = [r for r in task_rows if r["status"] not in CLOSED_TASK_STATUSES]
overdue_rows = [r for r in open_rows if r["due_date"] and r["due_date"] < today]
due_today_rows = [r for r in open_rows if r["due_date"] == today]
due_week_rows = [r for r in open_rows if r["due_date"] and today <= r["due_date"] <= next_week]
client_pending_rows = [r for r in open_rows if r["status"] == "blocked"]
review_rows = [r for r in task_rows if r["status"] == "completed"]
clients = _load_clients(db, tenant_id, branch_id, current_user, roles)
staff = _staff_rows(db, tenant_id, branch_id, tasks)
billing = _billing_rows(db, tenant_id, branch_id, fy)
subscriptions_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles, fy)) if tenant_id else 0
overview = {
"tenant": scope["tenant"],
"branch": scope["branch"],
"tenant_id": tenant_id,
"branch_id": branch_id,
"financial_year": fy,
"client_count": len(clients),
"subscription_count": subscriptions_count,
"open_task_count": len(open_rows),
"overdue_count": len(overdue_rows),
"due_today_count": len(due_today_rows),
"due_week_count": len(due_week_rows),
"client_pending_count": len(client_pending_rows),
"review_pending_count": len(review_rows),
"staff_count": len(staff),
"invoice_count": billing["invoice_count"],
"outstanding": billing["outstanding"],
"today": today,
}
return {
"roles": sorted(roles),
"overview": overview,
"branch_work": task_rows[:80],
"due_today": due_today_rows[:25],
"due_week": due_week_rows[:25],
"overdue": overdue_rows[:25],
"client_pending": client_pending_rows[:25],
"review_queue": review_rows[:50],
"clients": clients,
"staff_rows": staff,
"billing": billing,
"reports": _report_cards(),
"wizards": _wizard_cards(),
}
def _report_cards() -> list[dict[str, str]]:
return [
{"group": "Work Reports", "title": "Branch Work Status", "desc": "Due today, due this week, overdue and blocked work for the active branch.", "href": "/partner/dashboard?tab=branch-work"},
{"group": "Work Reports", "title": "Overdue Task Report", "desc": "Tasks past internal target date requiring partner escalation.", "href": "/partner/dashboard?tab=branch-work"},
{"group": "Review Reports", "title": "Partner Review Queue", "desc": "Completed work waiting for partner review or final sign-off.", "href": "/partner/dashboard?tab=review"},
{"group": "Client Reports", "title": "Branch Client Health", "desc": "Clients, services, open tasks and overdue items in the branch.", "href": "/partner/dashboard?tab=clients"},
{"group": "Staff Reports", "title": "Staff Workload", "desc": "Branch team workload, overdue count and client pending count.", "href": "/partner/dashboard?tab=staff"},
{"group": "Billing Reports", "title": "Billing Control", "desc": "Invoices raised, drafts and outstanding amount for the active branch.", "href": "/partner/dashboard?tab=billing"},
]
def _wizard_cards() -> list[dict[str, str]]:
return [
{"title": "Add / Manage Clients", "desc": "Open client master for branch client creation and edits.", "href": "/clients"},
{"title": "Client Service Subscriptions", "desc": "Assign firm services to branch clients and monitor engagements.", "href": "/services/engagements"},
{"title": "Generate / Track Tasks", "desc": "Use work tracker for generated compliance and audit tasks.", "href": "/work"},
{"title": "Partner Review Board", "desc": "Review completed work and send rework or clarifications.", "href": "/partner/reviews"},
{"title": "Documents", "desc": "Open branch and engagement documents.", "href": "/documents"},
{"title": "Billing", "desc": "Raise invoices and track collection follow-up.", "href": "/billing"},
]