This commit is contained in:
parent
4627d1cea0
commit
d762081bd5
26 changed files with 5048 additions and 1 deletions
0
kick/__init__.py
Normal file
0
kick/__init__.py
Normal file
8
kick/apps.py
Normal file
8
kick/apps.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class KickConfig(AppConfig):
|
||||
"""Django app configuration for the kick app."""
|
||||
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "kick"
|
||||
708
kick/feeds.py
Normal file
708
kick/feeds.py
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||
from django.db.models import Prefetch
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
from django.utils.html import format_html_join
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
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 twitch.feeds import TTVDropsAtomBaseFeed
|
||||
from twitch.feeds import TTVDropsBaseFeed
|
||||
from twitch.feeds import discord_timestamp
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import datetime
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.http import HttpResponse
|
||||
|
||||
|
||||
def _with_campaign_related(
|
||||
queryset: QuerySet[KickDropCampaign],
|
||||
) -> QuerySet[KickDropCampaign]:
|
||||
"""Apply related-select/prefetches needed by feed rendering.
|
||||
|
||||
Returns:
|
||||
QuerySet[KickDropCampaign]: QuerySet with related objects optimized for feed rendering.
|
||||
"""
|
||||
return queryset.select_related("organization", "category").prefetch_related(
|
||||
Prefetch(
|
||||
"channels",
|
||||
queryset=KickChannel.objects.select_related("user").order_by("slug"),
|
||||
to_attr="channels_ordered",
|
||||
),
|
||||
Prefetch(
|
||||
"rewards",
|
||||
queryset=KickReward.objects.order_by("required_units", "name"),
|
||||
to_attr="rewards_ordered",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _active_campaigns(
|
||||
queryset: QuerySet[KickDropCampaign],
|
||||
) -> QuerySet[KickDropCampaign]:
|
||||
"""Filter campaign queryset down to currently active campaigns.
|
||||
|
||||
Returns:
|
||||
QuerySet[KickDropCampaign]: Filtered queryset with active campaigns.
|
||||
"""
|
||||
now: datetime.datetime = timezone.now()
|
||||
return queryset.filter(starts_at__lte=now, ends_at__gte=now)
|
||||
|
||||
|
||||
def _campaign_dates_html(
|
||||
campaign: KickDropCampaign,
|
||||
*,
|
||||
use_discord_relative: bool = False,
|
||||
) -> SafeText:
|
||||
"""Render campaign date rows in standard or Discord relative format.
|
||||
|
||||
Returns:
|
||||
SafeText: HTML with campaign dates or empty if no dates are available.
|
||||
"""
|
||||
starts_at: datetime.datetime | None = campaign.starts_at
|
||||
ends_at: datetime.datetime | None = campaign.ends_at
|
||||
|
||||
if not starts_at and not ends_at:
|
||||
return SafeText("")
|
||||
|
||||
formatter = discord_timestamp if use_discord_relative else naturaltime
|
||||
|
||||
start_part: SafeText = (
|
||||
format_html(
|
||||
"Starts: {} ({})",
|
||||
starts_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
formatter(starts_at),
|
||||
)
|
||||
if starts_at
|
||||
else SafeText("")
|
||||
)
|
||||
end_part: SafeText = (
|
||||
format_html(
|
||||
"Ends: {} ({})",
|
||||
ends_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
formatter(ends_at),
|
||||
)
|
||||
if ends_at
|
||||
else SafeText("")
|
||||
)
|
||||
|
||||
if start_part and end_part:
|
||||
return format_html("<p>{}<br />{}</p>", start_part, end_part)
|
||||
if start_part:
|
||||
return format_html("<p>{}</p>", start_part)
|
||||
return format_html("<p>{}</p>", end_part)
|
||||
|
||||
|
||||
def _campaign_rewards_html(campaign: KickDropCampaign) -> SafeText:
|
||||
"""Render a compact rewards summary for a campaign.
|
||||
|
||||
Rewards are rendered as a simple list of "X minutes: Reward Name" entries.
|
||||
|
||||
Returns:
|
||||
SafeText: HTML with rewards summary or empty if no rewards are available.
|
||||
"""
|
||||
rewards: list[KickReward] = getattr(campaign, "rewards_ordered", [])
|
||||
if not rewards:
|
||||
rewards = list(
|
||||
campaign.rewards.order_by("required_units", "name"), # type: ignore[reportAttributeAccessIssue]
|
||||
)
|
||||
|
||||
if not rewards:
|
||||
return SafeText("")
|
||||
|
||||
items: list[SafeText] = [
|
||||
format_html("<li>{} minutes: {}</li>", reward.required_units, reward.name)
|
||||
for reward in rewards
|
||||
]
|
||||
|
||||
html: SafeText = format_html(
|
||||
"<ul>{}</ul>",
|
||||
format_html_join("", "{}", [(item,) for item in items]),
|
||||
)
|
||||
return format_html("<p>Rewards:</p>{}", html)
|
||||
|
||||
|
||||
def _campaign_channels_html(campaign: KickDropCampaign) -> SafeText:
|
||||
"""Render up to 5 linked channels for a campaign.
|
||||
|
||||
If there are more than 5 channels, a note about additional channels will be added.
|
||||
|
||||
Returns:
|
||||
SafeText: HTML with linked channels or empty if no channels are available.
|
||||
"""
|
||||
channels: list[KickChannel] = getattr(campaign, "channels_ordered", [])
|
||||
if not channels:
|
||||
channels = list(campaign.channels.select_related("user").order_by("slug"))
|
||||
|
||||
if not channels:
|
||||
return SafeText("")
|
||||
|
||||
max_links: int = 5
|
||||
items: list[SafeText] = []
|
||||
for channel in channels[:max_links]:
|
||||
label: str = channel.user.username if channel.user else channel.slug
|
||||
if channel.channel_url:
|
||||
items.append(
|
||||
format_html('<li><a href="{}">{}</a></li>', channel.channel_url, label),
|
||||
)
|
||||
else:
|
||||
items.append(format_html("<li>{}</li>", label))
|
||||
|
||||
if len(channels) > max_links:
|
||||
items.append(format_html("<li>... and {} more</li>", len(channels) - max_links))
|
||||
|
||||
html: SafeText = format_html(
|
||||
"<ul>{}</ul>",
|
||||
format_html_join("", "{}", [(item,) for item in items]),
|
||||
)
|
||||
return format_html("<p>Channels with this drop:</p>{}", html)
|
||||
|
||||
|
||||
def _campaign_links_html(campaign: KickDropCampaign) -> SafeText:
|
||||
"""Render campaign external links if available.
|
||||
|
||||
Links can include the main campaign URL and a separate connect URL for claiming rewards.
|
||||
|
||||
Returns:
|
||||
SafeText: HTML with campaign links or empty if no links are available.
|
||||
"""
|
||||
links: list[SafeText] = []
|
||||
|
||||
if campaign.url:
|
||||
links.append(format_html('<a href="{}">About</a>', campaign.url))
|
||||
if campaign.connect_url:
|
||||
links.append(format_html('<a href="{}">Connect</a>', campaign.connect_url))
|
||||
|
||||
if not links:
|
||||
return SafeText("")
|
||||
|
||||
return format_html(
|
||||
"<p>{}</p>",
|
||||
format_html_join(" | ", "{}", [(link,) for link in links]),
|
||||
)
|
||||
|
||||
|
||||
class KickOrganizationFeed(TTVDropsBaseFeed):
|
||||
"""RSS feed for latest Kick organizations."""
|
||||
|
||||
title: str = "TTVDrops Kick Organizations"
|
||||
link: str = "/kick/organizations/"
|
||||
description: str = "Latest organizations on Kick in TTVDrops"
|
||||
|
||||
_limit: int | None = None
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> HttpResponse:
|
||||
"""Capture optional ?limit query parameter.
|
||||
|
||||
A ?limit parameter can be used to limit the number of organizations returned by the feed.
|
||||
|
||||
Returns:
|
||||
HttpResponse: An HTTP response containing the feed XML.
|
||||
"""
|
||||
if request.GET.get("limit"):
|
||||
try:
|
||||
self._limit = int(request.GET.get("limit", 200))
|
||||
except TypeError, ValueError:
|
||||
self._limit = None
|
||||
return super().__call__(request, *args, **kwargs)
|
||||
|
||||
def items(self) -> QuerySet[KickOrganization]:
|
||||
"""Return latest organizations with configurable limit."""
|
||||
limit: int = self._limit if self._limit is not None else 200
|
||||
return KickOrganization.objects.order_by("-added_at")[:limit]
|
||||
|
||||
def item_title(self, item: KickOrganization) -> SafeText:
|
||||
"""Return organization name as title."""
|
||||
return SafeText(item.name)
|
||||
|
||||
def item_description(self, item: KickOrganization) -> SafeText:
|
||||
"""Return organization summary HTML."""
|
||||
parts: list[SafeText] = []
|
||||
|
||||
if item.logo_url:
|
||||
parts.append(
|
||||
format_html(
|
||||
'<img src="{}" alt="{}" width="160" height="160" />',
|
||||
item.logo_url,
|
||||
item.name,
|
||||
),
|
||||
)
|
||||
|
||||
if item.url:
|
||||
parts.append(format_html('<p><a href="{}">Website</a></p>', item.url))
|
||||
|
||||
return SafeText("".join(str(p) for p in parts))
|
||||
|
||||
def item_link(self, item: KickOrganization) -> str:
|
||||
"""Return the link to organization detail."""
|
||||
return reverse("kick:organization_detail", args=[item.kick_id])
|
||||
|
||||
def item_pubdate(self, item: KickOrganization) -> datetime.datetime:
|
||||
"""Return publication date with fallback."""
|
||||
return item.added_at or timezone.now()
|
||||
|
||||
def item_updateddate(self, item: KickOrganization) -> datetime.datetime:
|
||||
"""Return updated date with fallback."""
|
||||
return item.updated_at or timezone.now()
|
||||
|
||||
def item_author_name(self, item: KickOrganization) -> str:
|
||||
"""Return author name for feed item."""
|
||||
return item.name or "Kick"
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return feed URL."""
|
||||
return reverse("kick:organization_feed")
|
||||
|
||||
|
||||
class KickCategoryFeed(TTVDropsBaseFeed):
|
||||
"""RSS feed for latest Kick games."""
|
||||
|
||||
title: str = "TTVDrops Kick Games"
|
||||
link: str = "/kick/games/"
|
||||
description: str = "Latest games on Kick in TTVDrops"
|
||||
|
||||
_limit: int | None = None
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> HttpResponse:
|
||||
"""Capture optional ?limit query parameter.
|
||||
|
||||
Returns:
|
||||
HttpResponse: The response from the parent __call__ after capturing the limit.
|
||||
"""
|
||||
if request.GET.get("limit"):
|
||||
try:
|
||||
self._limit = int(request.GET.get("limit", 200))
|
||||
except TypeError, ValueError:
|
||||
self._limit = None
|
||||
return super().__call__(request, *args, **kwargs)
|
||||
|
||||
def items(self) -> QuerySet[KickCategory]:
|
||||
"""Return latest games with configurable limit."""
|
||||
limit: int = self._limit if self._limit is not None else 200
|
||||
return KickCategory.objects.order_by("-added_at")[:limit]
|
||||
|
||||
def item_title(self, item: KickCategory) -> SafeText:
|
||||
"""Return game name as title."""
|
||||
return SafeText(item.name)
|
||||
|
||||
def item_description(self, item: KickCategory) -> SafeText:
|
||||
"""Return game summary HTML."""
|
||||
parts: list[SafeText] = []
|
||||
|
||||
if item.image_url:
|
||||
parts.append(
|
||||
format_html(
|
||||
'<img src="{}" alt="{}" width="320" height="180" />',
|
||||
item.image_url,
|
||||
item.name,
|
||||
),
|
||||
)
|
||||
|
||||
if item.kick_url:
|
||||
parts.append(
|
||||
format_html('<p><a href="{}">Kick game</a></p>', item.kick_url),
|
||||
)
|
||||
|
||||
category_campaign_feed: str = reverse(
|
||||
"kick:game_campaign_feed",
|
||||
args=[item.kick_id],
|
||||
)
|
||||
parts.append(
|
||||
format_html(
|
||||
'<p><a href="{}">Campaign feed</a></p>',
|
||||
category_campaign_feed,
|
||||
),
|
||||
)
|
||||
|
||||
return SafeText("".join(str(p) for p in parts))
|
||||
|
||||
def item_link(self, item: KickCategory) -> str:
|
||||
"""Return the link to game detail."""
|
||||
return reverse("kick:game_detail", args=[item.kick_id])
|
||||
|
||||
def item_pubdate(self, item: KickCategory) -> datetime.datetime:
|
||||
"""Return publication date with fallback."""
|
||||
return item.added_at or timezone.now()
|
||||
|
||||
def item_updateddate(self, item: KickCategory) -> datetime.datetime:
|
||||
"""Return updated date with fallback."""
|
||||
return item.updated_at or timezone.now()
|
||||
|
||||
def item_author_name(self, item: KickCategory) -> str:
|
||||
"""Return author name for feed item."""
|
||||
return item.name or "Kick"
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return feed URL."""
|
||||
return reverse("kick:game_feed")
|
||||
|
||||
|
||||
class KickCampaignFeed(TTVDropsBaseFeed):
|
||||
"""RSS feed for currently active Kick drop campaigns."""
|
||||
|
||||
title: str = "Kick Drop Campaigns"
|
||||
link: str = "/kick/campaigns/"
|
||||
description: str = "Active Kick drop campaigns on TTVDrops"
|
||||
|
||||
item_guid_is_permalink = True
|
||||
_limit: int | None = None
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> HttpResponse:
|
||||
"""Capture optional ?limit query parameter.
|
||||
|
||||
A ?limit parameter can be used to limit the number of campaigns returned by the feed.
|
||||
|
||||
Returns:
|
||||
HttpResponse: An HTTP response containing the feed XML.
|
||||
"""
|
||||
if request.GET.get("limit"):
|
||||
try:
|
||||
self._limit = int(request.GET.get("limit", 200))
|
||||
except TypeError, ValueError:
|
||||
self._limit = None
|
||||
return super().__call__(request, *args, **kwargs)
|
||||
|
||||
def items(self) -> list[KickDropCampaign]:
|
||||
"""Return active campaigns ordered by newest starts_at."""
|
||||
limit: int = self._limit if self._limit is not None else 200
|
||||
queryset: QuerySet[KickDropCampaign] = _active_campaigns(
|
||||
KickDropCampaign.objects.order_by("-starts_at"),
|
||||
)
|
||||
return list(_with_campaign_related(queryset)[:limit])
|
||||
|
||||
def item_title(self, item: KickDropCampaign) -> SafeText:
|
||||
"""Return campaign title with optional game prefix."""
|
||||
game_name: str = item.category.name if item.category else "Kick"
|
||||
return SafeText(f"{game_name}: {item.name}")
|
||||
|
||||
def item_description(self, item: KickDropCampaign) -> SafeText:
|
||||
"""Return campaign summary HTML."""
|
||||
parts: list[SafeText] = []
|
||||
|
||||
if item.image_url:
|
||||
parts.append(
|
||||
format_html(
|
||||
'<img src="{}" alt="{}" width="160" height="160" />',
|
||||
item.image_url,
|
||||
item.name,
|
||||
),
|
||||
)
|
||||
|
||||
parts.extend(
|
||||
(
|
||||
_campaign_dates_html(item),
|
||||
_campaign_rewards_html(item),
|
||||
_campaign_channels_html(item),
|
||||
_campaign_links_html(item),
|
||||
),
|
||||
)
|
||||
|
||||
return SafeText("".join(str(p) for p in parts if p))
|
||||
|
||||
def item_link(self, item: KickDropCampaign) -> str:
|
||||
"""Return campaign detail URL."""
|
||||
return reverse("kick:campaign_detail", args=[item.kick_id])
|
||||
|
||||
def item_guid(self, item: KickDropCampaign) -> str:
|
||||
"""Return unique item GUID as absolute URL."""
|
||||
return self._absolute_url(reverse("kick:campaign_detail", args=[item.kick_id]))
|
||||
|
||||
def item_pubdate(self, item: KickDropCampaign) -> datetime.datetime:
|
||||
"""Return publication date with fallback chain."""
|
||||
if item.starts_at:
|
||||
return item.starts_at
|
||||
if item.added_at:
|
||||
return item.added_at
|
||||
return timezone.now()
|
||||
|
||||
def item_updateddate(self, item: KickDropCampaign) -> datetime.datetime:
|
||||
"""Return update date with fallback chain."""
|
||||
if item.api_updated_at:
|
||||
return item.api_updated_at
|
||||
if item.updated_at:
|
||||
return item.updated_at
|
||||
return timezone.now()
|
||||
|
||||
def item_categories(self, item: KickDropCampaign) -> tuple[str, ...]:
|
||||
"""Return category labels for this campaign."""
|
||||
categories: list[str] = ["kick", "drops"]
|
||||
if item.category and item.category.name:
|
||||
categories.append(item.category.name)
|
||||
if item.organization and item.organization.name:
|
||||
categories.append(item.organization.name)
|
||||
return tuple(categories)
|
||||
|
||||
def item_author_name(self, item: KickDropCampaign) -> str:
|
||||
"""Return author name from organization/category fallback."""
|
||||
if item.organization and item.organization.name:
|
||||
return item.organization.name
|
||||
if item.category and item.category.name:
|
||||
return item.category.name
|
||||
return "Kick"
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return feed URL."""
|
||||
return reverse("kick:campaign_feed")
|
||||
|
||||
|
||||
class KickCategoryCampaignFeed(TTVDropsBaseFeed):
|
||||
"""RSS feed for active campaigns under a specific Kick game."""
|
||||
|
||||
item_guid_is_permalink = True
|
||||
_limit: int | None = None
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> HttpResponse:
|
||||
"""Capture optional ?limit query parameter.
|
||||
|
||||
Returns:
|
||||
HttpResponse: The response from the parent __call__ after capturing the limit.
|
||||
"""
|
||||
if request.GET.get("limit"):
|
||||
try:
|
||||
self._limit = int(request.GET.get("limit", 200))
|
||||
except TypeError, ValueError:
|
||||
self._limit = None
|
||||
return super().__call__(request, *args, **kwargs)
|
||||
|
||||
def get_object(self, request: HttpRequest, kick_id: int) -> KickCategory: # noqa: ARG002
|
||||
"""Return game object for this feed URL."""
|
||||
return KickCategory.objects.get(kick_id=kick_id)
|
||||
|
||||
def title(self, obj: KickCategory) -> str:
|
||||
"""Return dynamic feed title for game campaigns."""
|
||||
return f"TTVDrops: {obj.name} Kick Campaigns"
|
||||
|
||||
def link(self, obj: KickCategory) -> str:
|
||||
"""Return detail URL for game."""
|
||||
return reverse("kick:game_detail", args=[obj.kick_id])
|
||||
|
||||
def description(self, obj: KickCategory) -> str:
|
||||
"""Return dynamic feed description for game campaigns."""
|
||||
return f"Active Kick drop campaigns for {obj.name}"
|
||||
|
||||
def items(self, obj: KickCategory) -> list[KickDropCampaign]:
|
||||
"""Return active campaigns for this game."""
|
||||
limit: int = self._limit if self._limit is not None else 200
|
||||
queryset: QuerySet[KickDropCampaign] = _active_campaigns(
|
||||
KickDropCampaign.objects.filter(category=obj).order_by("-starts_at"),
|
||||
)
|
||||
return list(_with_campaign_related(queryset)[:limit])
|
||||
|
||||
def item_title(self, item: KickDropCampaign) -> SafeText:
|
||||
"""Return campaign title."""
|
||||
game_name: str = item.category.name if item.category else "Kick"
|
||||
return SafeText(f"{game_name}: {item.name}")
|
||||
|
||||
def item_description(self, item: KickDropCampaign) -> SafeText:
|
||||
"""Return campaign summary HTML."""
|
||||
parts: list[SafeText] = []
|
||||
|
||||
if item.image_url:
|
||||
parts.append(
|
||||
format_html(
|
||||
'<img src="{}" alt="{}" width="160" height="160" />',
|
||||
item.image_url,
|
||||
item.name,
|
||||
),
|
||||
)
|
||||
|
||||
parts.extend(
|
||||
(
|
||||
_campaign_dates_html(item),
|
||||
_campaign_rewards_html(item),
|
||||
_campaign_channels_html(item),
|
||||
_campaign_links_html(item),
|
||||
),
|
||||
)
|
||||
|
||||
return SafeText("".join(str(p) for p in parts if p))
|
||||
|
||||
def item_link(self, item: KickDropCampaign) -> str:
|
||||
"""Return campaign detail URL."""
|
||||
return reverse("kick:campaign_detail", args=[item.kick_id])
|
||||
|
||||
def item_guid(self, item: KickDropCampaign) -> str:
|
||||
"""Return absolute GUID URL."""
|
||||
return self._absolute_url(reverse("kick:campaign_detail", args=[item.kick_id]))
|
||||
|
||||
def item_pubdate(self, item: KickDropCampaign) -> datetime.datetime:
|
||||
"""Return publication date with fallback chain."""
|
||||
if item.starts_at:
|
||||
return item.starts_at
|
||||
if item.added_at:
|
||||
return item.added_at
|
||||
return timezone.now()
|
||||
|
||||
def item_updateddate(self, item: KickDropCampaign) -> datetime.datetime:
|
||||
"""Return update date with fallback chain."""
|
||||
if item.api_updated_at:
|
||||
return item.api_updated_at
|
||||
if item.updated_at:
|
||||
return item.updated_at
|
||||
return timezone.now()
|
||||
|
||||
def item_author_name(self, item: KickDropCampaign) -> str:
|
||||
"""Return author for campaign item."""
|
||||
if item.organization and item.organization.name:
|
||||
return item.organization.name
|
||||
return "Kick"
|
||||
|
||||
def feed_url(self, obj: KickCategory) -> str:
|
||||
"""Return feed URL."""
|
||||
return reverse("kick:game_campaign_feed", args=[obj.kick_id])
|
||||
|
||||
|
||||
class KickOrganizationAtomFeed(TTVDropsAtomBaseFeed, KickOrganizationFeed):
|
||||
"""Atom feed variant for organizations."""
|
||||
|
||||
subtitle: str = KickOrganizationFeed.description
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return Atom feed URL."""
|
||||
return reverse("kick:organization_feed_atom")
|
||||
|
||||
|
||||
class KickCategoryAtomFeed(TTVDropsAtomBaseFeed, KickCategoryFeed):
|
||||
"""Atom feed variant for games."""
|
||||
|
||||
subtitle: str = KickCategoryFeed.description
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return Atom feed URL."""
|
||||
return reverse("kick:game_feed_atom")
|
||||
|
||||
|
||||
class KickCampaignAtomFeed(TTVDropsAtomBaseFeed, KickCampaignFeed):
|
||||
"""Atom feed variant for campaigns."""
|
||||
|
||||
subtitle: str = KickCampaignFeed.description
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return Atom feed URL."""
|
||||
return reverse("kick:campaign_feed_atom")
|
||||
|
||||
|
||||
class KickCategoryCampaignAtomFeed(TTVDropsAtomBaseFeed, KickCategoryCampaignFeed):
|
||||
"""Atom feed variant for game-specific campaigns."""
|
||||
|
||||
def feed_url(self, obj: KickCategory) -> str:
|
||||
"""Return Atom feed URL."""
|
||||
return reverse("kick:game_campaign_feed_atom", args=[obj.kick_id])
|
||||
|
||||
|
||||
class KickOrganizationDiscordFeed(TTVDropsAtomBaseFeed, KickOrganizationFeed):
|
||||
"""Discord (Atom) feed variant for organizations."""
|
||||
|
||||
subtitle: str = KickOrganizationFeed.description
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return Discord feed URL."""
|
||||
return reverse("kick:organization_feed_discord")
|
||||
|
||||
|
||||
class KickCategoryDiscordFeed(TTVDropsAtomBaseFeed, KickCategoryFeed):
|
||||
"""Discord (Atom) feed variant for games."""
|
||||
|
||||
subtitle: str = KickCategoryFeed.description
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return Discord feed URL."""
|
||||
return reverse("kick:game_feed_discord")
|
||||
|
||||
|
||||
class KickCampaignDiscordFeed(TTVDropsAtomBaseFeed, KickCampaignFeed):
|
||||
"""Discord (Atom) feed variant for campaigns with relative timestamps."""
|
||||
|
||||
subtitle: str = KickCampaignFeed.description
|
||||
|
||||
def item_description(self, item: KickDropCampaign) -> SafeText:
|
||||
"""Return campaign summary HTML with Discord relative timestamps."""
|
||||
parts: list[SafeText] = []
|
||||
|
||||
if item.image_url:
|
||||
parts.append(
|
||||
format_html(
|
||||
'<img src="{}" alt="{}" width="160" height="160" />',
|
||||
item.image_url,
|
||||
item.name,
|
||||
),
|
||||
)
|
||||
|
||||
parts.extend(
|
||||
(
|
||||
_campaign_dates_html(item, use_discord_relative=True),
|
||||
_campaign_rewards_html(item),
|
||||
_campaign_channels_html(item),
|
||||
_campaign_links_html(item),
|
||||
),
|
||||
)
|
||||
|
||||
return SafeText("".join(str(p) for p in parts if p))
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return Discord feed URL."""
|
||||
return reverse("kick:campaign_feed_discord")
|
||||
|
||||
|
||||
class KickCategoryCampaignDiscordFeed(TTVDropsAtomBaseFeed, KickCategoryCampaignFeed):
|
||||
"""Discord (Atom) feed variant for game-specific campaigns with relative timestamps."""
|
||||
|
||||
def item_description(self, item: KickDropCampaign) -> SafeText:
|
||||
"""Return campaign summary HTML with Discord relative timestamps."""
|
||||
parts: list[SafeText] = []
|
||||
|
||||
if item.image_url:
|
||||
parts.append(
|
||||
format_html(
|
||||
'<img src="{}" alt="{}" width="160" height="160" />',
|
||||
item.image_url,
|
||||
item.name,
|
||||
),
|
||||
)
|
||||
|
||||
parts.extend(
|
||||
(
|
||||
_campaign_dates_html(item, use_discord_relative=True),
|
||||
_campaign_rewards_html(item),
|
||||
_campaign_channels_html(item),
|
||||
_campaign_links_html(item),
|
||||
),
|
||||
)
|
||||
|
||||
return SafeText("".join(str(p) for p in parts if p))
|
||||
|
||||
def feed_url(self, obj: KickCategory) -> str:
|
||||
"""Return Discord feed URL."""
|
||||
return reverse("kick:game_campaign_feed_discord", args=[obj.kick_id])
|
||||
0
kick/management/__init__.py
Normal file
0
kick/management/__init__.py
Normal file
0
kick/management/commands/__init__.py
Normal file
0
kick/management/commands/__init__.py
Normal file
218
kick/management/commands/import_kick_drops.py
Normal file
218
kick/management/commands/import_kick_drops.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import logging
|
||||
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 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")
|
||||
|
||||
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).",
|
||||
)
|
||||
|
||||
def handle(self, *args: object, **options: object) -> None: # noqa: ARG002
|
||||
"""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:
|
||||
"""Import a single campaign and all its related objects."""
|
||||
# Organisation
|
||||
org_data: KickOrganizationSchema = data.organization
|
||||
org, created = KickOrganization.objects.update_or_create(
|
||||
kick_id=org_data.id,
|
||||
defaults={
|
||||
"name": org_data.name,
|
||||
"logo_url": org_data.logo_url,
|
||||
"url": org_data.url,
|
||||
"restricted": org_data.restricted,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
logger.info("Created new organization: %s", org.kick_id)
|
||||
|
||||
# Category
|
||||
cat_data: KickCategorySchema = data.category
|
||||
category, created = KickCategory.objects.update_or_create(
|
||||
kick_id=cat_data.id,
|
||||
defaults={
|
||||
"name": cat_data.name,
|
||||
"slug": cat_data.slug,
|
||||
"image_url": cat_data.image_url,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
logger.info("Created new category: %s", category.kick_id)
|
||||
|
||||
# Campaign
|
||||
campaign, created = KickDropCampaign.objects.update_or_create(
|
||||
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,
|
||||
},
|
||||
)
|
||||
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, created = KickUser.objects.update_or_create(
|
||||
kick_id=user_data.id,
|
||||
defaults={
|
||||
"username": user_data.username,
|
||||
"profile_picture": user_data.profile_picture,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
logger.info("Created new user: %s", user.kick_id)
|
||||
|
||||
channel, created = KickChannel.objects.update_or_create(
|
||||
kick_id=ch_data.id,
|
||||
defaults={
|
||||
"slug": ch_data.slug,
|
||||
"description": ch_data.description,
|
||||
"banner_picture_url": ch_data.banner_picture_url,
|
||||
"user": user,
|
||||
},
|
||||
)
|
||||
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, created = KickCategory.objects.get_or_create(
|
||||
kick_id=reward_data.category_id,
|
||||
defaults={"name": "", "slug": "", "image_url": ""},
|
||||
)
|
||||
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(
|
||||
kick_id=reward_data.organization_id,
|
||||
defaults={
|
||||
"name": "",
|
||||
"logo_url": "",
|
||||
"url": "",
|
||||
"restricted": False,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
logger.info("Created new organization: %s", reward_org.kick_id)
|
||||
|
||||
KickReward.objects.update_or_create(
|
||||
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,
|
||||
},
|
||||
)
|
||||
545
kick/migrations/0001_initial.py
Normal file
545
kick/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
# Generated by Django 6.0.3 on 2026-03-14 05:12
|
||||
|
||||
import auto_prefetch
|
||||
import django.db.models.deletion
|
||||
import django.db.models.manager
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Initial migration for the kick app."""
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="KickChannel",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"kick_id",
|
||||
models.PositiveBigIntegerField(
|
||||
editable=False,
|
||||
unique=True,
|
||||
verbose_name="Kick Channel ID",
|
||||
),
|
||||
),
|
||||
("slug", models.TextField(blank=True, default="", verbose_name="Slug")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Description",
|
||||
),
|
||||
),
|
||||
(
|
||||
"banner_picture_url",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="May be empty or a relative path.",
|
||||
verbose_name="Banner Picture URL",
|
||||
),
|
||||
),
|
||||
("added_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Kick Channel",
|
||||
"verbose_name_plural": "Kick Channels",
|
||||
"ordering": ["slug"],
|
||||
"abstract": False,
|
||||
"base_manager_name": "prefetch_manager",
|
||||
},
|
||||
managers=[
|
||||
("objects", django.db.models.manager.Manager()),
|
||||
("prefetch_manager", django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="KickCategory",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"kick_id",
|
||||
models.PositiveIntegerField(
|
||||
editable=False,
|
||||
help_text="Integer identifier from the Kick API.",
|
||||
unique=True,
|
||||
verbose_name="Kick Category ID",
|
||||
),
|
||||
),
|
||||
("name", models.TextField(verbose_name="Name")),
|
||||
(
|
||||
"slug",
|
||||
models.SlugField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=200,
|
||||
verbose_name="Slug",
|
||||
),
|
||||
),
|
||||
(
|
||||
"image_url",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=500,
|
||||
verbose_name="Image URL",
|
||||
),
|
||||
),
|
||||
("added_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Kick Category",
|
||||
"verbose_name_plural": "Kick Categories",
|
||||
"ordering": ["name"],
|
||||
"abstract": False,
|
||||
"base_manager_name": "prefetch_manager",
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["kick_id"],
|
||||
name="kick_kickca_kick_id_7a71c0_idx",
|
||||
),
|
||||
models.Index(fields=["name"], name="kick_kickca_name_bb9784_idx"),
|
||||
models.Index(fields=["slug"], name="kick_kickca_slug_c983b1_idx"),
|
||||
],
|
||||
},
|
||||
managers=[
|
||||
("objects", django.db.models.manager.Manager()),
|
||||
("prefetch_manager", django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="KickOrganization",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"kick_id",
|
||||
models.TextField(
|
||||
editable=False,
|
||||
help_text="ULID string identifier from the Kick API.",
|
||||
unique=True,
|
||||
verbose_name="Kick Organization ID",
|
||||
),
|
||||
),
|
||||
("name", models.TextField(verbose_name="Name")),
|
||||
(
|
||||
"logo_url",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=500,
|
||||
verbose_name="Logo URL",
|
||||
),
|
||||
),
|
||||
(
|
||||
"url",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=500,
|
||||
verbose_name="URL",
|
||||
),
|
||||
),
|
||||
(
|
||||
"restricted",
|
||||
models.BooleanField(default=False, verbose_name="Restricted"),
|
||||
),
|
||||
("added_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Kick Organization",
|
||||
"verbose_name_plural": "Kick Organizations",
|
||||
"ordering": ["name"],
|
||||
"abstract": False,
|
||||
"base_manager_name": "prefetch_manager",
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["kick_id"],
|
||||
name="kick_kickor_kick_id_c64a0d_idx",
|
||||
),
|
||||
models.Index(fields=["name"], name="kick_kickor_name_2f9336_idx"),
|
||||
],
|
||||
},
|
||||
managers=[
|
||||
("objects", django.db.models.manager.Manager()),
|
||||
("prefetch_manager", django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="KickDropCampaign",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"kick_id",
|
||||
models.TextField(
|
||||
editable=False,
|
||||
help_text="ULID string identifier from the Kick API.",
|
||||
unique=True,
|
||||
verbose_name="Kick Campaign ID",
|
||||
),
|
||||
),
|
||||
("name", models.TextField(verbose_name="Name")),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="e.g. 'active' or 'expired'.",
|
||||
max_length=50,
|
||||
verbose_name="Status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"starts_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Starts At",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ends_at",
|
||||
models.DateTimeField(blank=True, null=True, verbose_name="Ends At"),
|
||||
),
|
||||
(
|
||||
"connect_url",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="URL to link an account for the campaign.",
|
||||
max_length=500,
|
||||
verbose_name="Connect URL",
|
||||
),
|
||||
),
|
||||
(
|
||||
"url",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=500,
|
||||
verbose_name="URL",
|
||||
),
|
||||
),
|
||||
(
|
||||
"rule_id",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Rule ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"rule_name",
|
||||
models.TextField(blank=True, default="", verbose_name="Rule Name"),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When the campaign was created on Kick.",
|
||||
null=True,
|
||||
verbose_name="Created At (Kick)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"api_updated_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When the campaign was last updated on Kick.",
|
||||
null=True,
|
||||
verbose_name="Updated At (Kick)",
|
||||
),
|
||||
),
|
||||
("added_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"category",
|
||||
auto_prefetch.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="campaigns",
|
||||
to="kick.kickcategory",
|
||||
verbose_name="Category",
|
||||
),
|
||||
),
|
||||
(
|
||||
"channels",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="campaigns",
|
||||
to="kick.kickchannel",
|
||||
verbose_name="Channels",
|
||||
),
|
||||
),
|
||||
(
|
||||
"organization",
|
||||
auto_prefetch.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="campaigns",
|
||||
to="kick.kickorganization",
|
||||
verbose_name="Organization",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Kick Drop Campaign",
|
||||
"verbose_name_plural": "Kick Drop Campaigns",
|
||||
"ordering": ["-starts_at"],
|
||||
"abstract": False,
|
||||
"base_manager_name": "prefetch_manager",
|
||||
},
|
||||
managers=[
|
||||
("objects", django.db.models.manager.Manager()),
|
||||
("prefetch_manager", django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="KickReward",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"kick_id",
|
||||
models.TextField(
|
||||
editable=False,
|
||||
help_text="ULID string identifier from the Kick API.",
|
||||
unique=True,
|
||||
verbose_name="Kick Reward ID",
|
||||
),
|
||||
),
|
||||
("name", models.TextField(verbose_name="Name")),
|
||||
(
|
||||
"image_url",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="May be a relative path (e.g. 'drops/reward-image/...').",
|
||||
verbose_name="Image URL",
|
||||
),
|
||||
),
|
||||
(
|
||||
"required_units",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of watch-minutes required to earn this reward.",
|
||||
verbose_name="Required Units",
|
||||
),
|
||||
),
|
||||
("added_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"campaign",
|
||||
auto_prefetch.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="rewards",
|
||||
to="kick.kickdropcampaign",
|
||||
verbose_name="Campaign",
|
||||
),
|
||||
),
|
||||
(
|
||||
"category",
|
||||
auto_prefetch.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rewards",
|
||||
to="kick.kickcategory",
|
||||
verbose_name="Category",
|
||||
),
|
||||
),
|
||||
(
|
||||
"organization",
|
||||
auto_prefetch.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rewards",
|
||||
to="kick.kickorganization",
|
||||
verbose_name="Organization",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Kick Reward",
|
||||
"verbose_name_plural": "Kick Rewards",
|
||||
"ordering": ["required_units", "name"],
|
||||
"abstract": False,
|
||||
"base_manager_name": "prefetch_manager",
|
||||
},
|
||||
managers=[
|
||||
("objects", django.db.models.manager.Manager()),
|
||||
("prefetch_manager", django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="KickUser",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"kick_id",
|
||||
models.PositiveBigIntegerField(
|
||||
editable=False,
|
||||
unique=True,
|
||||
verbose_name="Kick User ID",
|
||||
),
|
||||
),
|
||||
("username", models.TextField(verbose_name="Username")),
|
||||
(
|
||||
"profile_picture",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=500,
|
||||
verbose_name="Profile Picture URL",
|
||||
),
|
||||
),
|
||||
("added_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Kick User",
|
||||
"verbose_name_plural": "Kick Users",
|
||||
"ordering": ["username"],
|
||||
"abstract": False,
|
||||
"base_manager_name": "prefetch_manager",
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["kick_id"],
|
||||
name="kick_kickus_kick_id_5eda21_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["username"],
|
||||
name="kick_kickus_usernam_68f5af_idx",
|
||||
),
|
||||
],
|
||||
},
|
||||
managers=[
|
||||
("objects", django.db.models.manager.Manager()),
|
||||
("prefetch_manager", django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="kickchannel",
|
||||
name="user",
|
||||
field=auto_prefetch.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="channels",
|
||||
to="kick.kickuser",
|
||||
verbose_name="User",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="kickdropcampaign",
|
||||
index=models.Index(
|
||||
fields=["kick_id"],
|
||||
name="kick_kickdr_kick_id_2d0bcf_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="kickdropcampaign",
|
||||
index=models.Index(fields=["name"], name="kick_kickdr_name_26e308_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="kickdropcampaign",
|
||||
index=models.Index(fields=["status"], name="kick_kickdr_status_fbc875_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="kickdropcampaign",
|
||||
index=models.Index(
|
||||
fields=["-starts_at"],
|
||||
name="kick_kickdr_starts__f84d8a_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="kickdropcampaign",
|
||||
index=models.Index(
|
||||
fields=["ends_at"],
|
||||
name="kick_kickdr_ends_at_ed0dbd_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="kickreward",
|
||||
index=models.Index(
|
||||
fields=["kick_id"],
|
||||
name="kick_kickre_kick_id_687d52_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="kickreward",
|
||||
index=models.Index(
|
||||
fields=["required_units"],
|
||||
name="kick_kickre_require_9953e6_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="kickchannel",
|
||||
index=models.Index(
|
||||
fields=["kick_id"],
|
||||
name="kick_kickch_kick_id_4e780e_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="kickchannel",
|
||||
index=models.Index(fields=["slug"], name="kick_kickch_slug_1a8efe_idx"),
|
||||
),
|
||||
]
|
||||
0
kick/migrations/__init__.py
Normal file
0
kick/migrations/__init__.py
Normal file
436
kick/models.py
Normal file
436
kick/models.py
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import auto_prefetch
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import datetime
|
||||
|
||||
logger: logging.Logger = logging.getLogger("ttvdrops")
|
||||
|
||||
KICK_IMAGE_BASE_URL = "https://files.kick.com/"
|
||||
|
||||
|
||||
# MARK: KickOrganization
|
||||
class KickOrganization(auto_prefetch.Model):
|
||||
"""Represents an organization on Kick that owns drop campaigns."""
|
||||
|
||||
kick_id = models.TextField(
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name="Kick Organization ID",
|
||||
help_text="ULID string identifier from the Kick API.",
|
||||
)
|
||||
name = models.TextField(verbose_name="Name")
|
||||
logo_url = models.URLField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Logo URL",
|
||||
)
|
||||
url = models.URLField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="URL",
|
||||
)
|
||||
restricted = models.BooleanField(default=False, verbose_name="Restricted")
|
||||
|
||||
added_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
|
||||
class Meta(auto_prefetch.Model.Meta):
|
||||
ordering = ["name"]
|
||||
verbose_name = "Kick Organization"
|
||||
verbose_name_plural = "Kick Organizations"
|
||||
indexes = [
|
||||
models.Index(fields=["kick_id"]),
|
||||
models.Index(fields=["name"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.kick_id
|
||||
|
||||
|
||||
# MARK: KickCategory
|
||||
class KickCategory(auto_prefetch.Model):
|
||||
"""Represents a game/category on Kick."""
|
||||
|
||||
kick_id = models.PositiveIntegerField(
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name="Kick Category ID",
|
||||
help_text="Integer identifier from the Kick API.",
|
||||
)
|
||||
name = models.TextField(verbose_name="Name")
|
||||
slug = models.SlugField(max_length=200, blank=True, default="", verbose_name="Slug")
|
||||
image_url = models.URLField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Image URL",
|
||||
)
|
||||
|
||||
added_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
|
||||
class Meta(auto_prefetch.Model.Meta):
|
||||
ordering = ["name"]
|
||||
verbose_name = "Kick Category"
|
||||
verbose_name_plural = "Kick Categories"
|
||||
indexes = [
|
||||
models.Index(fields=["kick_id"]),
|
||||
models.Index(fields=["name"]),
|
||||
models.Index(fields=["slug"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or str(self.kick_id)
|
||||
|
||||
@property
|
||||
def get_absolute_url(self) -> str:
|
||||
"""Return the URL to the game detail page."""
|
||||
return reverse("kick:game_detail", args=[self.kick_id])
|
||||
|
||||
@property
|
||||
def kick_url(self) -> str:
|
||||
"""Return the URL to the game page on Kick."""
|
||||
return f"https://kick.com/category/{self.slug}" if self.slug else ""
|
||||
|
||||
|
||||
# MARK: KickUser
|
||||
class KickUser(auto_prefetch.Model):
|
||||
"""Represents a Kick user associated with a channel."""
|
||||
|
||||
kick_id = models.PositiveBigIntegerField(
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name="Kick User ID",
|
||||
)
|
||||
username = models.TextField(verbose_name="Username")
|
||||
profile_picture = models.URLField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Profile Picture URL",
|
||||
)
|
||||
|
||||
added_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
|
||||
class Meta(auto_prefetch.Model.Meta):
|
||||
ordering = ["username"]
|
||||
verbose_name = "Kick User"
|
||||
verbose_name_plural = "Kick Users"
|
||||
indexes = [
|
||||
models.Index(fields=["kick_id"]),
|
||||
models.Index(fields=["username"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.username or str(self.kick_id)
|
||||
|
||||
@property
|
||||
def kick_profile_url(self) -> str:
|
||||
"""Return the Kick profile URL for this user."""
|
||||
return f"https://kick.com/{self.username}" if self.username else ""
|
||||
|
||||
|
||||
# MARK: KickChannel
|
||||
class KickChannel(auto_prefetch.Model):
|
||||
"""Represents a Kick channel that participates in drop campaigns."""
|
||||
|
||||
kick_id = models.PositiveBigIntegerField(
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name="Kick Channel ID",
|
||||
)
|
||||
slug = models.TextField(blank=True, default="", verbose_name="Slug")
|
||||
description = models.TextField(blank=True, default="", verbose_name="Description")
|
||||
banner_picture_url = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Banner Picture URL",
|
||||
help_text="May be empty or a relative path.",
|
||||
)
|
||||
user = auto_prefetch.ForeignKey(
|
||||
KickUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="channels",
|
||||
verbose_name="User",
|
||||
)
|
||||
|
||||
added_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
|
||||
class Meta(auto_prefetch.Model.Meta):
|
||||
ordering = ["slug"]
|
||||
verbose_name = "Kick Channel"
|
||||
verbose_name_plural = "Kick Channels"
|
||||
indexes = [
|
||||
models.Index(fields=["kick_id"]),
|
||||
models.Index(fields=["slug"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.slug or str(self.kick_id)
|
||||
|
||||
@property
|
||||
def channel_url(self) -> str:
|
||||
"""Return the Kick channel URL."""
|
||||
return f"https://kick.com/{self.slug}" if self.slug else ""
|
||||
|
||||
|
||||
# MARK: KickDropCampaign
|
||||
class KickDropCampaign(auto_prefetch.Model):
|
||||
"""Represents a Kick drop campaign."""
|
||||
|
||||
kick_id = models.TextField(
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name="Kick Campaign ID",
|
||||
help_text="ULID string identifier from the Kick API.",
|
||||
)
|
||||
name = models.TextField(verbose_name="Name")
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Status",
|
||||
help_text="e.g. 'active' or 'expired'.",
|
||||
)
|
||||
starts_at = models.DateTimeField(null=True, blank=True, verbose_name="Starts At")
|
||||
ends_at = models.DateTimeField(null=True, blank=True, verbose_name="Ends At")
|
||||
connect_url = models.URLField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Connect URL",
|
||||
help_text="URL to link an account for the campaign.",
|
||||
)
|
||||
url = models.URLField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="URL",
|
||||
)
|
||||
rule_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Rule ID")
|
||||
rule_name = models.TextField(blank=True, default="", verbose_name="Rule Name")
|
||||
|
||||
organization = auto_prefetch.ForeignKey(
|
||||
KickOrganization,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="campaigns",
|
||||
verbose_name="Organization",
|
||||
)
|
||||
category = auto_prefetch.ForeignKey(
|
||||
KickCategory,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="campaigns",
|
||||
verbose_name="Category",
|
||||
)
|
||||
channels = models.ManyToManyField(
|
||||
KickChannel,
|
||||
blank=True,
|
||||
related_name="campaigns",
|
||||
verbose_name="Channels",
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Created At (Kick)",
|
||||
help_text="When the campaign was created on Kick.",
|
||||
)
|
||||
api_updated_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Updated At (Kick)",
|
||||
help_text="When the campaign was last updated on Kick.",
|
||||
)
|
||||
added_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
|
||||
class Meta(auto_prefetch.Model.Meta):
|
||||
ordering = ["-starts_at"]
|
||||
verbose_name = "Kick Drop Campaign"
|
||||
verbose_name_plural = "Kick Drop Campaigns"
|
||||
indexes = [
|
||||
models.Index(fields=["kick_id"]),
|
||||
models.Index(fields=["name"]),
|
||||
models.Index(fields=["status"]),
|
||||
models.Index(fields=["-starts_at"]),
|
||||
models.Index(fields=["ends_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.kick_id
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if the campaign is currently active based on dates."""
|
||||
now: datetime.datetime = timezone.now()
|
||||
if self.starts_at is None or self.ends_at is None:
|
||||
return False
|
||||
return self.starts_at <= now <= self.ends_at
|
||||
|
||||
@property
|
||||
def image_url(self) -> str:
|
||||
"""Return the image URL for the campaign."""
|
||||
# Image from first drop
|
||||
if self.rewards.exists(): # pyright: ignore[reportAttributeAccessIssue]
|
||||
first_reward: KickReward = self.rewards.first() # pyright: ignore[reportAttributeAccessIssue]
|
||||
if first_reward and first_reward.image_url:
|
||||
return first_reward.full_image_url
|
||||
|
||||
if self.category and self.category.image_url:
|
||||
return self.category.image_url
|
||||
|
||||
if self.organization and self.organization.logo_url:
|
||||
return self.organization.logo_url
|
||||
|
||||
return ""
|
||||
|
||||
@property
|
||||
def duration(self) -> str | None:
|
||||
"""Human-readable duration of the campaign, or None if not available.
|
||||
|
||||
Uses Django's timesince filter format (e.g. "2 days, 3 hours").
|
||||
"""
|
||||
if self.starts_at and self.ends_at:
|
||||
delta: datetime.timedelta = self.ends_at - self.starts_at
|
||||
total_seconds: int = int(delta.total_seconds())
|
||||
days, remainder = divmod(total_seconds, 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, _ = divmod(remainder, 60)
|
||||
|
||||
parts: list[str] = []
|
||||
if days > 0:
|
||||
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||
if hours > 0:
|
||||
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||
if minutes > 0:
|
||||
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||
|
||||
return ", ".join(parts) if parts else "0 minutes"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalized_reward_name(name: str) -> str:
|
||||
"""Normalize reward names to merge console/connected variants.
|
||||
|
||||
Some Kick rewards appear twice with and without a trailing "(Con)" marker,
|
||||
and occasionally differ only by spacing around punctuation like "&".
|
||||
|
||||
Returns:
|
||||
A normalized, case-insensitive reward name key.
|
||||
"""
|
||||
normalized: str = name.strip()
|
||||
normalized = re.sub(r"\s*\(con\)\s*$", "", normalized, flags=re.IGNORECASE)
|
||||
normalized = re.sub(r"\s*&\s*", " & ", normalized)
|
||||
normalized = re.sub(r"\s+", " ", normalized)
|
||||
return normalized.casefold()
|
||||
|
||||
@property
|
||||
def merged_rewards(self) -> list[KickReward]:
|
||||
"""Return rewards de-duplicated by normalized name.
|
||||
|
||||
If both a base reward and a "(Con)" variant exist, prefer the base reward name.
|
||||
"""
|
||||
rewards_by_name: dict[str, KickReward] = {}
|
||||
for reward in self.rewards.all().order_by("required_units", "name", "kick_id"): # pyright: ignore[reportAttributeAccessIssue]
|
||||
key: str = self._normalized_reward_name(reward.name)
|
||||
existing: KickReward | None = rewards_by_name.get(key)
|
||||
if existing is None:
|
||||
rewards_by_name[key] = reward
|
||||
continue
|
||||
|
||||
existing_is_con: bool = existing.name.strip().casefold().endswith("(con)")
|
||||
reward_is_con: bool = reward.name.strip().casefold().endswith("(con)")
|
||||
if existing_is_con and not reward_is_con:
|
||||
rewards_by_name[key] = reward
|
||||
|
||||
return list(rewards_by_name.values())
|
||||
|
||||
|
||||
# MARK: KickReward
|
||||
class KickReward(auto_prefetch.Model):
|
||||
"""Represents a reward that can be earned from a Kick drop campaign."""
|
||||
|
||||
kick_id = models.TextField(
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name="Kick Reward ID",
|
||||
help_text="ULID string identifier from the Kick API.",
|
||||
)
|
||||
name = models.TextField(verbose_name="Name")
|
||||
image_url = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Image URL",
|
||||
help_text="May be a relative path (e.g. 'drops/reward-image/...').",
|
||||
)
|
||||
required_units = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Required Units",
|
||||
help_text="Number of watch-minutes required to earn this reward.",
|
||||
)
|
||||
campaign = auto_prefetch.ForeignKey(
|
||||
KickDropCampaign,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="rewards",
|
||||
verbose_name="Campaign",
|
||||
)
|
||||
category = auto_prefetch.ForeignKey(
|
||||
KickCategory,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rewards",
|
||||
verbose_name="Category",
|
||||
)
|
||||
organization = auto_prefetch.ForeignKey(
|
||||
KickOrganization,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rewards",
|
||||
verbose_name="Organization",
|
||||
)
|
||||
|
||||
added_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
|
||||
class Meta(auto_prefetch.Model.Meta):
|
||||
ordering = ["required_units", "name"]
|
||||
verbose_name = "Kick Reward"
|
||||
verbose_name_plural = "Kick Rewards"
|
||||
indexes = [
|
||||
models.Index(fields=["kick_id"]),
|
||||
models.Index(fields=["required_units"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.kick_id
|
||||
|
||||
@property
|
||||
def full_image_url(self) -> str:
|
||||
"""Return the absolute image URL for this reward.
|
||||
|
||||
If the image_url is a relative path, prepend the Kick image base URL.
|
||||
"""
|
||||
if not self.image_url:
|
||||
return ""
|
||||
if self.image_url.startswith("http"):
|
||||
return self.image_url
|
||||
return "https://ext.cdn.kick.com/" + self.image_url
|
||||
126
kick/schemas.py
Normal file
126
kick/schemas.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class KickUserSchema(BaseModel):
|
||||
"""Schema for the user nested inside a Kick channel."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
profile_picture: str = ""
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class KickChannelSchema(BaseModel):
|
||||
"""Schema for a Kick channel participating in a drop campaign."""
|
||||
|
||||
id: int
|
||||
slug: str = ""
|
||||
description: str = ""
|
||||
banner_picture_url: str = ""
|
||||
user: KickUserSchema
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class KickCategorySchema(BaseModel):
|
||||
"""Schema for a Kick category (game)."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
slug: str = ""
|
||||
image_url: str = ""
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class KickOrganizationSchema(BaseModel):
|
||||
"""Schema for a Kick organization that owns drop campaigns."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
logo_url: str = ""
|
||||
url: str = ""
|
||||
restricted: bool = False
|
||||
email_notification: bool = False
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class KickRewardSchema(BaseModel):
|
||||
"""Schema for a reward earned from a Kick drop campaign."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
image_url: str = ""
|
||||
required_units: int
|
||||
category_id: int
|
||||
organization_id: str
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class KickRuleSchema(BaseModel):
|
||||
"""Schema for the rule attached to a drop campaign."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class KickDropCampaignSchema(BaseModel):
|
||||
"""Schema for a Kick drop campaign."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
status: str = ""
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
connect_url: str = ""
|
||||
url: str = ""
|
||||
category: KickCategorySchema
|
||||
organization: KickOrganizationSchema
|
||||
channels: list[KickChannelSchema] = Field(default_factory=list)
|
||||
rewards: list[KickRewardSchema] = Field(default_factory=list)
|
||||
rule: KickRuleSchema
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class KickDropsResponseSchema(BaseModel):
|
||||
"""Schema for the top-level response from the Kick drops API."""
|
||||
|
||||
data: list[KickDropCampaignSchema]
|
||||
message: str = ""
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"populate_by_name": True,
|
||||
}
|
||||
0
kick/tests/__init__.py
Normal file
0
kick/tests/__init__.py
Normal file
935
kick/tests/test_kick.py
Normal file
935
kick/tests/test_kick.py
Normal file
|
|
@ -0,0 +1,935 @@
|
|||
import re
|
||||
from datetime import UTC
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from pydantic import ValidationError
|
||||
|
||||
from kick.feeds import discord_timestamp
|
||||
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 KickDropCampaignSchema
|
||||
from kick.schemas import KickDropsResponseSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.test.client import _MonkeyPatchedWSGIResponse
|
||||
|
||||
from kick.schemas import KickDropCampaignSchema
|
||||
from kick.schemas import KickRewardSchema
|
||||
|
||||
# Minimal valid campaign fixture (single campaign, active status)
|
||||
SINGLE_CAMPAIGN_JSON: dict[str, list[dict[str, Any]] | str] = {
|
||||
"data": [
|
||||
{
|
||||
"id": "01KKBNEM8TZG7ASRG42TK7RKRB",
|
||||
"name": "PUBG 9th Anniversary",
|
||||
"status": "active",
|
||||
"starts_at": "2026-03-11T08:00:00Z",
|
||||
"ends_at": "2026-03-31T07:59:00Z",
|
||||
"created_at": "2026-03-10T10:42:29.793929Z",
|
||||
"updated_at": "2026-03-10T10:42:30.248864Z",
|
||||
"connect_url": "https://accounts.krafton.com/auth/kick/callback",
|
||||
"url": "https://accounts.krafton.com",
|
||||
"category": {
|
||||
"id": 53,
|
||||
"name": "PUBG: Battlegrounds",
|
||||
"slug": "pubg-battlegrounds",
|
||||
"image_url": "https://files.kick.com/images/subcategories/53/banner/pubg-battlegrounds.jpg",
|
||||
},
|
||||
"organization": {
|
||||
"id": "01KFF1CP2Q2X6EFDN9M85M3M97",
|
||||
"name": "KRAFTON",
|
||||
"logo_url": "https://krafton.com/wp-content/uploads/2021/06/logo-krafton-brandcenter.png",
|
||||
"url": "https://krafton.com",
|
||||
"restricted": False,
|
||||
"email_notification": False,
|
||||
},
|
||||
"channels": [],
|
||||
"rewards": [
|
||||
{
|
||||
"id": "01KKBM2YDPSBJD437HF7CDPTQT",
|
||||
"name": "9th Anniversary Flamin' Cake",
|
||||
"image_url": "drops/reward-image/01kkbm2ydpsbjd437hf7cdptqt-01kkbm2ydpsbjd437hf8yhrzqm.png",
|
||||
"required_units": 30,
|
||||
"category_id": 53,
|
||||
"organization_id": "01KFF1CP2Q2X6EFDN9M85M3M97",
|
||||
},
|
||||
],
|
||||
"rule": {"id": 1, "name": "Watch to redeem"},
|
||||
},
|
||||
],
|
||||
"message": "Success",
|
||||
}
|
||||
|
||||
# Campaign with channels fixture
|
||||
CAMPAIGN_WITH_CHANNELS_JSON: dict[str, list[dict[str, Any]] | str] = {
|
||||
"data": [
|
||||
{
|
||||
"id": "01K8X4WHMVTF4HQNT9RRTGBACK",
|
||||
"name": "Team Ricoy MP5",
|
||||
"status": "expired",
|
||||
"starts_at": "2025-11-13T21:00:00Z",
|
||||
"ends_at": "2025-11-23T23:59:00Z",
|
||||
"created_at": "2025-10-31T12:46:39.78377Z",
|
||||
"updated_at": "2025-11-05T20:42:33.761611Z",
|
||||
"connect_url": "https://kick.facepunch.com",
|
||||
"url": "https://kick.facepunch.com",
|
||||
"category": {
|
||||
"id": 13,
|
||||
"name": "Rust",
|
||||
"slug": "rust",
|
||||
"image_url": "https://files.kick.com/images/subcategories/13/banner/77786fd4-4f33-4221-b8ee-2bf3cb4d041e",
|
||||
},
|
||||
"organization": {
|
||||
"id": "01K6WKP5BBMPZJ89G5Y7QK1E9P",
|
||||
"name": "Facepunch Studios",
|
||||
"logo_url": "https://images.squarespace-cdn.com/content/v1/627cb6fa4355783e5e375440/b47ec50e-bc8b-4801-87cb-dee12da27748/default-light.png?format=1500w",
|
||||
"url": "https://kick.facepunch.com",
|
||||
"restricted": False,
|
||||
"email_notification": False,
|
||||
},
|
||||
"channels": [
|
||||
{
|
||||
"id": 1661881,
|
||||
"slug": "ricoy",
|
||||
"description": "",
|
||||
"banner_picture_url": "https://files.kick.com/images/channel/1661881/banner_image/default-banner-2.jpg",
|
||||
"user": {
|
||||
"id": 1711034,
|
||||
"username": "Ricoy",
|
||||
"profile_picture": "https://files.kick.com/images/user/1711034/profile_image/conversion/101aa7a7-6f75-43d2-a0d3-8fde7aa7f664-medium.webp",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": 969446,
|
||||
"slug": "dilanzito",
|
||||
"description": "",
|
||||
"banner_picture_url": "https://files.kick.com/images/channel/969446/banner_image/default-banner-2.jpg",
|
||||
"user": {
|
||||
"id": 1011977,
|
||||
"username": "dilanzito",
|
||||
"profile_picture": "https://files.kick.com/images/user/1011977/profile_image/conversion/32a59b29-c319-4210-b5a0-ac14dd112f13-medium.webp",
|
||||
},
|
||||
},
|
||||
],
|
||||
"rewards": [
|
||||
{
|
||||
"id": "01K8X3P1D3AE2PNEBP1VJCVXAR",
|
||||
"name": "Team Ricoy MP5",
|
||||
"image_url": "drops/reward-image/01k8x3p1d3ae2pnebp1vjcvxar.png",
|
||||
"required_units": 120,
|
||||
"category_id": 13,
|
||||
"organization_id": "01K6WKP5BBMPZJ89G5Y7QK1E9P",
|
||||
},
|
||||
],
|
||||
"rule": {"id": 1, "name": "Watch to redeem"},
|
||||
},
|
||||
],
|
||||
"message": "Success",
|
||||
}
|
||||
|
||||
|
||||
# MARK: Schema tests
|
||||
class KickDropsResponseSchemaTest(TestCase):
|
||||
"""Tests for Pydantic schema validation of the Kick drops API response."""
|
||||
|
||||
def test_valid_single_campaign(self) -> None:
|
||||
"""Schema validates a minimal valid campaign without channels."""
|
||||
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
|
||||
SINGLE_CAMPAIGN_JSON,
|
||||
)
|
||||
assert result.message == "Success"
|
||||
assert len(result.data) == 1
|
||||
campaign: KickDropCampaignSchema = result.data[0]
|
||||
assert campaign.id == "01KKBNEM8TZG7ASRG42TK7RKRB"
|
||||
assert campaign.name == "PUBG 9th Anniversary"
|
||||
assert campaign.status == "active"
|
||||
assert len(campaign.channels) == 0
|
||||
assert len(campaign.rewards) == 1
|
||||
|
||||
def test_campaign_with_channels(self) -> None:
|
||||
"""Schema validates a campaign that includes channel data."""
|
||||
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
|
||||
CAMPAIGN_WITH_CHANNELS_JSON,
|
||||
)
|
||||
campaign: KickDropCampaignSchema = result.data[0]
|
||||
assert len(campaign.channels) == 2
|
||||
assert campaign.channels[0].slug == "ricoy"
|
||||
assert campaign.channels[0].user.username == "Ricoy"
|
||||
|
||||
def test_reward_fields(self) -> None:
|
||||
"""Reward fields parse correctly including relative image URL."""
|
||||
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
|
||||
SINGLE_CAMPAIGN_JSON,
|
||||
)
|
||||
reward: KickRewardSchema = result.data[0].rewards[0]
|
||||
assert reward.id == "01KKBM2YDPSBJD437HF7CDPTQT"
|
||||
assert reward.required_units == 30
|
||||
assert "drops/reward-image/" in reward.image_url
|
||||
|
||||
def test_empty_channels_list(self) -> None:
|
||||
"""Schema accepts an empty channels list."""
|
||||
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
|
||||
SINGLE_CAMPAIGN_JSON,
|
||||
)
|
||||
assert result.data[0].channels == []
|
||||
|
||||
def test_extra_fields_rejected(self) -> None:
|
||||
"""Extra fields in the API response cause a ValidationError."""
|
||||
bad_payload: dict[str, str | list] = {
|
||||
"data": [],
|
||||
"message": "Success",
|
||||
"unexpected_field": "oops",
|
||||
}
|
||||
with pytest.raises(ValidationError):
|
||||
KickDropsResponseSchema.model_validate(bad_payload)
|
||||
|
||||
|
||||
# MARK: Model tests
|
||||
class KickRewardFullImageUrlTest(TestCase):
|
||||
"""Tests for KickReward.full_image_url property."""
|
||||
|
||||
def _make_reward(self, image_url: str) -> KickReward:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-1",
|
||||
name="Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=99,
|
||||
name="Cat",
|
||||
slug="cat",
|
||||
)
|
||||
campaign: KickDropCampaign = KickDropCampaign.objects.create(
|
||||
kick_id="camp-1",
|
||||
name="Test Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
return KickReward.objects.create(
|
||||
kick_id="reward-1",
|
||||
name="Reward",
|
||||
image_url=image_url,
|
||||
required_units=60,
|
||||
campaign=campaign,
|
||||
category=cat,
|
||||
organization=org,
|
||||
)
|
||||
|
||||
def test_relative_image_url_gets_base_prefix(self) -> None:
|
||||
"""If image_url is relative, full_image_url should prepend the Kick files base URL."""
|
||||
reward: KickReward = self._make_reward("drops/reward-image/abc.png")
|
||||
assert (
|
||||
reward.full_image_url
|
||||
== "https://ext.cdn.kick.com/drops/reward-image/abc.png"
|
||||
)
|
||||
|
||||
def test_absolute_image_url_unchanged(self) -> None:
|
||||
"""If image_url is already absolute, full_image_url should return it unchanged."""
|
||||
reward: KickReward = self._make_reward("https://example.com/image.png")
|
||||
assert reward.full_image_url == "https://example.com/image.png"
|
||||
|
||||
def test_empty_image_url_returns_empty(self) -> None:
|
||||
"""If image_url is empty, full_image_url should also be empty."""
|
||||
reward: KickReward = self._make_reward("")
|
||||
assert not reward.full_image_url
|
||||
|
||||
|
||||
class KickChannelUrlTest(TestCase):
|
||||
"""Tests for KickChannel.channel_url property."""
|
||||
|
||||
def test_url_with_slug(self) -> None:
|
||||
"""If slug is present, channel_url should return the full Kick channel URL."""
|
||||
user: KickUser = KickUser.objects.create(kick_id=1, username="testuser")
|
||||
channel: KickChannel = KickChannel.objects.create(
|
||||
kick_id=100,
|
||||
slug="testuser",
|
||||
user=user,
|
||||
)
|
||||
assert channel.channel_url == "https://kick.com/testuser"
|
||||
|
||||
def test_url_without_slug(self) -> None:
|
||||
"""If slug is empty, channel_url should return an empty string."""
|
||||
channel: KickChannel = KickChannel.objects.create(kick_id=101, slug="")
|
||||
assert not channel.channel_url
|
||||
|
||||
|
||||
class KickDropCampaignIsActiveTest(TestCase):
|
||||
"""Tests for KickDropCampaign.is_active property."""
|
||||
|
||||
def _make_campaign(
|
||||
self,
|
||||
starts_at: dt | None,
|
||||
ends_at: dt | None,
|
||||
) -> KickDropCampaign:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-active",
|
||||
name="Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=1,
|
||||
name="Cat",
|
||||
slug="cat",
|
||||
)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id="camp-active",
|
||||
name="Active Campaign",
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_no_dates_is_not_active(self) -> None:
|
||||
"""If starts_at and ends_at are None, is_active should return False."""
|
||||
campaign: KickDropCampaign = self._make_campaign(None, None)
|
||||
assert not campaign.is_active
|
||||
|
||||
def test_expired_campaign_is_not_active(self) -> None:
|
||||
"""If current date is past ends_at, is_active should return False."""
|
||||
campaign: KickDropCampaign = self._make_campaign(
|
||||
dt(2020, 1, 1, tzinfo=UTC),
|
||||
dt(2020, 12, 31, tzinfo=UTC),
|
||||
)
|
||||
assert not campaign.is_active
|
||||
|
||||
def test_future_campaign_is_not_active(self) -> None:
|
||||
"""If current date is before starts_at, is_active should return False."""
|
||||
campaign: KickDropCampaign = self._make_campaign(
|
||||
dt(2099, 1, 1, tzinfo=UTC),
|
||||
dt(2099, 12, 31, tzinfo=UTC),
|
||||
)
|
||||
assert not campaign.is_active
|
||||
|
||||
|
||||
class KickDropCampaignMergedRewardsTest(TestCase):
|
||||
"""Tests for KickDropCampaign.merged_rewards property."""
|
||||
|
||||
def _make_campaign(self) -> KickDropCampaign:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-merge",
|
||||
name="Merge Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=801,
|
||||
name="Merge Cat",
|
||||
slug="merge-cat",
|
||||
)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id="camp-merge",
|
||||
name="Merge Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_merges_reward_with_con_suffix(self) -> None:
|
||||
"""Rewards with and without '(Con)' should be treated as one entry."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-base",
|
||||
name="Anniversary Cake",
|
||||
image_url="drops/reward-image/base.png",
|
||||
required_units=30,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-con",
|
||||
name="Anniversary Cake (Con)",
|
||||
image_url="drops/reward-image/con.png",
|
||||
required_units=30,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
|
||||
merged: list[KickReward] = campaign.merged_rewards
|
||||
assert len(merged) == 1
|
||||
assert merged[0].name == "Anniversary Cake"
|
||||
|
||||
def test_keeps_con_name_when_only_variant_available(self) -> None:
|
||||
"""If only '(Con)' exists, it should still appear in merged rewards."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-only-con",
|
||||
name="Anniversary Cake(Con)",
|
||||
image_url="drops/reward-image/con-only.png",
|
||||
required_units=30,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
|
||||
merged: list[KickReward] = campaign.merged_rewards
|
||||
assert len(merged) == 1
|
||||
assert merged[0].name == "Anniversary Cake(Con)"
|
||||
|
||||
def test_merges_rewards_with_ampersand_spacing_difference(self) -> None:
|
||||
"""Rewards that only differ by ampersand spacing should be treated as one entry."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-anniversary-base",
|
||||
name="9th Anniversary Cake & Confetti",
|
||||
image_url="drops/reward-image/cake-confetti-base.png",
|
||||
required_units=60,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-anniversary-con",
|
||||
name="9th Anniversary Cake&Confetti (Con)",
|
||||
image_url="drops/reward-image/cake-confetti-con.png",
|
||||
required_units=60,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
|
||||
merged: list[KickReward] = campaign.merged_rewards
|
||||
assert len(merged) == 1
|
||||
assert merged[0].name == "9th Anniversary Cake & Confetti"
|
||||
|
||||
|
||||
# MARK: Management command tests
|
||||
class ImportKickDropsCommandTest(TestCase):
|
||||
"""Tests for the import_kick_drops management command."""
|
||||
|
||||
def _run_command(self, json_payload: dict) -> tuple[str, str]:
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = json_payload
|
||||
mock_response.raise_for_status.return_value = None
|
||||
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
with patch(
|
||||
"kick.management.commands.import_kick_drops.httpx.get",
|
||||
return_value=mock_response,
|
||||
):
|
||||
call_command("import_kick_drops", stdout=stdout, stderr=stderr)
|
||||
return stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def test_imports_single_campaign(self) -> None:
|
||||
"""Command creates campaign and related objects from valid API response."""
|
||||
self._run_command(SINGLE_CAMPAIGN_JSON)
|
||||
assert KickDropCampaign.objects.count() == 1
|
||||
assert KickOrganization.objects.count() == 1
|
||||
assert KickCategory.objects.count() == 1
|
||||
assert KickReward.objects.count() == 1
|
||||
|
||||
campaign: KickDropCampaign = KickDropCampaign.objects.get()
|
||||
assert campaign.name == "PUBG 9th Anniversary"
|
||||
assert campaign.status == "active"
|
||||
assert campaign.organization.name == "KRAFTON"
|
||||
assert campaign.category.name == "PUBG: Battlegrounds"
|
||||
|
||||
def test_imports_campaign_with_channels(self) -> None:
|
||||
"""Command creates channels and links them to the campaign."""
|
||||
self._run_command(CAMPAIGN_WITH_CHANNELS_JSON)
|
||||
campaign: KickDropCampaign = KickDropCampaign.objects.get()
|
||||
assert campaign.channels.count() == 2
|
||||
assert KickUser.objects.count() == 2
|
||||
assert KickChannel.objects.count() == 2
|
||||
slugs: set[str] = set(campaign.channels.values_list("slug", flat=True))
|
||||
assert slugs == {"ricoy", "dilanzito"}
|
||||
|
||||
def test_import_is_idempotent(self) -> None:
|
||||
"""Running the import twice does not duplicate records."""
|
||||
self._run_command(SINGLE_CAMPAIGN_JSON)
|
||||
self._run_command(SINGLE_CAMPAIGN_JSON)
|
||||
assert KickDropCampaign.objects.count() == 1
|
||||
assert KickOrganization.objects.count() == 1
|
||||
assert KickReward.objects.count() == 1
|
||||
|
||||
def test_http_error_is_handled_gracefully(self) -> None:
|
||||
"""HTTP error during fetch writes to stderr and does not crash."""
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
with patch(
|
||||
"kick.management.commands.import_kick_drops.httpx.get",
|
||||
side_effect=httpx.HTTPError("connection refused"),
|
||||
):
|
||||
call_command("import_kick_drops", stdout=stdout, stderr=stderr)
|
||||
|
||||
assert "HTTP error" in stderr.getvalue()
|
||||
assert KickDropCampaign.objects.count() == 0
|
||||
|
||||
def test_validation_error_is_handled_gracefully(self) -> None:
|
||||
"""Invalid JSON structure writes to stderr and does not crash."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"totally": "wrong"}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
with patch(
|
||||
"kick.management.commands.import_kick_drops.httpx.get",
|
||||
return_value=mock_response,
|
||||
):
|
||||
call_command("import_kick_drops", stdout=stdout, stderr=stderr)
|
||||
|
||||
assert "validation failed" in stderr.getvalue()
|
||||
assert KickDropCampaign.objects.count() == 0
|
||||
|
||||
def test_success_message_printed(self) -> None:
|
||||
"""Success output should report the number of campaigns imported."""
|
||||
stdout, _ = self._run_command(SINGLE_CAMPAIGN_JSON)
|
||||
assert "1/1" in stdout
|
||||
|
||||
|
||||
# MARK: View tests
|
||||
class KickDashboardViewTest(TestCase):
|
||||
"""Tests for the kick dashboard view."""
|
||||
|
||||
def _make_active_campaign(self) -> KickDropCampaign:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-view-1",
|
||||
name="Org View",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=200,
|
||||
name="View Cat",
|
||||
slug="view-cat",
|
||||
)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id="camp-view-1",
|
||||
name="Active View Campaign",
|
||||
status="active",
|
||||
starts_at=dt(2020, 1, 1, tzinfo=UTC),
|
||||
ends_at=dt(2099, 12, 31, tzinfo=UTC),
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_dashboard_returns_200(self) -> None:
|
||||
"""Dashboard view should return HTTP 200 status code."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:dashboard"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_dashboard_shows_active_campaigns(self) -> None:
|
||||
"""Active campaigns should be displayed on the dashboard."""
|
||||
campaign: KickDropCampaign = self._make_active_campaign()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:dashboard"),
|
||||
)
|
||||
assert campaign.name in response.content.decode()
|
||||
|
||||
|
||||
class KickCampaignListViewTest(TestCase):
|
||||
"""Tests for the kick campaign list view."""
|
||||
|
||||
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)
|
||||
ends_at = dt(2099, 12, 31, tzinfo=UTC)
|
||||
else:
|
||||
starts_at = dt(2020, 1, 1, tzinfo=UTC)
|
||||
ends_at = dt(2020, 12, 31, tzinfo=UTC)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id=kick_id,
|
||||
name=name,
|
||||
status=status,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_campaign_list_returns_200(self) -> None:
|
||||
"""Campaign list view should return HTTP 200 status code."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_list"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_campaign_list_shows_campaigns(self) -> None:
|
||||
"""Campaigns should be displayed in the campaign list view."""
|
||||
campaign: KickDropCampaign = self._make_campaign("camp-list-1", "List Campaign")
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_list"),
|
||||
)
|
||||
assert campaign.name in response.content.decode()
|
||||
|
||||
def test_campaign_list_status_filter(self) -> None:
|
||||
"""Filtering by status should show only campaigns with that status."""
|
||||
active: KickDropCampaign = self._make_campaign(
|
||||
"camp-list-a",
|
||||
"Active Camp",
|
||||
status="active",
|
||||
)
|
||||
expired: KickDropCampaign = self._make_campaign(
|
||||
"camp-list-e",
|
||||
"Expired Camp",
|
||||
status="expired",
|
||||
)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_list") + "?status=active",
|
||||
)
|
||||
content: str = response.content.decode()
|
||||
assert active.name in content
|
||||
assert expired.name not in content
|
||||
|
||||
|
||||
class KickCampaignDetailViewTest(TestCase):
|
||||
"""Tests for the kick campaign detail view."""
|
||||
|
||||
def _make_campaign(self) -> KickDropCampaign:
|
||||
"""Helper method to create a campaign with related org and category for detail view tests.
|
||||
|
||||
Returns:
|
||||
KickDropCampaign: The created campaign instance.
|
||||
"""
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-det-1",
|
||||
name="Detail Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=400,
|
||||
name="Detail Cat",
|
||||
slug="detail-cat",
|
||||
)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id="camp-det-1",
|
||||
name="Detail Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_campaign_detail_returns_200(self) -> None:
|
||||
"""Campaign detail view should return HTTP 200 status code for existing campaign."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_detail", kwargs={"kick_id": campaign.kick_id}),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_campaign_detail_shows_name(self) -> None:
|
||||
"""Campaign detail view should display the campaign name."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_detail", kwargs={"kick_id": campaign.kick_id}),
|
||||
)
|
||||
assert campaign.name in response.content.decode()
|
||||
|
||||
def test_campaign_detail_404_for_unknown(self) -> None:
|
||||
"""Campaign detail view should return HTTP 404 status code for unknown campaign."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_detail", kwargs={"kick_id": "nonexistent-id"}),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class KickCategoryListViewTest(TestCase):
|
||||
"""Tests for the kick category list view."""
|
||||
|
||||
def test_category_list_returns_200(self) -> None:
|
||||
"""Category list view should return HTTP 200 status code."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_list"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_category_list_shows_categories(self) -> None:
|
||||
"""Category list view should display the categories."""
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=500,
|
||||
name="Category View",
|
||||
slug="category-view",
|
||||
)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_list"),
|
||||
)
|
||||
assert cat.name in response.content.decode()
|
||||
|
||||
|
||||
class KickCategoryDetailViewTest(TestCase):
|
||||
"""Tests for the kick category detail view."""
|
||||
|
||||
def _make_category(self) -> KickCategory:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-catdet-1",
|
||||
name="Catdet Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=600,
|
||||
name="Catdet Cat",
|
||||
slug="catdet-cat",
|
||||
)
|
||||
KickDropCampaign.objects.create(
|
||||
kick_id="camp-catdet-1",
|
||||
name="Catdet Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
return cat
|
||||
|
||||
def test_category_detail_returns_200(self) -> None:
|
||||
"""Category detail view should return HTTP 200 status code for existing category."""
|
||||
cat: KickCategory = self._make_category()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_detail", kwargs={"kick_id": cat.kick_id}),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_category_detail_shows_name(self) -> None:
|
||||
"""Category detail view should display the category name."""
|
||||
cat: KickCategory = self._make_category()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_detail", kwargs={"kick_id": cat.kick_id}),
|
||||
)
|
||||
assert cat.name in response.content.decode()
|
||||
|
||||
def test_category_detail_404_for_unknown(self) -> None:
|
||||
"""Category detail view should return HTTP 404 status code for unknown category."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_detail", kwargs={"kick_id": 99999}),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class KickOrganizationListViewTest(TestCase):
|
||||
"""Tests for the kick organization list view."""
|
||||
|
||||
def test_organization_list_returns_200(self) -> None:
|
||||
"""Organization list view should return HTTP 200 status code."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:organization_list"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_organization_list_shows_orgs(self) -> None:
|
||||
"""Organization list view should display the organizations."""
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-list-1",
|
||||
name="List Org View",
|
||||
)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:organization_list"),
|
||||
)
|
||||
assert org.name in response.content.decode()
|
||||
|
||||
|
||||
class KickOrganizationDetailViewTest(TestCase):
|
||||
"""Tests for the kick organization detail view."""
|
||||
|
||||
def _make_org(self) -> KickOrganization:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-orgdet-1",
|
||||
name="Orgdet Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=700,
|
||||
name="Orgdet Cat",
|
||||
slug="orgdet-cat",
|
||||
)
|
||||
KickDropCampaign.objects.create(
|
||||
kick_id="camp-orgdet-1",
|
||||
name="Orgdet Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
return org
|
||||
|
||||
def test_organization_detail_returns_200(self) -> None:
|
||||
"""Organization detail view should return HTTP 200 status code for existing organization."""
|
||||
org: KickOrganization = self._make_org()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:organization_detail", kwargs={"kick_id": org.kick_id}),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_organization_detail_shows_name(self) -> None:
|
||||
"""Organization detail view should display the organization name."""
|
||||
org: KickOrganization = self._make_org()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:organization_detail", kwargs={"kick_id": org.kick_id}),
|
||||
)
|
||||
assert org.name in response.content.decode()
|
||||
|
||||
def test_organization_detail_404_for_unknown(self) -> None:
|
||||
"""Organization detail view should return HTTP 404 status code for unknown organization."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse(
|
||||
"kick:organization_detail",
|
||||
kwargs={"kick_id": "nonexistent-org-id"},
|
||||
),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class KickFeedsTest(TestCase):
|
||||
"""Tests for Kick RSS/Atom/Discord feed endpoints."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Create a minimal active campaign fixture for feed tests."""
|
||||
self.org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-feed-1",
|
||||
name="Feed Org",
|
||||
logo_url="https://example.com/org-logo.png",
|
||||
url="https://example.com/org",
|
||||
)
|
||||
self.category: KickCategory = KickCategory.objects.create(
|
||||
kick_id=123,
|
||||
name="Feed Category",
|
||||
slug="feed-category",
|
||||
image_url="https://example.com/category.png",
|
||||
)
|
||||
self.campaign: KickDropCampaign = KickDropCampaign.objects.create(
|
||||
kick_id="camp-feed-1",
|
||||
name="Feed Campaign",
|
||||
status="active",
|
||||
starts_at=timezone.now() - timedelta(hours=1),
|
||||
ends_at=timezone.now() + timedelta(days=1),
|
||||
organization=self.org,
|
||||
category=self.category,
|
||||
url="https://example.com/campaign",
|
||||
connect_url="https://example.com/connect",
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
self.user: KickUser = KickUser.objects.create(
|
||||
kick_id=2001,
|
||||
username="feeduser",
|
||||
)
|
||||
self.channel: KickChannel = KickChannel.objects.create(
|
||||
kick_id=2002,
|
||||
slug="feedchannel",
|
||||
user=self.user,
|
||||
)
|
||||
self.campaign.channels.add(self.channel)
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-feed-1",
|
||||
name="Feed Reward",
|
||||
image_url="https://example.com/reward.png",
|
||||
required_units=30,
|
||||
campaign=self.campaign,
|
||||
category=self.category,
|
||||
organization=self.org,
|
||||
)
|
||||
|
||||
def test_rss_feed_routes_return_200(self) -> None:
|
||||
"""Kick RSS feeds should return 200 and browser-friendly content type."""
|
||||
urls: list[str] = [
|
||||
reverse("kick:campaign_feed"),
|
||||
reverse("kick:game_feed"),
|
||||
reverse("kick:game_campaign_feed", args=[self.category.kick_id]),
|
||||
reverse("kick:organization_feed"),
|
||||
]
|
||||
|
||||
for url in urls:
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
assert response["Content-Disposition"] == "inline"
|
||||
|
||||
def test_atom_feed_routes_return_200(self) -> None:
|
||||
"""Kick Atom feeds should return 200 and include Atom XML root."""
|
||||
urls: list[str] = [
|
||||
reverse("kick:campaign_feed_atom"),
|
||||
reverse("kick:game_feed_atom"),
|
||||
reverse("kick:game_campaign_feed_atom", args=[self.category.kick_id]),
|
||||
reverse("kick:organization_feed_atom"),
|
||||
]
|
||||
|
||||
for url in urls:
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "<feed" in content
|
||||
assert "http://www.w3.org/2005/Atom" in content
|
||||
|
||||
def test_discord_feed_routes_return_200(self) -> None:
|
||||
"""Kick Discord feeds should return 200 and include Atom XML root."""
|
||||
urls: list[str] = [
|
||||
reverse("kick:campaign_feed_discord"),
|
||||
reverse("kick:game_feed_discord"),
|
||||
reverse(
|
||||
"kick:game_campaign_feed_discord",
|
||||
args=[self.category.kick_id],
|
||||
),
|
||||
reverse("kick:organization_feed_discord"),
|
||||
]
|
||||
|
||||
for url in urls:
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "<feed" in content
|
||||
assert "http://www.w3.org/2005/Atom" in content
|
||||
|
||||
def test_discord_campaign_feeds_contain_discord_timestamps(self) -> None:
|
||||
"""Kick Discord campaign feeds should include escaped Discord relative timestamps."""
|
||||
urls: list[str] = [
|
||||
reverse("kick:campaign_feed_discord"),
|
||||
reverse(
|
||||
"kick:game_campaign_feed_discord",
|
||||
args=[self.category.kick_id],
|
||||
),
|
||||
]
|
||||
|
||||
for url in urls:
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
content: str = response.content.decode("utf-8")
|
||||
discord_pattern: re.Pattern[str] = re.compile(r"&lt;t:\d+:R&gt;")
|
||||
assert discord_pattern.search(content), (
|
||||
f"Expected Discord timestamp in feed {url}, got: {content}"
|
||||
)
|
||||
|
||||
def test_discord_timestamp_helper(self) -> None:
|
||||
"""discord_timestamp helper should return escaped Discord token and handle None."""
|
||||
sample_dt: dt = dt(2026, 3, 14, 12, 0, 0, tzinfo=UTC)
|
||||
result: str = str(discord_timestamp(sample_dt))
|
||||
assert result.startswith("<t:")
|
||||
assert result.endswith(":R>")
|
||||
assert not str(discord_timestamp(None))
|
||||
177
kick/urls.py
Normal file
177
kick/urls.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from kick import views
|
||||
from kick.feeds import KickCampaignAtomFeed
|
||||
from kick.feeds import KickCampaignDiscordFeed
|
||||
from kick.feeds import KickCampaignFeed
|
||||
from kick.feeds import KickCategoryAtomFeed
|
||||
from kick.feeds import KickCategoryCampaignAtomFeed
|
||||
from kick.feeds import KickCategoryCampaignDiscordFeed
|
||||
from kick.feeds import KickCategoryCampaignFeed
|
||||
from kick.feeds import KickCategoryDiscordFeed
|
||||
from kick.feeds import KickCategoryFeed
|
||||
from kick.feeds import KickOrganizationAtomFeed
|
||||
from kick.feeds import KickOrganizationDiscordFeed
|
||||
from kick.feeds import KickOrganizationFeed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.urls.resolvers import URLPattern
|
||||
from django.urls.resolvers import URLResolver
|
||||
|
||||
app_name = "kick"
|
||||
|
||||
urlpatterns: list[URLPattern | URLResolver] = [
|
||||
# /kick/
|
||||
path(
|
||||
route="",
|
||||
view=views.dashboard,
|
||||
name="dashboard",
|
||||
),
|
||||
# /kick/campaigns/
|
||||
path(
|
||||
route="campaigns/",
|
||||
view=views.campaign_list_view,
|
||||
name="campaign_list",
|
||||
),
|
||||
# /kick/campaigns/<kick_id>/
|
||||
path(
|
||||
route="campaigns/<str:kick_id>/",
|
||||
view=views.campaign_detail_view,
|
||||
name="campaign_detail",
|
||||
),
|
||||
# /kick/games/
|
||||
path(
|
||||
route="games/",
|
||||
view=views.category_list_view,
|
||||
name="game_list",
|
||||
),
|
||||
# /kick/games/<kick_id>/
|
||||
path(
|
||||
route="games/<int:kick_id>/",
|
||||
view=views.category_detail_view,
|
||||
name="game_detail",
|
||||
),
|
||||
# Legacy category routes kept for backward compatibility
|
||||
path(
|
||||
route="categories/",
|
||||
view=views.category_list_view,
|
||||
name="category_list",
|
||||
),
|
||||
path(
|
||||
route="categories/<int:kick_id>/",
|
||||
view=views.category_detail_view,
|
||||
name="category_detail",
|
||||
),
|
||||
# /kick/organizations/
|
||||
path(
|
||||
route="organizations/",
|
||||
view=views.organization_list_view,
|
||||
name="organization_list",
|
||||
),
|
||||
# /kick/organizations/<kick_id>/
|
||||
path(
|
||||
route="organizations/<str:kick_id>/",
|
||||
view=views.organization_detail_view,
|
||||
name="organization_detail",
|
||||
),
|
||||
# RSS feeds
|
||||
# /kick/rss/campaigns/ - all active campaigns
|
||||
path(
|
||||
"rss/campaigns/",
|
||||
KickCampaignFeed(),
|
||||
name="campaign_feed",
|
||||
),
|
||||
# /kick/rss/games/ - newly added games
|
||||
path(
|
||||
"rss/games/",
|
||||
KickCategoryFeed(),
|
||||
name="game_feed",
|
||||
),
|
||||
# /kick/rss/games/<kick_id>/campaigns/ - active campaigns for a specific game
|
||||
path(
|
||||
"rss/games/<int:kick_id>/campaigns/",
|
||||
KickCategoryCampaignFeed(),
|
||||
name="game_campaign_feed",
|
||||
),
|
||||
# Legacy category feed routes kept for backward compatibility
|
||||
path(
|
||||
"rss/categories/",
|
||||
KickCategoryFeed(),
|
||||
name="category_feed",
|
||||
),
|
||||
path(
|
||||
"rss/categories/<int:kick_id>/campaigns/",
|
||||
KickCategoryCampaignFeed(),
|
||||
name="category_campaign_feed",
|
||||
),
|
||||
# /kick/rss/organizations/ - newly added organizations
|
||||
path(
|
||||
"rss/organizations/",
|
||||
KickOrganizationFeed(),
|
||||
name="organization_feed",
|
||||
),
|
||||
# Atom feeds (added alongside RSS to preserve backward compatibility)
|
||||
path(
|
||||
"atom/campaigns/",
|
||||
KickCampaignAtomFeed(),
|
||||
name="campaign_feed_atom",
|
||||
),
|
||||
path(
|
||||
"atom/games/",
|
||||
KickCategoryAtomFeed(),
|
||||
name="game_feed_atom",
|
||||
),
|
||||
path(
|
||||
"atom/games/<int:kick_id>/campaigns/",
|
||||
KickCategoryCampaignAtomFeed(),
|
||||
name="game_campaign_feed_atom",
|
||||
),
|
||||
path(
|
||||
"atom/categories/",
|
||||
KickCategoryAtomFeed(),
|
||||
name="category_feed_atom",
|
||||
),
|
||||
path(
|
||||
"atom/categories/<int:kick_id>/campaigns/",
|
||||
KickCategoryCampaignAtomFeed(),
|
||||
name="category_campaign_feed_atom",
|
||||
),
|
||||
path(
|
||||
"atom/organizations/",
|
||||
KickOrganizationAtomFeed(),
|
||||
name="organization_feed_atom",
|
||||
),
|
||||
# Discord feeds (Atom feeds with Discord relative timestamps)
|
||||
path(
|
||||
"discord/campaigns/",
|
||||
KickCampaignDiscordFeed(),
|
||||
name="campaign_feed_discord",
|
||||
),
|
||||
path(
|
||||
"discord/games/",
|
||||
KickCategoryDiscordFeed(),
|
||||
name="game_feed_discord",
|
||||
),
|
||||
path(
|
||||
"discord/games/<int:kick_id>/campaigns/",
|
||||
KickCategoryCampaignDiscordFeed(),
|
||||
name="game_campaign_feed_discord",
|
||||
),
|
||||
path(
|
||||
"discord/categories/",
|
||||
KickCategoryDiscordFeed(),
|
||||
name="category_feed_discord",
|
||||
),
|
||||
path(
|
||||
"discord/categories/<int:kick_id>/campaigns/",
|
||||
KickCategoryCampaignDiscordFeed(),
|
||||
name="category_campaign_feed_discord",
|
||||
),
|
||||
path(
|
||||
"discord/organizations/",
|
||||
KickOrganizationDiscordFeed(),
|
||||
name="organization_feed_discord",
|
||||
),
|
||||
]
|
||||
483
kick/views.py
Normal file
483
kick/views.py
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Literal
|
||||
|
||||
from django.core.paginator import EmptyPage
|
||||
from django.core.paginator import PageNotAnInteger
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Count
|
||||
from django.http import Http404
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from kick.models import KickCategory
|
||||
from kick.models import KickDropCampaign
|
||||
from kick.models import KickOrganization
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import datetime
|
||||
|
||||
from django.core.paginator import Page
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.http import HttpResponse
|
||||
|
||||
from kick.models import KickChannel
|
||||
from kick.models import KickReward
|
||||
|
||||
logger: logging.Logger = logging.getLogger("ttvdrops.kick.views")
|
||||
|
||||
|
||||
def _build_seo_context(
|
||||
page_title: str = "Kick Drops",
|
||||
page_description: str | None = None,
|
||||
og_type: str = "website",
|
||||
robots_directive: str = "index, follow",
|
||||
) -> dict[str, str]:
|
||||
"""Build minimal SEO context for template rendering.
|
||||
|
||||
Args:
|
||||
page_title: The title of the page for <title> and OG tags.
|
||||
page_description: Optional description for meta and OG tags.
|
||||
og_type: Open Graph type (default "website").
|
||||
robots_directive: Value for meta robots tag (default "index, follow").
|
||||
|
||||
Returns:
|
||||
A dictionary with SEO-related context variables.
|
||||
"""
|
||||
return {
|
||||
"page_title": page_title,
|
||||
"page_description": page_description or "Archive of Kick drops.",
|
||||
"og_type": og_type,
|
||||
"robots_directive": robots_directive,
|
||||
}
|
||||
|
||||
|
||||
def _build_breadcrumb_schema(items: list[dict[str, str]]) -> str:
|
||||
"""Build a serialised BreadcrumbList JSON-LD string.
|
||||
|
||||
Args:
|
||||
items: A list of breadcrumb items, each with "name" and "url" keys
|
||||
|
||||
Returns:
|
||||
A JSON string representing the BreadcrumbList schema.
|
||||
"""
|
||||
breadcrumb_items: list[dict[str, str | int]] = [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": i + 1,
|
||||
"name": item["name"],
|
||||
"item": item["url"],
|
||||
}
|
||||
for i, item in enumerate(items)
|
||||
]
|
||||
return json.dumps({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": breadcrumb_items,
|
||||
})
|
||||
|
||||
|
||||
def _build_pagination_info(
|
||||
request: HttpRequest,
|
||||
page_obj: Page,
|
||||
base_url: str,
|
||||
) -> list[dict[str, str]] | None:
|
||||
"""Build rel="prev"/"next" pagination link info.
|
||||
|
||||
Args:
|
||||
request: The current HTTP request (used to build absolute URLs).
|
||||
page_obj: The current Page object from the paginator.
|
||||
base_url: The base URL for pagination links (without ?page=).
|
||||
|
||||
Returns:
|
||||
A list of pagination link dictionaries or None if no links.
|
||||
"""
|
||||
sep: Literal["&", "?"] = "&" if "?" in base_url else "?"
|
||||
links: list[dict[str, str]] = []
|
||||
if page_obj.has_previous():
|
||||
links.append({
|
||||
"rel": "prev",
|
||||
"url": request.build_absolute_uri(
|
||||
f"{base_url}{sep}page={page_obj.previous_page_number()}",
|
||||
),
|
||||
})
|
||||
if page_obj.has_next():
|
||||
links.append({
|
||||
"rel": "next",
|
||||
"url": request.build_absolute_uri(
|
||||
f"{base_url}{sep}page={page_obj.next_page_number()}",
|
||||
),
|
||||
})
|
||||
return links or None
|
||||
|
||||
|
||||
# MARK: /kick/
|
||||
def dashboard(request: HttpRequest) -> HttpResponse:
|
||||
"""Dashboard showing currently active Kick drop campaigns.
|
||||
|
||||
This view focuses on active campaigns, showing them in order of start date.
|
||||
For a complete list of campaigns with filtering and pagination, see the
|
||||
campaign_list_view.
|
||||
|
||||
Returns:
|
||||
An HttpResponse rendering the dashboard template with active campaigns.
|
||||
"""
|
||||
now: datetime.datetime = timezone.now()
|
||||
active_campaigns: QuerySet[KickDropCampaign] = (
|
||||
KickDropCampaign.objects
|
||||
.filter(starts_at__lte=now, ends_at__gte=now)
|
||||
.select_related("organization", "category")
|
||||
.prefetch_related("channels__user", "rewards")
|
||||
.order_by("-starts_at")
|
||||
)
|
||||
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title="Kick Drops",
|
||||
page_description="Overview of active Kick drop campaigns.",
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"kick/dashboard.html",
|
||||
{
|
||||
"active_campaigns": active_campaigns,
|
||||
"now": now,
|
||||
**seo_context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# MARK: /kick/campaigns/
|
||||
def campaign_list_view(request: HttpRequest) -> HttpResponse:
|
||||
"""Paginated list of all Kick campaigns with optional filtering.
|
||||
|
||||
Supports filtering by game and status (active/upcoming/expired) via query parameters.
|
||||
Pagination is implemented with 100 campaigns per page.
|
||||
|
||||
Returns:
|
||||
An HttpResponse rendering the campaign list template with the filtered and paginated campaigns.
|
||||
"""
|
||||
game_filter: str | None = request.GET.get("game") or request.GET.get("category")
|
||||
status_filter: str | None = request.GET.get("status")
|
||||
per_page = 100
|
||||
now: datetime.datetime = timezone.now()
|
||||
|
||||
queryset: QuerySet[KickDropCampaign] = (
|
||||
KickDropCampaign.objects
|
||||
.select_related("organization", "category")
|
||||
.prefetch_related("rewards")
|
||||
.order_by("-starts_at")
|
||||
)
|
||||
|
||||
if game_filter:
|
||||
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)
|
||||
elif status_filter == "expired":
|
||||
queryset = queryset.filter(ends_at__lt=now)
|
||||
|
||||
paginator: Paginator[KickDropCampaign] = Paginator(queryset, per_page)
|
||||
page: str | Literal[1] = request.GET.get("page") or 1
|
||||
try:
|
||||
campaigns: Page[KickDropCampaign] = paginator.page(page)
|
||||
except PageNotAnInteger:
|
||||
campaigns = paginator.page(1)
|
||||
except EmptyPage:
|
||||
campaigns = paginator.page(paginator.num_pages)
|
||||
|
||||
title = "Kick Drop Campaigns"
|
||||
if status_filter:
|
||||
title += f" ({status_filter.capitalize()})"
|
||||
|
||||
base_url = "/kick/campaigns/"
|
||||
if status_filter:
|
||||
base_url += f"?status={status_filter}"
|
||||
if game_filter:
|
||||
base_url += f"&game={game_filter}"
|
||||
elif game_filter:
|
||||
base_url += f"?game={game_filter}"
|
||||
|
||||
pagination_info: list[dict[str, str]] | None = _build_pagination_info(
|
||||
request,
|
||||
campaigns,
|
||||
base_url,
|
||||
)
|
||||
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title=title,
|
||||
page_description="Browse Kick drop campaigns.",
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"kick/campaign_list.html",
|
||||
{
|
||||
"campaigns": campaigns,
|
||||
"page_obj": campaigns,
|
||||
"is_paginated": campaigns.has_other_pages(),
|
||||
"games": KickCategory.objects.order_by("name"),
|
||||
"status_options": ["active", "upcoming", "expired"],
|
||||
"now": now,
|
||||
"selected_game": game_filter or "",
|
||||
"selected_status": status_filter or "",
|
||||
"pagination_info": pagination_info,
|
||||
**seo_context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# MARK: /kick/campaigns/<kick_id>/
|
||||
def campaign_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse:
|
||||
"""Detail view for a single Kick drop campaign.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object.
|
||||
kick_id: The unique identifier for the Kick campaign.
|
||||
|
||||
Returns:
|
||||
An HttpResponse rendering the campaign detail template with the campaign information.
|
||||
|
||||
Raises:
|
||||
Http404: If no campaign is found matching the kick_id.
|
||||
"""
|
||||
try:
|
||||
campaign: KickDropCampaign = (
|
||||
KickDropCampaign.objects
|
||||
.select_related("organization", "category")
|
||||
.prefetch_related("channels__user", "rewards__category")
|
||||
.get(kick_id=kick_id)
|
||||
)
|
||||
except KickDropCampaign.DoesNotExist as exc:
|
||||
msg = "No campaign found matching the query"
|
||||
raise Http404(msg) from exc
|
||||
|
||||
now: datetime.datetime = timezone.now()
|
||||
rewards: list[KickReward] = list(
|
||||
campaign.rewards.order_by("required_units").select_related("category"), # type: ignore[union-attr]
|
||||
)
|
||||
channels: list[KickChannel] = list(campaign.channels.select_related("user"))
|
||||
reward_count: int = len(rewards)
|
||||
channels_count: int = len(channels)
|
||||
total_watch_minutes: int = sum(reward.required_units for reward in rewards)
|
||||
|
||||
breadcrumb_schema: str = _build_breadcrumb_schema([
|
||||
{"name": "Home", "url": request.build_absolute_uri("/")},
|
||||
{
|
||||
"name": "Kick Campaigns",
|
||||
"url": request.build_absolute_uri(reverse("kick:campaign_list")),
|
||||
},
|
||||
{
|
||||
"name": campaign.name,
|
||||
"url": request.build_absolute_uri(
|
||||
reverse("kick:campaign_detail", args=[campaign.kick_id]),
|
||||
),
|
||||
},
|
||||
])
|
||||
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title=campaign.name,
|
||||
page_description=f"Kick drop campaign: {campaign.name}.",
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"kick/campaign_detail.html",
|
||||
{
|
||||
"campaign": campaign,
|
||||
"rewards": rewards,
|
||||
"channels": channels,
|
||||
"reward_count": reward_count,
|
||||
"channels_count": channels_count,
|
||||
"total_watch_minutes": total_watch_minutes,
|
||||
"now": now,
|
||||
"breadcrumb_schema": breadcrumb_schema,
|
||||
**seo_context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# MARK: /kick/games/
|
||||
def category_list_view(request: HttpRequest) -> HttpResponse:
|
||||
"""List of all Kick games with their campaign counts.
|
||||
|
||||
Returns:
|
||||
An HttpResponse rendering the category list template with all games and their campaign counts.
|
||||
"""
|
||||
categories: QuerySet[KickCategory] = KickCategory.objects.annotate(
|
||||
campaign_count=Count("campaigns", distinct=True),
|
||||
).order_by("name")
|
||||
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title="Kick Games",
|
||||
page_description="Games on Kick that have drop campaigns.",
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"kick/category_list.html",
|
||||
{
|
||||
"categories": categories,
|
||||
**seo_context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# MARK: /kick/games/<kick_id>/
|
||||
def category_detail_view(request: HttpRequest, kick_id: int) -> HttpResponse:
|
||||
"""Detail view for a Kick game with its drop campaigns.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object.
|
||||
kick_id: The unique identifier for the Kick game.
|
||||
|
||||
Returns:
|
||||
An HttpResponse rendering the category detail template with the game information and its campaigns.
|
||||
|
||||
Raises:
|
||||
Http404: If no game is found matching the kick_id.
|
||||
"""
|
||||
try:
|
||||
category: KickCategory = KickCategory.objects.get(kick_id=kick_id)
|
||||
except KickCategory.DoesNotExist as exc:
|
||||
msg = "No game found matching the query"
|
||||
raise Http404(msg) from exc
|
||||
|
||||
now: datetime.datetime = timezone.now()
|
||||
all_campaigns: list[KickDropCampaign] = list(
|
||||
KickDropCampaign.objects
|
||||
.filter(category=category)
|
||||
.select_related("organization")
|
||||
.prefetch_related("channels__user", "rewards")
|
||||
.order_by("-starts_at"),
|
||||
)
|
||||
|
||||
active_campaigns: list[KickDropCampaign] = [
|
||||
c
|
||||
for c in all_campaigns
|
||||
if c.starts_at and c.ends_at and c.starts_at <= now <= c.ends_at
|
||||
]
|
||||
upcoming_campaigns: list[KickDropCampaign] = [
|
||||
c for c in all_campaigns if c.starts_at and c.starts_at > now
|
||||
]
|
||||
expired_campaigns: list[KickDropCampaign] = [
|
||||
c for c in all_campaigns if c.ends_at and c.ends_at < now
|
||||
]
|
||||
|
||||
breadcrumb_schema: str = _build_breadcrumb_schema([
|
||||
{"name": "Home", "url": request.build_absolute_uri("/")},
|
||||
{
|
||||
"name": "Kick Games",
|
||||
"url": request.build_absolute_uri(reverse("kick:game_list")),
|
||||
},
|
||||
{
|
||||
"name": category.name,
|
||||
"url": request.build_absolute_uri(
|
||||
reverse("kick:game_detail", args=[category.kick_id]),
|
||||
),
|
||||
},
|
||||
])
|
||||
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title=category.name,
|
||||
page_description=f"Kick drop campaigns for {category.name}.",
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"kick/category_detail.html",
|
||||
{
|
||||
"category": category,
|
||||
"active_campaigns": active_campaigns,
|
||||
"upcoming_campaigns": upcoming_campaigns,
|
||||
"expired_campaigns": expired_campaigns,
|
||||
"now": now,
|
||||
"breadcrumb_schema": breadcrumb_schema,
|
||||
**seo_context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# MARK: /kick/organizations/
|
||||
def organization_list_view(request: HttpRequest) -> HttpResponse:
|
||||
"""List of all Kick organizations.
|
||||
|
||||
Returns:
|
||||
An HttpResponse rendering the organization list template with all organizations and their campaign counts.
|
||||
"""
|
||||
orgs: QuerySet[KickOrganization] = KickOrganization.objects.annotate(
|
||||
campaign_count=Count("campaigns", distinct=True),
|
||||
).order_by("name")
|
||||
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title="Kick Organizations",
|
||||
page_description="Organizations that run Kick drop campaigns.",
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"kick/org_list.html",
|
||||
{
|
||||
"orgs": orgs,
|
||||
**seo_context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# MARK: /kick/organizations/<kick_id>/
|
||||
def organization_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse:
|
||||
"""Detail view for a Kick organization with its campaigns.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object.
|
||||
kick_id: The unique identifier for the Kick organization.
|
||||
|
||||
Returns:
|
||||
An HttpResponse rendering the organization detail template with the organization information and its campaigns.
|
||||
|
||||
Raises:
|
||||
Http404: If no organization is found matching the kick_id.
|
||||
"""
|
||||
try:
|
||||
org: KickOrganization = KickOrganization.objects.get(kick_id=kick_id)
|
||||
except KickOrganization.DoesNotExist as exc:
|
||||
msg = "No organization found matching the query"
|
||||
raise Http404(msg) from exc
|
||||
|
||||
now: datetime.datetime = timezone.now()
|
||||
campaigns: list[KickDropCampaign] = list(
|
||||
KickDropCampaign.objects
|
||||
.filter(organization=org)
|
||||
.select_related("category")
|
||||
.prefetch_related("rewards")
|
||||
.order_by("-starts_at"),
|
||||
)
|
||||
|
||||
breadcrumb_schema: str = _build_breadcrumb_schema([
|
||||
{"name": "Home", "url": request.build_absolute_uri("/")},
|
||||
{
|
||||
"name": "Kick Organizations",
|
||||
"url": request.build_absolute_uri(reverse("kick:organization_list")),
|
||||
},
|
||||
{
|
||||
"name": org.name,
|
||||
"url": request.build_absolute_uri(
|
||||
reverse("kick:organization_detail", args=[org.kick_id]),
|
||||
),
|
||||
},
|
||||
])
|
||||
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title=org.name,
|
||||
page_description=f"Kick drop campaigns by {org.name}.",
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"kick/organization_detail.html",
|
||||
{
|
||||
"org": org,
|
||||
"campaigns": campaigns,
|
||||
"now": now,
|
||||
"breadcrumb_schema": breadcrumb_schema,
|
||||
**seo_context,
|
||||
},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue