From 9f8c7fb78dcb5c350a99ee8649e3cc0e9a468f1b Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 16 May 2026 12:21:10 +0000 Subject: [PATCH] v2.1: Security - Captcha, Admin-Login, Auth-Decorator --- Dockerfile | 3 +- app/apply_security.py | 52 ++ app/auth.py | 50 ++ app/login_routes.py | 35 ++ app/main.py | 20 + app/main.py.backup-security | 1128 +++++++++++++++++++++++++++++++++++ 6 files changed, 1286 insertions(+), 2 deletions(-) create mode 100644 app/apply_security.py create mode 100644 app/auth.py create mode 100644 app/login_routes.py create mode 100644 app/main.py.backup-security diff --git a/Dockerfile b/Dockerfile index 92acc90..6184f95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.12-slim WORKDIR /app -RUN pip install --no-cache-dir flask flask-cors requests +RUN pip install --no-cache-dir flask flask-cors werkzeug requests COPY app/ ./ @@ -12,7 +12,6 @@ EXPOSE 8080 ENV PORT=8080 ENV DATA_DIR=/data -ENV OLLAMA_URL=http://192.168.0.150:11434 VOLUME ["/data"] diff --git a/app/apply_security.py b/app/apply_security.py new file mode 100644 index 0000000..b6c2051 --- /dev/null +++ b/app/apply_security.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +import re + +with open('/root/reservation-system/app/main.py', 'r') as f: + content = f.read() + +# Add auth import +old_import = "from database import get_db, init_db, generate_booking_number, log_change" +new_import = """from database import get_db, init_db, generate_booking_number, log_change +from auth import require_auth, verify_captcha +from login_routes import auth_bp""" +content = content.replace(old_import, new_import) + +# Add secret key config after app creation +old_cors = "CORS(app)" +new_cors = """CORS(app) + +# Security config +app.secret_key = os.environ.get('SESSION_SECRET', 'dev-secret-change-in-production') + +# Register auth blueprint +app.register_blueprint(auth_bp) + +@app.before_request +def check_auth(): + # Public endpoints don't require auth + public_endpoints = ['/', '/api/captcha', '/api/rooms', '/api/availability', + '/api/health', '/api/admin/login'] + if request.path in public_endpoints or request.path.startswith('/static/'): + return None + + # Admin endpoints require login + if request.path.startswith('/api/admin/') and session.get('user_role') != 'admin': + return jsonify({"error": "Unauthorized"}), 401""" +content = content.replace(old_cors, new_cors) + +# Protect POST reservations with captcha +old_post = ''' if request.method == 'POST': + data = request.get_json()''' +new_post = ''' if request.method == 'POST': + data = request.get_json() + + # Captcha for non-admin bookings + if session.get('user_role') != 'admin': + if not verify_captcha(data.get('captcha_token'), data.get('captcha_answer')): + return jsonify({"error": "Invalid captcha. Please solve the math problem."}), 403''' +content = content.replace(old_post, new_post, 1) + +with open('/root/reservation-system/app/main.py', 'w') as f: + f.write(content) + +print("Security patches applied!") diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..7e316dd --- /dev/null +++ b/app/auth.py @@ -0,0 +1,50 @@ +import os +import hashlib +import random +import time +from functools import wraps +from flask import session, request, jsonify + +# Config +ADMIN_PASSWORD_HASH = os.environ.get('ADMIN_PASSWORD', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi') +SECRET_KEY = os.environ.get('SESSION_SECRET', 'dev-secret-change-in-production') + +def generate_captcha(): + a = random.randint(1, 15) + b = random.randint(1, 15) + op = random.choice(['+', '-']) + if op == '+': + answer = a + b + else: + answer = a - b + token = hashlib.sha256(f"{a}{op}{b}{int(time.time()/600)}captcha".encode()).hexdigest()[:16] + return { + "question": f"{a} {op} {b} = ?", + "token": token, + "answer": answer + } + +def verify_captcha(token, answer): + if not token or not answer: + return False + stored = session.get('captcha_answer') + if stored and str(stored) == str(answer): + session.pop('captcha_answer', None) + return True + return False + +def require_auth(role='admin'): + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + if 'user_role' not in session: + return jsonify({"error": "Unauthorized"}), 401 + if role == 'admin' and session['user_role'] != 'admin': + return jsonify({"error": "Admin required"}), 403 + return f(*args, **kwargs) + return decorated + return decorator + +def check_admin_password(password): + from werkzeug.security import check_password_hash + return check_password_hash(ADMIN_PASSWORD_HASH, password) diff --git a/app/login_routes.py b/app/login_routes.py new file mode 100644 index 0000000..99bc49a --- /dev/null +++ b/app/login_routes.py @@ -0,0 +1,35 @@ +"""Login und Captcha Routes""" +from flask import Blueprint, request, jsonify, session +from auth import generate_captcha, verify_captcha, check_admin_password + +auth_bp = Blueprint('auth', __name__) + +@auth_bp.route('/api/captcha', methods=['GET']) +def get_captcha(): + captcha = generate_captcha() + session['captcha_answer'] = captcha['answer'] + return jsonify({"question": captcha['question'], "token": captcha['token']}) + +@auth_bp.route('/api/admin/login', methods=['POST']) +def admin_login(): + data = request.get_json() or {} + password = data.get('password', '') + + if check_admin_password(password): + session['user_role'] = 'admin' + session['login_time'] = __import__('time').time() + return jsonify({"status": "ok", "role": "admin"}) + return jsonify({"error": "Invalid credentials"}), 401 + +@auth_bp.route('/api/admin/logout', methods=['POST']) +def admin_logout(): + session.pop('user_role', None) + session.pop('login_time', None) + return jsonify({"status": "ok"}) + +@auth_bp.route('/api/session', methods=['GET']) +def check_session(): + role = session.get('user_role') + if role: + return jsonify({"role": role, "logged_in": True}) + return jsonify({"role": None, "logged_in": False}) diff --git a/app/main.py b/app/main.py index c54791b..a17cf2b 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,8 @@ from flask import Flask, request, jsonify, render_template, send_from_directory, from flask_cors import CORS from database import get_db, init_db, generate_booking_number, log_change +from auth import require_auth, verify_captcha +from login_routes import auth_bp from utils.ollama_client import ( parse_email_with_ollama, generate_confirmation_email, @@ -21,6 +23,24 @@ app = Flask(__name__, static_folder='static') CORS(app) +# Security config +app.secret_key = os.environ.get('SESSION_SECRET', 'dev-secret-change-in-production') + +# Register auth blueprint +app.register_blueprint(auth_bp) + +@app.before_request +def check_auth(): + # Public endpoints don't require auth + public_endpoints = ['/', '/api/captcha', '/api/rooms', '/api/availability', + '/api/health', '/api/admin/login'] + if request.path in public_endpoints or request.path.startswith('/static/'): + return None + + # Admin endpoints require login + if request.path.startswith('/api/admin/') and session.get('user_role') != 'admin': + return jsonify({"error": "Unauthorized"}), 401 + # Konfiguration DEFAULT_OPEN_HOUR = 10 # 10:00 DEFAULT_CLOSE_HOUR = 23 # 23:00 diff --git a/app/main.py.backup-security b/app/main.py.backup-security new file mode 100644 index 0000000..c54791b --- /dev/null +++ b/app/main.py.backup-security @@ -0,0 +1,1128 @@ +#!/usr/bin/env python3 +"""Reservierungssystem - Flask Backend""" + +import os +import json +from datetime import datetime, timedelta +from functools import wraps + +from flask import Flask, request, jsonify, render_template, send_from_directory, g +from flask_cors import CORS + +from database import get_db, init_db, generate_booking_number, log_change +from utils.ollama_client import ( + parse_email_with_ollama, + generate_confirmation_email, + suggest_table_for_group +) + +app = Flask(__name__, + template_folder='templates', + static_folder='static') +CORS(app) + +# Konfiguration +DEFAULT_OPEN_HOUR = 10 # 10:00 +DEFAULT_CLOSE_HOUR = 23 # 23:00 +RESERVATION_DURATION = 120 # Minuten + +def init_app(): + """App initialisieren""" + init_db() + + # Standard-Daten einfügen + with get_db() as db: + # Default Raum + room = db.execute("SELECT id FROM rooms LIMIT 1").fetchone() + if not room: + cursor = db.execute( + "INSERT INTO rooms (name, capacity, color) VALUES (?, ?, ?)", + ("Hauptsaal", 50, "#3498db") + ) + room_id = cursor.lastrowid + + # Default Bereich + cursor = db.execute( + "INSERT INTO areas (room_id, name) VALUES (?, ?)", + (room_id, "Fensterbereich") + ) + area_id = cursor.lastrowid + + # Beispiel-Tische + tables = [ + ("T1", 2, 50, 50), + ("T2", 4, 200, 50), + ("T3", 4, 350, 50), + ("T4", 6, 500, 50), + ("T5", 8, 50, 200), + ("T6", 2, 200, 200), + ] + for name, seats, x, y in tables: + db.execute( + """INSERT INTO tables (area_id, name, seats, x, y) + VALUES (?, ?, ?, ?, ?)""", + (area_id, name, seats, x, y) + ) + + db.commit() + print("✓ Standard-Daten erstellt") + +# API-Routen + +@app.route('/') +def index(): + """Hauptseite - Dashboard""" + return render_template('index.html') + +@app.route('/api/health') +def health(): + """Health-Check""" + return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()}) + +# Dashboard-Daten +@app.route('/api/dashboard') +def dashboard(): + """Dashboard-Daten laden""" + today = datetime.now().date().isoformat() + + with get_db() as db: + # Heutige Reservierungen + today_count = db.execute( + "SELECT COUNT(*) FROM reservations WHERE date = ? AND status IN ('confirmed', 'pending')", + (today,) + ).fetchone()[0] + + # Unbearbeitete E-Mails + pending_emails = db.execute( + "SELECT COUNT(*) FROM emails WHERE status = 'needs_review'" + ).fetchone()[0] + + # Freie Tische heute + total_tables = db.execute( + "SELECT COUNT(*) FROM tables WHERE is_active = 1" + ).fetchone()[0] + + # Heutige Gäste gesamt + guests_today = db.execute( + "SELECT COALESCE(SUM(guests), 0) FROM reservations WHERE date = ? AND status IN ('confirmed', 'pending')", + (today,) + ).fetchone()[0] + + # Heutige Reservierungen (Liste) + today_reservations = db.execute(''' + SELECT r.*, g.name as guest_name, g.phone + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.date = ? AND r.status IN ('confirmed', 'pending') + ORDER BY r.time_from + ''', (today,)).fetchall() + + return jsonify({ + "today_count": today_count, + "pending_emails": pending_emails, + "total_tables": total_tables, + "guests_today": guests_today, + "today_reservations": [dict(r) for r in today_reservations] + }) + +# Gäste-Verwaltung +@app.route('/api/guests', methods=['GET', 'POST']) +def guests(): + """Gäste auflisten oder neuen Gast erstellen""" + if request.method == 'GET': + search = request.args.get('search', '') + with get_db() as db: + if search: + rows = db.execute(''' + SELECT g.*, t.name as preferred_table + FROM guests g + LEFT JOIN tables t ON g.preferred_table_id = t.id + WHERE g.name LIKE ? OR g.phone LIKE ? OR g.email LIKE ? + ORDER BY g.name + ''', (f'%{search}%', f'%{search}%', f'%{search}%')).fetchall() + else: + rows = db.execute(''' + SELECT g.*, t.name as preferred_table + FROM guests g + LEFT JOIN tables t ON g.preferred_table_id = t.id + ORDER BY g.name + ''').fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neuen Gast erstellen + data = request.get_json() + with get_db() as db: + cursor = db.execute(''' + INSERT INTO guests (name, phone, email, notes) + VALUES (?, ?, ?, ?) + ''', (data.get('name'), data.get('phone'), data.get('email'), data.get('notes'))) + db.commit() + return jsonify({"id": cursor.lastrowid, "message": "Gast erstellt"}) + +@app.route('/api/guests/', methods=['GET', 'PUT', 'DELETE']) +def guest_detail(guest_id): + """Einzelnen Gast verwalten""" + with get_db() as db: + if request.method == 'GET': + # Gast mit Historie + guest = db.execute('SELECT * FROM guests WHERE id = ?', (guest_id,)).fetchone() + if not guest: + return jsonify({"error": "Gast nicht gefunden"}), 404 + + # Letzte Reservierungen + history = db.execute(''' + SELECT booking_number, date, time_from, guests + FROM reservations + WHERE guest_id = ? + ORDER BY date DESC + LIMIT 5 + ''', (guest_id,)).fetchall() + + result = dict(guest) + result['reservation_history'] = [dict(h) for h in history] + return jsonify(result) + + elif request.method == 'PUT': + data = request.get_json() + db.execute(''' + UPDATE guests + SET name = ?, phone = ?, email = ?, preferred_table_id = ?, notes = ? + WHERE id = ? + ''', (data.get('name'), data.get('phone'), data.get('email'), + data.get('preferred_table_id'), data.get('notes'), guest_id)) + db.commit() + return jsonify({"message": "Gast aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('DELETE FROM guests WHERE id = ?', (guest_id,)) + db.commit() + return jsonify({"message": "Gast gelöscht"}) + +# Tische und Räume +@app.route('/api/rooms') +def rooms(): + """Alle Räume mit Bereichen und Tischen""" + with get_db() as db: + rooms = db.execute('SELECT * FROM rooms').fetchall() + result = [] + + for room in rooms: + room_dict = dict(room) + areas = db.execute('SELECT * FROM areas WHERE room_id = ?', (room['id'],)).fetchall() + room_dict['areas'] = [] + + for area in areas: + area_dict = dict(area) + tables = db.execute( + 'SELECT * FROM tables WHERE area_id = ? AND is_active = 1', + (area['id'],) + ).fetchall() + area_dict['tables'] = [dict(t) for t in tables] + room_dict['areas'].append(area_dict) + + result.append(room_dict) + + return jsonify(result) + +# Reservierungen +@app.route('/api/reservations', methods=['GET', 'POST']) +def reservations(): + """Reservierungen auflisten oder neu erstellen""" + if request.method == 'GET': + date = request.args.get('date') + status = request.args.get('status', 'confirmed,pending') + + with get_db() as db: + query = ''' + SELECT r.*, g.name as guest_name, g.phone, g.email + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.status IN ({}) + '''.format(','.join(f'"{s}"' for s in status.split(','))) + + params = [] + if date: + query += ' AND r.date = ?' + params.append(date) + + query += ' ORDER BY r.time_from' + + rows = db.execute(query, params).fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neue Reservierung + data = request.get_json() + + # Gast finden oder erstellen + guest_id = data.get('guest_id') + if not guest_id and data.get('email'): + with get_db() as db: + guest = db.execute('SELECT id FROM guests WHERE email = ?', + (data['email'],)).fetchone() + if guest: + guest_id = guest['id'] + + # Buchungsnummer generieren + booking_number = generate_booking_number() + + with get_db() as db: + cursor = db.execute(''' + INSERT INTO reservations + (booking_number, guest_id, table_ids, date, time_from, time_to, guests, + occasion, notes, source, phone_caller_name, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + booking_number, + guest_id, + json.dumps(data.get('table_ids', [])), + data.get('date'), + data.get('time_from'), + data.get('time_to'), + data.get('guests'), + data.get('occasion'), + data.get('notes'), + data.get('source', 'manual'), + data.get('phone_caller_name'), + data.get('created_by', 'system') + )) + + # Gast-Zähler aktualisieren + if guest_id: + db.execute(''' + UPDATE guests + SET visit_count = visit_count + 1, last_visit = ? + WHERE id = ? + ''', (data.get('date'), guest_id)) + + db.commit() + + return jsonify({ + "id": cursor.lastrowid, + "booking_number": booking_number, + "message": "Reservierung erstellt" + }) + +@app.route('/api/reservations/', methods=['GET', 'PUT', 'DELETE']) +def reservation_detail(res_id): + """Einzelne Reservierung verwalten""" + with get_db() as db: + if request.method == 'GET': + # Reservierung mit Historie + res = db.execute(''' + SELECT r.*, g.name as guest_name, g.phone, g.email + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.id = ? + ''', (res_id,)).fetchone() + + if not res: + return jsonify({"error": "Reservierung nicht gefunden"}), 404 + + # Änderungshistorie + history = db.execute(''' + SELECT * FROM reservation_history + WHERE reservation_id = ? + ORDER BY changed_at DESC + ''', (res_id,)).fetchall() + + result = dict(res) + result['change_history'] = [dict(h) for h in history] + return jsonify(result) + + elif request.method == 'PUT': + data = request.get_json() + + # Alte Werte für Historie + old = db.execute('SELECT * FROM reservations WHERE id = ?', (res_id,)).fetchone() + + # Felder aktualisieren + update_fields = [] + params = [] + + if 'date' in data: + update_fields.append('date = ?') + params.append(data['date']) + log_change(db, res_id, old['booking_number'], 'date', old['date'], data['date']) + + if 'time_from' in data: + update_fields.append('time_from = ?') + params.append(data['time_from']) + log_change(db, res_id, old['booking_number'], 'time_from', old['time_from'], data['time_from']) + + if 'guests' in data: + update_fields.append('guests = ?') + params.append(data['guests']) + log_change(db, res_id, old['booking_number'], 'guests', old['guests'], data['guests']) + + if 'table_ids' in data: + update_fields.append('table_ids = ?') + params.append(json.dumps(data['table_ids'])) + + if 'status' in data: + update_fields.append('status = ?') + params.append(data['status']) + + if update_fields: + params.append(res_id) + db.execute(f''' + UPDATE reservations + SET {', '.join(update_fields)} + WHERE id = ? + ''', params) + db.commit() + + return jsonify({"message": "Reservierung aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('UPDATE reservations SET status = "cancelled" WHERE id = ?', (res_id,)) + db.commit() + return jsonify({"message": "Reservierung storniert"}) + +# E-Mail-Verarbeitung +@app.route('/api/emails', methods=['GET', 'POST']) +def emails(): + """E-Mails verwalten""" + if request.method == 'GET': + status = request.args.get('status', 'needs_review') + with get_db() as db: + rows = db.execute(''' + SELECT e.*, r.booking_number as linked_booking + FROM emails e + LEFT JOIN reservations r ON e.linked_reservation_id = r.id + WHERE e.status = ? + ORDER BY e.received_at DESC + ''', (status,)).fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neue E-Mail verarbeiten + data = request.get_json() + + # Mit Ollama parsen + parsed = parse_email_with_ollama( + data.get('subject', ''), + data.get('body', ''), + data.get('sender_name', '') + ) + + with get_db() as db: + cursor = db.execute(''' + INSERT INTO emails + (message_id, subject, body, sender, sender_email, parsed_json, + confidence, action_type, status, booking_number_found, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + data.get('message_id'), + data.get('subject'), + data.get('body'), + data.get('sender_name'), + data.get('sender_email'), + json.dumps(parsed), + parsed.get('confidence', 0), + parsed.get('intent', 'unknown'), + 'auto_processed' if not parsed.get('needs_review') else 'needs_review', + parsed.get('booking_number'), + datetime.now() + )) + db.commit() + + email_id = cursor.lastrowid + + # Wenn keine Review nötig, automatisch verarbeiten + if not parsed.get('needs_review') and parsed.get('intent') in ['new', 'modification']: + process_email_reservation(db, email_id, parsed) + + return jsonify({ + "email_id": email_id, + "parsed": parsed, + "status": "processed" if not parsed.get('needs_review') else "needs_review" + }) + +def process_email_reservation(db, email_id, parsed_data): + """E-Mail automatisch in Reservierung umwandeln""" + + intent = parsed_data.get('intent') + booking_number = parsed_data.get('booking_number') + + if intent == 'modification' and booking_number: + # Bestehende Reservierung ändern + existing = db.execute( + 'SELECT id FROM reservations WHERE booking_number = ?', + (booking_number,) + ).fetchone() + + if existing: + # Felder aktualisieren + updates = [] + params = [] + + if parsed_data.get('date'): + updates.append('date = ?') + params.append(parsed_data['date']) + if parsed_data.get('time'): + updates.append('time_from = ?') + params.append(parsed_data['time']) + if parsed_data.get('guests'): + updates.append('guests = ?') + params.append(parsed_data['guests']) + + if updates: + params.extend([existing['id']]) + db.execute(f''' + UPDATE reservations + SET {', '.join(updates)} + WHERE id = ? + ''', params) + + db.execute(''' + UPDATE emails + SET linked_reservation_id = ?, status = 'confirmed' + WHERE id = ? + ''', (existing['id'], email_id)) + + elif intent == 'new': + # Neue Reservierung erstellen + # Gast finden oder erstellen + guest_id = None + if parsed_data.get('email'): + guest = db.execute( + 'SELECT id FROM guests WHERE email = ?', + (parsed_data['email'],) + ).fetchone() + if guest: + guest_id = guest['id'] + else: + cursor = db.execute(''' + INSERT INTO guests (name, phone, email) + VALUES (?, ?, ?) + ''', (parsed_data.get('name'), parsed_data.get('phone'), parsed_data['email'])) + guest_id = cursor.lastrowid + + # Neue Buchungsnummer + new_booking = generate_booking_number() + + cursor = db.execute(''' + INSERT INTO reservations + (booking_number, guest_id, table_ids, date, time_from, guests, + occasion, notes, source, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + new_booking, + guest_id, + json.dumps([]), # Tische später zuweisen + parsed_data.get('date'), + parsed_data.get('time'), + parsed_data.get('guests', 2), + parsed_data.get('occasion'), + parsed_data.get('notes'), + 'email', + 'confirmed' + )) + + res_id = cursor.lastrowid + + db.execute(''' + UPDATE emails + SET linked_reservation_id = ?, status = 'confirmed' + WHERE id = ? + ''', (res_id, email_id)) + +# Verfügbarkeit prüfen +@app.route('/api/availability') +def check_availability(): + """Verfügbare Tische für Zeitfenster prüfen""" + date = request.args.get('date') + time_from = request.args.get('time_from') + time_to = request.args.get('time_to') + guests = int(request.args.get('guests', 2)) + + if not all([date, time_from]): + return jsonify({"error": "Datum und Uhrzeit erforderlich"}), 400 + + if not time_to: + # Standard-Dauer 2 Stunden + from datetime import datetime as dt + tf = dt.strptime(time_from, '%H:%M') + time_to = (tf + timedelta(minutes=120)).strftime('%H:%M') + + with get_db() as db: + # Alle aktiven Tische laden + all_tables = db.execute(''' + SELECT t.*, a.name as area_name + FROM tables t + JOIN areas a ON t.area_id = a.id + WHERE t.is_active = 1 + ''').fetchall() + + # Belegte Tische in diesem Zeitraum + occupied = db.execute(''' + SELECT DISTINCT json_each.value as table_id + FROM reservations r, json_each(r.table_ids) + WHERE r.date = ? + AND r.status IN ('confirmed', 'pending') + AND NOT (r.time_to <= ? OR r.time_from >= ?) + ''', (date, time_from, time_to)).fetchall() + + occupied_ids = {row['table_id'] for row in occupied} + + # Verfügbare Tische filtern + available = [dict(t) for t in all_tables + if t['id'] not in occupied_ids and t['seats'] >= guests] + + # Nach Größe sortieren (beste Passung zuerst) + available.sort(key=lambda x: (x['seats'] - guests, x['seats'])) + + return jsonify({ + "available_tables": available, + "total": len(all_tables), + "occupied": len(occupied_ids) + }) + +# Statistiken +@app.route('/api/stats') +def statistics(): + """Statistiken für Dashboard""" + from_date = request.args.get('from', (datetime.now() - timedelta(days=30)).date().isoformat()) + to_date = request.args.get('to', datetime.now().date().isoformat()) + + with get_db() as db: + # Gesamtreservierungen + total = db.execute(''' + SELECT COUNT(*) as count, COALESCE(SUM(guests), 0) as guests + FROM reservations + WHERE date BETWEEN ? AND ? + AND status IN ('confirmed', 'completed') + ''', (from_date, to_date)).fetchone() + + # Durchschnittliche Gruppengröße + avg_size = db.execute(''' + SELECT AVG(guests) as avg + FROM reservations + WHERE date BETWEEN ? AND ? + AND status IN ('confirmed', 'completed') + ''', (from_date, to_date)).fetchone() + + # Nach Wochentag + by_day = db.execute(''' + SELECT strftime('%w', date) as day, COUNT(*) as count + FROM reservations + WHERE date BETWEEN ? AND ? + AND status IN ('confirmed', 'completed') + GROUP BY day + ''', (from_date, to_date)).fetchall() + + # Stammgäste (mehr als 2 Besuche) + regulars = db.execute(''' + SELECT COUNT(*) FROM guests WHERE visit_count >= 2 + ''').fetchone()[0] + + return jsonify({ + "period": {"from": from_date, "to": to_date}, + "total_reservations": total['count'], + "total_guests": total['guests'], + "average_group_size": round(avg_size['avg'], 1) if avg_size['avg'] else 0, + "by_day": {row['day']: row['count'] for row in by_day}, + "regular_guests": regulars + }) + +if __name__ == '__main__': + init_app() + port = int(os.environ.get('PORT', 8080)) + print(f"🍽️ Reservierungssystem starting on port {port}") + app.run(host='0.0.0.0', port=port, debug=False)# NEUE API ENDPOINTS FÜR ROOM BOOKINGS +# Diese werden ans Ende der main.py angehängt + +# ============================================================================= +# ROOM BOOKING APIs (NEU) +# ============================================================================= + +@app.route('/api/rooms-with-bookings', methods=['GET']) +def rooms_with_bookings(): + """Räume mit aktuellen Raumbuchungen laden""" + date = request.args.get('date', datetime.now().date().isoformat()) + + with get_db() as db: + rooms = db.execute('SELECT * FROM rooms').fetchall() + result = [] + + for room in rooms: + room_dict = dict(room) + + # Bereiche und Tische laden + areas = db.execute('SELECT * FROM areas WHERE room_id = ?', (room['id'],)).fetchall() + room_dict['areas'] = [] + for area in areas: + area_dict = dict(area) + tables = db.execute( + 'SELECT * FROM tables WHERE area_id = ? AND is_active = 1', + (area['id'],) + ).fetchall() + area_dict['tables'] = [dict(t) for t in tables] + room_dict['areas'].append(area_dict) + + # Raumbuchungen für das Datum laden + bookings = db.execute(''' + SELECT * FROM room_bookings + WHERE room_id = ? AND date = ? AND status = 'confirmed' + ORDER BY time_from + ''', (room['id'], date)).fetchall() + room_dict['bookings'] = [dict(b) for b in bookings] + + result.append(room_dict) + + return jsonify(result) + +@app.route('/api/room-bookings', methods=['GET', 'POST']) +def room_bookings(): + """Raumbuchungen auflisten oder erstellen""" + if request.method == 'GET': + date = request.args.get('date') + room_id = request.args.get('room_id') + + with get_db() as db: + query = ''' + SELECT rb.*, r.name as room_name, r.capacity as room_capacity + FROM room_bookings rb + JOIN rooms r ON rb.room_id = r.id + WHERE 1=1 + ''' + params = [] + + if date: + query += ' AND rb.date = ?' + params.append(date) + if room_id: + query += ' AND rb.room_id = ?' + params.append(room_id) + + query += ' ORDER BY rb.date DESC, rb.time_from' + + rows = db.execute(query, params).fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neue Raumbuchung erstellen + data = request.get_json() + + # Validierung + required = ['room_id', 'date', 'time_from', 'time_to', 'guests', 'name'] + for field in required: + if not data.get(field): + return jsonify({"error": f"{field} ist erforderlich"}), 400 + + with get_db() as db: + # Prüfe Verfügbarkeit + existing = db.execute(''' + SELECT COUNT(*) FROM room_bookings + WHERE room_id = ? AND date = ? AND status = 'confirmed' + AND ( + (time_from < ? AND time_to > ?) OR + (time_from < ? AND time_to > ?) OR + (time_from >= ? AND time_to <= ?) + ) + ''', ( + data['room_id'], data['date'], + data['time_to'], data['time_from'], + data['time_to'], data['time_from'], + data['time_from'], data['time_to'] + )).fetchone()[0] + + if existing > 0: + return jsonify({"error": "Raum ist im gewählten Zeitraum bereits belegt"}), 409 + + # Kapazität prüfen + room = db.execute('SELECT capacity FROM rooms WHERE id = ?', + (data['room_id'],)).fetchone() + if room and data['guests'] > room['capacity']: + return jsonify({"error": f"Maximale Kapazität: {room['capacity']} Personen"}), 400 + + # Buchung erstellen + cursor = db.execute(''' + INSERT INTO room_bookings + (room_id, date, time_from, time_to, guests, name, phone, email, + event_type, notes, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + data['room_id'], data['date'], data['time_from'], data['time_to'], + data['guests'], data['name'], data.get('phone'), data.get('email'), + data.get('event_type'), data.get('notes'), + data.get('status', 'confirmed') + )) + db.commit() + + return jsonify({ + "id": cursor.lastrowid, + "message": "Raumbuchung erstellt" + }), 201 + +@app.route('/api/room-bookings/', methods=['GET', 'PUT', 'DELETE']) +def room_booking_detail(booking_id): + """Einzelne Raumbuchung verwalten""" + with get_db() as db: + if request.method == 'GET': + booking = db.execute(''' + SELECT rb.*, r.name as room_name, r.capacity as room_capacity + FROM room_bookings rb + JOIN rooms r ON rb.room_id = r.id + WHERE rb.id = ? + ''', (booking_id,)).fetchone() + + if not booking: + return jsonify({"error": "Raumbuchung nicht gefunden"}), 404 + + return jsonify(dict(booking)) + + elif request.method == 'PUT': + data = request.get_json() + + update_fields = [] + params = [] + + if 'date' in data: + update_fields.append('date = ?') + params.append(data['date']) + if 'time_from' in data: + update_fields.append('time_from = ?') + params.append(data['time_from']) + if 'time_to' in data: + update_fields.append('time_to = ?') + params.append(data['time_to']) + if 'guests' in data: + update_fields.append('guests = ?') + params.append(data['guests']) + if 'name' in data: + update_fields.append('name = ?') + params.append(data['name']) + if 'phone' in data: + update_fields.append('phone = ?') + params.append(data['phone']) + if 'email' in data: + update_fields.append('email = ?') + params.append(data['email']) + if 'event_type' in data: + update_fields.append('event_type = ?') + params.append(data['event_type']) + if 'notes' in data: + update_fields.append('notes = ?') + params.append(data['notes']) + if 'status' in data: + update_fields.append('status = ?') + params.append(data['status']) + + if update_fields: + params.append(booking_id) + db.execute(f''' + UPDATE room_bookings + SET {', '.join(update_fields)} + WHERE id = ? + ''', params) + db.commit() + + return jsonify({"message": "Raumbuchung aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('DELETE FROM room_bookings WHERE id = ?', (booking_id,)) + db.commit() + return jsonify({"message": "Raumbuchung gelöscht"}) + +@app.route('/api/room-availability', methods=['GET']) +def room_availability(): + """Verfügbarkeit für alle Räume prüfen""" + date = request.args.get('date', datetime.now().date().isoformat()) + time_from = request.args.get('time_from', '10:00') + time_to = request.args.get('time_to', '12:00') + + with get_db() as db: + rooms = db.execute('SELECT * FROM rooms').fetchall() + result = [] + + for room in rooms: + # Prüfe ob Raum belegt ist + existing = db.execute(''' + SELECT COUNT(*) FROM room_bookings + WHERE room_id = ? AND date = ? AND status = 'confirmed' + AND ( + (time_from < ? AND time_to > ?) OR + (time_from < ? AND time_to > ?) OR + (time_from >= ? AND time_to <= ?) + ) + ''', (room['id'], date, time_to, time_from, time_to, time_from, + time_from, time_to)).fetchone()[0] + + # Lade Tische im Raum + areas = db.execute('SELECT id FROM areas WHERE room_id = ?', (room['id'],)).fetchall() + area_ids = [a['id'] for a in areas] + + table_count = 0 + if area_ids: + placeholders = ','.join('?' for _ in area_ids) + table_count = db.execute(f''' + SELECT COUNT(*) FROM tables + WHERE area_id IN ({placeholders}) AND is_active = 1 + ''', area_ids).fetchone()[0] + + result.append({ + 'room_id': room['id'], + 'room_name': room['name'], + 'capacity': room['capacity'], + 'color': room['color'], + 'available': existing == 0, + 'table_count': table_count, + 'date': date, + 'time_from': time_from, + 'time_to': time_to + }) + + return jsonify(result) + +@app.route('/api/tables/', methods=['GET', 'POST']) +def tables_by_area(area_id): + """Tische für einen Bereich verwalten""" + if request.method == 'GET': + with get_db() as db: + tables = db.execute( + 'SELECT * FROM tables WHERE area_id = ? ORDER BY name', + (area_id,) + ).fetchall() + return jsonify([dict(t) for t in tables]) + + # POST: Neuen Tisch erstellen + data = request.get_json() + + with get_db() as db: + cursor = db.execute(''' + INSERT INTO tables (area_id, name, x, y, width, height, seats, shape, is_combinable) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + area_id, + data.get('name', 'Neuer Tisch'), + data.get('x', 0), + data.get('y', 0), + data.get('width', 100), + data.get('height', 100), + data.get('seats', 4), + data.get('shape', 'rect'), + data.get('is_combinable', 1) + )) + db.commit() + return jsonify({"id": cursor.lastrowid, "message": "Tisch erstellt"}), 201 + +@app.route('/api/tables/', methods=['PUT', 'DELETE']) +def table_detail(table_id): + """Einzelnen Tisch verwalten""" + with get_db() as db: + if request.method == 'PUT': + data = request.get_json() + + update_fields = [] + params = [] + + if 'name' in data: + update_fields.append('name = ?') + params.append(data['name']) + if 'x' in data: + update_fields.append('x = ?') + params.append(data['x']) + if 'y' in data: + update_fields.append('y = ?') + params.append(data['y']) + if 'seats' in data: + update_fields.append('seats = ?') + params.append(data['seats']) + if 'is_active' in data: + update_fields.append('is_active = ?') + params.append(data['is_active']) + + if update_fields: + params.append(table_id) + db.execute(f''' + UPDATE tables SET {', '.join(update_fields)} WHERE id = ? + ''', params) + db.commit() + + return jsonify({"message": "Tisch aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('UPDATE tables SET is_active = 0 WHERE id = ?', (table_id,)) + db.commit() + return jsonify({"message": "Tisch deaktiviert"}) + +# ============================================================================= +# ERWEITERTE RESERVIERUNGS-ENDPOINTS +# ============================================================================= + +@app.route('/api/reservations/check-availability', methods=['POST']) +def check_reservation_availability(): + """Verfügbarkeit für Reservierung prüfen (erweitert)""" + data = request.get_json() + + date = data.get('date') + time_from = data.get('time_from') + time_to = data.get('time_to', '23:00') + guests = data.get('guests', 2) + preferred_room_id = data.get('room_id') + + if not all([date, time_from]): + return jsonify({"error": "Datum und Uhrzeit erforderlich"}), 400 + + with get_db() as db: + # Basis-Query für verfügbare Tische + base_query = ''' + SELECT t.*, a.name as area_name, r.id as room_id, r.name as room_name + FROM tables t + JOIN areas a ON t.area_id = a.id + JOIN rooms r ON a.room_id = r.id + WHERE t.is_active = 1 + ''' + params = [] + + if preferred_room_id: + base_query += ' AND r.id = ?' + params.append(preferred_room_id) + + base_query += ' AND t.seats >= ?' + params.append(guests) + + all_tables = db.execute(base_query, params).fetchall() + + # Belegte Tische finden + occupied_query = ''' + SELECT DISTINCT json_each.value as table_id + FROM reservations r, json_each(r.table_ids) + WHERE r.date = ? + AND r.status IN ('confirmed', 'pending') + AND NOT (r.time_to <= ? OR r.time_from >= ?) + ''' + occupied = db.execute(occupied_query, (date, time_from, time_to)).fetchall() + occupied_ids = {str(row['table_id']) for row in occupied} + + # Verfügbare Tische filtern + available = [] + for t in all_tables: + if str(t['id']) not in occupied_ids: + table_dict = dict(t) + table_dict['is_available'] = True + available.append(table_dict) + + # Nach Raum gruppieren + rooms_with_tables = {} + for t in available: + room_id = t['room_id'] + if room_id not in rooms_with_tables: + rooms_with_tables[room_id] = { + 'room_id': room_id, + 'room_name': t['room_name'], + 'tables': [] + } + rooms_with_tables[room_id]['tables'].append(t) + + return jsonify({ + "date": date, + "time_from": time_from, + "time_to": time_to, + "guests": guests, + "available_tables": available, + "available_count": len(available), + "rooms": list(rooms_with_tables.values()) + }) + +@app.route('/api/dashboard/extended', methods=['GET']) +def dashboard_extended(): + """Erweitertes Dashboard mit Raumbuchungen""" + today = datetime.now().date().isoformat() + + with get_db() as db: + # Standard-Dashboard-Daten + today_count = db.execute( + "SELECT COUNT(*) FROM reservations WHERE date = ? AND status IN ('confirmed', 'pending')", + (today,) + ).fetchone()[0] + + # Raumbuchungen heute + room_bookings_count = db.execute( + "SELECT COUNT(*) FROM room_bookings WHERE date = ? AND status = 'confirmed'", + (today,) + ).fetchone()[0] + + # Unbearbeitete E-Mails + pending_emails = db.execute( + "SELECT COUNT(*) FROM emails WHERE status = 'needs_review'" + ).fetchone()[0] + + # Freie Tische + total_tables = db.execute( + "SELECT COUNT(*) FROM tables WHERE is_active = 1" + ).fetchone()[0] + + # Heutige Gäste + guests_today = db.execute(''' + SELECT COALESCE(SUM(guests), 0) FROM reservations + WHERE date = ? AND status IN ('confirmed', 'pending') + ''', (today,)).fetchone()[0] + + # Raumbuchungs-Gäste + room_guests = db.execute(''' + SELECT COALESCE(SUM(guests), 0) FROM room_bookings + WHERE date = ? AND status = 'confirmed' + ''', (today,)).fetchone()[0] + + # Aktuelle Reservierungen + today_reservations = db.execute(''' + SELECT r.*, g.name as guest_name, g.phone + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.date = ? AND r.status IN ('confirmed', 'pending') + ORDER BY r.time_from + ''', (today,)).fetchall() + + # Aktuelle Raumbuchungen + today_room_bookings = db.execute(''' + SELECT rb.*, r.name as room_name + FROM room_bookings rb + JOIN rooms r ON rb.room_id = r.id + WHERE rb.date = ? AND rb.status = 'confirmed' + ORDER BY rb.time_from + ''', (today,)).fetchall() + + # Räume mit Auslastung + rooms = db.execute('SELECT * FROM rooms').fetchall() + rooms_data = [] + for room in rooms: + # Tische im Raum zählen + area_ids = db.execute('SELECT id FROM areas WHERE room_id = ?', + (room['id'],)).fetchall() + table_count = 0 + if area_ids: + ids = [a['id'] for a in area_ids] + placeholders = ','.join('?' for _ in ids) + table_count = db.execute(f''' + SELECT COUNT(*) FROM tables + WHERE area_id IN ({placeholders}) AND is_active = 1 + ''', ids).fetchone()[0] + + # Aktuelle Buchungen + bookings = db.execute(''' + SELECT COUNT(*) FROM room_bookings + WHERE room_id = ? AND date = ? AND status = 'confirmed' + ''', (room['id'], today)).fetchone()[0] + + rooms_data.append({ + 'id': room['id'], + 'name': room['name'], + 'capacity': room['capacity'], + 'color': room['color'], + 'table_count': table_count, + 'bookings_today': bookings + }) + + return jsonify({ + "today": today, + "reservations_count": today_count, + "room_bookings_count": room_bookings_count, + "pending_emails": pending_emails, + "total_tables": total_tables, + "guests_today": guests_today, + "room_guests_today": room_guests, + "total_guests_today": guests_today + room_guests, + "reservations": [dict(r) for r in today_reservations], + "room_bookings": [dict(r) for r in today_room_bookings], + "rooms": rooms_data + })