Files
shopdb-flask/shopdb/plugins/registry.py
cproudlock b8c22244a1
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:02:07 -04:00

123 lines
3.9 KiB
Python

"""Plugin registry for tracking installed and enabled plugins."""
import json
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
@dataclass
class PluginState:
"""Persistent state for a plugin."""
name: str
version: str
installed_at: str
enabled: bool = True
migrations_applied: List[str] = field(default_factory=list)
config: Dict = field(default_factory=dict)
class PluginRegistry:
"""
Manages plugin state persistence.
Stores state in JSON file in instance folder.
"""
def __init__(self, state_file: Path):
self.state_file = state_file
self._plugins: Dict[str, PluginState] = {}
self._load()
def _load(self) -> None:
"""Load registry from file."""
if self.state_file.exists():
try:
with open(self.state_file, 'r') as f:
data = json.load(f)
for name, state_data in data.get('plugins', {}).items():
self._plugins[name] = PluginState(**state_data)
except (json.JSONDecodeError, TypeError):
# Corrupted file, start fresh
self._plugins = {}
def _save(self) -> None:
"""Save registry to file."""
self.state_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.state_file, 'w') as f:
json.dump({
'plugins': {
name: asdict(state)
for name, state in self._plugins.items()
}
}, f, indent=2)
def register(self, name: str, version: str, enabled: bool = True) -> PluginState:
"""Register a newly installed plugin. enabled=False leaves it off until a
site opts in (used for plugins that provision extra tables)."""
state = PluginState(
name=name,
version=version,
installed_at=datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
enabled=enabled
)
self._plugins[name] = state
self._save()
return state
def unregister(self, name: str) -> None:
"""Remove plugin from registry."""
if name in self._plugins:
del self._plugins[name]
self._save()
def get(self, name: str) -> Optional[PluginState]:
"""Get plugin state."""
return self._plugins.get(name)
def is_installed(self, name: str) -> bool:
"""Check if plugin is installed."""
return name in self._plugins
def is_enabled(self, name: str) -> bool:
"""Check if plugin is enabled."""
state = self._plugins.get(name)
return state.enabled if state else False
def enable(self, name: str) -> None:
"""Enable a plugin."""
if name in self._plugins:
self._plugins[name].enabled = True
self._save()
def disable(self, name: str) -> None:
"""Disable a plugin."""
if name in self._plugins:
self._plugins[name].enabled = False
self._save()
def get_enabled_plugins(self) -> List[str]:
"""Get list of enabled plugin names."""
return [
name for name, state in self._plugins.items()
if state.enabled
]
def add_migration(self, name: str, revision: str) -> None:
"""Record that a migration was applied."""
if name in self._plugins:
if revision not in self._plugins[name].migrations_applied:
self._plugins[name].migrations_applied.append(revision)
self._save()
def get_all(self) -> Dict[str, PluginState]:
"""Get all registered plugins."""
return self._plugins.copy()
def update_config(self, name: str, config: Dict) -> None:
"""Update plugin configuration."""
if name in self._plugins:
self._plugins[name].config.update(config)
self._save()