diff --git a/app/modules/partner_dashboard/service.py b/app/modules/partner_dashboard/service.py index a3c91db..8f81824 100644 --- a/app/modules/partner_dashboard/service.py +++ b/app/modules/partner_dashboard/service.py @@ -7,6 +7,7 @@ from typing import Any from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, selectinload +from app.modules.clients.association_models import ClientAssociation from app.modules.clients.models import Client from app.modules.alerts.workflow_escalations import list_workflow_escalations from app.modules.core.iam.models import User @@ -89,6 +90,41 @@ def _financial_year(request) -> str | None: return value or None +def _is_scoped_partner(roles: set[str]) -> bool: + """Return True only for a normal Partner login. + + System Admin and Firm Admin retain their existing administrative scope. A + normal Partner is always ownership-scoped, even when a branch is selected. + """ + return "Partner" in roles and "System Admin" not in roles and "Firm Admin" not in roles + + +def _partner_client_access_expression(*, partner_user_id: int, tenant_id: int | None): + association_access = exists( + select(ClientAssociation.id).where( + ClientAssociation.client_id == Client.id, + ClientAssociation.partner_user_id == int(partner_user_id), + or_( + ClientAssociation.firm_tenant_id == tenant_id, + ClientAssociation.firm_tenant_id.is_(None), + ) if tenant_id is not None else ClientAssociation.id.is_not(None), + ) + ) + review_access = exists( + select(ClientServiceSubscription.id).where( + ClientServiceSubscription.client_id == Client.id, + ClientServiceSubscription.review_partner_user_id == int(partner_user_id), + ClientServiceSubscription.is_active.is_(True), + ClientServiceSubscription.tenant_id == tenant_id if tenant_id is not None else ClientServiceSubscription.id.is_not(None), + ) + ) + return or_( + Client.partner_id == int(partner_user_id), + association_access, + review_access, + ) + + 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) @@ -97,7 +133,7 @@ def _task_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user 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: + if _is_scoped_partner(roles): stmt = stmt.where( ClientServiceTaskInstance.subscription.has( or_( @@ -117,7 +153,7 @@ def _subscription_scope(stmt, tenant_id: int | None, branch_id: int | None, curr 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: + if _is_scoped_partner(roles): stmt = stmt.where( or_( ClientServiceSubscription.assigned_partner_user_id == current_user.id, @@ -133,11 +169,11 @@ def _client_scope(stmt, tenant_id: int | None, branch_id: int | None, current_us 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: + if _is_scoped_partner(roles): stmt = stmt.where( - or_( - Client.partner_id == current_user.id, - Client.default_review_partner_user_id == current_user.id, + _partner_client_access_expression( + partner_user_id=int(current_user.id), + tenant_id=tenant_id, ) ) return stmt @@ -213,7 +249,7 @@ def _load_clients(db: Session, tenant_id: int | None, branch_id: int | None, cur "service_count": service_count, "task_count": task_count, "overdue_count": overdue_count, - "href": f"/clients/{client.id}/edit", + "href": f"/clients/{client.id}", }) return out @@ -258,7 +294,14 @@ def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks return rows[:25] -def _billing_rows(db: Session, tenant_id: int | None, branch_id: int | None, fy: str | None) -> dict[str, Any]: +def _billing_rows( + db: Session, + tenant_id: int | None, + branch_id: int | None, + fy: str | None, + current_user, + roles: set[str], +) -> 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) @@ -266,6 +309,15 @@ def _billing_rows(db: Session, tenant_id: int | None, branch_id: int | None, fy: stmt = stmt.where(BillingInvoice.branch_id == branch_id) if fy: stmt = stmt.where(BillingInvoice.financial_year == fy) + if _is_scoped_partner(roles): + allowed_client_ids = select(Client.id).where( + Client.tenant_id == tenant_id, + _partner_client_access_expression( + partner_user_id=int(current_user.id), + tenant_id=tenant_id, + ), + ) + stmt = stmt.where(BillingInvoice.client_id.in_(allowed_client_ids)) 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 { @@ -297,7 +349,7 @@ def build_partner_dashboard_payload(db: Session, request, current_user) -> dict[ 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) + billing = _billing_rows(db, tenant_id, branch_id, fy, current_user, roles) unified_escalations = list_workflow_escalations(db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=current_user.id) subscriptions_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles, fy)) if tenant_id else 0 diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/clients.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/clients.html index 73b0f57..e55fb44 100644 --- a/app/modules/partner_dashboard/templates/partner_dashboard/partials/clients.html +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/clients.html @@ -1,11 +1,11 @@
-

Branch Clients

Client health by service count, open tasks and overdue items.

Manage Clients
+

Assigned Clients

Owned clients and review-assigned clients, with service count, open tasks and overdue items.

Manage Clients
{% for c in clients %}

{{ c.client_name }}

{{ c.client_code }}{% if c.gstin %} · {{ c.gstin }}{% elif c.pan %} · {{ c.pan }}{% endif %}

{% if c.overdue_count > 0 %}{{ c.overdue_count }} overdue{% endif %}
{{ c.service_count }}
Services
{{ c.task_count }}
Tasks
{{ c.overdue_count }}
Overdue
- {% else %}
No branch clients found.
{% endfor %} + {% else %}
No assigned or review clients found.
{% endfor %}