mirror of
https://github.com/FuzzyGrim/Yamtrack.git
synced 2026-03-03 03:47:02 +00:00
Merge branch 'dev' into feature/progress_line
This commit is contained in:
15
.pre-commit-config.yaml
Normal file
15
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: ruff
|
||||
entry: ruff check --fix
|
||||
language: system
|
||||
types: [python]
|
||||
require_serial: true
|
||||
- id: ruff-format
|
||||
name: ruff-format
|
||||
entry: ruff format
|
||||
language: system
|
||||
types: [python]
|
||||
require_serial: true
|
||||
@@ -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
|
||||
```
|
||||
@@ -117,6 +118,7 @@ Then run the following commands.
|
||||
|
||||
```bash
|
||||
python -m pip install -U -r requirements-dev.txt
|
||||
pre-commit install
|
||||
cd src
|
||||
python manage.py migrate
|
||||
python manage.py runserver & celery -A config worker --beat --scheduler django --loglevel DEBUG & tailwindcss -i ./static/css/input.css -o ./static/css/tailwind.css --watch
|
||||
|
||||
@@ -4,7 +4,7 @@ extend-exclude = ["migrations"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = ["ANN", "PT", "PD", "D100", "D104", "RUF012", "PLR0913", "SLF001"]
|
||||
ignore = ["ANN", "PT", "PD", "D100", "D104", "RUF012", "PLR0913", "SLF001", "COM812"]
|
||||
|
||||
[tool.ruff.lint.pycodestyle]
|
||||
max-doc-length = 88
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
coverage==7.13.0
|
||||
djlint==1.36.4
|
||||
fakeredis==2.33.0
|
||||
fakeredis==2.32.1
|
||||
pre-commit==4.5.1
|
||||
pytest-django==4.11.1
|
||||
pytest-playwright==0.7.2
|
||||
ruff==0.14.10
|
||||
|
||||
@@ -3,6 +3,7 @@ apprise==1.9.6
|
||||
beautifulsoup4==4.14.3
|
||||
celery==5.6.0
|
||||
croniter==6.0.0
|
||||
defusedxml==0.7.1
|
||||
Django==5.2.9
|
||||
django-allauth[socialaccount]==65.13.1
|
||||
django-celery-beat==2.8.1
|
||||
|
||||
@@ -50,6 +50,7 @@ COLORS = {
|
||||
"background": "bg-cyan-400",
|
||||
"hex": "#06b6d4",
|
||||
},
|
||||
"lime": {"text": "text-lime-400", "background": "bg-lime-400", "hex": "#84cc16"},
|
||||
"sky": {
|
||||
"text": "text-sky-400",
|
||||
"background": "bg-sky-400",
|
||||
@@ -196,6 +197,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,
|
||||
"sample_query": "Catan",
|
||||
"unicode_icon": "🎲",
|
||||
"verb": ("play", "played"),
|
||||
"text_color": COLORS["lime"]["text"],
|
||||
"stats_color": COLORS["lime"]["hex"],
|
||||
"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": "year",
|
||||
},
|
||||
}
|
||||
|
||||
# --- Status Configuration ---
|
||||
@@ -334,6 +352,7 @@ def get_status_stats_color(status):
|
||||
"""Get the stats color for a status."""
|
||||
return get_status_property(status, "stats_color")
|
||||
|
||||
|
||||
def get_status_background_color(status):
|
||||
"""Get the background color for a status."""
|
||||
return get_status_property(status, "background_color")
|
||||
|
||||
@@ -5,6 +5,7 @@ from app import config
|
||||
from app.models import (
|
||||
TV,
|
||||
Anime,
|
||||
BoardGame,
|
||||
Book,
|
||||
Comic,
|
||||
Episode,
|
||||
@@ -235,8 +236,7 @@ class MangaForm(MediaForm):
|
||||
model = Manga
|
||||
labels = {
|
||||
"progress": (
|
||||
f"Progress "
|
||||
f"({config.get_unit(MediaTypes.MANGA.value, short=False)}s)"
|
||||
f"Progress ({config.get_unit(MediaTypes.MANGA.value, short=False)}s)"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -290,8 +290,7 @@ class BookForm(MediaForm):
|
||||
model = Book
|
||||
labels = {
|
||||
"progress": (
|
||||
f"Progress "
|
||||
f"({config.get_unit(MediaTypes.BOOK.value, short=False)}s)"
|
||||
f"Progress ({config.get_unit(MediaTypes.BOOK.value, short=False)}s)"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -305,8 +304,22 @@ class ComicForm(MediaForm):
|
||||
model = Comic
|
||||
labels = {
|
||||
"progress": (
|
||||
f"Progress "
|
||||
f"({config.get_unit(MediaTypes.COMIC.value, short=False)}s)"
|
||||
f"Progress ({config.get_unit(MediaTypes.COMIC.value, short=False)}s)"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BoardgameForm(MediaForm):
|
||||
"""Form for board games."""
|
||||
|
||||
class Meta(MediaForm.Meta):
|
||||
"""Bind form to model."""
|
||||
|
||||
model = BoardGame
|
||||
labels = {
|
||||
"progress": (
|
||||
"Progress "
|
||||
f"({config.get_unit(MediaTypes.BOARDGAME.value, short=False)}s)"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
102
src/app/migrations/0053_add_boardgame.py
Normal file
102
src/app/migrations/0053_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', '0052_alter_item_title'),
|
||||
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),
|
||||
),
|
||||
]
|
||||
23
src/app/migrations/0054_fix_movie_progress.py
Normal file
23
src/app/migrations/0054_fix_movie_progress.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.8 on 2026-02-01
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fix_movie_progress(apps, _):
|
||||
"""Set progress=1 for movies with status=Completed and progress=0."""
|
||||
Movie = apps.get_model("app", "Movie")
|
||||
Movie.objects.filter(status="Completed", progress=0).update(progress=1)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Migration to fix movie progress."""
|
||||
|
||||
dependencies = [
|
||||
("app", "0053_add_boardgame"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
fix_movie_progress, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
@@ -833,7 +835,7 @@ class Media(models.Model):
|
||||
"""Update fields depending on the progress of the media."""
|
||||
if self.progress < 0:
|
||||
self.progress = 0
|
||||
else:
|
||||
elif self.status == Status.IN_PROGRESS.value:
|
||||
max_progress = providers.services.get_media_metadata(
|
||||
self.item.media_type,
|
||||
self.item.media_id,
|
||||
@@ -1614,3 +1616,9 @@ class Comic(Media):
|
||||
"""Model for comics."""
|
||||
|
||||
tracker = FieldTracker()
|
||||
|
||||
|
||||
class BoardGame(Media):
|
||||
"""Model for board games."""
|
||||
|
||||
tracker = FieldTracker()
|
||||
|
||||
295
src/app/providers/bgg.py
Normal file
295
src/app/providers/bgg.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""BoardGameGeek (BGG) API provider for board game metadata.
|
||||
|
||||
API Documentation: https://boardgamegeek.com/wiki/page/BGG_XML_API2
|
||||
API Terms: https://boardgamegeek.com/wiki/page/XML_API_Terms_of_Use
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from app import helpers
|
||||
from app.models import MediaTypes, Sources
|
||||
from app.providers import services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
base_url = "https://boardgamegeek.com/xmlapi2"
|
||||
|
||||
# BGG's /thing endpoint has a max of 20 IDs per request
|
||||
RESULTS_PER_PAGE = 20
|
||||
|
||||
|
||||
def handle_error(error):
|
||||
"""Handle BGG API errors."""
|
||||
if error.response.status_code == requests.codes.unauthorized:
|
||||
raise services.ProviderAPIError(
|
||||
Sources.BGG.value,
|
||||
error,
|
||||
"BGG API requires authorization",
|
||||
)
|
||||
raise services.ProviderAPIError(Sources.BGG.value, error)
|
||||
|
||||
|
||||
def search(query, page):
|
||||
"""Search for board games on BoardGameGeek."""
|
||||
cache_key = (
|
||||
f"search_{Sources.BGG.value}_{MediaTypes.BOARDGAME.value}_{query}_{page}"
|
||||
)
|
||||
data = cache.get(cache_key)
|
||||
|
||||
if data is None:
|
||||
# Cache search results separately so page changes don't re-query BGG
|
||||
search_results_cache_key = (
|
||||
f"search_results_{Sources.BGG.value}_{MediaTypes.BOARDGAME.value}_{query}"
|
||||
)
|
||||
all_results = cache.get(search_results_cache_key)
|
||||
|
||||
if all_results is None:
|
||||
try:
|
||||
root = services.api_request(
|
||||
Sources.BGG.value,
|
||||
"GET",
|
||||
f"{base_url}/search",
|
||||
params={"query": query, "type": "boardgame"},
|
||||
headers={"Authorization": f"Bearer {settings.BGG_API_TOKEN}"},
|
||||
response_format="xml",
|
||||
)
|
||||
except requests.exceptions.HTTPError as error:
|
||||
handle_error(error)
|
||||
|
||||
# Parse all results (BGG returns all at once, no server-side pagination)
|
||||
all_results = []
|
||||
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:
|
||||
all_results.append(
|
||||
{
|
||||
"id": game_id,
|
||||
"name": name_elem.get("value", "Unknown"),
|
||||
}
|
||||
)
|
||||
|
||||
cache.set(search_results_cache_key, all_results)
|
||||
|
||||
# Client-side pagination
|
||||
total_results = len(all_results)
|
||||
start_idx = (page - 1) * RESULTS_PER_PAGE
|
||||
end_idx = start_idx + RESULTS_PER_PAGE
|
||||
page_results = all_results[start_idx:end_idx]
|
||||
|
||||
# Fetch thumbnails for this page
|
||||
thumbnails = _fetch_thumbnails([r["id"] for r in page_results])
|
||||
|
||||
results = [
|
||||
{
|
||||
"media_id": r["id"],
|
||||
"source": Sources.BGG.value,
|
||||
"media_type": MediaTypes.BOARDGAME.value,
|
||||
"title": r["name"],
|
||||
"image": thumbnails.get(r["id"], settings.IMG_NONE),
|
||||
}
|
||||
for r in page_results
|
||||
]
|
||||
|
||||
data = helpers.format_search_response(
|
||||
page,
|
||||
RESULTS_PER_PAGE,
|
||||
total_results,
|
||||
results,
|
||||
)
|
||||
|
||||
cache.set(cache_key, data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _fetch_thumbnails(game_ids):
|
||||
"""Fetch thumbnail images for a list of game IDs."""
|
||||
if not game_ids:
|
||||
return {}
|
||||
|
||||
try:
|
||||
root = services.api_request(
|
||||
Sources.BGG.value,
|
||||
"GET",
|
||||
f"{base_url}/thing",
|
||||
params={"id": ",".join(game_ids)},
|
||||
headers={"Authorization": f"Bearer {settings.BGG_API_TOKEN}"},
|
||||
response_format="xml",
|
||||
)
|
||||
|
||||
thumbnails = {}
|
||||
for item in root.findall(".//item"):
|
||||
game_id = item.get("id")
|
||||
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
|
||||
except (requests.exceptions.HTTPError, services.ProviderAPIError):
|
||||
logger.exception("Failed to fetch thumbnails from BGG")
|
||||
return {}
|
||||
else:
|
||||
return thumbnails
|
||||
|
||||
|
||||
def boardgame(media_id):
|
||||
"""Return the metadata for the selected board game from BGG."""
|
||||
cache_key = f"{Sources.BGG.value}_{MediaTypes.BOARDGAME.value}_{media_id}"
|
||||
data = cache.get(cache_key)
|
||||
|
||||
if data is None:
|
||||
try:
|
||||
root = services.api_request(
|
||||
Sources.BGG.value,
|
||||
"GET",
|
||||
f"{base_url}/thing",
|
||||
params={"id": media_id, "stats": "1"},
|
||||
headers={"Authorization": f"Bearer {settings.BGG_API_TOKEN}"},
|
||||
response_format="xml",
|
||||
)
|
||||
except requests.exceptions.HTTPError as error:
|
||||
handle_error(error)
|
||||
|
||||
item = root.find(".//item")
|
||||
if item is None:
|
||||
services.raise_not_found_error(Sources.BGG.value, media_id, "boardgame")
|
||||
|
||||
data = {
|
||||
"media_id": media_id,
|
||||
"source": Sources.BGG.value,
|
||||
"source_url": f"https://boardgamegeek.com/boardgame/{media_id}",
|
||||
"media_type": MediaTypes.BOARDGAME.value,
|
||||
"title": get_title(item),
|
||||
"max_progress": None,
|
||||
"image": get_image(item),
|
||||
"synopsis": get_description(item),
|
||||
"genres": get_categories(item),
|
||||
"score": get_score(item),
|
||||
"score_count": get_score_count(item),
|
||||
"details": {
|
||||
"year": get_year(item),
|
||||
"players": get_players(item),
|
||||
"playtime": get_playtime(item),
|
||||
"min_age": get_min_age(item),
|
||||
"designers": get_designers(item),
|
||||
"publishers": get_publishers(item),
|
||||
},
|
||||
}
|
||||
|
||||
cache.set(cache_key, data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_title(item):
|
||||
"""Return the primary name of the game."""
|
||||
name_elem = item.find(".//name[@type='primary']")
|
||||
return name_elem.get("value", "Unknown") if name_elem is not None else "Unknown"
|
||||
|
||||
|
||||
def get_image(item):
|
||||
"""Return the image URL."""
|
||||
image_elem = item.find("image")
|
||||
if image_elem is not None and image_elem.text:
|
||||
return image_elem.text
|
||||
return settings.IMG_NONE
|
||||
|
||||
|
||||
def get_description(item):
|
||||
"""Return the description."""
|
||||
desc_elem = item.find("description")
|
||||
if desc_elem is not None and desc_elem.text:
|
||||
return desc_elem.text
|
||||
return "No synopsis available"
|
||||
|
||||
|
||||
def get_year(item):
|
||||
"""Return the year published."""
|
||||
year_elem = item.find("yearpublished")
|
||||
return year_elem.get("value") if year_elem is not None else None
|
||||
|
||||
|
||||
def get_players(item):
|
||||
"""Return the player count range."""
|
||||
minplayers_elem = item.find("minplayers")
|
||||
maxplayers_elem = item.find("maxplayers")
|
||||
minplayers = minplayers_elem.get("value") if minplayers_elem is not None else None
|
||||
maxplayers = maxplayers_elem.get("value") if maxplayers_elem is not None else None
|
||||
|
||||
if minplayers and maxplayers:
|
||||
if minplayers == maxplayers:
|
||||
return f"{minplayers} players"
|
||||
return f"{minplayers}-{maxplayers} players"
|
||||
return None
|
||||
|
||||
|
||||
def get_playtime(item):
|
||||
"""Return the playing time."""
|
||||
playtime_elem = item.find("playingtime")
|
||||
playtime = playtime_elem.get("value") if playtime_elem is not None else None
|
||||
return f"{playtime} min" if playtime else None
|
||||
|
||||
|
||||
def get_min_age(item):
|
||||
"""Return the minimum age."""
|
||||
minage_elem = item.find("minage")
|
||||
minage = minage_elem.get("value") if minage_elem is not None else None
|
||||
return f"{minage}+" if minage else None
|
||||
|
||||
|
||||
def get_score(item):
|
||||
"""Return the average rating."""
|
||||
avg_rating_elem = item.find(".//statistics/ratings/average")
|
||||
if avg_rating_elem is not None:
|
||||
try:
|
||||
return round(float(avg_rating_elem.get("value", 0)), 1)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_score_count(item):
|
||||
"""Return the number of ratings."""
|
||||
usersrated_elem = item.find(".//statistics/ratings/usersrated")
|
||||
if usersrated_elem is not None:
|
||||
try:
|
||||
return int(usersrated_elem.get("value", 0))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_categories(item):
|
||||
"""Return the list of categories."""
|
||||
categories = [
|
||||
link.get("value")
|
||||
for link in item.findall(".//link[@type='boardgamecategory']")
|
||||
if link.get("value")
|
||||
]
|
||||
return categories if categories else None
|
||||
|
||||
|
||||
def get_designers(item):
|
||||
"""Return the list of designers."""
|
||||
designers = [
|
||||
link.get("value")
|
||||
for link in item.findall(".//link[@type='boardgamedesigner']")
|
||||
if link.get("value")
|
||||
]
|
||||
return ", ".join(designers) if designers else None
|
||||
|
||||
|
||||
def get_publishers(item):
|
||||
"""Return the first few publishers."""
|
||||
publishers = [
|
||||
link.get("value")
|
||||
for link in item.findall(".//link[@type='boardgamepublisher']")[:3]
|
||||
if link.get("value")
|
||||
]
|
||||
return ", ".join(publishers) if publishers else None
|
||||
@@ -125,7 +125,7 @@ def external_game(external_id, source=ExternalGameSource.STEAM):
|
||||
url = f"{base_url}/external_games"
|
||||
query = (
|
||||
f'fields game; where uid = "{external_id}" & '
|
||||
f'external_game_source = {source};'
|
||||
f"external_game_source = {source};"
|
||||
)
|
||||
headers = {
|
||||
"Client-ID": settings.IGDB_ID,
|
||||
@@ -314,7 +314,9 @@ def game(media_id):
|
||||
# Check if response is empty (no results found)
|
||||
if not response:
|
||||
services.raise_not_found_error(
|
||||
Sources.IGDB.value, media_id, "game",
|
||||
Sources.IGDB.value,
|
||||
media_id,
|
||||
"game",
|
||||
)
|
||||
|
||||
response = response[0] # response is a list with a single element
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
from defusedxml import ElementTree
|
||||
from django.conf import settings
|
||||
from pyrate_limiter import RedisBucket
|
||||
from redis import ConnectionPool
|
||||
@@ -10,6 +11,7 @@ from requests_ratelimiter import LimiterAdapter, LimiterSession
|
||||
|
||||
from app.models import MediaTypes, Sources
|
||||
from app.providers import (
|
||||
bgg,
|
||||
comicvine,
|
||||
hardcover,
|
||||
igdb,
|
||||
@@ -74,6 +76,10 @@ session.mount(
|
||||
"https://api.hardcover.app/v1/graphql",
|
||||
LimiterAdapter(per_minute=50),
|
||||
)
|
||||
session.mount(
|
||||
"https://boardgamegeek.com/xmlapi2",
|
||||
LimiterAdapter(per_second=2),
|
||||
)
|
||||
|
||||
|
||||
class ProviderAPIError(Exception):
|
||||
@@ -126,8 +132,29 @@ def raise_not_found_error(provider, media_id, media_type="item"):
|
||||
raise ProviderAPIError(provider, mock_error, error_msg)
|
||||
|
||||
|
||||
def api_request(provider, method, url, params=None, data=None, headers=None):
|
||||
"""Make a request to the API and return the response as a dictionary."""
|
||||
def api_request(
|
||||
provider,
|
||||
method,
|
||||
url,
|
||||
params=None,
|
||||
data=None,
|
||||
headers=None,
|
||||
response_format="json",
|
||||
):
|
||||
"""Make a request to the API and return the response.
|
||||
|
||||
Args:
|
||||
provider: Provider identifier for error messages
|
||||
method: HTTP method ("GET" or "POST")
|
||||
url: Request URL
|
||||
params: Query params for GET, JSON body for POST
|
||||
data: Raw data for POST
|
||||
headers: Request headers
|
||||
response_format: "json" (default) or "xml" for XML parsing
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict or ElementTree for XML
|
||||
"""
|
||||
try:
|
||||
request_kwargs = {
|
||||
"url": url,
|
||||
@@ -145,6 +172,9 @@ def api_request(provider, method, url, params=None, data=None, headers=None):
|
||||
|
||||
response = request_func(**request_kwargs)
|
||||
response.raise_for_status()
|
||||
|
||||
if response_format == "xml":
|
||||
return ElementTree.fromstring(response.text)
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.HTTPError as error:
|
||||
@@ -164,6 +194,7 @@ def api_request(provider, method, url, params=None, data=None, headers=None):
|
||||
params=params,
|
||||
data=data,
|
||||
headers=headers,
|
||||
response_format=response_format,
|
||||
)
|
||||
|
||||
raise error from None
|
||||
@@ -207,29 +238,31 @@ 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.boardgame(media_id),
|
||||
}
|
||||
return metadata_retrievers[media_type]()
|
||||
|
||||
|
||||
def search(media_type, query, page, source=None):
|
||||
"""Search for media based on the query and return the results."""
|
||||
if media_type == MediaTypes.MANGA.value:
|
||||
if source == Sources.MANGAUPDATES.value:
|
||||
response = mangaupdates.search(query, page)
|
||||
else:
|
||||
response = mal.search(media_type, query, page)
|
||||
elif media_type == MediaTypes.ANIME.value:
|
||||
response = mal.search(media_type, query, page)
|
||||
elif media_type in (MediaTypes.TV.value, MediaTypes.MOVIE.value):
|
||||
response = tmdb.search(media_type, query, page)
|
||||
elif media_type == MediaTypes.GAME.value:
|
||||
response = igdb.search(query, page)
|
||||
elif media_type == MediaTypes.BOOK.value:
|
||||
if source == Sources.OPENLIBRARY.value:
|
||||
response = openlibrary.search(query, page)
|
||||
else:
|
||||
response = hardcover.search(query, page)
|
||||
elif media_type == MediaTypes.COMIC.value:
|
||||
response = comicvine.search(query, page)
|
||||
|
||||
return response
|
||||
search_handlers = {
|
||||
MediaTypes.MANGA.value: lambda: (
|
||||
mangaupdates.search(query, page)
|
||||
if source == Sources.MANGAUPDATES.value
|
||||
else mal.search(media_type, query, page)
|
||||
),
|
||||
MediaTypes.ANIME.value: lambda: mal.search(media_type, query, page),
|
||||
MediaTypes.TV.value: lambda: tmdb.search(media_type, query, page),
|
||||
MediaTypes.MOVIE.value: lambda: tmdb.search(media_type, query, page),
|
||||
MediaTypes.SEASON.value: lambda: tmdb.search(MediaTypes.TV.value, query, page),
|
||||
MediaTypes.EPISODE.value: lambda: tmdb.search(MediaTypes.TV.value, query, page),
|
||||
MediaTypes.GAME.value: lambda: igdb.search(query, page),
|
||||
MediaTypes.BOOK.value: lambda: (
|
||||
openlibrary.search(query, page)
|
||||
if source == Sources.OPENLIBRARY.value
|
||||
else hardcover.search(query, page)
|
||||
),
|
||||
MediaTypes.COMIC.value: lambda: comicvine.search(query, page),
|
||||
MediaTypes.BOARDGAME.value: lambda: bgg.search(query, page),
|
||||
}
|
||||
return search_handlers[media_type]()
|
||||
|
||||
@@ -161,9 +161,29 @@ def movie(media_id):
|
||||
url,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if response.get("belongs_to_collection", {}) is not None and (
|
||||
collection_id := response.get("belongs_to_collection", {}).get("id")
|
||||
):
|
||||
collection_response = services.api_request(
|
||||
Sources.TMDB.value,
|
||||
"GET",
|
||||
f"{base_url}/collection/{collection_id}",
|
||||
params={**base_params},
|
||||
)
|
||||
else:
|
||||
collection_response = {}
|
||||
except requests.exceptions.HTTPError as error:
|
||||
handle_error(error)
|
||||
|
||||
# Filter out collection items from recommendations, to avoid duplicates
|
||||
collection_items = get_collection(collection_response)
|
||||
collection_ids = [item["media_id"] for item in collection_items]
|
||||
recommended_items = response.get("recommendations", {}).get("results", [])
|
||||
filtered_recommendations = [
|
||||
item for item in recommended_items if item["id"] not in collection_ids
|
||||
]
|
||||
|
||||
data = {
|
||||
"media_id": media_id,
|
||||
"source": Sources.TMDB.value,
|
||||
@@ -186,8 +206,9 @@ def movie(media_id):
|
||||
"languages": get_languages(response["spoken_languages"]),
|
||||
},
|
||||
"related": {
|
||||
collection_response.get("name", "collection"): collection_items,
|
||||
"recommendations": get_related(
|
||||
response.get("recommendations", {}).get("results", [])[:15],
|
||||
filtered_recommendations[:15],
|
||||
MediaTypes.MOVIE.value,
|
||||
),
|
||||
},
|
||||
@@ -580,6 +601,30 @@ def get_related(related_medias, media_type, parent_response=None):
|
||||
return related
|
||||
|
||||
|
||||
def get_collection(collection_response):
|
||||
"""Format media collection list to match related media."""
|
||||
|
||||
def date_key(media):
|
||||
date = media.get("release_date", "")
|
||||
if date is None or date == "":
|
||||
# If release date is unknown, sort by title after known releases
|
||||
title = get_title(media)
|
||||
date = f"9999-99-99-{title}"
|
||||
return date
|
||||
|
||||
parts = sorted(collection_response.get("parts", []), key=date_key)
|
||||
return [
|
||||
{
|
||||
"source": Sources.TMDB.value,
|
||||
"media_type": MediaTypes.MOVIE.value,
|
||||
"image": get_image_url(media["poster_path"]),
|
||||
"media_id": media["id"],
|
||||
"title": get_title(media),
|
||||
}
|
||||
for media in parts
|
||||
]
|
||||
|
||||
|
||||
def process_episodes(season_metadata, episodes_in_db):
|
||||
"""Process the episodes for the selected season."""
|
||||
episodes_metadata = []
|
||||
|
||||
@@ -227,6 +227,7 @@ def status_color(status):
|
||||
"""Return the color associated with the status."""
|
||||
return config.get_status_text_color(status)
|
||||
|
||||
|
||||
@register.filter
|
||||
def status_background_color(status):
|
||||
"""Return the background color associated with the status."""
|
||||
@@ -236,11 +237,10 @@ def status_background_color(status):
|
||||
@register.filter
|
||||
def natural_day(datetime, user):
|
||||
"""Format date with natural language (Today, Tomorrow, etc.)."""
|
||||
# Get today's date in the current timezone
|
||||
today = timezone.localdate()
|
||||
|
||||
# Extract just the date part for comparison
|
||||
datetime_date = datetime.date()
|
||||
local_dt = timezone.localtime(datetime)
|
||||
datetime_date = local_dt.date()
|
||||
|
||||
# Calculate the difference in days
|
||||
diff = datetime_date - today
|
||||
|
||||
@@ -247,5 +247,3 @@ class EpisodeStatusTests(TestCase):
|
||||
|
||||
self.tv.refresh_from_db()
|
||||
self.assertEqual(self.tv.status, Status.PLANNING.value)
|
||||
|
||||
|
||||
|
||||
@@ -47,5 +47,3 @@ class ItemModel(TestCase):
|
||||
episode_number=2,
|
||||
)
|
||||
self.assertEqual(str(item), "Test Show S1E2")
|
||||
|
||||
|
||||
|
||||
@@ -71,5 +71,3 @@ class MediaModel(TestCase):
|
||||
Anime.objects.get(item__media_id="1", user=self.user).progress,
|
||||
26,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -816,5 +816,3 @@ class MediaManagerTests(TestCase):
|
||||
).first()
|
||||
|
||||
self.assertIsNone(non_existent)
|
||||
|
||||
|
||||
|
||||
@@ -320,5 +320,3 @@ class TVStatusTests(TestCase):
|
||||
|
||||
season1 = Season.objects.get(pk=self.season1.pk)
|
||||
self.assertEqual(season1.status, original_season1_status)
|
||||
|
||||
|
||||
|
||||
@@ -631,5 +631,3 @@ class Metadata(TestCase):
|
||||
hardcover.handle_error(error)
|
||||
|
||||
self.assertEqual(cm.exception.provider, Sources.HARDCOVER.value)
|
||||
|
||||
|
||||
|
||||
@@ -114,5 +114,3 @@ class Search(TestCase):
|
||||
"""Test the search method for books from Hardcover with no results."""
|
||||
response = hardcover.search("xjkqzptmvnsieurytowahdbfglc", 1)
|
||||
self.assertEqual(response["results"], [])
|
||||
|
||||
|
||||
|
||||
@@ -373,7 +373,9 @@ class ServicesTests(TestCase):
|
||||
def test_get_media_metadata_tmdb_episode_not_found(self, mock_episode):
|
||||
"""Test the get_media_metadata function for TMDB episodes that don't exist."""
|
||||
mock_response = type(
|
||||
"Response", (), {"status_code": 404, "text": "Episode not found"},
|
||||
"Response",
|
||||
(),
|
||||
{"status_code": 404, "text": "Episode not found"},
|
||||
)()
|
||||
mock_error = type("Error", (), {"response": mock_response})()
|
||||
mock_episode.side_effect = services.ProviderAPIError(
|
||||
@@ -394,7 +396,6 @@ class ServicesTests(TestCase):
|
||||
|
||||
mock_episode.assert_called_once_with("1396", 1, "3")
|
||||
|
||||
|
||||
@patch("app.providers.hardcover.book")
|
||||
def test_get_media_metadata_hardcover_book(self, mock_book):
|
||||
"""Test the get_media_metadata function for books from Hardcover."""
|
||||
|
||||
@@ -250,5 +250,3 @@ class DeleteMedia(TestCase):
|
||||
Episode.objects.filter(related_season__user=self.user).count(),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.test import TestCase
|
||||
@@ -230,5 +229,3 @@ class CreateEntryViewTests(TestCase):
|
||||
self.client.post(reverse("create_entry"), form_data)
|
||||
|
||||
self.assertEqual(Item.objects.count(), initial_count)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
@@ -132,5 +131,3 @@ class DeleteHistoryRecordViewTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
|
||||
@@ -166,5 +166,3 @@ class HomeViewTests(TestCase):
|
||||
response.context["media_list"]["total"],
|
||||
15,
|
||||
) # 15 TV shows total
|
||||
|
||||
|
||||
|
||||
@@ -114,5 +114,3 @@ class MediaDetailsViewTests(TestCase):
|
||||
Sources.TMDB.value,
|
||||
[1],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
@@ -103,5 +102,3 @@ class MediaListViewTests(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "app/components/media_table_items.html")
|
||||
|
||||
|
||||
|
||||
@@ -53,5 +53,3 @@ class MediaSearchViewTests(TestCase):
|
||||
1,
|
||||
Sources.TMDB.value,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -157,5 +157,3 @@ class ProgressEditAnime(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(Anime.objects.get(item__media_id="1").progress, 1)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
@@ -67,5 +66,3 @@ class StatisticsViewTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertTrue(date_is_none)
|
||||
|
||||
|
||||
|
||||
@@ -91,5 +91,3 @@ class TrackModalViewTests(TestCase):
|
||||
response.context["form"].initial["media_type"],
|
||||
MediaTypes.MOVIE.value,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -257,13 +257,16 @@ LOGGING = {
|
||||
"disable_existing_loggers": False,
|
||||
"loggers": {
|
||||
"requests_ratelimiter.requests_ratelimiter": {
|
||||
"level": "DEBUG" if DEBUG else "WARNING",
|
||||
"level": "WARNING",
|
||||
},
|
||||
"psycopg": {
|
||||
"level": "DEBUG" if DEBUG else "WARNING",
|
||||
},
|
||||
"urllib3": {
|
||||
"level": "DEBUG" if DEBUG else "WARNING",
|
||||
"level": "WARNING",
|
||||
},
|
||||
"celery.utils.functional": {
|
||||
"level": "WARNING",
|
||||
},
|
||||
},
|
||||
"formatters": {
|
||||
@@ -374,6 +377,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(
|
||||
@@ -483,7 +495,7 @@ SELECT2_THEME = "tailwindcss-4"
|
||||
|
||||
# Celery settings
|
||||
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_BROKER_URL = config("CELERY_REDIS_URL", default=REDIS_URL)
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
|
||||
if REDIS_PREFIX:
|
||||
|
||||
@@ -289,10 +289,14 @@ def process_anime_bulk(items, events_bulk):
|
||||
|
||||
if episodes:
|
||||
for episode in episodes:
|
||||
episode_datetime = datetime.fromtimestamp(
|
||||
episode["airingAt"],
|
||||
tz=ZoneInfo("UTC"),
|
||||
)
|
||||
# when schedule less than total episodes and end date is null
|
||||
if episode["airingAt"] is None:
|
||||
episode_datetime = datetime.min.replace(tzinfo=ZoneInfo("UTC"))
|
||||
else:
|
||||
episode_datetime = datetime.fromtimestamp(
|
||||
episode["airingAt"],
|
||||
tz=ZoneInfo("UTC"),
|
||||
)
|
||||
events_bulk.append(
|
||||
Event(
|
||||
item=item,
|
||||
@@ -302,7 +306,7 @@ def process_anime_bulk(items, events_bulk):
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Anime: %s (%s), not found in AniList",
|
||||
"Anime: %s (%s), not proccesed by AniList",
|
||||
item.title,
|
||||
item.media_id,
|
||||
)
|
||||
@@ -353,35 +357,52 @@ def get_anime_schedule_bulk(media_ids):
|
||||
total_episodes = media["episodes"]
|
||||
mal_id = str(media["idMal"])
|
||||
|
||||
# First check if we know the total episode count
|
||||
if total_episodes:
|
||||
if airing_schedule:
|
||||
# Filter out episodes beyond the total count
|
||||
original_length = len(airing_schedule)
|
||||
airing_schedule = [
|
||||
episode
|
||||
for episode in airing_schedule
|
||||
if episode["episode"] <= total_episodes
|
||||
]
|
||||
if not total_episodes:
|
||||
continue
|
||||
|
||||
# Log if any filtering occurred
|
||||
if original_length > len(airing_schedule):
|
||||
logger.info(
|
||||
"Filtered episodes for MAL ID %s - keep only %s episodes",
|
||||
mal_id,
|
||||
total_episodes,
|
||||
)
|
||||
if airing_schedule:
|
||||
# Filter out episodes beyond the total count
|
||||
original_length = len(airing_schedule)
|
||||
airing_schedule = [
|
||||
episode
|
||||
for episode in airing_schedule
|
||||
if episode["episode"] <= total_episodes
|
||||
]
|
||||
|
||||
# Add final episode if schedule is missing or incomplete
|
||||
if (
|
||||
not airing_schedule
|
||||
or airing_schedule[-1]["episode"] < total_episodes
|
||||
):
|
||||
end_date_timestamp = anilist_date_parser(media["endDate"])
|
||||
if end_date_timestamp:
|
||||
airing_schedule.append(
|
||||
{"episode": total_episodes, "airingAt": end_date_timestamp},
|
||||
)
|
||||
# Log if any filtering occurred
|
||||
if original_length > len(airing_schedule):
|
||||
logger.info(
|
||||
"Filtered episodes for MAL ID %s - keep only %s episodes",
|
||||
mal_id,
|
||||
total_episodes,
|
||||
)
|
||||
|
||||
# incomplete data from AniList
|
||||
if not airing_schedule or airing_schedule[-1]["episode"] < total_episodes:
|
||||
mal_metadata = services.get_media_metadata(
|
||||
media_type=MediaTypes.ANIME.value,
|
||||
media_id=mal_id,
|
||||
source=Sources.MAL.value,
|
||||
)
|
||||
mal_total_episodes = mal_metadata["max_progress"]
|
||||
if mal_total_episodes and mal_total_episodes > total_episodes:
|
||||
logger.info(
|
||||
"MAL ID %s - MAL has %s episodes, AniList has %s",
|
||||
mal_id,
|
||||
mal_total_episodes,
|
||||
total_episodes,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Adding final episode for MAL ID %s - Ep %s",
|
||||
mal_id,
|
||||
total_episodes,
|
||||
)
|
||||
end_date_timestamp = anilist_date_parser(media["endDate"])
|
||||
airing_schedule.append(
|
||||
{"episode": total_episodes, "airingAt": end_date_timestamp},
|
||||
)
|
||||
|
||||
# Store the processed schedule
|
||||
all_data[mal_id] = airing_schedule
|
||||
@@ -719,33 +740,32 @@ def process_other(item, events_bulk):
|
||||
return
|
||||
|
||||
date_key = config.get_date_key(item.media_type)
|
||||
content_number = metadata["max_progress"]
|
||||
|
||||
if date_key in metadata["details"] and metadata["details"][date_key]:
|
||||
try:
|
||||
if date_key in metadata["details"] and content_number:
|
||||
if metadata["details"][date_key]:
|
||||
content_datetime = date_parser(metadata["details"][date_key])
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
content_number = (
|
||||
None
|
||||
if item.media_type == MediaTypes.MOVIE.value
|
||||
else metadata["max_progress"]
|
||||
)
|
||||
events_bulk.append(
|
||||
Event(
|
||||
item=item,
|
||||
content_number=content_number,
|
||||
datetime=content_datetime,
|
||||
),
|
||||
)
|
||||
content_datetime = datetime.min.replace(tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
elif item.source == Sources.MANGAUPDATES.value and metadata["max_progress"]:
|
||||
if item.media_type == MediaTypes.MOVIE.value:
|
||||
content_number = None
|
||||
|
||||
events_bulk.append(
|
||||
Event(
|
||||
item=item,
|
||||
content_number=content_number,
|
||||
datetime=content_datetime,
|
||||
),
|
||||
)
|
||||
|
||||
elif item.source == Sources.MANGAUPDATES.value and content_number:
|
||||
# MangaUpdates doesn't have an end date, so use a placeholder
|
||||
content_datetime = datetime.min.replace(tzinfo=ZoneInfo("UTC"))
|
||||
events_bulk.append(
|
||||
Event(
|
||||
item=item,
|
||||
content_number=metadata["max_progress"],
|
||||
content_number=content_number,
|
||||
datetime=content_datetime,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -160,14 +160,12 @@ class ReloadCalendarTaskTests(TestCase):
|
||||
datetime=timezone.now(),
|
||||
),
|
||||
)
|
||||
mock_process_other.side_effect = (
|
||||
lambda item, events_bulk: events_bulk.append(
|
||||
Event(
|
||||
item=item,
|
||||
content_number=1,
|
||||
datetime=timezone.now(),
|
||||
),
|
||||
)
|
||||
mock_process_other.side_effect = lambda item, events_bulk: events_bulk.append(
|
||||
Event(
|
||||
item=item,
|
||||
content_number=1,
|
||||
datetime=timezone.now(),
|
||||
),
|
||||
)
|
||||
# Setup mock for process_anime_bulk to create events for anime items
|
||||
mock_process_anime_bulk.side_effect = lambda items, events_bulk: [
|
||||
@@ -214,14 +212,12 @@ class ReloadCalendarTaskTests(TestCase):
|
||||
def test_fetch_releases_specific_items(self, mock_process_other):
|
||||
"""Test fetch_releases with specific items to process."""
|
||||
# Setup mock
|
||||
mock_process_other.side_effect = (
|
||||
lambda item, events_bulk: events_bulk.append(
|
||||
Event(
|
||||
item=item,
|
||||
content_number=1,
|
||||
datetime=timezone.now(),
|
||||
),
|
||||
)
|
||||
mock_process_other.side_effect = lambda item, events_bulk: events_bulk.append(
|
||||
Event(
|
||||
item=item,
|
||||
content_number=1,
|
||||
datetime=timezone.now(),
|
||||
),
|
||||
)
|
||||
|
||||
# Call the task with specific items
|
||||
@@ -703,6 +699,7 @@ class ReloadCalendarTaskTests(TestCase):
|
||||
"""Test process_other with invalid date."""
|
||||
# Setup mock with invalid date
|
||||
mock_get_media_metadata.return_value = {
|
||||
"max_progress": None,
|
||||
"details": {
|
||||
"release_date": "invalid-date",
|
||||
},
|
||||
@@ -720,6 +717,7 @@ class ReloadCalendarTaskTests(TestCase):
|
||||
"""Test process_other with no date."""
|
||||
# Setup mock with no date
|
||||
mock_get_media_metadata.return_value = {
|
||||
"max_progress": None,
|
||||
"details": {},
|
||||
}
|
||||
|
||||
@@ -767,10 +765,13 @@ class ReloadCalendarTaskTests(TestCase):
|
||||
expected_date = datetime.datetime.fromtimestamp(870739200, tz=ZoneInfo("UTC"))
|
||||
self.assertEqual(events_bulk[0].datetime, expected_date)
|
||||
|
||||
@patch("events.calendar.services.get_media_metadata")
|
||||
@patch("events.calendar.services.api_request")
|
||||
def test_process_anime_bulk_no_matching_anime_anilist(self, mock_api_request):
|
||||
def test_process_anime_bulk_no_matching_anime_anilist(
|
||||
self, mock_api_request, mock_get_media_metadata
|
||||
):
|
||||
"""Test process_anime_bulk with no matching anime in Anilist."""
|
||||
# Setup mock with empty media list
|
||||
# Setup mock with empty media list (AniList returns nothing)
|
||||
mock_api_request.return_value = {
|
||||
"data": {
|
||||
"Page": {
|
||||
@@ -779,6 +780,11 @@ class ReloadCalendarTaskTests(TestCase):
|
||||
},
|
||||
},
|
||||
}
|
||||
# Mock the fallback to MAL via get_media_metadata
|
||||
mock_get_media_metadata.return_value = {
|
||||
"max_progress": 1,
|
||||
"details": {"end_date": "1997-08-05"},
|
||||
}
|
||||
|
||||
# Process anime items
|
||||
events_bulk = []
|
||||
|
||||
@@ -384,6 +384,7 @@ class SimklImporter:
|
||||
user=self.user,
|
||||
status=movie_status,
|
||||
score=movie["user_rating"],
|
||||
progress=1 if movie_status == Status.COMPLETED.value else 0,
|
||||
start_date=self._get_date(movie.get("last_watched_at")),
|
||||
end_date=self._get_date(movie.get("last_watched_at")),
|
||||
notes=movie["memo"]["text"] if movie["memo"] != {} else "",
|
||||
|
||||
@@ -397,6 +397,7 @@ class TraktImporter:
|
||||
user=self.user,
|
||||
end_date=watched_at,
|
||||
status=Status.COMPLETED.value,
|
||||
progress=1,
|
||||
)
|
||||
movie_obj._history_date = parse_datetime(watched_at)
|
||||
|
||||
@@ -605,7 +606,7 @@ class TraktImporter:
|
||||
msg = f"Error processing comment entry: {entry}"
|
||||
raise MediaImportUnexpectedError(msg) from e
|
||||
|
||||
def _process_generic_entry(self, entry, entry_type, attribute_updates=None):
|
||||
def _process_generic_entry(self, entry, entry_type, attribute_updates):
|
||||
"""Process a generic entry (watchlist, rating, or comment)."""
|
||||
if entry["type"] == "movie":
|
||||
logger.info(
|
||||
@@ -613,12 +614,18 @@ class TraktImporter:
|
||||
entry["movie"]["title"],
|
||||
entry_type,
|
||||
)
|
||||
# Movies with Completed status (from ratings and comments)
|
||||
# should have progress=1
|
||||
status = attribute_updates.get("status", Status.COMPLETED.value)
|
||||
if status == Status.COMPLETED.value:
|
||||
attribute_updates["progress"] = 1
|
||||
|
||||
self._process_media_item(
|
||||
entry,
|
||||
entry["movie"],
|
||||
MediaTypes.MOVIE.value,
|
||||
app.models.Movie,
|
||||
attribute_updates or {},
|
||||
attribute_updates,
|
||||
)
|
||||
elif entry["type"] == "show":
|
||||
logger.info(
|
||||
@@ -631,7 +638,7 @@ class TraktImporter:
|
||||
entry["show"],
|
||||
MediaTypes.TV.value,
|
||||
app.models.TV,
|
||||
attribute_updates or {},
|
||||
attribute_updates,
|
||||
)
|
||||
elif entry["type"] == "season":
|
||||
logger.info(
|
||||
@@ -645,7 +652,7 @@ class TraktImporter:
|
||||
entry["show"],
|
||||
MediaTypes.SEASON.value,
|
||||
app.models.Season,
|
||||
attribute_updates or {},
|
||||
attribute_updates,
|
||||
entry["season"]["number"],
|
||||
)
|
||||
|
||||
@@ -655,7 +662,7 @@ class TraktImporter:
|
||||
media_data,
|
||||
media_type,
|
||||
model_class,
|
||||
defaults=None,
|
||||
defaults,
|
||||
season_number=None,
|
||||
):
|
||||
"""Process media items for watchlist, ratings, and comments."""
|
||||
|
||||
@@ -204,4 +204,3 @@ class YamtrackImporter:
|
||||
|
||||
msg = f"Missing metadata for: {row}"
|
||||
raise MediaImportError(msg)
|
||||
|
||||
|
||||
@@ -101,5 +101,3 @@ class ImportAniList(TestCase):
|
||||
"new",
|
||||
"fhdsufdsu",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -45,5 +45,3 @@ class ImportGoodreads(TestCase):
|
||||
read_book = Book.objects.get(status=Status.IN_PROGRESS.value)
|
||||
self.assertEqual(read_book.status, Status.IN_PROGRESS.value)
|
||||
self.assertEqual(read_book.progress, 0)
|
||||
|
||||
|
||||
|
||||
@@ -171,5 +171,3 @@ class HelpersTest(TestCase):
|
||||
|
||||
schedule = CrontabSchedule.objects.first()
|
||||
self.assertEqual(schedule.day_of_week, "*/2")
|
||||
|
||||
|
||||
|
||||
@@ -39,5 +39,3 @@ class ImportHowLongToBeat(TestCase):
|
||||
game.history.first().history_date,
|
||||
datetime(2024, 2, 9, 15, 54, 48, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -137,5 +137,3 @@ class ImportIMDB(TestCase):
|
||||
self.assertEqual(imported_counts.get(MediaTypes.MOVIE.value, 0), 5)
|
||||
|
||||
self.assertIn("They were matched to the same TMDB ID 155", warnings)
|
||||
|
||||
|
||||
|
||||
@@ -114,5 +114,3 @@ class ImportKitsu(TestCase):
|
||||
self.assertEqual(instance.progress, 26)
|
||||
self.assertEqual(instance.status, Status.COMPLETED.value)
|
||||
self.assertEqual(instance.notes, "Great series!")
|
||||
|
||||
|
||||
|
||||
@@ -87,5 +87,3 @@ class ImportMAL(TestCase):
|
||||
self.user,
|
||||
"new",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ class ImportSimkl(TestCase):
|
||||
movie_obj = Movie.objects.get(item=movie_item)
|
||||
self.assertEqual(movie_obj.status, Status.COMPLETED.value)
|
||||
self.assertEqual(movie_obj.score, 9)
|
||||
self.assertEqual(movie_obj.progress, 1)
|
||||
|
||||
anime_item = Item.objects.get(media_type=MediaTypes.ANIME.value)
|
||||
self.assertEqual(anime_item.title, "Cowboy Bebop")
|
||||
@@ -229,5 +230,3 @@ class ImportSimkl(TestCase):
|
||||
|
||||
for episode in season1_episodes:
|
||||
self.assertIsNotNone(episode.end_date)
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ class ImportTrakt(TestCase):
|
||||
self.assertEqual(len(trakt_importer.bulk_media[MediaTypes.MOVIE.value]), 1)
|
||||
self.assertEqual(len(trakt_importer.media_instances[MediaTypes.MOVIE.value]), 1)
|
||||
|
||||
# Verify progress is set to 1 for completed movies
|
||||
movie_obj = trakt_importer.bulk_media[MediaTypes.MOVIE.value][0]
|
||||
self.assertEqual(movie_obj.progress, 1)
|
||||
|
||||
# Process the same movie again to test repeat handling
|
||||
trakt_importer.process_watched_movie(movie_entry)
|
||||
self.assertEqual(len(trakt_importer.bulk_media[MediaTypes.MOVIE.value]), 2)
|
||||
@@ -269,5 +273,3 @@ class ImportTrakt(TestCase):
|
||||
self.assertEqual(importer.username, "testuser")
|
||||
self.assertIsNone(importer.refresh_token)
|
||||
self.assertEqual(importer.mode, "new")
|
||||
|
||||
|
||||
|
||||
@@ -135,6 +135,57 @@ class ImportYamtrackPartials(TestCase):
|
||||
self.assertEqual(Book.objects.filter(user=self.user).count(), 3)
|
||||
self.assertEqual(Movie.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_season_episode_search_by_title(self):
|
||||
"""Test that seasons and episodes can be resolved by title (no media_id).
|
||||
|
||||
This test verifies the fix that allows searching for SEASON and EPISODE
|
||||
media types by searching for the parent TV show on TMDB. Before the fix,
|
||||
services.search() didn't handle SEASON/EPISODE types and would fail with:
|
||||
UnboundLocalError: cannot access local variable 'response'
|
||||
"""
|
||||
test_rows = [
|
||||
# Season with title only (no media_id)
|
||||
{
|
||||
"media_id": "",
|
||||
"source": "",
|
||||
"media_type": "season",
|
||||
"title": "Friends",
|
||||
"image": "",
|
||||
"season_number": "1",
|
||||
"episode_number": "",
|
||||
},
|
||||
# Episode with title only (no media_id)
|
||||
{
|
||||
"media_id": "",
|
||||
"source": "",
|
||||
"media_type": "episode",
|
||||
"title": "Friends",
|
||||
"image": "",
|
||||
"season_number": "1",
|
||||
"episode_number": "1",
|
||||
},
|
||||
]
|
||||
|
||||
importer = yamtrack.YamtrackImporter(None, self.user, "new")
|
||||
|
||||
for row in test_rows:
|
||||
original_row = row.copy()
|
||||
|
||||
importer._handle_missing_metadata(
|
||||
row,
|
||||
row["media_type"],
|
||||
int(row["season_number"]) if row["season_number"] else None,
|
||||
int(row["episode_number"]) if row["episode_number"] else None,
|
||||
)
|
||||
|
||||
# Verify media_id was resolved from TMDB search
|
||||
self.assertNotEqual(row["media_id"], original_row["media_id"])
|
||||
self.assertEqual(str(row["media_id"]), "1668") # Friends TV show ID
|
||||
self.assertEqual(row["source"], "tmdb")
|
||||
# Title and image should be populated from TMDB
|
||||
self.assertNotEqual(row["title"], "")
|
||||
self.assertNotEqual(row["image"], "")
|
||||
|
||||
def test_end_dates(self):
|
||||
"""Test end dates during import."""
|
||||
book = Book.objects.filter(user=self.user).first()
|
||||
@@ -158,5 +209,3 @@ class ImportYamtrackPartials(TestCase):
|
||||
books[2].end_date,
|
||||
datetime(2024, 3, 9, 0, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
--color-amber-700: oklch(55.5% 0.163 48.998);
|
||||
--color-yellow-400: oklch(85.2% 0.199 91.936);
|
||||
--color-yellow-600: oklch(68.1% 0.162 75.834);
|
||||
--color-lime-400: oklch(84.1% 0.238 128.85);
|
||||
--color-green-400: oklch(79.2% 0.209 151.711);
|
||||
--color-emerald-300: oklch(84.5% 0.143 164.978);
|
||||
--color-emerald-400: oklch(76.5% 0.177 163.223);
|
||||
@@ -45,6 +46,7 @@
|
||||
--color-fuchsia-700: oklch(51.8% 0.253 323.949);
|
||||
--color-sky-400: oklch(0.746 0.16 232.661);
|
||||
--color-slate-200: oklch(92.9% 0.013 255.508);
|
||||
--color-slate-400: oklch(70.4% 0.04 256.788);
|
||||
--color-gray-100: oklch(96.7% 0.003 264.542);
|
||||
--color-gray-200: oklch(92.8% 0.006 264.531);
|
||||
--color-gray-300: oklch(87.2% 0.01 258.338);
|
||||
@@ -1325,6 +1327,9 @@
|
||||
.bg-indigo-700 {
|
||||
background-color: var(--color-indigo-700);
|
||||
}
|
||||
.bg-lime-400 {
|
||||
background-color: var(--color-lime-400);
|
||||
}
|
||||
.bg-orange-400 {
|
||||
background-color: var(--color-orange-400);
|
||||
}
|
||||
@@ -1662,6 +1667,9 @@
|
||||
.text-indigo-600 {
|
||||
color: var(--color-indigo-600);
|
||||
}
|
||||
.text-lime-400 {
|
||||
color: var(--color-lime-400);
|
||||
}
|
||||
.text-orange-400 {
|
||||
color: var(--color-orange-400);
|
||||
}
|
||||
@@ -1743,9 +1751,16 @@
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.ring-\[1\.5px\] {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1.5px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.ring-indigo-500 {
|
||||
--tw-ring-color: var(--color-indigo-500);
|
||||
}
|
||||
.ring-slate-400 {
|
||||
--tw-ring-color: var(--color-slate-400);
|
||||
}
|
||||
.blur {
|
||||
--tw-blur: blur(8px);
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
@@ -2315,11 +2330,6 @@
|
||||
--tw-ring-color: var(--color-indigo-200);
|
||||
}
|
||||
}
|
||||
.focus\:ring-indigo-300 {
|
||||
&:focus {
|
||||
--tw-ring-color: var(--color-indigo-300);
|
||||
}
|
||||
}
|
||||
.focus\:ring-indigo-400 {
|
||||
&:focus {
|
||||
--tw-ring-color: var(--color-indigo-400);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% load app_tags %}
|
||||
|
||||
<div class="{% if secondary_color %}bg-[#39404b]{% else %}bg-[#2a2f35]{% endif %} rounded-lg overflow-hidden shadow-lg relative"
|
||||
<div class="{% if secondary_color %}bg-[#39404b]{% else %}bg-[#2a2f35]{% endif %} rounded-lg overflow-hidden shadow-lg relative {% if active %}ring-[1.5px] ring-slate-400{% endif %}"
|
||||
x-data="{ trackOpen: false, listsOpen: false, historyOpen: false }">
|
||||
<div class="relative">
|
||||
<a href="{{ item|media_url }}">
|
||||
|
||||
@@ -478,7 +478,12 @@
|
||||
<h2 class="text-xl font-bold mb-4">{{ name|no_underscore|title }}</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-4">
|
||||
{% for result in related_items %}
|
||||
{% include "app/components/media_card.html" with item=result.item title=result.item.season_title|default:result.item.title media=result.media %}
|
||||
{# Set active to highlight the current movie in a collection. Avoid TV media, since seasons have same ID as the parent show #}
|
||||
{% if media_type == MediaTypes.MOVIE.value %}
|
||||
{% include "app/components/media_card.html" with item=result.item title=result.item.season_title|default:result.item.title media=result.media active=media.media_id|str_equals:result.item.media_id %}
|
||||
{% else %}
|
||||
{% include "app/components/media_card.html" with item=result.item title=result.item.season_title|default:result.item.title media=result.media %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -105,16 +105,14 @@
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95">
|
||||
{% for value, label in sort_choices %}
|
||||
{% if media_type != MediaTypes.MOVIE.value or value != 'progress' %}
|
||||
<button hx-get="{% url 'medialist' media_type %}"
|
||||
{% if media_list %} hx-target="{{ layout_class }}" {% else %} hx-target="#empty_list" {% endif %}
|
||||
hx-include="#filter-form"
|
||||
hx-indicator="#loading-indicator"
|
||||
class="block w-full px-4 py-2 text-left text-sm transition-colors cursor-pointer"
|
||||
:class="sort === '{{ value }}' ? 'bg-indigo-600 text-white font-medium' : 'text-gray-300 hover:bg-[#454d5a] hover:text-white'"
|
||||
@click="sort = '{{ value }}'; open = false"
|
||||
type="button">{{ label }}</button>
|
||||
{% endif %}
|
||||
<button hx-get="{% url 'medialist' media_type %}"
|
||||
{% if media_list %} hx-target="{{ layout_class }}" {% else %} hx-target="#empty_list" {% endif %}
|
||||
hx-include="#filter-form"
|
||||
hx-indicator="#loading-indicator"
|
||||
class="block w-full px-4 py-2 text-left text-sm transition-colors cursor-pointer"
|
||||
:class="sort === '{{ value }}' ? 'bg-indigo-600 text-white font-medium' : 'text-gray-300 hover:bg-[#454d5a] hover:text-white'"
|
||||
@click="sort = '{{ value }}'; open = false"
|
||||
type="button">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,8 +33,11 @@ class CustomUserAdmin(UserAdmin):
|
||||
list_display = ("username", "is_staff", "is_active", "is_demo", "last_login")
|
||||
list_filter = ("is_staff", "is_active", "is_demo")
|
||||
|
||||
def get_fieldsets(self, _, __=None):
|
||||
def get_fieldsets(self, _, obj=None):
|
||||
"""Customize the fieldsets for the User model in the admin interface."""
|
||||
if not obj:
|
||||
return self.add_fieldsets
|
||||
|
||||
fieldsets = [
|
||||
(None, {"fields": ("username", "password")}),
|
||||
("Permissions", {"fields": ("is_staff", "is_active")}),
|
||||
|
||||
53
src/users/migrations/0043_add_boardgame_preferences.py
Normal file
53
src/users/migrations/0043_add_boardgame_preferences.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 14:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0053_add_boardgame'),
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('users', '0042_add_date_time_format_preferences'),
|
||||
]
|
||||
|
||||
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'),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-26 11:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0042_add_date_time_format_preferences'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='progress_bar',
|
||||
field=models.BooleanField(default=False, help_text='Show progress bar'),
|
||||
),
|
||||
]
|
||||
19
src/users/migrations/0044_user_progress_bar.py
Normal file
19
src/users/migrations/0044_user_progress_bar.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-26 11:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Migration for user progress bar setting."""
|
||||
|
||||
dependencies = [
|
||||
("users", "0043_add_boardgame_preferences"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="progress_bar",
|
||||
field=models.BooleanField(default=False, help_text="Show progress bar"),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase
|
||||
@@ -92,5 +91,3 @@ class DeleteImportScheduleTests(TestCase):
|
||||
self.assertIn("Import schedule not found", str(messages[0]))
|
||||
|
||||
self.assertTrue(PeriodicTask.objects.filter(id=self.other_task.id).exists())
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
@@ -43,5 +42,3 @@ class DemoProfileTests(TestCase):
|
||||
)
|
||||
self.assertTrue(auth.get_user(self.client).check_password("testpass123"))
|
||||
self.assertContains(response, "not allowed for the demo account")
|
||||
|
||||
|
||||
|
||||
@@ -217,5 +217,3 @@ class NotificationTests(TestCase):
|
||||
messages = list(get_messages(response.wsgi_request))
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertIn("Failed", str(messages[0]))
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
@@ -50,5 +49,3 @@ class Profile(TestCase):
|
||||
)
|
||||
self.assertTrue(auth.get_user(self.client).check_password("12345"))
|
||||
self.assertContains(response, "Your old password was entered incorrectly")
|
||||
|
||||
|
||||
|
||||
@@ -48,5 +48,3 @@ class RegenerateTokenTests(TestCase):
|
||||
messages = list(get_messages(response.wsgi_request))
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertIn("Token regenerated successfully", str(messages[0]))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user