Add pagination functionality to campaign list view
This commit is contained in:
parent
c25236bc07
commit
0fbb38b2d7
3 changed files with 321 additions and 7 deletions
|
|
@ -18,7 +18,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body py-3">
|
<div class="card-body py-3">
|
||||||
<form method="get" action="{% url 'twitch:campaign_list' %}" class="row g-3">
|
<form method="get" action="{% url 'twitch:campaign_list' %}" class="row g-3">
|
||||||
<div class="col-md-5">
|
<div class="col-md-3">
|
||||||
<label for="game" class="form-label">Game</label>
|
<label for="game" class="form-label">Game</label>
|
||||||
<select class="form-select" id="game" name="game">
|
<select class="form-select" id="game" name="game">
|
||||||
<option value="">All Games</option>
|
<option value="">All Games</option>
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5">
|
<div class="col-md-3">
|
||||||
<label for="status" class="form-label">Status</label>
|
<label for="status" class="form-label">Status</label>
|
||||||
<select class="form-select" id="status" name="status">
|
<select class="form-select" id="status" name="status">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
|
|
@ -40,7 +40,17 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 d-flex align-items-end">
|
<div class="col-md-2">
|
||||||
|
<label for="per_page" class="form-label">Per Page</label>
|
||||||
|
<select class="form-select" id="per_page" name="per_page">
|
||||||
|
{% for option in per_page_options %}
|
||||||
|
<option value="{{ option }}" {% if selected_per_page == option %}selected{% endif %}>
|
||||||
|
{{ option }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
<i class="fas fa-search me-2"></i>Apply Filters
|
<i class="fas fa-search me-2"></i>Apply Filters
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -136,6 +146,105 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<nav aria-label="Campaign pagination">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 24 %}per_page={{ selected_per_page }}&{% endif %}page=1"
|
||||||
|
aria-label="First">
|
||||||
|
<span aria-hidden="true">««</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 24 %}per_page={{ selected_per_page }}&{% endif %}page={{ page_obj.previous_page_number }}"
|
||||||
|
aria-label="Previous">
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link" aria-label="First">
|
||||||
|
<span aria-hidden="true">««</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link" aria-label="Previous">
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in page_obj.paginator.page_range %}
|
||||||
|
{% if page_obj.number == num %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ num }}</span>
|
||||||
|
</li>
|
||||||
|
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 24 %}per_page={{ selected_per_page }}&{% endif %}page={{ num }}">{{ num }}</a>
|
||||||
|
</li>
|
||||||
|
{% elif num == 1 or num == page_obj.paginator.num_pages %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 24 %}per_page={{ selected_per_page }}&{% endif %}page={{ num }}">{{ num }}</a>
|
||||||
|
</li>
|
||||||
|
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">...</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 24 %}per_page={{ selected_per_page }}&{% endif %}page={{ page_obj.next_page_number }}"
|
||||||
|
aria-label="Next">
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 24 %}per_page={{ selected_per_page }}&{% endif %}page={{ page_obj.paginator.num_pages }}"
|
||||||
|
aria-label="Last">
|
||||||
|
<span aria-hidden="true">»»</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link" aria-label="Next">
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link" aria-label="Last">
|
||||||
|
<span aria-hidden="true">»»</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 text-center">
|
||||||
|
<small class="text-muted">
|
||||||
|
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 }})
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
|
|
@ -146,7 +255,13 @@
|
||||||
<div class="row text-center g-2">
|
<div class="row text-center g-2">
|
||||||
<div class="col-md-4 mb-2 mb-md-0">
|
<div class="col-md-4 mb-2 mb-md-0">
|
||||||
<div class="p-2 border rounded">
|
<div class="p-2 border rounded">
|
||||||
<h3 class="twitch-color mb-0">{{ campaigns|length }}</h3>
|
<h3 class="twitch-color mb-0">
|
||||||
|
{% if is_paginated %}
|
||||||
|
{{ page_obj.paginator.count }}
|
||||||
|
{% else %}
|
||||||
|
{{ campaigns|length }}
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
<p class="mb-0 small text-muted">Total Campaigns</p>
|
<p class="mb-0 small text-muted">Total Campaigns</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
168
twitch/tests/test_pagination.py
Normal file
168
twitch/tests/test_pagination.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from django.db.models import Count, Prefetch, Q
|
from django.db.models import Count, Prefetch, Q
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
@ -20,6 +21,24 @@ class DropCampaignListView(ListView):
|
||||||
model = DropCampaign
|
model = DropCampaign
|
||||||
template_name = "twitch/campaign_list.html"
|
template_name = "twitch/campaign_list.html"
|
||||||
context_object_name = "campaigns"
|
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]:
|
def get_queryset(self) -> QuerySet[DropCampaign]:
|
||||||
"""Get queryset of drop campaigns.
|
"""Get queryset of drop campaigns.
|
||||||
|
|
@ -27,9 +46,9 @@ class DropCampaignListView(ListView):
|
||||||
Returns:
|
Returns:
|
||||||
QuerySet: Filtered drop campaigns.
|
QuerySet: Filtered drop campaigns.
|
||||||
"""
|
"""
|
||||||
queryset = super().get_queryset()
|
queryset: QuerySet[DropCampaign] = super().get_queryset()
|
||||||
status_filter = self.request.GET.get("status")
|
status_filter: str | None = self.request.GET.get("status")
|
||||||
game_filter = self.request.GET.get("game")
|
game_filter: str | None = self.request.GET.get("game")
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if status_filter:
|
if status_filter:
|
||||||
|
|
@ -62,6 +81,18 @@ class DropCampaignListView(ListView):
|
||||||
context["selected_status"] = self.request.GET.get("status", "")
|
context["selected_status"] = self.request.GET.get("status", "")
|
||||||
context["selected_game"] = self.request.GET.get("game", "")
|
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
|
# Current time for active campaign highlighting
|
||||||
context["now"] = timezone.now()
|
context["now"] = timezone.now()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue