Public Access
feat(meals): enforce three lower-calorie dinners with media
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
Ingredient,
|
||||
Recipe,
|
||||
RecipeCatalogStatus,
|
||||
RecipeImportStaging,
|
||||
RecipeImportStatus,
|
||||
)
|
||||
from app.services.wikibooks_import import (
|
||||
WIKIBOOKS_SOURCE,
|
||||
WikibooksImportService,
|
||||
canonical_json_sha256,
|
||||
)
|
||||
|
||||
|
||||
class RecipeImportNotFoundError(LookupError):
|
||||
"""Raised when a staged recipe does not exist."""
|
||||
|
||||
|
||||
class RecipeImportConflictError(ValueError):
|
||||
"""Raised when a review transition or publication is unsafe."""
|
||||
|
||||
|
||||
def approve_recipe_import(
|
||||
db: Session, import_id: UUID, reviewed_by: str
|
||||
) -> tuple[RecipeImportStaging, bool]:
|
||||
"""Atomically publish one pending Wikibooks candidate."""
|
||||
staged = _locked_import(db, import_id)
|
||||
if staged.status == RecipeImportStatus.APPROVED:
|
||||
if staged.recipe_id and db.get(Recipe, staged.recipe_id):
|
||||
return staged, False
|
||||
elif staged.status != RecipeImportStatus.PENDING:
|
||||
raise RecipeImportConflictError(
|
||||
f"Only pending imports can be approved; current status is {staged.status.value}"
|
||||
)
|
||||
if staged.source != WIKIBOOKS_SOURCE:
|
||||
raise RecipeImportConflictError(f"Unsupported recipe source: {staged.source}")
|
||||
|
||||
raw_payload = _mapping(staged.raw_payload, "raw_payload")
|
||||
if canonical_json_sha256(raw_payload) != staged.payload_sha256:
|
||||
raise RecipeImportConflictError("Raw payload checksum does not match provenance")
|
||||
|
||||
normalizer = WikibooksImportService("MealPlanner recipe review")
|
||||
normalized = normalizer.normalize_page(raw_payload)
|
||||
validation_report = normalizer.validate_recipe(normalized)
|
||||
if not validation_report["valid"]:
|
||||
raise RecipeImportConflictError("Candidate no longer passes publication validation")
|
||||
|
||||
existing = (
|
||||
db.query(Recipe)
|
||||
.filter(
|
||||
Recipe.external_source == staged.source,
|
||||
Recipe.external_id == staged.external_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise RecipeImportConflictError(
|
||||
"A recipe from this source page is already published"
|
||||
)
|
||||
|
||||
ingredient_refs = _ingredient_refs(db, normalized["ingredients"])
|
||||
cook_time = (normalized.get("cook_time_minutes") or 0) + (
|
||||
normalized.get("baking_time_minutes") or 0
|
||||
)
|
||||
recipe = Recipe(
|
||||
family_profile_id=None,
|
||||
name=normalized["name"],
|
||||
description=normalized.get("description"),
|
||||
image_url=None,
|
||||
image_source=None,
|
||||
prep_time_minutes=normalized.get("prep_time_minutes"),
|
||||
cook_time_minutes=cook_time or None,
|
||||
servings=normalized["servings"],
|
||||
cuisine_tags=[],
|
||||
dietary_tags=[],
|
||||
protein_type=None,
|
||||
spice_level=None,
|
||||
calories_per_serving=None,
|
||||
ingredients=ingredient_refs,
|
||||
side_dishes=[],
|
||||
instructions=list(normalized["instructions"]),
|
||||
source_url=staged.source_url,
|
||||
scraped_at=staged.acquired_at,
|
||||
is_manually_added=False,
|
||||
external_source=staged.source,
|
||||
external_id=staged.external_id,
|
||||
discovery_reason=f"Approved import from revision {staged.source_revision}",
|
||||
catalog_status=RecipeCatalogStatus.ACTIVE,
|
||||
catalog_findings=[],
|
||||
)
|
||||
db.add(recipe)
|
||||
db.flush()
|
||||
|
||||
staged.normalized_recipe = normalized
|
||||
staged.validation_report = validation_report
|
||||
staged.status = RecipeImportStatus.APPROVED
|
||||
staged.rejection_reason = None
|
||||
staged.recipe_id = recipe.id
|
||||
staged.reviewed_at = datetime.now(timezone.utc)
|
||||
staged.reviewed_by = reviewed_by[:300]
|
||||
db.commit()
|
||||
db.refresh(staged)
|
||||
return staged, True
|
||||
|
||||
|
||||
def reject_recipe_import(
|
||||
db: Session, import_id: UUID, reason: str, reviewed_by: str
|
||||
) -> RecipeImportStaging:
|
||||
"""Atomically reject a pending or quarantined candidate."""
|
||||
staged = _locked_import(db, import_id)
|
||||
if staged.status == RecipeImportStatus.APPROVED:
|
||||
raise RecipeImportConflictError("Approved imports cannot be rejected")
|
||||
if staged.status == RecipeImportStatus.REJECTED:
|
||||
if staged.rejection_reason == reason:
|
||||
return staged
|
||||
raise RecipeImportConflictError("Import was already rejected with another reason")
|
||||
|
||||
staged.status = RecipeImportStatus.REJECTED
|
||||
staged.rejection_reason = reason
|
||||
staged.reviewed_at = datetime.now(timezone.utc)
|
||||
staged.reviewed_by = reviewed_by[:300]
|
||||
db.commit()
|
||||
db.refresh(staged)
|
||||
return staged
|
||||
|
||||
|
||||
def _locked_import(db: Session, import_id: UUID) -> RecipeImportStaging:
|
||||
staged = db.execute(
|
||||
select(RecipeImportStaging)
|
||||
.where(RecipeImportStaging.id == import_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if staged is None:
|
||||
raise RecipeImportNotFoundError("Recipe import not found")
|
||||
return staged
|
||||
|
||||
|
||||
def _ingredient_refs(
|
||||
db: Session, ingredients: list[Mapping[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
prepared: list[tuple[str, str, float, str | None, str | None]] = []
|
||||
for item in ingredients:
|
||||
name = " ".join(str(item.get("name", "")).split())
|
||||
name_lower = name.casefold()
|
||||
quantity = item.get("quantity")
|
||||
unit = item.get("unit")
|
||||
source_text = item.get("source_text")
|
||||
if not name or len(name) > 200:
|
||||
raise RecipeImportConflictError("Ingredient name is invalid")
|
||||
if not isinstance(quantity, (int, float)) or isinstance(quantity, bool) or quantity <= 0:
|
||||
raise RecipeImportConflictError("Ingredient quantity is invalid")
|
||||
if unit is not None and (not isinstance(unit, str) or len(unit) > 50):
|
||||
raise RecipeImportConflictError("Ingredient unit is invalid")
|
||||
prepared.append((name, name_lower, float(quantity), unit, source_text))
|
||||
|
||||
for name, name_lower, _, unit, _ in prepared:
|
||||
db.execute(
|
||||
pg_insert(Ingredient.__table__)
|
||||
.values(name=name, name_lower=name_lower, unit=unit, aliases=[])
|
||||
.on_conflict_do_nothing(index_elements=[Ingredient.name_lower])
|
||||
)
|
||||
|
||||
canonical = {
|
||||
row.name_lower: row
|
||||
for row in db.query(Ingredient)
|
||||
.filter(Ingredient.name_lower.in_([item[1] for item in prepared]))
|
||||
.all()
|
||||
}
|
||||
if len(canonical) != len({item[1] for item in prepared}):
|
||||
raise RecipeImportConflictError("Canonical ingredient creation failed")
|
||||
|
||||
return [
|
||||
{
|
||||
"ingredient_id": str(canonical[name_lower].id),
|
||||
"qty": quantity,
|
||||
"unit": unit,
|
||||
"notes": source_text,
|
||||
}
|
||||
for _, name_lower, quantity, unit, source_text in prepared
|
||||
]
|
||||
|
||||
|
||||
def _mapping(value: Any, field: str) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise RecipeImportConflictError(f"{field} must be an object")
|
||||
return value
|
||||
Reference in New Issue
Block a user