Public Access
feat(meals): enforce three lower-calorie dinners with media
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
"""Validate and atomically load the bundled starter recipe catalog."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Ingredient, Recipe, RecipeCatalogStatus
|
||||
|
||||
|
||||
STARTER_CATALOG_VERSION = 2
|
||||
STARTER_RECIPE_COUNT = 50
|
||||
STARTER_DISCOVERY_REASON = "bundled_starter_catalog_v1"
|
||||
# Fixed PostgreSQL bigint advisory-lock key for this loader (ASCII: MPLSTART).
|
||||
# Changing it would allow concurrent catalog loads to bypass each other.
|
||||
STARTER_CATALOG_ADVISORY_LOCK_ID = 0x4D504C5354415254
|
||||
DEFAULT_CATALOG_PATH = Path(__file__).resolve().parents[1] / "data" / "starter_recipes.json"
|
||||
|
||||
MAX_SERVINGS = 50
|
||||
MAX_TIME_MINUTES = 1_440
|
||||
MAX_INGREDIENT_QUANTITY = 10_000
|
||||
MAX_CALORIES_PER_SERVING = 500
|
||||
MAX_SPICE_LEVEL = 5
|
||||
_PLACEHOLDER_TEXT = frozenset(
|
||||
{"coming soon", "directions", "instructions", "method", "n a", "tbd", "todo"}
|
||||
)
|
||||
_PLACEHOLDER_PHRASES = ("lorem ipsum", "placeholder")
|
||||
_RECIPE_KEYS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"description",
|
||||
"servings",
|
||||
"prep_time_minutes",
|
||||
"cook_time_minutes",
|
||||
"cuisine_tags",
|
||||
"dietary_tags",
|
||||
"protein_type",
|
||||
"spice_level",
|
||||
"calories_per_serving",
|
||||
"ingredients",
|
||||
"instructions",
|
||||
}
|
||||
)
|
||||
_INGREDIENT_KEYS = frozenset({"name", "quantity", "unit"})
|
||||
_CATALOG_SCALAR_FIELDS = (
|
||||
"description",
|
||||
"servings",
|
||||
"prep_time_minutes",
|
||||
"cook_time_minutes",
|
||||
"cuisine_tags",
|
||||
"dietary_tags",
|
||||
"protein_type",
|
||||
"spice_level",
|
||||
"calories_per_serving",
|
||||
)
|
||||
|
||||
|
||||
class StarterRecipeCatalogError(ValueError):
|
||||
"""Raised when the bundled catalog is incomplete or unsafe to load."""
|
||||
|
||||
|
||||
def read_starter_catalog(path: Path = DEFAULT_CATALOG_PATH) -> Mapping[str, Any]:
|
||||
"""Read the JSON catalog without accepting non-standard numeric constants."""
|
||||
try:
|
||||
with path.open(encoding="utf-8") as catalog_file:
|
||||
catalog = json.load(catalog_file, parse_constant=_reject_json_constant)
|
||||
except OSError as exc:
|
||||
raise StarterRecipeCatalogError(f"Catalog cannot be read: {path}") from exc
|
||||
except ValueError as exc:
|
||||
raise StarterRecipeCatalogError("Catalog is not valid JSON") from exc
|
||||
if not isinstance(catalog, Mapping):
|
||||
raise StarterRecipeCatalogError("Catalog root must be an object")
|
||||
return catalog
|
||||
|
||||
|
||||
def validate_starter_catalog(catalog: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return normalized recipes after validating the complete, fixed-size catalog."""
|
||||
_require_exact_keys(catalog, {"version", "recipes"}, "catalog")
|
||||
if catalog["version"] != STARTER_CATALOG_VERSION:
|
||||
raise StarterRecipeCatalogError(
|
||||
f"Catalog version must be {STARTER_CATALOG_VERSION}"
|
||||
)
|
||||
recipes = catalog["recipes"]
|
||||
if not isinstance(recipes, list) or len(recipes) != STARTER_RECIPE_COUNT:
|
||||
raise StarterRecipeCatalogError(
|
||||
f"Catalog must contain exactly {STARTER_RECIPE_COUNT} recipes"
|
||||
)
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
names: set[str] = set()
|
||||
for index, recipe in enumerate(recipes, 1):
|
||||
if not isinstance(recipe, Mapping):
|
||||
raise StarterRecipeCatalogError(f"Recipe {index} must be an object")
|
||||
_require_exact_keys(recipe, _RECIPE_KEYS, f"Recipe {index}")
|
||||
name = _text(recipe["name"], f"Recipe {index} name", maximum=300)
|
||||
name_key = _canonical_name(name)
|
||||
if name_key in names:
|
||||
raise StarterRecipeCatalogError(f"Recipe {index} name is not unique")
|
||||
names.add(name_key)
|
||||
|
||||
ingredients = _validate_ingredients(recipe["ingredients"], index)
|
||||
instructions = _validate_instructions(recipe["instructions"], index)
|
||||
normalized.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": _text(
|
||||
recipe["description"], f"Recipe {index} description", maximum=10_000
|
||||
),
|
||||
"servings": _positive_int(
|
||||
recipe["servings"], f"Recipe {index} servings", MAX_SERVINGS
|
||||
),
|
||||
"prep_time_minutes": _positive_int(
|
||||
recipe["prep_time_minutes"],
|
||||
f"Recipe {index} prep_time_minutes",
|
||||
MAX_TIME_MINUTES,
|
||||
),
|
||||
"cook_time_minutes": _positive_int(
|
||||
recipe["cook_time_minutes"],
|
||||
f"Recipe {index} cook_time_minutes",
|
||||
MAX_TIME_MINUTES,
|
||||
),
|
||||
"cuisine_tags": _tags(recipe["cuisine_tags"], f"Recipe {index} cuisine_tags"),
|
||||
"dietary_tags": _tags(recipe["dietary_tags"], f"Recipe {index} dietary_tags"),
|
||||
"protein_type": _text(
|
||||
recipe["protein_type"], f"Recipe {index} protein_type", maximum=50
|
||||
),
|
||||
"spice_level": _bounded_int(
|
||||
recipe["spice_level"], f"Recipe {index} spice_level", 0, MAX_SPICE_LEVEL
|
||||
),
|
||||
"calories_per_serving": _positive_int(
|
||||
recipe["calories_per_serving"],
|
||||
f"Recipe {index} calories_per_serving",
|
||||
MAX_CALORIES_PER_SERVING,
|
||||
),
|
||||
"ingredients": ingredients,
|
||||
"instructions": instructions,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def load_bundled_starter_catalog(
|
||||
db: Session,
|
||||
*,
|
||||
path: Path = DEFAULT_CATALOG_PATH,
|
||||
dry_run: bool = False,
|
||||
update_existing: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Load the bundled JSON catalog after validating all recipes before writes."""
|
||||
return load_starter_catalog(
|
||||
db,
|
||||
read_starter_catalog(path),
|
||||
dry_run=dry_run,
|
||||
update_existing=update_existing,
|
||||
)
|
||||
|
||||
|
||||
def load_starter_catalog(
|
||||
db: Session,
|
||||
catalog: Mapping[str, Any],
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
update_existing: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Load or explicitly synchronize bundled recipes in one transaction."""
|
||||
recipes = validate_starter_catalog(catalog)
|
||||
catalog_names = {recipe["name"] for recipe in recipes}
|
||||
catalog_by_name = {recipe["name"]: recipe for recipe in recipes}
|
||||
ingredient_names = {
|
||||
_canonical_name(ingredient["name"]): ingredient
|
||||
for recipe in recipes
|
||||
for ingredient in recipe["ingredients"]
|
||||
}
|
||||
|
||||
with db.begin():
|
||||
# This transaction-scoped lock must precede every catalog Recipe read or write.
|
||||
db.execute(select(func.pg_advisory_xact_lock(STARTER_CATALOG_ADVISORY_LOCK_ID)))
|
||||
marked_recipes = list(
|
||||
db.execute(
|
||||
select(Recipe)
|
||||
.where(Recipe.discovery_reason == STARTER_DISCOVERY_REASON)
|
||||
.with_for_update()
|
||||
).scalars()
|
||||
)
|
||||
marked_by_name = {recipe.name: recipe for recipe in marked_recipes}
|
||||
if len(marked_by_name) != len(marked_recipes):
|
||||
raise StarterRecipeCatalogError("Bundled catalog has duplicate persisted recipe names")
|
||||
unknown_marked_names = set(marked_by_name).difference(catalog_names)
|
||||
if unknown_marked_names:
|
||||
raise StarterRecipeCatalogError(
|
||||
"Bundled catalog marker is attached to an unknown recipe"
|
||||
)
|
||||
resolved_recipe_ingredients = _resolve_recipe_ingredients(db, marked_recipes)
|
||||
missing_marked_names = catalog_names.difference(marked_by_name)
|
||||
if update_existing and missing_marked_names:
|
||||
raise StarterRecipeCatalogError(
|
||||
"Update requires exactly all "
|
||||
f"{STARTER_RECIPE_COUNT} existing marker-owned recipe names"
|
||||
)
|
||||
|
||||
recipes_to_update: list[Recipe] = []
|
||||
for recipe in marked_recipes:
|
||||
expected = catalog_by_name[recipe.name]
|
||||
if update_existing:
|
||||
_assert_bundled_recipe_ownership(recipe)
|
||||
if not _bundled_recipe_content_matches(
|
||||
recipe, expected, resolved_recipe_ingredients
|
||||
):
|
||||
recipes_to_update.append(recipe)
|
||||
else:
|
||||
_assert_bundled_recipe(
|
||||
recipe,
|
||||
expected,
|
||||
resolved_recipe_ingredients,
|
||||
)
|
||||
|
||||
name_collisions = list(
|
||||
db.execute(
|
||||
select(Recipe).where(Recipe.name.in_(catalog_names)).with_for_update()
|
||||
).scalars()
|
||||
)
|
||||
if any(recipe.discovery_reason != STARTER_DISCOVERY_REASON for recipe in name_collisions):
|
||||
raise StarterRecipeCatalogError(
|
||||
"Catalog recipe name conflicts with an existing non-bundled recipe"
|
||||
)
|
||||
|
||||
canonical_ingredients = {
|
||||
ingredient.name_lower: ingredient
|
||||
for ingredient in db.execute(
|
||||
select(Ingredient).where(Ingredient.name_lower.in_(ingredient_names))
|
||||
).scalars()
|
||||
}
|
||||
new_recipes = (
|
||||
[]
|
||||
if update_existing
|
||||
else [recipe for recipe in recipes if recipe["name"] not in marked_by_name]
|
||||
)
|
||||
summary = {
|
||||
"catalog_version": STARTER_CATALOG_VERSION,
|
||||
"dry_run": dry_run,
|
||||
"validated_recipes": len(recipes),
|
||||
"existing_recipes": len(marked_recipes),
|
||||
"created_recipes": len(new_recipes),
|
||||
"updated_recipes": len(recipes_to_update),
|
||||
"existing_ingredients": len(canonical_ingredients),
|
||||
"created_ingredients": len(ingredient_names) - len(canonical_ingredients),
|
||||
}
|
||||
if dry_run:
|
||||
return summary
|
||||
|
||||
for name_lower, ingredient in ingredient_names.items():
|
||||
db.execute(
|
||||
pg_insert(Ingredient.__table__)
|
||||
.values(
|
||||
name=ingredient["name"],
|
||||
name_lower=name_lower,
|
||||
unit=ingredient["unit"],
|
||||
aliases=[],
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[Ingredient.name_lower])
|
||||
)
|
||||
canonical_ingredients = {
|
||||
ingredient.name_lower: ingredient
|
||||
for ingredient in db.execute(
|
||||
select(Ingredient).where(Ingredient.name_lower.in_(ingredient_names))
|
||||
).scalars()
|
||||
}
|
||||
if set(canonical_ingredients) != set(ingredient_names):
|
||||
raise StarterRecipeCatalogError("Canonical ingredient creation failed")
|
||||
|
||||
for recipe in recipes_to_update:
|
||||
_synchronize_bundled_recipe(
|
||||
recipe,
|
||||
catalog_by_name[recipe.name],
|
||||
canonical_ingredients,
|
||||
)
|
||||
|
||||
for recipe in new_recipes:
|
||||
db.add(
|
||||
Recipe(
|
||||
family_profile_id=None,
|
||||
name=recipe["name"],
|
||||
description=recipe["description"],
|
||||
image_url=None,
|
||||
image_source=None,
|
||||
prep_time_minutes=recipe["prep_time_minutes"],
|
||||
cook_time_minutes=recipe["cook_time_minutes"],
|
||||
servings=recipe["servings"],
|
||||
cuisine_tags=recipe["cuisine_tags"],
|
||||
dietary_tags=recipe["dietary_tags"],
|
||||
protein_type=recipe["protein_type"],
|
||||
spice_level=recipe["spice_level"],
|
||||
calories_per_serving=recipe["calories_per_serving"],
|
||||
ingredients=_catalog_recipe_ingredients(recipe, canonical_ingredients),
|
||||
side_dishes=[],
|
||||
instructions=recipe["instructions"],
|
||||
source_url=None,
|
||||
scraped_at=None,
|
||||
is_manually_added=True,
|
||||
external_source=None,
|
||||
external_id=None,
|
||||
discovery_reason=STARTER_DISCOVERY_REASON,
|
||||
catalog_status=RecipeCatalogStatus.ACTIVE,
|
||||
catalog_findings=[],
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
return summary
|
||||
|
||||
|
||||
def _validate_ingredients(value: Any, recipe_index: int) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list) or len(value) < 2:
|
||||
raise StarterRecipeCatalogError(f"Recipe {recipe_index} needs at least two ingredients")
|
||||
ingredients: list[dict[str, Any]] = []
|
||||
for index, ingredient in enumerate(value, 1):
|
||||
if not isinstance(ingredient, Mapping):
|
||||
raise StarterRecipeCatalogError(
|
||||
f"Recipe {recipe_index} ingredient {index} must be an object"
|
||||
)
|
||||
_require_exact_keys(
|
||||
ingredient,
|
||||
_INGREDIENT_KEYS,
|
||||
f"Recipe {recipe_index} ingredient {index}",
|
||||
)
|
||||
ingredients.append(
|
||||
{
|
||||
"name": _text(
|
||||
ingredient["name"],
|
||||
f"Recipe {recipe_index} ingredient {index} name",
|
||||
maximum=200,
|
||||
),
|
||||
"quantity": _positive_number(
|
||||
ingredient["quantity"],
|
||||
f"Recipe {recipe_index} ingredient {index} quantity",
|
||||
MAX_INGREDIENT_QUANTITY,
|
||||
),
|
||||
"unit": _text(
|
||||
ingredient["unit"], f"Recipe {recipe_index} ingredient {index} unit", maximum=50
|
||||
),
|
||||
}
|
||||
)
|
||||
return ingredients
|
||||
|
||||
|
||||
def _validate_instructions(value: Any, recipe_index: int) -> list[str]:
|
||||
if not isinstance(value, list) or len(value) < 2:
|
||||
raise StarterRecipeCatalogError(f"Recipe {recipe_index} needs at least two instructions")
|
||||
instructions = [
|
||||
_text(instruction, f"Recipe {recipe_index} instruction {index}", maximum=5_000)
|
||||
for index, instruction in enumerate(value, 1)
|
||||
]
|
||||
for instruction in instructions:
|
||||
if len(instruction) < 20 or len(re.findall(r"[A-Za-z0-9]+", instruction)) < 4:
|
||||
raise StarterRecipeCatalogError(
|
||||
f"Recipe {recipe_index} instructions must be substantive"
|
||||
)
|
||||
return instructions
|
||||
|
||||
|
||||
def _tags(value: Any, field: str) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise StarterRecipeCatalogError(f"{field} must be an array")
|
||||
return [_text(tag, field, maximum=50) for tag in value]
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, maximum: int) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise StarterRecipeCatalogError(f"{field} must be text")
|
||||
text = " ".join(value.split())
|
||||
if not text or len(text) > maximum or _is_placeholder(text):
|
||||
raise StarterRecipeCatalogError(f"{field} is missing, too long, or placeholder text")
|
||||
return text
|
||||
|
||||
|
||||
def _positive_int(value: Any, field: str, maximum: int) -> int:
|
||||
return _bounded_int(value, field, 1, maximum)
|
||||
|
||||
|
||||
def _bounded_int(value: Any, field: str, minimum: int, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
||||
raise StarterRecipeCatalogError(f"{field} must be an integer between {minimum} and {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_number(value: Any, field: str, maximum: int) -> int | float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise StarterRecipeCatalogError(f"{field} must be a number")
|
||||
if not math.isfinite(value) or not 0 < value <= maximum:
|
||||
raise StarterRecipeCatalogError(f"{field} must be greater than zero and at most {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _require_exact_keys(
|
||||
value: Mapping[str, Any], expected: set[str] | frozenset[str], field: str
|
||||
) -> None:
|
||||
if set(value) != set(expected):
|
||||
raise StarterRecipeCatalogError(f"{field} has unexpected or missing fields")
|
||||
|
||||
|
||||
def _canonical_name(value: str) -> str:
|
||||
return " ".join(value.split()).casefold()
|
||||
|
||||
|
||||
def _is_placeholder(value: str) -> bool:
|
||||
normalized = re.sub(r"[^a-z0-9]+", " ", value.casefold()).strip()
|
||||
return normalized in _PLACEHOLDER_TEXT or any(
|
||||
phrase in normalized for phrase in _PLACEHOLDER_PHRASES
|
||||
)
|
||||
|
||||
|
||||
def _resolve_recipe_ingredients(
|
||||
db: Session, recipes: list[Recipe]
|
||||
) -> dict[str, Ingredient]:
|
||||
ingredient_ids: set[UUID] = set()
|
||||
for recipe in recipes:
|
||||
if not isinstance(recipe.ingredients, list):
|
||||
continue
|
||||
for item in recipe.ingredients:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
try:
|
||||
ingredient_ids.add(UUID(str(item.get("ingredient_id"))))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
if not ingredient_ids:
|
||||
return {}
|
||||
return {
|
||||
str(ingredient.id): ingredient
|
||||
for ingredient in db.execute(
|
||||
select(Ingredient).where(Ingredient.id.in_(ingredient_ids))
|
||||
).scalars()
|
||||
}
|
||||
|
||||
|
||||
def _assert_bundled_recipe(
|
||||
recipe: Recipe,
|
||||
expected: Mapping[str, Any],
|
||||
resolved_ingredients: Mapping[str, Ingredient],
|
||||
) -> None:
|
||||
_assert_bundled_recipe_ownership(recipe)
|
||||
if not _bundled_recipe_content_matches(recipe, expected, resolved_ingredients):
|
||||
raise StarterRecipeCatalogError("Existing bundled recipe content has drifted")
|
||||
|
||||
|
||||
def _assert_bundled_recipe_ownership(recipe: Recipe) -> None:
|
||||
if (
|
||||
recipe.family_profile_id is not None
|
||||
or not recipe.is_manually_added
|
||||
or recipe.external_source is not None
|
||||
or recipe.external_id is not None
|
||||
or recipe.source_url is not None
|
||||
or recipe.scraped_at is not None
|
||||
or recipe.image_url is not None
|
||||
or recipe.image_source is not None
|
||||
or recipe.servings_scaled is not None
|
||||
or recipe.side_dishes != []
|
||||
or recipe.discovery_reason != STARTER_DISCOVERY_REASON
|
||||
or recipe.catalog_status != RecipeCatalogStatus.ACTIVE
|
||||
or recipe.catalog_findings != []
|
||||
or recipe.catalog_reviewed_at is not None
|
||||
):
|
||||
raise StarterRecipeCatalogError(
|
||||
"Existing bundled recipe does not match the required catalog ownership"
|
||||
)
|
||||
|
||||
|
||||
def _bundled_recipe_content_matches(
|
||||
recipe: Recipe,
|
||||
expected: Mapping[str, Any],
|
||||
resolved_ingredients: Mapping[str, Ingredient],
|
||||
) -> bool:
|
||||
if any(getattr(recipe, field) != expected[field] for field in _CATALOG_SCALAR_FIELDS):
|
||||
return False
|
||||
if list(recipe.instructions or []) != expected["instructions"]:
|
||||
return False
|
||||
if not isinstance(recipe.ingredients, list) or len(recipe.ingredients) != len(
|
||||
expected["ingredients"]
|
||||
):
|
||||
return False
|
||||
for actual, expected_ingredient in zip(recipe.ingredients, expected["ingredients"]):
|
||||
if not isinstance(actual, Mapping) or set(actual) != {
|
||||
"ingredient_id",
|
||||
"qty",
|
||||
"unit",
|
||||
"notes",
|
||||
}:
|
||||
return False
|
||||
resolved = resolved_ingredients.get(str(actual["ingredient_id"]))
|
||||
quantity = actual["qty"]
|
||||
if (
|
||||
resolved is None
|
||||
or resolved.name_lower != _canonical_name(expected_ingredient["name"])
|
||||
or isinstance(quantity, bool)
|
||||
or not isinstance(quantity, (int, float))
|
||||
or not math.isfinite(quantity)
|
||||
or quantity != expected_ingredient["quantity"]
|
||||
or actual["unit"] != expected_ingredient["unit"]
|
||||
or actual["notes"] is not None
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _synchronize_bundled_recipe(
|
||||
recipe: Recipe,
|
||||
expected: Mapping[str, Any],
|
||||
canonical_ingredients: Mapping[str, Ingredient],
|
||||
) -> None:
|
||||
for field in _CATALOG_SCALAR_FIELDS:
|
||||
value = expected[field]
|
||||
setattr(recipe, field, list(value) if field.endswith("_tags") else value)
|
||||
recipe.ingredients = _catalog_recipe_ingredients(expected, canonical_ingredients)
|
||||
recipe.instructions = list(expected["instructions"])
|
||||
|
||||
|
||||
def _catalog_recipe_ingredients(
|
||||
recipe: Mapping[str, Any], canonical_ingredients: Mapping[str, Ingredient]
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"ingredient_id": str(
|
||||
canonical_ingredients[_canonical_name(item["name"])].id
|
||||
),
|
||||
"qty": item["quantity"],
|
||||
"unit": item["unit"],
|
||||
"notes": None,
|
||||
}
|
||||
for item in recipe["ingredients"]
|
||||
]
|
||||
|
||||
|
||||
def _reject_json_constant(value: str) -> None:
|
||||
raise ValueError(f"Unsupported JSON numeric constant: {value}")
|
||||
Reference in New Issue
Block a user