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'
{self.board}
') if self.location: html_parts.append(f'
{self.location}
') 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'
{player_info}
' ) html_parts.append( f'
{"".join(players_html)}
' ) 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 ) def __str__(self): return self.title 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