From d762081bd5edaea55c53ae6c4aac097efb8b72de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Sun, 15 Mar 2026 04:19:03 +0100 Subject: [PATCH] Add support for Kick --- config/settings.py | 1 + config/urls.py | 1 + kick/__init__.py | 0 kick/apps.py | 8 + kick/feeds.py | 708 +++++++++++++ kick/management/__init__.py | 0 kick/management/commands/__init__.py | 0 kick/management/commands/import_kick_drops.py | 218 ++++ kick/migrations/0001_initial.py | 545 ++++++++++ kick/migrations/__init__.py | 0 kick/models.py | 436 ++++++++ kick/schemas.py | 126 +++ kick/tests/__init__.py | 0 kick/tests/test_kick.py | 935 ++++++++++++++++++ kick/urls.py | 177 ++++ kick/views.py | 483 +++++++++ templates/base.html | 43 +- templates/kick/campaign_detail.html | 224 +++++ templates/kick/campaign_list.html | 120 +++ templates/kick/category_detail.html | 517 ++++++++++ templates/kick/category_list.html | 44 + templates/kick/dashboard.html | 176 ++++ templates/kick/org_list.html | 44 + templates/kick/organization_detail.html | 210 ++++ .../ttvdrops-import-kick-drops.service | 24 + .../systemd/ttvdrops-import-kick-drops.timer | 9 + 26 files changed, 5048 insertions(+), 1 deletion(-) create mode 100644 kick/__init__.py create mode 100644 kick/apps.py create mode 100644 kick/feeds.py create mode 100644 kick/management/__init__.py create mode 100644 kick/management/commands/__init__.py create mode 100644 kick/management/commands/import_kick_drops.py create mode 100644 kick/migrations/0001_initial.py create mode 100644 kick/migrations/__init__.py create mode 100644 kick/models.py create mode 100644 kick/schemas.py create mode 100644 kick/tests/__init__.py create mode 100644 kick/tests/test_kick.py create mode 100644 kick/urls.py create mode 100644 kick/views.py create mode 100644 templates/kick/campaign_detail.html create mode 100644 templates/kick/campaign_list.html create mode 100644 templates/kick/category_detail.html create mode 100644 templates/kick/category_list.html create mode 100644 templates/kick/dashboard.html create mode 100644 templates/kick/org_list.html create mode 100644 templates/kick/organization_detail.html create mode 100644 tools/systemd/ttvdrops-import-kick-drops.service create mode 100644 tools/systemd/ttvdrops-import-kick-drops.timer diff --git a/config/settings.py b/config/settings.py index 186fd4d..ca45e20 100644 --- a/config/settings.py +++ b/config/settings.py @@ -139,6 +139,7 @@ INSTALLED_APPS: list[str] = [ "django.contrib.staticfiles", "django.contrib.postgres", "twitch.apps.TwitchConfig", + "kick.apps.KickConfig", ] MIDDLEWARE: list[str] = [ diff --git a/config/urls.py b/config/urls.py index 5e1405f..0a9ea65 100644 --- a/config/urls.py +++ b/config/urls.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: urlpatterns: list[URLPattern | URLResolver] = [ path(route="sitemap.xml", view=twitch_views.sitemap_view, name="sitemap"), path(route="", view=include("twitch.urls", namespace="twitch")), + path(route="kick/", view=include("kick.urls", namespace="kick")), ] # Serve media in development diff --git a/kick/__init__.py b/kick/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kick/apps.py b/kick/apps.py new file mode 100644 index 0000000..409a7f8 --- /dev/null +++ b/kick/apps.py @@ -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" diff --git a/kick/feeds.py b/kick/feeds.py new file mode 100644 index 0000000..6d3685a --- /dev/null +++ b/kick/feeds.py @@ -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("

{}
{}

", start_part, end_part) + if start_part: + return format_html("

{}

", start_part) + return format_html("

{}

", 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("
  • {} minutes: {}
  • ", reward.required_units, reward.name) + for reward in rewards + ] + + html: SafeText = format_html( + "", + format_html_join("", "{}", [(item,) for item in items]), + ) + return format_html("

    Rewards:

    {}", 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('
  • {}
  • ', channel.channel_url, label), + ) + else: + items.append(format_html("
  • {}
  • ", label)) + + if len(channels) > max_links: + items.append(format_html("
  • ... and {} more
  • ", len(channels) - max_links)) + + html: SafeText = format_html( + "", + format_html_join("", "{}", [(item,) for item in items]), + ) + return format_html("

    Channels with this drop:

    {}", 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('About', campaign.url)) + if campaign.connect_url: + links.append(format_html('Connect', campaign.connect_url)) + + if not links: + return SafeText("") + + return format_html( + "

    {}

    ", + 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( + '{}', + item.logo_url, + item.name, + ), + ) + + if item.url: + parts.append(format_html('

    Website

    ', 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( + '{}', + item.image_url, + item.name, + ), + ) + + if item.kick_url: + parts.append( + format_html('

    Kick game

    ', item.kick_url), + ) + + category_campaign_feed: str = reverse( + "kick:game_campaign_feed", + args=[item.kick_id], + ) + parts.append( + format_html( + '

    Campaign feed

    ', + 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( + '{}', + 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( + '{}', + 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( + '{}', + 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( + '{}', + 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]) diff --git a/kick/management/__init__.py b/kick/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kick/management/commands/__init__.py b/kick/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kick/management/commands/import_kick_drops.py b/kick/management/commands/import_kick_drops.py new file mode 100644 index 0000000..ab3ff68 --- /dev/null +++ b/kick/management/commands/import_kick_drops.py @@ -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, + }, + ) diff --git a/kick/migrations/0001_initial.py b/kick/migrations/0001_initial.py new file mode 100644 index 0000000..d34751b --- /dev/null +++ b/kick/migrations/0001_initial.py @@ -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"), + ), + ] diff --git a/kick/migrations/__init__.py b/kick/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kick/models.py b/kick/models.py new file mode 100644 index 0000000..7cef031 --- /dev/null +++ b/kick/models.py @@ -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 diff --git a/kick/schemas.py b/kick/schemas.py new file mode 100644 index 0000000..098721a --- /dev/null +++ b/kick/schemas.py @@ -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, + } diff --git a/kick/tests/__init__.py b/kick/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kick/tests/test_kick.py b/kick/tests/test_kick.py new file mode 100644 index 0000000..59420ef --- /dev/null +++ b/kick/tests/test_kick.py @@ -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 " 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 " 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)) diff --git a/kick/urls.py b/kick/urls.py new file mode 100644 index 0000000..1cfa7f6 --- /dev/null +++ b/kick/urls.py @@ -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// + path( + route="campaigns//", + view=views.campaign_detail_view, + name="campaign_detail", + ), + # /kick/games/ + path( + route="games/", + view=views.category_list_view, + name="game_list", + ), + # /kick/games// + path( + route="games//", + 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//", + view=views.category_detail_view, + name="category_detail", + ), + # /kick/organizations/ + path( + route="organizations/", + view=views.organization_list_view, + name="organization_list", + ), + # /kick/organizations// + path( + route="organizations//", + 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//campaigns/ - active campaigns for a specific game + path( + "rss/games//campaigns/", + KickCategoryCampaignFeed(), + name="game_campaign_feed", + ), + # Legacy category feed routes kept for backward compatibility + path( + "rss/categories/", + KickCategoryFeed(), + name="category_feed", + ), + path( + "rss/categories//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//campaigns/", + KickCategoryCampaignAtomFeed(), + name="game_campaign_feed_atom", + ), + path( + "atom/categories/", + KickCategoryAtomFeed(), + name="category_feed_atom", + ), + path( + "atom/categories//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//campaigns/", + KickCategoryCampaignDiscordFeed(), + name="game_campaign_feed_discord", + ), + path( + "discord/categories/", + KickCategoryDiscordFeed(), + name="category_feed_discord", + ), + path( + "discord/categories//campaigns/", + KickCategoryCampaignDiscordFeed(), + name="category_campaign_feed_discord", + ), + path( + "discord/organizations/", + KickOrganizationDiscordFeed(), + name="organization_feed_discord", + ), +] diff --git a/kick/views.py b/kick/views.py new file mode 100644 index 0000000..d803524 --- /dev/null +++ b/kick/views.py @@ -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 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, + }, + ) diff --git a/templates/base.html b/templates/base.html index a8cad8e..71488ab 100644 --- a/templates/base.html +++ b/templates/base.html @@ -73,6 +73,42 @@ type="application/atom+xml" title="Newly added reward campaigns (Discord)" href="{% url 'twitch:reward_campaign_feed_discord' %}" /> + <link rel="alternate" + type="application/rss+xml" + title="All Kick campaigns (RSS)" + href="{% url 'kick:campaign_feed' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick campaigns (Atom)" + href="{% url 'kick:campaign_feed_atom' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick campaigns (Discord)" + href="{% url 'kick:campaign_feed_discord' %}" /> + <link rel="alternate" + type="application/rss+xml" + title="All Kick games (RSS)" + href="{% url 'kick:game_feed' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick games (Atom)" + href="{% url 'kick:game_feed_atom' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick games (Discord)" + href="{% url 'kick:game_feed_discord' %}" /> + <link rel="alternate" + type="application/rss+xml" + title="All Kick organizations (RSS)" + href="{% url 'kick:organization_feed' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick organizations (Atom)" + href="{% url 'kick:organization_feed_atom' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick organizations (Discord)" + href="{% url 'kick:organization_feed_discord' %}" /> {# Allow child templates to inject page-specific alternates into the head #} {% block extra_head %} {% endblock extra_head %} @@ -234,9 +270,14 @@ <a href="{% url 'twitch:emote_gallery' %}">Emotes</a> | <a href="https://www.twitch.tv/drops/inventory">Inventory</a> <br /> + <strong>Kick</strong> + <a href="{% url 'kick:dashboard' %}">Dashboard</a> | + <a href="{% url 'kick:campaign_list' %}">Campaigns</a> | + <a href="{% url 'kick:game_list' %}">Games</a> | + <a href="{% url 'kick:organization_list' %}">Organizations</a> + <br /> <strong>Other sites</strong> <a href="#">Steam</a> | - <a href="#">Kick</a> | <a href="#">YouTube</a> | <a href="#">TikTok</a> | <a href="#">Discord</a> diff --git a/templates/kick/campaign_detail.html b/templates/kick/campaign_detail.html new file mode 100644 index 0000000..30b03dc --- /dev/null +++ b/templates/kick/campaign_detail.html @@ -0,0 +1,224 @@ +{% extends "base.html" %} +{% block title %} + {{ campaign.name }} — Kick Campaign +{% endblock title %} +{% block extra_head %} + {% if campaign.category %} + <link rel="alternate" + type="application/rss+xml" + title="{{ campaign.category.name }} campaigns (RSS)" + href="{% url 'kick:game_campaign_feed' campaign.category.kick_id %}" /> + <link rel="alternate" + type="application/atom+xml" + title="{{ campaign.category.name }} campaigns (Atom)" + href="{% url 'kick:game_campaign_feed_atom' campaign.category.kick_id %}" /> + <link rel="alternate" + type="application/atom+xml" + title="{{ campaign.category.name }} campaigns (Discord)" + href="{% url 'kick:game_campaign_feed_discord' campaign.category.kick_id %}" /> + {% endif %} +{% endblock extra_head %} +{% block content %} + <main> + <nav aria-label="Breadcrumb"> + <a href="{% url 'kick:dashboard' %}">Kick</a> > + <a href="{% url 'kick:campaign_list' %}">Campaigns</a> > + {{ campaign.name }} + </nav> + <div style="display: flex; + align-items: flex-start; + gap: 1.5rem; + margin-bottom: 1rem"> + <div style="flex-shrink: 0;"> + {% if campaign.image_url %} + <img src="{{ campaign.image_url }}" + alt="{{ campaign.name }} image" + width="200" + height="200" + loading="lazy" + style="max-width: 100%; + height: auto; + border-radius: 8px" /> + {% else %} + <div style="width: 200px; + height: 200px; + background-color: #eee; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px">No Image</div> + {% endif %} + </div> + <div> + <h1 style="margin: 0 0 0.25rem 0;">{{ campaign.name }}</h1> + {% if campaign.category %} + <div> + <a href="{% url 'kick:game_campaign_feed' campaign.category.kick_id %}" + title="RSS feed for {{ campaign.category.name }} campaigns">[rss]</a> + <a href="{% url 'kick:game_campaign_feed_atom' campaign.category.kick_id %}" + title="Atom feed for {{ campaign.category.name }} campaigns">[atom]</a> + <a href="{% url 'kick:game_campaign_feed_discord' campaign.category.kick_id %}" + title="Discord feed for {{ campaign.category.name }} campaigns">[discord]</a> + </div> + {% endif %} + <p style="margin: 0.25rem 0; color: #666;"> + Status: {{ campaign.status|default:"unknown"|capfirst }} + {% if campaign.rule_name %}- Rule: {{ campaign.rule_name }}{% endif %} + </p> + {% if campaign.category or campaign.organization %} + <p style="margin: 0.25rem 0;"> + {% if campaign.category %} + <a href="{% url 'kick:game_detail' campaign.category.kick_id %}">{{ campaign.category.name }}</a> + {% endif %} + {% if campaign.organization %} + {% if campaign.category %}-{% endif %} + <a href="{% url 'kick:organization_detail' campaign.organization.kick_id %}">{{ campaign.organization.name }}</a> + {% endif %} + </p> + {% endif %} + <p style="margin: 0.25rem 0; font-size: 0.85rem; color: #666;"> + ID: {{ campaign.kick_id }} + {% if campaign.rule_id %}- Rule ID: {{ campaign.rule_id }}{% endif %} + - Added: <time datetime="{{ campaign.added_at|date:'c' }}" + title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}">{{ campaign.added_at|date:"M d, Y" }}</time> + ({{ campaign.added_at|timesince }} ago) + - Updated: <time datetime="{{ campaign.updated_at|date:'c' }}" + title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y" }}</time> + ({{ campaign.updated_at|timesince }} ago) + </p> + {% if campaign.ends_at %} + <p style="margin: 0.25rem 0;"> + <strong>Ends:</strong> + <time datetime="{{ campaign.ends_at|date:'c' }}" + title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}">{{ campaign.ends_at|date:"M d, Y H:i" }}</time> + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.starts_at %} + <p style="margin: 0.25rem 0;"> + <strong>Starts:</strong> + <time datetime="{{ campaign.starts_at|date:'c' }}" + title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}">{{ campaign.starts_at|date:"M d, Y H:i" }}</time> + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.duration %} + <p style="margin: 0.25rem 0;"> + <strong>Duration:</strong> {{ campaign.duration }} + </p> + {% endif %} + <p style="margin: 0.25rem 0;"> + {% if reward_count %} + {{ reward_count }} reward{{ reward_count|pluralize }} + ({{ total_watch_minutes }} total watch minute{{ total_watch_minutes|pluralize }}) + {% else %} + No rewards + {% endif %} + </p> + <div style="margin-top: 0.5rem; font-size: 0.9rem;"> + <strong>Participating channels:</strong> + {% if campaign.channels.all %} + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for channel in campaign.channels.all|slice:":5" %} + <li> + {% if channel.user %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.user.username }}</a> + {% else %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.name }}</a> + {% endif %} + </li> + {% endfor %} + {% if campaign.channels.count > 5 %} + <li style="color: #666; font-style: italic;">... and {{ campaign.channels.count|add:"-5" }} more</li> + {% endif %} + </ul> + {% else %} + <p style="margin: 0.25rem 0 0 0;"> + <!-- Is game wide--> + <a href="{{ campaign.category.kick_url }}" + rel="nofollow noopener" + target="_blank">{{ campaign.category.name }}</a> is game wide. + </p> + {% endif %} + </div> + {% if campaign.created_at %} + <p style="margin: 0.25rem 0;"> + <time datetime="{{ campaign.created_at|date:'c' }}" + title="{{ campaign.created_at|date:'DATETIME_FORMAT' }}"> + Created: {{ campaign.created_at|date:"M d, Y H:i" }} ({{ campaign.created_at|timesince }} ago) + </time> + </p> + {% endif %} + {% if campaign.api_updated_at %} + <p style="margin: 0.25rem 0;"> + <time datetime="{{ campaign.api_updated_at|date:'c' }}" + title="{{ campaign.api_updated_at|date:'DATETIME_FORMAT' }}"> + API Updated: {{ campaign.api_updated_at|date:"M d, Y H:i" }} ({{ campaign.api_updated_at|timesince }} ago) + </time> + </p> + {% endif %} + <p style="margin: 0.5rem 0 0 0;"> + {% if campaign.url %}<a href="{{ campaign.url }}" rel="nofollow noopener" target="_blank">Details</a>{% endif %} + {% if campaign.connect_url %} + {% if campaign.url %}-{% endif %} + <a href="{{ campaign.connect_url }}" + rel="nofollow noopener" + target="_blank">Connect account</a> + {% endif %} + </p> + </div> + </div> + <hr /> + <h2>Rewards</h2> + {% if rewards %} + {% for reward in rewards %} + <article> + <div style="display: flex; gap: 1rem; align-items: flex-start;"> + <div style="flex-shrink: 0;"> + {% if reward.full_image_url %} + <img src="{{ reward.full_image_url }}" + alt="{{ reward.name }}" + width="96" + height="96" + loading="lazy" + style="width: 96px; + height: auto; + border-radius: 6px" /> + {% else %} + <div style="width: 96px; + height: 96px; + background-color: #eee; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px">No Image</div> + {% endif %} + </div> + <div> + <h3 style="margin: 0 0 0.25rem 0;">{{ reward.name }}</h3> + {% if reward.required_units %} + <p style="margin: 0.25rem 0;">{{ reward.required_units }} minutes watched</p> + {% else %} + <p style="margin: 0.25rem 0;">No watch-time requirement</p> + {% endif %} + </div> + </div> + </article> + {% endfor %} + {% else %} + <p>No drops available for this campaign.</p> + {% endif %} + </main> +{% endblock content %} diff --git a/templates/kick/campaign_list.html b/templates/kick/campaign_list.html new file mode 100644 index 0000000..7e6d149 --- /dev/null +++ b/templates/kick/campaign_list.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} +{% block title %} + Kick Drop Campaigns +{% endblock title %} +{% block extra_head %} + <link rel="alternate" + type="application/rss+xml" + title="All Kick campaigns (RSS)" + href="{% url 'kick:campaign_feed' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick campaigns (Atom)" + href="{% url 'kick:campaign_feed_atom' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick campaigns (Discord)" + href="{% url 'kick:campaign_feed_discord' %}" /> +{% endblock extra_head %} +{% block content %} + <main> + <h1>Kick Drop Campaigns</h1> + <div> + <a href="{% url 'kick:campaign_feed' %}" + title="RSS feed for all campaigns">[rss]</a> + <a href="{% url 'kick:campaign_feed_atom' %}" + title="Atom feed for all campaigns">[atom]</a> + <a href="{% url 'kick:campaign_feed_discord' %}" + title="Discord feed for all campaigns">[discord]</a> + </div> + <form method="get" action="{% url 'kick:campaign_list' %}"> + <div style="display: flex; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem"> + <select name="game"> + <option value="">All Games</option> + {% for game in games %} + <option value="{{ game.kick_id }}" + {% if selected_game == game.kick_id|stringformat:"s" %}selected{% endif %}>{{ game.name }}</option> + {% endfor %} + </select> + <select name="status"> + <option value="">All Statuses</option> + {% for s in status_options %} + <option value="{{ s }}" {% if selected_status == s %}selected{% endif %}>{{ s|capfirst }}</option> + {% endfor %} + </select> + <button type="submit">Filter</button> + {% if selected_status or selected_game %} + <a href="{% url 'kick:campaign_list' %}">Clear</a> + {% endif %} + </div> + </form> + {% if campaigns %} + <table> + <thead> + <tr> + <th>Name</th> + <th>Game</th> + <th>Organization</th> + <th>Status</th> + <th>Starts</th> + <th>Ends</th> + </tr> + </thead> + <tbody> + {% for campaign in campaigns %} + <tr> + <td> + <a href="{% url 'kick:campaign_detail' campaign.kick_id %}">{{ campaign.name }}</a> + </td> + <td> + <a href="{% url 'kick:game_detail' campaign.category.kick_id %}">{{ campaign.category.name }}</a> + </td> + <td> + <a href="{% url 'kick:organization_detail' campaign.organization.kick_id %}">{{ campaign.organization.name }}</a> + </td> + <td>{{ campaign.status }}</td> + <td> + {% if campaign.starts_at %} + <time datetime="{{ campaign.starts_at|date:'c' }}" + title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}">{{ campaign.starts_at|date:"M d, Y" }}</time> + {% if campaign.starts_at < now %} + ({{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} + {% endif %} + </td> + <td> + {% if campaign.ends_at %} + <time datetime="{{ campaign.ends_at|date:'c' }}" + title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}">{{ campaign.ends_at|date:"M d, Y" }}</time> + {% if campaign.ends_at < now %} + ({{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + {% if is_paginated %} + <nav aria-label="Pagination"> + {% if page_obj.has_previous %} + <a href="?page={{ page_obj.previous_page_number }}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if selected_game %}&game={{ selected_game }}{% endif %}">Previous</a> + {% endif %} + <span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span> + {% if page_obj.has_next %} + <a href="?page={{ page_obj.next_page_number }}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if selected_game %}&game={{ selected_game }}{% endif %}">Next</a> + {% endif %} + </nav> + {% endif %} + {% else %} + <p>No campaigns found.</p> + {% endif %} + </main> +{% endblock content %} diff --git a/templates/kick/category_detail.html b/templates/kick/category_detail.html new file mode 100644 index 0000000..ddcb7b6 --- /dev/null +++ b/templates/kick/category_detail.html @@ -0,0 +1,517 @@ +{% extends "base.html" %} +{% block title %} + {{ category.name }} — Kick Game +{% endblock title %} +{% block extra_head %} + <link rel="alternate" + type="application/rss+xml" + title="{{ category.name }} campaigns (RSS)" + href="{% url 'kick:game_campaign_feed' category.kick_id %}" /> + <link rel="alternate" + type="application/atom+xml" + title="{{ category.name }} campaigns (Atom)" + href="{% url 'kick:game_campaign_feed_atom' category.kick_id %}" /> + <link rel="alternate" + type="application/atom+xml" + title="{{ category.name }} campaigns (Discord)" + href="{% url 'kick:game_campaign_feed_discord' category.kick_id %}" /> +{% endblock extra_head %} +{% block content %} + <main> + <nav aria-label="Breadcrumb"> + <a href="{% url 'kick:dashboard' %}">Kick</a> > + <a href="{% url 'kick:game_list' %}">Games</a> > + {{ category.name }} + </nav> + <div style="display: flex; + align-items: flex-start; + gap: 1.5rem; + margin-bottom: 1rem"> + <div style="flex-shrink: 0;"> + {% if category.image_url %} + <img src="{{ category.image_url }}" + alt="{{ category.name }} image" + width="200" + height="200" + loading="lazy" + style="max-width: 100%; + height: auto; + border-radius: 8px" /> + {% else %} + <div style="width: 200px; + height: 200px; + background-color: #eee; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px">No Image</div> + {% endif %} + </div> + <div> + <h1 style="margin: 0 0 0.25rem 0;">{{ category.name }}</h1> + <div> + <a href="{% url 'kick:game_campaign_feed' category.kick_id %}" + title="RSS feed for {{ category.name }} campaigns">[rss]</a> + <a href="{% url 'kick:game_campaign_feed_atom' category.kick_id %}" + title="Atom feed for {{ category.name }} campaigns">[atom]</a> + <a href="{% url 'kick:game_campaign_feed_discord' category.kick_id %}" + title="Discord feed for {{ category.name }} campaigns">[discord]</a> + </div> + {% if category.kick_url %} + <p style="margin: 0.25rem 0;"> + <a href="{{ category.kick_url }}" + rel="nofollow noopener" + target="_blank">{{ category.kick_url }}</a> + </p> + {% endif %} + <p style="margin: 0.25rem 0; font-size: 0.85rem; color: #666;"> + ID: {{ category.kick_id }} + - Added: <time datetime="{{ category.added_at|date:'c' }}" + title="{{ category.added_at|date:'DATETIME_FORMAT' }}">{{ category.added_at|date:"M d, Y" }}</time> + ({{ category.added_at|timesince }} ago) + - Updated: <time datetime="{{ category.updated_at|date:'c' }}" + title="{{ category.updated_at|date:'DATETIME_FORMAT' }}">{{ category.updated_at|date:"M d, Y" }}</time> + ({{ category.updated_at|timesince }} ago) + </p> + <p style="margin: 0.25rem 0; color: #666;"> + Active: {{ active_campaigns|length }} + - Upcoming: {{ upcoming_campaigns|length }} + - Expired: {{ expired_campaigns|length }} + </p> + </div> + </div> + <hr /> + {% if active_campaigns %} + <h2>Active Campaigns ({{ active_campaigns|length }})</h2> + {% for campaign in active_campaigns %} + <article> + <header> + <h3> + <a href="{% url 'kick:campaign_detail' campaign.kick_id %}">{{ campaign.name }}</a> + </h3> + <div style="font-size: 0.9rem; color: #666;"> + {% if campaign.organization %} + Organization: + <a href="{% url 'kick:organization_detail' campaign.organization.kick_id %}">{{ campaign.organization.name }}</a> + - + {% endif %} + Status: {{ campaign.status|default:"unknown"|capfirst }} + </div> + </header> + <div style="display: flex; gap: 1rem;"> + <div style="flex-shrink: 0;"> + {% if campaign.image_url %} + <img src="{{ campaign.image_url }}" + width="200" + height="200" + alt="{{ campaign.name }} image" + loading="lazy" + style="width: 200px; + height: auto; + border-radius: 8px" /> + {% else %} + <div style="width: 200px; + height: 200px; + background-color: #eee; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px">No Image</div> + {% endif %} + </div> + <div style="flex: 1;"> + {% if campaign.starts_at %} + <p style="margin: 0.25rem 0;"> + <strong>Starts:</strong> + <time datetime="{{ campaign.starts_at|date:'c' }}" + title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.starts_at|date:"M d, Y H:i" }} + </time> + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.ends_at %} + <p style="margin: 0.25rem 0;"> + <strong>Ends:</strong> + <time datetime="{{ campaign.ends_at|date:'c' }}" + title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.ends_at|date:"M d, Y H:i" }} + </time> + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.duration %} + <p style="margin: 0.25rem 0;"> + <strong>Duration:</strong> {{ campaign.duration }} + </p> + {% endif %} + {% if campaign.rule_name %} + <p style="margin: 0.25rem 0;"> + <strong>Rule:</strong> {{ campaign.rule_name }} + </p> + {% endif %} + {% if campaign.connect_url %} + <p style="margin: 0.25rem 0;"> + <a href="{{ campaign.connect_url }}" + rel="nofollow noopener" + target="_blank">Connect account</a> + </p> + {% endif %} + <div style="margin-top: 0.5rem; font-size: 0.9rem;"> + <strong>Participating channels:</strong> + {% if campaign.channels.all %} + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for channel in campaign.channels.all|slice:":5" %} + <li> + {% if channel.user %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.user.username }}</a> + {% else %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.slug }}</a> + {% endif %} + </li> + {% endfor %} + {% if campaign.channels.count > 5 %} + <li style="color: #666; font-style: italic;">... and {{ campaign.channels.count|add:"-5" }} more</li> + {% endif %} + </ul> + {% else %} + <p style="margin: 0.25rem 0 0 0;"> + {% if category.kick_url %} + <a href="{{ category.kick_url }}" + rel="nofollow noopener" + target="_blank">{{ category.name }}</a> is game wide. + {% else %} + Game wide. + {% endif %} + </p> + {% endif %} + </div> + {% if campaign.merged_rewards %} + <div style="margin-top: 0.75rem;"> + <strong>Rewards:</strong> + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for reward in campaign.merged_rewards %} + <li> + {% if reward.full_image_url %} + <img src="{{ reward.full_image_url }}" + alt="{{ reward.name }}" + width="56" + height="56" + loading="lazy" + style="vertical-align: middle; + border-radius: 4px" /> + {% endif %} + {{ reward.name }} ({{ reward.required_units }} min) + </li> + {% endfor %} + </ul> + </div> + {% endif %} + </div> + </div> + </article> + {% endfor %} + {% endif %} + {% if upcoming_campaigns %} + <h2>Upcoming Campaigns ({{ upcoming_campaigns|length }})</h2> + {% for campaign in upcoming_campaigns %} + <article> + <header> + <h3> + <a href="{% url 'kick:campaign_detail' campaign.kick_id %}">{{ campaign.name }}</a> + </h3> + <div style="font-size: 0.9rem; color: #666;"> + {% if campaign.organization %} + Organization: + <a href="{% url 'kick:organization_detail' campaign.organization.kick_id %}">{{ campaign.organization.name }}</a> + - + {% endif %} + Status: {{ campaign.status|default:"unknown"|capfirst }} + </div> + </header> + <div style="display: flex; gap: 1rem;"> + <div style="flex-shrink: 0;"> + {% if campaign.image_url %} + <img src="{{ campaign.image_url }}" + width="200" + height="200" + alt="{{ campaign.name }} image" + loading="lazy" + style="width: 200px; + height: auto; + border-radius: 8px" /> + {% else %} + <div style="width: 200px; + height: 200px; + background-color: #eee; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px">No Image</div> + {% endif %} + </div> + <div style="flex: 1;"> + {% if campaign.starts_at %} + <p style="margin: 0.25rem 0;"> + <strong>Starts:</strong> + <time datetime="{{ campaign.starts_at|date:'c' }}" + title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.starts_at|date:"M d, Y H:i" }} + </time> + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.ends_at %} + <p style="margin: 0.25rem 0;"> + <strong>Ends:</strong> + <time datetime="{{ campaign.ends_at|date:'c' }}" + title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.ends_at|date:"M d, Y H:i" }} + </time> + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.duration %} + <p style="margin: 0.25rem 0;"> + <strong>Duration:</strong> {{ campaign.duration }} + </p> + {% endif %} + {% if campaign.rule_name %} + <p style="margin: 0.25rem 0;"> + <strong>Rule:</strong> {{ campaign.rule_name }} + </p> + {% endif %} + {% if campaign.connect_url %} + <p style="margin: 0.25rem 0;"> + <a href="{{ campaign.connect_url }}" + rel="nofollow noopener" + target="_blank">Connect account</a> + </p> + {% endif %} + <div style="margin-top: 0.5rem; font-size: 0.9rem;"> + <strong>Participating channels:</strong> + {% if campaign.channels.all %} + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for channel in campaign.channels.all|slice:":5" %} + <li> + {% if channel.user %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.user.username }}</a> + {% else %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.slug }}</a> + {% endif %} + </li> + {% endfor %} + {% if campaign.channels.count > 5 %} + <li style="color: #666; font-style: italic;">... and {{ campaign.channels.count|add:"-5" }} more</li> + {% endif %} + </ul> + {% else %} + <p style="margin: 0.25rem 0 0 0;"> + {% if category.kick_url %} + <a href="{{ category.kick_url }}" + rel="nofollow noopener" + target="_blank">{{ category.name }}</a> is game wide. + {% else %} + Game wide. + {% endif %} + </p> + {% endif %} + </div> + {% if campaign.merged_rewards %} + <div style="margin-top: 0.75rem;"> + <strong>Rewards:</strong> + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for reward in campaign.merged_rewards %} + <li> + {% if reward.full_image_url %} + <img src="{{ reward.full_image_url }}" + alt="{{ reward.name }}" + width="56" + height="56" + loading="lazy" + style="vertical-align: middle; + border-radius: 4px" /> + {% endif %} + {{ reward.name }} ({{ reward.required_units }} min) + </li> + {% endfor %} + </ul> + </div> + {% endif %} + </div> + </div> + </article> + {% endfor %} + {% endif %} + {% if expired_campaigns %} + <h2>Expired Campaigns ({{ expired_campaigns|length }})</h2> + {% for campaign in expired_campaigns %} + <article> + <header> + <h3> + <a href="{% url 'kick:campaign_detail' campaign.kick_id %}">{{ campaign.name }}</a> + </h3> + <div style="font-size: 0.9rem; color: #666;"> + {% if campaign.organization %} + Organization: + <a href="{% url 'kick:organization_detail' campaign.organization.kick_id %}">{{ campaign.organization.name }}</a> + - + {% endif %} + Status: {{ campaign.status|default:"unknown"|capfirst }} + </div> + </header> + <div style="display: flex; gap: 1rem;"> + <div style="flex-shrink: 0;"> + {% if campaign.image_url %} + <img src="{{ campaign.image_url }}" + width="200" + height="200" + alt="{{ campaign.name }} image" + loading="lazy" + style="width: 200px; + height: auto; + border-radius: 8px" /> + {% else %} + <div style="width: 200px; + height: 200px; + background-color: #eee; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px">No Image</div> + {% endif %} + </div> + <div style="flex: 1;"> + {% if campaign.starts_at %} + <p style="margin: 0.25rem 0;"> + <strong>Starts:</strong> + <time datetime="{{ campaign.starts_at|date:'c' }}" + title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.starts_at|date:"M d, Y H:i" }} + </time> + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.ends_at %} + <p style="margin: 0.25rem 0;"> + <strong>Ends:</strong> + <time datetime="{{ campaign.ends_at|date:'c' }}" + title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.ends_at|date:"M d, Y H:i" }} + </time> + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.duration %} + <p style="margin: 0.25rem 0;"> + <strong>Duration:</strong> {{ campaign.duration }} + </p> + {% endif %} + {% if campaign.rule_name %} + <p style="margin: 0.25rem 0;"> + <strong>Rule:</strong> {{ campaign.rule_name }} + </p> + {% endif %} + {% if campaign.connect_url %} + <p style="margin: 0.25rem 0;"> + <a href="{{ campaign.connect_url }}" + rel="nofollow noopener" + target="_blank">Connect account</a> + </p> + {% endif %} + <div style="margin-top: 0.5rem; font-size: 0.9rem;"> + <strong>Participating channels:</strong> + {% if campaign.channels.all %} + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for channel in campaign.channels.all|slice:":5" %} + <li> + {% if channel.user %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.user.username }}</a> + {% else %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.slug }}</a> + {% endif %} + </li> + {% endfor %} + {% if campaign.channels.count > 5 %} + <li style="color: #666; font-style: italic;">... and {{ campaign.channels.count|add:"-5" }} more</li> + {% endif %} + </ul> + {% else %} + <p style="margin: 0.25rem 0 0 0;"> + {% if category.kick_url %} + <a href="{{ category.kick_url }}" + rel="nofollow noopener" + target="_blank">{{ category.name }}</a> is game wide. + {% else %} + Game wide. + {% endif %} + </p> + {% endif %} + </div> + {% if campaign.merged_rewards %} + <div style="margin-top: 0.75rem;"> + <strong>Rewards:</strong> + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for reward in campaign.merged_rewards %} + <li> + {% if reward.full_image_url %} + <img src="{{ reward.full_image_url }}" + alt="{{ reward.name }}" + width="56" + height="56" + loading="lazy" + style="vertical-align: middle; + border-radius: 4px" /> + {% endif %} + {{ reward.name }} ({{ reward.required_units }} min) + </li> + {% endfor %} + </ul> + </div> + {% endif %} + </div> + </div> + </article> + {% endfor %} + {% endif %} + {% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %} + <p>No campaigns found for this game.</p> + {% endif %} + </main> +{% endblock content %} diff --git a/templates/kick/category_list.html b/templates/kick/category_list.html new file mode 100644 index 0000000..9efd2a8 --- /dev/null +++ b/templates/kick/category_list.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %} + Kick Games +{% endblock title %} +{% block extra_head %} + <link rel="alternate" + type="application/rss+xml" + title="All Kick games (RSS)" + href="{% url 'kick:game_feed' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick games (Atom)" + href="{% url 'kick:game_feed_atom' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick games (Discord)" + href="{% url 'kick:game_feed_discord' %}" /> +{% endblock extra_head %} +{% block content %} + <main> + <h1>Kick Games</h1> + <div> + <a href="{% url 'kick:game_feed' %}" title="RSS feed for all games">[rss]</a> + <a href="{% url 'kick:game_feed_atom' %}" + title="Atom feed for all games">[atom]</a> + <a href="{% url 'kick:game_feed_discord' %}" + title="Discord feed for all games">[discord]</a> + </div> + {% if categories %} + <ul> + {% for category in categories %} + <li> + <a href="{% url 'kick:game_detail' category.kick_id %}">{{ category.name }}</a> + {% if category.campaign_count %} + - {{ category.campaign_count }} campaign{{ category.campaign_count|pluralize }} + {% endif %} + </li> + {% endfor %} + </ul> + {% else %} + <p>No games found.</p> + {% endif %} + </main> +{% endblock content %} diff --git a/templates/kick/dashboard.html b/templates/kick/dashboard.html new file mode 100644 index 0000000..0f568d0 --- /dev/null +++ b/templates/kick/dashboard.html @@ -0,0 +1,176 @@ +{% extends "base.html" %} +{% block title %} + Kick Drops +{% endblock title %} +{% block extra_head %} + <link rel="alternate" + type="application/rss+xml" + title="All Kick campaigns (RSS)" + href="{% url 'kick:campaign_feed' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick campaigns (Atom)" + href="{% url 'kick:campaign_feed_atom' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick campaigns (Discord)" + href="{% url 'kick:campaign_feed_discord' %}" /> +{% endblock extra_head %} +{% block content %} + <main> + <h1>Active Kick Drop Campaigns</h1> + <!-- RSS Feeds --> + <div> + <a href="{% url 'kick:campaign_feed' %}" + title="RSS feed for all campaigns">[rss]</a> + <a href="{% url 'kick:campaign_feed_atom' %}" + title="Atom feed for all campaigns">[atom]</a> + <a href="{% url 'kick:campaign_feed_discord' %}" + title="Discord feed for all campaigns">[discord]</a> + </div> + <hr /> + {% if active_campaigns %} + {% for campaign in active_campaigns %} + <!-- {{ campaign }} --> + <article> + <header> + <h2> + <a href="{% url 'kick:game_detail' campaign.category.kick_id %}">{{ campaign.category.name }}</a> + </h2> + <div style="font-size: 0.9rem; color: #666;"> + {% if campaign.organization %} + Organization: + <a href="{% url 'kick:organization_detail' campaign.organization.kick_id %}">{{ campaign.organization.name }}</a> + {% else %} + Organization: Unknown + {% endif %} + </div> + </header> + <div style="display: flex; gap: 1rem;"> + <div style="flex-shrink: 0;"> + {% if campaign.image_url %} + <img src="{{ campaign.image_url }}" + width="200" + height="200" + alt="{{ campaign.name }} image" + style="width: 200px; + height: auto; + border-radius: 8px" /> + {% else %} + <div style="width: 200px; + height: 200px; + background-color: #eee; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px">No Image</div> + {% endif %} + </div> + <div style="flex: 1;"> + <h3> + <a href="{% url 'kick:campaign_detail' campaign.kick_id %}">{{ campaign.name }}</a> + </h3> + <!-- Start --> + {% if campaign.starts_at %} + <p style="margin: 0.25rem 0;"> + <strong>Starts:</strong> + <time datetime="{{ campaign.starts_at|date:'c' }}" + title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.starts_at|date:"M d, Y H:i" }} + </time> + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + <!-- End --> + {% if campaign.ends_at %} + <p style="margin: 0.25rem 0;"> + <strong>Ends:</strong> + <time datetime="{{ campaign.ends_at|date:'c' }}" + title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.ends_at|date:"M d, Y H:i" }} + </time> + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + <!-- Duration --> + {% if campaign.duration %} + <p style="margin: 0.25rem 0;"> + <strong>Duration:</strong> {{ campaign.duration }} + </p> + {% endif %} + {% if campaign.rule_name %} + <p style="margin: 0.25rem 0;"> + <strong>Rule:</strong> {{ campaign.rule_name }} + </p> + {% endif %} + {% if campaign.connect_url %} + <p style="margin: 0.25rem 0;"> + <a href="{{ campaign.connect_url }}" + rel="nofollow noopener" + target="_blank">Connect account</a> + </p> + {% endif %} + <div style="margin-top: 0.5rem; font-size: 0.9rem;"> + <strong>Participating channels:</strong> + {% if campaign.channels.all %} + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for channel in campaign.channels.all|slice:":5" %} + <li> + {% if channel.user %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.user.username }}</a> + {% else %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.slug }}</a> + {% endif %} + </li> + {% endfor %} + {% if campaign.channels.count > 5 %} + <li style="color: #666; font-style: italic;">... and {{ campaign.channels.count|add:"-5" }} more</li> + {% endif %} + </ul> + {% else %} + <p style="margin: 0.25rem 0 0 0;"> + <a href="{{ campaign.category.kick_url }}">{{ campaign.category.name }}</a> is game wide. + </p> + {% endif %} + </div> + {% if campaign.merged_rewards %} + <div style="margin-top: 0.75rem;"> + <strong>Rewards:</strong> + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for reward in campaign.merged_rewards %} + <li> + {% if reward.full_image_url %} + <img src="{{ reward.full_image_url }}" + alt="{{ reward.name }}" + width="56" + height="56" + style="vertical-align: middle; + border-radius: 4px" /> + {% endif %} + {{ reward.name }} ({{ reward.required_units }} min) + </li> + {% endfor %} + </ul> + </div> + {% endif %} + </div> + </div> + </article> + {% endfor %} + {% else %} + <p>No active Kick drop campaigns at the moment. Check back later!</p> + {% endif %} + </main> +{% endblock content %} diff --git a/templates/kick/org_list.html b/templates/kick/org_list.html new file mode 100644 index 0000000..c3aca0b --- /dev/null +++ b/templates/kick/org_list.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %} + Kick Organizations +{% endblock title %} +{% block extra_head %} + <link rel="alternate" + type="application/rss+xml" + title="All Kick organizations (RSS)" + href="{% url 'kick:organization_feed' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick organizations (Atom)" + href="{% url 'kick:organization_feed_atom' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick organizations (Discord)" + href="{% url 'kick:organization_feed_discord' %}" /> +{% endblock extra_head %} +{% block content %} + <main> + <h1>Kick Organizations</h1> + <div> + <a href="{% url 'kick:organization_feed' %}" + title="RSS feed for all organizations">[rss]</a> + <a href="{% url 'kick:organization_feed_atom' %}" + title="Atom feed for all organizations">[atom]</a> + <a href="{% url 'kick:organization_feed_discord' %}" + title="Discord feed for all organizations">[discord]</a> + </div> + {% if orgs %} + <ul> + {% for org in orgs %} + <li> + <a href="{% url 'kick:organization_detail' org.kick_id %}">{{ org.name }}</a> + {% if org.campaign_count %}- {{ org.campaign_count }} campaign{{ org.campaign_count|pluralize }}{% endif %} + {% if org.url %}- <a href="{{ org.url }}" rel="nofollow noopener" target="_blank">{{ org.url }}</a>{% endif %} + </li> + {% endfor %} + </ul> + {% else %} + <p>No organizations found.</p> + {% endif %} + </main> +{% endblock content %} diff --git a/templates/kick/organization_detail.html b/templates/kick/organization_detail.html new file mode 100644 index 0000000..4ec3915 --- /dev/null +++ b/templates/kick/organization_detail.html @@ -0,0 +1,210 @@ +{% extends "base.html" %} +{% block title %} + {{ org.name }} — Kick Organization +{% endblock title %} +{% block extra_head %} + <link rel="alternate" + type="application/rss+xml" + title="All Kick organizations (RSS)" + href="{% url 'kick:organization_feed' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick organizations (Atom)" + href="{% url 'kick:organization_feed_atom' %}" /> + <link rel="alternate" + type="application/atom+xml" + title="All Kick organizations (Discord)" + href="{% url 'kick:organization_feed_discord' %}" /> +{% endblock extra_head %} +{% block content %} + <main> + <nav aria-label="Breadcrumb"> + <a href="{% url 'kick:dashboard' %}">Kick</a> > + <a href="{% url 'kick:organization_list' %}">Organizations</a> > + {{ org.name }} + </nav> + <div style="display: flex; + align-items: flex-start; + gap: 1.5rem; + margin-bottom: 1rem"> + {% if org.logo_url %} + <img src="{{ org.logo_url }}" + alt="{{ org.name }} logo" + width="200" + height="200" + loading="lazy" + style="max-width: 100%; + height: auto; + border-radius: 8px" /> + {% endif %} + <div> + <h1 style="margin: 0 0 0.25rem 0;">{{ org.name }}</h1> + <div> + <a href="{% url 'kick:organization_feed' %}" + title="RSS feed for all organizations">[rss]</a> + <a href="{% url 'kick:organization_feed_atom' %}" + title="Atom feed for all organizations">[atom]</a> + <a href="{% url 'kick:organization_feed_discord' %}" + title="Discord feed for all organizations">[discord]</a> + </div> + {% if org.restricted %}<p style="margin: 0.25rem 0; color: #b00;">Restricted</p>{% endif %} + {% if org.url %} + <p style="margin: 0.25rem 0;"> + <a href="{{ org.url }}" rel="nofollow noopener" target="_blank">{{ org.url }}</a> + </p> + {% endif %} + <p style="margin: 0.25rem 0; font-size: 0.85rem; color: #666;"> + ID: {{ org.kick_id }} + - Added: <time datetime="{{ org.added_at|date:'c' }}" + title="{{ org.added_at|date:'DATETIME_FORMAT' }}">{{ org.added_at|date:"M d, Y" }}</time> + ({{ org.added_at|timesince }} ago) + - Updated: <time datetime="{{ org.updated_at|date:'c' }}" + title="{{ org.updated_at|date:'DATETIME_FORMAT' }}">{{ org.updated_at|date:"M d, Y" }}</time> + ({{ org.updated_at|timesince }} ago) + </p> + </div> + </div> + <hr /> + <h2>Campaigns</h2> + {% if campaigns %} + {% for campaign in campaigns %} + <article> + <header> + <h3> + <a href="{% url 'kick:campaign_detail' campaign.kick_id %}">{{ campaign.name }}</a> + </h3> + <div style="font-size: 0.9rem; color: #666;"> + {% if campaign.category %} + Game: + <a href="{% url 'kick:game_detail' campaign.category.kick_id %}">{{ campaign.category.name }}</a> + - + {% endif %} + Status: {{ campaign.status }} + </div> + </header> + <div style="display: flex; gap: 1rem;"> + <div style="flex-shrink: 0;"> + {% if campaign.image_url %} + <img src="{{ campaign.image_url }}" + width="200" + height="200" + alt="{{ campaign.name }} image" + style="width: 200px; + height: auto; + border-radius: 8px" /> + {% else %} + <div style="width: 200px; + height: 200px; + background-color: #eee; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px">No Image</div> + {% endif %} + </div> + <div style="flex: 1;"> + {% if campaign.starts_at %} + <p style="margin: 0.25rem 0;"> + <strong>Starts:</strong> + <time datetime="{{ campaign.starts_at|date:'c' }}" + title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.starts_at|date:"M d, Y H:i" }} + </time> + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.ends_at %} + <p style="margin: 0.25rem 0;"> + <strong>Ends:</strong> + <time datetime="{{ campaign.ends_at|date:'c' }}" + title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}"> + {{ campaign.ends_at|date:"M d, Y H:i" }} + </time> + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} + </p> + {% endif %} + {% if campaign.duration %} + <p style="margin: 0.25rem 0;"> + <strong>Duration:</strong> {{ campaign.duration }} + </p> + {% endif %} + {% if campaign.rule_name %} + <p style="margin: 0.25rem 0;"> + <strong>Rule:</strong> {{ campaign.rule_name }} + </p> + {% endif %} + {% if campaign.connect_url %} + <p style="margin: 0.25rem 0;"> + <a href="{{ campaign.connect_url }}" + rel="nofollow noopener" + target="_blank">Connect account</a> + </p> + {% endif %} + <div style="margin-top: 0.5rem; font-size: 0.9rem;"> + <strong>Participating channels:</strong> + {% if campaign.channels.all %} + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for channel in campaign.channels.all|slice:":5" %} + <li> + {% if channel.user %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.user.username }}</a> + {% else %} + <a href="{{ channel.channel_url }}" + rel="nofollow noopener" + target="_blank">{{ channel.slug }}</a> + {% endif %} + </li> + {% endfor %} + {% if campaign.channels.count > 5 %} + <li style="color: #666; font-style: italic;">... and {{ campaign.channels.count|add:"-5" }} more</li> + {% endif %} + </ul> + {% else %} + {% if campaign.category %} + <p style="margin: 0.25rem 0 0 0;"> + <a href="{{ campaign.category.kick_url }}">{{ campaign.category.name }}</a> is game wide. + </p> + {% else %} + <p style="margin: 0.25rem 0 0 0;">Channel wide.</p> + {% endif %} + {% endif %} + </div> + {% if campaign.merged_rewards %} + <div style="margin-top: 0.75rem;"> + <strong>Rewards:</strong> + <ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;"> + {% for reward in campaign.merged_rewards %} + <li> + {% if reward.full_image_url %} + <img src="{{ reward.full_image_url }}" + alt="{{ reward.name }}" + width="56" + height="56" + style="vertical-align: middle; + border-radius: 4px" /> + {% endif %} + {{ reward.name }} ({{ reward.required_units }} min) + </li> + {% endfor %} + </ul> + </div> + {% endif %} + </div> + </div> + </article> + {% endfor %} + {% else %} + <p>No campaigns from this organization.</p> + {% endif %} + </main> +{% endblock content %} diff --git a/tools/systemd/ttvdrops-import-kick-drops.service b/tools/systemd/ttvdrops-import-kick-drops.service new file mode 100644 index 0000000..6858562 --- /dev/null +++ b/tools/systemd/ttvdrops-import-kick-drops.service @@ -0,0 +1,24 @@ +[Unit] +Description=TTVDrops import Kick drops +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=ttvdrops +Group=ttvdrops +WorkingDirectory=/home/ttvdrops/ttvdrops +EnvironmentFile=/home/ttvdrops/ttvdrops/.env +ExecStart=/usr/bin/uv run python manage.py import_kick_drops + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=ttvdrops-import-kick + +# Resource limits +MemoryLimit=512M +CPUQuota=50% + +[Install] +WantedBy=multi-user.target diff --git a/tools/systemd/ttvdrops-import-kick-drops.timer b/tools/systemd/ttvdrops-import-kick-drops.timer new file mode 100644 index 0000000..af98bce --- /dev/null +++ b/tools/systemd/ttvdrops-import-kick-drops.timer @@ -0,0 +1,9 @@ +[Unit] +Description=TTVDrops import Kick drops at :01, :16, :31, and :46 + +[Timer] +OnCalendar=*-*-* *:01,16,31,46:00 +Persistent=true + +[Install] +WantedBy=timers.target