This commit is contained in:
parent
08a7fa2693
commit
2993dc75b6
3 changed files with 156 additions and 30 deletions
|
|
@ -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,13 +188,15 @@ 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: KickCategory | None = None
|
||||||
|
if cat_data is not None:
|
||||||
category_defaults: dict[str, KickFieldValue] = {
|
category_defaults: dict[str, KickFieldValue] = {
|
||||||
"name": cat_data.name,
|
"name": cat_data.name,
|
||||||
"slug": cat_data.slug,
|
"slug": cat_data.slug,
|
||||||
"image_url": cat_data.image_url,
|
"image_url": cat_data.image_url,
|
||||||
}
|
}
|
||||||
category: KickCategory | None = KickCategory.objects.filter(
|
category = KickCategory.objects.filter(
|
||||||
kick_id=cat_data.id,
|
kick_id=cat_data.id,
|
||||||
).first()
|
).first()
|
||||||
created = category is None
|
created = category is None
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue