Employees + USB default-disabled with an enable-time provisioning note
Both plugins provision extra tables, so they now install disabled and explain
themselves before a site opts in.
- Plugin contract gains get_provisioning_note() -> {tables, note, docs}.
Employees and USB implement it (what tables get created in shopdb, how they
are referenced, link to the schema README; USB references the captured
DLP/reminder plans).
- Manifest default_enabled=false for employees + usb; the plugins list API
returns provisioning_note + default_enabled; install now registers a plugin
disabled when default_enabled is false.
- Setup wizard Features step renders the provisioning note the moment a plugin
with one is enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,11 +36,22 @@
|
|||||||
<h2>Features</h2>
|
<h2>Features</h2>
|
||||||
<p class="hint">Turn features on or off. Changes to routes take effect after the app restarts.</p>
|
<p class="hint">Turn features on or off. Changes to routes take effect after the app restarts.</p>
|
||||||
<div v-if="plugins.length" class="plugin-list">
|
<div v-if="plugins.length" class="plugin-list">
|
||||||
<label v-for="p in plugins" :key="p.name" class="plugin-row">
|
<div v-for="p in plugins" :key="p.name" class="plugin-item">
|
||||||
<input type="checkbox" :checked="p.enabled" @change="togglePlugin(p, $event.target.checked)" />
|
<label class="plugin-row">
|
||||||
<span class="plugin-name">{{ p.name }}</span>
|
<input type="checkbox" :checked="p.enabled" @change="togglePlugin(p, $event.target.checked)" />
|
||||||
<span class="plugin-desc">{{ p.description }}</span>
|
<span class="plugin-name">{{ p.name }}</span>
|
||||||
</label>
|
<span class="plugin-desc">{{ p.description }}</span>
|
||||||
|
</label>
|
||||||
|
<!-- Transparency note for plugins that provision extra tables -->
|
||||||
|
<div v-if="p.enabled && p.provisioning_note" class="provision-note">
|
||||||
|
<p>{{ p.provisioning_note.note }}</p>
|
||||||
|
<p class="provision-tables">
|
||||||
|
Creates in shopdb:
|
||||||
|
<code v-for="t in p.provisioning_note.tables" :key="t">{{ t }}</code>
|
||||||
|
</p>
|
||||||
|
<p v-if="p.provisioning_note.docs" class="provision-docs">Schema: <code>{{ p.provisioning_note.docs }}</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="hint">No optional plugins found.</p>
|
<p v-else class="hint">No optional plugins found.</p>
|
||||||
|
|
||||||
@@ -308,6 +319,10 @@ async function finish() {
|
|||||||
.plugin-row { display: grid; grid-template-columns: auto auto 1fr; gap: 0.6rem; align-items: baseline; padding: 0.5rem 0.6rem; background: var(--bg); border-radius: 6px; }
|
.plugin-row { display: grid; grid-template-columns: auto auto 1fr; gap: 0.6rem; align-items: baseline; padding: 0.5rem 0.6rem; background: var(--bg); border-radius: 6px; }
|
||||||
.plugin-name { font-weight: 600; text-transform: capitalize; }
|
.plugin-name { font-weight: 600; text-transform: capitalize; }
|
||||||
.plugin-desc { color: var(--text-light); font-size: 0.85rem; }
|
.plugin-desc { color: var(--text-light); font-size: 0.85rem; }
|
||||||
|
.provision-note { margin: 0.3rem 0 0.2rem 1.9rem; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 0.82rem; }
|
||||||
|
.provision-note p { margin: 0 0 0.35rem; }
|
||||||
|
.provision-note p:last-child { margin-bottom: 0; }
|
||||||
|
.provision-tables code, .provision-docs code { background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; padding: 0 0.3rem; margin-right: 0.3rem; font-size: 0.78rem; }
|
||||||
.seed-result { margin-top: 0.75rem; color: var(--success); font-size: 0.88rem; }
|
.seed-result { margin-top: 0.75rem; color: var(--success); font-size: 0.88rem; }
|
||||||
.config-block { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
.config-block { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||||
.config-title { margin: 0 0 0.75rem; font-size: 1rem; text-transform: capitalize; }
|
.config-title { margin: 0 0 0.75rem; font-size: 1rem; text-transform: capitalize; }
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
"core_version": ">=0.1.0,<1.0.0",
|
"core_version": ">=0.1.0,<1.0.0",
|
||||||
"api_prefix": "/api/employees",
|
"api_prefix": "/api/employees",
|
||||||
"provides": {
|
"provides": {
|
||||||
"features": ["employee_directory"]
|
"features": [
|
||||||
}
|
"employee_directory"
|
||||||
}
|
]
|
||||||
|
},
|
||||||
|
"default_enabled": false
|
||||||
|
}
|
||||||
@@ -60,6 +60,20 @@ class EmployeesPlugin(BasePlugin):
|
|||||||
External mode reads a separate DB via employee_connection instead."""
|
External mode reads a separate DB via employee_connection instead."""
|
||||||
return [DirectoryEmployee]
|
return [DirectoryEmployee]
|
||||||
|
|
||||||
|
def get_provisioning_note(self) -> Optional[Dict]:
|
||||||
|
return {
|
||||||
|
'tables': ['directoryemployees'],
|
||||||
|
'note': ('Enabling this in self-hosted mode creates a '
|
||||||
|
'"directoryemployees" table in the shopdb database (SSO, '
|
||||||
|
'First_Name, Last_Name, Team, Role, Picture). It is '
|
||||||
|
'referenced across the site: employee search, notification '
|
||||||
|
'recognition (name + photo on the shopfloor/lobby displays), '
|
||||||
|
'and USB check-in/out name resolution. Manage people under '
|
||||||
|
'Settings > Employee Directory, or point at an existing HR '
|
||||||
|
'database instead (external mode).'),
|
||||||
|
'docs': 'plugins/employees/README.md',
|
||||||
|
}
|
||||||
|
|
||||||
def get_config_schema(self) -> List[Dict]:
|
def get_config_schema(self) -> List[Dict]:
|
||||||
"""Employee directory DB connection. Host/name/user are settings the
|
"""Employee directory DB connection. Host/name/user are settings the
|
||||||
wizard can edit; the password stays in .env (emitted, not stored)."""
|
wizard can edit; the password stays in .env (emitted, not stored)."""
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "usb",
|
"name": "usb",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "USB device checkout management",
|
"description": "USB device checkout management",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.1.0,<1.0.0",
|
"core_version": ">=0.1.0,<1.0.0",
|
||||||
"api_prefix": "/api/usb"
|
"api_prefix": "/api/usb",
|
||||||
}
|
"default_enabled": false
|
||||||
|
}
|
||||||
@@ -56,6 +56,20 @@ class USBPlugin(BasePlugin):
|
|||||||
"""Return list of SQLAlchemy model classes."""
|
"""Return list of SQLAlchemy model classes."""
|
||||||
return [USBDeviceType, USBDevice, USBCheckout]
|
return [USBDeviceType, USBDevice, USBCheckout]
|
||||||
|
|
||||||
|
def get_provisioning_note(self) -> Optional[Dict]:
|
||||||
|
return {
|
||||||
|
'tables': ['usbdevicetypes', 'usbdevices', 'usbcheckouts'],
|
||||||
|
'note': ('Enabling this creates USB tracking tables in the shopdb '
|
||||||
|
'database (usbdevicetypes, usbdevices, usbcheckouts) for CMMC '
|
||||||
|
'removable-media check-in/out. Devices, check-in/out events, '
|
||||||
|
'and per-event log ids live here. Planned build-out: a USB-ID '
|
||||||
|
'standard, overdue-device email reminders, and DLP/Fabric '
|
||||||
|
'approval integration (see the captured design notes). A site '
|
||||||
|
'with an existing cmmc_usb database can use external mode '
|
||||||
|
'instead.'),
|
||||||
|
'docs': 'plugins/usb/README.md',
|
||||||
|
}
|
||||||
|
|
||||||
def get_config_schema(self) -> List[Dict]:
|
def get_config_schema(self) -> List[Dict]:
|
||||||
"""CMMC USB check-in/out database connection. Host/name/user are settings
|
"""CMMC USB check-in/out database connection. Host/name/user are settings
|
||||||
the wizard can edit; the password stays in .env (emitted, not stored)."""
|
the wizard can edit; the password stays in .env (emitted, not stored)."""
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ class PluginManager:
|
|||||||
config_schema = temp.get_config_schema()
|
config_schema = temp.get_config_schema()
|
||||||
except Exception:
|
except Exception:
|
||||||
config_schema = []
|
config_schema = []
|
||||||
|
try:
|
||||||
|
provisioning_note = temp.get_provisioning_note()
|
||||||
|
except Exception:
|
||||||
|
provisioning_note = None
|
||||||
|
manifest = self.loader.load_manifest(name)
|
||||||
available.append({
|
available.append({
|
||||||
'name': meta.name,
|
'name': meta.name,
|
||||||
'version': meta.version,
|
'version': meta.version,
|
||||||
@@ -157,6 +162,8 @@ class PluginManager:
|
|||||||
'enabled': state.enabled if state else False,
|
'enabled': state.enabled if state else False,
|
||||||
'installedat': state.installed_at if state else None,
|
'installedat': state.installed_at if state else None,
|
||||||
'config_schema': config_schema,
|
'config_schema': config_schema,
|
||||||
|
'provisioning_note': provisioning_note,
|
||||||
|
'default_enabled': manifest.get('default_enabled', True),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error inspecting plugin {name}: {e}")
|
logger.warning(f"Error inspecting plugin {name}: {e}")
|
||||||
@@ -203,7 +210,10 @@ class PluginManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Register plugin
|
# Register plugin
|
||||||
self.registry.register(name, manifest_version)
|
# Plugins that provision extra tables install disabled until a site
|
||||||
|
# opts in (manifest default_enabled=false).
|
||||||
|
self.registry.register(name, manifest_version,
|
||||||
|
enabled=manifest.get('default_enabled', True))
|
||||||
|
|
||||||
# Load the plugin
|
# Load the plugin
|
||||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||||
|
|||||||
@@ -74,6 +74,20 @@ class BasePlugin(ABC):
|
|||||||
"""Return dict of service name -> service class."""
|
"""Return dict of service name -> service class."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
def get_provisioning_note(self) -> Optional[Dict]:
|
||||||
|
"""Transparency note shown when a site enables this plugin.
|
||||||
|
|
||||||
|
Return None for plugins that need no special setup. For plugins that
|
||||||
|
create extra tables (e.g. a self-hosted directory or USB tables), return:
|
||||||
|
{
|
||||||
|
'tables': ['directoryemployees', ...], # created in the shopdb DB
|
||||||
|
'note': 'Plain-language what/why.',
|
||||||
|
'docs': 'plugins/<name>/README.md', # where the schema lives
|
||||||
|
}
|
||||||
|
The setup wizard shows this the moment the plugin is checked.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
def get_config_schema(self) -> List[Dict]:
|
def get_config_schema(self) -> List[Dict]:
|
||||||
"""Declare the config fields this plugin needs, for the setup wizard.
|
"""Declare the config fields this plugin needs, for the setup wizard.
|
||||||
|
|
||||||
|
|||||||
@@ -53,13 +53,14 @@ class PluginRegistry:
|
|||||||
}
|
}
|
||||||
}, f, indent=2)
|
}, f, indent=2)
|
||||||
|
|
||||||
def register(self, name: str, version: str) -> PluginState:
|
def register(self, name: str, version: str, enabled: bool = True) -> PluginState:
|
||||||
"""Register a newly installed plugin."""
|
"""Register a newly installed plugin. enabled=False leaves it off until a
|
||||||
|
site opts in (used for plugins that provision extra tables)."""
|
||||||
state = PluginState(
|
state = PluginState(
|
||||||
name=name,
|
name=name,
|
||||||
version=version,
|
version=version,
|
||||||
installed_at=datetime.utcnow().isoformat(),
|
installed_at=datetime.utcnow().isoformat(),
|
||||||
enabled=True
|
enabled=enabled
|
||||||
)
|
)
|
||||||
self._plugins[name] = state
|
self._plugins[name] = state
|
||||||
self._save()
|
self._save()
|
||||||
|
|||||||
Reference in New Issue
Block a user