"""Settings API routes.""" from flask import Blueprint, request from flask_jwt_extended import jwt_required from shopdb.extensions import db, cache from shopdb.core.models import Setting, AuditLog from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.authz import require_permission, require_role settings_bp = Blueprint('settings', __name__) # Cache key for settings SETTINGS_CACHE_KEY = 'system_settings' SETTINGS_CACHE_TTL = 300 # 5 minutes # Placeholder returned in API responses for secret values so they are never # exposed in plaintext. Sending it back on update is treated as "unchanged". SECRET_MASK = '********' # Optional asset identifiers and the asset types they can be toggled on. # Drives per-type seed keys and the Settings matrix UI. The asset type names # match the AssetType.assettype values seeded by each plugin. IDENTIFIER_LABELS = { 'gaugelabreference': 'Gauge Lab Reference', 'maintenancereference': 'Maintenance Reference', 'fqdn': 'FQDN / hostname', } IDENTIFIER_ASSETTYPES = ['equipment', 'computer', 'printer', 'network_device'] # Global-search result types that can be toggled on/off independently of whether # the owning plugin is enabled. Keys match the `type` field on search results; # seed keys are search__enabled (boolean, default true). Drives the # Settings "Search" toggles and the filter in shopdb/core/api/search.py. SEARCH_DOMAINS = { 'application': 'Applications', 'knowledgebase': 'Knowledge Base', 'employee': 'Employees', 'equipment': 'Equipment', 'computer': 'PCs', 'printer': 'Printers', 'network_device': 'Network Devices', 'notification': 'Notifications', 'subnet': 'Subnets', } def _is_secret(key: str) -> bool: return 'password' in key or 'token' in key or 'secret' in key def _serialize_setting(setting): """Serialize a setting, masking secret values so they never leave the API.""" data = setting.to_dict() if _is_secret(setting.key): data['value'] = SECRET_MASK if setting.value else '' return data def get_cached_settings(): """Get all settings from cache or database.""" cached = cache.get(SETTINGS_CACHE_KEY) if cached is not None: return cached settings = Setting.query.all() result = {s.key: s.get_typed_value() for s in settings} cache.set(SETTINGS_CACHE_KEY, result, timeout=SETTINGS_CACHE_TTL) return result def invalidate_settings_cache(): """Clear the settings cache.""" cache.delete(SETTINGS_CACHE_KEY) @settings_bp.route('', methods=['GET']) @jwt_required(optional=True) def list_settings(): """List all settings, optionally filtered by category.""" category = request.args.get('category') query = Setting.query if category: query = query.filter_by(category=category) settings = query.order_by(Setting.category, Setting.key).all() return success_response([_serialize_setting(s) for s in settings]) @settings_bp.route('/', methods=['GET']) @jwt_required(optional=True) def get_setting(key: str): """Get a single setting by key.""" setting = Setting.query.filter_by(key=key).first() if not setting: return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404) return success_response(_serialize_setting(setting)) @settings_bp.route('/', methods=['PUT']) @jwt_required() @require_permission('settings.edit') def update_setting(key: str): """Update a setting value.""" data = request.get_json() if data is None or 'value' not in data: return error_response(ErrorCodes.VALIDATION_ERROR, 'value is required') setting = Setting.query.filter_by(key=key).first() # Upsert: create the row on first write (e.g. plugin config keys the setup # wizard saves). New keys default to a plugin-scoped string setting. if not setting: setting = Setting(key=key, value='', valuetype='string', category='plugin') db.session.add(setting) # Track old value for audit old_value = setting.value value = data['value'] # A secret submitted as the mask placeholder means "leave unchanged" - the # client only ever received the mask, so don't overwrite the real secret. if _is_secret(key) and value == SECRET_MASK: return success_response(_serialize_setting(setting), message='Setting unchanged') # Convert value to string for storage if isinstance(value, bool): setting.value = 'true' if value else 'false' else: setting.value = str(value) if value is not None else None # Audit log (mask sensitive values) is_sensitive = _is_secret(key) AuditLog.log('updated', 'Setting', entityname=key, changes={ 'value': { 'old': '***' if is_sensitive else old_value, 'new': '***' if is_sensitive else setting.value } }) db.session.commit() invalidate_settings_cache() return success_response(_serialize_setting(setting), message='Setting updated') @settings_bp.route('', methods=['POST']) @jwt_required() @require_permission('settings.edit') def create_setting(): """Create a new setting (admin only).""" data = request.get_json() if not data or not data.get('key'): return error_response(ErrorCodes.VALIDATION_ERROR, 'key is required') if Setting.query.filter_by(key=data['key']).first(): return error_response(ErrorCodes.CONFLICT, f"Setting '{data['key']}' already exists", http_code=409) value = data.get('value') if isinstance(value, bool): value_str = 'true' if value else 'false' else: value_str = str(value) if value is not None else None setting = Setting( key=data['key'], value=value_str, valuetype=data.get('valuetype', 'string'), category=data.get('category', 'general'), description=data.get('description') ) db.session.add(setting) db.session.commit() invalidate_settings_cache() return success_response(setting.to_dict(), message='Setting created', http_code=201) def build_default_settings(): """Return the full default-settings list (identifier toggles + static). Shared by the /settings/seed route and the `flask seed settings` CLI so the two definitions never drift. """ # Asset identifier feature toggles, per identifier AND per asset type. # Key format: identifier___enabled (boolean). Admins pick # which optional identifiers show on which asset types. See ADR-001. identifierdefaults = [ { 'key': f'identifier_{name}_{assettype}_enabled', 'value': 'true', 'valuetype': 'boolean', 'category': 'identifiers', 'description': f'Show the {label} identifier on {assettype} assets', } for name, label in IDENTIFIER_LABELS.items() for assettype in IDENTIFIER_ASSETTYPES ] # Per-domain global-search toggles (search__enabled). searchdefaults = [ { 'key': f'search_{key}_enabled', 'value': 'true', 'valuetype': 'boolean', 'category': 'search', 'description': f'Include {label} in global search results', } for key, label in SEARCH_DOMAINS.items() ] # Facility floor-map blueprint. Each site instance (ADR-004) points these # at its own floor-plan image and pixel dimensions; the map frontend reads # them instead of hardcoding one facility's plan. Defaults are the West # Jefferson sitemap so an un-reconfigured install still renders. mapdefaults = [ { 'key': 'map_blueprint_light', 'value': '/static/images/sitemap2025-light.png', 'valuetype': 'string', 'category': 'map', 'description': 'Floor-map blueprint image (light theme) for this facility' }, { 'key': 'map_blueprint_dark', 'value': '/static/images/sitemap2025-dark.png', 'valuetype': 'string', 'category': 'map', 'description': 'Floor-map blueprint image (dark theme) for this facility' }, { 'key': 'map_width', 'value': '3300', 'valuetype': 'integer', 'category': 'map', 'description': 'Floor-map blueprint width in pixels (native size of the image)' }, { 'key': 'map_height', 'value': '2550', 'valuetype': 'integer', 'category': 'map', 'description': 'Floor-map blueprint height in pixels (native size of the image)' }, ] # Site identity. Each instance (ADR-004) sets its own public URL - used for # QR codes and any absolute link the app emits - and facility name shown on # the shopfloor dashboard. Blank site_base_url falls back to the browsing # origin so nothing breaks before a site configures it. sitedefaults = [ { 'key': 'setup_complete', 'value': 'false', 'valuetype': 'boolean', 'category': 'site', 'description': 'Set true once the first-run setup wizard has been finished' }, { 'key': 'site_base_url', 'value': '', 'valuetype': 'string', 'category': 'site', 'description': 'Public base URL of this site (scheme + host), e.g. https://shopdb.example.net. Used for QR codes and absolute links. Blank = use the browsing origin.' }, { 'key': 'facility_name', 'value': 'West Jefferson', 'valuetype': 'string', 'category': 'site', 'description': 'Facility name shown on the shopfloor dashboard header' }, { 'key': 'pc_access_domain', 'value': 'device.geaerospace.net', 'valuetype': 'string', 'category': 'site', 'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.' }, ] # Collector pc-type -> ComputerType mapping is computers-plugin domain; # the plugin seeds pctypemap_ settings on install. defaults = sitedefaults + identifierdefaults + searchdefaults + mapdefaults + [ # Zabbix integration { 'key': 'zabbix_enabled', 'value': 'false', 'valuetype': 'boolean', 'category': 'integrations', 'description': 'Enable Zabbix integration for printer supply monitoring' }, { 'key': 'zabbix_url', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)' }, { 'key': 'zabbix_token', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': 'Zabbix API authentication token' }, # Dell warranty lookup (Dell TechDirect Warranty API, OAuth2) { 'key': 'warranty_dell_enabled', 'value': 'false', 'valuetype': 'boolean', 'category': 'integrations', 'description': 'Enable Dell warranty lookups (service-tag entitlements)' }, { 'key': 'warranty_dell_clientid', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': 'Dell TechDirect API client id' }, { 'key': 'warranty_dell_clientsecret', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': 'Dell TechDirect API client secret' }, { 'key': 'warranty_dell_tokenurl', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': 'Dell OAuth token URL (blank = Dell default)' }, { 'key': 'warranty_dell_apiurl', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': 'Dell warranty API URL (blank = Dell default)' }, # Email/SMTP settings { 'key': 'smtp_enabled', 'value': 'false', 'valuetype': 'boolean', 'category': 'email', 'description': 'Enable email notifications and alerts' }, { 'key': 'smtp_host', 'value': '', 'valuetype': 'string', 'category': 'email', 'description': 'SMTP server hostname' }, { 'key': 'smtp_port', 'value': '587', 'valuetype': 'integer', 'category': 'email', 'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)' }, { 'key': 'smtp_username', 'value': '', 'valuetype': 'string', 'category': 'email', 'description': 'SMTP authentication username' }, { 'key': 'smtp_password', 'value': '', 'valuetype': 'string', 'category': 'email', 'description': 'SMTP authentication password' }, { 'key': 'smtp_use_tls', 'value': 'true', 'valuetype': 'boolean', 'category': 'email', 'description': 'Use TLS encryption for SMTP connection' }, { 'key': 'smtp_from_address', 'value': '', 'valuetype': 'string', 'category': 'email', 'description': 'From address for outgoing emails' }, { 'key': 'smtp_from_name', 'value': 'ShopDB', 'valuetype': 'string', 'category': 'email', 'description': 'From name for outgoing emails' }, { 'key': 'alert_recipients', 'value': '', 'valuetype': 'string', 'category': 'email', 'description': 'Default email recipients for alerts (comma-separated)' }, # Audit log settings { 'key': 'audit_retention_days', 'value': '90', 'valuetype': 'integer', 'category': 'audit', 'description': 'Number of days to retain audit logs (0 = keep forever)' }, # Authentication settings { 'key': 'saml_enabled', 'value': 'false', 'valuetype': 'boolean', 'category': 'auth', 'description': 'Enable SAML SSO authentication' }, { 'key': 'saml_idp_metadata_url', 'value': '', 'valuetype': 'string', 'category': 'auth', 'description': 'SAML Identity Provider metadata URL' }, { 'key': 'saml_entity_id', 'value': '', 'valuetype': 'string', 'category': 'auth', 'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)' }, { 'key': 'saml_acs_url', 'value': '', 'valuetype': 'string', 'category': 'auth', 'description': 'SAML Assertion Consumer Service URL' }, { 'key': 'saml_allow_local_login', 'value': 'true', 'valuetype': 'boolean', 'category': 'auth', 'description': 'Allow local username/password login when SAML is enabled' }, { 'key': 'saml_auto_create_users', 'value': 'true', 'valuetype': 'boolean', 'category': 'auth', 'description': 'Automatically create users on first SAML login' }, { 'key': 'saml_admin_group', 'value': '', 'valuetype': 'string', 'category': 'auth', 'description': 'SAML group name that grants admin role' }, ] return defaults @settings_bp.route('/seed', methods=['POST']) @jwt_required() @require_permission('settings.edit') def seed_default_settings(): """Seed default settings if they don't exist.""" created = 0 for d in build_default_settings(): if not Setting.query.filter_by(key=d['key']).first(): setting = Setting(**d) db.session.add(setting) created += 1 db.session.commit() invalidate_settings_cache() return success_response({'created': created}, message=f'{created} default settings created')