Public Access
197 lines
6.8 KiB
Python
197 lines
6.8 KiB
Python
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
|