-
Notifications
You must be signed in to change notification settings - Fork 231
Improve pagination in web ui #1625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Hi @pombredanne @TG1999 @keshav-space @Hritik14 , Can you please review this PR It has passed all workflow tests and was also working fine on my local server. Thank you |
d8068aa
to
c28765a
Compare
8d177c8
to
6ca25ee
Compare
@tdruez please see the changes now workflow tests are also successful . |
@tdruez I would request you to please review the PR I have made changes In the code after you requested some changes after first review. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See the various comments.
Generally, this implementation seems too complex and not "reusable" enough at it requires to many moving parts.
vulnerabilities/forms.py
Outdated
@@ -3,7 +3,7 @@ | |||
# VulnerableCode is a trademark of nexB Inc. | |||
# SPDX-License-Identifier: Apache-2.0 | |||
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. | |||
# See https://github.com/aboutcode-org/vulnerablecode for support or download. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unwanted changes
vulnerabilities/forms.py
Outdated
@@ -12,26 +12,87 @@ | |||
|
|||
from vulnerabilities.models import ApiUser | |||
|
|||
from .models import * |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid * imports.
vulnerabilities/forms.py
Outdated
widget=forms.Select( | ||
attrs={ | ||
"class": "select is-small", | ||
"onchange": "handlePageSizeChange(this.value)", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid inline JavaScript.
vulnerabilities/forms.py
Outdated
def clean_search(self): | ||
return self.cleaned_data.get("search", "") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the purpose of this?
vulnerabilities/forms.py
Outdated
def get_queryset(self, query=None): | ||
""" | ||
Get queryset with search/filter/ordering applied. | ||
Args: | ||
query (str, optional): Direct query for testing | ||
""" | ||
if query is not None: | ||
return self._search(query) | ||
|
||
if not self.is_valid(): | ||
return self.model.objects.none() | ||
|
||
return self._search(self.clean_search()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the need for this logic?
vulnerabilities/forms.py
Outdated
def _search(self, query): | ||
"""Execute package-specific search logic.""" | ||
return ( | ||
self.model.objects.search(query) | ||
.with_vulnerability_counts() | ||
.prefetch_related() | ||
.order_by("package_url") | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not following the Django forms conventions.
vulnerabilities/forms.py
Outdated
def _search(self, query): | ||
"""Execute vulnerability-specific search logic.""" | ||
return self.model.objects.search(query=query).with_package_counts() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment as above.
<a href="?page={{ page_obj.previous_page_number }}&search={{ search|urlencode }}&page_size={{ page_obj.paginator.per_page }}" | ||
class="pagination-previous">Previous</a> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if other filters are active, those are lost when using the pagination?
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All the complexity of those templates should likely be handled in Python.
vulnerabilities/views.py
Outdated
model = models.Package | ||
template_name = "packages.html" | ||
ordering = ["type", "namespace", "name", "version"] | ||
class BaseSearchView(ListView): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we use the django_filters.views.FilterView
instead of re-implementing it?
@tdruez what do you mean by "reusable" here. I would code the implementation again to make it more simpler and satisfy the required needs. |
@Rishi-source Let's say I want to add this pagination on another Django view, could you document the few steps I need to do with this current implementation? This would help to figure out where we can improve the "reusability". |
170df8c
to
1302bed
Compare
vulnerabilities/pagination_mixin.py
Outdated
{"value": 100, "label": "100 per page"}, | ||
] | ||
|
||
def get_paginate_by(self, queryset=None): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function handles page size from URL parameters. Sets default to 20 items, max of 100. Validates to ensure positive integers only. Returns default if invalid values provided.
vulnerabilities/pagination_mixin.py
Outdated
except (ValueError, TypeError): | ||
return self.paginate_by | ||
|
||
def get_context_data(self, **kwargs): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It adds pagination data to template context - current page size, size choices (can be handled by altering PAGE_SIZE_CHOICES) , total count and page range. it also preserves search params across pagination.
vulnerabilities/pagination_mixin.py
Outdated
|
||
return context | ||
|
||
def _get_page_range(self, paginator, current_page, window=2): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it creates page number sequence. Shows all numbers for small sets (<5 pages). For larger sets, shows first/last pages around current page with (...) for gaps.
vulnerabilities/views.py
Outdated
@@ -36,6 +36,8 @@ | |||
from vulnerabilities.utils import get_severity_range | |||
from vulnerablecode.settings import env | |||
|
|||
from .pagination_mixin import PaginatedListViewMixin |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import the Mixin and the PaginatedListViewMixin class.
@@ -62,25 +64,21 @@ def get_purl_version_class(purl: models.Package): | |||
return purl_version_class | |||
|
|||
|
|||
class PackageSearch(ListView): | |||
class PackageSearch(PaginatedListViewMixin, ListView): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add the PaginatedListViewMixin in it and this would convert any ListView into paginated class.
Hi @tdruez , I have altered code to improve the reusability for that I have made a file |
window.location.href = newUrl; | ||
} | ||
</script> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This js function manages URL parameters when changing pagination size. It update the page size parameter while preserving search terms, and remove the current page parameter to reset pagination.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there any risk of XSS there?
@Rishi-source Thanks but looking at multiple comments is hard to follow. Could you also provide a combined documentation of the few steps required to reuse this pagination in another app? |
Pagination Implementation DocumentationCore Components of Code
Implementation Steps
from django.views.generic import ListView
from .pagination_mixins import PaginatedListViewMixin # Make sure to import the mixin
class MyListView(PaginatedListViewMixin, ListView): # Please make sure to put "PaginatedListViewMixin" Above all the mixins in case if there are 2 or more mixins are already their as position of mixin will matter here
model = MyModel
template_name = "my_template.html"
def get_queryset(self):
return super().get_queryset().order_by('id')
{% extends "base.html" %}
{% block content %}
{# Like This #}
{% if is_paginated %}
{% include 'pagination.html' with page_obj=page_obj %}
{% endif %}
{% endblock %} Customization Options
class MyListView(PaginatedListViewMixin, ListView):
PAGE_SIZE_CHOICES = [
{"value": 10, "label": "10 per page"},
{"value": 25, "label": "25 per page"},
{"value": 50, "label": "50 per page"},
]
paginate_by = 20 # Set the Default size
def get_context_data(self, **kwargs):
# Change window variable to show more/fewer pages around current page
context = super().get_context_data(**kwargs)
context['page_range'] = self._get_page_range(
context['paginator'],
context['page_obj'].number,
window=3 # Show 3 pages on each side i.e. left and right
return context Notes
Common Issues and Solutions
Remember that this implementation assumes:
|
@tdruez Here’s the documentation on implementing pagination using this approach. Please review and let me know if any changes are required. |
Hi @tdruez and @pombredanne , I have implemented this pagination code in both VCIO and SCIO and it is working nice.It preserved all the filter and add a option for the user to select the items per page. I have already shared the screenshot for the VCIO pagination view here are the screenshot for SCIO pagination. @tdruez you just have to follow the steps which I have provided in the Documentation for implementing this pagination. ![]() ![]() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks
Here is some feedback!
The PR title should be using imperative style:
Not Pagination Updated - Items per page option added
But rather:
"Improve pagination in web ui"
then add details in the body.
Could you also add tests? PaginatedListViewMixin is not trivial code
Also as @tdruez mentioned in late November, the template should have little to no logic and instead the logic should be in the Python code.
vulnerabilities/pagination_mixin.py
Outdated
Get the number of items to paginate by from the request. | ||
""" | ||
try: | ||
page_size = int(self.request.GET.get("page_size", self.paginate_by)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this the standard approach to get a request arg?
vulnerabilities/pagination_mixin.py
Outdated
|
||
def get_pagination_context(self, paginator, page_obj): | ||
""" | ||
Generate pagination-related context data, preserving filters. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generate pagination-related context data, preserving filters. | |
Return a mapping of pagination-related context data, preserving filters. |
vulnerabilities/pagination_mixin.py
Outdated
query_params = self.request.GET.copy() | ||
query_params.pop("page", None) | ||
|
||
base_query_string = query_params.urlencode() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this the right way to generate a URL query string? Is there a better way?
window.location.href = newUrl; | ||
} | ||
</script> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there any risk of XSS there?
vulnerabilities/pagination_mixin.py
Outdated
@@ -0,0 +1,87 @@ | |||
class PaginatedListViewMixin: | |||
""" | |||
A mixin that adds pagination functionality to ListView-based views. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would call the file pagination.py instead
caab3c0
to
fabe035
Compare
Signed-off-by: Rishi Garg <[email protected]>
Signed-off-by: Rishi Garg <[email protected]>
vulnerabilities/pagination.py
Outdated
|
||
paginate_by = 20 | ||
max_page_size = 100 | ||
PAGE_SIZE_CHOICES = [ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why this option is uppercase?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This option contains the choices for the total number of items to be displayed on the page. Since the values are hardcoded, I highlighted them in uppercase to make it more noticeable in the code.
vulnerabilities/pagination.py
Outdated
queryset = super().get_queryset() | ||
if not queryset: | ||
queryset = self.model.objects.all() | ||
if not isinstance(queryset, QuerySet): | ||
queryset = self.model.objects.all() | ||
return queryset |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should only have 1 instruction in your try block wrapping the thing you are catching the exception for.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, what's the purpose of overriding this method?
vulnerabilities/pagination.py
Outdated
try: | ||
page_size = int(clean_page_size) if clean_page_size else self.paginate_by | ||
valid_sizes = {choice["value"] for choice in self.PAGE_SIZE_CHOICES} | ||
if page_size not in valid_sizes: | ||
logger.warning(f"Attempted to use unauthorized page size: {page_size}") | ||
return self.paginate_by | ||
return page_size |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as above, do not put all you logic in a try block, just the code that may raise the exception.
vulnerabilities/pagination.py
Outdated
def get_page_range(self, paginator, page_obj): | ||
""" | ||
Generate a list of page numbers for navigation | ||
""" | ||
num_pages = paginator.num_pages | ||
current_page = page_obj.number | ||
if num_pages <= 7: | ||
return list(range(1, num_pages + 1)) | ||
pages = [] | ||
pages.append(1) | ||
if current_page > 4: | ||
pages.append("...") | ||
start = max(2, current_page - 2) | ||
end = min(num_pages - 1, current_page + 2) | ||
pages.extend(range(start, end + 1)) | ||
if current_page < num_pages - 3: | ||
pages.append("...") | ||
if num_pages > 1: | ||
pages.append(num_pages) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The values should not be hardcoded but configurable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This part shows numbers and ellipses (1 2 3 4 ... 10 11 12 13) based on hardcoded values. I can replace these with configurable variables to make the display pattern easily adjustable in the future but it will still be hardcoded but in a variable form.
vulnerabilities/pagination.py
Outdated
try: | ||
if not queryset or queryset.count() == 0: | ||
queryset = self.model.objects.all() | ||
paginator = Paginator(queryset, page_size) | ||
page_params = self.request.GET.getlist("page") | ||
page_number = page_params[-1] if page_params else "1" | ||
try: | ||
page_number = int(re.sub(r"\D", "", str(page_number))) | ||
if not page_number: | ||
page_number = 1 | ||
except (ValueError, TypeError): | ||
page_number = 1 | ||
page_number = max(1, min(page_number, paginator.num_pages)) | ||
page = paginator.page(page_number) | ||
return (paginator, page, page.object_list, page.has_other_pages()) | ||
except Exception as e: | ||
logger.error(f"Pagination error: {e}") | ||
queryset = self.model.objects.all() | ||
paginator = Paginator(queryset, page_size) | ||
page = paginator.page(1) | ||
return (paginator, page, page.object_list, page.has_other_pages()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
-
Inefficient Queryset Handling
queryset.count() == 0
performs an unnecessary database query.- Suggested fix: Use
queryset.exists()
instead.
-
Overcomplicated Page Number Extraction
re.sub(r"\D", "", str(page_number))
is unnecessary.- Suggested fix: Use
page_number = int(self.request.GET.get("page", "1"))
.
-
Nested Try-Except Blocks
- The current structure makes debugging harder.
- Suggested fix: Use a single
try-except
block.
-
Broad Exception Handling
- Catching
Exception
hides real bugs. - Suggested fix: Handle
EmptyPage
andInvalidPage
explicitly.
- Catching
-
Redundant Queryset Reassignment in Exception Handling
- Fetching
self.model.objects.all()
again is unnecessary. - Suggested fix: Log the error and return a fallback page.
- Fetching
function handlePageSizeChange(value) { | ||
const url = new URL(window.location.href); | ||
const params = new URLSearchParams(url.search); | ||
params.set('page_size', value); | ||
params.delete('page'); | ||
|
||
const search = params.get('search'); | ||
if (search) { | ||
params.set('search', search); | ||
} | ||
|
||
const newUrl = `${window.location.pathname}?${params.toString()}`; | ||
window.location.href = newUrl; | ||
} | ||
</script> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A no-JS approach would work better.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure I will remove this JS code and handle is via form tag
Signed-off-by: RISHI GARG <[email protected]>
Signed-off-by: RISHI GARG <[email protected]>
Signed-off-by: RISHI GARG <[email protected]>
Hello @tdruez, please review this pull request I have made all the changes required. |
Hi @pombredanne please review this PR I have added tests and also addressed all the given review points. |
Add pagination to VCIO & SCIO #1616 #1617 #1618
This change aligns Package Search functionality and Vulnerability Search, improving consistency across the application and enhancing user control over result display.
For Package Search:
For Vulnerability Search:
Signed-off-by: Rishi Garg [email protected]