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