Five findings from the review of the WINC import, plus the rendering each fix needed to be usable. An undo that hit a conflict wrote status 'undone' anyway, and undo_batch returns immediately for an undone batch. So the one entry that needed a retry was the only one that could never get it: the human resolves the conflict, clicks Undo, and gets a no-op. A conflicted undo is now 'partially-undone', undoneat is stamped only when the batch is genuinely finished, and the batches table offers "Retry undo" for that status. The column was String(16) and the new value is exactly 16 characters, so migration 7d38 widens it to 32 rather than rely on an exact fit. _UNDO_REFERENCES listed only assets, so a business unit this batch created was hard-deleted even when something else still pointed at it. Two of the three missing referrers are real FKs (dashboarddefaults.businessunitid, businessunits.parentid): MySQL raises and the whole undo rolls back, so the batch cannot be undone at all. The third, notifications.businessunitid, has no FK, so the delete succeeded and left a notification pointing at an id that no longer exists - silently, on both engines. All four are listed and the guard is tested through the route. The reference guard also queried tables that need not exist. A lean site drops the tables of plugins it does not install (ADR-014), and notifications is plugin-owned, so on such a site the query raised 1146, rolled the undo back and left the batch permanently un-undoable. Absent tables are skipped; a table that does not exist cannot hold a reference. _PACKET_GROUPS reports on location, but apply_plan only ever creates business units - it sets locationid only when the location already resolved. A plan needing a new location asked the admin to acknowledge it, got the tick, wrote nothing, and reported nothing. build_packet now takes the groups the writer can actually create; anything else needed is 'uncreatable' and blocks at plan time, where the admin can act on it, rather than dying at commit on a message about acknowledging something the screen had no control for. The writer refuses the same case independently. Both the console packet and the import page name the blocking groups: setting blocking without printing the reason would leave the banner pointing at nothing. The web undo route caught ValueError only, so refusing a batch belonging to another importer surfaced as a 500. That refusal is new too: the journal is a general primitive and undo_batch does not filter on kind, so any other importer's batches were reversible through this endpoint, carrying WINC's references map - which lists none of that importer's tables, making every reference check pass and every delete unconditional. Nine tests. The two guards were verified by planting violations: dropping dashboarddefaults from the references map, and passing creatable=None, each fail the test written for them. Scope: product
1541 lines
62 KiB
Python
1541 lines
62 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')
|
|
|
|
# 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')
|