276 lines
9.6 KiB
Python
276 lines
9.6 KiB
Python
import json
|
|
from dataclasses import dataclass
|
|
from typing import Optional, Tuple
|
|
from uuid import uuid4
|
|
|
|
from django.apps import apps
|
|
from django.db import models
|
|
from django.urls import reverse
|
|
from django_extensions.db.models import TimeStampedModel
|
|
from imagekit.models import ImageSpecField
|
|
from imagekit.processors import ResizeToFit
|
|
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
|
from foods.sources.rscraper import RecipeScraperService
|
|
from foods.sources.usda import NutritionCalculator
|
|
|
|
BNULL = {"blank": True, "null": True}
|
|
|
|
|
|
@dataclass
|
|
class FoodLogData(BaseLogData, WithPeopleLogData):
|
|
calories: Optional[int] = None
|
|
meal: Optional[str] = None
|
|
|
|
|
|
class FoodCategory(TimeStampedModel):
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
name = models.CharField(max_length=255)
|
|
category_image = models.ImageField(upload_to="food/category/", **BNULL)
|
|
category_image_small = ImageSpecField(
|
|
source="category_image",
|
|
processors=[ResizeToFit(100, 100)],
|
|
format="JPEG",
|
|
options={"quality": 60},
|
|
)
|
|
category_image_medium = ImageSpecField(
|
|
source="category_image",
|
|
processors=[ResizeToFit(300, 300)],
|
|
format="JPEG",
|
|
options={"quality": 75},
|
|
)
|
|
allrecipe_id = models.CharField(max_length=255, **BNULL)
|
|
description = models.TextField(**BNULL)
|
|
|
|
@classmethod
|
|
def find_or_create(cls, title: str) -> "FoodCategory":
|
|
return cls.objects.filter(title=title).first()
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class Food(ScrobblableMixin):
|
|
# Recipe source tracking
|
|
source_url = models.URLField(null=True, blank=True, unique=True)
|
|
source_site = models.CharField(max_length=100, null=True, blank=True)
|
|
|
|
# Recipe data
|
|
description = models.TextField(**BNULL) # Recipe title
|
|
ingredients = models.TextField(**BNULL) # JSON or newline-separated
|
|
instructions = models.TextField(**BNULL) # JSON or newline-separated
|
|
prep_time_minutes = models.IntegerField(**BNULL)
|
|
cook_time_minutes = models.IntegerField(**BNULL)
|
|
total_time_minutes = models.IntegerField(**BNULL)
|
|
servings = models.IntegerField(**BNULL)
|
|
yield_text = models.CharField(max_length=255, **BNULL) # e.g., "8 calzones"
|
|
|
|
# Nutrition (per serving)
|
|
calories = models.IntegerField(**BNULL)
|
|
protein = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
|
fat = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
|
carbohydrates = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
|
fiber = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
|
sugar = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
|
sodium = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
|
|
|
recipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
|
|
recipe_image_small = ImageSpecField(
|
|
source="recipe_image",
|
|
processors=[ResizeToFit(100, 100)],
|
|
format="JPEG",
|
|
options={"quality": 60},
|
|
)
|
|
recipe_image_medium = ImageSpecField(
|
|
source="recipe_image",
|
|
processors=[ResizeToFit(300, 300)],
|
|
format="JPEG",
|
|
options={"quality": 75},
|
|
)
|
|
allrecipe_id = models.CharField(max_length=255, **BNULL)
|
|
allrecipe_rating = models.FloatField(**BNULL)
|
|
category = models.ForeignKey(FoodCategory, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=["source_url"]),
|
|
models.Index(fields=["allrecipe_id"]),
|
|
models.Index(fields=["description"]),
|
|
]
|
|
|
|
def get_absolute_url(self) -> str:
|
|
return reverse("foods:food_detail", kwargs={"slug": self.uuid})
|
|
|
|
@property
|
|
def subtitle(self):
|
|
if self.category:
|
|
return self.category.name
|
|
|
|
@property
|
|
def strings(self) -> ScrobblableConstants:
|
|
return ScrobblableConstants(verb="Eating", tags="food")
|
|
|
|
@property
|
|
def allrecipe_link(self) -> str:
|
|
link = ""
|
|
if self.producer and self.allrecipe_id:
|
|
if self.allrecipe_id:
|
|
link = f"https://www.allrecipe.com/{self.allrecipe_id}/"
|
|
return link
|
|
|
|
@property
|
|
def primary_image_url(self) -> str:
|
|
url = ""
|
|
if self.recipe_image:
|
|
url = self.recipe_image.url
|
|
return url
|
|
|
|
@property
|
|
def logdata_cls(self):
|
|
return FoodLogData
|
|
|
|
@classmethod
|
|
def find_or_create_from_recipe(cls, url: str, category=None) -> Tuple["Food", bool]:
|
|
"""
|
|
Scrape a recipe URL and create/update Food instance.
|
|
Uses django-taggit for tags and existing FoodCategory for category.
|
|
"""
|
|
# Check if URL already exists
|
|
existing = cls.objects.filter(source_url=url).first()
|
|
if existing:
|
|
return existing, False
|
|
|
|
# Scrape the recipe
|
|
scraper = RecipeScraperService()
|
|
recipe_data = scraper.scrape_url(url)
|
|
|
|
# Download image
|
|
recipe_image = None
|
|
if recipe_data.get("image"):
|
|
recipe_image = scraper.download_image(recipe_data["image"])
|
|
|
|
# Extract tags
|
|
tags = scraper.extract_tags(recipe_data)
|
|
|
|
# Calculate nutrition
|
|
nutrition = recipe_data.get("nutrition")
|
|
if not nutrition:
|
|
calculator = NutritionCalculator()
|
|
servings = scraper.parse_servings(recipe_data.get("yields", "1")) or 1
|
|
nutrition = calculator.calculate_nutrition(
|
|
recipe_data.get("ingredients", []), servings
|
|
)
|
|
else:
|
|
servings = scraper.parse_servings(recipe_data.get("yields", "1")) or 1
|
|
|
|
# Get or create category (if not provided)
|
|
if not category and recipe_data.get("category"):
|
|
category, _ = FoodCategory.objects.get_or_create(
|
|
name=recipe_data["category"]
|
|
)
|
|
|
|
# Create Food instance
|
|
food = cls.objects.create(
|
|
title=recipe_data.get("title"),
|
|
source_url=url,
|
|
source_site=recipe_data.get("site"),
|
|
ingredients=json.dumps(recipe_data.get("ingredients", [])),
|
|
instructions=json.dumps(recipe_data.get("instructions", [])),
|
|
prep_time_minutes=recipe_data.get("prep_time"),
|
|
cook_time_minutes=recipe_data.get("cook_time"),
|
|
total_time_minutes=recipe_data.get("total_time"),
|
|
servings=servings,
|
|
yield_text=recipe_data.get("yields"),
|
|
# cuisine=recipe_data.get("cuisine"),
|
|
# course=recipe_data.get("course"),
|
|
calories=int(nutrition.get("calories", 0)) if nutrition else None,
|
|
protein=nutrition.get("protein") if nutrition else None,
|
|
fat=nutrition.get("fat") if nutrition else None,
|
|
carbohydrates=(nutrition.get("carbohydrates") if nutrition else None),
|
|
fiber=nutrition.get("fiber") if nutrition else None,
|
|
category=category,
|
|
)
|
|
|
|
# Add tags via django-taggit
|
|
if tags:
|
|
food.genre.add(*tags)
|
|
|
|
# Save image if downloaded
|
|
if recipe_image:
|
|
food.recipe_image.save(recipe_image.name, recipe_image, save=True)
|
|
|
|
if "allrecipes.com" in url:
|
|
food.allrecipe_id = url.split("/recipe/")[-1].split("/")[0]
|
|
food.save()
|
|
|
|
return food, True
|
|
|
|
@classmethod
|
|
def find_or_create_from_ingredients(
|
|
cls,
|
|
ingredients: list[str],
|
|
title: str,
|
|
servings: int = 1,
|
|
category=None,
|
|
) -> Tuple["Food", bool]:
|
|
"""
|
|
Create Food from ingredient list (no URL).
|
|
Calculates nutrition using USDA API.
|
|
"""
|
|
# Check if similar recipe exists
|
|
existing = cls.objects.filter(description__icontains=title).first()
|
|
if existing:
|
|
return existing, False
|
|
|
|
calculator = NutritionCalculator()
|
|
nutrition = calculator.calculate_nutrition(ingredients, servings)
|
|
|
|
food = cls.objects.create(
|
|
description=title,
|
|
ingredients=json.dumps(ingredients),
|
|
servings=servings,
|
|
calories=int(nutrition.get("calories", 0)) if nutrition else None,
|
|
protein=nutrition.get("protein") if nutrition else None,
|
|
fat=nutrition.get("fat") if nutrition else None,
|
|
carbohydrates=(nutrition.get("carbohydrates") if nutrition else None),
|
|
fiber=nutrition.get("fiber") if nutrition else None,
|
|
category=category,
|
|
)
|
|
|
|
return food, True
|
|
|
|
def refresh_nutrition(self) -> bool:
|
|
"""Recalculate nutrition from ingredients (useful if USDA data updated)."""
|
|
if not self.ingredients:
|
|
return False
|
|
|
|
try:
|
|
ingredients = json.loads(self.ingredients)
|
|
calculator = NutritionCalculator()
|
|
nutrition = calculator.calculate_nutrition(ingredients, self.servings or 1)
|
|
|
|
self.calories = int(nutrition.get("calories", 0))
|
|
self.protein = nutrition.get("protein")
|
|
self.fat = nutrition.get("fat")
|
|
self.carbohydrates = nutrition.get("carbohydrates")
|
|
self.fiber = nutrition.get("fiber")
|
|
self.save(
|
|
update_fields=[
|
|
"calories",
|
|
"protein",
|
|
"fat",
|
|
"carbohydrates",
|
|
"fiber",
|
|
]
|
|
)
|
|
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def scrobbles(self, user_id):
|
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
|
return Scrobble.objects.filter(user_id=user_id, food=self).order_by(
|
|
"-timestamp"
|
|
)
|