Add firm admin dashboard and workspace switcher

This commit is contained in:
A R R R Associates
2026-07-04 16:22:45 +05:30
parent 7e10558186
commit a3b57155cf
17 changed files with 752 additions and 22 deletions
+374
View File
@@ -0,0 +1,374 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
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, FinancialYear, Tenant
from app.modules.clients.models import Client
from app.modules.services.models import (
FirmServiceSelection,
FirmServiceTaskTemplate,
ServiceCatalogue,
)
try:
from app.modules.billing.models import BillingSettings
except Exception: # pragma: no cover - optional module guard
BillingSettings = None
try:
from app.modules.email_integration.models import PlatformEmailSettings
except Exception: # pragma: no cover - optional W2 guard
PlatformEmailSettings = None
try:
from app.modules.core.audit.models import AuditLog
except Exception: # pragma: no cover - older schema guard
AuditLog = None
FIRM_ADMIN_ROLES = {"Firm Admin", "System Admin"}
def _count(db: Session, stmt) -> int:
value = db.execute(stmt).scalar()
return int(value or 0)
def _active_tenant_id(request, current_user, roles: set[str]) -> int | None:
tenant_id = request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None)
if "System Admin" not in roles:
tenant_id = getattr(current_user, "tenant_id", None)
return int(tenant_id) if tenant_id else None
def _active_branch_id(request, current_user, roles: set[str]) -> int | None:
branch_id = request.session.get("active_branch_id") or getattr(current_user, "branch_id", None)
if "System Admin" in roles or "Firm Admin" in roles:
if branch_id in (None, "", 0, "0"):
return None
return int(branch_id)
return int(getattr(current_user, "branch_id", 0) or 0) or None
def _tenant(db: Session, tenant_id: int | None) -> Tenant | None:
return db.get(Tenant, int(tenant_id)) if tenant_id else None
def _role_ids(db: Session, role_names: set[str]) -> list[int]:
return list(db.execute(select(Role.id).where(Role.name.in_(role_names))).scalars().all())
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_firm_admin_dashboard(db: Session, current_user) -> bool:
roles = set(get_user_role_names(db, current_user.id))
return bool(roles.intersection(FIRM_ADMIN_ROLES))
def _branch_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
if not tenant_id:
return []
rows = db.execute(
select(Branch)
.where(Branch.tenant_id == tenant_id)
.order_by(Branch.is_head_office.desc(), Branch.name.asc())
).scalars().all()
out: list[dict[str, Any]] = []
for branch in rows:
partner_count = _count(
db,
select(func.count(func.distinct(User.id)))
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.where(
User.tenant_id == tenant_id,
User.branch_id == branch.id,
User.is_active.is_(True),
Role.name == "Partner",
),
)
user_count = _count(
db,
select(func.count(User.id)).where(
User.tenant_id == tenant_id,
User.branch_id == branch.id,
User.is_active.is_(True),
),
)
client_count = _count(
db,
select(func.count(Client.id)).where(
Client.tenant_id == tenant_id,
Client.branch_id == branch.id,
Client.is_active.is_(True),
),
)
out.append(
{
"id": branch.id,
"code": branch.code,
"name": branch.name,
"is_active": branch.is_active,
"is_head_office": branch.is_head_office,
"allow_login": branch.allow_login,
"allow_new_assignments": branch.allow_new_assignments,
"timezone": branch.timezone,
"partner_count": partner_count,
"user_count": user_count,
"client_count": client_count,
"local_storage_path": branch.local_storage_path,
"smtp_configured": bool(branch.smtp_host and branch.smtp_port and branch.smtp_username),
}
)
return out
def _user_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
if not tenant_id:
return []
users = db.execute(
select(User)
.where(User.tenant_id == tenant_id)
.order_by(User.is_active.desc(), User.full_name.asc(), User.email.asc())
.limit(100)
).scalars().all()
rows: list[dict[str, Any]] = []
for user in users:
rows.append(
{
"id": user.id,
"name": user.full_name or user.email,
"email": user.email,
"designation": user.designation,
"mobile": user.mobile,
"branch_id": user.branch_id,
"roles": get_user_role_names(db, user.id),
"is_active": user.is_active,
"allow_login": user.allow_login,
"is_locked": user.is_locked,
"must_change_password": user.must_change_password,
}
)
return rows
def _service_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
if not tenant_id:
return []
selections = db.execute(
select(FirmServiceSelection, ServiceCatalogue)
.join(ServiceCatalogue, ServiceCatalogue.id == FirmServiceSelection.service_catalogue_id)
.where(FirmServiceSelection.tenant_id == tenant_id)
.order_by(ServiceCatalogue.category.asc(), ServiceCatalogue.service_name.asc())
).all()
out: list[dict[str, Any]] = []
for selection, catalogue in selections:
template_count = _count(
db,
select(func.count(FirmServiceTaskTemplate.id)).where(
FirmServiceTaskTemplate.tenant_id == tenant_id,
FirmServiceTaskTemplate.service_catalogue_id == catalogue.id,
FirmServiceTaskTemplate.is_active.is_(True),
),
)
out.append(
{
"id": selection.id,
"service_code": catalogue.service_code,
"service_name": catalogue.service_name,
"category": catalogue.category or getattr(getattr(catalogue, "service_category", None), "name", None) or "-",
"recurrence_type": catalogue.recurrence_type or "-",
"is_enabled": selection.is_enabled,
"default_branch_id": selection.default_branch_id,
"template_count": template_count,
"ready": bool(selection.is_enabled and template_count > 0),
}
)
return out
def _financial_year_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
if not tenant_id:
return []
years = db.execute(
select(FinancialYear)
.where(FinancialYear.tenant_id == tenant_id)
.order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc())
).scalars().all()
return [
{
"id": fy.id,
"year_code": fy.year_code,
"assessment_year": fy.assessment_year,
"start_date": fy.start_date,
"end_date": fy.end_date,
"is_current": fy.is_current,
"is_locked": fy.is_locked,
"locked_at_utc": fy.locked_at_utc,
}
for fy in years
]
def _billing_settings_count(db: Session, tenant_id: int | None) -> int:
if not tenant_id or BillingSettings is None:
return 0
return _count(db, select(func.count(BillingSettings.id)).where(BillingSettings.tenant_id == tenant_id))
def _smtp_status(db: Session, tenant_id: int | None) -> dict[str, Any]:
if PlatformEmailSettings is not None:
try:
row = db.execute(select(PlatformEmailSettings).order_by(PlatformEmailSettings.id.desc())).scalars().first()
return {
"available": True,
"configured": bool(row and row.smtp_host and row.smtp_port and row.from_email),
"label": "Configured" if row and row.smtp_host and row.smtp_port and row.from_email else "Pending",
"message": getattr(row, "from_email", None) or "Platform SMTP not configured",
}
except Exception:
pass
return {"available": False, "configured": False, "label": "Check settings", "message": "Open email settings to verify SMTP."}
def build_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
roles = set(get_user_role_names(db, current_user.id))
tenant_id = _active_tenant_id(request, current_user, roles)
branch_id = _active_branch_id(request, current_user, roles)
tenant = _tenant(db, tenant_id)
branches = _branch_rows(db, tenant_id)
users = _user_rows(db, tenant_id)
services = _service_rows(db, tenant_id)
financial_years = _financial_year_rows(db, tenant_id)
billing_settings_count = _billing_settings_count(db, tenant_id)
firm_admin_roles = _role_ids(db, {"Firm Admin"})
firm_admin_count = 0
if tenant_id and firm_admin_roles:
firm_admin_count = _count(
db,
select(func.count(func.distinct(User.id)))
.join(UserRole, UserRole.user_id == User.id)
.where(User.tenant_id == tenant_id, UserRole.role_id.in_(firm_admin_roles), User.is_active.is_(True)),
)
partner_count = _count(
db,
select(func.count(func.distinct(User.id)))
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.where(User.tenant_id == tenant_id, Role.name == "Partner", User.is_active.is_(True)),
) if tenant_id else 0
client_count = _count(db, select(func.count(Client.id)).where(Client.tenant_id == tenant_id, Client.is_active.is_(True))) if tenant_id else 0
user_count = _count(db, select(func.count(User.id)).where(User.tenant_id == tenant_id, User.is_active.is_(True))) if tenant_id else 0
service_count = len([s for s in services if s["is_enabled"]])
services_without_templates = len([s for s in services if s["is_enabled"] and s["template_count"] <= 0])
current_fy = next((fy for fy in financial_years if fy["is_current"]), None)
branding_ready = bool(tenant and (tenant.display_name or tenant.logo_path or tenant.primary_color or tenant.contact_email))
setup_checks = [
{"key": "branches", "label": "Branches created", "ok": len(branches) > 0, "detail": f"{len(branches)} branch(es)"},
{"key": "firm_admin", "label": "Firm Admin user", "ok": firm_admin_count > 0, "detail": f"{firm_admin_count} active"},
{"key": "partners", "label": "Partners assigned", "ok": partner_count > 0, "detail": f"{partner_count} active"},
{"key": "services", "label": "Services selected", "ok": service_count > 0, "detail": f"{service_count} enabled"},
{"key": "templates", "label": "Task templates ready", "ok": services_without_templates == 0 and service_count > 0, "detail": f"{services_without_templates} service(s) pending"},
{"key": "fy", "label": "Current financial year", "ok": bool(current_fy), "detail": current_fy["year_code"] if current_fy else "Not set"},
{"key": "branding", "label": "Branding/contact", "ok": branding_ready, "detail": "Started" if branding_ready else "Pending"},
{"key": "billing", "label": "Billing settings", "ok": billing_settings_count > 0, "detail": f"{billing_settings_count} setup row(s)"},
]
setup_score = sum(1 for item in setup_checks if item["ok"])
overview = {
"tenant": tenant,
"tenant_id": tenant_id,
"branch_id": branch_id,
"branch_count": len(branches),
"active_branch_count": len([b for b in branches if b["is_active"]]),
"user_count": user_count,
"firm_admin_count": firm_admin_count,
"partner_count": partner_count,
"client_count": client_count,
"enabled_service_count": service_count,
"services_without_templates": services_without_templates,
"fy_count": len(financial_years),
"current_fy": current_fy,
"billing_settings_count": billing_settings_count,
"branding_ready": branding_ready,
"setup_score": setup_score,
"setup_total": len(setup_checks),
"setup_percent": round((setup_score / len(setup_checks)) * 100) if setup_checks else 0,
"smtp": _smtp_status(db, tenant_id),
"today": date.today(),
}
return {
"roles": sorted(roles),
"tenant": tenant,
"overview": overview,
"setup_checks": setup_checks,
"branches": branches,
"users": users,
"services": services,
"financial_years": financial_years,
"reports": _report_cards(),
"wizards": _wizard_cards(),
"audit_logs": _audit_rows(db, tenant_id),
}
def _report_cards() -> list[dict[str, str]]:
return [
{"group": "Setup Reports", "title": "Firm Setup Completeness", "desc": "Branches, users, roles, services, FY, branding and billing readiness.", "href": "/firm-admin/dashboard?tab=overview"},
{"group": "Branch Reports", "title": "Branch Readiness", "desc": "Active branches, partner assignment, users and local storage readiness.", "href": "/firm-admin/dashboard?tab=branches"},
{"group": "User Reports", "title": "Users & Roles", "desc": "Firm Admin, Partner, Manager, Staff and login readiness.", "href": "/firm-admin/dashboard?tab=users"},
{"group": "Service Reports", "title": "Service Setup Readiness", "desc": "Enabled services and missing task templates.", "href": "/firm-admin/dashboard?tab=services"},
{"group": "FY Reports", "title": "Financial Year Status", "desc": "Current, locked and historical financial years.", "href": "/firm-admin/dashboard?tab=financial-years"},
]
def _wizard_cards() -> list[dict[str, str]]:
return [
{"title": "Add / Manage Branches", "desc": "Create branch, office timing, login control and local storage path.", "href": "/system-settings/branches"},
{"title": "Invite Users", "desc": "Create Firm Admin, Partner, Manager, Staff and role mapping.", "href": "/system-settings/users"},
{"title": "Firm Profile & Branding", "desc": "Display name, logo, colours and contact details.", "href": "/system-settings/branding"},
{"title": "Select Firm Services", "desc": "Enable services from system catalogue for this firm.", "href": "/services"},
{"title": "Firm Task Templates", "desc": "Customize task list and document requirements for enabled services.", "href": "/services/templates"},
{"title": "Financial Years", "desc": "Create, switch, lock and manage financial years.", "href": "/system-settings/financial-years"},
{"title": "Billing Settings", "desc": "Invoice prefix, GST settings, payment gateway and bank details.", "href": "/billing/settings"},
]
def _audit_rows(db: Session, tenant_id: int | None) -> list[Any]:
if AuditLog is None or not tenant_id:
return []
try:
return list(
db.execute(
select(AuditLog)
.where(getattr(AuditLog, "target_tenant_id", tenant_id) == tenant_id)
.order_by(AuditLog.created_at_utc.desc())
.limit(25)
).scalars().all()
)
except Exception:
try:
return list(db.execute(select(AuditLog).order_by(AuditLog.created_at_utc.desc()).limit(25)).scalars().all())
except Exception:
return []