From 155ed2a4f8ec7aca9a6b5ffc6f374a76d2531324 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Tue, 21 Jul 2026 17:09:13 -0400 Subject: [PATCH] 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 --- PROJECT.org | 6 +- pyproject.toml | 1 + .../0012_add_nutrition_and_off_fields.py | 86 ++++++++++++++ vrobbler/apps/drinks/models.py | 82 +++++++++++++ .../migrations/0008_add_off_code_field.py | 24 ++++ vrobbler/apps/foods/models.py | 64 +++++++++- vrobbler/apps/foods/sources/openfoodfacts.py | 90 ++++++++++++++ vrobbler/apps/foods/urls.py | 6 + vrobbler/apps/foods/views.py | 110 +++++++++++++++++- vrobbler/apps/scrobbles/views.py | 7 ++ vrobbler/templates/drinks/drink_detail.html | 84 ++++++++++++- vrobbler/templates/foods/food_search.html | 96 +++++++++++++++ .../templates/scrobbles/_last_scrobbles.html | 9 ++ .../scrobbles/_scrobble_all_content.html | 2 + .../templates/scrobbles/scrobble_explore.html | 2 + 15 files changed, 659 insertions(+), 10 deletions(-) create mode 100644 vrobbler/apps/drinks/migrations/0012_add_nutrition_and_off_fields.py create mode 100644 vrobbler/apps/foods/migrations/0008_add_off_code_field.py create mode 100644 vrobbler/apps/foods/sources/openfoodfacts.py create mode 100644 vrobbler/templates/foods/food_search.html diff --git a/PROJECT.org b/PROJECT.org index 50332a7..55ecce7 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -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: diff --git a/pyproject.toml b/pyproject.toml index f58a881..690b3bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/vrobbler/apps/drinks/migrations/0012_add_nutrition_and_off_fields.py b/vrobbler/apps/drinks/migrations/0012_add_nutrition_and_off_fields.py new file mode 100644 index 0000000..d582c98 --- /dev/null +++ b/vrobbler/apps/drinks/migrations/0012_add_nutrition_and_off_fields.py @@ -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" + ), + ), + ] diff --git a/vrobbler/apps/drinks/models.py b/vrobbler/apps/drinks/models.py index 2d9f83a..5a8e506 100644 --- a/vrobbler/apps/drinks/models.py +++ b/vrobbler/apps/drinks/models.py @@ -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}) diff --git a/vrobbler/apps/foods/migrations/0008_add_off_code_field.py b/vrobbler/apps/foods/migrations/0008_add_off_code_field.py new file mode 100644 index 0000000..27c7e32 --- /dev/null +++ b/vrobbler/apps/foods/migrations/0008_add_off_code_field.py @@ -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" + ), + ), + ] diff --git a/vrobbler/apps/foods/models.py b/vrobbler/apps/foods/models.py index 2371252..c932cde 100644 --- a/vrobbler/apps/foods/models.py +++ b/vrobbler/apps/foods/models.py @@ -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: diff --git a/vrobbler/apps/foods/sources/openfoodfacts.py b/vrobbler/apps/foods/sources/openfoodfacts.py new file mode 100644 index 0000000..da07b82 --- /dev/null +++ b/vrobbler/apps/foods/sources/openfoodfacts.py @@ -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 diff --git a/vrobbler/apps/foods/urls.py b/vrobbler/apps/foods/urls.py index fedd903..a4eb3e0 100644 --- a/vrobbler/apps/foods/urls.py +++ b/vrobbler/apps/foods/urls.py @@ -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//", views.FoodDetailView.as_view(), diff --git a/vrobbler/apps/foods/views.py b/vrobbler/apps/foods/views.py index e45642f..d7de168 100644 --- a/vrobbler/apps/foods/views.py +++ b/vrobbler/apps/foods/views.py @@ -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")) diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 6cb2c9b..0f65366 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -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)) diff --git a/vrobbler/templates/drinks/drink_detail.html b/vrobbler/templates/drinks/drink_detail.html index 97c5baa..e3777b2 100644 --- a/vrobbler/templates/drinks/drink_detail.html +++ b/vrobbler/templates/drinks/drink_detail.html @@ -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; + } {% endblock %} {% block lists %}
+ {% if object.drink_image %} +
+ {{ object.title }} +
+ {% endif %}
- {% if object.description%} + {% if object.description %}

{{object.description|safe|linebreaks|truncatewords:160}}


{% endif %} -

- -

+ + {% if object.calories or object.protein %} +
+ {% if object.calories %} +
+
{{ object.calories }}
+
kcal
+
+ {% endif %} + {% if object.protein %} +
+
{{ object.protein }}g
+
Protein
+
+ {% endif %} + {% if object.fat %} +
+
{{ object.fat }}g
+
Fat
+
+ {% endif %} + {% if object.carbohydrates %} +
+
{{ object.carbohydrates }}g
+
Carbs
+
+ {% endif %} + {% if object.fiber %} +
+
{{ object.fiber }}g
+
Fiber
+
+ {% endif %} + {% if object.sugar %} +
+
{{ object.sugar }}g
+
Sugar
+
+ {% endif %} +
+ {% endif %} + + {% if object.ingredients %} +
Ingredients
+

{{ object.ingredients }}

+ {% endif %} + + {% if object.off_code %} +

OFF: {{ object.off_code }}

+ {% endif %}
diff --git a/vrobbler/templates/foods/food_search.html b/vrobbler/templates/foods/food_search.html new file mode 100644 index 0000000..4a92e8c --- /dev/null +++ b/vrobbler/templates/foods/food_search.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% load static %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+

Search Food & Drinks

+
+ +
+
+
+
+ +
+
+ +
+
+
+ + {% if query %} +
+ {% if results %} +

{{ results|length }} result{{ results|length|pluralize }}

+ {% for product in results %} +
+
+ {% if product.image_url %} + + {% endif %} +
+
{{ product.product_name }}
+
+ {% if product.brands %}{{ product.brands }}{% endif %} + {% if product.categories %} · {{ product.categories|join:", " }}{% endif %} +
+ {% if product.nutriments.calories %} +
+ {{ product.nutriments.calories }} kcal + {% if product.nutriments.protein %}P {{ product.nutriments.protein }}g{% endif %} + {% if product.nutriments.fat %}F {{ product.nutriments.fat }}g{% endif %} + {% if product.nutriments.carbohydrates %}C {{ product.nutriments.carbohydrates }}g{% endif %} +
+ {% endif %} +
+
+
+ {% csrf_token %} + + + +
+
+ {% endfor %} + {% else %} +
+ No products found matching your search. +
+ {% endif %} +
+ {% else %} +
+

Enter a food or drink name to search via Open Food Facts.

+
+ {% endif %} +
+
+{% endblock %} diff --git a/vrobbler/templates/scrobbles/_last_scrobbles.html b/vrobbler/templates/scrobbles/_last_scrobbles.html index 9ac29c1..21644e6 100644 --- a/vrobbler/templates/scrobbles/_last_scrobbles.html +++ b/vrobbler/templates/scrobbles/_last_scrobbles.html @@ -183,6 +183,15 @@

No coffee today

{% endif %} +

Drinks

+ {% if Drink %} + {% with scrobbles=Drink count=Drink_count time=Drink_time %} + {% include "scrobbles/_scrobble_table.html" %} + {% endwith %} + {% else %} +

No drinks today

+ {% endif %} +

Brick sets

{% if BrickSet %} {% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %} diff --git a/vrobbler/templates/scrobbles/_scrobble_all_content.html b/vrobbler/templates/scrobbles/_scrobble_all_content.html index c95873a..37506e3 100644 --- a/vrobbler/templates/scrobbles/_scrobble_all_content.html +++ b/vrobbler/templates/scrobbles/_scrobble_all_content.html @@ -93,6 +93,8 @@ {{ scrobble.wine.title }} {% elif scrobble.coffee %} {{ scrobble.coffee.title }} + {% elif scrobble.drink %} + {{ scrobble.drink.title }} {% elif scrobble.water %} {{ scrobble.water.title }} {% elif scrobble.web_page %} diff --git a/vrobbler/templates/scrobbles/scrobble_explore.html b/vrobbler/templates/scrobbles/scrobble_explore.html index 936efab..7188f0b 100644 --- a/vrobbler/templates/scrobbles/scrobble_explore.html +++ b/vrobbler/templates/scrobbles/scrobble_explore.html @@ -87,6 +87,8 @@ {{ scrobble.wine.title }} {% elif scrobble.coffee %} {{ scrobble.coffee.title }} + {% elif scrobble.drink %} + {{ scrobble.drink.title }} {% elif scrobble.web_page %} {{ scrobble.web_page.title }} {% elif scrobble.podcast_episode %}