Applied on top ofaf13337so nothing already on wilm is rewritten. AUTHORIZATION. The six new policy GETs were @jwt_required(optional=True) with no permission at all, so an anonymous caller could list configuration and business policies - gated only by a feature flag that 404s while off. They now require computers.view. The six policy write routes stacked @jwt_required() above @require_permission, the same pattern removed from nine endpoints routes in this very push: the outer decorator answers an anonymous request 401 before require_permission can consult the guest role, so a grant to guests silently does nothing. Removed, and the now-unused import with them. tests/test_core/test_public_read_surface.py failed ataf13337with seven ungated GETs. It passes now. The seventh, endpoints.claim_next_command, is genuinely agent-facing - authenticated in the handler by the endpoints collector key, because a bay has no user session - so it joins PUBLIC_ENDPOINTS with its reason written down, exactly as report_command_result already does in test_authz.py. Note which half is public: claiming a queued command is the agent's business, while ENQUEUEING one is the real authority and stays gated on endpoints.command. INSTALLER. -BaseUrl and -CollectorKey are now mandatory with no defaults, and https is enforced unless -AllowInsecureHttp is passed for a local test box. The defaults were a real hostname (ADR-015) and a live collector key: a key in source is a key in git history, and that key is also the command channel's kill switch. Removing it here does not undo the disclosure - it must be rotated on the server, and purged fromaf13337- but it stops the next clone carrying it. IMPORT. A committed import wrote thousands of rows and left no audit trail; it now writes one AuditLog row inside the same transaction, so it cannot survive a rollback or go missing from a commit. The computer-type dropdown asked for per_page: 200, which the API ignores in favour of perpage and clamps to 100 anyway - it uses a new computersApi.types.listAll(), the pattern already applied to the other selectors. And the comment claiming the template example rows are "emitted as commented rows the engine skips" was the opposite of the truth: they import as live data, the emitted instructions say so, and a test depends on it. Not fixed here, deliberately: rotating the leaked key, purging it from history, and the endpoint agent's own hardening (per-host credentials, a protected process denylist so stop cannot kill security tooling, signing before the PowerShell runner is enabled, registry ACLs, and the 25-second long poll that will pin a worker thread per bay).
1325 lines
52 KiB
Python
1325 lines
52 KiB
Python
"""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',
|
|
}
|
|
|
|
|
|
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',
|
|
'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=<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)
|
|
|
|
|
|
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/<tablename>', 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 -> assetnumber map for existing computers.
|
|
|
|
Azure device-group exports align on hostname (their displayName), while the
|
|
import engine keys assets on assetnumber. This lets the Azure adapter route
|
|
a matched device to its real asset so the import updates in place.
|
|
"""
|
|
assets = db.metadata.tables['assets']
|
|
computers = db.metadata.tables['computers']
|
|
query = sa_select(
|
|
computers.c.hostname, assets.c.assetnumber
|
|
).select_from(
|
|
computers.join(assets, computers.c.assetid == assets.c.assetid)
|
|
).where(computers.c.hostname.isnot(None))
|
|
mapping = {}
|
|
for hostname, assetnumber in db.session.execute(query):
|
|
if hostname:
|
|
mapping[hostname.strip().lower()] = assetnumber
|
|
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)]
|
|
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'])
|
|
@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',
|
|
'<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'])
|
|
@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.
|
|
#
|
|
# 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_<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.
|
|
#
|
|
# 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.'
|
|
},
|
|
{
|
|
'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')
|