Show the kiosk label prefix, and let a plugin declare the settings it owns

Three defects, all found on printedparts_label_prefix, all one root cause:
nothing in the framework knew that setting existed.

The parts kiosk runs logged out. An unauthenticated read of a setting is
limited to an allowlist, the key was not on it, so the kiosk got a 404 and
fell back to no prefix. An admin previewing the same page while logged in saw
the prefix, which is why it looked like it worked.

The same setting also looked like it would not save. The row did not exist on
a site that installed the plugin before the setting was added, so the first
save created it - under the placeholder category the settings API uses for
keys it does not recognise, where the plugin's settings page, which lists by
category, could no longer see it. The value was in the database the whole
time.

And the row was missing in the first place because seeding ran from
on_install / on_enable, which fire only on a state transition. Neither runs
again on an upgrade, so a setting added in a later plugin version never
reached a site that installed an earlier one. The comment claiming enable ran
every upgrade cycle was simply wrong.

A plugin now declares the settings it owns in get_settings_defaults(): key,
default, type, category, description, and whether a logged-out page may read
it. The framework seeds declared keys at install, at enable, and on every
flask plugin upgrade-all; files a first-time write under the declared
category; re-homes any row left in the placeholder category, value untouched;
and answers an anonymous read for keys marked public. Core carries no list of
any plugin's keys.

Contract 0.16.0 (additive optional hook). printedparts and printers move to
the hook and floor their core_version at 0.16.0. The dev database had two rows
in the misfiled state (printedparts_alert_email, employee_db_host); the first
repairs itself on the next upgrade pass.
This commit is contained in:
cproudlock
2026-08-06 18:17:49 -04:00
parent 1aeb3bd1d4
commit 593dd46525
15 changed files with 538 additions and 85 deletions

View File

@@ -36,7 +36,13 @@ from .plugins import plugin_manager
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name
# on the import surface, minor bump.
__contract_version__ = '0.15.0'
# 0.16.0: added the get_settings_defaults hook so a plugin declares the Setting
# rows it owns (key, value, type, category, description, public). The framework
# seeds them at install, at enable, and on `flask plugin upgrade-all`, files a
# first-time write under the declared category, and lets a plugin mark a key
# readable without auth for pages that run logged out. Additive optional hook,
# minor bump.
__contract_version__ = '0.16.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -52,6 +52,8 @@ SECRET_MASK = '********'
# frontend/src/utils/siteSettings.js + mapConfig.js + setupState.js read before
# login. Whole categories that are purely presentation are allowed wholesale;
# the rest are named keys so a new integration key does not leak by default.
# A plugin adds its own public keys by declaring public=True in
# get_settings_defaults - core does not carry a list of every plugin's keys.
PUBLIC_SETTING_CATEGORIES = {'branding', 'map'}
PUBLIC_SETTING_KEYS = {
'site_base_url', 'facility_name', 'printer_hostname_template',
@@ -60,9 +62,25 @@ PUBLIC_SETTING_KEYS = {
}
def _plugin_declared_settings() -> dict:
"""Declared settings ({key: entry}) of every loaded plugin, or {}."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return {}
try:
return pm.get_declared_plugin_settings()
except Exception:
current_app.logger.exception('Could not read plugin setting declarations')
return {}
def _is_public_setting(setting) -> bool:
return (setting.category in PUBLIC_SETTING_CATEGORIES
or setting.key in PUBLIC_SETTING_KEYS)
# A plugin declares its own public keys (get_settings_defaults, public=True)
# so a kiosk or print page that renders before login can read them without
# core carrying a list of every plugin's keys.
if setting.category in PUBLIC_SETTING_CATEGORIES or setting.key in PUBLIC_SETTING_KEYS:
return True
return bool(_plugin_declared_settings().get(setting.key, {}).get('public'))
# Optional asset identifiers and the asset types they can be toggled on.
# Drives per-type seed keys and the Settings matrix UI. The asset type names
@@ -92,6 +110,17 @@ SEARCH_DOMAINS = {
'subnet': 'Subnets',
}
def _declared_default(key: str) -> dict:
"""Return the declared default for a key (core defaults, then plugins).
Empty dict when nobody declares it - a genuinely ad-hoc key.
"""
for entry in build_default_settings():
if entry['key'] == key:
return entry
return _plugin_declared_settings().get(key, {})
def _is_secret(key: str) -> bool:
return 'password' in key or 'token' in key or 'secret' in key
@@ -256,9 +285,19 @@ def update_setting(key: str):
setting = Setting.query.filter_by(key=key).first()
# Upsert: create the row on first write (e.g. plugin config keys the setup
# wizard saves). New keys default to a plugin-scoped string setting.
# wizard saves). Take the category, type and description from whoever
# declares the key - core defaults or a plugin's get_settings_defaults.
# Filing a declared key under a placeholder category hid it from the
# settings page that had just written it, which read as "did not save".
if not setting:
setting = Setting(key=key, value='', valuetype='string', category='plugin')
declared = _declared_default(key)
setting = Setting(
key=key,
value='',
valuetype=declared.get('valuetype', 'string'),
category=declared.get('category', 'plugin'),
description=declared.get('description'),
)
db.session.add(setting)
# Track old value for audit

View File

@@ -114,6 +114,11 @@ class PluginManager:
per-plugin migrations extend that chain. See ADR-008. Idempotent.
"""
results: Dict[str, str] = {}
# Settings declared by a later plugin version reach an already-installed
# site here. on_install/on_enable fire only on a state transition, so an
# upgrade is the only moment left to seed them. Runs even with no
# migration manager - it is independent of the Alembic chains.
self.sync_all_plugin_settings()
if not self.migration_manager:
return results
# Only ADOPTED plugins (those in the registry) get migrated. A plugin
@@ -134,6 +139,50 @@ class PluginManager:
results[name] = f'error: {ex}'
return results
def sync_all_plugin_settings(self) -> int:
"""Seed every enabled plugin's declared settings. Idempotent.
Returns the number of plugins whose settings were touched. Best-effort
per plugin: one bad plugin must not abort a deploy's upgrade pass.
"""
touched = 0
for name in list(self.registry.get_all().keys()):
if not self.registry.is_enabled(name):
continue
try:
plugin = self.loader.load_plugin(name, self._app, self._db)
except Exception:
logger.exception("Could not load %s to sync its settings", name)
continue
if plugin and self._seed_plugin_settings(plugin):
touched += 1
return touched
def get_declared_plugin_settings(self) -> Dict[str, dict]:
"""Return {key: declared entry} across all loaded plugins.
The settings API uses this to file a first-time write under the owning
plugin's category instead of a placeholder, and to decide which keys an
unauthenticated caller may read.
"""
declared: Dict[str, dict] = {}
for name, plugin in self.loader.get_all_loaded().items():
try:
entries = plugin.get_settings_defaults() or []
except Exception:
logger.exception("get_settings_defaults failed for %s", name)
continue
for entry in entries:
key = entry.get('key')
if key:
declared[key] = entry
return declared
def get_public_setting_keys(self) -> set:
"""Setting keys plugins declare readable without authentication."""
return {key for key, entry in self.get_declared_plugin_settings().items()
if entry.get('public')}
def _register_plugin_components(self, plugin: BasePlugin) -> None:
"""Register plugin's blueprint, models, CLI commands, etc."""
# Register blueprint
@@ -260,6 +309,7 @@ class PluginManager:
if plugin:
self._register_plugin_components(plugin)
self._seed_plugin_permissions(plugin)
self._seed_plugin_settings(plugin)
plugin.on_install(self._app)
logger.info(f"Installed plugin: {name} v{manifest_version}")
@@ -288,6 +338,58 @@ class PluginManager:
"Seeded %d permission(s) for plugin %s",
created, plugin.meta.name)
def _seed_plugin_settings(self, plugin: BasePlugin) -> bool:
"""Idempotently create Setting rows for a plugin's declared settings.
Runs at install, at enable, and on `flask plugin upgrade-all`. Existing
values are never overwritten - only missing rows are created, and a row
whose category does not match the declaration is re-homed. That repair
matters: before a key was declared, the first save created it under the
settings API's placeholder category, where the owning plugin's settings
page (which filters by category) could not see it again, so the value
looked like it never saved.
Returns True if anything was created or repaired. Best-effort: a plugin
that raises must not abort the lifecycle.
"""
try:
entries = plugin.get_settings_defaults() or []
except Exception:
logger.exception(
"get_settings_defaults failed for %s", plugin.meta.name)
return False
if not entries:
return False
from shopdb.core.models import Setting
created = 0
repaired = 0
with self._app.app_context():
for entry in entries:
key = entry.get('key')
if not key:
continue
setting = Setting.query.filter_by(key=key).first()
if setting is None:
self._db.session.add(Setting(
key=key,
value=entry.get('value', ''),
valuetype=entry.get('valuetype', 'string'),
category=entry.get('category', 'plugin'),
description=entry.get('description'),
))
created += 1
continue
category = entry.get('category')
if category and setting.category != category:
setting.category = category
repaired += 1
if created or repaired:
self._db.session.commit()
logger.info(
"Plugin %s settings: %d created, %d re-homed",
plugin.meta.name, created, repaired)
return bool(created or repaired)
def _is_core_tier(self, name: str) -> bool:
"""True when the plugin's manifest marks it tier=core (mandatory).
@@ -406,6 +508,7 @@ class PluginManager:
plugin = self.loader.load_plugin(name, self._app, self._db)
if plugin:
self._seed_plugin_permissions(plugin)
self._seed_plugin_settings(plugin)
plugin.on_enable(self._app)
except Exception:
logger.exception(f"on_enable hook failed for plugin {name}")

View File

@@ -104,6 +104,28 @@ class BasePlugin(ABC):
"""
return []
def get_settings_defaults(self) -> List[Dict]:
"""Declare the Setting rows this plugin owns.
The framework seeds these at install, at enable, and on every
`flask plugin upgrade-all`, so a setting added in a later plugin
version reaches a site that installed an earlier one. Declaring a key
here is also what tells the settings API which category and type the
key belongs to, so a save never has to invent one.
Each entry is a dict:
key - the Setting key
value - default value (string form)
valuetype - 'string' | 'boolean' | 'integer' | 'json'
category - grouping the plugin's settings page filters on
description - what the setting does
public - True if an UNAUTHENTICATED caller may read it (kiosk
and print pages render before login); default False.
Never mark a credential or an integration URL public.
Return [] (default) if the plugin owns no settings.
"""
return []
def get_setting(self, key: str, default=None):
"""Read a plugin-scoped setting from the core Setting store.

View File

@@ -869,7 +869,10 @@ def upgrade_all_plugins():
Idempotent. Run this after `flask db upgrade` on every deploy and
upgrade. It stamps each bundled plugin's anchor revision into
alembic_version_<plugin> and applies any per-plugin migrations added
after the ownership cutover (ADR-008). Safe to re-run at head.
after the ownership cutover (ADR-008). It also seeds any settings a
plugin declares (get_settings_defaults) that this site is missing, so a
setting added in a later version reaches a site that installed earlier.
Safe to re-run at head.
"""
pm = current_app.extensions.get('plugin_manager')
if not pm: