"""Settings API routes.""" import os import re from flask import Blueprint, request, current_app from flask_jwt_extended import jwt_required, get_jwt_identity from werkzeug.utils import secure_filename from sqlalchemy import select as sa_select 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 from shopdb.utils.uploads import send_upload settings_bp = Blueprint('settings', __name__) # Floor-map blueprint uploads live in the instance dir and are served publicly # (the kiosk dashboards read them without auth). MAP_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} # Branding logo uploads allow everything a map image does plus .ico (favicons). BRANDING_IMAGE_EXTENSIONS = MAP_IMAGE_EXTENSIONS | {'.ico'} # Branding logo kinds and the setting key each one writes. BRANDING_KIND_SETTINGS = { 'site': 'site_logo', 'qr': 'qr_logo', 'badge': 'badge_logo', 'favicon': 'site_favicon', } def _map_dir(): return os.path.join(current_app.instance_path, 'maps') def _branding_dir(): return os.path.join(current_app.instance_path, 'branding') # 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 = '********' # Public-settings allowlist. An UNAUTHENTICATED caller (kiosk dashboards, print # pages, the login screen, the setup router) may read only these; everything # else (smtp_host, employee_db_host, zabbix_url, servicenow URLs, ...) needs a # valid token. Secrets stay masked in both cases. Keep in sync with the keys # frontend/src/utils/siteSettings.js + mapConfig.js + setupState.js read before # login. Whole categories that are purely presentation are allowed wholesale; # the rest are named keys so a new integration key does not leak by default. # A plugin adds its own public keys by declaring public=True in # get_settings_defaults - core does not carry a list of every plugin's keys. PUBLIC_SETTING_CATEGORIES = {'branding', 'map'} PUBLIC_SETTING_KEYS = { 'site_base_url', 'facility_name', 'printer_hostname_template', 'contact_email_domain', 'servicenow_enabled', 'setup_complete', 'site_timezone', } # Environment channel label. Sourced from the SHOPDB_ENV_LABEL environment # variable (set by the edge docker-compose override and the bleeding-edge # installer, unset on stable/main), NOT from a settings row. It is injected as a # synthetic public item into the settings list so the frontend picks it up # through the same read-through it already uses for branding, with no extra # request. Empty when unset, which is how a stable deployment renders no DEV # badge. Public and read-only: it carries no secret and cannot be edited via the # settings API, only by redeploying with a different environment value. ENV_LABEL_KEY = 'env_label' def _env_label() -> str: """The configured environment channel label (e.g. 'DEV'), or '' when unset.""" return (os.environ.get('SHOPDB_ENV_LABEL') or '').strip() def _env_label_item() -> dict: """Synthetic, serialized settings item carrying the environment label. Shaped like a real serialized Setting so the frontend read-through treats it as just another public key. settingid is None because it is not a DB row. """ return { 'settingid': None, 'key': ENV_LABEL_KEY, 'value': _env_label(), 'valuetype': 'string', 'category': 'branding', 'description': 'Environment channel label (from SHOPDB_ENV_LABEL); ' 'drives the DEV badge in the sidebar. Read-only.', 'createddate': None, 'modifieddate': None, } def _plugin_declared_settings() -> dict: """Declared settings ({key: entry}) of every loaded plugin, or {}.""" pm = current_app.extensions.get('plugin_manager') if not pm: return {} try: return pm.get_declared_plugin_settings() except Exception: current_app.logger.exception('Could not read plugin setting declarations') return {} def _is_public_setting(setting) -> bool: # A plugin declares its own public keys (get_settings_defaults, public=True) # so a kiosk or print page that renders before login can read them without # core carrying a list of every plugin's keys. if setting.category in PUBLIC_SETTING_CATEGORIES or setting.key in PUBLIC_SETTING_KEYS: return True return bool(_plugin_declared_settings().get(setting.key, {}).get('public')) # 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 = ['machine', 'computer', 'printer', 'network_device', 'measuring_tool'] # 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', 'guide': 'Guides', 'employee': 'Employees', 'machine': 'Machines', 'computer': 'PCs', 'printer': 'Printers', 'network_device': 'Network Devices', 'measuring_tool': 'Measuring Tools', 'notification': 'Notifications', 'subnet': 'Subnets', 'usb_device': 'USB Devices', 'printed_item': 'Printed Items', } def _declared_default(key: str) -> dict: """Return the declared default for a key (core defaults, then plugins). Empty dict when nobody declares it - a genuinely ad-hoc key. """ for entry in build_default_settings(): if entry['key'] == key: return entry return _plugin_declared_settings().get(key, {}) 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('/map-blueprint', methods=['POST']) @jwt_required() @require_role('admin') def upload_map_blueprint(): """Upload a floor-map blueprint image and point the setting at it. multipart/form-data: file=, theme=light|dark. Saves to the instance maps dir and sets map_blueprint_ to the served URL. """ theme = (request.form.get('theme') or '').strip().lower() if theme not in ('light', 'dark'): return error_response(ErrorCodes.VALIDATION_ERROR, 'theme must be light or dark') upload = request.files.get('file') if not upload or not upload.filename: return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') ext = os.path.splitext(upload.filename)[1].lower() if ext not in MAP_IMAGE_EXTENSIONS: return error_response(ErrorCodes.VALIDATION_ERROR, f'Unsupported image type {ext}') os.makedirs(_map_dir(), exist_ok=True) filename = secure_filename(f'blueprint-{theme}{ext}') upload.save(os.path.join(_map_dir(), filename)) url = f'/api/settings/map-blueprint/{filename}' key = f'map_blueprint_{theme}' setting = Setting.query.filter_by(key=key).first() if setting: setting.value = url else: db.session.add(Setting(key=key, value=url, valuetype='string', category='map')) db.session.commit() invalidate_settings_cache() return success_response({'key': key, 'value': url}, message='Blueprint uploaded') @settings_bp.route('/map-blueprint/', methods=['GET']) def serve_map_blueprint(filename): """Serve an uploaded blueprint image (public - kiosks read it).""" return send_upload(_map_dir(), filename) @settings_bp.route('/branding-logo', methods=['POST']) @jwt_required() @require_role('admin') def upload_branding_logo(): """Upload a branding logo image and point the matching setting at it. multipart/form-data: file=, kind=site|qr|badge|favicon. Saves to the instance branding dir and sets the matching branding setting to the served URL. .ico is accepted in addition to the map image types (for favicons). """ kind = (request.form.get('kind') or '').strip().lower() if kind not in BRANDING_KIND_SETTINGS: return error_response( ErrorCodes.VALIDATION_ERROR, 'kind must be one of: ' + ', '.join(sorted(BRANDING_KIND_SETTINGS))) upload = request.files.get('file') if not upload or not upload.filename: return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') ext = os.path.splitext(upload.filename)[1].lower() if ext not in BRANDING_IMAGE_EXTENSIONS: return error_response(ErrorCodes.VALIDATION_ERROR, f'Unsupported image type {ext}') os.makedirs(_branding_dir(), exist_ok=True) filename = secure_filename(f'logo-{kind}{ext}') upload.save(os.path.join(_branding_dir(), filename)) url = f'/api/settings/branding/{filename}' key = BRANDING_KIND_SETTINGS[kind] setting = Setting.query.filter_by(key=key).first() if setting: setting.value = url else: db.session.add(Setting(key=key, value=url, valuetype='string', category='branding')) db.session.commit() invalidate_settings_cache() return success_response({'key': key, 'value': url}, message='Logo uploaded') @settings_bp.route('/branding/', methods=['GET']) def serve_branding_logo(filename): """Serve an uploaded branding logo (public - kiosks/print pages read it).""" return send_upload(_branding_dir(), filename) def _asset_import_dir(): """Configured Asset Collector CSV location, or '' when unset.""" setting = Setting.query.filter_by(key='asset_import_csv_location').first() return (setting.value or '').strip() if setting else '' # The collector script that gets served (with the site's CSV path baked in) from # the Import Site Assets page. Copied into the image under scripts/ (see # Dockerfile), so it is always available next to the running API. _COLLECTOR_SCRIPT = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), 'scripts', 'site_imports', 'assetcollector', 'Collect-ShopDB.ps1') def _embed_store_path(script_text, path): """Return the collector script with $NetworkStoreRoot set to path. The script ships with a BLANK default on that line; rewriting it means an operator downloads a script already pointed at this facility's CSV store and does not have to edit PowerShell by hand. A blank path leaves the shipped blank default untouched, so a site that has not configured asset_import_csv_location keeps everything local instead of inheriting another facility's share. """ if not path: return script_text literal = path.replace("'", "''") replacement = "$NetworkStoreRoot = '%s'" % literal # A function replacement is used deliberately: a UNC path is full of # backslashes, and a string replacement runs them through re's escape # processing (so \s, \w, a trailing \1 all raise or corrupt). A callable # returns the text verbatim. return re.sub( r"(?m)^\$NetworkStoreRoot\s*=.*$", lambda _match: replacement, script_text, count=1) @settings_bp.route('/asset-import/script', methods=['GET']) @require_role('admin') def download_collector_script(): """Download Collect-ShopDB.ps1 with this site's CSV path embedded.""" try: with open(_COLLECTOR_SCRIPT, 'r', encoding='utf-8') as handle: script_text = handle.read() except OSError: return error_response(ErrorCodes.NOT_FOUND, 'Collector script is not available on the server', http_code=404) script_text = _embed_store_path(script_text, _asset_import_dir()) response = current_app.response_class(script_text, mimetype='text/plain') response.headers['Content-Disposition'] = 'attachment; filename=Collect-ShopDB.ps1' return response @settings_bp.route('/asset-import/templates', methods=['GET']) @require_role('admin') def list_import_templates(): """List the importable CSV tables in the order they must be uploaded.""" from shopdb.core.services.csvimport import IMPORTABLE tables = [{'table': name, 'filename': '%s.csv' % name} for name in IMPORTABLE] return success_response({'tables': tables}) @settings_bp.route('/asset-import/templates/', methods=['GET']) @require_role('admin') def download_import_template(tablename): """Download a generated CSV template for a single importable table.""" from shopdb.core.services.csvimport import ( IMPORTABLE, generate_template, ImportError_) if tablename not in IMPORTABLE: return error_response(ErrorCodes.NOT_FOUND, 'No such importable table: %s' % tablename, http_code=404) try: template_text = generate_template(tablename) except ImportError_ as exc: return error_response(ErrorCodes.VALIDATION_ERROR, str(exc)) response = current_app.response_class(template_text, mimetype='text/csv') response.headers['Content-Disposition'] = ( 'attachment; filename=%s.csv' % tablename) return response def _existing_hostname_map(): """Case-insensitive hostname -> assetid map for existing computers. Azure and Thor exports align on hostname (their displayName), while the import engine keys assets on assetnumber. This lets those adapters route a matched device to its real asset so the import updates in place. THE ID, NOT THE ASSETNUMBER, and that is not a style choice. The resolver takes any all-digit value at face value as a literal id rather than looking it up, so an assetnumber like `0600` resolved to asset 600 - a different asset entirely, or none. This site numbers operations 0NNN, so those assetnumbers are real and common: one of them pointed a PC at a printer's record, and another at an id that does not exist, which is the foreign-key error an Azure import died on. Handing over the id the resolver is going to treat it as makes the passthrough correct by construction. """ assets = db.metadata.tables['assets'] computers = db.metadata.tables['computers'] query = sa_select( computers.c.hostname, assets.c.assetid ).select_from( computers.join(assets, computers.c.assetid == assets.c.assetid) ).where(computers.c.hostname.isnot(None)) mapping = {} for hostname, assetid in db.session.execute(query): if hostname: mapping[hostname.strip().lower()] = assetid return mapping @settings_bp.route('/asset-import/import', methods=['POST']) @require_role('admin') def import_asset_csvs(): """Validate uploaded CSVs and, when commit=true, load them. Mirrors the 'flask csv import' CLI: every file runs through import_csv inside ONE transaction with a shared resolver, so a computers.csv can resolve the assets.csv uploaded alongside it. A dry run (the default) rolls the whole transaction back and reports what WOULD change; commit=true persists it. The whole run is all-or-nothing - if any file has a row problem, nothing is written even on a commit. A 'source' form field selects a foreign adapter (azure, thor); it defaults to 'native' where uploads are already canonical per-table files. Foreign uploads are transformed into canonical files first, then run identically. """ from sqlalchemy.exc import SQLAlchemyError from shopdb.core.services.csvimport import ( ImportError_, Resolver, dependency_order, import_csv, table_from_filename) from shopdb.core.services.importsources import ( ImportSourceError, get_source) uploads = request.files.getlist('files') if not uploads: return error_response(ErrorCodes.VALIDATION_ERROR, 'No CSV files were uploaded') commit = str(request.form.get('commit', '')).strip().lower() in ( '1', 'true', 'yes', 'on') # A foreign source (azure, thor) is transformed into canonical files before # import. 'native' (or nothing) means the files already ARE canonical. source_key = (request.form.get('source') or 'native').strip().lower() source = None if source_key and source_key != 'native': try: source = get_source(source_key) except ImportSourceError as exc: return error_response(ErrorCodes.VALIDATION_ERROR, str(exc)) source_options = { 'computertype': (request.form.get('computertype') or '').strip(), 'category': (request.form.get('category') or '').strip(), } # Azure and Thor align on hostname: their device name is the ShopDB # computers.hostname, not the assetnumber. Hand the adapter a # case-insensitive hostname -> existing assetnumber map so devices that # already exist update in place (keyed to their real asset) instead of # minting a duplicate asset. if source is not None and getattr(source, 'key', None) in ('azure', 'thor'): source_options['hostname_map'] = _existing_hostname_map() texts = {} filenames = {} if source is not None: # One foreign export becomes several canonical files. Each upload is # transformed and its canonical outputs merged; a later file's table # wins, matching the native one-file-per-table rule. for upload in uploads: name = upload.filename or '' if not name.lower().endswith('.csv'): continue try: raw = upload.read().decode('utf-8-sig') except (UnicodeDecodeError, OSError): return error_response(ErrorCodes.VALIDATION_ERROR, 'Could not read %s as UTF-8 text' % name) try: produced = source.transform(raw, options=source_options) except ImportSourceError as exc: return error_response(ErrorCodes.VALIDATION_ERROR, '%s: %s' % (name, str(exc))) for table, csv_text in produced.items(): texts[table] = csv_text filenames[table] = '%s (from %s)' % (table, name) else: # Map each upload to a table by filename. A later file with the same # table name wins, matching the folder-import behaviour. for upload in uploads: name = upload.filename or '' if not name.lower().endswith('.csv'): continue table = table_from_filename(name) try: texts[table] = upload.read().decode('utf-8-sig') except (UnicodeDecodeError, OSError): return error_response(ErrorCodes.VALIDATION_ERROR, 'Could not read %s as UTF-8 text' % name) filenames[table] = name if not texts: return error_response(ErrorCodes.VALIDATION_ERROR, 'None of the uploaded files were CSV files') ordered = dependency_order(list(texts)) skipped = sorted(set(texts) - set(ordered)) reconcile_setting = Setting.query.filter_by( key='asset_import_reconcile_threshold').first() try: reconcile_threshold = float(reconcile_setting.value) if reconcile_setting and reconcile_setting.value else 0.0 except (TypeError, ValueError): reconcile_threshold = 0.0 resolver = Resolver(reconcile_threshold=reconcile_threshold) files_report = [] problem_count = 0 warning_count = 0 reconciled_count = 0 request_error = None try: for table in ordered: try: result = import_csv(table, texts[table], resolver=resolver, commit=False) except ImportError_ as exc: request_error = '%s: %s' % (filenames[table], str(exc)) break except SQLAlchemyError as exc: db.session.rollback() request_error = '%s: database error: %s' % ( filenames[table], str(exc).split(chr(10))[0]) break problems = [ {'line': p.line, 'column': p.column, 'message': p.message} for p in result.problems ] # Amber tier: a warning is a finding beside a row that still # imported (an unresolved machine link, a contested bay), NOT a # reason to fail the file. Reported alongside problems so the UI can # show Errors / Warnings / Info distinctly, but never gates commit. warnings = [ {'line': w.line, 'column': w.column, 'reason': w.reason, 'action': w.action, 'subject': w.subject, 'target': w.target} for w in result.warnings ] problem_count += len(problems) warning_count += len(warnings) reconciled_count += len(result.reconciliations) files_report.append({ 'table': table, 'filename': filenames[table], 'created': result.created, 'updated': result.updated, 'problems': problems, 'warnings': warnings, 'reconciliations': result.reconciliations, }) applied = False if request_error is not None: db.session.rollback() elif problem_count > 0: # A row problem anywhere fails the whole run, so nothing is written # even when commit was asked for. db.session.rollback() elif commit: # One audit row for the whole run, written INSIDE the same # transaction so it cannot survive a rollback or go missing from a # commit. Every other write in this app is auditable; a bulk import # that creates thousands of rows was the one path where nobody could # answer "who loaded this, and when". AuditLog.log( 'imported', 'SiteAssets', entityname=', '.join(f['table'] for f in files_report) or 'none', changes={ 'files': [f['table'] for f in files_report], 'created': sum(f['created'] for f in files_report), 'updated': sum(f['updated'] for f in files_report), 'reconciled': reconciled_count, }) db.session.commit() applied = True else: db.session.rollback() except Exception: db.session.rollback() raise total_created = sum(f['created'] for f in files_report) total_updated = sum(f['updated'] for f in files_report) return success_response({ 'committed': applied, 'requested_commit': commit, 'request_error': request_error, 'files': files_report, 'skipped': skipped, 'problem_count': problem_count, 'warning_count': warning_count, 'reconciled_count': reconciled_count, 'reconcile_threshold': reconcile_threshold, 'total_created': total_created, 'total_updated': total_updated, }) @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() # Unauthenticated callers see only the public allowlist (branding + a few # bootstrap keys); an authed principal sees everything (secrets masked). if get_jwt_identity() is None: settings = [s for s in settings if _is_public_setting(s)] items = [_serialize_setting(s) for s in settings] # Inject the environment channel label as a synthetic public item so the # frontend read-through surfaces it alongside branding. It is env-sourced, # not a DB row, and is always present (empty string when unset) so the # public-settings contract can rely on the key existing. Drop any DB row that # somehow shares the key so the env value is authoritative and appears once. # Skip it when a category filter is active and does not select branding, so a # scoped query is not polluted. if not category or category == 'branding': items = [i for i in items if i.get('key') != ENV_LABEL_KEY] items.append(_env_label_item()) return success_response(items) @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) # A non-public key is invisible to an unauthenticated caller (404, not 403, # so its existence is not confirmed either). if get_jwt_identity() is None and not _is_public_setting(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']) @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). Take the category, type and description from whoever # declares the key - core defaults or a plugin's get_settings_defaults. # Filing a declared key under a placeholder category hid it from the # settings page that had just written it, which read as "did not save". if not setting: declared = _declared_default(key) setting = Setting( key=key, value='', valuetype=declared.get('valuetype', 'string'), category=declared.get('category', 'plugin'), description=declared.get('description'), ) 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('/test-email', methods=['POST']) @require_permission('settings.edit') def test_email(): """Send a test email to verify SMTP configuration. Request: { "to": "addr@example.com" } (falls back to alert_recipients) Returns a 200 with a `sent` flag either way. When SMTP is not configured the response explains that gracefully; when a real send fails the SMTP error is surfaced with any credential scrubbed out. """ from shopdb.utils.mailer import get_smtp_config, render_email, try_send data = request.get_json() or {} config = get_smtp_config() recipient = data.get('to') or config['alert_recipients'] if not config['enabled'] or not config['host']: return success_response( {'sent': False, 'reason': 'notconfigured'}, message='Email is not configured (SMTP disabled or host unset).') if not recipient: return error_response( ErrorCodes.VALIDATION_ERROR, 'No recipient. Provide "to" or set Alert Recipients.') html, text = render_email( 'ShopDB test email', '

This is a test message confirming your SMTP settings work.

') ok, error = try_send(recipient, 'ShopDB test email', html, text=text) if ok: return success_response({'sent': True}, message='Test email sent.') return success_response( {'sent': False, 'error': error}, message='Test email failed: ' + (error or 'unknown error')) @settings_bp.route('', methods=['POST']) @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 a generic # placeholder floor plan so an un-reconfigured install still renders. mapdefaults = [ { 'key': 'map_blueprint_light', 'value': '/static/images/floorplan-placeholder.svg', 'valuetype': 'string', 'category': 'map', 'description': 'Floor-map blueprint image (light theme) for this facility' }, { 'key': 'map_blueprint_dark', 'value': '/static/images/floorplan-placeholder.svg', '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. # # THE SUBPATH BELONGS IN THE VALUE. Consumers append the in-app route and # add nothing themselves (resolveCodeText in print/assetLabel.js, buildQrUrl # in utils/qrTarget.js), so a site served at /shopdb must carry that here or # every generated link 404s. This said "scheme + host" for a while, which is # true only of a site mounted at the root - and the email copy of the same # setting has always said https://host/ops. sitedefaults = [ { 'key': 'setup_complete', 'value': 'false', 'valuetype': 'boolean', 'category': 'site', 'description': 'Set true once the first-run setup wizard has been finished' }, { 'key': 'site_timezone', 'value': 'America/New_York', 'valuetype': 'string', 'category': 'site', 'description': "IANA timezone for this site (e.g. America/New_York, America/Chicago). Notification start/end times and daily-reset expiry are computed in this zone." }, { 'key': 'employee_directory_mode', 'value': 'selfhosted', 'valuetype': 'string', 'category': 'site', 'description': "Employee directory source: 'selfhosted' (tables in this app, default) or 'external' (a separate HR database)" }, { 'key': 'usb_directory_mode', 'value': 'selfhosted', 'valuetype': 'string', 'category': 'site', 'description': "USB check-in/out source: 'selfhosted' (tables in this app, default) or 'external' (a separate cmmc_usb database)" }, { 'key': 'site_base_url', 'value': '', 'valuetype': 'string', 'category': 'site', 'description': 'Public base URL of this site INCLUDING any subpath it is served under, e.g. https://shopdb.example.net or https://host/shopdb. Consumers append the in-app route to this and add nothing themselves, so a subpath left off here produces links that 404. Blank = use the browsing origin.' }, { 'key': 'audit_retentiondays', 'value': '0', 'valuetype': 'integer', 'category': 'site', 'description': 'Delete audit log entries older than this many days. 0 (default) keeps everything forever. The audit log is append-only and grows for as long as the site runs, so a long-lived site eventually needs a limit here; nothing is deleted until one is set, and the settings page shows how many entries a purge would remove before it runs.' }, { 'key': 'facility_name', 'value': '', 'valuetype': 'string', 'category': 'site', 'description': 'Facility name shown on the shopfloor dashboard header (blank = frontend falls back to ShopDB)' }, { 'key': 'pc_access_domain', 'value': 'device.geaerospace.net', # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. '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.' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. }, { 'key': 'employeeid_pattern', 'value': r'^\d{9}$', 'valuetype': 'string', 'category': 'site', 'description': 'Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never errors.' }, { 'key': 'dualpath_single_machine', 'value': 'true', 'valuetype': 'boolean', 'category': 'site', 'description': 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. Off = list and count both bays separately.' }, { 'key': 'printer_hostname_template', 'value': 'Printer-{ip}.printer.geaerospace.net', # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. 'valuetype': 'string', 'category': 'site', 'description': 'Template for a printer hostname built from its IP. {ip} is the dash-separated IP address.' }, { 'key': 'contact_email_domain', 'value': 'geaerospace.com', 'valuetype': 'string', 'category': 'site', 'description': 'Email domain appended to a contact SSO to build email/Teams links (sso@domain). Blank disables contact action buttons.' }, { 'key': 'asset_import_csv_location', 'value': '', 'valuetype': 'string', 'category': 'site', 'description': r'Shared network location where the Asset Collector script deposits its CSVs and where ShopDB reads them for import (e.g. \\server.fqdn\share\folder). This path is baked into the downloadable collector script. Blank = not configured.' }, { 'key': 'asset_import_reconcile_threshold', 'value': '0', 'valuetype': 'string', 'category': 'site', 'description': 'Similarity (0 to 1) a fuzzy lookup match must reach for the asset importer to reconcile a near-miss name (e.g. "Dell Inc." to an existing "Dell") onto an existing vendor, model, type, status, business unit, or location. Reconciled matches are applied but reported for review. 0 (the default) disables fuzzy reconciliation - names must match exactly after normalization. Labels whose digit runs differ (e.g. "Bay 310" vs "Bay 311") are never fuzzy-matched. Operating systems and asset rows are never reconciled.' }, ] # Site branding. Each instance (ADR-004) can replace the shipped GE defaults # with its own logos, favicon, and primary color. Blank values fall back to # the built-in shipped assets so an un-reconfigured install still renders. brandingdefaults = [ { 'key': 'site_logo', 'value': '/ge-aerospace-logo.svg', 'valuetype': 'string', 'category': 'branding', 'description': 'Main site logo shown in the app header and login page' }, { 'key': 'qr_logo', 'value': '/ge-monogram.svg', 'valuetype': 'string', 'category': 'branding', 'description': 'Logo composited in the center of printer QR labels (blank = no QR overlay)' }, { 'key': 'badge_logo', 'value': '/ge-aerospace-logo.svg', 'valuetype': 'string', 'category': 'branding', 'description': 'Logo shown on the machine badge print page' }, { 'key': 'site_favicon', 'value': '', 'valuetype': 'string', 'category': 'branding', 'description': 'Browser tab favicon (blank = shipped /favicon.svg)' }, { 'key': 'brand_primary_color', 'value': '', 'valuetype': 'string', 'category': 'branding', 'description': 'Primary brand color as a CSS color value (blank = built-in theme color)' }, { 'key': 'brand_primary_dark_color', 'value': '', 'valuetype': 'string', 'category': 'branding', 'description': 'Primary hover/active color (blank = derived by darkening the primary color ~15%)' }, { 'key': 'brand_accent_color', 'value': '', 'valuetype': 'string', 'category': 'branding', 'description': 'Accent color for secondary buttons and badges (blank = built-in theme color)' }, { 'key': 'brand_sidebar_color', 'value': '', 'valuetype': 'string', 'category': 'branding', 'description': 'Sidebar background color (blank = built-in theme color)' }, ] # Printed QR/label targets. Blank template = QR links to the asset's own # detail page on this instance; a non-blank value is a URL template with # {placeholder} substitution so a site can point labels anywhere. printingdefaults = [ { 'key': 'qr_target_printer', 'value': '', 'valuetype': 'string', 'category': 'printing', 'description': 'Custom URL template for printer QR labels. Blank = link to the printer page. Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}.' }, { 'key': 'qr_target_usb', 'value': '', 'valuetype': 'string', 'category': 'printing', 'description': 'Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: {id}, {serialnumber}, {alias}.' }, { 'key': 'usb_label_style', 'value': 'barcode', 'valuetype': 'string', 'category': 'printing', 'description': "USB mini-label code style: 'barcode' (CODE128 of the serial number) or 'qr' (QR code linking to the QR target)." }, { 'key': 'qr_target_machine', 'value': '', 'valuetype': 'string', 'category': 'printing', 'description': 'Custom URL template for machine labels. Blank = link to the machine page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.' }, { 'key': 'qr_target_computer', 'value': '', 'valuetype': 'string', 'category': 'printing', 'description': 'Custom URL template for computer labels. Blank = link to the computer page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.' }, { 'key': 'qr_target_network_device', 'value': '', 'valuetype': 'string', 'category': 'printing', 'description': 'Custom URL template for network-device labels. Blank = link to the device page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.' }, { 'key': 'qr_target_measuring_tool', 'value': '', 'valuetype': 'string', 'category': 'printing', 'description': 'Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}.' }, { 'key': 'label_default_style', 'value': 'card', 'valuetype': 'string', 'category': 'printing', 'description': "Default asset-label layout: 'card' (badge with image and identity) or 'plain' (just the code and a caption)." }, { 'key': 'label_default_codetype', 'value': 'qr', 'valuetype': 'string', 'category': 'printing', 'description': "Default asset-label code type: 'qr' (QR code) or 'barcode' (CODE128)." }, ] # Per-asset-type default for what a label's code encodes. Machines default # to their machine number (assetnumber), measuring tools to their inspection # location code, everything else to a link to the asset page. Values: # assetpage | assetnumber | serialnumber | location | custom. labelencodesdefaults = { 'machine': 'assetnumber', 'computer': 'assetpage', 'printer': 'assetpage', 'network_device': 'assetpage', 'measuring_tool': 'location', } printingdefaults += [ { 'key': f'label_default_encodes_{assettype}', 'value': value, 'valuetype': 'string', 'category': 'printing', 'description': f'What a {assettype} label encodes by default: assetpage, ' 'assetnumber, serialnumber' + (', location' if assettype == 'measuring_tool' else '') + ', or custom.', } for assettype, value in labelencodesdefaults.items() ] # Collector pc-type -> ComputerType mapping is computers-plugin domain; # the plugin seeds pctypemap_ settings on install. defaults = sitedefaults + brandingdefaults + printingdefaults + identifierdefaults + searchdefaults + mapdefaults + [ # ServiceNow ticket links. Each instance points these at its own # ServiceNow tenant; blank/disabled renders tickets as plain text. # # Shipped OFF and BLANK, like the Zabbix block below. These defaults # used to be one tenant's QA instance, which is wrong twice over: a # sister site adopting this product would silently link its technicians # at somebody else's ServiceNow, and even at the originating site a QA # tenant is not where a real ticket lives. { 'key': 'servicenow_enabled', 'value': 'false', 'valuetype': 'boolean', 'category': 'integrations', 'description': 'Enable ServiceNow ticket recognition and links in search and dashboards' }, { 'key': 'servicenow_search_url', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': 'ServiceNow global-search URL template. {ticket} is the ticket number.' }, { # ADR-015-OK: the DEFAULT is empty, so no cloud is assumed. Which # Azure portal a site uses is a tenant fact, not a product one - # this site is on the US Government cloud (portal.azure.us), and a # commercial-cloud default would be silently wrong for it. The link # simply does not render until a site fills these in. 'key': 'entra_device_url', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': ( 'Entra device properties URL template. {objectid} is the ' 'device object id. Example (US Gov): https://portal.azure.us/' '?feature.msaljs=false#view/Microsoft_AAD_Devices/' 'DeviceDetailsMenuBlade/~/Properties/objectId/{objectid}/deviceId/' ) }, { 'key': 'intune_device_url', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': ( 'Intune device URL template. {deviceid} is the Entra device ' 'id. Example (US Gov): https://portal.azure.us/' '?feature.msaljs=false#view/Microsoft_Intune_Devices/' 'DeviceSettingsMenuBlade/~/overview/aadDeviceId/{deviceid}' ) }, { 'key': 'servicenow_ticket_prefixes', 'value': 'GEINC,GECHG,GERIT,GESCT', 'valuetype': 'string', 'category': 'integrations', 'description': 'Comma-separated ticket-number prefixes recognized as ServiceNow tickets' }, { 'key': 'servicenow_incident_url', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': 'ServiceNow incident URL template. {ticket} is the incident number.' }, { 'key': 'servicenow_change_url', 'value': '', 'valuetype': 'string', 'category': 'integrations', 'description': 'ServiceNow change-request URL template. {ticket} is the change number.' }, # 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)' }, { 'key': 'site_base_url', 'value': '', 'valuetype': 'string', 'category': 'email', 'description': 'Public base URL of this site (e.g. ' 'https://host/ops), used to build links in emails' }, { 'key': 'alert_webhook_url', 'value': '', 'valuetype': 'string', 'category': 'email', 'description': 'Optional webhook URL alerts also POST to (e.g. a ' 'Microsoft Teams Incoming Webhook or Workflow)' }, { 'key': 'alert_webhook_format', 'value': 'teams', 'valuetype': 'string', 'category': 'email', 'description': 'Alert webhook payload format: teams (classic ' 'Incoming Webhook), adaptivecard (Teams Workflow), ' 'or json (generic {title,text})' }, # 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']) @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')