* feat: per-site configurable cookie categories
Operators can now choose which cookie categories the banner displays
for a given site — useful for sites that genuinely don't use
e.g. marketing cookies and shouldn't be forced to show the toggle.
**Backend**
* New ``enabled_categories`` JSONB column on ``site_configs``,
``site_group_configs``, and ``org_configs`` (migration 0003).
NULL at a level means "inherit"; an explicit list overrides.
* ``config_resolver`` merges ``enabled_categories`` through the
existing cascade (system → org → group → site) and normalises
the result via ``_normalise_enabled_categories``:
- Unknown slugs stripped.
- ``necessary`` is forced in regardless of the operator's input
— it's never optional.
- Empty / invalid values fall back to the full five-category
default so a cleared field doesn't silently hide the banner.
- Output is returned in canonical display order so insertion
order from the cascade doesn't leak into the UI.
* ``build_public_config`` surfaces ``enabled_categories`` to the
banner-facing public config endpoint.
* Schemas for site/group/org config create + update + response all
include the new field.
**Banner**
* ``apps/banner/src/banner.ts`` replaces the hard-coded
``ALL_CATEGORIES`` / ``NON_ESSENTIAL`` constants with a runtime
``resolveEnabledCategories(config)`` helper. ``renderCategories``
takes the enabled list and only renders toggles for those
categories; ``nonEssentialFor(enabled)`` derives the user-toggleable
subset. Falls back to all five when the field is missing in the
config payload so older banner bundles against newer APIs (and
vice versa) don't break.
* ``SiteConfig`` type in ``apps/banner/src/types.ts`` has
``enabled_categories?: CategorySlug[]`` to match.
**Admin UI**
* New ``SiteCategoriesTab`` component — five checkboxes, ``necessary``
locked on, with "Reset to inherited" to clear the site override.
Wired in as a new core tab on ``SiteDetailPage`` between
Configuration and Cookies.
* ``SiteConfig`` type in ``types/api.ts`` declares ``enabled_categories``
and a new ``ALL_COOKIE_CATEGORIES`` constant exposing label/description
metadata shared between the tab component and any future display of
the list.
**Semantics of a disabled category**
When the operator unticks e.g. ``marketing`` for a site:
* The toggle is not rendered in the banner.
* A visitor can never grant consent for ``marketing``.
* Any cookie or script that classifies into ``marketing`` stays
blocked permanently by the auto-blocker.
That's the correct behaviour for sites that genuinely don't use a
category: declare it, hide it from the visitor, have the blocker
enforce it.
**Tests**
* ``test_config_resolver.py`` — 13 new cases covering the full
cascade, ``necessary`` forcing, unknown-slug stripping, empty /
non-list values, canonical display order, and the public-config
surface. 37 passed total.
* ``test_SiteCategoriesTab.test.tsx`` — renders all five, locks
``necessary``, pre-fills from an override, saves the explicit
list, and resets to inherited by sending NULL. 6 cases.
* Full API suite (610) and admin-ui suite (139) both green;
banner bundle builds cleanly with 363 tests passing.
* style: ruff format config_resolver.py
206 lines
7.1 KiB
Python
206 lines
7.1 KiB
Python
"""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
|
|
|
|
# Every known cookie category, in the canonical display order the
|
|
# banner uses. The system default for ``enabled_categories`` is this
|
|
# full list; operators subset from the top via the cascade.
|
|
ALL_CATEGORIES: list[str] = [
|
|
"necessary",
|
|
"functional",
|
|
"analytics",
|
|
"marketing",
|
|
"personalisation",
|
|
]
|
|
|
|
# ``necessary`` is never optional — operators can't hide it and the
|
|
# merged result always contains it, even if it's been accidentally
|
|
# dropped from every layer of the cascade.
|
|
REQUIRED_CATEGORIES: frozenset[str] = frozenset({"necessary"})
|
|
|
|
|
|
# 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,
|
|
# All five categories visible by default; any cascade layer may
|
|
# narrow this to a subset. The resolver normalises the result
|
|
# via ``_normalise_enabled_categories``.
|
|
"enabled_categories": ALL_CATEGORIES,
|
|
}
|
|
|
|
|
|
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
|
|
|
|
resolved["enabled_categories"] = _normalise_enabled_categories(
|
|
resolved.get("enabled_categories")
|
|
)
|
|
|
|
return resolved
|
|
|
|
|
|
def _normalise_enabled_categories(value: Any) -> list[str]:
|
|
"""Clean a merged ``enabled_categories`` value into a canonical list.
|
|
|
|
- ``None`` / empty / invalid types fall back to the full default.
|
|
- Unknown slugs are stripped so a typo can't light up a category
|
|
the banner doesn't actually render.
|
|
- ``necessary`` is always forced into the output — required
|
|
categories can never be absent, regardless of what the operator
|
|
configured. The order mirrors ``ALL_CATEGORIES`` so the banner
|
|
renders tabs in a consistent order no matter the insertion order.
|
|
"""
|
|
if not isinstance(value, list) or not value:
|
|
return list(ALL_CATEGORIES)
|
|
|
|
known = set(ALL_CATEGORIES)
|
|
picked = {slug for slug in value if isinstance(slug, str) and slug in known}
|
|
picked.update(REQUIRED_CATEGORIES)
|
|
return [slug for slug in ALL_CATEGORIES if slug in picked]
|
|
|
|
|
|
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"),
|
|
# Public name is ``enabled_categories`` here; the banner schema
|
|
# converts that to ``enabledCategories`` when it serialises.
|
|
"enabled_categories": _normalise_enabled_categories(resolved.get("enabled_categories")),
|
|
}
|
|
|
|
|
|
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",
|
|
"enabled_categories",
|
|
)
|
|
|
|
|
|
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
|