import json
import logging
from typing import TYPE_CHECKING
from typing import Literal
from django.core.paginator import EmptyPage
from django.core.paginator import PageNotAnInteger
from django.core.paginator import Paginator
from django.db.models import Count
from django.http import Http404
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from kick.models import KickCategory
from kick.models import KickDropCampaign
from kick.models import KickOrganization
if TYPE_CHECKING:
import datetime
from django.core.paginator import Page
from django.db.models import QuerySet
from django.http import HttpRequest
from django.http import HttpResponse
from kick.models import KickChannel
from kick.models import KickReward
logger: logging.Logger = logging.getLogger("ttvdrops.kick.views")
def _build_seo_context(
page_title: str = "Kick Drops",
page_description: str | None = None,
og_type: str = "website",
robots_directive: str = "index, follow",
) -> dict[str, str]:
"""Build minimal SEO context for template rendering.
Args:
page_title: The title of the page for
and OG tags.
page_description: Optional description for meta and OG tags.
og_type: Open Graph type (default "website").
robots_directive: Value for meta robots tag (default "index, follow").
Returns:
A dictionary with SEO-related context variables.
"""
return {
"page_title": page_title,
"page_description": page_description or "Archive of Kick drops.",
"og_type": og_type,
"robots_directive": robots_directive,
}
def _build_breadcrumb_schema(items: list[dict[str, str]]) -> str:
"""Build a serialised BreadcrumbList JSON-LD string.
Args:
items: A list of breadcrumb items, each with "name" and "url" keys
Returns:
A JSON string representing the BreadcrumbList schema.
"""
breadcrumb_items: list[dict[str, str | int]] = [
{
"@type": "ListItem",
"position": i + 1,
"name": item["name"],
"item": item["url"],
}
for i, item in enumerate(items)
]
return json.dumps({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumb_items,
})
def _build_pagination_info(
request: HttpRequest,
page_obj: Page,
base_url: str,
) -> list[dict[str, str]] | None:
"""Build rel="prev"/"next" pagination link info.
Args:
request: The current HTTP request (used to build absolute URLs).
page_obj: The current Page object from the paginator.
base_url: The base URL for pagination links (without ?page=).
Returns:
A list of pagination link dictionaries or None if no links.
"""
sep: Literal["&", "?"] = "&" if "?" in base_url else "?"
links: list[dict[str, str]] = []
if page_obj.has_previous():
links.append({
"rel": "prev",
"url": request.build_absolute_uri(
f"{base_url}{sep}page={page_obj.previous_page_number()}",
),
})
if page_obj.has_next():
links.append({
"rel": "next",
"url": request.build_absolute_uri(
f"{base_url}{sep}page={page_obj.next_page_number()}",
),
})
return links or None
# MARK: /kick/
def dashboard(request: HttpRequest) -> HttpResponse:
"""Dashboard showing currently active Kick drop campaigns.
This view focuses on active campaigns, showing them in order of start date.
For a complete list of campaigns with filtering and pagination, see the
campaign_list_view.
Returns:
An HttpResponse rendering the dashboard template with active campaigns.
"""
now: datetime.datetime = timezone.now()
active_campaigns: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.filter(starts_at__lte=now, ends_at__gte=now)
.select_related("organization", "category")
.prefetch_related("channels__user", "rewards")
.order_by("-starts_at")
)
seo_context: dict[str, str] = _build_seo_context(
page_title="Kick Drops",
page_description="Overview of active Kick drop campaigns.",
)
return render(
request,
"kick/dashboard.html",
{
"active_campaigns": active_campaigns,
"now": now,
**seo_context,
},
)
# MARK: /kick/campaigns/
def campaign_list_view(request: HttpRequest) -> HttpResponse:
"""Paginated list of all Kick campaigns with optional filtering.
Supports filtering by game and status (active/upcoming/expired) via query parameters.
Pagination is implemented with 100 campaigns per page.
Returns:
An HttpResponse rendering the campaign list template with the filtered and paginated campaigns.
"""
game_filter: str | None = request.GET.get("game") or request.GET.get("category")
status_filter: str | None = request.GET.get("status")
per_page = 100
now: datetime.datetime = timezone.now()
queryset: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.select_related("organization", "category")
.prefetch_related("rewards")
.order_by("-starts_at")
)
if game_filter:
queryset = queryset.filter(category__kick_id=game_filter)
if status_filter == "active":
queryset = queryset.filter(starts_at__lte=now, ends_at__gte=now)
elif status_filter == "upcoming":
queryset = queryset.filter(starts_at__gt=now)
elif status_filter == "expired":
queryset = queryset.filter(ends_at__lt=now)
paginator: Paginator[KickDropCampaign] = Paginator(queryset, per_page)
page: str | Literal[1] = request.GET.get("page") or 1
try:
campaigns: Page[KickDropCampaign] = paginator.page(page)
except PageNotAnInteger:
campaigns = paginator.page(1)
except EmptyPage:
campaigns = paginator.page(paginator.num_pages)
title = "Kick Drop Campaigns"
if status_filter:
title += f" ({status_filter.capitalize()})"
base_url = "/kick/campaigns/"
if status_filter:
base_url += f"?status={status_filter}"
if game_filter:
base_url += f"&game={game_filter}"
elif game_filter:
base_url += f"?game={game_filter}"
pagination_info: list[dict[str, str]] | None = _build_pagination_info(
request,
campaigns,
base_url,
)
seo_context: dict[str, str] = _build_seo_context(
page_title=title,
page_description="Browse Kick drop campaigns.",
)
return render(
request,
"kick/campaign_list.html",
{
"campaigns": campaigns,
"page_obj": campaigns,
"is_paginated": campaigns.has_other_pages(),
"games": KickCategory.objects.order_by("name"),
"status_options": ["active", "upcoming", "expired"],
"now": now,
"selected_game": game_filter or "",
"selected_status": status_filter or "",
"pagination_info": pagination_info,
**seo_context,
},
)
# MARK: /kick/campaigns//
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//
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//
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,
},
)