Compare commits

...

3 commits

Author SHA1 Message Date
e433a5d89d
Remove import of _MonkeyPatchedWSGIResponse in test_views.py
All checks were successful
Deploy to Server / deploy (push) Successful in 22s
2026-04-02 01:46:30 +02:00
ef6c2b84ab
Enhance campaign detail template 2026-04-02 01:45:50 +02:00
e709009b99
Add management command to import a range of Chzzk campaigns 2026-04-01 20:44:44 +02:00
4 changed files with 259 additions and 46 deletions

View file

@ -0,0 +1,59 @@
from typing import TYPE_CHECKING
from django.core.management import BaseCommand
from django.core.management import CommandError
from django.core.management import call_command
if TYPE_CHECKING:
import argparse
class Command(BaseCommand):
"""Django management command to import a range of Chzzk campaigns by calling import_chzzk_campaign repeatedly."""
help = (
"Import a range of Chzzk campaigns by calling import_chzzk_campaign repeatedly."
)
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
"""Add command-line arguments for the management command."""
parser.add_argument("start", type=int, help="Starting campaign number")
parser.add_argument("end", type=int, help="Ending campaign number (inclusive)")
parser.add_argument(
"--step",
type=int,
default=None,
help="Step to move between numbers (default -1 for descending, 1 for ascending)",
)
def handle(self, **options) -> None:
"""Main handler for the management command. Calls import_chzzk_campaign for each campaign number in the specified range.
Raises:
ValueError: If step is 0.
"""
start: int = options["start"]
end: int = options["end"]
step: int | None = options["step"]
if step is None:
step = -1 if start > end else 1
if step == 0:
msg = "Step cannot be 0"
raise ValueError(msg)
range_end: int = end + 1 if step > 0 else end - 1
msg: str = f"Importing campaigns from {start} to {end} with step {step}"
self.stdout.write(self.style.SUCCESS(msg))
for campaign_no in range(start, range_end, step):
self.stdout.write(f"Importing campaign {campaign_no}...")
try:
call_command("import_chzzk_campaign", str(campaign_no))
except CommandError as exc:
msg = f"Failed campaign {campaign_no}: {exc}"
self.stdout.write(self.style.ERROR(msg))
self.stdout.write(self.style.SUCCESS("Batch import complete."))

View file

@ -60,3 +60,46 @@ class ChzzkDashboardViewTests(TestCase):
assert included in campaigns assert included in campaigns
assert all(c.state != "TESTING" for c in campaigns) assert all(c.state != "TESTING" for c in campaigns)
def test_campaign_detail_view_renders_chzzk_campaign_and_rewards(self) -> None:
"""Test that the campaign detail view correctly renders the details of a chzzk campaign and its rewards."""
now: datetime = timezone.now()
campaign: ChzzkCampaign = ChzzkCampaign.objects.create(
campaign_no=2001,
title="Campaign Detail Test",
description="Detailed campaign description",
category_type="game",
category_id="1",
category_value="TestGame",
service_id="chzzk",
state="ACTIVE",
start_date=now - timedelta(days=1),
end_date=now + timedelta(days=1),
has_ios_based_reward=False,
drops_campaign_not_started=False,
source_api="unit-test",
)
campaign.rewards.create( # pyright: ignore[reportAttributeAccessIssue]
reward_no=10,
title="Reward A",
reward_type="ITEM",
campaign_reward_type="Standard",
condition_type="watch",
condition_for_minutes=15,
ios_based_reward=False,
code_remaining_count=100,
)
response: _MonkeyPatchedWSGIResponse = self.client.get(
reverse("chzzk:campaign_detail", args=[campaign.campaign_no]),
)
assert response.status_code == 200
content: str = response.content.decode()
assert campaign.title in content
assert "Reward A" in content
assert "watch" in content
assert "100" in content
assert "No" in content # ios_based_reward=False

View file

@ -1,6 +1,7 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.shortcuts import get_object_or_404
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -62,7 +63,8 @@ def campaign_detail_view(request: HttpRequest, campaign_no: int) -> HttpResponse
Returns: Returns:
HttpResponse: The HTTP response containing the rendered campaign detail page. HttpResponse: The HTTP response containing the rendered campaign detail page.
""" """
campaign: models.ChzzkCampaign = models.ChzzkCampaign.objects.get( campaign: models.ChzzkCampaign = get_object_or_404(
models.ChzzkCampaign,
campaign_no=campaign_no, campaign_no=campaign_no,
) )
rewards: QuerySet[models.ChzzkReward, models.ChzzkReward] = campaign.rewards.all() # pyright: ignore[reportAttributeAccessIssue] rewards: QuerySet[models.ChzzkReward, models.ChzzkReward] = campaign.rewards.all() # pyright: ignore[reportAttributeAccessIssue]

View file

@ -1,52 +1,161 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% load image_tags %}
{% block title %} {% block title %}
{{ campaign.title }} {{ campaign.title }}
{% endblock title %} {% 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 %} {% block content %}
<main> <div style="display: flex; align-items: flex-start;">
<h1>{{ campaign.title }}</h1> <!-- Campaign image -->
<nav> <div style="margin-right: 16px;">
<a href="{% url 'chzzk:dashboard' %}">chzzk</a> &gt; <a href="{% url 'chzzk:campaign_list' %}">Campaigns</a> &gt; {{ campaign.title }} {% if campaign.image_url %}
</nav> <img src="{{ campaign.image_url }}"
{% if campaign.image_url %} alt="{{ campaign.title }}"
<img src="{{ campaign.image_url }}" width="160"
alt="{{ campaign.title }}" height="auto" />
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 %} {% endif %}
{% if campaign.ends_at %} </div>
<div> <!-- Campaign Title -->
Ends: <time datetime="{{ campaign.ends_at|date:'c' }}">{{ campaign.ends_at|date:'M d, Y H:i' }}</time> <div style="display: flex; flex-direction: column;">
</div> <h1 style="margin-top: 0; margin-bottom: 0px;">{{ campaign.title }}</h1>
{% endif %} <!-- Add breadcrumbs -->
</section> <div>
<hr /> <a href="{% url 'chzzk:dashboard' %}">chzzk</a> > <a href="{% url 'chzzk:campaign_list' %}">Campaigns</a> > {{ campaign.title }}
<h2>Rewards</h2> </div>
{% if rewards %} <!-- Campaign description -->
<ul> <p>{{ campaign.description|linebreaksbr }}</p>
{% for r in rewards %}<li>{{ r.title }} — {{ r.condition_for_minutes }} minutes of watch time</li>{% endfor %} <div>
</ul> Published
{% else %} {% if campaign.scraped_at %}
<p>No rewards available.</p> <time datetime="{{ campaign.scraped_at|date:'c' }}"
{% endif %} title="{{ campaign.scraped_at|date:'DATETIME_FORMAT' }}">{{ campaign.scraped_at|date:"M d, Y H:i" }}</time> ({{ campaign.scraped_at|timesince }} ago)
{% if campaign.external_url %} {% elif campaign.start_date %}
<p> <time datetime="{{ campaign.start_date|date:'c' }}"
<a class="btn" href="{{ campaign.external_url }}">View on chzzk</a> title="{{ campaign.start_date|date:'DATETIME_FORMAT' }}">{{ campaign.start_date|date:"M d, Y H:i" }}</time> ({{ campaign.start_date|timesince }} ago)
</p> {% else %}
{% endif %} unknown
</main> {% endif %}
</div>
<div>
Last updated
{% if campaign.scraped_at %}
<time datetime="{{ campaign.scraped_at|date:'c' }}"
title="{{ campaign.scraped_at|date:'DATETIME_FORMAT' }}">{{ campaign.scraped_at|date:"M d, Y H:i" }}</time> ({{ campaign.scraped_at|timesince }} ago)
{% elif campaign.updated_at %}
<time datetime="{{ campaign.updated_at|date:'c' }}"
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y H:i" }}</time> ({{ campaign.updated_at|timesince }} ago)
{% else %}
unknown
{% endif %}
</div>
<!-- Campaign end times -->
<div>
{% if campaign.end_date %}
{% if campaign.end_date < now %}
Ended
<time datetime="{{ campaign.end_date|date:'c' }}"
title="{{ campaign.end_date|date:'DATETIME_FORMAT' }}">{{ campaign.end_date|date:"M d, Y H:i" }}</time> ({{ campaign.end_date|timesince }} ago)
{% else %}
Ends in
<time datetime="{{ campaign.end_date|date:'c' }}"
title="{{ campaign.end_date|date:'DATETIME_FORMAT' }}">{{ campaign.end_date|date:"M d, Y H:i" }}</time> (in {{ campaign.end_date|timeuntil }})
{% endif %}
{% else %}
Ends unknown
{% endif %}
</div>
<!-- Campaign start times -->
<div>
{% if campaign.start_date %}
{% if campaign.start_date > now %}
Starts in
<time datetime="{{ campaign.start_date|date:'c' }}"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }}">{{ campaign.start_date|date:"M d, Y H:i" }}</time> (in {{ campaign.start_date|timeuntil }})
{% else %}
Started
<time datetime="{{ campaign.start_date|date:'c' }}"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }}">{{ campaign.start_date|date:"M d, Y H:i" }}</time> ({{ campaign.start_date|timesince }} ago)
{% endif %}
{% else %}
Starts unknown
{% endif %}
</div>
<!-- Campaign duration -->
<div>
Duration is
{% if campaign.start_date and campaign.end_date %}
<time datetime="P{{ campaign.end_date|date:'Y' }}-{{ campaign.end_date|date:'m' }}-{{ campaign.end_date|date:'d' }}T{{ campaign.end_date|date:'H' }}:00:00"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }} to {{ campaign.end_date|date:'DATETIME_FORMAT' }}">{{ campaign.end_date|timeuntil:campaign.start_date }}</time>
{% else %}
unknown
{% endif %}
</div>
<!-- Buttons -->
<div>
{% if campaign.pc_link_url %}<a href="{{ campaign.pc_link_url }}" target="_blank">[details]</a>{% endif %}
{% if campaign.account_link_url %}<a href="{{ campaign.account_link_url }}" target="_blank">[connect]</a>{% endif %}
</div>
</div>
</div>
<h5>Campaign Info</h5>
{% if rewards %}
<table style="border-collapse: collapse; width: 100%;">
<thead>
<tr>
<th style="text-align: left; padding: 4px;">Image</th>
<th style="text-align: left; padding: 4px;">Reward</th>
<th style="text-align: left; padding: 4px;">Type</th>
<th style="text-align: left; padding: 4px;">Condition</th>
<th style="text-align: left; padding: 4px;">iOS Reward</th>
<th style="text-align: left; padding: 4px;">Codes Left</th>
</tr>
</thead>
<tbody>
{% for reward in rewards %}
<tr>
<td style="vertical-align: top; padding: 4px;">
{% if reward.image_url %}
<img src="{{ reward.image_url }}"
alt="{{ reward.title }}"
width="160"
height="auto"
style="object-fit: cover;
margin-right: 3px" />
{% endif %}
</td>
<td style="vertical-align: top; padding: 4px;">
{{ reward.title }}
{% if reward.condition_for_minutes %}
<div style="font-size: 0.9em; color: #a9a9a9;">{{ reward.condition_for_minutes }} minutes watched</div>
{% endif %}
</td>
<td style="vertical-align: top; padding: 4px;">{{ reward.reward_type }}</td>
<td style="vertical-align: top; padding: 4px;">{{ reward.condition_type }}</td>
<td style="vertical-align: top; padding: 4px;">{{ reward.ios_based_reward|yesno:"Yes,No" }}</td>
{% if reward.reward_type == "CODE_FIRST_COME" %}
<td style="vertical-align: top; padding: 4px;">{{ reward.code_remaining_count }}</td>
{% else %}
<td style="vertical-align: top; padding: 4px;">Unlimited</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No rewards available for this campaign.</p>
{% endif %}
{% endblock content %} {% endblock content %}