The scanner has been reporting the same count for weeks, which is what a rule that only prints becomes. It now FAILS the build, and it looks where the leaks actually were: PowerShell, the installer, the seeds, generated JSON, the frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools. A line that is deliberate declares itself with an ADR-015-OK marker and a reason, so the claim is visible in review instead of tolerated in silence. What it found, fixed here: - The shadow client wrote one site's ShopDB URL into HKLM whenever the registry disagreed. At the site it was written for that reads as healing drift; anywhere else it overwrites the site's own address on every enforce cycle, and the site cannot win because the cycle repeats. The bay's value now wins, an explicit -BaseUrl seeds it, and with neither there is nothing honest to write, so it says so and skips. - The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a kiosk elsewhere quietly opened a server it has no business reaching. The fallback is now this site's site_base_url, baked in at seed time, and the dispatcher refuses rather than guessing when neither is set. Its legacy shortcut matcher derives the host from that URL instead of naming one. - The OpenAPI generator hardcoded a production hostname into every spec it generated, which then published to a public wiki. The relative mount is the only server it can honestly name; a site passes its own by environment. - Placeholders and examples in the UI and the client help offered real internal subnets and a real production URL. They now use documentation ranges. Both publication gates - the export scrub and the docs publishability test - carry the site patterns, which neither did. One plant's hostname, FQDN and internal networks are out of the documentation and the generated specs. Comments naming the reference site are reworded rather than deleted: the reasoning is worth keeping, the plant name is not what makes it true.
985 lines
38 KiB
Python
985 lines
38 KiB
Python
"""Settings API routes."""
|
|
|
|
import os
|
|
|
|
from flask import Blueprint, request, current_app
|
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
|
from werkzeug.utils import secure_filename
|
|
|
|
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',
|
|
}
|
|
|
|
|
|
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_<type>_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',
|
|
'machine': 'Machines',
|
|
'computer': 'PCs',
|
|
'printer': 'Printers',
|
|
'network_device': 'Network Devices',
|
|
'measuring_tool': 'Measuring Tools',
|
|
'notification': 'Notifications',
|
|
'subnet': 'Subnets',
|
|
}
|
|
|
|
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=<image>, theme=light|dark. Saves to the instance
|
|
maps dir and sets map_blueprint_<theme> 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/<path:filename>', 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=<image>, 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/<path:filename>', methods=['GET'])
|
|
def serve_branding_logo(filename):
|
|
"""Serve an uploaded branding logo (public - kiosks/print pages read it)."""
|
|
return send_upload(_branding_dir(), filename)
|
|
|
|
|
|
@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)]
|
|
return success_response([_serialize_setting(s) for s in settings])
|
|
|
|
|
|
@settings_bp.route('/<key>', 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('/<key>', 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). 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'])
|
|
@jwt_required()
|
|
@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',
|
|
'<p>This is a test message confirming your SMTP settings work.</p>')
|
|
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'])
|
|
@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_<name>_<assettype>_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_<type>_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.
|
|
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 (scheme + host), e.g. https://shopdb.example.net. Used for QR codes and absolute links. Blank = use the browsing origin.'
|
|
},
|
|
{
|
|
'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.'
|
|
},
|
|
]
|
|
|
|
# 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_<pxetype> 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.
|
|
{
|
|
'key': 'servicenow_enabled',
|
|
'value': 'true',
|
|
'valuetype': 'boolean',
|
|
'category': 'integrations',
|
|
'description': 'Enable ServiceNow ticket recognition and links in search and dashboards'
|
|
},
|
|
{
|
|
'key': 'servicenow_search_url',
|
|
'value': (
|
|
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
|
|
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
|
|
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
|
),
|
|
'valuetype': 'string',
|
|
'category': 'integrations',
|
|
'description': 'ServiceNow global-search URL template. {ticket} is the ticket number.'
|
|
},
|
|
{
|
|
'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': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/',
|
|
'valuetype': 'string',
|
|
'category': 'integrations',
|
|
'description': 'ServiceNow incident URL template. {ticket} is the incident number.'
|
|
},
|
|
{
|
|
'key': 'servicenow_change_url',
|
|
'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/',
|
|
'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'])
|
|
@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')
|