Fix Kick importer
All checks were successful
Deploy to Server / deploy (push) Successful in 25s

This commit is contained in:
Joakim Hellsén 2026-05-09 22:46:17 +02:00
commit 2993dc75b6
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
3 changed files with 156 additions and 30 deletions

View file

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
import httpx import httpx
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction
from pydantic import ValidationError from pydantic import ValidationError
from kick.models import KickCategory from kick.models import KickCategory
@ -72,6 +73,11 @@ class Command(BaseCommand):
default=KICK_DROPS_API_URL, default=KICK_DROPS_API_URL,
help="API endpoint to fetch (default: %(default)s).", help="API endpoint to fetch (default: %(default)s).",
) )
parser.add_argument(
"--reimport",
action="store_true",
help="Clear existing Kick import data before importing the fetched response.",
)
@staticmethod @staticmethod
def _save_if_changed( def _save_if_changed(
@ -88,6 +94,17 @@ class Command(BaseCommand):
if changed_fields: if changed_fields:
obj.save(update_fields=changed_fields) obj.save(update_fields=changed_fields)
@staticmethod
def _clear_existing_import_data() -> None:
"""Delete existing Kick import data before rebuilding from the API response."""
with transaction.atomic():
KickDropCampaign.objects.all().delete()
KickReward.objects.all().delete()
KickChannel.objects.all().delete()
KickUser.objects.all().delete()
KickOrganization.objects.all().delete()
KickCategory.objects.all().delete()
def handle( def handle(
self, self,
*_args: str, *_args: str,
@ -126,6 +143,12 @@ class Command(BaseCommand):
return return
campaigns: list[KickDropCampaignSchema] = drops_response.data campaigns: list[KickDropCampaignSchema] = drops_response.data
if options.get("reimport"):
self.stdout.write(
"Reimport requested. Clearing existing Kick import data ...",
)
self._clear_existing_import_data()
self.stdout.write(f"Found {len(campaigns)} campaign(s). Importing ...") self.stdout.write(f"Found {len(campaigns)} campaign(s). Importing ...")
imported = 0 imported = 0
@ -165,25 +188,27 @@ class Command(BaseCommand):
logger.info("Created new organization: %s", org.kick_id) logger.info("Created new organization: %s", org.kick_id)
# Category # Category
cat_data: KickCategorySchema = data.category cat_data: KickCategorySchema | None = data.category
category_defaults: dict[str, KickFieldValue] = { category: KickCategory | None = None
"name": cat_data.name, if cat_data is not None:
"slug": cat_data.slug, category_defaults: dict[str, KickFieldValue] = {
"image_url": cat_data.image_url, "name": cat_data.name,
} "slug": cat_data.slug,
category: KickCategory | None = KickCategory.objects.filter( "image_url": cat_data.image_url,
kick_id=cat_data.id, }
).first() category = KickCategory.objects.filter(
created = category is None
if category is None:
category = KickCategory.objects.create(
kick_id=cat_data.id, kick_id=cat_data.id,
**category_defaults, ).first()
) created = category is None
else: if category is None:
self._save_if_changed(category, category_defaults) category = KickCategory.objects.create(
if created: kick_id=cat_data.id,
logger.info("Created new category: %s", category.kick_id) **category_defaults,
)
else:
self._save_if_changed(category, category_defaults)
if created:
logger.info("Created new category: %s", category.kick_id)
# Campaign # Campaign
campaign_defaults: dict[str, KickFieldValue] = { campaign_defaults: dict[str, KickFieldValue] = {
@ -260,17 +285,18 @@ class Command(BaseCommand):
for reward_data in data.rewards: for reward_data in data.rewards:
# Resolve reward's category (may differ from campaign category) # Resolve reward's category (may differ from campaign category)
reward_category: KickCategory = category reward_category: KickCategory | None = category
if reward_data.category_id != cat_data.id: if reward_data.category_id > 0 and (
reward_category = KickCategory.objects.filter( cat_data is None or reward_data.category_id != cat_data.id
):
reward_category, created = KickCategory.objects.get_or_create(
kick_id=reward_data.category_id, kick_id=reward_data.category_id,
).first() or KickCategory.objects.create( defaults={
kick_id=reward_data.category_id, "name": "",
name="", "slug": "",
slug="", "image_url": "",
image_url="", },
) )
created = not reward_category.name and not reward_category.slug
if created: if created:
logger.info("Created new category: %s", reward_category.kick_id) logger.info("Created new category: %s", reward_category.kick_id)

View file

@ -102,7 +102,7 @@ class KickDropCampaignSchema(BaseModel):
updated_at: datetime | None = None updated_at: datetime | None = None
connect_url: str = "" connect_url: str = ""
url: str = "" url: str = ""
category: KickCategorySchema category: KickCategorySchema | None = None
organization: KickOrganizationSchema organization: KickOrganizationSchema
channels: list[KickChannelSchema] = Field(default_factory=list) channels: list[KickChannelSchema] = Field(default_factory=list)
rewards: list[KickRewardSchema] = Field(default_factory=list) rewards: list[KickRewardSchema] = Field(default_factory=list)

View file

@ -1,5 +1,6 @@
import datetime import datetime
import re import re
from copy import deepcopy
from datetime import UTC from datetime import UTC
from datetime import datetime as dt from datetime import datetime as dt
from datetime import timedelta from datetime import timedelta
@ -194,6 +195,17 @@ class KickDropsResponseSchemaTest(TestCase):
) )
assert result.data[0].channels == [] assert result.data[0].channels == []
def test_missing_campaign_category(self) -> None:
"""Schema accepts campaigns where Kick omits the category object."""
payload: dict[str, Any] = deepcopy(SINGLE_CAMPAIGN_JSON)
del payload["data"][0]["category"]
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
payload,
)
assert result.data[0].category is None
def test_extra_fields_rejected(self) -> None: def test_extra_fields_rejected(self) -> None:
"""Extra fields in the API response cause a ValidationError.""" """Extra fields in the API response cause a ValidationError."""
bad_payload: dict[str, str | list] = { bad_payload: dict[str, str | list] = {
@ -452,7 +464,7 @@ class KickDropCampaignMergedRewardsTest(TestCase):
class ImportKickDropsCommandTest(TestCase): class ImportKickDropsCommandTest(TestCase):
"""Tests for the import_kick_drops management command.""" """Tests for the import_kick_drops management command."""
def _run_command(self, json_payload: dict) -> tuple[str, str]: def _run_command(self, json_payload: dict, **options: Any) -> tuple[str, str]: # noqa: ANN401
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = json_payload mock_response.json.return_value = json_payload
@ -464,7 +476,7 @@ class ImportKickDropsCommandTest(TestCase):
"kick.management.commands.import_kick_drops.httpx.get", "kick.management.commands.import_kick_drops.httpx.get",
return_value=mock_response, return_value=mock_response,
): ):
call_command("import_kick_drops", stdout=stdout, stderr=stderr) call_command("import_kick_drops", stdout=stdout, stderr=stderr, **options)
return stdout.getvalue(), stderr.getvalue() return stdout.getvalue(), stderr.getvalue()
def test_imports_single_campaign(self) -> None: def test_imports_single_campaign(self) -> None:
@ -493,6 +505,33 @@ class ImportKickDropsCommandTest(TestCase):
slugs: set[str] = set(campaign.channels.values_list("slug", flat=True)) slugs: set[str] = set(campaign.channels.values_list("slug", flat=True))
assert slugs == {"ricoy", "dilanzito"} assert slugs == {"ricoy", "dilanzito"}
def test_imports_campaign_without_category(self) -> None:
"""Command imports campaigns where Kick omits the category object."""
payload: dict[str, Any] = deepcopy(SINGLE_CAMPAIGN_JSON)
del payload["data"][0]["category"]
self._run_command(payload)
campaign: KickDropCampaign = KickDropCampaign.objects.get()
reward: KickReward = KickReward.objects.get()
assert campaign.category is None
assert reward.category is not None
assert reward.category.kick_id == 53
def test_imports_campaign_without_category_and_zero_reward_category(self) -> None:
"""Command treats reward category_id=0 as no category."""
payload: dict[str, Any] = deepcopy(SINGLE_CAMPAIGN_JSON)
del payload["data"][0]["category"]
payload["data"][0]["rewards"][0]["category_id"] = 0
self._run_command(payload)
campaign: KickDropCampaign = KickDropCampaign.objects.get()
reward: KickReward = KickReward.objects.get()
assert campaign.category is None
assert reward.category is None
assert KickCategory.objects.count() == 0
def test_import_is_idempotent(self) -> None: def test_import_is_idempotent(self) -> None:
"""Running the import twice does not duplicate records.""" """Running the import twice does not duplicate records."""
self._run_command(SINGLE_CAMPAIGN_JSON) self._run_command(SINGLE_CAMPAIGN_JSON)
@ -501,6 +540,67 @@ class ImportKickDropsCommandTest(TestCase):
assert KickOrganization.objects.count() == 1 assert KickOrganization.objects.count() == 1
assert KickReward.objects.count() == 1 assert KickReward.objects.count() == 1
def test_reimport_clears_existing_kick_data_before_import(self) -> None:
"""Reimport mode clears existing Kick records before importing."""
org: KickOrganization = KickOrganization.objects.create(
kick_id="old-org",
name="Old Org",
)
category: KickCategory = KickCategory.objects.create(
kick_id=0,
name="",
)
user: KickUser = KickUser.objects.create(
kick_id=999,
username="olduser",
)
channel: KickChannel = KickChannel.objects.create(
kick_id=999,
slug="oldchannel",
user=user,
)
campaign: KickDropCampaign = KickDropCampaign.objects.create(
kick_id="old-campaign",
name="Old Campaign",
organization=org,
category=category,
rule_id=1,
rule_name="Watch to redeem",
)
campaign.channels.add(channel)
KickReward.objects.create(
kick_id="old-reward",
name="Old Reward",
campaign=campaign,
category=category,
organization=org,
)
stdout, _ = self._run_command(SINGLE_CAMPAIGN_JSON, reimport=True)
assert "Reimport requested" in stdout
assert set(KickDropCampaign.objects.values_list("kick_id", flat=True)) == {
"01KKBNEM8TZG7ASRG42TK7RKRB",
}
assert set(KickCategory.objects.values_list("kick_id", flat=True)) == {53}
assert not KickOrganization.objects.filter(kick_id="old-org").exists()
assert not KickUser.objects.filter(kick_id=999).exists()
assert not KickChannel.objects.filter(kick_id=999).exists()
assert not KickReward.objects.filter(kick_id="old-reward").exists()
def test_reimport_does_not_clear_existing_data_when_validation_fails(self) -> None:
"""Reimport mode validates the fetched response before deleting records."""
KickOrganization.objects.create(
kick_id="kept-org",
name="Kept Org",
)
stdout, stderr = self._run_command({"totally": "wrong"}, reimport=True)
assert "Reimport requested" not in stdout
assert "validation failed" in stderr
assert KickOrganization.objects.filter(kick_id="kept-org").exists()
def test_http_error_is_handled_gracefully(self) -> None: def test_http_error_is_handled_gracefully(self) -> None:
"""HTTP error during fetch writes to stderr and does not crash.""" """HTTP error during fetch writes to stderr and does not crash."""
stdout = StringIO() stdout = StringIO()