576 lines
17 KiB
Python
576 lines
17 KiB
Python
from dataclasses import dataclass
|
|
from typing import Optional
|
|
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 drinks.cellartracker import get_wine_from_cellartracker_id
|
|
from drinks.roastdb import get_coffee_from_slug, search_coffee_on_roastdb
|
|
from drinks.untappd import get_beer_from_untappd_id
|
|
from drinks.vivino import get_wine_from_vivino_id
|
|
from imagekit.models import ImageSpecField
|
|
from imagekit.processors import ResizeToFit
|
|
from scrobbles.dataclasses import BaseLogData
|
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
|
|
|
BNULL = {"blank": True, "null": True}
|
|
|
|
|
|
class WineType(models.TextChoices):
|
|
RED = "red", "Red"
|
|
WHITE = "white", "White"
|
|
ROSE = "rosé", "Rosé"
|
|
SPARKLING = "sparkling", "Sparkling"
|
|
DESSERT = "dessert", "Dessert"
|
|
FORTIFIED = "fortified", "Fortified"
|
|
ORANGE = "orange", "Orange"
|
|
|
|
|
|
class DrinkFormat(models.TextChoices):
|
|
ALUMINUM_CAN = "aluminum_can", "Aluminum Can"
|
|
GLASS_BOTTLE = "glass_bottle", "Glass Bottle"
|
|
PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"
|
|
METAL_BOTTLE = "metal_bottle", "Metal Bottle"
|
|
KEG = "keg", "Keg"
|
|
BY_THE_GLASS = "by_the_glass", "By the Glass"
|
|
DRAFT = "draft", "Draft"
|
|
GROWLER = "growler", "Growler"
|
|
BOX = "box", "Box"
|
|
|
|
|
|
class RoastLevel(models.TextChoices):
|
|
LIGHT = "light", "Light"
|
|
MEDIUM = "medium", "Medium"
|
|
DARK = "dark", "Dark"
|
|
|
|
|
|
class BrewingMethod(models.TextChoices):
|
|
POUROVER = "pourover", "Pour-over"
|
|
DRIP = "drip", "Drip"
|
|
FRENCH_PRESS = "french_press", "French Press"
|
|
ESPRESSO = "espresso", "Espresso"
|
|
AEROPRESS = "aeropress", "AeroPress"
|
|
KEURIG = "keurig", "Keurig"
|
|
NESCAPRESSO = "nespresso", "Nespresso"
|
|
COLD_BREW = "cold_brew", "Cold Brew"
|
|
MOKA_POT = "moka_pot", "Moka Pot"
|
|
SIPHON = "siphon", "Siphon"
|
|
TURKISH = "turkish", "Turkish"
|
|
CHEMEX = "chemex", "Chemex"
|
|
|
|
|
|
ML_PER_OZ = 29.5735
|
|
|
|
|
|
@dataclass
|
|
class DrinkLogData(BaseLogData):
|
|
rating: Optional[str] = None
|
|
format: Optional[str] = None
|
|
size_ml: Optional[int] = None
|
|
|
|
@property
|
|
def size_oz(self) -> Optional[float]:
|
|
if self.size_ml:
|
|
return round(self.size_ml / ML_PER_OZ, 1)
|
|
return None
|
|
|
|
@property
|
|
def format_display(self) -> str:
|
|
if self.format:
|
|
return DrinkFormat(self.format).label
|
|
return ""
|
|
|
|
@classmethod
|
|
def override_fields(cls) -> dict:
|
|
from django import forms
|
|
|
|
return {
|
|
"format": forms.ChoiceField(
|
|
choices=[("", "---------")] + list(DrinkFormat.choices),
|
|
required=False,
|
|
),
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class BeerLogData(DrinkLogData):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class WineLogData(DrinkLogData):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class CoffeeLogData(DrinkLogData):
|
|
roast_level: Optional[str] = None
|
|
brewing_method: Optional[str] = None
|
|
single_origin_or_blend: Optional[str] = None
|
|
|
|
@property
|
|
def roast_level_display(self) -> str:
|
|
if self.roast_level:
|
|
return RoastLevel(self.roast_level).label
|
|
return ""
|
|
|
|
@property
|
|
def brewing_method_display(self) -> str:
|
|
if self.brewing_method:
|
|
return BrewingMethod(self.brewing_method).label
|
|
return ""
|
|
|
|
@classmethod
|
|
def override_fields(cls) -> dict:
|
|
from django import forms
|
|
|
|
fields = super().override_fields()
|
|
fields.update(
|
|
{
|
|
"roast_level": forms.ChoiceField(
|
|
choices=[("", "---------")] + list(RoastLevel.choices),
|
|
required=False,
|
|
),
|
|
"brewing_method": forms.ChoiceField(
|
|
choices=[("", "---------")] + list(BrewingMethod.choices),
|
|
required=False,
|
|
),
|
|
}
|
|
)
|
|
return fields
|
|
|
|
|
|
class Drink(ScrobblableMixin):
|
|
calories = models.PositiveIntegerField(**BNULL)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
@property
|
|
def is_alcoholic(self) -> bool:
|
|
return True
|
|
|
|
|
|
# --- Beer models ---
|
|
|
|
|
|
class BeerStyle(TimeStampedModel):
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
name = models.CharField(max_length=255)
|
|
description = models.TextField(**BNULL)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
db_table = "beers_beerstyle"
|
|
|
|
|
|
class BeerProducer(TimeStampedModel):
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
name = models.CharField(max_length=255)
|
|
description = models.TextField(**BNULL)
|
|
location = models.CharField(max_length=255, **BNULL)
|
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
|
|
|
def find_or_create(cls, title: str) -> "BeerProducer":
|
|
return cls.objects.filter(title=title).first()
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
db_table = "beers_beerproducer"
|
|
|
|
|
|
class Beer(Drink):
|
|
description = models.TextField(**BNULL)
|
|
ibu = models.SmallIntegerField(**BNULL)
|
|
abv = models.FloatField(**BNULL)
|
|
styles = models.ManyToManyField(BeerStyle, related_name="styles")
|
|
non_alcoholic = models.BooleanField(default=False)
|
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
|
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
|
untappd_image = models.ImageField(upload_to="drinks/untappd/", **BNULL)
|
|
untappd_image_small = ImageSpecField(
|
|
source="untappd_image",
|
|
processors=[ResizeToFit(100, 100)],
|
|
format="JPEG",
|
|
options={"quality": 60},
|
|
)
|
|
untappd_image_medium = ImageSpecField(
|
|
source="untappd_image",
|
|
processors=[ResizeToFit(300, 300)],
|
|
format="JPEG",
|
|
options={"quality": 75},
|
|
)
|
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
|
untappd_rating = models.FloatField(**BNULL)
|
|
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
|
def get_absolute_url(self) -> str:
|
|
return reverse("drinks:beer_detail", kwargs={"slug": self.uuid})
|
|
|
|
def __str__(self):
|
|
return f"{self.title} by {self.producer}"
|
|
|
|
@property
|
|
def subtitle(self):
|
|
return self.producer.name
|
|
|
|
@property
|
|
def strings(self) -> ScrobblableConstants:
|
|
return ScrobblableConstants(verb="Drinking", tags="beer")
|
|
|
|
@property
|
|
def beeradvocate_link(self) -> str:
|
|
link = ""
|
|
if self.producer and self.beeradvocate_id:
|
|
if self.beeradvocate_id:
|
|
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
|
|
return link
|
|
|
|
@property
|
|
def untappd_link(self) -> str:
|
|
link = ""
|
|
if self.untappd_id:
|
|
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
|
|
return link
|
|
|
|
@property
|
|
def primary_image_url(self) -> str:
|
|
url = ""
|
|
if self.untappd_image:
|
|
url = self.untappd_image.url
|
|
return url
|
|
|
|
@property
|
|
def logdata_cls(self):
|
|
return BeerLogData
|
|
|
|
@classmethod
|
|
def find_or_create(cls, untappd_id: str) -> "Beer":
|
|
beer = cls.objects.filter(untappd_id=untappd_id).first()
|
|
|
|
if not beer:
|
|
beer_dict = get_beer_from_untappd_id(untappd_id)
|
|
producer_dict = {}
|
|
style_ids = []
|
|
for key in list(beer_dict.keys()):
|
|
if "producer__" in key:
|
|
pkey = key.replace("producer__", "")
|
|
producer_dict[pkey] = beer_dict.pop(key)
|
|
if "styles" in key:
|
|
for style in beer_dict.pop("styles"):
|
|
style_inst, created = BeerStyle.objects.get_or_create(
|
|
name=style
|
|
)
|
|
style_ids.append(style_inst.id)
|
|
|
|
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
|
|
beer_dict["producer_id"] = producer.id
|
|
beer = Beer.objects.create(**beer_dict)
|
|
for style_id in style_ids:
|
|
beer.styles.add(style_id)
|
|
|
|
return beer
|
|
|
|
def scrobbles(self, user_id):
|
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
|
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
|
|
"-timestamp"
|
|
)
|
|
|
|
class Meta:
|
|
db_table = "beers_beer"
|
|
|
|
|
|
# --- Wine models ---
|
|
|
|
|
|
class WineGrape(TimeStampedModel):
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
name = models.CharField(max_length=255)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class WineRegion(TimeStampedModel):
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
name = models.CharField(max_length=255)
|
|
country = models.CharField(max_length=255, **BNULL)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class WineProducer(TimeStampedModel):
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
name = models.CharField(max_length=255)
|
|
description = models.TextField(**BNULL)
|
|
location = models.CharField(max_length=255, **BNULL)
|
|
vivino_id = models.CharField(max_length=255, **BNULL)
|
|
cellartracker_id = models.CharField(max_length=255, **BNULL)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class Wine(Drink):
|
|
description = models.TextField(**BNULL)
|
|
wine_type = models.CharField(max_length=20, choices=WineType.choices, **BNULL)
|
|
grapes = models.ManyToManyField(WineGrape, related_name="wines", blank=True)
|
|
region = models.ForeignKey(WineRegion, on_delete=models.DO_NOTHING, **BNULL)
|
|
producer = models.ForeignKey(WineProducer, on_delete=models.DO_NOTHING, **BNULL)
|
|
vintage = models.CharField(max_length=255, **BNULL)
|
|
vivino_id = models.CharField(max_length=255, **BNULL)
|
|
vivino_rating = models.FloatField(**BNULL)
|
|
vivino_image = models.ImageField(upload_to="drinks/vivino/", **BNULL)
|
|
vivino_image_small = ImageSpecField(
|
|
source="vivino_image",
|
|
processors=[ResizeToFit(100, 100)],
|
|
format="JPEG",
|
|
options={"quality": 60},
|
|
)
|
|
vivino_image_medium = ImageSpecField(
|
|
source="vivino_image",
|
|
processors=[ResizeToFit(300, 300)],
|
|
format="JPEG",
|
|
options={"quality": 75},
|
|
)
|
|
cellartracker_id = models.CharField(max_length=255, **BNULL)
|
|
cellartracker_rating = models.FloatField(**BNULL)
|
|
cellartracker_image = models.ImageField(upload_to="drinks/cellartracker/", **BNULL)
|
|
|
|
def get_absolute_url(self) -> str:
|
|
return reverse("drinks:wine_detail", kwargs={"slug": self.uuid})
|
|
|
|
def __str__(self):
|
|
return f"{self.title} by {self.producer}"
|
|
|
|
@property
|
|
def subtitle(self):
|
|
return self.producer.name
|
|
|
|
@property
|
|
def strings(self) -> ScrobblableConstants:
|
|
return ScrobblableConstants(verb="Drinking", tags="wine")
|
|
|
|
@property
|
|
def vivino_link(self) -> str:
|
|
if self.vivino_id:
|
|
return f"https://www.vivino.com/{self.vivino_id}"
|
|
return ""
|
|
|
|
@property
|
|
def cellartracker_link(self) -> str:
|
|
if self.cellartracker_id:
|
|
return (
|
|
f"https://www.cellartracker.com/wine.asp?iWine={self.cellartracker_id}"
|
|
)
|
|
return ""
|
|
|
|
@property
|
|
def primary_image_url(self) -> str:
|
|
if self.vivino_image:
|
|
return self.vivino_image.url
|
|
if self.cellartracker_image:
|
|
return self.cellartracker_image.url
|
|
return ""
|
|
|
|
@property
|
|
def logdata_cls(self):
|
|
return WineLogData
|
|
|
|
def save_image_from_url(self, url):
|
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
|
r = requests.get(url, headers=headers)
|
|
if r.status_code == 200:
|
|
fname = f"{self.title}_{self.uuid}.jpg"
|
|
self.vivino_image.save(fname, ContentFile(r.content), save=True)
|
|
|
|
@classmethod
|
|
def find_or_create(
|
|
cls, vivino_id: str = None, cellartracker_id: str = None
|
|
) -> "Wine":
|
|
wine = None
|
|
if vivino_id:
|
|
wine = cls.objects.filter(vivino_id=vivino_id).first()
|
|
if not wine and cellartracker_id:
|
|
wine = cls.objects.filter(cellartracker_id=cellartracker_id).first()
|
|
|
|
if wine:
|
|
return wine
|
|
|
|
wine_dict = None
|
|
if vivino_id:
|
|
wine_dict = get_wine_from_vivino_id(vivino_id)
|
|
elif cellartracker_id:
|
|
wine_dict = get_wine_from_cellartracker_id(cellartracker_id)
|
|
|
|
if not wine_dict or not wine_dict.get("title"):
|
|
return None
|
|
|
|
producer_name = wine_dict.pop("producer__name", "")
|
|
region_name = wine_dict.pop("region", "")
|
|
grape_names = wine_dict.pop("grapes", [])
|
|
image_url = wine_dict.pop("image_url", "")
|
|
|
|
producer = None
|
|
if producer_name:
|
|
producer, _ = WineProducer.objects.get_or_create(name=producer_name)
|
|
|
|
region = None
|
|
if region_name:
|
|
region, _ = WineRegion.objects.get_or_create(name=region_name)
|
|
|
|
wine = cls.objects.create(
|
|
title=wine_dict.get("title", ""),
|
|
description=wine_dict.get("description", ""),
|
|
wine_type=wine_dict.get("wine_type", ""),
|
|
vintage=wine_dict.get("vintage", ""),
|
|
vivino_id=wine_dict.get("vivino_id", vivino_id),
|
|
vivino_rating=wine_dict.get("vivino_rating"),
|
|
cellartracker_id=wine_dict.get("cellartracker_id", cellartracker_id),
|
|
cellartracker_rating=wine_dict.get("cellartracker_rating"),
|
|
producer=producer,
|
|
region=region,
|
|
)
|
|
|
|
for grape_name in grape_names:
|
|
grape, _ = WineGrape.objects.get_or_create(name=grape_name)
|
|
wine.grapes.add(grape)
|
|
|
|
if image_url:
|
|
try:
|
|
wine.save_image_from_url(image_url)
|
|
except Exception:
|
|
pass
|
|
|
|
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(
|
|
"-timestamp"
|
|
)
|
|
|
|
|
|
# --- Coffee models ---
|
|
|
|
|
|
class CoffeeRoaster(TimeStampedModel):
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
name = models.CharField(max_length=255)
|
|
description = models.TextField(**BNULL)
|
|
location = models.CharField(max_length=255, **BNULL)
|
|
roastdb_id = models.CharField(max_length=255, **BNULL)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class Coffee(Drink):
|
|
description = models.TextField(**BNULL)
|
|
origin = models.CharField(max_length=255, **BNULL)
|
|
roaster = models.ForeignKey(CoffeeRoaster, on_delete=models.DO_NOTHING, **BNULL)
|
|
roastdb_id = models.CharField(max_length=255, **BNULL)
|
|
roastdb_image = models.ImageField(upload_to="drinks/roastdb/", **BNULL)
|
|
roastdb_image_small = ImageSpecField(
|
|
source="roastdb_image",
|
|
processors=[ResizeToFit(100, 100)],
|
|
format="JPEG",
|
|
options={"quality": 60},
|
|
)
|
|
roastdb_image_medium = ImageSpecField(
|
|
source="roastdb_image",
|
|
processors=[ResizeToFit(300, 300)],
|
|
format="JPEG",
|
|
options={"quality": 75},
|
|
)
|
|
|
|
def get_absolute_url(self) -> str:
|
|
return reverse("drinks:coffee_detail", kwargs={"slug": self.uuid})
|
|
|
|
def __str__(self):
|
|
return f"{self.title} by {self.roaster}"
|
|
|
|
@property
|
|
def subtitle(self):
|
|
return self.roaster.name
|
|
|
|
@property
|
|
def strings(self) -> ScrobblableConstants:
|
|
return ScrobblableConstants(verb="Drinking", tags="coffee")
|
|
|
|
@property
|
|
def primary_image_url(self) -> str:
|
|
if self.roastdb_image:
|
|
return self.roastdb_image.url
|
|
return ""
|
|
|
|
@property
|
|
def logdata_cls(self):
|
|
return CoffeeLogData
|
|
|
|
def save_image_from_url(self, url):
|
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
|
r = requests.get(url, headers=headers)
|
|
if r.status_code == 200:
|
|
fname = f"{self.title}_{self.uuid}.jpg"
|
|
self.roastdb_image.save(fname, ContentFile(r.content), save=True)
|
|
|
|
@classmethod
|
|
def find_or_create(cls, roastdb_id: str = None, slug: str = None) -> "Coffee":
|
|
coffee = None
|
|
if roastdb_id:
|
|
coffee = cls.objects.filter(roastdb_id=roastdb_id).first()
|
|
if not coffee and slug:
|
|
coffee = cls.objects.filter(roastdb_id=slug).first()
|
|
if coffee:
|
|
return coffee
|
|
|
|
coffee_dict = None
|
|
if slug:
|
|
coffee_dict = get_coffee_from_slug(slug)
|
|
elif roastdb_id:
|
|
coffee_dict = get_coffee_from_slug(roastdb_id)
|
|
|
|
if not coffee_dict or not coffee_dict.get("title"):
|
|
return None
|
|
|
|
roaster_name = coffee_dict.pop("roaster__name", "")
|
|
image_url = coffee_dict.pop("image_url", "")
|
|
|
|
roaster = None
|
|
if roaster_name:
|
|
roaster, _ = CoffeeRoaster.objects.get_or_create(name=roaster_name)
|
|
|
|
coffee = cls.objects.create(
|
|
title=coffee_dict.get("title", ""),
|
|
description=coffee_dict.get("description", ""),
|
|
origin=coffee_dict.get("origin", ""),
|
|
roastdb_id=slug or roastdb_id,
|
|
roaster=roaster,
|
|
)
|
|
|
|
if image_url:
|
|
try:
|
|
coffee.save_image_from_url(image_url)
|
|
except Exception:
|
|
pass
|
|
|
|
return coffee
|
|
|
|
def scrobbles(self, user_id):
|
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
|
return Scrobble.objects.filter(user_id=user_id, coffee=self).order_by(
|
|
"-timestamp"
|
|
)
|