models.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. from functools import cached_property
  2. import logging
  3. from dataclasses import dataclass
  4. from datetime import datetime
  5. from typing import Optional, Any
  6. from uuid import uuid4
  7. from django import forms
  8. import requests
  9. from boardgames.sources.bgg import lookup_boardgame_from_bgg
  10. from django.conf import settings
  11. from django.core.files.base import ContentFile
  12. from django.db import models
  13. from django.urls import reverse
  14. from django_extensions.db.models import TimeStampedModel
  15. from imagekit.models import ImageSpecField
  16. from imagekit.processors import ResizeToFit
  17. from locations.models import GeoLocation
  18. from people.models import Person
  19. from scrobbles.dataclasses import BaseLogData, LongPlayLogData
  20. from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
  21. logger = logging.getLogger(__name__)
  22. BNULL = {"blank": True, "null": True}
  23. @dataclass
  24. class BoardGameScoreLogData(BaseLogData):
  25. person_id: Optional[int] = None
  26. bgg_username: Optional[str] = None
  27. color: Optional[str] = None
  28. character: Optional[str] = None
  29. team: Optional[str] = None
  30. score: Optional[int] = None
  31. win: Optional[bool] = None
  32. new: Optional[bool] = None
  33. rank: Optional[int] = None
  34. seat_order: Optional[int] = None
  35. role: Optional[str] = None
  36. rank: Optional[int] = None
  37. seat_order: Optional[int] = None
  38. role: Optional[str] = None
  39. lichess_username: Optional[str] = None
  40. @property
  41. def person(self) -> Optional[Person]:
  42. return Person.objects.filter(id=self.person_id).first()
  43. @property
  44. def name(self) -> str:
  45. name = ""
  46. if self.person:
  47. name = self.person.name
  48. return name
  49. def __str__(self) -> str:
  50. out = self.name
  51. if self.score:
  52. out += f" {self.score}"
  53. if self.color:
  54. out += f" ({self.color})"
  55. if self.win:
  56. out += f" [W]"
  57. return out
  58. @dataclass
  59. class BoardGameLogData(BaseLogData, LongPlayLogData):
  60. players: Optional[list[BoardGameScoreLogData]] = None
  61. location_id: Optional[int] = None
  62. difficulty: Optional[int] = None
  63. solo: Optional[bool] = None
  64. two_handed: Optional[bool] = None
  65. expansion_ids: Optional[int] = None
  66. moves: Optional[list] = None
  67. rated: Optional[str] = None
  68. speed: Optional[str] = None
  69. variant: Optional[str] = None
  70. lichess_id: Optional[int] = None
  71. board: Optional[str] = None
  72. rounds: Optional[int] = None
  73. details: Optional[str] = None
  74. _excluded_fields = {
  75. "lichess_id",
  76. "speed",
  77. "rated",
  78. "moves",
  79. "variant",
  80. }
  81. @cached_property
  82. def location(self):
  83. if not self.location_id:
  84. return
  85. return BoardGameLocation.objects.filter(id=self.location_id).first()
  86. @cached_property
  87. def player_log(self) -> str:
  88. if self.players:
  89. return ", ".join(
  90. [
  91. BoardGameScoreLogData(**player).__str__()
  92. for player in self.players
  93. ]
  94. )
  95. return ""
  96. @classmethod
  97. def override_fields(cls) -> dict:
  98. fields = {}
  99. for base in cls.mro()[1:]:
  100. if hasattr(base, "override_fields"):
  101. base_fields = base.override_fields()
  102. fields.update(base_fields)
  103. custom_fields = {
  104. "location_id": forms.ModelChoiceField(
  105. queryset=BoardGameLocation.objects.all(),
  106. required=False,
  107. widget=forms.Select(),
  108. )
  109. }
  110. fields.update(custom_fields)
  111. return fields
  112. class BoardGamePublisher(TimeStampedModel):
  113. name = models.CharField(max_length=255)
  114. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  115. logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
  116. bgg_id = models.IntegerField(**BNULL)
  117. def __str__(self):
  118. return self.name
  119. def get_absolute_url(self):
  120. return reverse(
  121. "boardgames:publisher_detail", kwargs={"slug": self.uuid}
  122. )
  123. class BoardGameDesigner(TimeStampedModel):
  124. name = models.CharField(max_length=255)
  125. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  126. bgg_id = models.IntegerField(**BNULL)
  127. bio = models.TextField(**BNULL)
  128. def __str__(self) -> str:
  129. return str(self.name)
  130. def get_absolute_url(self):
  131. return reverse(
  132. "boardgames:designer_detail", kwargs={"slug": self.uuid}
  133. )
  134. class BoardGameLocation(TimeStampedModel):
  135. name = models.CharField(max_length=255)
  136. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  137. bgstats_id = models.UUIDField(**BNULL)
  138. description = models.TextField(**BNULL)
  139. geo_location = models.ForeignKey(
  140. GeoLocation, **BNULL, on_delete=models.DO_NOTHING
  141. )
  142. def __str__(self) -> str:
  143. return str(self.name)
  144. def get_absolute_url(self):
  145. return reverse(
  146. "boardgames:location_detail", kwargs={"slug": self.uuid}
  147. )
  148. class BoardGame(ScrobblableMixin):
  149. COMPLETION_PERCENT = getattr(
  150. settings, "BOARD_GAME_COMPLETION_PERCENT", 100
  151. )
  152. FIELDS_FROM_BGGEEK = [
  153. "igdb_id",
  154. "alternative_name",
  155. "rating",
  156. "rating_count",
  157. "release_date",
  158. "cover",
  159. "screenshot",
  160. ]
  161. title = models.CharField(max_length=255)
  162. publisher = models.ForeignKey(
  163. BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
  164. )
  165. publishers = models.ManyToManyField(
  166. BoardGamePublisher,
  167. related_name="board_games",
  168. )
  169. designers = models.ManyToManyField(
  170. BoardGameDesigner,
  171. related_name="board_games",
  172. )
  173. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  174. description = models.TextField(**BNULL)
  175. cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
  176. cover_small = ImageSpecField(
  177. source="cover",
  178. processors=[ResizeToFit(100, 100)],
  179. format="JPEG",
  180. options={"quality": 60},
  181. )
  182. cover_medium = ImageSpecField(
  183. source="cover",
  184. processors=[ResizeToFit(300, 300)],
  185. format="JPEG",
  186. options={"quality": 75},
  187. )
  188. layout_image = models.ImageField(upload_to="boardgames/layouts/", **BNULL)
  189. layout_image_small = ImageSpecField(
  190. source="layout_image",
  191. processors=[ResizeToFit(100, 100)],
  192. format="JPEG",
  193. options={"quality": 60},
  194. )
  195. layout_image_medium = ImageSpecField(
  196. source="layout_image",
  197. processors=[ResizeToFit(300, 300)],
  198. format="JPEG",
  199. options={"quality": 75},
  200. )
  201. rating = models.FloatField(**BNULL)
  202. bgg_rank = models.IntegerField(**BNULL)
  203. max_players = models.PositiveSmallIntegerField(**BNULL)
  204. min_players = models.PositiveSmallIntegerField(**BNULL)
  205. published_date = models.DateField(**BNULL)
  206. published_year = models.IntegerField(**BNULL)
  207. recommended_age = models.PositiveSmallIntegerField(**BNULL)
  208. bggeek_id = models.CharField(max_length=255, **BNULL)
  209. bgstats_id = models.UUIDField(**BNULL)
  210. uses_teams = models.BooleanField(default=False, **BNULL)
  211. cooperative = models.BooleanField(default=False, **BNULL)
  212. highest_wins = models.BooleanField(default=True, **BNULL)
  213. no_points = models.BooleanField(default=False, **BNULL)
  214. min_play_time = models.IntegerField(**BNULL)
  215. max_play_time = models.IntegerField(**BNULL)
  216. expansion_for_boardgame = models.ForeignKey(
  217. "self", **BNULL, on_delete=models.DO_NOTHING
  218. )
  219. def __str__(self):
  220. return self.title
  221. def get_absolute_url(self):
  222. return reverse(
  223. "boardgames:boardgame_detail", kwargs={"slug": self.uuid}
  224. )
  225. @property
  226. def logdata_cls(self):
  227. return BoardGameLogData
  228. @property
  229. def strings(self) -> ScrobblableConstants:
  230. return ScrobblableConstants(verb="Playing", tags="game_die")
  231. def primary_image_url(self) -> str:
  232. url = ""
  233. if self.cover:
  234. url = self.cover.url
  235. return url
  236. def bggeek_link(self):
  237. link = ""
  238. if self.bggeek_id:
  239. link = f"https://boardgamegeek.com/boardgame/{self.bggeek_id}"
  240. return link
  241. def fix_metadata(self, data: dict = {}, force_update=False) -> None:
  242. if not self.published_date or force_update:
  243. if not data:
  244. data = lookup_boardgame_from_bgg(str(self.bggeek_id))
  245. cover_url = data.pop("cover_url")
  246. year = data.pop("year_published")
  247. publisher_name = data.pop("publisher_name")
  248. if year:
  249. data["published_year"] = int(year)
  250. if not data["min_players"]:
  251. data.pop("min_players")
  252. if not data["min_players"]:
  253. data.pop("max_players")
  254. # Fun trick for updating all fields at once
  255. BoardGame.objects.filter(pk=self.id).update(**data)
  256. self.refresh_from_db()
  257. # Add publishers
  258. (
  259. self.publisher,
  260. _created,
  261. ) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
  262. self.save()
  263. # Go get cover image if the URL is present
  264. if cover_url and not self.cover:
  265. self.save_image_from_url(cover_url)
  266. def save_image_from_url(self, url):
  267. headers = {"User-Agent": "Vrobbler 0.11.12"}
  268. r = requests.get(url, headers=headers)
  269. if r.status_code == 200:
  270. fname = f"{self.title}_cover_{self.uuid}.jpg"
  271. self.cover.save(fname, ContentFile(r.content), save=True)
  272. @classmethod
  273. def find_or_create(
  274. cls, lookup_id: str, data: dict[str, Any] = {}
  275. ) -> "BoardGame":
  276. """Given a Lookup ID (either BGG or BGA ID), return a board game object"""
  277. game = cls.objects.filter(bggeek_id=lookup_id).first()
  278. if game:
  279. logger.info("Board game exists in database.", extra={"lookup_id": lookup_id, "data": data})
  280. return game
  281. bgg_data = lookup_boardgame_from_bgg(data.get("name"))
  282. mechanics = bgg_data.pop("mechanics", [])
  283. designers = bgg_data.pop("designers", [])
  284. categories = bgg_data.pop("categories", [])
  285. publishers = bgg_data.pop("publishers", [])
  286. cover_url = bgg_data.pop("cover_url")
  287. game = cls.objects.create(
  288. **bgg_data
  289. )
  290. game.save_image_from_url(cover_url)
  291. game.cooperative = data.get("cooperative", False)
  292. game.highest_wins = data.get("highestWins", True)
  293. game.no_points = data.get("noPoints", False)
  294. game.uses_teams = data.get("useTeams", False)
  295. game.bgstats_id = data.get("uuid", None)
  296. game.save()
  297. if designers:
  298. for designer_name in designers:
  299. designer, created = BoardGameDesigner.objects.get_or_create(
  300. name=designer_name
  301. )
  302. game.designers.add(designer.id)
  303. if publishers:
  304. for name in publishers:
  305. publisher, _ = BoardGamePublisher.objects.get_or_create(
  306. name=name
  307. )
  308. game.publishers.add(publisher)
  309. return game