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