-
{{ 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()