ttvdrops/kick/api.py
2026-07-20 19:30:52 +02:00

213 lines
6.5 KiB
Python

from __future__ import annotations
from datetime import UTC
from typing import TYPE_CHECKING
from typing import Any
from typing import Literal
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET
from kick.models import KickDropCampaign
if TYPE_CHECKING:
from datetime import datetime
from django.db.models import QuerySet
from django.http import HttpRequest
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickOrganization
from kick.models import KickReward
from kick.models import KickUser
CampaignStatus = Literal["active", "upcoming", "expired", "unknown"]
VALID_STATUS_FILTERS: frozenset[str] = frozenset({"active", "upcoming", "expired"})
DEFAULT_PAGE_SIZE = 100
MAX_PAGE_SIZE = 500
def _datetime_to_json(value: datetime | None) -> str | None:
if value is None:
return None
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def _campaign_status(campaign: KickDropCampaign, now: datetime) -> CampaignStatus:
if campaign.starts_at and campaign.ends_at:
if campaign.starts_at <= now <= campaign.ends_at:
return "active"
if campaign.starts_at > now:
return "upcoming"
return "expired"
return "unknown"
def _serialize_category(category: KickCategory | None) -> dict[str, Any] | None:
if category is None:
return None
return {
"kick_id": category.kick_id,
"name": category.name,
"slug": category.slug,
"image_url": category.image_url,
}
def _serialize_organization(
organization: KickOrganization | None,
) -> dict[str, Any] | None:
if organization is None:
return None
return {
"kick_id": organization.kick_id,
"name": organization.name,
"logo_url": organization.logo_url,
"url": organization.url,
}
def _serialize_user(user: KickUser | None) -> dict[str, Any] | None:
if user is None:
return None
return {
"kick_id": user.kick_id,
"username": user.username,
"profile_picture": user.profile_picture,
}
def _serialize_channel(channel: KickChannel) -> dict[str, Any]:
return {
"kick_id": channel.kick_id,
"slug": channel.slug,
"url": channel.channel_url,
"user": _serialize_user(channel.user),
}
def _serialize_reward(reward: KickReward) -> dict[str, Any]:
return {
"kick_id": reward.kick_id,
"name": reward.name,
"image_url": reward.full_image_url,
"required_minutes_watched": reward.required_units,
}
def _serialize_campaign(
campaign: KickDropCampaign,
now: datetime,
) -> dict[str, Any]:
return {
"kick_id": campaign.kick_id,
"name": campaign.name,
"status": _campaign_status(campaign, now),
"image_url": campaign.image_url,
"connect_url": campaign.connect_url,
"url": campaign.url,
"starts_at": _datetime_to_json(campaign.starts_at),
"ends_at": _datetime_to_json(campaign.ends_at),
"category": _serialize_category(campaign.category),
"organization": _serialize_organization(campaign.organization),
"channels": [
_serialize_channel(channel) for channel in campaign.channels.all()
],
"rewards": [_serialize_reward(reward) for reward in campaign.rewards.all()],
"is_fully_imported": campaign.is_fully_imported,
"added_at": _datetime_to_json(campaign.added_at),
"updated_at": _datetime_to_json(campaign.updated_at),
}
def _parse_integer_query(
request: HttpRequest,
name: str,
default: int,
) -> int | JsonResponse:
raw_value: str | None = request.GET.get(name)
if raw_value is None:
return default
try:
return int(raw_value)
except ValueError:
return JsonResponse(
{
"detail": [
{
"type": "int_parsing",
"loc": ["query", name],
"msg": "Input should be a valid integer",
},
],
},
status=422,
)
@require_GET
def campaign_list_api(request: HttpRequest) -> JsonResponse:
"""Return a paginated JSON representation of fully imported Kick campaigns."""
page = _parse_integer_query(request, "page", 1)
if isinstance(page, JsonResponse):
return page
page = max(page, 1)
page_size = _parse_integer_query(request, "page_size", DEFAULT_PAGE_SIZE)
if isinstance(page_size, JsonResponse):
return page_size
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
status_filter: str | None = request.GET.get("status")
if status_filter is not None and status_filter not in VALID_STATUS_FILTERS:
return JsonResponse(
{
"detail": [
{
"type": "literal_error",
"loc": ["query", "status"],
"msg": "Input should be 'active', 'upcoming' or 'expired'",
},
],
},
status=422,
)
now: datetime = timezone.now()
queryset: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.filter(is_fully_imported=True)
.select_related("category", "organization")
.prefetch_related("channels__user", "rewards")
.order_by("-starts_at", "kick_id")
)
game_filter: int | JsonResponse | None = None
if request.GET.get("game") is not None:
game_filter = _parse_integer_query(request, "game", 0)
if isinstance(game_filter, JsonResponse):
return game_filter
if game_filter is not None:
queryset = queryset.filter(category__kick_id=game_filter)
if status_filter == "active":
queryset = queryset.filter(starts_at__lte=now, ends_at__gte=now)
elif status_filter == "upcoming":
queryset = queryset.filter(starts_at__gt=now, ends_at__isnull=False)
elif status_filter == "expired":
queryset = queryset.filter(starts_at__lte=now, ends_at__lt=now)
total: int = queryset.count()
offset: int = (page - 1) * page_size
campaigns: list[KickDropCampaign] = (
list(queryset[offset : offset + page_size]) if offset < total else []
)
return JsonResponse({
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_campaign(campaign, now) for campaign in campaigns],
})