diff --git a/kick/management/commands/import_kick_drops.py b/kick/management/commands/import_kick_drops.py index 4116350..4027227 100644 --- a/kick/management/commands/import_kick_drops.py +++ b/kick/management/commands/import_kick_drops.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING import httpx from django.core.management.base import BaseCommand +from django.db import transaction from pydantic import ValidationError from kick.models import KickCategory @@ -72,6 +73,11 @@ class Command(BaseCommand): default=KICK_DROPS_API_URL, 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 def _save_if_changed( @@ -88,6 +94,17 @@ class Command(BaseCommand): if 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( self, *_args: str, @@ -126,6 +143,12 @@ class Command(BaseCommand): return 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 ...") imported = 0 @@ -165,25 +188,27 @@ class Command(BaseCommand): logger.info("Created new organization: %s", org.kick_id) # Category - cat_data: KickCategorySchema = data.category - category_defaults: dict[str, KickFieldValue] = { - "name": cat_data.name, - "slug": cat_data.slug, - "image_url": cat_data.image_url, - } - category: KickCategory | None = KickCategory.objects.filter( - kick_id=cat_data.id, - ).first() - created = category is None - if category is None: - category = KickCategory.objects.create( + cat_data: KickCategorySchema | None = data.category + category: KickCategory | None = None + if cat_data is not None: + category_defaults: dict[str, KickFieldValue] = { + "name": cat_data.name, + "slug": cat_data.slug, + "image_url": cat_data.image_url, + } + category = KickCategory.objects.filter( kick_id=cat_data.id, - **category_defaults, - ) - else: - self._save_if_changed(category, category_defaults) - if created: - logger.info("Created new category: %s", category.kick_id) + ).first() + created = category is None + if category is None: + category = KickCategory.objects.create( + kick_id=cat_data.id, + **category_defaults, + ) + else: + self._save_if_changed(category, category_defaults) + if created: + logger.info("Created new category: %s", category.kick_id) # Campaign campaign_defaults: dict[str, KickFieldValue] = { @@ -260,17 +285,18 @@ class Command(BaseCommand): for reward_data in data.rewards: # Resolve reward's category (may differ from campaign category) - reward_category: KickCategory = category - if reward_data.category_id != cat_data.id: - reward_category = KickCategory.objects.filter( + reward_category: KickCategory | None = category + if reward_data.category_id > 0 and ( + 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, - ).first() or KickCategory.objects.create( - kick_id=reward_data.category_id, - name="", - slug="", - image_url="", + defaults={ + "name": "", + "slug": "", + "image_url": "", + }, ) - created = not reward_category.name and not reward_category.slug if created: logger.info("Created new category: %s", reward_category.kick_id) diff --git a/kick/schemas.py b/kick/schemas.py index 098721a..2b26114 100644 --- a/kick/schemas.py +++ b/kick/schemas.py @@ -102,7 +102,7 @@ class KickDropCampaignSchema(BaseModel): updated_at: datetime | None = None connect_url: str = "" url: str = "" - category: KickCategorySchema + category: KickCategorySchema | None = None organization: KickOrganizationSchema channels: list[KickChannelSchema] = Field(default_factory=list) rewards: list[KickRewardSchema] = Field(default_factory=list) diff --git a/kick/tests/test_kick.py b/kick/tests/test_kick.py index 7fb1c43..142fab4 100644 --- a/kick/tests/test_kick.py +++ b/kick/tests/test_kick.py @@ -1,5 +1,6 @@ import datetime import re +from copy import deepcopy from datetime import UTC from datetime import datetime as dt from datetime import timedelta @@ -194,6 +195,17 @@ class KickDropsResponseSchemaTest(TestCase): ) 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: """Extra fields in the API response cause a ValidationError.""" bad_payload: dict[str, str | list] = { @@ -452,7 +464,7 @@ class KickDropCampaignMergedRewardsTest(TestCase): class ImportKickDropsCommandTest(TestCase): """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.json.return_value = json_payload @@ -464,7 +476,7 @@ class ImportKickDropsCommandTest(TestCase): "kick.management.commands.import_kick_drops.httpx.get", 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() 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)) 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: """Running the import twice does not duplicate records.""" self._run_command(SINGLE_CAMPAIGN_JSON) @@ -501,6 +540,67 @@ class ImportKickDropsCommandTest(TestCase): assert KickOrganization.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: """HTTP error during fetch writes to stderr and does not crash.""" stdout = StringIO()