Extract Knowledge Base into a plugin (reference non-asset plugin)

First feature extracted from core into a plugin per "plugin is the product",
mirroring the notifications plugin. KB is a NON-asset plugin: it contributes a
model + blueprint + nav item but registers no AssetType.

- plugins/knowledgebase/: manifest.json (api_prefix /api/knowledgebase, no deps),
  models/ (KnowledgeBase, contract-pure imports via shopdb.api), api/ (the
  blueprint, same routes/prefix so the frontend is unchanged), plugin.py
  (get_blueprint + get_models + get_navigation_items).
- De-cored: removed shopdb/core/models/knowledgebase.py + api/knowledgebase.py,
  their __init__ exports, and 'knowledgebase' from CORE_BLUEPRINT_NAMES; dropped
  the hardcoded KB nav item from dashboard.py (now via the plugin nav hook).
- search.py and reports.py lazy-import KnowledgeBase from the plugin and degrade
  gracefully (search skips via _require_enabled when disabled; kb-popularity
  report returns 503 if the plugin is absent).
- Registered in instance/plugins.json (enabled).

The knowledgebase table stays in the core Alembic chain (bundled-plugin schema
folded into core, ADR-004); the model just maps it. KB was never in the
shopdb.api contract surface, so no __contract_version__ bump.

Pinned with characterization tests first (test_knowledgebase.py); they pass
unchanged against the plugin blueprint. 163 tests pass, naming green, app boots
7 bundled plugins, KB endpoint/nav/search verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 20:32:37 -04:00
parent 530928d5e7
commit a6f6c3f51f
16 changed files with 396 additions and 244 deletions

View File

@@ -0,0 +1,5 @@
"""Knowledge Base plugin package."""
from .plugin import KnowledgeBasePlugin
__all__ = ['KnowledgeBasePlugin']

View File

@@ -0,0 +1,5 @@
"""Knowledge Base plugin API."""
from .routes import knowledgebase_bp
__all__ = ['knowledgebase_bp']

View File

@@ -1,207 +1,210 @@
"""Knowledge Base API endpoints.""" """Knowledge Base API endpoints."""
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import (
from shopdb.core.models import KnowledgeBase, Application db,
from shopdb.utils.responses import ( Application,
success_response, success_response,
error_response, error_response,
paginated_response, paginated_response,
ErrorCodes ErrorCodes,
) get_pagination_params,
from shopdb.utils.pagination import get_pagination_params, paginate_query paginate_query,
)
knowledgebase_bp = Blueprint('knowledgebase', __name__)
from ..models import KnowledgeBase
@knowledgebase_bp.route('', methods=['GET']) knowledgebase_bp = Blueprint('knowledgebase', __name__)
@jwt_required(optional=True)
def list_articles():
"""List all knowledge base articles.""" @knowledgebase_bp.route('', methods=['GET'])
page, per_page = get_pagination_params(request) @jwt_required(optional=True)
def list_articles():
query = KnowledgeBase.query.filter_by(isactive=True) """List all knowledge base articles."""
page, per_page = get_pagination_params(request)
# Search
if search := request.args.get('search'): query = KnowledgeBase.query.filter_by(isactive=True)
query = query.filter(
db.or_( # Search
KnowledgeBase.shortdescription.ilike(f'%{search}%'), if search := request.args.get('search'):
KnowledgeBase.keywords.ilike(f'%{search}%') query = query.filter(
) db.or_(
) KnowledgeBase.shortdescription.ilike(f'%{search}%'),
KnowledgeBase.keywords.ilike(f'%{search}%')
# Filter by topic/application )
if appid := request.args.get('appid'): )
query = query.filter(KnowledgeBase.appid == int(appid))
# Filter by topic/application
# Sort options if appid := request.args.get('appid'):
sort = request.args.get('sort', 'clicks') query = query.filter(KnowledgeBase.appid == int(appid))
order = request.args.get('order', 'desc')
# Sort options
if sort == 'clicks': sort = request.args.get('sort', 'clicks')
query = query.order_by( order = request.args.get('order', 'desc')
KnowledgeBase.clicks.desc() if order == 'desc' else KnowledgeBase.clicks.asc(),
KnowledgeBase.lastupdated.desc() if sort == 'clicks':
) query = query.order_by(
elif sort == 'topic': KnowledgeBase.clicks.desc() if order == 'desc' else KnowledgeBase.clicks.asc(),
query = query.join(Application).order_by( KnowledgeBase.lastupdated.desc()
Application.appname.desc() if order == 'desc' else Application.appname.asc() )
) elif sort == 'topic':
elif sort == 'description': query = query.join(Application).order_by(
query = query.order_by( Application.appname.desc() if order == 'desc' else Application.appname.asc()
KnowledgeBase.shortdescription.desc() if order == 'desc' else KnowledgeBase.shortdescription.asc() )
) elif sort == 'description':
elif sort == 'lastupdated': query = query.order_by(
query = query.order_by( KnowledgeBase.shortdescription.desc() if order == 'desc' else KnowledgeBase.shortdescription.asc()
KnowledgeBase.lastupdated.desc() if order == 'desc' else KnowledgeBase.lastupdated.asc() )
) elif sort == 'lastupdated':
else: query = query.order_by(
query = query.order_by(KnowledgeBase.clicks.desc()) KnowledgeBase.lastupdated.desc() if order == 'desc' else KnowledgeBase.lastupdated.asc()
)
items, total = paginate_query(query, page, per_page) else:
data = [] query = query.order_by(KnowledgeBase.clicks.desc())
for article in items:
article_dict = article.to_dict() items, total = paginate_query(query, page, per_page)
if article.application: data = []
article_dict['application'] = { for article in items:
'appid': article.application.appid, article_dict = article.to_dict()
'appname': article.application.appname if article.application:
} article_dict['application'] = {
else: 'appid': article.application.appid,
article_dict['application'] = None 'appname': article.application.appname
data.append(article_dict) }
else:
return paginated_response(data, page, per_page, total) article_dict['application'] = None
data.append(article_dict)
@knowledgebase_bp.route('/stats', methods=['GET']) return paginated_response(data, page, per_page, total)
@jwt_required(optional=True)
def get_stats():
"""Get knowledge base statistics.""" @knowledgebase_bp.route('/stats', methods=['GET'])
total_clicks = db.session.query( @jwt_required(optional=True)
db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0) def get_stats():
).filter(KnowledgeBase.isactive == True).scalar() """Get knowledge base statistics."""
total_clicks = db.session.query(
total_articles = KnowledgeBase.query.filter_by(isactive=True).count() db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0)
).filter(KnowledgeBase.isactive == True).scalar()
return success_response({
'totalclicks': int(total_clicks), total_articles = KnowledgeBase.query.filter_by(isactive=True).count()
'totalarticles': total_articles
}) return success_response({
'totalclicks': int(total_clicks),
'totalarticles': total_articles
@knowledgebase_bp.route('/<int:link_id>', methods=['GET']) })
@jwt_required(optional=True)
def get_article(link_id: int):
"""Get a single knowledge base article.""" @knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
article = KnowledgeBase.query.get(link_id) @jwt_required(optional=True)
def get_article(link_id: int):
if not article or not article.isactive: """Get a single knowledge base article."""
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) article = KnowledgeBase.query.get(link_id)
data = article.to_dict() if not article or not article.isactive:
if article.application: return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
data['application'] = {
'appid': article.application.appid, data = article.to_dict()
'appname': article.application.appname if article.application:
} data['application'] = {
else: 'appid': article.application.appid,
data['application'] = None 'appname': article.application.appname
}
return success_response(data) else:
data['application'] = None
@knowledgebase_bp.route('/<int:link_id>/click', methods=['POST']) return success_response(data)
@jwt_required(optional=True)
def track_click(link_id: int):
"""Increment click counter and return the URL to redirect to.""" @knowledgebase_bp.route('/<int:link_id>/click', methods=['POST'])
article = KnowledgeBase.query.get(link_id) @jwt_required(optional=True)
def track_click(link_id: int):
if not article or not article.isactive: """Increment click counter and return the URL to redirect to."""
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) article = KnowledgeBase.query.get(link_id)
article.increment_clicks() if not article or not article.isactive:
db.session.commit() return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
return success_response({ article.increment_clicks()
'linkurl': article.linkurl, db.session.commit()
'clicks': article.clicks
}) return success_response({
'linkurl': article.linkurl,
'clicks': article.clicks
@knowledgebase_bp.route('', methods=['POST']) })
@jwt_required()
def create_article():
"""Create a new knowledge base article.""" @knowledgebase_bp.route('', methods=['POST'])
data = request.get_json() @jwt_required()
def create_article():
if not data or not data.get('shortdescription'): """Create a new knowledge base article."""
return error_response(ErrorCodes.VALIDATION_ERROR, 'shortdescription is required') data = request.get_json()
if not data.get('linkurl'): if not data or not data.get('shortdescription'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'linkurl is required') return error_response(ErrorCodes.VALIDATION_ERROR, 'shortdescription is required')
# Validate application if provided if not data.get('linkurl'):
if data.get('appid'): return error_response(ErrorCodes.VALIDATION_ERROR, 'linkurl is required')
app = Application.query.get(data['appid'])
if not app: # Validate application if provided
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) if data.get('appid'):
app = Application.query.get(data['appid'])
article = KnowledgeBase( if not app:
shortdescription=data['shortdescription'], return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
linkurl=data['linkurl'],
appid=data.get('appid'), article = KnowledgeBase(
keywords=data.get('keywords'), shortdescription=data['shortdescription'],
clicks=0 linkurl=data['linkurl'],
) appid=data.get('appid'),
keywords=data.get('keywords'),
db.session.add(article) clicks=0
db.session.commit() )
return success_response(article.to_dict(), message='Article created', http_code=201) db.session.add(article)
db.session.commit()
@knowledgebase_bp.route('/<int:link_id>', methods=['PUT']) return success_response(article.to_dict(), message='Article created', http_code=201)
@jwt_required()
def update_article(link_id: int):
"""Update a knowledge base article.""" @knowledgebase_bp.route('/<int:link_id>', methods=['PUT'])
article = KnowledgeBase.query.get(link_id) @jwt_required()
def update_article(link_id: int):
if not article: """Update a knowledge base article."""
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) article = KnowledgeBase.query.get(link_id)
data = request.get_json() if not article:
if not data: return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
data = request.get_json()
# Validate application if being changed if not data:
if 'appid' in data and data['appid']: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
app = Application.query.get(data['appid'])
if not app: # Validate application if being changed
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) if 'appid' in data and data['appid']:
app = Application.query.get(data['appid'])
fields = ['shortdescription', 'linkurl', 'appid', 'keywords', 'isactive'] if not app:
for key in fields: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
if key in data:
setattr(article, key, data[key]) fields = ['shortdescription', 'linkurl', 'appid', 'keywords', 'isactive']
for key in fields:
db.session.commit() if key in data:
return success_response(article.to_dict(), message='Article updated') setattr(article, key, data[key])
db.session.commit()
@knowledgebase_bp.route('/<int:link_id>', methods=['DELETE']) return success_response(article.to_dict(), message='Article updated')
@jwt_required()
def delete_article(link_id: int):
"""Delete (deactivate) a knowledge base article.""" @knowledgebase_bp.route('/<int:link_id>', methods=['DELETE'])
article = KnowledgeBase.query.get(link_id) @jwt_required()
def delete_article(link_id: int):
if not article: """Delete (deactivate) a knowledge base article."""
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) article = KnowledgeBase.query.get(link_id)
article.isactive = False if not article:
db.session.commit() return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
return success_response(message='Article deleted') article.isactive = False
db.session.commit()
return success_response(message='Article deleted')

View File

@@ -0,0 +1,12 @@
{
"name": "knowledgebase",
"version": "1.0.0",
"description": "Knowledge Base articles linking to external resources",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/knowledgebase",
"provides": {
"features": ["knowledgebase"]
}
}

View File

@@ -0,0 +1,5 @@
"""Knowledge Base plugin models."""
from .knowledgebase import KnowledgeBase
__all__ = ['KnowledgeBase']

View File

@@ -1,27 +1,33 @@
"""Knowledge Base models.""" """Knowledge Base model.
from shopdb.extensions import db Non-asset plugin model. The `knowledgebase` table lives in the core Alembic
from .base import BaseModel chain (bundled-plugin schema is folded into core, ADR-004); this class just
maps it and is registered via the plugin's get_models hook. The appid FK
references the core applications table by name, which resolves at mapper config
class KnowledgeBase(BaseModel): time without importing the core model.
"""Knowledge Base article linking to external resources.""" """
__tablename__ = 'knowledgebase'
from shopdb.api import db, BaseModel
linkid = db.Column(db.Integer, primary_key=True)
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'))
shortdescription = db.Column(db.String(500), nullable=False) class KnowledgeBase(BaseModel):
linkurl = db.Column(db.String(2000)) """Knowledge Base article linking to external resources."""
keywords = db.Column(db.String(500)) __tablename__ = 'knowledgebase'
clicks = db.Column(db.Integer, default=0)
lastupdated = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now()) linkid = db.Column(db.Integer, primary_key=True)
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'))
# Relationships shortdescription = db.Column(db.String(500), nullable=False)
application = db.relationship('Application', backref=db.backref('knowledgebase_articles', lazy='dynamic')) linkurl = db.Column(db.String(2000))
keywords = db.Column(db.String(500))
def __repr__(self): clicks = db.Column(db.Integer, default=0)
return f"<KnowledgeBase {self.linkid}: {self.shortdescription[:50] if self.shortdescription else 'No desc'}>" lastupdated = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
def increment_clicks(self): # Relationship to the core Application model (resolved by class name).
"""Increment click counter.""" application = db.relationship('Application', backref=db.backref('knowledgebase_articles', lazy='dynamic'))
self.clicks = (self.clicks or 0) + 1
def __repr__(self):
return f"<KnowledgeBase {self.linkid}: {self.shortdescription[:50] if self.shortdescription else 'No desc'}>"
def increment_clicks(self):
"""Increment click counter."""
self.clicks = (self.clicks or 0) + 1

View File

@@ -0,0 +1,74 @@
"""Knowledge Base plugin main class.
Reference NON-asset plugin: contributes a model + blueprint + nav item but no
AssetType (it is not an asset). Mirrors the notifications plugin.
"""
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 .models import KnowledgeBase
from .api import knowledgebase_bp
logger = logging.getLogger(__name__)
class KnowledgeBasePlugin(BasePlugin):
"""Knowledge Base plugin - articles linking to external resources."""
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', 'knowledgebase'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Knowledge Base articles linking to external resources'
),
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/knowledgebase'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return knowledgebase_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [KnowledgeBase]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Knowledge Base plugin initialized (v{self.meta.version})")
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'Knowledge Base',
'icon': 'book-open',
'route': '/knowledgebase',
'position': 35,
'section': 'information',
},
]

View File

@@ -102,7 +102,6 @@ CORE_BLUEPRINT_NAMES = (
'operatingsystems', 'operatingsystems',
'dashboard', 'dashboard',
'applications', 'applications',
'knowledgebase',
'search', 'search',
'reports', 'reports',
'collector', 'collector',

View File

@@ -11,7 +11,6 @@ from .locations import locations_bp
from .operatingsystems import operatingsystems_bp from .operatingsystems import operatingsystems_bp
from .dashboard import dashboard_bp from .dashboard import dashboard_bp
from .applications import applications_bp from .applications import applications_bp
from .knowledgebase import knowledgebase_bp
from .search import search_bp from .search import search_bp
from .reports import reports_bp from .reports import reports_bp
from .collector import collector_bp from .collector import collector_bp
@@ -33,7 +32,6 @@ __all__ = [
'operatingsystems_bp', 'operatingsystems_bp',
'dashboard_bp', 'dashboard_bp',
'applications_bp', 'applications_bp',
'knowledgebase_bp',
'search_bp', 'search_bp',
'reports_bp', 'reports_bp',
'collector_bp', 'collector_bp',

View File

@@ -142,7 +142,6 @@ def get_navigation():
# Add core information section items # Add core information section items
all_items.extend([ all_items.extend([
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'}, {'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'},
{'name': 'Knowledge Base', 'icon': 'book-open', 'route': '/knowledgebase', 'position': 35, 'section': 'information'},
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'}, {'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'},
]) ])

View File

@@ -9,7 +9,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import ( from shopdb.core.models import (
Asset, AssetType, AssetStatus, Asset, AssetType, AssetStatus,
Application, KnowledgeBase Application
) )
from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.responses import success_response, error_response, ErrorCodes
@@ -165,6 +165,15 @@ def kb_popularity():
""" """
limit = min(int(request.args.get('limit', 20)), 100) limit = min(int(request.args.get('limit', 20)), 100)
# Knowledge Base is a plugin; degrade gracefully if it is not installed.
try:
from plugins.knowledgebase.models import KnowledgeBase
except ImportError:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'KB popularity requires the knowledgebase plugin',
http_code=503)
articles = KnowledgeBase.query.filter( articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True KnowledgeBase.isactive == True
).order_by( ).order_by(

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import joinedload
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import ( from shopdb.core.models import (
Application, KnowledgeBase, Application,
Asset, AssetType, Communication, Vendor, Model Asset, AssetType, Communication, Vendor, Model
) )
from shopdb.utils.responses import success_response from shopdb.utils.responses import success_response
@@ -131,6 +131,8 @@ def _search_knowledgebase(query, search_term):
"""Search Knowledge Base by description and keywords.""" """Search Knowledge Base by description and keywords."""
results = [] results = []
try: try:
_require_enabled('knowledgebase')
from plugins.knowledgebase.models import KnowledgeBase
kb_articles = KnowledgeBase.query.filter( kb_articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True, KnowledgeBase.isactive == True,
db.or_( db.or_(
@@ -153,6 +155,8 @@ def _search_knowledgebase(query, search_term):
'linkurl': kb.linkurl, 'linkurl': kb.linkurl,
'relevance': relevance 'relevance': relevance
}) })
except ImportError:
pass # knowledgebase plugin absent or disabled
except Exception as e: except Exception as e:
logger.error(f"KnowledgeBase search failed: {e}") logger.error(f"KnowledgeBase search failed: {e}")
return results return results

View File

@@ -12,7 +12,6 @@ from .relationship import AssetRelationship, RelationshipType
from .communication import Communication, CommunicationType from .communication import Communication, CommunicationType
from .user import User, Role, Permission from .user import User, Role, Permission
from .application import Application, AppVersion, AppOwner, SupportTeam from .application import Application, AppVersion, AppOwner, SupportTeam
from .knowledgebase import KnowledgeBase
from .setting import Setting from .setting import Setting
from .auditlog import AuditLog from .auditlog import AuditLog
@@ -50,7 +49,6 @@ __all__ = [
'AppOwner', 'AppOwner',
'SupportTeam', 'SupportTeam',
# Knowledge Base # Knowledge Base
'KnowledgeBase',
# Settings # Settings
'Setting', 'Setting',
# Audit # Audit

View File

@@ -0,0 +1,34 @@
"""Characterization tests for the Knowledge Base API.
Written before extracting Knowledge Base into a plugin (pinning-flask-behavior):
the endpoints must behave identically whether KB is a core blueprint or a plugin
blueprint, since both register at /api/knowledgebase.
"""
def test_create_list_get_article(client, db, auth_headers):
"""Create an article, see it in the list, fetch it by id."""
created = client.post('/api/knowledgebase',
json={'shortdescription': 'How to reset a printer',
'linkurl': 'https://kb.example/printer-reset',
'keywords': 'printer reset'},
headers=auth_headers)
assert created.status_code == 201, created.get_json()
linkid = created.get_json()['data']['linkid']
listing = client.get('/api/knowledgebase', headers=auth_headers)
assert listing.status_code == 200
ids = [a['linkid'] for a in listing.get_json()['data']]
assert linkid in ids
fetched = client.get(f'/api/knowledgebase/{linkid}', headers=auth_headers)
assert fetched.status_code == 200
assert fetched.get_json()['data']['shortdescription'] == 'How to reset a printer'
def test_create_requires_shortdescription(client, db, auth_headers):
"""shortdescription is required."""
resp = client.post('/api/knowledgebase',
json={'linkurl': 'https://kb.example/x'},
headers=auth_headers)
assert resp.status_code == 400

View File

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

View File

@@ -89,12 +89,13 @@ def test_paginated_response_shape(client, auth_headers):
def test_plugin_loader_discovers_bundled_plugins(app): def test_plugin_loader_discovers_bundled_plugins(app):
"""Plugin manager finds the six bundled plugins.""" """Plugin manager finds the bundled plugins."""
from shopdb.plugins import plugin_manager from shopdb.plugins import plugin_manager
expected_plugins = { expected_plugins = {
'computers', 'computers',
'equipment', 'equipment',
'knowledgebase',
'network', 'network',
'notifications', 'notifications',
'printers', 'printers',