ttvdrops/kick/api.py

610 lines
17 KiB
Python

from __future__ import annotations
import datetime # ruff: ignore[typing-only-standard-library-import]
from typing import TYPE_CHECKING
from typing import Literal
from django.db import models
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from ninja import Router
from ninja import Schema
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickDropCampaign
from kick.models import KickOrganization
from kick.models import KickUser
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest
from kick.models import KickReward
CampaignStatus = Literal["active", "upcoming", "expired", "unknown"]
CampaignStatusFilter = Literal["active", "upcoming", "expired", "unknown"]
VALID_STATUS_FILTERS: frozenset[str] = frozenset({
"active",
"upcoming",
"expired",
"unknown",
})
DEFAULT_PAGE_SIZE = 100
MAX_PAGE_SIZE = 500
api = Router()
# MARK: Schemas
class V1StatsSchema(Schema):
"""Aggregate counts for Kick drops."""
total_campaigns: int
active: int
upcoming: int
expired: int
partial_dates: int
total_organizations: int
total_categories: int
total_channels: int
class V1CategorySchema(Schema):
"""Kick category (game) nested in a campaign."""
kick_id: int
name: str
slug: str
image_url: str
class V1OrganizationSummarySchema(Schema):
"""Kick organization nested in a campaign."""
kick_id: str
name: str
logo_url: str
url: str
class V1UserSchema(Schema):
"""Kick user nested in a channel."""
kick_id: int
username: str
profile_picture: str
class V1ChannelSchema(Schema):
"""Kick channel participating in a drop campaign."""
kick_id: int
slug: str
url: str
user: V1UserSchema | None
class V1RewardSchema(Schema):
"""Reward earned from a Kick drop campaign."""
kick_id: str
name: str
image_url: str
required_minutes_watched: int
class V1CampaignSummarySchema(Schema):
"""Compact Kick drop campaign for list views."""
kick_id: str
name: str
status: str
image_url: str
starts_at: datetime.datetime | None
ends_at: datetime.datetime | None
category: V1CategorySchema | None
organization: V1OrganizationSummarySchema | None
reward_count: int
is_fully_imported: bool
class V1CampaignDetailSchema(V1CampaignSummarySchema):
"""Full Kick drop campaign detail."""
connect_url: str
url: str
channels: list[V1ChannelSchema]
rewards: list[V1RewardSchema]
added_at: datetime.datetime | None
updated_at: datetime.datetime | None
class V1PaginationSchema(Schema):
"""Common pagination fields."""
total: int
page: int
page_size: int
class V1CampaignListSchema(V1PaginationSchema):
"""Paginated campaign list."""
items: list[V1CampaignDetailSchema]
class V1OrganizationListItemSchema(Schema):
"""Kick organization in a paginated list."""
kick_id: str
name: str
logo_url: str
url: str
campaign_count: int
active_campaign_count: int
class V1OrganizationListSchema(V1PaginationSchema):
"""Paginated organization list."""
items: list[V1OrganizationListItemSchema]
class V1OrganizationDetailSchema(Schema):
"""Kick organization with its campaigns."""
kick_id: str
name: str
logo_url: str
url: str
campaign_count: int
active_campaign_count: int
campaigns: list[V1CampaignSummarySchema]
class V1CategoryListItemSchema(Schema):
"""Kick category (game) in a paginated list."""
kick_id: int
name: str
slug: str
image_url: str
campaign_count: int
active_campaign_count: int
class V1CategoryListSchema(V1PaginationSchema):
"""Paginated category list."""
items: list[V1CategoryListItemSchema]
class V1CategoryDetailSchema(Schema):
"""Kick category (game) with its campaigns."""
kick_id: int
name: str
slug: str
image_url: str
campaign_count: int
active_campaign_count: int
campaigns: list[V1CampaignSummarySchema]
# MARK: Helpers
def _paginate[ModelT: models.Model](
queryset: QuerySet[ModelT, ModelT],
*,
page: int,
page_size: int,
) -> tuple[list[ModelT], int, int, int]:
page = max(page, 1)
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
offset = (page - 1) * page_size
total = queryset.count()
items = list(queryset[offset : offset + page_size]) if offset < total else []
return items, total, page, page_size
def _campaign_status(
campaign: KickDropCampaign,
now: datetime.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 _apply_status_filter(
queryset: QuerySet[KickDropCampaign],
status: str | None,
now: datetime.datetime,
) -> QuerySet[KickDropCampaign]:
if status == "active":
return queryset.filter(starts_at__lte=now, ends_at__gte=now)
if status == "upcoming":
return queryset.filter(starts_at__gt=now, ends_at__isnull=False)
if status == "expired":
return queryset.filter(starts_at__lte=now, ends_at__lt=now)
if status == "unknown":
return queryset.filter(
models.Q(starts_at__isnull=True) | models.Q(ends_at__isnull=True),
)
return queryset
# MARK: Serializers
def _serialize_category(category: KickCategory | None) -> V1CategorySchema | None:
if category is None:
return None
return V1CategorySchema(
kick_id=category.kick_id,
name=category.name,
slug=category.slug,
image_url=category.image_url,
)
def _serialize_organization_summary(
organization: KickOrganization | None,
) -> V1OrganizationSummarySchema | None:
if organization is None:
return None
return V1OrganizationSummarySchema(
kick_id=organization.kick_id,
name=organization.name,
logo_url=organization.logo_url,
url=organization.url,
)
def _serialize_user(user: KickUser | None) -> V1UserSchema | None:
if user is None:
return None
return V1UserSchema(
kick_id=user.kick_id,
username=user.username,
profile_picture=user.profile_picture,
)
def _serialize_channel(channel: KickChannel) -> V1ChannelSchema:
return V1ChannelSchema(
kick_id=channel.kick_id,
slug=channel.slug,
url=channel.channel_url,
user=_serialize_user(channel.user),
)
def _serialize_reward(reward: KickReward) -> V1RewardSchema:
return V1RewardSchema(
kick_id=reward.kick_id,
name=reward.name,
image_url=reward.full_image_url,
required_minutes_watched=reward.required_units,
)
def _serialize_campaign_summary(
campaign: KickDropCampaign,
now: datetime.datetime,
) -> V1CampaignSummarySchema:
return V1CampaignSummarySchema(
kick_id=campaign.kick_id,
name=campaign.name,
status=_campaign_status(campaign, now),
image_url=campaign.image_url,
starts_at=campaign.starts_at,
ends_at=campaign.ends_at,
category=_serialize_category(campaign.category),
organization=_serialize_organization_summary(campaign.organization),
reward_count=campaign.rewards.count(), # pyright: ignore[reportAttributeAccessIssue]
is_fully_imported=campaign.is_fully_imported,
)
def _serialize_campaign(
campaign: KickDropCampaign,
now: datetime.datetime,
) -> V1CampaignDetailSchema:
return V1CampaignDetailSchema(
kick_id=campaign.kick_id,
name=campaign.name,
status=_campaign_status(campaign, now),
image_url=campaign.image_url,
starts_at=campaign.starts_at,
ends_at=campaign.ends_at,
category=_serialize_category(campaign.category),
organization=_serialize_organization_summary(campaign.organization),
reward_count=campaign.rewards.count(), # pyright: ignore[reportAttributeAccessIssue]
is_fully_imported=campaign.is_fully_imported,
connect_url=campaign.connect_url,
url=campaign.url,
channels=[_serialize_channel(channel) for channel in campaign.channels.all()],
rewards=[
_serialize_reward(reward)
for reward in campaign.rewards.all() # pyright: ignore[reportAttributeAccessIssue]
],
added_at=campaign.added_at,
updated_at=campaign.updated_at,
)
# MARK: Endpoints
@api.get("/", response=V1StatsSchema)
def stats(request: HttpRequest) -> V1StatsSchema:
"""Return aggregate counts for Kick drops."""
now: datetime.datetime = timezone.now()
total_campaigns: int = KickDropCampaign.objects.filter(
is_fully_imported=True,
).count()
active: int = KickDropCampaign.objects.filter(
is_fully_imported=True,
starts_at__lte=now,
ends_at__gte=now,
).count()
upcoming: int = KickDropCampaign.objects.filter(
is_fully_imported=True,
starts_at__gt=now,
ends_at__isnull=False,
).count()
expired: int = KickDropCampaign.objects.filter(
is_fully_imported=True,
starts_at__lte=now,
ends_at__lt=now,
).count()
return V1StatsSchema(
total_campaigns=total_campaigns,
active=active,
upcoming=upcoming,
expired=expired,
partial_dates=total_campaigns - active - upcoming - expired,
total_organizations=KickOrganization.objects.count(),
total_categories=KickCategory.objects.count(),
total_channels=KickChannel.objects.count(),
)
@api.get("/campaigns/", response=V1CampaignListSchema)
def list_campaigns( # ruff:ignore[too-many-positional-arguments]
request: HttpRequest,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
status: CampaignStatusFilter | None = None,
game: int | None = None,
organization: str | None = None,
search: str | None = None,
) -> V1CampaignListSchema:
"""Return paginated Kick drop campaigns."""
now: datetime.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")
)
if game is not None:
queryset = queryset.filter(category__kick_id=game)
if organization is not None:
queryset = queryset.filter(organization__kick_id=organization)
if search is not None:
queryset = queryset.filter(name__icontains=search)
queryset = _apply_status_filter(queryset, status, now)
items, total, current_page, current_page_size = _paginate(
queryset,
page=page,
page_size=page_size,
)
return V1CampaignListSchema(
total=total,
page=current_page,
page_size=current_page_size,
items=[_serialize_campaign(campaign, now) for campaign in items],
)
@api.get("/campaigns/{kick_id}/", response=V1CampaignDetailSchema)
def get_campaign(
request: HttpRequest,
kick_id: str,
) -> V1CampaignDetailSchema:
"""Return a single Kick drop campaign.
Raises:
Http404: If the campaign does not exist or is not fully imported.
"""
try:
campaign: KickDropCampaign = (
KickDropCampaign.objects
.filter(is_fully_imported=True)
.select_related("category", "organization")
.prefetch_related("channels__user", "rewards")
.get(kick_id=kick_id)
)
except KickDropCampaign.DoesNotExist as exc:
msg = "Campaign not found"
raise Http404(msg) from exc
return _serialize_campaign(campaign, timezone.now())
@api.get("/games/", response=V1CategoryListSchema)
def list_games(
request: HttpRequest,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
search: str | None = None,
) -> V1CategoryListSchema:
"""Return paginated Kick categories (games)."""
now: datetime.datetime = timezone.now()
queryset: QuerySet[KickCategory] = KickCategory.objects.annotate(
campaign_count=models.Count(
"campaigns",
filter=models.Q(campaigns__is_fully_imported=True),
),
active_campaign_count=models.Count(
"campaigns",
filter=models.Q(
campaigns__is_fully_imported=True,
campaigns__starts_at__lte=now,
campaigns__ends_at__gte=now,
),
),
).order_by("name")
if search is not None:
queryset = queryset.filter(name__icontains=search)
items, total, current_page, current_page_size = _paginate(
queryset,
page=page,
page_size=page_size,
)
return V1CategoryListSchema(
total=total,
page=current_page,
page_size=current_page_size,
items=[
V1CategoryListItemSchema(
kick_id=cat.kick_id,
name=cat.name,
slug=cat.slug,
image_url=cat.image_url,
campaign_count=cat.campaign_count, # pyright: ignore[reportAttributeAccessIssue]
active_campaign_count=cat.active_campaign_count, # pyright: ignore[reportAttributeAccessIssue]
)
for cat in items
],
)
@api.get("/games/{kick_id}/", response=V1CategoryDetailSchema)
def get_game(
request: HttpRequest,
kick_id: int,
) -> V1CategoryDetailSchema:
"""Return a single Kick category (game) with its campaigns."""
category: KickCategory = get_object_or_404(KickCategory, kick_id=kick_id)
now: datetime.datetime = timezone.now()
campaigns: list[KickDropCampaign] = list(
category.campaigns # pyright: ignore[reportAttributeAccessIssue]
.filter(is_fully_imported=True)
.select_related("organization")
.prefetch_related("rewards")
.order_by("-starts_at"),
)
return V1CategoryDetailSchema(
kick_id=category.kick_id,
name=category.name,
slug=category.slug,
image_url=category.image_url,
campaign_count=len(campaigns),
active_campaign_count=sum(
1 for c in campaigns if _campaign_status(c, now) == "active"
),
campaigns=[_serialize_campaign_summary(c, now) for c in campaigns],
)
@api.get("/organizations/", response=V1OrganizationListSchema)
def list_organizations(
request: HttpRequest,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
search: str | None = None,
) -> V1OrganizationListSchema:
"""Return paginated Kick organizations."""
now: datetime.datetime = timezone.now()
queryset: QuerySet[KickOrganization] = KickOrganization.objects.annotate(
campaign_count=models.Count(
"campaigns",
filter=models.Q(campaigns__is_fully_imported=True),
),
active_campaign_count=models.Count(
"campaigns",
filter=models.Q(
campaigns__is_fully_imported=True,
campaigns__starts_at__lte=now,
campaigns__ends_at__gte=now,
),
),
).order_by("name")
if search is not None:
queryset = queryset.filter(name__icontains=search)
items, total, current_page, current_page_size = _paginate(
queryset,
page=page,
page_size=page_size,
)
return V1OrganizationListSchema(
total=total,
page=current_page,
page_size=current_page_size,
items=[
V1OrganizationListItemSchema(
kick_id=org.kick_id,
name=org.name,
logo_url=org.logo_url,
url=org.url,
campaign_count=org.campaign_count, # pyright: ignore[reportAttributeAccessIssue]
active_campaign_count=org.active_campaign_count, # pyright: ignore[reportAttributeAccessIssue]
)
for org in items
],
)
@api.get("/organizations/{kick_id}/", response=V1OrganizationDetailSchema)
def get_organization(
request: HttpRequest,
kick_id: str,
) -> V1OrganizationDetailSchema:
"""Return a single Kick organization with its campaigns."""
organization: KickOrganization = get_object_or_404(
KickOrganization,
kick_id=kick_id,
)
now: datetime.datetime = timezone.now()
campaigns: list[KickDropCampaign] = list(
organization.campaigns # pyright: ignore[reportAttributeAccessIssue]
.filter(is_fully_imported=True)
.select_related("category")
.prefetch_related("rewards")
.order_by("-starts_at"),
)
return V1OrganizationDetailSchema(
kick_id=organization.kick_id,
name=organization.name,
logo_url=organization.logo_url,
url=organization.url,
campaign_count=len(campaigns),
active_campaign_count=sum(
1 for c in campaigns if _campaign_status(c, now) == "active"
),
campaigns=[_serialize_campaign_summary(c, now) for c in campaigns],
)