From 740e5268ddec24ff2777c4e58dbdf207673a18e3 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Tue, 14 Jul 2026 21:05:22 -0400 Subject: [PATCH] [drinks] Add better wine lookups --- PROJECT.org | 6 +- vrobbler.conf.example | 1 + vrobbler/apps/drinks/fastcork.py | 47 ++++++++++ .../0011_add_wine_fastcork_fields.py | 43 ++++++++++ vrobbler/apps/drinks/models.py | 66 ++++++++++++++ vrobbler/apps/drinks/urls.py | 10 +++ vrobbler/apps/drinks/views.py | 66 ++++++++++++++ vrobbler/apps/scrobbles/constants.py | 2 +- vrobbler/apps/scrobbles/scrobblers.py | 35 ++++---- vrobbler/apps/scrobbles/views.py | 4 + vrobbler/settings.py | 2 + vrobbler/templates/drinks/wine_detail.html | 7 +- vrobbler/templates/drinks/wine_search.html | 85 +++++++++++++++++++ 13 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 vrobbler/apps/drinks/fastcork.py create mode 100644 vrobbler/apps/drinks/migrations/0011_add_wine_fastcork_fields.py create mode 100644 vrobbler/templates/drinks/wine_search.html diff --git a/PROJECT.org b/PROJECT.org index 846fa2c..47f8222 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/24] :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: @@ -619,6 +619,10 @@ The Edit log form should have from top to bottom: - Expansion ids (which should a multi-select widget of expansions for this game) - Location (which should be a drop down of BoardGameLocations for this user) +** DONE Use FastCork to lookup wine data :drinks:wine:metadata: +:PROPERTIES: +:ID: add9a951-d957-415d-b41e-feee40479774 +:END: * Version 60.1 [1/1] ** DONE [#B] Migrate coffee scrobbles to new Coffee model :drinks:mgmtcmd: :PROPERTIES: diff --git a/vrobbler.conf.example b/vrobbler.conf.example index 808987e..eac266b 100644 --- a/vrobbler.conf.example +++ b/vrobbler.conf.example @@ -25,6 +25,7 @@ VROBBLER_TODOIST_CLIENT_ID="" VROBBLER_TODOIST_CLIENT_SECRET="" VROBBLER_GOOGLE_API_KEY="" VROBBLER_LICHESS_API_KEY = "" +VROBBLER_FASTCORK_API_KEY="fc_" # Storages # VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME" diff --git a/vrobbler/apps/drinks/fastcork.py b/vrobbler/apps/drinks/fastcork.py new file mode 100644 index 0000000..4eef4f9 --- /dev/null +++ b/vrobbler/apps/drinks/fastcork.py @@ -0,0 +1,47 @@ +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +FASTCORK_BASE_URL = "https://fastcork.com" +FASTCORK_SEARCH_URL = f"{FASTCORK_BASE_URL}/v1/search" + + +def search_wines(query: str) -> list[dict]: + api_key = settings.FASTCORK_API_KEY + if not api_key: + logger.warning("FASTCORK_API_KEY is not configured") + return [] + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload = {"query": query, "lang": "en"} + + try: + response = requests.post( + FASTCORK_SEARCH_URL, headers=headers, json=payload, timeout=10 + ) + except requests.RequestException: + logger.exception("FastCork search request failed") + return [] + + if response.status_code != 200: + logger.warning( + "Bad response from FastCork search", + extra={"status_code": response.status_code}, + ) + return [] + + data = response.json() + if not data.get("success"): + logger.warning( + "FastCork search returned success=false", + extra={"response": data}, + ) + return [] + + return data.get("results", {}).get("query", []) diff --git a/vrobbler/apps/drinks/migrations/0011_add_wine_fastcork_fields.py b/vrobbler/apps/drinks/migrations/0011_add_wine_fastcork_fields.py new file mode 100644 index 0000000..5f3f9b6 --- /dev/null +++ b/vrobbler/apps/drinks/migrations/0011_add_wine_fastcork_fields.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.29 on 2026-07-15 00:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("drinks", "0010_alter_beerproducer_options_alter_beerstyle_options_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="wine", + name="abv", + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name="wine", + name="aroma", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="wine", + name="decanting_time_minutes", + field=models.SmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="wine", + name="food_pairing", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="wine", + name="serving_temperature_max_celsius", + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name="wine", + name="serving_temperature_min_celsius", + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/vrobbler/apps/drinks/models.py b/vrobbler/apps/drinks/models.py index ba631e0..2d9f83a 100644 --- a/vrobbler/apps/drinks/models.py +++ b/vrobbler/apps/drinks/models.py @@ -367,6 +367,12 @@ class Wine(ScrobblableMixin): cellartracker_id = models.CharField(max_length=255, **BNULL) cellartracker_rating = models.FloatField(**BNULL) cellartracker_image = models.ImageField(upload_to="drinks/cellartracker/", **BNULL) + abv = models.FloatField(**BNULL) + aroma = models.TextField(**BNULL) + food_pairing = models.TextField(**BNULL) + serving_temperature_min_celsius = models.FloatField(**BNULL) + serving_temperature_max_celsius = models.FloatField(**BNULL) + decanting_time_minutes = models.SmallIntegerField(**BNULL) def get_absolute_url(self) -> str: return reverse("drinks:wine_detail", kwargs={"slug": self.uuid}) @@ -475,6 +481,66 @@ class Wine(ScrobblableMixin): return wine + @classmethod + def find_or_create_from_search(cls, wine_data: dict) -> "Wine": + title = wine_data.get("full_wine_name", "") + producer_name = wine_data.get("winery", "") + + wine = None + if title and producer_name: + wine = cls.objects.filter(title=title, producer__name=producer_name).first() + elif title: + wine = cls.objects.filter(title=title).first() + + if wine: + return wine + + producer = None + if producer_name: + producer, _ = WineProducer.objects.get_or_create( + name=producer_name, + defaults={ + "description": wine_data.get("winery_description", ""), + }, + ) + if not producer.description and wine_data.get("winery_description"): + producer.description = wine_data["winery_description"] + producer.save() + + region = None + region_name = wine_data.get("region", "") + if region_name: + region, _ = WineRegion.objects.get_or_create(name=region_name) + + wine = cls.objects.create( + title=title, + description=wine_data.get("tasting_notes", ""), + wine_type=wine_data.get("wine_type", "").lower(), + vintage=wine_data.get("vintage", ""), + producer=producer, + region=region, + abv=wine_data.get("alc_percentage"), + aroma=wine_data.get("aroma", ""), + food_pairing=wine_data.get("food_pairing", ""), + serving_temperature_min_celsius=wine_data.get( + "serving_temperature_celcius_range", {} + ).get("min_temp"), + serving_temperature_max_celsius=wine_data.get( + "serving_temperature_celcius_range", {} + ).get("max_temp"), + decanting_time_minutes=wine_data.get("decanting_time_minutes"), + ) + + grape_variety = wine_data.get("grape_variety", "") + if grape_variety: + for grape_name in grape_variety.split(","): + grape_name = grape_name.strip() + if grape_name: + grape, _ = WineGrape.objects.get_or_create(name=grape_name) + wine.grapes.add(grape) + + return wine + def scrobbles(self, user_id): Scrobble = apps.get_model("scrobbles", "Scrobble") return Scrobble.objects.filter(user_id=user_id, wine=self).order_by( diff --git a/vrobbler/apps/drinks/urls.py b/vrobbler/apps/drinks/urls.py index 4b2fb86..a9be57b 100644 --- a/vrobbler/apps/drinks/urls.py +++ b/vrobbler/apps/drinks/urls.py @@ -12,6 +12,16 @@ urlpatterns = [ name="beer_detail", ), path("wines/", views.WineListView.as_view(), name="wine_list"), + path( + "wines/search/", + views.WineSearchView.as_view(), + name="wine_search", + ), + path( + "wines/scrobble-from-search/", + views.WineScrobbleFromSearchView.as_view(), + name="wine_scrobble_from_search", + ), path( "wines//", views.WineDetailView.as_view(), diff --git a/vrobbler/apps/drinks/views.py b/vrobbler/apps/drinks/views.py index 427ff0a..15def25 100644 --- a/vrobbler/apps/drinks/views.py +++ b/vrobbler/apps/drinks/views.py @@ -1,6 +1,11 @@ from django.contrib import messages from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse +from django.utils import timezone from django.views import View +from django.views.generic import TemplateView +from drinks.fastcork import search_wines from drinks.models import Beer, Coffee, Drink, Wine from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView @@ -56,3 +61,64 @@ class QuickWaterScrobbleView(View): def get(self, request, *args, **kwargs): return self.post(request, *args, **kwargs) + + +class WineSearchView(TemplateView): + template_name = "drinks/wine_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_wines(query) + context["results"] = results + self.request.session["wine_search_results"] = results + self.request.session["wine_search_query"] = query + + return context + + +class WineScrobbleFromSearchView(View): + def post(self, request, *args, **kwargs): + if not request.user.is_authenticated: + messages.error(request, "You must be logged in to scrobble wine.") + return HttpResponseRedirect("/") + + wine_index = request.POST.get("wine_index") + query = request.POST.get("query", "") + results = request.session.get("wine_search_results", []) + + try: + wine_index = int(wine_index) + wine_data = results[wine_index] + except (TypeError, IndexError, ValueError): + messages.error(request, "Invalid wine selection.") + return HttpResponseRedirect( + reverse("drinks:wine_search") + f"?q={query}" if query else "/" + ) + + from scrobbles.models import Scrobble + + wine = Wine.find_or_create_from_search(wine_data) + + if not wine: + messages.error(request, "Could not create wine from search result.") + return HttpResponseRedirect( + reverse("drinks:wine_search") + f"?q={query}" if query else "/" + ) + + scrobble_dict = { + "user_id": request.user.id, + "timestamp": timezone.now(), + "playback_position_seconds": 0, + "source": "FastCork", + } + scrobble = Scrobble.create_or_update(wine, 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("drinks:wine_list")) diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.py index c89cbdf..bc85c59 100644 --- a/vrobbler/apps/scrobbles/constants.py +++ b/vrobbler/apps/scrobbles/constants.py @@ -69,7 +69,7 @@ SCROBBLE_CONTENT_URLS = { "-s": ["https://www.thesportsdb.com/event/"], "-g": ["https://boardgamegeek.com/boardgame/"], "-u": ["https://untappd.com/"], - "-wi": ["https://www.vivino.com/", "https://www.cellartracker.com/"], + "-wi": [], "-co": ["https://roastdb.com/"], "-b": ["https://www.amazon.com/"], "-t": ["https://app.todoist.com/app/task/{id}"], diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 506a78c..ddb2ae5 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -649,22 +649,7 @@ def manual_scrobble_from_url( scrobble_fn = MANUAL_SCROBBLE_FNS[content_key] - if content_key == "-wi": - if "cellartracker.com" in url: - return manual_scrobble_wine( - cellartracker_id=item_id, - user_id=user_id, - source="CellarTracker", - action=action, - ) - else: - return manual_scrobble_wine( - vivino_id=item_id, - user_id=user_id, - source="Vivino", - action=action, - ) - elif content_key == "-co": + if content_key == "-co": return manual_scrobble_coffee( roastdb_url=url, user_id=user_id, @@ -1281,17 +1266,29 @@ def manual_scrobble_beer( def manual_scrobble_wine( + search_query: str = None, vivino_id: str = None, cellartracker_id: str = None, user_id: int = None, - source: str = "Vivino", + source: str = "FastCork", action: Optional[str] = None, ): - wine = Wine.find_or_create(vivino_id=vivino_id, cellartracker_id=cellartracker_id) + wine = None + + if search_query: + from drinks.fastcork import search_wines + + results = search_wines(search_query) + if results: + wine = Wine.find_or_create_from_search(results[0]) + else: + wine = Wine.find_or_create( + vivino_id=vivino_id, cellartracker_id=cellartracker_id + ) if not wine: logger.error( - f"No wine found for ID vivino={vivino_id} cellartracker={cellartracker_id}" + f"No wine found for query={search_query} vivino={vivino_id} cellartracker={cellartracker_id}" ) return diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 95558ce..6cb2c9b 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -636,6 +636,10 @@ class ManualScrobbleView(FormView): else: key = item_str[:2] item_id = item_str[3:] + + if key == "-wi": + return HttpResponseRedirect(reverse("drinks:wine_search") + f"?q={item_id}") + scrobble_fn = MANUAL_SCROBBLE_FNS[key] scrobble = eval(scrobble_fn)(item_id, self.request.user.id) diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 6ef61b9..d6fc699 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -87,6 +87,8 @@ AMAZON_PAAPI_SECRET_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_SECRET_KEY", "") AMAZON_PAAPI_ASSOCIATE_TAG = os.getenv("VROBBLER_AMAZON_PAAPI_ASSOCIATE_TAG", "") AMAZON_PAAPI_COUNTRY = os.getenv("VROBBLER_AMAZON_PAAPI_COUNTRY", "US") +FASTCORK_API_KEY = os.getenv("VROBBLER_FASTCORK_API_KEY", "") + DEFAULT_TASK_CONTEXT_TAGS = [ "Dev", "Home", diff --git a/vrobbler/templates/drinks/wine_detail.html b/vrobbler/templates/drinks/wine_detail.html index 4daf718..7c92c00 100644 --- a/vrobbler/templates/drinks/wine_detail.html +++ b/vrobbler/templates/drinks/wine_detail.html @@ -34,11 +34,8 @@
{% endif %}

- {% if object.vivino_link %} - - {% endif %} - {% if object.cellartracker_link %} - CellarTracker + {% if object.abv %} + {{ object.abv }}% ABV {% endif %}

diff --git a/vrobbler/templates/drinks/wine_search.html b/vrobbler/templates/drinks/wine_search.html new file mode 100644 index 0000000..79aecaf --- /dev/null +++ b/vrobbler/templates/drinks/wine_search.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% load static %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+

Search Wines

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

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

+ {% for wine in results %} +
+
+
{{ wine.full_wine_name }}
+
+ {{ wine.winery }} · {{ wine.region }} · {{ wine.vintage }} +
+
+ {{ wine.wine_type }} · {{ wine.grape_variety }} · {{ wine.alc_percentage }}% ABV +
+ {% if wine.tasting_notes %} +
{{ wine.tasting_notes }}
+ {% endif %} +
+
+ {% csrf_token %} + + + +
+
+ {% endfor %} + {% else %} +
+ No wines found matching your search. +
+ {% endif %} +
+ {% else %} +
+

Enter a wine name to search via FastCork.

+
+ {% endif %} +
+
+{% endblock %}