Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user