Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 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

@@ -0,0 +1,224 @@
"""Knowledge Base API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import (
db,
Application,
success_response,
error_response,
paginated_response,
ErrorCodes,
get_pagination_params,
paginate_query,
)
from ..models import KnowledgeBase
from shopdb.api import require_permission, apply_import_timestamps
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))
# Exact-match natural-key lookups for idempotent import. linkurl is the
# stable natural key; shortdescription (the title) is offered as a fallback.
if exactlinkurl := request.args.get('linkurl'):
query = query.filter(KnowledgeBase.linkurl == exactlinkurl)
if exacttitle := request.args.get('shortdescription'):
query = query.filter(KnowledgeBase.shortdescription == exacttitle)
# 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 = db.session.get(KnowledgeBase, 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 = db.session.get(KnowledgeBase, 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()
@require_permission('kb.create')
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 = db.session.get(Application, 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)
apply_import_timestamps(article, data)
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()
@require_permission('kb.edit')
def update_article(link_id: int):
"""Update a knowledge base article."""
article = db.session.get(KnowledgeBase, 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 = db.session.get(Application, 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])
apply_import_timestamps(article, data)
db.session.commit()
return success_response(article.to_dict(), message='Article updated')
@knowledgebase_bp.route('/<int:link_id>', methods=['DELETE'])
@jwt_required()
@require_permission('kb.delete')
def delete_article(link_id: int):
"""Delete (deactivate) a knowledge base article."""
article = db.session.get(KnowledgeBase, 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

@@ -0,0 +1,13 @@
{
"name": "knowledgebase",
"display_name": "Knowledge Base",
"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,14 @@
"""Alembic environment for the knowledgebase plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_knowledgebase. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'knowledgebase'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""knowledgebase plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_knowledgebase. From this
anchor forward, new knowledgebase schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'knowledgebase0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

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

View File

@@ -0,0 +1,40 @@
"""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 datetime import datetime, timezone
from shopdb.api import db, BaseModel
def _utcnow():
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
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=_utcnow, onupdate=_utcnow)
# 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

View File

@@ -0,0 +1,83 @@
"""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',
},
]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('kb.view', 'View knowledge base', 'knowledgebase'),
('kb.create', 'Create KB articles', 'knowledgebase'),
('kb.edit', 'Edit KB articles', 'knowledgebase'),
('kb.delete', 'Delete KB articles', 'knowledgebase'),
]