diff --git a/vrobbler/apps/boardgames/admin.py b/vrobbler/apps/boardgames/admin.py new file mode 100644 index 0000000..743fe2c --- /dev/null +++ b/vrobbler/apps/boardgames/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin + +from boardgames.models import BoardGame, BoardGamePublisher + +from scrobbles.admin import ScrobbleInline + + +@admin.register(BoardGamePublisher) +class BoardGamePublisherAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ( + "name", + "uuid", + ) + ordering = ("-created",) + + +@admin.register(BoardGame) +class GameAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ( + "bggeek_id", + "title", + "published_date", + ) + search_fields = ("title",) + ordering = ("-created",) + inlines = [ + ScrobbleInline, + ] diff --git a/vrobbler/apps/boardgames/bgg.py b/vrobbler/apps/boardgames/bgg.py new file mode 100644 index 0000000..d5aa843 --- /dev/null +++ b/vrobbler/apps/boardgames/bgg.py @@ -0,0 +1,60 @@ +from typing import Optional + +import requests +from bs4 import BeautifulSoup + +SEARCH_ID_URL = "https://boardgamegeek.com/xmlapi/search?search={query}" +GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}" + + +def take_first(thing: Optional[list]) -> str: + first = "" + try: + first = thing[0] + except IndexError: + pass + + if first: + first = first.get_text() + + return first + + +def get_id_from_bgg(title): + soup = "" + headers = {"User-Agent": "Vrobbler 0.11.12"} + url = SEARCH_ID_URL.format(query=title) + r = requests.get(url, headers=headers) + if r.status_code == 200: + soup = BeautifulSoup(r.text, "xml") + return soup + + +def get_game_by_id_from_bgg(game_id): + soup = None + game_dict = {} + headers = {"User-Agent": "Vrobbler 0.11.12"} + url = GAME_ID_URL.format(id=game_id) + r = requests.get(url, headers=headers) + if r.status_code == 200: + soup = BeautifulSoup(r.text, "xml") + + if soup: + seconds_to_play = None + minutes = take_first(soup.findAll("playingtime")) + if minutes: + seconds_to_play = int(minutes) * 60 + + game_dict = { + "title": take_first(soup.findAll("name", primary="true")), + "description": take_first(soup.findAll("description")), + "year_published": take_first(soup.findAll("yearpublished")), + "publisher_name": take_first(soup.findAll("boardgamepublisher")), + "cover_url": take_first(soup.findAll("image")), + "min_players": take_first(soup.findAll("minplayers")), + "max_players": take_first(soup.findAll("maxplayers")), + "recommended_age": take_first(soup.findAll("age")), + "run_time_seconds": seconds_to_play, + } + + return game_dict diff --git a/vrobbler/apps/boardgames/migrations/0001_initial.py b/vrobbler/apps/boardgames/migrations/0001_initial.py new file mode 100644 index 0000000..ef8fddf --- /dev/null +++ b/vrobbler/apps/boardgames/migrations/0001_initial.py @@ -0,0 +1,168 @@ +# Generated by Django 4.1.7 on 2023-04-17 22:11 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import taggit.managers +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("scrobbles", "0038_alter_objectwithgenres_tag"), + ] + + operations = [ + migrations.CreateModel( + name="BoardGamePublisher", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=255)), + ( + "uuid", + models.UUIDField( + blank=True, + default=uuid.uuid4, + editable=False, + null=True, + ), + ), + ( + "logo", + models.ImageField( + blank=True, + null=True, + upload_to="games/platform-logos/", + ), + ), + ("igdb_id", models.IntegerField(blank=True, null=True)), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + migrations.CreateModel( + name="BoardGame", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "run_time_seconds", + models.IntegerField(blank=True, null=True), + ), + ( + "run_time_ticks", + models.PositiveBigIntegerField(blank=True, null=True), + ), + ("title", models.CharField(max_length=255)), + ( + "uuid", + models.UUIDField( + blank=True, + default=uuid.uuid4, + editable=False, + null=True, + ), + ), + ( + "cover", + models.ImageField( + blank=True, null=True, upload_to="boardgames/covers/" + ), + ), + ( + "layout_image", + models.ImageField( + blank=True, null=True, upload_to="boardgames/layouts/" + ), + ), + ("summary", models.TextField(blank=True, null=True)), + ("rating", models.FloatField(blank=True, null=True)), + ( + "max_players", + models.PositiveSmallIntegerField(blank=True, null=True), + ), + ( + "min_players", + models.PositiveSmallIntegerField(blank=True, null=True), + ), + ("published_date", models.DateField(blank=True, null=True)), + ( + "recommened_age", + models.PositiveSmallIntegerField(blank=True, null=True), + ), + ( + "seconds_to_play", + models.IntegerField(blank=True, null=True), + ), + ( + "bggeek_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "genre", + taggit.managers.TaggableManager( + help_text="A comma-separated list of tags.", + through="scrobbles.ObjectWithGenres", + to="scrobbles.Genre", + verbose_name="Tags", + ), + ), + ( + "publisher", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="boardgames.boardgamepublisher", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/vrobbler/apps/boardgames/migrations/0002_boardgame_description.py b/vrobbler/apps/boardgames/migrations/0002_boardgame_description.py new file mode 100644 index 0000000..4923b98 --- /dev/null +++ b/vrobbler/apps/boardgames/migrations/0002_boardgame_description.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2023-04-17 22:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("boardgames", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="boardgame", + name="description", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/vrobbler/apps/boardgames/migrations/0003_rename_recommened_age_boardgame_recommended_age.py b/vrobbler/apps/boardgames/migrations/0003_rename_recommened_age_boardgame_recommended_age.py new file mode 100644 index 0000000..3bb0ca4 --- /dev/null +++ b/vrobbler/apps/boardgames/migrations/0003_rename_recommened_age_boardgame_recommended_age.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2023-04-17 22:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("boardgames", "0002_boardgame_description"), + ] + + operations = [ + migrations.RenameField( + model_name="boardgame", + old_name="recommened_age", + new_name="recommended_age", + ), + ] diff --git a/vrobbler/apps/boardgames/migrations/0004_remove_boardgame_seconds_to_play.py b/vrobbler/apps/boardgames/migrations/0004_remove_boardgame_seconds_to_play.py new file mode 100644 index 0000000..4e56d6d --- /dev/null +++ b/vrobbler/apps/boardgames/migrations/0004_remove_boardgame_seconds_to_play.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.7 on 2023-04-17 22:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("boardgames", "0003_rename_recommened_age_boardgame_recommended_age"), + ] + + operations = [ + migrations.RemoveField( + model_name="boardgame", + name="seconds_to_play", + ), + ] diff --git a/vrobbler/apps/boardgames/migrations/0005_remove_boardgame_summary.py b/vrobbler/apps/boardgames/migrations/0005_remove_boardgame_summary.py new file mode 100644 index 0000000..61158b0 --- /dev/null +++ b/vrobbler/apps/boardgames/migrations/0005_remove_boardgame_summary.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.7 on 2023-04-17 22:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("boardgames", "0004_remove_boardgame_seconds_to_play"), + ] + + operations = [ + migrations.RemoveField( + model_name="boardgame", + name="summary", + ), + ] diff --git a/vrobbler/apps/boardgames/migrations/__init__.py b/vrobbler/apps/boardgames/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/boardgames/models.py b/vrobbler/apps/boardgames/models.py new file mode 100644 index 0000000..9fa50a4 --- /dev/null +++ b/vrobbler/apps/boardgames/models.py @@ -0,0 +1,128 @@ +import logging +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +import requests +from boardgames.bgg import get_game_by_id_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 scrobbles.mixins import ScrobblableMixin + +logger = logging.getLogger(__name__) +BNULL = {"blank": True, "null": True} + + +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) + igdb_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 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 + ) + uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) + description = models.TextField(**BNULL) + cover = models.ImageField(upload_to="boardgames/covers/", **BNULL) + layout_image = models.ImageField(upload_to="boardgames/layouts/", **BNULL) + rating = models.FloatField(**BNULL) + max_players = models.PositiveSmallIntegerField(**BNULL) + min_players = models.PositiveSmallIntegerField(**BNULL) + published_date = models.DateField(**BNULL) + recommended_age = models.PositiveSmallIntegerField(**BNULL) + bggeek_id = models.CharField(max_length=255, **BNULL) + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse( + "boardgames:boardgame_detail", kwargs={"slug": self.uuid} + ) + + 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.bggeek_id or force_update: + + if not data: + data = get_game_by_id_from_bgg(self.bggeek_id) + + cover_url = data.pop("cover_url") + year = data.pop("year_published") + publisher_name = data.pop("publisher_name") + + if year: + data["published_date"] = datetime(int(year), 1, 1) + + # 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: + headers = {"User-Agent": "Vrobbler 0.11.12"} + r = requests.get(cover_url, headers=headers) + logger.debug(r.status_code) + if r.status_code == 200: + fname = f"{self.title}_cover_{self.uuid}.jpg" + self.cover.save(fname, ContentFile(r.content), save=True) + logger.debug("Loaded cover image from BGGeek") + + @classmethod + def find_or_create(cls, lookup_id: str) -> Optional["BoardGame"]: + """Given a Lookup ID (either BGG or BGA ID), return a board game object""" + data = {} + boardgame = None + + data = get_game_by_id_from_bgg(lookup_id) + + if data: + boardgame, created = cls.objects.get_or_create( + title=data["title"], bggeek_id=lookup_id + ) + if created: + boardgame.fix_metadata(data=data) + + return boardgame diff --git a/vrobbler/apps/boardgames/utils.py b/vrobbler/apps/boardgames/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/scrobbles/migrations/0038_alter_objectwithgenres_tag.py b/vrobbler/apps/scrobbles/migrations/0038_alter_objectwithgenres_tag.py new file mode 100644 index 0000000..a64c2cb --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0038_alter_objectwithgenres_tag.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.7 on 2023-04-17 22:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("scrobbles", "0037_scrobble_media_type"), + ] + + operations = [ + migrations.AlterField( + model_name="objectwithgenres", + name="tag", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_items", + to="scrobbles.genre", + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/migrations/0039_scrobble_board_game.py b/vrobbler/apps/scrobbles/migrations/0039_scrobble_board_game.py new file mode 100644 index 0000000..a1e47b2 --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0039_scrobble_board_game.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.7 on 2023-04-17 22:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("boardgames", "0001_initial"), + ("scrobbles", "0038_alter_objectwithgenres_tag"), + ] + + operations = [ + migrations.AddField( + model_name="scrobble", + name="board_game", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="boardgames.boardgame", + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/mixins.py b/vrobbler/apps/scrobbles/mixins.py index f68cfdc..1b79fdf 100644 --- a/vrobbler/apps/scrobbles/mixins.py +++ b/vrobbler/apps/scrobbles/mixins.py @@ -27,6 +27,7 @@ class ObjectWithGenres(GenericTaggedItemBase): Genre, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s_items", + **BNULL, ) diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index ad70295..756af0d 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -4,6 +4,7 @@ import logging from typing import Optional from uuid import uuid4 +from boardgames.models import BoardGame from books.models import Book from django.conf import settings from django.contrib.auth import get_user_model @@ -434,6 +435,9 @@ class Scrobble(TimeStampedModel): video_game = models.ForeignKey( VideoGame, on_delete=models.DO_NOTHING, **BNULL ) + board_game = models.ForeignKey( + BoardGame, on_delete=models.DO_NOTHING, **BNULL + ) media_type = models.CharField( max_length=14, choices=MediaType.choices, default=MediaType.VIDEO ) @@ -583,6 +587,8 @@ class Scrobble(TimeStampedModel): media_obj = self.book if self.video_game: media_obj = self.video_game + if self.board_game: + media_obj = self.board_game return media_obj def __str__(self): @@ -614,6 +620,9 @@ class Scrobble(TimeStampedModel): if media_class == "VideoGame": media_query = models.Q(video_game=media) scrobble_data["video_game_id"] = media.id + if media_class == "BoardGame": + media_query = models.Q(board_game=media) + scrobble_data["board_game_id"] = media.id scrobble = ( cls.objects.filter( diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 96bd5eb..5baea47 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -112,6 +112,7 @@ INSTALLED_APPS = [ "podcasts", "sports", "books", + "boardgames", "videogames", "mathfilters", "rest_framework",