from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Response, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.database import get_db from app.models import ( FamilyProfile, ScrapeLog, EmailLog, MealPlan, Recipe, RecipeImportStaging, RecipeImportStatus, ) from app.schemas.recipe_import import ( RecipeImportDetail, RecipeImportListResponse, RecipeImportRejectRequest, ) from app.security import require_admin from app.services.scraper_service import ScraperService, enqueue_scrape from app.services.feedback_analyzer import FeedbackAnalyzer from app.services.recipe_import_review import ( RecipeImportConflictError, RecipeImportNotFoundError, approve_recipe_import, reject_recipe_import, ) from app.services.wikibooks_import import WikibooksImportError, WikibooksImportService from app.config import settings from typing import List, Optional from datetime import datetime, timedelta from uuid import UUID import logging logger = logging.getLogger(__name__) router = APIRouter(dependencies=[Depends(require_admin)]) def _import_list_item(row: RecipeImportStaging) -> dict: normalized = row.normalized_recipe if isinstance(row.normalized_recipe, dict) else {} return { "id": row.id, "source": row.source, "external_id": row.external_id, "source_url": row.source_url, "source_revision": row.source_revision, "name": normalized.get("name"), "status": row.status.value, "acquired_at": row.acquired_at, "reviewed_at": row.reviewed_at, "reviewed_by": row.reviewed_by, "recipe_id": row.recipe_id, } @router.get("/recipe-imports", response_model=RecipeImportListResponse) def list_recipe_imports( import_status: RecipeImportStatus | None = Query(default=None, alias="status"), source: str | None = Query(default=None, min_length=1, max_length=100), limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ): query = db.query(RecipeImportStaging) if import_status is not None: query = query.filter(RecipeImportStaging.status == import_status) if source is not None: query = query.filter(RecipeImportStaging.source == source) total = query.count() rows = ( query.order_by(RecipeImportStaging.acquired_at.desc(), RecipeImportStaging.id) .offset(offset) .limit(limit) .all() ) return { "items": [_import_list_item(row) for row in rows], "total": total, "limit": limit, "offset": offset, "has_next": offset + len(rows) < total, } @router.get("/recipe-imports/{import_id}", response_model=RecipeImportDetail) def get_recipe_import(import_id: UUID, db: Session = Depends(get_db)): staged = db.get(RecipeImportStaging, import_id) if staged is None: raise HTTPException(status_code=404, detail="Recipe import not found") return staged @router.post("/recipe-imports/{import_id}/approve", response_model=RecipeImportDetail) def approve_staged_recipe( import_id: UUID, response: Response, db: Session = Depends(get_db), reviewer: str = Depends(require_admin), ): try: staged, created = approve_recipe_import(db, import_id, reviewer) except RecipeImportNotFoundError as exc: db.rollback() raise HTTPException(status_code=404, detail=str(exc)) from exc except RecipeImportConflictError as exc: db.rollback() raise HTTPException(status_code=409, detail=str(exc)) from exc except IntegrityError as exc: db.rollback() raise HTTPException(status_code=409, detail="Recipe publication conflicted") from exc response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK return staged @router.post("/recipe-imports/{import_id}/reject", response_model=RecipeImportDetail) def reject_staged_recipe( import_id: UUID, payload: RecipeImportRejectRequest, db: Session = Depends(get_db), reviewer: str = Depends(require_admin), ): try: return reject_recipe_import(db, import_id, payload.reason, reviewer) except RecipeImportNotFoundError as exc: db.rollback() raise HTTPException(status_code=404, detail=str(exc)) from exc except RecipeImportConflictError as exc: db.rollback() raise HTTPException(status_code=409, detail=str(exc)) from exc @router.post("/scrape", status_code=202) def trigger_scrape( background_tasks: BackgroundTasks, source: str = "lucky_california", scrape_type: str = "weekly_ad", db: Session = Depends(get_db), ): log = enqueue_scrape( db, source=source, scrape_type=scrape_type, background_tasks=background_tasks, ) return {"status": "queued", "scrape_log_id": str(log.id)} @router.get("/logs") def get_logs( limit: int = 50, source: Optional[str] = None, db: Session = Depends(get_db) ): query = db.query(ScrapeLog).order_by(ScrapeLog.started_at.desc()) if source: query = query.filter(ScrapeLog.source == source) logs = query.limit(limit).all() return { "logs": [ { "id": str(log.id), "source": log.source, "scrape_type": log.scrape_type, "status": log.status.value, "items_scraped": log.items_scraped, "error_message": log.error_message, "started_at": log.started_at.isoformat() if log.started_at else None, "completed_at": log.completed_at.isoformat() if log.completed_at else None, "duration_seconds": log.duration_seconds } for log in logs ] } @router.get("/logs/{log_id}") def get_log(log_id: str, db: Session = Depends(get_db)): log = db.query(ScrapeLog).filter(ScrapeLog.id == log_id).first() if not log: raise HTTPException(status_code=404, detail="Log not found") return { "id": str(log.id), "source": log.source, "scrape_type": log.scrape_type, "status": log.status.value, "items_scraped": log.items_scraped, "error_message": log.error_message, "started_at": log.started_at.isoformat() if log.started_at else None, "completed_at": log.completed_at.isoformat() if log.completed_at else None, "duration_seconds": log.duration_seconds } @router.get("/email-logs") def get_email_logs( limit: int = 50, status: Optional[str] = None, db: Session = Depends(get_db) ): query = db.query(EmailLog).order_by(EmailLog.created_at.desc()) if status: query = query.filter(EmailLog.status == status) logs = query.limit(limit).all() return { "logs": [ { "id": str(log.id), "recipient_email": log.recipient_email, "recipient_name": log.recipient_name, "template": log.template, "status": log.status.value, "error_message": log.error_message, "created_at": log.created_at.isoformat() if log.created_at else None, "delivered_at": log.delivered_at.isoformat() if log.delivered_at else None } for log in logs ] } @router.get("/meal-plans") def get_all_meal_plans( limit: int = 10, status: Optional[str] = None, db: Session = Depends(get_db) ): query = db.query(MealPlan).order_by(MealPlan.week_start_date.desc()) if status: query = query.filter(MealPlan.status == status) plans = query.limit(limit).all() return { "meal_plans": [ { "id": str(plan.id), "week_start_date": plan.week_start_date.isoformat() if plan.week_start_date else None, "status": plan.status.value, "total_estimated_cost": float(plan.total_estimated_cost) if plan.total_estimated_cost else None, "item_count": len(plan.items) if plan.items else 0, "created_at": plan.created_at.isoformat() if plan.created_at else None } for plan in plans ] } @router.post("/test-email") def test_email(email: str, db: Session = Depends(get_db)): from app.services.email import get_email_backend html = "
This is a test email from MealPlanner.
" text = "MealPlanner Test Email\n\nThis is a test email from MealPlanner." try: backend = get_email_backend() backend.send( to=email, subject="MealPlanner Test Email", html=html, text=text, ) status = "sent" except Exception as e: status = f"failed: {e}" email_log = EmailLog( recipient_email=email, template="test", status=status, created_at=datetime.now() ) db.add(email_log) db.commit() db.refresh(email_log) return { "message": f"Test email {status}", "email_log_id": str(email_log.id), "recipient": email, "status": status, } @router.get("/stats") def get_stats(db: Session = Depends(get_db)): from app.models import Recipe, FamilyProfile, Ingredient recipe_count = db.query(Recipe).count() ingredient_count = db.query(Ingredient).count() plan_count = db.query(MealPlan).count() return { "recipes": recipe_count, "ingredients": ingredient_count, "meal_plans": plan_count } @router.post("/trigger-images", status_code=200) def trigger_images( limit: int = 10, missing_only: bool = True, force: bool = False, recipe_id: Optional[UUID] = None, db: Session = Depends(get_db), ): from app.services.image_generation import generate_images_batch recipe_ids = [str(recipe_id)] if recipe_id else None result = generate_images_batch( db, recipe_ids=recipe_ids, missing_only=missing_only, force=force, limit=limit, ) return result @router.post("/trigger-discovery", status_code=200) def trigger_discovery( family_profile_id: UUID, force: bool = False, limit: int = Query(default=5, ge=1, le=25), db: Session = Depends(get_db), ): profile = ( db.query(FamilyProfile) .filter(FamilyProfile.id == family_profile_id) .first() ) if not profile: raise HTTPException(status_code=404, detail="Family profile not found") analyzer = FeedbackAnalyzer(lookback_weeks=4) analysis = analyzer.analyze(db, profile.id) if not analysis.discovery_queries and not force: return {"status": "skipped", "reason": "No discovery queries and force=false"} try: staged = WikibooksImportService( settings.WIKIBOOKS_USER_AGENT ).stage_discovered_pages( db, limit=limit, queries=analysis.discovery_queries or None, ) db.commit() except WikibooksImportError as exc: db.rollback() raise HTTPException(status_code=502, detail="Wikibooks staging failed") from exc pending = sum(row.status == RecipeImportStatus.PENDING for row in staged) quarantined = sum(row.status == RecipeImportStatus.QUARANTINED for row in staged) logger.info( "Manual discovery: staged %d Wikibooks candidates for review", len(staged) ) return { "status": "success", "candidates_staged": len(staged), "pending": pending, "quarantined": quarantined, "recipes_added": 0, "discovery_queries": analysis.discovery_queries, "confidence": analysis.confidence, }