diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 11000c59..bff75fe8 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1184,6 +1184,16 @@ export const setupApi = { seedStarter() { return api.post('/setup/seed-starter') }, + // What is configured and what is not, with a route to each fix. Backs the + // standing Setup Checklist page as well as the wizard's review step. + checklist() { + return api.get('/setup/checklist') + }, + // Account counts plus whether plugins.json and the running process disagree, + // which is the only way to tell that a plugin toggle needs a restart. + health() { + return api.get('/setup/health') + }, complete() { return api.post('/setup/complete') } diff --git a/frontend/src/composables/setupState.js b/frontend/src/composables/setupState.js index 67806a11..3a113386 100644 --- a/frontend/src/composables/setupState.js +++ b/frontend/src/composables/setupState.js @@ -14,6 +14,21 @@ export function isSetupLoaded() { return loaded.value } +// Force the next navigation to re-read setup state. +// +// WHY THIS IS NEEDED. The router guard only calls refreshSetupState() when +// isSetupLoaded() is false, so the answer is fetched once per PAGE LOAD. Signing +// out and back in is not a page load: the module keeps `loaded` true and the +// stale value with it. An admin already browsing when setup state changes - or +// anyone signing in on a tab that was open beforehand - therefore never gets +// steered into the wizard, and a hard refresh is the only way out. Call this on +// login and logout so the next navigation asks again. +export function resetSetupState() { + loaded.value = false + setupComplete.value = true // optimistic until re-read, so nothing flashes + setupSkipped.value = false // a new session should be offered the wizard +} + export async function refreshSetupState() { try { const response = await settingsApi.get('setup_complete') diff --git a/frontend/src/router/routes/core.js b/frontend/src/router/routes/core.js index 81974c37..1a855504 100644 --- a/frontend/src/router/routes/core.js +++ b/frontend/src/router/routes/core.js @@ -284,5 +284,14 @@ export default [ name: 'shopdbbackups', component: () => import('../../views/settings/ShopdbBackupsList.vue'), meta: { requiresAuth: true, requiresAdmin: true } + }, + { + // The other half of the first-run wizard. The wizard asks only for what a + // site cannot run without; this is where the rest stays visible instead of + // being buried across a dozen settings pages. + path: 'settings/setupchecklist', + name: 'setup-checklist', + component: () => import('../../views/settings/SetupChecklist.vue'), + meta: { requiresAuth: true, requiresAdmin: true } } ] diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index aa9f5d48..8ce2fad0 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import { authApi, employeesApi } from '../api' +import { resetSetupState } from '../composables/setupState' export const useAuthStore = defineStore('auth', { state: () => ({ @@ -43,6 +44,10 @@ export const useAuthStore = defineStore('auth', { localStorage.setItem('token', access_token) localStorage.setItem('refreshToken', refresh_token) await this.enrichFromDirectory() + // Signing in is not a page load, so the router guard would otherwise + // keep whatever setup state it cached earlier in this tab and skip + // steering a fresh admin into the wizard. + resetSetupState() return { success: true } } catch (error) { @@ -64,6 +69,9 @@ export const useAuthStore = defineStore('auth', { localStorage.removeItem('token') localStorage.removeItem('refreshToken') localStorage.removeItem('user') + // The next user to sign in on this tab gets a fresh answer, not this + // one's. Also clears a session-only "skip for now". + resetSetupState() }, // Clear the forced-password-change flag after a successful change so the diff --git a/frontend/src/views/SetupWizard.vue b/frontend/src/views/SetupWizard.vue index 4ec684ec..aa829e52 100644 --- a/frontend/src/views/SetupWizard.vue +++ b/frontend/src/views/SetupWizard.vue @@ -3,7 +3,7 @@

Set up ShopDB

-

A few quick steps to get this site ready.

+

The few things a site cannot run without. Everything else is on the setup checklist afterwards.

  1. {{ i < step ? '✓' : i + 1 }} @@ -13,10 +13,25 @@
- -
-

Site details

-

Used for the dashboard header and QR / absolute links.

+ +
+

Administrator

+

You are signed in as an administrator, so the account this site was claimed with already exists. This step just confirms what is there.

+
+
Accounts
{{ health.users ?? '-' }}
+
Administrators
{{ health.admins ?? '-' }}
+
+

+ Only one administrator exists. If that account is lost, nobody can administer this site. + Consider adding a second before going live. +

+

Add or change accounts under Settings > Users.

+
+ + +
+

Site identity

+

Used for the dashboard header, QR codes and every absolute link the app generates.

@@ -25,24 +40,129 @@
+
+ + + +
- site logo - Skip to keep the shipped GE branding. You can change this later under Settings > Branding. + +
+ site logo on the sidebar background +
As shown in the sidebar
+
+ Skip to keep the shipped branding. Changeable later under Settings > Branding. +
+
+ + +
+

Authentication

+

How people sign in. Local accounts always work; turn on SAML to use your identity provider.

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +

+ With local login off, a misconfigured IdP locks everyone out, including you. + Leave it on until a SAML sign-in has been proven to work. +

Features

-

Turn features on or off. Changes to routes take effect after the app restarts.

+

Turn features on or off. Each one adds its own pages, API routes and tables. + Assets, applications, locations and users are core and always present, so they are not listed here.

+ +
+ {{ recommendedOff.length }} recommended feature(s) are off. + +
+ + +
+ Applying changes. + + Enabled but not yet loaded: {{ (health.plugins.awaitingrestart || []).join(', ') }}. + + + Disabled but still running: {{ (health.plugins.stillloaded || []).join(', ') }}. + +

Each worker picks the change up after finishing what it is serving, + so the site stays up while they roll over. This clears on its own, + usually within 20 seconds.

+

+ + {{ readyWorkers.size }} of {{ REQUIRED_CLEAN_WORKERS }} workers confirmed + ({{ pollElapsed }}s) +

+ +
+

Enabled features all match what is running.

+
-
+
@@ -58,14 +178,12 @@ Connect to your own database -

{{ p.provisioning_note.note }}

Creates in shopdb: {{ t }}

Schema: {{ p.provisioning_note.docs }}

-
@@ -82,68 +200,125 @@

No optional plugins found.

- +
-

Paste these into .env and restart the app (secrets are not stored in the database):

+

Paste these into .env and restart the app (these secrets are not stored in the database):

{{ envLines }}
- -
-

Floor map

-

Upload your facility blueprint (light and dark) for the default level, and set its native pixel size. Additional levels can be added later in Settings.

-
- - - - light blueprint -
-
- - - - dark blueprint -
-
-
- - + +
+

Mail and backups

+

Without a mail server no alert or notification can leave the site, and without a destination no backup leaves this host.

+ +

Outgoing mail

+ +
+
+
+ + +
+
+ + +
- - + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ +

Backup destination

+
+ + + + Nothing leaves this machine, so losing the host loses the backups with it. + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- -
-

Reference data

-

Seed the lookup data a new site needs: statuses, types, permissions, and a short list of common vendors. Both are idempotent - safe to run more than once.

-

This does not create any assets. The site starts empty and assets are loaded afterwards via the import API, so an empty dashboard here is expected.

-
-
- -

Statuses, machine/location/relationship types, permissions, default settings. Run this if you installed without the CLI seed.

-
-
- -

Dell, HP, Lenovo, and other common hardware vendors.

-
-
-

{{ seedResult }}

-
+ +
+

Review

+

Seed the lookup data this site needs, then finish. Anything still outstanding stays on the setup checklist, reachable from Settings at any time.

- -
-

All set

-

You can change any of this later under Settings. Finish to go to the dashboard.

+
+ + +
+

{{ seedResult }}

+

Both are idempotent, so running them twice is harmless. Neither creates assets; the site starts empty and assets arrive through the import API.

+ +

Still to do

+

Loading...

+

{{ checklistError }}

+

Nothing outstanding. This site is fully configured.

+
    +
  • + {{ statusLabel(a.status) }} + {{ a.label }} + {{ a.detail }} +
  • +
@@ -165,7 +340,7 @@ diff --git a/frontend/src/views/settings/settingsNav.js b/frontend/src/views/settings/settingsNav.js index a291f97c..0e23275e 100644 --- a/frontend/src/views/settings/settingsNav.js +++ b/frontend/src/views/settings/settingsNav.js @@ -4,7 +4,7 @@ // Order: site identity + the reference-data catalogs users touch daily come // first, then the platform/system groups (integrations, communication, search, // plugins, access) cluster together at the end. -import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup } from 'lucide-vue-next' +import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup, ListChecks} from 'lucide-vue-next' export const settingsGroups = [ { @@ -75,6 +75,7 @@ export const settingsGroups = [ { title: 'System', cards: [ + { to: '/settings/setupchecklist', icon: ListChecks, title: 'Setup Checklist', description: 'What this site has configured and what is still outstanding' }, { to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' }, { to: '/settings/shopdbbackups', icon: DatabaseBackup, title: 'Site Backups', description: 'Scheduled database and file backups with guided restore; keeps any day in the last 5' }, ], diff --git a/plugins/backups/manifest.json b/plugins/backups/manifest.json index 55f2806f..b2bfe88c 100644 --- a/plugins/backups/manifest.json +++ b/plugins/backups/manifest.json @@ -2,6 +2,7 @@ "name": "backups", "version": "1.0.0", "description": "Per-asset configuration backups with revision history. Kind-pluggable: NTLARS/DNC registry, part marker, UDC, CMM. Parseable kinds store a JSON projection in ShopDB and re-render to their native format on download; opaque vendor formats stay on the SFLD share with ShopDB holding metadata and the UNC pointer.", + "recommended": true, "author": "ShopDB Team", "dependencies": [], "core_version": ">=0.16.0,<1.0.0", diff --git a/plugins/computers/manifest.json b/plugins/computers/manifest.json index 7d4038de..49f8d757 100644 --- a/plugins/computers/manifest.json +++ b/plugins/computers/manifest.json @@ -1,23 +1,24 @@ { - "name": "computers", - "version": "1.0.0", - "description": "Computer management plugin for PCs, servers, and workstations with software tracking", - "author": "ShopDB Team", - "dependencies": [], - "core_version": ">=0.20.0,<1.0.0", - "api_prefix": "/api/computers", - "provides": { - "asset_type": "computer", - "features": [ - "computer_tracking", - "software_inventory", - "remote_access", - "os_management" - ] - }, - "settings": { - "enable_winrm": true, - "enable_vnc": true, - "auto_report_interval_hours": 24 - } + "name": "computers", + "version": "1.0.0", + "description": "Computer management plugin for PCs, servers, and workstations with software tracking", + "recommended": true, + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.20.0,<1.0.0", + "api_prefix": "/api/computers", + "provides": { + "asset_type": "computer", + "features": [ + "computer_tracking", + "software_inventory", + "remote_access", + "os_management" + ] + }, + "settings": { + "enable_winrm": true, + "enable_vnc": true, + "auto_report_interval_hours": 24 + } } diff --git a/plugins/guides/manifest.json b/plugins/guides/manifest.json index d9234e98..6aebb0ff 100644 --- a/plugins/guides/manifest.json +++ b/plugins/guides/manifest.json @@ -3,11 +3,14 @@ "display_name": "Guides", "version": "1.0.0", "description": "Step-by-step guides written and read inside ShopDB, with images, working checklists and printable output", + "recommended": true, "author": "ShopDB Team", "dependencies": [], "core_version": ">=0.1.0,<1.0.0", "api_prefix": "/api/guides", "provides": { - "features": ["guides"] + "features": [ + "guides" + ] } } diff --git a/plugins/knowledgebase/manifest.json b/plugins/knowledgebase/manifest.json index 46dfb510..972e7496 100644 --- a/plugins/knowledgebase/manifest.json +++ b/plugins/knowledgebase/manifest.json @@ -3,11 +3,14 @@ "display_name": "Knowledge Base", "version": "1.0.0", "description": "Knowledge Base articles linking to external resources", + "recommended": true, "author": "ShopDB Team", "dependencies": [], "core_version": ">=0.1.0,<1.0.0", "api_prefix": "/api/knowledgebase", "provides": { - "features": ["knowledgebase"] + "features": [ + "knowledgebase" + ] } } diff --git a/plugins/machines/manifest.json b/plugins/machines/manifest.json index 9252e458..28072a0f 100644 --- a/plugins/machines/manifest.json +++ b/plugins/machines/manifest.json @@ -1,22 +1,23 @@ { - "name": "machines", - "version": "1.0.0", - "description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines", - "author": "ShopDB Team", - "dependencies": [], - "core_version": ">=0.20.0,<1.0.0", - "api_prefix": "/api/machines", - "provides": { - "asset_type": "machine", - "features": [ - "machine_tracking", - "maintenance_scheduling", - "vendor_management", - "model_catalog" - ] - }, - "settings": { - "enable_maintenance_alerts": true, - "maintenance_alert_days": 30 - } + "name": "machines", + "version": "1.0.0", + "description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines", + "recommended": true, + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.20.0,<1.0.0", + "api_prefix": "/api/machines", + "provides": { + "asset_type": "machine", + "features": [ + "machine_tracking", + "maintenance_scheduling", + "vendor_management", + "model_catalog" + ] + }, + "settings": { + "enable_maintenance_alerts": true, + "maintenance_alert_days": 30 + } } diff --git a/plugins/network/manifest.json b/plugins/network/manifest.json index bb477dc5..3faeb4ba 100644 --- a/plugins/network/manifest.json +++ b/plugins/network/manifest.json @@ -1,22 +1,22 @@ { - "name": "network", - "version": "1.0.0", - "description": "Network device management plugin for switches, APs, cameras, and IDFs", - "author": "ShopDB Team", - "dependencies": [], - "core_version": ">=0.20.0,<1.0.0", - "api_prefix": "/api/network", - "provides": { - "asset_type": "network_device", - "features": [ - "network_device_tracking", - "port_management", - "firmware_tracking", - "poe_monitoring" - ] - }, - "settings": { - "enable_snmp_polling": false, - "snmp_community": "public" - } + "name": "network", + "version": "1.0.0", + "description": "Network device management plugin for switches, APs, cameras, and IDFs", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.20.0,<1.0.0", + "api_prefix": "/api/network", + "provides": { + "asset_type": "network_device", + "features": [ + "network_device_tracking", + "port_management", + "firmware_tracking", + "poe_monitoring" + ] + }, + "settings": { + "enable_snmp_polling": false, + "snmp_community": "public" + } } diff --git a/plugins/notifications/manifest.json b/plugins/notifications/manifest.json index 889fcd78..3f5373ef 100644 --- a/plugins/notifications/manifest.json +++ b/plugins/notifications/manifest.json @@ -1,12 +1,16 @@ -{ - "name": "notifications", - "version": "1.0.0", - "description": "Notifications and announcements management plugin", - "author": "ShopDB Team", - "dependencies": [], - "core_version": ">=0.1.0,<1.0.0", - "api_prefix": "/api/notifications", - "provides": { - "features": ["notifications", "announcements", "calendar_events"] - } -} +{ + "name": "notifications", + "version": "1.0.0", + "description": "Notifications and announcements management plugin", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/notifications", + "provides": { + "features": [ + "notifications", + "announcements", + "calendar_events" + ] + } +} diff --git a/plugins/printers/manifest.json b/plugins/printers/manifest.json index 75ae7812..9460d7d8 100644 --- a/plugins/printers/manifest.json +++ b/plugins/printers/manifest.json @@ -1,25 +1,26 @@ { - "name": "printers", - "version": "1.0.0", - "description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes", - "author": "ShopDB Team", - "dependencies": [], - "core_version": ">=0.20.0,<1.0.0", - "api_prefix": "/api/printers", - "provides": { - "machine_category": "Printer", - "features": [ - "printer_extensions", - "driver_management", - "supply_tracking", - "zabbix_integration", - "qr_codes" - ] - }, - "settings": { - "zabbix_url": "", - "zabbix_token": "", - "supply_alert_threshold": 10, - "default_driver_source": "internal" - } + "name": "printers", + "version": "1.0.0", + "description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes", + "recommended": true, + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.20.0,<1.0.0", + "api_prefix": "/api/printers", + "provides": { + "machine_category": "Printer", + "features": [ + "printer_extensions", + "driver_management", + "supply_tracking", + "zabbix_integration", + "qr_codes" + ] + }, + "settings": { + "zabbix_url": "", + "zabbix_token": "", + "supply_alert_threshold": 10, + "default_driver_source": "internal" + } } diff --git a/plugins/warranty/manifest.json b/plugins/warranty/manifest.json index d744e228..6101ffdd 100644 --- a/plugins/warranty/manifest.json +++ b/plugins/warranty/manifest.json @@ -2,11 +2,14 @@ "name": "warranty", "version": "1.0.0", "description": "Asset warranty tracking - manual entry now, Dell/Lenovo/HP provider lookups later. Derived coverage status + report buckets.", + "recommended": true, "author": "ShopDB Team", "dependencies": [], "core_version": ">=0.2.0,<1.0.0", "api_prefix": "/api/warranty", "provides": { - "features": ["warranty-tracking"] + "features": [ + "warranty-tracking" + ] } } diff --git a/shopdb/core/api/setup.py b/shopdb/core/api/setup.py index 8c7d539e..bf36a3c5 100644 --- a/shopdb/core/api/setup.py +++ b/shopdb/core/api/setup.py @@ -155,6 +155,367 @@ def seed_starter(): ) +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')