Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abf0eae493 | |||
| 740e5268dd |
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** 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] 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:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
: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)
|
- 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)
|
- 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]
|
* Version 60.1 [1/1]
|
||||||
** DONE [#B] Migrate coffee scrobbles to new Coffee model :drinks:mgmtcmd:
|
** DONE [#B] Migrate coffee scrobbles to new Coffee model :drinks:mgmtcmd:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "60.1"
|
version = "60.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ VROBBLER_TODOIST_CLIENT_ID="<id>"
|
|||||||
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
||||||
VROBBLER_GOOGLE_API_KEY="<key>"
|
VROBBLER_GOOGLE_API_KEY="<key>"
|
||||||
VROBBLER_LICHESS_API_KEY = "<key>"
|
VROBBLER_LICHESS_API_KEY = "<key>"
|
||||||
|
VROBBLER_FASTCORK_API_KEY="fc_<key>"
|
||||||
|
|
||||||
# Storages
|
# Storages
|
||||||
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
# 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_id = models.CharField(max_length=255, **BNULL)
|
||||||
cellartracker_rating = models.FloatField(**BNULL)
|
cellartracker_rating = models.FloatField(**BNULL)
|
||||||
cellartracker_image = models.ImageField(upload_to="drinks/cellartracker/", **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:
|
def get_absolute_url(self) -> str:
|
||||||
return reverse("drinks:wine_detail", kwargs={"slug": self.uuid})
|
return reverse("drinks:wine_detail", kwargs={"slug": self.uuid})
|
||||||
@ -475,6 +481,66 @@ class Wine(ScrobblableMixin):
|
|||||||
|
|
||||||
return wine
|
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):
|
def scrobbles(self, user_id):
|
||||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
return Scrobble.objects.filter(user_id=user_id, wine=self).order_by(
|
return Scrobble.objects.filter(user_id=user_id, wine=self).order_by(
|
||||||
|
|||||||
@ -12,6 +12,16 @@ urlpatterns = [
|
|||||||
name="beer_detail",
|
name="beer_detail",
|
||||||
),
|
),
|
||||||
path("wines/", views.WineListView.as_view(), name="wine_list"),
|
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(
|
path(
|
||||||
"wines/<slug:slug>/",
|
"wines/<slug:slug>/",
|
||||||
views.WineDetailView.as_view(),
|
views.WineDetailView.as_view(),
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import HttpResponseRedirect
|
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 import View
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from drinks.fastcork import search_wines
|
||||||
from drinks.models import Beer, Coffee, Drink, Wine
|
from drinks.models import Beer, Coffee, Drink, Wine
|
||||||
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||||
|
|
||||||
@ -56,3 +61,64 @@ class QuickWaterScrobbleView(View):
|
|||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
return self.post(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/"],
|
"-s": ["https://www.thesportsdb.com/event/"],
|
||||||
"-g": ["https://boardgamegeek.com/boardgame/"],
|
"-g": ["https://boardgamegeek.com/boardgame/"],
|
||||||
"-u": ["https://untappd.com/"],
|
"-u": ["https://untappd.com/"],
|
||||||
"-wi": ["https://www.vivino.com/", "https://www.cellartracker.com/"],
|
"-wi": [],
|
||||||
"-co": ["https://roastdb.com/"],
|
"-co": ["https://roastdb.com/"],
|
||||||
"-b": ["https://www.amazon.com/"],
|
"-b": ["https://www.amazon.com/"],
|
||||||
"-t": ["https://app.todoist.com/app/task/{id}"],
|
"-t": ["https://app.todoist.com/app/task/{id}"],
|
||||||
|
|||||||
@ -649,22 +649,7 @@ def manual_scrobble_from_url(
|
|||||||
|
|
||||||
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
|
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
|
||||||
|
|
||||||
if content_key == "-wi":
|
if content_key == "-co":
|
||||||
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":
|
|
||||||
return manual_scrobble_coffee(
|
return manual_scrobble_coffee(
|
||||||
roastdb_url=url,
|
roastdb_url=url,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@ -1281,17 +1266,29 @@ def manual_scrobble_beer(
|
|||||||
|
|
||||||
|
|
||||||
def manual_scrobble_wine(
|
def manual_scrobble_wine(
|
||||||
|
search_query: str = None,
|
||||||
vivino_id: str = None,
|
vivino_id: str = None,
|
||||||
cellartracker_id: str = None,
|
cellartracker_id: str = None,
|
||||||
user_id: int = None,
|
user_id: int = None,
|
||||||
source: str = "Vivino",
|
source: str = "FastCork",
|
||||||
action: Optional[str] = None,
|
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:
|
if not wine:
|
||||||
logger.error(
|
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
|
return
|
||||||
|
|
||||||
|
|||||||
@ -636,6 +636,10 @@ class ManualScrobbleView(FormView):
|
|||||||
else:
|
else:
|
||||||
key = item_str[:2]
|
key = item_str[:2]
|
||||||
item_id = item_str[3:]
|
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_fn = MANUAL_SCROBBLE_FNS[key]
|
||||||
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
|
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_ASSOCIATE_TAG = os.getenv("VROBBLER_AMAZON_PAAPI_ASSOCIATE_TAG", "")
|
||||||
AMAZON_PAAPI_COUNTRY = os.getenv("VROBBLER_AMAZON_PAAPI_COUNTRY", "US")
|
AMAZON_PAAPI_COUNTRY = os.getenv("VROBBLER_AMAZON_PAAPI_COUNTRY", "US")
|
||||||
|
|
||||||
|
FASTCORK_API_KEY = os.getenv("VROBBLER_FASTCORK_API_KEY", "")
|
||||||
|
|
||||||
DEFAULT_TASK_CONTEXT_TAGS = [
|
DEFAULT_TASK_CONTEXT_TAGS = [
|
||||||
"Dev",
|
"Dev",
|
||||||
"Home",
|
"Home",
|
||||||
|
|||||||
@ -34,11 +34,8 @@
|
|||||||
<hr />
|
<hr />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p style="float:right;">
|
<p style="float:right;">
|
||||||
{% if object.vivino_link %}
|
{% if object.abv %}
|
||||||
<a href="{{object.vivino_link}}"><img src="{% static "images/vivino-logo.png" %}" width=35></a>
|
<span class="text-muted">{{ object.abv }}% ABV</span>
|
||||||
{% endif %}
|
|
||||||
{% if object.cellartracker_link %}
|
|
||||||
<a href="{{object.cellartracker_link}}">CellarTracker</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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