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:
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,8 +42,8 @@ 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)
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,
@ -57,20 +55,16 @@ class Command(BaseCommand):
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: ChzzkCampaignV2
campaign_data = ChzzkApiResponseV2.model_validate(data).content
else:
msg: str = f"Unknown API version: {api_version}"
self.stdout.write(self.style.ERROR(msg))
continue
# 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,
source_api=api_version,
defaults={
"title": campaign_data.title,
"image_url": campaign_data.image_url,
@ -95,14 +89,13 @@ class Command(BaseCommand):
"account_link_url": campaign_data.account_link_url,
"scraped_at": timezone.now(),
"scrape_status": "success",
"raw_json": data,
"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} from {api_version}",
),
self.style.SUCCESS(f"Created campaign {campaign_no}"),
)
for reward in campaign_data.reward_list:
reward_, created = ChzzkReward.objects.update_or_create(
@ -131,7 +124,5 @@ class Command(BaseCommand):
)
self.stdout.write(
self.style.SUCCESS(
f"Imported campaign {campaign_no} from {api_version}",
),
self.style.SUCCESS(f"Imported campaign {campaign_no}"),
)

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.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(

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
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:

View file

@ -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

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")),
# 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")),
]

View file

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