"""First-run setup wizard support endpoints. The wizard itself is frontend; these endpoints cover the pieces that need server work: seeding common starter data and marking setup finished. Site, plugin, and map configuration reuse the existing settings / plugins APIs. """ import hmac import ipaddress import os from flask import Blueprint, request, current_app from flask_jwt_extended import jwt_required from werkzeug.security import generate_password_hash from shopdb.extensions import db from shopdb.core.models import Vendor, Setting, User, Role from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.authz import require_role setup_bp = Blueprint('setup', __name__) # Common hardware vendors most sites will want on hand. STARTER_VENDORS = [ ('Dell Inc.', 'https://www.dell.com'), ('HP Inc.', 'https://www.hp.com'), ('Lenovo', 'https://www.lenovo.com'), ('Xerox', 'https://www.xerox.com'), ('Zebra Technologies', 'https://www.zebra.com'), ('Cisco', 'https://www.cisco.com'), ('Brother', 'https://www.brother.com'), ('Microsoft', 'https://www.microsoft.com'), ] # Minimum length for the bootstrap administrator password. 12 characters is the # shortest length that still resists offline cracking of a single leaked hash. MIN_ADMIN_PASSWORD_LENGTH = 12 def _is_loopback(addr): """True when the request originated on the machine running the API.""" if not addr: return False try: return ipaddress.ip_address(addr.split('%')[0]).is_loopback except ValueError: return False def _bootstrap_allowed(): """Proof of operator control for first-admin creation. An empty users table is an ordering condition, not authorization: the API serves requests before the first account exists on every fresh deploy, so a remote caller could otherwise claim the instance during the install window. Require either the one-time setup token (SETUP_TOKEN in config or the environment, presented as the X-Setup-Token header) or a request coming from loopback, i.e. the host the operator is installing on. """ token = str(current_app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN') or '').strip() if token: supplied = (request.headers.get('X-Setup-Token') or '').strip() return bool(supplied) and hmac.compare_digest(supplied, token) return _is_loopback(request.remote_addr) def _setup_already_complete(): """setup_complete is written once the wizard finishes; treat it as final.""" row = Setting.query.filter_by(key='setup_complete').first() return bool(row) and str(row.value).strip().lower() in ('true', '1', 'yes') @setup_bp.route('/needs-admin', methods=['GET']) def needs_admin(): """True when no users exist yet - the app's very first run. Public so the login screen can offer to create the first admin.""" return success_response({'needsadmin': User.query.count() == 0}) @setup_bp.route('/create-admin', methods=['POST']) def create_admin(): """Create the first admin. Only works while there are zero users, setup has not been marked complete, and the caller proves operator control (setup token or a loopback request).""" if User.query.count() > 0 or _setup_already_complete(): return error_response(ErrorCodes.FORBIDDEN, 'An account already exists; sign in instead.', http_code=403) if not _bootstrap_allowed(): return error_response( ErrorCodes.FORBIDDEN, 'First-admin creation requires the setup token (X-Setup-Token) ' 'or a request from the server itself.', http_code=403) data = request.get_json() or {} username = (data.get('username') or '').strip() email = (data.get('email') or '').strip() password = data.get('password') or '' if not (username and email and password): return error_response(ErrorCodes.VALIDATION_ERROR, 'username, email and password are required') if len(password) < MIN_ADMIN_PASSWORD_LENGTH: return error_response( ErrorCodes.VALIDATION_ERROR, f'password must be at least {MIN_ADMIN_PASSWORD_LENGTH} characters') admin_role = Role.query.filter_by(rolename='admin').first() if not admin_role: admin_role = Role(rolename='admin', description='Administrator') db.session.add(admin_role) user = User(username=username, email=email, passwordhash=generate_password_hash(password), isactive=True) user.roles.append(admin_role) db.session.add(user) db.session.commit() current_app.logger.warning('First administrator %r created from %s', username, request.remote_addr) return success_response({'username': username}, message='Admin created', http_code=201) @setup_bp.route('/seed-reference', methods=['POST']) @jwt_required() @require_role('admin') def seed_reference(): """Seed core reference data + permissions + default settings. Idempotent - reuses the same routines as the `flask seed ...` CLI commands.""" # Invoke the seed CLI commands in a proper click context via the runner. runner = current_app.test_cli_runner() for command in ('reference-data', 'permissions', 'settings'): result = runner.invoke(args=['seed', command]) if result.exit_code not in (0, None): return error_response(ErrorCodes.INTERNAL_ERROR, f'seed {command} failed: {result.output}', http_code=500) return success_response(message='Reference data, permissions and settings seeded.') @setup_bp.route('/seed-starter', methods=['POST']) @jwt_required() @require_role('admin') def seed_starter(): """Add common vendors that are not already present. Idempotent.""" existing = {v.vendor.strip().lower() for v in Vendor.query.all()} added = [] for name, website in STARTER_VENDORS: if name.strip().lower() in existing: continue db.session.add(Vendor(vendor=name, website=website)) added.append(name) db.session.commit() return success_response( {'added': added, 'addedcount': len(added)}, message=f'Seeded {len(added)} vendor(s).' ) def _setting_values(): """Every setting as {key: value}, read once. The checklist inspects roughly forty keys across a dozen categories, so it reads the table once rather than issuing a query per item. """ return {row.key: (row.value or '') for row in Setting.query.all()} def _truthy(value): return str(value or '').strip().lower() in ('true', '1', 'yes', 'on') def _filled(values, *keys): """True only when every named key has a non-empty value.""" return all(str(values.get(k) or '').strip() for k in keys) # Values that are SEEDED, not chosen. A setting holding one of these is still # at its shipped default, so reporting it as configured would tell a site it had # done something it has not: site_logo ships as the GE mark and the floor map # ships as a placeholder image, and both are non-empty from the first boot. SHIPPED_DEFAULTS = { 'site_logo': ('/ge-aerospace-logo.svg',), 'map_blueprint_light': ('/static/images/floorplan-placeholder.svg',), 'map_blueprint_dark': ('/static/images/floorplan-placeholder.svg',), } def _customised(values, key): """True when a key holds something other than what shipped with it.""" value = str(values.get(key) or '').strip() if not value: return False if value in SHIPPED_DEFAULTS.get(key, ()): return False # Defensive: a renamed placeholder asset should still read as unconfigured. return 'placeholder' not in value.lower() def _area(key, label, status, detail, route, wizard=False): return {'key': key, 'label': label, 'status': status, 'detail': detail, 'route': route, 'inwizard': wizard} def _enabled_plugin_names(): """Plugins the registry says are on. Empty when plugins are unavailable.""" from shopdb.plugins import plugin_manager registry = getattr(plugin_manager, 'registry', None) if registry is None: return set() try: return set(registry.get_enabled_plugins()) except Exception: # a corrupt registry must not break the checklist return set() def _loaded_plugin_names(): """Plugins actually loaded into THIS process.""" from shopdb.plugins import plugin_manager loader = getattr(plugin_manager, 'loader', None) if loader is None: return set() try: return set(loader.get_all_loaded().keys()) except Exception: return set() def _manager_call(method): """Call a plugin-manager method by name, tolerating its absence.""" from shopdb.plugins import plugin_manager getter = getattr(plugin_manager, method, None) if getter is None: return set() try: return set(getter()) except Exception: return set() def _enabled_on_disk(): """Enabled plugins per plugins.json, or None when it cannot be read. None and the empty set mean different things: "I could not tell you" versus "nothing is enabled". Collapsing them made an all-disabled site fall back to a stale in-memory view. """ from shopdb.plugins import plugin_manager getter = getattr(plugin_manager, 'enabled_on_disk', None) if getter is None: return None try: return set(getter()) except Exception: return None def _booted_with_names(): return _manager_call('get_booted_with') def _deferred_registration_names(): """Plugins whose routes are waiting on a restart in this process.""" from shopdb.plugins import plugin_manager getter = getattr(plugin_manager, 'get_deferred_registrations', None) if getter is None: return set() try: return set(getter()) except Exception: return set() def _build_checklist(values): """Configured-vs-not for every area a site is expected to set up. WHY THIS EXISTS. The wizard covers only what a site cannot run without; everything else would otherwise be invisible until someone went looking for it in Settings. There are well over a hundred settings, and a wizard that asked for all of them would be worse than no wizard. This is the other half: a standing list of what is still unconfigured, with a link straight to the page that configures it. Status is deliberately three-valued. 'partial' matters because several areas have an enable flag plus the fields that flag needs - SAML switched on with no IdP metadata is a broken site, and reporting that as 'ok' would hide it. """ enabled = _enabled_plugin_names() areas = [] # --- identity ------------------------------------------------------- # Only facility_name is genuinely required. The wizard offers "blank = use # the browsing origin" for site_base_url and "Use the server timezone" for # site_timezone, and saves '' for both - so demanding them here told an # operator who took an option the wizard offered that they had not finished. if _filled(values, 'facility_name'): status, detail = 'ok', values.get('facility_name', '') else: status, detail = 'todo', 'Facility name is unset' areas.append(_area('site_identity', 'Site identity', status, detail, '/settings/siteconfig/site', wizard=True)) # --- authentication ------------------------------------------------- if _truthy(values.get('saml_enabled')): if _filled(values, 'saml_entity_id', 'saml_acs_url', 'saml_idp_metadata_url'): status, detail = 'ok', 'SAML configured' else: status, detail = 'partial', 'SAML is on but the IdP fields are incomplete' else: status, detail = 'todo', 'Local accounts only; SAML is off' areas.append(_area('authentication', 'Authentication', status, detail, '/settings/security/authentication', wizard=True)) # --- email ---------------------------------------------------------- if _truthy(values.get('smtp_enabled')): if _filled(values, 'smtp_host', 'smtp_from_address'): status, detail = 'ok', values.get('smtp_host', '') else: status, detail = 'partial', 'SMTP is on but host or from-address is unset' else: status, detail = 'todo', 'No mail server; alerts and notifications cannot send' areas.append(_area('email', 'Email / SMTP', status, detail, '/settings/email', wizard=True)) # --- backup destination --------------------------------------------- kind = (values.get('backup_destination_kind') or '').strip().lower() if kind == 'smb' and _filled(values, 'backup_smb_host', 'backup_smb_share'): status, detail = 'ok', f"SMB: {values.get('backup_smb_host')}" elif kind in ('aws', 's3') and _filled(values, 'backup_aws_bucket'): status, detail = 'ok', f"S3: {values.get('backup_aws_bucket')}" elif kind in ('smb', 'aws', 's3'): status, detail = 'partial', f'{kind} selected but its fields are incomplete' else: # 'local' is the seeded default and a legitimate choice, but it means # no copy leaves this host, so a site should make that decision rather # than inherit it. status, detail = 'todo', 'Backups stay on this host; no offsite copy' areas.append(_area('backup', 'Backup destination', status, detail, '/settings/shopdbbackups', wizard=True)) # --- branding ------------------------------------------------------- if _customised(values, 'site_logo'): status, detail = 'ok', 'Custom logo set' else: status, detail = 'todo', 'Using the shipped logo and colours' areas.append(_area('branding', 'Branding', status, detail, '/settings/siteconfig/branding')) # --- floor map ------------------------------------------------------ has_blueprint = _customised(values, 'map_blueprint_light') if has_blueprint and _filled(values, 'map_width', 'map_height'): status, detail = 'ok', f"{values.get('map_width')} x {values.get('map_height')}" elif has_blueprint: status, detail = 'partial', 'Blueprint uploaded but dimensions are unset' else: status, detail = 'todo', 'Still on the shipped placeholder blueprint' areas.append(_area('floormap', 'Floor map', status, detail, '/settings/siteconfig/floormap')) # --- integrations --------------------------------------------------- live = [name for flag, name in ( ('zabbix_enabled', 'Zabbix'), ('warranty_dell_enabled', 'Dell warranty'), ('servicenow_enabled', 'ServiceNow'), ) if _truthy(values.get(flag))] if live: status, detail = 'ok', ', '.join(live) else: status, detail = 'todo', 'Zabbix, Dell warranty and ServiceNow are all off' areas.append(_area('integrations', 'Integrations', status, detail, '/settings/integrations')) # --- identifiers ---------------------------------------------------- # Every identifier_* key is seeded 'true' (settings.py build_default_settings), # so "any enabled" was true on every fresh site and this area could never # read anything but ok. Report it as a review item instead of pretending a # decision was made. ident_total = [k for k in values if k.startswith('identifier_')] ident_on = [k for k in ident_total if _truthy(values[k])] if ident_total and len(ident_on) < len(ident_total): status = 'ok' detail = f'{len(ident_on)} of {len(ident_total)} identifier fields enabled' else: status = 'todo' detail = 'All identifier fields are on by default; trim to what this site uses' areas.append(_area('identifiers', 'Asset identifiers', status, detail, '/settings/assetconfig/identifiers')) # --- printing / QR --------------------------------------------------- qr = [k for k in values if k.startswith('qr_target_') and _filled(values, k)] if qr: status, detail = 'ok', f'{len(qr)} QR target(s) set' else: status, detail = 'todo', 'QR codes will fall back to defaults' areas.append(_area('printing', 'Printing and labels', status, detail, '/settings/printing')) # --- endpoint agent policy (only when the plugin is on) -------------- if 'endpoints' in enabled: # The runtime default is PERMISSIVE: shared_key_allowed() in # plugins/endpoints/api/agentauth.py reads the setting with default=True, # so an absent row means the fleet-wide key is accepted. Treating absent # as "off" told an admin the key was refused on exactly the sites that # still accept it, which is the opposite of the truth about a credential # readable by any local user. raw = values.get('plugin.endpoints.sharedkeyallowed') shared = True if raw in (None, '') else _truthy(raw) if shared: status = 'partial' detail = ('The fleet-wide shared key is still accepted' + ('' if raw not in (None, '') else ' (setting unset, defaults to allowed)')) else: status, detail = 'ok', 'Shared key refused; per-PC credentials only' else: status, detail = 'na', 'Endpoints plugin is not enabled' areas.append(_area('endpoint_policy', 'Endpoint agent policy', status, detail, '/settings/plugins')) # --- locations ------------------------------------------------------- # Location is a top-level export, so the import cannot fail; only the query # can. Swallowing that reported "no locations" as a data fact AND left the # session in a failed transaction, so the next count() below raised and the # whole endpoint 500'd with a misleading detail already in the payload. from shopdb.core.models import Location try: count = Location.query.count() except Exception: db.session.rollback() count = None if count is None: status, detail = 'todo', 'Could not read locations' else: status = 'ok' if count else 'todo' detail = f'{count} location(s)' if count else 'No locations defined yet' areas.append(_area('locations', 'Locations', status, detail, '/settings/locations/list')) # --- reference data --------------------------------------------------- vendors = Vendor.query.count() status = 'ok' if vendors else 'todo' detail = f'{vendors} vendor(s)' if vendors else 'No vendors seeded' areas.append(_area('reference_data', 'Reference data', status, detail, '/settings/catalog/vendors', wizard=True)) return areas @setup_bp.route('/checklist', methods=['GET']) @jwt_required() @require_role('admin') def checklist(): """What is configured and what is not, with a link to each fix.""" values = _setting_values() areas = _build_checklist(values) counts = {'ok': 0, 'partial': 0, 'todo': 0, 'na': 0} for area in areas: counts[area['status']] = counts.get(area['status'], 0) + 1 return success_response({ 'areas': areas, 'counts': counts, 'complete': _setup_already_complete(), }) @setup_bp.route('/health', methods=['GET']) @jwt_required() @require_role('admin') def health(): """Post-setup reality check, including whether a restart is outstanding. RESTART DETECTION IS THE POINT. Plugins are read from plugins.json at app startup, so enabling one in the wizard changes the file but not the running process: every route that plugin contributes answers 404 until the app is restarted, with nothing on screen to say so. Comparing what the registry says is enabled against what this process actually loaded turns that into something the wizard can state plainly and then re-verify. """ # Intent comes from plugins.json re-read from disk, because that file is # shared by every gunicorn worker while each worker's in-memory registry is # its own. Reality is the set of plugins THIS process registered routes for # at boot. Two things make loader.get_all_loaded() the wrong comparison: # installing a plugin instantiates it (so it looks loaded while its # blueprint was skipped), and a plugin enabled through another worker is # invisible here. The boot snapshot has neither problem - a plugin enabled # after start is missing from every worker's snapshot. # `or` would treat "nothing enabled" as "could not read", falling back to # this worker's stale boot view and hiding every stale-loaded plugin. enabled = _enabled_on_disk() if enabled is None: enabled = _enabled_plugin_names() booted = _booted_with_names() pending = sorted(enabled - booted) stale = sorted(booted - enabled) loaded = _loaded_plugin_names() # role.users is a dynamic relationship (AppenderQuery), so it answers to # .count() and NOT to len(). len() raised here, the exception was swallowed, # and the endpoint reported zero admins on a site that had one. admin_role = Role.query.filter_by(rolename='admin').first() admins = admin_role.users.count() if admin_role else 0 return success_response({ 'database': True, # reaching here means the session works # Which worker answered. Each one recycles independently when the # enabled set changes, so a single clean reply only proves THAT worker # has rolled. A caller that sees clean replies from several distinct # pids knows the change has actually taken hold across the pool. 'worker': os.getpid(), 'users': User.query.count(), 'admins': admins, 'plugins': { 'enabled': sorted(enabled), 'loaded': sorted(loaded), 'awaitingrestart': pending, 'stillloaded': stale, }, 'restartrequired': bool(pending or stale), }) @setup_bp.route('/complete', methods=['POST']) @jwt_required() @require_role('admin') def mark_complete(): """Flag the first-run setup as finished.""" row = Setting.query.filter_by(key='setup_complete').first() if row: row.value = 'true' else: db.session.add(Setting(key='setup_complete', value='true', valuetype='boolean', category='site')) db.session.commit() return success_response({'complete': True}, message='Setup marked complete.')