Skip to content

Commit ee7e733

Browse files
committed
Feat: Pagination improved in the UI
Signed-off-by: Rishi Garg <rishigarg2503@gmail.com>
1 parent fabe035 commit ee7e733

File tree

4 files changed

+275
-10
lines changed

4 files changed

+275
-10
lines changed

vulnerabilities/pagination.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,143 @@
1+
import logging
2+
import re
3+
4+
from django.core.paginator import Paginator
5+
from django.db.models.query import QuerySet
16
from rest_framework.pagination import PageNumberPagination
27

8+
logger = logging.getLogger(__name__)
9+
310

411
class SmallResultSetPagination(PageNumberPagination):
512
page_size_query_param = "page_size"
613
max_page_size = 100
14+
15+
16+
class PaginatedListViewMixin:
17+
"""
18+
A mixin that adds pagination functionality to ListView-based views.
19+
"""
20+
21+
paginate_by = 20
22+
max_page_size = 100
23+
PAGE_SIZE_CHOICES = [
24+
{"value": 20, "label": "20 per page"},
25+
{"value": 50, "label": "50 per page"},
26+
{"value": 100, "label": "100 per page"},
27+
]
28+
29+
def get_queryset(self):
30+
"""
31+
Ensure a queryset is always available
32+
"""
33+
try:
34+
queryset = super().get_queryset()
35+
if not queryset:
36+
queryset = self.model.objects.all()
37+
if not isinstance(queryset, QuerySet):
38+
queryset = self.model.objects.all()
39+
return queryset
40+
except Exception as e:
41+
logger.error(f"Error in get_queryset: {e}")
42+
return self.model.objects.all()
43+
44+
def sanitize_page_size(self, raw_page_size):
45+
"""
46+
Sanitize page size input to prevent XSS and injection attempts.
47+
"""
48+
if not raw_page_size:
49+
return self.paginate_by
50+
clean_page_size = re.sub(r"\D", "", str(raw_page_size))
51+
try:
52+
page_size = int(clean_page_size) if clean_page_size else self.paginate_by
53+
valid_sizes = {choice["value"] for choice in self.PAGE_SIZE_CHOICES}
54+
if page_size not in valid_sizes:
55+
logger.warning(f"Attempted to use unauthorized page size: {page_size}")
56+
return self.paginate_by
57+
return page_size
58+
except (ValueError, TypeError):
59+
logger.info("Empty or invalid page_size input attempted")
60+
return self.paginate_by
61+
62+
def get_paginate_by(self, queryset=None):
63+
"""
64+
Get the number of items to paginate by from the request.
65+
"""
66+
raw_page_size = self.request.GET.get("page_size")
67+
return self.sanitize_page_size(raw_page_size)
68+
69+
def get_page_range(self, paginator, page_obj):
70+
"""
71+
Generate a list of page numbers for navigation
72+
"""
73+
num_pages = paginator.num_pages
74+
current_page = page_obj.number
75+
if num_pages <= 7:
76+
return list(range(1, num_pages + 1))
77+
pages = []
78+
pages.append(1)
79+
if current_page > 4:
80+
pages.append("...")
81+
start = max(2, current_page - 2)
82+
end = min(num_pages - 1, current_page + 2)
83+
pages.extend(range(start, end + 1))
84+
if current_page < num_pages - 3:
85+
pages.append("...")
86+
if num_pages > 1:
87+
pages.append(num_pages)
88+
return [str(p) for p in pages]
89+
90+
def paginate_queryset(self, queryset, page_size):
91+
try:
92+
if not queryset or queryset.count() == 0:
93+
queryset = self.model.objects.all()
94+
paginator = Paginator(queryset, page_size)
95+
page_params = self.request.GET.getlist("page")
96+
page_number = page_params[-1] if page_params else "1"
97+
try:
98+
page_number = int(re.sub(r"\D", "", str(page_number)))
99+
if not page_number:
100+
page_number = 1
101+
except (ValueError, TypeError):
102+
page_number = 1
103+
page_number = max(1, min(page_number, paginator.num_pages))
104+
page = paginator.page(page_number)
105+
return (paginator, page, page.object_list, page.has_other_pages())
106+
except Exception as e:
107+
logger.error(f"Pagination error: {e}")
108+
queryset = self.model.objects.all()
109+
paginator = Paginator(queryset, page_size)
110+
page = paginator.page(1)
111+
return (paginator, page, page.object_list, page.has_other_pages())
112+
113+
def get_context_data(self, **kwargs):
114+
"""
115+
Return a mapping of pagination-related context data, preserving filters.
116+
"""
117+
queryset = self.get_queryset()
118+
page_size = self.get_paginate_by()
119+
paginator, page, object_list, is_paginated = self.paginate_queryset(queryset, page_size)
120+
page_range = self.get_page_range(paginator, page)
121+
122+
search = self.request.GET.get("search", "")
123+
124+
context = super().get_context_data(
125+
object_list=object_list,
126+
page_obj=page,
127+
paginator=paginator,
128+
is_paginated=is_paginated,
129+
**kwargs,
130+
)
131+
132+
context.update(
133+
{
134+
"current_page_size": page_size,
135+
"page_size_choices": self.PAGE_SIZE_CHOICES,
136+
"total_count": paginator.count,
137+
"page_range": page_range,
138+
"search": search,
139+
"previous_page_url": page.previous_page_number() if page.has_previous() else None,
140+
"next_page_url": page.next_page_number() if page.has_next() else None,
141+
}
142+
)
143+
return context
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from django.test import TestCase
2+
from django.urls import reverse
3+
4+
from vulnerabilities.models import Package
5+
6+
7+
class PaginationFunctionalityTests(TestCase):
8+
@classmethod
9+
def setUpTestData(cls):
10+
for i in range(150):
11+
Package.objects.create(
12+
type="test",
13+
namespace="test",
14+
name=f"package{i}",
15+
version=str(i),
16+
qualifiers={},
17+
subpath="",
18+
)
19+
20+
def test_default_pagination(self):
21+
response = self.client.get(reverse("package_search"))
22+
self.assertEqual(response.status_code, 200)
23+
page_obj = response.context["page_obj"]
24+
self.assertIsNotNone(page_obj)
25+
self.assertEqual(len(page_obj.object_list), 20)
26+
self.assertEqual(response.context["total_count"], 150)
27+
self.assertEqual(response.context["current_page_size"], 20)
28+
29+
def test_page_size_variations(self):
30+
valid_page_sizes = [20, 50, 100]
31+
for size in valid_page_sizes:
32+
url = f"{reverse('package_search')}?page_size={size}"
33+
response = self.client.get(url)
34+
self.assertEqual(response.status_code, 200)
35+
self.assertIn(response.context["current_page_size"], [20, size])
36+
37+
def test_page_navigation(self):
38+
response = self.client.get(reverse("package_search"))
39+
first_page = response.context["page_obj"]
40+
self.assertEqual(first_page.number, 1)
41+
self.assertTrue(first_page.has_next())
42+
self.assertFalse(first_page.has_previous())
43+
self.assertGreater(first_page.paginator.num_pages, 1)
44+
45+
46+
class PaginationSecurityTests(TestCase):
47+
@classmethod
48+
def setUpTestData(cls):
49+
for i in range(50):
50+
Package.objects.create(
51+
type="test",
52+
namespace="test",
53+
name=f"package{i}",
54+
version=str(i),
55+
qualifiers={},
56+
subpath="",
57+
)
58+
59+
def test_invalid_page_size_inputs(self):
60+
malicious_inputs = [
61+
"abc",
62+
"-10",
63+
"0",
64+
"9999999999",
65+
"11",
66+
"<script>",
67+
"../../etc/passwd",
68+
"' OR 1=1 --",
69+
"",
70+
]
71+
for input_value in malicious_inputs:
72+
url = f"{reverse('package_search')}?page_size={input_value}"
73+
response = self.client.get(url)
74+
self.assertEqual(response.status_code, 200)
75+
self.assertEqual(response.context["current_page_size"], 20)
76+
77+
def test_sql_injection_prevention(self):
78+
sql_injection_payloads = [
79+
"1' OR '1'='1",
80+
"1; DROP TABLE packages;",
81+
"' UNION SELECT * FROM auth_user--",
82+
"1 OR 1=1",
83+
]
84+
initial_package_count = Package.objects.count()
85+
for payload in sql_injection_payloads:
86+
urls = [
87+
f"{reverse('package_search')}?page={payload}",
88+
f"{reverse('package_search')}?page_size={payload}",
89+
]
90+
for url in urls:
91+
response = self.client.get(url)
92+
self.assertEqual(response.status_code, 200)
93+
self.assertEqual(Package.objects.count(), initial_package_count)
94+
95+
96+
class PaginationEdgeCaseTests(TestCase):
97+
@classmethod
98+
def setUpTestData(cls):
99+
for i in range(5):
100+
Package.objects.create(
101+
type="test",
102+
namespace="test",
103+
name=f"package{i}",
104+
version=str(i),
105+
)
106+
107+
def test_small_dataset_pagination(self):
108+
response = self.client.get(reverse("package_search"))
109+
self.assertEqual(response.status_code, 200)
110+
self.assertLessEqual(len(response.context["page_obj"].object_list), 20)
111+
112+
def test_out_of_range_page_number(self):
113+
out_of_range_urls = [
114+
f"{reverse('package_search')}?page=9999",
115+
f"{reverse('package_search')}?page=-5",
116+
f"{reverse('package_search')}?page=abc",
117+
]
118+
for url in out_of_range_urls:
119+
response = self.client.get(url)
120+
self.assertEqual(response.status_code, 200)
121+
self.assertEqual(response.context["page_obj"].number, 1)

vulnerabilities/tests/test_view.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from vulnerabilities.utils import get_purl_version_class
2727
from vulnerabilities.views import PackageDetails
2828
from vulnerabilities.views import PackageSearch
29+
from vulnerabilities.views import get_purl_version_class
30+
from vulnerabilities.views import purl_sort_key
2931

3032
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
3133
TEST_DIR = os.path.join(BASE_DIR, "test_data/package_sort")
@@ -58,14 +60,17 @@ def setUp(self):
5860
Package.objects.create(**attrs)
5961

6062
def test_packages_search_view_paginator(self):
61-
response = self.client.get("/packages/search?type=deb&name=&page=1")
62-
self.assertEqual(response.status_code, 200)
63-
response = self.client.get("/packages/search?type=deb&name=&page=*")
64-
self.assertEqual(response.status_code, 404)
65-
response = self.client.get("/packages/search?type=deb&name=&page=")
66-
self.assertEqual(response.status_code, 200)
67-
response = self.client.get("/packages/search?type=&name=&page=")
68-
self.assertEqual(response.status_code, 200)
63+
test_cases = [
64+
("/packages/search?type=deb&name=&page=1", 200),
65+
("/packages/search?type=deb&name=&page=*", 200),
66+
("/packages/search?type=deb&name=&page=", 200),
67+
("/packages/search?type=&name=&page=", 200),
68+
]
69+
for url, expected_status in test_cases:
70+
response = self.client.get(url)
71+
self.assertEqual(response.status_code, expected_status)
72+
if "*" in url or "&page=" in url:
73+
self.assertEqual(response.context["page_obj"].number, 1)
6974

7075
def test_package_view(self):
7176
qs = PackageSearch().get_queryset(query="pkg:nginx/nginx@1.0.15?foo=bar")

vulnerabilities/views.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@
3535
from vulnerablecode import __version__ as VULNERABLECODE_VERSION
3636
from vulnerablecode.settings import env
3737

38+
from .pagination import PaginatedListViewMixin
39+
3840
PAGE_SIZE = 20
3941

4042

41-
class PackageSearch(ListView):
43+
class PackageSearch(PaginatedListViewMixin, ListView):
4244
model = models.Package
4345
template_name = "packages.html"
4446
ordering = ["type", "namespace", "name", "version"]
@@ -66,7 +68,7 @@ def get_queryset(self, query=None):
6668
)
6769

6870

69-
class VulnerabilitySearch(ListView):
71+
class VulnerabilitySearch(PaginatedListViewMixin, ListView):
7072
model = models.Vulnerability
7173
template_name = "vulnerabilities.html"
7274
ordering = ["vulnerability_id"]

0 commit comments

Comments
 (0)