Support teams: contact management moved from a row expander to a modal (Contacts (N) button per team); application detail Support card and the modal show Email (mailto) and Teams chat buttons for contacts with an SSO, derived as sso@ + a new contact_email_domain site setting (default geaerospace.com, blank hides the buttons). Audit log: hovering a user SSO shows the full name, resolved best-effort from the employee directory in either mode. Docs/hygiene from a standards review: CLAUDE.md active-state, CONTRACT-STABILITY.md and README brought to contract 0.10.0 / 11 plugins / migration head 7d22; get_asset_panels endpoint path fixed in the hook docstring; leftover debug console.logs removed. 781 tests pass; contacts modal, action-button hrefs, and the audit tooltip verified live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
780 lines
28 KiB
Python
780 lines
28 KiB
Python
"""Settings API routes."""
|
|
|
|
import os
|
|
|
|
from flask import Blueprint, request, current_app, send_from_directory
|
|
from flask_jwt_extended import jwt_required
|
|
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
|
|
|
|
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 = '********'
|
|
|
|
# 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 _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_from_directory(_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_from_directory(_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()
|
|
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)
|
|
|
|
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). 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_<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': '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',
|
|
'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.'
|
|
},
|
|
{
|
|
'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',
|
|
'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)."
|
|
},
|
|
]
|
|
|
|
# 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)'
|
|
},
|
|
# 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')
|