This commit has no recorded session.
diff
commit 740e5268ddec24ff2777c4e58dbdf207673a18e3Author: Colin Powell <colin@unbl.ink>Date: Tue Jul 14 21:05:22 2026 -0400 [drinks] Add better wine lookups@@ -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:@@ -25,6 +25,7 @@ VROBBLER_TODOIST_CLIENT_ID="<id>" VROBBLER_TODOIST_CLIENT_SECRET="<key>" VROBBLER_GOOGLE_API_KEY="<key>" VROBBLER_LICHESS_API_KEY = "<key>"+VROBBLER_FASTCORK_API_KEY="fc_<key>" # Storages # VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"@@ -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", [])@@ -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),+ ),+ ]@@ -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(@@ -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/<slug:slug>/", views.WineDetailView.as_view(),@@ -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"))@@ -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}"],@@ -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 @@ -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) @@ -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",@@ -34,11 +34,8 @@ <hr /> {% endif %} <p style="float:right;">- {% if object.vivino_link %}- <a href="{{object.vivino_link}}"><img src="{% static "images/vivino-logo.png" %}" width=35></a>- {% endif %}- {% if object.cellartracker_link %}- <a href="{{object.cellartracker_link}}">CellarTracker</a>+ {% if object.abv %}+ <span class="text-muted">{{ object.abv }}% ABV</span> {% endif %} </p> </div>@@ -0,0 +1,85 @@+{% 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-notes { font-size: 0.875rem; margin-top: 0.5rem; font-style: italic; }+ .no-results { padding: 2rem; text-align: center; color: #6c757d; }+</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 Wines</h1>+ </div>++<div class="container" style="margin-bottom: 100px;">+ <form method="get" action="{% url 'drinks:wine_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 wine..."+ 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 wine in results %}+ <div class="result-item">+ <div>+ <div class="result-title">{{ wine.full_wine_name }}</div>+ <div class="result-meta">+ {{ wine.winery }} · {{ wine.region }} · {{ wine.vintage }}+ </div>+ <div class="result-detail">+ {{ wine.wine_type }} · {{ wine.grape_variety }} · {{ wine.alc_percentage }}% ABV+ </div>+ {% if wine.tasting_notes %}+ <div class="result-notes">{{ wine.tasting_notes }}</div>+ {% endif %}+ </div>+ <form method="post" action="{% url 'drinks:wine_scrobble_from_search' %}">+ {% csrf_token %}+ <input type="hidden" name="wine_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 wines found matching your search.+ </div>+ {% endif %}+ </div>+ {% else %}+ <div class="no-results">+ <p>Enter a wine name to search via FastCork.</p>+ </div>+ {% endif %}+</div>+</main>+{% endblock %}