From 27f91fb877f4502804c932a28546ab6c745cc103 Mon Sep 17 00:00:00 2001 From: Caine Date: Thu, 2 Apr 2026 16:50:56 +0100 Subject: Phase 2+3: bulk pantry add, smart shopping list, recipe URL import --- kitchen/urls.py | 3 + kitchen/views.py | 377 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 379 insertions(+), 1 deletion(-) (limited to 'kitchen') diff --git a/kitchen/urls.py b/kitchen/urls.py index 7dedb7a..2eabc81 100644 --- a/kitchen/urls.py +++ b/kitchen/urls.py @@ -19,4 +19,7 @@ urlpatterns = [ path("", include(router.urls)), path("what-can-i-cook/", views.what_can_i_cook, name="what-can-i-cook"), path("log-cook/", views.log_cook, name="log-cook"), + path("bulk-pantry-add/", views.bulk_pantry_add, name="bulk-pantry-add"), + path("generate-shopping-list/", views.generate_shopping_list, name="generate-shopping-list"), + path("import-recipe/", views.import_recipe_url, name="import-recipe"), ] diff --git a/kitchen/views.py b/kitchen/views.py index 58685ab..6f34333 100644 --- a/kitchen/views.py +++ b/kitchen/views.py @@ -1,5 +1,5 @@ from decimal import Decimal -from datetime import date +from datetime import date, timedelta from rest_framework import viewsets, status from rest_framework.decorators import api_view, permission_classes, action @@ -419,3 +419,378 @@ def _deduct_ingredient(ingredient, amount, unit): }) return deducted + + +# --- Bulk Pantry Add (Photo Intake) --- + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def bulk_pantry_add(request): + """ + Add multiple items to pantry at once. Designed for photo intake workflow. + + Body: + { + "items": [ + {"ingredient_name": "eggs", "quantity": 6, "unit": "items", "location": "fridge"}, + {"ingredient_name": "pork mince", "quantity": 500, "unit": "g", "location": "fridge"}, + {"ingredient_name": "frozen stir fry veg", "quantity": 1, "unit": "bags", "location": "freezer"} + ] + } + + Looks up ingredients by name (case-insensitive, checks aliases too). + If an ingredient doesn't exist, creates it with sensible defaults. + If the item already exists in that location, adds to the quantity. + """ + items = request.data.get("items", []) + if not items: + return Response({"error": "No items provided"}, status=status.HTTP_400_BAD_REQUEST) + + results = [] + + for item_data in items: + name = item_data.get("ingredient_name", "").strip() + quantity = Decimal(str(item_data.get("quantity", 0))) + unit = item_data.get("unit", "items") + location = item_data.get("location", "fridge") + expiry_days = item_data.get("expiry_days") # optional override + + if not name: + results.append({"error": "Missing ingredient_name", "input": item_data}) + continue + + # Look up ingredient by name or alias + ingredient = _find_ingredient(name) + + if not ingredient: + # Create new ingredient + ingredient = Ingredient.objects.create( + name=name.lower(), + default_unit=unit, + aliases=[name] if name.lower() != name else [], + ) + results.append({ + "ingredient": ingredient.name, + "action": "created_new_ingredient", + "quantity": str(quantity), + "unit": unit, + "location": location, + }) + + # Calculate expiry + expiry_date = None + if location == "fridge": + if expiry_days: + expiry_date = date.today() + timedelta(days=int(expiry_days)) + elif ingredient.shelf_life_days: + expiry_date = date.today() + timedelta(days=ingredient.shelf_life_days) + + # Check if item already exists in this location + existing = PantryItem.objects.filter( + ingredient=ingredient, location=location, quantity__gt=0 + ).first() + + if existing: + existing.quantity += quantity + if expiry_date: + existing.expiry_date = expiry_date # refresh expiry with new stock + existing.save() + results.append({ + "ingredient": ingredient.name, + "action": "added_to_existing", + "added": str(quantity), + "new_total": str(existing.quantity), + "unit": unit, + "location": location, + }) + else: + PantryItem.objects.create( + ingredient=ingredient, + quantity=quantity, + unit=unit, + location=location, + expiry_date=expiry_date, + is_staple=_is_known_staple(ingredient), + ) + results.append({ + "ingredient": ingredient.name, + "action": "created", + "quantity": str(quantity), + "unit": unit, + "location": location, + "expiry_date": str(expiry_date) if expiry_date else None, + }) + + return Response({"added": len(results), "results": results}, status=status.HTTP_201_CREATED) + + +def _find_ingredient(name): + """Find ingredient by name or alias (case-insensitive).""" + # Exact name match + try: + return Ingredient.objects.get(name__iexact=name) + except Ingredient.DoesNotExist: + pass + + # Search aliases (JSONField contains) + for ingredient in Ingredient.objects.all(): + if any(alias.lower() == name.lower() for alias in ingredient.aliases): + return ingredient + + return None + + +def _is_known_staple(ingredient): + """Check if an ingredient should be marked as a staple.""" + staple_names = {"onions", "frozen chips", "salt", "black pepper", "olive oil", "paprika", "garlic powder"} + return ingredient.name.lower() in staple_names + + +# --- Smart Shopping List Generation --- + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def generate_shopping_list(request): + """ + Generate a smart shopping list based on: + 1. Staples that need restocking (quantity = 0) + 2. Items expiring within 2 days (need replacing) + 3. What's needed to cook specific meta-recipes (optional: ?recipes=1,2) + 4. General low-stock items + + Query params: + - recipes: comma-separated meta-recipe IDs to shop for + - servings: servings per recipe (default 2) + - add: if "true", adds items to the shopping list model + """ + recipe_ids = request.query_params.get("recipes", "") + servings = int(request.query_params.get("servings", 2)) + auto_add = request.query_params.get("add", "").lower() == "true" + + suggestions = [] + + # 1. Staples at zero + for item in PantryItem.objects.filter(is_staple=True, quantity=0).select_related("ingredient"): + suggestions.append({ + "ingredient": item.ingredient.name, + "reason": "restock staple", + "priority": "high", + "section": _get_section(item.ingredient), + }) + + # 2. Items expiring soon (replace them) + cutoff = date.today() + timedelta(days=2) + for item in PantryItem.objects.filter( + expiry_date__isnull=False, expiry_date__lte=cutoff, quantity__gt=0 + ).select_related("ingredient"): + suggestions.append({ + "ingredient": item.ingredient.name, + "reason": f"expiring {item.expiry_date} ({item.location})", + "priority": "medium", + "section": _get_section(item.ingredient), + }) + + # 3. Recipe-specific needs + if recipe_ids: + try: + ids = [int(x.strip()) for x in recipe_ids.split(",")] + except ValueError: + return Response({"error": "Invalid recipe IDs"}, status=status.HTTP_400_BAD_REQUEST) + + for meta in MetaRecipe.objects.filter(id__in=ids).prefetch_related( + "slots__options__ingredient", "base_ingredients__ingredient" + ): + # Check base ingredients + for base in meta.base_ingredients.all(): + needed = base.quantity_per_serving * servings + available = _get_pantry_total(base.ingredient_id) + if available < needed: + is_staple = PantryItem.objects.filter( + ingredient=base.ingredient, is_staple=True + ).exists() + if not is_staple: + suggestions.append({ + "ingredient": base.ingredient.name, + "reason": f"for {meta.name} ({needed} {base.unit} needed, have {available})", + "quantity": str(needed - available), + "unit": base.unit, + "priority": "high", + "section": _get_section(base.ingredient), + }) + + # Check slots — suggest cheapest/preferred option per required slot + for slot in meta.slots.all(): + if not slot.required: + continue + + # Check if ANY option is available + any_available = False + for option in slot.options.all(): + needed = option.quantity_per_serving * servings + available = _get_pantry_total(option.ingredient_id) + if available >= needed: + any_available = True + break + + if not any_available: + # Suggest the first option (could be smarter — prefer Tom's preferences) + option = slot.options.first() + if option: + needed = option.quantity_per_serving * servings + suggestions.append({ + "ingredient": option.ingredient.name, + "reason": f"for {meta.name} ({slot.name} slot)", + "quantity": str(needed), + "unit": option.unit, + "priority": "high", + "section": _get_section(option.ingredient), + }) + + # Deduplicate by ingredient name + seen = set() + unique = [] + for s in suggestions: + if s["ingredient"] not in seen: + seen.add(s["ingredient"]) + unique.append(s) + + # Sort by section then priority + section_order = {"protein": 0, "veg": 1, "carbs": 2, "dairy": 3, "other": 4} + priority_order = {"high": 0, "medium": 1, "low": 2} + unique.sort(key=lambda x: (section_order.get(x["section"], 5), priority_order.get(x["priority"], 3))) + + # Optionally add to shopping list model + if auto_add: + for s in unique: + ingredient = _find_ingredient(s["ingredient"]) + ShoppingListItem.objects.get_or_create( + name=s["ingredient"], + checked=False, + defaults={ + "ingredient": ingredient, + "quantity": Decimal(s.get("quantity", "0")) if s.get("quantity") else None, + "unit": s.get("unit", ""), + "reason": s["reason"], + }, + ) + + return Response({ + "count": len(unique), + "suggestions": unique, + "auto_added": auto_add, + }) + + +def _get_pantry_total(ingredient_id): + """Get total quantity of an ingredient across all pantry locations.""" + total = Decimal("0") + for item in PantryItem.objects.filter(ingredient_id=ingredient_id, quantity__gt=0): + total += item.quantity + return total + + +def _get_section(ingredient): + """Determine shopping section from ingredient tags.""" + tag_names = set(ingredient.tags.values_list("name", flat=True)) + if "protein" in tag_names: + return "protein" + if "veg" in tag_names: + return "veg" + if "carb" in tag_names: + return "carbs" + if "dairy" in tag_names: + return "dairy" + return "other" + + +# --- Recipe URL Import --- + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def import_recipe_url(request): + """ + Import a recipe from a URL using recipe-scrapers library. + + Body: + { + "url": "https://www.bbcgoodfood.com/recipes/sausage-traybake", + "create": true // if false, just returns parsed data without saving + } + + Supports 100+ sites including BBC Good Food, Allrecipes, Jamie Oliver, etc. + """ + from recipe_scrapers import scrape_html + import requests as req + + url = request.data.get("url", "").strip() + should_create = request.data.get("create", False) + + if not url: + return Response({"error": "URL required"}, status=status.HTTP_400_BAD_REQUEST) + + try: + # Fetch the page + response = req.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10) + response.raise_for_status() + + # Parse with recipe-scrapers + scraper = scrape_html(html=response.text, org_url=url) + + parsed = { + "title": scraper.title(), + "ingredients": scraper.ingredients(), + "instructions": scraper.instructions(), + "servings": _parse_servings(scraper.yields()), + "prep_time": scraper.prep_time() if hasattr(scraper, 'prep_time') else None, + "cook_time": scraper.cook_time() if hasattr(scraper, 'cook_time') else None, + "total_time": scraper.total_time() if hasattr(scraper, 'total_time') else None, + "image": scraper.image() if hasattr(scraper, 'image') else None, + "source_url": url, + } + + if should_create: + # Create the recipe + recipe = Recipe.objects.create( + name=parsed["title"], + method=parsed["instructions"], + servings=parsed["servings"] or 2, + prep_time_mins=parsed["prep_time"], + cook_time_mins=parsed["cook_time"], + source_url=url, + ) + + # Try to match ingredients to existing ones + ingredient_results = [] + for ing_text in parsed["ingredients"]: + ingredient_results.append({ + "raw": ing_text, + "status": "needs_review", + "note": "Ingredient text imported raw — use admin or API to link to ingredient model", + }) + + parsed["recipe_id"] = recipe.id + parsed["ingredient_results"] = ingredient_results + parsed["message"] = ( + f"Recipe '{recipe.name}' created (id={recipe.id}). " + f"Ingredients imported as raw text — they need linking to ingredient models " + f"via admin or API. Caine can help parse and link them." + ) + + return Response(parsed) + + except req.RequestException as e: + return Response({"error": f"Failed to fetch URL: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response({"error": f"Failed to parse recipe: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) + + +def _parse_servings(yields_str): + """Extract number from yields string like '4 servings'.""" + if not yields_str: + return None + import re + match = re.search(r'(\d+)', str(yields_str)) + return int(match.group(1)) if match else None -- cgit v1.2.3