Add support for Kick
All checks were successful
Deploy to Server / deploy (push) Successful in 10s

This commit is contained in:
Joakim Hellsén 2026-03-15 04:19:03 +01:00
commit d762081bd5
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
26 changed files with 5048 additions and 1 deletions

View file

@ -139,6 +139,7 @@ INSTALLED_APPS: list[str] = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.postgres", "django.contrib.postgres",
"twitch.apps.TwitchConfig", "twitch.apps.TwitchConfig",
"kick.apps.KickConfig",
] ]
MIDDLEWARE: list[str] = [ MIDDLEWARE: list[str] = [

View file

@ -14,6 +14,7 @@ if TYPE_CHECKING:
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
path(route="sitemap.xml", view=twitch_views.sitemap_view, name="sitemap"), path(route="sitemap.xml", view=twitch_views.sitemap_view, name="sitemap"),
path(route="", view=include("twitch.urls", namespace="twitch")), path(route="", view=include("twitch.urls", namespace="twitch")),
path(route="kick/", view=include("kick.urls", namespace="kick")),
] ]
# Serve media in development # Serve media in development

0
kick/__init__.py Normal file
View file

8
kick/apps.py Normal file
View file

@ -0,0 +1,8 @@
from django.apps import AppConfig
class KickConfig(AppConfig):
"""Django app configuration for the kick app."""
default_auto_field = "django.db.models.BigAutoField"
name = "kick"

708
kick/feeds.py Normal file
View file

@ -0,0 +1,708 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.db.models import Prefetch
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.html import format_html_join
from django.utils.safestring import SafeText
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickDropCampaign
from kick.models import KickOrganization
from kick.models import KickReward
from twitch.feeds import TTVDropsAtomBaseFeed
from twitch.feeds import TTVDropsBaseFeed
from twitch.feeds import discord_timestamp
if TYPE_CHECKING:
import datetime
from django.db.models import QuerySet
from django.http import HttpRequest
from django.http import HttpResponse
def _with_campaign_related(
queryset: QuerySet[KickDropCampaign],
) -> QuerySet[KickDropCampaign]:
"""Apply related-select/prefetches needed by feed rendering.
Returns:
QuerySet[KickDropCampaign]: QuerySet with related objects optimized for feed rendering.
"""
return queryset.select_related("organization", "category").prefetch_related(
Prefetch(
"channels",
queryset=KickChannel.objects.select_related("user").order_by("slug"),
to_attr="channels_ordered",
),
Prefetch(
"rewards",
queryset=KickReward.objects.order_by("required_units", "name"),
to_attr="rewards_ordered",
),
)
def _active_campaigns(
queryset: QuerySet[KickDropCampaign],
) -> QuerySet[KickDropCampaign]:
"""Filter campaign queryset down to currently active campaigns.
Returns:
QuerySet[KickDropCampaign]: Filtered queryset with active campaigns.
"""
now: datetime.datetime = timezone.now()
return queryset.filter(starts_at__lte=now, ends_at__gte=now)
def _campaign_dates_html(
campaign: KickDropCampaign,
*,
use_discord_relative: bool = False,
) -> SafeText:
"""Render campaign date rows in standard or Discord relative format.
Returns:
SafeText: HTML with campaign dates or empty if no dates are available.
"""
starts_at: datetime.datetime | None = campaign.starts_at
ends_at: datetime.datetime | None = campaign.ends_at
if not starts_at and not ends_at:
return SafeText("")
formatter = discord_timestamp if use_discord_relative else naturaltime
start_part: SafeText = (
format_html(
"Starts: {} ({})",
starts_at.strftime("%Y-%m-%d %H:%M %Z"),
formatter(starts_at),
)
if starts_at
else SafeText("")
)
end_part: SafeText = (
format_html(
"Ends: {} ({})",
ends_at.strftime("%Y-%m-%d %H:%M %Z"),
formatter(ends_at),
)
if ends_at
else SafeText("")
)
if start_part and end_part:
return format_html("<p>{}<br />{}</p>", start_part, end_part)
if start_part:
return format_html("<p>{}</p>", start_part)
return format_html("<p>{}</p>", end_part)
def _campaign_rewards_html(campaign: KickDropCampaign) -> SafeText:
"""Render a compact rewards summary for a campaign.
Rewards are rendered as a simple list of "X minutes: Reward Name" entries.
Returns:
SafeText: HTML with rewards summary or empty if no rewards are available.
"""
rewards: list[KickReward] = getattr(campaign, "rewards_ordered", [])
if not rewards:
rewards = list(
campaign.rewards.order_by("required_units", "name"), # type: ignore[reportAttributeAccessIssue]
)
if not rewards:
return SafeText("")
items: list[SafeText] = [
format_html("<li>{} minutes: {}</li>", reward.required_units, reward.name)
for reward in rewards
]
html: SafeText = format_html(
"<ul>{}</ul>",
format_html_join("", "{}", [(item,) for item in items]),
)
return format_html("<p>Rewards:</p>{}", html)
def _campaign_channels_html(campaign: KickDropCampaign) -> SafeText:
"""Render up to 5 linked channels for a campaign.
If there are more than 5 channels, a note about additional channels will be added.
Returns:
SafeText: HTML with linked channels or empty if no channels are available.
"""
channels: list[KickChannel] = getattr(campaign, "channels_ordered", [])
if not channels:
channels = list(campaign.channels.select_related("user").order_by("slug"))
if not channels:
return SafeText("")
max_links: int = 5
items: list[SafeText] = []
for channel in channels[:max_links]:
label: str = channel.user.username if channel.user else channel.slug
if channel.channel_url:
items.append(
format_html('<li><a href="{}">{}</a></li>', channel.channel_url, label),
)
else:
items.append(format_html("<li>{}</li>", label))
if len(channels) > max_links:
items.append(format_html("<li>... and {} more</li>", len(channels) - max_links))
html: SafeText = format_html(
"<ul>{}</ul>",
format_html_join("", "{}", [(item,) for item in items]),
)
return format_html("<p>Channels with this drop:</p>{}", html)
def _campaign_links_html(campaign: KickDropCampaign) -> SafeText:
"""Render campaign external links if available.
Links can include the main campaign URL and a separate connect URL for claiming rewards.
Returns:
SafeText: HTML with campaign links or empty if no links are available.
"""
links: list[SafeText] = []
if campaign.url:
links.append(format_html('<a href="{}">About</a>', campaign.url))
if campaign.connect_url:
links.append(format_html('<a href="{}">Connect</a>', campaign.connect_url))
if not links:
return SafeText("")
return format_html(
"<p>{}</p>",
format_html_join(" | ", "{}", [(link,) for link in links]),
)
class KickOrganizationFeed(TTVDropsBaseFeed):
"""RSS feed for latest Kick organizations."""
title: str = "TTVDrops Kick Organizations"
link: str = "/kick/organizations/"
description: str = "Latest organizations on Kick in TTVDrops"
_limit: int | None = None
def __call__(
self,
request: HttpRequest,
*args: object,
**kwargs: object,
) -> HttpResponse:
"""Capture optional ?limit query parameter.
A ?limit parameter can be used to limit the number of organizations returned by the feed.
Returns:
HttpResponse: An HTTP response containing the feed XML.
"""
if request.GET.get("limit"):
try:
self._limit = int(request.GET.get("limit", 200))
except TypeError, ValueError:
self._limit = None
return super().__call__(request, *args, **kwargs)
def items(self) -> QuerySet[KickOrganization]:
"""Return latest organizations with configurable limit."""
limit: int = self._limit if self._limit is not None else 200
return KickOrganization.objects.order_by("-added_at")[:limit]
def item_title(self, item: KickOrganization) -> SafeText:
"""Return organization name as title."""
return SafeText(item.name)
def item_description(self, item: KickOrganization) -> SafeText:
"""Return organization summary HTML."""
parts: list[SafeText] = []
if item.logo_url:
parts.append(
format_html(
'<img src="{}" alt="{}" width="160" height="160" />',
item.logo_url,
item.name,
),
)
if item.url:
parts.append(format_html('<p><a href="{}">Website</a></p>', item.url))
return SafeText("".join(str(p) for p in parts))
def item_link(self, item: KickOrganization) -> str:
"""Return the link to organization detail."""
return reverse("kick:organization_detail", args=[item.kick_id])
def item_pubdate(self, item: KickOrganization) -> datetime.datetime:
"""Return publication date with fallback."""
return item.added_at or timezone.now()
def item_updateddate(self, item: KickOrganization) -> datetime.datetime:
"""Return updated date with fallback."""
return item.updated_at or timezone.now()
def item_author_name(self, item: KickOrganization) -> str:
"""Return author name for feed item."""
return item.name or "Kick"
def feed_url(self) -> str:
"""Return feed URL."""
return reverse("kick:organization_feed")
class KickCategoryFeed(TTVDropsBaseFeed):
"""RSS feed for latest Kick games."""
title: str = "TTVDrops Kick Games"
link: str = "/kick/games/"
description: str = "Latest games on Kick in TTVDrops"
_limit: int | None = None
def __call__(
self,
request: HttpRequest,
*args: object,
**kwargs: object,
) -> HttpResponse:
"""Capture optional ?limit query parameter.
Returns:
HttpResponse: The response from the parent __call__ after capturing the limit.
"""
if request.GET.get("limit"):
try:
self._limit = int(request.GET.get("limit", 200))
except TypeError, ValueError:
self._limit = None
return super().__call__(request, *args, **kwargs)
def items(self) -> QuerySet[KickCategory]:
"""Return latest games with configurable limit."""
limit: int = self._limit if self._limit is not None else 200
return KickCategory.objects.order_by("-added_at")[:limit]
def item_title(self, item: KickCategory) -> SafeText:
"""Return game name as title."""
return SafeText(item.name)
def item_description(self, item: KickCategory) -> SafeText:
"""Return game summary HTML."""
parts: list[SafeText] = []
if item.image_url:
parts.append(
format_html(
'<img src="{}" alt="{}" width="320" height="180" />',
item.image_url,
item.name,
),
)
if item.kick_url:
parts.append(
format_html('<p><a href="{}">Kick game</a></p>', item.kick_url),
)
category_campaign_feed: str = reverse(
"kick:game_campaign_feed",
args=[item.kick_id],
)
parts.append(
format_html(
'<p><a href="{}">Campaign feed</a></p>',
category_campaign_feed,
),
)
return SafeText("".join(str(p) for p in parts))
def item_link(self, item: KickCategory) -> str:
"""Return the link to game detail."""
return reverse("kick:game_detail", args=[item.kick_id])
def item_pubdate(self, item: KickCategory) -> datetime.datetime:
"""Return publication date with fallback."""
return item.added_at or timezone.now()
def item_updateddate(self, item: KickCategory) -> datetime.datetime:
"""Return updated date with fallback."""
return item.updated_at or timezone.now()
def item_author_name(self, item: KickCategory) -> str:
"""Return author name for feed item."""
return item.name or "Kick"
def feed_url(self) -> str:
"""Return feed URL."""
return reverse("kick:game_feed")
class KickCampaignFeed(TTVDropsBaseFeed):
"""RSS feed for currently active Kick drop campaigns."""
title: str = "Kick Drop Campaigns"
link: str = "/kick/campaigns/"
description: str = "Active Kick drop campaigns on TTVDrops"
item_guid_is_permalink = True
_limit: int | None = None
def __call__(
self,
request: HttpRequest,
*args: object,
**kwargs: object,
) -> HttpResponse:
"""Capture optional ?limit query parameter.
A ?limit parameter can be used to limit the number of campaigns returned by the feed.
Returns:
HttpResponse: An HTTP response containing the feed XML.
"""
if request.GET.get("limit"):
try:
self._limit = int(request.GET.get("limit", 200))
except TypeError, ValueError:
self._limit = None
return super().__call__(request, *args, **kwargs)
def items(self) -> list[KickDropCampaign]:
"""Return active campaigns ordered by newest starts_at."""
limit: int = self._limit if self._limit is not None else 200
queryset: QuerySet[KickDropCampaign] = _active_campaigns(
KickDropCampaign.objects.order_by("-starts_at"),
)
return list(_with_campaign_related(queryset)[:limit])
def item_title(self, item: KickDropCampaign) -> SafeText:
"""Return campaign title with optional game prefix."""
game_name: str = item.category.name if item.category else "Kick"
return SafeText(f"{game_name}: {item.name}")
def item_description(self, item: KickDropCampaign) -> SafeText:
"""Return campaign summary HTML."""
parts: list[SafeText] = []
if item.image_url:
parts.append(
format_html(
'<img src="{}" alt="{}" width="160" height="160" />',
item.image_url,
item.name,
),
)
parts.extend(
(
_campaign_dates_html(item),
_campaign_rewards_html(item),
_campaign_channels_html(item),
_campaign_links_html(item),
),
)
return SafeText("".join(str(p) for p in parts if p))
def item_link(self, item: KickDropCampaign) -> str:
"""Return campaign detail URL."""
return reverse("kick:campaign_detail", args=[item.kick_id])
def item_guid(self, item: KickDropCampaign) -> str:
"""Return unique item GUID as absolute URL."""
return self._absolute_url(reverse("kick:campaign_detail", args=[item.kick_id]))
def item_pubdate(self, item: KickDropCampaign) -> datetime.datetime:
"""Return publication date with fallback chain."""
if item.starts_at:
return item.starts_at
if item.added_at:
return item.added_at
return timezone.now()
def item_updateddate(self, item: KickDropCampaign) -> datetime.datetime:
"""Return update date with fallback chain."""
if item.api_updated_at:
return item.api_updated_at
if item.updated_at:
return item.updated_at
return timezone.now()
def item_categories(self, item: KickDropCampaign) -> tuple[str, ...]:
"""Return category labels for this campaign."""
categories: list[str] = ["kick", "drops"]
if item.category and item.category.name:
categories.append(item.category.name)
if item.organization and item.organization.name:
categories.append(item.organization.name)
return tuple(categories)
def item_author_name(self, item: KickDropCampaign) -> str:
"""Return author name from organization/category fallback."""
if item.organization and item.organization.name:
return item.organization.name
if item.category and item.category.name:
return item.category.name
return "Kick"
def feed_url(self) -> str:
"""Return feed URL."""
return reverse("kick:campaign_feed")
class KickCategoryCampaignFeed(TTVDropsBaseFeed):
"""RSS feed for active campaigns under a specific Kick game."""
item_guid_is_permalink = True
_limit: int | None = None
def __call__(
self,
request: HttpRequest,
*args: object,
**kwargs: object,
) -> HttpResponse:
"""Capture optional ?limit query parameter.
Returns:
HttpResponse: The response from the parent __call__ after capturing the limit.
"""
if request.GET.get("limit"):
try:
self._limit = int(request.GET.get("limit", 200))
except TypeError, ValueError:
self._limit = None
return super().__call__(request, *args, **kwargs)
def get_object(self, request: HttpRequest, kick_id: int) -> KickCategory: # noqa: ARG002
"""Return game object for this feed URL."""
return KickCategory.objects.get(kick_id=kick_id)
def title(self, obj: KickCategory) -> str:
"""Return dynamic feed title for game campaigns."""
return f"TTVDrops: {obj.name} Kick Campaigns"
def link(self, obj: KickCategory) -> str:
"""Return detail URL for game."""
return reverse("kick:game_detail", args=[obj.kick_id])
def description(self, obj: KickCategory) -> str:
"""Return dynamic feed description for game campaigns."""
return f"Active Kick drop campaigns for {obj.name}"
def items(self, obj: KickCategory) -> list[KickDropCampaign]:
"""Return active campaigns for this game."""
limit: int = self._limit if self._limit is not None else 200
queryset: QuerySet[KickDropCampaign] = _active_campaigns(
KickDropCampaign.objects.filter(category=obj).order_by("-starts_at"),
)
return list(_with_campaign_related(queryset)[:limit])
def item_title(self, item: KickDropCampaign) -> SafeText:
"""Return campaign title."""
game_name: str = item.category.name if item.category else "Kick"
return SafeText(f"{game_name}: {item.name}")
def item_description(self, item: KickDropCampaign) -> SafeText:
"""Return campaign summary HTML."""
parts: list[SafeText] = []
if item.image_url:
parts.append(
format_html(
'<img src="{}" alt="{}" width="160" height="160" />',
item.image_url,
item.name,
),
)
parts.extend(
(
_campaign_dates_html(item),
_campaign_rewards_html(item),
_campaign_channels_html(item),
_campaign_links_html(item),
),
)
return SafeText("".join(str(p) for p in parts if p))
def item_link(self, item: KickDropCampaign) -> str:
"""Return campaign detail URL."""
return reverse("kick:campaign_detail", args=[item.kick_id])
def item_guid(self, item: KickDropCampaign) -> str:
"""Return absolute GUID URL."""
return self._absolute_url(reverse("kick:campaign_detail", args=[item.kick_id]))
def item_pubdate(self, item: KickDropCampaign) -> datetime.datetime:
"""Return publication date with fallback chain."""
if item.starts_at:
return item.starts_at
if item.added_at:
return item.added_at
return timezone.now()
def item_updateddate(self, item: KickDropCampaign) -> datetime.datetime:
"""Return update date with fallback chain."""
if item.api_updated_at:
return item.api_updated_at
if item.updated_at:
return item.updated_at
return timezone.now()
def item_author_name(self, item: KickDropCampaign) -> str:
"""Return author for campaign item."""
if item.organization and item.organization.name:
return item.organization.name
return "Kick"
def feed_url(self, obj: KickCategory) -> str:
"""Return feed URL."""
return reverse("kick:game_campaign_feed", args=[obj.kick_id])
class KickOrganizationAtomFeed(TTVDropsAtomBaseFeed, KickOrganizationFeed):
"""Atom feed variant for organizations."""
subtitle: str = KickOrganizationFeed.description
def feed_url(self) -> str:
"""Return Atom feed URL."""
return reverse("kick:organization_feed_atom")
class KickCategoryAtomFeed(TTVDropsAtomBaseFeed, KickCategoryFeed):
"""Atom feed variant for games."""
subtitle: str = KickCategoryFeed.description
def feed_url(self) -> str:
"""Return Atom feed URL."""
return reverse("kick:game_feed_atom")
class KickCampaignAtomFeed(TTVDropsAtomBaseFeed, KickCampaignFeed):
"""Atom feed variant for campaigns."""
subtitle: str = KickCampaignFeed.description
def feed_url(self) -> str:
"""Return Atom feed URL."""
return reverse("kick:campaign_feed_atom")
class KickCategoryCampaignAtomFeed(TTVDropsAtomBaseFeed, KickCategoryCampaignFeed):
"""Atom feed variant for game-specific campaigns."""
def feed_url(self, obj: KickCategory) -> str:
"""Return Atom feed URL."""
return reverse("kick:game_campaign_feed_atom", args=[obj.kick_id])
class KickOrganizationDiscordFeed(TTVDropsAtomBaseFeed, KickOrganizationFeed):
"""Discord (Atom) feed variant for organizations."""
subtitle: str = KickOrganizationFeed.description
def feed_url(self) -> str:
"""Return Discord feed URL."""
return reverse("kick:organization_feed_discord")
class KickCategoryDiscordFeed(TTVDropsAtomBaseFeed, KickCategoryFeed):
"""Discord (Atom) feed variant for games."""
subtitle: str = KickCategoryFeed.description
def feed_url(self) -> str:
"""Return Discord feed URL."""
return reverse("kick:game_feed_discord")
class KickCampaignDiscordFeed(TTVDropsAtomBaseFeed, KickCampaignFeed):
"""Discord (Atom) feed variant for campaigns with relative timestamps."""
subtitle: str = KickCampaignFeed.description
def item_description(self, item: KickDropCampaign) -> SafeText:
"""Return campaign summary HTML with Discord relative timestamps."""
parts: list[SafeText] = []
if item.image_url:
parts.append(
format_html(
'<img src="{}" alt="{}" width="160" height="160" />',
item.image_url,
item.name,
),
)
parts.extend(
(
_campaign_dates_html(item, use_discord_relative=True),
_campaign_rewards_html(item),
_campaign_channels_html(item),
_campaign_links_html(item),
),
)
return SafeText("".join(str(p) for p in parts if p))
def feed_url(self) -> str:
"""Return Discord feed URL."""
return reverse("kick:campaign_feed_discord")
class KickCategoryCampaignDiscordFeed(TTVDropsAtomBaseFeed, KickCategoryCampaignFeed):
"""Discord (Atom) feed variant for game-specific campaigns with relative timestamps."""
def item_description(self, item: KickDropCampaign) -> SafeText:
"""Return campaign summary HTML with Discord relative timestamps."""
parts: list[SafeText] = []
if item.image_url:
parts.append(
format_html(
'<img src="{}" alt="{}" width="160" height="160" />',
item.image_url,
item.name,
),
)
parts.extend(
(
_campaign_dates_html(item, use_discord_relative=True),
_campaign_rewards_html(item),
_campaign_channels_html(item),
_campaign_links_html(item),
),
)
return SafeText("".join(str(p) for p in parts if p))
def feed_url(self, obj: KickCategory) -> str:
"""Return Discord feed URL."""
return reverse("kick:game_campaign_feed_discord", args=[obj.kick_id])

View file

View file

View file

@ -0,0 +1,218 @@
import logging
from typing import TYPE_CHECKING
import httpx
from django.core.management.base import BaseCommand
from pydantic import ValidationError
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickDropCampaign
from kick.models import KickOrganization
from kick.models import KickReward
from kick.models import KickUser
from kick.schemas import KickDropsResponseSchema
if TYPE_CHECKING:
from django.core.management.base import CommandParser
from kick.schemas import KickCategorySchema
from kick.schemas import KickDropCampaignSchema
from kick.schemas import KickOrganizationSchema
from kick.schemas import KickUserSchema
logger: logging.Logger = logging.getLogger("ttvdrops")
KICK_DROPS_API_URL = "https://web.kick.com/api/v1/drops/campaigns"
# Kick's public API requires a browser-like User-Agent.
REQUEST_HEADERS: dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0"
),
"Accept": "application/json",
"Referer": "https://kick.com/",
}
class Command(BaseCommand):
"""Import drop campaigns from the Kick public API."""
help = "Fetch and import Kick drop campaigns from the public API."
def add_arguments(self, parser: CommandParser) -> None:
"""Add command-line arguments for the import_kick_drops command."""
parser.add_argument(
"--url",
default=KICK_DROPS_API_URL,
help="API endpoint to fetch (default: %(default)s).",
)
def handle(self, *args: object, **options: object) -> None: # noqa: ARG002
"""Main entry point for the command."""
url: str = str(options["url"])
self.stdout.write(f"Fetching Kick drops from {url} ...")
try:
response: httpx.Response = httpx.get(
url,
headers=REQUEST_HEADERS,
timeout=30,
follow_redirects=True,
)
response.raise_for_status()
except httpx.HTTPError as exc:
self.stderr.write(
self.style.ERROR(f"HTTP error fetching Kick drops: {exc}"),
)
return
try:
payload: dict = response.json()
except Exception as exc: # noqa: BLE001
self.stderr.write(self.style.ERROR(f"Failed to parse JSON response: {exc}"))
return
try:
drops_response: KickDropsResponseSchema = (
KickDropsResponseSchema.model_validate(payload)
)
except ValidationError as exc:
self.stderr.write(self.style.ERROR(f"Response validation failed: {exc}"))
return
campaigns: list[KickDropCampaignSchema] = drops_response.data
self.stdout.write(f"Found {len(campaigns)} campaign(s). Importing ...")
imported = 0
for campaign_data in campaigns:
try:
self._import_campaign(campaign_data)
imported += 1
except Exception as exc:
logger.exception("Failed to import campaign %s", campaign_data.id)
self.stderr.write(
self.style.WARNING(f"Skipped campaign {campaign_data.id!r}: {exc}"),
)
self.stdout.write(
self.style.SUCCESS(f"Imported {imported}/{len(campaigns)} campaign(s)."),
)
def _import_campaign(self, data: KickDropCampaignSchema) -> None:
"""Import a single campaign and all its related objects."""
# Organisation
org_data: KickOrganizationSchema = data.organization
org, created = KickOrganization.objects.update_or_create(
kick_id=org_data.id,
defaults={
"name": org_data.name,
"logo_url": org_data.logo_url,
"url": org_data.url,
"restricted": org_data.restricted,
},
)
if created:
logger.info("Created new organization: %s", org.kick_id)
# Category
cat_data: KickCategorySchema = data.category
category, created = KickCategory.objects.update_or_create(
kick_id=cat_data.id,
defaults={
"name": cat_data.name,
"slug": cat_data.slug,
"image_url": cat_data.image_url,
},
)
if created:
logger.info("Created new category: %s", category.kick_id)
# Campaign
campaign, created = KickDropCampaign.objects.update_or_create(
kick_id=data.id,
defaults={
"name": data.name,
"status": data.status,
"starts_at": data.starts_at,
"ends_at": data.ends_at,
"connect_url": data.connect_url,
"url": data.url,
"rule_id": data.rule.id,
"rule_name": data.rule.name,
"organization": org,
"category": category,
"created_at": data.created_at,
"api_updated_at": data.updated_at,
},
)
if created:
logger.info("Created new campaign: %s", campaign.kick_id)
# Channels
channel_objs: list[KickChannel] = []
for ch_data in data.channels:
user_data: KickUserSchema = ch_data.user
user, created = KickUser.objects.update_or_create(
kick_id=user_data.id,
defaults={
"username": user_data.username,
"profile_picture": user_data.profile_picture,
},
)
if created:
logger.info("Created new user: %s", user.kick_id)
channel, created = KickChannel.objects.update_or_create(
kick_id=ch_data.id,
defaults={
"slug": ch_data.slug,
"description": ch_data.description,
"banner_picture_url": ch_data.banner_picture_url,
"user": user,
},
)
if created:
logger.info("Created new channel: %s", channel.kick_id)
channel_objs.append(channel)
campaign.channels.set(channel_objs)
for reward_data in data.rewards:
# Resolve reward's category (may differ from campaign category)
reward_category: KickCategory = category
if reward_data.category_id != cat_data.id:
reward_category, created = KickCategory.objects.get_or_create(
kick_id=reward_data.category_id,
defaults={"name": "", "slug": "", "image_url": ""},
)
if created:
logger.info("Created new category: %s", reward_category.kick_id)
# Resolve reward's organization (may differ from campaign org)
reward_org: KickOrganization = org
if reward_data.organization_id != org_data.id:
reward_org, created = KickOrganization.objects.get_or_create(
kick_id=reward_data.organization_id,
defaults={
"name": "",
"logo_url": "",
"url": "",
"restricted": False,
},
)
if created:
logger.info("Created new organization: %s", reward_org.kick_id)
KickReward.objects.update_or_create(
kick_id=reward_data.id,
defaults={
"name": reward_data.name,
"image_url": reward_data.image_url,
"required_units": reward_data.required_units,
"campaign": campaign,
"category": reward_category,
"organization": reward_org,
},
)

View file

@ -0,0 +1,545 @@
# Generated by Django 6.0.3 on 2026-03-14 05:12
import auto_prefetch
import django.db.models.deletion
import django.db.models.manager
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
"""Initial migration for the kick app."""
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="KickChannel",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"kick_id",
models.PositiveBigIntegerField(
editable=False,
unique=True,
verbose_name="Kick Channel ID",
),
),
("slug", models.TextField(blank=True, default="", verbose_name="Slug")),
(
"description",
models.TextField(
blank=True,
default="",
verbose_name="Description",
),
),
(
"banner_picture_url",
models.TextField(
blank=True,
default="",
help_text="May be empty or a relative path.",
verbose_name="Banner Picture URL",
),
),
("added_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Kick Channel",
"verbose_name_plural": "Kick Channels",
"ordering": ["slug"],
"abstract": False,
"base_manager_name": "prefetch_manager",
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.CreateModel(
name="KickCategory",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"kick_id",
models.PositiveIntegerField(
editable=False,
help_text="Integer identifier from the Kick API.",
unique=True,
verbose_name="Kick Category ID",
),
),
("name", models.TextField(verbose_name="Name")),
(
"slug",
models.SlugField(
blank=True,
default="",
max_length=200,
verbose_name="Slug",
),
),
(
"image_url",
models.URLField(
blank=True,
default="",
max_length=500,
verbose_name="Image URL",
),
),
("added_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Kick Category",
"verbose_name_plural": "Kick Categories",
"ordering": ["name"],
"abstract": False,
"base_manager_name": "prefetch_manager",
"indexes": [
models.Index(
fields=["kick_id"],
name="kick_kickca_kick_id_7a71c0_idx",
),
models.Index(fields=["name"], name="kick_kickca_name_bb9784_idx"),
models.Index(fields=["slug"], name="kick_kickca_slug_c983b1_idx"),
],
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.CreateModel(
name="KickOrganization",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"kick_id",
models.TextField(
editable=False,
help_text="ULID string identifier from the Kick API.",
unique=True,
verbose_name="Kick Organization ID",
),
),
("name", models.TextField(verbose_name="Name")),
(
"logo_url",
models.URLField(
blank=True,
default="",
max_length=500,
verbose_name="Logo URL",
),
),
(
"url",
models.URLField(
blank=True,
default="",
max_length=500,
verbose_name="URL",
),
),
(
"restricted",
models.BooleanField(default=False, verbose_name="Restricted"),
),
("added_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Kick Organization",
"verbose_name_plural": "Kick Organizations",
"ordering": ["name"],
"abstract": False,
"base_manager_name": "prefetch_manager",
"indexes": [
models.Index(
fields=["kick_id"],
name="kick_kickor_kick_id_c64a0d_idx",
),
models.Index(fields=["name"], name="kick_kickor_name_2f9336_idx"),
],
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.CreateModel(
name="KickDropCampaign",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"kick_id",
models.TextField(
editable=False,
help_text="ULID string identifier from the Kick API.",
unique=True,
verbose_name="Kick Campaign ID",
),
),
("name", models.TextField(verbose_name="Name")),
(
"status",
models.CharField(
blank=True,
default="",
help_text="e.g. 'active' or 'expired'.",
max_length=50,
verbose_name="Status",
),
),
(
"starts_at",
models.DateTimeField(
blank=True,
null=True,
verbose_name="Starts At",
),
),
(
"ends_at",
models.DateTimeField(blank=True, null=True, verbose_name="Ends At"),
),
(
"connect_url",
models.URLField(
blank=True,
default="",
help_text="URL to link an account for the campaign.",
max_length=500,
verbose_name="Connect URL",
),
),
(
"url",
models.URLField(
blank=True,
default="",
max_length=500,
verbose_name="URL",
),
),
(
"rule_id",
models.PositiveIntegerField(
blank=True,
null=True,
verbose_name="Rule ID",
),
),
(
"rule_name",
models.TextField(blank=True, default="", verbose_name="Rule Name"),
),
(
"created_at",
models.DateTimeField(
blank=True,
help_text="When the campaign was created on Kick.",
null=True,
verbose_name="Created At (Kick)",
),
),
(
"api_updated_at",
models.DateTimeField(
blank=True,
help_text="When the campaign was last updated on Kick.",
null=True,
verbose_name="Updated At (Kick)",
),
),
("added_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"category",
auto_prefetch.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="campaigns",
to="kick.kickcategory",
verbose_name="Category",
),
),
(
"channels",
models.ManyToManyField(
blank=True,
related_name="campaigns",
to="kick.kickchannel",
verbose_name="Channels",
),
),
(
"organization",
auto_prefetch.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="campaigns",
to="kick.kickorganization",
verbose_name="Organization",
),
),
],
options={
"verbose_name": "Kick Drop Campaign",
"verbose_name_plural": "Kick Drop Campaigns",
"ordering": ["-starts_at"],
"abstract": False,
"base_manager_name": "prefetch_manager",
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.CreateModel(
name="KickReward",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"kick_id",
models.TextField(
editable=False,
help_text="ULID string identifier from the Kick API.",
unique=True,
verbose_name="Kick Reward ID",
),
),
("name", models.TextField(verbose_name="Name")),
(
"image_url",
models.TextField(
blank=True,
default="",
help_text="May be a relative path (e.g. 'drops/reward-image/...').",
verbose_name="Image URL",
),
),
(
"required_units",
models.PositiveIntegerField(
default=0,
help_text="Number of watch-minutes required to earn this reward.",
verbose_name="Required Units",
),
),
("added_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"campaign",
auto_prefetch.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="rewards",
to="kick.kickdropcampaign",
verbose_name="Campaign",
),
),
(
"category",
auto_prefetch.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rewards",
to="kick.kickcategory",
verbose_name="Category",
),
),
(
"organization",
auto_prefetch.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rewards",
to="kick.kickorganization",
verbose_name="Organization",
),
),
],
options={
"verbose_name": "Kick Reward",
"verbose_name_plural": "Kick Rewards",
"ordering": ["required_units", "name"],
"abstract": False,
"base_manager_name": "prefetch_manager",
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.CreateModel(
name="KickUser",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"kick_id",
models.PositiveBigIntegerField(
editable=False,
unique=True,
verbose_name="Kick User ID",
),
),
("username", models.TextField(verbose_name="Username")),
(
"profile_picture",
models.URLField(
blank=True,
default="",
max_length=500,
verbose_name="Profile Picture URL",
),
),
("added_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Kick User",
"verbose_name_plural": "Kick Users",
"ordering": ["username"],
"abstract": False,
"base_manager_name": "prefetch_manager",
"indexes": [
models.Index(
fields=["kick_id"],
name="kick_kickus_kick_id_5eda21_idx",
),
models.Index(
fields=["username"],
name="kick_kickus_usernam_68f5af_idx",
),
],
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.AddField(
model_name="kickchannel",
name="user",
field=auto_prefetch.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="channels",
to="kick.kickuser",
verbose_name="User",
),
),
migrations.AddIndex(
model_name="kickdropcampaign",
index=models.Index(
fields=["kick_id"],
name="kick_kickdr_kick_id_2d0bcf_idx",
),
),
migrations.AddIndex(
model_name="kickdropcampaign",
index=models.Index(fields=["name"], name="kick_kickdr_name_26e308_idx"),
),
migrations.AddIndex(
model_name="kickdropcampaign",
index=models.Index(fields=["status"], name="kick_kickdr_status_fbc875_idx"),
),
migrations.AddIndex(
model_name="kickdropcampaign",
index=models.Index(
fields=["-starts_at"],
name="kick_kickdr_starts__f84d8a_idx",
),
),
migrations.AddIndex(
model_name="kickdropcampaign",
index=models.Index(
fields=["ends_at"],
name="kick_kickdr_ends_at_ed0dbd_idx",
),
),
migrations.AddIndex(
model_name="kickreward",
index=models.Index(
fields=["kick_id"],
name="kick_kickre_kick_id_687d52_idx",
),
),
migrations.AddIndex(
model_name="kickreward",
index=models.Index(
fields=["required_units"],
name="kick_kickre_require_9953e6_idx",
),
),
migrations.AddIndex(
model_name="kickchannel",
index=models.Index(
fields=["kick_id"],
name="kick_kickch_kick_id_4e780e_idx",
),
),
migrations.AddIndex(
model_name="kickchannel",
index=models.Index(fields=["slug"], name="kick_kickch_slug_1a8efe_idx"),
),
]

View file

436
kick/models.py Normal file
View file

@ -0,0 +1,436 @@
import logging
import re
from typing import TYPE_CHECKING
import auto_prefetch
from django.db import models
from django.urls import reverse
from django.utils import timezone
if TYPE_CHECKING:
import datetime
logger: logging.Logger = logging.getLogger("ttvdrops")
KICK_IMAGE_BASE_URL = "https://files.kick.com/"
# MARK: KickOrganization
class KickOrganization(auto_prefetch.Model):
"""Represents an organization on Kick that owns drop campaigns."""
kick_id = models.TextField(
unique=True,
editable=False,
verbose_name="Kick Organization ID",
help_text="ULID string identifier from the Kick API.",
)
name = models.TextField(verbose_name="Name")
logo_url = models.URLField(
max_length=500,
blank=True,
default="",
verbose_name="Logo URL",
)
url = models.URLField(
max_length=500,
blank=True,
default="",
verbose_name="URL",
)
restricted = models.BooleanField(default=False, verbose_name="Restricted")
added_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class Meta(auto_prefetch.Model.Meta):
ordering = ["name"]
verbose_name = "Kick Organization"
verbose_name_plural = "Kick Organizations"
indexes = [
models.Index(fields=["kick_id"]),
models.Index(fields=["name"]),
]
def __str__(self) -> str:
return self.name or self.kick_id
# MARK: KickCategory
class KickCategory(auto_prefetch.Model):
"""Represents a game/category on Kick."""
kick_id = models.PositiveIntegerField(
unique=True,
editable=False,
verbose_name="Kick Category ID",
help_text="Integer identifier from the Kick API.",
)
name = models.TextField(verbose_name="Name")
slug = models.SlugField(max_length=200, blank=True, default="", verbose_name="Slug")
image_url = models.URLField(
max_length=500,
blank=True,
default="",
verbose_name="Image URL",
)
added_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class Meta(auto_prefetch.Model.Meta):
ordering = ["name"]
verbose_name = "Kick Category"
verbose_name_plural = "Kick Categories"
indexes = [
models.Index(fields=["kick_id"]),
models.Index(fields=["name"]),
models.Index(fields=["slug"]),
]
def __str__(self) -> str:
return self.name or str(self.kick_id)
@property
def get_absolute_url(self) -> str:
"""Return the URL to the game detail page."""
return reverse("kick:game_detail", args=[self.kick_id])
@property
def kick_url(self) -> str:
"""Return the URL to the game page on Kick."""
return f"https://kick.com/category/{self.slug}" if self.slug else ""
# MARK: KickUser
class KickUser(auto_prefetch.Model):
"""Represents a Kick user associated with a channel."""
kick_id = models.PositiveBigIntegerField(
unique=True,
editable=False,
verbose_name="Kick User ID",
)
username = models.TextField(verbose_name="Username")
profile_picture = models.URLField(
max_length=500,
blank=True,
default="",
verbose_name="Profile Picture URL",
)
added_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class Meta(auto_prefetch.Model.Meta):
ordering = ["username"]
verbose_name = "Kick User"
verbose_name_plural = "Kick Users"
indexes = [
models.Index(fields=["kick_id"]),
models.Index(fields=["username"]),
]
def __str__(self) -> str:
return self.username or str(self.kick_id)
@property
def kick_profile_url(self) -> str:
"""Return the Kick profile URL for this user."""
return f"https://kick.com/{self.username}" if self.username else ""
# MARK: KickChannel
class KickChannel(auto_prefetch.Model):
"""Represents a Kick channel that participates in drop campaigns."""
kick_id = models.PositiveBigIntegerField(
unique=True,
editable=False,
verbose_name="Kick Channel ID",
)
slug = models.TextField(blank=True, default="", verbose_name="Slug")
description = models.TextField(blank=True, default="", verbose_name="Description")
banner_picture_url = models.TextField(
blank=True,
default="",
verbose_name="Banner Picture URL",
help_text="May be empty or a relative path.",
)
user = auto_prefetch.ForeignKey(
KickUser,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="channels",
verbose_name="User",
)
added_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class Meta(auto_prefetch.Model.Meta):
ordering = ["slug"]
verbose_name = "Kick Channel"
verbose_name_plural = "Kick Channels"
indexes = [
models.Index(fields=["kick_id"]),
models.Index(fields=["slug"]),
]
def __str__(self) -> str:
return self.slug or str(self.kick_id)
@property
def channel_url(self) -> str:
"""Return the Kick channel URL."""
return f"https://kick.com/{self.slug}" if self.slug else ""
# MARK: KickDropCampaign
class KickDropCampaign(auto_prefetch.Model):
"""Represents a Kick drop campaign."""
kick_id = models.TextField(
unique=True,
editable=False,
verbose_name="Kick Campaign ID",
help_text="ULID string identifier from the Kick API.",
)
name = models.TextField(verbose_name="Name")
status = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="Status",
help_text="e.g. 'active' or 'expired'.",
)
starts_at = models.DateTimeField(null=True, blank=True, verbose_name="Starts At")
ends_at = models.DateTimeField(null=True, blank=True, verbose_name="Ends At")
connect_url = models.URLField(
max_length=500,
blank=True,
default="",
verbose_name="Connect URL",
help_text="URL to link an account for the campaign.",
)
url = models.URLField(
max_length=500,
blank=True,
default="",
verbose_name="URL",
)
rule_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Rule ID")
rule_name = models.TextField(blank=True, default="", verbose_name="Rule Name")
organization = auto_prefetch.ForeignKey(
KickOrganization,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="campaigns",
verbose_name="Organization",
)
category = auto_prefetch.ForeignKey(
KickCategory,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="campaigns",
verbose_name="Category",
)
channels = models.ManyToManyField(
KickChannel,
blank=True,
related_name="campaigns",
verbose_name="Channels",
)
created_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Created At (Kick)",
help_text="When the campaign was created on Kick.",
)
api_updated_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Updated At (Kick)",
help_text="When the campaign was last updated on Kick.",
)
added_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class Meta(auto_prefetch.Model.Meta):
ordering = ["-starts_at"]
verbose_name = "Kick Drop Campaign"
verbose_name_plural = "Kick Drop Campaigns"
indexes = [
models.Index(fields=["kick_id"]),
models.Index(fields=["name"]),
models.Index(fields=["status"]),
models.Index(fields=["-starts_at"]),
models.Index(fields=["ends_at"]),
]
def __str__(self) -> str:
return self.name or self.kick_id
@property
def is_active(self) -> bool:
"""Check if the campaign is currently active based on dates."""
now: datetime.datetime = timezone.now()
if self.starts_at is None or self.ends_at is None:
return False
return self.starts_at <= now <= self.ends_at
@property
def image_url(self) -> str:
"""Return the image URL for the campaign."""
# Image from first drop
if self.rewards.exists(): # pyright: ignore[reportAttributeAccessIssue]
first_reward: KickReward = self.rewards.first() # pyright: ignore[reportAttributeAccessIssue]
if first_reward and first_reward.image_url:
return first_reward.full_image_url
if self.category and self.category.image_url:
return self.category.image_url
if self.organization and self.organization.logo_url:
return self.organization.logo_url
return ""
@property
def duration(self) -> str | None:
"""Human-readable duration of the campaign, or None if not available.
Uses Django's timesince filter format (e.g. "2 days, 3 hours").
"""
if self.starts_at and self.ends_at:
delta: datetime.timedelta = self.ends_at - self.starts_at
total_seconds: int = int(delta.total_seconds())
days, remainder = divmod(total_seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, _ = divmod(remainder, 60)
parts: list[str] = []
if days > 0:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours > 0:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes > 0:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
return ", ".join(parts) if parts else "0 minutes"
return None
@staticmethod
def _normalized_reward_name(name: str) -> str:
"""Normalize reward names to merge console/connected variants.
Some Kick rewards appear twice with and without a trailing "(Con)" marker,
and occasionally differ only by spacing around punctuation like "&".
Returns:
A normalized, case-insensitive reward name key.
"""
normalized: str = name.strip()
normalized = re.sub(r"\s*\(con\)\s*$", "", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"\s*&\s*", " & ", normalized)
normalized = re.sub(r"\s+", " ", normalized)
return normalized.casefold()
@property
def merged_rewards(self) -> list[KickReward]:
"""Return rewards de-duplicated by normalized name.
If both a base reward and a "(Con)" variant exist, prefer the base reward name.
"""
rewards_by_name: dict[str, KickReward] = {}
for reward in self.rewards.all().order_by("required_units", "name", "kick_id"): # pyright: ignore[reportAttributeAccessIssue]
key: str = self._normalized_reward_name(reward.name)
existing: KickReward | None = rewards_by_name.get(key)
if existing is None:
rewards_by_name[key] = reward
continue
existing_is_con: bool = existing.name.strip().casefold().endswith("(con)")
reward_is_con: bool = reward.name.strip().casefold().endswith("(con)")
if existing_is_con and not reward_is_con:
rewards_by_name[key] = reward
return list(rewards_by_name.values())
# MARK: KickReward
class KickReward(auto_prefetch.Model):
"""Represents a reward that can be earned from a Kick drop campaign."""
kick_id = models.TextField(
unique=True,
editable=False,
verbose_name="Kick Reward ID",
help_text="ULID string identifier from the Kick API.",
)
name = models.TextField(verbose_name="Name")
image_url = models.TextField(
blank=True,
default="",
verbose_name="Image URL",
help_text="May be a relative path (e.g. 'drops/reward-image/...').",
)
required_units = models.PositiveIntegerField(
default=0,
verbose_name="Required Units",
help_text="Number of watch-minutes required to earn this reward.",
)
campaign = auto_prefetch.ForeignKey(
KickDropCampaign,
on_delete=models.CASCADE,
related_name="rewards",
verbose_name="Campaign",
)
category = auto_prefetch.ForeignKey(
KickCategory,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rewards",
verbose_name="Category",
)
organization = auto_prefetch.ForeignKey(
KickOrganization,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rewards",
verbose_name="Organization",
)
added_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class Meta(auto_prefetch.Model.Meta):
ordering = ["required_units", "name"]
verbose_name = "Kick Reward"
verbose_name_plural = "Kick Rewards"
indexes = [
models.Index(fields=["kick_id"]),
models.Index(fields=["required_units"]),
]
def __str__(self) -> str:
return self.name or self.kick_id
@property
def full_image_url(self) -> str:
"""Return the absolute image URL for this reward.
If the image_url is a relative path, prepend the Kick image base URL.
"""
if not self.image_url:
return ""
if self.image_url.startswith("http"):
return self.image_url
return "https://ext.cdn.kick.com/" + self.image_url

126
kick/schemas.py Normal file
View file

@ -0,0 +1,126 @@
from datetime import datetime # noqa: TC003
from pydantic import BaseModel
from pydantic import Field
class KickUserSchema(BaseModel):
"""Schema for the user nested inside a Kick channel."""
id: int
username: str
profile_picture: str = ""
model_config = {
"extra": "forbid",
"populate_by_name": True,
}
class KickChannelSchema(BaseModel):
"""Schema for a Kick channel participating in a drop campaign."""
id: int
slug: str = ""
description: str = ""
banner_picture_url: str = ""
user: KickUserSchema
model_config = {
"extra": "forbid",
"populate_by_name": True,
}
class KickCategorySchema(BaseModel):
"""Schema for a Kick category (game)."""
id: int
name: str
slug: str = ""
image_url: str = ""
model_config = {
"extra": "forbid",
"populate_by_name": True,
}
class KickOrganizationSchema(BaseModel):
"""Schema for a Kick organization that owns drop campaigns."""
id: str
name: str
logo_url: str = ""
url: str = ""
restricted: bool = False
email_notification: bool = False
model_config = {
"extra": "forbid",
"populate_by_name": True,
}
class KickRewardSchema(BaseModel):
"""Schema for a reward earned from a Kick drop campaign."""
id: str
name: str
image_url: str = ""
required_units: int
category_id: int
organization_id: str
model_config = {
"extra": "forbid",
"populate_by_name": True,
}
class KickRuleSchema(BaseModel):
"""Schema for the rule attached to a drop campaign."""
id: int
name: str
model_config = {
"extra": "forbid",
"populate_by_name": True,
}
class KickDropCampaignSchema(BaseModel):
"""Schema for a Kick drop campaign."""
id: str
name: str
status: str = ""
starts_at: datetime | None = None
ends_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
connect_url: str = ""
url: str = ""
category: KickCategorySchema
organization: KickOrganizationSchema
channels: list[KickChannelSchema] = Field(default_factory=list)
rewards: list[KickRewardSchema] = Field(default_factory=list)
rule: KickRuleSchema
model_config = {
"extra": "forbid",
"populate_by_name": True,
}
class KickDropsResponseSchema(BaseModel):
"""Schema for the top-level response from the Kick drops API."""
data: list[KickDropCampaignSchema]
message: str = ""
model_config = {
"extra": "forbid",
"populate_by_name": True,
}

0
kick/tests/__init__.py Normal file
View file

935
kick/tests/test_kick.py Normal file
View file

@ -0,0 +1,935 @@
import re
from datetime import UTC
from datetime import datetime as dt
from datetime import timedelta
from io import StringIO
from typing import TYPE_CHECKING
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import patch
import httpx
import pytest
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from pydantic import ValidationError
from kick.feeds import discord_timestamp
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickDropCampaign
from kick.models import KickOrganization
from kick.models import KickReward
from kick.models import KickUser
from kick.schemas import KickDropCampaignSchema
from kick.schemas import KickDropsResponseSchema
if TYPE_CHECKING:
from django.test.client import _MonkeyPatchedWSGIResponse
from kick.schemas import KickDropCampaignSchema
from kick.schemas import KickRewardSchema
# Minimal valid campaign fixture (single campaign, active status)
SINGLE_CAMPAIGN_JSON: dict[str, list[dict[str, Any]] | str] = {
"data": [
{
"id": "01KKBNEM8TZG7ASRG42TK7RKRB",
"name": "PUBG 9th Anniversary",
"status": "active",
"starts_at": "2026-03-11T08:00:00Z",
"ends_at": "2026-03-31T07:59:00Z",
"created_at": "2026-03-10T10:42:29.793929Z",
"updated_at": "2026-03-10T10:42:30.248864Z",
"connect_url": "https://accounts.krafton.com/auth/kick/callback",
"url": "https://accounts.krafton.com",
"category": {
"id": 53,
"name": "PUBG: Battlegrounds",
"slug": "pubg-battlegrounds",
"image_url": "https://files.kick.com/images/subcategories/53/banner/pubg-battlegrounds.jpg",
},
"organization": {
"id": "01KFF1CP2Q2X6EFDN9M85M3M97",
"name": "KRAFTON",
"logo_url": "https://krafton.com/wp-content/uploads/2021/06/logo-krafton-brandcenter.png",
"url": "https://krafton.com",
"restricted": False,
"email_notification": False,
},
"channels": [],
"rewards": [
{
"id": "01KKBM2YDPSBJD437HF7CDPTQT",
"name": "9th Anniversary Flamin' Cake",
"image_url": "drops/reward-image/01kkbm2ydpsbjd437hf7cdptqt-01kkbm2ydpsbjd437hf8yhrzqm.png",
"required_units": 30,
"category_id": 53,
"organization_id": "01KFF1CP2Q2X6EFDN9M85M3M97",
},
],
"rule": {"id": 1, "name": "Watch to redeem"},
},
],
"message": "Success",
}
# Campaign with channels fixture
CAMPAIGN_WITH_CHANNELS_JSON: dict[str, list[dict[str, Any]] | str] = {
"data": [
{
"id": "01K8X4WHMVTF4HQNT9RRTGBACK",
"name": "Team Ricoy MP5",
"status": "expired",
"starts_at": "2025-11-13T21:00:00Z",
"ends_at": "2025-11-23T23:59:00Z",
"created_at": "2025-10-31T12:46:39.78377Z",
"updated_at": "2025-11-05T20:42:33.761611Z",
"connect_url": "https://kick.facepunch.com",
"url": "https://kick.facepunch.com",
"category": {
"id": 13,
"name": "Rust",
"slug": "rust",
"image_url": "https://files.kick.com/images/subcategories/13/banner/77786fd4-4f33-4221-b8ee-2bf3cb4d041e",
},
"organization": {
"id": "01K6WKP5BBMPZJ89G5Y7QK1E9P",
"name": "Facepunch Studios",
"logo_url": "https://images.squarespace-cdn.com/content/v1/627cb6fa4355783e5e375440/b47ec50e-bc8b-4801-87cb-dee12da27748/default-light.png?format=1500w",
"url": "https://kick.facepunch.com",
"restricted": False,
"email_notification": False,
},
"channels": [
{
"id": 1661881,
"slug": "ricoy",
"description": "",
"banner_picture_url": "https://files.kick.com/images/channel/1661881/banner_image/default-banner-2.jpg",
"user": {
"id": 1711034,
"username": "Ricoy",
"profile_picture": "https://files.kick.com/images/user/1711034/profile_image/conversion/101aa7a7-6f75-43d2-a0d3-8fde7aa7f664-medium.webp",
},
},
{
"id": 969446,
"slug": "dilanzito",
"description": "",
"banner_picture_url": "https://files.kick.com/images/channel/969446/banner_image/default-banner-2.jpg",
"user": {
"id": 1011977,
"username": "dilanzito",
"profile_picture": "https://files.kick.com/images/user/1011977/profile_image/conversion/32a59b29-c319-4210-b5a0-ac14dd112f13-medium.webp",
},
},
],
"rewards": [
{
"id": "01K8X3P1D3AE2PNEBP1VJCVXAR",
"name": "Team Ricoy MP5",
"image_url": "drops/reward-image/01k8x3p1d3ae2pnebp1vjcvxar.png",
"required_units": 120,
"category_id": 13,
"organization_id": "01K6WKP5BBMPZJ89G5Y7QK1E9P",
},
],
"rule": {"id": 1, "name": "Watch to redeem"},
},
],
"message": "Success",
}
# MARK: Schema tests
class KickDropsResponseSchemaTest(TestCase):
"""Tests for Pydantic schema validation of the Kick drops API response."""
def test_valid_single_campaign(self) -> None:
"""Schema validates a minimal valid campaign without channels."""
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
SINGLE_CAMPAIGN_JSON,
)
assert result.message == "Success"
assert len(result.data) == 1
campaign: KickDropCampaignSchema = result.data[0]
assert campaign.id == "01KKBNEM8TZG7ASRG42TK7RKRB"
assert campaign.name == "PUBG 9th Anniversary"
assert campaign.status == "active"
assert len(campaign.channels) == 0
assert len(campaign.rewards) == 1
def test_campaign_with_channels(self) -> None:
"""Schema validates a campaign that includes channel data."""
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
CAMPAIGN_WITH_CHANNELS_JSON,
)
campaign: KickDropCampaignSchema = result.data[0]
assert len(campaign.channels) == 2
assert campaign.channels[0].slug == "ricoy"
assert campaign.channels[0].user.username == "Ricoy"
def test_reward_fields(self) -> None:
"""Reward fields parse correctly including relative image URL."""
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
SINGLE_CAMPAIGN_JSON,
)
reward: KickRewardSchema = result.data[0].rewards[0]
assert reward.id == "01KKBM2YDPSBJD437HF7CDPTQT"
assert reward.required_units == 30
assert "drops/reward-image/" in reward.image_url
def test_empty_channels_list(self) -> None:
"""Schema accepts an empty channels list."""
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
SINGLE_CAMPAIGN_JSON,
)
assert result.data[0].channels == []
def test_extra_fields_rejected(self) -> None:
"""Extra fields in the API response cause a ValidationError."""
bad_payload: dict[str, str | list] = {
"data": [],
"message": "Success",
"unexpected_field": "oops",
}
with pytest.raises(ValidationError):
KickDropsResponseSchema.model_validate(bad_payload)
# MARK: Model tests
class KickRewardFullImageUrlTest(TestCase):
"""Tests for KickReward.full_image_url property."""
def _make_reward(self, image_url: str) -> KickReward:
org: KickOrganization = KickOrganization.objects.create(
kick_id="org-1",
name="Org",
)
cat: KickCategory = KickCategory.objects.create(
kick_id=99,
name="Cat",
slug="cat",
)
campaign: KickDropCampaign = KickDropCampaign.objects.create(
kick_id="camp-1",
name="Test Campaign",
organization=org,
category=cat,
rule_id=1,
rule_name="Watch to redeem",
)
return KickReward.objects.create(
kick_id="reward-1",
name="Reward",
image_url=image_url,
required_units=60,
campaign=campaign,
category=cat,
organization=org,
)
def test_relative_image_url_gets_base_prefix(self) -> None:
"""If image_url is relative, full_image_url should prepend the Kick files base URL."""
reward: KickReward = self._make_reward("drops/reward-image/abc.png")
assert (
reward.full_image_url
== "https://ext.cdn.kick.com/drops/reward-image/abc.png"
)
def test_absolute_image_url_unchanged(self) -> None:
"""If image_url is already absolute, full_image_url should return it unchanged."""
reward: KickReward = self._make_reward("https://example.com/image.png")
assert reward.full_image_url == "https://example.com/image.png"
def test_empty_image_url_returns_empty(self) -> None:
"""If image_url is empty, full_image_url should also be empty."""
reward: KickReward = self._make_reward("")
assert not reward.full_image_url
class KickChannelUrlTest(TestCase):
"""Tests for KickChannel.channel_url property."""
def test_url_with_slug(self) -> None:
"""If slug is present, channel_url should return the full Kick channel URL."""
user: KickUser = KickUser.objects.create(kick_id=1, username="testuser")
channel: KickChannel = KickChannel.objects.create(
kick_id=100,
slug="testuser",
user=user,
)
assert channel.channel_url == "https://kick.com/testuser"
def test_url_without_slug(self) -> None:
"""If slug is empty, channel_url should return an empty string."""
channel: KickChannel = KickChannel.objects.create(kick_id=101, slug="")
assert not channel.channel_url
class KickDropCampaignIsActiveTest(TestCase):
"""Tests for KickDropCampaign.is_active property."""
def _make_campaign(
self,
starts_at: dt | None,
ends_at: dt | None,
) -> KickDropCampaign:
org: KickOrganization = KickOrganization.objects.create(
kick_id="org-active",
name="Org",
)
cat: KickCategory = KickCategory.objects.create(
kick_id=1,
name="Cat",
slug="cat",
)
return KickDropCampaign.objects.create(
kick_id="camp-active",
name="Active Campaign",
starts_at=starts_at,
ends_at=ends_at,
organization=org,
category=cat,
rule_id=1,
rule_name="Watch to redeem",
)
def test_no_dates_is_not_active(self) -> None:
"""If starts_at and ends_at are None, is_active should return False."""
campaign: KickDropCampaign = self._make_campaign(None, None)
assert not campaign.is_active
def test_expired_campaign_is_not_active(self) -> None:
"""If current date is past ends_at, is_active should return False."""
campaign: KickDropCampaign = self._make_campaign(
dt(2020, 1, 1, tzinfo=UTC),
dt(2020, 12, 31, tzinfo=UTC),
)
assert not campaign.is_active
def test_future_campaign_is_not_active(self) -> None:
"""If current date is before starts_at, is_active should return False."""
campaign: KickDropCampaign = self._make_campaign(
dt(2099, 1, 1, tzinfo=UTC),
dt(2099, 12, 31, tzinfo=UTC),
)
assert not campaign.is_active
class KickDropCampaignMergedRewardsTest(TestCase):
"""Tests for KickDropCampaign.merged_rewards property."""
def _make_campaign(self) -> KickDropCampaign:
org: KickOrganization = KickOrganization.objects.create(
kick_id="org-merge",
name="Merge Org",
)
cat: KickCategory = KickCategory.objects.create(
kick_id=801,
name="Merge Cat",
slug="merge-cat",
)
return KickDropCampaign.objects.create(
kick_id="camp-merge",
name="Merge Campaign",
organization=org,
category=cat,
rule_id=1,
rule_name="Watch to redeem",
)
def test_merges_reward_with_con_suffix(self) -> None:
"""Rewards with and without '(Con)' should be treated as one entry."""
campaign: KickDropCampaign = self._make_campaign()
KickReward.objects.create(
kick_id="reward-base",
name="Anniversary Cake",
image_url="drops/reward-image/base.png",
required_units=30,
campaign=campaign,
category=campaign.category,
organization=campaign.organization,
)
KickReward.objects.create(
kick_id="reward-con",
name="Anniversary Cake (Con)",
image_url="drops/reward-image/con.png",
required_units=30,
campaign=campaign,
category=campaign.category,
organization=campaign.organization,
)
merged: list[KickReward] = campaign.merged_rewards
assert len(merged) == 1
assert merged[0].name == "Anniversary Cake"
def test_keeps_con_name_when_only_variant_available(self) -> None:
"""If only '(Con)' exists, it should still appear in merged rewards."""
campaign: KickDropCampaign = self._make_campaign()
KickReward.objects.create(
kick_id="reward-only-con",
name="Anniversary Cake(Con)",
image_url="drops/reward-image/con-only.png",
required_units=30,
campaign=campaign,
category=campaign.category,
organization=campaign.organization,
)
merged: list[KickReward] = campaign.merged_rewards
assert len(merged) == 1
assert merged[0].name == "Anniversary Cake(Con)"
def test_merges_rewards_with_ampersand_spacing_difference(self) -> None:
"""Rewards that only differ by ampersand spacing should be treated as one entry."""
campaign: KickDropCampaign = self._make_campaign()
KickReward.objects.create(
kick_id="reward-anniversary-base",
name="9th Anniversary Cake & Confetti",
image_url="drops/reward-image/cake-confetti-base.png",
required_units=60,
campaign=campaign,
category=campaign.category,
organization=campaign.organization,
)
KickReward.objects.create(
kick_id="reward-anniversary-con",
name="9th Anniversary Cake&Confetti (Con)",
image_url="drops/reward-image/cake-confetti-con.png",
required_units=60,
campaign=campaign,
category=campaign.category,
organization=campaign.organization,
)
merged: list[KickReward] = campaign.merged_rewards
assert len(merged) == 1
assert merged[0].name == "9th Anniversary Cake & Confetti"
# MARK: Management command tests
class ImportKickDropsCommandTest(TestCase):
"""Tests for the import_kick_drops management command."""
def _run_command(self, json_payload: dict) -> tuple[str, str]:
mock_response = MagicMock()
mock_response.json.return_value = json_payload
mock_response.raise_for_status.return_value = None
stdout = StringIO()
stderr = StringIO()
with patch(
"kick.management.commands.import_kick_drops.httpx.get",
return_value=mock_response,
):
call_command("import_kick_drops", stdout=stdout, stderr=stderr)
return stdout.getvalue(), stderr.getvalue()
def test_imports_single_campaign(self) -> None:
"""Command creates campaign and related objects from valid API response."""
self._run_command(SINGLE_CAMPAIGN_JSON)
assert KickDropCampaign.objects.count() == 1
assert KickOrganization.objects.count() == 1
assert KickCategory.objects.count() == 1
assert KickReward.objects.count() == 1
campaign: KickDropCampaign = KickDropCampaign.objects.get()
assert campaign.name == "PUBG 9th Anniversary"
assert campaign.status == "active"
assert campaign.organization.name == "KRAFTON"
assert campaign.category.name == "PUBG: Battlegrounds"
def test_imports_campaign_with_channels(self) -> None:
"""Command creates channels and links them to the campaign."""
self._run_command(CAMPAIGN_WITH_CHANNELS_JSON)
campaign: KickDropCampaign = KickDropCampaign.objects.get()
assert campaign.channels.count() == 2
assert KickUser.objects.count() == 2
assert KickChannel.objects.count() == 2
slugs: set[str] = set(campaign.channels.values_list("slug", flat=True))
assert slugs == {"ricoy", "dilanzito"}
def test_import_is_idempotent(self) -> None:
"""Running the import twice does not duplicate records."""
self._run_command(SINGLE_CAMPAIGN_JSON)
self._run_command(SINGLE_CAMPAIGN_JSON)
assert KickDropCampaign.objects.count() == 1
assert KickOrganization.objects.count() == 1
assert KickReward.objects.count() == 1
def test_http_error_is_handled_gracefully(self) -> None:
"""HTTP error during fetch writes to stderr and does not crash."""
stdout = StringIO()
stderr = StringIO()
with patch(
"kick.management.commands.import_kick_drops.httpx.get",
side_effect=httpx.HTTPError("connection refused"),
):
call_command("import_kick_drops", stdout=stdout, stderr=stderr)
assert "HTTP error" in stderr.getvalue()
assert KickDropCampaign.objects.count() == 0
def test_validation_error_is_handled_gracefully(self) -> None:
"""Invalid JSON structure writes to stderr and does not crash."""
mock_response = MagicMock()
mock_response.json.return_value = {"totally": "wrong"}
mock_response.raise_for_status.return_value = None
stdout = StringIO()
stderr = StringIO()
with patch(
"kick.management.commands.import_kick_drops.httpx.get",
return_value=mock_response,
):
call_command("import_kick_drops", stdout=stdout, stderr=stderr)
assert "validation failed" in stderr.getvalue()
assert KickDropCampaign.objects.count() == 0
def test_success_message_printed(self) -> None:
"""Success output should report the number of campaigns imported."""
stdout, _ = self._run_command(SINGLE_CAMPAIGN_JSON)
assert "1/1" in stdout
# MARK: View tests
class KickDashboardViewTest(TestCase):
"""Tests for the kick dashboard view."""
def _make_active_campaign(self) -> KickDropCampaign:
org: KickOrganization = KickOrganization.objects.create(
kick_id="org-view-1",
name="Org View",
)
cat: KickCategory = KickCategory.objects.create(
kick_id=200,
name="View Cat",
slug="view-cat",
)
return KickDropCampaign.objects.create(
kick_id="camp-view-1",
name="Active View Campaign",
status="active",
starts_at=dt(2020, 1, 1, tzinfo=UTC),
ends_at=dt(2099, 12, 31, tzinfo=UTC),
organization=org,
category=cat,
rule_id=1,
rule_name="Watch to redeem",
)
def test_dashboard_returns_200(self) -> None:
"""Dashboard view should return HTTP 200 status code."""
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:dashboard"),
)
assert response.status_code == 200
def test_dashboard_shows_active_campaigns(self) -> None:
"""Active campaigns should be displayed on the dashboard."""
campaign: KickDropCampaign = self._make_active_campaign()
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:dashboard"),
)
assert campaign.name in response.content.decode()
class KickCampaignListViewTest(TestCase):
"""Tests for the kick campaign list view."""
def _make_campaign(
self,
kick_id: str,
name: str,
status: str = "active",
) -> KickDropCampaign:
org, _ = KickOrganization.objects.get_or_create(
kick_id="org-list",
defaults={"name": "List Org"},
)
cat, _ = KickCategory.objects.get_or_create(
kick_id=300,
defaults={"name": "List Cat", "slug": "list-cat"},
)
# Set dates so the active/expired filter works correctly
if status == "active":
starts_at = dt(2020, 1, 1, tzinfo=UTC)
ends_at = dt(2099, 12, 31, tzinfo=UTC)
else:
starts_at = dt(2020, 1, 1, tzinfo=UTC)
ends_at = dt(2020, 12, 31, tzinfo=UTC)
return KickDropCampaign.objects.create(
kick_id=kick_id,
name=name,
status=status,
starts_at=starts_at,
ends_at=ends_at,
organization=org,
category=cat,
rule_id=1,
rule_name="Watch to redeem",
)
def test_campaign_list_returns_200(self) -> None:
"""Campaign list view should return HTTP 200 status code."""
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:campaign_list"),
)
assert response.status_code == 200
def test_campaign_list_shows_campaigns(self) -> None:
"""Campaigns should be displayed in the campaign list view."""
campaign: KickDropCampaign = self._make_campaign("camp-list-1", "List Campaign")
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:campaign_list"),
)
assert campaign.name in response.content.decode()
def test_campaign_list_status_filter(self) -> None:
"""Filtering by status should show only campaigns with that status."""
active: KickDropCampaign = self._make_campaign(
"camp-list-a",
"Active Camp",
status="active",
)
expired: KickDropCampaign = self._make_campaign(
"camp-list-e",
"Expired Camp",
status="expired",
)
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:campaign_list") + "?status=active",
)
content: str = response.content.decode()
assert active.name in content
assert expired.name not in content
class KickCampaignDetailViewTest(TestCase):
"""Tests for the kick campaign detail view."""
def _make_campaign(self) -> KickDropCampaign:
"""Helper method to create a campaign with related org and category for detail view tests.
Returns:
KickDropCampaign: The created campaign instance.
"""
org: KickOrganization = KickOrganization.objects.create(
kick_id="org-det-1",
name="Detail Org",
)
cat: KickCategory = KickCategory.objects.create(
kick_id=400,
name="Detail Cat",
slug="detail-cat",
)
return KickDropCampaign.objects.create(
kick_id="camp-det-1",
name="Detail Campaign",
organization=org,
category=cat,
rule_id=1,
rule_name="Watch to redeem",
)
def test_campaign_detail_returns_200(self) -> None:
"""Campaign detail view should return HTTP 200 status code for existing campaign."""
campaign: KickDropCampaign = self._make_campaign()
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:campaign_detail", kwargs={"kick_id": campaign.kick_id}),
)
assert response.status_code == 200
def test_campaign_detail_shows_name(self) -> None:
"""Campaign detail view should display the campaign name."""
campaign: KickDropCampaign = self._make_campaign()
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:campaign_detail", kwargs={"kick_id": campaign.kick_id}),
)
assert campaign.name in response.content.decode()
def test_campaign_detail_404_for_unknown(self) -> None:
"""Campaign detail view should return HTTP 404 status code for unknown campaign."""
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:campaign_detail", kwargs={"kick_id": "nonexistent-id"}),
)
assert response.status_code == 404
class KickCategoryListViewTest(TestCase):
"""Tests for the kick category list view."""
def test_category_list_returns_200(self) -> None:
"""Category list view should return HTTP 200 status code."""
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:game_list"),
)
assert response.status_code == 200
def test_category_list_shows_categories(self) -> None:
"""Category list view should display the categories."""
cat: KickCategory = KickCategory.objects.create(
kick_id=500,
name="Category View",
slug="category-view",
)
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:game_list"),
)
assert cat.name in response.content.decode()
class KickCategoryDetailViewTest(TestCase):
"""Tests for the kick category detail view."""
def _make_category(self) -> KickCategory:
org: KickOrganization = KickOrganization.objects.create(
kick_id="org-catdet-1",
name="Catdet Org",
)
cat: KickCategory = KickCategory.objects.create(
kick_id=600,
name="Catdet Cat",
slug="catdet-cat",
)
KickDropCampaign.objects.create(
kick_id="camp-catdet-1",
name="Catdet Campaign",
organization=org,
category=cat,
rule_id=1,
rule_name="Watch to redeem",
)
return cat
def test_category_detail_returns_200(self) -> None:
"""Category detail view should return HTTP 200 status code for existing category."""
cat: KickCategory = self._make_category()
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:game_detail", kwargs={"kick_id": cat.kick_id}),
)
assert response.status_code == 200
def test_category_detail_shows_name(self) -> None:
"""Category detail view should display the category name."""
cat: KickCategory = self._make_category()
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:game_detail", kwargs={"kick_id": cat.kick_id}),
)
assert cat.name in response.content.decode()
def test_category_detail_404_for_unknown(self) -> None:
"""Category detail view should return HTTP 404 status code for unknown category."""
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:game_detail", kwargs={"kick_id": 99999}),
)
assert response.status_code == 404
class KickOrganizationListViewTest(TestCase):
"""Tests for the kick organization list view."""
def test_organization_list_returns_200(self) -> None:
"""Organization list view should return HTTP 200 status code."""
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:organization_list"),
)
assert response.status_code == 200
def test_organization_list_shows_orgs(self) -> None:
"""Organization list view should display the organizations."""
org: KickOrganization = KickOrganization.objects.create(
kick_id="org-list-1",
name="List Org View",
)
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:organization_list"),
)
assert org.name in response.content.decode()
class KickOrganizationDetailViewTest(TestCase):
"""Tests for the kick organization detail view."""
def _make_org(self) -> KickOrganization:
org: KickOrganization = KickOrganization.objects.create(
kick_id="org-orgdet-1",
name="Orgdet Org",
)
cat: KickCategory = KickCategory.objects.create(
kick_id=700,
name="Orgdet Cat",
slug="orgdet-cat",
)
KickDropCampaign.objects.create(
kick_id="camp-orgdet-1",
name="Orgdet Campaign",
organization=org,
category=cat,
rule_id=1,
rule_name="Watch to redeem",
)
return org
def test_organization_detail_returns_200(self) -> None:
"""Organization detail view should return HTTP 200 status code for existing organization."""
org: KickOrganization = self._make_org()
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:organization_detail", kwargs={"kick_id": org.kick_id}),
)
assert response.status_code == 200
def test_organization_detail_shows_name(self) -> None:
"""Organization detail view should display the organization name."""
org: KickOrganization = self._make_org()
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("kick:organization_detail", kwargs={"kick_id": org.kick_id}),
)
assert org.name in response.content.decode()
def test_organization_detail_404_for_unknown(self) -> None:
"""Organization detail view should return HTTP 404 status code for unknown organization."""
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse(
"kick:organization_detail",
kwargs={"kick_id": "nonexistent-org-id"},
),
)
assert response.status_code == 404
class KickFeedsTest(TestCase):
"""Tests for Kick RSS/Atom/Discord feed endpoints."""
def setUp(self) -> None:
"""Create a minimal active campaign fixture for feed tests."""
self.org: KickOrganization = KickOrganization.objects.create(
kick_id="org-feed-1",
name="Feed Org",
logo_url="https://example.com/org-logo.png",
url="https://example.com/org",
)
self.category: KickCategory = KickCategory.objects.create(
kick_id=123,
name="Feed Category",
slug="feed-category",
image_url="https://example.com/category.png",
)
self.campaign: KickDropCampaign = KickDropCampaign.objects.create(
kick_id="camp-feed-1",
name="Feed Campaign",
status="active",
starts_at=timezone.now() - timedelta(hours=1),
ends_at=timezone.now() + timedelta(days=1),
organization=self.org,
category=self.category,
url="https://example.com/campaign",
connect_url="https://example.com/connect",
rule_id=1,
rule_name="Watch to redeem",
)
self.user: KickUser = KickUser.objects.create(
kick_id=2001,
username="feeduser",
)
self.channel: KickChannel = KickChannel.objects.create(
kick_id=2002,
slug="feedchannel",
user=self.user,
)
self.campaign.channels.add(self.channel)
KickReward.objects.create(
kick_id="reward-feed-1",
name="Feed Reward",
image_url="https://example.com/reward.png",
required_units=30,
campaign=self.campaign,
category=self.category,
organization=self.org,
)
def test_rss_feed_routes_return_200(self) -> None:
"""Kick RSS feeds should return 200 and browser-friendly content type."""
urls: list[str] = [
reverse("kick:campaign_feed"),
reverse("kick:game_feed"),
reverse("kick:game_campaign_feed", args=[self.category.kick_id]),
reverse("kick:organization_feed"),
]
for url in urls:
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
assert response["Content-Disposition"] == "inline"
def test_atom_feed_routes_return_200(self) -> None:
"""Kick Atom feeds should return 200 and include Atom XML root."""
urls: list[str] = [
reverse("kick:campaign_feed_atom"),
reverse("kick:game_feed_atom"),
reverse("kick:game_campaign_feed_atom", args=[self.category.kick_id]),
reverse("kick:organization_feed_atom"),
]
for url in urls:
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "<feed" in content
assert "http://www.w3.org/2005/Atom" in content
def test_discord_feed_routes_return_200(self) -> None:
"""Kick Discord feeds should return 200 and include Atom XML root."""
urls: list[str] = [
reverse("kick:campaign_feed_discord"),
reverse("kick:game_feed_discord"),
reverse(
"kick:game_campaign_feed_discord",
args=[self.category.kick_id],
),
reverse("kick:organization_feed_discord"),
]
for url in urls:
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "<feed" in content
assert "http://www.w3.org/2005/Atom" in content
def test_discord_campaign_feeds_contain_discord_timestamps(self) -> None:
"""Kick Discord campaign feeds should include escaped Discord relative timestamps."""
urls: list[str] = [
reverse("kick:campaign_feed_discord"),
reverse(
"kick:game_campaign_feed_discord",
args=[self.category.kick_id],
),
]
for url in urls:
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
content: str = response.content.decode("utf-8")
discord_pattern: re.Pattern[str] = re.compile(r"&amp;lt;t:\d+:R&amp;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("&lt;t:")
assert result.endswith(":R&gt;")
assert not str(discord_timestamp(None))

177
kick/urls.py Normal file
View file

@ -0,0 +1,177 @@
from typing import TYPE_CHECKING
from django.urls import path
from kick import views
from kick.feeds import KickCampaignAtomFeed
from kick.feeds import KickCampaignDiscordFeed
from kick.feeds import KickCampaignFeed
from kick.feeds import KickCategoryAtomFeed
from kick.feeds import KickCategoryCampaignAtomFeed
from kick.feeds import KickCategoryCampaignDiscordFeed
from kick.feeds import KickCategoryCampaignFeed
from kick.feeds import KickCategoryDiscordFeed
from kick.feeds import KickCategoryFeed
from kick.feeds import KickOrganizationAtomFeed
from kick.feeds import KickOrganizationDiscordFeed
from kick.feeds import KickOrganizationFeed
if TYPE_CHECKING:
from django.urls.resolvers import URLPattern
from django.urls.resolvers import URLResolver
app_name = "kick"
urlpatterns: list[URLPattern | URLResolver] = [
# /kick/
path(
route="",
view=views.dashboard,
name="dashboard",
),
# /kick/campaigns/
path(
route="campaigns/",
view=views.campaign_list_view,
name="campaign_list",
),
# /kick/campaigns/<kick_id>/
path(
route="campaigns/<str:kick_id>/",
view=views.campaign_detail_view,
name="campaign_detail",
),
# /kick/games/
path(
route="games/",
view=views.category_list_view,
name="game_list",
),
# /kick/games/<kick_id>/
path(
route="games/<int:kick_id>/",
view=views.category_detail_view,
name="game_detail",
),
# Legacy category routes kept for backward compatibility
path(
route="categories/",
view=views.category_list_view,
name="category_list",
),
path(
route="categories/<int:kick_id>/",
view=views.category_detail_view,
name="category_detail",
),
# /kick/organizations/
path(
route="organizations/",
view=views.organization_list_view,
name="organization_list",
),
# /kick/organizations/<kick_id>/
path(
route="organizations/<str:kick_id>/",
view=views.organization_detail_view,
name="organization_detail",
),
# RSS feeds
# /kick/rss/campaigns/ - all active campaigns
path(
"rss/campaigns/",
KickCampaignFeed(),
name="campaign_feed",
),
# /kick/rss/games/ - newly added games
path(
"rss/games/",
KickCategoryFeed(),
name="game_feed",
),
# /kick/rss/games/<kick_id>/campaigns/ - active campaigns for a specific game
path(
"rss/games/<int:kick_id>/campaigns/",
KickCategoryCampaignFeed(),
name="game_campaign_feed",
),
# Legacy category feed routes kept for backward compatibility
path(
"rss/categories/",
KickCategoryFeed(),
name="category_feed",
),
path(
"rss/categories/<int:kick_id>/campaigns/",
KickCategoryCampaignFeed(),
name="category_campaign_feed",
),
# /kick/rss/organizations/ - newly added organizations
path(
"rss/organizations/",
KickOrganizationFeed(),
name="organization_feed",
),
# Atom feeds (added alongside RSS to preserve backward compatibility)
path(
"atom/campaigns/",
KickCampaignAtomFeed(),
name="campaign_feed_atom",
),
path(
"atom/games/",
KickCategoryAtomFeed(),
name="game_feed_atom",
),
path(
"atom/games/<int:kick_id>/campaigns/",
KickCategoryCampaignAtomFeed(),
name="game_campaign_feed_atom",
),
path(
"atom/categories/",
KickCategoryAtomFeed(),
name="category_feed_atom",
),
path(
"atom/categories/<int:kick_id>/campaigns/",
KickCategoryCampaignAtomFeed(),
name="category_campaign_feed_atom",
),
path(
"atom/organizations/",
KickOrganizationAtomFeed(),
name="organization_feed_atom",
),
# Discord feeds (Atom feeds with Discord relative timestamps)
path(
"discord/campaigns/",
KickCampaignDiscordFeed(),
name="campaign_feed_discord",
),
path(
"discord/games/",
KickCategoryDiscordFeed(),
name="game_feed_discord",
),
path(
"discord/games/<int:kick_id>/campaigns/",
KickCategoryCampaignDiscordFeed(),
name="game_campaign_feed_discord",
),
path(
"discord/categories/",
KickCategoryDiscordFeed(),
name="category_feed_discord",
),
path(
"discord/categories/<int:kick_id>/campaigns/",
KickCategoryCampaignDiscordFeed(),
name="category_campaign_feed_discord",
),
path(
"discord/organizations/",
KickOrganizationDiscordFeed(),
name="organization_feed_discord",
),
]

483
kick/views.py Normal file
View file

@ -0,0 +1,483 @@
import json
import logging
from typing import TYPE_CHECKING
from typing import Literal
from django.core.paginator import EmptyPage
from django.core.paginator import PageNotAnInteger
from django.core.paginator import Paginator
from django.db.models import Count
from django.http import Http404
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from kick.models import KickCategory
from kick.models import KickDropCampaign
from kick.models import KickOrganization
if TYPE_CHECKING:
import datetime
from django.core.paginator import Page
from django.db.models import QuerySet
from django.http import HttpRequest
from django.http import HttpResponse
from kick.models import KickChannel
from kick.models import KickReward
logger: logging.Logger = logging.getLogger("ttvdrops.kick.views")
def _build_seo_context(
page_title: str = "Kick Drops",
page_description: str | None = None,
og_type: str = "website",
robots_directive: str = "index, follow",
) -> dict[str, str]:
"""Build minimal SEO context for template rendering.
Args:
page_title: The title of the page for <title> and OG tags.
page_description: Optional description for meta and OG tags.
og_type: Open Graph type (default "website").
robots_directive: Value for meta robots tag (default "index, follow").
Returns:
A dictionary with SEO-related context variables.
"""
return {
"page_title": page_title,
"page_description": page_description or "Archive of Kick drops.",
"og_type": og_type,
"robots_directive": robots_directive,
}
def _build_breadcrumb_schema(items: list[dict[str, str]]) -> str:
"""Build a serialised BreadcrumbList JSON-LD string.
Args:
items: A list of breadcrumb items, each with "name" and "url" keys
Returns:
A JSON string representing the BreadcrumbList schema.
"""
breadcrumb_items: list[dict[str, str | int]] = [
{
"@type": "ListItem",
"position": i + 1,
"name": item["name"],
"item": item["url"],
}
for i, item in enumerate(items)
]
return json.dumps({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumb_items,
})
def _build_pagination_info(
request: HttpRequest,
page_obj: Page,
base_url: str,
) -> list[dict[str, str]] | None:
"""Build rel="prev"/"next" pagination link info.
Args:
request: The current HTTP request (used to build absolute URLs).
page_obj: The current Page object from the paginator.
base_url: The base URL for pagination links (without ?page=).
Returns:
A list of pagination link dictionaries or None if no links.
"""
sep: Literal["&", "?"] = "&" if "?" in base_url else "?"
links: list[dict[str, str]] = []
if page_obj.has_previous():
links.append({
"rel": "prev",
"url": request.build_absolute_uri(
f"{base_url}{sep}page={page_obj.previous_page_number()}",
),
})
if page_obj.has_next():
links.append({
"rel": "next",
"url": request.build_absolute_uri(
f"{base_url}{sep}page={page_obj.next_page_number()}",
),
})
return links or None
# MARK: /kick/
def dashboard(request: HttpRequest) -> HttpResponse:
"""Dashboard showing currently active Kick drop campaigns.
This view focuses on active campaigns, showing them in order of start date.
For a complete list of campaigns with filtering and pagination, see the
campaign_list_view.
Returns:
An HttpResponse rendering the dashboard template with active campaigns.
"""
now: datetime.datetime = timezone.now()
active_campaigns: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.filter(starts_at__lte=now, ends_at__gte=now)
.select_related("organization", "category")
.prefetch_related("channels__user", "rewards")
.order_by("-starts_at")
)
seo_context: dict[str, str] = _build_seo_context(
page_title="Kick Drops",
page_description="Overview of active Kick drop campaigns.",
)
return render(
request,
"kick/dashboard.html",
{
"active_campaigns": active_campaigns,
"now": now,
**seo_context,
},
)
# MARK: /kick/campaigns/
def campaign_list_view(request: HttpRequest) -> HttpResponse:
"""Paginated list of all Kick campaigns with optional filtering.
Supports filtering by game and status (active/upcoming/expired) via query parameters.
Pagination is implemented with 100 campaigns per page.
Returns:
An HttpResponse rendering the campaign list template with the filtered and paginated campaigns.
"""
game_filter: str | None = request.GET.get("game") or request.GET.get("category")
status_filter: str | None = request.GET.get("status")
per_page = 100
now: datetime.datetime = timezone.now()
queryset: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.select_related("organization", "category")
.prefetch_related("rewards")
.order_by("-starts_at")
)
if game_filter:
queryset = queryset.filter(category__kick_id=game_filter)
if status_filter == "active":
queryset = queryset.filter(starts_at__lte=now, ends_at__gte=now)
elif status_filter == "upcoming":
queryset = queryset.filter(starts_at__gt=now)
elif status_filter == "expired":
queryset = queryset.filter(ends_at__lt=now)
paginator: Paginator[KickDropCampaign] = Paginator(queryset, per_page)
page: str | Literal[1] = request.GET.get("page") or 1
try:
campaigns: Page[KickDropCampaign] = paginator.page(page)
except PageNotAnInteger:
campaigns = paginator.page(1)
except EmptyPage:
campaigns = paginator.page(paginator.num_pages)
title = "Kick Drop Campaigns"
if status_filter:
title += f" ({status_filter.capitalize()})"
base_url = "/kick/campaigns/"
if status_filter:
base_url += f"?status={status_filter}"
if game_filter:
base_url += f"&game={game_filter}"
elif game_filter:
base_url += f"?game={game_filter}"
pagination_info: list[dict[str, str]] | None = _build_pagination_info(
request,
campaigns,
base_url,
)
seo_context: dict[str, str] = _build_seo_context(
page_title=title,
page_description="Browse Kick drop campaigns.",
)
return render(
request,
"kick/campaign_list.html",
{
"campaigns": campaigns,
"page_obj": campaigns,
"is_paginated": campaigns.has_other_pages(),
"games": KickCategory.objects.order_by("name"),
"status_options": ["active", "upcoming", "expired"],
"now": now,
"selected_game": game_filter or "",
"selected_status": status_filter or "",
"pagination_info": pagination_info,
**seo_context,
},
)
# MARK: /kick/campaigns/<kick_id>/
def campaign_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse:
"""Detail view for a single Kick drop campaign.
Args:
request: The HTTP request object.
kick_id: The unique identifier for the Kick campaign.
Returns:
An HttpResponse rendering the campaign detail template with the campaign information.
Raises:
Http404: If no campaign is found matching the kick_id.
"""
try:
campaign: KickDropCampaign = (
KickDropCampaign.objects
.select_related("organization", "category")
.prefetch_related("channels__user", "rewards__category")
.get(kick_id=kick_id)
)
except KickDropCampaign.DoesNotExist as exc:
msg = "No campaign found matching the query"
raise Http404(msg) from exc
now: datetime.datetime = timezone.now()
rewards: list[KickReward] = list(
campaign.rewards.order_by("required_units").select_related("category"), # type: ignore[union-attr]
)
channels: list[KickChannel] = list(campaign.channels.select_related("user"))
reward_count: int = len(rewards)
channels_count: int = len(channels)
total_watch_minutes: int = sum(reward.required_units for reward in rewards)
breadcrumb_schema: str = _build_breadcrumb_schema([
{"name": "Home", "url": request.build_absolute_uri("/")},
{
"name": "Kick Campaigns",
"url": request.build_absolute_uri(reverse("kick:campaign_list")),
},
{
"name": campaign.name,
"url": request.build_absolute_uri(
reverse("kick:campaign_detail", args=[campaign.kick_id]),
),
},
])
seo_context: dict[str, str] = _build_seo_context(
page_title=campaign.name,
page_description=f"Kick drop campaign: {campaign.name}.",
)
return render(
request,
"kick/campaign_detail.html",
{
"campaign": campaign,
"rewards": rewards,
"channels": channels,
"reward_count": reward_count,
"channels_count": channels_count,
"total_watch_minutes": total_watch_minutes,
"now": now,
"breadcrumb_schema": breadcrumb_schema,
**seo_context,
},
)
# MARK: /kick/games/
def category_list_view(request: HttpRequest) -> HttpResponse:
"""List of all Kick games with their campaign counts.
Returns:
An HttpResponse rendering the category list template with all games and their campaign counts.
"""
categories: QuerySet[KickCategory] = KickCategory.objects.annotate(
campaign_count=Count("campaigns", distinct=True),
).order_by("name")
seo_context: dict[str, str] = _build_seo_context(
page_title="Kick Games",
page_description="Games on Kick that have drop campaigns.",
)
return render(
request,
"kick/category_list.html",
{
"categories": categories,
**seo_context,
},
)
# MARK: /kick/games/<kick_id>/
def category_detail_view(request: HttpRequest, kick_id: int) -> HttpResponse:
"""Detail view for a Kick game with its drop campaigns.
Args:
request: The HTTP request object.
kick_id: The unique identifier for the Kick game.
Returns:
An HttpResponse rendering the category detail template with the game information and its campaigns.
Raises:
Http404: If no game is found matching the kick_id.
"""
try:
category: KickCategory = KickCategory.objects.get(kick_id=kick_id)
except KickCategory.DoesNotExist as exc:
msg = "No game found matching the query"
raise Http404(msg) from exc
now: datetime.datetime = timezone.now()
all_campaigns: list[KickDropCampaign] = list(
KickDropCampaign.objects
.filter(category=category)
.select_related("organization")
.prefetch_related("channels__user", "rewards")
.order_by("-starts_at"),
)
active_campaigns: list[KickDropCampaign] = [
c
for c in all_campaigns
if c.starts_at and c.ends_at and c.starts_at <= now <= c.ends_at
]
upcoming_campaigns: list[KickDropCampaign] = [
c for c in all_campaigns if c.starts_at and c.starts_at > now
]
expired_campaigns: list[KickDropCampaign] = [
c for c in all_campaigns if c.ends_at and c.ends_at < now
]
breadcrumb_schema: str = _build_breadcrumb_schema([
{"name": "Home", "url": request.build_absolute_uri("/")},
{
"name": "Kick Games",
"url": request.build_absolute_uri(reverse("kick:game_list")),
},
{
"name": category.name,
"url": request.build_absolute_uri(
reverse("kick:game_detail", args=[category.kick_id]),
),
},
])
seo_context: dict[str, str] = _build_seo_context(
page_title=category.name,
page_description=f"Kick drop campaigns for {category.name}.",
)
return render(
request,
"kick/category_detail.html",
{
"category": category,
"active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_campaigns,
"now": now,
"breadcrumb_schema": breadcrumb_schema,
**seo_context,
},
)
# MARK: /kick/organizations/
def organization_list_view(request: HttpRequest) -> HttpResponse:
"""List of all Kick organizations.
Returns:
An HttpResponse rendering the organization list template with all organizations and their campaign counts.
"""
orgs: QuerySet[KickOrganization] = KickOrganization.objects.annotate(
campaign_count=Count("campaigns", distinct=True),
).order_by("name")
seo_context: dict[str, str] = _build_seo_context(
page_title="Kick Organizations",
page_description="Organizations that run Kick drop campaigns.",
)
return render(
request,
"kick/org_list.html",
{
"orgs": orgs,
**seo_context,
},
)
# MARK: /kick/organizations/<kick_id>/
def organization_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse:
"""Detail view for a Kick organization with its campaigns.
Args:
request: The HTTP request object.
kick_id: The unique identifier for the Kick organization.
Returns:
An HttpResponse rendering the organization detail template with the organization information and its campaigns.
Raises:
Http404: If no organization is found matching the kick_id.
"""
try:
org: KickOrganization = KickOrganization.objects.get(kick_id=kick_id)
except KickOrganization.DoesNotExist as exc:
msg = "No organization found matching the query"
raise Http404(msg) from exc
now: datetime.datetime = timezone.now()
campaigns: list[KickDropCampaign] = list(
KickDropCampaign.objects
.filter(organization=org)
.select_related("category")
.prefetch_related("rewards")
.order_by("-starts_at"),
)
breadcrumb_schema: str = _build_breadcrumb_schema([
{"name": "Home", "url": request.build_absolute_uri("/")},
{
"name": "Kick Organizations",
"url": request.build_absolute_uri(reverse("kick:organization_list")),
},
{
"name": org.name,
"url": request.build_absolute_uri(
reverse("kick:organization_detail", args=[org.kick_id]),
),
},
])
seo_context: dict[str, str] = _build_seo_context(
page_title=org.name,
page_description=f"Kick drop campaigns by {org.name}.",
)
return render(
request,
"kick/organization_detail.html",
{
"org": org,
"campaigns": campaigns,
"now": now,
"breadcrumb_schema": breadcrumb_schema,
**seo_context,
},
)

View file

@ -73,6 +73,42 @@
type="application/atom+xml" type="application/atom+xml"
title="Newly added reward campaigns (Discord)" title="Newly added reward campaigns (Discord)"
href="{% url 'twitch:reward_campaign_feed_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 #} {# Allow child templates to inject page-specific alternates into the head #}
{% block extra_head %} {% block extra_head %}
{% endblock extra_head %} {% endblock extra_head %}
@ -234,9 +270,14 @@
<a href="{% url 'twitch:emote_gallery' %}">Emotes</a> | <a href="{% url 'twitch:emote_gallery' %}">Emotes</a> |
<a href="https://www.twitch.tv/drops/inventory">Inventory</a> <a href="https://www.twitch.tv/drops/inventory">Inventory</a>
<br /> <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> <strong>Other sites</strong>
<a href="#">Steam</a> | <a href="#">Steam</a> |
<a href="#">Kick</a> |
<a href="#">YouTube</a> | <a href="#">YouTube</a> |
<a href="#">TikTok</a> | <a href="#">TikTok</a> |
<a href="#">Discord</a> <a href="#">Discord</a>

View file

@ -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> &gt;
<a href="{% url 'kick:campaign_list' %}">Campaigns</a> &gt;
{{ 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 %}

View file

@ -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 %}

View file

@ -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> &gt;
<a href="{% url 'kick:game_list' %}">Games</a> &gt;
{{ 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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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> &gt;
<a href="{% url 'kick:organization_list' %}">Organizations</a> &gt;
{{ 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 %}

View file

@ -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

View file

@ -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