Extract employee directory into a plugin

Third core feature pulled into a plugin (blueprint-only, like slides). The
employee directory is a read-only lookup over a separate HR database.

- plugins/employees/: manifest (api_prefix /api/employees, no deps), api/ (moved
  blueprint, contract-pure: success/error/ErrorCodes + employee_connection all
  from shopdb.api), plugin.py (get_blueprint, get_models -> []).
- employee_connection STAYS core infrastructure in shopdb.api (config-driven
  external DB connector, shared by search + the notifications shopfloor feed). So
  no get_services needed and no contract change - the plugin owns the directory
  FEATURE, core owns the shared connector.
- Fixed a latent bug in the move: error paths used ErrorCodes.DATABASE_ERROR
  which does not exist -> ErrorCodes.INTERNAL_ERROR (so a directory outage now
  returns a clean 500 envelope instead of an AttributeError crash).
- De-cored: deleted shopdb/core/api/employees.py, removed from
  CORE_BLUEPRINT_NAMES + core/api/__init__ import/__all__. Registered in
  instance/plugins.json.

Pinned first: validation (400) + graceful-degrade (500) characterization tests;
the degrade test caught the DATABASE_ERROR bug and goes green with the fix.
184 tests pass, naming green, app boots 9 bundled plugins, endpoint verified live.

Plugin extractions complete: knowledgebase, slides, employees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 21:13:15 -04:00
parent b6ec4cb577
commit d20682fd06
10 changed files with 128 additions and 13 deletions

View File

@@ -0,0 +1,5 @@
"""Employees plugin package."""
from .plugin import EmployeesPlugin
__all__ = ['EmployeesPlugin']

View File

@@ -0,0 +1,5 @@
"""Employees plugin API."""
from .routes import employees_bp
__all__ = ['employees_bp']

View File

@@ -1,16 +1,22 @@
"""Employee lookup API endpoints.
These read from the separate employee directory DB (see shopdb.utils.employee_db).
They are intentionally reachable by the unauthenticated shopfloor kiosk displays
(recognition wall), so they are not JWT-gated; keep them read-only and never
return more than the directory fields below.
These read from the separate employee directory DB (employee_connection is core
infrastructure exposed via shopdb.api, shared with search + notifications). The
endpoints are intentionally reachable by the unauthenticated shopfloor kiosk
displays (recognition wall), so they are not JWT-gated; keep them read-only and
never return more than the directory fields below.
"""
import logging
from flask import Blueprint, request
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.employee_db import employee_connection
from shopdb.api import (
success_response,
error_response,
ErrorCodes,
employee_connection,
)
logger = logging.getLogger(__name__)
@@ -56,7 +62,7 @@ def search_employees():
except Exception:
logger.exception('Employee search failed')
return error_response(
ErrorCodes.DATABASE_ERROR,
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)
@@ -92,7 +98,7 @@ def lookup_employee(sso):
except Exception:
logger.exception('Employee lookup failed for SSO %s', sso)
return error_response(
ErrorCodes.DATABASE_ERROR,
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)
@@ -138,7 +144,7 @@ def lookup_employees():
except Exception:
logger.exception('Employee multi-lookup failed')
return error_response(
ErrorCodes.DATABASE_ERROR,
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)

View File

@@ -0,0 +1,12 @@
{
"name": "employees",
"version": "1.0.0",
"description": "Read-only employee directory lookup (separate HR database)",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/employees",
"provides": {
"features": ["employee_directory"]
}
}

View File

@@ -0,0 +1,63 @@
"""Employees plugin main class.
Blueprint-only plugin: a read-only employee directory lookup over a separate HR
database. No model (the directory is an external DB) and no AssetType. The
connection helper (employee_connection) stays core infrastructure in shopdb.api,
shared with search and the notifications shopfloor feed.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from .api import employees_bp
logger = logging.getLogger(__name__)
class EmployeesPlugin(BasePlugin):
"""Employees plugin - read-only HR directory lookup."""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'employees'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Read-only employee directory lookup'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/employees'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return employees_bp
def get_models(self) -> List[Type]:
"""No models - the directory is an external database."""
return []
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Employees plugin initialized (v{self.meta.version})")

View File

@@ -105,7 +105,6 @@ CORE_BLUEPRINT_NAMES = (
'search',
'reports',
'collector',
'employees',
'settings',
'auditlogs',
'users',

View File

@@ -14,7 +14,6 @@ from .applications import applications_bp
from .search import search_bp
from .reports import reports_bp
from .collector import collector_bp
from .employees import employees_bp
from .settings import settings_bp
from .auditlogs import auditlogs_bp
from .users import users_bp
@@ -34,7 +33,6 @@ __all__ = [
'search_bp',
'reports_bp',
'collector_bp',
'employees_bp',
'settings_bp',
'auditlogs_bp',
'users_bp',

View File

@@ -0,0 +1,26 @@
"""Characterization tests for the employee directory endpoints.
Written before extracting employees into a plugin. The wjf_employees DB is not
available under test, so these assert the DB-independent behaviors: validation
(400) and graceful failure (500 envelope, not an unhandled crash) when the
directory is unreachable. Behavior must be identical as a plugin blueprint.
"""
def test_search_requires_min_query(client, db):
"""A too-short query is rejected before touching the directory DB."""
resp = client.get('/api/employees/search?q=a')
assert resp.status_code == 400
def test_lookup_requires_numeric_sso(client, db):
"""Non-numeric SSO is rejected without touching the directory DB."""
resp = client.get('/api/employees/lookup/abc')
assert resp.status_code == 400
def test_search_degrades_when_directory_unreachable(client, db):
"""With the directory DB unavailable, search returns a clean 500 envelope."""
resp = client.get('/api/employees/search?q=smith')
assert resp.status_code == 500
assert resp.get_json()['status'] == 'error'

View File

@@ -17,7 +17,7 @@ from shopdb.plugins import plugin_manager
from shopdb.plugins.base import BasePlugin, PluginMeta
BUNDLED_PLUGINS = ('computers', 'equipment', 'knowledgebase', 'network', 'notifications', 'printers', 'slides', 'usb')
BUNDLED_PLUGINS = ('computers', 'employees', 'equipment', 'knowledgebase', 'network', 'notifications', 'printers', 'slides', 'usb')
@pytest.fixture

View File

@@ -94,6 +94,7 @@ def test_plugin_loader_discovers_bundled_plugins(app):
expected_plugins = {
'computers',
'employees',
'equipment',
'knowledgebase',
'network',