diff --git a/vulnerabilities/forms.py b/vulnerabilities/forms.py index a00885637..d550fb3a7 100644 --- a/vulnerabilities/forms.py +++ b/vulnerabilities/forms.py @@ -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. +# See https://github.com/nexB/vulnerablecode for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # @@ -12,9 +12,42 @@ from vulnerabilities.models import ApiUser +from .models import * -class PackageSearchForm(forms.Form): +class PaginationForm(forms.Form): + """Form to handle page size selection across the application.""" + + PAGE_CHOICES = [ + ("20", "20 per page"), + ("50", "50 per page"), + ("100", "100 per page"), + ] + + page_size = forms.ChoiceField( + choices=PAGE_CHOICES, + initial="20", + required=False, + widget=forms.Select( + attrs={ + "class": "select is-small", + "onchange": "handlePageSizeChange(this.value)", + "id": "page-size-select", + } + ), + ) + + +class BaseSearchForm(forms.Form): + """Base form for implementing search functionality.""" + + search = forms.CharField(required=True) + + def clean_search(self): + return self.cleaned_data.get("search", "") + + +class PackageSearchForm(BaseSearchForm): search = forms.CharField( required=True, widget=forms.TextInput( @@ -23,8 +56,7 @@ class PackageSearchForm(forms.Form): ) -class VulnerabilitySearchForm(forms.Form): - +class VulnerabilitySearchForm(BaseSearchForm): search = forms.CharField( required=True, widget=forms.TextInput( @@ -78,4 +110,4 @@ def clean_username(self): return username def save_m2m(self): - pass + pass \ No newline at end of file diff --git a/vulnerabilities/templates/includes/pagination.html b/vulnerabilities/templates/includes/pagination.html index 93703b73c..b57e83850 100644 --- a/vulnerabilities/templates/includes/pagination.html +++ b/vulnerabilities/templates/includes/pagination.html @@ -1,20 +1,25 @@ {% if is_paginated %} diff --git a/vulnerabilities/templates/packages.html b/vulnerabilities/templates/packages.html index acf45daca..0739b38b6 100644 --- a/vulnerabilities/templates/packages.html +++ b/vulnerabilities/templates/packages.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load static %} {% load humanize %} {% load widget_tweaks %} @@ -20,12 +21,7 @@
- + {{ pagination_form.page_size }}
{% if is_paginated %} @@ -68,8 +64,8 @@ {{ package.purl }} + href="{{ package.get_absolute_url }}?search={{ search }}" + target="_self">{{ package.purl }} {{ package.vulnerability_count }} {{ package.patched_vulnerability_count }} @@ -77,7 +73,7 @@ {% empty %} - No Package found. + No Package found. {% endfor %} @@ -85,18 +81,10 @@ - {% if is_paginated %} - {% include 'includes/pagination.html' with page_obj=page_obj %} - {% endif %} - + {% if is_paginated %} + {% include 'includes/pagination.html' with page_obj=page_obj %} + {% endif %} {% endif %} - + {% endblock %} \ No newline at end of file diff --git a/vulnerabilities/templates/vulnerabilities.html b/vulnerabilities/templates/vulnerabilities.html index cc8fabfb6..850e34322 100644 --- a/vulnerabilities/templates/vulnerabilities.html +++ b/vulnerabilities/templates/vulnerabilities.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load static %} {% load humanize %} {% load widget_tweaks %} @@ -20,17 +21,12 @@
- + {{ pagination_form.page_size }}
{% if is_paginated %} - {% include 'includes/pagination.html' with page_obj=page_obj %} - {% endif %} + {% include 'includes/pagination.html' with page_obj=page_obj %} + {% endif %} @@ -50,9 +46,9 @@ {% for vulnerability in page_obj %} - {{ vulnerability.vulnerability_id }} + {{ vulnerability.vulnerability_id }} @@ -73,7 +69,7 @@ {% empty %} - No vulnerability found. + No vulnerability found. {% endfor %} @@ -81,19 +77,10 @@ - - {% if is_paginated %} - {% include 'includes/pagination.html' with page_obj=page_obj %} - {% endif %} + {% if is_paginated %} + {% include 'includes/pagination.html' with page_obj=page_obj %} + {% endif %} {% endif %} - - + {% endblock %} \ No newline at end of file diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 6b03b5c40..e0e8078a1 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -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. +# See https://github.com/nexB/vulnerablecode for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # import logging @@ -32,6 +32,7 @@ from vulnerabilities import models from vulnerabilities.forms import ApiUserCreationForm from vulnerabilities.forms import PackageSearchForm +from vulnerabilities.forms import PaginationForm from vulnerabilities.forms import VulnerabilitySearchForm from vulnerabilities.models import VulnerabilityStatusType from vulnerabilities.severity_systems import EPSS @@ -40,6 +41,7 @@ from vulnerablecode.settings import env PAGE_SIZE = 20 +MAX_PAGE_SIZE = 100 def purl_sort_key(purl: models.Package): @@ -65,73 +67,129 @@ def get_purl_version_class(purl: models.Package): return purl_version_class -class PackageSearch(ListView): - model = models.Package - template_name = "packages.html" - ordering = ["type", "namespace", "name", "version"] +class BaseSearchView(ListView): + """Base view for implementing search functionality with pagination.""" + paginate_by = PAGE_SIZE + max_page_size = MAX_PAGE_SIZE - def get_paginate_by(self, queryset): - page_size = self.request.GET.get("page_size", "") - return int(page_size) if page_size.isdigit() else self.paginate_by + def get_paginate_by(self, queryset=None): + """ + This function would get and validate the page size from request parameters. + It returns a page size between 1 and max_page_size, defaulting to + self.paginate_by for invalid inputs. + """ + try: + page_size = int(self.request.GET.get("page_size", self.paginate_by)) + if page_size <= 0: + return self.paginate_by + return min(page_size, self.max_page_size) + except (ValueError, TypeError): + return self.paginate_by def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - request_query = self.request.GET - context["package_search_form"] = PackageSearchForm(request_query) - context["search"] = request_query.get("search") - context["page_size"] = self.get_paginate_by(self.get_queryset()) + context.update( + { + "pagination_form": PaginationForm(initial={"page_size": self.get_paginate_by()}), + } + ) return context + +class PackageSearch(BaseSearchView): + model = models.Package + template_name = "packages.html" + form_class = PackageSearchForm + ordering = ["type", "namespace", "name", "version"] + def get_queryset(self, query=None): """ - Return a Package queryset for the ``query``. - Make a best effort approach to find matching packages either based - on exact purl, partial purl or just name and namespace. + Return a Package queryset based on search parameters. + + Args: + query (str, optional): Direct search query, mainly used for testing. + If not provided, uses form data from request. + Returns: + QuerySet: Filtered and ordered package queryset """ - query = query or self.request.GET.get("search") or "" + if query is not None: + # Handle direct query (used in tests) + return ( + self.model.objects.search(query) + .with_vulnerability_counts() + .prefetch_related() + .order_by("package_url") + ) + + # Handle form submission + self.form = self.form_class(self.request.GET) + if not self.form.is_valid(): + return self.model.objects.none() + + search_query = self.form.cleaned_data.get("search", "") return ( - self.model.objects.search(query) + self.model.objects.search(search_query) .with_vulnerability_counts() .prefetch_related() .order_by("package_url") ) + def get_context_data(self, **kwargs): + """ + Get the context data for template rendering. + Adds form and search parameters to context. + """ + context = super().get_context_data(**kwargs) + context.update( + { + "package_search_form": self.form, + "search": self.request.GET.get("search"), + } + ) + return context + -class VulnerabilitySearch(ListView): +class VulnerabilitySearch(BaseSearchView): model = models.Vulnerability template_name = "vulnerabilities.html" + form_class = VulnerabilitySearchForm ordering = ["vulnerability_id"] - paginate_by = PAGE_SIZE - def get_paginate_by(self, queryset): - page_size = self.request.GET.get("page_size", "") - return int(page_size) if page_size.isdigit() else self.paginate_by + def get_queryset(self, query=None): + """ + Return a Vulnerability queryset based on search parameters. + + Args: + query (str, optional): Direct search query, mainly used for testing. + If not provided, uses form data from request. + Returns: + QuerySet: Filtered vulnerability queryset + """ + if query is not None: + return self.model.objects.search(query=query).with_package_counts() + + self.form = self.form_class(self.request.GET) + if not self.form.is_valid(): + return self.model.objects.none() + + search_query = self.form.cleaned_data.get("search", "") + return self.model.objects.search(query=search_query).with_package_counts() def get_context_data(self, **kwargs): + """ + Get the context data for template rendering. + Adds form and search parameters to context. + """ context = super().get_context_data(**kwargs) - request_query = self.request.GET - context["vulnerability_search_form"] = VulnerabilitySearchForm(request_query) - context["search"] = request_query.get("search") - context["page_size"] = self.get_paginate_by(self.get_queryset()) + context.update( + { + "vulnerability_search_form": self.form, + "search": self.request.GET.get("search"), + } + ) return context - def get_queryset(self): - query = self.request.GET.get("search") or "" - return self.model.objects.search(query=query).with_package_counts() - - def paginate_queryset(self, queryset, page_size): - paginator = Paginator(queryset, page_size) - page = self.request.GET.get("page", "1") - try: - page_number = int(page) - page_obj = paginator.page(page_number) - except (ValueError, PageNotAnInteger): - page_obj = paginator.page(1) - except EmptyPage: - page_obj = paginator.page(paginator.num_pages) - return (paginator, page_obj, page_obj.object_list, page_obj.has_other_pages()) - class PackageDetails(DetailView): model = models.Package @@ -275,11 +333,15 @@ def get(self, request): Token {auth_token} -If you did NOT request this API key, you can either ignore this email or contact us at support@nexb.com and let us know in the forward that you did not request an API key. +If you did NOT request this API key, you can either ignore +this email or contact us at support@nexb.com and let us know in the forward +that you did not request an API key. The API root is at https://public.vulnerablecode.io/api -To learn more about using the VulnerableCode.io API, please refer to the live API documentation at https://public.vulnerablecode.io/api/docs -To learn about VulnerableCode, refer to the general documentation at https://vulnerablecode.readthedocs.io +To learn more about using the VulnerableCode.io API, +please refer to the live API documentation at https://public.vulnerablecode.io/api/docs +To learn about VulnerableCode, refer to the +general documentation at https://vulnerablecode.readthedocs.io -- Sincerely, @@ -321,4 +383,4 @@ def form_valid(self, form): return response def get_success_url(self): - return reverse_lazy("api_user_request") + return reverse_lazy("api_user_request") \ No newline at end of file diff --git a/vulnerablecode/static/js/pagination.js b/vulnerablecode/static/js/pagination.js new file mode 100644 index 000000000..c09bd10f0 --- /dev/null +++ b/vulnerablecode/static/js/pagination.js @@ -0,0 +1,17 @@ +// static/js/pagination.js +// This function would handles the pagination dropdown change event, maintaining existing search parameters. +// This would also update the page size in the URL and reloads the page with the new page size parameter. +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()}`; + if (window.location.href !== newUrl) { + window.location.href = newUrl; + } +} \ No newline at end of file