"""Configuration hierarchy resolver. Resolves site configuration by merging: System Defaults → Org Defaults → Site Group Defaults → Site Config → Regional Overrides Produces a fully resolved public config suitable for the banner script. """ from __future__ import annotations from typing import Any # System-level defaults (hard-coded, lowest priority) SYSTEM_DEFAULTS: dict[str, Any] = { "blocking_mode": "opt_in", "tcf_enabled": False, "gpp_enabled": True, "gpp_supported_apis": ["usnat"], "gpc_enabled": True, "gpc_jurisdictions": ["US-CA", "US-CO", "US-CT", "US-TX", "US-MT"], "gpc_global_honour": False, "gcm_enabled": True, "shopify_privacy_enabled": False, "gcm_default": { "ad_storage": "denied", "ad_user_data": "denied", "ad_personalization": "denied", "analytics_storage": "denied", "functionality_storage": "denied", "personalization_storage": "denied", "security_storage": "granted", }, "banner_config": None, "privacy_policy_url": None, "terms_url": None, "consent_expiry_days": 365, } def resolve_config( site_config: dict[str, Any], org_defaults: dict[str, Any] | None = None, group_defaults: dict[str, Any] | None = None, region: str | None = None, ) -> dict[str, Any]: """Resolve the full configuration by merging layers. Args: site_config: Site-specific configuration from the database. org_defaults: Organisation-level default overrides (optional). group_defaults: Site-group-level default overrides (optional). region: ISO region code for regional mode override (optional). Returns: Fully resolved configuration dictionary. """ # Start with system defaults resolved = {**SYSTEM_DEFAULTS} # Apply organisation defaults (if any) if org_defaults: _merge_non_none(resolved, org_defaults) # Apply site group defaults (if any) if group_defaults: _merge_non_none(resolved, group_defaults) # Apply site-specific config _merge_non_none(resolved, site_config) # Apply regional blocking mode override if region and site_config.get("regional_modes"): regional_modes = site_config["regional_modes"] if isinstance(regional_modes, dict): # Try exact match first, then fall back to DEFAULT regional_mode = regional_modes.get(region) or regional_modes.get("DEFAULT") if regional_mode: resolved["blocking_mode"] = regional_mode return resolved def build_public_config( site_id: str, resolved: dict[str, Any], ) -> dict[str, Any]: """Build a public configuration JSON for the banner script. Strips internal fields and adds the site_id for identification. """ return { "id": resolved.get("id", ""), "site_id": site_id, "blocking_mode": resolved["blocking_mode"], "regional_modes": resolved.get("regional_modes"), "tcf_enabled": resolved["tcf_enabled"], "gpp_enabled": resolved["gpp_enabled"], "gpp_supported_apis": resolved.get("gpp_supported_apis"), "gpc_enabled": resolved["gpc_enabled"], "gpc_jurisdictions": resolved.get("gpc_jurisdictions"), "gpc_global_honour": resolved["gpc_global_honour"], "gcm_enabled": resolved["gcm_enabled"], "gcm_default": resolved.get("gcm_default"), "shopify_privacy_enabled": resolved["shopify_privacy_enabled"], "banner_config": resolved.get("banner_config"), "privacy_policy_url": resolved.get("privacy_policy_url"), "terms_url": resolved.get("terms_url"), "consent_expiry_days": resolved["consent_expiry_days"], "consent_group_id": resolved.get("consent_group_id"), "ab_test": resolved.get("ab_test"), } CONFIG_FIELDS = ( "blocking_mode", "regional_modes", "tcf_enabled", "tcf_publisher_cc", "gpp_enabled", "gpp_supported_apis", "gpc_enabled", "gpc_jurisdictions", "gpc_global_honour", "gcm_enabled", "gcm_default", "shopify_privacy_enabled", "banner_config", "privacy_policy_url", "terms_url", "consent_expiry_days", ) def orm_to_config_dict(obj: Any, *, include_id: bool = False) -> dict[str, Any]: """Convert a SiteConfig or OrgConfig ORM object to a dict of config fields. Only includes fields that are explicitly set (not NULL). This allows the hierarchy to work correctly: unset fields at higher-priority layers don't block inheritance from lower-priority layers. """ d: dict[str, Any] = {} if include_id and hasattr(obj, "id"): d["id"] = str(obj.id) for field in CONFIG_FIELDS: if hasattr(obj, field): value = getattr(obj, field) if value is not None: d[field] = value return d def _merge_non_none(target: dict[str, Any], source: dict[str, Any]) -> None: """Merge source into target, skipping None values in source.""" for key, value in source.items(): if value is not None: target[key] = value