Enforce partner client ownership and partner-scoped imports

This commit is contained in:
A R R R Associates
2026-07-29 06:05:10 +05:30
parent 83f4cd2d4f
commit a58ec7b807
9 changed files with 205 additions and 108 deletions
+3 -15
View File
@@ -1,29 +1,17 @@
"""Clients module.
"""Clients module with lazy router exports.
Routers are exposed lazily so importing ``app.modules.clients.models`` does
not initialise the clients API, UI, importer, and related modules. This keeps
the existing ``api_router`` and ``ui_router`` public exports while preventing
circular imports with client groups.
Importing client models must not initialise the complete UI/import stack.
"""
from __future__ import annotations
from typing import Any
__all__ = ["api_router", "ui_router"]
def __getattr__(name: str) -> Any:
if name == "api_router":
from .api import router
return router
if name == "ui_router":
from .ui import router
return router
raise AttributeError(
f"module {__name__!r} has no attribute {name!r}"
)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+16 -1
View File
@@ -47,6 +47,21 @@ def build_scope(request, user, permission_checker):
)
def is_partner_role(role_names) -> bool:
names = {str(name).strip().lower() for name in (role_names or [])}
return "partner" in names and "firm admin" not in names and "system admin" not in names
def enforce_partner_scope(scope: ClientAccessScope, *, user, role_names) -> ClientAccessScope:
if is_partner_role(role_names):
scope.allow_all_clients = False
scope.own_only = True
scope.locked_partner_id = int(user.id)
scope.can_assign_partner = False
return scope
def effective_partner_id(row: dict):
return row.get("assoc_partner_user_id") or row.get("partner_id")
@@ -71,6 +86,6 @@ def can_view_client_row(scope: ClientAccessScope, row: dict, *, user_id: int) ->
return False
if scope.own_only and scope.locked_partner_id and effective_partner_id(row) != scope.locked_partner_id:
return False
return bool(row.get("has_review_access"))
return True
+16 -13
View File
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from app.core.db.deps import get_common_db
from app.core.security.session_auth import require_login
from app.modules.clients.access import ClientAccessScope
from app.modules.clients.access import ClientAccessScope, enforce_partner_scope, is_partner_role
from app.modules.clients.schemas import ClientAuditLogOut, ClientFilterOptions, ClientListResponse, ClientOut, ClientUpdate, ClientCreate
from app.modules.clients.service import (
activate_client_service,
@@ -23,6 +23,7 @@ from app.modules.clients.service import (
update_client_service,
)
from app.modules.core.rbac.permission_guard import require_permission
from app.modules.core.rbac.deps import get_user_roles
router = APIRouter(prefix="/api/v1/clients", tags=["clients-api"])
@@ -34,17 +35,19 @@ def _api_scope_from_user(db, user):
except Exception:
return False
own_only = has("clients.view.own_only") or not has("clients.assign_partner")
return ClientAccessScope(
scope = ClientAccessScope(
tenant_id=user.tenant_id,
branch_id=user.branch_id,
allow_cross_branch=has("clients.cross_branch"),
allow_cross_tenant=has("clients.cross_tenant"),
allow_all_clients=has("clients.view.all") and not own_only,
own_only=own_only,
locked_partner_id=user.id if own_only else None,
can_assign_partner=has("clients.assign_partner"),
can_change_branch=has("clients.cross_branch"),
can_change_tenant=has("clients.cross_tenant"),
)
return enforce_partner_scope(scope, user=user, role_names=get_user_roles(db, user.id))
@router.get("/filters", response_model=ClientFilterOptions)
def api_client_filters():
@@ -73,7 +76,8 @@ def api_list_clients(
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
partner_id=partner_id,
partner_id=None if scope.own_only else partner_id,
viewer_partner_id=user.id if scope.own_only else None,
q=q,
status=status,
client_type=client_type,
@@ -105,7 +109,8 @@ def api_export_clients(
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
partner_id=partner_id,
partner_id=None if scope.own_only else partner_id,
viewer_partner_id=user.id if scope.own_only else None,
q=q,
status=status,
client_type=client_type,
@@ -122,9 +127,7 @@ def api_export_clients(
def api_get_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.view")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
if scope.own_only and row.partner_id != scope.locked_partner_id:
raise HTTPException(status_code=404, detail="Client not found.")
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=user.id if scope.own_only else None, allow_review_access=True)
return row
@router.post("", response_model=ClientOut, status_code=201)
@@ -137,7 +140,7 @@ def api_create_client(data: ClientCreate, db: Session = Depends(get_common_db),
def api_update_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.edit")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=user.id if scope.own_only else None)
if scope.own_only and row.partner_id != scope.locked_partner_id:
raise HTTPException(status_code=404, detail="Client not found.")
return update_client_service(db, row=row, data=data, actor_user_id=user.id, scope=scope)
@@ -146,33 +149,33 @@ def api_update_client(client_id: int, data: ClientUpdate, db: Session = Depends(
def api_deactivate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.deactivate")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=user.id if scope.own_only else None)
return deactivate_client_service(db, row=row, actor_user_id=user.id)
@router.post("/{client_id}/activate", response_model=ClientOut)
def api_activate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.activate")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=user.id if scope.own_only else None)
return activate_client_service(db, row=row, actor_user_id=user.id)
@router.post("/{client_id}/archive", response_model=ClientOut)
def api_archive_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.archive")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=user.id if scope.own_only else None)
return archive_client_service(db, row=row, actor_user_id=user.id)
@router.post("/{client_id}/restore", response_model=ClientOut)
def api_restore_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.restore")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=user.id if scope.own_only else None)
return restore_client_service(db, row=row, actor_user_id=user.id)
@router.get("/{client_id}/audit-logs", response_model=list[ClientAuditLogOut])
def api_client_audit_logs(client_id: int, limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.audit_log.view")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=user.id if scope.own_only else None)
return list_client_audit_logs(db, row=row, limit=limit)
+48 -8
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from math import ceil
from sqlalchemy import asc, case, desc, func, or_, select
from sqlalchemy import and_, asc, case, desc, exists, func, or_, select
from sqlalchemy.orm import Session
from app.core.security.passwords import hash_password
@@ -13,6 +13,7 @@ from app.modules.client_groups.models import ClientGroup
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.services.models import ClientServiceSubscription
def _safe_sort(sort_by: str, sort_order: str):
@@ -28,6 +29,7 @@ def build_clients_query(
allow_cross_branch: bool = False,
allow_all_clients: bool = False,
partner_id: int | None = None,
viewer_partner_id: int | None = None,
q: str = "",
status: str = "",
client_type: str = "",
@@ -35,6 +37,14 @@ def build_clients_query(
include_archived: bool = False,
):
assoc = ClientAssociation
review_access = exists(
select(ClientServiceSubscription.id).where(
ClientServiceSubscription.tenant_id == tenant_id,
ClientServiceSubscription.client_id == Client.id,
ClientServiceSubscription.review_partner_user_id == viewer_partner_id,
ClientServiceSubscription.is_active.is_(True),
)
) if viewer_partner_id is not None else None
stmt = (
select(
Client,
@@ -48,6 +58,7 @@ def build_clients_query(
assoc.consultant_id.label("assoc_consultant_id"),
assoc.partner_user_id.label("assoc_partner_user_id"),
assoc.created_source.label("assoc_created_source"),
(review_access if review_access is not None else False).label("has_review_access"),
)
.outerjoin(assoc, assoc.client_id == Client.id)
.outerjoin(ClientGroup, ClientGroup.id == Client.client_group_id)
@@ -61,7 +72,11 @@ def build_clients_query(
stmt = stmt.where(Client.is_archived.is_(False))
if branch_id and not allow_all_clients and not allow_cross_branch:
stmt = stmt.where(Client.branch_id == branch_id)
if partner_id:
if viewer_partner_id is not None:
stmt = stmt.where(
or_(Client.partner_id == viewer_partner_id, assoc.partner_user_id == viewer_partner_id, review_access)
)
elif partner_id:
stmt = stmt.where((Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id))
if status:
stmt = stmt.where(Client.status == status)
@@ -82,13 +97,13 @@ def build_clients_query(
def list_clients(
db: Session, *, tenant_id: int, branch_id: int | None = None,
allow_cross_branch: bool = False, allow_all_clients: bool = False,
partner_id: int | None = None, q: str = "", status: str = "", client_type: str = "",
partner_id: int | None = None, viewer_partner_id: int | None = None, q: str = "", status: str = "", client_type: str = "",
client_group_id: int | None = None, include_archived: bool = False,
page: int = 1, per_page: int = 25, sort_by: str = "client_name", sort_order: str = "asc",
) -> dict:
stmt = build_clients_query(
tenant_id=tenant_id, branch_id=branch_id, allow_cross_branch=allow_cross_branch,
allow_all_clients=allow_all_clients, partner_id=partner_id, q=q, status=status,
allow_all_clients=allow_all_clients, partner_id=partner_id, viewer_partner_id=viewer_partner_id, q=q, status=status,
client_type=client_type, client_group_id=client_group_id, include_archived=include_archived,
)
total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
@@ -100,7 +115,7 @@ def list_clients(
stmt.order_by(_safe_sort(sort_by, sort_order)).offset(offset).limit(per_page)
).all()
rows=[]
for client, partner_name, branch_name, tenant_name, client_group_name, client_group_code, association_type, assoc_firm_tenant_id, assoc_consultant_id, assoc_partner_user_id, assoc_created_source in result:
for client, partner_name, branch_name, tenant_name, client_group_name, client_group_code, association_type, assoc_firm_tenant_id, assoc_consultant_id, assoc_partner_user_id, assoc_created_source, has_review_access in result:
row={**client.__dict__}; row.pop("_sa_instance_state",None)
row.update({
"partner_name":partner_name,"branch_name":branch_name,"tenant_name":tenant_name,
@@ -108,17 +123,39 @@ def list_clients(
"association_type":association_type,"assoc_firm_tenant_id":assoc_firm_tenant_id,
"assoc_consultant_id":assoc_consultant_id,"assoc_partner_user_id":assoc_partner_user_id,
"assoc_created_source":assoc_created_source,"effective_partner_id":assoc_partner_user_id or row.get("partner_id"),
"has_review_access": bool(has_review_access),
}); rows.append(row)
stats_stmt=select(func.count(Client.id),func.sum(case((Client.status=="active",1),else_=0)),func.sum(case((Client.status=="inactive",1),else_=0)),func.sum(case((Client.status=="archived",1),else_=0)))
if not allow_all_clients:
stats_stmt=stats_stmt.where(Client.tenant_id==tenant_id)
if branch_id and not allow_cross_branch: stats_stmt=stats_stmt.where(Client.branch_id==branch_id)
if partner_id: stats_stmt=stats_stmt.where(Client.partner_id==partner_id)
if viewer_partner_id is not None:
review_stats = exists(select(ClientServiceSubscription.id).where(
ClientServiceSubscription.tenant_id == tenant_id,
ClientServiceSubscription.client_id == Client.id,
ClientServiceSubscription.review_partner_user_id == viewer_partner_id,
ClientServiceSubscription.is_active.is_(True),
))
stats_stmt = stats_stmt.where(or_(Client.partner_id == viewer_partner_id, review_stats))
elif partner_id:
stats_stmt=stats_stmt.where(Client.partner_id==partner_id)
total_all,active,inactive,archived=db.execute(stats_stmt).one()
return {"rows":rows,"meta":{"total":total,"page":page,"per_page":per_page,"pages":pages},"stats":{"total":int(total_all or 0),"active":int(active or 0),"inactive":int(inactive or 0),"archived":int(archived or 0)}}
def get_client_detail_payload(db: Session, client_id: int):
def has_partner_review_access(db: Session, *, tenant_id: int, client_id: int, partner_user_id: int) -> bool:
return bool(db.execute(
select(ClientServiceSubscription.id).where(
ClientServiceSubscription.tenant_id == tenant_id,
ClientServiceSubscription.client_id == client_id,
ClientServiceSubscription.review_partner_user_id == partner_user_id,
ClientServiceSubscription.is_active.is_(True),
).limit(1)
).scalar_one_or_none())
def get_client_detail_payload(db: Session, client_id: int, *, viewer_partner_id: int | None = None):
assoc=ClientAssociation
stmt=(select(
Client, ClientGroup.group_name.label("client_group_name"), ClientGroup.group_code.label("client_group_code"),
@@ -133,7 +170,10 @@ def get_client_detail_payload(db: Session, client_id: int):
row.update({"client_group_name":client_group_name,"client_group_code":client_group_code,"association_type":association_type,
"assoc_firm_tenant_id":assoc_firm_tenant_id,"assoc_consultant_id":assoc_consultant_id,
"assoc_partner_user_id":assoc_partner_user_id,"assoc_created_source":assoc_created_source,
"effective_partner_id":assoc_partner_user_id or row.get("partner_id")})
"effective_partner_id":assoc_partner_user_id or row.get("partner_id"),
"has_review_access": bool(viewer_partner_id is not None and has_partner_review_access(
db, tenant_id=int(row["tenant_id"]), client_id=int(row["id"]), partner_user_id=int(viewer_partner_id)
))})
return row
+9 -3
View File
@@ -30,16 +30,15 @@ from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.documents.models import PermanentClientDocument
from app.modules.consultants.service import sync_primary_client_consultant_link
from app.modules.clients.models import Client
def _get_client_group(*args, **kwargs):
# Imported lazily to prevent clients ↔ client_groups circular imports.
from app.modules.client_groups.service import get_group
return get_group(*args, **kwargs)
def _payload_from_schema(data):
payload = data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True)
# primary_consultant_id belongs to ClientConsultantLink, not the clients table.
@@ -367,6 +366,8 @@ def get_client_or_404(
branch_id: int | None,
allow_cross_branch: bool,
allow_all_clients: bool = False,
viewer_partner_id: int | None = None,
allow_review_access: bool = False,
):
row = repository.get_client_by_id(db, client_id)
if not row:
@@ -375,6 +376,11 @@ def get_client_or_404(
raise HTTPException(status_code=404, detail="Client not found in current tenant.")
if not allow_all_clients and not allow_cross_branch and branch_id and row.branch_id != branch_id:
raise HTTPException(status_code=404, detail="Client not found in current branch.")
if viewer_partner_id is not None and int(row.partner_id or 0) != int(viewer_partner_id):
if not allow_review_access or not repository.has_partner_review_access(
db, tenant_id=tenant_id, client_id=client_id, partner_user_id=viewer_partner_id
):
raise HTTPException(status_code=404, detail="Client not found.")
return row
@@ -51,12 +51,8 @@
<input type="hidden" name="q" value="{{ q }}">
<input type="hidden" name="status" value="{{ status }}">
<input type="hidden" name="client_type" value="{{ client_type }}">
{% if client_group_id %}
<input type="hidden" name="client_group_id" value="{{ client_group_id }}">
{% endif %}
{% if partner_id %}
<input type="hidden" name="partner_id" value="{{ partner_id }}">
{% endif %}
{% if client_group_id %}<input type="hidden" name="client_group_id" value="{{ client_group_id }}">{% endif %}
{% if partner_id %}<input type="hidden" name="partner_id" value="{{ partner_id }}">{% endif %}
<input type="hidden" name="include_archived" value="{{ 'true' if include_archived else 'false' }}">
<input type="hidden" name="sort_by" value="{{ sort_by }}">
<input type="hidden" name="sort_order" value="{{ sort_order }}">
@@ -73,12 +69,8 @@
{% if meta.pages > 1 %}
<nav class="flex items-center gap-1" aria-label="Client list pagination">
{% set query = namespace(value='q=' ~ (q|urlencode) ~ '&status=' ~ (status|urlencode) ~ '&client_type=' ~ (client_type|urlencode) ~ '&include_archived=' ~ ('true' if include_archived else 'false') ~ '&per_page=' ~ meta.per_page ~ '&sort_by=' ~ (sort_by|urlencode) ~ '&sort_order=' ~ (sort_order|urlencode)) %}
{% if client_group_id %}
{% set query.value = query.value ~ '&client_group_id=' ~ client_group_id %}
{% endif %}
{% if partner_id %}
{% set query.value = query.value ~ '&partner_id=' ~ partner_id %}
{% endif %}
{% if client_group_id %}{% set query.value = query.value ~ '&client_group_id=' ~ client_group_id %}{% endif %}
{% if partner_id %}{% set query.value = query.value ~ '&partner_id=' ~ partner_id %}{% endif %}
{% set common_query = query.value %}
{% if meta.page > 1 %}
+58 -37
View File
@@ -11,7 +11,7 @@ from app.core.security.session_auth import get_current_user
from app.core.security.otp import start_otp, verify_otp
from app.core.templating import templates
from app.modules.clients import repository
from app.modules.clients.access import build_scope, can_view_client_row
from app.modules.clients.access import build_scope, can_view_client_row, enforce_partner_scope, is_partner_role
from app.modules.clients.constants import CLIENT_ACCEPTANCE_STATUS, CLIENT_CATEGORY_OPTIONS, CLIENT_STATUS, CLIENT_TYPES, RISK_CATEGORIES
from app.modules.clients.filters import ClientListFilters
from app.modules.clients.import_service import (
@@ -150,6 +150,15 @@ def _elevate_scope_for_system_admin(scope, role_names: set[str]):
return scope
def _apply_role_scope(scope, user, role_names):
scope = _elevate_scope_for_system_admin(scope, role_names)
return enforce_partner_scope(scope, user=user, role_names=role_names)
def _viewer_partner_id(user, role_names):
return int(user.id) if is_partner_role(role_names) else None
def _form_bool(value):
return value in ("1", "true", "True", "on", "yes")
@@ -313,7 +322,7 @@ def clients_list(
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
filters = ClientListFilters.from_params(
q=q,
@@ -336,7 +345,8 @@ def clients_list(
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
partner_id=filters.partner_id,
partner_id=None if is_partner_role(role_names) else filters.partner_id,
viewer_partner_id=_viewer_partner_id(user, role_names),
q=filters.q,
status=filters.status,
client_type=filters.client_type,
@@ -400,7 +410,7 @@ def clients_export(
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
filters = ClientListFilters.from_params(
q=q,
@@ -422,7 +432,8 @@ def clients_export(
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
partner_id=filters.partner_id,
partner_id=None if is_partner_role(role_names) else filters.partner_id,
viewer_partner_id=_viewer_partner_id(user, role_names),
q=filters.q,
status=filters.status,
client_type=filters.client_type,
@@ -458,7 +469,7 @@ def client_import_page(request: Request):
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
return _render(
request,
@@ -469,7 +480,7 @@ def client_import_page(request: Request):
scope=scope,
role_names=sorted(role_names),
current_tenant=repository.get_tenant(db, scope.tenant_id),
partners=repository.list_partners_for_scope(db, tenant_id=scope.tenant_id, branch_id=None if scope.allow_cross_branch else scope.branch_id),
partners=([repository.get_partner(db, user.id)] if is_partner_role(role_names) else repository.list_partners_for_scope(db, tenant_id=scope.tenant_id, branch_id=None if scope.allow_cross_branch else scope.branch_id)),
import_errors=[],
)
finally:
@@ -488,7 +499,7 @@ def client_import_template(request: Request):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
payload = build_client_import_template_bytes(current_user=user, tenant_id=scope.tenant_id, partner_id=user.id if 'partner' in role_names else None)
return Response(
content=payload,
@@ -511,7 +522,7 @@ async def client_import_preview(request: Request, excel_file: UploadFile = File(
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
@@ -543,7 +554,7 @@ async def client_import_commit(request: Request):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
preview_rows = deserialize_preview_rows(form.get("preview_payload") or "[]")
@@ -578,7 +589,7 @@ def client_new_page(request: Request):
role_names = _role_names(db, user)
form_mode = _resolve_form_mode(role_names)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
defaults = {
"status": "active",
@@ -626,7 +637,7 @@ async def client_create(request: Request):
role_names = _role_names(db, user)
form_mode = _resolve_form_mode(role_names)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
request._form = form
@@ -678,13 +689,14 @@ def client_detail(request: Request, client_id: int):
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
row = repository.get_client_detail_payload(db, client_id)
row = repository.get_client_detail_payload(db, client_id, viewer_partner_id=_viewer_partner_id(user, role_names))
if not row or not can_view_client_row(scope, row, user_id=user.id):
return _redirect_denied()
audit_logs = list_client_audit_logs(db, row=type("Tmp", (), {"id": row["id"]})(), limit=10) if has("clients.audit_log.view") else []
review_only = bool(is_partner_role(role_names) and int(row.get("effective_partner_id") or 0) != int(user.id))
audit_logs = list_client_audit_logs(db, row=type("Tmp", (), {"id": row["id"]})(), limit=10) if has("clients.audit_log.view") and not review_only else []
permanent_document_count = db.execute(
select(func.count(PermanentClientDocument.id)).where(
PermanentClientDocument.client_id == client_id,
@@ -701,11 +713,11 @@ def client_detail(request: Request, client_id: int):
audit_logs=audit_logs,
permanent_document_count=permanent_document_count,
scope=scope,
can_edit=has("clients.edit"),
can_deactivate=has("clients.deactivate"),
can_activate=has("clients.activate"),
can_archive=has("clients.archive"),
can_restore=has("clients.restore"),
can_edit=has("clients.edit") and not review_only,
can_deactivate=has("clients.deactivate") and not review_only,
can_activate=has("clients.activate") and not review_only,
can_archive=has("clients.archive") and not review_only,
can_restore=has("clients.restore") and not review_only,
consultant_summary=get_client_consultant_summary(db, tenant_id=int(row["tenant_id"]), client_id=client_id),
client_group=get_group(db, tenant_id=int(row["tenant_id"]), group_id=int(row["client_group_id"])) if row.get("client_group_id") else None,
can_manage_acceptance=has("clients.acceptance.manage"),
@@ -730,7 +742,7 @@ def client_edit_page(request: Request, client_id: int):
role_names = _role_names(db, user)
form_mode = _resolve_form_mode(role_names)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
row = get_client_or_404(
db,
@@ -739,6 +751,7 @@ def client_edit_page(request: Request, client_id: int):
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
return _render(
@@ -773,7 +786,7 @@ async def client_update(request: Request, client_id: int):
role_names = _role_names(db, user)
form_mode = _resolve_form_mode(role_names)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
row = get_client_or_404(
db,
@@ -782,6 +795,7 @@ async def client_update(request: Request, client_id: int):
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
form = await request.form()
@@ -834,7 +848,7 @@ async def client_acceptance_approve(request: Request, client_id: int):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
@@ -844,6 +858,7 @@ async def client_acceptance_approve(request: Request, client_id: int):
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
try:
row = approve_client_acceptance_service(db, row=row, actor_user_id=user.id, review_notes=form.get("acceptance_review_notes"))
@@ -883,7 +898,7 @@ async def client_acceptance_reject(request: Request, client_id: int):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
@@ -893,6 +908,7 @@ async def client_acceptance_reject(request: Request, client_id: int):
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
row = reject_client_acceptance_service(db, row=row, actor_user_id=user.id, rejection_reason=form.get("acceptance_rejection_reason"))
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
@@ -912,7 +928,7 @@ async def client_acceptance_pending(request: Request, client_id: int):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
@@ -922,6 +938,7 @@ async def client_acceptance_pending(request: Request, client_id: int):
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
row = mark_client_acceptance_pending_service(db, row=row, actor_user_id=user.id, review_notes=form.get("acceptance_review_notes"))
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
@@ -943,7 +960,7 @@ async def client_deactivate(request: Request, client_id: int):
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
@@ -955,6 +972,7 @@ async def client_deactivate(request: Request, client_id: int):
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
deactivate_client_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url="/clients", status_code=303)
@@ -976,7 +994,7 @@ async def client_activate(request: Request, client_id: int):
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
@@ -988,6 +1006,7 @@ async def client_activate(request: Request, client_id: int):
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
activate_client_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
@@ -1009,7 +1028,7 @@ async def client_archive(request: Request, client_id: int):
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
@@ -1021,6 +1040,7 @@ async def client_archive(request: Request, client_id: int):
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
archive_client_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url="/clients?include_archived=true", status_code=303)
@@ -1042,7 +1062,7 @@ async def client_restore(request: Request, client_id: int):
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
scope = _apply_role_scope(scope, user, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
@@ -1054,6 +1074,7 @@ async def client_restore(request: Request, client_id: int):
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
restore_client_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
@@ -1073,7 +1094,7 @@ async def client_acceptance_request_declarations(request: Request, client_id: in
if not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
request_client_acceptance_declarations_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
@@ -1122,7 +1143,7 @@ async def client_acceptance_kyc_sync(request: Request, client_id: int, csrf_toke
if not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
sync_client_kyc_from_permanent_documents_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
@@ -1141,7 +1162,7 @@ async def client_acceptance_kyc_verify(request: Request, client_id: int, csrf_to
if not has("clients.acceptance.approve") and not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
verify_client_kyc_service(db, row=row, actor_user_id=user.id, notes=verification_notes)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
@@ -1160,7 +1181,7 @@ async def client_acceptance_kyc_reject(request: Request, client_id: int, csrf_to
if not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
reject_client_kyc_service(db, row=row, actor_user_id=user.id, notes=verification_notes)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
@@ -1179,7 +1200,7 @@ async def client_engagement_letter_draft(request: Request, client_id: int, csrf_
if not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
draft_client_engagement_letter_service(db, row=row, actor_user_id=user.id, title=title, body_text=body_text)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
@@ -1198,7 +1219,7 @@ async def client_engagement_letter_approve_send(request: Request, client_id: int
if not has("clients.acceptance.approve"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
approve_and_send_engagement_letter_service(db, row=row, letter_id=letter_id, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
@@ -1214,7 +1235,7 @@ def client_engagement_letter_download(request: Request, client_id: int, letter_i
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=True, allow_all_clients=scope.allow_all_clients)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=True, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
letter = get_current_engagement_letter(db, client_id=client_id)
if not letter or letter.id != letter_id:
return _redirect_denied()
@@ -1235,7 +1256,7 @@ async def client_engagement_letter_verify_manual(request: Request, client_id: in
if not has("clients.acceptance.approve") and not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
verify_manual_engagement_letter_service(db, row=row, letter_id=letter_id, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally: