Add is_fully_imported field to DropCampaign and KickDropCampaign models; update views and commands to filter by this field
All checks were successful
Deploy to Server / deploy (push) Successful in 18s

This commit is contained in:
Joakim Hellsén 2026-03-20 00:55:32 +01:00
commit a8747791c0
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
12 changed files with 242 additions and 13 deletions

View file

@ -1,6 +1,7 @@
import json
import os
import sys
from collections.abc import Mapping
from datetime import UTC
from datetime import datetime
from pathlib import Path
@ -15,6 +16,7 @@ from colorama import Fore
from colorama import Style
from colorama import init as colorama_init
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError
from pydantic import ValidationError
@ -35,6 +37,8 @@ from twitch.utils import normalize_twitch_box_art_url
from twitch.utils import parse_date
if TYPE_CHECKING:
from collections.abc import Mapping
from django.core.management.base import CommandParser
from django.db.models import Model
from json_repair import JSONReturnType
@ -545,7 +549,7 @@ class Command(BaseCommand):
return valid_responses, broken_dir
def _save_if_changed(self, obj: Model, defaults: dict[str, object]) -> bool:
def _save_if_changed(self, obj: Model, defaults: Mapping[str, object]) -> bool:
"""Save the model instance only when data actually changed.
This prevents unnecessary updates and avoids touching fields like
@ -618,7 +622,7 @@ class Command(BaseCommand):
if campaign_org_obj:
owner_orgs.add(campaign_org_obj)
defaults: dict[str, str] = {
defaults: dict[str, object] = {
"display_name": game_data.display_name or (game_data.name or ""),
"name": game_data.name or "",
"slug": game_data.slug or "",
@ -681,7 +685,7 @@ class Command(BaseCommand):
# Use name as display_name fallback if displayName is None
display_name: str = channel_info.display_name or channel_info.name
defaults: dict[str, str] = {
defaults: dict[str, object] = {
"name": channel_info.name,
"display_name": display_name,
}
@ -698,7 +702,7 @@ class Command(BaseCommand):
return channel_obj
def process_responses(
def process_responses( # noqa: PLR0915
self,
responses: list[dict[str, Any]],
file_path: Path,
@ -778,7 +782,7 @@ class Command(BaseCommand):
raise ValueError(msg)
continue
defaults: dict[str, str | datetime | Game | bool] = {
defaults: dict[str, object] = {
"name": drop_campaign.name,
"description": drop_campaign.description,
"image_url": drop_campaign.image_url,
@ -800,6 +804,16 @@ class Command(BaseCommand):
f"{Fore.GREEN}{Style.RESET_ALL} Created new campaign: {drop_campaign.name}",
)
# Always run additional commands after import
call_command("download_box_art")
call_command("download_campaign_images")
call_command("convert_images_to_modern_formats")
# After all downloads and processing, mark as fully imported
if campaign_obj:
campaign_obj.is_fully_imported = True
campaign_obj.save(update_fields=["is_fully_imported"])
action: Literal["Imported new", "Updated"] = (
"Imported new" if created else "Updated"
)
@ -1026,7 +1040,7 @@ class Command(BaseCommand):
f"{Fore.YELLOW}{Style.RESET_ALL} Game not found for reward campaign: {game_id}",
)
defaults: dict[str, str | datetime | Game | bool | None] = {
defaults: dict[str, object] = {
"name": reward_campaign.name,
"brand": reward_campaign.brand,
"starts_at": starts_at_dt,
@ -1084,6 +1098,7 @@ class Command(BaseCommand):
else:
msg: str = f"Path does not exist: {input_path}"
raise CommandError(msg)
except KeyboardInterrupt:
tqdm.write(self.style.WARNING("\n\nInterrupted by user!"))
tqdm.write(self.style.WARNING("Shutting down gracefully..."))

View file

@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-19 19:20
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
"""Add a new field `is_fully_imported` to the `DropCampaign` model to track whether all related images and formats have been imported and are ready for display. This field will help us filter out campaigns that are not yet fully imported in our views and APIs, ensuring that only complete campaigns are shown to users."""
dependencies = [
("twitch", "0014_dropcampaign_image_mime_type_and_more"),
]
operations = [
migrations.AddField(
model_name="dropcampaign",
name="is_fully_imported",
field=models.BooleanField(
default=False,
help_text="True if all images and formats are imported and ready for display.",
),
),
]

View file

@ -0,0 +1,25 @@
from django.db import migrations
def mark_all_drops_fully_imported(apps, schema_editor) -> None: # noqa: ANN001, ARG001
"""Marks all existing DropCampaigns as fully imported.
This was needed to ensure that the Twitch API view only returns campaigns that are ready for display.
"""
DropCampaign = apps.get_model("twitch", "DropCampaign")
DropCampaign.objects.all().update(is_fully_imported=True)
class Migration(migrations.Migration):
"""Marks all existing DropCampaigns as fully imported.
This was needed to ensure that the Twitch API view only returns campaigns that are ready for display.
"""
dependencies = [
("twitch", "0015_dropcampaign_is_fully_imported"),
]
operations = [
migrations.RunPython(mark_all_drops_fully_imported),
]

View file

@ -429,6 +429,10 @@ class DropCampaign(auto_prefetch.Model):
auto_now=True,
help_text="Timestamp when this campaign record was last updated.",
)
is_fully_imported = models.BooleanField(
default=False,
help_text="True if all images and formats are imported and ready for display.",
)
class Meta(auto_prefetch.Model.Meta):
ordering = ["-start_at"]

View file

@ -566,6 +566,7 @@ class TestChannelListView:
start_at=now - timedelta(days=10),
end_at=now + timedelta(days=10),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
for i in range(150)
]
@ -609,6 +610,7 @@ class TestChannelListView:
start_at=now - timedelta(days=5),
end_at=now + timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Create upcoming campaign
@ -619,6 +621,7 @@ class TestChannelListView:
start_at=now + timedelta(days=5),
end_at=now + timedelta(days=10),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Create expired campaign
@ -629,6 +632,7 @@ class TestChannelListView:
start_at=now - timedelta(days=10),
end_at=now - timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Test active filter
@ -648,7 +652,7 @@ class TestChannelListView:
name="Game",
display_name="Game",
)
now: datetime.datetime = timezone.now()
now = timezone.now()
# Create active campaign
DropCampaign.objects.create(
@ -658,16 +662,18 @@ class TestChannelListView:
start_at=now - timedelta(days=5),
end_at=now + timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Create upcoming campaign
_upcoming_campaign: DropCampaign = DropCampaign.objects.create(
DropCampaign.objects.create(
twitch_id="upcoming",
name="Upcoming Campaign",
game=game,
start_at=now + timedelta(days=5),
start_at=now + timedelta(days=1),
end_at=now + timedelta(days=10),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Create expired campaign
@ -678,6 +684,7 @@ class TestChannelListView:
start_at=now - timedelta(days=10),
end_at=now - timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Test upcoming filter
@ -707,6 +714,7 @@ class TestChannelListView:
start_at=now - timedelta(days=5),
end_at=now + timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Create upcoming campaign
@ -717,6 +725,7 @@ class TestChannelListView:
start_at=now + timedelta(days=5),
end_at=now + timedelta(days=10),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Create expired campaign
@ -727,6 +736,7 @@ class TestChannelListView:
start_at=now - timedelta(days=10),
end_at=now - timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Test expired filter
@ -761,6 +771,7 @@ class TestChannelListView:
start_at=now - timedelta(days=5),
end_at=now + timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
DropCampaign.objects.create(
twitch_id="c2",
@ -769,6 +780,7 @@ class TestChannelListView:
start_at=now - timedelta(days=5),
end_at=now + timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Create campaign for game 2
@ -779,6 +791,7 @@ class TestChannelListView:
start_at=now - timedelta(days=5),
end_at=now + timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
# Test filtering by game1
@ -819,6 +832,7 @@ class TestChannelListView:
start_at=now - timedelta(days=5),
end_at=now + timedelta(days=5),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
for i in range(150)
]
@ -1337,6 +1351,7 @@ class TestSitemapView:
operation_names=["DropCampaignDetails"],
start_at=now - datetime.timedelta(days=1),
end_at=now + datetime.timedelta(days=1),
is_fully_imported=True,
)
inactive_campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="camp2",
@ -1346,6 +1361,7 @@ class TestSitemapView:
operation_names=["DropCampaignDetails"],
start_at=now - datetime.timedelta(days=10),
end_at=now - datetime.timedelta(days=5),
is_fully_imported=True,
)
kick_org: KickOrganization = KickOrganization.objects.create(
@ -1364,6 +1380,7 @@ class TestSitemapView:
category=kick_cat,
starts_at=now - datetime.timedelta(days=1),
ends_at=now + datetime.timedelta(days=1),
is_fully_imported=True,
)
kick_inactive: KickDropCampaign = KickDropCampaign.objects.create(
kick_id="kcamp2",
@ -1372,6 +1389,7 @@ class TestSitemapView:
category=kick_cat,
starts_at=now - datetime.timedelta(days=10),
ends_at=now - datetime.timedelta(days=5),
is_fully_imported=True,
)
badge: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="badge1")
@ -1707,6 +1725,7 @@ class TestSEOPaginationLinks:
description="Desc",
game=game,
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
response = client.get(reverse("twitch:campaign_list"))
@ -1731,6 +1750,7 @@ class TestSEOPaginationLinks:
description="Desc",
game=game,
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
response = client.get(reverse("twitch:campaign_list"))

View file

@ -430,7 +430,9 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
game_filter: str | None = request.GET.get("game")
status_filter: str | None = request.GET.get("status")
per_page: int = 100
queryset: QuerySet[DropCampaign] = DropCampaign.objects.all()
queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(
is_fully_imported=True,
)
if game_filter:
queryset = queryset.filter(game__twitch_id=game_filter)