Locks the public surface plugin authors at sister sites depend on.
Contract version (shopdb/__init__.py):
- __contract_version__ = '0.1.0'. Per ADR-002, plugins declare a
compatible range in manifest.json `core_version`. Pre-1.0 signals
the contract is still settling; sister sites should pin tightly.
BasePlugin hook changes (shopdb/plugins/base.py):
- Add get_collector_schema() per ADR-006. Returns JSON Schema (with
identityfield + fields) describing the payload of an external
collector pushing to /api/collector/<pluginname>. Defaults to None
(no auto-registered endpoint).
- Remove get_event_handlers(). Event bus deferred indefinitely per
ADR-001 (no real use case yet; add via new ADR if it appears).
Hook reference (docs/PLUGIN-HOOKS.md):
- Canonical reference for the contract: required hooks (meta,
get_blueprint, get_models), optional hooks (init_app,
get_cli_commands, get_services, get_dashboard_widgets,
get_navigation_items, get_searchable_fields, get_collector_schema),
lifecycle hooks (on_install, on_uninstall, on_enable, on_disable),
helpers exposed in shopdb.api (audit_log, Setting,
resolve_asset_position).
- Versioning rules + change-classification guidance.
Compliance tests (tests/test_plugin_contract.py):
- 8 distinct contract assertions parametrized over 6 bundled plugins
(computers, equipment, network, notifications, printers, usb).
- Asserts: subclasses BasePlugin; manifest has required fields; meta
returns valid PluginMeta; get_blueprint returns Blueprint or None;
get_models returns model classes; get_collector_schema returns
None or {identityfield, fields}; get_navigation_items and
get_searchable_fields return list.
- Plus 3 framework-level: __contract_version__ is valid semver,
get_event_handlers absent, get_collector_schema present.
Test count: 15 -> 66 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
7.1 KiB
Python
221 lines
7.1 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.
|
|
__contract_version__ = '0.1.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
|
|
|
|
|
|
def register_blueprints(app: Flask):
|
|
"""Register core API blueprints."""
|
|
from .core.api import (
|
|
auth_bp,
|
|
assets_bp,
|
|
machines_bp,
|
|
machinetypes_bp,
|
|
pctypes_bp,
|
|
statuses_bp,
|
|
vendors_bp,
|
|
models_bp,
|
|
businessunits_bp,
|
|
locations_bp,
|
|
operatingsystems_bp,
|
|
dashboard_bp,
|
|
applications_bp,
|
|
knowledgebase_bp,
|
|
search_bp,
|
|
reports_bp,
|
|
collector_bp,
|
|
employees_bp,
|
|
slides_bp,
|
|
settings_bp,
|
|
auditlogs_bp,
|
|
users_bp,
|
|
)
|
|
|
|
api_prefix = '/api'
|
|
|
|
app.register_blueprint(auth_bp, url_prefix=f'{api_prefix}/auth')
|
|
app.register_blueprint(assets_bp, url_prefix=f'{api_prefix}/assets')
|
|
app.register_blueprint(machines_bp, url_prefix=f'{api_prefix}/machines')
|
|
app.register_blueprint(machinetypes_bp, url_prefix=f'{api_prefix}/machinetypes')
|
|
app.register_blueprint(pctypes_bp, url_prefix=f'{api_prefix}/pctypes')
|
|
app.register_blueprint(statuses_bp, url_prefix=f'{api_prefix}/statuses')
|
|
app.register_blueprint(vendors_bp, url_prefix=f'{api_prefix}/vendors')
|
|
app.register_blueprint(models_bp, url_prefix=f'{api_prefix}/models')
|
|
app.register_blueprint(businessunits_bp, url_prefix=f'{api_prefix}/businessunits')
|
|
app.register_blueprint(locations_bp, url_prefix=f'{api_prefix}/locations')
|
|
app.register_blueprint(operatingsystems_bp, url_prefix=f'{api_prefix}/operatingsystems')
|
|
app.register_blueprint(dashboard_bp, url_prefix=f'{api_prefix}/dashboard')
|
|
app.register_blueprint(applications_bp, url_prefix=f'{api_prefix}/applications')
|
|
app.register_blueprint(knowledgebase_bp, url_prefix=f'{api_prefix}/knowledgebase')
|
|
app.register_blueprint(search_bp, url_prefix=f'{api_prefix}/search')
|
|
app.register_blueprint(reports_bp, url_prefix=f'{api_prefix}/reports')
|
|
app.register_blueprint(collector_bp, url_prefix=f'{api_prefix}/collector')
|
|
app.register_blueprint(employees_bp, url_prefix=f'{api_prefix}/employees')
|
|
app.register_blueprint(slides_bp, url_prefix=f'{api_prefix}/slides')
|
|
app.register_blueprint(settings_bp, url_prefix=f'{api_prefix}/settings')
|
|
app.register_blueprint(auditlogs_bp, url_prefix=f'{api_prefix}/auditlogs')
|
|
app.register_blueprint(users_bp, url_prefix=f'{api_prefix}/users')
|
|
|
|
|
|
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'
|
|
)
|