Files
vrobbler/vrobbler/apps/foods/models.py

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"
)