Files
formeleon/routes/admin.py
T
br 5fff17531d
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Successful in 1m1s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Skipped
i18 drin
mail drin
fehlende routen drin
prod-reife erreicht.
TODOS:

Deine aktuellen offenen TODOs
1. Rate-Limiting (Spam-Schutz VOR dem reCAPTCHA)
Das reCAPTCHA hält zwar Bots ab, aber wenn jemand böswillig ein Script schreibt, das 1.000 POST-Requests pro Sekunde an /api/v1/submissions sendet, muss dein Backend jedes Mal Google kontaktieren und die Datenbank befragen. Das frisst Server-Ressourcen.

Todo: Einbinden von Flask-Limiter und Absichern der API-Route (z.B. max. 10 Submissions pro Minute pro IP-Adresse oder Tenant).
2. Billing & Feature-Enforcement (Limits pro Tenant durchsetzen)
Die Mandanten-Architektur und das config-Feld im JSON existieren bereits. Aber wir erzwingen diese Limits aktuell nicht im Backend.

Todo: Im Dashboard (templates/admin/tenants/view.html) UI-Controls einbauen, um Feature-Flags für einen Tenant festzulegen:
max_forms (z.B. 1 für Free-User, 10 für Pro-User).
allow_instant_mail (Dürfen E-Mails an den Tenant gesendet werden?).
allow_autoresponder (Darf der Autoresponder für Endkunden genutzt werden?).
Todo: Diese Limits hart im Backend (beim Erstellen von Forms und beim Mailversand) kontrollieren. (Der Mailer prüft das zwar schon halbwegs, das UI zum Pflegen fehlt aber noch komplett).
3. E-Mail Templates aufwerten (HTML statt Plain-Text)
Bisher versendet der Service E-Mails als reinen Text (plain). Für eine professionelle SaaS-Lösung sollten diese Mails ansprechend aussehen.

Todo: Im services/mailer.py den MIMEMultipart so erweitern, dass er saubere HTML-Mails aus einem Jinja2-Template (templates/emails/...) generiert, inklusive Branding/Logo deines Services.
4. Frontend-Snippet "Graceful Degradation"
Das embed.js arbeitet aktuell sehr gut. Aber was ist, wenn das Formeleon-Backend mal für 5 Minuten down ist?

Todo: Einen sauberen Timeout im fetch einbauen und dem Endkunden auf der Webseite eine schöne, fallback HTML-Meldung anzeigen ("Entschuldigung, das Formular ist momentan nicht erreichbar"), anstatt dass sich das Script lautlos in der Console mit einem CORS/Network-Error verabschiedet.
5. CI/CD Pipeline (Deployment)
Wenn der Code fertig ist, willst du ihn sicher nicht immer händisch per git pull auf deinen Produktionsserver schieben und dort docker compose up tippen.

Todo: Eine simple Pipeline (Github Actions / Gitea Actions) anlegen, die den Code testet und automatisch auf dem Zielserver ausrollt.
2026-09-29 05:16:01 +02:00

817 lines
27 KiB
Python

# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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/<id> - View/edit tenant
GET /admin/forms/ - List all forms
GET /admin/forms/<id> - View/edit form
GET /admin/forms/create - Create new form
GET /admin/submissions/ - List submissions
GET /admin/submissions/<id> - View submission details
GET /admin/users/ - List users
GET /admin/users/<id> - 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/<tenant_id>', 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/<tenant_id>/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/<form_id>', 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/<form_id>/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/<submission_id>')
@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/<submission_id>/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/<user_id>', 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/<user_id>/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/<user_id>/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/<user_id>/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))