feat: add Open Food Facts search selector for food and drinks
- Add openfoodfacts Python SDK dependency - Create foods/sources/openfoodfacts.py API wrapper with text_search - Add off_code field + find_or_create_from_search() to Food model - Add nutrition fields (protein, fat, carbs, fiber, sugar, sodium), ingredients, image, off_code to Drink model - Create FoodSearchView and FoodScrobbleFromSearchView at /foods/search/ - Route beverages to Drink model based on OFF category keywords - Update ManualScrobbleView to redirect '-f Food Name' to food search - Add Drinks section to home page and scrobble detail templates - Update drink_detail.html with nutrition grid and image display
This commit is contained in:
@ -185,7 +185,7 @@ events).
|
||||
- Timestamp picker (default to now)
|
||||
- Optional: barcode scanning for food/drinks via CameraX
|
||||
|
||||
* Backlog [0/25] :vrobbler:project:personal:
|
||||
* Backlog [1/26] :vrobbler:project:personal:
|
||||
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
@ -714,6 +714,10 @@ at /api/docs/.
|
||||
- Verify schema generates cleanly for all ViewSets
|
||||
- Fix any serializer issues that block schema generation
|
||||
|
||||
** DONE [#B] Add open food facts source for Food and Drink :source:food:drinks:
|
||||
:PROPERTIES:
|
||||
:ID: d8a3e498-b03c-050d-a9bf-86e76eaa9201
|
||||
:END:
|
||||
* Version 61.5 [2/2]
|
||||
** DONE [#A] Fix referer bug in OSM tiles :bug:osm:templates:
|
||||
:PROPERTIES:
|
||||
|
||||
@ -68,6 +68,7 @@ vaderSentiment = "^3.3.2"
|
||||
sqids = "^0.5.2"
|
||||
python-amazon-paapi = "^6.3.0"
|
||||
yake = "^0.7.3"
|
||||
openfoodfacts = "^5.3.0"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-21 20:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("drinks", "0011_add_wine_fastcork_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="carbohydrates",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="drink_image",
|
||||
field=models.ImageField(blank=True, null=True, upload_to="drinks/image/"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="fat",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="fiber",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="ingredients",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="off_code",
|
||||
field=models.CharField(blank=True, max_length=255, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="protein",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="sodium",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="source_site",
|
||||
field=models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="sugar",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="drink",
|
||||
index=models.Index(
|
||||
fields=["off_code"], name="drinks_drin_off_cod_346283_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -153,9 +153,35 @@ class CoffeeLogData(DrinkLogData):
|
||||
class Drink(ScrobblableMixin):
|
||||
calories = models.PositiveIntegerField(**BNULL)
|
||||
is_alcoholic = models.BooleanField(default=False)
|
||||
off_code = models.CharField(max_length=255, unique=True, **BNULL)
|
||||
source_site = models.CharField(max_length=100, **BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
ingredients = models.TextField(**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)
|
||||
drink_image = models.ImageField(upload_to="drinks/image/", **BNULL)
|
||||
drink_image_small = ImageSpecField(
|
||||
source="drink_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
drink_image_medium = ImageSpecField(
|
||||
source="drink_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "drinks_drink"
|
||||
indexes = [
|
||||
models.Index(fields=["off_code"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title or "Drink"
|
||||
@ -167,6 +193,62 @@ class Drink(ScrobblableMixin):
|
||||
)
|
||||
return water
|
||||
|
||||
@classmethod
|
||||
def find_or_create_from_search(cls, product_data: dict) -> "Drink":
|
||||
code = product_data.get("code", "")
|
||||
product_name = product_data.get("product_name", "")
|
||||
brands = product_data.get("brands", "")
|
||||
|
||||
drink = None
|
||||
if code:
|
||||
drink = cls.objects.filter(off_code=code).first()
|
||||
if not drink and product_name:
|
||||
drink = cls.objects.filter(
|
||||
title=product_name, source_site="Open Food Facts"
|
||||
).first()
|
||||
|
||||
if drink:
|
||||
return drink
|
||||
|
||||
nutriments = product_data.get("nutriments", {})
|
||||
categories = product_data.get("categories", [])
|
||||
is_alcoholic = any(
|
||||
"alcoholic" in cat.lower() or "beer" in cat.lower() or "wine" in cat.lower()
|
||||
for cat in categories
|
||||
)
|
||||
|
||||
drink = cls.objects.create(
|
||||
title=product_name,
|
||||
description=f"{brands} - {product_name}" if brands else product_name,
|
||||
source_site="Open Food Facts",
|
||||
ingredients=product_data.get("ingredients_text", ""),
|
||||
calories=nutriments.get("calories"),
|
||||
is_alcoholic=is_alcoholic,
|
||||
protein=nutriments.get("protein"),
|
||||
fat=nutriments.get("fat"),
|
||||
carbohydrates=nutriments.get("carbohydrates"),
|
||||
fiber=nutriments.get("fiber"),
|
||||
sugar=nutriments.get("sugar"),
|
||||
sodium=nutriments.get("sodium"),
|
||||
off_code=code,
|
||||
)
|
||||
|
||||
image_url = product_data.get("image_url", "")
|
||||
if image_url:
|
||||
drink.save_image_from_url(image_url)
|
||||
|
||||
return drink
|
||||
|
||||
def save_image_from_url(self, url: str):
|
||||
headers = {"User-Agent": "Vrobbler/1.0"}
|
||||
try:
|
||||
r = requests.get(url, headers=headers, timeout=10)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
self.drink_image.save(fname, ContentFile(r.content), save=True)
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("drinks:drink_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
|
||||
24
vrobbler/apps/foods/migrations/0008_add_off_code_field.py
Normal file
24
vrobbler/apps/foods/migrations/0008_add_off_code_field.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-21 20:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("foods", "0007_alter_food_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="off_code",
|
||||
field=models.CharField(blank=True, max_length=255, null=True, unique=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="food",
|
||||
index=models.Index(
|
||||
fields=["off_code"], name="foods_food_off_cod_40ebc3_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -3,16 +3,18 @@ from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from django.apps import apps
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from foods.sources.rscraper import RecipeScraperService
|
||||
from foods.sources.usda import NutritionCalculator
|
||||
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}
|
||||
|
||||
@ -89,12 +91,14 @@ class Food(ScrobblableMixin):
|
||||
)
|
||||
allrecipe_id = models.CharField(max_length=255, **BNULL)
|
||||
allrecipe_rating = models.FloatField(**BNULL)
|
||||
off_code = models.CharField(max_length=255, unique=True, **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=["off_code"]),
|
||||
models.Index(fields=["description"]),
|
||||
]
|
||||
|
||||
@ -239,6 +243,62 @@ class Food(ScrobblableMixin):
|
||||
|
||||
return food, True
|
||||
|
||||
@classmethod
|
||||
def find_or_create_from_search(cls, product_data: dict) -> "Food":
|
||||
code = product_data.get("code", "")
|
||||
product_name = product_data.get("product_name", "")
|
||||
brands = product_data.get("brands", "")
|
||||
|
||||
food = None
|
||||
if code:
|
||||
food = cls.objects.filter(off_code=code).first()
|
||||
if not food and product_name:
|
||||
food = cls.objects.filter(
|
||||
title=product_name, source_site="Open Food Facts"
|
||||
).first()
|
||||
|
||||
if food:
|
||||
return food
|
||||
|
||||
nutriments = product_data.get("nutriments", {})
|
||||
|
||||
category = None
|
||||
categories = product_data.get("categories", [])
|
||||
if categories:
|
||||
category, _ = FoodCategory.objects.get_or_create(name=categories[0])
|
||||
|
||||
food = cls.objects.create(
|
||||
title=product_name,
|
||||
description=f"{brands} - {product_name}" if brands else product_name,
|
||||
source_site="Open Food Facts",
|
||||
ingredients=product_data.get("ingredients_text", ""),
|
||||
calories=nutriments.get("calories"),
|
||||
protein=nutriments.get("protein"),
|
||||
fat=nutriments.get("fat"),
|
||||
carbohydrates=nutriments.get("carbohydrates"),
|
||||
fiber=nutriments.get("fiber"),
|
||||
sugar=nutriments.get("sugar"),
|
||||
sodium=nutriments.get("sodium"),
|
||||
off_code=code,
|
||||
category=category,
|
||||
)
|
||||
|
||||
image_url = product_data.get("image_url", "")
|
||||
if image_url:
|
||||
food.save_image_from_url(image_url)
|
||||
|
||||
return food
|
||||
|
||||
def save_image_from_url(self, url: str):
|
||||
headers = {"User-Agent": "Vrobbler/1.0"}
|
||||
try:
|
||||
r = requests.get(url, headers=headers, timeout=10)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
self.recipe_image.save(fname, ContentFile(r.content), save=True)
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
def refresh_nutrition(self) -> bool:
|
||||
"""Recalculate nutrition from ingredients (useful if USDA data updated)."""
|
||||
if not self.ingredients:
|
||||
|
||||
90
vrobbler/apps/foods/sources/openfoodfacts.py
Normal file
90
vrobbler/apps/foods/sources/openfoodfacts.py
Normal file
@ -0,0 +1,90 @@
|
||||
import logging
|
||||
|
||||
import openfoodfacts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OFF_API = openfoodfacts.API(
|
||||
user_agent="Vrobbler/1.0 (https://github.com/powellc/vrobbler)",
|
||||
version=openfoodfacts.APIVersion.v2,
|
||||
)
|
||||
|
||||
|
||||
def search_products(query: str) -> list[dict]:
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
results = OFF_API.product.text_search(query)
|
||||
except Exception:
|
||||
logger.exception("Open Food Facts search request failed")
|
||||
return []
|
||||
|
||||
products = results.get("products", [])
|
||||
if not products:
|
||||
return []
|
||||
|
||||
return [_normalize_product(p) for p in products]
|
||||
|
||||
|
||||
def get_product(code: str) -> dict | None:
|
||||
try:
|
||||
product = OFF_API.product.get(code)
|
||||
except Exception:
|
||||
logger.exception("Open Food Facts product lookup failed")
|
||||
return None
|
||||
|
||||
if not product:
|
||||
return None
|
||||
|
||||
return _normalize_product(product)
|
||||
|
||||
|
||||
def _normalize_product(product: dict) -> dict:
|
||||
nutriments = product.get("nutriments", {})
|
||||
categories = product.get("categories_tags", [])
|
||||
category_names = []
|
||||
for cat in categories:
|
||||
if cat.startswith("en:"):
|
||||
category_names.append(cat[3:].replace("-", " ").title())
|
||||
elif cat.startswith("fr:"):
|
||||
category_names.append(cat[3:].replace("-", " ").title())
|
||||
|
||||
return {
|
||||
"code": product.get("code", ""),
|
||||
"product_name": product.get("product_name", product.get("product_name_en", "")),
|
||||
"brands": product.get("brands", ""),
|
||||
"categories": category_names,
|
||||
"ingredients_text": product.get(
|
||||
"ingredients_text_en", product.get("ingredients_text", "")
|
||||
),
|
||||
"image_url": product.get("image_front_url", product.get("image_url", "")),
|
||||
"nutriments": {
|
||||
"calories": _safe_int(nutriments.get("energy-kcal_100g")),
|
||||
"protein": _safe_float(nutriments.get("proteins_100g")),
|
||||
"fat": _safe_float(nutriments.get("fat_100g")),
|
||||
"carbohydrates": _safe_float(nutriments.get("carbohydrates_100g")),
|
||||
"fiber": _safe_float(nutriments.get("fiber_100g")),
|
||||
"sugar": _safe_float(nutriments.get("sugars_100g")),
|
||||
"sodium": _safe_float(nutriments.get("sodium_100g")),
|
||||
},
|
||||
"serving_size": product.get("serving_size", ""),
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(value) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_float(value) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@ -6,6 +6,12 @@ app_name = "foods"
|
||||
|
||||
urlpatterns = [
|
||||
path("foods/", views.FoodListView.as_view(), name="food_list"),
|
||||
path("foods/search/", views.FoodSearchView.as_view(), name="food_search"),
|
||||
path(
|
||||
"foods/scrobble-from-search/",
|
||||
views.FoodScrobbleFromSearchView.as_view(),
|
||||
name="food_scrobble_from_search",
|
||||
),
|
||||
path(
|
||||
"foods/<slug:slug>/",
|
||||
views.FoodDetailView.as_view(),
|
||||
|
||||
@ -1,11 +1,51 @@
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
from django.views.generic import TemplateView
|
||||
from drinks.models import Drink
|
||||
from foods.models import Food
|
||||
|
||||
from foods.sources.openfoodfacts import search_products
|
||||
from scrobbles.views import (
|
||||
ScrobbleableListView,
|
||||
ScrobbleableDetailView,
|
||||
ChartContextMixin,
|
||||
ScrobbleableDetailView,
|
||||
ScrobbleableListView,
|
||||
)
|
||||
|
||||
BEVERAGE_KEYWORDS = [
|
||||
"beverage",
|
||||
"drink",
|
||||
"water",
|
||||
"beer",
|
||||
"wine",
|
||||
"juice",
|
||||
"coffee",
|
||||
"tea",
|
||||
"soda",
|
||||
"carbonated",
|
||||
"milk",
|
||||
"smoothie",
|
||||
"shake",
|
||||
"energy drink",
|
||||
"alcohol",
|
||||
"liquor",
|
||||
"spirits",
|
||||
"cocktail",
|
||||
"cider",
|
||||
"kombucha",
|
||||
]
|
||||
|
||||
|
||||
def _is_beverage(product_data: dict) -> bool:
|
||||
categories = product_data.get("categories", [])
|
||||
for cat in categories:
|
||||
cat_lower = cat.lower()
|
||||
for keyword in BEVERAGE_KEYWORDS:
|
||||
if keyword in cat_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FoodListView(ScrobbleableListView):
|
||||
model = Food
|
||||
@ -13,3 +53,67 @@ class FoodListView(ScrobbleableListView):
|
||||
|
||||
class FoodDetailView(ScrobbleableDetailView, ChartContextMixin):
|
||||
model = Food
|
||||
|
||||
|
||||
class FoodSearchView(TemplateView):
|
||||
template_name = "foods/food_search.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
query = self.request.GET.get("q", "").strip()
|
||||
context["query"] = query
|
||||
|
||||
if query:
|
||||
results = search_products(query)
|
||||
context["results"] = results
|
||||
self.request.session["food_search_results"] = results
|
||||
self.request.session["food_search_query"] = query
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class FoodScrobbleFromSearchView(View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
messages.error(request, "You must be logged in to scrobble food.")
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
product_index = request.POST.get("product_index")
|
||||
query = request.POST.get("query", "")
|
||||
results = request.session.get("food_search_results", [])
|
||||
|
||||
try:
|
||||
product_index = int(product_index)
|
||||
product_data = results[product_index]
|
||||
except (TypeError, IndexError, ValueError):
|
||||
messages.error(request, "Invalid food selection.")
|
||||
return HttpResponseRedirect(
|
||||
reverse("foods:food_search") + f"?q={query}" if query else "/"
|
||||
)
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
if _is_beverage(product_data):
|
||||
media = Drink.find_or_create_from_search(product_data)
|
||||
else:
|
||||
media = Food.find_or_create_from_search(product_data)
|
||||
|
||||
if not media:
|
||||
messages.error(request, "Could not create food from search result.")
|
||||
return HttpResponseRedirect(
|
||||
reverse("foods:food_search") + f"?q={query}" if query else "/"
|
||||
)
|
||||
|
||||
scrobble_dict = {
|
||||
"user_id": request.user.id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_seconds": 0,
|
||||
"source": "Open Food Facts",
|
||||
}
|
||||
scrobble = Scrobble.create_or_update(media, request.user.id, scrobble_dict)
|
||||
|
||||
if scrobble:
|
||||
return HttpResponseRedirect(scrobble.redirect_url(request.user.id))
|
||||
|
||||
messages.error(request, "Failed to create scrobble.")
|
||||
return HttpResponseRedirect(reverse("foods:food_list"))
|
||||
|
||||
@ -640,9 +640,16 @@ class ManualScrobbleView(FormView):
|
||||
if key == "-wi":
|
||||
return HttpResponseRedirect(reverse("drinks:wine_search") + f"?q={item_id}")
|
||||
|
||||
if key == "-f" and " - " not in item_id:
|
||||
return HttpResponseRedirect(reverse("foods:food_search") + f"?q={item_id}")
|
||||
|
||||
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
|
||||
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
|
||||
|
||||
if not scrobble:
|
||||
messages.error(self.request, "Could not create scrobble.")
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))
|
||||
|
||||
return HttpResponseRedirect(scrobble.redirect_url(self.request.user.id))
|
||||
|
||||
|
||||
|
||||
@ -22,20 +22,96 @@
|
||||
width: 600px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.nutrition-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.nutrition-item {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nutrition-item .value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nutrition-item .label {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
|
||||
<div class="row">
|
||||
{% if object.drink_image %}
|
||||
<div class="cover">
|
||||
<img src="{{ object.drink_image.url }}" alt="{{ object.title }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="summary">
|
||||
{% if object.description%}
|
||||
{% if object.description %}
|
||||
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||
<hr />
|
||||
{% endif %}
|
||||
<p style="float:right;">
|
||||
<a href="{{object.untappd_link}}"><img src="{% static "images/untappd-logo.png" %}" width=35></a>
|
||||
</p>
|
||||
|
||||
{% if object.calories or object.protein %}
|
||||
<div class="nutrition-grid">
|
||||
{% if object.calories %}
|
||||
<div class="nutrition-item">
|
||||
<div class="value">{{ object.calories }}</div>
|
||||
<div class="label">kcal</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.protein %}
|
||||
<div class="nutrition-item">
|
||||
<div class="value">{{ object.protein }}g</div>
|
||||
<div class="label">Protein</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.fat %}
|
||||
<div class="nutrition-item">
|
||||
<div class="value">{{ object.fat }}g</div>
|
||||
<div class="label">Fat</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.carbohydrates %}
|
||||
<div class="nutrition-item">
|
||||
<div class="value">{{ object.carbohydrates }}g</div>
|
||||
<div class="label">Carbs</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.fiber %}
|
||||
<div class="nutrition-item">
|
||||
<div class="value">{{ object.fiber }}g</div>
|
||||
<div class="label">Fiber</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.sugar %}
|
||||
<div class="nutrition-item">
|
||||
<div class="value">{{ object.sugar }}g</div>
|
||||
<div class="label">Sugar</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.ingredients %}
|
||||
<h5>Ingredients</h5>
|
||||
<p>{{ object.ingredients }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if object.off_code %}
|
||||
<p><small class="text-muted">OFF: {{ object.off_code }}</small></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
96
vrobbler/templates/foods/food_search.html
Normal file
96
vrobbler/templates/foods/food_search.html
Normal file
@ -0,0 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.search-container { margin-bottom: 2rem; }
|
||||
.result-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.result-item:last-child { border-bottom: none; }
|
||||
.result-title { font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.result-meta { font-size: 0.875rem; color: #6c757d; }
|
||||
.result-detail { font-size: 0.875rem; color: #6c757d; margin-top: 0.25rem; }
|
||||
.result-nutrition { font-size: 0.875rem; margin-top: 0.5rem; }
|
||||
.result-nutrition span { margin-right: 1rem; }
|
||||
.no-results { padding: 2rem; text-align: center; color: #6c757d; }
|
||||
.result-image { width: 60px; height: 60px; object-fit: contain; margin-right: 1rem; }
|
||||
.result-row { display: flex; align-items: flex-start; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div
|
||||
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Search Food & Drinks</h1>
|
||||
</div>
|
||||
|
||||
<div class="container" style="margin-bottom: 100px;">
|
||||
<form method="get" action="{% url 'foods:food_search' %}" class="search-container">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<input type="text"
|
||||
name="q"
|
||||
class="form-control form-control-lg"
|
||||
placeholder="Search for a food or drink product..."
|
||||
value="{{ query }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if query %}
|
||||
<div class="search-results">
|
||||
{% if results %}
|
||||
<p class="text-muted">{{ results|length }} result{{ results|length|pluralize }}</p>
|
||||
{% for product in results %}
|
||||
<div class="result-item">
|
||||
<div class="result-row">
|
||||
{% if product.image_url %}
|
||||
<img src="{{ product.image_url }}" alt="" class="result-image">
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="result-title">{{ product.product_name }}</div>
|
||||
<div class="result-meta">
|
||||
{% if product.brands %}{{ product.brands }}{% endif %}
|
||||
{% if product.categories %} · {{ product.categories|join:", " }}{% endif %}
|
||||
</div>
|
||||
{% if product.nutriments.calories %}
|
||||
<div class="result-nutrition">
|
||||
<span>{{ product.nutriments.calories }} kcal</span>
|
||||
{% if product.nutriments.protein %}<span>P {{ product.nutriments.protein }}g</span>{% endif %}
|
||||
{% if product.nutriments.fat %}<span>F {{ product.nutriments.fat }}g</span>{% endif %}
|
||||
{% if product.nutriments.carbohydrates %}<span>C {{ product.nutriments.carbohydrates }}g</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{% url 'foods:food_scrobble_from_search' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="product_index" value="{{ forloop.counter0 }}">
|
||||
<input type="hidden" name="query" value="{{ query }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Scrobble</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="no-results">
|
||||
No products found matching your search.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-results">
|
||||
<p>Enter a food or drink name to search via Open Food Facts.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@ -183,6 +183,15 @@
|
||||
<p>No coffee today</p>
|
||||
{% endif %}
|
||||
|
||||
<h3><a href="{% url 'drinks:drink_list' %}">Drinks</a></h3>
|
||||
{% if Drink %}
|
||||
{% with scrobbles=Drink count=Drink_count time=Drink_time %}
|
||||
{% include "scrobbles/_scrobble_table.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<p>No drinks today</p>
|
||||
{% endif %}
|
||||
|
||||
<h3><a href="{% url 'bricksets:brickset_list' %}">Brick sets</a></h3>
|
||||
{% if BrickSet %}
|
||||
{% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %}
|
||||
|
||||
@ -93,6 +93,8 @@
|
||||
<a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a>
|
||||
{% elif scrobble.coffee %}
|
||||
<a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a>
|
||||
{% elif scrobble.drink %}
|
||||
<a href="{% url 'drinks:drink_detail' scrobble.drink.uuid %}">{{ scrobble.drink.title }}</a>
|
||||
{% elif scrobble.water %}
|
||||
<a href="{% url 'drinks:water_detail' scrobble.water.uuid %}">{{ scrobble.water.title }}</a>
|
||||
{% elif scrobble.web_page %}
|
||||
|
||||
@ -87,6 +87,8 @@
|
||||
<a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a>
|
||||
{% elif scrobble.coffee %}
|
||||
<a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a>
|
||||
{% elif scrobble.drink %}
|
||||
<a href="{% url 'drinks:drink_detail' scrobble.drink.uuid %}">{{ scrobble.drink.title }}</a>
|
||||
{% elif scrobble.web_page %}
|
||||
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
|
||||
{% elif scrobble.podcast_episode %}
|
||||
|
||||
Reference in New Issue
Block a user