From 0fbb38b2d7a8b9d25308daa5f7553c4274b55471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Mon, 21 Jul 2025 19:26:44 +0200 Subject: [PATCH] Add pagination functionality to campaign list view --- templates/twitch/campaign_list.html | 123 +++++++++++++++++++- twitch/tests/test_pagination.py | 168 ++++++++++++++++++++++++++++ twitch/views.py | 37 +++++- 3 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 twitch/tests/test_pagination.py diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index c922f07..31f4d42 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -18,7 +18,7 @@
-
+
-
+
-
+
+ + +
+
@@ -136,6 +146,105 @@
+ + {% if is_paginated %} +
+
+ +
+
+ +
+
+ + Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ page_obj.paginator.count }} + campaigns + (Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}) + +
+
+ {% endif %} +
@@ -146,7 +255,13 @@
-

{{ campaigns|length }}

+

+ {% if is_paginated %} + {{ page_obj.paginator.count }} + {% else %} + {{ campaigns|length }} + {% endif %} +

Total Campaigns

diff --git a/twitch/tests/test_pagination.py b/twitch/tests/test_pagination.py new file mode 100644 index 0000000..0bacaed --- /dev/null +++ b/twitch/tests/test_pagination.py @@ -0,0 +1,168 @@ +"""Tests for pagination functionality in the campaign list view.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from django.urls import reverse +from django.utils import timezone + +from twitch.models import DropCampaign, Game, Organization + +if TYPE_CHECKING: + from django.test import Client + + +@pytest.mark.django_db +class TestCampaignListPagination: + """Test cases for campaign list pagination.""" + + def test_default_pagination(self, client: Client) -> None: + """Test that pagination works with default settings.""" + # Create test data - enough to require pagination + game = Game.objects.create(id="test-game", slug="test-game", display_name="Test Game") + org = Organization.objects.create(id="test-org", name="Test Org") + + # Create 30 campaigns to test pagination + now = timezone.now() + campaigns = [] + for i in range(30): + campaign = DropCampaign.objects.create( + id=f"campaign-{i}", + name=f"Campaign {i}", + game=game, + owner=org, + start_at=now, + end_at=now + timezone.timedelta(days=1), + status="ACTIVE", + ) + campaigns.append(campaign) + + # Test first page + response = client.get(reverse("twitch:campaign_list")) + assert response.status_code == 200 + assert "page_obj" in response.context + assert response.context["page_obj"].number == 1 + assert len(response.context["campaigns"]) == 24 # Default paginate_by + assert response.context["page_obj"].paginator.count == 30 + + # Test second page + response = client.get(reverse("twitch:campaign_list") + "?page=2") + assert response.status_code == 200 + assert response.context["page_obj"].number == 2 + assert len(response.context["campaigns"]) == 6 # Remaining campaigns + + def test_custom_per_page(self, client: Client) -> None: + """Test that custom per_page parameter works.""" + # Create test data + game = Game.objects.create(id="test-game", slug="test-game", display_name="Test Game") + org = Organization.objects.create(id="test-org", name="Test Org") + + now = timezone.now() + for i in range(25): + DropCampaign.objects.create( + id=f"campaign-{i}", + name=f"Campaign {i}", + game=game, + owner=org, + start_at=now, + end_at=now + timezone.timedelta(days=1), + status="ACTIVE", + ) + + # Test with per_page=12 + response = client.get(reverse("twitch:campaign_list") + "?per_page=12") + assert response.status_code == 200 + assert len(response.context["campaigns"]) == 12 + assert response.context["selected_per_page"] == 12 + + # Test with per_page=48 + response = client.get(reverse("twitch:campaign_list") + "?per_page=48") + assert response.status_code == 200 + assert len(response.context["campaigns"]) == 25 # All campaigns fit on one page + + def test_invalid_per_page_fallback(self, client: Client) -> None: + """Test that invalid per_page values fall back to default.""" + # Create test data + game = Game.objects.create(id="test-game", slug="test-game", display_name="Test Game") + org = Organization.objects.create(id="test-org", name="Test Org") + + now = timezone.now() + for i in range(30): + DropCampaign.objects.create( + id=f"campaign-{i}", + name=f"Campaign {i}", + game=game, + owner=org, + start_at=now, + end_at=now + timezone.timedelta(days=1), + status="ACTIVE", + ) + + # Test with invalid per_page value + response = client.get(reverse("twitch:campaign_list") + "?per_page=999") + assert response.status_code == 200 + assert len(response.context["campaigns"]) == 24 # Falls back to default + assert response.context["selected_per_page"] == 24 + + # Test with non-numeric per_page value + response = client.get(reverse("twitch:campaign_list") + "?per_page=invalid") + assert response.status_code == 200 + assert len(response.context["campaigns"]) == 24 # Falls back to default + + def test_pagination_with_filters(self, client: Client) -> None: + """Test that pagination works correctly with filters.""" + # Create test data with different statuses + game = Game.objects.create(id="test-game", slug="test-game", display_name="Test Game") + org = Organization.objects.create(id="test-org", name="Test Org") + + now = timezone.now() + # Create 20 active campaigns + for i in range(20): + DropCampaign.objects.create( + id=f"active-{i}", + name=f"Active Campaign {i}", + game=game, + owner=org, + start_at=now, + end_at=now + timezone.timedelta(days=1), + status="ACTIVE", + ) + + # Create 10 expired campaigns + for i in range(10): + DropCampaign.objects.create( + id=f"expired-{i}", + name=f"Expired Campaign {i}", + game=game, + owner=org, + start_at=now - timezone.timedelta(days=2), + end_at=now - timezone.timedelta(days=1), + status="EXPIRED", + ) + + # Test filtering by active status with pagination + response = client.get(reverse("twitch:campaign_list") + "?status=ACTIVE&per_page=12") + assert response.status_code == 200 + assert len(response.context["campaigns"]) == 12 + assert response.context["page_obj"].paginator.count == 20 # Only active campaigns + assert all(c.status == "ACTIVE" for c in response.context["campaigns"]) + + # Test second page of active campaigns + response = client.get(reverse("twitch:campaign_list") + "?status=ACTIVE&per_page=12&page=2") + assert response.status_code == 200 + assert len(response.context["campaigns"]) == 8 # Remaining active campaigns + assert response.context["page_obj"].number == 2 + + def test_context_variables(self, client: Client) -> None: + """Test that all necessary context variables are present.""" + response = client.get(reverse("twitch:campaign_list")) + assert response.status_code == 200 + + # Check for pagination-related context + context = response.context + assert "per_page_options" in context + assert "selected_per_page" in context + assert context["per_page_options"] == [12, 24, 48, 96] + assert context["selected_per_page"] == 24 # Default value diff --git a/twitch/views.py b/twitch/views.py index a625c5c..5f4e510 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any from django.db.models import Count, Prefetch, Q +from django.db.models.query import QuerySet from django.shortcuts import render from django.utils import timezone from django.views.generic import DetailView, ListView @@ -20,6 +21,24 @@ class DropCampaignListView(ListView): model = DropCampaign template_name = "twitch/campaign_list.html" context_object_name = "campaigns" + paginate_by = 24 # 24 campaigns per page (6x4 grid on larger screens) + + def get_paginate_by(self, queryset) -> int: + """Get the pagination size, allowing override via URL parameter. + + Args: + queryset: The queryset being paginated. + + Returns: + int: Number of items per page. + """ + per_page: str | None = self.request.GET.get("per_page") + if per_page and per_page.isdigit(): + per_page_int = int(per_page) + # Limit to reasonable values to prevent performance issues + if per_page_int in {12, 24, 48, 96}: + return per_page_int + return self.paginate_by def get_queryset(self) -> QuerySet[DropCampaign]: """Get queryset of drop campaigns. @@ -27,9 +46,9 @@ class DropCampaignListView(ListView): Returns: QuerySet: Filtered drop campaigns. """ - queryset = super().get_queryset() - status_filter = self.request.GET.get("status") - game_filter = self.request.GET.get("game") + queryset: QuerySet[DropCampaign] = super().get_queryset() + status_filter: str | None = self.request.GET.get("status") + game_filter: str | None = self.request.GET.get("game") # Apply filters if status_filter: @@ -62,6 +81,18 @@ class DropCampaignListView(ListView): context["selected_status"] = self.request.GET.get("status", "") context["selected_game"] = self.request.GET.get("game", "") + # Add per_page options and current selection + context["per_page_options"] = [12, 24, 48, 96] + per_page: str = self.request.GET.get("per_page", str(self.paginate_by)) + if per_page.isdigit(): + per_page_int = int(per_page) + if per_page_int in {12, 24, 48, 96}: + context["selected_per_page"] = per_page_int + else: + context["selected_per_page"] = self.paginate_by + else: + context["selected_per_page"] = self.paginate_by + # Current time for active campaign highlighting context["now"] = timezone.now()