This commit is contained in:
2026-06-09 23:10:17 +02:00
parent 7b3b9a890f
commit 90e5f61b91
15 changed files with 911 additions and 86 deletions
+89
View File
@@ -0,0 +1,89 @@
from ..models import db
from ..models.session import Vote
from .session_recipes import add_recipe_batch
from .session_settings import FINAL_ROUND_MODE, get_match_mode, get_required_likes, get_voting_round, set_voting_round
def get_qualified_recipes(session):
required_likes = get_required_likes(session)
qualified = []
for recipe in session.recipes:
yes_count = Vote.query.filter_by(
session_id=session.id,
recipe_id=recipe.id,
vote="yes",
).count()
if yes_count >= required_likes:
qualified.append(recipe)
return qualified
def all_participants_finished_current_recipes(session):
recipe_count = len(session.recipes)
if recipe_count == 0:
return False
for participant in session.participants:
vote_count = Vote.query.filter_by(
session_id=session.id,
participant_id=participant.id,
).count()
if vote_count < recipe_count:
return False
return True
def enter_final_round(session, qualified_recipes):
qualified_ids = {recipe.id for recipe in qualified_recipes}
session.matched_recipe_id = None
Vote.query.filter_by(session_id=session.id).delete()
for recipe in list(session.recipes):
if recipe.id not in qualified_ids:
session.recipes.remove(recipe)
db.session.delete(recipe)
db.session.flush()
finalist_recipes = sorted(session.recipes, key=lambda recipe: recipe.position)
for index, recipe in enumerate(finalist_recipes):
recipe.position = index
set_voting_round(session, 2)
def handle_final_round_vote(session, recipe):
yes_count = Vote.query.filter_by(session_id=session.id, recipe_id=recipe.id, vote="yes").count()
if get_voting_round(session) == 2 and yes_count >= get_required_likes(session):
session.status = "finished"
session.matched_recipe_id = recipe.id
return "match"
if all_participants_finished_current_recipes(session):
qualified_recipes = get_qualified_recipes(session)
if get_voting_round(session) == 1:
if len(qualified_recipes) >= 3:
enter_final_round(session, qualified_recipes)
return "final_round"
add_recipe_batch(session)
return "more_recipes"
if qualified_recipes:
session.status = "finished"
session.matched_recipe_id = qualified_recipes[0].id
return "match"
set_voting_round(session, 1)
add_recipe_batch(session)
return "more_recipes"
return "update"
def is_final_round_mode(session):
return get_match_mode(session) == FINAL_ROUND_MODE
+97 -3
View File
@@ -1,4 +1,14 @@
DUMMY_RECIPES = [
import random
import re
from html import unescape
from html.parser import HTMLParser
from urllib.parse import urljoin
from urllib.request import Request, urlopen
CHEFKOCH_TODAY_URL = "https://www.chefkoch.de/rezepte/was-koche-ich-heute/"
FALLBACK_RECIPES = [
{
"title": "Cremige Pasta mit Tomaten",
"url": "https://www.chefkoch.de/",
@@ -50,6 +60,90 @@ DUMMY_RECIPES = [
]
class ChefkochTodayParser(HTMLParser):
def __init__(self):
super().__init__()
self.recipes = []
self._current_link = None
self._current_text = []
self._current_image_url = ""
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "a":
href = attrs.get("href", "")
if re.search(r"/rezepte/\d+/.+\.html", href):
self._current_link = urljoin(CHEFKOCH_TODAY_URL, href)
self._current_text = []
self._current_image_url = ""
elif tag == "img" and self._current_link:
self._current_image_url = attrs.get("src") or attrs.get("data-src") or ""
def handle_data(self, data):
if self._current_link:
self._current_text.append(data)
def handle_endtag(self, tag):
if tag != "a" or not self._current_link:
return
title, meta = parse_recipe_text(" ".join(self._current_text))
if title and not any(recipe["url"] == self._current_link for recipe in self.recipes):
self.recipes.append(
{
"title": title,
"url": self._current_link,
"image_url": self._current_image_url,
"meta": meta,
}
)
self._current_link = None
self._current_text = []
self._current_image_url = ""
def parse_recipe_text(text):
text = unescape(re.sub(r"\s+", " ", text)).strip()
text = text.replace("rating_star", "").strip()
rating_match = re.match(r"(?P<rating>\d+[,.]\d)\s*/?\s*5?\s*\((?P<count>\d+)\)\s*(?P<title>.+)", text)
if rating_match:
rating = rating_match.group("rating")
count = rating_match.group("count")
title = clean_recipe_title(rating_match.group("title"))
return title, f"{rating}/5 ({count})"
return clean_recipe_title(text), ""
def clean_recipe_title(title):
title = re.sub(r"\s+von\s+essen\s*&\s*trinken.*$", "", title, flags=re.IGNORECASE)
title = re.sub(r"[^\w\sÄÖÜäöüß.,&'’:/()\-]", "", title)
return re.sub(r"\s+", " ", title).strip()
def fetch_chefkoch_today_recipes():
cache_buster = random.randint(100000, 999999)
request = Request(
f"{CHEFKOCH_TODAY_URL}?rf={cache_buster}",
headers={
"User-Agent": "Mozilla/5.0 (compatible; RezeptFinder/1.0)",
"Cache-Control": "no-cache",
},
)
with urlopen(request, timeout=8) as response:
html = response.read().decode("utf-8", errors="replace")
parser = ChefkochTodayParser()
parser.feed(html)
return parser.recipes[:8]
def get_recipes():
# Später ersetzen durch Chefkoch-Vorschau-Importer.
return DUMMY_RECIPES
try:
recipes = fetch_chefkoch_today_recipes()
except Exception:
return FALLBACK_RECIPES
return recipes or FALLBACK_RECIPES
+57
View File
@@ -0,0 +1,57 @@
from ..models import db
from ..models.session import Recipe, Vote
from .recipes import get_recipes
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 add_recipe_batch(session, size=8):
existing_urls = {recipe.url for recipe in session.recipes}
next_position = len(session.recipes)
added = 0
for _ in range(3):
for recipe_data in get_recipes():
if recipe_data["url"] in existing_urls:
continue
session.recipes.append(Recipe(position=next_position, **recipe_data))
existing_urls.add(recipe_data["url"])
next_position += 1
added += 1
if added == size:
db.session.flush()
return added
if added:
db.session.flush()
return added
for recipe_data in get_recipes()[:size]:
session.recipes.append(Recipe(position=next_position, **recipe_data))
next_position += 1
added += 1
db.session.flush()
return added
def replace_recipe_batch(session, size=8):
session.matched_recipe_id = None
Vote.query.filter_by(session_id=session.id).delete()
for recipe in list(session.recipes):
db.session.delete(recipe)
db.session.flush()
session.recipes.clear()
return add_recipe_batch(session, size)
+62
View File
@@ -0,0 +1,62 @@
from ..models import db
from ..models.session import SessionSettings
FIRST_MATCH_MODE = "first_match"
FINAL_ROUND_MODE = "final_round"
MATCH_MODES = {FIRST_MATCH_MODE, FINAL_ROUND_MODE}
def get_required_likes(session):
participant_count = max(2, len(session.participants))
if not session.settings:
return participant_count
return max(2, min(session.settings.required_likes, participant_count))
def set_required_likes(session, required_likes):
participant_count = max(2, len(session.participants))
required_likes = max(2, min(int(required_likes), participant_count))
if not session.settings:
session.settings = SessionSettings(required_likes=required_likes)
db.session.add(session.settings)
else:
session.settings.required_likes = required_likes
return required_likes
def get_match_mode(session):
if not session.settings:
return FIRST_MATCH_MODE
return session.settings.match_mode if session.settings.match_mode in MATCH_MODES else FIRST_MATCH_MODE
def get_voting_round(session):
if not session.settings:
return 1
return max(1, session.settings.voting_round)
def ensure_settings(session):
if not session.settings:
session.settings = SessionSettings()
db.session.add(session.settings)
return session.settings
def set_match_mode(session, match_mode):
settings = ensure_settings(session)
settings.match_mode = match_mode if match_mode in MATCH_MODES else FIRST_MATCH_MODE
settings.voting_round = 1
return settings.match_mode
def set_voting_round(session, voting_round):
settings = ensure_settings(session)
settings.voting_round = max(1, int(voting_round))
return settings.voting_round