"""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.2.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', 'machines', 'machinetypes', 'pctypes', 'statuses', '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 `_bp` exported by `shopdb.core.api` and a URL prefix `/api/`. 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('/') 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' )