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

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

View File

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

View File

@@ -142,7 +142,6 @@ def get_navigation():
# Add core information section items
all_items.extend([
{'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'},
])

View File

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

View File

@@ -9,7 +9,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
Asset, AssetType, AssetStatus,
Application, KnowledgeBase
Application
)
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)
# 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(
KnowledgeBase.isactive == True
).order_by(

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
"""Knowledge Base models."""
from shopdb.extensions import db
from .base import BaseModel
class KnowledgeBase(BaseModel):
"""Knowledge Base article linking to external resources."""
__tablename__ = 'knowledgebase'
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)
linkurl = db.Column(db.String(2000))
keywords = db.Column(db.String(500))
clicks = db.Column(db.Integer, default=0)
lastupdated = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
# Relationships
application = db.relationship('Application', backref=db.backref('knowledgebase_articles', lazy='dynamic'))
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