From 58c9fab3626101b42b105d59b1c271c5bf78e2ad Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 3 Mar 2026 19:53:11 +1300 Subject: [PATCH 1/5] feat: rework the pagination with per-model setting Signed-off-by: tdruez --- component_catalog/views.py | 3 +-- dejacode/settings.py | 17 +++++++++++++++++ dje/views.py | 28 ++++++++++++++++++++++++---- product_portfolio/views.py | 11 +++++------ reporting/views.py | 5 ++--- workflow/views.py | 1 - 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/component_catalog/views.py b/component_catalog/views.py index dd4d9848..a650e000 100644 --- a/component_catalog/views.py +++ b/component_catalog/views.py @@ -353,7 +353,6 @@ class ComponentListView( template_list_table = "component_catalog/tables/component_list_table.html" include_reference_dataspace = True put_results_in_session = True - paginate_by = settings.PAGINATE_BY or 200 group_name_version = True table_headers = ( @@ -1734,9 +1733,9 @@ class ScanListView( AddPackagePermissionMixin, APIWrapperListView, ): - paginate_by = 50 template_name = "component_catalog/scan_list.html" template_list_table = "component_catalog/tables/scan_list_table.html" + paginate_by = settings.DEJACODE_PAGINATE_BY.get("scan", 50) def dispatch(self, request, *args, **kwargs): user = self.request.user diff --git a/dejacode/settings.py b/dejacode/settings.py index eff683f8..83910892 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -387,6 +387,23 @@ def gettext_noop(s): PAGINATE_BY = env.int("PAGINATE_BY", default=None) TAB_PAGINATE_BY = env.int("TAB_PAGINATE_BY", default=100) +# List views pagination, controls the number of items displayed per page. +# Syntax in .env: DEJACODE_PAGINATE_BY=product=20,package=100 +DEJACODE_PAGINATE_BY = env.dict( + "DEJACODE_PAGINATE_BY", + default={ + "product": 50, + "component": PAGINATE_BY or 100, + "package": 100, + "license": 100, + "owner": 100, + "report": 50, + "request": 50, + "scan": 50, + "vulnerability": 100, + }, +) + ADMIN_FORMS_CONFIGURATION = env.dict("ADMIN_FORMS_CONFIGURATION", default={}) # Location of the changelog file diff --git a/dje/views.py b/dje/views.py index ee73845d..9d752003 100644 --- a/dje/views.py +++ b/dje/views.py @@ -260,8 +260,29 @@ def get_queryset(self): return qs.scope(dataspace, include_reference=self.include_reference_dataspace) -class PreviousNextPaginationMixin: +class PaginationMixin: query_dict_page_param = "page" + paginate_by = None + default_paginate_by = 100 + + def get_paginate_by(self, queryset): + """ + Determine the number of items per page. + + Resolution order: + 1. ``paginate_by`` set directly on the view instance. + 2. Per-model value from the ``DEJACODE_PAGINATE_BY`` setting. + 3. ``default_paginate_by`` as a fallback. + """ + if self.paginate_by: + return self.paginate_by + + if self.model: + model_name = self.model._meta.model_name + if paginate_by := settings.DEJACODE_PAGINATE_BY.get(model_name): + return paginate_by + + return self.default_paginate_by def get_previous_next(self, page_obj): """Return url links for the previous and next navigation.""" @@ -330,12 +351,11 @@ class DataspacedFilterView( GetDataspaceMixin, HasPermissionMixin, TableHeaderMixin, - PreviousNextPaginationMixin, + PaginationMixin, FilterView, ): template_name = "object_list_base.html" template_list_table = None - paginate_by = settings.PAGINATE_BY or 100 # Required if `show_previous_and_next_object_links` enabled on the # details view. put_results_in_session = False @@ -2246,7 +2266,7 @@ def page(self, number): class APIWrapperListView( - PreviousNextPaginationMixin, + PaginationMixin, ListView, ): paginate_by = 100 diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 3584bce1..01403eb2 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -99,7 +99,7 @@ from dje.views import Header from dje.views import LicenseDataForBuilderMixin from dje.views import ObjectDetailsView -from dje.views import PreviousNextPaginationMixin +from dje.views import PaginationMixin from dje.views import SendAboutFilesView from dje.views import TabContentView from dje.views import TabField @@ -164,7 +164,6 @@ class ProductListView( filterset_class = ProductFilterSet template_name = "product_portfolio/product_list.html" template_list_table = "product_portfolio/tables/product_list_table.html" - paginate_by = 50 put_results_in_session = False group_name_version = True table_headers = ( @@ -733,7 +732,7 @@ def get_context_data(self, **kwargs): class ProductTabInventoryView( LoginRequiredMixin, BaseProductViewMixin, - PreviousNextPaginationMixin, + PaginationMixin, TabContentView, ): template_name = "product_portfolio/tabs/tab_inventory.html" @@ -978,7 +977,7 @@ def inject_scan_data(scancodeio, feature_grouped, dataspace_uuid): class ProductTabCodebaseView( LoginRequiredMixin, BaseProductViewMixin, - PreviousNextPaginationMixin, + PaginationMixin, TabContentView, ): template_name = "product_portfolio/tabs/tab_codebase.html" @@ -1059,7 +1058,7 @@ def has_any_values(field_name): class ProductTabDependenciesView( LoginRequiredMixin, BaseProductViewMixin, - PreviousNextPaginationMixin, + PaginationMixin, TableHeaderMixin, TabContentView, ): @@ -1138,7 +1137,7 @@ def get_context_data(self, **kwargs): class ProductTabVulnerabilitiesView( LoginRequiredMixin, BaseProductViewMixin, - PreviousNextPaginationMixin, + PaginationMixin, TableHeaderMixin, TabContentView, ): diff --git a/reporting/views.py b/reporting/views.py index 86aad47b..ac4c49d9 100644 --- a/reporting/views.py +++ b/reporting/views.py @@ -34,7 +34,7 @@ from dje.views import DataspacedFilterView from dje.views import DownloadableMixin from dje.views import HasPermissionMixin -from dje.views import PreviousNextPaginationMixin +from dje.views import PaginationMixin from reporting.filters import ReportFilterSet from reporting.forms import RuntimeFilterBaseFormSet from reporting.forms import RuntimeFilterForm @@ -81,7 +81,7 @@ def get(self, request, *args, **kwargs): class ReportDetailsView( LoginRequiredMixin, - PreviousNextPaginationMixin, + PaginationMixin, BootstrapCSSMixin, DownloadableMixin, HasPermissionMixin, @@ -294,7 +294,6 @@ class ReportListView( filterset_class = ReportFilterSet template_name = "reporting/report_list.html" template_list_table = "reporting/includes/report_list_table.html" - paginate_by = 50 def get_queryset(self): qs = super().get_queryset() diff --git a/workflow/views.py b/workflow/views.py index bb12cff1..ecb13e8d 100644 --- a/workflow/views.py +++ b/workflow/views.py @@ -45,7 +45,6 @@ class RequestListView( filterset_class = RequestFilterSet template_name = "workflow/request_list.html" template_list_table = "workflow/includes/request_list_table.html" - paginate_by = 50 def get_queryset(self): """ From 278c6cd22af20b3c50d5a93cef6d67992c2c7de5 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 4 Mar 2026 13:14:24 +1300 Subject: [PATCH 2/5] add deprecation warning for PAGINATE_BY Signed-off-by: tdruez --- dejacode/settings.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/dejacode/settings.py b/dejacode/settings.py index 83910892..fd0941fc 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -10,6 +10,7 @@ import sys import tempfile +import warnings from pathlib import Path import environ @@ -383,17 +384,22 @@ def gettext_noop(s): # Set False to hide the "Product Portfolio" section in the navbar. SHOW_PP_IN_NAV = env.bool("SHOW_PP_IN_NAV", default=True) +# An integer specifying how many objects should be displayed per table whithin tabs. +TAB_PAGINATE_BY = env.int("TAB_PAGINATE_BY", default=100) + # An integer specifying how many objects should be displayed per page. PAGINATE_BY = env.int("PAGINATE_BY", default=None) -TAB_PAGINATE_BY = env.int("TAB_PAGINATE_BY", default=100) +if PAGINATE_BY: + warnings.warn("The PAGINATE_BY setting is deprecated. Use DEJACODE_PAGINATE_BY instead.") + # List views pagination, controls the number of items displayed per page. -# Syntax in .env: DEJACODE_PAGINATE_BY=product=20,package=100 +# Syntax in .env: DEJACODE_PAGINATE_BY=product=20,package=100,license=100,request=50,scan=50 DEJACODE_PAGINATE_BY = env.dict( "DEJACODE_PAGINATE_BY", default={ "product": 50, - "component": PAGINATE_BY or 100, + "component": 100, "package": 100, "license": 100, "owner": 100, From 52c819dac484a19e22be3704386f23db99d60940 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 4 Mar 2026 13:14:44 +1300 Subject: [PATCH 3/5] add documentation for new DEJACODE_PAGINATE_BY setting Signed-off-by: tdruez --- docs/application-settings.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/application-settings.rst b/docs/application-settings.rst index 4e638c56..d7e1903f 100644 --- a/docs/application-settings.rst +++ b/docs/application-settings.rst @@ -162,6 +162,16 @@ longer than this value. # 1 hour, in seconds. SESSION_COOKIE_AGE=3600 +.. _dejacode_settings_paginate_by: + +DEJACODE_PAGINATE_BY +-------------------- + +The number of objects display per page for each object type can be customized with the +following setting:: + + DEJACODE_PAGINATE_BY=product=20,package=100,license=100,report=50,request=50,scan=50 + DEJACODE_LOG_LEVEL ------------------ From 4741588709eacb5620dce59e07a7db023a4359bc Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 5 Mar 2026 11:37:51 +1300 Subject: [PATCH 4/5] add unit test for the get_paginate_by Signed-off-by: tdruez --- component_catalog/tests/test_views.py | 20 ++++++++++++++++++++ dje/views.py | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/component_catalog/tests/test_views.py b/component_catalog/tests/test_views.py index a2703e40..9f463fe8 100644 --- a/component_catalog/tests/test_views.py +++ b/component_catalog/tests/test_views.py @@ -42,6 +42,7 @@ from component_catalog.tests import make_package from component_catalog.views import ComponentAddView from component_catalog.views import ComponentListView +from component_catalog.views import PackageListView from component_catalog.views import PackageTabScanView from dejacode_toolkit.scancodeio import get_webhook_url from dejacode_toolkit.vulnerablecode import VulnerableCode @@ -55,6 +56,7 @@ from dje.tests import add_perms from dje.tests import create_superuser from dje.tests import create_user +from dje.views import PaginationMixin from license_library.models import License from license_library.models import LicenseAssignedTag from license_library.models import LicenseTag @@ -1134,6 +1136,24 @@ def test_package_list_view_num_queries(self): with self.assertNumQueries(16): self.client.get(reverse("component_catalog:package_list")) + def test_package_list_view_pagination(self): + list_view = PackageListView() + + # 1. Default value from DEJACODE_PAGINATE_BY + self.assertIsNone(list_view.paginate_by) + self.assertEqual(50, list_view.get_paginate_by(queryset=None)) + + # 2. Default value from the PaginationMixin.default_paginate_by + with override_settings(DEJACODE_PAGINATE_BY={}): + self.assertIsNone(list_view.paginate_by) + expected = PaginationMixin.default_paginate_by + self.assertEqual(expected, list_view.get_paginate_by(queryset=None)) + + # 3. Value from custom DEJACODE_PAGINATE_BY + with override_settings(DEJACODE_PAGINATE_BY={"package": 20}): + self.assertIsNone(list_view.paginate_by) + self.assertEqual(20, list_view.get_paginate_by(queryset=None)) + def test_package_views_urls(self): p1 = Package( filename="filename.zip", diff --git a/dje/views.py b/dje/views.py index 9d752003..5fe4ec3f 100644 --- a/dje/views.py +++ b/dje/views.py @@ -277,10 +277,13 @@ def get_paginate_by(self, queryset): if self.paginate_by: return self.paginate_by - if self.model: + if self.model and settings.DEJACODE_PAGINATE_BY: model_name = self.model._meta.model_name if paginate_by := settings.DEJACODE_PAGINATE_BY.get(model_name): - return paginate_by + try: + return int(paginate_by) + except ValueError: + return self.default_paginate_by return self.default_paginate_by From 744e235ed59eacaaa9cff8256abc35c534145797 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 5 Mar 2026 12:21:38 +1300 Subject: [PATCH 5/5] fix failing tests Signed-off-by: tdruez --- component_catalog/tests/test_views.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/component_catalog/tests/test_views.py b/component_catalog/tests/test_views.py index 9f463fe8..5efafcb1 100644 --- a/component_catalog/tests/test_views.py +++ b/component_catalog/tests/test_views.py @@ -519,8 +519,8 @@ def test_component_catalog_history_tab(self): self.assertContains(response, "Changed name.") def test_component_catalog_productcomponent_secured_hierarchy_and_product_usage(self): - component1 = Component.objects.create(name="c1", dataspace=self.nexb_dataspace) - product1 = Product.objects.create(name="p1", dataspace=self.nexb_dataspace) + component1 = Component.objects.create(name="component1", dataspace=self.nexb_dataspace) + product1 = Product.objects.create(name="product1", dataspace=self.nexb_dataspace) ProductComponent.objects.create( product=product1, component=component1, dataspace=self.nexb_dataspace ) @@ -1139,17 +1139,13 @@ def test_package_list_view_num_queries(self): def test_package_list_view_pagination(self): list_view = PackageListView() - # 1. Default value from DEJACODE_PAGINATE_BY - self.assertIsNone(list_view.paginate_by) - self.assertEqual(50, list_view.get_paginate_by(queryset=None)) - - # 2. Default value from the PaginationMixin.default_paginate_by + # Default value from the PaginationMixin.default_paginate_by with override_settings(DEJACODE_PAGINATE_BY={}): self.assertIsNone(list_view.paginate_by) expected = PaginationMixin.default_paginate_by self.assertEqual(expected, list_view.get_paginate_by(queryset=None)) - # 3. Value from custom DEJACODE_PAGINATE_BY + # Value from custom DEJACODE_PAGINATE_BY with override_settings(DEJACODE_PAGINATE_BY={"package": 20}): self.assertIsNone(list_view.paginate_by) self.assertEqual(20, list_view.get_paginate_by(queryset=None))