Files
shopdb-flask/shopdb/api/__init__.py
cproudlock 6f085a175d Phase 3 (part 1): manifest-first loader, shopdb.api namespace, auto-register
Hardens the plugin framework so sister-site adoption is safe.

Loader rewrite (shopdb/plugins/loader.py):
- Reads manifest.json directly. Dependency sort and version checks
  no longer instantiate plugin classes (avoids __init__ side effects).
- Fail-loud policy: in dev/test (DEBUG or TESTING true), plugin
  errors re-raise. In production, errors log with full context and
  the plugin is excluded from registration. Framework keeps booting.
- Contract-version range check via packaging.SpecifierSet. Plugin's
  manifest.core_version must include the framework's
  __contract_version__ or load fails per the policy above.
- Manifest validation: required fields (name, version, description),
  name matches directory, JSON parseable.

Exceptions (shopdb/exceptions.py):
- PluginNotFoundError, PluginContractError, PluginVersionError,
  PluginDependencyError. Specific types replace generic Exception
  swallowing.

Auto-register core blueprints (shopdb/__init__.py):
- CORE_BLUEPRINT_NAMES tuple drives registration. Adding a core
  resource is one entry, not three lines (import + register call).
- Replaces 27 hand-coded register_blueprint calls.
- Asserts each blueprint is exported by shopdb.core.api at boot.

Public API namespace (shopdb/api/__init__.py):
- audit_log: thin wrapper over AuditLog.log() with stable signature.
- resolve_asset_position: implements ADR-001 position resolution
  (asset > related > location). Asset.mapx/mapy and
  AssetRelationship.inheritsposition columns are part of the locked
  contract surface but not yet in models; helper degrades gracefully
  to location-only fallback until the migration lands.

BasePlugin helpers (shopdb/plugins/base.py):
- get_setting(key, default), set_setting(key, value, ...). Settings
  namespaced as plugin.<pluginname>.<key> so two plugins can use the
  same key without colliding.

Manifest version compatibility (plugins/*/manifest.json):
- Bumped core_version from ">=1.0.0" to ">=0.1.0,<1.0.0" so all
  bundled plugins satisfy the new range check.

Contract version bump (shopdb/__init__.py):
- 0.1.0 -> 0.2.0. Additive surface change (Setting helpers,
  shopdb.api namespace) per ADR-002 minor-bump rules.

Tests (tests/test_plugin_loader.py, tests/test_api_namespace.py):
- 13 loader tests: manifest validation failures, version range
  checks, plugin.py import errors, strict-vs-isolate behavior under
  TESTING vs production-like config, manifest-first dependency sort.
- 8 api-namespace tests: audit_log roundtrip, resolve position
  fallback chain, plugin.get_setting/set_setting roundtrip with
  per-plugin namespacing.

Test count: 66 -> 87 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:15:28 -04:00

97 lines
3.3 KiB
Python

"""Public API namespace exposed to plugins.
Plugin authors at sister sites import from this module. The contract is
locked in ADR-001 and versioned per ADR-002. Helpers added here become
part of the platform contract; bumps follow ADR-002 rules.
Currently exposed:
- audit_log: record an audit log entry with consistent schema
- resolve_asset_position: compute an asset's resolved map position
Setting helpers are exposed via BasePlugin instance methods
(plugin.get_setting, plugin.set_setting), not from this namespace.
"""
from typing import Any, Dict, Optional
from shopdb.core.models import AuditLog
def audit_log(
action: str,
entitytype: str,
entityid: int = None,
entityname: str = None,
changes: Dict = None,
details: Dict = None,
) -> AuditLog:
"""Record an audit log entry with the framework's standard schema.
Plugins call this to record state changes on their own assets in a
way that is consistent with core auditing. The function delegates to
AuditLog.log() which already captures the current user, IP address,
and user agent from the Flask request context.
Args:
action: Action verb in past tense ('created', 'updated', 'deleted')
entitytype: Class name of the entity affected ('Computer', 'Printer')
entityid: Primary key of the entity
entityname: Human-readable identifier (hostname, asset number)
changes: Dict with 'before' and 'after' snapshots for updates
details: Arbitrary additional context
Returns:
The created AuditLog instance, already committed to the DB.
"""
return AuditLog.log(
action=action,
entitytype=entitytype,
entityid=entityid,
entityname=entityname,
changes=changes,
details=details,
)
def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
"""Compute the resolved map position for an asset.
Per ADR-001, position resolution follows this priority:
1. Asset-specific override (asset.mapx, asset.mapy)
2. Walk relationships where inheritsposition is true (partof, then controls)
3. Asset's location coords (asset.location.mapx, .mapy)
4. None (asset is rendered in an unplaced tray)
Returns a dict {'mapx', 'mapy', 'positionsource'} or None if no
position can be resolved.
Note: Asset.mapx/mapy and AssetRelationship.inheritsposition columns
are part of the locked ADR-001 contract surface but have not yet
been added to the models. Until they are, this helper falls back to
the location-only path. The full algorithm activates automatically
once those columns exist.
"""
if hasattr(asset, 'mapx') and hasattr(asset, 'mapy'):
if asset.mapx is not None and asset.mapy is not None:
return {
'mapx': asset.mapx,
'mapy': asset.mapy,
'positionsource': 'self',
}
location = getattr(asset, 'location', None)
if location is not None:
location_mapx = getattr(location, 'mapx', None)
location_mapy = getattr(location, 'mapy', None)
if location_mapx is not None and location_mapy is not None:
return {
'mapx': location_mapx,
'mapy': location_mapy,
'positionsource': 'location',
}
return None
__all__ = ['audit_log', 'resolve_asset_position']