# ============================================================================== # Formeleon - Multi-tenant Form Generator # Author: Björn Runden # Module: routes/admin.py # Description: Admin Dashboard routes for form management. # Provides CRUD operations for tenants, forms, and submissions. # ============================================================================== """ Admin Dashboard routes for Formeleon. This module provides the management interface for: - Tenant management (CRUD) - Form configuration (CRUD) - Submission viewing and export - User management - Dashboard analytics Routes: GET /admin/ - Dashboard overview GET /admin/tenants/ - List all tenants GET /admin/tenants/ - View/edit tenant GET /admin/forms/ - List all forms GET /admin/forms/ - View/edit form GET /admin/forms/create - Create new form GET /admin/submissions/ - List submissions GET /admin/submissions/ - View submission details GET /admin/users/ - List users GET /admin/users/ - View/edit user Authentication is handled via the auth middleware or dev mode bypass. """ import uuid import logging from typing import Optional, List, Dict, Any from datetime import datetime, timezone from flask import Blueprint, request, redirect, url_for, session, current_app, jsonify from flask import render_template, flash, abort from models import db, Tenant, FormConfig, Submission, User, UserRole, TenantStatus, SubmissionStatus from models import AuditLog from services.recaptcha import RecaptchaService from services.tenant_admin_service import TenantAdminService from services.form_admin_service import FormAdminService from services.user_admin_service import UserAdminService from services.exporter import ExporterService logger = logging.getLogger(__name__) # Create admin blueprint admin_bp = Blueprint('admin', __name__, template_folder='../templates', static_folder='../static') def login_required(func): """Decorator to require login for admin routes.""" def wrapper(*args, **kwargs): if not (session.get('user_id') and session.get('authenticated')): return redirect(url_for('auth.login', next=request.url)) return func(*args, **kwargs) wrapper.__name__ = func.__name__ return wrapper def admin_required(func): """Decorator to require admin privileges.""" def wrapper(*args, **kwargs): if not (session.get('user_id') and session.get('authenticated')): return redirect(url_for('auth.login', next=request.url)) user = User.query.get(session['user_id']) if not user or not user.is_admin(): abort(403, description=_('Admin privileges required')) return func(*args, **kwargs) wrapper.__name__ = func.__name__ return wrapper def get_current_user() -> Optional[User]: """Get current authenticated user.""" if 'user_id' in session: return User.query.get(session['user_id']) return None def get_current_tenant() -> Optional[Tenant]: """Get current tenant from session or user context.""" 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 @admin_bp.before_request def before_request(): """Before request handler for admin routes.""" # Ensure user exists in database if authenticated in session if session.get('user_id'): user = User.query.get(session['user_id']) if not user: # Clean up invalid session session.clear() return redirect(url_for('auth.login')) @admin_bp.route('/') @login_required def dashboard(): """ Admin dashboard overview. Shows: - Number of tenants - Number of forms - Recent submissions - Quick access links """ user = get_current_user() # Get statistics tenant_count = Tenant.query.count() form_count = FormConfig.query.count() submission_count = Submission.query.count() # Recent submissions recent_submissions = Submission.query.filter(Submission.status != SubmissionStatus.ARCHIVED).order_by( Submission.created_at.desc() ).limit(7).all() # Recent forms recent_forms = FormConfig.query.order_by( FormConfig.created_at.desc() ).limit(7).all() # Tenant list tenants = Tenant.query.order_by(Tenant.created_at.desc()).limit(5).all() return render_template('admin/dashboard.html', user=user, tenant_count=tenant_count, form_count=form_count, submission_count=submission_count, recent_submissions=recent_submissions, recent_forms=recent_forms, tenants=tenants, dev_mode=current_app.config.get('DEV_MODE', False)) @admin_bp.route('/tenants/') @login_required def list_tenants(): """List all tenants.""" user = get_current_user() tenants = TenantAdminService.get_tenants(user) return render_template('admin/tenants/list.html', user=user, tenants=tenants, dev_mode=current_app.config.get('DEV_MODE', False)) @admin_bp.route('/tenants/create', methods=['GET', 'POST']) @admin_required def create_tenant(): """Create a new tenant.""" user = get_current_user() if request.method == 'POST': # Validate form data name = request.form.get('name', '').strip() public_key = request.form.get('public_key', '').strip() allowed_origins = request.form.get('allowed_origins', '*').strip() status = request.form.get('status', 'active') tenant = TenantAdminService.create_tenant( name, public_key, allowed_origins, status, user ) flash('Tenant created successfully!', 'success') return redirect(url_for('admin.list_tenants')) return render_template('admin/tenants/create.html', user=user, dev_mode=current_app.config.get('DEV_MODE', False)) @admin_bp.route('/tenants/', methods=['GET', 'POST']) @login_required def view_tenant(tenant_id): """View and edit a tenant.""" user = get_current_user() tenant = Tenant.query.get(tenant_id) if not tenant: abort(404, description='Tenant not found') # Check permissions if user.role != UserRole.ADMIN and tenant not in user.tenants: abort(403, description='Access denied') if request.method == 'POST': if not user.is_admin(): abort(403, description='Admin privileges required to edit tenant') TenantAdminService.update_tenant( tenant, request.form.get('name'), request.form.get('public_key'), request.form.get('allowed_origins'), request.form.get('status'), user ) flash('Tenant updated successfully!', 'success') return redirect(url_for('admin.view_tenant', tenant_id=tenant.id)) # Get tenant forms forms = FormConfig.query.filter_by(tenant_id=tenant.id).order_by( FormConfig.created_at.desc() ).all() return render_template('admin/tenants/view.html', user=user, tenant=tenant, forms=forms, dev_mode=current_app.config.get('DEV_MODE', False), is_admin=user.is_admin()) @admin_bp.route('/tenants//delete', methods=['POST']) @admin_required def delete_tenant(tenant_id): """Delete a tenant.""" user = get_current_user() tenant = Tenant.query.get(tenant_id) if not tenant: abort(404, description='Tenant not found') TenantAdminService.delete_tenant(tenant, user) flash('Tenant deleted successfully!', 'success') return redirect(url_for('admin.list_tenants')) @admin_bp.route('/forms/') @login_required def list_forms(): """List all forms for the current tenant/user.""" user = get_current_user() tenant = get_current_tenant() forms = FormAdminService.get_forms(user, tenant) return render_template('admin/forms/list.html', user=user, forms=forms, tenant=tenant, dev_mode=current_app.config.get('DEV_MODE', False), is_admin=user.is_admin()) @admin_bp.route('/forms/create', methods=['GET', 'POST']) @login_required def create_form(): """Create a new form.""" user = get_current_user() tenant = get_current_tenant() if not tenant: flash('No tenant selected. Please select a tenant.', 'error') return redirect(url_for('admin.dashboard')) if request.method == 'POST': # Get form data name = request.form.get('name', '').strip() description = request.form.get('description', '').strip() # Parse form structure from JSON form_structure_str = request.form.get('form_structure', '{}') try: import json form_structure = json.loads(form_structure_str) except json.JSONDecodeError: form_structure = {'version': '1.0', 'fields': []} # Parse settings settings_str = request.form.get('settings', '{}') try: settings = json.loads(settings_str) except json.JSONDecodeError: settings = {} form = FormAdminService.create_form( tenant, name, description, form_structure, settings, request.form.get('is_active', 'true').lower() == 'true', user ) flash('Form created successfully!', 'success') return redirect(url_for('admin.view_form', form_id=form.id)) # Provide default form structure for new forms default_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 }, { 'name': 'message', 'type': 'textarea', 'label': 'Message', 'required': True } ], 'submit_button': { 'text': 'Submit', 'style': 'primary' } } return render_template('admin/forms/create.html', user=user, tenant=tenant, default_form_structure=default_form_structure, dev_mode=current_app.config.get('DEV_MODE', False)) @admin_bp.route('/forms/', methods=['GET', 'POST']) @login_required def view_form(form_id): """View and edit a form.""" user = get_current_user() form = FormConfig.query.get(form_id) if not form: abort(404, description='Form not found') # Check permissions tenant = Tenant.query.get(form.tenant_id) if not tenant: abort(404, description='Tenant not found') if user.role != UserRole.ADMIN and tenant not in user.tenants: abort(403, description='Access denied') if request.method == 'POST': form_structure_str = request.form.get('form_structure', '{}') form_structure = None try: import json form_structure = json.loads(form_structure_str) except json.JSONDecodeError: pass settings_str = request.form.get('settings', '{}') settings = None try: import json settings = json.loads(settings_str) except json.JSONDecodeError: pass FormAdminService.update_form( form, request.form.get('name'), request.form.get('description'), request.form.get('is_active', 'true').lower() == 'true', form_structure, settings, user, tenant ) flash('Form updated successfully!', 'success') return redirect(url_for('admin.view_form', form_id=form.id)) # Get form submissions submissions = Submission.query.filter_by(form_config_id=form.id).order_by( Submission.created_at.desc() ).limit(10).all() return render_template('admin/forms/view.html', user=user, form=form, tenant=tenant, submissions=submissions, dev_mode=current_app.config.get('DEV_MODE', False), is_admin=user.is_admin()) @admin_bp.route('/forms//delete', methods=['POST']) @login_required def delete_form(form_id): """Delete a form.""" user = get_current_user() form = FormConfig.query.get(form_id) if not form: abort(404, description='Form not found') tenant = Tenant.query.get(form.tenant_id) if not tenant: abort(404, description='Tenant not found') # Check permissions if user.role != UserRole.ADMIN and tenant not in user.tenants: abort(403, description='Access denied') FormAdminService.delete_form(form, user, tenant) flash('Form deleted successfully!', 'success') return redirect(url_for('admin.list_forms')) @admin_bp.route('/submissions/') @login_required def list_submissions(): """List form submissions.""" user = get_current_user() tenant = get_current_tenant() # Check if we should show archived submissions show_archived = request.args.get('show_archived') == 'true' # Base query based on user access if user.role == UserRole.ADMIN: query = Submission.query.join(FormConfig) elif tenant: query = Submission.query.filter_by(tenant_id=tenant.id) else: # Return empty template if no access return render_template('admin/submissions/list.html', user=user, submissions=[], tenant=tenant) # Filter out archived unless requested if not show_archived: query = query.filter(Submission.status != SubmissionStatus.ARCHIVED) submissions = query.order_by(Submission.created_at.desc()).all() return render_template('admin/submissions/list.html', user=user, submissions=submissions, tenant=tenant, show_archived=show_archived, dev_mode=current_app.config.get('DEV_MODE', False), is_admin=user.is_admin()) @admin_bp.route('/submissions/export') @login_required def export_submissions(): """Export submissions as CSV or JSON.""" user = get_current_user() tenant = get_current_tenant() format_type = request.args.get('format', 'csv') form_id = request.args.get('form_id') # Security: Build query based on user access if user.role == UserRole.ADMIN: query = Submission.query elif tenant: query = Submission.query.filter_by(tenant_id=tenant.id) else: abort(403) if form_id: query = query.filter_by(form_config_id=form_id) # Exclude archived from default exports query = query.filter(Submission.status != SubmissionStatus.ARCHIVED) submissions = query.order_by(Submission.created_at.desc()).all() filename = f"formeleon_export_{datetime.now().strftime('%Y%m%d')}" if form_id: filename += f"_{form_id}" if format_type == 'json': return ExporterService.generate_json_response(submissions, f"{filename}.json") else: return ExporterService.generate_csv_response(submissions, f"{filename}.csv") @admin_bp.route('/submissions/bulk-action', methods=['POST']) @login_required def bulk_action_submissions(): """Perform bulk actions (like archive) on multiple submissions.""" user = get_current_user() tenant = get_current_tenant() action = request.form.get('action') submission_ids = request.form.getlist('submission_ids') if not action or not submission_ids: flash('No action or items selected.', 'warning') return redirect(url_for('admin.list_submissions')) # Security check: Ensure user can modify these submissions if user.role == UserRole.ADMIN: subs_to_update = Submission.query.filter(Submission.id.in_(submission_ids)).all() elif tenant: subs_to_update = Submission.query.filter(Submission.id.in_(submission_ids), Submission.tenant_id == tenant.id).all() else: abort(403) count = 0 if action == 'archive': for sub in subs_to_update: sub.status = SubmissionStatus.ARCHIVED count += 1 db.session.commit() flash(f'Successfully archived {count} submissions.', 'success') elif action == 'unarchive': for sub in subs_to_update: sub.status = SubmissionStatus.PROCESSED count += 1 db.session.commit() flash(f'Successfully unarchived {count} submissions.', 'success') return redirect(url_for('admin.list_submissions')) @admin_bp.route('/submissions/') @login_required def view_submission(submission_id): """View submission details.""" user = get_current_user() submission = Submission.query.get(submission_id) if not submission: abort(404, description='Submission not found') # Check permissions form = FormConfig.query.get(submission.form_config_id) tenant = Tenant.query.get(submission.tenant_id) if not form or not tenant: abort(404, description='Related form or tenant not found') if user.role != UserRole.ADMIN and tenant not in user.tenants: abort(403, description='Access denied') return render_template('admin/submissions/view.html', user=user, submission=submission, form=form, tenant=tenant, dev_mode=current_app.config.get('DEV_MODE', False), is_admin=user.is_admin()) @admin_bp.route('/submissions//status', methods=['POST']) @login_required def update_submission_status(submission_id): """Update submission status.""" user = get_current_user() submission = Submission.query.get(submission_id) if not submission: abort(404, description='Submission not found') tenant = Tenant.query.get(submission.tenant_id) if not tenant: abort(404, description='Tenant not found') if user.role != UserRole.ADMIN and tenant not in user.tenants: abort(403, description='Access denied') status = request.form.get('status') if status in [s.value for s in SubmissionStatus]: submission.status = SubmissionStatus(status) db.session.commit() # Audit log AuditLog.log_action( action='update_status', resource_type='submission', user=user, tenant=tenant, resource_id=submission.id, details={'status': status} ) flash('Submission status updated!', 'success') return redirect(url_for('admin.view_submission', submission_id=submission.id)) @admin_bp.route('/users/') @admin_required def list_users(): """List all users (admin only).""" admin_user = get_current_user() users = UserAdminService.get_users() return render_template('admin/users/list.html', admin_user=admin_user, users=users, dev_mode=current_app.config.get('DEV_MODE', False), is_admin=admin_user.is_admin()) @admin_bp.route('/users/', methods=['GET', 'POST']) @admin_required def view_user(user_id): """View and edit a user (admin only).""" admin_user = get_current_user() target_user = User.query.get(user_id) if not target_user: abort(404, description='User not found') if request.method == 'POST': UserAdminService.update_user( target_user, request.form.get('email'), request.form.get('name'), request.form.get('role'), admin_user ) flash('User updated successfully!', 'success') return redirect(url_for('admin.view_user', user_id=target_user.id)) # Get user tenants tenants = target_user.tenants return render_template('admin/users/view.html', admin_user=admin_user, target_user=target_user, tenants=tenants, dev_mode=current_app.config.get('DEV_MODE', False), UserRole=UserRole) @admin_bp.route('/users//delete', methods=['POST']) @admin_required def delete_user(user_id): """Delete a user (admin only).""" user = get_current_user() target_user = User.query.get(user_id) if not target_user: abort(404, description='User not found') # Don't allow deleting self if target_user.id == user.id: flash('Cannot delete your own account!', 'error') return redirect(url_for('admin.view_user', user_id=target_user.id)) UserAdminService.delete_user(target_user, user) flash('User deleted successfully!', 'success') return redirect(url_for('admin.list_users')) @admin_bp.route('/users//tenants', methods=['GET', 'POST']) @admin_required def manage_user_tenants(user_id): """Manage tenant assignments for a user (admin only).""" admin_user = get_current_user() target_user = User.query.get(user_id) if not target_user: abort(404, description='User not found') if request.method == 'POST': # Add or remove tenant assignments action = request.form.get('action') tenant_id = request.form.get('tenant_id') if action == 'add' and tenant_id: tenant = Tenant.query.get(tenant_id) if tenant and tenant not in target_user.tenants: target_user.tenants.append(tenant) db.session.commit() AuditLog.log_action( action='assign_tenant', resource_type='user', user=admin_user, resource_id=target_user.id, details={'tenant_id': tenant_id, 'tenant_name': tenant.name} ) flash(f'User assigned to tenant {tenant.name}!', 'success') elif action == 'remove' and tenant_id: tenant = Tenant.query.get(tenant_id) if tenant and tenant in target_user.tenants: target_user.tenants.remove(tenant) db.session.commit() AuditLog.log_action( action='remove_tenant', resource_type='user', user=admin_user, resource_id=target_user.id, details={'tenant_id': tenant_id, 'tenant_name': tenant.name} ) flash(f'User removed from tenant {tenant.name}!', 'success') return redirect(url_for('admin.manage_user_tenants', user_id=target_user.id)) # Get all tenants and user's current tenants all_tenants = Tenant.query.order_by(Tenant.name).all() user_tenants = list(target_user.tenants) return render_template('admin/users/tenants.html', admin_user=admin_user, target_user=target_user, all_tenants=all_tenants, user_tenants=user_tenants, dev_mode=current_app.config.get('DEV_MODE', False)) @admin_bp.route('/users//role', methods=['POST']) @admin_required def update_user_role(user_id): """Update user role (admin only).""" admin_user = get_current_user() target_user = User.query.get(user_id) if not target_user: abort(404, description='User not found') # Don't allow changing own role if target_user.id == admin_user.id: flash('Cannot change your own role!', 'error') return redirect(url_for('admin.view_user', user_id=target_user.id)) new_role = request.form.get('role') if new_role and new_role in [r.value for r in UserRole]: old_role = target_user.role.value target_user.role = UserRole(new_role) db.session.commit() AuditLog.log_action( action='update_role', resource_type='user', user=admin_user, resource_id=target_user.id, details={'old_role': old_role, 'new_role': new_role} ) flash(f'User role updated to {new_role}!', 'success') else: flash('Invalid role!', 'error') return redirect(url_for('admin.view_user', user_id=target_user.id)) @admin_bp.route('/profile') @login_required def profile(): """User profile page.""" user = get_current_user() if not user: return redirect(url_for('auth.login')) return render_template('admin/profile.html', user=user, dev_mode=current_app.config.get('DEV_MODE', False)) @admin_bp.route('/api/recaptcha-config') @login_required def get_recaptcha_config(): """Get reCAPTCHA configuration for client-side rendering.""" recaptcha_service = RecaptchaService() return jsonify({ 'enabled': recaptcha_service.is_enabled(), 'site_key': recaptcha_service.get_site_key(), 'dev_mode': recaptcha_service.is_dev_mode(), 'mock_score': recaptcha_service.get_dev_mode_score() if recaptcha_service.is_dev_mode() else None }) @admin_bp.route('/settings') @admin_required def settings(): """Application settings (admin only).""" user = get_current_user() # Get system statistics tenant_count = Tenant.query.count() user_count = User.query.count() form_count = FormConfig.query.count() submission_count = Submission.query.count() # Get audit logs audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(50).all() return render_template('admin/settings.html', user=user, tenant_count=tenant_count, user_count=user_count, form_count=form_count, submission_count=submission_count, audit_logs=audit_logs, dev_mode=current_app.config.get('DEV_MODE', False))