Add boardgames as scrobblable
This commit is contained in:
30
vrobbler/apps/boardgames/admin.py
Normal file
30
vrobbler/apps/boardgames/admin.py
Normal file
@ -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
vrobbler/apps/boardgames/bgg.py
Normal file
60
vrobbler/apps/boardgames/bgg.py
Normal file
@ -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
vrobbler/apps/boardgames/migrations/0001_initial.py
Normal file
168
vrobbler/apps/boardgames/migrations/0001_initial.py
Normal file
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
@ -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
vrobbler/apps/boardgames/migrations/__init__.py
Normal file
0
vrobbler/apps/boardgames/migrations/__init__.py
Normal file
128
vrobbler/apps/boardgames/models.py
Normal file
128
vrobbler/apps/boardgames/models.py
Normal file
@ -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
vrobbler/apps/boardgames/utils.py
Normal file
0
vrobbler/apps/boardgames/utils.py
Normal file
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -27,6 +27,7 @@ class ObjectWithGenres(GenericTaggedItemBase):
|
||||
Genre,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="%(app_label)s_%(class)s_items",
|
||||
**BNULL,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -112,6 +112,7 @@ INSTALLED_APPS = [
|
||||
"podcasts",
|
||||
"sports",
|
||||
"books",
|
||||
"boardgames",
|
||||
"videogames",
|
||||
"mathfilters",
|
||||
"rest_framework",
|
||||
|
||||
Reference in New Issue
Block a user