Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
View File
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from app.modules.clients.api import router as clients_api
from app.modules.core.iam.api import router as users_api
from app.modules.core.iam.auth_api import router as auth_api
from app.modules.core.rbac.api import router as rbac_api
from app.modules.core.tenancy.api import router as tenancy_api
from app.modules.system.health.api import router as health_api
api_router = APIRouter()
api_router.include_router(health_api)
api_router.include_router(tenancy_api)
api_router.include_router(rbac_api)
api_router.include_router(auth_api)
api_router.include_router(users_api)
api_router.include_router(clients_api)
View File
+9
View File
@@ -0,0 +1,9 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.core.db.urls import get_common_db_url
class CommonBase(DeclarativeBase):
pass
CommonEngine = create_engine(get_common_db_url(), pool_pre_ping=True, future=True)
CommonSessionLocal = sessionmaker(bind=CommonEngine, autocommit=False, autoflush=False, future=True)
+10
View File
@@ -0,0 +1,10 @@
from typing import Generator
from sqlalchemy.orm import Session
from app.core.db.common import CommonSessionLocal
def get_common_db() -> Generator[Session, None, None]:
db = CommonSessionLocal()
try:
yield db
finally:
db.close()
+30
View File
@@ -0,0 +1,30 @@
import os
from sqlalchemy.engine import URL
from app.core.settings import get_settings
def sqlite_url(path: str) -> str:
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
return f"sqlite+pysqlite:///{path}"
def postgres_url(user: str, password: str, host: str, port: int, db: str) -> str:
return URL.create(
drivername="postgresql+psycopg",
username=user,
password=password,
host=host,
port=port,
database=db,
).render_as_string(hide_password=False)
def get_common_db_url() -> str:
s = get_settings()
if s.DB_BACKEND.lower() == "sqlite":
return sqlite_url(s.SQLITE_COMMON_PATH)
return postgres_url(s.PG_USER, s.PG_PASSWORD, s.PG_HOST, s.PG_PORT, s.PG_DB_COMMON)
View File
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from ipaddress import ip_address, ip_network
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.types import ASGIApp
from app.core.settings import get_settings
_CONTEXT_SECRET_HEADER = "X-AuditFirm-Context-Secret"
_TENANT_HEADER = "X-Tenant-Code"
_BRANCH_HEADER = "X-Branch-Code"
_YEAR_HEADER = "X-Year-Code"
def _csv_values(value: str | None) -> list[str]:
return [item.strip() for item in (value or "").split(",") if item.strip()]
def _safe_env(value: str | None) -> str:
return (value or "").strip().lower()
def _host_matches_trusted_entry(client_host: str, trusted_entry: str) -> bool:
"""Return True when client_host matches a trusted host/IP/CIDR entry.
Deliberately does not support '*' wildcard. For Docker/Coolify internal
networks, use an explicit CIDR such as 172.16.0.0/12.
"""
client_host = (client_host or "").strip().lower()
trusted_entry = (trusted_entry or "").strip().lower()
if not client_host or not trusted_entry:
return False
if client_host == trusted_entry:
return True
try:
client_ip = ip_address(client_host)
except ValueError:
return False
try:
if "/" in trusted_entry:
return client_ip in ip_network(trusted_entry, strict=False)
return client_ip == ip_address(trusted_entry)
except ValueError:
return False
def _normalise_session_int(value):
if value in (None, "", 0, "0"):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
class ContextResolveMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
self.s = get_settings()
def _context_headers_are_trusted(self, request: Request) -> bool:
"""Permit context headers only from trusted internal callers.
Public users must not be able to switch tenant/branch/FY by adding
X-Tenant-Code, X-Branch-Code or X-Year-Code headers. The production-safe
default is TRUST_CONTEXT_HEADERS=false.
"""
if not bool(getattr(self.s, "TRUST_CONTEXT_HEADERS", False)):
return False
required_secret = (getattr(self.s, "CONTEXT_HEADER_SECRET", "") or "").strip()
if required_secret:
supplied_secret = (request.headers.get(_CONTEXT_SECRET_HEADER) or "").strip()
if supplied_secret != required_secret:
return False
elif _safe_env(getattr(self.s, "ENV", "")) in {"prod", "production"}:
return False
client_host = request.client.host if request.client else ""
trusted_entries = _csv_values(getattr(self.s, "TRUST_CONTEXT_HEADER_HOSTS", ""))
return any(_host_matches_trusted_entry(client_host, item) for item in trusted_entries)
async def dispatch(self, request: Request, call_next):
# Trusted production context priority:
# 1) Authenticated UI session selected tenant/branch/FY.
# 2) Domain resolver mapping for pre-login/domain-routed requests.
# 3) Trusted internal headers only when explicitly enabled with secret/host.
# 4) Application defaults.
session = request.scope.get("session") or {}
trust_headers = self._context_headers_are_trusted(request)
session_tenant_id = _normalise_session_int(session.get("active_tenant_id") or session.get("tenant_id"))
session_branch_id = _normalise_session_int(session.get("active_branch_id") or session.get("branch_id"))
session_tenant_code = (session.get("active_tenant_code") or session.get("tenant_code") or "").strip() or None
session_branch_code = (session.get("active_branch_code") or session.get("branch_code") or "").strip() or None
domain_tenant_code = getattr(request.state, "domain_tenant_code", None)
domain_branch_code = getattr(request.state, "domain_branch_code", None)
tenant_code = (
session_tenant_code
or domain_tenant_code
or (request.headers.get(_TENANT_HEADER) if trust_headers else None)
or self.s.DEFAULT_TENANT_CODE
)
branch_code = (
session_branch_code
or domain_branch_code
or (request.headers.get(_BRANCH_HEADER) if trust_headers else None)
or self.s.DEFAULT_BRANCH_CODE
)
year_code = (
session.get("active_financial_year")
or (request.headers.get(_YEAR_HEADER) if trust_headers else None)
or self.s.DEFAULT_YEAR_CODE
)
request.state.active_tenant_id = session_tenant_id
request.state.active_branch_id = session_branch_id
request.state.tenant_code = tenant_code
request.state.branch_code = branch_code
request.state.year_code = year_code
request.state.context_headers_trusted = trust_headers
return await call_next(request)
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.types import ASGIApp
from app.core.db.common import CommonSessionLocal
from app.modules.domain_management.services import normalize_request_host, resolve_domain_context
class DomainResolverMiddleware(BaseHTTPMiddleware):
"""Resolve request host to platform / tenant / consultant context.
Phase 7T.2 is intentionally read-only:
- It does not redirect users.
- It does not change database records.
- It does not override logged-in user permissions.
- It only exposes a trusted runtime context on request.state.
Later phases use this context for branding, marketplace mode, tenant subdomains,
consultant domains, and custom domain verification.
"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next):
host_header = request.headers.get("x-forwarded-host") or request.headers.get("host")
host = normalize_request_host(host_header)
# Safe defaults; every template/route can read these without checking existence.
request.state.request_host = host
request.state.domain_resolved = False
request.state.domain_mapping_id = None
request.state.domain_name = host
request.state.domain_type = None
request.state.domain_tenant_id = None
request.state.domain_tenant_code = None
request.state.domain_branch_id = None
request.state.domain_branch_code = None
request.state.domain_consultant_id = None
request.state.domain_parent_tenant_id = None
request.state.domain_is_verified = False
request.state.domain_status = None
request.state.domain_context = {
"is_resolved": False,
"host": host,
"mapping_id": None,
"domain_name": host,
"domain_type": None,
"tenant_id": None,
"tenant_code": None,
"branch_id": None,
"branch_code": None,
"consultant_id": None,
"parent_tenant_id": None,
"is_verified": False,
"status": None,
}
# Static files and empty/invalid host can proceed without DB lookup.
if host and not request.url.path.startswith("/static/"):
db = CommonSessionLocal()
try:
resolved = resolve_domain_context(db, host)
if resolved.is_resolved:
request.state.domain_resolved = True
request.state.domain_mapping_id = resolved.mapping_id
request.state.domain_name = resolved.domain_name
request.state.domain_type = resolved.domain_type
request.state.domain_tenant_id = resolved.tenant_id
request.state.domain_tenant_code = resolved.tenant_code
request.state.domain_branch_id = resolved.branch_id
request.state.domain_branch_code = resolved.branch_code
request.state.domain_consultant_id = resolved.consultant_id
request.state.domain_parent_tenant_id = resolved.parent_tenant_id
request.state.domain_is_verified = resolved.is_verified
request.state.domain_status = resolved.status
request.state.domain_context = {
"is_resolved": True,
"host": resolved.host,
"mapping_id": resolved.mapping_id,
"domain_name": resolved.domain_name,
"domain_type": resolved.domain_type,
"tenant_id": resolved.tenant_id,
"tenant_code": resolved.tenant_code,
"branch_id": resolved.branch_id,
"branch_code": resolved.branch_code,
"consultant_id": resolved.consultant_id,
"parent_tenant_id": resolved.parent_tenant_id,
"is_verified": resolved.is_verified,
"status": resolved.status,
}
except Exception:
# Domain resolution must never take the ERP down. If the domain table is
# missing during deployment or DB is temporarily unavailable, continue
# with the normal default context.
pass
finally:
db.close()
response = await call_next(request)
if getattr(request.state, "domain_resolved", False):
response.headers["X-AuditFirm-Domain-Resolved"] = "1"
response.headers["X-AuditFirm-Domain-Type"] = str(getattr(request.state, "domain_type", "") or "")
return response
+21
View File
@@ -0,0 +1,21 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
resp = await call_next(request)
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
resp.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
# CSP: allow Tailwind CDN only
resp.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none';"
)
return resp
View File
+16
View File
@@ -0,0 +1,16 @@
import secrets
from fastapi import Request
CSRF_KEY = "csrf_token"
def get_or_create_csrf_token(request: Request) -> str:
token = request.session.get(CSRF_KEY)
if not token:
token = secrets.token_urlsafe(32)
request.session[CSRF_KEY] = token
return token
def validate_csrf(request: Request, form_token: str | None) -> None:
token = request.session.get(CSRF_KEY)
if not token or not form_token or token != form_token:
raise PermissionError("CSRF validation failed")
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from fastapi import Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from sqlalchemy import select
from app.core.db.deps import get_common_db
from app.core.security.jwt_tokens import decode_token
from app.modules.core.iam.models import User
bearer = HTTPBearer(auto_error=False)
def get_current_user_jwt(
creds: HTTPAuthorizationCredentials | None = Depends(bearer),
db: Session = Depends(get_common_db),
) -> User | None:
if not creds or not creds.credentials:
return None
data = decode_token(creds.credentials)
if data.get("typ") != "access":
return None
user_id = int(data.get("sub", 0) or 0)
if not user_id:
return None
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user or not user.is_active:
return None
return user
def require_jwt_user(user: User | None = Depends(get_current_user_jwt)) -> User:
if not user:
raise PermissionError("Not authenticated (JWT)")
return user
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from jwt import PyJWTError
from app.core.settings import get_settings
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def encode_access_token(payload: dict[str, Any], expires_minutes: int) -> str:
s = get_settings()
now = utcnow()
exp = now + timedelta(minutes=expires_minutes)
token_payload = {
**payload,
"iss": s.JWT_ISSUER,
"aud": s.JWT_AUDIENCE,
"iat": int(now.timestamp()),
"exp": int(exp.timestamp()),
"typ": "access",
}
return jwt.encode(token_payload, s.SECRET_KEY, algorithm="HS256")
def decode_token(token: str) -> dict[str, Any]:
s = get_settings()
try:
data = jwt.decode(
token,
s.SECRET_KEY,
algorithms=["HS256"],
audience=s.JWT_AUDIENCE,
issuer=s.JWT_ISSUER,
options={"require": ["exp", "iat", "iss", "aud"]},
)
return data
except PyJWTError as e:
raise PermissionError("Invalid token") from e
+23
View File
@@ -0,0 +1,23 @@
import secrets
from fastapi import Request
OTP_CODE_KEY = "otp_code"
OTP_VERIFIED_KEY = "otp_verified"
def start_otp(request: Request) -> str:
# 6-digit numeric code
code = str(secrets.randbelow(900000) + 100000)
request.session[OTP_CODE_KEY] = code
request.session[OTP_VERIFIED_KEY] = False
return code
def verify_otp(request: Request, code: str) -> bool:
expected = request.session.get(OTP_CODE_KEY)
if expected and code and code.strip() == expected:
request.session[OTP_VERIFIED_KEY] = True
request.session.pop(OTP_CODE_KEY, None)
return True
return False
def is_otp_verified(request: Request) -> bool:
return bool(request.session.get(OTP_VERIFIED_KEY))
+8
View File
@@ -0,0 +1,8 @@
from passlib.context import CryptContext
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(p: str) -> str:
return _pwd.hash(p)
def verify_password(p: str, h: str) -> bool:
return _pwd.verify(p, h)
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from sqlalchemy import select
from app.core.db.deps import get_common_db
from app.modules.core.iam.models import User
from app.modules.core.tenancy.models import Branch
from app.modules.core.tenancy.settings_models import BranchSettings
SESSION_USER_ID_KEY = "user_id"
SESSION_LOGIN_AT_KEY = "login_at"
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def get_current_user(request: Request, db: Session = Depends(get_common_db)) -> User | None:
user_id = request.session.get(SESSION_USER_ID_KEY)
if not user_id:
return None
user = db.execute(select(User).where(User.id == int(user_id))).scalar_one_or_none()
if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None:
return None
# Enforce session duration from BranchSettings
login_at = request.session.get(SESSION_LOGIN_AT_KEY)
if login_at:
try:
login_at_dt = datetime.fromisoformat(login_at)
except Exception:
login_at_dt = None
else:
login_at_dt = None
bs = db.execute(select(BranchSettings).where(BranchSettings.branch_id == user.branch_id)).scalar_one_or_none()
max_minutes = bs.session_duration_minutes if bs else 480
if login_at_dt:
if _now_utc() - login_at_dt > timedelta(minutes=max_minutes):
# expire session
request.session.pop(SESSION_USER_ID_KEY, None)
request.session.pop(SESSION_LOGIN_AT_KEY, None)
return None
return user
def require_login(user: User | None = Depends(get_current_user)) -> User:
if not user:
raise PermissionError("Not authenticated")
return user
+76
View File
@@ -0,0 +1,76 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore"
)
APP_NAME: str = "Audit_Firm_v2.0.3.6"
ENV: str = "dev"
DEBUG: bool = True
SECRET_KEY: str = "change-me-to-a-long-random-string"
COOKIE_SECURE: bool = False
COOKIE_SAMESITE: str = "lax"
COOKIE_SESSION_NAME: str = "af2sid"
DB_BACKEND: str = Field(default="sqlite", description="sqlite|postgres")
SQLITE_COMMON_PATH: str = "./data/common.db"
PG_HOST: str = "127.0.0.1"
PG_PORT: int = 5432
PG_USER: str = "postgres"
PG_PASSWORD: str = "postgres"
PG_DB_COMMON: str = "audit_common"
DEFAULT_TENANT_CODE: str = "default"
DEFAULT_BRANCH_CODE: str = "main"
DEFAULT_YEAR_CODE: str = "2025-26"
DEFAULT_TIMEZONE: str = "Asia/Kolkata"
# Public base URL used for email links such as invite and password reset.
# In Coolify production set this to https://your-erp-domain.
ERP_PUBLIC_BASE_URL: str = "http://localhost:8000"
# Print OTP to server logs only in local/dev troubleshooting. Keep false in UAT/production.
DEV_AUTH_OTP_PRINT: bool = False
# Context headers are disabled by default for public deployments.
# When disabled, browser/client supplied X-Tenant-Code, X-Branch-Code,
# and X-Year-Code are ignored. Enable only for trusted internal runners
# or reverse proxies that also restrict/strip external request headers.
TRUST_CONTEXT_HEADERS: bool = False
TRUST_CONTEXT_HEADER_HOSTS: str = "127.0.0.1,localhost,::1"
CONTEXT_HEADER_SECRET: str = ""
# JWT Configuration
JWT_ISSUER: str = "Audit_Firm_v2.0.3.6"
JWT_AUDIENCE: str = "audit_firm_clients"
JWT_ACCESS_MINUTES: int = 15
JWT_REFRESH_DAYS: int = 30
# Bootstrap Admin
BOOTSTRAP_ADMIN_EMAIL: str = "admin@auditfirm.local"
BOOTSTRAP_ADMIN_PASSWORD: str = "ChangeMe@123"
INVITE_TOKEN_HOURS: int = 72
PASSWORD_RESET_HOURS: int = 2
PASSWORD_MIN_LENGTH: int = 8
@lru_cache
def get_settings() -> Settings:
s = Settings()
# Normalize cookie values
s.COOKIE_SAMESITE = (s.COOKIE_SAMESITE or "lax").lower()
if s.COOKIE_SAMESITE not in {"lax", "strict", "none"}:
s.COOKIE_SAMESITE = "lax"
return s
+951
View File
@@ -0,0 +1,951 @@
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",
"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",
"documents.view",
"documents.upload",
"documents.download",
"documents.delete",
"documents.audit.view",
"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",
"documents.view",
"documents.upload",
"documents.download",
"documents.delete",
"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",
"documents.view",
"documents.upload",
"documents.download",
"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",
],
"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()
+622
View File
@@ -0,0 +1,622 @@
from urllib.parse import parse_qsl, urlencode
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
templates = Jinja2Templates(directory="app")
from app.core.db.common import CommonSessionLocal
from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants
from app.modules.core.rbac.ui_permissions import (
can_change_branch_tenant,
can_export_clients,
can_import_service_tasks,
can_import_services,
can_manage_branches,
can_manage_clients,
can_manage_rbac,
can_manage_service_tasks,
can_manage_services,
can_manage_settings,
can_manage_tenants,
can_manage_users,
can_view_employee_dashboard,
can_view_employees,
can_manage_employees,
can_change_employee_status,
can_switch_employee_tenant,
can_switch_employee_branch,
can_view_employee_portal,
can_edit_own_employee_profile,
can_view_own_employee_work,
can_manage_employee_work,
can_view_employee_progress,
can_request_employee_registration,
can_approve_employee_registrations,
can_punch_employee_attendance,
can_view_own_employee_attendance,
can_view_all_employee_attendance,
can_approve_employee_attendance,
can_apply_employee_leave,
can_view_own_employee_leave,
can_view_all_employee_leave,
can_approve_employee_leave,
can_manage_employee_leave_types,
can_manage_employee_leave_balances,
can_view_own_employee_documents,
can_upload_own_employee_documents,
can_view_all_employee_documents,
can_manage_employee_documents,
can_verify_employee_documents,
can_manage_employee_document_types,
can_view_employee_onboarding,
can_manage_employee_onboarding,
can_approve_employee_onboarding,
can_view_employee_offboarding,
can_manage_employee_offboarding,
can_approve_employee_offboarding,
can_request_own_employee_offboarding,
can_import_employee_hr,
can_manage_employee_payroll_structures,
can_run_employee_payroll,
can_view_employee_payroll,
can_view_own_employee_payslips,
can_approve_employee_payroll,
can_view_consultants,
can_manage_consultants,
can_link_consultant_clients,
can_manage_consultant_service_requests,
can_manage_consultant_conversions,
can_view_consultant_portal,
can_manage_own_consultant_workspace,
can_switch_client_branch,
can_switch_client_tenant,
can_switch_service_branch,
can_switch_service_tenant,
can_view_audit,
can_view_branches,
can_view_clients,
can_view_billing,
can_create_billing,
can_generate_billing_invoices,
can_view_billing_fee_structure,
can_import_billing_fee_structure,
can_view_platform_billing,
can_manage_platform_billing,
can_generate_platform_billing,
can_manage_platform_plans,
can_manage_platform_subscriptions,
can_view_marketplace_leads,
can_create_marketplace_leads,
can_assign_marketplace_leads,
can_update_marketplace_leads,
can_convert_marketplace_leads,
can_view_documents,
can_upload_documents,
can_download_documents,
can_delete_documents,
can_view_rbac,
can_view_services,
can_view_settings,
can_view_tenants,
can_view_users,
can_view_own_alerts,
can_manage_alerts,
can_view_notice_cases,
can_manage_notice_cases,
can_upload_notice_case_documents,
can_download_notice_case_documents,
can_delete_notice_case_documents,
)
def build_page_url(base_url: str, page: int, query: str | None = None) -> str:
params = dict(parse_qsl((query or "").lstrip("?"), keep_blank_values=True))
params["page"] = str(page)
qs = urlencode(params)
return f"{base_url}?{qs}" if qs else base_url
def get_active_tenant_id(request, current_user=None):
if not current_user:
return None
return request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None)
def get_active_branch_id(request, current_user=None):
if not current_user:
return None
# return None when "all branches" context is active
val = request.session.get("active_branch_id")
if val in (None, "", 0, "0"):
return None
return val
def get_active_tenant_code(request, current_user=None):
if not current_user:
return None
return request.session.get("active_tenant_code") or request.session.get("tenant_code") or getattr(request.state, "tenant_code", None)
def get_active_branch_code(request, current_user=None):
if not current_user:
return None
val = request.session.get("active_branch_code") or request.session.get("branch_code")
return val or getattr(request.state, "branch_code", None)
def get_active_financial_year(request, current_user=None):
if not current_user:
return None
return request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
def get_active_assessment_year(request, current_user=None):
if not current_user:
return None
fy_code = get_active_financial_year(request, current_user)
if not fy_code:
return None
db = CommonSessionLocal()
try:
from app.modules.core.tenancy.models import FinancialYear
tenant_id = get_active_tenant_id(request, current_user) or getattr(current_user, "tenant_id", None)
fy = db.execute(
select(FinancialYear).where(
FinancialYear.tenant_id == tenant_id,
FinancialYear.year_code == fy_code,
)
).scalar_one_or_none()
return fy.assessment_year if fy else None
except Exception:
return None
finally:
db.close()
def get_unread_alert_count(request, current_user=None):
if not current_user:
return 0
try:
from app.modules.alerts.service import count_unread_alerts
except Exception:
return 0
db = CommonSessionLocal()
try:
return count_unread_alerts(db, current_user)
except Exception:
return 0
finally:
db.close()
def _safe_static_path(path: str | None) -> str | None:
path = (path or "").strip()
if not path:
return None
if path.startswith("/static/"):
return path
if path.startswith("app/ui/static/"):
return "/static/" + path.split("app/ui/static/", 1)[1]
return path
def get_domain_context(request) -> dict:
"""Return safe domain context populated by Phase 7T.2 middleware."""
try:
ctx = getattr(request.state, "domain_context", None)
return ctx if isinstance(ctx, dict) else {"is_resolved": False}
except Exception:
return {"is_resolved": False}
def _branding_default() -> dict:
return {
"firm_name": "Audit Firm ERP",
"branch_name": "",
"logo_url": None,
"favicon_url": None,
"primary_color": "#2563eb",
"accent_color": "#0f172a",
"contact_email": None,
"contact_mobile": None,
"website_url": None,
"domain_name": None,
"domain_type": None,
"domain_resolved": False,
"is_marketplace_domain": False,
"is_consultant_domain": False,
"consultant_name": None,
"consultant_firm_name": None,
}
def _tenant_branding_from_row(tenant, branch=None, default: dict | None = None) -> dict:
default = default or _branding_default()
if not tenant:
return default.copy()
return {
**default,
"firm_name": getattr(tenant, "display_name", None) or getattr(tenant, "name", None) or default["firm_name"],
"branch_name": getattr(branch, "name", None) if branch else "All Branches",
"logo_url": _safe_static_path(getattr(tenant, "logo_path", None)),
"favicon_url": _safe_static_path(getattr(tenant, "favicon_path", None)),
"primary_color": getattr(tenant, "primary_color", None) or default["primary_color"],
"accent_color": getattr(tenant, "accent_color", None) or default["accent_color"],
"contact_email": getattr(tenant, "contact_email", None),
"contact_mobile": getattr(tenant, "contact_mobile", None),
"website_url": getattr(tenant, "website_url", None),
}
def _domain_branding(request, default: dict | None = None) -> dict:
default = default or _branding_default()
ctx = get_domain_context(request)
if not ctx.get("is_resolved"):
return default.copy()
db = CommonSessionLocal()
try:
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.consultants.models import ConsultantProfile
from app.modules.core.iam.models import User
domain_type = ctx.get("domain_type")
tenant_id = ctx.get("tenant_id") or ctx.get("parent_tenant_id")
branch_id = ctx.get("branch_id")
consultant_id = ctx.get("consultant_id")
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() if tenant_id else None
branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() if branch_id else None
branding = _tenant_branding_from_row(tenant, branch, default)
branding.update({
"domain_name": ctx.get("domain_name") or ctx.get("host"),
"domain_type": domain_type,
"domain_resolved": True,
"is_marketplace_domain": domain_type == "marketplace",
"is_consultant_domain": str(domain_type or "").startswith("consultant_"),
})
if domain_type == "marketplace":
branding["firm_name"] = "FilingABC"
branding["branch_name"] = "Marketplace"
return branding
if consultant_id:
consultant = db.execute(select(ConsultantProfile).where(ConsultantProfile.id == consultant_id)).scalar_one_or_none()
if consultant:
consultant_name = getattr(consultant, "contact_person", None) or getattr(consultant, "firm_name", None) or "Consultant"
consultant_firm_name = getattr(consultant, "firm_name", None) or consultant_name
branding["consultant_name"] = consultant_name
branding["consultant_firm_name"] = consultant_firm_name
branding["firm_name"] = consultant_firm_name
branding["branch_name"] = "Consultant Workspace"
branding["contact_email"] = getattr(consultant, "email", None) or branding.get("contact_email")
branding["contact_mobile"] = getattr(consultant, "mobile", None) or branding.get("contact_mobile")
# If the consultant user has a profile photo, use it as the domain logo.
user_id = getattr(consultant, "user_id", None)
if user_id:
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
logo = _safe_static_path(getattr(user, "profile_photo_path", None))
if logo:
branding["logo_url"] = logo
return branding
except Exception:
return default.copy()
finally:
db.close()
def get_current_tenant_name(request, current_user=None):
if not current_user:
return _domain_branding(request).get("firm_name") or "Audit Firm"
db = CommonSessionLocal()
try:
from app.modules.core.tenancy.models import Tenant
tenant_id = get_active_tenant_id(request, current_user)
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none()
if not tenant:
return _domain_branding(request).get("firm_name") or "Audit Firm"
return getattr(tenant, "display_name", None) or tenant.name or "Audit Firm"
except Exception:
return _domain_branding(request).get("firm_name") or "Audit Firm"
finally:
db.close()
def get_current_branch_name(request, current_user=None):
if not current_user:
return _domain_branding(request).get("branch_name") or "-"
db = CommonSessionLocal()
try:
from app.modules.core.tenancy.models import Branch
branch_id = get_active_branch_id(request, current_user) or getattr(current_user, "branch_id", None)
if not branch_id:
return "All Branches"
branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none()
return branch.name if branch else "-"
except Exception:
return "-"
finally:
db.close()
def get_current_firm_branding(request, current_user=None):
default = _branding_default()
# Before login, domain branding is the only safe branding source. This supports
# arrr.associates, auditfirm.filingabc.com, filingabc.com and consultant domains.
if not current_user:
return _domain_branding(request, default)
db = CommonSessionLocal()
try:
from app.modules.core.tenancy.models import Branch, Tenant
tenant_id = get_active_tenant_id(request, current_user)
branch_id = get_active_branch_id(request, current_user) or getattr(current_user, "branch_id", None)
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none()
branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() if branch_id else None
if not tenant:
return _domain_branding(request, default)
branding = _tenant_branding_from_row(tenant, branch, default)
ctx = get_domain_context(request)
if ctx.get("is_resolved"):
branding.update({
"domain_name": ctx.get("domain_name") or ctx.get("host"),
"domain_type": ctx.get("domain_type"),
"domain_resolved": True,
"is_marketplace_domain": ctx.get("domain_type") == "marketplace",
"is_consultant_domain": str(ctx.get("domain_type") or "").startswith("consultant_"),
})
return branding
except Exception:
return _domain_branding(request, default)
finally:
db.close()
def get_user_profile_photo_url(current_user=None):
if not current_user:
return None
try:
from app.modules.core.iam.profile_service import profile_photo_url
return profile_photo_url(current_user)
except Exception:
return None
def get_user_initials(current_user=None):
try:
from app.modules.core.iam.profile_service import user_initials
return user_initials(current_user)
except Exception:
return "U"
def get_client_sidebar_auditor_card(request, current_user=None):
"""Return the client-facing auditor card for the logged-in client user.
This is used only by the sidebar. It reuses Phase 7Q.5 auditor_service and
does not create or alter any business workflow.
"""
if not current_user:
return None
db = CommonSessionLocal()
try:
from app.modules.clients.auditor_service import build_client_auditor_card
from app.modules.clients.models import Client
from app.modules.core.tenancy.models import Branch, Tenant
tenant_id = get_active_tenant_id(request, current_user) or getattr(current_user, "tenant_id", None)
email = (getattr(current_user, "email", None) or "").strip().lower()
stmt = (
select(Client, Tenant.name.label("tenant_name"), Branch.name.label("branch_name"))
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
.join(Branch, Branch.id == Client.branch_id, isouter=True)
.where(Client.is_active.is_(True), Client.is_archived.is_(False))
)
if tenant_id:
stmt = stmt.where(Client.tenant_id == int(tenant_id))
if email:
stmt = stmt.where((Client.portal_user_id == current_user.id) | (Client.email == email) | (Client.alternate_email == email))
else:
stmt = stmt.where(Client.portal_user_id == current_user.id)
result = db.execute(stmt.order_by(Client.id.desc())).first()
if not result:
return None
client, tenant_name, branch_name = result
client_row = {
"id": client.id,
"tenant_id": client.tenant_id,
"branch_id": client.branch_id,
"tenant_name": tenant_name,
"branch_name": branch_name,
"partner_id": client.partner_id,
"default_review_partner_user_id": client.default_review_partner_user_id,
}
return build_client_auditor_card(db, client_row)
except Exception:
return None
finally:
db.close()
def get_context_tenants(request, current_user=None, permissions=None, role_names=None):
if not current_user:
return []
if not (
can_switch_service_tenant(current_user, permissions, role_names)
or can_switch_client_tenant(current_user, permissions, role_names)
or can_switch_employee_tenant(current_user, permissions, role_names)
):
return []
db = CommonSessionLocal()
try:
scope = build_scope(db, current_user)
return list_visible_tenants(db, scope)
finally:
db.close()
def get_context_branches(request, current_user=None, permissions=None, role_names=None):
if not current_user:
return []
if not (
can_switch_service_branch(current_user, permissions, role_names)
or can_switch_client_branch(current_user, permissions, role_names)
or can_switch_employee_branch(current_user, permissions, role_names)
):
return []
db = CommonSessionLocal()
try:
scope = build_scope(db, current_user)
tenant_id = int(get_active_tenant_id(request, current_user) or current_user.tenant_id)
return list_visible_branches(db, scope, tenant_id=tenant_id)
finally:
db.close()
def get_context_financial_years(request, current_user=None, permissions=None, role_names=None):
if not current_user:
return []
db = CommonSessionLocal()
try:
from app.modules.core.tenancy.models import FinancialYear
tenant_id = int(get_active_tenant_id(request, current_user) or current_user.tenant_id)
return db.execute(
select(FinancialYear)
.where(FinancialYear.tenant_id == tenant_id)
.order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc())
).scalars().all()
finally:
db.close()
templates.env.globals.update(
can_view_users=can_view_users,
can_view_own_alerts=can_view_own_alerts,
can_manage_alerts=can_manage_alerts,
can_view_notice_cases=can_view_notice_cases,
can_manage_notice_cases=can_manage_notice_cases,
can_upload_notice_case_documents=can_upload_notice_case_documents,
can_download_notice_case_documents=can_download_notice_case_documents,
can_delete_notice_case_documents=can_delete_notice_case_documents,
can_view_employee_dashboard=can_view_employee_dashboard,
can_view_employees=can_view_employees,
can_manage_employees=can_manage_employees,
can_change_employee_status=can_change_employee_status,
can_switch_employee_tenant=can_switch_employee_tenant,
can_switch_employee_branch=can_switch_employee_branch,
can_view_employee_portal=can_view_employee_portal,
can_edit_own_employee_profile=can_edit_own_employee_profile,
can_view_own_employee_work=can_view_own_employee_work,
can_manage_employee_work=can_manage_employee_work,
can_view_employee_progress=can_view_employee_progress,
can_request_employee_registration=can_request_employee_registration,
can_approve_employee_registrations=can_approve_employee_registrations,
can_punch_employee_attendance=can_punch_employee_attendance,
can_view_own_employee_attendance=can_view_own_employee_attendance,
can_view_all_employee_attendance=can_view_all_employee_attendance,
can_approve_employee_attendance=can_approve_employee_attendance,
can_apply_employee_leave=can_apply_employee_leave,
can_view_own_employee_leave=can_view_own_employee_leave,
can_view_all_employee_leave=can_view_all_employee_leave,
can_approve_employee_leave=can_approve_employee_leave,
can_manage_employee_leave_types=can_manage_employee_leave_types,
can_manage_employee_leave_balances=can_manage_employee_leave_balances,
can_view_own_employee_documents=can_view_own_employee_documents,
can_upload_own_employee_documents=can_upload_own_employee_documents,
can_view_all_employee_documents=can_view_all_employee_documents,
can_manage_employee_documents=can_manage_employee_documents,
can_verify_employee_documents=can_verify_employee_documents,
can_manage_employee_document_types=can_manage_employee_document_types,
can_view_employee_onboarding=can_view_employee_onboarding,
can_manage_employee_onboarding=can_manage_employee_onboarding,
can_approve_employee_onboarding=can_approve_employee_onboarding,
can_view_employee_offboarding=can_view_employee_offboarding,
can_manage_employee_offboarding=can_manage_employee_offboarding,
can_approve_employee_offboarding=can_approve_employee_offboarding,
can_request_own_employee_offboarding=can_request_own_employee_offboarding,
can_import_employee_hr=can_import_employee_hr,
can_manage_employee_payroll_structures=can_manage_employee_payroll_structures,
can_run_employee_payroll=can_run_employee_payroll,
can_view_employee_payroll=can_view_employee_payroll,
can_view_own_employee_payslips=can_view_own_employee_payslips,
can_approve_employee_payroll=can_approve_employee_payroll,
can_manage_users=can_manage_users,
can_view_consultants=can_view_consultants,
can_manage_consultants=can_manage_consultants,
can_link_consultant_clients=can_link_consultant_clients,
can_manage_consultant_service_requests=can_manage_consultant_service_requests,
can_manage_consultant_conversions=can_manage_consultant_conversions,
can_view_consultant_portal=can_view_consultant_portal,
can_manage_own_consultant_workspace=can_manage_own_consultant_workspace,
can_view_settings=can_view_settings,
can_manage_settings=can_manage_settings,
can_view_rbac=can_view_rbac,
can_manage_rbac=can_manage_rbac,
can_view_audit=can_view_audit,
can_view_tenants=can_view_tenants,
can_manage_tenants=can_manage_tenants,
can_view_branches=can_view_branches,
can_manage_branches=can_manage_branches,
can_change_branch_tenant=can_change_branch_tenant,
can_view_services=can_view_services,
can_manage_services=can_manage_services,
can_manage_service_tasks=can_manage_service_tasks,
can_import_services=can_import_services,
can_import_service_tasks=can_import_service_tasks,
can_switch_service_tenant=can_switch_service_tenant,
can_switch_service_branch=can_switch_service_branch,
can_view_clients=can_view_clients,
can_view_billing=can_view_billing,
can_create_billing=can_create_billing,
can_generate_billing_invoices=can_generate_billing_invoices,
can_view_billing_fee_structure=can_view_billing_fee_structure,
can_import_billing_fee_structure=can_import_billing_fee_structure,
can_view_platform_billing=can_view_platform_billing,
can_manage_platform_billing=can_manage_platform_billing,
can_generate_platform_billing=can_generate_platform_billing,
can_manage_platform_plans=can_manage_platform_plans,
can_manage_platform_subscriptions=can_manage_platform_subscriptions,
can_view_marketplace_leads=can_view_marketplace_leads,
can_create_marketplace_leads=can_create_marketplace_leads,
can_assign_marketplace_leads=can_assign_marketplace_leads,
can_update_marketplace_leads=can_update_marketplace_leads,
can_convert_marketplace_leads=can_convert_marketplace_leads,
can_view_documents=can_view_documents,
can_upload_documents=can_upload_documents,
can_download_documents=can_download_documents,
can_delete_documents=can_delete_documents,
can_manage_clients=can_manage_clients,
can_export_clients=can_export_clients,
can_switch_client_tenant=can_switch_client_tenant,
can_switch_client_branch=can_switch_client_branch,
get_unread_alert_count=get_unread_alert_count,
get_current_tenant_name=get_current_tenant_name,
get_current_branch_name=get_current_branch_name,
get_current_firm_branding=get_current_firm_branding,
get_domain_context=get_domain_context,
get_user_profile_photo_url=get_user_profile_photo_url,
get_user_initials=get_user_initials,
get_client_sidebar_auditor_card=get_client_sidebar_auditor_card,
get_context_tenants=get_context_tenants,
get_context_branches=get_context_branches,
get_context_financial_years=get_context_financial_years,
get_active_tenant_id=get_active_tenant_id,
get_active_tenant_code=get_active_tenant_code,
get_active_branch_id=get_active_branch_id,
get_active_branch_code=get_active_branch_code,
get_active_financial_year=get_active_financial_year,
get_active_assessment_year=get_active_assessment_year,
build_page_url=build_page_url,
)