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.
{{ 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 @@
+
+
+
+
+
-
- Skip to keep the shipped GE branding. You can change this later under Settings > Branding.
+
+
+
+ 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.
@@ -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.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
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.
@@ -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')