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 models."""
from .knowledgebase import KnowledgeBase
__all__ = ['KnowledgeBase']

View File

@@ -0,0 +1,33 @@
"""Knowledge Base model.
Non-asset plugin model. The `knowledgebase` table lives in the core Alembic
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
time without importing the core model.
"""
from shopdb.api import db, 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())
# Relationship to the core Application model (resolved by class name).
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