| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- from functools import cached_property
- import logging
- from dataclasses import dataclass
- from datetime import datetime
- from typing import Optional, Any
- from uuid import uuid4
- from django import forms
- import requests
- from boardgames.sources.bgg import lookup_boardgame_from_bgg
- 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
- _excluded_fields = {
- "lichess_id",
- "speed",
- "rated",
- "moves",
- "variant",
- }
- @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 ""
- @classmethod
- def override_fields(cls) -> dict:
- fields = {}
- for base in cls.mro()[1:]:
- if hasattr(base, "override_fields"):
- base_fields = base.override_fields()
- fields.update(base_fields)
- custom_fields = {
- "location_id": forms.ModelChoiceField(
- queryset=BoardGameLocation.objects.all(),
- required=False,
- widget=forms.Select(),
- )
- }
- fields.update(custom_fields)
- return fields
- 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 game:
- logger.info("Board game exists in database.", extra={"lookup_id": lookup_id, "data": data})
- return game
- bgg_data = lookup_boardgame_from_bgg(data.get("name"))
- mechanics = bgg_data.pop("mechanics", [])
- designers = bgg_data.pop("designers", [])
- categories = bgg_data.pop("categories", [])
- publishers = bgg_data.pop("publishers", [])
- 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)
- 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
|