{{ reward.name }}
+ {% if reward.required_units %} +{{ reward.required_units }} minutes watched
+ {% else %} +No watch-time requirement
+ {% endif %} +diff --git a/config/settings.py b/config/settings.py index 186fd4d..ca45e20 100644 --- a/config/settings.py +++ b/config/settings.py @@ -139,6 +139,7 @@ INSTALLED_APPS: list[str] = [ "django.contrib.staticfiles", "django.contrib.postgres", "twitch.apps.TwitchConfig", + "kick.apps.KickConfig", ] MIDDLEWARE: list[str] = [ diff --git a/config/urls.py b/config/urls.py index 5e1405f..0a9ea65 100644 --- a/config/urls.py +++ b/config/urls.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: urlpatterns: list[URLPattern | URLResolver] = [ path(route="sitemap.xml", view=twitch_views.sitemap_view, name="sitemap"), path(route="", view=include("twitch.urls", namespace="twitch")), + path(route="kick/", view=include("kick.urls", namespace="kick")), ] # Serve media in development diff --git a/kick/__init__.py b/kick/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kick/apps.py b/kick/apps.py new file mode 100644 index 0000000..409a7f8 --- /dev/null +++ b/kick/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class KickConfig(AppConfig): + """Django app configuration for the kick app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "kick" diff --git a/kick/feeds.py b/kick/feeds.py new file mode 100644 index 0000000..6d3685a --- /dev/null +++ b/kick/feeds.py @@ -0,0 +1,708 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.contrib.humanize.templatetags.humanize import naturaltime +from django.db.models import Prefetch +from django.urls import reverse +from django.utils import timezone +from django.utils.html import format_html +from django.utils.html import format_html_join +from django.utils.safestring import SafeText + +from kick.models import KickCategory +from kick.models import KickChannel +from kick.models import KickDropCampaign +from kick.models import KickOrganization +from kick.models import KickReward +from twitch.feeds import TTVDropsAtomBaseFeed +from twitch.feeds import TTVDropsBaseFeed +from twitch.feeds import discord_timestamp + +if TYPE_CHECKING: + import datetime + + from django.db.models import QuerySet + from django.http import HttpRequest + from django.http import HttpResponse + + +def _with_campaign_related( + queryset: QuerySet[KickDropCampaign], +) -> QuerySet[KickDropCampaign]: + """Apply related-select/prefetches needed by feed rendering. + + Returns: + QuerySet[KickDropCampaign]: QuerySet with related objects optimized for feed rendering. + """ + return queryset.select_related("organization", "category").prefetch_related( + Prefetch( + "channels", + queryset=KickChannel.objects.select_related("user").order_by("slug"), + to_attr="channels_ordered", + ), + Prefetch( + "rewards", + queryset=KickReward.objects.order_by("required_units", "name"), + to_attr="rewards_ordered", + ), + ) + + +def _active_campaigns( + queryset: QuerySet[KickDropCampaign], +) -> QuerySet[KickDropCampaign]: + """Filter campaign queryset down to currently active campaigns. + + Returns: + QuerySet[KickDropCampaign]: Filtered queryset with active campaigns. + """ + now: datetime.datetime = timezone.now() + return queryset.filter(starts_at__lte=now, ends_at__gte=now) + + +def _campaign_dates_html( + campaign: KickDropCampaign, + *, + use_discord_relative: bool = False, +) -> SafeText: + """Render campaign date rows in standard or Discord relative format. + + Returns: + SafeText: HTML with campaign dates or empty if no dates are available. + """ + starts_at: datetime.datetime | None = campaign.starts_at + ends_at: datetime.datetime | None = campaign.ends_at + + if not starts_at and not ends_at: + return SafeText("") + + formatter = discord_timestamp if use_discord_relative else naturaltime + + start_part: SafeText = ( + format_html( + "Starts: {} ({})", + starts_at.strftime("%Y-%m-%d %H:%M %Z"), + formatter(starts_at), + ) + if starts_at + else SafeText("") + ) + end_part: SafeText = ( + format_html( + "Ends: {} ({})", + ends_at.strftime("%Y-%m-%d %H:%M %Z"), + formatter(ends_at), + ) + if ends_at + else SafeText("") + ) + + if start_part and end_part: + return format_html("
{}
{}
{}
", start_part) + return format_html("{}
", end_part) + + +def _campaign_rewards_html(campaign: KickDropCampaign) -> SafeText: + """Render a compact rewards summary for a campaign. + + Rewards are rendered as a simple list of "X minutes: Reward Name" entries. + + Returns: + SafeText: HTML with rewards summary or empty if no rewards are available. + """ + rewards: list[KickReward] = getattr(campaign, "rewards_ordered", []) + if not rewards: + rewards = list( + campaign.rewards.order_by("required_units", "name"), # type: ignore[reportAttributeAccessIssue] + ) + + if not rewards: + return SafeText("") + + items: list[SafeText] = [ + format_html("Rewards:
{}", html) + + +def _campaign_channels_html(campaign: KickDropCampaign) -> SafeText: + """Render up to 5 linked channels for a campaign. + + If there are more than 5 channels, a note about additional channels will be added. + + Returns: + SafeText: HTML with linked channels or empty if no channels are available. + """ + channels: list[KickChannel] = getattr(campaign, "channels_ordered", []) + if not channels: + channels = list(campaign.channels.select_related("user").order_by("slug")) + + if not channels: + return SafeText("") + + max_links: int = 5 + items: list[SafeText] = [] + for channel in channels[:max_links]: + label: str = channel.user.username if channel.user else channel.slug + if channel.channel_url: + items.append( + format_html('Channels with this drop:
{}", html) + + +def _campaign_links_html(campaign: KickDropCampaign) -> SafeText: + """Render campaign external links if available. + + Links can include the main campaign URL and a separate connect URL for claiming rewards. + + Returns: + SafeText: HTML with campaign links or empty if no links are available. + """ + links: list[SafeText] = [] + + if campaign.url: + links.append(format_html('About', campaign.url)) + if campaign.connect_url: + links.append(format_html('Connect', campaign.connect_url)) + + if not links: + return SafeText("") + + return format_html( + "{}
", + format_html_join(" | ", "{}", [(link,) for link in links]), + ) + + +class KickOrganizationFeed(TTVDropsBaseFeed): + """RSS feed for latest Kick organizations.""" + + title: str = "TTVDrops Kick Organizations" + link: str = "/kick/organizations/" + description: str = "Latest organizations on Kick in TTVDrops" + + _limit: int | None = None + + def __call__( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> HttpResponse: + """Capture optional ?limit query parameter. + + A ?limit parameter can be used to limit the number of organizations returned by the feed. + + Returns: + HttpResponse: An HTTP response containing the feed XML. + """ + if request.GET.get("limit"): + try: + self._limit = int(request.GET.get("limit", 200)) + except TypeError, ValueError: + self._limit = None + return super().__call__(request, *args, **kwargs) + + def items(self) -> QuerySet[KickOrganization]: + """Return latest organizations with configurable limit.""" + limit: int = self._limit if self._limit is not None else 200 + return KickOrganization.objects.order_by("-added_at")[:limit] + + def item_title(self, item: KickOrganization) -> SafeText: + """Return organization name as title.""" + return SafeText(item.name) + + def item_description(self, item: KickOrganization) -> SafeText: + """Return organization summary HTML.""" + parts: list[SafeText] = [] + + if item.logo_url: + parts.append( + format_html( + '+ Status: {{ campaign.status|default:"unknown"|capfirst }} + {% if campaign.rule_name %}- Rule: {{ campaign.rule_name }}{% endif %} +
+ {% if campaign.category or campaign.organization %} ++ {% if campaign.category %} + {{ campaign.category.name }} + {% endif %} + {% if campaign.organization %} + {% if campaign.category %}-{% endif %} + {{ campaign.organization.name }} + {% endif %} +
+ {% endif %} ++ ID: {{ campaign.kick_id }} + {% if campaign.rule_id %}- Rule ID: {{ campaign.rule_id }}{% endif %} + - Added: + ({{ campaign.added_at|timesince }} ago) + - Updated: + ({{ campaign.updated_at|timesince }} ago) +
+ {% if campaign.ends_at %} ++ Ends: + + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.starts_at %} ++ Starts: + + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.duration %} ++ Duration: {{ campaign.duration }} +
+ {% endif %} ++ {% if reward_count %} + {{ reward_count }} reward{{ reward_count|pluralize }} + ({{ total_watch_minutes }} total watch minute{{ total_watch_minutes|pluralize }}) + {% else %} + No rewards + {% endif %} +
++ + {{ campaign.category.name }} is game wide. +
+ {% endif %} ++ +
+ {% endif %} + {% if campaign.api_updated_at %} ++ +
+ {% endif %} ++ {% if campaign.url %}Details{% endif %} + {% if campaign.connect_url %} + {% if campaign.url %}-{% endif %} + Connect account + {% endif %} +
+{{ reward.required_units }} minutes watched
+ {% else %} +No watch-time requirement
+ {% endif %} +No drops available for this campaign.
+ {% endif %} +| Name | +Game | +Organization | +Status | +Starts | +Ends | +
|---|---|---|---|---|---|
| + {{ campaign.name }} + | ++ {{ campaign.category.name }} + | ++ {{ campaign.organization.name }} + | +{{ campaign.status }} | ++ {% if campaign.starts_at %} + + {% if campaign.starts_at < now %} + ({{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} + {% endif %} + | ++ {% if campaign.ends_at %} + + {% if campaign.ends_at < now %} + ({{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} + {% endif %} + | +
No campaigns found.
+ {% endif %} ++ ID: {{ category.kick_id }} + - Added: + ({{ category.added_at|timesince }} ago) + - Updated: + ({{ category.updated_at|timesince }} ago) +
++ Active: {{ active_campaigns|length }} + - Upcoming: {{ upcoming_campaigns|length }} + - Expired: {{ expired_campaigns|length }} +
++ Starts: + + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.ends_at %} ++ Ends: + + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.duration %} ++ Duration: {{ campaign.duration }} +
+ {% endif %} + {% if campaign.rule_name %} ++ Rule: {{ campaign.rule_name }} +
+ {% endif %} + {% if campaign.connect_url %} ++ Connect account +
+ {% endif %} ++ {% if category.kick_url %} + {{ category.name }} is game wide. + {% else %} + Game wide. + {% endif %} +
+ {% endif %} ++ Starts: + + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.ends_at %} ++ Ends: + + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.duration %} ++ Duration: {{ campaign.duration }} +
+ {% endif %} + {% if campaign.rule_name %} ++ Rule: {{ campaign.rule_name }} +
+ {% endif %} + {% if campaign.connect_url %} ++ Connect account +
+ {% endif %} ++ {% if category.kick_url %} + {{ category.name }} is game wide. + {% else %} + Game wide. + {% endif %} +
+ {% endif %} ++ Starts: + + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.ends_at %} ++ Ends: + + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.duration %} ++ Duration: {{ campaign.duration }} +
+ {% endif %} + {% if campaign.rule_name %} ++ Rule: {{ campaign.rule_name }} +
+ {% endif %} + {% if campaign.connect_url %} ++ Connect account +
+ {% endif %} ++ {% if category.kick_url %} + {{ category.name }} is game wide. + {% else %} + Game wide. + {% endif %} +
+ {% endif %} +No campaigns found for this game.
+ {% endif %} +No games found.
+ {% endif %} ++ Starts: + + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} +
+ {% endif %} + + {% if campaign.ends_at %} ++ Ends: + + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} +
+ {% endif %} + + {% if campaign.duration %} ++ Duration: {{ campaign.duration }} +
+ {% endif %} + {% if campaign.rule_name %} ++ Rule: {{ campaign.rule_name }} +
+ {% endif %} + {% if campaign.connect_url %} ++ Connect account +
+ {% endif %} ++ {{ campaign.category.name }} is game wide. +
+ {% endif %} +No active Kick drop campaigns at the moment. Check back later!
+ {% endif %} +No organizations found.
+ {% endif %} +Restricted
{% endif %} + {% if org.url %} ++ {{ org.url }} +
+ {% endif %} ++ ID: {{ org.kick_id }} + - Added: + ({{ org.added_at|timesince }} ago) + - Updated: + ({{ org.updated_at|timesince }} ago) +
++ Starts: + + {% if campaign.starts_at < now %} + (started {{ campaign.starts_at|timesince }} ago) + {% else %} + (in {{ campaign.starts_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.ends_at %} ++ Ends: + + {% if campaign.ends_at < now %} + (ended {{ campaign.ends_at|timesince }} ago) + {% else %} + (in {{ campaign.ends_at|timeuntil }}) + {% endif %} +
+ {% endif %} + {% if campaign.duration %} ++ Duration: {{ campaign.duration }} +
+ {% endif %} + {% if campaign.rule_name %} ++ Rule: {{ campaign.rule_name }} +
+ {% endif %} + {% if campaign.connect_url %} ++ Connect account +
+ {% endif %} ++ {{ campaign.category.name }} is game wide. +
+ {% else %} +Channel wide.
+ {% endif %} + {% endif %} +No campaigns from this organization.
+ {% endif %} +