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:
@@ -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,
|
||||
|
||||
@@ -12,13 +12,27 @@ from flask import Blueprint, request, current_app
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, Application
|
||||
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
|
||||
collector_bp = Blueprint('collector', __name__)
|
||||
|
||||
|
||||
def _computer_models():
|
||||
"""Lazily import the computers plugin models.
|
||||
|
||||
The legacy /pc, /apps, /heartbeat, /bulk endpoints predate the generic
|
||||
collector contract and are computers-specific. Importing the plugin lazily
|
||||
(instead of at module load) keeps core importable when the computers plugin
|
||||
is absent or disabled. Returns (Computer, ComputerInstalledApp) or None.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||
return Computer, ComputerInstalledApp
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def require_api_key(f):
|
||||
"""Require API key authentication."""
|
||||
@wraps(f)
|
||||
@@ -40,7 +54,15 @@ def require_api_key(f):
|
||||
|
||||
|
||||
def _find_pc(hostname):
|
||||
"""Find a computer by hostname, falling back to its asset number."""
|
||||
"""Find a computer by hostname, falling back to its asset number.
|
||||
|
||||
Returns None if not found or if the computers plugin is unavailable;
|
||||
callers already treat None as a 404.
|
||||
"""
|
||||
models = _computer_models()
|
||||
if not models:
|
||||
return None
|
||||
Computer, _ = models
|
||||
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
||||
if comp:
|
||||
return comp
|
||||
@@ -237,6 +259,9 @@ def update_installed_apps():
|
||||
f'PC with hostname {hostname} not found',
|
||||
http_code=404)
|
||||
|
||||
# comp existing implies the computers plugin is loaded.
|
||||
_, ComputerInstalledApp = _computer_models()
|
||||
|
||||
updated_count = created_count = skipped_count = 0
|
||||
|
||||
for app_data in apps:
|
||||
|
||||
@@ -152,6 +152,37 @@ def get_navigation():
|
||||
return success_response(all_items)
|
||||
|
||||
|
||||
@dashboard_bp.route('/widgets', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_widgets():
|
||||
"""Aggregate dashboard widget definitions from all enabled plugins.
|
||||
|
||||
Consumer for the BasePlugin.get_dashboard_widgets hook. Skips disabled
|
||||
plugins, isolates a broken plugin in prod (re-raises in dev/test), and
|
||||
returns the merged list sorted by position.
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
return success_response([])
|
||||
|
||||
widgets = []
|
||||
for name, plugin in pm.get_all_plugins().items():
|
||||
if not pm.registry.is_enabled(name):
|
||||
continue
|
||||
try:
|
||||
for widget in plugin.get_dashboard_widgets() or []:
|
||||
widget['plugin'] = name
|
||||
widgets.append(widget)
|
||||
except Exception:
|
||||
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||
raise
|
||||
current_app.logger.exception(
|
||||
'Plugin %s get_dashboard_widgets failed', name)
|
||||
|
||||
widgets.sort(key=lambda w: w.get('position', 99))
|
||||
return success_response(widgets)
|
||||
|
||||
|
||||
@dashboard_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint (no auth required)."""
|
||||
|
||||
@@ -11,7 +11,6 @@ from shopdb.core.models import (
|
||||
Asset, AssetType, AssetStatus,
|
||||
Application, KnowledgeBase
|
||||
)
|
||||
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
|
||||
reports_bp = Blueprint('reports', __name__)
|
||||
@@ -317,6 +316,16 @@ def software_compliance():
|
||||
- appid: Filter to specific application
|
||||
- format: 'json' (default) or 'csv'
|
||||
"""
|
||||
# Install tracking lives in the computers plugin; degrade gracefully if
|
||||
# it is not installed.
|
||||
try:
|
||||
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||
except ImportError:
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Software compliance requires the computers plugin',
|
||||
http_code=503)
|
||||
|
||||
# Get required applications
|
||||
required_apps = Application.query.filter(
|
||||
Application.isactive == True,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user