This commit is contained in:
2026-06-09 15:25:54 +02:00
commit 7b3b9a890f
16 changed files with 600 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import os
from flask import Flask
from flask_migrate import Migrate
from flask_socketio import SocketIO
from dotenv import load_dotenv
from .models import db
socketio = SocketIO(cors_allowed_origins="*")
migrate = Migrate()
def create_app():
load_dotenv()
app = Flask(__name__)
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret")
app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv(
"DATABASE_URL",
"postgresql+psycopg2://rezept_finder:rezept_finder@localhost:5432/rezept_finder",
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
migrate.init_app(app, db)
socketio.init_app(app, cors_allowed_origins=os.getenv("FRONTEND_ORIGIN", "*"))
from .routes.sessions import bp as sessions_bp
app.register_blueprint(sessions_bp, url_prefix="/api/sessions")
from .socket import events # noqa: F401
return app
+5
View File
@@ -0,0 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
from .session import Session, Participant, Recipe, Vote # noqa: E402,F401
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime
from . import db
class Session(db.Model):
__tablename__ = "sessions"
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(6), unique=True, nullable=False, index=True)
status = db.Column(db.String(20), nullable=False, default="waiting")
current_recipe_index = db.Column(db.Integer, nullable=False, default=0)
matched_recipe_id = db.Column(db.Integer, db.ForeignKey("recipes.id"), nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
participants = db.relationship("Participant", backref="session", cascade="all, delete-orphan")
recipes = db.relationship("Recipe", backref="session", cascade="all, delete-orphan", order_by="Recipe.position")
class Participant(db.Model):
__tablename__ = "participants"
id = db.Column(db.Integer, primary_key=True)
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False)
name = db.Column(db.String(80), nullable=False)
is_host = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
class Recipe(db.Model):
__tablename__ = "recipes"
id = db.Column(db.Integer, primary_key=True)
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False)
position = db.Column(db.Integer, nullable=False)
title = db.Column(db.String(255), nullable=False)
url = db.Column(db.Text, nullable=False)
image_url = db.Column(db.Text, nullable=True)
meta = db.Column(db.String(255), nullable=True)
class Vote(db.Model):
__tablename__ = "votes"
id = db.Column(db.Integer, primary_key=True)
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False)
participant_id = db.Column(db.Integer, db.ForeignKey("participants.id"), nullable=False)
recipe_id = db.Column(db.Integer, db.ForeignKey("recipes.id"), nullable=False)
vote = db.Column(db.String(3), nullable=False) # yes/no
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint("participant_id", "recipe_id", name="uq_participant_recipe_vote"),
)
+81
View File
@@ -0,0 +1,81 @@
import random
import string
from flask import Blueprint, jsonify, request
from ..models import db
from ..models.session import Session, Participant, Recipe
from ..services.recipes import get_recipes
bp = Blueprint("sessions", __name__)
def generate_code():
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
def serialize_session(session):
return {
"id": session.id,
"code": session.code,
"status": session.status,
"currentRecipeIndex": session.current_recipe_index,
"participants": [
{"id": p.id, "name": p.name, "isHost": p.is_host}
for p in session.participants
],
"recipes": [
{
"id": r.id,
"title": r.title,
"url": r.url,
"imageUrl": r.image_url,
"meta": r.meta,
"position": r.position,
}
for r in session.recipes
],
"matchedRecipeId": session.matched_recipe_id,
}
@bp.post("")
def create_session():
data = request.get_json() or {}
name = (data.get("name") or "Host").strip()
code = generate_code()
while Session.query.filter_by(code=code).first():
code = generate_code()
session = Session(code=code)
db.session.add(session)
db.session.flush()
host = Participant(session_id=session.id, name=name, is_host=True)
db.session.add(host)
for index, recipe_data in enumerate(get_recipes()):
db.session.add(Recipe(session_id=session.id, position=index, **recipe_data))
db.session.commit()
return jsonify({"session": serialize_session(session), "participantId": host.id})
@bp.post("/<code>/join")
def join_session(code):
data = request.get_json() or {}
name = (data.get("name") or "Gast").strip()
session = Session.query.filter_by(code=code.upper()).first_or_404()
if session.status != "waiting":
return jsonify({"error": "Session wurde bereits gestartet."}), 409
participant = Participant(session_id=session.id, name=name, is_host=False)
db.session.add(participant)
db.session.commit()
return jsonify({"session": serialize_session(session), "participantId": participant.id})
@bp.get("/<code>")
def get_session(code):
session = Session.query.filter_by(code=code.upper()).first_or_404()
return jsonify({"session": serialize_session(session)})
+55
View File
@@ -0,0 +1,55 @@
DUMMY_RECIPES = [
{
"title": "Cremige Pasta mit Tomaten",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "ca. 25 Minuten",
},
{
"title": "Kartoffel-Brokkoli-Auflauf",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "vegetarisch",
},
{
"title": "Schnelle Reispfanne",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "einfach",
},
{
"title": "Wraps mit Hähnchen",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "schnell",
},
{
"title": "Gemüse-Curry",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "würzig",
},
{
"title": "Ofenkartoffeln mit Dip",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "günstig",
},
{
"title": "Gnocchi-Pfanne",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "15 Minuten",
},
{
"title": "Pizza-Toast",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "MVP-Klassiker",
},
]
def get_recipes():
# Später ersetzen durch Chefkoch-Vorschau-Importer.
return DUMMY_RECIPES
+1
View File
@@ -0,0 +1 @@
# Socket package
+117
View File
@@ -0,0 +1,117 @@
from flask_socketio import emit, join_room
from sqlalchemy.exc import IntegrityError
from .. import socketio
from ..models import db
from ..models.session import Session, Vote
def room_name(code):
return f"session:{code.upper()}"
def serialize_recipe(recipe):
return {
"id": recipe.id,
"title": recipe.title,
"url": recipe.url,
"imageUrl": recipe.image_url,
"meta": recipe.meta,
"position": recipe.position,
}
def emit_session_update(session):
socketio.emit(
"session:update",
{
"code": session.code,
"status": session.status,
"currentRecipeIndex": session.current_recipe_index,
"matchedRecipeId": session.matched_recipe_id,
"participants": [
{"id": p.id, "name": p.name, "isHost": p.is_host}
for p in session.participants
],
},
room=room_name(session.code),
)
@socketio.on("session:subscribe")
def subscribe(data):
code = data.get("code", "").upper()
session = Session.query.filter_by(code=code).first()
if not session:
emit("error", {"message": "Session nicht gefunden."})
return
join_room(room_name(code))
emit_session_update(session)
@socketio.on("session:start")
def start_session(data):
code = data.get("code", "").upper()
participant_id = data.get("participantId")
session = Session.query.filter_by(code=code).first()
if not session:
emit("error", {"message": "Session nicht gefunden."})
return
host = next((p for p in session.participants if p.id == participant_id and p.is_host), None)
if not host:
emit("error", {"message": "Nur der Host kann starten."})
return
session.status = "running"
session.current_recipe_index = 0
db.session.commit()
emit_session_update(session)
@socketio.on("vote")
def vote(data):
code = data.get("code", "").upper()
participant_id = data.get("participantId")
recipe_id = data.get("recipeId")
vote_value = data.get("vote")
if vote_value not in ["yes", "no"]:
emit("error", {"message": "Ungültige Stimme."})
return
session = Session.query.filter_by(code=code).first()
if not session or session.status != "running":
emit("error", {"message": "Session läuft nicht."})
return
recipe = next((r for r in session.recipes if r.id == recipe_id), None)
if not recipe:
emit("error", {"message": "Rezept nicht gefunden."})
return
db.session.add(Vote(session_id=session.id, participant_id=participant_id, recipe_id=recipe.id, vote=vote_value))
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return
participant_count = len(session.participants)
yes_count = Vote.query.filter_by(session_id=session.id, recipe_id=recipe.id, vote="yes").count()
vote_count = Vote.query.filter_by(session_id=session.id, recipe_id=recipe.id).count()
if yes_count == participant_count:
session.status = "finished"
session.matched_recipe_id = recipe.id
db.session.commit()
socketio.emit("session:match", {"recipe": serialize_recipe(recipe)}, room=room_name(code))
emit_session_update(session)
return
if vote_count == participant_count:
session.current_recipe_index += 1
if session.current_recipe_index >= len(session.recipes):
session.status = "finished"
db.session.commit()
emit_session_update(session)