fix untracked old seasons created from migration #1348

This commit is contained in:
FuzzyGrim
2026-04-15 00:13:38 +02:00
parent 45e116ac75
commit 82a1f3010a
4 changed files with 197 additions and 7 deletions

View File

@@ -1,5 +1,8 @@
# Generated by Django 5.2.12 on 2026-04-05 17:30
from datetime import datetime
from zoneinfo import ZoneInfo
from django.db import migrations
MEDIA_TYPE_TV = "tv"
@@ -7,6 +10,7 @@ MEDIA_TYPE_SEASON = "season"
STATUS_COMPLETED = "Completed"
STATUS_IN_PROGRESS = "In progress"
STATUS_PLANNING = "Planning"
MIGRATION_CREATED_AT = datetime(2026, 4, 5, 15, 30, tzinfo=ZoneInfo("UTC"))
def reopen_completed_tv_with_new_seasons(apps, _):
@@ -32,13 +36,14 @@ def reopen_completed_tv_with_new_seasons(apps, _):
existing_seasons.values_list("item__season_number", flat=True),
)
# Only genuinely upcoming seasons should reopen a completed show.
missing_season_items = list(
Item.objects.filter(
media_id=tv.item.media_id,
source=tv.item.source,
media_type=MEDIA_TYPE_SEASON,
season_number__gt=0,
event__isnull=False,
event__datetime__gte=MIGRATION_CREATED_AT,
)
.exclude(season_number__in=existing_season_numbers)
.distinct()

View File

@@ -0,0 +1,104 @@
# Generated by Django 5.2.12 on 2026-04-14 12:00
from collections import defaultdict
from datetime import datetime
from zoneinfo import ZoneInfo
from django.db import migrations, transaction
from django.db.models import Exists, OuterRef
from django.utils import timezone
MEDIA_TYPE_TV = "tv"
STATUS_COMPLETED = "Completed"
STATUS_IN_PROGRESS = "In progress"
STATUS_PLANNING = "Planning"
BUGGY_MIGRATION_START = datetime(2026, 4, 6, 20, 0, tzinfo=ZoneInfo("UTC"))
BUGGY_MIGRATION_END = datetime(2026, 4, 14, 23, 0, tzinfo=ZoneInfo("UTC"))
def fix_reopened_completed_tv_seasons(apps, _):
"""Remove bad planning seasons created by 0057 and restore affected TVs."""
Event = apps.get_model("events", "Event")
HistoricalSeason = apps.get_model("app", "HistoricalSeason")
HistoricalTV = apps.get_model("app", "HistoricalTV")
Season = apps.get_model("app", "Season")
TV = apps.get_model("app", "TV")
now = timezone.now()
season_history = HistoricalSeason.objects.filter(id=OuterRef("id"))
future_events = Event.objects.filter(
item_id=OuterRef("item_id"),
datetime__gte=now,
)
# 0057 created plain Planning seasons without history. If those seasons also
# have no future events and were created during the v0.25.1 release window
# (April 6, 2026 22:00 UTC+2 to April 15, 2026 01:00 UTC+2), they are very
# likely the bogus backfill we want to remove rather than a legitimate
# upcoming season.
bogus_seasons = list(
Season.objects
.filter(
status=STATUS_PLANNING,
related_tv__status=STATUS_IN_PROGRESS,
related_tv__item__media_type=MEDIA_TYPE_TV,
created_at__gte=BUGGY_MIGRATION_START,
created_at__lte=BUGGY_MIGRATION_END,
)
.annotate(
has_history=Exists(season_history),
has_future_events=Exists(future_events),
)
.filter(
has_history=False,
has_future_events=False,
)
.select_related("related_tv", "user")
)
if not bogus_seasons:
return
seasons_by_tv_id = defaultdict(list)
for season in bogus_seasons:
seasons_by_tv_id[season.related_tv_id].append(season)
with transaction.atomic():
Season.objects.filter(
id__in=[season.id for season in bogus_seasons],
).delete()
for tv_id, tv_seasons in seasons_by_tv_id.items():
tv = tv_seasons[0].related_tv
latest_history = (
HistoricalTV.objects
.filter(id=tv.id)
.order_by("-history_date", "-history_id")
.first()
)
if not latest_history or latest_history.status != STATUS_COMPLETED:
continue
# Users often had the parent TV marked completed without tracking
# seasons individually, so once the bogus 0057 seasons are gone we
# can safely restore the parent TV to completed.
if tv.status != STATUS_IN_PROGRESS:
continue
tv.status = STATUS_COMPLETED
TV.objects.filter(pk=tv.pk).update(status=tv.status)
class Migration(migrations.Migration):
"""Repair bad planning seasons created for already completed shows."""
dependencies = [
("events", "0014_delete_empty_content_number_comic_events"),
("app", "0059_usermessage_user_shown_idx"),
]
operations = [
migrations.RunPython(
fix_reopened_completed_tv_seasons,
reverse_code=migrations.RunPython.noop,
),
]

View File

@@ -6,6 +6,7 @@ import requests
from django.core.cache import cache
from django.db import transaction
from django.db.models import Prefetch
from django.utils import timezone
from simple_history.utils import bulk_create_with_history, bulk_update_with_history
from app.models import TV, Item, MediaTypes, Season, Status
@@ -33,7 +34,11 @@ def process_tv(tv_item, events_bulk):
seasons_to_process,
events_bulk,
)
reopen_completed_tv_with_new_seasons(tv_item, processed_season_items)
reopen_completed_tv_with_new_seasons(
tv_item,
processed_season_items,
events_bulk,
)
except services.ProviderAPIError:
logger.warning(
@@ -126,12 +131,15 @@ def process_tv_seasons(tv_item, seasons_to_process, events_bulk):
return processed_season_items
def reopen_completed_tv_with_new_seasons(tv_item, season_items):
def reopen_completed_tv_with_new_seasons(tv_item, season_items, events_bulk):
"""Reopen completed TV entries and create planning seasons when needed."""
season_item_map = {
season_item.season_number: season_item
eligible_season_items = [
season_item
for season_item in season_items
if season_item.season_number and season_item.season_number > 0
]
season_item_map = {
season_item.season_number: season_item for season_item in eligible_season_items
}
if not season_item_map:
logger.info(
@@ -140,7 +148,22 @@ def reopen_completed_tv_with_new_seasons(tv_item, season_items):
)
return
sorted_season_numbers = sorted(season_item_map)
# Only future season events should reopen a completed show; processed
# past-only seasons can exist when local season tracking was incomplete.
future_season_numbers = {
event.item.season_number
for event in events_bulk
if event.item in eligible_season_items and event.datetime >= timezone.now()
}
if not future_season_numbers:
logger.info(
"%s - Processed seasons have no future events; "
"skipping completed-TV reopening",
tv_item,
)
return
sorted_season_numbers = sorted(future_season_numbers)
completed_tvs = list(
TV.objects.filter(
item=tv_item,

View File

@@ -126,7 +126,7 @@ class CalendarTVTests(CalendarFixturesMixin, TestCase):
"image": "http://example.com/season2.jpg",
"season_number": 2,
"episodes": [
{"episode_number": 1, "air_date": "2026-01-20"},
{"episode_number": 1, "air_date": "2027-01-20"},
],
"tvdb_id": "81189",
},
@@ -149,6 +149,64 @@ class CalendarTVTests(CalendarFixturesMixin, TestCase):
self.assertEqual(season_two.status, Status.PLANNING.value)
self.assertEqual(len(events_bulk), 1)
@patch("events.calendar.tv.get_tvmaze_episode_map")
@patch("events.calendar.tv.tmdb.tv_with_seasons")
@patch("events.calendar.tv.tmdb.tv")
def test_process_tv_does_not_reopen_completed_show_for_past_only_season(
self,
mock_tv,
mock_tv_with_seasons,
mock_get_tvmaze_episode_map,
):
"""Past-only seasons should not reopen a completed TV entry."""
TV.objects.filter(item=self.tv_item, user=self.user).update(
status=Status.COMPLETED.value,
)
Season.objects.filter(item=self.season_item, user=self.user).update(
status=Status.COMPLETED.value,
)
Event.objects.create(
item=self.season_item,
content_number=1,
datetime=date_parser("2008-01-20"),
)
mock_tv.return_value = {
"related": {
"seasons": [
{"season_number": 1, "episodes": [1]},
{"season_number": 2, "episodes": [1]},
],
},
"next_episode_season": 2,
}
mock_tv_with_seasons.return_value = {
"season/2": {
"image": "http://example.com/season2.jpg",
"season_number": 2,
"episodes": [
{"episode_number": 1, "air_date": "2010-01-20"},
],
"tvdb_id": "81189",
},
}
mock_get_tvmaze_episode_map.return_value = {}
events_bulk = []
process_tv(self.tv_item, events_bulk)
tv = TV.objects.get(item=self.tv_item, user=self.user)
self.assertEqual(tv.status, Status.COMPLETED.value)
self.assertFalse(
Season.objects.filter(
item__media_id=self.tv_item.media_id,
item__source=self.tv_item.source,
item__season_number=2,
user=self.user,
).exists(),
)
self.assertEqual(len(events_bulk), 1)
@patch("events.calendar.tv.services.api_request")
def test_get_tvmaze_episode_map(self, mock_api_request):
"""Test get_tvmaze_episode_map function."""