mirror of
https://github.com/FuzzyGrim/Yamtrack.git
synced 2026-03-03 02:37:02 +00:00
Board Game Category Feature
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||

|
||||

|
||||
|
||||
Yamtrack is a self hosted media tracker for movies, tv shows, anime, manga, video games and books.
|
||||
Yamtrack is a self hosted media tracker for movies, tv shows, anime, manga, video games, books, comics, and board games.
|
||||
|
||||
## 🚀 Demo
|
||||
|
||||
@@ -14,7 +14,7 @@ You can try the app at [yamtrack.fuzzygrim.com](https://yamtrack.fuzzygrim.com)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎬 Track movies, tv shows, anime, manga, games, books and comics.
|
||||
- 🎬 Track movies, tv shows, anime, manga, games, books, comics, and board games.
|
||||
- 📺 Track each season of a tv show individually and episodes watched.
|
||||
- ⭐ Save score, status, progress, repeats (rewatches, rereads...), start and end dates, or write a note.
|
||||
- 📈 Keep a tracking history with each action with a media, such as when you added it, when you started it, when you started watching it again, etc.
|
||||
@@ -109,6 +109,7 @@ MAL_API=API_KEY
|
||||
IGDB_ID=IGDB_ID
|
||||
IGDB_SECRET=IGDB_SECRET
|
||||
STEAM_API_KEY=STEAM_API_SECRET
|
||||
BGG_API_TOKEN=BGG_API_TOKEN
|
||||
SECRET=SECRET
|
||||
DEBUG=True
|
||||
```
|
||||
|
||||
@@ -155,6 +155,23 @@ MEDIA_TYPE_CONFIG = {
|
||||
5.1c-.2-.5.1-1.1.6-1.3l1.9-.7c.5-.2 1.1.1 1.3.6Z"/>""",
|
||||
"unit": ("#", "Issue"),
|
||||
},
|
||||
MediaTypes.BOARDGAME.value: {
|
||||
"sources": [Sources.BGG],
|
||||
"default_source": Sources.BGG.label,
|
||||
"sample_query": "Catan",
|
||||
"unicode_icon": "🎲",
|
||||
"verb": ("play", "played"),
|
||||
"text_color": "text-lime-400",
|
||||
"stats_color": "#84cc16",
|
||||
"svg_icon": """
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
|
||||
<circle cx="8" cy="8" r="2"/>
|
||||
<path d="M16 8h-2"/>
|
||||
<circle cx="16" cy="16" r="2"/>
|
||||
<path d="M8 16v-2"/>""",
|
||||
"unit": ("#", "Play"),
|
||||
"date_key": "publish_date",
|
||||
},
|
||||
}
|
||||
|
||||
# --- Status Configuration ---
|
||||
|
||||
@@ -5,6 +5,7 @@ from app import config
|
||||
from app.models import (
|
||||
TV,
|
||||
Anime,
|
||||
BoardGame,
|
||||
Book,
|
||||
Comic,
|
||||
Episode,
|
||||
@@ -311,6 +312,21 @@ class ComicForm(MediaForm):
|
||||
}
|
||||
|
||||
|
||||
class BoardgameForm(MediaForm):
|
||||
"""Form for board games."""
|
||||
|
||||
class Meta(MediaForm.Meta):
|
||||
"""Bind form to model."""
|
||||
|
||||
model = BoardGame
|
||||
labels = {
|
||||
"progress": (
|
||||
f"Progress "
|
||||
f"({media_type_config.get_unit(MediaTypes.BOARDGAME.value, short=False)}s)"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TvForm(MediaForm):
|
||||
"""Form for TV shows."""
|
||||
|
||||
|
||||
102
src/app/migrations/0052_add_boardgame.py
Normal file
102
src/app/migrations/0052_add_boardgame.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 14:51
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
import simple_history.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0051_migrate_simkl_periodoc_tasks'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BoardGame',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.DecimalValidator(3, 1), django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
|
||||
('progress', models.PositiveIntegerField(default=0)),
|
||||
('progressed_at', model_utils.fields.MonitorField(default=django.utils.timezone.now, monitor='progress')),
|
||||
('status', models.CharField(choices=[('Completed', 'Completed'), ('In progress', 'In Progress'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='Completed', max_length=20)),
|
||||
('start_date', models.DateTimeField(blank=True, null=True)),
|
||||
('end_date', models.DateTimeField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True, default='')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['user', 'item', '-created_at'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HistoricalBoardGame',
|
||||
fields=[
|
||||
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
|
||||
('score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.DecimalValidator(3, 1), django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
|
||||
('progress', models.PositiveIntegerField(default=0)),
|
||||
('status', models.CharField(choices=[('Completed', 'Completed'), ('In progress', 'In Progress'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='Completed', max_length=20)),
|
||||
('start_date', models.DateTimeField(blank=True, null=True)),
|
||||
('end_date', models.DateTimeField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True, default='')),
|
||||
('history_id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('history_date', models.DateTimeField(db_index=True)),
|
||||
('history_change_reason', models.CharField(max_length=100, null=True)),
|
||||
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'historical board game',
|
||||
'verbose_name_plural': 'historical board games',
|
||||
'ordering': ('-history_date', '-history_id'),
|
||||
'get_latest_by': ('history_date', 'history_id'),
|
||||
},
|
||||
bases=(simple_history.models.HistoricalChanges, models.Model),
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='item',
|
||||
name='app_item_media_type_valid',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='item',
|
||||
name='app_item_source_valid',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='media_type',
|
||||
field=models.CharField(choices=[('tv', 'TV Show'), ('season', 'TV Season'), ('episode', 'Episode'), ('movie', 'Movie'), ('anime', 'Anime'), ('manga', 'Manga'), ('game', 'Game'), ('book', 'Book'), ('comic', 'Comic'), ('boardgame', 'Board Game')], default='movie', max_length=10),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='source',
|
||||
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('manual', 'Manual')], max_length=20),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='item',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('source__in', ['tmdb', 'mal', 'mangaupdates', 'igdb', 'openlibrary', 'hardcover', 'comicvine', 'bgg', 'manual'])), name='app_item_source_valid'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='item',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('media_type__in', ['tv', 'season', 'episode', 'movie', 'anime', 'manga', 'game', 'book', 'comic', 'boardgame'])), name='app_item_media_type_valid'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalboardgame',
|
||||
name='history_user',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -45,6 +45,7 @@ class Sources(models.TextChoices):
|
||||
OPENLIBRARY = "openlibrary", "Open Library"
|
||||
HARDCOVER = "hardcover", "Hardcover"
|
||||
COMICVINE = "comicvine", "Comic Vine"
|
||||
BGG = "bgg", "BoardGameGeek"
|
||||
MANUAL = "manual", "Manual"
|
||||
|
||||
|
||||
@@ -60,6 +61,7 @@ class MediaTypes(models.TextChoices):
|
||||
GAME = "game", "Game"
|
||||
BOOK = "book", "Book"
|
||||
COMIC = "comic", "Comic"
|
||||
BOARDGAME = "boardgame", "Boardgame"
|
||||
|
||||
|
||||
class Item(CalendarTriggerMixin, models.Model):
|
||||
@@ -1614,3 +1616,9 @@ class Comic(Media):
|
||||
"""Model for comics."""
|
||||
|
||||
tracker = FieldTracker()
|
||||
|
||||
|
||||
class BoardGame(Media):
|
||||
"""Model for board games."""
|
||||
|
||||
tracker = FieldTracker()
|
||||
|
||||
300
src/app/providers/bgg.py
Normal file
300
src/app/providers/bgg.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""BoardGameGeek (BGG) API provider for board game metadata.
|
||||
|
||||
IMPORTANT: As of November 2024, BGG requires API registration and authorization tokens.
|
||||
|
||||
To use this provider:
|
||||
1. Register for API access at: https://boardgamegeek.com/using_the_xml_api
|
||||
2. Obtain your Bearer token from BGG
|
||||
3. Set BGG_API_TOKEN environment variable with your token
|
||||
|
||||
API Documentation: https://boardgamegeek.com/wiki/page/BGG_XML_API2
|
||||
Registration & Authorization: https://boardgamegeek.com/using_the_xml_api
|
||||
API Terms: https://boardgamegeek.com/wiki/page/XML_API_Terms_of_Use
|
||||
|
||||
Rate Limiting: BGG recommends waiting 5 seconds between requests to avoid 500/503 errors.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from app.models import MediaTypes, Sources
|
||||
from app.providers import services
|
||||
from app import helpers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
base_url = "https://boardgamegeek.com/xmlapi2"
|
||||
|
||||
# Rate limiting: Disabled - BGG handles rate limiting on their end
|
||||
# If you encounter 429/500/503 errors, enable by setting MIN_REQUEST_INTERVAL > 0
|
||||
MIN_REQUEST_INTERVAL = 0 # seconds
|
||||
_last_request_time = 0
|
||||
|
||||
|
||||
def _rate_limit():
|
||||
"""Ensure minimum time between BGG API requests."""
|
||||
global _last_request_time
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - _last_request_time
|
||||
|
||||
if time_since_last < MIN_REQUEST_INTERVAL:
|
||||
sleep_time = MIN_REQUEST_INTERVAL - time_since_last
|
||||
time.sleep(sleep_time)
|
||||
|
||||
_last_request_time = time.time()
|
||||
|
||||
|
||||
def _bgg_request(endpoint, params=None):
|
||||
"""Make a rate-limited request to BGG API.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint (e.g., 'search', 'thing')
|
||||
params: Query parameters dict
|
||||
|
||||
Returns:
|
||||
ElementTree root element
|
||||
|
||||
Raises:
|
||||
ProviderAPIError: On API errors
|
||||
"""
|
||||
_rate_limit()
|
||||
|
||||
url = f"{base_url}/{endpoint}"
|
||||
headers = {
|
||||
"User-Agent": "Yamtrack/1.0 (https://github.com/FuzzyGrim/Yamtrack)"
|
||||
}
|
||||
|
||||
# BGG now requires Bearer token authorization (as of Nov 2024)
|
||||
bgg_token = getattr(settings, "BGG_API_TOKEN", None)
|
||||
if bgg_token:
|
||||
headers["Authorization"] = f"Bearer {bgg_token}"
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
|
||||
# Check for missing/invalid authorization
|
||||
if response.status_code == 401:
|
||||
logger.error(
|
||||
"BGG API requires authorization. Register at "
|
||||
"https://boardgamegeek.com/using_the_xml_api "
|
||||
"and set BGG_API_TOKEN in your environment."
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# BGG may return 202 (queued request), need to retry
|
||||
if response.status_code == 202:
|
||||
logger.info("BGG queued request, retrying...")
|
||||
time.sleep(2)
|
||||
return _bgg_request(endpoint, params)
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.text)
|
||||
return root
|
||||
|
||||
except requests.exceptions.HTTPError as error:
|
||||
raise services.ProviderAPIError(Sources.BGG.value, error)
|
||||
except ET.ParseError as error:
|
||||
logger.exception("Failed to parse BGG XML response")
|
||||
raise services.ProviderAPIError(
|
||||
Sources.BGG.value,
|
||||
error,
|
||||
"Invalid XML response from BGG",
|
||||
)
|
||||
|
||||
|
||||
def search(query, page=1):
|
||||
"""Search for board games on BoardGameGeek.
|
||||
|
||||
Args:
|
||||
query: Search term
|
||||
page: Page number for client-side pagination
|
||||
|
||||
Returns:
|
||||
Formatted search response with results
|
||||
"""
|
||||
# Cache all game IDs separately from page-specific results
|
||||
ids_cache_key = f"bgg_search_ids_{query.lower()}"
|
||||
game_data = cache.get(ids_cache_key)
|
||||
|
||||
if not game_data:
|
||||
# First search or cache expired - fetch from BGG
|
||||
params = {
|
||||
"query": query,
|
||||
"type": "boardgame",
|
||||
}
|
||||
root = _bgg_request("search", params)
|
||||
|
||||
# Collect game IDs and names from search results
|
||||
game_ids = []
|
||||
game_names = {}
|
||||
for item in root.findall(".//item"):
|
||||
game_id = item.get("id")
|
||||
name_elem = item.find("name")
|
||||
if name_elem is not None and game_id:
|
||||
game_ids.append(game_id)
|
||||
game_names[game_id] = name_elem.get("value", "Unknown")
|
||||
|
||||
game_data = {"ids": game_ids, "names": game_names}
|
||||
# Cache game IDs for 1 day
|
||||
cache.set(ids_cache_key, game_data, 60 * 60 * 24)
|
||||
|
||||
game_ids = game_data["ids"]
|
||||
game_names = game_data["names"]
|
||||
|
||||
# Check if this specific page is cached
|
||||
page_cache_key = f"bgg_search_page_{query.lower()}_p{page}"
|
||||
cached_page = cache.get(page_cache_key)
|
||||
if cached_page:
|
||||
return cached_page
|
||||
|
||||
# Paginate: 20 results per page
|
||||
per_page = 20
|
||||
total_results = len(game_ids)
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
page_ids = game_ids[start_idx:end_idx]
|
||||
|
||||
# Fetch thumbnails only for current page
|
||||
results = []
|
||||
if page_ids:
|
||||
try:
|
||||
thing_params = {
|
||||
"id": ",".join(page_ids),
|
||||
# Don't filter by type - allows BGG to return expansions/accessories too
|
||||
}
|
||||
thing_root = _bgg_request("thing", thing_params)
|
||||
|
||||
# Build a map of game_id -> image
|
||||
thumbnails = {}
|
||||
for item in thing_root.findall(".//item"):
|
||||
game_id = item.get("id")
|
||||
# Try thumbnail first, fall back to full image if no thumbnail
|
||||
thumbnail_elem = item.find("thumbnail")
|
||||
if thumbnail_elem is not None and thumbnail_elem.text:
|
||||
thumbnails[game_id] = thumbnail_elem.text
|
||||
else:
|
||||
image_elem = item.find("image")
|
||||
if image_elem is not None and image_elem.text:
|
||||
thumbnails[game_id] = image_elem.text
|
||||
|
||||
# Build results with images
|
||||
for game_id in page_ids:
|
||||
results.append({
|
||||
"media_id": game_id,
|
||||
"source": Sources.BGG.value,
|
||||
"media_type": MediaTypes.BOARDGAME.value,
|
||||
"title": game_names[game_id],
|
||||
"image": thumbnails.get(game_id, settings.IMG_NONE),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch thumbnails: {e}")
|
||||
# Fallback to results without images
|
||||
for game_id in page_ids:
|
||||
results.append({
|
||||
"media_id": game_id,
|
||||
"source": Sources.BGG.value,
|
||||
"media_type": MediaTypes.BOARDGAME.value,
|
||||
"title": game_names[game_id],
|
||||
"image": settings.IMG_NONE,
|
||||
})
|
||||
|
||||
data = helpers.format_search_response(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_results=total_results,
|
||||
results=results,
|
||||
)
|
||||
|
||||
# Cache this page for 1 day
|
||||
cache.set(page_cache_key, data, 60 * 60 * 24)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def metadata(media_id):
|
||||
"""Get detailed metadata for a board game.
|
||||
|
||||
Args:
|
||||
media_id: BGG thing ID
|
||||
|
||||
Returns:
|
||||
Dict with game details
|
||||
"""
|
||||
cache_key = f"bgg_metadata_{media_id}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
params = {
|
||||
"id": media_id,
|
||||
"stats": "1",
|
||||
}
|
||||
|
||||
root = _bgg_request("thing", params)
|
||||
|
||||
item = root.find(".//item")
|
||||
if item is None:
|
||||
raise services.ProviderAPIError(
|
||||
Sources.BGG.value,
|
||||
None,
|
||||
f"Game not found: {media_id}",
|
||||
)
|
||||
|
||||
# Extract primary name
|
||||
name_elem = item.find(".//name[@type='primary']")
|
||||
title = name_elem.get("value", "Unknown") if name_elem is not None else "Unknown"
|
||||
|
||||
# Extract image
|
||||
image_elem = item.find("image")
|
||||
image = image_elem.text if image_elem is not None else settings.IMG_NONE
|
||||
|
||||
# Extract description
|
||||
desc_elem = item.find("description")
|
||||
description = desc_elem.text if desc_elem is not None else ""
|
||||
|
||||
# Extract year
|
||||
year_elem = item.find("yearpublished")
|
||||
year = year_elem.get("value", "") if year_elem is not None else ""
|
||||
|
||||
# Extract player counts
|
||||
minplayers_elem = item.find("minplayers")
|
||||
maxplayers_elem = item.find("maxplayers")
|
||||
minplayers = minplayers_elem.get("value", "") if minplayers_elem is not None else ""
|
||||
maxplayers = maxplayers_elem.get("value", "") if maxplayers_elem is not None else ""
|
||||
|
||||
# Extract playtime
|
||||
playtime_elem = item.find("playingtime")
|
||||
playtime = playtime_elem.get("value", "") if playtime_elem is not None else ""
|
||||
|
||||
# Extract minimum age
|
||||
minage_elem = item.find("minage")
|
||||
minage = minage_elem.get("value", "") if minage_elem is not None else ""
|
||||
|
||||
# Extract BGG rating
|
||||
avg_rating_elem = item.find(".//statistics/ratings/average")
|
||||
avg_rating = avg_rating_elem.get("value", "") if avg_rating_elem is not None else ""
|
||||
|
||||
result = {
|
||||
"media_id": media_id,
|
||||
"source": Sources.BGG.value,
|
||||
"media_type": MediaTypes.BOARDGAME.value,
|
||||
"title": title,
|
||||
"image": image,
|
||||
"description": description,
|
||||
"year": year,
|
||||
"players": f"{minplayers}-{maxplayers}" if minplayers and maxplayers else "",
|
||||
"playtime": f"{playtime} min" if playtime else "",
|
||||
"age": f"{minage}+" if minage else "",
|
||||
"bgg_rating": avg_rating,
|
||||
"max_progress": None, # Board games don't have max progress - tracks plays instead
|
||||
"related": {},
|
||||
}
|
||||
|
||||
# Cache for 7 days
|
||||
cache.set(cache_key, result, 60 * 60 * 24 * 7)
|
||||
return result
|
||||
@@ -10,6 +10,7 @@ from requests_ratelimiter import LimiterAdapter, LimiterSession
|
||||
|
||||
from app.models import MediaTypes, Sources
|
||||
from app.providers import (
|
||||
bgg,
|
||||
comicvine,
|
||||
hardcover,
|
||||
igdb,
|
||||
@@ -207,6 +208,7 @@ def get_media_metadata(
|
||||
if source == Sources.HARDCOVER.value
|
||||
else openlibrary.book(media_id),
|
||||
MediaTypes.COMIC.value: lambda: comicvine.comic(media_id),
|
||||
MediaTypes.BOARDGAME.value: lambda: bgg.metadata(media_id),
|
||||
}
|
||||
return metadata_retrievers[media_type]()
|
||||
|
||||
@@ -231,5 +233,7 @@ def search(media_type, query, page, source=None):
|
||||
response = hardcover.search(query, page)
|
||||
elif media_type == MediaTypes.COMIC.value:
|
||||
response = comicvine.search(query, page)
|
||||
elif media_type == MediaTypes.BOARDGAME.value:
|
||||
response = bgg.search(query, page)
|
||||
|
||||
return response
|
||||
|
||||
@@ -374,6 +374,15 @@ IGDB_SECRET = config(
|
||||
)
|
||||
IGDB_NSFW = config("IGDB_NSFW", default=False, cast=bool)
|
||||
|
||||
# BoardGameGeek API Token - Register at https://boardgamegeek.com/using_the_xml_api
|
||||
BGG_API_TOKEN = config(
|
||||
"BGG_API_TOKEN",
|
||||
default=secret(
|
||||
"BGG_API_TOKEN_FILE",
|
||||
"92f43ab1-d1d5-4e18-8b82-d1f56dc12927",
|
||||
),
|
||||
)
|
||||
|
||||
STEAM_API_KEY = config(
|
||||
"STEAM_API_KEY",
|
||||
default=secret(
|
||||
|
||||
57
src/users/migrations/0038_add_boardgame_preferences.py
Normal file
57
src/users/migrations/0038_add_boardgame_preferences.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 14:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0052_add_boardgame'),
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('users', '0037_remove_user_home_sort_valid_alter_user_home_sort'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='user',
|
||||
name='last_search_type_valid',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='boardgame_enabled',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='boardgame_layout',
|
||||
field=models.CharField(choices=[('grid', 'Grid'), ('table', 'Table')], default='grid', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='boardgame_sort',
|
||||
field=models.CharField(choices=[('score', 'Rating'), ('title', 'Title'), ('progress', 'Progress'), ('start_date', 'Start Date'), ('end_date', 'End Date')], default='score', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='boardgame_status',
|
||||
field=models.CharField(choices=[('All', 'All'), ('Completed', 'Completed'), ('In progress', 'In Progress'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='All', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='home_sort',
|
||||
field=models.CharField(choices=[('upcoming', 'Upcoming'), ('recent', 'Recent'), ('completion', 'Completion'), ('episodes_left', 'Episodes Left'), ('title', 'Title')], default='upcoming', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='last_search_type',
|
||||
field=models.CharField(choices=[('tv', 'TV Show'), ('season', 'TV Season'), ('episode', 'Episode'), ('movie', 'Movie'), ('anime', 'Anime'), ('manga', 'Manga'), ('game', 'Game'), ('book', 'Book'), ('comic', 'Comic'), ('boardgame', 'Board Game')], default='tv', max_length=10),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='user',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('last_search_type__in', ['tv', 'movie', 'anime', 'manga', 'game', 'book', 'comic', 'boardgame'])), name='last_search_type_valid'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='user',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('home_sort__in', ['upcoming', 'recent', 'completion', 'episodes_left', 'title'])), name='home_sort_valid'),
|
||||
),
|
||||
]
|
||||
@@ -267,6 +267,24 @@ class User(AbstractUser):
|
||||
choices=MediaStatusChoices.choices,
|
||||
)
|
||||
|
||||
# Media type preferences: Board Games
|
||||
boardgame_enabled = models.BooleanField(default=True)
|
||||
boardgame_layout = models.CharField(
|
||||
max_length=20,
|
||||
default=LayoutChoices.GRID,
|
||||
choices=LayoutChoices.choices,
|
||||
)
|
||||
boardgame_sort = models.CharField(
|
||||
max_length=20,
|
||||
default=MediaSortChoices.SCORE,
|
||||
choices=MediaSortChoices.choices,
|
||||
)
|
||||
boardgame_status = models.CharField(
|
||||
max_length=20,
|
||||
default=MediaStatusChoices.ALL,
|
||||
choices=MediaStatusChoices.choices,
|
||||
)
|
||||
|
||||
# UI preferences
|
||||
clickable_media_cards = models.BooleanField(
|
||||
default=False,
|
||||
|
||||
Reference in New Issue
Block a user