Add game list and detail views with templates for Twitch Drops Tracker

This commit is contained in:
Joakim Hellsén 2025-07-10 03:09:27 +02:00
commit c995c82dcb
8 changed files with 343 additions and 12 deletions

View file

@ -77,6 +77,12 @@
<i class="fas fa-gift me-1"></i> Campaigns
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/games/' in request.path %}active{% endif %}"
href="{% url 'twitch:game_list' %}">
<i class="fas fa-gamepad me-1"></i> Games
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">
<i class="fas fa-cog me-1"></i> Admin

View file

@ -19,7 +19,9 @@
<div class="col-md-8">
<h1 class="mb-3">{{ campaign.name }}</h1>
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="badge bg-primary">{{ campaign.game.display_name }}</span>
<a href="{% url 'twitch:game_detail' campaign.game.id %}" class="badge bg-primary text-decoration-none">
<i class="fas fa-gamepad me-1"></i>{{ campaign.game.display_name }}
</a>
{% if campaign.start_at <= now and campaign.end_at >= now %}
{% if campaign.status == 'ACTIVE' %}
<span class="badge bg-success">Active</span>

View file

@ -27,7 +27,12 @@
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ campaign.name }}</h5>
<h6 class="card-subtitle mb-2 text-muted">{{ campaign.game.display_name }}</h6>
<h6 class="card-subtitle mb-2 text-muted">
<a href="{% url 'twitch:game_detail' campaign.game.id %}"
class="text-decoration-none">
<i class="fas fa-gamepad me-1"></i>{{ campaign.game.display_name }}
</a>
</h6>
<p class="card-text small">{{ campaign.description|truncatewords:20 }}</p>
</div>
<div class="card-footer bg-transparent">

View file

@ -0,0 +1,169 @@
{% extends "base.html" %}
{% block title %}{{ game.display_name }} - Twitch Drops Tracker{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'twitch:game_list' %}">Games</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ game.display_name }}</li>
</ol>
</nav>
<h1 class="mb-4">
<i class="fas fa-gamepad me-2 twitch-color"></i>{{ game.display_name }}
</h1>
<p class="lead">View all drop campaigns for {{ game.display_name }}.</p>
</div>
</div>
{% if active_campaigns %}
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="fas fa-circle-play me-2"></i>Active Campaigns</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Campaign</th>
<th>Organization</th>
<th>Time Remaining</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for campaign in active_campaigns %}
<tr>
<td>{{ campaign.name }}</td>
<td>{{ campaign.owner.name }}</td>
<td>
<span class="badge bg-success" data-bs-toggle="tooltip"
title="Ends on {{ campaign.end_at|date:'M d, Y H:i' }}">
Ends in {{ campaign.end_at|timeuntil }}
</span>
</td>
<td>
<a href="{% url 'twitch:campaign_detail' campaign.id %}"
class="btn btn-sm btn-primary">
<i class="fas fa-eye me-1"></i> View Details
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if upcoming_campaigns %}
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="fas fa-calendar-alt me-2"></i>Upcoming Campaigns</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Campaign</th>
<th>Organization</th>
<th>Starts In</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for campaign in upcoming_campaigns %}
<tr>
<td>{{ campaign.name }}</td>
<td>{{ campaign.owner.name }}</td>
<td>
<span class="badge bg-info text-dark" data-bs-toggle="tooltip"
title="Starts on {{ campaign.start_at|date:'M d, Y H:i' }}">
Starts in {{ campaign.start_at|timeuntil }}
</span>
</td>
<td>
<a href="{% url 'twitch:campaign_detail' campaign.id %}"
class="btn btn-sm btn-primary">
<i class="fas fa-eye me-1"></i> View Details
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if expired_campaigns %}
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0"><i class="fas fa-history me-2"></i>Past Campaigns</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Campaign</th>
<th>Organization</th>
<th>Ended</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for campaign in expired_campaigns %}
<tr>
<td>{{ campaign.name }}</td>
<td>{{ campaign.owner.name }}</td>
<td>
<span class="badge bg-secondary" data-bs-toggle="tooltip"
title="Ended on {{ campaign.end_at|date:'M d, Y H:i' }}">
{{ campaign.end_at|timesince }} ago
</span>
</td>
<td>
<a href="{% url 'twitch:campaign_detail' campaign.id %}"
class="btn btn-sm btn-primary">
<i class="fas fa-eye me-1"></i> View Details
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
<div class="row">
<div class="col-12">
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i> No campaigns found for this game.
</div>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Games - Twitch Drops Tracker{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1 class="mb-4"><i class="fas fa-gamepad me-2 twitch-color"></i>Games</h1>
<p class="lead">Browse all games with Twitch drop campaigns.</p>
</div>
</div>
<div class="row row-cols-1 row-cols-md-3 g-4">
{% for item in games_with_counts %}
<div class="col">
<div class="card h-100 border-0 shadow-sm hover-effect">
<div class="card-body">
<h5 class="card-title">
<a href="{% url 'twitch:game_detail' item.game.id %}" class="text-decoration-none">
{{ item.game.display_name }}
</a>
</h5>
<div class="d-flex justify-content-between mt-3">
<span class="badge bg-secondary">
<i class="fas fa-gift me-1"></i> {{ item.campaign_count }} Campaigns
</span>
{% if item.active_count > 0 %}
<span class="badge bg-success">
<i class="fas fa-circle-play me-1"></i> {{ item.active_count }} Active
</span>
{% endif %}
</div>
</div>
<div class="card-footer bg-transparent border-0">
<a href="{% url 'twitch:game_detail' item.game.id %}" class="btn btn-sm btn-primary">
<i class="fas fa-eye me-1"></i> View Campaigns
</a>
</div>
</div>
</div>
{% empty %}
<div class="col-12">
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i> No games found.
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -8,7 +8,7 @@ from pathlib import Path
from typing import Any
from django.core.management.base import BaseCommand, CommandError, CommandParser
from django.db import transaction, OperationalError
from django.db import OperationalError, transaction
from twitch.models import DropBenefit, DropBenefitEdge, DropCampaign, Game, Organization, TimeBasedDrop
@ -60,7 +60,7 @@ class Command(BaseCommand):
help="Delay in seconds between retries for database operations (default: 0.5)",
)
def handle(self, **options) -> None:
def handle(self, **options) -> None: # noqa: ANN003
"""Execute the command.
Args:
@ -235,14 +235,14 @@ class Command(BaseCommand):
Args:
campaign_data: The drop campaign data to import.
Raises:
OperationalError: If the database is still locked after max retries.
"""
# Retry logic for database operations
max_retries = getattr(self, "max_retries", 5) # Default to 5 if not set
retry_delay = getattr(self, "retry_delay", 0.5) # Default to 0.5 seconds if not set
for attempt in range(max_retries):
try:
with transaction.atomic():
@ -326,11 +326,9 @@ class Command(BaseCommand):
# Check if this is a database lock error
if "database is locked" in str(e).lower():
if attempt < max_retries - 1: # Don't sleep on the last attempt
sleep_time = retry_delay * (2 ** attempt) # Exponential backoff
sleep_time = retry_delay * (2**attempt) # Exponential backoff
self.stdout.write(
self.style.WARNING(
f"Database locked, retrying in {sleep_time:.2f}s (attempt {attempt + 1}/{max_retries})"
)
self.style.WARNING(f"Database locked, retrying in {sleep_time:.2f}s (attempt {attempt + 1}/{max_retries})")
)
time.sleep(sleep_time)
else:

View file

@ -10,4 +10,6 @@ urlpatterns = [
path("", views.dashboard, name="dashboard"),
path("campaigns/", views.DropCampaignListView.as_view(), name="campaign_list"),
path("campaigns/<str:pk>/", views.DropCampaignDetailView.as_view(), name="campaign_detail"),
path("games/", views.GameListView.as_view(), name="game_list"),
path("games/<str:pk>/", views.GameDetailView.as_view(), name="game_detail"),
]

View file

@ -40,7 +40,7 @@ class DropCampaignListView(ListView):
return queryset.select_related("game", "owner").order_by("-start_at")
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
def get_context_data(self, **kwargs) -> dict[str, Any]:
"""Add additional context data.
Args:
@ -74,7 +74,7 @@ class DropCampaignDetailView(DetailView):
template_name = "twitch/campaign_detail.html"
context_object_name = "campaign"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
def get_context_data(self, **kwargs) -> dict[str, Any]:
"""Add additional context data.
Args:
@ -99,6 +99,106 @@ class DropCampaignDetailView(DetailView):
return context
class GameListView(ListView):
"""List view for games."""
model = Game
template_name = "twitch/game_list.html"
context_object_name = "games"
def get_queryset(self) -> QuerySet[Game]:
"""Get queryset of games.
Returns:
QuerySet: Sorted games.
"""
return super().get_queryset().order_by("display_name")
def get_context_data(self, **kwargs) -> dict[str, Any]:
"""Add additional context data.
Args:
**kwargs: Additional arguments.
Returns:
dict: Context data.
"""
context = super().get_context_data(**kwargs)
# Get campaign count for each game
games_with_counts = []
for game in context["games"]:
campaign_count = DropCampaign.objects.filter(game=game).count()
active_count = DropCampaign.objects.filter(
game=game,
start_at__lte=timezone.now(),
end_at__gte=timezone.now(),
status="ACTIVE",
).count()
games_with_counts.append({
"game": game,
"campaign_count": campaign_count,
"active_count": active_count,
})
context["games_with_counts"] = games_with_counts
return context
class GameDetailView(DetailView):
"""Detail view for a game."""
model = Game
template_name = "twitch/game_detail.html"
context_object_name = "game"
def get_context_data(self, **kwargs) -> dict[str, Any]:
"""Add additional context data.
Args:
**kwargs: Additional arguments.
Returns:
dict: Context data.
"""
context = super().get_context_data(**kwargs)
game = self.get_object()
# Get all campaigns for this game
now = timezone.now()
# Active campaigns
active_campaigns = (
DropCampaign.objects.filter(
game=game,
start_at__lte=now,
end_at__gte=now,
status="ACTIVE",
)
.select_related("owner")
.order_by("end_at")
)
# Upcoming campaigns
upcoming_campaigns = (
DropCampaign.objects.filter(game=game, start_at__gt=now, status="UPCOMING").select_related("owner").order_by("start_at")
)
# Expired campaigns
expired_campaigns = (
DropCampaign.objects.filter(game=game, end_at__lt=now).select_related("owner").order_by("-end_at")[:10]
) # Limit to 10 most recent
context.update({
"active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_campaigns,
"now": now,
})
return context
def dashboard(request: HttpRequest) -> HttpResponse:
"""Dashboard view showing active campaigns and progress.