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>
123 lines
3.9 KiB
Python
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()
|