from __future__ import annotations from fastapi import FastAPI from datetime import date, datetime, timezone from sqlalchemy import inspect, select, text from app.core.db.common import CommonBase, CommonEngine, CommonSessionLocal from app.core.security.passwords import hash_password from app.core.settings import get_settings from app.modules.core.iam.models import User from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken from app.modules.core.audit.models import AuditLog from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole from app.modules.core.rbac.permissions_registry import PERMISSIONS from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant from app.modules.core.tenancy.settings_models import BranchSettings from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeLeaveRequest, EmployeeDocumentType, EmployeeDocument, EmployeeOnboardingChecklistItem, EmployeeOnboardingTask, EmployeeOffboardingRequest, EmployeeOffboardingTask, EmployeeSalaryStructure, EmployeePayrollRun, EmployeePayslip from app.modules.consultants.models import ClientConsultantLink, ConsultantManagedClient, ConsultantProfile, ConsultantWorkspace, ConsultantServiceRequest from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate from app.modules.billing.models import BillingSettings, BillingInvoice, BillingInvoiceLine, BillingFeeGroup, BillingFeeGroupService from app.modules.platform_billing.models import PlatformBillingAccount, PlatformInvoice, PlatformInvoiceLine, PlatformPayment, PlatformPlan, PlatformPlanFeature, PlatformSubscription from app.modules.marketplace.models import MarketplaceLead, MarketplaceLeadAssignment from app.modules.documents.models import EngagementDocument, EngagementDocumentVersion, DocumentAccessLog from app.modules.alerts.models import UserAlert from app.modules.notice_cases.models import NoticeCase, NoticeCaseEvent, NoticeCaseHearing, NoticeCaseOrder, NoticeCaseDocument from app.modules.notifications.automation import start_notification_scheduler DEFAULT_ROLES = [ "System Admin", "Firm Admin", "Partner", "Branch Manager", "Staff", "Client", "Consultant", ] LEGACY_ROLE_RENAMES = { "SystemAdmin": "System Admin", "Manager": "Branch Manager", } DEFAULT_PERMISSIONS = list(PERMISSIONS.items()) def _ensure_user_lifecycle_columns() -> None: inspector = inspect(CommonEngine) existing = {c["name"] for c in inspector.get_columns("users")} if "users" in inspector.get_table_names() else set() dialect = CommonEngine.dialect.name ddl_map = { "allow_login": "BOOLEAN DEFAULT TRUE", "is_locked": "BOOLEAN DEFAULT FALSE", "locked_at_utc": "TIMESTAMP NULL", "deleted_at": "TIMESTAMP NULL", "must_change_password": "BOOLEAN DEFAULT FALSE", "password_changed_at_utc": "TIMESTAMP NULL", } for col, ddl in ddl_map.items(): if col in existing: continue with CommonEngine.begin() as conn: conn.execute(text(f"ALTER TABLE users ADD COLUMN {col} {ddl}")) if dialect == "postgres" and col in {"allow_login", "is_locked"}: default_value = "TRUE" if col == "allow_login" else "FALSE" conn.execute(text(f"UPDATE users SET {col} = {default_value} WHERE {col} IS NULL")) ROLE_PERMISSION_MAP = { "System Admin": [ "system.settings.view", "system.settings.edit", "system.settings.manage", "users.view", "users.manage", "users.invite", "users.reset_password", "rbac.view", "rbac.manage", "audit.view", "alerts.view_self", "alerts.manage", "services.view", "services.create", "services.edit", "services.selection.manage", "services.deactivate", "services.cross_branch", "services.cross_tenant", "services.catalogue.manage", "service_tasks.view", "service_tasks.create", "service_tasks.edit", "service_tasks.deactivate", "clients.view", "clients.create", "clients.import", "clients.edit", "clients.deactivate", "clients.activate", "clients.archive", "clients.restore", "clients.assign_partner", "clients.cross_branch", "clients.cross_tenant", "clients.export", "clients.audit_log.view", "clients.acceptance.manage", "clients.acceptance.approve", "employees.dashboard.view", "employees.view", "employees.create", "employees.edit", "employees.status", "employees.cross_branch", "employees.cross_tenant", "consultants.view", "consultants.manage", "consultants.link_clients", "consultants.cross_branch", "consultants.managed_clients.manage", "consultants.workspace.manage", "consultants.service_requests.manage", "consultants.conversions.manage", # System Admin has billing support/view access only. # System Admin must not create, import, generate, approve, post, cancel, # or record firm-level client bills. "billing.view", "billing.payment.view", "billing.reports", "billing.cross_branch", "billing.cross_tenant", "billing_fee_structure.view", # Platform/SaaS billing is System Admin revenue layer. "platform_billing.view", "platform_billing.create", "platform_billing.edit", "platform_billing.generate", "platform_billing.post", "platform_billing.cancel", "platform_billing.payment.create", "platform_billing.payment.view", "platform_billing.reports", "platform_plans.manage", "platform_subscriptions.manage", # Marketplace / public lead management. "marketplace_leads.view", "marketplace_leads.create", "marketplace_leads.assign", "marketplace_leads.update", "marketplace_leads.convert", "marketplace_leads.reports", "marketplace_leads.view_assigned", "alerts.view_self", "employees.ess.view", "employees.ess.profile.edit", "employees.work.view_self", "employees.work.manage", "employees.progress.view", "employees.registration.request", "employees.registration.approve", "employees.attendance.punch", "employees.attendance.view_self", "employees.attendance.view_all", "employees.attendance.approve", "employees.leave.apply", "employees.leave.view_self", "employees.leave.view_all", "employees.leave.approve", "employees.leave_type.manage", "employees.leave_balance.manage", "employees.documents.view_self", "employees.documents.upload_self", "employees.documents.view_all", "employees.documents.manage", "employees.documents.verify", "employees.documents.delete", "employees.document_type.manage", "employees.onboarding.view", "employees.onboarding.manage", "employees.onboarding.approve", "employees.offboarding.view", "employees.offboarding.manage", "employees.offboarding.approve", "employees.offboarding.request_self", "employees.payroll.payout", "employees.payroll.view_self", "employees.payroll.view", "employees.payroll.run", "employees.payroll.structure.manage", "employees.import", "employees.import.employee", "employees.import.leave_type", "employees.import.leave_balance", "employees.import.salary_structure", ], "Firm Admin": [ "system.settings.view", "system.settings.edit", "users.view", "users.manage", "users.invite", "users.reset_password", "audit.view", "alerts.view_self", "alerts.manage", "services.view", "services.create", "services.edit", "services.selection.manage", "services.deactivate", "services.cross_branch", "service_tasks.view", "service_tasks.create", "service_tasks.edit", "service_tasks.deactivate", "clients.view", "clients.create", "clients.import", "clients.edit", "clients.deactivate", "clients.activate", "clients.archive", "clients.restore", "clients.assign_partner", "clients.cross_branch", "clients.export", "clients.audit_log.view", "clients.acceptance.manage", "clients.acceptance.approve", "documents.view", "documents.upload", "documents.download", "documents.delete", "documents.audit.view", "udin.view", "udin.manage", "udin.export", "employees.dashboard.view", "employees.view", "employees.create", "employees.edit", "employees.status", "employees.cross_branch", "consultants.view", "consultants.manage", "consultants.link_clients", "consultants.cross_branch", "consultants.managed_clients.manage", "consultants.workspace.manage", "consultants.service_requests.manage", "consultants.conversions.manage", "billing.view", "billing.create", "billing.edit", "billing.approve", "billing.post", "billing.cancel", "billing.payment.create", "billing.payment.view", "billing.reports", "billing.cross_branch", "billing_fee_structure.view", "billing_fee_structure.import", "billing_fee_structure.edit", "billing_fee_structure.delete", "billing_invoice.generate", "billing_invoice.bulk_generate", # Audit Firm can work on leads assigned to its audit firm. "marketplace_leads.view_assigned", "marketplace_leads.update", "marketplace_leads.convert", "employees.ess.view", "employees.ess.profile.edit", "employees.work.view_self", "employees.work.manage", "employees.progress.view", "employees.registration.request", "employees.registration.approve", "employees.attendance.punch", "employees.attendance.view_self", "employees.attendance.view_all", "employees.attendance.approve", "employees.leave.apply", "employees.leave.view_self", "employees.leave.view_all", "employees.leave.approve", "employees.leave_type.manage", "employees.leave_balance.manage", "employees.documents.view_self", "employees.documents.upload_self", "employees.documents.view_all", "employees.documents.manage", "employees.documents.verify", "employees.documents.delete", "employees.document_type.manage", "employees.onboarding.view", "employees.onboarding.manage", "employees.onboarding.approve", "employees.offboarding.view", "employees.offboarding.manage", "employees.offboarding.approve", "employees.offboarding.request_self", "employees.payroll.payout", "employees.payroll.view_self", "employees.payroll.view", "employees.payroll.run", "employees.payroll.structure.manage", "employees.import", "employees.import.employee", "employees.import.leave_type", "employees.import.leave_balance", "employees.import.salary_structure", ], "Partner": [ "users.view", "system.settings.view", "services.view", "services.cross_branch", "service_tasks.view", "clients.view", "clients.create", "clients.import", "clients.edit", "clients.deactivate", "clients.activate", "clients.archive", "clients.restore", "clients.export", "clients.audit_log.view", "clients.acceptance.manage", "clients.acceptance.approve", "documents.view", "documents.upload", "documents.download", "documents.delete", "udin.view", "udin.manage", "udin.export", "clients.view.own_only", "employees.dashboard.view", "employees.view", "employees.create", "employees.edit", "employees.status", "consultants.view", "consultants.link_clients", "consultants.cross_branch", "consultants.managed_clients.manage", "consultants.workspace.manage", # Partner has almost the same firm-billing privileges as Firm Admin, # but is intentionally scoped to own clients through billing.view_own. "billing.view", "billing.create", "billing.edit", "billing.approve", "billing.post", "billing.cancel", "billing.payment.create", "billing.payment.view", "billing.reports", "billing.view_own", "billing_fee_structure.view", "billing_fee_structure.import", "billing_fee_structure.edit", "billing_fee_structure.delete", "billing_invoice.generate", "billing_invoice.bulk_generate", # Partner can handle assigned marketplace leads for own clients/work. "marketplace_leads.view_assigned", "marketplace_leads.update", "marketplace_leads.convert", "employees.ess.view", "employees.ess.profile.edit", "employees.work.view_self", "employees.work.manage", "employees.progress.view", "employees.registration.request", "employees.registration.approve", "employees.attendance.punch", "employees.attendance.view_self", "employees.attendance.view_all", "employees.attendance.approve", "employees.leave.apply", "employees.leave.view_self", "employees.leave.view_all", "employees.leave.approve", "employees.leave_type.manage", "employees.leave_balance.manage", "employees.documents.view_self", "employees.documents.upload_self", "employees.documents.view_all", "employees.documents.manage", "employees.documents.verify", "employees.documents.delete", "employees.document_type.manage", "employees.onboarding.view", "employees.onboarding.manage", "employees.onboarding.approve", "employees.offboarding.view", "employees.offboarding.manage", "employees.offboarding.approve", "employees.offboarding.request_self", "employees.payroll.payout", "employees.payroll.view_self", "employees.payroll.view", "employees.payroll.run", "employees.payroll.structure.manage", "employees.import", "employees.import.employee", "employees.import.leave_type", "employees.import.leave_balance", "employees.import.salary_structure", ], "Branch Manager": [ "users.view", "services.view", "services.create", "services.edit", "service_tasks.view", "service_tasks.create", "service_tasks.edit", "clients.view", "clients.create", "clients.edit", "clients.deactivate", "clients.activate", "clients.export", "clients.audit_log.view", "clients.acceptance.manage", "clients.acceptance.approve", "documents.view", "documents.upload", "documents.download", "udin.view", "employees.dashboard.view", "employees.view", "employees.create", "employees.edit", "employees.status", "consultants.view", "billing.view", "billing.create", "billing_fee_structure.view", "employees.ess.view", "employees.ess.profile.edit", "employees.work.view_self", "employees.work.manage", "employees.progress.view", "employees.registration.request", "employees.registration.approve", "employees.attendance.punch", "employees.attendance.view_self", "employees.attendance.view_all", "employees.attendance.approve", "employees.leave.apply", "employees.leave.view_self", "employees.leave.view_all", "employees.leave.approve", "employees.leave_type.manage", "employees.leave_balance.manage", "employees.documents.view_self", "employees.documents.upload_self", "employees.documents.view_all", "employees.documents.manage", "employees.documents.verify", "employees.documents.delete", "employees.document_type.manage", "employees.onboarding.view", "employees.onboarding.manage", "employees.onboarding.approve", "employees.offboarding.view", "employees.offboarding.manage", "employees.offboarding.approve", "employees.offboarding.request_self", "employees.payroll.view_self", "employees.payroll.view", "employees.payroll.run", "employees.payroll.structure.manage", "employees.import", "employees.import.employee", "employees.import.leave_type", "employees.import.leave_balance", "employees.import.salary_structure", ], "Staff": [ "alerts.view_self", "employees.ess.view", "employees.ess.profile.edit", "employees.work.view_self", "employees.registration.request", "employees.attendance.punch", "employees.attendance.view_self", "employees.leave.apply", "employees.leave.view_self", "employees.documents.view_self", "employees.documents.upload_self", "employees.offboarding.request_self", "employees.payroll.view_self", "documents.view", "documents.upload", "documents.download", "udin.view", ], "Client": [], "Consultant": [ "alerts.view_self", "consultants.portal.view", "consultants.managed_clients.manage", "consultants.workspace.manage", ], } # Keep existing databases aligned with the billing permission policy. # The normal startup seed only adds missing permissions; it does not remove # permissions that were granted in an earlier patch. This sync is limited to # billing permissions for these default roles so existing non-billing features # and custom modules are not touched. BILLING_PERMISSION_CODES = { "billing.view", "billing.create", "billing.edit", "billing.approve", "billing.post", "billing.cancel", "billing.payment.create", "billing.payment.view", "billing.reports", "billing.cross_branch", "billing.cross_tenant", "billing.view_own", "billing_fee_structure.view", "billing_fee_structure.import", "billing_fee_structure.edit", "billing_fee_structure.delete", "billing_invoice.generate", "billing_invoice.bulk_generate", } BILLING_ROLE_PERMISSION_SYNC = { "System Admin": { "billing.view", "billing.payment.view", "billing.reports", "billing.cross_branch", "billing.cross_tenant", "billing_fee_structure.view", }, "Firm Admin": { "billing.view", "billing.create", "billing.edit", "billing.approve", "billing.post", "billing.cancel", "billing.payment.create", "billing.payment.view", "billing.reports", "billing.cross_branch", "billing_fee_structure.view", "billing_fee_structure.import", "billing_fee_structure.edit", "billing_fee_structure.delete", "billing_invoice.generate", "billing_invoice.bulk_generate", }, "Partner": { "billing.view", "billing.create", "billing.edit", "billing.approve", "billing.post", "billing.cancel", "billing.payment.create", "billing.payment.view", "billing.reports", "billing.view_own", "billing_fee_structure.view", "billing_fee_structure.import", "billing_fee_structure.edit", "billing_fee_structure.delete", "billing_invoice.generate", "billing_invoice.bulk_generate", }, } NOTICE_CASE_ROLE_PERMISSIONS = { "System Admin": [ "notice_cases.view", "notice_cases.create", "notice_cases.edit", "notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage", "notice_cases.documents.upload", "notice_cases.documents.download", "notice_cases.documents.delete", "notice_cases.cross_branch", "notice_cases.cross_tenant", ], "Firm Admin": [ "notice_cases.view", "notice_cases.create", "notice_cases.edit", "notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage", "notice_cases.documents.upload", "notice_cases.documents.download", "notice_cases.documents.delete", "notice_cases.cross_branch", ], "Partner": [ "notice_cases.view", "notice_cases.create", "notice_cases.edit", "notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage", "notice_cases.documents.upload", "notice_cases.documents.download", ], "Branch Manager": [ "notice_cases.view", "notice_cases.create", "notice_cases.edit", "notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage", "notice_cases.documents.upload", "notice_cases.documents.download", "notice_cases.cross_branch", ], "Staff": [ "notice_cases.view", "notice_cases.events.manage", "notice_cases.documents.upload", "notice_cases.documents.download", ], } for _role_name, _codes in NOTICE_CASE_ROLE_PERMISSIONS.items(): _target = ROLE_PERMISSION_MAP.setdefault(_role_name, []) for _code in _codes: if isinstance(_target, set): _target.add(_code) elif _code not in _target: _target.append(_code) def _fy_dates_from_code(year_code: str) -> tuple[date, date, str]: parts = (year_code or "").split("-", 1) try: start_year = int(parts[0]) except Exception: start_year = 2025 end_year = start_year + 1 assessment_year = f"{end_year}-{str(end_year + 1)[-2:]}" return date(start_year, 4, 1), date(end_year, 3, 31), assessment_year def _ensure_financial_year(db, tenant_id: int, year_code: str) -> FinancialYear: fy = db.execute( select(FinancialYear).where( FinancialYear.tenant_id == tenant_id, FinancialYear.year_code == year_code, ) ).scalar_one_or_none() if fy: return fy start_date, end_date, assessment_year = _fy_dates_from_code(year_code) current_exists = db.execute( select(FinancialYear.id).where( FinancialYear.tenant_id == tenant_id, FinancialYear.is_current.is_(True), ) ).first() now = datetime.now(timezone.utc) fy = FinancialYear( tenant_id=tenant_id, year_code=year_code, assessment_year=assessment_year, start_date=start_date, end_date=end_date, is_current=current_exists is None, is_locked=False, created_at_utc=now, updated_at_utc=now, ) db.add(fy) db.commit() db.refresh(fy) return fy def _ensure_financial_years_for_all_tenants(db, default_year_code: str) -> None: tenant_ids = db.execute(select(Tenant.id)).scalars().all() for tenant_id in tenant_ids: _ensure_financial_year(db, int(tenant_id), default_year_code) def on_startup(app: FastAPI) -> None: s = get_settings() inspector = inspect(CommonEngine) existing_tables = set(inspector.get_table_names()) if "audit_logs" not in existing_tables: CommonBase.metadata.create_all(bind=CommonEngine, tables=[AuditLog.__table__]) existing_tables = set(inspect(CommonEngine).get_table_names()) required_tables = { "tenants", "branches", "branch_settings", "users", "roles", "permissions", "role_permissions", "user_roles", "audit_logs", } missing_optional_tables = [] if "invite_tokens" not in existing_tables: missing_optional_tables.append(InviteToken.__table__) if "password_reset_tokens" not in existing_tables: missing_optional_tables.append(PasswordResetToken.__table__) if "service_categories" not in existing_tables: missing_optional_tables.append(ServiceCategory.__table__) if "service_catalogues" not in existing_tables: missing_optional_tables.append(ServiceCatalogue.__table__) if "firm_service_selections" not in existing_tables: missing_optional_tables.append(FirmServiceSelection.__table__) if "firm_service_task_templates" not in existing_tables: missing_optional_tables.append(FirmServiceTaskTemplate.__table__) billing_tables = [ ("billing_settings", BillingSettings.__table__), ("billing_fee_groups", BillingFeeGroup.__table__), ("billing_fee_group_services", BillingFeeGroupService.__table__), ("billing_invoices", BillingInvoice.__table__), ("billing_invoice_lines", BillingInvoiceLine.__table__), ] for table_name, table in billing_tables: if table_name not in existing_tables: missing_optional_tables.append(table) platform_billing_tables = [ ("platform_plans", PlatformPlan.__table__), ("platform_plan_features", PlatformPlanFeature.__table__), ("platform_billing_accounts", PlatformBillingAccount.__table__), ("platform_subscriptions", PlatformSubscription.__table__), ("platform_invoices", PlatformInvoice.__table__), ("platform_invoice_lines", PlatformInvoiceLine.__table__), ("platform_payments", PlatformPayment.__table__), ] marketplace_tables = [ ("marketplace_leads", MarketplaceLead.__table__), ("marketplace_lead_assignments", MarketplaceLeadAssignment.__table__), ] for table_name, table in platform_billing_tables: if table_name not in existing_tables: missing_optional_tables.append(table) for table_name, table in marketplace_tables: if table_name not in existing_tables: missing_optional_tables.append(table) documents_tables = [ ("engagement_documents", EngagementDocument.__table__), ("engagement_document_versions", EngagementDocumentVersion.__table__), ("document_access_logs", DocumentAccessLog.__table__), ] for table_name, table in documents_tables: if table_name not in existing_tables: missing_optional_tables.append(table) if "user_alerts" not in existing_tables: missing_optional_tables.append(UserAlert.__table__) if "financial_years" not in existing_tables: missing_optional_tables.append(FinancialYear.__table__) notice_case_tables = [ ("notice_cases", NoticeCase.__table__), ("notice_case_events", NoticeCaseEvent.__table__), ("notice_case_hearings", NoticeCaseHearing.__table__), ("notice_case_orders", NoticeCaseOrder.__table__), ("notice_case_documents", NoticeCaseDocument.__table__), ] for table_name, table in notice_case_tables: if table_name not in existing_tables: missing_optional_tables.append(table) if "employees" not in existing_tables: missing_optional_tables.append(Employee.__table__) if "employee_registration_requests" not in existing_tables and "employees" in existing_tables: missing_optional_tables.append(EmployeeRegistrationRequest.__table__) if "employee_attendance" not in existing_tables and "employees" in existing_tables: missing_optional_tables.append(EmployeeAttendance.__table__) if "employee_onboarding_checklist_items" not in existing_tables and "employees" in existing_tables: missing_optional_tables.append(EmployeeOnboardingChecklistItem.__table__) if "employee_onboarding_tasks" not in existing_tables and "employees" in existing_tables: missing_optional_tables.append(EmployeeOnboardingTask.__table__) if "employee_offboarding_requests" not in existing_tables and "employees" in existing_tables: missing_optional_tables.append(EmployeeOffboardingRequest.__table__) if "employee_offboarding_tasks" not in existing_tables and "employees" in existing_tables: missing_optional_tables.append(EmployeeOffboardingTask.__table__) if "employee_salary_structures" not in existing_tables and "employees" in existing_tables: missing_optional_tables.append(EmployeeSalaryStructure.__table__) if "employee_payroll_runs" not in existing_tables and "employees" in existing_tables: missing_optional_tables.append(EmployeePayrollRun.__table__) if "employee_payslips" not in existing_tables and "employees" in existing_tables: missing_optional_tables.append(EmployeePayslip.__table__) if "consultant_workspaces" not in existing_tables and "consultant_profiles" in existing_tables: missing_optional_tables.append(ConsultantWorkspace.__table__) if "consultant_service_requests" not in existing_tables and "consultant_profiles" in existing_tables: missing_optional_tables.append(ConsultantServiceRequest.__table__) if missing_optional_tables: CommonBase.metadata.create_all(bind=CommonEngine, tables=missing_optional_tables) if not required_tables.issubset(existing_tables): raise RuntimeError("Database schema is not initialized. Run 'alembic upgrade head' first.") _ensure_user_lifecycle_columns() db = CommonSessionLocal() try: tenant = db.execute(select(Tenant).where(Tenant.code == s.DEFAULT_TENANT_CODE)).scalar_one_or_none() if not tenant: tenant = Tenant(code=s.DEFAULT_TENANT_CODE, name="Default Tenant", is_active=True) db.add(tenant) db.commit() db.refresh(tenant) branch = db.execute( select(Branch).where(Branch.tenant_id == tenant.id, Branch.code == s.DEFAULT_BRANCH_CODE) ).scalar_one_or_none() if not branch: branch = Branch( tenant_id=tenant.id, code=s.DEFAULT_BRANCH_CODE, name="Main Branch", timezone=s.DEFAULT_TIMEZONE, is_active=True, allow_login=True, ) db.add(branch) db.commit() db.refresh(branch) bs = db.execute(select(BranchSettings).where(BranchSettings.branch_id == branch.id)).scalar_one_or_none() if not bs: bs = BranchSettings(branch_id=branch.id) db.add(bs) db.commit() _ensure_financial_years_for_all_tenants(db, s.DEFAULT_YEAR_CODE) for legacy_name, new_name in LEGACY_ROLE_RENAMES.items(): legacy_role = db.execute(select(Role).where(Role.name == legacy_name)).scalar_one_or_none() target_role = db.execute(select(Role).where(Role.name == new_name)).scalar_one_or_none() if legacy_role and not target_role: legacy_role.name = new_name elif legacy_role and target_role: for user_role in db.execute( select(UserRole).where(UserRole.role_id == legacy_role.id) ).scalars().all(): exists = db.execute( select(UserRole).where( UserRole.user_id == user_role.user_id, UserRole.role_id == target_role.id, ) ).scalar_one_or_none() if not exists: db.add(UserRole(user_id=user_role.user_id, role_id=target_role.id)) db.flush() db.delete(legacy_role) db.commit() for role_name in DEFAULT_ROLES: exists = db.execute(select(Role).where(Role.name == role_name)).scalar_one_or_none() if not exists: db.add(Role(name=role_name, is_active=True)) db.commit() for code, name in DEFAULT_PERMISSIONS: exists = db.execute(select(Permission).where(Permission.code == code)).scalar_one_or_none() if not exists: db.add(Permission(code=code, name=name, is_active=True)) db.commit() roles = {r.name: r for r in db.execute(select(Role)).scalars().all()} permissions = {p.code: p for p in db.execute(select(Permission)).scalars().all()} for role_name, permission_codes in ROLE_PERMISSION_MAP.items(): role = roles.get(role_name) if not role: continue permission_codes = list(dict.fromkeys(permission_codes)) for code in permission_codes: permission = permissions.get(code) if not permission: continue exists = db.execute( select(RolePermission).where( RolePermission.role_id == role.id, RolePermission.permission_id == permission.id, ) ).scalar_one_or_none() if not exists: db.add(RolePermission(role_id=role.id, permission_id=permission.id)) db.commit() # Enforce the updated billing privilege matrix for existing databases. # This removes stale billing permissions from System Admin and grants # Partner own-client billing privileges without altering other modules. billing_permissions = { code: permissions[code] for code in BILLING_PERMISSION_CODES if code in permissions } for role_name, allowed_codes in BILLING_ROLE_PERMISSION_SYNC.items(): role = roles.get(role_name) if not role: continue allowed_permission_ids = { billing_permissions[code].id for code in allowed_codes if code in billing_permissions } billing_permission_ids = {permission.id for permission in billing_permissions.values()} existing_links = db.execute( select(RolePermission).where( RolePermission.role_id == role.id, RolePermission.permission_id.in_(billing_permission_ids), ) ).scalars().all() if billing_permission_ids else [] existing_ids = {link.permission_id for link in existing_links} for link in existing_links: if link.permission_id not in allowed_permission_ids: db.delete(link) for permission_id in allowed_permission_ids - existing_ids: db.add(RolePermission(role_id=role.id, permission_id=permission_id)) db.commit() any_user = db.execute(select(User.id)).first() if not any_user: admin = User( email=s.BOOTSTRAP_ADMIN_EMAIL, full_name="System Admin", password_hash=hash_password(s.BOOTSTRAP_ADMIN_PASSWORD), tenant_id=tenant.id, branch_id=branch.id, is_active=True, allow_login=True, is_locked=False, deleted_at=None, ) db.add(admin) db.commit() db.refresh(admin) if roles.get("System Admin"): exists = db.execute( select(UserRole).where( UserRole.user_id == admin.id, UserRole.role_id == roles["System Admin"].id, ) ).scalar_one_or_none() if not exists: db.add(UserRole(user_id=admin.id, role_id=roles["System Admin"].id)) db.commit() finally: db.close() # Phase 7O: start alert notification/escalation automation after schema and seed checks. start_notification_scheduler()