2080 lines
69 KiB
Python
Executable File
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"))
|