Three defects, all found on printedparts_label_prefix, all one root cause: nothing in the framework knew that setting existed. The parts kiosk runs logged out. An unauthenticated read of a setting is limited to an allowlist, the key was not on it, so the kiosk got a 404 and fell back to no prefix. An admin previewing the same page while logged in saw the prefix, which is why it looked like it worked. The same setting also looked like it would not save. The row did not exist on a site that installed the plugin before the setting was added, so the first save created it - under the placeholder category the settings API uses for keys it does not recognise, where the plugin's settings page, which lists by category, could no longer see it. The value was in the database the whole time. And the row was missing in the first place because seeding ran from on_install / on_enable, which fire only on a state transition. Neither runs again on an upgrade, so a setting added in a later plugin version never reached a site that installed an earlier one. The comment claiming enable ran every upgrade cycle was simply wrong. A plugin now declares the settings it owns in get_settings_defaults(): key, default, type, category, description, and whether a logged-out page may read it. The framework seeds declared keys at install, at enable, and on every flask plugin upgrade-all; files a first-time write under the declared category; re-homes any row left in the placeholder category, value untouched; and answers an anonymous read for keys marked public. Core carries no list of any plugin's keys. Contract 0.16.0 (additive optional hook). printedparts and printers move to the hook and floor their core_version at 0.16.0. The dev database had two rows in the misfiled state (printedparts_alert_email, employee_db_host); the first repairs itself on the next upgrade pass.
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""Flask application factory."""
|
|
|
|
import os
|
|
import logging
|
|
from flask import Flask, send_from_directory
|
|
|
|
from .config import config
|
|
from .extensions import db, jwt, 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.
|
|
# 0.4.0: removed the never-implemented get_searchable_fields hook (search is a
|
|
# core concern over the asset model) and wired the get_dashboard_widgets hook to
|
|
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
|
|
# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge
|
|
# plugin report cards into the Reports hub. Additive optional hook, minor bump.
|
|
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
|
|
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
|
|
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
|
|
# 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api
|
|
# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the
|
|
# machines plugin list/detail to collapse dual-bay pairs. Two additive names,
|
|
# minor bump.
|
|
# 0.10.0: added the get_permissions hook so a plugin declares the RBAC
|
|
# permissions its own routes enforce, instead of core accumulating them in one
|
|
# catalog. Consumed by full_permission_catalog() (core + enabled plugins),
|
|
# which backs seeding, the role grid, and API-token scope validation. Additive
|
|
# optional hook, minor bump.
|
|
# 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's
|
|
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
|
# managed service token without importing core token internals. Additive name
|
|
# on the import surface, minor bump.
|
|
# 0.16.0: added the get_settings_defaults hook so a plugin declares the Setting
|
|
# rows it owns (key, value, type, category, description, public). The framework
|
|
# seeds them at install, at enable, and on `flask plugin upgrade-all`, files a
|
|
# first-time write under the declared category, and lets a plugin mark a key
|
|
# readable without auth for pages that run logged out. Additive optional hook,
|
|
# minor bump.
|
|
__contract_version__ = '0.16.0'
|
|
|
|
# Product release version (see ADR-007). The product version and the
|
|
# plugin-contract version above are distinct series with independent
|
|
# bump rules. Not part of the shopdb.api contract surface, so it is
|
|
# not re-exported there.
|
|
__version__ = '0.8.1'
|
|
|
|
|
|
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)
|
|
|
|
# Per-plugin collector keys (ADR-006) are dynamic env-vars
|
|
# (COLLECTOR_API_KEY_<PLUGINNAME>) that from_object cannot pick up because
|
|
# they are not class attributes. Copy them in explicitly so per-plugin
|
|
# credential isolation works in real deploys, not just tests.
|
|
for envname, envvalue in os.environ.items():
|
|
if envname.startswith('COLLECTOR_API_KEY_') and envvalue:
|
|
app.config[envname] = envvalue
|
|
|
|
# Ensure instance folder exists
|
|
os.makedirs(app.instance_path, exist_ok=True)
|
|
|
|
# Configure logging
|
|
configure_logging(app)
|
|
|
|
# Initialize extensions
|
|
init_extensions(app)
|
|
|
|
# An optional unique column accepts any number of NULLs but exactly one
|
|
# empty string, so a second blank code collided and became a 500. Normalise
|
|
# blank to NULL once, at the mapper, rather than in each endpoint.
|
|
from .utils.blankunique import register_blank_unique_normaliser
|
|
register_blank_unique_normaliser()
|
|
|
|
# Initialize plugin manager
|
|
with app.app_context():
|
|
plugin_manager.init_app(app, db)
|
|
|
|
# Register core blueprints
|
|
register_blueprints(app)
|
|
|
|
# Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
|
|
# any JWT decode and mint a request-scoped JWT for the token's owner.
|
|
from .utils.apitoken_auth import install_apitoken_auth
|
|
install_apitoken_auth(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',
|
|
'modeltypes',
|
|
'plugins',
|
|
'vendors',
|
|
'models',
|
|
'businessunits',
|
|
'locations',
|
|
'operatingsystems',
|
|
'dashboard',
|
|
'dashboarddefaults',
|
|
'applications',
|
|
'supportteams',
|
|
'search',
|
|
'reports',
|
|
'collector',
|
|
'settings',
|
|
'auditlogs',
|
|
'users',
|
|
'customfields',
|
|
'setup',
|
|
'pluginui',
|
|
'apitokens',
|
|
'docs',
|
|
)
|
|
|
|
|
|
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, relationships_cli, csv_cli
|
|
|
|
app.cli.add_command(plugin_cli)
|
|
app.cli.add_command(db_cli)
|
|
app.cli.add_command(seed_cli)
|
|
app.cli.add_command(relationships_cli)
|
|
app.cli.add_command(csv_cli)
|
|
|
|
|
|
def register_error_handlers(app: Flask):
|
|
"""Register error handlers."""
|
|
from .utils.responses import error_response, ErrorCodes
|
|
from .exceptions import ShopDBException
|
|
|
|
# A uniqueness violation is the caller's problem, not a server fault. Without
|
|
# this it surfaced as a bare 500 with a SQLAlchemy traceback in the log and
|
|
# nothing usable on screen - the operator saw "internal server error" for
|
|
# having reused a code that was already taken.
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
@app.errorhandler(IntegrityError)
|
|
def handle_integrity_error(error):
|
|
from .extensions import db
|
|
db.session.rollback()
|
|
message = str(getattr(error, 'orig', error))
|
|
app.logger.warning('integrity error: %s', message)
|
|
if 'Duplicate entry' in message or 'UNIQUE constraint' in message:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'That value is already in use. Codes and identifiers must be unique.',
|
|
http_code=409)
|
|
if 'foreign key constraint' in message.lower():
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'That record refers to something that does not exist, or is still in use elsewhere.',
|
|
http_code=400)
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'The database rejected that change.', http_code=400)
|
|
|
|
@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:
|
|
response = send_from_directory(frontend_dist, path)
|
|
# Asset filenames carry a content hash, so a given URL never
|
|
# changes - cache them hard. Everything else stays revalidated.
|
|
if path.startswith('assets/'):
|
|
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
|
else:
|
|
response.headers['Cache-Control'] = 'no-cache'
|
|
return response
|
|
except Exception:
|
|
pass
|
|
|
|
# index.html names the hashed chunks, so a stale copy points at files a
|
|
# deploy has already deleted and the SPA stops navigating. Always
|
|
# revalidate it.
|
|
response = send_from_directory(frontend_dist, 'index.html')
|
|
response.headers['Cache-Control'] = 'no-cache'
|
|
return response
|
|
|
|
|
|
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'
|
|
)
|