Improve performance and add type hints

This commit is contained in:
Joakim Hellsén 2026-04-11 00:44:16 +02:00
commit b7e10e766e
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
23 changed files with 745 additions and 178 deletions

View file

@ -206,8 +206,8 @@ class KickOrganizationFeed(TTVDropsBaseFeed):
def __call__(
self,
request: HttpRequest,
*args: object,
**kwargs: object,
*args: str | int,
**kwargs: str | int,
) -> HttpResponse:
"""Capture optional ?limit query parameter.
@ -283,8 +283,8 @@ class KickCategoryFeed(TTVDropsBaseFeed):
def __call__(
self,
request: HttpRequest,
*args: object,
**kwargs: object,
*args: str | int,
**kwargs: str | int,
) -> HttpResponse:
"""Capture optional ?limit query parameter.
@ -372,8 +372,8 @@ class KickCampaignFeed(TTVDropsBaseFeed):
def __call__(
self,
request: HttpRequest,
*args: object,
**kwargs: object,
*args: str | int,
**kwargs: str | int,
) -> HttpResponse:
"""Capture optional ?limit query parameter.
@ -481,8 +481,8 @@ class KickCategoryCampaignFeed(TTVDropsBaseFeed):
def __call__(
self,
request: HttpRequest,
*args: object,
**kwargs: object,
*args: str | int,
**kwargs: str | int,
) -> HttpResponse:
"""Capture optional ?limit query parameter.

View file

@ -1,4 +1,7 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING
import httpx
@ -14,6 +17,8 @@ from kick.models import KickUser
from kick.schemas import KickDropsResponseSchema
if TYPE_CHECKING:
from collections.abc import Mapping
from django.core.management.base import CommandParser
from kick.schemas import KickCategorySchema
@ -23,6 +28,26 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger("ttvdrops")
type KickImportModel = (
KickOrganization
| KickCategory
| KickDropCampaign
| KickUser
| KickChannel
| KickReward
)
type KickFieldValue = (
str
| bool
| int
| datetime
| KickOrganization
| KickCategory
| KickDropCampaign
| KickUser
| None
)
KICK_DROPS_API_URL = "https://web.kick.com/api/v1/drops/campaigns"
# Kick's public API requires a browser-like User-Agent.
@ -48,7 +73,26 @@ class Command(BaseCommand):
help="API endpoint to fetch (default: %(default)s).",
)
def handle(self, *args: object, **options: object) -> None: # noqa: ARG002
@staticmethod
def _save_if_changed(
obj: KickImportModel,
defaults: Mapping[str, KickFieldValue],
) -> None:
"""Persist only changed fields to avoid unnecessary updates."""
changed_fields: list[str] = []
for field, new_value in defaults.items():
if getattr(obj, field, None) != new_value:
setattr(obj, field, new_value)
changed_fields.append(field)
if changed_fields:
obj.save(update_fields=changed_fields)
def handle(
self,
*_args: str,
**options: str | bool | int | None,
) -> None:
"""Main entry point for the command."""
url: str = str(options["url"])
self.stdout.write(f"Fetching Kick drops from {url} ...")
@ -99,54 +143,75 @@ class Command(BaseCommand):
self.style.SUCCESS(f"Imported {imported}/{len(campaigns)} campaign(s)."),
)
def _import_campaign(self, data: KickDropCampaignSchema) -> None:
def _import_campaign(self, data: KickDropCampaignSchema) -> None: # noqa: PLR0914, PLR0915
"""Import a single campaign and all its related objects."""
# Organisation
# Organization
org_data: KickOrganizationSchema = data.organization
org, created = KickOrganization.objects.update_or_create(
org_defaults: dict[str, str | bool] = {
"name": org_data.name,
"logo_url": org_data.logo_url,
"url": org_data.url,
"restricted": org_data.restricted,
}
org: KickOrganization | None = KickOrganization.objects.filter(
kick_id=org_data.id,
defaults={
"name": org_data.name,
"logo_url": org_data.logo_url,
"url": org_data.url,
"restricted": org_data.restricted,
},
)
).first()
created: bool = org is None
if org is None:
org = KickOrganization.objects.create(kick_id=org_data.id, **org_defaults)
else:
self._save_if_changed(org, org_defaults)
if created:
logger.info("Created new organization: %s", org.kick_id)
# Category
cat_data: KickCategorySchema = data.category
category, created = KickCategory.objects.update_or_create(
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,
defaults={
"name": cat_data.name,
"slug": cat_data.slug,
"image_url": cat_data.image_url,
},
)
).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, created = KickDropCampaign.objects.update_or_create(
campaign_defaults: dict[str, KickFieldValue] = {
"name": data.name,
"status": data.status,
"starts_at": data.starts_at,
"ends_at": data.ends_at,
"connect_url": data.connect_url,
"url": data.url,
"rule_id": data.rule.id,
"rule_name": data.rule.name,
"organization": org,
"category": category,
"created_at": data.created_at,
"api_updated_at": data.updated_at,
"is_fully_imported": True,
}
campaign: KickDropCampaign | None = KickDropCampaign.objects.filter(
kick_id=data.id,
defaults={
"name": data.name,
"status": data.status,
"starts_at": data.starts_at,
"ends_at": data.ends_at,
"connect_url": data.connect_url,
"url": data.url,
"rule_id": data.rule.id,
"rule_name": data.rule.name,
"organization": org,
"category": category,
"created_at": data.created_at,
"api_updated_at": data.updated_at,
"is_fully_imported": True,
},
)
).first()
created = campaign is None
if campaign is None:
campaign = KickDropCampaign.objects.create(
kick_id=data.id,
**campaign_defaults,
)
else:
self._save_if_changed(campaign, campaign_defaults)
if created:
logger.info("Created new campaign: %s", campaign.kick_id)
@ -154,25 +219,38 @@ class Command(BaseCommand):
channel_objs: list[KickChannel] = []
for ch_data in data.channels:
user_data: KickUserSchema = ch_data.user
user, created = KickUser.objects.update_or_create(
user_defaults: dict[str, KickFieldValue] = {
"username": user_data.username,
"profile_picture": user_data.profile_picture,
}
user: KickUser | None = KickUser.objects.filter(
kick_id=user_data.id,
defaults={
"username": user_data.username,
"profile_picture": user_data.profile_picture,
},
)
).first()
created = user is None
if user is None:
user = KickUser.objects.create(kick_id=user_data.id, **user_defaults)
else:
self._save_if_changed(user, user_defaults)
if created:
logger.info("Created new user: %s", user.kick_id)
channel, created = KickChannel.objects.update_or_create(
channel_defaults: dict[str, KickFieldValue] = {
"slug": ch_data.slug,
"description": ch_data.description,
"banner_picture_url": ch_data.banner_picture_url,
"user": user,
}
channel: KickChannel | None = KickChannel.objects.filter(
kick_id=ch_data.id,
defaults={
"slug": ch_data.slug,
"description": ch_data.description,
"banner_picture_url": ch_data.banner_picture_url,
"user": user,
},
)
).first()
created = channel is None
if channel is None:
channel = KickChannel.objects.create(
kick_id=ch_data.id,
**channel_defaults,
)
else:
self._save_if_changed(channel, channel_defaults)
if created:
logger.info("Created new channel: %s", channel.kick_id)
@ -184,36 +262,46 @@ class Command(BaseCommand):
# Resolve reward's category (may differ from campaign category)
reward_category: KickCategory = category
if reward_data.category_id != cat_data.id:
reward_category, created = KickCategory.objects.get_or_create(
reward_category = KickCategory.objects.filter(
kick_id=reward_data.category_id,
defaults={"name": "", "slug": "", "image_url": ""},
).first() or KickCategory.objects.create(
kick_id=reward_data.category_id,
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)
# Resolve reward's organization (may differ from campaign org)
reward_org: KickOrganization = org
if reward_data.organization_id != org_data.id:
reward_org, created = KickOrganization.objects.get_or_create(
reward_org = KickOrganization.objects.filter(
kick_id=reward_data.organization_id,
defaults={
"name": "",
"logo_url": "",
"url": "",
"restricted": False,
},
).first() or KickOrganization.objects.create(
kick_id=reward_data.organization_id,
name="",
logo_url="",
url="",
restricted=False,
)
created = not reward_org.name and not reward_org.url
if created:
logger.info("Created new organization: %s", reward_org.kick_id)
KickReward.objects.update_or_create(
reward_defaults: dict[str, KickFieldValue] = {
"name": reward_data.name,
"image_url": reward_data.image_url,
"required_units": reward_data.required_units,
"campaign": campaign,
"category": reward_category,
"organization": reward_org,
}
reward: KickReward | None = KickReward.objects.filter(
kick_id=reward_data.id,
defaults={
"name": reward_data.name,
"image_url": reward_data.image_url,
"required_units": reward_data.required_units,
"campaign": campaign,
"category": reward_category,
"organization": reward_org,
},
)
).first()
if reward is None:
KickReward.objects.create(kick_id=reward_data.id, **reward_defaults)
else:
self._save_if_changed(reward, reward_defaults)

View file

@ -669,20 +669,25 @@ class KickDashboardViewTest(TestCase):
class KickCampaignListViewTest(TestCase):
"""Tests for the kick campaign list view."""
@classmethod
def setUpTestData(cls) -> None:
"""Set up shared test data for campaign list view tests."""
cls.org: KickOrganization = KickOrganization.objects.create(
kick_id="org-list",
name="List Org",
)
cls.cat: KickCategory = KickCategory.objects.create(
kick_id=300,
name="List Cat",
slug="list-cat",
)
def _make_campaign(
self,
kick_id: str,
name: str,
status: str = "active",
) -> KickDropCampaign:
org, _ = KickOrganization.objects.get_or_create(
kick_id="org-list",
defaults={"name": "List Org"},
)
cat, _ = KickCategory.objects.get_or_create(
kick_id=300,
defaults={"name": "List Cat", "slug": "list-cat"},
)
# Set dates so the active/expired filter works correctly
if status == "active":
starts_at = dt(2020, 1, 1, tzinfo=UTC)
@ -696,8 +701,8 @@ class KickCampaignListViewTest(TestCase):
status=status,
starts_at=starts_at,
ends_at=ends_at,
organization=org,
category=cat,
organization=self.org,
category=self.cat,
rule_id=1,
rule_name="Watch to redeem",
is_fully_imported=True,