The floor map became a set of drawings instead of one picture, and search now reaches what the forms have been collecting all along. A site can hold more than one building and a building more than one level, and every map position records which drawing its coordinates belong to. An existing single-map site upgrades into one building and one default level carrying the blueprint it already had, and nothing moves on screen. Landmark recalibration moves a level's markers onto a redrawn plan, solved per axis from the landmarks and never from the image dimensions, dry run by default, snapshotted before it applies. Two identifiers the UI collects were findable in almost no way and two plugins' records in no way at all: gaugelabreference matched only measuring tools, maintenancereference matched nothing anywhere, and USB devices and printed items were absent from search entirely because neither is an asset. All four are searchable now, from the global bar and from every list's own search box. Contract 0.20.0 is additive: a plugin that writes a map position must write its level with it. The version and the changelog are the release; the detail is in the entry.
364 lines
14 KiB
Python
364 lines
14 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.
|
|
# 0.18.0: added DISPLAY_ROLES, DISPLAY_ROLE_PATHS and normalize_display_role
|
|
# beside DashboardDefault (which 0.17.0 imported but never listed in __all__).
|
|
# The role vocabulary is now the kiosk's own - Dashboard / Lobby / 3DPrintRoom,
|
|
# the literal values of C:\Enrollment\display-type.txt - so a plugin holding its
|
|
# own copy of that map (geenforce did) can read core's instead of drifting from
|
|
# it. Additive names on the import surface, minor bump.
|
|
# 0.19.0: get_dashboard_widgets returns DATA AND SHAPE, not a component name.
|
|
# The old shape ('name' + 'component' + 'size') named a Vue component per
|
|
# widget, which cannot survive a lean build - a plugin's component may never be
|
|
# staged into the frontend bundle (ADR-013) - and five plugins were declaring
|
|
# widgets that pointed at components nobody had written. Core now owns the
|
|
# renderers and a card declares id / title / endpoint / render / severity /
|
|
# permission / empty / position. BREAKING for any plugin still using the old
|
|
# shape, which is why it is recorded here: the change itself shipped earlier
|
|
# without a bump, and a contract that changes silently is not a contract.
|
|
__contract_version__ = '0.20.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.11.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)
|
|
|
|
# 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))
|
|
|
|
@app.after_request
|
|
def apply_security_headers(response):
|
|
"""Baseline response headers. The app shipped with none of these.
|
|
|
|
Deliberately the three that cost nothing and break nothing:
|
|
|
|
nosniff a response whose bytes and declared type disagree is
|
|
not re-typed by the browser into something executable.
|
|
This is the general form of the upload problem that
|
|
shopdb/utils/uploads.py addresses per-file.
|
|
frame-ancestors
|
|
same-origin only. The kiosks open routes directly
|
|
rather than framing them, so this costs the fleet
|
|
nothing and stops the UI being framed elsewhere and
|
|
clicked through. Sent as X-Frame-Options too, because
|
|
the display bays run browsers old enough to want it.
|
|
Referrer-Policy
|
|
an asset id or a hostname in a path is not handed to
|
|
whatever a user clicks through to.
|
|
|
|
NOT a full page CSP. This app serves an SPA with inline styles, so a
|
|
real script-src policy is a change worth making on its own with the
|
|
frontend in front of you - claiming one here by adding a permissive
|
|
header would be worse than having none.
|
|
"""
|
|
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
|
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
|
|
response.headers.setdefault('Referrer-Policy',
|
|
'strict-origin-when-cross-origin')
|
|
# setdefault: an upload response has already declared its own, stricter
|
|
# Content-Security-Policy, and this must not weaken it.
|
|
response.headers.setdefault('Content-Security-Policy',
|
|
"frame-ancestors 'self'")
|
|
return response
|
|
|
|
return app
|
|
|
|
|
|
CORE_BLUEPRINT_NAMES = (
|
|
'auth',
|
|
'assets',
|
|
'modeltypes',
|
|
'plugins',
|
|
'vendors',
|
|
'models',
|
|
'businessunits',
|
|
'locations',
|
|
'maplevels',
|
|
'mappositions',
|
|
'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'
|
|
)
|