Files
shopdb-flask/shopdb/core/api/settings.py

1676 lines
68 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',
}
# Environment channel label. Sourced from the SHOPDB_ENV_LABEL environment
# variable (set by the edge docker-compose override and the bleeding-edge
# installer, unset on stable/main), NOT from a settings row. It is injected as a
# synthetic public item into the settings list so the frontend picks it up
# through the same read-through it already uses for branding, with no extra
# request. Empty when unset, which is how a stable deployment renders no DEV
# badge. Public and read-only: it carries no secret and cannot be edited via the
# settings API, only by redeploying with a different environment value.
ENV_LABEL_KEY = 'env_label'
def _env_label() -> str:
"""The configured environment channel label (e.g. 'DEV'), or '' when unset."""
return (os.environ.get('SHOPDB_ENV_LABEL') or '').strip()
def _env_label_item() -> dict:
"""Synthetic, serialized settings item carrying the environment label.
Shaped like a real serialized Setting so the frontend read-through treats it
as just another public key. settingid is None because it is not a DB row.
"""
return {
'settingid': None,
'key': ENV_LABEL_KEY,
'value': _env_label(),
'valuetype': 'string',
'category': 'branding',
'description': 'Environment channel label (from SHOPDB_ENV_LABEL); '
'drives the DEV badge in the sidebar. Read-only.',
'createddate': None,
'modifieddate': None,
}
def _plugin_declared_settings() -> dict:
"""Declared settings ({key: entry}) of every loaded plugin, or {}."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return {}
try:
return pm.get_declared_plugin_settings()
except Exception:
current_app.logger.exception('Could not read plugin setting declarations')
return {}
def _is_public_setting(setting) -> bool:
# A plugin declares its own public keys (get_settings_defaults, public=True)
# so a kiosk or print page that renders before login can read them without
# core carrying a list of every plugin's keys.
if setting.category in PUBLIC_SETTING_CATEGORIES or setting.key in PUBLIC_SETTING_KEYS:
return True
return bool(_plugin_declared_settings().get(setting.key, {}).get('public'))
# Optional asset identifiers and the asset types they can be toggled on.
# Drives per-type seed keys and the Settings matrix UI. The asset type names
# match the AssetType.assettype values seeded by each plugin.
IDENTIFIER_LABELS = {
'gaugelabreference': 'Gauge Lab Reference',
'maintenancereference': 'Maintenance Reference',
'fqdn': 'FQDN / hostname',
}
IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device',
'measuring_tool']
# Global-search result types that can be toggled on/off independently of whether
# the owning plugin is enabled. Keys match the `type` field on search results;
# seed keys are search_<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 -> assetid map for existing computers.
Azure and Thor exports align on hostname (their displayName), while the
import engine keys assets on assetnumber. This lets those adapters route a
matched device to its real asset so the import updates in place.
THE ID, NOT THE ASSETNUMBER, and that is not a style choice. The resolver
takes any all-digit value at face value as a literal id rather than looking
it up, so an assetnumber like `0600` resolved to asset 600 - a different
asset entirely, or none. This site numbers operations 0NNN, so those
assetnumbers are real and common: one of them pointed a PC at a printer's
record, and another at an id that does not exist, which is the foreign-key
error an Azure import died on. Handing over the id the resolver is going to
treat it as makes the passthrough correct by construction.
"""
assets = db.metadata.tables['assets']
computers = db.metadata.tables['computers']
query = sa_select(
computers.c.hostname, assets.c.assetid
).select_from(
computers.join(assets, computers.c.assetid == assets.c.assetid)
).where(computers.c.hostname.isnot(None))
mapping = {}
for hostname, assetid in db.session.execute(query):
if hostname:
mapping[hostname.strip().lower()] = assetid
return mapping
@settings_bp.route('/asset-import/import', methods=['POST'])
@require_role('admin')
def import_asset_csvs():
"""Validate uploaded CSVs and, when commit=true, load them.
Mirrors the 'flask csv import' CLI: every file runs through import_csv inside
ONE transaction with a shared resolver, so a computers.csv can resolve the
assets.csv uploaded alongside it. A dry run (the default) rolls the whole
transaction back and reports what WOULD change; commit=true persists it. The
whole run is all-or-nothing - if any file has a row problem, nothing is
written even on a commit.
A 'source' form field selects a foreign adapter (azure, thor); it defaults
to 'native' where uploads are already canonical per-table files. Foreign
uploads are transformed into canonical files first, then run identically.
"""
from sqlalchemy.exc import SQLAlchemyError
from shopdb.core.services.csvimport import (
ImportError_, Resolver, dependency_order, import_csv, table_from_filename)
from shopdb.core.services.importsources import (
ImportSourceError, get_source)
uploads = request.files.getlist('files')
if not uploads:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No CSV files were uploaded')
commit = str(request.form.get('commit', '')).strip().lower() in (
'1', 'true', 'yes', 'on')
# A foreign source (azure, thor) is transformed into canonical files before
# import. 'native' (or nothing) means the files already ARE canonical.
source_key = (request.form.get('source') or 'native').strip().lower()
source = None
if source_key and source_key != 'native':
try:
source = get_source(source_key)
except ImportSourceError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
source_options = {
'computertype': (request.form.get('computertype') or '').strip(),
'category': (request.form.get('category') or '').strip(),
}
# Azure and Thor align on hostname: their device name is the ShopDB
# computers.hostname, not the assetnumber. Hand the adapter a
# case-insensitive hostname -> existing assetnumber map so devices that
# already exist update in place (keyed to their real asset) instead of
# minting a duplicate asset.
if source is not None and getattr(source, 'key', None) in ('azure', 'thor'):
source_options['hostname_map'] = _existing_hostname_map()
texts = {}
filenames = {}
if source is not None:
# One foreign export becomes several canonical files. Each upload is
# transformed and its canonical outputs merged; a later file's table
# wins, matching the native one-file-per-table rule.
for upload in uploads:
name = upload.filename or ''
if not name.lower().endswith('.csv'):
continue
try:
raw = upload.read().decode('utf-8-sig')
except (UnicodeDecodeError, OSError):
return error_response(ErrorCodes.VALIDATION_ERROR,
'Could not read %s as UTF-8 text' % name)
try:
produced = source.transform(raw, options=source_options)
except ImportSourceError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR,
'%s: %s' % (name, str(exc)))
for table, csv_text in produced.items():
texts[table] = csv_text
filenames[table] = '%s (from %s)' % (table, name)
else:
# Map each upload to a table by filename. A later file with the same
# table name wins, matching the folder-import behaviour.
for upload in uploads:
name = upload.filename or ''
if not name.lower().endswith('.csv'):
continue
table = table_from_filename(name)
try:
texts[table] = upload.read().decode('utf-8-sig')
except (UnicodeDecodeError, OSError):
return error_response(ErrorCodes.VALIDATION_ERROR,
'Could not read %s as UTF-8 text' % name)
filenames[table] = name
if not texts:
return error_response(ErrorCodes.VALIDATION_ERROR,
'None of the uploaded files were CSV files')
ordered = dependency_order(list(texts))
skipped = sorted(set(texts) - set(ordered))
reconcile_setting = Setting.query.filter_by(
key='asset_import_reconcile_threshold').first()
try:
reconcile_threshold = float(reconcile_setting.value) if reconcile_setting and reconcile_setting.value else 0.0
except (TypeError, ValueError):
reconcile_threshold = 0.0
resolver = Resolver(reconcile_threshold=reconcile_threshold)
files_report = []
problem_count = 0
warning_count = 0
reconciled_count = 0
request_error = None
try:
for table in ordered:
try:
result = import_csv(table, texts[table], resolver=resolver, commit=False)
except ImportError_ as exc:
request_error = '%s: %s' % (filenames[table], str(exc))
break
except SQLAlchemyError as exc:
db.session.rollback()
request_error = '%s: database error: %s' % (
filenames[table], str(exc).split(chr(10))[0])
break
problems = [
{'line': p.line, 'column': p.column, 'message': p.message}
for p in result.problems
]
# Amber tier: a warning is a finding beside a row that still
# imported (an unresolved machine link, a contested bay), NOT a
# reason to fail the file. Reported alongside problems so the UI can
# show Errors / Warnings / Info distinctly, but never gates commit.
warnings = [
{'line': w.line, 'column': w.column, 'reason': w.reason,
'action': w.action, 'subject': w.subject, 'target': w.target}
for w in result.warnings
]
problem_count += len(problems)
warning_count += len(warnings)
reconciled_count += len(result.reconciliations)
files_report.append({
'table': table,
'filename': filenames[table],
'created': result.created,
'updated': result.updated,
'problems': problems,
'warnings': warnings,
'reconciliations': result.reconciliations,
})
applied = False
if request_error is not None:
db.session.rollback()
elif problem_count > 0:
# A row problem anywhere fails the whole run, so nothing is written
# even when commit was asked for.
db.session.rollback()
elif commit:
# One audit row for the whole run, written INSIDE the same
# transaction so it cannot survive a rollback or go missing from a
# commit. Every other write in this app is auditable; a bulk import
# that creates thousands of rows was the one path where nobody could
# answer "who loaded this, and when".
AuditLog.log(
'imported', 'SiteAssets',
entityname=', '.join(f['table'] for f in files_report) or 'none',
changes={
'files': [f['table'] for f in files_report],
'created': sum(f['created'] for f in files_report),
'updated': sum(f['updated'] for f in files_report),
'reconciled': reconciled_count,
})
db.session.commit()
applied = True
else:
db.session.rollback()
except Exception:
db.session.rollback()
raise
total_created = sum(f['created'] for f in files_report)
total_updated = sum(f['updated'] for f in files_report)
return success_response({
'committed': applied,
'requested_commit': commit,
'request_error': request_error,
'files': files_report,
'skipped': skipped,
'problem_count': problem_count,
'warning_count': warning_count,
'reconciled_count': reconciled_count,
'reconcile_threshold': reconcile_threshold,
'total_created': total_created,
'total_updated': total_updated,
})
def _winc_upload_text():
"""Read the single uploaded WINC export as text, or (None, error_response)."""
uploads = request.files.getlist('files')
if not uploads:
return None, error_response(ErrorCodes.VALIDATION_ERROR,
'No export file was uploaded')
upload = uploads[0]
name = upload.filename or ''
if not name.lower().endswith('.csv'):
return None, error_response(ErrorCodes.VALIDATION_ERROR,
'The WINC export must be a .csv file')
try:
return upload.read().decode('utf-8-sig'), None
except (UnicodeDecodeError, OSError):
return None, error_response(ErrorCodes.VALIDATION_ERROR,
'Could not read %s as UTF-8 text' % name)
@settings_bp.route('/asset-import/winc/preview', methods=['POST'])
@require_role('admin')
def winc_import_preview():
"""Read-only WINC analysis: report what a commit would do. Writes nothing.
WINC (a Maximo-style asset dump) is not a canonical-CSV source: it updates
existing machines only, needs acknowledgment before creating business units,
and is reversible. This returns the approval packet the UI renders - counts,
the business units it would create, and any unresolved values that block it.
"""
from shopdb.core.services.wincimport import preview, WincImportError
export_text, failure = _winc_upload_text()
if failure is not None:
return failure
try:
report = preview(export_text)
except WincImportError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
finally:
# A preview must leave nothing behind even though it only read: the
# analyzer runs against the request-scoped session.
db.session.rollback()
return success_response(report)
@settings_bp.route('/asset-import/winc/commit', methods=['POST'])
@require_role('admin')
def winc_import_commit():
"""Apply a WINC plan reversibly. Requires acknowledgment for needs-create.
The 'acknowledge' form field is a comma-separated list of needs-create group
names the admin confirmed (e.g. 'businessunit'). The whole write is one
transaction recorded to the import journal, so it can be undone later.
"""
from shopdb.core.services.wincimport import commit as winc_commit
from shopdb.core.services.wincimport import WincImportError
export_text, failure = _winc_upload_text()
if failure is not None:
return failure
acknowledge = [
part.strip() for part in
(request.form.get('acknowledge') or '').split(',') if part.strip()]
# The hash the PREVIEW returned. Required here, because without it the
# writer's drift check compares a freshly built packet against the plan it
# was built from and can never fail - so "the admin approved this plan"
# meant only "the admin ticked these group names".
planhash = (request.form.get('planhash') or '').strip()
if not planhash:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'planhash is required: send the value the preview returned, so the '
'commit can prove it is applying the plan that was approved.')
try:
report = winc_commit(export_text, acknowledge=acknowledge,
createdby=get_jwt_identity(), planhash=planhash)
AuditLog.log(
'imported', 'SiteAssets', entityname='winc-machine-adoption',
changes={'batchid': report.get('batchid'),
'rows': report.get('rowcount')})
db.session.commit()
except WincImportError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
except Exception:
db.session.rollback()
raise
return success_response(report)
@settings_bp.route('/asset-import/winc/batches', methods=['GET'])
@require_role('admin')
def winc_import_batches():
"""List applied/undone WINC import batches, newest first (for the undo UI)."""
from shopdb.core.services.wincimport import list_batches
return success_response({'batches': list_batches()})
@settings_bp.route('/asset-import/winc/undo', methods=['POST'])
@require_role('admin')
def winc_import_undo():
"""Reverse an applied WINC import batch by id. Conservative and reversible."""
from shopdb.core.services.wincimport import undo as winc_undo
from shopdb.core.services.wincimport import WincImportError
payload = request.get_json(silent=True) or {}
batchid = (payload.get('batchid') or request.form.get('batchid') or '').strip()
if not batchid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'A batchid is required')
try:
result = winc_undo(batchid)
AuditLog.log(
'undone', 'SiteAssets', entityname='winc-machine-adoption',
changes={'batchid': batchid, 'reverted': result['reverted'],
'conflicts': len(result['conflicts'])})
db.session.commit()
except (ValueError, WincImportError) as exc:
# WincImportError too: refusing a batch of another importer's kind is a
# bad request, not a server fault. Preview and commit already catch it;
# this route did not, so the refusal surfaced as a 500.
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
except Exception:
db.session.rollback()
raise
return success_response(result)
@settings_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_settings():
"""List all settings, optionally filtered by category."""
category = request.args.get('category')
query = Setting.query
if category:
query = query.filter_by(category=category)
settings = query.order_by(Setting.category, Setting.key).all()
# Unauthenticated callers see only the public allowlist (branding + a few
# bootstrap keys); an authed principal sees everything (secrets masked).
if get_jwt_identity() is None:
settings = [s for s in settings if _is_public_setting(s)]
items = [_serialize_setting(s) for s in settings]
# Inject the environment channel label as a synthetic public item so the
# frontend read-through surfaces it alongside branding. It is env-sourced,
# not a DB row, and is always present (empty string when unset) so the
# public-settings contract can rely on the key existing. Drop any DB row that
# somehow shares the key so the env value is authoritative and appears once.
# Skip it when a category filter is active and does not select branding, so a
# scoped query is not polluted.
if not category or category == 'branding':
items = [i for i in items if i.get('key') != ENV_LABEL_KEY]
items.append(_env_label_item())
return success_response(items)
@settings_bp.route('/<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')
# The declared valuetype is a promise about what every reader will get, and
# nothing checked it: "A6-not-a-number" was stored verbatim on an integer
# key and every consumer then read 0, because Setting.get_typed_value
# swallows the ValueError. The setting looked set, the behaviour was the
# default, and nothing said so.
valuetype = (setting.valuetype or 'string').lower()
if value is not None and not isinstance(value, bool):
text = str(value).strip()
if valuetype == 'integer' and text:
try:
int(text)
except ValueError:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'%s is a whole-number setting (got %r)' % (key, value))
elif valuetype == 'float' and text:
try:
float(text)
except ValueError:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'%s is a number setting (got %r)' % (key, value))
elif valuetype == 'boolean' and text:
if text.lower() not in ('true', 'false', '1', '0', 'yes', 'no'):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'%s is a true/false setting (got %r)' % (key, value))
# 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.'
},
{
# ADR-015-OK: the DEFAULT is empty, so no cloud is assumed. Which
# Azure portal a site uses is a tenant fact, not a product one -
# this site is on the US Government cloud (portal.azure.us), and a
# commercial-cloud default would be silently wrong for it. The link
# simply does not render until a site fills these in.
'key': 'entra_device_url',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': (
'Entra device properties URL template. {objectid} is the '
'device object id. Example (US Gov): https://portal.azure.us/'
'?feature.msaljs=false#view/Microsoft_AAD_Devices/'
'DeviceDetailsMenuBlade/~/Properties/objectId/{objectid}/deviceId/'
)
},
{
'key': 'intune_device_url',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': (
'Intune device URL template. {deviceid} is the Entra device '
'id. Example (US Gov): https://portal.azure.us/'
'?feature.msaljs=false#view/Microsoft_Intune_Devices/'
'DeviceSettingsMenuBlade/~/overview/aadDeviceId/{deviceid}'
)
},
{
# ADR-015-OK: the DEFAULT is empty, so no cloud is assumed. Same
# reasoning as entra_device_url - the LAPS blade lives in the same
# tenant-specific portal, so the host cannot be a product fact.
'key': 'laps_device_url',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': (
'Azure LAPS (local admin password) URL template. {objectid} is '
'the device object id. Example (US Gov): https://portal.azure.us/'
'?feature.msaljs=true#view/Microsoft_AAD_Devices/'
'DeviceDetailsMenuBlade/~/LocalAdminPassword/objectId/{objectid}'
)
},
{
'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})'
},
# Backup destination settings
#
# These are the ground wiring for where the one-shot backup container
# ships each set. 'local' keeps sets on the mounted backups volume and
# needs no credentials. 'smb' and 'aws' are declared so the UI and the
# backup runner can be configured now; the client libraries (an SMB
# stack, boto3/awscli) are deliberately NOT pulled in yet - those land
# behind the security review that vets third-party deps against
# external exploit. Until then a non-local destination is accepted and
# persisted but the runner treats it as not-yet-armed.
#
# Secrets follow the existing convention: any key containing
# 'password'/'secret'/'token' is masked by _is_secret on the way out
# and preserved when the client submits the mask back unchanged.
{
'key': 'backup_destination_kind',
'value': 'local',
'valuetype': 'string',
'category': 'backup',
'description': 'Where backup sets are shipped: local, smb, or aws. '
'Non-local destinations are wiring-only until the '
'security review clears their client libraries.'
},
{
'key': 'backup_smb_host',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'SMB/CIFS server hostname or IP for the backup share'
},
{
'key': 'backup_smb_share',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'SMB share name (the top-level export, no path)'
},
{
'key': 'backup_smb_path',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'Subpath within the SMB share to write sets into'
},
{
'key': 'backup_smb_username',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'SMB authentication username (DOMAIN\\user allowed)'
},
{
'key': 'backup_smb_password',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'SMB authentication password'
},
{
'key': 'backup_aws_region',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'AWS region for the S3 backup bucket (e.g. us-east-1)'
},
{
'key': 'backup_aws_bucket',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'S3 bucket name that holds backup sets'
},
{
'key': 'backup_aws_prefix',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'Optional key prefix within the S3 bucket'
},
{
'key': 'backup_aws_accesskeyid',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'AWS access key id for the backup bucket'
},
{
'key': 'backup_aws_secretaccesskey',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'AWS secret access key for the backup bucket'
},
# Audit log retention lives at 'audit_retentiondays' (category site),
# defined above and read by shopdb/core/services/auditretention.py. A
# second key 'audit_retention_days' was seeded here with a different
# default and a different category, and the Audit & Logging page wrote
# to THAT one - so the page saved a value, read it back, and nothing
# ever consulted it. Removed rather than aliased: two keys for one
# setting is how this happened.
# 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')