Decouple core from plugins; wire widgets hook; drop dead search hook

Architectural pass from the skill review ("plugin is the product" boundary).

Core no longer imports plugin models at module load (was a hard import-time
dependency that broke core if the computers plugin was absent/disabled):
- collector.py, applications.py, reports.py: lazy + guarded imports of the
  computers plugin models. Endpoints that need install-tracking now return 503
  when the plugin is absent instead of failing at import.

Search honors runtime enable/disable:
- search.py: _require_enabled(name) raises ImportError for a disabled plugin,
  so each plugin-scoped block skips it (a disabled plugin's rows leave search).
- Replace hardcoded root/rootpassword employee-DB connection in _search_employees
  with the shared env-backed employee_connection helper.

Plugin hooks (integrating-plugin-hooks: every hook needs a consumer):
- get_dashboard_widgets: add the consumer GET /api/dashboard/widgets (5 plugins
  already implemented the hook; it had none). Skips disabled, isolates in prod.
- get_searchable_fields: REMOVED. Zero plugins implemented it and there was no
  consumer; global search is a core concern over the asset model. Contract
  reduction, __contract_version__ 0.3.0 -> 0.4.0.

Docs/contract: PLUGIN-HOOKS.md (widgets consumer note, searchable-fields removal,
0.4.0), PLUGIN-QUICKSTART.md, ADR-001 hook list. Tests: widgets endpoint
aggregate + disabled-skip; contract tests for the removed/added hooks.

151 tests pass, naming/style green, app boots all 6 plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 20:00:10 -04:00
parent 6979edfc0c
commit a515c28e3b
12 changed files with 940 additions and 792 deletions

View File

@@ -7,13 +7,49 @@ from shopdb.extensions import db
from shopdb.core.models import (
Application, AppVersion, AppOwner, SupportTeam, AuditLog
)
from plugins.computers.models import Computer, ComputerInstalledApp
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
def _computer_models():
"""Lazily import the computers plugin models, or None if unavailable.
Application install-tracking is a join over the computers plugin's tables.
Importing lazily keeps the applications API importable when the computers
plugin is absent or disabled.
"""
try:
from plugins.computers.models import Computer, ComputerInstalledApp
return Computer, ComputerInstalledApp
except ImportError:
return None
def _installed_count(appid):
"""Count active installs of an app, 0 when the computers plugin is absent."""
models = _computer_models()
if not models:
return 0
_, ComputerInstalledApp = models
return ComputerInstalledApp.query.filter_by(appid=appid, isactive=True).count()
def _require_computer_models():
"""Resolve (Computer, ComputerInstalledApp) or a 503 response tuple.
Usage: `models, err = _require_computer_models(); if err: return err`.
"""
models = _computer_models()
if not models:
return None, error_response(
ErrorCodes.INTERNAL_ERROR,
'Install tracking requires the computers plugin',
http_code=503)
return models, None
from shopdb.utils.pagination import get_pagination_params, paginate_query
applications_bp = Blueprint('applications', __name__)
@@ -66,8 +102,7 @@ def list_applications():
}
else:
app_dict['supportteam'] = None
app_dict['installedcount'] = ComputerInstalledApp.query.filter_by(
appid=app.appid, isactive=True).count()
app_dict['installedcount'] = _installed_count(app.appid)
data.append(app_dict)
return paginated_response(data, page, per_page, total)
@@ -97,8 +132,7 @@ def get_application(app_id: int):
else:
data['supportteam'] = None
data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()]
data['installedcount'] = ComputerInstalledApp.query.filter_by(
appid=app.appid, isactive=True).count()
data['installedcount'] = _installed_count(app.appid)
return success_response(data)
@@ -263,6 +297,11 @@ def list_installed_machines(app_id: int):
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
models, err = _require_computer_models()
if err:
return err
Computer, ComputerInstalledApp = models
installed = ComputerInstalledApp.query.filter_by(
appid=app_id, isactive=True).all()
data = []
@@ -293,6 +332,11 @@ def list_installed_machines(app_id: int):
@jwt_required(optional=True)
def list_machine_applications(machine_id: int):
"""List all applications installed on a computer."""
models, err = _require_computer_models()
if err:
return err
Computer, ComputerInstalledApp = models
comp = Computer.query.get(machine_id)
if not comp:
return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404)
@@ -305,6 +349,11 @@ def list_machine_applications(machine_id: int):
@jwt_required()
def install_application(machine_id: int):
"""Install an application on a computer."""
models, err = _require_computer_models()
if err:
return err
Computer, ComputerInstalledApp = models
comp = Computer.query.get(machine_id)
if not comp:
return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404)
@@ -351,6 +400,11 @@ def install_application(machine_id: int):
@jwt_required()
def uninstall_application(machine_id: int, app_id: int):
"""Uninstall an application from a computer."""
models, err = _require_computer_models()
if err:
return err
Computer, ComputerInstalledApp = models
installed = ComputerInstalledApp.query.filter_by(
computerid=machine_id,
appid=app_id,
@@ -370,6 +424,11 @@ def uninstall_application(machine_id: int, app_id: int):
@jwt_required()
def update_installed_app(machine_id: int, app_id: int):
"""Update installed application (e.g., change version)."""
models, err = _require_computer_models()
if err:
return err
Computer, ComputerInstalledApp = models
installed = ComputerInstalledApp.query.filter_by(
computerid=machine_id,
appid=app_id,