diff --git a/chzzk/management/commands/import_chzzk_campaign.py b/chzzk/management/commands/import_chzzk_campaign.py index f4c0875..484dd66 100644 --- a/chzzk/management/commands/import_chzzk_campaign.py +++ b/chzzk/management/commands/import_chzzk_campaign.py @@ -4,7 +4,6 @@ from typing import Any if TYPE_CHECKING: import argparse - from chzzk.schemas import ChzzkCampaignV1 from chzzk.schemas import ChzzkCampaignV2 @@ -16,7 +15,6 @@ from django.utils import timezone from chzzk.models import ChzzkCampaign from chzzk.models import ChzzkReward -from chzzk.schemas import ChzzkApiResponseV1 from chzzk.schemas import ChzzkApiResponseV2 if TYPE_CHECKING: @@ -44,94 +42,87 @@ class Command(BaseCommand): def handle(self, **options) -> None: """Main handler for the management command. Fetches campaign data from both API versions, validates, and stores them.""" campaign_no: int = int(options["campaign_no"]) - for api_version, url_template in CHZZK_API_URLS: - url: str = url_template.format(campaign_no=campaign_no) - resp: requests.Response = requests.get( - url, - timeout=2, - headers={ - "Accept": "application/json", - "User-Agent": USER_AGENT, - }, + api_version: str = "v2" # TODO(TheLovinator): Add support for v1 API # noqa: TD003 + url: str = f"https://api.chzzk.naver.com/service/{api_version}/drops/campaigns/{campaign_no}" + resp: requests.Response = requests.get( + url, + timeout=2, + headers={ + "Accept": "application/json", + "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() - data: dict[str, Any] = resp.json() - - campaign_data: ChzzkCampaignV1 | ChzzkCampaignV2 - 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, + for reward in campaign_data.reward_list: + reward_, created = ChzzkReward.objects.update_or_create( + campaign=campaign_obj, + reward_no=reward.reward_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, + "image_url": reward.image_url, + "title": reward.title, + "reward_type": reward.reward_type, "campaign_reward_type": getattr( - campaign_data, + reward, "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": data, + "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 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.style.SUCCESS( - f"Imported campaign {campaign_no} from {api_version}", - ), - ) + self.stdout.write( + self.style.SUCCESS(f"Imported campaign {campaign_no}"), + ) diff --git a/chzzk/migrations/0001_initial.py b/chzzk/migrations/0001_initial.py index 52e6e62..7208889 100644 --- a/chzzk/migrations/0001_initial.py +++ b/chzzk/migrations/0001_initial.py @@ -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.utils.timezone @@ -7,7 +7,7 @@ from django.db import models class Migration(migrations.Migration): - """Initial migration for ChzzkCampaign and ChzzkReward models.""" + """Initial migration for chzzk app, creating ChzzkCampaign and ChzzkReward models.""" initial = True @@ -26,7 +26,7 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("campaign_no", models.BigIntegerField(unique=True)), + ("campaign_no", models.BigIntegerField()), ("title", models.CharField(max_length=255)), ("image_url", models.URLField()), ("description", models.TextField()), @@ -53,11 +53,11 @@ class Migration(migrations.Migration): ("scraped_at", models.DateTimeField(default=django.utils.timezone.now)), ("source_api", models.CharField(max_length=16)), ("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={ "ordering": ["-start_date"], - "unique_together": {("campaign_no", "source_api")}, }, ), migrations.CreateModel( diff --git a/chzzk/migrations/0002_alter_chzzkcampaign_campaign_no.py b/chzzk/migrations/0002_alter_chzzkcampaign_campaign_no.py deleted file mode 100644 index e35eb48..0000000 --- a/chzzk/migrations/0002_alter_chzzkcampaign_campaign_no.py +++ /dev/null @@ -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(), - ), - ] diff --git a/chzzk/models.py b/chzzk/models.py index 2aefe93..c1c5840 100644 --- a/chzzk/models.py +++ b/chzzk/models.py @@ -26,12 +26,12 @@ class ChzzkCampaign(models.Model): # Scraping metadata 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") - raw_json = models.JSONField() + raw_json_v1 = models.JSONField(null=True, blank=True) + raw_json_v2 = models.JSONField(null=True, blank=True) class Meta: - unique_together = ("campaign_no", "source_api") ordering = ["-start_date"] def __str__(self) -> str: diff --git a/chzzk/schemas.py b/chzzk/schemas.py index 20a0986..dc0df2b 100644 --- a/chzzk/schemas.py +++ b/chzzk/schemas.py @@ -4,26 +4,8 @@ from pydantic import BaseModel 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): - """Pydantic schema for Chzzk v2 reward object.""" + """Pydantic schema for api v2 reward object.""" title: str reward_no: int = Field(..., alias="rewardNo") @@ -37,39 +19,14 @@ class ChzzkRewardV2(BaseModel): 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): - """Pydantic schema for Chzzk v2 campaign object.""" + """Pydantic schema for api v2 campaign object.""" title: str state: str + description: str campaign_no: int = Field(..., alias="campaignNo") image_url: str = Field(..., alias="imageUrl") - description: str category_type: str = Field(..., alias="categoryType") category_id: str = Field(..., alias="categoryId") category_value: str = Field(..., alias="categoryValue") @@ -87,18 +44,8 @@ class ChzzkCampaignV2(BaseModel): 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): - """Pydantic schema for Chzzk v2 API response.""" + """Pydantic schema for api v2 API response.""" code: int message: Any | None diff --git a/chzzk/urls.py b/chzzk/urls.py new file mode 100644 index 0000000..7b6209f --- /dev/null +++ b/chzzk/urls.py @@ -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// + path( + "campaigns//", + 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", + ), +] diff --git a/chzzk/views.py b/chzzk/views.py index e69de29..bc87bfc 100644 --- a/chzzk/views.py +++ b/chzzk/views.py @@ -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( + '{}', + item.image_url, + item.title, + ), + ) + if getattr(item, "description", ""): + parts.append(format_html("

{}

", item.description)) + + # Link back to the PC detail URL when available + if getattr(item, "pc_link_url", ""): + parts.append( + format_html('

Details

', 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") diff --git a/config/urls.py b/config/urls.py index 15b37bb..6abb8f8 100644 --- a/config/urls.py +++ b/config/urls.py @@ -49,6 +49,8 @@ urlpatterns: list[URLPattern | URLResolver] = [ path(route="twitch/", view=include("twitch.urls", namespace="twitch")), # Kick app path(route="kick/", view=include("kick.urls", namespace="kick")), + # Chzzk app + path(route="chzzk/", view=include("chzzk.urls", namespace="chzzk")), # YouTube app path(route="youtube/", view=include("youtube.urls", namespace="youtube")), ] diff --git a/templates/base.html b/templates/base.html index 79611af..be2daed 100644 --- a/templates/base.html +++ b/templates/base.html @@ -228,6 +228,10 @@ Games | Organizations
+ Chzzk + Dashboard | + Campaigns +
Other sites Steam | YouTube | diff --git a/templates/chzzk/campaign_detail.html b/templates/chzzk/campaign_detail.html new file mode 100644 index 0000000..7a38c83 --- /dev/null +++ b/templates/chzzk/campaign_detail.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %} + {{ campaign.title }} +{% endblock title %} +{% block content %} +
+

{{ campaign.title }}

+ + {% if campaign.image_url %} + {{ campaign.title }} + {% endif %} + {% if campaign.description %} +
+ {{ campaign.description|linebreaksbr }} +
+ {% endif %} +
+ {% if campaign.starts_at %} +
+ Starts: +
+ {% endif %} + {% if campaign.ends_at %} +
+ Ends: +
+ {% endif %} +
+
+

Rewards

+ {% if rewards %} +
    + {% for r in rewards %}
  • {{ r.title }} — {{ r.condition_for_minutes }} minutes of watch time
  • {% endfor %} +
+ {% else %} +

No rewards available.

+ {% endif %} + {% if campaign.external_url %} +

+ View on chzzk +

+ {% endif %} +
+{% endblock content %} diff --git a/templates/chzzk/campaign_list.html b/templates/chzzk/campaign_list.html new file mode 100644 index 0000000..d256674 --- /dev/null +++ b/templates/chzzk/campaign_list.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %} + chzzk Campaigns +{% endblock title %} +{% block extra_head %} + +{% endblock extra_head %} +{% block content %} +
+

chzzk campaigns

+ + + {% if campaigns %} + + + + + + + + + + + {% for c in campaigns %} + + + + + + + {% endfor %} + +
NameOrganizationStartEnd
+ {{ c.title }} + + {% if c.organization %} + {{ c.organization.name }} + {% endif %} + + {% if c.start_date %} + ({{ c.start_date|timesince }} ago) + {% endif %} + + {% if c.end_date %} + ({{ c.end_date|timesince }} ago) + {% endif %} +
+ {% if is_paginated %} + {% include "includes/pagination.html" with page_obj=page_obj %} + {% endif %} + {% else %} +

No campaigns found.

+ {% endif %} +
+{% endblock content %} diff --git a/templates/chzzk/dashboard.html b/templates/chzzk/dashboard.html new file mode 100644 index 0000000..c7c6169 --- /dev/null +++ b/templates/chzzk/dashboard.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} +{% block title %} + Chzzk Drops +{% endblock title %} +{% block extra_head %} + + + +{% endblock extra_head %} +{% block content %} +
+

Active Chzzk Drops

+
CHZZK is South Korean alternative to Twitch.
+ +
+ {% if active_campaigns %} + {% for campaign in active_campaigns %} + +
+
+

{{ campaign.category_value }}

+
Status: {{ campaign.state }} | Campaign #{{ campaign.campaign_no }}
+
+
+
+ {% if campaign.image_url %} + {{ campaign.title }} image + {% else %} +
No Image
+ {% endif %} +
+
+

+ {{ campaign.title }} +

+ {% if campaign.description %} +

{{ campaign.description }}

+ {% endif %} + + {% if campaign.start_date %} +

+ Starts: + + {% if campaign.start_date < now %} + (started {{ campaign.start_date|timesince }} ago) + {% else %} + (in {{ campaign.start_date|timeuntil }}) + {% endif %} +

+ {% endif %} + + {% if campaign.end_date %} +

+ Ends: + + {% if campaign.end_date < now %} + (ended {{ campaign.end_date|timesince }} ago) + {% else %} + (in {{ campaign.end_date|timeuntil }}) + {% endif %} +

+ {% endif %} + {% if campaign.reward_type %} +

+ Reward Type: {{ campaign.reward_type }} +

+ {% endif %} +

+ {% if campaign.account_link_url %}Connect account |{% endif %} + {% if campaign.pc_link_url %}View on Chzzk{% endif %} +

+
+ Participating channels: +

{{ campaign.category_value }} is game wide.

+
+ {% if campaign.rewards.all %} +
+ Rewards: +
    + {% for reward in campaign.rewards.all %} +
  • + {% if reward.image_url %} + {{ reward.title }} + {% endif %} + {{ reward.title }} ({{ reward.condition_for_minutes }} min) +
  • + {% endfor %} +
+
+ {% endif %} +
+
+
+ {% endfor %} + {% else %} +

No active Chzzk drop campaigns at the moment. Check back later!

+ {% endif %} +
+{% endblock content %}