Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abf0eae493 | |||
| 740e5268dd |
@ -88,7 +88,7 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/24] :vrobbler:project:personal:
|
||||
* Backlog [0/25] :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,12 @@ 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)
|
||||
|
||||
* Version 60.2 [1/1]
|
||||
** 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:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "60.1"
|
||||
version = "60.2"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
47
vrobbler/apps/drinks/fastcork.py
Normal file
47
vrobbler/apps/drinks/fastcork.py
Normal file
@ -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>
|
||||
|
||||
85
vrobbler/templates/drinks/wine_search.html
Normal file
85
vrobbler/templates/drinks/wine_search.html
Normal file
@ -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 %}
|
||||
Reference in New Issue
Block a user