Bladeren bron

Add boardgames as scrobblable

Colin Powell 2 jaren geleden
bovenliggende
commit
0217c96faf

+ 30 - 0
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,
+    ]

+ 60 - 0
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

+ 168 - 0
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,
+            },
+        ),
+    ]

+ 18 - 0
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),
+        ),
+    ]

+ 18 - 0
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",
+        ),
+    ]

+ 17 - 0
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",
+        ),
+    ]

+ 17 - 0
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",
+        ),
+    ]

+ 0 - 0
vrobbler/apps/boardgames/migrations/__init__.py


+ 128 - 0
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

+ 0 - 0
vrobbler/apps/boardgames/utils.py


+ 25 - 0
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",
+            ),
+        ),
+    ]

+ 25 - 0
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",
+            ),
+        ),
+    ]

+ 1 - 0
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,
     )
 
 

+ 9 - 0
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(

+ 1 - 0
vrobbler/settings.py

@@ -112,6 +112,7 @@ INSTALLED_APPS = [
     "podcasts",
     "sports",
     "books",
+    "boardgames",
     "videogames",
     "mathfilters",
     "rest_framework",