Files
Kochbuch/recipes/parsing.py
T
Kyle DammannandClaude Opus 5.5 6f7e4f92ca Add phase 2: recipes with visibility, photos, search and favourites
- Recipe model with ingredients (amount/range, unit, group, note), steps,
  lowercase tags, author notes, source URL and private/public visibility
- Central RecipeQuerySet.visible_to() used by every read path; edit only
  by owner, delete by owner or admin (visible recipes only)
- Text-based editing: ingredient/step parser with roundtrip and scaling
- Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF,
  thumbnails, cover image, served only through permission-checked view
- Search across title, description, ingredients, tags; ingredient and tag
  filters; "mine" and favourites views
- Admin can delete all recipes of a deactivated user
- Acceptance tests: private recipes unreachable for others and admins
  in list, search, tags, favourites, detail, edit, delete and image URLs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:37:13 +01:00

249 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Parsing and formatting of ingredient lines, steps and tags.
Recipes are edited as plain text (one ingredient per line, group headers end with ":",
steps separated by blank lines). This module converts between that text and the
structured data stored in the database, and scales amounts for the serving calculator.
"""
import re
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
MAX_TAGS = 15
MAX_TAG_LENGTH = 40
# Abbreviations that get a canonical spelling.
CANONICAL_UNITS = {
"g": "g", "gr": "g", "gr.": "g", "gramm": "g",
"kg": "kg", "kilo": "kg", "kilogramm": "kg",
"mg": "mg",
"ml": "ml", "milliliter": "ml",
"cl": "cl", "dl": "dl",
"l": "l", "liter": "l", "ltr": "l", "ltr.": "l",
"el": "EL", "el.": "EL", "essl.": "EL", "esslöffel": "EL", "tbsp": "EL", "tbsp.": "EL",
"tl": "TL", "tl.": "TL", "teel.": "TL", "teelöffel": "TL", "tsp": "TL", "tsp.": "TL",
"msp": "Msp.", "msp.": "Msp.", "messerspitze": "Msp.", "messerspitzen": "Msp.",
"pck": "Pck.", "pck.": "Pck.", "pkg": "Pck.", "pkg.": "Pck.", "päckchen": "Pck.", "packung": "Pck.",
"stk": "Stück", "stk.": "Stück", "st.": "Stück", "stück": "Stück",
"prise": "Prise", "prisen": "Prise",
"cup": "Cup", "cups": "Cup", "oz": "oz", "lb": "lb", "lbs": "lb",
}
# Unit words kept as written (plural forms matter in German).
UNIT_WORDS = {
"bund", "dose", "dosen", "zehe", "zehen", "tasse", "tassen", "becher", "glas", "gläser",
"scheibe", "scheiben", "handvoll", "zweig", "zweige", "blatt", "blätter", "schuss",
"tropfen", "würfel", "stange", "stangen", "knolle", "knollen", "kopf", "köpfe",
"flasche", "flaschen", "beutel", "tafel", "tafeln", "spritzer", "stiel", "stiele",
}
FRACTION_CHARS = {"½": Decimal("0.5"), "¼": Decimal("0.25"), "¾": Decimal("0.75"), "⅓": Decimal(1) / 3,
"⅔": Decimal(2) / 3, "⅛": Decimal("0.125")}
NICE_FRACTIONS = [(Decimal("0.125"), "⅛"), (Decimal("0.25"), "¼"), (Decimal(1) / 3, "⅓"), (Decimal("0.5"), "½"),
(Decimal(2) / 3, "⅔"), (Decimal("0.75"), "¾")]
_FRAC = "".join(FRACTION_CHARS)
_NUMBER = rf"(?:\d+\s+\d+\s*/\s*\d+|\d+\s*/\s*\d+|\d+\s*[{_FRAC}]|[{_FRAC}]|\d+(?:[.,]\d+)?)"
_AMOUNT_RE = re.compile(rf"^(?P<a>{_NUMBER})(?:\s*(?:-|–|bis)\s*(?P<b>{_NUMBER}))?(?=[\s\D]|$)", re.IGNORECASE)
_BULLET_RE = re.compile(r"^\s*(?:[-*•·▢□◦]|\d+[.)](?=\s))\s*")
_STEP_NUMBER_RE = re.compile(r"^\s*(?:schritt\s*)?\d+\s*[.):]\s*", re.IGNORECASE)
@dataclass
class ParsedIngredient:
name: str
amount: Decimal | None = None
amount_max: Decimal | None = None
unit: str = ""
note: str = ""
group: str = ""
def parse_number(text):
text = text.strip().replace(" ", " ")
if not text:
return None
try:
if "/" in text:
parts = text.split()
whole = Decimal(parts[0]) if len(parts) == 2 else Decimal(0)
num, den = (parts[-1] if len(parts) == 2 else text).replace(" ", "").split("/")
if Decimal(den) == 0:
return None
return whole + Decimal(num) / Decimal(den)
if text[-1] in FRACTION_CHARS:
whole = text[:-1].strip()
return (Decimal(whole) if whole else Decimal(0)) + FRACTION_CHARS[text[-1]]
return Decimal(text.replace(",", "."))
except (InvalidOperation, ValueError):
return None
def _split_unit(rest):
"""Split a leading unit off the remaining text. Returns (unit, remainder)."""
match = re.match(r"^(\S+)(?:\s+(.*))?$", rest)
if not match:
return "", rest
word, remainder = match.group(1), (match.group(2) or "").strip()
if not remainder:
return "", rest # "2 Eier": the only word is the ingredient, not a unit
key = word.lower()
if key in CANONICAL_UNITS:
return CANONICAL_UNITS[key], remainder
if key in UNIT_WORDS:
return word, remainder
return "", rest
def parse_ingredient_line(line, group=""):
line = _BULLET_RE.sub("", line.strip()).strip()
if not line:
return None
amount = amount_max = None
rest = line
match = _AMOUNT_RE.match(line)
if match:
amount = parse_number(match.group("a"))
amount_max = parse_number(match.group("b")) if match.group("b") else None
if amount is not None:
rest = line[match.end():].strip()
# "200g Mehl": unit glued to the number
glued = re.match(r"^([a-zA-ZäöüÄÖÜ.]+)\s+(.*)$", rest)
if glued and glued.group(1).lower() in CANONICAL_UNITS and not line[match.end():match.end() + 1].isspace():
unit, rest = CANONICAL_UNITS[glued.group(1).lower()], glued.group(2)
else:
unit, rest = _split_unit(rest)
else:
unit = ""
else:
unit = ""
name, note = rest, ""
if "," in rest:
name, note = (part.strip() for part in rest.split(",", 1))
if not name:
name, note = note, ""
return ParsedIngredient(name=name, amount=amount, amount_max=amount_max, unit=unit, note=note, group=group)
def is_group_header(line):
stripped = line.strip()
return stripped.endswith(":") and len(stripped) > 1 and not _AMOUNT_RE.match(stripped)
def parse_ingredients(text):
"""Parse the ingredient textarea into a list of ParsedIngredient."""
items, group = [], ""
for raw in (text or "").splitlines():
if not raw.strip():
continue
if is_group_header(raw):
group = _BULLET_RE.sub("", raw.strip())[:-1].strip()[:100]
continue
item = parse_ingredient_line(raw, group)
if item:
items.append(item)
return items
def parse_steps(text):
"""Blank lines separate steps; without blank lines every line is a step."""
text = (text or "").replace("\r\n", "\n").strip()
if not text:
return []
chunks = re.split(r"\n\s*\n", text) if re.search(r"\n\s*\n", text) else text.split("\n")
steps = []
for chunk in chunks:
chunk = " ".join(part.strip() for part in chunk.split("\n") if part.strip())
chunk = _STEP_NUMBER_RE.sub("", chunk).strip()
if chunk:
steps.append(chunk)
return steps
def normalize_tag(tag):
tag = re.sub(r"\s+", " ", (tag or "").strip().lstrip("#")).lower()
return tag[:MAX_TAG_LENGTH]
def parse_tags(text):
seen, tags = set(), []
for raw in re.split(r"[,;\n]", text or ""):
tag = normalize_tag(raw)
if tag and tag not in seen:
seen.add(tag)
tags.append(tag)
return tags[:MAX_TAGS]
# --- Formatting & scaling ------------------------------------------------------------
def format_amount(value):
if value is None:
return ""
value = Decimal(value)
whole = int(value)
frac = value - whole
for target, glyph in NICE_FRACTIONS:
if abs(frac - target) < Decimal("0.02"):
return f"{whole} {glyph}" if whole else glyph
if frac < Decimal("0.02"):
return str(whole)
if frac > Decimal("0.98"):
return str(whole + 1)
rounded = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP).normalize()
return f"{rounded:f}".replace(".", ",")
def _round_metric(value):
"""Gram/millilitre amounts: no fractions; coarser rounding for big numbers."""
if value >= 100:
step = Decimal(5)
elif value >= 10:
step = Decimal(1)
else:
return value
return (value / step).quantize(Decimal(1), rounding=ROUND_HALF_UP) * step
def scale_amount(amount, amount_max, unit, factor):
"""Scale an amount (and optional upper bound of a range) and normalise metric units
(1250 g -> 1,25 kg). Returns (amount, amount_max, unit)."""
if amount is None:
return None, None, unit
values = [Decimal(v) * Decimal(factor) if v is not None else None for v in (amount, amount_max)]
if unit in ("g", "ml"):
values = [_round_metric(v) if v is not None else None for v in values]
if values[0] >= 1000:
values = [v / 1000 if v is not None else None for v in values]
unit = "kg" if unit == "g" else "l"
return values[0], values[1], unit
def format_ingredient(amount, amount_max, unit, name, note=""):
parts = []
if amount is not None:
parts.append(format_amount(amount) + (f"-{format_amount(amount_max)}" if amount_max is not None else ""))
if unit:
parts.append(unit)
parts.append(name)
text = " ".join(parts)
return f"{text}, {note}" if note else text
def ingredients_to_text(ingredients):
"""Inverse of parse_ingredients for the edit form. Accepts objects with the same attributes."""
lines, group = [], ""
for ing in ingredients:
if ing.group != group:
if lines:
lines.append("")
if ing.group:
lines.append(f"{ing.group}:")
group = ing.group
lines.append(format_ingredient(ing.amount, ing.amount_max, ing.unit, ing.name, ing.note))
return "\n".join(lines)
def steps_to_text(steps):
return "\n\n".join(steps)