ai_qc/backend/client_config.py
nickviljoen bf89466d06 feat(settings): default-profile UI per client (admin-only) for Box webhook flow
Adds a "Default Profile" sub-tab to the Settings modal. Lists the
current client's profiles as radio buttons, shows which is the active
default and whether it's a runtime override or the static value from
client_config.py. Admins click a different profile + Set to override;
clear-override button reverts to the static value.

Storage layer: backend/client_defaults.json (gitignored, per-server),
following the same pattern as user_access.json. Resolution order in
client_config.get_default_profile(): override → static
default_profile field → None. The Box webhook handler is the sole
consumer that needs profile selection without a logged-in user; it
now reads via get_default_profile() so overrides take effect.

Why a separate JSON, not rewriting client_config.py: a buggy override
write can never break server boot — worst case the override is
ignored and the static value applies. Cleaner separation between
"static config you check in" and "runtime overrides admins make".

Backend:
- client_config.get_default_profile(client_id) — resolver
- client_config.set_default_profile(client_id, profile_id) — validates
  + writes (rejects profiles not in client's profile list)
- client_config.clear_default_profile_override(client_id)
- GET /api/clients/<id>/default_profile (any auth'd user)
- PUT /api/clients/<id>/default_profile (admin-only, _require_admin)
- DELETE /api/clients/<id>/default_profile (admin-only)
- Box webhook handler in api_server.py now uses get_default_profile()

Frontend:
- New "Default Profile" tab button + tab content in Settings modal
- showTab hook loads settings when tab activates
- loadDefaultProfileSettings / saveDefaultProfile /
  clearDefaultProfileOverride functions
- DOM-construction (createElement + textContent) used throughout —
  no innerHTML with interpolated values, so user-controllable
  strings (client_id, profile_id) can never cause XSS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:50:20 +02:00

255 lines
9 KiB
Python

#!/usr/bin/env python3
"""
Client configuration module for managing client-profile relationships
"""
CLIENT_PROFILES = {
'diageo': {
'name': 'Diageo',
'profiles': ['diageo_key_visual', 'diageo_packaging', 'static_general', 'video_general'],
'display_name': 'Diageo',
'description': 'Diageo brand profiles for key visuals and packaging'
},
'unilever': {
'name': 'Unilever',
'profiles': ['unilever_key_visual', 'unilever_packaging', 'static_general', 'video_general'],
'display_name': 'Unilever',
'description': 'Unilever brand profiles for key visuals and packaging'
},
'loreal': {
'name': "L'Oreal",
'profiles': ['loreal_static', 'static_general', 'video_general'],
'display_name': "L'Oreal",
'description': "L'Oreal brand profiles with focused and comprehensive static QC checks",
'box_folder_id': '381501258415',
'box_reports_folder_id': '382076841334',
'default_profile': 'loreal_static',
},
'amazon': {
'name': 'Amazon',
'profiles': ['amazon_static', 'static_general', 'video_general'],
'display_name': 'Amazon',
'description': 'Amazon brand profiles with design guideline QC checks'
},
'boots': {
'name': 'Boots',
'profiles': ['boots_static', 'boots_ppack', 'static_general', 'video_general'],
'display_name': 'Boots',
'description': 'Boots retail promotional artwork compliance checks'
},
'honda': {
'name': 'Honda',
'profiles': ['static_general', 'video_general'],
'display_name': 'Honda',
'description': 'Honda brand profiles for automotive marketing QC checks'
},
'axa': {
'name': 'AXA',
'profiles': ['axa_policy_document', 'axa_policy_document_diff', 'axa_accessibility', 'static_general', 'video_general'],
'display_name': 'AXA',
'description': 'AXA brand profiles, including multi-page policy document QC for AXA Ireland'
},
'rank': {
'name': 'Rank',
'profiles': ['static_general', 'video_general'],
'display_name': 'Rank',
'description': 'Rank brand profiles for marketing QC checks'
},
'google': {
'name': 'Google',
'profiles': ['static_general', 'video_general'],
'display_name': 'Google',
'description': 'Demo client — scope pending'
},
'hp': {
'name': 'HP',
'profiles': ['static_general', 'video_general'],
'display_name': 'HP',
'description': 'Demo client — scope pending'
},
'ferrero': {
'name': 'Ferrero',
'profiles': ['static_general', 'video_general'],
'display_name': 'Ferrero',
'description': 'Demo client — scope pending'
},
'general': {
'name': 'General',
'profiles': ['static_general', 'video_general', 'inclusive_accessibility'],
'display_name': 'General / Other',
'description': 'General purpose profiles and accessibility checks'
}
}
def get_client_profiles(client_id):
"""Get profiles available for a specific client"""
return CLIENT_PROFILES.get(client_id, {}).get('profiles', [])
def get_all_clients():
"""Get all available clients"""
return CLIENT_PROFILES
def get_client_by_box_folder(folder_id):
"""Reverse-lookup: which client owns this Box folder_id?
Used by the Box webhook handler. Returns the client_id (key) or None.
Folder IDs are compared as strings since the Box API returns them as such.
"""
target = str(folder_id) if folder_id is not None else None
if not target:
return None
for cid, cfg in CLIENT_PROFILES.items():
if str(cfg.get('box_folder_id', '') or '') == target:
return cid
return None
def get_clients_with_box_folder():
"""Return [(client_id, client_config_dict), ...] for clients with a Box folder configured."""
return [
(cid, cfg) for cid, cfg in CLIENT_PROFILES.items()
if cfg.get('box_folder_id')
]
# ---------- Runtime override for default_profile ----------
#
# The static `default_profile` field on each client in CLIENT_PROFILES is the
# baseline value, set in code at deploy time. Admins can override it at runtime
# via the Settings UI; overrides persist to backend/client_defaults.json
# (gitignored, per-server). This separation means a buggy override write can
# never break server boot — worst case the override is ignored and the static
# value applies.
import os as _os
import json as _json
_DEFAULTS_OVERRIDE_PATH = _os.path.join(
_os.path.dirname(_os.path.abspath(__file__)), 'client_defaults.json'
)
def _load_default_overrides():
"""Return the override dict {client_id: profile_id}; empty dict if no file or unreadable."""
if not _os.path.exists(_DEFAULTS_OVERRIDE_PATH):
return {}
try:
with open(_DEFAULTS_OVERRIDE_PATH, 'r') as f:
data = _json.load(f)
return data if isinstance(data, dict) else {}
except (OSError, _json.JSONDecodeError):
return {}
def _save_default_overrides(data):
"""Persist the override dict. Writes to a temp path then renames for atomicity."""
tmp_path = _DEFAULTS_OVERRIDE_PATH + '.tmp'
with open(tmp_path, 'w') as f:
_json.dump(data, f, indent=2, sort_keys=True)
_os.replace(tmp_path, _DEFAULTS_OVERRIDE_PATH)
def get_default_profile(client_id):
"""Return the effective default profile for a client.
Resolution order: runtime override → static `default_profile` field → None.
Used by the Box webhook handler (no logged-in user, needs a profile to run).
"""
overrides = _load_default_overrides()
if client_id in overrides:
return overrides[client_id]
cfg = CLIENT_PROFILES.get(client_id, {})
return cfg.get('default_profile')
def set_default_profile(client_id, profile_id):
"""Persist a runtime override for a client's default profile.
Validates that the client exists and the profile is one of the client's
allowed profiles. Returns (True, None) on success or (False, reason) on
rejection.
"""
if client_id not in CLIENT_PROFILES:
return False, f'unknown client: {client_id}'
allowed = get_client_profiles(client_id)
if profile_id not in allowed:
return False, f"profile '{profile_id}' is not in client {client_id}'s profile list"
overrides = _load_default_overrides()
overrides[client_id] = profile_id
_save_default_overrides(overrides)
return True, None
def clear_default_profile_override(client_id):
"""Remove a runtime override so the static default applies again."""
overrides = _load_default_overrides()
if client_id in overrides:
del overrides[client_id]
_save_default_overrides(overrides)
def validate_client_profile(client_id, profile_id):
"""Validate that a profile belongs to a client"""
client_profiles = get_client_profiles(client_id)
return profile_id in client_profiles
def get_profiles_with_visibility(client_id):
"""
Get all profiles available to a client considering visibility settings
Args:
client_id: Client ID to get profiles for
Returns:
List of profile IDs available to this client
"""
import os
import json
import glob
available_profiles = []
# First, get the profiles assigned to this client in CLIENT_PROFILES
# This is our baseline for backward compatibility
baseline_profiles = get_client_profiles(client_id)
# Get profiles directory
profiles_dir = os.path.join(os.path.dirname(__file__), 'profiles')
profile_files = glob.glob(os.path.join(profiles_dir, '*.json'))
for profile_file in profile_files:
try:
with open(profile_file, 'r') as f:
profile_data = json.load(f)
profile_id = os.path.basename(profile_file).replace('.json', '')
# Check if profile has visibility settings
has_visibility_settings = 'visibility' in profile_data
if has_visibility_settings:
# Use new visibility system if configured
visibility = profile_data.get('visibility', 'all')
visible_to_clients = profile_data.get('visible_to_clients', [])
if visibility == 'all':
available_profiles.append(profile_id)
elif visibility == 'client_specific' and client_id in visible_to_clients:
available_profiles.append(profile_id)
else:
# Fall back to CLIENT_PROFILES mapping for backward compatibility
if profile_id in baseline_profiles:
available_profiles.append(profile_id)
except (json.JSONDecodeError, FileNotFoundError):
continue
return available_profiles
# Admin membership now lives in backend/user_access.json.
# Kept as a thin shim so any older callers keep working.
def is_admin(email):
"""Deprecated — delegates to user_access.is_admin()."""
from user_access import is_admin as _is_admin
return _is_admin(email)