Add channel list and detail views

This commit is contained in:
Joakim Hellsén 2025-09-08 22:41:48 +02:00
commit fc52a94284
5 changed files with 365 additions and 1 deletions

View file

@ -77,6 +77,7 @@
<a href="{% url 'twitch:campaign_list' %}">Campaigns</a> |
<a href="{% url 'twitch:game_list' %}">Games</a> |
<a href="{% url 'twitch:org_list' %}">Organizations</a> |
<a href="{% url 'twitch:channel_list' %}">Channels</a> |
<a href="{% url 'twitch:docs_rss' %}">RSS</a> |
{% if user.is_authenticated %}
<a href="{% url 'twitch:debug' %}">Debug</a> |

View file

@ -0,0 +1,160 @@
{% extends "base.html" %}
{% block title %}
{{ channel.display_name }} - Channel Details
{% endblock title %}
{% block content %}
<!-- Channel Title -->
<h1 id="channel-name">{{ channel.display_name }}</h1>
{% if channel.display_name != channel.name %}
<p id="channel-username">
Username: <code>{{ channel.name }}</code>
</p>
{% endif %}
<!-- Channel Info -->
<p>
<strong>Channel ID:</strong> {{ channel.id }}
</p>
<p>
<strong>Added to database:</strong>
<time datetime="{{ channel.added_at|date:'c' }}"
title="{{ channel.added_at|date:'DATETIME_FORMAT' }}">
{{ channel.added_at|timesince }} ago ({{ channel.added_at|date:'M d, Y H:i' }})
</time>
</p>
{% if active_campaigns %}
<h5 id="active-campaigns-header">Active Campaigns</h5>
<table id="active-campaigns-table">
<tbody>
{% for campaign in active_campaigns %}
<tr id="campaign-row-{{ campaign.id }}">
<td>
<a href="{% url 'twitch:campaign_detail' campaign.id %}">{{ campaign.clean_name }}</a>
{% if campaign.time_based_drops.all %}
<div class="campaign-benefits">
{% for benefit in campaign.sorted_benefits %}
<span class="benefit-item" title="{{ benefit.name }}">
{% if benefit.image_asset_url %}
<img src="{{ benefit.image_asset_url }}"
alt="{{ benefit.name }}"
width="24"
height="24"
style="display: inline-block;
margin-right: 4px;
vertical-align: middle">
{% endif %}
{{ benefit.name }}
</span>
{% endfor %}
</div>
{% endif %}
</td>
<td>
{% if campaign.game %}
<a href="{% url 'twitch:game_detail' campaign.game.id %}">
{{ campaign.game.display_name|default:campaign.game.name }}
</a>
{% else %}
Unknown Game
{% endif %}
</td>
<td>
<span title="Ends on {{ campaign.end_at|date:'M d, Y H:i' }}">Ends in {{ campaign.end_at|timeuntil }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if upcoming_campaigns %}
<h5 id="upcoming-campaigns-header">Upcoming Campaigns</h5>
<table id="upcoming-campaigns-table">
<tbody>
{% for campaign in upcoming_campaigns %}
<tr id="campaign-row-{{ campaign.id }}">
<td>
<a href="{% url 'twitch:campaign_detail' campaign.id %}">{{ campaign.clean_name }}</a>
{% if campaign.time_based_drops.all %}
<div class="campaign-benefits">
{% for benefit in campaign.sorted_benefits %}
<span class="benefit-item" title="{{ benefit.name }}">
{% if benefit.image_asset_url %}
<img src="{{ benefit.image_asset_url }}"
alt="{{ benefit.name }}"
width="24"
height="24"
style="display: inline-block;
margin-right: 4px;
vertical-align: middle">
{% endif %}
{{ benefit.name }}
</span>
{% endfor %}
</div>
{% endif %}
</td>
<td>
{% if campaign.game %}
<a href="{% url 'twitch:game_detail' campaign.game.id %}">
{{ campaign.game.display_name|default:campaign.game.name }}
</a>
{% else %}
Unknown Game
{% endif %}
</td>
<td>
<span title="Starts on {{ campaign.start_at|date:'M d, Y H:i' }}">Starts in {{ campaign.start_at|timeuntil }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if expired_campaigns %}
<h5 id="expired-campaigns-header">Past Campaigns</h5>
<table id="expired-campaigns-table">
<tbody>
{% for campaign in expired_campaigns %}
<tr id="campaign-row-{{ campaign.id }}">
<td>
<a href="{% url 'twitch:campaign_detail' campaign.id %}">{{ campaign.clean_name }}</a>
{% if campaign.time_based_drops.all %}
<div class="campaign-benefits">
{% for benefit in campaign.sorted_benefits %}
<span class="benefit-item" title="{{ benefit.name }}">
{% if benefit.image_asset_url %}
<img src="{{ benefit.image_asset_url }}"
alt="{{ benefit.name }}"
width="24"
height="24"
style="display: inline-block;
margin-right: 4px;
vertical-align: middle">
{% endif %}
{{ benefit.name }}
</span>
{% endfor %}
</div>
{% endif %}
</td>
<td>
{% if campaign.game %}
<a href="{% url 'twitch:game_detail' campaign.game.id %}">
{{ campaign.game.display_name|default:campaign.game.name }}
</a>
{% else %}
Unknown Game
{% endif %}
</td>
<td>
<span title="Ended on {{ campaign.end_at|date:'M d, Y H:i' }}">{{ campaign.end_at|timesince }} ago</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
<p id="no-campaigns-message">No campaigns found for this channel.</p>
{% endif %}
{{ channel_data|safe }}
{% endblock content %}

View file

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
Channels - Twitch Drops Tracker
{% endblock title %}
{% block content %}
<h1 id="page-title">Channels</h1>
<p>Browse all channels that can participate in drop campaigns</p>
<form id="search-form"
method="get"
action="{% url 'twitch:channel_list' %}">
<label for="search">Search:</label>
<input type="text"
id="search"
name="search"
value="{{ search_query }}"
placeholder="Search channels...">
<button id="search-button" type="submit">Search</button>
{% if search_query %}
<a href="{% url 'twitch:channel_list' %}">Clear</a>
{% endif %}
</form>
{% if channels %}
<table>
<thead>
<tr>
<th>Channel</th>
<th>Username</th>
<th>Campaigns</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{% for channel in channels %}
<tr id="channel-row-{{ channel.id }}">
<td>
<a id="channel-link-{{ channel.id }}"
href="{% url 'twitch:channel_detail' channel.id %}">{{ channel.display_name }}</a>
</td>
<td>{{ channel.name }}</td>
<td>{{ channel.campaign_count }}</td>
<td>
<time datetime="{{ channel.added_at|date:'c' }}"
title="{{ channel.added_at|date:'DATETIME_FORMAT' }}">
{{ channel.added_at|timesince }} ago
</time>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
{% if is_paginated %}
<p>
{% if page_obj.has_previous %}
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page=1">««</a>
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page={{ page_obj.previous_page_number }}">«</a>
{% endif %}
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
{% if page_obj.has_next %}
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page={{ page_obj.next_page_number }}">»</a>
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page={{ page_obj.paginator.num_pages }}">»»</a>
{% endif %}
</p>
<p>Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ page_obj.paginator.count }} channels</p>
{% endif %}
{% else %}
{% if search_query %}
<p>No channels match your search query "{{ search_query }}".</p>
{% else %}
<p>No channels found.</p>
{% endif %}
{% endif %}
{% endblock content %}

View file

@ -24,6 +24,8 @@ urlpatterns = [
path("organizations/", views.OrgListView.as_view(), name="org_list"),
path("organizations/<str:pk>/", views.OrgDetailView.as_view(), name="organization_detail"),
path("organizations/<str:org_id>/subscribe/", views.subscribe_org_notifications, name="subscribe_org_notifications"),
path("channels/", views.ChannelListView.as_view(), name="channel_list"),
path("channels/<str:pk>/", views.ChannelDetailView.as_view(), name="channel_detail"),
path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
path("rss/games/", GameFeed(), name="game_feed"),
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),

View file

@ -22,7 +22,7 @@ from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers.data import JsonLexer
from twitch.models import DropBenefit, DropCampaign, Game, NotificationSubscription, Organization, TimeBasedDrop
from twitch.models import Channel, DropBenefit, DropCampaign, Game, NotificationSubscription, Organization, TimeBasedDrop
if TYPE_CHECKING:
from django.db.models import QuerySet
@ -769,3 +769,130 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
},
]
return render(request, "twitch/docs_rss.html", {"feeds": feeds})
class ChannelListView(ListView):
"""List view for channels."""
model = Channel
template_name = "twitch/channel_list.html"
context_object_name = "channels"
paginate_by = 200
def get_queryset(self) -> QuerySet[Channel]:
"""Get queryset of channels.
Returns:
QuerySet: Filtered channels.
"""
queryset: QuerySet[Channel] = super().get_queryset()
search_query: str | None = self.request.GET.get("search")
if search_query:
queryset = queryset.filter(Q(name__icontains=search_query) | Q(display_name__icontains=search_query))
return queryset.annotate(campaign_count=Count("allowed_campaigns", distinct=True)).order_by("-campaign_count", "name")
def get_context_data(self, **kwargs) -> dict[str, Any]:
"""Add additional context data.
Args:
**kwargs: Additional arguments.
Returns:
dict: Context data.
"""
context: dict[str, Any] = super().get_context_data(**kwargs)
context["search_query"] = self.request.GET.get("search", "")
return context
class ChannelDetailView(DetailView):
"""Detail view for a channel."""
model = Channel
template_name = "twitch/channel_detail.html"
context_object_name = "channel"
def get_context_data(self, **kwargs: object) -> dict[str, Any]:
"""Add additional context data.
Args:
**kwargs: Additional arguments.
Returns:
dict: Context data with active, upcoming, and expired campaigns for this channel.
"""
context: dict[str, Any] = super().get_context_data(**kwargs)
channel: Channel = self.get_object()
now: datetime.datetime = timezone.now()
all_campaigns: QuerySet[DropCampaign, DropCampaign] = (
DropCampaign.objects.filter(allow_channels=channel)
.select_related("game__owner")
.prefetch_related(
Prefetch(
"time_based_drops", queryset=TimeBasedDrop.objects.prefetch_related(Prefetch("benefits", queryset=DropBenefit.objects.order_by("name")))
)
)
.order_by("-start_at")
)
active_campaigns: list[DropCampaign] = [
campaign
for campaign in all_campaigns
if campaign.start_at is not None and campaign.start_at <= now and campaign.end_at is not None and campaign.end_at >= now
]
active_campaigns.sort(key=lambda c: c.end_at if c.end_at is not None else datetime.datetime.max.replace(tzinfo=datetime.UTC))
upcoming_campaigns: list[DropCampaign] = [campaign for campaign in all_campaigns if campaign.start_at is not None and campaign.start_at > now]
upcoming_campaigns.sort(key=lambda c: c.start_at if c.start_at is not None else datetime.datetime.max.replace(tzinfo=datetime.UTC))
expired_campaigns: list[DropCampaign] = [campaign for campaign in all_campaigns if campaign.end_at is not None and campaign.end_at < now]
# Add unique sorted benefits to each campaign object
for campaign in all_campaigns:
benefits_dict = {} # Use dict to track unique benefits by ID
for drop in campaign.time_based_drops.all(): # type: ignore[attr-defined]
for benefit in drop.benefits.all():
benefits_dict[benefit.id] = benefit
# Sort benefits by name and attach to campaign
campaign.sorted_benefits = sorted(benefits_dict.values(), key=lambda b: b.name) # type: ignore[attr-defined]
serialized_channel = serialize(
"json",
[channel],
fields=(
"name",
"display_name",
),
)
channel_data = json.loads(serialized_channel)
if all_campaigns.exists():
serialized_campaigns = serialize(
"json",
all_campaigns,
fields=(
"name",
"description",
"details_url",
"account_link_url",
"image_url",
"start_at",
"end_at",
"is_account_connected",
),
)
campaigns_data = json.loads(serialized_campaigns)
channel_data[0]["fields"]["campaigns"] = campaigns_data
context.update({
"active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_campaigns,
"now": now,
"channel_data": format_and_color_json(channel_data[0]),
})
return context