393 lines
13 KiB
Python
393 lines
13 KiB
Python
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from functools import cached_property
|
|
from typing import Any, Optional
|
|
from uuid import uuid4
|
|
|
|
import requests
|
|
from boardgames.sources.bgg import lookup_boardgame_from_bgg
|
|
from django import forms
|
|
from django.conf import settings
|
|
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 imagekit.models import ImageSpecField
|
|
from imagekit.processors import ResizeToFit
|
|
from locations.models import GeoLocation
|
|
from people.models import Person
|
|
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
|
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
BNULL = {"blank": True, "null": True}
|
|
|
|
|
|
@dataclass
|
|
class BoardGameScoreLogData(BaseLogData):
|
|
person_id: Optional[int] = None
|
|
bgg_username: Optional[str] = None
|
|
color: Optional[str] = None
|
|
character: Optional[str] = None
|
|
team: Optional[str] = None
|
|
score: Optional[int] = None
|
|
win: Optional[bool] = None
|
|
new: Optional[bool] = None
|
|
rank: Optional[int] = None
|
|
seat_order: Optional[int] = None
|
|
role: Optional[str] = None
|
|
rank: Optional[int] = None
|
|
seat_order: Optional[int] = None
|
|
role: Optional[str] = None
|
|
lichess_username: Optional[str] = None
|
|
|
|
@property
|
|
def person(self) -> Optional[Person]:
|
|
return Person.objects.filter(id=self.person_id).first()
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
name = ""
|
|
if self.person:
|
|
name = self.person.name
|
|
return name
|
|
|
|
def __str__(self) -> str:
|
|
out = self.name
|
|
if self.score:
|
|
out += f" {self.score}"
|
|
if self.color:
|
|
out += f" ({self.color})"
|
|
if self.win:
|
|
out += f" [W]"
|
|
return out
|
|
|
|
|
|
@dataclass
|
|
class BoardGameLogData(BaseLogData, LongPlayLogData):
|
|
players: Optional[list[BoardGameScoreLogData]] = None
|
|
location_id: Optional[int] = None
|
|
difficulty: Optional[int] = None
|
|
solo: Optional[bool] = None
|
|
two_handed: Optional[bool] = None
|
|
expansion_ids: Optional[int] = None
|
|
moves: Optional[list] = None
|
|
rated: Optional[str] = None
|
|
speed: Optional[str] = None
|
|
variant: Optional[str] = None
|
|
lichess_id: Optional[int] = None
|
|
board: Optional[str] = None
|
|
rounds: Optional[int] = None
|
|
details: Optional[str] = None
|
|
raw_data: Optional[dict] = None
|
|
|
|
_excluded_fields = {
|
|
"lichess_id",
|
|
"speed",
|
|
"rated",
|
|
"moves",
|
|
"variant",
|
|
}
|
|
|
|
@classmethod
|
|
def override_fields(cls) -> dict:
|
|
from scrobbles.forms import NotesDictField
|
|
|
|
fields = {}
|
|
for base in cls.mro()[1:]:
|
|
if hasattr(base, "override_fields"):
|
|
base_fields = base.override_fields()
|
|
fields.update(base_fields)
|
|
custom_fields = {
|
|
"notes": NotesDictField(required=False),
|
|
"location_id": forms.ModelChoiceField(
|
|
queryset=BoardGameLocation.objects.all(),
|
|
required=False,
|
|
widget=forms.Select(),
|
|
),
|
|
}
|
|
fields.update(custom_fields)
|
|
return fields
|
|
|
|
@cached_property
|
|
def location(self):
|
|
if not self.location_id:
|
|
return
|
|
return BoardGameLocation.objects.filter(id=self.location_id).first()
|
|
|
|
@cached_property
|
|
def player_log(self) -> str:
|
|
if self.players:
|
|
return ", ".join(
|
|
[BoardGameScoreLogData(**player).__str__() for player in self.players]
|
|
)
|
|
return ""
|
|
|
|
def as_html(self) -> str:
|
|
html_parts = []
|
|
|
|
if self.board:
|
|
html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
|
|
|
|
if self.location:
|
|
html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
|
|
|
|
if self.players:
|
|
players_html = []
|
|
for player_data in self.players:
|
|
player = BoardGameScoreLogData(**player_data)
|
|
player_info = player.name
|
|
if player.score:
|
|
player_info += f" ({player.score})"
|
|
if player.win:
|
|
player_info += " 🏆"
|
|
if player.new:
|
|
player_info += " (new)"
|
|
players_html.append(
|
|
f'<div class="boardgame-player">{player_info}</div>'
|
|
)
|
|
html_parts.append(
|
|
f'<div class="boardgame-players">{"".join(players_html)}</div>'
|
|
)
|
|
|
|
return "".join(html_parts)
|
|
|
|
|
|
class BoardGamePublisher(TimeStampedModel):
|
|
name = models.CharField(max_length=255)
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
|
|
bgg_id = models.IntegerField(**BNULL)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("boardgames:publisher_detail", kwargs={"slug": self.uuid})
|
|
|
|
|
|
class BoardGameDesigner(TimeStampedModel):
|
|
name = models.CharField(max_length=255)
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
bgg_id = models.IntegerField(**BNULL)
|
|
bio = models.TextField(**BNULL)
|
|
|
|
def __str__(self) -> str:
|
|
return str(self.name)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("boardgames:designer_detail", kwargs={"slug": self.uuid})
|
|
|
|
|
|
class BoardGameLocation(TimeStampedModel):
|
|
name = models.CharField(max_length=255)
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
bgstats_id = models.UUIDField(**BNULL)
|
|
description = models.TextField(**BNULL)
|
|
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
|
|
|
|
def __str__(self) -> str:
|
|
return str(self.name)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("boardgames:location_detail", kwargs={"slug": self.uuid})
|
|
|
|
|
|
class BoardGame(ScrobblableMixin):
|
|
COMPLETION_PERCENT = getattr(settings, "BOARD_GAME_COMPLETION_PERCENT", 100)
|
|
|
|
FIELDS_FROM_BGGEEK = [
|
|
"igdb_id",
|
|
"alternative_name",
|
|
"rating",
|
|
"rating_count",
|
|
"release_date",
|
|
"cover",
|
|
"screenshot",
|
|
]
|
|
|
|
title = models.CharField(max_length=255)
|
|
publisher = models.ForeignKey(
|
|
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
|
)
|
|
publishers = models.ManyToManyField(
|
|
BoardGamePublisher,
|
|
related_name="board_games",
|
|
)
|
|
designers = models.ManyToManyField(
|
|
BoardGameDesigner,
|
|
related_name="board_games",
|
|
)
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
description = models.TextField(**BNULL)
|
|
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
|
|
cover_small = ImageSpecField(
|
|
source="cover",
|
|
processors=[ResizeToFit(100, 100)],
|
|
format="JPEG",
|
|
options={"quality": 60},
|
|
)
|
|
cover_medium = ImageSpecField(
|
|
source="cover",
|
|
processors=[ResizeToFit(300, 300)],
|
|
format="JPEG",
|
|
options={"quality": 75},
|
|
)
|
|
layout_image = models.ImageField(upload_to="boardgames/layouts/", **BNULL)
|
|
layout_image_small = ImageSpecField(
|
|
source="layout_image",
|
|
processors=[ResizeToFit(100, 100)],
|
|
format="JPEG",
|
|
options={"quality": 60},
|
|
)
|
|
layout_image_medium = ImageSpecField(
|
|
source="layout_image",
|
|
processors=[ResizeToFit(300, 300)],
|
|
format="JPEG",
|
|
options={"quality": 75},
|
|
)
|
|
rating = models.FloatField(**BNULL)
|
|
bgg_rank = models.IntegerField(**BNULL)
|
|
max_players = models.PositiveSmallIntegerField(**BNULL)
|
|
min_players = models.PositiveSmallIntegerField(**BNULL)
|
|
published_date = models.DateField(**BNULL)
|
|
published_year = models.IntegerField(**BNULL)
|
|
recommended_age = models.PositiveSmallIntegerField(**BNULL)
|
|
bggeek_id = models.CharField(max_length=255, **BNULL)
|
|
bgstats_id = models.UUIDField(**BNULL)
|
|
uses_teams = models.BooleanField(default=False, **BNULL)
|
|
cooperative = models.BooleanField(default=False, **BNULL)
|
|
highest_wins = models.BooleanField(default=True, **BNULL)
|
|
no_points = models.BooleanField(default=False, **BNULL)
|
|
min_play_time = models.IntegerField(**BNULL)
|
|
max_play_time = models.IntegerField(**BNULL)
|
|
expansion_for_boardgame = models.ForeignKey(
|
|
"self", **BNULL, on_delete=models.DO_NOTHING
|
|
)
|
|
|
|
@property
|
|
def subtitle(self) -> str:
|
|
return self.publisher
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})
|
|
|
|
@property
|
|
def logdata_cls(self):
|
|
return BoardGameLogData
|
|
|
|
@property
|
|
def strings(self) -> ScrobblableConstants:
|
|
return ScrobblableConstants(verb="Playing", tags="game_die")
|
|
|
|
def primary_image_url(self) -> str:
|
|
url = ""
|
|
if self.cover:
|
|
url = self.cover.url
|
|
return url
|
|
|
|
def bggeek_link(self):
|
|
link = ""
|
|
if self.bggeek_id:
|
|
link = f"https://boardgamegeek.com/boardgame/{self.bggeek_id}"
|
|
return link
|
|
|
|
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
|
|
|
|
if not self.published_date or force_update:
|
|
if not data:
|
|
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
|
|
|
cover_url = data.pop("cover_url")
|
|
year = data.pop("year_published")
|
|
publisher_name = data.pop("publisher_name")
|
|
|
|
if year:
|
|
data["published_year"] = int(year)
|
|
|
|
if not data["min_players"]:
|
|
data.pop("min_players")
|
|
if not data["min_players"]:
|
|
data.pop("max_players")
|
|
|
|
# Fun trick for updating all fields at once
|
|
BoardGame.objects.filter(pk=self.id).update(**data)
|
|
self.refresh_from_db()
|
|
|
|
# Add publishers
|
|
(
|
|
self.publisher,
|
|
_created,
|
|
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
|
|
self.save()
|
|
|
|
# Go get cover image if the URL is present
|
|
if cover_url and not self.cover:
|
|
self.save_image_from_url(cover_url)
|
|
|
|
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}_cover_{self.uuid}.jpg"
|
|
self.cover.save(fname, ContentFile(r.content), save=True)
|
|
|
|
@classmethod
|
|
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
|
|
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
|
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
|
if not game:
|
|
game = cls.objects.filter(title=lookup_id).first()
|
|
|
|
if game:
|
|
logger.info(
|
|
"Board game exists in database.",
|
|
extra={"lookup_id": lookup_id, "data": data},
|
|
)
|
|
return game
|
|
|
|
if data.get("bggId"):
|
|
bgg_data = lookup_boardgame_from_bgg(lookup_id=data.get("bggId"))
|
|
elif data.get("name"):
|
|
bgg_data = lookup_boardgame_from_bgg(title=data.get("name"))
|
|
else:
|
|
if int(lookup_id):
|
|
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
|
|
else:
|
|
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
|
|
|
mechanics = bgg_data.pop("mechanics", [])
|
|
designers = bgg_data.pop("designers", [])
|
|
categories = bgg_data.pop("categories", [])
|
|
publishers = bgg_data.pop("publishers", [])
|
|
publisher = bgg_data.pop("publisher", [])
|
|
cover_url = bgg_data.pop("cover_url")
|
|
|
|
game = cls.objects.create(**bgg_data)
|
|
|
|
game.save_image_from_url(cover_url)
|
|
game.cooperative = data.get("cooperative", False)
|
|
game.highest_wins = data.get("highestWins", True)
|
|
game.no_points = data.get("noPoints", False)
|
|
game.uses_teams = data.get("useTeams", False)
|
|
game.bgstats_id = data.get("uuid", None)
|
|
if publisher:
|
|
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
|
|
game.publisher = publisher
|
|
game.save()
|
|
|
|
if designers:
|
|
for designer_name in designers:
|
|
designer, created = BoardGameDesigner.objects.get_or_create(
|
|
name=designer_name
|
|
)
|
|
game.designers.add(designer.id)
|
|
|
|
if publishers:
|
|
for name in publishers:
|
|
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
|
|
game.publishers.add(publisher)
|
|
|
|
return game
|