chzzk #1

Merged
TheLovinator merged 8 commits from chzzk into master 2026-04-01 04:09:20 +02:00
12 changed files with 594 additions and 164 deletions
Showing only changes of commit 9ce324fd2d - Show all commits

Implement Chzzk campaign management features, including models, views, and templates
Some checks failed
Deploy to Server / deploy (push) Failing after 9s

Joakim Hellsén 2026-04-01 04:04:58 +02:00
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk

View file

@ -4,7 +4,6 @@ from typing import Any
if TYPE_CHECKING: if TYPE_CHECKING:
import argparse import argparse
from chzzk.schemas import ChzzkCampaignV1
from chzzk.schemas import ChzzkCampaignV2 from chzzk.schemas import ChzzkCampaignV2
@ -16,7 +15,6 @@ from django.utils import timezone
from chzzk.models import ChzzkCampaign from chzzk.models import ChzzkCampaign
from chzzk.models import ChzzkReward from chzzk.models import ChzzkReward
from chzzk.schemas import ChzzkApiResponseV1
from chzzk.schemas import ChzzkApiResponseV2 from chzzk.schemas import ChzzkApiResponseV2
if TYPE_CHECKING: if TYPE_CHECKING:
@ -44,94 +42,87 @@ class Command(BaseCommand):
def handle(self, **options) -> None: def handle(self, **options) -> None:
"""Main handler for the management command. Fetches campaign data from both API versions, validates, and stores them.""" """Main handler for the management command. Fetches campaign data from both API versions, validates, and stores them."""
campaign_no: int = int(options["campaign_no"]) campaign_no: int = int(options["campaign_no"])
for api_version, url_template in CHZZK_API_URLS: api_version: str = "v2" # TODO(TheLovinator): Add support for v1 API # noqa: TD003
url: str = url_template.format(campaign_no=campaign_no) url: str = f"https://api.chzzk.naver.com/service/{api_version}/drops/campaigns/{campaign_no}"
resp: requests.Response = requests.get( resp: requests.Response = requests.get(
url, url,
timeout=2, timeout=2,
headers={ headers={
"Accept": "application/json", "Accept": "application/json",
"User-Agent": USER_AGENT, "User-Agent": USER_AGENT,
}, },
)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
campaign_data: ChzzkCampaignV2
campaign_data = ChzzkApiResponseV2.model_validate(data).content
# Prepare raw JSON defaults for both API versions so DB inserts won't fail
raw_json_v1_val = data if api_version == "v1" else {}
raw_json_v2_val = data if api_version == "v2" else {}
# Save campaign
campaign_obj, created = ChzzkCampaign.objects.update_or_create(
campaign_no=campaign_data.campaign_no,
defaults={
"title": campaign_data.title,
"image_url": campaign_data.image_url,
"description": campaign_data.description,
"category_type": campaign_data.category_type,
"category_id": campaign_data.category_id,
"category_value": campaign_data.category_value,
"pc_link_url": campaign_data.pc_link_url,
"mobile_link_url": campaign_data.mobile_link_url,
"service_id": campaign_data.service_id,
"state": campaign_data.state,
"start_date": campaign_data.start_date,
"end_date": campaign_data.end_date,
"has_ios_based_reward": campaign_data.has_ios_based_reward,
"drops_campaign_not_started": campaign_data.drops_campaign_not_started,
"campaign_reward_type": getattr(
campaign_data,
"campaign_reward_type",
"",
),
"reward_type": getattr(campaign_data, "reward_type", ""),
"account_link_url": campaign_data.account_link_url,
"scraped_at": timezone.now(),
"scrape_status": "success",
"raw_json_v1": raw_json_v1_val,
"raw_json_v2": raw_json_v2_val,
},
)
if created:
self.stdout.write(
self.style.SUCCESS(f"Created campaign {campaign_no}"),
) )
resp.raise_for_status() for reward in campaign_data.reward_list:
data: dict[str, Any] = resp.json() reward_, created = ChzzkReward.objects.update_or_create(
campaign=campaign_obj,
campaign_data: ChzzkCampaignV1 | ChzzkCampaignV2 reward_no=reward.reward_no,
if api_version == "v1":
campaign_data = ChzzkApiResponseV1.model_validate(data).content
elif api_version == "v2":
campaign_data = ChzzkApiResponseV2.model_validate(data).content
else:
msg: str = f"Unknown API version: {api_version}"
self.stdout.write(self.style.ERROR(msg))
continue
# Save campaign
campaign_obj, created = ChzzkCampaign.objects.update_or_create(
campaign_no=campaign_data.campaign_no,
source_api=api_version,
defaults={ defaults={
"title": campaign_data.title, "image_url": reward.image_url,
"image_url": campaign_data.image_url, "title": reward.title,
"description": campaign_data.description, "reward_type": reward.reward_type,
"category_type": campaign_data.category_type,
"category_id": campaign_data.category_id,
"category_value": campaign_data.category_value,
"pc_link_url": campaign_data.pc_link_url,
"mobile_link_url": campaign_data.mobile_link_url,
"service_id": campaign_data.service_id,
"state": campaign_data.state,
"start_date": campaign_data.start_date,
"end_date": campaign_data.end_date,
"has_ios_based_reward": campaign_data.has_ios_based_reward,
"drops_campaign_not_started": campaign_data.drops_campaign_not_started,
"campaign_reward_type": getattr( "campaign_reward_type": getattr(
campaign_data, reward,
"campaign_reward_type", "campaign_reward_type",
"", "",
), ),
"reward_type": getattr(campaign_data, "reward_type", ""), "condition_type": reward.condition_type,
"account_link_url": campaign_data.account_link_url, "condition_for_minutes": reward.condition_for_minutes,
"scraped_at": timezone.now(), "ios_based_reward": reward.ios_based_reward,
"scrape_status": "success", "code_remaining_count": reward.code_remaining_count,
"raw_json": data,
}, },
) )
if created: if created:
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(
f"Created campaign {campaign_no} from {api_version}", f" Created reward {reward_.reward_no} for campaign {campaign_no}",
), ),
) )
for reward in campaign_data.reward_list:
reward_, created = ChzzkReward.objects.update_or_create(
campaign=campaign_obj,
reward_no=reward.reward_no,
defaults={
"image_url": reward.image_url,
"title": reward.title,
"reward_type": reward.reward_type,
"campaign_reward_type": getattr(
reward,
"campaign_reward_type",
"",
),
"condition_type": reward.condition_type,
"condition_for_minutes": reward.condition_for_minutes,
"ios_based_reward": reward.ios_based_reward,
"code_remaining_count": reward.code_remaining_count,
},
)
if created:
self.stdout.write(
self.style.SUCCESS(
f" Created reward {reward_.reward_no} for campaign {campaign_no}",
),
)
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(f"Imported campaign {campaign_no}"),
f"Imported campaign {campaign_no} from {api_version}", )
),
)

View file

@ -1,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-03-31 19:33 # Generated by Django 6.0.3 on 2026-04-01 01:57
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
@ -7,7 +7,7 @@ from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
"""Initial migration for ChzzkCampaign and ChzzkReward models.""" """Initial migration for chzzk app, creating ChzzkCampaign and ChzzkReward models."""
initial = True initial = True
@ -26,7 +26,7 @@ class Migration(migrations.Migration):
verbose_name="ID", verbose_name="ID",
), ),
), ),
("campaign_no", models.BigIntegerField(unique=True)), ("campaign_no", models.BigIntegerField()),
("title", models.CharField(max_length=255)), ("title", models.CharField(max_length=255)),
("image_url", models.URLField()), ("image_url", models.URLField()),
("description", models.TextField()), ("description", models.TextField()),
@ -53,11 +53,11 @@ class Migration(migrations.Migration):
("scraped_at", models.DateTimeField(default=django.utils.timezone.now)), ("scraped_at", models.DateTimeField(default=django.utils.timezone.now)),
("source_api", models.CharField(max_length=16)), ("source_api", models.CharField(max_length=16)),
("scrape_status", models.CharField(default="success", max_length=32)), ("scrape_status", models.CharField(default="success", max_length=32)),
("raw_json", models.JSONField()), ("raw_json_v1", models.JSONField(blank=True, null=True)),
("raw_json_v2", models.JSONField(blank=True, null=True)),
], ],
options={ options={
"ordering": ["-start_date"], "ordering": ["-start_date"],
"unique_together": {("campaign_no", "source_api")},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(

View file

@ -1,20 +0,0 @@
# Generated by Django 6.0.3 on 2026-03-31 19:53
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
"""Alter campaign_no field in ChzzkCampaign to remove unique constraint."""
dependencies = [
("chzzk", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="chzzkcampaign",
name="campaign_no",
field=models.BigIntegerField(),
),
]

View file

@ -26,12 +26,12 @@ class ChzzkCampaign(models.Model):
# Scraping metadata # Scraping metadata
scraped_at = models.DateTimeField(default=timezone.now) scraped_at = models.DateTimeField(default=timezone.now)
source_api = models.CharField(max_length=16) # 'v1' or 'v2' source_api = models.CharField(max_length=16)
scrape_status = models.CharField(max_length=32, default="success") scrape_status = models.CharField(max_length=32, default="success")
raw_json = models.JSONField() raw_json_v1 = models.JSONField(null=True, blank=True)
raw_json_v2 = models.JSONField(null=True, blank=True)
class Meta: class Meta:
unique_together = ("campaign_no", "source_api")
ordering = ["-start_date"] ordering = ["-start_date"]
def __str__(self) -> str: def __str__(self) -> str:

View file

@ -4,26 +4,8 @@ from pydantic import BaseModel
from pydantic import Field from pydantic import Field
class ChzzkRewardV1(BaseModel):
"""Pydantic schema for Chzzk v1 reward object."""
title: str
reward_no: int = Field(..., alias="rewardNo")
image_url: str = Field(..., alias="imageUrl")
reward_type: str = Field(..., alias="rewardType")
condition_type: str = Field(..., alias="conditionType")
condition_for_minutes: int = Field(..., alias="conditionForMinutes")
ios_based_reward: bool = Field(..., alias="iosBasedReward")
code_remaining_count: int = Field(..., alias="codeRemainingCount")
# Only in v1 API
campaign_reward_type: str | None = Field(None, alias="campaignRewardType")
model_config = {"extra": "forbid"}
class ChzzkRewardV2(BaseModel): class ChzzkRewardV2(BaseModel):
"""Pydantic schema for Chzzk v2 reward object.""" """Pydantic schema for api v2 reward object."""
title: str title: str
reward_no: int = Field(..., alias="rewardNo") reward_no: int = Field(..., alias="rewardNo")
@ -37,39 +19,14 @@ class ChzzkRewardV2(BaseModel):
model_config = {"extra": "forbid"} model_config = {"extra": "forbid"}
class ChzzkCampaignV1(BaseModel):
"""Pydantic schema for Chzzk v1 campaign object."""
title: str
state: str
description: str
campaign_no: int = Field(..., alias="campaignNo")
image_url: str = Field(..., alias="imageUrl")
category_type: str = Field(..., alias="categoryType")
category_id: str = Field(..., alias="categoryId")
category_value: str = Field(..., alias="categoryValue")
pc_link_url: str = Field(..., alias="pcLinkUrl")
mobile_link_url: str = Field(..., alias="mobileLinkUrl")
service_id: str = Field(..., alias="serviceId")
start_date: str = Field(..., alias="startDate")
end_date: str = Field(..., alias="endDate")
reward_list: list[ChzzkRewardV1] = Field(..., alias="rewardList")
has_ios_based_reward: bool = Field(..., alias="hasIosBasedReward")
drops_campaign_not_started: bool = Field(..., alias="dropsCampaignNotStarted")
campaign_reward_type: str | None = Field(None, alias="campaignRewardType")
account_link_url: str = Field(..., alias="accountLinkUrl")
model_config = {"extra": "forbid"}
class ChzzkCampaignV2(BaseModel): class ChzzkCampaignV2(BaseModel):
"""Pydantic schema for Chzzk v2 campaign object.""" """Pydantic schema for api v2 campaign object."""
title: str title: str
state: str state: str
description: str
campaign_no: int = Field(..., alias="campaignNo") campaign_no: int = Field(..., alias="campaignNo")
image_url: str = Field(..., alias="imageUrl") image_url: str = Field(..., alias="imageUrl")
description: str
category_type: str = Field(..., alias="categoryType") category_type: str = Field(..., alias="categoryType")
category_id: str = Field(..., alias="categoryId") category_id: str = Field(..., alias="categoryId")
category_value: str = Field(..., alias="categoryValue") category_value: str = Field(..., alias="categoryValue")
@ -87,18 +44,8 @@ class ChzzkCampaignV2(BaseModel):
model_config = {"extra": "forbid"} model_config = {"extra": "forbid"}
class ChzzkApiResponseV1(BaseModel):
"""Pydantic schema for Chzzk v1 API response."""
code: int
message: Any | None
content: ChzzkCampaignV1
model_config = {"extra": "forbid"}
class ChzzkApiResponseV2(BaseModel): class ChzzkApiResponseV2(BaseModel):
"""Pydantic schema for Chzzk v2 API response.""" """Pydantic schema for api v2 API response."""
code: int code: int
message: Any | None message: Any | None

49
chzzk/urls.py Normal file
View file

@ -0,0 +1,49 @@
from typing import TYPE_CHECKING
from django.urls import path
from chzzk import views
if TYPE_CHECKING:
from django.urls.resolvers import URLPattern
app_name = "chzzk"
urlpatterns: list[URLPattern] = [
# /chzzk/
path(
"",
views.dashboard_view,
name="dashboard",
),
# /chzzk/campaigns/
path(
"campaigns/",
views.CampaignListView.as_view(),
name="campaign_list",
),
# /chzzk/campaigns/<campaign_no>/
path(
"campaigns/<int:campaign_no>/",
views.campaign_detail_view,
name="campaign_detail",
),
# /chzzk/rss/campaigns
path(
"rss/campaigns",
views.ChzzkCampaignFeed(),
name="campaign_feed",
),
# /chzzk/atom/campaigns
path(
"atom/campaigns",
views.ChzzkCampaignFeed(),
name="campaign_feed_atom",
),
# /chzzk/discord/campaigns
path(
"discord/campaigns",
views.ChzzkCampaignFeed(),
name="campaign_feed_discord",
),
]

View file

@ -0,0 +1,206 @@
from typing import TYPE_CHECKING
from django.db.models.query import QuerySet
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.safestring import SafeText
from django.views import generic
from chzzk import models
from twitch.feeds import TTVDropsBaseFeed
if TYPE_CHECKING:
import datetime
from django.http import HttpResponse
from django.http.request import HttpRequest
from pytest_django.asserts import QuerySet
def dashboard_view(request: HttpRequest) -> HttpResponse:
"""View function for the dashboard page showing all the active chzzk campaigns.
Args:
request (HttpRequest): The incoming HTTP request.
Returns:
HttpResponse: The HTTP response containing the rendered dashboard page.
"""
active_campaigns: QuerySet[models.ChzzkCampaign, models.ChzzkCampaign] = (
models.ChzzkCampaign.objects.filter(end_date__gte=timezone.now()).order_by(
"-start_date",
)
)
return render(
request=request,
template_name="chzzk/dashboard.html",
context={
"active_campaigns": active_campaigns,
},
)
class CampaignListView(generic.ListView):
"""List view showing all chzzk campaigns."""
model = models.ChzzkCampaign
template_name = "chzzk/campaign_list.html"
context_object_name = "campaigns"
paginate_by = 25
def campaign_detail_view(request: HttpRequest, campaign_no: int) -> HttpResponse:
"""View function for the campaign detail page showing information about a single chzzk campaign.
Args:
request (HttpRequest): The incoming HTTP request.
campaign_no (int): The campaign number of the campaign to display.
Returns:
HttpResponse: The HTTP response containing the rendered campaign detail page.
"""
campaign: models.ChzzkCampaign = models.ChzzkCampaign.objects.get(
campaign_no=campaign_no,
)
rewards: QuerySet[models.ChzzkReward, models.ChzzkReward] = campaign.rewards.all() # pyright: ignore[reportAttributeAccessIssue]
return render(
request=request,
template_name="chzzk/campaign_detail.html",
context={
"campaign": campaign,
"rewards": rewards,
},
)
class ChzzkCampaignFeed(TTVDropsBaseFeed):
"""RSS feed for the latest chzzk campaigns."""
title: str = "chzzk campaigns"
link: str = "/chzzk/campaigns/"
description: str = "Latest chzzk campaigns"
_limit: int | None = None
def __call__(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Allow an optional 'limit' query parameter to specify the number of items in the feed.
Args:
request (HttpRequest): The incoming HTTP request.
*args: Additional positional arguments.
**kwargs: Additional keyword arguments.
Returns:
HttpResponse: The HTTP response containing the feed.
"""
if request.GET.get("limit"):
try:
self._limit = int(request.GET.get("limit", 50))
except ValueError, TypeError:
self._limit = None
return super().__call__(request, *args, **kwargs)
def items(self) -> QuerySet[models.ChzzkCampaign, models.ChzzkCampaign]:
"""Return the latest chzzk campaigns with raw_json data, ordered by start date.
Returns:
QuerySet: A queryset of ChzzkCampaign objects.
"""
limit: int = self._limit if self._limit is not None else 50
return models.ChzzkCampaign.objects.filter(raw_json__isnull=False).order_by(
"-start_date",
)[:limit]
def item_title(self, item: models.ChzzkCampaign) -> str:
"""Return the title of the feed item, which is the campaign title.
Args:
item (ChzzkCampaign): The campaign object for the feed item.
Returns:
str: The title of the feed item.
"""
return item.title
def item_description(self, item: models.ChzzkCampaign) -> SafeText:
"""Return the description of the feed item, which includes the campaign image and description.
Args:
item (ChzzkCampaign): The campaign object for the feed item.
Returns:
SafeText: The HTML description of the feed item.
"""
parts: list[SafeText] = []
if getattr(item, "image_url", ""):
parts.append(
format_html(
'<img src="{}" alt="{}" width="320" height="180" />',
item.image_url,
item.title,
),
)
if getattr(item, "description", ""):
parts.append(format_html("<p>{}</p>", item.description))
# Link back to the PC detail URL when available
if getattr(item, "pc_link_url", ""):
parts.append(
format_html('<p><a href="{}">Details</a></p>', item.pc_link_url),
)
return SafeText("".join(str(p) for p in parts))
def item_link(self, item: models.ChzzkCampaign) -> str:
"""Return the URL for the feed item, which is the campaign detail page.
Args:
item (ChzzkCampaign): The campaign object for the feed item.
Returns:
str: The URL for the feed item.
"""
return reverse("chzzk:campaign_detail", args=[item.pk])
def item_pubdate(self, item: models.ChzzkCampaign) -> datetime.datetime:
"""Return the publication date of the feed item, which is the campaign start date.
Args:
item (ChzzkCampaign): The campaign object for the feed item.
Returns:
datetime.datetime: The publication date of the feed item.
"""
return getattr(item, "start_date", timezone.now()) or timezone.now()
def item_updateddate(self, item: models.ChzzkCampaign) -> datetime.datetime:
"""Return the last updated date of the feed item, which is the campaign scraped date.
Args:
item (ChzzkCampaign): The campaign object for the feed item.
Returns:
datetime.datetime: The last updated date of the feed item.
"""
return getattr(item, "scraped_at", timezone.now()) or timezone.now()
def item_author_name(self, item: models.ChzzkCampaign) -> str:
"""Return the author name for the feed item. Since we don't have a specific author, return a default value.
Args:
item (ChzzkCampaign): The campaign object for the feed item.
Returns:
str: The author name for the feed item.
"""
return item.category_id or "Unknown Category"
def feed_url(self) -> str:
"""Return the URL of the feed itself.
Returns:
str: The URL of the feed.
"""
return reverse("chzzk:campaign_feed")

View file

@ -49,6 +49,8 @@ urlpatterns: list[URLPattern | URLResolver] = [
path(route="twitch/", view=include("twitch.urls", namespace="twitch")), path(route="twitch/", view=include("twitch.urls", namespace="twitch")),
# Kick app # Kick app
path(route="kick/", view=include("kick.urls", namespace="kick")), path(route="kick/", view=include("kick.urls", namespace="kick")),
# Chzzk app
path(route="chzzk/", view=include("chzzk.urls", namespace="chzzk")),
# YouTube app # YouTube app
path(route="youtube/", view=include("youtube.urls", namespace="youtube")), path(route="youtube/", view=include("youtube.urls", namespace="youtube")),
] ]

View file

@ -228,6 +228,10 @@
<a href="{% url 'kick:game_list' %}">Games</a> | <a href="{% url 'kick:game_list' %}">Games</a> |
<a href="{% url 'kick:organization_list' %}">Organizations</a> <a href="{% url 'kick:organization_list' %}">Organizations</a>
<br /> <br />
<strong>Chzzk</strong>
<a href="{% url 'chzzk:dashboard' %}">Dashboard</a> |
<a href="{% url 'chzzk:campaign_list' %}">Campaigns</a>
<br />
<strong>Other sites</strong> <strong>Other sites</strong>
<a href="#">Steam</a> | <a href="#">Steam</a> |
<a href="{% url 'youtube:index' %}">YouTube</a> | <a href="{% url 'youtube:index' %}">YouTube</a> |

View file

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}
{{ campaign.title }}
{% endblock title %}
{% block content %}
<main>
<h1>{{ campaign.title }}</h1>
<nav>
<a href="{% url 'chzzk:dashboard' %}">chzzk</a> &gt; <a href="{% url 'chzzk:campaign_list' %}">Campaigns</a> &gt; {{ campaign.title }}
</nav>
{% if campaign.image_url %}
<img src="{{ campaign.image_url }}"
alt="{{ campaign.title }}"
height="auto"
width="auto"
style="max-width:200px;
height:auto;
border-radius:8px" />
{% endif %}
{% if campaign.description %}
<section class="description">
{{ campaign.description|linebreaksbr }}
</section>
{% endif %}
<section class="times">
{% if campaign.starts_at %}
<div>
Starts: <time datetime="{{ campaign.starts_at|date:'c' }}">{{ campaign.starts_at|date:'M d, Y H:i' }}</time>
</div>
{% endif %}
{% if campaign.ends_at %}
<div>
Ends: <time datetime="{{ campaign.ends_at|date:'c' }}">{{ campaign.ends_at|date:'M d, Y H:i' }}</time>
</div>
{% endif %}
</section>
<hr />
<h2>Rewards</h2>
{% if rewards %}
<ul>
{% for r in rewards %}<li>{{ r.title }} — {{ r.condition_for_minutes }} minutes of watch time</li>{% endfor %}
</ul>
{% else %}
<p>No rewards available.</p>
{% endif %}
{% if campaign.external_url %}
<p>
<a class="btn" href="{{ campaign.external_url }}">View on chzzk</a>
</p>
{% endif %}
</main>
{% endblock content %}

View file

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}
chzzk Campaigns
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="All chzzk campaigns (RSS)"
href="{% url 'chzzk:campaign_feed' %}" />
{% endblock extra_head %}
{% block content %}
<main>
<h1>chzzk campaigns</h1>
<nav>
<a href="{% url 'chzzk:dashboard' %}">chzzk</a> > Campaigns
</nav>
<!-- <div class="feeds">
<a href="{% url 'chzzk:campaign_feed' %}" title="RSS feed for all campaigns">[rss]</a>
</div> -->
{% if campaigns %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Organization</th>
<th>Start</th>
<th>End</th>
</tr>
</thead>
<tbody>
{% for c in campaigns %}
<tr>
<td>
<a href="{% url 'chzzk:campaign_detail' c.pk %}">{{ c.title }}</a>
</td>
<td>
{% if c.organization %}
<a href="{% url 'chzzk:organization_detail' c.organization.pk %}">{{ c.organization.name }}</a>
{% endif %}
</td>
<td>
{% if c.start_date %}
<time datetime="{{ c.start_date|date:'c' }}">{{ c.start_date|date:'M d, Y' }}</time> ({{ c.start_date|timesince }} ago)
{% endif %}
</td>
<td>
{% if c.end_date %}
<time datetime="{{ c.end_date|date:'c' }}">{{ c.end_date|date:'M d, Y' }}</time> ({{ c.end_date|timesince }} ago)
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if is_paginated %}
{% include "includes/pagination.html" with page_obj=page_obj %}
{% endif %}
{% else %}
<p>No campaigns found.</p>
{% endif %}
</main>
{% endblock content %}

View file

@ -0,0 +1,137 @@
{% extends "base.html" %}
{% block title %}
Chzzk Drops
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="All Chzzk campaigns (RSS)"
href="{% url 'chzzk:campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All Chzzk campaigns (Atom)"
href="{% url 'chzzk:campaign_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All Chzzk campaigns (Discord)"
href="{% url 'chzzk:campaign_feed_discord' %}" />
{% endblock extra_head %}
{% block content %}
<main>
<h1>Active Chzzk Drops</h1>
<div>CHZZK is South Korean alternative to Twitch.</div>
<!--RSS Feeds
<div>
<a href="{% url 'chzzk:campaign_feed' %}" title="RSS feed for all campaigns">[rss]</a>
<a href="{% url 'chzzk:campaign_feed_atom' %}" title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'chzzk:campaign_feed_discord' %}" title="Discord feed for all campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> -->
<hr />
{% if active_campaigns %}
{% for campaign in active_campaigns %}
<!-- {{ campaign }} -->
<article>
<header>
<h2>{{ campaign.category_value }}</h2>
<div style="font-size: 0.9rem; color: #666;">Status: {{ campaign.state }} | Campaign #{{ campaign.campaign_no }}</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.title }} 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 'chzzk:campaign_detail' campaign.campaign_no %}">{{ campaign.title }}</a>
</h3>
{% if campaign.description %}
<p style="margin: 0.25rem 0; font-style: italic;">{{ campaign.description }}</p>
{% endif %}
<!-- Start -->
{% if campaign.start_date %}
<p style="margin: 0.25rem 0;">
<strong>Starts:</strong>
<time datetime="{{ campaign.start_date|date:'c' }}"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }}">
{{ campaign.start_date|date:"M d, Y H:i" }}
</time>
{% if campaign.start_date < now %}
(started {{ campaign.start_date|timesince }} ago)
{% else %}
(in {{ campaign.start_date|timeuntil }})
{% endif %}
</p>
{% endif %}
<!-- End -->
{% if campaign.end_date %}
<p style="margin: 0.25rem 0;">
<strong>Ends:</strong>
<time datetime="{{ campaign.end_date|date:'c' }}"
title="{{ campaign.end_date|date:'DATETIME_FORMAT' }}">
{{ campaign.end_date|date:"M d, Y H:i" }}
</time>
{% if campaign.end_date < now %}
(ended {{ campaign.end_date|timesince }} ago)
{% else %}
(in {{ campaign.end_date|timeuntil }})
{% endif %}
</p>
{% endif %}
{% if campaign.reward_type %}
<p style="margin: 0.25rem 0;">
<strong>Reward Type:</strong> {{ campaign.reward_type }}
</p>
{% endif %}
<p style="margin: 0.25rem 0;">
{% if campaign.account_link_url %}<a href="{{ campaign.account_link_url }}">Connect account</a> |{% endif %}
{% if campaign.pc_link_url %}<a href="{{ campaign.pc_link_url }}" target="_blank">View on Chzzk</a>{% endif %}
</p>
<div style="margin-top: 0.5rem; font-size: 0.9rem;">
<strong>Participating channels:</strong>
<p style="margin: 0.25rem 0 0 0;">{{ campaign.category_value }} is game wide.</p>
</div>
{% if campaign.rewards.all %}
<div style="margin-top: 0.75rem;">
<strong>Rewards:</strong>
<ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;">
{% for reward in campaign.rewards.all %}
<li>
{% if reward.image_url %}
<img src="{{ reward.image_url }}"
alt="{{ reward.title }}"
width="56"
height="56"
style="vertical-align: middle;
border-radius: 4px" />
{% endif %}
{{ reward.title }} ({{ reward.condition_for_minutes }} min)
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
</article>
{% endfor %}
{% else %}
<p>No active Chzzk drop campaigns at the moment. Check back later!</p>
{% endif %}
</main>
{% endblock content %}