# ============================================================================== # Formeleon - Multi-tenant Form Generator # Author: Björn Runden # Module: app.py # Description: Main Flask application initialization and Blueprint registration. # Sets up database, configuration, and registers all route blueprints. # ============================================================================== """ Main Flask application module for Formeleon. This module: - Loads application configuration - Initializes Flask extensions (SQLAlchemy, Migrate, CORS, Session) - Registers all route blueprints (API, Admin, Auth) - Sets up error handlers - Provides utility functions for request context The application uses Flask Blueprints for modular route organization: - /api/v1/* - Public Form API for form submissions - /admin/* - Admin Dashboard for form management - /auth/* - Authentication routes (OIDC callbacks, logout) """ import os import logging from typing import Optional from flask import Flask, request, session, g from flask_cors import CORS from flask_session import Session from flask_migrate import Migrate import uuid # corrected for py3.13 from config import Config, config_map from config import Config, config_map from models import db, Tenant, User, UserRole, Submission, FormConfig, AuditLog, SubmissionStatus # Initialize extensions globally (will be bound to app in create_app) migrate = Migrate() cors = CORS() def get_config_class() -> type: """ Get the appropriate configuration class based on FLASK_ENV. :return: Configuration class to use """ env = os.environ.get('FLASK_ENV', 'development') return config_map.get(env, Config) def create_app(config_class: Optional[type] = None) -> Flask: """ Create and configure the Flask application instance. :param config_class: Configuration class to use (defaults to environment-based) :return: Configured Flask application instance """ # Initialize Flask app app = Flask(__name__) app.config.from_object(config_class or get_config_class()) # Set up logging setup_logging(app) # Initialize extensions with app initialize_extensions(app) # Register blueprints register_blueprints(app) # Register error handlers register_error_handlers(app) # Register CLI commands register_cli_commands(app) # Set up dev mode middleware if enabled if app.config['DEV_MODE']: setup_dev_mode_middleware(app) return app def setup_logging(app: Flask) -> None: """ Configure logging for the application. :param app: Flask application instance """ log_level = logging.DEBUG if app.config['DEBUG'] else logging.INFO logging.basicConfig( level=log_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) app.logger.setLevel(log_level) # Suppress Flask-SQLAlchemy logs unless in debug mode if not app.config['DEBUG']: logging.getLogger('flask_sqlalchemy').setLevel(logging.WARNING) def initialize_extensions(app: Flask) -> None: """ Initialize Flask extensions with the application. :param app: Flask application instance """ # Database db.init_app(app) # Migration migrate.init_app(app, db) # CORS if app.config['ENABLE_CORS']: cors.init_app(app, resources={ r'/api/*': { 'origins': app.config.get('ALLOWED_ORIGINS', '*'), 'methods': ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 'allow_headers': ['Content-Type', 'Authorization', 'X-Request-ID'] } }) # Session app.config['SESSION_TYPE'] = 'sqlalchemy' app.config['SESSION_SQLALCHEMY'] = db Session(app) # Ensure secret key is set if not app.config.get('SECRET_KEY'): app.config['SECRET_KEY'] = str(uuid.uuid4()) app.logger.warning('Generated random SECRET_KEY - set explicitly in production!') def register_blueprints(app: Flask) -> None: """ Register all route blueprints with the application. :param app: Flask application instance """ # Import blueprints here to avoid circular imports from routes.api import api_bp from routes.admin import admin_bp from routes.auth import auth_bp # Register blueprints with URL prefixes app.register_blueprint(api_bp, url_prefix='/api/v1') app.register_blueprint(admin_bp, url_prefix='/admin') app.register_blueprint(auth_bp, url_prefix='/auth') # Register root route for health check @app.route('/') def health_check(): """Health check endpoint.""" return { 'status': 'ok', 'version': '1.0.0', 'dev_mode': app.config['DEV_MODE'] }, 200 def register_error_handlers(app: Flask) -> None: """ Register custom error handlers for the application. :param app: Flask application instance """ @app.errorhandler(400) def bad_request_error(error): """Handle 400 Bad Request errors.""" return { 'error': 'Bad Request', 'message': str(error.description) if error.description else 'Invalid request data' }, 400 @app.errorhandler(401) def unauthorized_error(error): """Handle 401 Unauthorized errors.""" return { 'error': 'Unauthorized', 'message': str(error.description) if error.description else 'Authentication required' }, 401 @app.errorhandler(403) def forbidden_error(error): """Handle 403 Forbidden errors.""" return { 'error': 'Forbidden', 'message': str(error.description) if error.description else 'Access denied' }, 403 @app.errorhandler(404) def not_found_error(error): """Handle 404 Not Found errors.""" return { 'error': 'Not Found', 'message': str(error.description) if error.description else 'Resource not found' }, 404 @app.errorhandler(429) def too_many_requests_error(error): """Handle 429 Too Many Requests errors.""" return { 'error': 'Too Many Requests', 'message': str(error.description) if error.description else 'Rate limit exceeded' }, 429 @app.errorhandler(500) def internal_server_error(error): """Handle 500 Internal Server Error.""" app.logger.error(f'Internal Server Error: {error}') return { 'error': 'Internal Server Error', 'message': 'An unexpected error occurred' }, 500 def register_cli_commands(app: Flask) -> None: """ Register custom CLI commands for the application. :param app: Flask application instance """ import click from flask.cli import with_appcontext @app.cli.command('seed-db') @with_appcontext def seed_db(): """Seed the database with test data for development.""" import uuid # Check if data already exists if Tenant.query.first(): print('Database already contains data. Use --force to overwrite.') return # Create dev tenant tenant = Tenant( id='tenant_dev_123', name='Development Tenant', public_key='dev-public-key-12345', allowed_origins='http://localhost:5000,http://localhost:8080', status=TenantStatus.ACTIVE ) db.session.add(tenant) # Create sample form sample_form = FormConfig( id='form_sample_001', tenant_id='tenant_dev_123', name='Contact Form', description='Sample contact form for testing', form_structure={ 'version': '1.0', 'fields': [ { 'name': 'name', 'type': 'text', 'label': 'Your Name', 'required': True, 'validation': {'min_length': 2, 'max_length': 100} }, { 'name': 'email', 'type': 'email', 'label': 'Email Address', 'required': True, 'validation': {'pattern': r'^[^@]+@[^@]+\.[^@]+$'} }, { 'name': 'subject', 'type': 'text', 'label': 'Subject', 'required': True }, { 'name': 'message', 'type': 'textarea', 'label': 'Message', 'required': True, 'validation': {'min_length': 10} } ], 'submit_button': { 'text': 'Send Message', 'style': 'primary' } }, settings={ 'email_notification': { 'enabled': False, 'recipients': ['admin@formeleon.local'] }, 'success_message': 'Thank you for your message!', 'recaptcha_enabled': True } ) db.session.add(sample_form) # Create dev admin user admin_user = User( id='user_dev_admin_001', email=app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'), name=app.config.get('DEV_USER_NAME', 'Dev Admin'), role=UserRole.ADMIN, sub='dev-admin-sub-123' ) db.session.add(admin_user) # Associate user with tenant from models import user_tenant_association db.session.execute( user_tenant_association.insert().values( user_id=admin_user.id, tenant_id=tenant.id ) ) db.session.commit() print(f'Seeded database with tenant {tenant.id}, form {sample_form.id}, and user {admin_user.id}') @app.cli.command('create-tenant') @click.argument('name') @click.argument('public_key') @click.option('--allowed-origins', default='*', help='Comma-separated allowed origins') @with_appcontext def create_tenant(name, public_key, allowed_origins): """Create a new tenant.""" tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}' tenant = Tenant( id=tenant_id, name=name, public_key=public_key, allowed_origins=allowed_origins ) db.session.add(tenant) db.session.commit() print(f'Created tenant {tenant_id} with public key {public_key}') @app.cli.command('create-user') @click.argument('email') @click.argument('name') @click.option('--role', default='user', help='User role: admin, tenant_admin, user, viewer') @with_appcontext def create_user(email, name, role): """Create a new user.""" from models import UserRole as UR role_enum = getattr(UR, role.upper(), UR.USER) user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}' user = User( id=user_id, email=email, name=name, role=role_enum ) db.session.add(user) db.session.commit() print(f'Created user {user_id} with email {email} and role {role}') def setup_dev_mode_middleware(app: Flask) -> None: """ Set up development mode middleware for fake authentication. This middleware injects a fake Zitadel user session when DEV_MODE is enabled, bypassing the need for actual OIDC authentication during development. :param app: Flask application instance """ from functools import wraps @app.before_request def inject_dev_user(): """Inject dev user into session if DEV_MODE is enabled.""" # Skip for static files, auth routes, and API routes if (request.path.startswith('/static/') or request.path.startswith('/auth/') or request.path.startswith('/api/')): return # Skip if user is already authenticated if 'user_id' in session: return # Inject dev user dev_user = { 'id': 'user_dev_admin_001', 'email': app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'), 'name': app.config.get('DEV_USER_NAME', 'Dev Admin'), 'role': app.config.get('DEV_USER_ROLE', 'admin'), 'sub': 'dev-admin-sub-123', 'tenant_id': app.config.get('DEV_TENANT_ID', 'tenant_dev_123'), 'dev_mode': True } session['user_id'] = dev_user['id'] session['user'] = dev_user session.permanent = True # Load or create dev user in database from models import User, UserRole, Tenant user = User.query.get(dev_user['id']) if not user: role = UserRole.ADMIN if dev_user['role'] == 'admin' else UserRole.USER user = User( id=dev_user['id'], email=dev_user['email'], name=dev_user['name'], role=role, sub=dev_user['sub'] ) db.session.add(user) db.session.commit() # Ensure dev tenant exists tenant = Tenant.query.get(dev_user['tenant_id']) if not tenant: tenant = Tenant( id=dev_user['tenant_id'], name='Development Tenant', public_key='dev-public-key-12345', allowed_origins='*' ) db.session.add(tenant) db.session.commit() def get_current_user() -> Optional[User]: """ Get the current authenticated user from the request context. :return: User object if authenticated, None otherwise """ from flask import session if 'user_id' not in session: return None return User.query.get(session['user_id']) def get_current_tenant() -> Optional[Tenant]: """ Get the current tenant from the request context. :return: Tenant object if available, None otherwise """ from flask import session if 'tenant_id' in session: return Tenant.query.get(session['tenant_id']) user = get_current_user() if user and user.tenants: return user.tenants[0] return None def get_request_context() -> dict: """ Get standardized request context for audit logging. :return: Dictionary with request context information """ return { 'ip_address': request.remote_addr, 'user_agent': request.user_agent.string if request.user_agent else None, 'path': request.path, 'method': request.method } # Create application instance app = create_app() if __name__ == '__main__': # This allows running the app directly with python app.py # In production, use gunicorn or other WSGI servers app.run(host='0.0.0.0', port=5000, debug=app.config['DEBUG'])