- 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]>
249 lines
9.0 KiB
Python
249 lines
9.0 KiB
Python
"""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)
|