diff --git a/vulnerabilities/forms.py b/vulnerabilities/forms.py index a00885637..1123187b6 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,57 @@ 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", "") + + 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()) + + +class PackageSearchForm(BaseSearchForm): + model = Package search = forms.CharField( required=True, widget=forms.TextInput( @@ -22,9 +70,18 @@ class PackageSearchForm(forms.Form): ), ) + def _search(self, query): + """Execute package-specific search logic.""" + return ( + self.model.objects.search(query) + .with_vulnerability_counts() + .prefetch_related() + .order_by("package_url") + ) -class VulnerabilitySearchForm(forms.Form): +class VulnerabilitySearchForm(BaseSearchForm): + model = Vulnerability search = forms.CharField( required=True, widget=forms.TextInput( @@ -32,6 +89,10 @@ class VulnerabilitySearchForm(forms.Form): ), ) + def _search(self, query): + """Execute vulnerability-specific search logic.""" + return self.model.objects.search(query=query).with_package_counts() + class ApiUserCreationForm(forms.ModelForm): """ diff --git a/vulnerabilities/templates/includes/pagination.html b/vulnerabilities/templates/includes/pagination.html index 0d6dad430..b57e83850 100644 --- a/vulnerabilities/templates/includes/pagination.html +++ b/vulnerabilities/templates/includes/pagination.html @@ -1,39 +1,56 @@ - \ No newline at end of file + + + +{% endif %} \ No newline at end of file diff --git a/vulnerabilities/templates/packages.html b/vulnerabilities/templates/packages.html index 1f7687429..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 %} @@ -18,6 +19,11 @@
{{ page_obj.paginator.count|intcomma }} results
+
+
+ {{ pagination_form.page_size }} +
+
{% if is_paginated %} {% include 'includes/pagination.html' with page_obj=page_obj %} {% endif %} @@ -58,8 +64,8 @@ {{ package.purl }} + href="{{ package.get_absolute_url }}?search={{ search }}" + target="_self">{{ package.purl }} {{ package.vulnerability_count }} {{ package.patched_vulnerability_count }} @@ -67,7 +73,7 @@ {% empty %} - No Package found. + No Package found. {% endfor %} @@ -75,10 +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 %} + +{% endblock %} \ No newline at end of file diff --git a/vulnerabilities/templates/vulnerabilities.html b/vulnerabilities/templates/vulnerabilities.html index 023d3f97f..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 %} @@ -18,9 +19,14 @@
{{ page_obj.paginator.count|intcomma }} results
- {% if is_paginated %} - {% include 'includes/pagination.html' with page_obj=page_obj %} - {% endif %} +
+
+ {{ pagination_form.page_size }} +
+
+ {% if is_paginated %} + {% include 'includes/pagination.html' with page_obj=page_obj %} + {% endif %} @@ -40,9 +46,9 @@ {% for vulnerability in page_obj %} - {{ vulnerability.vulnerability_id }} + {{ vulnerability.vulnerability_id }} @@ -63,7 +69,7 @@ {% empty %} - No vulnerability found. + No vulnerability found. {% endfor %} @@ -71,11 +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 %} + +{% endblock %} \ No newline at end of file diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 51cdcd049..32b748d6c 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -29,14 +29,15 @@ 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 from vulnerabilities.severity_systems import SCORING_SYSTEMS from vulnerabilities.utils import get_severity_range from vulnerablecode.settings import env PAGE_SIZE = 20 +MAX_PAGE_SIZE = 100 def purl_sort_key(purl: models.Package): @@ -62,51 +63,99 @@ 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=None): + """ + Get and validate the requested page size. + Required 2 positional_argument get_paginate_by(positional_argument1, positional_argument2) + """ + 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): + """Add pagination form to the template context.""" 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.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. - """ - query = query or self.request.GET.get("search") or "" - return ( - self.model.objects.search(query) - .with_vulnerability_counts() - .prefetch_related() - .order_by("package_url") + """Get queryset from form's search method.""" + if query is not None: + form = self.form_class() + return form.get_queryset(query=query) + + if hasattr(self, "request"): + self.form = self.form_class(self.request.GET) + return self.form.get_queryset() + + return self.model.objects.none() + + def get_context_data(self, **kwargs): + """Extends the template context with search form and search query for Packages.""" + context = super().get_context_data(**kwargs) + if not hasattr(self, "form"): + self.form = self.form_class() + context.update( + { + "package_search_form": self.form, + "search": getattr(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_queryset(self, query=None): + """Get queryset from form's search method.""" + if query is not None: + form = self.form_class() + return form.get_queryset(query=query) + + if hasattr(self, "request"): + self.form = self.form_class(self.request.GET) + return self.form.get_queryset() + + return self.model.objects.none() def get_context_data(self, **kwargs): + """Extends the template context with search form and search query for Vulnerability.""" context = super().get_context_data(**kwargs) - request_query = self.request.GET - context["vulnerability_search_form"] = VulnerabilitySearchForm(request_query) - context["search"] = request_query.get("search") + if not hasattr(self, "form"): + self.form = self.form_class() + context.update( + { + "vulnerability_search_form": self.form, + "search": getattr(self.request, "GET", {}).get("search"), + } + ) return context - def get_queryset(self, query=None): - query = query or self.request.GET.get("search") or "" - return self.model.objects.search(query=query).with_package_counts() - class PackageDetails(DetailView): model = models.Package @@ -250,11 +299,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, 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