Files
shopdb-flask/shopdb/__init__.py
cproudlock f663cc5bbe Enforce plugin contract purity: single import surface via shopdb.api
Plugins were reaching into internal core paths (shopdb.core.models.*,
shopdb.extensions, shopdb.utils.*), coupling them to core's file layout and
violating the ADR-001 contract. Consolidate onto one versioned surface.

- shopdb.api: expand from 2 helpers to the full plugin import surface -
  db, cache; BaseModel, AuditMixin; core models (Asset, AssetType,
  AssetStatus, Vendor, Model, Communication, CommunicationType, Location,
  Setting, AuditLog, Application, AppVersion, OperatingSystem); response +
  pagination helpers; employee_connection. Documented in PLUGIN-HOOKS.md.
- Migrate all 22 plugin source files to import only from shopdb.api (plus
  shopdb.plugins.base for the ABC).
- Drop the printers plugin's legacy MachineType dependency: remove
  _ensure_legacy_machine_types and the seed_supplies machinetypeid lookup
  (Model.machinetypeid is nullable; printers carry type via PrinterType).
- Guard test test_plugins_only_import_contract_surface scans plugin source
  and fails on any core import outside shopdb.api / shopdb.plugins.base.
- Scaffold templates updated so generated plugins are contract-pure.
- Bump __contract_version__ 0.2.0 -> 0.3.0 (additive surface expansion;
  manifests pin <1.0.0 so they still satisfy).

145 tests pass, naming/style green, app factory boots all 6 plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:45:06 -04:00

217 lines
6.2 KiB
Python

"""Flask application factory."""
import os
import logging
from flask import Flask, send_from_directory
from .config import config
from .extensions import db, migrate, jwt, cors, ma, init_extensions
from .plugins import plugin_manager
# Platform contract version. See ADR-001 for the contract surface and
# ADR-002 for the bump rules. Plugins declare a compatible range in
# their manifest.json `core_version` field. Pre-1.0 (0.x) means the
# contract is still settling; sister sites should pin tight ranges.
# 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache,
# model bases, core models, response + pagination helpers, employee_connection)
# so plugins no longer import internal core paths. Additive, hence minor bump.
__contract_version__ = '0.3.0'
def create_app(config_name: str = None) -> Flask:
"""
Application factory.
Args:
config_name: Configuration name ('development', 'production', 'testing')
Returns:
Configured Flask application
"""
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'development')
app = Flask(__name__, instance_relative_config=True)
config_class = config.get(config_name, config['default'])
# Production must validate its env-driven config before boot.
if config_name == 'production' and hasattr(config_class, 'validate'):
config_class.validate()
app.config.from_object(config_class)
# Load instance config if exists
app.config.from_pyfile('config.py', silent=True)
# Ensure instance folder exists
os.makedirs(app.instance_path, exist_ok=True)
# Configure logging
configure_logging(app)
# Initialize extensions
init_extensions(app)
# Initialize plugin manager
with app.app_context():
plugin_manager.init_app(app, db)
# Register core blueprints
register_blueprints(app)
# Register CLI commands
register_cli_commands(app)
# Register error handlers
register_error_handlers(app)
# Serve Vue frontend
register_frontend_routes(app)
# JWT user loader (identity is a string in JWT, convert to int for DB lookup)
@jwt.user_lookup_loader
def user_lookup_callback(_jwt_header, jwt_data):
from .core.models import User
identity = jwt_data["sub"]
return db.session.get(User, int(identity))
return app
CORE_BLUEPRINT_NAMES = (
'auth',
'assets',
'machinetypes',
'plugins',
'vendors',
'models',
'businessunits',
'locations',
'operatingsystems',
'dashboard',
'applications',
'knowledgebase',
'search',
'reports',
'collector',
'employees',
'slides',
'settings',
'auditlogs',
'users',
)
def register_blueprints(app: Flask):
"""Register core API blueprints from CORE_BLUEPRINT_NAMES.
Each entry maps to an attribute `<name>_bp` exported by
`shopdb.core.api` and a URL prefix `/api/<name>`. Adding a new
core resource is one entry in CORE_BLUEPRINT_NAMES, not a 3-line
edit in this function.
"""
from .core import api as api_module
api_prefix = '/api'
for name in CORE_BLUEPRINT_NAMES:
attr_name = f'{name}_bp'
if not hasattr(api_module, attr_name):
raise RuntimeError(
f'Core blueprint "{attr_name}" missing from shopdb.core.api. '
f'Either add it or remove "{name}" from CORE_BLUEPRINT_NAMES.'
)
bp = getattr(api_module, attr_name)
app.register_blueprint(bp, url_prefix=f'{api_prefix}/{name}')
def register_cli_commands(app: Flask):
"""Register Flask CLI commands."""
from .plugins.cli import plugin_cli
from .cli import db_cli, seed_cli
app.cli.add_command(plugin_cli)
app.cli.add_command(db_cli)
app.cli.add_command(seed_cli)
def register_error_handlers(app: Flask):
"""Register error handlers."""
from .utils.responses import error_response, ErrorCodes
from .exceptions import ShopDBException
@app.errorhandler(ShopDBException)
def handle_shopdb_exception(error):
http_codes = {
'NOT_FOUND': 404,
'UNAUTHORIZED': 401,
'FORBIDDEN': 403,
'CONFLICT': 409,
'VALIDATION_ERROR': 400,
}
http_code = http_codes.get(error.code, 400)
return error_response(
error.code,
error.message,
details=error.details,
http_code=http_code
)
@app.errorhandler(404)
def not_found_error(error):
return error_response(
ErrorCodes.NOT_FOUND,
'Resource not found',
http_code=404
)
@app.errorhandler(500)
def internal_error(error):
return error_response(
ErrorCodes.INTERNAL_ERROR,
'An internal error occurred',
http_code=500
)
@app.errorhandler(401)
def unauthorized_error(error):
return error_response(
ErrorCodes.UNAUTHORIZED,
'Authentication required',
http_code=401
)
def register_frontend_routes(app: Flask):
"""Serve Vue frontend static files."""
frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist')
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def serve_frontend(path):
# Don't serve API routes as frontend
if path.startswith('api/'):
from .utils.responses import error_response, ErrorCodes
return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404)
# Try to serve a static asset directly. send_from_directory handles
# the safe-join + 404 itself; no explicit existence probe needed
# (the probe was a path-traversal risk surface).
if path:
try:
return send_from_directory(frontend_dist, path)
except Exception:
pass
return send_from_directory(frontend_dist, 'index.html')
def configure_logging(app: Flask):
"""Configure application logging."""
log_level = app.config.get('LOG_LEVEL', 'INFO')
logging.basicConfig(
level=getattr(logging, log_level),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)