307 lines
11 KiB
Python
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)
|