from __future__ import annotations from dataclasses import dataclass from pathlib import Path 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.iam.password_flows_models import InviteToken from app.modules.core.rbac.models import Role, UserRole from app.modules.core.tenancy.models import Branch, Tenant, YearBackupExport from app.modules.services.models import ( ServiceCatalogue, ServiceCategory, ServiceDefaultTaskTemplate, ServiceDueDateRule, ) try: from app.modules.core.audit.models import AuditLog except Exception: # pragma: no cover - keeps dashboard usable if audit module is unavailable AuditLog = None # type: ignore[assignment] try: from app.modules.email_integration.models import PlatformEmailSetting except Exception: # pragma: no cover - dashboard falls back to not configured PlatformEmailSetting = None # type: ignore[assignment] STORAGE_ROOT = Path("/app/data/storage") BRANDING_ROOT = STORAGE_ROOT / "uploads" / "branding" def scalar_count(db: Session, statement) -> int: value = db.execute(statement).scalar() return int(value or 0) def system_admin_role_names(db: Session, user_id: int) -> set[str]: rows = db.execute( select(Role.name) .join(UserRole, UserRole.role_id == Role.id) .where(UserRole.user_id == user_id) ).all() return {name for (name,) in rows if name} def is_system_admin(db: Session, user: User | None) -> bool: if not user: return False return "System Admin" in system_admin_role_names(db, user.id) def _firm_admin_role_ids(db: Session) -> list[int]: names = ["Firm Admin", "firm_admin", "FirmAdmin"] return [ role_id for (role_id,) in db.execute(select(Role.id).where(Role.name.in_(names))).all() ] def _pending_firm_admin_invite_count(db: Session) -> int: role_ids = _firm_admin_role_ids(db) if not role_ids: return 0 return scalar_count( db, select(func.count(func.distinct(User.id))) .join(UserRole, UserRole.user_id == User.id) .join(InviteToken, InviteToken.user_id == User.id) .where(UserRole.role_id.in_(role_ids)) .where(InviteToken.used_at_utc.is_(None)) .where(User.must_change_password.is_(True)), ) def _platform_smtp_status(db: Session) -> dict[str, Any]: if PlatformEmailSetting is None: return { "configured": False, "active": False, "host": None, "port": None, "from_email": None, "from_name": None, "security": None, "message": "Platform SMTP model not available. Apply W2 SMTP migration/package first.", } setting = db.execute(select(PlatformEmailSetting).order_by(PlatformEmailSetting.id.asc())).scalars().first() if not setting: return { "configured": False, "active": False, "host": None, "port": None, "from_email": None, "from_name": None, "security": None, "message": "Platform SMTP is not configured.", } configured = bool(setting.smtp_host and setting.smtp_port and setting.from_email) return { "configured": configured, "active": bool(getattr(setting, "is_active", False)), "host": setting.smtp_host, "port": setting.smtp_port, "from_email": setting.from_email, "from_name": setting.from_name, "security": setting.smtp_security, "message": "Platform SMTP is ready." if configured and setting.is_active else "Platform SMTP is saved but not fully ready.", } def _dir_size_bytes(path: Path) -> int: if not path.exists(): return 0 total = 0 for item in path.rglob("*"): try: if item.is_file(): total += item.stat().st_size except OSError: continue return total def _file_count(path: Path) -> int: if not path.exists(): return 0 count = 0 for item in path.rglob("*"): try: if item.is_file(): count += 1 except OSError: continue return count def format_bytes(size: int) -> str: units = ["B", "KB", "MB", "GB", "TB"] value = float(size) for unit in units: if value < 1024 or unit == units[-1]: if unit == "B": return f"{int(value)} {unit}" return f"{value:.1f} {unit}" value /= 1024 return f"{size} B" def get_storage_status(db: Session) -> dict[str, Any]: root_exists = STORAGE_ROOT.exists() branding_exists = BRANDING_ROOT.exists() root_size = _dir_size_bytes(STORAGE_ROOT) branding_files = _file_count(BRANDING_ROOT) backup_exports = scalar_count(db, select(func.count(YearBackupExport.id))) return { "storage_root": str(STORAGE_ROOT), "storage_root_exists": root_exists, "branding_root": str(BRANDING_ROOT), "branding_root_exists": branding_exists, "branding_file_count": branding_files, "backup_export_count": backup_exports, "used_bytes": root_size, "used_display": format_bytes(root_size), } def get_overview(db: Session) -> dict[str, Any]: total_firms = scalar_count(db, select(func.count(Tenant.id))) active_firms = scalar_count(db, select(func.count(Tenant.id)).where(Tenant.is_active.is_(True))) inactive_firms = max(total_firms - active_firms, 0) service_count = scalar_count(db, select(func.count(ServiceCatalogue.id))) active_service_count = scalar_count(db, select(func.count(ServiceCatalogue.id)).where(ServiceCatalogue.is_active.is_(True))) category_count = scalar_count(db, select(func.count(ServiceCategory.id))) default_task_count = scalar_count(db, select(func.count(ServiceDefaultTaskTemplate.id))) due_rule_count = scalar_count(db, select(func.count(ServiceDueDateRule.id))) pending_invites = _pending_firm_admin_invite_count(db) storage = get_storage_status(db) smtp = _platform_smtp_status(db) return { "total_firms": total_firms, "active_firms": active_firms, "inactive_firms": inactive_firms, "pending_invites": pending_invites, "service_count": service_count, "active_service_count": active_service_count, "category_count": category_count, "default_task_count": default_task_count, "due_rule_count": due_rule_count, "storage": storage, "smtp": smtp, } def list_firms(db: Session, limit: int = 100) -> list[dict[str, Any]]: branch_counts = dict( db.execute( select(Branch.tenant_id, func.count(Branch.id)).group_by(Branch.tenant_id) ).all() ) user_counts = dict( db.execute( select(User.tenant_id, func.count(User.id)).group_by(User.tenant_id) ).all() ) role_ids = _firm_admin_role_ids(db) primary_admins: dict[int, str] = {} if role_ids: rows = db.execute( select(User.tenant_id, User.email, User.full_name) .join(UserRole, UserRole.user_id == User.id) .where(UserRole.role_id.in_(role_ids)) .order_by(User.id.asc()) ).all() for tenant_id, email, full_name in rows: if tenant_id not in primary_admins: primary_admins[tenant_id] = full_name or email tenants = db.execute(select(Tenant).order_by(Tenant.id.desc()).limit(limit)).scalars().all() return [ { "id": tenant.id, "code": tenant.code, "name": tenant.display_name or tenant.name, "legal_name": tenant.name, "firm_type": tenant.firm_type, "is_active": tenant.is_active, "branch_count": int(branch_counts.get(tenant.id, 0) or 0), "user_count": int(user_counts.get(tenant.id, 0) or 0), "primary_admin": primary_admins.get(tenant.id, "-"), "contact_email": tenant.contact_email, } for tenant in tenants ] def get_catalogue_summary(db: Session) -> dict[str, Any]: categories = db.execute(select(ServiceCategory).order_by(ServiceCategory.sort_order.asc(), ServiceCategory.name.asc())).scalars().all() services = db.execute(select(ServiceCatalogue).order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc()).limit(100)).scalars().all() task_counts = dict( db.execute( select(ServiceDefaultTaskTemplate.service_catalogue_id, func.count(ServiceDefaultTaskTemplate.id)) .group_by(ServiceDefaultTaskTemplate.service_catalogue_id) ).all() ) rule_counts = dict( db.execute( select(ServiceDueDateRule.service_catalogue_id, func.count(ServiceDueDateRule.id)) .group_by(ServiceDueDateRule.service_catalogue_id) ).all() ) return { "category_count": scalar_count(db, select(func.count(ServiceCategory.id))), "service_count": scalar_count(db, select(func.count(ServiceCatalogue.id))), "active_service_count": scalar_count(db, select(func.count(ServiceCatalogue.id)).where(ServiceCatalogue.is_active.is_(True))), "default_task_count": scalar_count(db, select(func.count(ServiceDefaultTaskTemplate.id))), "due_rule_count": scalar_count(db, select(func.count(ServiceDueDateRule.id))), "categories": categories, "services": [ { "id": service.id, "code": service.service_code, "name": service.service_name, "category": service.category or (service.service_category.name if service.service_category else "-"), "recurrence_type": service.recurrence_type or "-", "engagement_type": service.engagement_type, "is_active": service.is_active, "task_count": int(task_counts.get(service.id, 0) or 0), "rule_count": int(rule_counts.get(service.id, 0) or 0), } for service in services ], } def get_smtp_summary(db: Session) -> dict[str, Any]: return _platform_smtp_status(db) def get_report_cards(db: Session) -> list[dict[str, str]]: overview = get_overview(db) return [ { "title": "Firm Setup Completeness", "description": "Review firms, admin invite status, branches and basic readiness.", "metric": f"{overview['total_firms']} firms", "href": "/system-admin/dashboard?tab=firms", }, { "title": "Firm Admin Invite Status", "description": "Track Firm Admin users whose invite/password setup is still pending.", "metric": f"{overview['pending_invites']} pending", "href": "/system-admin/dashboard?tab=firms", }, { "title": "Service Catalogue Readiness", "description": "Check categories, services, default task templates and due-date rules.", "metric": f"{overview['service_count']} services / {overview['default_task_count']} tasks", "href": "/system-admin/dashboard?tab=catalogue", }, { "title": "Platform SMTP Status", "description": "Check whether system emails can be sent for firm invites and security notices.", "metric": "Ready" if overview["smtp"]["configured"] and overview["smtp"]["active"] else "Needs setup", "href": "/system-admin/dashboard?tab=smtp", }, { "title": "Storage Status", "description": "Verify persistent storage, branding files and FY backup exports.", "metric": overview["storage"]["used_display"], "href": "/system-admin/dashboard?tab=storage", }, { "title": "Audit Log Review", "description": "Review recent platform and system-level actions.", "metric": "Latest 25", "href": "/system-admin/dashboard?tab=audit-logs", }, ] def get_wizard_cards() -> list[dict[str, str]]: return [ { "title": "Create Firm Wizard", "description": "Create a firm, primary branch, Firm Admin, employee link and invite.", "href": "/wizards/system/firm/new", "action": "Open Wizard", }, { "title": "Platform SMTP Settings", "description": "Configure SMTP used for firm creation invites and system emails.", "href": "/email/platform-smtp", "action": "Configure SMTP", }, { "title": "Service Catalogue", "description": "Manage master service catalogue and default task templates.", "href": "/services/catalogue", "action": "Open Catalogue", }, { "title": "Bulk Service Imports", "description": "Use existing bulk import screens for service master and default tasks.", "href": "/services/bulk-imports", "action": "Open Imports", }, ] def get_recent_audit_logs(db: Session, limit: int = 25) -> list[Any]: if AuditLog is None: return [] return db.execute(select(AuditLog).order_by(AuditLog.created_at_utc.desc()).limit(limit)).scalars().all()