ttvdrops/kick/management/commands/import_kick_drops.py

307 lines
11 KiB
Python

from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING
import httpx
from django.core.management.base import BaseCommand
from pydantic import ValidationError
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickDropCampaign
from kick.models import KickOrganization
from kick.models import KickReward
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
from kick.schemas import KickDropCampaignSchema
from kick.schemas import KickOrganizationSchema
from kick.schemas import KickUserSchema
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.
REQUEST_HEADERS: dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0"
),
"Accept": "application/json",
"Referer": "https://kick.com/",
}
class Command(BaseCommand):
"""Import drop campaigns from the Kick public API."""
help = "Fetch and import Kick drop campaigns from the public API."
def add_arguments(self, parser: CommandParser) -> None:
"""Add command-line arguments for the import_kick_drops command."""
parser.add_argument(
"--url",
default=KICK_DROPS_API_URL,
help="API endpoint to fetch (default: %(default)s).",
)
@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} ...")
try:
response: httpx.Response = httpx.get(
url,
headers=REQUEST_HEADERS,
timeout=30,
follow_redirects=True,
)
response.raise_for_status()
except httpx.HTTPError as exc:
self.stderr.write(
self.style.ERROR(f"HTTP error fetching Kick drops: {exc}"),
)
return
try:
payload: dict = response.json()
except Exception as exc: # noqa: BLE001
self.stderr.write(self.style.ERROR(f"Failed to parse JSON response: {exc}"))
return
try:
drops_response: KickDropsResponseSchema = (
KickDropsResponseSchema.model_validate(payload)
)
except ValidationError as exc:
self.stderr.write(self.style.ERROR(f"Response validation failed: {exc}"))
return
campaigns: list[KickDropCampaignSchema] = drops_response.data
self.stdout.write(f"Found {len(campaigns)} campaign(s). Importing ...")
imported = 0
for campaign_data in campaigns:
try:
self._import_campaign(campaign_data)
imported += 1
except Exception as exc:
logger.exception("Failed to import campaign %s", campaign_data.id)
self.stderr.write(
self.style.WARNING(f"Skipped campaign {campaign_data.id!r}: {exc}"),
)
self.stdout.write(
self.style.SUCCESS(f"Imported {imported}/{len(campaigns)} campaign(s)."),
)
def _import_campaign(self, data: KickDropCampaignSchema) -> None: # noqa: PLR0914, PLR0915
"""Import a single campaign and all its related objects."""
# Organization
org_data: KickOrganizationSchema = data.organization
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,
).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_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(
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] = {
"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,
).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)
# Channels
channel_objs: list[KickChannel] = []
for ch_data in data.channels:
user_data: KickUserSchema = ch_data.user
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,
).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_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,
).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)
channel_objs.append(channel)
campaign.channels.set(channel_objs)
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(
kick_id=reward_data.category_id,
).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 = KickOrganization.objects.filter(
kick_id=reward_data.organization_id,
).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)
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,
).first()
if reward is None:
KickReward.objects.create(kick_id=reward_data.id, **reward_defaults)
else:
self._save_if_changed(reward, reward_defaults)