from typing import Optional, List, Dict, Any from sqlalchemy import or_, and_, select, case, nulls_first from sqlalchemy.orm import Session from .models import AppSetting import logging logger = logging.getLogger(__name__) from decimal import Decimal def convert_decimals(obj: Any) -> Any: """ Recursively converts Decimal objects to floats for JSON serialization. Handles nested structures and None values. """ if obj is None: return None if isinstance(obj, list): return [convert_decimals(i) for i in obj] elif isinstance(obj, dict): return {k: convert_decimals(v) for k, v in obj.items()} elif isinstance(obj, Decimal): return float(obj) return obj def deep_merge(dict1: Dict[str, Any], dict2: Dict[str, Any]) -> Dict[str, Any]: """ Recursively merges dict2 into dict1. """ for key, value in dict2.items(): if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict): deep_merge(dict1[key], value) else: dict1[key] = value return dict1 class AppSettingsService: """ Service to manage hierarchical configuration overrides. Hierarchy: System (Global) -> Tenant -> Company. """ @staticmethod def get_resolved_settings(db: Session, tenant_id: int, company_id: int) -> Dict[str, Any]: """ Retrieves settings from all levels (Global -> Tenant -> Company) and merges them. Treats 0 as None for hierarchy resolution. """ # Normalize 0 to None for context-less lookups t_id = tenant_id if tenant_id and tenant_id > 0 else None c_id = company_id if company_id and company_id > 0 else None stmt = ( select(AppSetting) .where( or_( and_(AppSetting.tenant_id.is_(None), AppSetting.company_id.is_(None)), and_(AppSetting.tenant_id == t_id, AppSetting.company_id.is_(None)) if t_id else False, and_(AppSetting.tenant_id == t_id, AppSetting.company_id == c_id) if t_id and c_id else False, ) ) .order_by( # Ensure the order is: Global (1) -> Tenant (2) -> Company (3) case( (and_(AppSetting.tenant_id.is_(None), AppSetting.company_id.is_(None)), 1), (and_(AppSetting.tenant_id.is_not(None), AppSetting.company_id.is_(None)), 2), (and_(AppSetting.tenant_id.is_not(None), AppSetting.company_id.is_not(None)), 3), else_=4 ).asc() ) ) results = db.execute(stmt).scalars().all() logger.info(f"RESOLVE: Found {len(results)} rows for Hierarchy") resolved_settings = {} for row in results: level_name = "GLOBAL" if not row.tenant_id else ("TENANT" if not row.company_id else "COMPANY") # Use safe data logging to avoid crashes settings_data = row.settings if row.settings else {} keys = list(settings_data.keys()) if isinstance(settings_data, dict) else "not-a-dict" logger.info(f"RESOLVE: Merging {level_name} layer with keys: {keys}") if isinstance(settings_data, dict): deep_merge(resolved_settings, settings_data) return resolved_settings @staticmethod def upsert_settings(db: Session, tenant_id: Optional[int], company_id: Optional[int], settings: Dict[str, Any]) -> AppSetting: """ Inserts or updates settings for a specific level. Treats 0 as None. """ # Normalize IDs: 0 or None means Global context at that level tenant_id = tenant_id if tenant_id and tenant_id > 0 else None company_id = company_id if company_id and company_id > 0 else None level_label = f"level(tenant={tenant_id}, company={company_id})" logger.info(f"UPSERT Settings START: {level_label}, keys_to_update={list(settings.keys())}") # Ensure all Decimals are converted to floats before deep merge and save settings = convert_decimals(settings) stmt = select(AppSetting).where( and_( AppSetting.tenant_id == tenant_id if tenant_id is not None else AppSetting.tenant_id.is_(None), AppSetting.company_id == company_id if company_id is not None else AppSetting.company_id.is_(None) ) ) existing = db.execute(stmt).scalar_one_or_none() if existing: # Deep merge at the root level (merging categories like ssisgen, ssismex, etc.) logger.info(f"UPSERT: Updating existing row ID={existing.id}") # Create a shallow copy of the top-level dict to ensure SQLAlchemy sees a new reference new_settings = dict(existing.settings) if existing.settings else {} # Detailed logging of what's changing for cat, data in settings.items(): old_keys = list(new_settings.get(cat, {}).keys()) new_keys = list(data.keys()) if isinstance(data, dict) else [] logger.info(f"UPSERT: Merging category [{cat}]. Old keys: {old_keys}, New keys to merge/overwrite: {new_keys}") logger.info(f"UPSERT: Merging {len(settings)} top-level categories into existing row.") deep_merge(new_settings, settings) # Second pass of conversion (merged results might still have Decimals if original row had them) existing.settings = convert_decimals(new_settings) from sqlalchemy.orm.attributes import flag_modified flag_modified(existing, "settings") logger.info(f"UPSERT: Row updated and flagged as modified. Fields in ssisgen root: {list(new_settings.get('ssisgen', {}).keys())[:10]}...") else: logger.info(f"UPSERT: Creating NEW row for {level_label}") existing = AppSetting( tenant_id=tenant_id, company_id=company_id, settings=settings ) db.add(existing) try: db.commit() db.refresh(existing) logger.info(f"UPSERT SUCCESS: Row ID={existing.id}, Final Settings Hash Keys={list(existing.settings.keys())}") except Exception as e: db.rollback() logger.error(f"UPSERT FAILED: {str(e)}") raise e return existing