Files
customer-portal/customer_portal/web/routes/admin.py

2080 lines
69 KiB
Python
Executable File

"""Admin routes with separate authentication.
Admin users have their own login, independent of customer accounts.
"""
import contextlib
from datetime import UTC, datetime
from functools import wraps
from flask import (
Blueprint,
flash,
g,
redirect,
render_template,
request,
session,
url_for,
)
from customer_portal.models import get_db
from customer_portal.models.admin_user import AdminUser
from customer_portal.models.settings import (
DEFAULT_ADMIN_CUSTOMER_VIEW,
DEFAULT_BRANDING_CONFIG,
DEFAULT_FIELD_CONFIG,
PortalSettings,
)
bp = Blueprint("admin", __name__)
def admin_required(f):
"""Decorator to require admin authentication."""
@wraps(f)
def decorated_function(*args, **kwargs):
admin_id = session.get("admin_user_id")
if not admin_id:
return redirect(url_for("admin.login"))
db = get_db()
admin = (
db.query(AdminUser)
.filter(AdminUser.id == admin_id, AdminUser.is_active == True) # noqa: E712
.first()
)
if not admin:
session.pop("admin_user_id", None)
flash("Admin-Sitzung abgelaufen. Bitte erneut anmelden.", "error")
return redirect(url_for("admin.login"))
g.admin_user = admin
return f(*args, **kwargs)
return decorated_function
@bp.route("/login", methods=["GET", "POST"])
def login():
"""Admin login page."""
# Already logged in as admin?
if session.get("admin_user_id"):
return redirect(url_for("admin.index"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or not password:
flash("Bitte Benutzername und Passwort eingeben.", "error")
return render_template("admin/login.html")
db = get_db()
admin = AdminUser.authenticate(db, username, password)
if admin:
session["admin_user_id"] = admin.id
admin.last_login_at = datetime.now(UTC)
db.commit()
flash(f"Willkommen, {admin.name}!", "success")
return redirect(url_for("admin.index"))
else:
flash("Ungueltige Anmeldedaten.", "error")
return render_template("admin/login.html")
@bp.route("/logout")
def logout():
"""Admin logout."""
session.pop("admin_user_id", None)
flash("Sie wurden abgemeldet.", "info")
return redirect(url_for("admin.login"))
@bp.route("/")
@admin_required
def index():
"""Admin dashboard."""
db = get_db()
from customer_portal.models.customer import Customer
# Get some stats
total_customers = db.query(Customer).count()
admin_count = (
db.query(AdminUser).filter(AdminUser.is_active == True).count()
) # noqa: E712
return render_template(
"admin/index.html",
total_customers=total_customers,
admin_count=admin_count,
)
@bp.route("/field-config", methods=["GET", "POST"])
@admin_required
def field_config():
"""Configure which fields are visible/editable for customers."""
from customer_portal.services.wordpress_api import WordPressAPI
db = get_db()
# Fetch WordPress booking fields
wp_schema = None
wp_error = None
try:
wp_schema = WordPressAPI.get_schema()
except ValueError as e:
wp_error = str(e)
except Exception as e:
wp_error = f"WordPress nicht erreichbar: {e}"
# Sprint 12: Legacy MEC fields removed - all fields now come from:
# - Standard fields (name, email, phone, address)
# - WordPress schema (custom booking fields)
# - Dynamic custom_fields in database
if request.method == "POST":
# Build config from form data
config = {
"profile_fields": {},
"sections": {},
"wp_fields": {}, # WordPress booking fields config
"custom_fields_visible": request.form.get("custom_fields_visible") == "on",
"sync_button_visible": request.form.get("sync_button_visible") == "on",
}
# Process profile fields
for field in [
"name",
"email",
"phone",
"address_street",
"address_zip",
"address_city",
]:
config["profile_fields"][field] = {
"visible": request.form.get(f"field_{field}_visible") == "on",
"editable": request.form.get(f"field_{field}_editable") == "on",
"label": request.form.get(
f"field_{field}_label", field.replace("_", " ").title()
),
}
# Process sections
for section in ["contact", "address", "email_settings", "account_info"]:
config["sections"][section] = {
"visible": request.form.get(f"section_{section}_visible") == "on",
"label": request.form.get(
f"section_{section}_label", section.replace("_", " ").title()
),
}
# Process WordPress booking fields (new schema)
if wp_schema and wp_schema.get("custom_fields"):
for field in wp_schema["custom_fields"]:
field_id = (
field.get("name")
or field.get("id")
or field.get("label", "").lower().replace(" ", "_")
)
if field_id:
config["wp_fields"][field_id] = {
"visible": request.form.get(f"wp_{field_id}_visible") == "on",
"label": request.form.get(
f"wp_{field_id}_label", field.get("label", field_id)
),
}
# Sprint 12: Legacy MEC fields processing removed
PortalSettings.save_field_config(db, config)
flash("Feld-Konfiguration erfolgreich gespeichert.", "success")
return redirect(url_for("admin.field_config"))
# Get current config
config = PortalSettings.get_field_config(db)
return render_template(
"admin/field_config.html",
config=config,
default_config=DEFAULT_FIELD_CONFIG,
wp_schema=wp_schema,
wp_error=wp_error,
)
@bp.route("/customers")
@admin_required
def customers():
"""List all customers with search and filter."""
db = get_db()
from customer_portal.models.customer import Customer
# Search query
search = request.args.get("search", "").strip()
query = db.query(Customer)
if search:
search_pattern = f"%{search}%"
query = query.filter(
(Customer.name.ilike(search_pattern))
| (Customer.email.ilike(search_pattern))
| (Customer.phone.ilike(search_pattern))
)
customers = query.order_by(Customer.created_at.desc()).all()
return render_template("admin/customers.html", customers=customers, search=search)
@bp.route("/customers/<int:customer_id>")
@admin_required
def customer_detail(customer_id: int):
"""View customer details."""
db = get_db()
from customer_portal.models.customer import Customer
from customer_portal.services.wordpress_api import WordPressAPI
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
flash("Kunde nicht gefunden.", "error")
return redirect(url_for("admin.customers"))
# Get field config for wp_fields labels and visibility
field_config = PortalSettings.get_field_config(db)
# Get admin customer view config (labels, hidden fields)
customer_view_config = PortalSettings.get_admin_customer_view(db)
# Get WordPress schema for field definitions
wp_schema = None
try:
wp_schema = WordPressAPI.get_schema()
except Exception:
pass # WordPress not reachable, use fallback
return render_template(
"admin/customer_detail.html",
customer=customer,
field_config=field_config,
customer_view_config=customer_view_config,
wp_schema=wp_schema,
)
@bp.route("/customers/<int:customer_id>/edit", methods=["GET", "POST"])
@admin_required
def customer_edit(customer_id: int):
"""Edit customer data.
Sprint 12: All customer data is stored in custom_fields JSON.
Only email remains as fixed identifier column.
"""
db = get_db()
from customer_portal.models.customer import Customer
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
flash("Kunde nicht gefunden.", "error")
return redirect(url_for("admin.customers"))
# Get field config for labels
field_config = PortalSettings.get_field_config(db)
if request.method == "POST":
# Sprint 12: All data goes to custom_fields
fields = customer.get_custom_fields()
# Core fields -> custom_fields
name = request.form.get("name", "").strip()
if name:
fields["name"] = name
phone = request.form.get("phone", "").strip()
if phone:
fields["phone"] = phone
address_street = request.form.get("address_street", "").strip()
if address_street:
fields["address_street"] = address_street
address_zip = request.form.get("address_zip", "").strip()
if address_zip:
fields["address_zip"] = address_zip
address_city = request.form.get("address_city", "").strip()
if address_city:
fields["address_city"] = address_city
# Update any other custom fields from form
for key in list(fields.keys()):
form_key = f"custom_{key}"
if form_key in request.form:
fields[key] = request.form.get(form_key, "").strip()
customer.set_custom_fields(fields)
# Email preferences (these stay as DB columns - portal-specific)
customer.email_notifications = request.form.get("email_notifications") == "on"
customer.email_reminders = request.form.get("email_reminders") == "on"
customer.email_invoices = request.form.get("email_invoices") == "on"
customer.email_marketing = request.form.get("email_marketing") == "on"
db.commit()
flash(f"Kunde '{customer.display_name}' erfolgreich aktualisiert.", "success")
return redirect(url_for("admin.customer_detail", customer_id=customer.id))
return render_template(
"admin/customer_edit.html",
customer=customer,
field_config=field_config,
)
@bp.route("/customers/<int:customer_id>/delete", methods=["POST"])
@admin_required
def customer_delete(customer_id: int):
"""Delete a customer."""
db = get_db()
from customer_portal.models.customer import Customer
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
flash("Kunde nicht gefunden.", "error")
return redirect(url_for("admin.customers"))
email = customer.email
name = customer.display_name
# Delete related records first
from customer_portal.models.otp_code import OTPCode
from customer_portal.models.session import Session
db.query(OTPCode).filter(OTPCode.customer_id == customer_id).delete()
db.query(Session).filter(Session.customer_id == customer_id).delete()
db.delete(customer)
db.commit()
flash(f"Kunde '{name}' ({email}) wurde geloescht.", "success")
return redirect(url_for("admin.customers"))
@bp.route("/admins")
@admin_required
def admins():
"""List all admin users."""
db = get_db()
admin_users = db.query(AdminUser).order_by(AdminUser.created_at.desc()).all()
return render_template("admin/admins.html", admin_users=admin_users)
@bp.route("/admins/create", methods=["GET", "POST"])
@admin_required
def create_admin():
"""Create a new admin user."""
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
password_confirm = request.form.get("password_confirm", "")
name = request.form.get("name", "").strip()
email = request.form.get("email", "").strip()
# Validation
errors = []
if not username:
errors.append("Benutzername ist erforderlich.")
if not password:
errors.append("Passwort ist erforderlich.")
if len(password) < 8:
errors.append("Passwort muss mindestens 8 Zeichen haben.")
if password != password_confirm:
errors.append("Passwoerter stimmen nicht ueberein.")
if not name:
errors.append("Name ist erforderlich.")
db = get_db()
if db.query(AdminUser).filter(AdminUser.username == username).first():
errors.append("Benutzername bereits vergeben.")
if errors:
for error in errors:
flash(error, "error")
return render_template("admin/create_admin.html")
# Create admin
admin = AdminUser(
username=username,
name=name,
email=email if email else None,
)
admin.set_password(password)
db.add(admin)
db.commit()
flash(f"Admin '{username}' erfolgreich erstellt.", "success")
return redirect(url_for("admin.admins"))
return render_template("admin/create_admin.html")
@bp.route("/admins/<int:admin_id>/toggle", methods=["POST"])
@admin_required
def toggle_admin_status(admin_id: int):
"""Toggle admin active status."""
db = get_db()
admin = db.query(AdminUser).filter(AdminUser.id == admin_id).first()
if not admin:
flash("Admin nicht gefunden.", "error")
return redirect(url_for("admin.admins"))
# Don't allow deactivating yourself
if admin.id == g.admin_user.id:
flash("Sie koennen sich nicht selbst deaktivieren.", "error")
return redirect(url_for("admin.admins"))
admin.is_active = not admin.is_active
db.commit()
status = "aktiviert" if admin.is_active else "deaktiviert"
flash(f"Admin '{admin.username}' wurde {status}.", "success")
return redirect(url_for("admin.admins"))
# =============================================================================
# Settings Routes (Phase 2)
# =============================================================================
@bp.route("/settings/mail", methods=["GET", "POST"])
@admin_required
def settings_mail():
"""Configure mail server settings."""
db = get_db()
if request.method == "POST":
# Get current config to preserve password if not changed
current = PortalSettings.get_mail_config(db)
config = {
"mail_server": request.form.get("mail_server", "").strip(),
"mail_port": int(request.form.get("mail_port", 587)),
"mail_use_tls": request.form.get("mail_use_tls") == "on",
"mail_use_ssl": request.form.get("mail_use_ssl") == "on",
"mail_username": request.form.get("mail_username", "").strip(),
"mail_default_sender": request.form.get("mail_default_sender", "").strip(),
"mail_default_sender_name": request.form.get(
"mail_default_sender_name", "Kundenportal"
).strip(),
}
# Only update password if provided
new_password = request.form.get("mail_password", "")
if new_password:
config["mail_password"] = new_password
else:
config["mail_password"] = current.get("mail_password", "")
PortalSettings.save_mail_config(db, config)
flash("Mail-Konfiguration gespeichert.", "success")
return redirect(url_for("admin.settings_mail"))
config = PortalSettings.get_mail_config(db)
return render_template("admin/settings_mail.html", config=config)
@bp.route("/settings/mail/test", methods=["POST"])
@admin_required
def settings_mail_test():
"""Send a test email."""
import smtplib
from email.mime.text import MIMEText
db = get_db()
config = PortalSettings.get_mail_config(db)
if not config.get("mail_server"):
flash("Mail-Server nicht konfiguriert.", "error")
return redirect(url_for("admin.settings_mail"))
test_email = request.form.get("test_email", "").strip()
if not test_email:
flash("Bitte Test-E-Mail-Adresse eingeben.", "error")
return redirect(url_for("admin.settings_mail"))
try:
msg = MIMEText(
"Dies ist eine Test-E-Mail vom Kundenportal.\n\nWenn Sie diese Nachricht erhalten, funktioniert der Mail-Server korrekt."
)
msg["Subject"] = "Kundenportal - Test-E-Mail"
msg["From"] = (
f"{config.get('mail_default_sender_name', 'Kundenportal')} <{config.get('mail_default_sender', '')}>"
)
msg["To"] = test_email
if config.get("mail_use_ssl"):
server = smtplib.SMTP_SSL(config["mail_server"], config["mail_port"])
else:
server = smtplib.SMTP(config["mail_server"], config["mail_port"])
if config.get("mail_use_tls"):
server.starttls()
if config.get("mail_username") and config.get("mail_password"):
server.login(config["mail_username"], config["mail_password"])
server.sendmail(
config.get("mail_default_sender", ""), [test_email], msg.as_string()
)
server.quit()
flash(f"Test-E-Mail an {test_email} gesendet.", "success")
except Exception as e:
flash(f"Fehler beim Senden: {e}", "error")
return redirect(url_for("admin.settings_mail"))
@bp.route("/settings/otp", methods=["GET", "POST"])
@admin_required
def settings_otp():
"""Configure OTP and security settings."""
db = get_db()
if request.method == "POST":
config = {
"otp_expiry_minutes": int(request.form.get("otp_expiry_minutes", 10)),
"otp_length": int(request.form.get("otp_length", 6)),
"otp_max_attempts": int(request.form.get("otp_max_attempts", 3)),
"prefill_token_expiry": int(request.form.get("prefill_token_expiry", 300)),
}
# Validate ranges
config["otp_expiry_minutes"] = max(1, min(60, config["otp_expiry_minutes"]))
config["otp_length"] = max(4, min(8, config["otp_length"]))
config["otp_max_attempts"] = max(1, min(10, config["otp_max_attempts"]))
config["prefill_token_expiry"] = max(
60, min(3600, config["prefill_token_expiry"])
)
PortalSettings.save_otp_config(db, config)
flash("OTP-Einstellungen gespeichert.", "success")
return redirect(url_for("admin.settings_otp"))
config = PortalSettings.get_otp_config(db)
return render_template("admin/settings_otp.html", config=config)
@bp.route("/settings/wordpress", methods=["GET", "POST"])
@admin_required
def settings_wordpress():
"""Configure WordPress integration settings."""
db = get_db()
if request.method == "POST":
config = {
"wp_api_url": request.form.get("wp_api_url", "").strip(),
"wp_api_secret": request.form.get("wp_api_secret", "").strip(),
"wp_booking_page_url": request.form.get("wp_booking_page_url", "").strip(),
}
PortalSettings.save_wordpress_config(db, config)
flash("WordPress-Einstellungen gespeichert.", "success")
return redirect(url_for("admin.settings_wordpress"))
config = PortalSettings.get_wordpress_config(db)
return render_template("admin/settings_wordpress.html", config=config)
@bp.route("/settings/wordpress/test", methods=["POST"])
@admin_required
def settings_wordpress_test():
"""Test WordPress API connection."""
import hashlib
import time
import requests
db = get_db()
config = PortalSettings.get_wordpress_config(db)
if not config.get("wp_api_url"):
flash("WordPress API URL nicht konfiguriert.", "error")
return redirect(url_for("admin.settings_wordpress"))
try:
# Build test request
api_url = config["wp_api_url"].rstrip("/")
test_url = f"{api_url}/test"
# Generate signature if secret is set
headers = {"Content-Type": "application/json"}
if config.get("wp_api_secret"):
timestamp = str(int(time.time()))
signature = hashlib.sha256(
f"{timestamp}{config['wp_api_secret']}".encode()
).hexdigest()
headers["X-Portal-Timestamp"] = timestamp
headers["X-Portal-Signature"] = signature
response = requests.get(test_url, headers=headers, timeout=10)
if response.status_code == 200:
flash("WordPress-Verbindung erfolgreich!", "success")
else:
flash(f"WordPress antwortet mit Status {response.status_code}", "warning")
except requests.exceptions.ConnectionError:
flash("Verbindung zu WordPress fehlgeschlagen. URL pruefen.", "error")
except requests.exceptions.Timeout:
flash("Zeitüberschreitung bei WordPress-Verbindung.", "error")
except Exception as e:
flash(f"Fehler: {e}", "error")
return redirect(url_for("admin.settings_wordpress"))
# =============================================================================
# Customer CSV Import/Export
# =============================================================================
@bp.route("/settings/csv", methods=["GET", "POST"])
@admin_required
def settings_csv():
"""Configure CSV export/import fields."""
db = get_db()
if request.method == "POST":
# Build config from form
export_fields = []
for field in PortalSettings.get_csv_config(db)["export_fields"]:
key = field["key"]
export_fields.append(
{
"key": key,
"label": request.form.get(f"label_{key}", field["label"]),
"enabled": request.form.get(f"enabled_{key}") == "on",
}
)
config = {
"export_fields": export_fields,
"include_custom_fields": request.form.get("include_custom_fields") == "on",
"delimiter": request.form.get("delimiter", ";"),
}
PortalSettings.save_csv_config(db, config)
flash("CSV-Konfiguration gespeichert.", "success")
return redirect(url_for("admin.settings_csv"))
config = PortalSettings.get_csv_config(db)
return render_template("admin/settings_csv.html", config=config)
@bp.route("/settings/customer-defaults", methods=["GET", "POST"])
@admin_required
def settings_customer_defaults():
"""Configure default email preferences for new customers."""
from customer_portal.models.customer import Customer
db = get_db()
if request.method == "POST":
config = {
"email_notifications": request.form.get("email_notifications") == "1",
"email_reminders": request.form.get("email_reminders") == "1",
"email_invoices": request.form.get("email_invoices") == "1",
"email_marketing": request.form.get("email_marketing") == "1",
}
PortalSettings.save_customer_defaults(db, config)
# Apply defaults to customers with NULL values (preserve existing preferences)
customers = db.query(Customer).all()
updated_count = 0
for customer in customers:
changed = False
if customer.email_notifications is None:
customer.email_notifications = config["email_notifications"]
changed = True
if customer.email_reminders is None:
customer.email_reminders = config["email_reminders"]
changed = True
if customer.email_invoices is None:
customer.email_invoices = config["email_invoices"]
changed = True
if customer.email_marketing is None:
customer.email_marketing = config["email_marketing"]
changed = True
if changed:
updated_count += 1
db.commit()
if updated_count > 0:
flash(
f"Gespeichert. {updated_count} Kunden mit fehlenden Werten aktualisiert.",
"success",
)
else:
flash("Kunden-Standardwerte gespeichert.", "success")
return redirect(url_for("admin.settings_customer_defaults"))
config = PortalSettings.get_customer_defaults(db)
# Count customers with NULL values (missing preferences)
null_count = (
db.query(Customer)
.filter(
(Customer.email_notifications == None) # noqa: E711
| (Customer.email_reminders == None) # noqa: E711
| (Customer.email_invoices == None) # noqa: E711
| (Customer.email_marketing == None) # noqa: E711
)
.count()
)
return render_template(
"admin/settings_customer_defaults.html",
config=config,
null_count=null_count,
)
# =============================================================================
# Branding Settings
# =============================================================================
@bp.route("/settings/branding", methods=["GET", "POST"])
@admin_required
def settings_branding():
"""Configure portal branding (logo, colors, company name)."""
db = get_db()
if request.method == "POST":
config = {
"company_name": request.form.get("company_name", "Kundenportal").strip(),
"logo_url": request.form.get("logo_url", "").strip(),
"favicon_url": request.form.get("favicon_url", "").strip(),
"colors": {
"primary": request.form.get("color_primary", "#198754").strip(),
"primary_hover": request.form.get(
"color_primary_hover", "#157347"
).strip(),
"background": request.form.get("color_background", "#1a1d21").strip(),
"header_bg": request.form.get("color_header_bg", "#212529").strip(),
"sidebar_bg": request.form.get("color_sidebar_bg", "#1a1d21").strip(),
"text": request.form.get("color_text", "#f8f9fa").strip(),
"muted": request.form.get("color_muted", "#6c757d").strip(),
"border": request.form.get("color_border", "#2d3238").strip(),
},
}
PortalSettings.save_branding(db, config)
flash("Branding-Einstellungen gespeichert.", "success")
return redirect(url_for("admin.settings_branding"))
config = PortalSettings.get_branding(db)
return render_template(
"admin/settings_branding.html",
config=config,
default_config=DEFAULT_BRANDING_CONFIG,
)
@bp.route("/settings/branding/reset", methods=["POST"])
@admin_required
def settings_branding_reset():
"""Reset branding to default values."""
db = get_db()
PortalSettings.save_branding(db, DEFAULT_BRANDING_CONFIG)
flash("Branding auf Standardwerte zurueckgesetzt.", "success")
return redirect(url_for("admin.settings_branding"))
@bp.route("/settings/branding/upload-logo", methods=["POST"])
@admin_required
def settings_branding_upload_logo():
"""Upload a logo file."""
import os
import uuid
from flask import current_app
from werkzeug.utils import secure_filename
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "svg", "webp"}
MAX_FILE_SIZE = 500 * 1024 # 500KB
def allowed_file(filename: str) -> bool:
return (
"." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
)
if "logo_file" not in request.files:
flash("Keine Datei ausgewaehlt.", "error")
return redirect(url_for("admin.settings_branding"))
file = request.files["logo_file"]
if file.filename == "":
flash("Keine Datei ausgewaehlt.", "error")
return redirect(url_for("admin.settings_branding"))
if not allowed_file(file.filename):
flash("Ungueliges Dateiformat. Erlaubt: PNG, JPG, GIF, SVG, WebP.", "error")
return redirect(url_for("admin.settings_branding"))
# Check file size
file.seek(0, 2) # Seek to end
file_size = file.tell()
file.seek(0) # Reset to start
if file_size > MAX_FILE_SIZE:
flash(f"Datei zu gross. Maximal {MAX_FILE_SIZE // 1024}KB erlaubt.", "error")
return redirect(url_for("admin.settings_branding"))
# Generate unique filename
ext = file.filename.rsplit(".", 1)[1].lower()
filename = f"logo_{uuid.uuid4().hex[:8]}.{ext}"
filename = secure_filename(filename)
# Save file
upload_dir = os.path.join(current_app.static_folder, "uploads")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, filename)
file.save(file_path)
# Update branding config with new logo URL
db = get_db()
config = PortalSettings.get_branding(db)
config["logo_url"] = url_for("static", filename=f"uploads/{filename}")
PortalSettings.save_branding(db, config)
flash("Logo erfolgreich hochgeladen.", "success")
return redirect(url_for("admin.settings_branding"))
@bp.route("/settings/branding/upload-favicon", methods=["POST"])
@admin_required
def settings_branding_upload_favicon():
"""Upload a favicon file."""
import os
import uuid
from flask import current_app
from werkzeug.utils import secure_filename
ALLOWED_EXTENSIONS = {"ico", "png", "svg"}
MAX_FILE_SIZE = 100 * 1024 # 100KB
def allowed_file(filename: str) -> bool:
return (
"." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
)
if "favicon_file" not in request.files:
flash("Keine Datei ausgewaehlt.", "error")
return redirect(url_for("admin.settings_branding"))
file = request.files["favicon_file"]
if file.filename == "":
flash("Keine Datei ausgewaehlt.", "error")
return redirect(url_for("admin.settings_branding"))
if not allowed_file(file.filename):
flash("Ungueliges Dateiformat. Erlaubt: ICO, PNG, SVG.", "error")
return redirect(url_for("admin.settings_branding"))
# Check file size
file.seek(0, 2)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
flash(f"Datei zu gross. Maximal {MAX_FILE_SIZE // 1024}KB erlaubt.", "error")
return redirect(url_for("admin.settings_branding"))
# Generate unique filename
ext = file.filename.rsplit(".", 1)[1].lower()
filename = f"favicon_{uuid.uuid4().hex[:8]}.{ext}"
filename = secure_filename(filename)
# Save file
upload_dir = os.path.join(current_app.static_folder, "uploads")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, filename)
file.save(file_path)
# Update branding config
db = get_db()
config = PortalSettings.get_branding(db)
config["favicon_url"] = url_for("static", filename=f"uploads/{filename}")
PortalSettings.save_branding(db, config)
flash("Favicon erfolgreich hochgeladen.", "success")
return redirect(url_for("admin.settings_branding"))
# =============================================================================
# Admin Customer View Configuration (Field Labels, Visibility)
# =============================================================================
@bp.route("/settings/customer-view", methods=["GET", "POST"])
@admin_required
def settings_customer_view():
"""Configure how customer details are displayed in admin."""
from customer_portal.models.customer import Customer
db = get_db()
# Auto-discover all custom_fields from existing customers
all_field_names = set()
customers = db.query(Customer).all()
for customer in customers:
custom_fields = customer.get_custom_fields()
if custom_fields:
all_field_names.update(custom_fields.keys())
# Sort fields alphabetically
discovered_fields = sorted(all_field_names)
if request.method == "POST":
field_labels = {}
hidden_fields = []
for field_name in discovered_fields:
# Get custom label (if set)
label = request.form.get(f"label_{field_name}", "").strip()
if label:
field_labels[field_name] = label
# Check if field should be hidden
if request.form.get(f"hidden_{field_name}") == "on":
hidden_fields.append(field_name)
config = {
"field_labels": field_labels,
"hidden_fields": hidden_fields,
"field_order": [], # Could be extended with drag-drop later
"contact_fields": DEFAULT_ADMIN_CUSTOMER_VIEW["contact_fields"],
"sections": DEFAULT_ADMIN_CUSTOMER_VIEW["sections"],
}
PortalSettings.save_admin_customer_view(db, config)
flash("Kundenansicht-Einstellungen gespeichert.", "success")
return redirect(url_for("admin.settings_customer_view"))
config = PortalSettings.get_admin_customer_view(db)
return render_template(
"admin/settings_customer_view.html",
config=config,
discovered_fields=discovered_fields,
default_config=DEFAULT_ADMIN_CUSTOMER_VIEW,
)
# =============================================================================
# Field Mapping & Sync Routes
# =============================================================================
@bp.route("/settings/field-mapping", methods=["GET", "POST"])
@admin_required
def settings_field_mapping():
"""Configure WordPress to Portal field mapping."""
from customer_portal.models.settings import DEFAULT_FIELD_MAPPING
from customer_portal.services.wordpress_api import WordPressAPI
db = get_db()
# Get WordPress schema for available fields
wp_schema = None
wp_error = None
try:
wp_schema = WordPressAPI.get_schema()
except Exception as e:
wp_error = str(e)
# Portal target fields
portal_db_fields = [
{"key": "db.name", "label": "Name (DB)"},
{"key": "db.phone", "label": "Telefon (DB)"},
{"key": "db.address_street", "label": "Strasse (DB)"},
{"key": "db.address_zip", "label": "PLZ (DB)"},
{"key": "db.address_city", "label": "Ort (DB)"},
]
if request.method == "POST":
mappings = {}
# Process portal fields (db.* and custom.*)
portal_fields = [
"db.phone",
"db.address_street",
"db.address_zip",
"db.address_city",
"custom.vorname",
"custom.nachname",
"custom.geburtsdatum",
"custom.pferdename",
"custom.geschlecht_pferd",
]
for portal_field in portal_fields:
wp_field = request.form.get(f"map_{portal_field}", "")
if wp_field:
# Store as portal_field -> wp_field mapping
mappings[portal_field] = wp_field
# Save field order if provided
field_order = request.form.get("field_order", "")
config = {
"mappings": mappings,
"auto_sync_on_booking": request.form.get("auto_sync_on_booking") == "on",
"field_order": field_order,
}
PortalSettings.save_field_mapping(db, config)
flash("Feld-Mapping gespeichert.", "success")
return redirect(url_for("admin.settings_field_mapping"))
config = PortalSettings.get_field_mapping(db)
return render_template(
"admin/settings_field_mapping.html",
config=config,
wp_schema=wp_schema,
wp_error=wp_error,
portal_db_fields=portal_db_fields,
default_mapping=DEFAULT_FIELD_MAPPING,
)
@bp.route("/customers/<int:customer_id>/sync", methods=["POST"])
@admin_required
def customer_sync(customer_id: int):
"""Sync customer data from their latest WordPress booking."""
from customer_portal.models.customer import Customer
from customer_portal.services.wordpress_api import WordPressAPI
db = get_db()
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
flash("Kunde nicht gefunden.", "error")
return redirect(url_for("admin.customers"))
# Get field mapping
mapping_config = PortalSettings.get_field_mapping(db)
mappings = mapping_config.get("mappings", {})
# Fetch bookings from WordPress
try:
bookings = WordPressAPI.get_bookings(customer.email)
except Exception as e:
flash(f"WordPress nicht erreichbar: {e}", "error")
return redirect(url_for("admin.customer_detail", customer_id=customer_id))
if not bookings:
flash("Keine Buchungen fuer diesen Kunden gefunden.", "warning")
return redirect(url_for("admin.customer_detail", customer_id=customer_id))
# Use most recent booking
latest_booking = bookings[0]
synced_fields = []
# Apply mapping from booking custom_fields
booking_fields = latest_booking.get("custom_fields", {})
custom_fields = customer.get_custom_fields()
# Mapping format: portal_field -> wp_field
for portal_target, wp_field in mappings.items():
# Skip empty mappings
if not wp_field:
continue
if wp_field in booking_fields:
value = booking_fields[wp_field]
if not value:
continue
if portal_target.startswith("db."):
# Sprint 12: All db.* fields now go to custom_fields
db_field = portal_target[3:]
if db_field not in custom_fields:
custom_fields[db_field] = value
synced_fields.append(f"{db_field}: {value}")
elif portal_target.startswith("custom."):
# Map to custom field
custom_key = portal_target[7:]
if custom_key not in custom_fields:
custom_fields[custom_key] = value
synced_fields.append(f"{custom_key}: {value}")
customer.set_custom_fields(custom_fields)
db.commit()
if synced_fields:
flash(f"Synchronisiert: {', '.join(synced_fields)}", "success")
else:
flash("Keine neuen Felder zu synchronisieren.", "info")
return redirect(url_for("admin.customer_detail", customer_id=customer_id))
@bp.route("/customers/sync-all", methods=["POST"])
@admin_required
def customers_sync_all():
"""Sync all customers from their WordPress bookings."""
from customer_portal.models.customer import Customer
from customer_portal.services.wordpress_api import WordPressAPI
db = get_db()
mapping_config = PortalSettings.get_field_mapping(db)
mappings = mapping_config.get("mappings", {})
customers = db.query(Customer).all()
synced_count = 0
error_count = 0
for customer in customers:
try:
bookings = WordPressAPI.get_bookings(customer.email)
if not bookings:
continue
latest_booking = bookings[0]
booking_fields = latest_booking.get("custom_fields", {})
custom_fields = customer.get_custom_fields()
changed = False
# Mapping format: portal_field -> wp_field
for portal_target, wp_field in mappings.items():
# Skip empty mappings
if not wp_field:
continue
if wp_field in booking_fields:
value = booking_fields[wp_field]
if not value:
continue
if portal_target.startswith("db."):
# Sprint 12: All db.* fields now go to custom_fields
db_field = portal_target[3:]
if db_field not in custom_fields:
custom_fields[db_field] = value
changed = True
elif portal_target.startswith("custom."):
custom_key = portal_target[7:]
if custom_key not in custom_fields:
custom_fields[custom_key] = value
changed = True
if changed:
customer.set_custom_fields(custom_fields)
synced_count += 1
except Exception:
error_count += 1
db.commit()
if synced_count > 0:
flash(f"{synced_count} Kunden synchronisiert.", "success")
if error_count > 0:
flash(f"{error_count} Fehler bei der Synchronisation.", "warning")
if synced_count == 0 and error_count == 0:
flash("Keine Kunden zu synchronisieren.", "info")
return redirect(url_for("admin.customers"))
def get_customer_field_value(customer, key: str) -> str:
"""Get field value from customer by key.
Sprint 12: Uses display properties for name/phone/address from custom_fields.
"""
addr = customer.display_address
field_map = {
"id": lambda c: c.id,
"email": lambda c: c.email,
"name": lambda c: c.display_name,
"phone": lambda c: c.display_phone,
"address_street": lambda c: addr.get("street", ""),
"address_zip": lambda c: addr.get("zip", ""),
"address_city": lambda c: addr.get("city", ""),
"email_notifications": lambda c: "Ja" if c.email_notifications else "Nein",
"email_reminders": lambda c: "Ja" if c.email_reminders else "Nein",
"email_invoices": lambda c: "Ja" if c.email_invoices else "Nein",
"email_marketing": lambda c: "Ja" if c.email_marketing else "Nein",
"created_at": lambda c: (
c.created_at.strftime("%d.%m.%Y %H:%M") if c.created_at else ""
),
"updated_at": lambda c: (
c.updated_at.strftime("%d.%m.%Y %H:%M") if c.updated_at else ""
),
}
getter = field_map.get(key)
return getter(customer) if getter else ""
@bp.route("/customers/export")
@admin_required
def customers_export():
"""Export all customers as CSV based on configuration."""
import csv
import io
import json
from flask import Response
db = get_db()
from customer_portal.models.customer import Customer
# Get CSV config
csv_config = PortalSettings.get_csv_config(db)
enabled_fields = [f for f in csv_config["export_fields"] if f["enabled"]]
delimiter = csv_config.get("delimiter", ";")
include_custom = csv_config.get("include_custom_fields", False)
customers = db.query(Customer).order_by(Customer.created_at.desc()).all()
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output, delimiter=delimiter, quoting=csv.QUOTE_ALL)
# Header row from enabled fields
headers = [f["label"] for f in enabled_fields]
if include_custom:
headers.append("Zusatzfelder (JSON)")
writer.writerow(headers)
# Data rows
for c in customers:
row = [get_customer_field_value(c, f["key"]) for f in enabled_fields]
if include_custom:
custom = c.get_custom_fields()
row.append(json.dumps(custom, ensure_ascii=False) if custom else "")
writer.writerow(row)
# Create response with BOM for Excel
csv_content = "\ufeff" + output.getvalue()
return Response(
csv_content,
mimetype="text/csv",
headers={
"Content-Disposition": "attachment; filename=kunden_export.csv",
"Content-Type": "text/csv; charset=utf-8",
},
)
def set_customer_field_value(customer, key: str, value: str) -> None:
"""Set field value on customer by key.
Sprint 12: Writes name/phone/address to custom_fields.
"""
def parse_bool(val):
return val.lower() in ("ja", "yes", "1", "true", "x") if val else False
def set_custom_field(c, field_key, val):
fields = c.get_custom_fields()
fields[field_key] = val
c.set_custom_fields(fields)
setters = {
"name": lambda c, v: set_custom_field(c, "name", v[:255] if v else ""),
"phone": lambda c, v: set_custom_field(c, "phone", v[:50] if v else ""),
"address_street": lambda c, v: set_custom_field(
c, "address_street", v[:255] if v else ""
),
"address_zip": lambda c, v: set_custom_field(
c, "address_zip", v[:20] if v else ""
),
"address_city": lambda c, v: set_custom_field(
c, "address_city", v[:255] if v else ""
),
"email_notifications": lambda c, v: setattr(
c, "email_notifications", parse_bool(v)
),
"email_reminders": lambda c, v: setattr(c, "email_reminders", parse_bool(v)),
"email_invoices": lambda c, v: setattr(c, "email_invoices", parse_bool(v)),
"email_marketing": lambda c, v: setattr(c, "email_marketing", parse_bool(v)),
}
setter = setters.get(key)
if setter and value.strip():
setter(customer, value.strip())
@bp.route("/customers/import", methods=["GET", "POST"])
@admin_required
def customers_import():
"""Import customers from CSV using configured field labels."""
import csv
import io
db = get_db()
# Get CSV config for label-to-key mapping
csv_config = PortalSettings.get_csv_config(db)
delimiter = csv_config.get("delimiter", ";")
# Build label -> key mapping (case-insensitive)
label_to_key = {}
for field in csv_config["export_fields"]:
label_to_key[field["label"].lower()] = field["key"]
if request.method == "POST":
if "csv_file" not in request.files:
flash("Keine Datei ausgewaehlt.", "error")
return redirect(url_for("admin.customers_import"))
file = request.files["csv_file"]
if file.filename == "":
flash("Keine Datei ausgewaehlt.", "error")
return redirect(url_for("admin.customers_import"))
if not file.filename.endswith(".csv"):
flash("Nur CSV-Dateien erlaubt.", "error")
return redirect(url_for("admin.customers_import"))
try:
# Read and decode CSV
content = file.read().decode("utf-8-sig") # Handle BOM
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
from customer_portal.models.customer import Customer
imported = 0
updated = 0
errors = []
# Map CSV headers to keys
header_map = {}
for header in reader.fieldnames or []:
key = label_to_key.get(header.lower())
if key:
header_map[header] = key
# Find email column
email_header = None
for header, key in header_map.items():
if key == "email":
email_header = header
break
if not email_header:
flash(
"E-Mail-Spalte nicht gefunden. Bitte CSV-Konfiguration pruefen.",
"error",
)
return redirect(url_for("admin.customers_import"))
for row_num, row in enumerate(reader, start=2):
email = row.get(email_header, "").strip().lower()
if not email or "@" not in email:
errors.append(f"Zeile {row_num}: Ungueltige E-Mail")
continue
# Find name column
name_header = None
for header, key in header_map.items():
if key == "name":
name_header = header
break
name = row.get(name_header, "").strip() if name_header else ""
if not name:
errors.append(f"Zeile {row_num}: Name fehlt")
continue
# Check if customer exists
existing = db.query(Customer).filter(Customer.email == email).first()
if existing:
# Update existing customer
for header, key in header_map.items():
if key not in ("id", "email", "created_at", "updated_at"):
set_customer_field_value(existing, key, row.get(header, ""))
existing.updated_at = datetime.now(UTC)
updated += 1
else:
# Create new customer with defaults from settings
customer = Customer.create_with_defaults(
db,
email=email[:255],
name=name[:255],
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
for header, key in header_map.items():
if key not in (
"id",
"email",
"name",
"created_at",
"updated_at",
):
set_customer_field_value(customer, key, row.get(header, ""))
db.add(customer)
imported += 1
db.commit()
if errors:
flash(
f"Import mit Warnungen: {imported} neu, {updated} aktualisiert, {len(errors)} Fehler",
"warning",
)
for err in errors[:5]: # Show first 5 errors
flash(err, "error")
else:
flash(
f"Import erfolgreich: {imported} neu, {updated} aktualisiert",
"success",
)
return redirect(url_for("admin.customers"))
except Exception as e:
flash(f"Import fehlgeschlagen: {e}", "error")
return redirect(url_for("admin.customers_import"))
# GET: Show import form with current field configuration
return render_template(
"admin/customers_import.html",
csv_config=csv_config,
)
# =============================================================================
# Booking Sync (Sprint 14)
# =============================================================================
@bp.route("/bookings")
@admin_required
def bookings():
"""List all bookings in admin view with sorting, filtering, search and pagination."""
from sqlalchemy import func, or_
from customer_portal.models.booking import Booking
from customer_portal.models.customer import Customer
db = get_db()
# Get filter parameters
status_filter = request.args.get("status", "")
customer_filter = request.args.get("customer_id", "")
kurs_filter = request.args.get("kurs", "")
search_query = request.args.get("q", "").strip()
# Default to current year and month if no filters are set
now = datetime.now()
has_any_filter = any(
[
request.args.get("status"),
request.args.get("customer_id"),
request.args.get("kurs"),
request.args.get("q"),
request.args.get("year"),
request.args.get("month"),
]
)
# If no filters set, default to current month/year
if not has_any_filter and "page" not in request.args:
year_filter = str(now.year)
month_filter = str(now.month)
else:
year_filter = request.args.get("year", "", type=str)
month_filter = request.args.get("month", "", type=str)
# Sorting parameters
sort_by = request.args.get("sort", "date")
sort_dir = request.args.get("dir", "desc")
# Pagination
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 50, type=int)
per_page = min(per_page, 200) # Max 200 per page
# Build query
query = db.query(Booking).join(Customer)
# Apply filters
if status_filter:
query = query.filter(Booking.status == status_filter)
if customer_filter:
with contextlib.suppress(ValueError):
query = query.filter(Booking.customer_id == int(customer_filter))
if kurs_filter:
query = query.filter(Booking.kurs_title.ilike(f"%{kurs_filter}%"))
# Year filter
if year_filter:
try:
year_int = int(year_filter)
query = query.filter(func.extract("year", Booking.kurs_date) == year_int)
except ValueError:
pass
# Month filter
if month_filter:
try:
month_int = int(month_filter)
query = query.filter(func.extract("month", Booking.kurs_date) == month_int)
except ValueError:
pass
# Search in multiple fields
if search_query:
search_pattern = f"%{search_query}%"
query = query.filter(
or_(
Booking.booking_number.ilike(search_pattern),
Booking.customer_name.ilike(search_pattern),
Booking.customer_email.ilike(search_pattern),
Booking.kurs_title.ilike(search_pattern),
Customer.name.ilike(search_pattern),
Customer.email.ilike(search_pattern),
)
)
# Apply sorting
sort_columns = {
"date": Booking.kurs_date,
"booking_nr": Booking.booking_number,
"customer": Booking.customer_name, # Use booking's customer_name field
"kurs": Booking.kurs_title,
"price": Booking.total_price,
"status": Booking.status,
"created": Booking.created_at,
}
sort_column = sort_columns.get(sort_by, Booking.kurs_date)
if sort_dir == "asc":
query = query.order_by(sort_column.asc().nullslast())
else:
query = query.order_by(sort_column.desc().nullsfirst())
# Get total count for pagination (before limit)
total_filtered = query.count()
# Apply pagination
offset = (page - 1) * per_page
bookings_list = query.offset(offset).limit(per_page).all()
# Calculate pagination info
total_pages = (total_filtered + per_page - 1) // per_page
# Get stats (always total, not filtered)
total_bookings = db.query(Booking).count()
confirmed_count = db.query(Booking).filter(Booking.status == "confirmed").count()
pending_count = db.query(Booking).filter(Booking.status == "pending").count()
cancelled_count = db.query(Booking).filter(Booking.status == "cancelled").count()
# Revenue stats
from sqlalchemy import func as sqlfunc
total_revenue = (
db.query(sqlfunc.coalesce(sqlfunc.sum(Booking.total_price), 0))
.filter(Booking.status == "confirmed")
.scalar()
or 0
)
# This month stats
this_month_start = datetime(now.year, now.month, 1)
this_month_bookings = (
db.query(Booking).filter(Booking.created_at >= this_month_start).count()
)
# Bookings per month for chart (last 12 months)
bookings_per_month = []
for i in range(11, -1, -1):
month = now.month - i
year = now.year
while month <= 0:
month += 12
year -= 1
month_start = datetime(year, month, 1)
if month == 12:
month_end = datetime(year + 1, 1, 1)
else:
month_end = datetime(year, month + 1, 1)
count = (
db.query(Booking)
.filter(Booking.kurs_date >= month_start, Booking.kurs_date < month_end)
.count()
)
bookings_per_month.append(
{"month": month_start.strftime("%b %Y"), "count": count}
)
# Top courses
top_courses = (
db.query(Booking.kurs_title, sqlfunc.count(Booking.id).label("count"))
.filter(Booking.kurs_title.isnot(None))
.group_by(Booking.kurs_title)
.order_by(sqlfunc.count(Booking.id).desc())
.limit(5)
.all()
)
top_courses = [
{"title": t[0][:30] + "..." if len(t[0]) > 30 else t[0], "count": t[1]}
for t in top_courses
]
# Get unique kurs titles for filter dropdown
kurs_titles = (
db.query(Booking.kurs_title)
.filter(Booking.kurs_title.isnot(None))
.distinct()
.order_by(Booking.kurs_title)
.all()
)
kurs_titles = [k[0] for k in kurs_titles if k[0]]
# Get available years for filter
available_years = (
db.query(func.extract("year", Booking.kurs_date))
.filter(Booking.kurs_date.isnot(None))
.distinct()
.order_by(func.extract("year", Booking.kurs_date).desc())
.all()
)
available_years = [int(y[0]) for y in available_years if y[0]]
# Get customers with bookings for filter dropdown
customers_with_bookings = (
db.query(Customer).join(Booking).distinct().order_by(Customer.email).all()
)
return render_template(
"admin/bookings.html",
bookings=bookings_list,
total_bookings=total_bookings,
total_filtered=total_filtered,
confirmed_count=confirmed_count,
pending_count=pending_count,
cancelled_count=cancelled_count,
total_revenue=total_revenue,
this_month_bookings=this_month_bookings,
bookings_per_month=bookings_per_month,
top_courses=top_courses,
status_filter=status_filter,
customer_filter=customer_filter,
kurs_filter=kurs_filter,
kurs_titles=kurs_titles,
search_query=search_query,
year_filter=year_filter,
month_filter=month_filter,
available_years=available_years,
customers_with_bookings=customers_with_bookings,
sort_by=sort_by,
sort_dir=sort_dir,
page=page,
per_page=per_page,
total_pages=total_pages,
)
@bp.route("/bookings/export")
@admin_required
def bookings_export():
"""Export bookings as CSV with current filters applied."""
import csv
import io
from flask import Response
from sqlalchemy import func, or_
from customer_portal.models.booking import Booking
from customer_portal.models.customer import Customer
db = get_db()
# Get filter parameters (same as bookings view)
status_filter = request.args.get("status", "")
customer_filter = request.args.get("customer_id", "")
kurs_filter = request.args.get("kurs", "")
search_query = request.args.get("q", "").strip()
year_filter = request.args.get("year", "")
month_filter = request.args.get("month", "")
# Build query
query = db.query(Booking).join(Customer)
# Apply filters
if status_filter:
query = query.filter(Booking.status == status_filter)
if customer_filter:
with contextlib.suppress(ValueError):
query = query.filter(Booking.customer_id == int(customer_filter))
if kurs_filter:
query = query.filter(Booking.kurs_title.ilike(f"%{kurs_filter}%"))
if year_filter:
with contextlib.suppress(ValueError):
query = query.filter(
func.extract("year", Booking.kurs_date) == int(year_filter)
)
if month_filter:
with contextlib.suppress(ValueError):
query = query.filter(
func.extract("month", Booking.kurs_date) == int(month_filter)
)
if search_query:
search_pattern = f"%{search_query}%"
query = query.filter(
or_(
Booking.booking_number.ilike(search_pattern),
Booking.customer_name.ilike(search_pattern),
Booking.customer_email.ilike(search_pattern),
Booking.kurs_title.ilike(search_pattern),
)
)
# Order by date descending
query = query.order_by(Booking.kurs_date.desc())
bookings = query.all()
# Collect all custom field keys
all_custom_keys = set()
for b in bookings:
if b.custom_fields:
all_custom_keys.update(b.custom_fields.keys())
custom_keys_sorted = sorted(all_custom_keys)
# Create CSV
output = io.StringIO()
writer = csv.writer(output, delimiter=";", quoting=csv.QUOTE_ALL)
# Header row
headers = [
"Buchungsnummer",
"WP ID",
"Status",
"Kunde Name",
"Kunde E-Mail",
"Kurs",
"Datum",
"Uhrzeit",
"Ort",
"Preis",
"Erstellt am",
]
headers.extend(custom_keys_sorted)
writer.writerow(headers)
# Data rows
for b in bookings:
row = [
b.booking_number or "",
b.wp_booking_id or "",
b.status_display,
b.customer.display_name if b.customer else "",
b.customer.email if b.customer else "",
b.kurs_title or "",
b.formatted_date,
b.formatted_time or "",
b.kurs_location or "",
f"{b.total_price:.2f}".replace(".", ",") if b.total_price else "",
b.created_at.strftime("%d.%m.%Y %H:%M") if b.created_at else "",
]
# Add custom fields
for key in custom_keys_sorted:
val = b.custom_fields.get(key, "") if b.custom_fields else ""
row.append(str(val) if val else "")
writer.writerow(row)
# Create response
output.seek(0)
# Add BOM for Excel compatibility
bom = "\ufeff"
# Generate filename
filename = f"buchungen_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return Response(
bom + output.getvalue(),
mimetype="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "text/csv; charset=utf-8",
},
)
@bp.route("/bookings/sync", methods=["GET", "POST"])
@admin_required
def bookings_sync():
"""Sync bookings from WordPress."""
from customer_portal.models.booking import Booking
from customer_portal.models.customer import Customer
from customer_portal.services.wordpress_api import WordPressAPI
db = get_db()
if request.method == "POST":
customer_id = request.form.get("customer_id")
sync_result = None
if customer_id:
# Sync single customer
customer = (
db.query(Customer).filter(Customer.id == int(customer_id)).first()
)
if customer:
sync_result = WordPressAPI.sync_bookings_for_customer(db, customer)
if sync_result["errors"]:
flash(
f"Sync abgeschlossen mit {len(sync_result['errors'])} Fehlern. "
f"{sync_result['created']} neu, {sync_result['updated']} aktualisiert.",
"warning",
)
else:
flash(
f"Sync erfolgreich: {sync_result['created']} neu, "
f"{sync_result['updated']} aktualisiert.",
"success",
)
else:
flash("Kunde nicht gefunden.", "error")
else:
# Sync all customers
sync_result = WordPressAPI.sync_all_customer_bookings(db)
if sync_result["errors"]:
flash(
f"Sync abgeschlossen mit {len(sync_result['errors'])} Fehlern. "
f"{sync_result['total_created']} neu, {sync_result['total_updated']} aktualisiert.",
"warning",
)
else:
flash(
f"Sync erfolgreich: {sync_result['customers_synced']} Kunden, "
f"{sync_result['total_created']} neu, {sync_result['total_updated']} aktualisiert.",
"success",
)
return redirect(url_for("admin.bookings_sync"))
# GET: Show sync page
customers = db.query(Customer).order_by(Customer.email).all()
total_bookings = db.query(Booking).count()
# Get last sync time (from most recent booking update)
last_synced = db.query(Booking.synced_at).order_by(Booking.synced_at.desc()).first()
last_sync_time = last_synced[0] if last_synced else None
return render_template(
"admin/bookings_sync.html",
customers=customers,
total_bookings=total_bookings,
last_sync_time=last_sync_time,
)
@bp.route("/bookings/<int:booking_id>")
@admin_required
def booking_detail(booking_id: int):
"""View booking details."""
from customer_portal.models.booking import Booking
db = get_db()
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
flash("Buchung nicht gefunden.", "error")
return redirect(url_for("admin.bookings"))
return render_template(
"admin/booking_detail.html",
booking=booking,
)
# =============================================================================
# Booking Import (Sprint 14)
# =============================================================================
@bp.route("/bookings/import", methods=["GET", "POST"])
@admin_required
def bookings_import():
"""Import bookings from CSV, JSON or Excel files.
Features:
- Supports CSV (semicolon-separated, German format)
- Supports JSON (MEC WordPress export format)
- Supports Excel (.xlsx)
- Automatic customer matching by email
- Overwrite protection (skip existing by default)
"""
from customer_portal.models.booking import Booking
from customer_portal.services.booking_import import BookingImportService
db = get_db()
if request.method == "POST":
if "import_file" not in request.files:
flash("Keine Datei ausgewaehlt.", "error")
return redirect(url_for("admin.bookings_import"))
file = request.files["import_file"]
if file.filename == "":
flash("Keine Datei ausgewaehlt.", "error")
return redirect(url_for("admin.bookings_import"))
# Get options
overwrite = request.form.get("overwrite") == "on"
delimiter = request.form.get("delimiter", ";")
# Determine file type
filename = file.filename.lower()
file_content = file.read()
try:
if filename.endswith(".json"):
result = BookingImportService.import_from_json(
db, file_content, overwrite=overwrite
)
elif filename.endswith(".xlsx"):
result = BookingImportService.import_from_excel(
db, file_content, overwrite=overwrite
)
elif filename.endswith(".csv"):
result = BookingImportService.import_from_csv(
db, file_content, overwrite=overwrite, delimiter=delimiter
)
else:
flash("Ungueltiges Dateiformat. Erlaubt: CSV, JSON, XLSX", "error")
return redirect(url_for("admin.bookings_import"))
# Show results
if result.get("success", True):
msg_parts = []
if result["created"] > 0:
msg_parts.append(f"{result['created']} neu importiert")
if result["updated"] > 0:
msg_parts.append(f"{result['updated']} aktualisiert")
if result["skipped_existing"] > 0:
msg_parts.append(
f"{result['skipped_existing']} uebersprungen (existieren bereits)"
)
if result["skipped"] - result["skipped_existing"] > 0:
msg_parts.append(
f"{result['skipped'] - result['skipped_existing']} uebersprungen (Fehler)"
)
if msg_parts:
flash(f"Import abgeschlossen: {', '.join(msg_parts)}", "success")
else:
flash("Import abgeschlossen, keine Aenderungen.", "info")
# Show warnings
for warning in result.get("warnings", [])[:5]:
flash(warning, "warning")
# Show errors
for error in result.get("errors", [])[:5]:
flash(error, "error")
if len(result.get("errors", [])) > 5:
flash(
f"... und {len(result['errors']) - 5} weitere Fehler", "error"
)
else:
flash(
f"Import fehlgeschlagen: {result.get('error', 'Unbekannter Fehler')}",
"error",
)
except Exception as e:
flash(f"Import fehlgeschlagen: {e!s}", "error")
return redirect(url_for("admin.bookings_import"))
# GET: Show import form
total_bookings = db.query(Booking).count()
return render_template(
"admin/bookings_import.html",
total_bookings=total_bookings,
)
@bp.route("/bookings/import/template/<format>")
@admin_required
def bookings_import_template(format: str):
"""Download import template in specified format."""
from flask import Response
from customer_portal.services.booking_import import BookingImportService
if format == "csv":
content = BookingImportService.get_import_template_csv()
mimetype = "text/csv"
filename = "buchungen_import_vorlage.csv"
# Add BOM for Excel
content = "\ufeff" + content
elif format == "json":
content = BookingImportService.get_import_template_json()
mimetype = "application/json"
filename = "buchungen_import_vorlage.json"
else:
flash("Unbekanntes Format.", "error")
return redirect(url_for("admin.bookings_import"))
return Response(
content,
mimetype=mimetype,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@bp.route("/bookings/<int:booking_id>/delete", methods=["POST"])
@admin_required
def booking_delete(booking_id: int):
"""Delete a booking."""
from customer_portal.models.booking import Booking
db = get_db()
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
flash("Buchung nicht gefunden.", "error")
return redirect(url_for("admin.bookings"))
booking_number = booking.booking_number or f"#{booking.id}"
db.delete(booking)
db.commit()
flash(f"Buchung {booking_number} wurde geloescht.", "success")
return redirect(url_for("admin.bookings"))