Machine data arrives as a Maximo asset export (validated against the WINC site) that must update existing machines in place, not create canonical per-table rows, so it does not fit the ShopDB-native/Azure/Thor CSV adapter path. Bulk edits to live machines also need an escape hatch, so the whole commit is journaled and can be undone as one batch. Add a dedicated dry-run analyzer, approval packet, resolver/suggest helpers, and a reversible writer/undo backed by a new importbatches/importjournalentries schema (with migration). Expose preview/commit/batches/undo settings routes and a matching frontend source card and panel, and surface the batch/journal contract through shopdb.api for plugins.
1526 lines
61 KiB
Python
1526 lines
61 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()]
|
|
|
|
try:
|
|
report = winc_commit(export_text, acknowledge=acknowledge,
|
|
createdby=get_jwt_identity())
|
|
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
|
|
|
|
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 as exc:
|
|
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')
|
|
|
|
# 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}'
|
|
)
|
|
},
|
|
{
|
|
'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')
|