Enforce partner ownership on partner dashboard
This commit is contained in:
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.modules.clients.association_models import ClientAssociation
|
||||||
from app.modules.clients.models import Client
|
from app.modules.clients.models import Client
|
||||||
from app.modules.alerts.workflow_escalations import list_workflow_escalations
|
from app.modules.alerts.workflow_escalations import list_workflow_escalations
|
||||||
from app.modules.core.iam.models import User
|
from app.modules.core.iam.models import User
|
||||||
@@ -89,6 +90,41 @@ def _financial_year(request) -> str | None:
|
|||||||
return value or 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):
|
def _task_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None = None):
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
stmt = stmt.where(ClientServiceTaskInstance.tenant_id == 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)
|
stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id)
|
||||||
if fy:
|
if fy:
|
||||||
stmt = stmt.where(ClientServiceTaskInstance.financial_year == 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(
|
stmt = stmt.where(
|
||||||
ClientServiceTaskInstance.subscription.has(
|
ClientServiceTaskInstance.subscription.has(
|
||||||
or_(
|
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)
|
stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id)
|
||||||
if fy:
|
if fy:
|
||||||
stmt = stmt.where(ClientServiceSubscription.financial_year == 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(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
|
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))
|
stmt = stmt.where(Client.is_active.is_(True))
|
||||||
if branch_id is not None:
|
if branch_id is not None:
|
||||||
stmt = stmt.where(Client.branch_id == branch_id)
|
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(
|
stmt = stmt.where(
|
||||||
or_(
|
_partner_client_access_expression(
|
||||||
Client.partner_id == current_user.id,
|
partner_user_id=int(current_user.id),
|
||||||
Client.default_review_partner_user_id == current_user.id,
|
tenant_id=tenant_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return stmt
|
return stmt
|
||||||
@@ -213,7 +249,7 @@ def _load_clients(db: Session, tenant_id: int | None, branch_id: int | None, cur
|
|||||||
"service_count": service_count,
|
"service_count": service_count,
|
||||||
"task_count": task_count,
|
"task_count": task_count,
|
||||||
"overdue_count": overdue_count,
|
"overdue_count": overdue_count,
|
||||||
"href": f"/clients/{client.id}/edit",
|
"href": f"/clients/{client.id}",
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -258,7 +294,14 @@ def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks
|
|||||||
return rows[:25]
|
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:
|
if BillingInvoice is None or not tenant_id:
|
||||||
return {"available": False, "invoices": [], "invoice_count": 0, "draft_count": 0, "outstanding": Decimal("0")}
|
return {"available": False, "invoices": [], "invoice_count": 0, "draft_count": 0, "outstanding": Decimal("0")}
|
||||||
stmt = select(BillingInvoice).where(BillingInvoice.tenant_id == tenant_id)
|
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)
|
stmt = stmt.where(BillingInvoice.branch_id == branch_id)
|
||||||
if fy:
|
if fy:
|
||||||
stmt = stmt.where(BillingInvoice.financial_year == 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())
|
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"))
|
outstanding = sum((_money(getattr(inv, "balance_amount", 0)) for inv in invoices), Decimal("0"))
|
||||||
return {
|
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)
|
clients = _load_clients(db, tenant_id, branch_id, current_user, roles)
|
||||||
staff = _staff_rows(db, tenant_id, branch_id, tasks)
|
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)
|
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
|
subscriptions_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles, fy)) if tenant_id else 0
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
<div class="flex items-center justify-between"><div><h2 class="text-lg font-semibold text-slate-900">Branch Clients</h2><p class="text-sm text-slate-500">Client health by service count, open tasks and overdue items.</p></div><a href="/clients" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Manage Clients</a></div>
|
<div class="flex items-center justify-between"><div><h2 class="text-lg font-semibold text-slate-900">Assigned Clients</h2><p class="text-sm text-slate-500">Owned clients and review-assigned clients, with service count, open tasks and overdue items.</p></div><a href="/clients" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Manage Clients</a></div>
|
||||||
<div class="mt-5 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
<div class="mt-5 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
{% for c in clients %}
|
{% for c in clients %}
|
||||||
<a href="{{ c.href }}" class="rounded-3xl border border-slate-200 p-5 hover:bg-slate-50">
|
<a href="{{ c.href }}" class="rounded-3xl border border-slate-200 p-5 hover:bg-slate-50">
|
||||||
<div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ c.client_name }}</h3><p class="mt-1 text-xs text-slate-500">{{ c.client_code }}{% if c.gstin %} · {{ c.gstin }}{% elif c.pan %} · {{ c.pan }}{% endif %}</p></div>{% if c.overdue_count > 0 %}<span class="rounded-full bg-red-100 px-2 py-1 text-xs font-semibold text-red-700">{{ c.overdue_count }} overdue</span>{% endif %}</div>
|
<div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ c.client_name }}</h3><p class="mt-1 text-xs text-slate-500">{{ c.client_code }}{% if c.gstin %} · {{ c.gstin }}{% elif c.pan %} · {{ c.pan }}{% endif %}</p></div>{% if c.overdue_count > 0 %}<span class="rounded-full bg-red-100 px-2 py-1 text-xs font-semibold text-red-700">{{ c.overdue_count }} overdue</span>{% endif %}</div>
|
||||||
<div class="mt-4 grid grid-cols-3 gap-2 text-center text-xs"><div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ c.service_count }}</div><div class="text-slate-500">Services</div></div><div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ c.task_count }}</div><div class="text-slate-500">Tasks</div></div><div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ c.overdue_count }}</div><div class="text-slate-500">Overdue</div></div></div>
|
<div class="mt-4 grid grid-cols-3 gap-2 text-center text-xs"><div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ c.service_count }}</div><div class="text-slate-500">Services</div></div><div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ c.task_count }}</div><div class="text-slate-500">Tasks</div></div><div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ c.overdue_count }}</div><div class="text-slate-500">Overdue</div></div></div>
|
||||||
</a>
|
</a>
|
||||||
{% else %}<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 xl:col-span-3">No branch clients found.</div>{% endfor %}
|
{% else %}<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 xl:col-span-3">No assigned or review clients found.</div>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user