Compare commits

..

17 Commits
58.8 ... main

Author SHA1 Message Date
77d92f6c96 [release] Bump to version 59.5
All checks were successful
ci / test (push) Successful in 2m13s
ci / build-and-deploy (push) Successful in 38s
- Fix bug where all variants for board games are in form
2026-07-05 00:15:58 -04:00
d5d0eb6cd8 [boardgames] Make sure variants are filtered by game 2026-07-05 00:15:26 -04:00
d6f71e0761 [release] Bump to version 59.4
All checks were successful
ci / test (push) Successful in 2m14s
ci / build-and-deploy (push) Successful in 37s
- Fix bug in fetching expansions for board games
- Board games should have genres extracted from family data
2026-07-04 11:53:28 -04:00
b00ebf49dd [boardgames] Fix getting BGG id 2026-07-04 11:53:05 -04:00
2385e9c7bd [release] Bump to version 59.3
All checks were successful
ci / test (push) Successful in 2m18s
ci / build-and-deploy (push) Successful in 37s
- Exclude some board games from auto-expansion imports
- Should be able to add new variants to board games via the log data form
2026-07-04 11:41:49 -04:00
d78529efe2 [boardgames] Add genres and categories 2026-07-04 11:41:24 -04:00
f373e98e3d [boardgames] Skip CCG games for auto expansion download 2026-07-04 11:32:17 -04:00
7559ce7824 [boardgames] Add ability to add new variants to form 2026-07-04 11:28:41 -04:00
2c481bd53a [release] Bump to version 59.2
All checks were successful
ci / test (push) Successful in 2m20s
ci / build-and-deploy (push) Successful in 36s
- Fix test failure in discgolf app
2026-07-04 10:18:42 -04:00
0deb3ee634 [discgolf] Fix breaking tests 2026-07-04 10:18:17 -04:00
28a53d70eb [release] Bump to version 59.1
Some checks failed
ci / test (push) Failing after 1m50s
ci / build-and-deploy (push) Has been skipped
- Fix bug when expansions have no image
2026-07-04 10:10:22 -04:00
98d4e8bacb [boardgames] Fix bug when expansion has no image 2026-07-04 10:09:55 -04:00
8f97131b8d [release] Bump to version 59.0
All checks were successful
ci / test (push) Successful in 2m16s
ci / build-and-deploy (push) Successful in 48s
- Add BoardGameVariant model
- Lookup all Expansions for a game when creating it
- Board game expansion lookup should be async on URL scrobbles
2026-07-04 09:36:52 -04:00
7c1f709f96 [boardgames] Fix saving variants 2026-07-04 01:50:05 -04:00
4b005e0e5b [boardgames] Lookup expansions async for URL scrobbles
All checks were successful
ci / test (push) Successful in 2m27s
ci / build-and-deploy (push) Has been skipped
2026-07-04 01:47:15 -04:00
d5dd63be0d [boardgames] Add expansion fetching 2026-07-04 01:34:42 -04:00
5aa89b7e0a [boardgames] Add idea of board game variants 2026-07-04 01:16:14 -04:00
29 changed files with 1206 additions and 19 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/23] :vrobbler:project:personal:
* Backlog [0/24] :vrobbler:project:personal:
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
@ -604,6 +604,119 @@ a helper method to create board game scrobbles given a json blob. It's
independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
** TODO [#A] Update how board game scrobbles work :boardgames:
*** Description
When we scrobble a board game from a BGG URL, instead of going to the media
detail page, we should go to the scrobble detail page, with the Edit Log form
expanded by default.
The Edit log form should have from top to bottom:
- Board/Variant (one or many BoardGameVariant in a multi-select widget)
- People (which should be similar to the Bird widget on BirdLocation and allow setting per user score, win true/false, rank, new true/false, seat_ordrer)
- Expansion ids (which should a multi-select widget of expansions for this game)
- Location (which should be a drop down of BoardGameLocations for this user)
* Version 59.5 [1/1]
** DONE [#A] Fix bug where all variants for board games are in form :boardgames:
:PROPERTIES:
:ID: 9c4cd193-580a-5b33-8832-1feffea7bd53
:END:
* Version 59.4 [2/2]
** DONE Fix bug in fetching expansions for board games :boardgames:
:PROPERTIES:
:ID: 17995312-e76e-4a50-b591-0eab78cb59ab
:END:
#+begin_src python
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/management/commands/fetch_expansions.py", line 60, in handle
fetch_and_link_expansions(game, expansions)
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/utils.py", line 51, in fetch_and_link_expansions
expansion = BoardGame.find_or_create(str(exp_id))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/models.py", line 409, in find_or_create
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/sources/bgg.py", line 15, in lookup_boardgame_from_bgg
game = bgg.game(game_id=lookup_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/boardgamegeek/api.py", line 1045, in game
raise BGGApiError(msg)
boardgamegeek.exceptions.BGGApiError: invalid data for game id: 242117
#+end_src
** DONE Board games should have genres extracted from family data :boardgames:metadata:
:PROPERTIES:
:ID: 7214b270-dccc-4b98-ac58-ff4f76c8cda9
:END:
* Version 59.3 [2/2]
** DONE Exclude some board games from auto-expansion imports :boardgames:
:PROPERTIES:
:ID: 51ffdf20-e732-4774-b781-c3501d26d46f
:END:
*** Description
Some board games, especially trading card games, have silly amounts of expansions.
We should have a setting SKIP_AUTO_EXPANSION_DOWNLOAD that is a list of BGG ids of
games where expansions should not be download automatically. This exclusion should also auto-include
any games with "Collectible Card Games" in it's family.
** DONE Should be able to add new variants to board games via the log data form :boardgames:
:PROPERTIES:
:ID: 5ed0dd25-3026-3da8-dc5c-f2a75751af9a
:END:
* Version 59.2 [1/1]
** DONE Fix test failure in discgolf app :discgolf:tests:
:PROPERTIES:
:ID: 813ae357-0568-5a4c-1a35-172e95d02740
:END:
* Version 59.1 [1/1]
** DONE Fix bug when expansions have no image :boardgames:bug:
:PROPERTIES:
:ID: 9fee96c9-c6a0-32d9-b6f8-212c60fc3540
:END:
* Version 59.0 [3/3]
** DONE [#A] Add BoardGameVariant model :boardgames:
:PROPERTIES:
:ID: 0ffb20d5-252f-b13d-473d-5529014602ff
:END:
*** Description
Variants represent unique boards being used per scrobble or scenarios when
playing a game. Scrobbles of a board game may have one or more
boardgame_variant_ids assocaited with their log data, and a variant is created
for one specific board game.
** DONE [#A] Lookup all Expansions for a game when creating it :boardgames:
:PROPERTIES:
:ID: 8a84b06d-555c-4701-9058-ff364c89c198
:END:
*** Description
We don't want to blow up the BGG API, but if possible with not too
many calls, when we scrobble a board game, in order to allow
populating the "Expansions" multi select, we should fetch any
expansions for the board game when creating it for the first time.
We should also create a managemnt script to update existing board games.
** DONE [#A] Board game expansion lookup should be async on URL scrobbles :boardgames:
:PROPERTIES:
:ID: 968d8dde-f906-cdf0-af4e-b87ce28ddbbb
:END:
* Version 58.8 [1/1]
** DONE [#B] Clean up trend templates :trends:templates:

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "58.8"
version = "59.5"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -5,6 +5,7 @@ from boardgames.models import (
BoardGameLocation,
BoardGamePublisher,
BoardGameDesigner,
BoardGameVariant,
)
from scrobbles.admin import ScrobbleInline
@ -42,6 +43,19 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
ordering = ("-created",)
@admin.register(BoardGameVariant)
class BoardGameVariantAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"board_game",
"uuid",
)
raw_id_fields = ("board_game",)
search_fields = ("name", "board_game__title")
ordering = ("-created",)
@admin.register(BoardGame)
class BoardGameAdmin(admin.ModelAdmin):
date_hierarchy = "created"

View File

@ -20,6 +20,12 @@ class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
fields = "__all__"
class BoardGameVariantSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGameVariant
fields = "__all__"
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGame

View File

@ -22,6 +22,12 @@ class BoardGameLocationViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated]
class BoardGameVariantViewSet(viewsets.ModelViewSet):
queryset = models.BoardGameVariant.objects.all().order_by("-created")
serializer_class = serializers.BoardGameVariantSerializer
permission_classes = [permissions.IsAuthenticated]
class BoardGameViewSet(viewsets.ModelViewSet):
queryset = models.BoardGame.objects.all().order_by("-created")
serializer_class = serializers.BoardGameSerializer

View File

@ -0,0 +1,81 @@
import logging
import time
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Refresh board game metadata from BGG (categories→genres, families→tags)"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Persist changes to the database",
)
parser.add_argument(
"--force",
action="store_true",
help="Update all games even if they already have a published_date",
)
parser.add_argument(
"--batch-size",
type=int,
default=50,
help="Number of games to process per batch (default: 50)",
)
parser.add_argument(
"--sleep",
type=float,
default=1.0,
help="Seconds to sleep between API calls (default: 1.0)",
)
def handle(self, *args, **options):
from boardgames.models import BoardGame
commit = options["commit"]
force = options["force"]
batch_size = options["batch_size"]
sleep_secs = options["sleep"]
qs = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude(bggeek_id="")
total = qs.count()
self.stdout.write(f"Found {total} board games with BGG IDs")
if not commit:
self.stdout.write(
"Dry run — no API calls will be made. Use --commit to run lookups."
)
return
enriched = 0
skipped = 0
for batch_num, offset in enumerate(range(0, total, batch_size)):
batch = qs[offset : offset + batch_size]
for game in batch:
try:
game.fix_metadata(force_update=force)
enriched += 1
except Exception as e:
self.stdout.write(
self.style.WARNING(
f" [SKIPPED] {game.title} (BGG {game.bggeek_id}): {e}"
)
)
skipped += 1
time.sleep(sleep_secs)
self.stdout.write(
f" Batch {batch_num + 1}: {offset + len(batch)}/{total}"
f"enriched: {enriched}, skipped: {skipped}"
)
self.stdout.write(
f"\nResults:\n"
f" Games enriched: {enriched}\n"
f" Games skipped: {skipped}"
)

View File

@ -0,0 +1,63 @@
import logging
from django.core.management.base import BaseCommand
from boardgames.utils import board_names_to_variants
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Convert existing board scrobble log 'board' keys to 'variant_ids'"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Persist changes to the database",
)
def handle(self, *args, **options):
commit = options.get("commit", False)
board_scrobbles = Scrobble.objects.filter(
board_game__isnull=False,
log__board__isnull=False,
).exclude(log__board="")
total = board_scrobbles.count()
self.stdout.write(f"Found {total} scrobbles with a 'board' key in log data")
if total == 0:
return
updated = 0
for scrobble in board_scrobbles.iterator(chunk_size=100):
log = scrobble.log
board_value = log.pop("board", None)
if not board_value:
continue
variant_ids = board_names_to_variants(
scrobble.board_game, [board_value]
)
if variant_ids:
log["variant_ids"] = variant_ids
if commit:
Scrobble.objects.filter(pk=scrobble.pk).update(log=log)
updated += 1
else:
updated += 1
if commit:
self.stdout.write(
self.style.SUCCESS(
f"Updated {updated} scrobbles (changes committed)"
)
)
else:
self.stdout.write(
f"Would update {updated} scrobbles (pass --commit to persist)"
)

View File

@ -0,0 +1,80 @@
import logging
from django.core.management.base import BaseCommand
from boardgames.models import BoardGame
from boardgames.sources.bgg import lookup_boardgame_from_bgg
from boardgames.utils import fetch_and_link_expansions
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Fetch and link expansions for existing board games from BGG"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Persist changes to the database",
)
parser.add_argument(
"--bggeek-id",
type=str,
help="Only process a single game by BGG ID",
)
def handle(self, *args, **options):
commit = options.get("commit", False)
bggeek_id = options.get("bggeek_id")
games = (
BoardGame.objects.exclude(bggeek_id__isnull=True)
.exclude(bggeek_id="")
.exclude(skip_expansions=True)
)
if bggeek_id:
games = games.filter(bggeek_id=bggeek_id)
total = games.count()
self.stdout.write(f"Found {total} board games with BGG IDs")
if total == 0:
return
updated = 0
for game in games.iterator(chunk_size=100):
try:
data = lookup_boardgame_from_bgg(lookup_id=str(game.bggeek_id))
except Exception as e:
self.stdout.write(
self.style.WARNING(
f"Failed to fetch BGG data for {game.title} ({game.bggeek_id}): {e}"
)
)
continue
expansions = data.get("expansions", [])
if not expansions:
continue
if commit:
fetch_and_link_expansions(game, expansions)
updated += 1
self.stdout.write(
f" Linked {len(expansions)} expansions to {game.title}"
)
else:
self.stdout.write(
f" Would link {len(expansions)} expansions to {game.title}"
)
updated += 1
if commit:
self.stdout.write(
self.style.SUCCESS(f"Updated {updated} games (changes committed)")
)
else:
self.stdout.write(
f"Would update {updated} games (pass --commit to persist)"
)

View File

@ -0,0 +1,62 @@
# Generated by Django 4.2.29 on 2026-07-02 22:23
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0015_alter_boardgame_genre"),
]
operations = [
migrations.CreateModel(
name="BoardGameVariant",
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)),
("description", models.TextField(blank=True, null=True)),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
(
"board_game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="variants",
to="boardgames.boardgame",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-07-04 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0016_boardgamevariant"),
]
operations = [
migrations.AddField(
model_name="boardgame",
name="skip_expansions",
field=models.BooleanField(default=False),
),
]

View File

@ -71,11 +71,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
difficulty: Optional[int] = None
solo: Optional[bool] = None
two_handed: Optional[bool] = None
expansion_ids: Optional[int] = None
expansion_ids: Optional[list[int]] = None
moves: Optional[list] = None
rated: Optional[str] = None
speed: Optional[str] = None
variant: Optional[str] = None
variant_ids: Optional[list[int]] = None
lichess_id: Optional[int] = None
board: Optional[str] = None
rounds: Optional[int] = None
@ -92,6 +93,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
@classmethod
def override_fields(cls) -> dict:
from boardgames.widgets import VariantSelectWidget
from scrobbles.forms import NotesDictField
fields = {}
@ -106,10 +108,30 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
required=False,
widget=forms.Select(),
),
"variant_ids": forms.ModelMultipleChoiceField(
queryset=BoardGameVariant.objects.all(),
required=False,
widget=VariantSelectWidget(attrs={"size": 5}),
),
"expansion_ids": forms.ModelMultipleChoiceField(
queryset=BoardGame.objects.filter(
expansion_for_boardgame__isnull=False
),
required=False,
widget=forms.SelectMultiple(attrs={"size": 5}),
),
}
fields.update(custom_fields)
return fields
@cached_property
def variants(self) -> list["BoardGameVariant"]:
if not self.variant_ids:
return []
return list(
BoardGameVariant.objects.filter(id__in=self.variant_ids)
)
@cached_property
def location(self):
if not self.location_id:
@ -135,6 +157,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
if self.board:
html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
if self.variants:
variant_names = ", ".join(v.name for v in self.variants)
html_parts.append(
f'<div class="boardgame-variants">Variants: {variant_names}</div>'
)
if self.location:
html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
@ -272,6 +300,7 @@ class BoardGame(ScrobblableMixin):
expansion_for_boardgame = models.ForeignKey(
"self", **BNULL, on_delete=models.DO_NOTHING
)
skip_expansions = models.BooleanField(default=False)
@property
def subtitle(self) -> str:
@ -306,18 +335,29 @@ class BoardGame(ScrobblableMixin):
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")
expansions = data.pop("expansions", [])
cover_url = data.pop("cover_url", "")
year_published = data.pop("year_published", None)
if year_published is None:
year_published = data.pop("published_year", None)
publisher_name = data.pop("publisher_name", "")
if year:
data["published_year"] = int(year)
if year_published:
data["published_year"] = int(year_published)
if not data["min_players"]:
data.pop("min_players")
if not data["min_players"]:
data.pop("max_players")
# Pop extra BGG metadata that isn't a model field
categories = data.pop("categories", [])
families = data.pop("families", [])
data.pop("mechanics", None)
data.pop("designers", None)
data.pop("publishers", None)
data.pop("publisher", None)
# Fun trick for updating all fields at once
BoardGame.objects.filter(pk=self.id).update(**data)
self.refresh_from_db()
@ -329,10 +369,20 @@ class BoardGame(ScrobblableMixin):
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
self.save()
for cat in categories:
self.genre.add(cat.strip())
for fam in families:
self.tags.add(fam.strip())
# Go get cover image if the URL is present
if cover_url and not self.cover:
self.save_image_from_url(cover_url)
from boardgames.utils import fetch_and_link_expansions
if not self.skip_expansions:
fetch_and_link_expansions(self, expansions)
def save_image_from_url(self, url):
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(url, headers=headers)
@ -341,7 +391,12 @@ class BoardGame(ScrobblableMixin):
self.cover.save(fname, ContentFile(r.content), save=True)
@classmethod
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
def find_or_create(
cls,
lookup_id: str,
data: dict[str, Any] = {},
defer_expansions: bool = False,
) -> "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 not game:
@ -364,16 +419,19 @@ class BoardGame(ScrobblableMixin):
else:
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
expansions = bgg_data.pop("expansions", [])
mechanics = bgg_data.pop("mechanics", [])
designers = bgg_data.pop("designers", [])
categories = bgg_data.pop("categories", [])
families = bgg_data.pop("families", [])
publishers = bgg_data.pop("publishers", [])
publisher = bgg_data.pop("publisher", [])
cover_url = bgg_data.pop("cover_url")
cover_url = bgg_data.pop("cover_url") or ""
game = cls.objects.create(**bgg_data)
game.save_image_from_url(cover_url)
if cover_url:
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)
@ -382,6 +440,18 @@ class BoardGame(ScrobblableMixin):
if publisher:
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
game.publisher = publisher
skip_expansions = (
game.bggeek_id is not None
and str(game.bggeek_id).isdigit()
and int(game.bggeek_id) in settings.SKIP_AUTO_EXPANSION_DOWNLOAD
) or any(
c == "Collectible Card Games" for c in categories
)
if skip_expansions:
game.skip_expansions = True
game.save()
if designers:
@ -396,4 +466,33 @@ class BoardGame(ScrobblableMixin):
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
game.publishers.add(publisher)
for cat in categories:
game.genre.add(cat.strip())
for fam in families:
game.tags.add(fam.strip())
if expansions and not game.skip_expansions:
if defer_expansions:
from boardgames.tasks import fetch_board_game_expansions
fetch_board_game_expansions.delay(game.id, expansions)
else:
from boardgames.utils import fetch_and_link_expansions
fetch_and_link_expansions(game, expansions)
return game
class BoardGameVariant(TimeStampedModel):
name = models.CharField(max_length=255)
board_game = models.ForeignKey(
BoardGame,
on_delete=models.CASCADE,
related_name="variants",
)
description = models.TextField(**BNULL)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
def __str__(self) -> str:
return f"{self.name} ({self.board_game.title})"

View File

@ -32,9 +32,13 @@ def lookup_boardgame_from_bgg(
)
game_dict["mechanics"] = game.mechanics
game_dict["categories"] = game.categories
game_dict["families"] = game.families
game_dict["designers"] = game.designers
game_dict["publishers"] = game.publishers
if game.publishers:
game_dict["publisher"] = game.publishers[0]
game_dict["expansions"] = [
{"id": exp.id, "name": exp.name} for exp in game.expansions
]
return game_dict

View File

@ -0,0 +1,29 @@
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task
def fetch_board_game_expansions(board_game_id, expansions_data):
from boardgames.models import BoardGame
from boardgames.utils import fetch_and_link_expansions
game = BoardGame.objects.filter(id=board_game_id).first()
if not game:
logger.warning(
"Board game not found for expansion linking",
extra={"board_game_id": board_game_id},
)
return
fetch_and_link_expansions(game, expansions_data)
logger.info(
"Linked expansions for board game",
extra={
"board_game_id": board_game_id,
"title": game.title,
"count": len(expansions_data),
},
)

View File

@ -0,0 +1,97 @@
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %}
{% for option in group_choices %}
{% include option.template_name with widget=option %}
{% endfor %}
{% endfor %}
</select>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" data-bs-toggle="modal" data-bs-target="#addVariantModal">
+ Add variant
</button>
<div class="modal fade" id="addVariantModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Variant</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newVariantName" class="form-label">Name</label>
<input type="text" class="form-control" id="newVariantName" placeholder="e.g. Map A">
</div>
<div class="mb-3">
<label for="newVariantDescription" class="form-label">Description (optional)</label>
<input type="text" class="form-control" id="newVariantDescription">
</div>
<p class="text-danger d-none" id="variantError"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveVariantBtn">Add</button>
</div>
</div>
</div>
</div>
<script>
(function() {
var select = document.getElementById('{{ widget.attrs.id }}');
if (!select) return;
var saveBtn = document.getElementById('saveVariantBtn');
if (!saveBtn) return;
var modalEl = document.getElementById('addVariantModal');
var nameInput = document.getElementById('newVariantName');
var descInput = document.getElementById('newVariantDescription');
var errorEl = document.getElementById('variantError');
saveBtn.addEventListener('click', function() {
var name = nameInput.value.trim();
if (!name) return;
var boardGameId = select.getAttribute('data-board-game-id');
var ajaxUrl = select.getAttribute('data-ajax-url');
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (!csrfToken) return;
errorEl.classList.add('d-none');
fetch(ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken.value,
},
body: new URLSearchParams({
name: name,
description: descInput.value.trim(),
board_game_id: boardGameId,
}),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
errorEl.textContent = data.error;
errorEl.classList.remove('d-none');
return;
}
var opt = document.createElement('option');
opt.value = data.id;
opt.textContent = data.name;
opt.selected = true;
select.appendChild(opt);
var modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
nameInput.value = '';
descInput.value = '';
})
.catch(function() {
errorEl.textContent = 'Failed to create variant';
errorEl.classList.remove('d-none');
});
});
})();
</script>

View File

@ -0,0 +1,226 @@
from unittest.mock import patch
import pytest
from django.contrib.auth import get_user_model
from boardgames.models import BoardGame, BoardGameVariant
User = get_user_model()
@pytest.mark.django_db
def test_board_game_variant_creation():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
description="A test variant",
)
assert variant.name == "Test Variant"
assert variant.board_game == game
assert variant.description == "A test variant"
assert variant.uuid is not None
@pytest.mark.django_db
def test_board_game_variant_str():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
)
assert str(variant) == "Test Variant (Test Game)"
@pytest.mark.django_db
def test_board_game_variant_optional_description():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
)
assert variant.description is None
@pytest.mark.django_db
def test_board_game_variant_related_name():
game = BoardGame.objects.create(title="Test Game")
variant1 = BoardGameVariant.objects.create(
name="Variant 1",
board_game=game,
)
variant2 = BoardGameVariant.objects.create(
name="Variant 2",
board_game=game,
)
assert list(game.variants.all()) == [variant1, variant2]
@pytest.mark.django_db
def test_board_game_variant_cascade_delete():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
)
game.delete()
assert BoardGameVariant.objects.count() == 0
def _mock_bgg_game(bggeek_id, title, expansions=None):
"""Build a fake BGG game object shape used by lookup_boardgame_from_bgg."""
class FakeGame:
id = bggeek_id
name = title
description = f"Description of {title}"
yearpublished = 2020
image = "https://example.com/cover.jpg"
minplayers = 1
maxplayers = 4
minage = 8
rating_average = 7.5
bgg_rank = 100
playingtime = 60
mechanics = []
categories = []
families = []
designers = []
publishers = []
@property
def expansions(self):
if expansions is None:
return []
return expansions
return FakeGame()
def _mock_bgg_client(return_game):
"""Return a callable that creates a fake BGGClient instance."""
class FakeBGGClient:
def __init__(self, access_token=None):
pass
def game(self, game_id=None, name=None):
return return_game
return FakeBGGClient()
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_find_or_create_links_expansions(mock_bgg, mock_get):
exp1 = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
exp2 = type("Thing", (), {"id": 201, "name": "Expansion 2"})()
base_game = _mock_bgg_game("100", "Base Game", expansions=[exp1, exp2])
exp1_game = _mock_bgg_game("200", "Expansion 1")
exp2_game = _mock_bgg_game("201", "Expansion 2")
mock_bgg.side_effect = [
_mock_bgg_client(base_game),
_mock_bgg_client(exp1_game),
_mock_bgg_client(exp2_game),
]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
game = BoardGame.find_or_create("100")
assert game.title == "Base Game"
expansions = BoardGame.objects.filter(expansion_for_boardgame=game)
assert expansions.count() == 2
assert {e.title for e in expansions} == {"Expansion 1", "Expansion 2"}
@pytest.mark.django_db
@patch("boardgames.sources.bgg.BGGClient")
def test_find_or_create_skips_expansions_for_existing_game(mock_bgg):
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
mock_bgg.assert_not_called()
result = BoardGame.find_or_create("100")
assert result == game
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_fetch_and_link_expansions(mock_bgg, mock_get):
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_game = _mock_bgg_game("200", "Expansion 1")
mock_bgg.side_effect = [_mock_bgg_client(exp_game)]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
from boardgames.utils import fetch_and_link_expansions
fetch_and_link_expansions(game, [{"id": 200, "name": "Expansion 1"}])
expansion = BoardGame.objects.get(bggeek_id="200")
assert expansion.expansion_for_boardgame == game
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_fix_metadata_links_expansions(mock_bgg, mock_get):
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
exp_game = _mock_bgg_game("200", "Expansion 1")
# first call = fix_metadata -> lookup_boardgame_from_bgg(lookup_id='100')
# second call = find_or_create inside fetch_and_link_expansions for expansion
mock_bgg.side_effect = [
_mock_bgg_client(base_with_exp),
_mock_bgg_client(exp_game),
]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
game.fix_metadata(force_update=True)
expansion = BoardGame.objects.get(bggeek_id="200")
assert expansion.expansion_for_boardgame == game
@pytest.mark.django_db
@patch("boardgames.sources.bgg.BGGClient")
def test_management_command_fetch_expansions_dry_run(mock_bgg, capsys):
from django.core.management import call_command
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
mock_bgg.side_effect = [_mock_bgg_client(base_with_exp)]
call_command("fetch_expansions")
captured = capsys.readouterr()
assert "Would link 1 expansions" in captured.out
assert BoardGame.objects.filter(bggeek_id="200").count() == 0
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_management_command_fetch_expansions_commit(mock_bgg, mock_get, capsys):
from django.core.management import call_command
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
exp_game = _mock_bgg_game("200", "Expansion 1")
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
mock_bgg.side_effect = [
_mock_bgg_client(base_with_exp),
_mock_bgg_client(exp_game),
]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
call_command("fetch_expansions", commit=True)
captured = capsys.readouterr()
assert "Updated 1 games" in captured.out
expansion = BoardGame.objects.get(bggeek_id="200")
assert expansion.expansion_for_boardgame == game

View File

@ -0,0 +1,106 @@
import pytest
from django.contrib.auth import get_user_model
from boardgames.models import BoardGame, BoardGameVariant
from boardgames.utils import board_names_to_variants
from scrobbles.models import Scrobble
User = get_user_model()
@pytest.mark.django_db
def test_board_names_to_variants_creates_variant():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map A"])
assert len(ids) == 1
variant = BoardGameVariant.objects.get(id=ids[0])
assert variant.name == "Map A"
assert variant.board_game == game
@pytest.mark.django_db
def test_board_names_to_variants_reuses_existing():
game = BoardGame.objects.create(title="Test Game")
existing = BoardGameVariant.objects.create(
name="Map A", board_game=game
)
ids = board_names_to_variants(game, ["Map A"])
assert len(ids) == 1
assert ids[0] == existing.id
@pytest.mark.django_db
def test_board_names_to_variants_multiple_names():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map A", "Map B"])
assert len(ids) == 2
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
assert names == {"Map A", "Map B"}
@pytest.mark.django_db
def test_board_names_to_variants_splits_fullwidth_slash():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map AMap B"])
assert len(ids) == 2
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
assert names == {"Map A", "Map B"}
@pytest.mark.django_db
def test_board_names_to_variants_skips_empty_parts():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map A"])
assert len(ids) == 1
assert BoardGameVariant.objects.get(id=ids[0]).name == "Map A"
@pytest.mark.django_db
def test_board_names_to_variants_different_games_independent():
game1 = BoardGame.objects.create(title="Game 1")
game2 = BoardGame.objects.create(title="Game 2")
ids1 = board_names_to_variants(game1, ["Map A"])
ids2 = board_names_to_variants(game2, ["Map A"])
assert ids1 != ids2
assert BoardGameVariant.objects.count() == 2
@pytest.mark.django_db
def test_management_command_dry_run(capsys):
from django.core.management import call_command
game = BoardGame.objects.create(title="Test Game")
user = User.objects.create(username="tester")
scrobble = Scrobble.objects.create(
user=user,
board_game=game,
log={"board": "Map A"},
)
call_command("convert_board_to_variants")
captured = capsys.readouterr()
assert "Would update 1 scrobbles" in captured.out
scrobble.refresh_from_db()
assert "board" in scrobble.log
@pytest.mark.django_db
def test_management_command_commit():
from django.core.management import call_command
game = BoardGame.objects.create(title="Test Game")
user = User.objects.create(username="tester")
scrobble = Scrobble.objects.create(
user=user,
board_game=game,
log={"board": "Map A"},
)
call_command("convert_board_to_variants", commit=True)
scrobble.refresh_from_db()
assert "board" not in scrobble.log
assert "variant_ids" in scrobble.log
variant = BoardGameVariant.objects.get(board_game=game, name="Map A")
assert variant.id in scrobble.log["variant_ids"]

View File

@ -20,4 +20,9 @@ urlpatterns = [
views.BoardGamePublisherDetailView.as_view(),
name="publisher_detail",
),
path(
"variants/ajax-create/",
views.ajax_create_variant,
name="ajax-create-variant",
),
]

View File

@ -0,0 +1,69 @@
import logging
from typing import Any
from boardgames.models import BoardGame, BoardGameVariant
logger = logging.getLogger(__name__)
def board_names_to_variants(
board_game: BoardGame, board_names: list[str]
) -> list[int]:
"""Given a board game and a list of board/scenario names, find or create
BoardGameVariant records and return their IDs.
Splits each name on the full-width slash ```` so that a single field
containing ``Map AMap B`` produces two separate variants.
"""
variant_ids: list[int] = []
for raw_name in board_names:
for part in raw_name.split(""):
name = part.strip()
if not name:
continue
variant, was_created = BoardGameVariant.objects.get_or_create(
board_game=board_game,
name=name,
)
logger.debug(
"Resolved board variant",
extra={
"board_game_id": board_game.id,
"variant_name": name,
"variant_id": variant.id,
"was_created": was_created,
},
)
variant_ids.append(variant.id)
return variant_ids
def fetch_and_link_expansions(
board_game: BoardGame, expansions_data: list[dict[str, Any]]
) -> None:
"""Given a board game and a list of expansion dicts (with 'id' and 'name'),
find or create each expansion BoardGame and link it via expansion_for_boardgame.
"""
if board_game.skip_expansions:
logger.info(
"Skipping expansion fetch for board game with skip_expansions=True",
extra={"board_game_id": board_game.id, "title": board_game.title},
)
return
for exp_data in expansions_data:
exp_id = exp_data.get("id")
if not exp_id:
continue
expansion = BoardGame.find_or_create(str(exp_id))
if expansion and expansion.id != board_game.id:
expansion.expansion_for_boardgame = board_game
expansion.save(update_fields=["expansion_for_boardgame"])
logger.info(
"Linked expansion to board game",
extra={
"board_game_id": board_game.id,
"expansion_id": expansion.id,
"expansion_name": expansion.title,
},
)

View File

@ -1,6 +1,9 @@
import datetime
from django.http import JsonResponse
from django.utils import timezone
from django.views import generic
from django.views.decorators.http import require_POST
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
from scrobbles.models import Scrobble
from scrobbles.views import (
@ -10,6 +13,38 @@ from scrobbles.views import (
)
@require_POST
def ajax_create_variant(request):
name = request.POST.get("name", "").strip()
board_game_id = request.POST.get("board_game_id")
description = request.POST.get("description", "").strip()
if not name or not board_game_id:
return JsonResponse({"error": "Name and board game are required"}, status=400)
try:
board_game_id = int(board_game_id)
except (ValueError, TypeError):
return JsonResponse({"error": "Invalid board game"}, status=400)
from boardgames.models import BoardGameVariant
variant = BoardGameVariant.objects.filter(
name__iexact=name,
board_game_id=board_game_id,
).first()
if variant:
return JsonResponse({"id": variant.id, "name": variant.name})
variant = BoardGameVariant.objects.create(
name=name,
board_game_id=board_game_id,
description=description or None,
)
return JsonResponse({"id": variant.id, "name": variant.name})
class BoardGameListView(ScrobbleableListView):
model = BoardGame

View File

@ -0,0 +1,9 @@
from django import forms
class VariantSelectWidget(forms.SelectMultiple):
template_name = "boardgames/widgets/variant_select.html"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
return context

View File

@ -12,7 +12,10 @@ logger = logging.getLogger(__name__)
def _parse_udisc_datetime(raw: str) -> datetime:
return parse_datetime(raw)
dt = parse_datetime(raw)
if timezone.is_naive(dt):
return timezone.make_aware(dt)
return dt
def _resolve_player(name: str, user_id: int) -> Person:

View File

@ -8,6 +8,7 @@ import pytz
import requests
from beers.models import Beer
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
from boardgames.utils import board_names_to_variants
from books.constants import READCOMICSONLINE_URL
from books.models import Book, BookLogData, BookPageLogData, Paper
from books.utils import parse_readcomicsonline_uri
@ -370,7 +371,7 @@ def manual_scrobble_board_game(
source: str = "BGG",
action: Optional[str] = None,
) -> Scrobble | None:
boardgame = BoardGame.find_or_create(bggeek_id)
boardgame = BoardGame.find_or_create(bggeek_id, defer_expansions=True)
if not boardgame:
logger.error(f"No board game found for ID {bggeek_id}")
@ -495,7 +496,9 @@ def email_scrobble_board_game(
if play_dict.get("rounds", False):
log_data["rounds"] = play_dict.get("rounds")
if play_dict.get("board", False):
log_data["board"] = play_dict.get("board")
log_data["variant_ids"] = board_names_to_variants(
base_game, [play_dict.get("board")]
)
log_data["players"] = []
for score_dict in play_dict.get("playerScores", []):

View File

@ -33,7 +33,7 @@ from django.http import (
JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.dateformat import DateFormat
from django.views.decorators.csrf import csrf_exempt
@ -51,6 +51,7 @@ from music.aggregators import (
scrobble_counts,
week_of_scrobbles,
)
from boardgames.models import BoardGame, BoardGameVariant
from pendulum.parsing.exceptions import ParserError
from profiles.models import UserProfile
from profiles.utils import now_user_timezone
@ -1235,6 +1236,29 @@ class ScrobbleDetailView(DetailView):
def get_form_class(self):
return self.object.media_obj.logdata_cls().form()
def _update_board_game_widgets(self, form):
if not isinstance(self.object.media_obj, BoardGame):
return
if "expansion_ids" in form.fields:
expansions = BoardGame.objects.filter(
expansion_for_boardgame=self.object.media_obj
)
form.fields["expansion_ids"].queryset = expansions
if "variant_ids" in form.fields:
form.fields["variant_ids"].queryset = BoardGameVariant.objects.filter(
board_game=self.object.media_obj
)
form.fields["variant_ids"].widget.attrs["data-board-game-id"] = (
self.object.media_obj.id
)
form.fields["variant_ids"].widget.attrs["data-ajax-url"] = (
self.request.build_absolute_uri(
reverse("boardgames:ajax-create-variant")
)
)
def get_form(self):
FormClass = self.get_form_class()
@ -1245,12 +1269,15 @@ class ScrobbleDetailView(DetailView):
else:
log["notes"] = self.object.logdata.notes_as_str(separator="\n")
return FormClass(initial=log)
form = FormClass(initial=log)
self._update_board_game_widgets(form)
return form
def post(self, request, *args, **kwargs):
self.object = self.get_object()
FormClass = self.get_form_class()
form = FormClass(request.POST)
self._update_board_game_widgets(form)
if form.is_valid():
data = form.cleaned_data.copy()
@ -1263,6 +1290,12 @@ class ScrobbleDetailView(DetailView):
if data.get("with_people_ids") is not None:
data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
if data.get("expansion_ids") is not None:
data["expansion_ids"] = [e.id for e in data["expansion_ids"]]
if data.get("variant_ids") is not None:
data["variant_ids"] = [v.id for v in data["variant_ids"]]
if data.get("mood_reason_ids") is not None:
data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]]
@ -1926,8 +1959,6 @@ class EmbeddableTopBoardGamesWidget(BaseEmbeddableWidget):
scrobble_filter = {"scrobble__played_to_completion": True}
def get_items(self, user, start_date, end_date):
from boardgames.models import BoardGame
return super().get_items(user, start_date, end_date, BoardGame)

View File

@ -103,7 +103,13 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = [os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000")]
CSRF_TRUSTED_ORIGINS = [
os.getenv(
"VROBBLER_TRUSTED_ORIGINS",
"http://localhost:8000",
),
"https://dev-vrobbler.lab.unbl.ink",
]
X_FRAME_OPTIONS = "SAMEORIGIN"
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
@ -399,6 +405,12 @@ else:
SCIHUB_DOMAIN = os.getenv("VROBBLER_SCIHUB_DOMAIN", "sci-hub.st")
SKIP_AUTO_EXPANSION_DOWNLOAD = [
int(x.strip())
for x in os.getenv("VROBBLER_SKIP_AUTO_EXPANSION_DOWNLOAD", "").split(",")
if x.strip().isdigit()
]
JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY
LOG_TYPE = "json" if JSON_LOGGING else "log"

View File

@ -46,6 +46,20 @@
<p>
<a href="{{object.start_url}}">Play again</a>
</p>
{% if object.genre.all %}
<p>Genres:
{% for tag in object.genre.all %}
<a href="{% url 'boardgames:boardgame_list' %}?genre={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
{% endfor %}
</p>
{% endif %}
{% if object.tags.all %}
<p>Tags:
{% for tag in object.tags.all %}
<a href="{% url 'boardgames:boardgame_list' %}?tag={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
{% endfor %}
</p>
{% endif %}
</div>
{% if charts %}
<div class="row">

View File

@ -16,6 +16,7 @@ from vrobbler.apps.boardgames.api.views import (
BoardGameDesignerViewSet,
BoardGamePublisherViewSet,
BoardGameLocationViewSet,
BoardGameVariantViewSet,
)
from vrobbler.apps.books import urls as book_urls
@ -146,6 +147,7 @@ router.register(r"boardgames", BoardGameViewSet)
router.register(r"boardgame-designers", BoardGameDesignerViewSet)
router.register(r"boardgame-publishers", BoardGamePublisherViewSet)
router.register(r"boardgame-locations", BoardGameLocationViewSet)
router.register(r"boardgame-variants", BoardGameVariantViewSet)
router.register(r"podcast-producers", ProducerViewSet)
router.register(r"podcast-episodes", PodcastEpisodeViewSet)
router.register(r"podcasts", PodcastViewSet)