From 9a6fef7a1357c3d38d97320d6840f1e6451abf3e Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 3 Dec 2022 12:36:09 +0000 Subject: [PATCH 01/12] Modify MultiTableMixin api to be a bit more like SingleTableMixin with a separation between table class definition and instantiation. --- django_tables2/views.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/django_tables2/views.py b/django_tables2/views.py index adaa3b38..4bc7ff17 100644 --- a/django_tables2/views.py +++ b/django_tables2/views.py @@ -182,6 +182,7 @@ class MultiTableMixin(TableMixinBase): """ tables = None + models = None tables_data = None table_prefix = "table_{}-" @@ -189,22 +190,37 @@ class MultiTableMixin(TableMixinBase): # override context table name to make sense in a multiple table context context_table_name = "tables" - def get_tables(self): + def get_tables_classes(self): """ - Return an array of table instances containing data. + Return the list of classes to use for the tables. """ - if self.tables is None: + if self.tables is None and self.models is None: klass = type(self).__name__ raise ImproperlyConfigured("No tables were specified. Define {}.tables".format(klass)) + if self.tables: + return self.tables + if self.models: + return [tables.table_factory(self.model) for model in self.models] + + raise ImproperlyConfigured( + "You must either specify {0}.tables or {0}.models".format(type(self).__name__) + ) + + + def get_tables(self,**kwargs): + """ + Return an array of table instances containing data. + """ + tables = self.get_tables_classes() data = self.get_tables_data() if data is None: - return self.tables + return tables - if len(data) != len(self.tables): + if len(data) != len(tables): klass = type(self).__name__ raise ImproperlyConfigured("len({}.tables_data) != len({}.tables)".format(klass, klass)) - return list(Table(data[i]) for i, Table in enumerate(self.tables)) + return list(Table(data[i], **kwargs) for i, Table in enumerate(tables)) def get_tables_data(self): """ From 42fe08427a387e17986d23d9a4d8bcea6bb1198d Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 3 Dec 2022 12:46:48 +0000 Subject: [PATCH 02/12] Tweak sourfce formatting to get actions to run --- django_tables2/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/django_tables2/views.py b/django_tables2/views.py index 4bc7ff17..6659999e 100644 --- a/django_tables2/views.py +++ b/django_tables2/views.py @@ -197,8 +197,10 @@ def get_tables_classes(self): if self.tables is None and self.models is None: klass = type(self).__name__ raise ImproperlyConfigured("No tables were specified. Define {}.tables".format(klass)) + if self.tables: return self.tables + if self.models: return [tables.table_factory(self.model) for model in self.models] @@ -206,8 +208,7 @@ def get_tables_classes(self): "You must either specify {0}.tables or {0}.models".format(type(self).__name__) ) - - def get_tables(self,**kwargs): + def get_tables(self, **kwargs): """ Return an array of table instances containing data. """ From 43323e82a23caf2e4734311613f08241e90845bd Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 3 Dec 2022 13:09:54 +0000 Subject: [PATCH 03/12] Remove MultiTableMixin models attribute - not allowing tests to work. Add new test for MultiTableMixin.get_tables_classes --- django_tables2/views.py | 20 +++++++------------- tests/test_views.py | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/django_tables2/views.py b/django_tables2/views.py index 6659999e..58de7e56 100644 --- a/django_tables2/views.py +++ b/django_tables2/views.py @@ -182,7 +182,6 @@ class MultiTableMixin(TableMixinBase): """ tables = None - models = None tables_data = None table_prefix = "table_{}-" @@ -194,19 +193,14 @@ def get_tables_classes(self): """ Return the list of classes to use for the tables. """ - if self.tables is None and self.models is None: - klass = type(self).__name__ - raise ImproperlyConfigured("No tables were specified. Define {}.tables".format(klass)) - - if self.tables: - return self.tables - - if self.models: - return [tables.table_factory(self.model) for model in self.models] + if self.tables is None: + raise ImproperlyConfigured( + "You must either specify {0}.tables or override {0}.get_tables_classes()".format( + type(self).__name__ + ) + ) - raise ImproperlyConfigured( - "You must either specify {0}.tables or {0}.models".format(type(self).__name__) - ) + return self.tables def get_tables(self, **kwargs): """ diff --git a/tests/test_views.py b/tests/test_views.py index 622e8132..e43b9cad 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -388,7 +388,20 @@ def test_with_empty_get_tables_list(self): class View(tables.MultiTableMixin, TemplateView): template_name = "multiple.html" - def get_tables(self): + def get_tables(self, **kwargs): + return [] + + response = View.as_view()(build_request("/")) + response.render() + + html = response.rendered_content + self.assertIn("

Multiple tables using MultiTableMixin

", html) + + def test_with_empty_get_tables_clases_list(self): + class View(tables.MultiTableMixin, TemplateView): + template_name = "multiple.html" + + def get_tables_classes(self): return [] response = View.as_view()(build_request("/")) From 6459dd11cf00ffbcec909cf79f7eb4d047ea3fe4 Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 21 Jan 2023 11:21:55 +0000 Subject: [PATCH 04/12] Reapply MultiTableMixin.get_tables_calsses Re do the change to allow MultiTableClasses.get_tablers_classes, but this time with f-string formatting. --- django_tables2/views.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/django_tables2/views.py b/django_tables2/views.py index 68c42eb6..86d0a0d9 100644 --- a/django_tables2/views.py +++ b/django_tables2/views.py @@ -204,22 +204,31 @@ class MultiTableMixin(TableMixinBase): # override context table name to make sense in a multiple table context context_table_name = "tables" - def get_tables(self): + def get_tables_classes(self): """ - Return an array of table instances containing data. + Return the list of classes to use for the tables. """ if self.tables is None: - view_name = type(self).__name__ - raise ImproperlyConfigured(f"No tables were specified. Define {view_name}.tables") + raise ImproperlyConfigured( + f"You must either specify {0}.tables or override {type(self).__name__}.get_tables_classes()" + ) + + return self.tables + + def get_tables(self, **kwargs): + """ + Return an array of table instances containing data. + """ + tables = self.get_tables_classes() data = self.get_tables_data() if data is None: - return self.tables + return tables - if len(data) != len(self.tables): - view_name = type(self).__name__ - raise ImproperlyConfigured(f"len({view_name}.tables_data) != len({view_name}.tables)") - return list(Table(data[i]) for i, Table in enumerate(self.tables)) + if len(data) != len(tables): + klass = type(self).__name__ + raise ImproperlyConfigured(f"len({klass}.tables_data) != len({klass}.tables)") + return list(Table(data[i], **kwargs) for i, Table in enumerate(tables)) def get_tables_data(self): """ From e071075fc364ea221ef519687a744fcc0f6064fa Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 21 Jan 2023 12:22:26 +0000 Subject: [PATCH 05/12] MultTable.get_tables_classes() tweaking Make the test for get_tables_classes() use a list of tables. Test will probably fail --- tests/test_views.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_views.py b/tests/test_views.py index 9012bbb8..f096432f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -415,6 +415,19 @@ class View(tables.MultiTableMixin, TemplateView): with self.assertRaisesMessage(ImproperlyConfigured, message): View.as_view()(build_request("/")) + def test_get_tables_clases_list(self): + class View(tables.MultiTableMixin, TemplateView): + template_name = "multiple.html" + + def get_tables_classes(self): + return [TableA,TableB] + + response = View.as_view()(build_request("/")) + response.render() + + html = response.rendered_content + self.assertIn("

Multiple tables using MultiTableMixin

", html) + def test_with_empty_get_tables_list(self): class View(tables.MultiTableMixin, TemplateView): template_name = "multiple.html" From 6aa6dbdd121f82c207f99916c37048a9b066190c Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 21 Jan 2023 12:27:24 +0000 Subject: [PATCH 06/12] Update test with correct exception message --- tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_views.py b/tests/test_views.py index f096432f..540ad8d4 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -411,7 +411,7 @@ def test_without_tables(self): class View(tables.MultiTableMixin, TemplateView): template_name = "multiple.html" - message = "No tables were specified. Define View.tables" + message = "You must either specify View.tables or override View.get_tables_classes()" with self.assertRaisesMessage(ImproperlyConfigured, message): View.as_view()(build_request("/")) From 682455132d9545321328c765d6c56a5482c84702 Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 21 Jan 2023 12:31:12 +0000 Subject: [PATCH 07/12] Missed f string change --- django_tables2/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_tables2/views.py b/django_tables2/views.py index 86d0a0d9..aff2c776 100644 --- a/django_tables2/views.py +++ b/django_tables2/views.py @@ -210,7 +210,7 @@ def get_tables_classes(self): """ if self.tables is None: raise ImproperlyConfigured( - f"You must either specify {0}.tables or override {type(self).__name__}.get_tables_classes()" + f"You must either specify {type(self).__name__}.tables or override {type(self).__name__}.get_tables_classes()" ) return self.tables From 1ae9a6354ff6574dd0eade39861ce1b99789233c Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 21 Jan 2023 12:35:40 +0000 Subject: [PATCH 08/12] Add tables data to get_tables_classes test --- tests/test_views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_views.py b/tests/test_views.py index 540ad8d4..87be38c6 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -417,6 +417,7 @@ class View(tables.MultiTableMixin, TemplateView): def test_get_tables_clases_list(self): class View(tables.MultiTableMixin, TemplateView): + tables_data = (Person.objects.all(), Region.objects.all()) template_name = "multiple.html" def get_tables_classes(self): From 7907edb787fec55688d0cedf943292e2d0e441ed Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 21 Jan 2023 12:56:38 +0000 Subject: [PATCH 09/12] Run tests through black --- tests/test_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_views.py b/tests/test_views.py index 6b298db5..1c9faa3f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -421,13 +421,13 @@ class View(tables.MultiTableMixin, TemplateView): template_name = "multiple.html" def get_tables_classes(self): - return [TableA,TableB] + return [TableA, TableB] response = View.as_view()(build_request("/")) response.render() html = response.rendered_content - self.assertEqual(html.count(""),2) + self.assertEqual(html.count("
"), 2) def test_with_empty_get_tables_list(self): class View(tables.MultiTableMixin, TemplateView): From 8873e7ae2f107bf62a82a24c35603d0a3b8ca2b6 Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 21 Jan 2023 12:59:01 +0000 Subject: [PATCH 10/12] Remove long line in views.py --- django_tables2/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/django_tables2/views.py b/django_tables2/views.py index f7c51907..9a33d4c6 100644 --- a/django_tables2/views.py +++ b/django_tables2/views.py @@ -209,8 +209,9 @@ def get_tables_classes(self): Return the list of classes to use for the tables. """ if self.tables is None: + klass = type(self).__name__ raise ImproperlyConfigured( - f"You must either specify {type(self).__name__}.tables or override {type(self).__name__}.get_tables_classes()" + f"You must either specify {klass}.tables or override {klass}.get_tables_classes()" ) return self.tables From 60fff482113a9e5e588ee1381723022bf14bae84 Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 21 Jan 2023 13:02:32 +0000 Subject: [PATCH 11/12] Resync to remote/HEAD and reapply the get_tables_classes() change with requested cahnges. --- .github/workflows/ci.yml | 23 ++-- .gitignore | 1 + CHANGELOG.md | 9 ++ django_tables2/__init__.py | 2 +- django_tables2/columns/base.py | 25 ++-- django_tables2/columns/checkboxcolumn.py | 4 +- django_tables2/columns/emailcolumn.py | 2 +- django_tables2/columns/manytomanycolumn.py | 2 +- django_tables2/config.py | 2 +- django_tables2/data.py | 6 +- django_tables2/export/export.py | 4 +- django_tables2/export/views.py | 2 +- django_tables2/rows.py | 2 +- django_tables2/tables.py | 15 +- .../django_tables2/bootstrap4-responsive.html | 15 ++ .../templates/django_tables2/bootstrap5.html | 97 +++++++++++++ django_tables2/templatetags/django_tables2.py | 5 +- django_tables2/utils.py | 10 +- django_tables2/views.py | 57 +++++--- docs/pages/api-reference.rst | 128 +++++++++--------- docs/pages/column-attributes.rst | 2 +- docs/pages/custom-data.rst | 4 +- docs/pages/export.rst | 8 +- docs/pages/ordering.rst | 2 +- example/README.md | 2 +- example/app/models.py | 2 +- example/app/tables.py | 10 ++ example/app/views.py | 55 ++++---- example/requirements.txt | 11 +- example/settings.py | 1 + example/templates/bootstrap5_template.html | 36 +++++ example/urls.py | 8 +- requirements/common.pip | 14 +- setup.py | 5 +- tests/app/models.py | 4 +- tests/app/views.py | 8 +- tests/columns/test_booleancolumn.py | 2 +- tests/columns/test_datecolumn.py | 2 +- tests/columns/test_datetimecolumn.py | 2 +- tests/columns/test_general.py | 8 +- tests/columns/test_linkcolumn.py | 47 +++---- tests/columns/test_manytomanycolumn.py | 4 +- tests/test_core.py | 29 ++-- tests/test_export.py | 6 +- tests/test_faq.py | 2 +- tests/test_models.py | 25 ++-- tests/test_ordering.py | 2 +- tests/test_paginators.py | 6 +- tests/test_tabledata.py | 28 ++-- tests/test_templates.py | 15 +- tests/test_templatetags.py | 26 ++-- tests/test_utils.py | 13 +- tests/test_views.py | 96 +++++++++++-- 53 files changed, 563 insertions(+), 333 deletions(-) create mode 100644 django_tables2/templates/django_tables2/bootstrap4-responsive.html create mode 100644 django_tables2/templates/django_tables2/bootstrap5.html create mode 100644 example/templates/bootstrap5_template.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49703b9c..78b37897 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ jobs: black: runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v4.2.0 + - uses: actions/setup-python@v4.4.0 - uses: actions/checkout@v3 - run: python -m pip install --upgrade black - run: black --check . @@ -12,7 +12,7 @@ jobs: flake8: runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v4.2.0 + - uses: actions/setup-python@v4.4.0 - uses: actions/checkout@v3 - run: python -m pip install flake8 - run: flake8 @@ -20,7 +20,7 @@ jobs: isort: runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v4.2.0 + - uses: actions/setup-python@v4.4.0 with: python-version: 3.8 - uses: actions/checkout@v3 @@ -31,28 +31,25 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9, "3.10"] - django-version: [3.2, 4.0, 4.1rc1] + python-version: [3.7, 3.8, 3.9, "3.10", 3.11] + django-version: [3.2, 4.0, 4.1] exclude: - - python-version: 3.6 - django-version: 4.0 - - python-version: 3.6 - django-version: 4.1rc1 - python-version: 3.7 django-version: 4.0 - python-version: 3.7 - django-version: 4.1rc1 + django-version: 4.1 - python-version: "3.10" django-version: 3.2 - + - python-version: 3.11 + django-version: 3.2 steps: - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.2.0 + uses: actions/setup-python@v4.4.0 with: python-version: ${{ matrix.python-version }} - uses: actions/checkout@v3 - - uses: actions/cache@v3.0.7 + - uses: actions/cache@v3.2.3 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} diff --git a/.gitignore b/.gitignore index b93b57ea..a3ec78af 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ .idea *.sw[po] pip-wheel-metadata +.vscode \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3e9d00..3582159c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change log +# 2.5.1 (2023-01-07) + - `TableMixinBase`: implement `get_paginate_by` ([#811](https://github.com/jieter/django-tables2/pull/811)) by [@Alirezaja1384](https://github.com/Alirezaja1384) + +# 2.5.0 (2022-12-27) +- Dropped support for python 3.6, added support for python 3.11 +- Add django_tables2/bootstrap4-responsive.html ([#874](https://github.com/jieter/django-tables2/pull/874)) by [@botlabsDev](https://github.com/botlabsDev) +- Pass record/value to `LinkColumn`'s attrs callables too ([#852](https://github.com/jieter/django-tables2/pull/852)) by [@wsldankers](https://github.com/wsldankers) + - Add template `bootstrap5.html` to support bootstrap 5 ([#880](https://github.com/jieter/django-tables2/pull/880), fixes [#796](https://github.com/jieter/django-tables2/issues/796) + # 2.4.1 (2021-10-04) - Add Persian (Farsi) locale ([#806](https://github.com/jieter/django-tables2/pull/806)) by [@Alirezaja1384](https://github.com/jieter/django-tables2/commits?author=Alirezaja1384) - Improved error message if openpyxl is not installed ([#816](https://github.com/jieter/django-tables2/pull/816)) diff --git a/django_tables2/__init__.py b/django_tables2/__init__.py index 9e5310e8..dcd439b1 100644 --- a/django_tables2/__init__.py +++ b/django_tables2/__init__.py @@ -20,7 +20,7 @@ from .utils import A from .views import MultiTableMixin, SingleTableMixin, SingleTableView -__version__ = "2.4.1" +__version__ = "2.5.1" __all__ = ( "Table", diff --git a/django_tables2/columns/base.py b/django_tables2/columns/base.py index 75a04ae0..f5edf77f 100644 --- a/django_tables2/columns/base.py +++ b/django_tables2/columns/base.py @@ -25,9 +25,7 @@ def __init__(self): def register(self, column): if not hasattr(column, "from_field"): - raise ImproperlyConfigured( - "{} is not a subclass of Column".format(column.__class__.__name__) - ) + raise ImproperlyConfigured(f"{column.__class__.__name__} is not a subclass of Column") self.columns.append(column) return column @@ -77,6 +75,11 @@ def __init__(self, url=None, accessor=None, attrs=None, reverse_args=None): accessor (Accessor): if supplied, the accessor will be used to decide on which object ``get_absolute_url()`` is called. attrs (dict): Customize attributes for the ```` tag. + Values of the dict can be either static text or a + callable. The callable can optionally declare any subset + of the following keyword arguments: value, record, column, + bound_column, bound_row, table. These arguments will then + be passed automatically. reverse_args (dict, tuple): Arguments to ``django.urls.reverse()``. If dict, the arguments are assumed to be keyword arguments to ``reverse()``, if tuple, a ``(viewname, args)`` or ``(viewname, kwargs)`` @@ -112,9 +115,7 @@ def compose_url(self, **kwargs): context = record else: raise TypeError( - "for linkify=True, '{}' must have a method get_absolute_url".format( - str(context) - ) + f"for linkify=True, '{context}' must have a method get_absolute_url" ) return context.get_absolute_url() @@ -143,7 +144,7 @@ def resolve_if_accessor(val): return reverse(**params) def get_attrs(self, **kwargs): - attrs = AttributeDict(self.attrs or {}) + attrs = AttributeDict(computed_values(self.attrs or {}, kwargs=kwargs)) attrs["href"] = self.compose_url(**kwargs) return attrs @@ -277,9 +278,7 @@ def __init__( initial_sort_descending=False, ): if not (accessor is None or isinstance(accessor, str) or callable(accessor)): - raise TypeError( - "accessor must be a string or callable, not %s" % type(accessor).__name__ - ) + raise TypeError(f"accessor must be a string or callable, not {type(accessor).__name__}") if callable(accessor) and default is not None: raise TypeError("accessor must be string when default is used, not callable") self.accessor = Accessor(accessor) if accessor else None @@ -847,9 +846,7 @@ def __getitem__(self, index): if column.name == index: return column raise KeyError( - "Column with name '{}' does not exist; choices are: {}".format(index, self.names()) + f"Column with name '{index}' does not exist; choices are: {self.names()}" ) else: - raise TypeError( - "Column indices must be integers or str, not {}".format(type(index).__name__) - ) + raise TypeError(f"Column indices must be integers or str, not {type(index).__name__}") diff --git a/django_tables2/columns/checkboxcolumn.py b/django_tables2/columns/checkboxcolumn.py index 9543c3e6..caecb7f9 100644 --- a/django_tables2/columns/checkboxcolumn.py +++ b/django_tables2/columns/checkboxcolumn.py @@ -56,7 +56,7 @@ def header(self): general = self.attrs.get("input") specific = self.attrs.get("th__input") attrs = AttributeDict(default, **(specific or general or {})) - return mark_safe("" % attrs.as_html()) + return mark_safe(f"") def render(self, value, bound_column, record): default = {"type": "checkbox", "name": bound_column.name, "value": value} @@ -68,7 +68,7 @@ def render(self, value, bound_column, record): attrs = dict(default, **(specific or general or {})) attrs = computed_values(attrs, kwargs={"record": record, "value": value}) - return mark_safe("" % AttributeDict(attrs).as_html()) + return mark_safe(f"") def is_checked(self, value, record): """ diff --git a/django_tables2/columns/emailcolumn.py b/django_tables2/columns/emailcolumn.py index 7b737f4b..16561517 100644 --- a/django_tables2/columns/emailcolumn.py +++ b/django_tables2/columns/emailcolumn.py @@ -32,7 +32,7 @@ class PeopleTable(tables.Table): """ def get_url(self, value): - return "mailto:{}".format(value) + return f"mailto:{value}" @classmethod def from_field(cls, field, **kwargs): diff --git a/django_tables2/columns/manytomanycolumn.py b/django_tables2/columns/manytomanycolumn.py index f0e79e1c..30c9f0e0 100644 --- a/django_tables2/columns/manytomanycolumn.py +++ b/django_tables2/columns/manytomanycolumn.py @@ -34,7 +34,7 @@ class Person(models.Model): @property def name(self): - return '{} {}'.format(self.first_name, self.last_name) + return f"{self.first_name} {self.last_name}" # tables.py class PersonTable(tables.Table): diff --git a/django_tables2/config.py b/django_tables2/config.py index 250f064e..5b371382 100644 --- a/django_tables2/config.py +++ b/django_tables2/config.py @@ -47,7 +47,7 @@ def configure(self, table): kwargs = {} # extract some options from the request for arg in ("page", "per_page"): - name = getattr(table, "prefixed_%s_field" % arg) + name = getattr(table, f"prefixed_{arg}_field") try: kwargs[arg] = int(self.request.GET[name]) except (ValueError, KeyError): diff --git a/django_tables2/data.py b/django_tables2/data.py index be909c49..c4900c63 100644 --- a/django_tables2/data.py +++ b/django_tables2/data.py @@ -64,7 +64,7 @@ def from_data(data): raise ValueError( "data must be QuerySet-like (have count() and order_by()) or support" - " list(data) -- {} has neither".format(type(data).__name__) + f" list(data) -- {type(data).__name__} has neither" ) @@ -161,9 +161,7 @@ def set_table(self, table): super().set_table(table) if self.model and getattr(table._meta, "model", None) and self.model != table._meta.model: warnings.warn( - "Table data is of type {} but {} is specified in Table.Meta.model".format( - self.model, table._meta.model - ) + f"Table data is of type {self.model} but {table._meta.model} is specified in Table.Meta.model" ) @property diff --git a/django_tables2/export/export.py b/django_tables2/export/export.py index f745d7a9..49bda338 100644 --- a/django_tables2/export/export.py +++ b/django_tables2/export/export.py @@ -46,7 +46,7 @@ class TableExport: def __init__(self, export_format, table, exclude_columns=None, dataset_kwargs=None): if not self.is_valid_format(export_format): - raise TypeError('Export format "{}" is not supported.'.format(export_format)) + raise TypeError(f'Export format "{export_format}" is not supported.') self.format = export_format self.dataset = self.table_to_dataset(table, exclude_columns, dataset_kwargs) @@ -99,7 +99,7 @@ def response(self, filename=None): """ response = HttpResponse(content_type=self.content_type()) if filename is not None: - response["Content-Disposition"] = 'attachment; filename="{}"'.format(filename) + response["Content-Disposition"] = f'attachment; filename="{filename}"' response.write(self.export()) return response diff --git a/django_tables2/export/views.py b/django_tables2/export/views.py index cf9df33b..bb434f2e 100644 --- a/django_tables2/export/views.py +++ b/django_tables2/export/views.py @@ -36,7 +36,7 @@ class Table(tables.Table): export_formats = (TableExport.CSV,) def get_export_filename(self, export_format): - return "{}.{}".format(self.export_name, export_format) + return f"{self.export_name}.{export_format}" def get_dataset_kwargs(self): return self.dataset_kwargs diff --git a/django_tables2/rows.py b/django_tables2/rows.py index 6aa4de24..507484f0 100644 --- a/django_tables2/rows.py +++ b/django_tables2/rows.py @@ -150,7 +150,7 @@ def _get_and_render_with(self, bound_column, render_func, default): if isinstance(penultimate, models.Model): try: field = accessor.get_field(self.record) - display_fn = getattr(penultimate, "get_%s_display" % remainder, None) + display_fn = getattr(penultimate, f"get_{remainder}_display", None) if getattr(field, "choices", ()) and display_fn: value = display_fn() remainder = None diff --git a/django_tables2/tables.py b/django_tables2/tables.py index da36e2ac..ed5aa227 100644 --- a/django_tables2/tables.py +++ b/django_tables2/tables.py @@ -175,12 +175,11 @@ def _check_types(self, options, class_name): for key in keys: value = getattr(options, key, None) if value is not None and not isinstance(value, types): - expression = "{}.{} = {}".format(class_name, key, value.__repr__()) + expression = f"{class_name}.{key} = {value.__repr__()}" + allowed = ", ".join([t.__name__ for t in types]) raise TypeError( - "{} (type {}), but type must be one of ({})".format( - expression, type(value).__name__, ", ".join([t.__name__ for t in types]) - ) + f"{expression} (type {type(value).__name__}), but type must be one of ({allowed})" ) @@ -280,7 +279,7 @@ def __init__( # note that although data is a keyword argument, it used to be positional # so it is assumed to be the first argument to this method. if data is None: - raise TypeError("Argument data to {} is required".format(type(self).__name__)) + raise TypeError(f"Argument data to {type(self).__name__} is required") self.exclude = exclude or self._meta.exclude self.sequence = sequence @@ -605,15 +604,15 @@ def prefix(self, value): @property def prefixed_order_by_field(self): - return "%s%s" % (self.prefix, self.order_by_field) + return f"{self.prefix}{self.order_by_field}" @property def prefixed_page_field(self): - return "%s%s" % (self.prefix, self.page_field) + return f"{self.prefix}{self.page_field}" @property def prefixed_per_page_field(self): - return "%s%s" % (self.prefix, self.per_page_field) + return f"{self.prefix}{self.per_page_field}" @property def sequence(self): diff --git a/django_tables2/templates/django_tables2/bootstrap4-responsive.html b/django_tables2/templates/django_tables2/bootstrap4-responsive.html new file mode 100644 index 00000000..f84fdd15 --- /dev/null +++ b/django_tables2/templates/django_tables2/bootstrap4-responsive.html @@ -0,0 +1,15 @@ +{% extends 'django_tables2/bootstrap4.html' %} + +{% block table-wrapper %} +
+ {% block table %} + {{ block.super }} + {% endblock table %} + + {% block pagination %} + {% if table.page and table.paginator.num_pages > 1 %} + {{ block.super }} + {% endif %} + {% endblock pagination %} +
+{% endblock table-wrapper %} diff --git a/django_tables2/templates/django_tables2/bootstrap5.html b/django_tables2/templates/django_tables2/bootstrap5.html new file mode 100644 index 00000000..b3896e40 --- /dev/null +++ b/django_tables2/templates/django_tables2/bootstrap5.html @@ -0,0 +1,97 @@ +{% load django_tables2 %} +{% load i18n %} +{% block table-wrapper %} +
+ {% block table %} +
+ {% block table.thead %} + {% if table.show_header %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.thead %} + {% block table.tbody %} + + {% for row in table.paginated_rows %} + {% block table.tbody.row %} + + {% for column, cell in row.items %} + + {% endfor %} + + {% endblock table.tbody.row %} + {% empty %} + {% if table.empty_text %} + {% block table.tbody.empty_text %} + + {% endblock table.tbody.empty_text %} + {% endif %} + {% endfor %} + + {% endblock table.tbody %} + {% block table.tfoot %} + {% if table.has_footer %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.tfoot %} +
+ {% if column.orderable %} + {{ column.header }} + {% else %} + {{ column.header }} + {% endif %} +
{% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}
{{ table.empty_text }}
{{ column.footer }}
+ {% endblock table %} + + {% block pagination %} + {% if table.page and table.paginator.num_pages > 1 %} + + {% endif %} + {% endblock pagination %} + +{% endblock table-wrapper %} diff --git a/django_tables2/templatetags/django_tables2.py b/django_tables2/templatetags/django_tables2.py index b864a31e..cfcf5276 100644 --- a/django_tables2/templatetags/django_tables2.py +++ b/django_tables2/templatetags/django_tables2.py @@ -112,7 +112,7 @@ def querystring(parser, token): # ``bits`` should now be empty of a=b pairs, it should either be empty, or # have ``without`` arguments. if bits and bits.pop(0) != "without": - raise TemplateSyntaxError("Malformed arguments to '%s'" % tag) + raise TemplateSyntaxError(f"Malformed arguments to '{tag}'") removals = [parser.compile_filter(bit) for bit in bits] return QuerystringNode(updates, removals, asvar=asvar) @@ -141,8 +141,7 @@ def render(self, context): table = tables.table_factory(model=queryset.model)(queryset, request=request) else: - klass = type(table).__name__ - raise ValueError("Expected table or queryset, not {}".format(klass)) + raise ValueError(f"Expected table or queryset, not {type(table).__name__}") if self.template_name: template_name = self.template_name.resolve(context) diff --git a/django_tables2/utils.py b/django_tables2/utils.py index e06474ff..f0b419ca 100644 --- a/django_tables2/utils.py +++ b/django_tables2/utils.py @@ -72,9 +72,9 @@ def __new__(cls, value): instance = super().__new__(cls, value) if Accessor.LEGACY_SEPARATOR in value: message = ( - "Use '__' to separate path components, not '.' in accessor '{}'" + f"Use '__' to separate path components, not '.' in accessor '{value}'" " (fallback will be removed in django_tables2 version 3)." - ).format(value) + ) warnings.warn(message, DeprecationWarning, stacklevel=3) @@ -309,9 +309,9 @@ def __new__(cls, value): instance.SEPARATOR = cls.LEGACY_SEPARATOR message = ( - "Use '__' to separate path components, not '.' in accessor '{}'" + f"Use '__' to separate path components, not '.' in accessor '{value}'" " (fallback will be removed in django_tables2 version 3)." - ).format(value) + ) warnings.warn(message, DeprecationWarning, stacklevel=3) @@ -392,7 +392,7 @@ def resolve(self, context, safe=True, quiet=False): ) if callable(current): if safe and getattr(current, "alters_data", False): - raise ValueError(self.ALTERS_DATA_ERROR_FMT.format(method=repr(current))) + raise ValueError(self.ALTERS_DATA_ERROR_FMT.format(method=current.__name__)) if not getattr(current, "do_not_call_in_templates", False): current = current() # Important that we break in None case, or a relationship diff --git a/django_tables2/views.py b/django_tables2/views.py index adaa3b38..9a33d4c6 100644 --- a/django_tables2/views.py +++ b/django_tables2/views.py @@ -1,4 +1,5 @@ from itertools import count +from typing import Optional from django.core.exceptions import ImproperlyConfigured from django.views.generic.list import ListView @@ -38,10 +39,15 @@ def get_table_pagination(self, table): return False paginate = {} - if getattr(self, "paginate_by", None) is not None: - paginate["per_page"] = self.paginate_by + + # Obtains and set page size from get_paginate_by + paginate_by = self.get_paginate_by(table.data) + if paginate_by is not None: + paginate["per_page"] = paginate_by + if hasattr(self, "paginator_class"): paginate["paginator_class"] = self.paginator_class + if getattr(self, "paginate_orphans", 0) != 0: paginate["orphans"] = self.paginate_orphans @@ -55,6 +61,18 @@ def get_table_pagination(self, table): return paginate + def get_paginate_by(self, table_data) -> Optional[int]: + """ + Determines the number of items per page, or ``None`` for no pagination. + + Args: + table_data: The table's data. + + Returns: + Optional[int]: Items per page or ``None`` for no pagination. + """ + return getattr(self, "paginate_by", None) + class SingleTableMixin(TableMixinBase): """ @@ -92,9 +110,8 @@ def get_table_class(self): if self.model: return tables.table_factory(self.model) - raise ImproperlyConfigured( - "You must either specify {0}.table_class or {0}.model".format(type(self).__name__) - ) + name = type(self).__name__ + raise ImproperlyConfigured(f"You must either specify {name}.table_class or {name}.model") def get_table(self, **kwargs): """ @@ -118,10 +135,8 @@ def get_table_data(self): elif hasattr(self, "get_queryset"): return self.get_queryset() - klass = type(self).__name__ - raise ImproperlyConfigured( - "Table data was not specified. Define {}.table_data".format(klass) - ) + view_name = type(self).__name__ + raise ImproperlyConfigured(f"Table data was not specified. Define {view_name}.table_data") def get_table_kwargs(self): """ @@ -189,22 +204,32 @@ class MultiTableMixin(TableMixinBase): # override context table name to make sense in a multiple table context context_table_name = "tables" - def get_tables(self): + def get_tables_classes(self): """ - Return an array of table instances containing data. + Return the list of classes to use for the tables. """ if self.tables is None: klass = type(self).__name__ - raise ImproperlyConfigured("No tables were specified. Define {}.tables".format(klass)) + raise ImproperlyConfigured( + f"You must either specify {klass}.tables or override {klass}.get_tables_classes()" + ) + + return self.tables + + def get_tables(self, **kwargs): + """ + Return an array of table instances containing data. + """ + tables = self.get_tables_classes() data = self.get_tables_data() if data is None: - return self.tables + return tables - if len(data) != len(self.tables): + if len(data) != len(tables): klass = type(self).__name__ - raise ImproperlyConfigured("len({}.tables_data) != len({}.tables)".format(klass, klass)) - return list(Table(data[i]) for i, Table in enumerate(self.tables)) + raise ImproperlyConfigured(f"len({klass}.tables_data) != len({klass}.tables)") + return list(Table(data[i], **kwargs) for i, Table in enumerate(tables)) def get_tables_data(self): """ diff --git a/docs/pages/api-reference.rst b/docs/pages/api-reference.rst index 564e9c42..abe16979 100644 --- a/docs/pages/api-reference.rst +++ b/docs/pages/api-reference.rst @@ -95,7 +95,7 @@ API Reference name = tables.Column() class Meta: - attrs = {"id": lambda: "table_{}".format(next(counter))} + attrs = {"id": lambda: f"table_{next(counter)}"} .. note:: This functionality is also available via the ``attrs`` keyword @@ -169,92 +169,92 @@ API Reference to it. i.e. you can't use the constructor's ``exclude`` argument to *undo* an exclusion. - fields (`tuple`): Fields to show in the table. - Used in conjunction with `~.Table.Meta.model`, specifies which fields - should have columns in the table. If `None`, all fields are used, - otherwise only those named:: + fields (`tuple`): Fields to show in the table. + Used in conjunction with `~.Table.Meta.model`, specifies which fields + should have columns in the table. If `None`, all fields are used, + otherwise only those named:: - # models.py - class Person(models.Model): - first_name = models.CharField(max_length=200) - last_name = models.CharField(max_length=200) + # models.py + class Person(models.Model): + first_name = models.CharField(max_length=200) + last_name = models.CharField(max_length=200) - # tables.py - class PersonTable(tables.Table): - class Meta: - model = Person - fields = ("first_name", ) + # tables.py + class PersonTable(tables.Table): + class Meta: + model = Person + fields = ("first_name", ) - model (:class:`django.core.db.models.Model`): Create columns from model. - A model to inspect and automatically create corresponding columns. + model (:class:`django.core.db.models.Model`): Create columns from model. + A model to inspect and automatically create corresponding columns. - This option allows a Django model to be specified to cause the table to - automatically generate columns that correspond to the fields in a - model. + This option allows a Django model to be specified to cause the table to + automatically generate columns that correspond to the fields in a + model. - order_by (tuple or str): The default ordering tuple or comma separated str. - A hyphen `-` can be used to prefix a column name to indicate - *descending* order, for example: `('name', '-age')` or `name,-age`. + order_by (tuple or str): The default ordering tuple or comma separated str. + A hyphen `-` can be used to prefix a column name to indicate + *descending* order, for example: `('name', '-age')` or `name,-age`. - .. note:: + .. note:: - This functionality is also available via the ``order_by`` keyword - argument to a table's constructor. + This functionality is also available via the ``order_by`` keyword + argument to a table's constructor. - sequence (iterable): The sequence of the table columns. - This allows the default order of columns (the order they were defined - in the Table) to be overridden. + sequence (iterable): The sequence of the table columns. + This allows the default order of columns (the order they were defined + in the Table) to be overridden. - The special item `'...'` can be used as a placeholder that will be - replaced with all the columns that were not explicitly listed. This - allows you to add columns to the front or back when using inheritance. + The special item `'...'` can be used as a placeholder that will be + replaced with all the columns that were not explicitly listed. This + allows you to add columns to the front or back when using inheritance. - Example:: + Example:: - >>> class Person(tables.Table): - ... first_name = tables.Column() - ... last_name = tables.Column() - ... - ... class Meta: - ... sequence = ("last_name", "...") - ... - >>> Person.base_columns.keys() - ['last_name', 'first_name'] + >>> class Person(tables.Table): + ... first_name = tables.Column() + ... last_name = tables.Column() + ... + ... class Meta: + ... sequence = ("last_name", "...") + ... + >>> Person.base_columns.keys() + ['last_name', 'first_name'] - The ``'...'`` item can be used at most once in the sequence value. If - it is not used, every column *must* be explicitly included. For example in the - above example, ``sequence = ('last_name', )`` would be **invalid** - because neither ``"..."`` or ``"first_name"`` were included. + The ``'...'`` item can be used at most once in the sequence value. If + it is not used, every column *must* be explicitly included. For example in the + above example, ``sequence = ('last_name', )`` would be **invalid** + because neither ``"..."`` or ``"first_name"`` were included. - .. note:: + .. note:: - This functionality is also available via the ``sequence`` keyword - argument to a table's constructor. + This functionality is also available via the ``sequence`` keyword + argument to a table's constructor. - orderable (bool): Default value for column's *orderable* attribute. - If the table and column don't specify a value, a column's ``orderable`` - value will fall back to this. This provides an easy mechanism to disable - ordering on an entire table, without adding ``orderable=False`` to each - column in a table. + orderable (bool): Default value for column's *orderable* attribute. + If the table and column don't specify a value, a column's ``orderable`` + value will fall back to this. This provides an easy mechanism to disable + ordering on an entire table, without adding ``orderable=False`` to each + column in a table. - .. note:: + .. note:: - This functionality is also available via the ``orderable`` keyword - argument to a table's constructor. + This functionality is also available via the ``orderable`` keyword + argument to a table's constructor. - template_name (str): The name of template to use when rendering the table. + template_name (str): The name of template to use when rendering the table. - .. note:: + .. note:: - This functionality is also available via the ``template_name`` keyword - argument to a table's constructor. + This functionality is also available via the ``template_name`` keyword + argument to a table's constructor. - localize (tuple): Specifies which fields should be localized in the - table. Read :ref:`localization-control` for more information. + localize (tuple): Specifies which fields should be localized in the + table. Read :ref:`localization-control` for more information. - unlocalize (tuple): Specifies which fields should be unlocalized in - the table. Read :ref:`localization-control` for more information. + unlocalize (tuple): Specifies which fields should be unlocalized in + the table. Read :ref:`localization-control` for more information. Columns ------- diff --git a/docs/pages/column-attributes.rst b/docs/pages/column-attributes.rst index c8fe1674..774fa3bf 100644 --- a/docs/pages/column-attributes.rst +++ b/docs/pages/column-attributes.rst @@ -70,7 +70,7 @@ This `attrs` can also be defined when subclassing a column, to allow better reus } } def render(self, record): - return "{} {}".format(record.first_name, record.last_name) + return f"{record.first_name} {record.last_name}"" class Table(tables.Table): person = PersonColumn() diff --git a/docs/pages/custom-data.rst b/docs/pages/custom-data.rst index c7cde7c1..f28e0ff0 100644 --- a/docs/pages/custom-data.rst +++ b/docs/pages/custom-data.rst @@ -68,10 +68,10 @@ This example shows how to render the row number in the first row:: ... self.counter = itertools.count() ... ... def render_row_number(self): - ... return "Row %d" % next(self.counter) + ... return f"Row {next(self.counter)}" ... ... def render_id(self, value): - ... return "<%s>" % value + ... return f"<{value}>" ... >>> table = SimpleTable([{"age": 31, "id": 10}, {"age": 34, "id": 11}]) >>> print(", ".join(map(str, table.rows[0]))) diff --git a/docs/pages/export.rst b/docs/pages/export.rst index ebb338ca..0234a407 100644 --- a/docs/pages/export.rst +++ b/docs/pages/export.rst @@ -13,7 +13,7 @@ formats, you must install the `tablib `_ package: .. note:: For all supported formats (xls, xlsx, etc.), you must install additional dependencies: `Installing tablib: + + + django_tables2 with bootstrap 5 template example + {% bootstrap_css %} + + + +
+ {% block body %} + + Bootstrap 5 - tables docs | + Bootstrap 5 - pagination docs + +

django_tables2 with Bootstrap 5 template example

+ +
+ {% if filter %} +
+
+ {% bootstrap_form filter.form layout='inline' %} + {% bootstrap_button 'filter' %} +
+
+ {% endif %} +
+ {% render_table table %} +
+
+ {% endblock %} +
+ + diff --git a/example/urls.py b/example/urls.py index 96086f6e..7d09574b 100644 --- a/example/urls.py +++ b/example/urls.py @@ -7,14 +7,12 @@ ClassBased, FilteredPersonListView, MultipleTables, - bootstrap, - bootstrap4, + template_example, checkbox, country_detail, index, multiple, person_detail, - semantic, tutorial, ) @@ -26,9 +24,7 @@ path("class-based-filtered/", FilteredPersonListView.as_view(), name="filtertableview"), path("checkbox/", checkbox, name="checkbox"), path("tutorial/", tutorial, name="tutorial"), - path("bootstrap/", bootstrap, name="bootstrap"), - path("bootstrap4/", bootstrap4, name="bootstrap4"), - path("semantic/", semantic, name="semantic"), + path("template//", template_example, name="template_example"), path("admin/doc/", include("django.contrib.admindocs.urls")), path("admin/", admin.site.urls), path("country//", country_detail, name="country_detail"), diff --git a/requirements/common.pip b/requirements/common.pip index fe0f5743..b7fb4e28 100644 --- a/requirements/common.pip +++ b/requirements/common.pip @@ -1,9 +1,7 @@ # xml parsing -lxml==4.8.0 -pytz>0 -# pinning to 3.0.0, which is supported by python 3.6 and does not contain this bug: -# https://github.com/jazzband/tablib/pull/490 -tablib[xls,yaml]==3.0.0 -openpyxl==3.0.9 -psycopg2-binary==2.9.1 -django-filter==2.3.0 +lxml==4.9.2 +pytz==2022.7 +tablib[xls,yaml]==3.3.0 +openpyxl==3.0.10 +psycopg2-binary==2.9.5 +django-filter==22.1 diff --git a/setup.py b/setup.py index 0eb1ac9a..35767d82 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ url="https://github.com/jieter/django-tables2/", packages=find_packages(exclude=["tests.*", "tests", "example.*", "example", "docs"]), include_package_data=True, # declarations in MANIFEST.in - install_requires=["Django>=1.11"], + install_requires=["Django>=3.2"], extras_require={"tablib": ["tablib"]}, classifiers=[ "Development Status :: 5 - Production/Stable", @@ -27,17 +27,18 @@ "Framework :: Django", "Framework :: Django :: 3.2", "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries", ], diff --git a/tests/app/models.py b/tests/app/models.py index f9df59a3..4bf2cff7 100644 --- a/tests/app/models.py +++ b/tests/app/models.py @@ -48,7 +48,7 @@ def __str__(self): @property def name(self): - return "%s %s" % (self.first_name, self.last_name) + return f"{self.first_name} {self.last_name}" def get_absolute_url(self): return reverse("person", args=(self.pk,)) @@ -68,7 +68,7 @@ def __str__(self): return self.name def get_absolute_url(self): - return "/group/{}/".format(self.pk) + return f"/group/{self.pk}/" class Occupation(models.Model): diff --git a/tests/app/views.py b/tests/app/views.py index 67ca11dc..2e39d97e 100644 --- a/tests/app/views.py +++ b/tests/app/views.py @@ -7,12 +7,10 @@ def person(request, pk): """A really simple view to provide an endpoint for the 'person' URL.""" person = get_object_or_404(Person, pk=pk) - return HttpResponse("Person: %s" % person) + return HttpResponse(f"Person: {person}") def occupation(request, pk): - """ - Another really simple view to provide an endpoint for the 'occupation' URL. - """ + """Another really simple view to provide an endpoint for the 'occupation' URL.""" occupation = get_object_or_404(Occupation, pk=pk) - return HttpResponse("Occupation: %s" % occupation) + return HttpResponse(f"Occupation: {occupation}") diff --git a/tests/columns/test_booleancolumn.py b/tests/columns/test_booleancolumn.py index 3dc57a56..a61c9a94 100644 --- a/tests/columns/test_booleancolumn.py +++ b/tests/columns/test_booleancolumn.py @@ -89,7 +89,7 @@ class Table(tables.Table): col_linkify = tables.BooleanColumn( accessor="col", attrs={"span": {"key": "value"}}, - linkify=lambda value: "/bool/{}".format(value), + linkify=lambda value: f"/bool/{value}", ) table = Table([{"col": True}, {"col": False}]) diff --git a/tests/columns/test_datecolumn.py b/tests/columns/test_datecolumn.py index bab44554..73ff95d1 100644 --- a/tests/columns/test_datecolumn.py +++ b/tests/columns/test_datecolumn.py @@ -7,7 +7,7 @@ def isoformat_link(value): - return "/test/{}/".format(value.isoformat()) + return f"/test/{value.isoformat()}/" class DateColumnTest(SimpleTestCase): diff --git a/tests/columns/test_datetimecolumn.py b/tests/columns/test_datetimecolumn.py index 84a9db76..7539a9b3 100644 --- a/tests/columns/test_datetimecolumn.py +++ b/tests/columns/test_datetimecolumn.py @@ -9,7 +9,7 @@ def isoformat_link(value): - return "/test/{}/".format(value.isoformat()) + return f"/test/{value.isoformat()}/" class DateTimeColumnTest(SimpleTestCase): diff --git a/tests/columns/test_general.py b/tests/columns/test_general.py index f3d85299..841153ab 100644 --- a/tests/columns/test_general.py +++ b/tests/columns/test_general.py @@ -416,7 +416,7 @@ class MyTable(tables.Table): population = tables.Column(verbose_name="Population") def get_column_class_names(self, classes_set, bound_column): - classes_set.add("prefix-%s" % bound_column.name) + classes_set.add(f"prefix-{bound_column.name}") return classes_set TEST_DATA = [ @@ -441,7 +441,7 @@ def test_computable_td_attrs(self): class Table(tables.Table): person = tables.Column(attrs={"cell": {"data-length": lambda table: len(table.data)}}) first_name = tables.Column( - attrs={"td": {"class": lambda table: "status-{}".format(len(table.data))}} + attrs={"td": {"class": lambda table: f"status-{len(table.data)}"}} ) table = Table(Person.objects.all()) @@ -480,7 +480,7 @@ class PersonColumn(tables.Column): } def render(self, record): - return "{} {}".format(record.first_name, record.last_name) + return f"{record.first_name} {record.last_name}" class Table(tables.Table): person = PersonColumn(empty_values=()) @@ -509,7 +509,7 @@ class Table(tables.Table): attrs={ "cell": { "data-first-name": data_first_name, - "class": lambda value: "status-{}".format(value), + "class": lambda value: f"status-{value}", } } ) diff --git a/tests/columns/test_linkcolumn.py b/tests/columns/test_linkcolumn.py index fe794052..01d3da83 100644 --- a/tests/columns/test_linkcolumn.py +++ b/tests/columns/test_linkcolumn.py @@ -40,7 +40,7 @@ class CustomLinkTable(tables.Table): first_name = tables.LinkColumn("person", text="foo::bar", args=[A("pk")]) last_name = tables.LinkColumn( "person", - text=lambda row: "%s %s" % (row["last_name"], row["first_name"]), + text=lambda row: f'{row["last_name"]} {row["first_name"]}', args=[A("pk")], ) @@ -59,8 +59,8 @@ class CustomLinkTable(tables.Table): html = CustomLinkTable(dataset).as_html(build_request()) - expected = 'edit'.format(reverse("person", args=(1,))) - self.assertIn(expected, html) + url = reverse("person", args=(1,)) + self.assertIn(f'edit', html) def test_null_foreign_key(self): class PersonTable(tables.Table): @@ -89,10 +89,8 @@ class Table(tables.Table): table = Table(Person.objects.all()) - expected = 'delete'.format( - reverse("person_delete", kwargs={"pk": willem.pk}) - ) - self.assertEqual(table.rows[0].get_cell("delete_link"), expected) + url = reverse("person_delete", kwargs={"pk": willem.pk}) + self.assertEqual(table.rows[0].get_cell("delete_link"), f'delete') def test_kwargs(self): class PersonTable(tables.Table): @@ -123,19 +121,22 @@ class PersonTable(tables.Table): def test_a_attrs_should_be_supported(self): class TestTable(tables.Table): - col = tables.LinkColumn( - "occupation", kwargs={"pk": A("col")}, attrs={"a": {"title": "Occupation Title"}} - ) + attrs = {"a": {"title": "Occupation Title", "id": lambda record: str(record["id"])}} + col = tables.LinkColumn("occupation", kwargs={"pk": A("col")}, attrs=attrs) col_linkify = tables.Column( accessor="col", - attrs={"a": {"title": "Occupation Title"}}, + attrs=attrs, linkify=("occupation", {"pk": A("col")}), ) - table = TestTable([{"col": 0}]) + table = TestTable([{"col": 0, "id": 1}]) self.assertEqual( attrs(table.rows[0].get_cell("col")), - {"href": reverse("occupation", kwargs={"pk": 0}), "title": "Occupation Title"}, + { + "href": reverse("occupation", kwargs={"pk": 0}), + "title": "Occupation Title", + "id": "1", + }, ) self.assertEqual(table.rows[0].get_cell("col"), table.rows[0].get_cell("col_linkify")) @@ -153,7 +154,8 @@ class Table(tables.Table): table = Table(Person.objects.all()) a_tag = table.rows[0].get_cell("first_name") - self.assertIn('href="{}"'.format(reverse("person", args=(person.pk,))), a_tag) + url = reverse("person", args=(person.pk,)) + self.assertIn(f'href="{url}"', a_tag) self.assertIn('style="color: red;"', a_tag) self.assertIn(person.first_name, a_tag) @@ -177,7 +179,7 @@ class PersonTable(tables.Table): person = Person.objects.create(first_name="Jan Pieter", last_name="Waagmeester") table = PersonTable(Person.objects.all()) - expected = '{}'.format(person.get_absolute_url(), person.last_name) + expected = f'{person.last_name}' self.assertEqual(table.rows[0].cells["last_name"], expected) # Explicit LinkColumn and regular column using linkify should have equal output @@ -192,9 +194,10 @@ class Table(tables.Table): first_name = tables.Column() last_name = tables.LinkColumn() - table = Table([dict(first_name="Jan Pieter", last_name="Waagmeester")]) + table = Table([{"first_name": "Jan Pieter", "last_name": "Waagmeester"}]) - with self.assertRaises(TypeError): + message = "for linkify=True, 'Waagmeester' must have a method get_absolute_url" + with self.assertRaisesMessage(TypeError, message): table.as_html(build_request()) def test_RelatedLinkColumn(self): @@ -207,10 +210,8 @@ class Table(tables.Table): table = Table(Person.objects.all()) - self.assertEqual( - table.rows[0].cells["occupation"], - 'Carpenter'.format(reverse("occupation", args=[carpenter.pk])), - ) + url = reverse("occupation", args=[carpenter.pk]) + self.assertEqual(table.rows[0].cells["occupation"], f'Carpenter') def test_RelatedLinkColumn_without_model(self): class Table(tables.Table): @@ -218,8 +219,8 @@ class Table(tables.Table): table = Table([{"occupation": "Fabricator"}]) - msg = "for linkify=True, 'Fabricator' must have a method get_absolute_url" - with self.assertRaisesMessage(TypeError, msg): + message = "for linkify=True, 'Fabricator' must have a method get_absolute_url" + with self.assertRaisesMessage(TypeError, message): table.rows[0].cells["occupation"] def test_value_returns_a_raw_value_without_html(self): diff --git a/tests/columns/test_manytomanycolumn.py b/tests/columns/test_manytomanycolumn.py index 83fe35e9..d45a8f76 100644 --- a/tests/columns/test_manytomanycolumn.py +++ b/tests/columns/test_manytomanycolumn.py @@ -84,7 +84,7 @@ class GroupTable(tables.Table): row = GroupTable(Group.objects.all()).rows[0] self.assertEqual( row.get_cell("name"), - '{}'.format(self.developers.pk, self.developers.name), + f'{self.developers.name}', ) self.assertEqual( row.get_cell("members"), @@ -99,7 +99,7 @@ class OccupationTable(tables.Table): row = OccupationTable(Occupation.objects.all()).rows[0] self.assertEqual( row.get_cell("name"), - '{}'.format(self.carpenter.pk, self.carpenter.name), + f'{self.carpenter.name}', ) self.assertEqual( row.get_cell("people"), diff --git a/tests/test_core.py b/tests/test_core.py index adb1ea4c..520d17e6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -30,7 +30,7 @@ class UnorderedTable(tables.Table): class CoreTest(SimpleTestCase): def test_omitting_data(self): - with self.assertRaises(TypeError): + with self.assertRaisesMessage(TypeError, "Argument data to UnorderedTable is required"): UnorderedTable() def test_column_named_items(self): @@ -114,21 +114,24 @@ class FlippedTweakedTableBase(tables.Table): self.assertTrue(table.tweaked) def test_Meta_attribute_incorrect_types(self): - with self.assertRaises(TypeError): + message = "Table1.exclude = 'foo' (type str), but type must be one of (tuple, list, set)" + with self.assertRaisesMessage(TypeError, message): - class MetaTable1(tables.Table): + class Table1(tables.Table): class Meta: exclude = "foo" - with self.assertRaises(TypeError): + message = "Table2.sequence = '...' (type str), but type must be one of (tuple, list, set)" + with self.assertRaisesMessage(TypeError, message): - class MetaTable2(tables.Table): + class Table2(tables.Table): class Meta: sequence = "..." - with self.assertRaises(TypeError): + message = "Table3.model = {} (type dict), but type must be one of (ModelBase)" + with self.assertRaisesMessage(TypeError, message): - class MetaTable3(tables.Table): + class Table3(tables.Table): class Meta: model = {} @@ -162,7 +165,7 @@ def test_attrs_support_computed_values(self): class TestTable(tables.Table): class Meta: - attrs = {"id": lambda: "test_table_%d" % next(counter)} + attrs = {"id": lambda: f"test_table_{next(counter)}"} self.assertEqual('id="test_table_0"', TestTable([]).attrs.as_html()) self.assertEqual('id="test_table_1"', TestTable([]).attrs.as_html()) @@ -307,7 +310,7 @@ class BookTable(tables.Table): name = tables.Column() # create some sample data - data = list([{"name": "Book No. %d" % i} for i in range(100)]) + data = list([{"name": f"Book No. {i}"} for i in range(100)]) books = BookTable(data) # external paginator @@ -330,10 +333,10 @@ class BookTable(tables.Table): self.assertTrue(books.page.has_next()) # accessing a non-existant page raises 404 - with self.assertRaises(EmptyPage): + with self.assertRaisesMessage(EmptyPage, "That page contains no results"): books.paginate(Paginator, page=9999, per_page=10) - with self.assertRaises(PageNotAnInteger): + with self.assertRaisesMessage(PageNotAnInteger, "That page number is not an integer"): books.paginate(Paginator, page="abc", per_page=10) def test_pagination_shouldnt_prevent_multiple_rendering(self): @@ -683,7 +686,7 @@ class Table(tables.Table): beta = tables.Column() table = Table( - MEMORY_DATA, row_attrs={"class": lambda table, record: "row-id-{}".format(record["i"])} + MEMORY_DATA, row_attrs={"class": lambda table, record: f"row-id-{record['i']}"} ) self.assertEqual(table.rows[0].attrs, {"class": "row-id-2 even"}) @@ -694,7 +697,7 @@ class Table(tables.Table): beta = tables.Column() class Meta: - row_attrs = {"class": lambda record: "row-id-{}".format(record["i"])} + row_attrs = {"class": lambda record: f"row-id-{record['i']}"} table = Table(MEMORY_DATA) self.assertEqual(table.rows[0].attrs, {"class": "row-id-2 even"}) diff --git a/tests/test_export.py b/tests/test_export.py index 608d18d6..2f9068b5 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -153,7 +153,7 @@ def test_view_should_support_csv_export(self): def test_should_raise_error_for_unsupported_file_type(self): table = Table([]) - with self.assertRaises(TypeError): + with self.assertRaisesMessage(TypeError, 'Export format "exe" is not supported.'): TableExport(table=table, export_format="exe") def test_should_support_json_export(self): @@ -194,7 +194,7 @@ def table_view(request): export_format = request.GET.get("_export", None) if TableExport.is_valid_format(export_format): exporter = TableExport(export_format, table) - return exporter.response("table.{}".format(export_format)) + return exporter.response(f"table.{export_format}") return render(request, "django_tables2/table.html", {"table": table}) @@ -369,7 +369,7 @@ def test_exporting_unicode_data(self): unicode_name = "木匠" Occupation.objects.create(name=unicode_name) - expected_csv = "Name,Boolean,Region\r\n{},,\r\n".format(unicode_name) + expected_csv = f"Name,Boolean,Region\r\n{unicode_name},,\r\n" response = OccupationView.as_view()(build_request("/?_export=csv")) self.assertEqual(response.getvalue().decode("utf8"), expected_csv) diff --git a/tests/test_faq.py b/tests/test_faq.py index e9167597..51c02856 100644 --- a/tests/test_faq.py +++ b/tests/test_faq.py @@ -32,7 +32,7 @@ def test_row_footer_total(self): class CountryTable(tables.Table): name = tables.Column() population = tables.Column( - footer=lambda table: "Total: {}".format(sum(x["population"] for x in table.data)) + footer=lambda table: f'Total: {sum(x["population"] for x in table.data)}' ) table = CountryTable(TEST_DATA) diff --git a/tests/test_models.py b/tests/test_models.py index 1c4dc7d7..0a8f5908 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -54,12 +54,10 @@ class Meta: PersonProxy, ContentType.objects.get(model="person").model_class(), ] - invalid = [object, {}, dict] - for Model in valid: test(Model) - for Model in invalid: + for Model in [object, {}, dict]: with self.assertRaises(TypeError): test(Model) @@ -245,10 +243,8 @@ class Meta: table = PersonTable(Person.objects.all()) html = table.as_html(build_request()) - self.assertIn('{}'.format(person.pk, person.name), html) - self.assertIn( - ''.format(person.pk), html - ) + self.assertIn(f'{person.name}', html) + self.assertIn(f'', html) def test_meta_linkify_dict(self): person = Person.objects.first() @@ -267,21 +263,18 @@ class Meta: table = PersonTable(Person.objects.all()) html = table.as_html(build_request()) - self.assertIn('{}'.format(person.get_absolute_url(), person.name), html) - self.assertIn( - '{}'.format(occupation.get_absolute_url(), occupation.name), html - ) + self.assertIn(f'{person.name}', html) + self.assertIn(f'{occupation.name}', html) self.assertIn( - ''.format(occupation.get_absolute_url()), - html, + f'', html ) class ColumnNameTest(TestCase): def setUp(self): for i in range(10): - Person.objects.create(first_name="Bob %d" % i, last_name="Builder") + Person.objects.create(first_name=f"Bob {i}", last_name="Builder") def test_column_verbose_name(self): """ @@ -504,7 +497,7 @@ class ModelSanityTest(TestCase): def setUpClass(cls): super().setUpClass() for i in range(10): - Person.objects.create(first_name="Bob %d" % i, last_name="Builder") + Person.objects.create(first_name=f"Bob {i}", last_name="Builder") def test_column_with_delete_accessor_shouldnt_delete_records(self): class PersonTable(tables.Table): @@ -527,7 +520,7 @@ def counting__str__(self): with mock.patch("tests.app.models.Person.__str__", counting__str__): for i in range(1, 4): - Person.objects.create(first_name="Bob %d" % i, last_name="Builder") + Person.objects.create(first_name=f"Bob {i}", last_name="Builder") class PersonTable(tables.Table): edit = tables.Column() diff --git a/tests/test_ordering.py b/tests/test_ordering.py index cd812e7b..57e10190 100644 --- a/tests/test_ordering.py +++ b/tests/test_ordering.py @@ -151,7 +151,7 @@ class PersonTable(tables.Table): # add 'name' key for each person. for person in PEOPLE: - person["name"] = "{p[first_name]} {p[last_name]}".format(p=person) + person["name"] = f"{person['first_name']} {person['last_name']}" self.assertEqual(brad["name"], "Bradley Ayers") table = PersonTable(PEOPLE, order_by="name") diff --git a/tests/test_paginators.py b/tests/test_paginators.py index d50c40f2..89177f3d 100644 --- a/tests/test_paginators.py +++ b/tests/test_paginators.py @@ -41,10 +41,10 @@ def test_no_count_call(self): # and again decreases when a lower page nu self.assertEqual(paginator.num_pages, 2) - with self.assertRaises(PageNotAnInteger): + with self.assertRaisesMessage(PageNotAnInteger, "That page number is not an integer"): paginator.page(1.5) - with self.assertRaises(EmptyPage): + with self.assertRaisesMessage(EmptyPage, "That page number is less than 1"): paginator.page(-1) with self.assertRaises(NotImplementedError): @@ -57,7 +57,7 @@ def test_no_count_call(self): last_page_number = 10**5 paginator.page(last_page_number) - with self.assertRaises(EmptyPage): + with self.assertRaisesMessage(EmptyPage, "That page contains no results"): paginator.page(last_page_number + 1) def test_lookahead(self): diff --git a/tests/test_tabledata.py b/tests/test_tabledata.py index 594da4ca..74dd7a82 100644 --- a/tests/test_tabledata.py +++ b/tests/test_tabledata.py @@ -10,27 +10,21 @@ class TableDataFactoryTest(TestCase): - def test_invalid_data_None(self): - with self.assertRaises(ValueError): - TableData.from_data(None) + def test_invalid_data(self): + message = "data must be QuerySet-like (have count() and order_by()) or support list(data)" - def test_invalid_data_int(self): - with self.assertRaises(ValueError): - TableData.from_data(1) - - def test_invalid_data_classes(self): class Klass: pass - with self.assertRaises(ValueError): - TableData.from_data(Klass()) - class Bad: def __len__(self): pass - with self.assertRaises(ValueError): - TableData.from_data(Bad()) + invalid = [None, 1, Klass(), Bad()] + + for data in invalid: + with self.subTest(data=data), self.assertRaisesMessage(ValueError, message): + TableData.from_data(data) def test_valid_QuerySet(self): data = TableData.from_data(Person.objects.all()) @@ -108,8 +102,8 @@ class listlike(list): class TableQuerysetDataTest(TestCase): def test_custom_TableData(self): """If TableQuerysetData._length is set, no count() query will be performed""" - for i in range(20): - Person.objects.create(first_name="first {}".format(i)) + for i in range(11): + Person.objects.create(first_name=f"first {i}") data = TableQuerysetData(Person.objects.all()) data._length = 10 @@ -127,8 +121,8 @@ class Meta: def test_queryset_union(self): for i in range(10): - Person.objects.create(first_name="first {}".format(i), last_name="foo") - Person.objects.create(first_name="first {}".format(i * 2), last_name="bar") + Person.objects.create(first_name=f"first {i}", last_name="foo") + Person.objects.create(first_name=f"first {i * 2}", last_name="bar") class MyTable(Table): class Meta: diff --git a/tests/test_templates.py b/tests/test_templates.py index 1af7c15e..99da44a7 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -158,7 +158,7 @@ class TestTable(tables.Table): def assert_table_localization(self, TestTable, expected): html = TestTable(self.simple_test_data).as_html(build_request()) - self.assertIn("{0}".format(self.expected_results[expected]), html) + self.assertIn(f"{self.expected_results[expected]}", html) def test_localization_check(self): self.assert_cond_localized_table(None, None) @@ -353,9 +353,9 @@ class ValidHTMLTest(SimpleTestCase): def test_templates(self): parser = etree.HTMLParser() - for name in ("table", "semantic", "bootstrap", "bootstrap4"): + for name in ("table", "semantic", "bootstrap", "bootstrap4", "bootstrap5"): table = CountryTable( - list([MEMORY_DATA] * 10), template_name="django_tables2/{}.html".format(name) + list([MEMORY_DATA] * 10), template_name=f"django_tables2/{name}.html" ).paginate(per_page=5) html = Template(self.template).render( @@ -378,11 +378,6 @@ def test_templates(self): min(error.line + self.context_lines, len(lines)), ) context = "\n".join( - [ - "{}: {}".format(i, line) - for i, line in zip(range(start + 1, end + 1), lines[start:end]) - ] - ) - raise AssertionError( - "template: {}; {} \n {}".format(table.template_name, str(error), context) + [f"{i}: {line}" for i, line in zip(range(start + 1, end + 1), lines[start:end])] ) + raise AssertionError(f"template: {table.template_name}; {error} \n {context}") diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index e3fadf44..46466a8d 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -19,7 +19,7 @@ class RenderTableTagTest(TestCase): def test_invalid_type(self): template = Template("{% load django_tables2 %}{% render_table table %}") - with self.assertRaises(ValueError): + with self.assertRaisesMessage(ValueError, "Expected table or queryset, not dict"): template.render(Context({"request": build_request(), "table": dict()})) def test_basic(self): @@ -98,17 +98,14 @@ def test_no_data_with_empty_text(self): @override_settings(DEBUG=True) def test_missing_variable(self): - # variable that doesn't exist (issue #8) + """Variable that doesn't exist (issue #8)""" template = Template("{% load django_tables2 %}{% render_table this_doesnt_exist %}") - with self.assertRaises(ValueError): + with self.assertRaisesMessage(ValueError, "Expected table or queryset, not str"): template.render(Context()) - @override_settings(DEBUG=False) - def test_missing_variable_debug_False(self): - template = Template("{% load django_tables2 %}{% render_table this_doesnt_exist %}") - # Should still be noisy with debug off - with self.assertRaises(ValueError): - template.render(Context()) + with self.subTest("Also works with DEBUG=False"), override_settings(DEBUG=False): + with self.assertRaisesMessage(ValueError, "Expected table or queryset, not str"): + template.render(Context()) def test_should_support_template_argument(self): table = CountryTable(MEMORY_DATA, order_by=("name", "population")) @@ -170,7 +167,8 @@ def test_basic(self): def test_requires_request(self): template = Template('{% load django_tables2 %}{% querystring "name"="Brad" %}') - with self.assertRaises(ImproperlyConfigured): + message = "Tag {% querystring %} requires django.template.context_processors.request to " + with self.assertRaisesMessage(ImproperlyConfigured, message): template.render(Context()) def test_supports_without(self): @@ -193,17 +191,15 @@ def test_only_without(self): self.assertEqual(set(qs.keys()), set(["c"])) def test_querystring_syntax_error(self): - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage(TemplateSyntaxError, "Malformed arguments to 'querystring'"): Template("{% load django_tables2 %}{% querystring foo= %}") def test_querystring_as_var(self): def assert_querystring_asvar(template_code, expected): template = Template( "{% load django_tables2 %}" - + "{% querystring " - + template_code - + " %}" - + "{{ varname }}" + "{% querystring " + template_code + " %}" + "{{ varname }}" ) # Should be something like: ?name=Brad&a=b&c=5&age=21 diff --git a/tests/test_utils.py b/tests/test_utils.py index fbeb023f..8c456ad0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,5 @@ -from unittest import TestCase - from django.db import models +from django.test import TestCase from django_tables2.utils import ( Accessor, @@ -20,7 +19,7 @@ def test_basic(self): obt = OrderByTuple(("a", "b", "c")) assert obt == (OrderBy("a"), OrderBy("b"), OrderBy("c")) - def test_intexing(self): + def test_indexing(self): obt = OrderByTuple(("a", "b", "c")) assert obt[0] == OrderBy("a") assert obt["b"] == OrderBy("b") @@ -114,7 +113,7 @@ def delete(self): delete.alters_data = True foo = Foo() - with self.assertRaises(ValueError): + with self.assertRaisesMessage(ValueError, "Refusing to call delete() because"): Accessor("delete").resolve(foo) self.assertFalse(foo.deleted) @@ -180,9 +179,7 @@ def test_supports_nested_structures(self): self.assertEqual(x, {"foo": {"bar": "baz"}}) def test_with_argument(self): - x = computed_values( - {"foo": lambda y: {"bar": lambda y: "baz-{}".format(y)}}, kwargs=dict(y=2) - ) + x = computed_values({"foo": lambda y: {"bar": lambda y: f"baz-{y}"}}, kwargs=dict(y=2)) self.assertEqual(x, {"foo": {"bar": "baz-2"}}) def test_returns_None_if_not_enough_kwargs(self): @@ -202,7 +199,7 @@ class SequenceTest(TestCase): def test_multiple_ellipsis(self): sequence = Sequence(["foo", "...", "bar", "..."]) - with self.assertRaises(ValueError): + with self.assertRaisesMessage(ValueError, "'...' must be used at most once in a sequence."): sequence.expand(["foo"]) diff --git a/tests/test_views.py b/tests/test_views.py index 622e8132..1c9faa3f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,3 +1,5 @@ +from math import ceil +from typing import Optional import django_filters as filters from django.core.exceptions import ImproperlyConfigured from django.test import TestCase @@ -60,7 +62,7 @@ class SimplePaginatedView(tables.SingleTableView): def test_should_support_default_pagination(self): total_records = 50 for i in range(1, total_records + 1): - Region.objects.create(name="region {:02d} / {}".format(i, total_records)) + Region.objects.create(name=f"region {i:02d} / {total_records}") expected_per_page = 25 @@ -83,8 +85,8 @@ def get_table_data(self): # in addition to New South Wales, Queensland, Tasmania and Victoria, # 21 records should be displayed. - self.assertContains(response, "21 / {}".format(total_records)) - self.assertNotContains(response, "22 / {}".format(total_records)) + self.assertContains(response, f"21 / {total_records}") + self.assertNotContains(response, f"22 / {total_records}") def test_should_support_default_pagination_with_table_options(self): class Table(tables.Table): @@ -175,6 +177,33 @@ class View(tables.SingleTableView): self.assertIsInstance(paginator, tables.LazyPaginator) self.assertEqual(paginator.orphans, 10) + def test_get_paginate_by_has_priority_over_paginate_by(self): + class View(tables.SingleTableView): + table_class = tables.Table + queryset = Region.objects.all() + # This paginate_by should be ignored because get_paginated_by is overridden + paginate_by = 4 + + def get_paginate_by(self, queryset): + # Should paginate by 10 + return 10 + + response = View.as_view()(build_request()) + self.assertEqual(response.context_data["table"].paginator.per_page, 10) + + def test_pass_queryset_to_get_paginate_by(self): + # defined in paginator_class + class View(tables.SingleTableView): + table_class = tables.Table + queryset = Region.objects.all() + + def get_paginate_by(self, table_data): + # Should split table items into 2 pages + return ceil(len(table_data) / 2) + + response = View.as_view()(build_request()) + self.assertEqual(response.context_data["table"].paginator.num_pages, 2) + def test_should_pass_kwargs_to_table_constructor(self): class PassKwargsView(SimpleView): table_data = [] @@ -203,7 +232,7 @@ class PaginationOverrideView(PrefixedView): def get_table_pagination(self, table): assert isinstance(table, tables.Table) - per_page = self.request.GET.get("%s_override" % table.prefixed_per_page_field) + per_page = self.request.GET.get(f"{table.prefixed_per_page_field}_override") if per_page is not None: return {"per_page": per_page} return super().get_table_pagination(table) @@ -252,12 +281,13 @@ class SimpleNoTableClassView(tables.SingleTableView): self.assertEqual(table_class.__name__, "RegionAutogeneratedTable") def test_get_tables_class_raises_no_model(self): - class SimpleNoTableClassNoModelView(tables.SingleTableView): + class Table(tables.SingleTableView): model = None table_class = None - view = SimpleNoTableClassNoModelView() - with self.assertRaises(ImproperlyConfigured): + view = Table() + message = "You must either specify Table.table_class or Table.model" + with self.assertRaisesMessage(ImproperlyConfigured, message): view.get_table_class() @@ -288,7 +318,7 @@ def test_should_paginate_by_default(self): total_records = 60 for i in range(1, total_records + 1): - Region.objects.create(name="region {i:02d} / {total_records}".format(**locals())) + Region.objects.create(name=f"region {i:02d} / {total_records}") expected_per_page = 25 @@ -381,14 +411,42 @@ def test_without_tables(self): class View(tables.MultiTableMixin, TemplateView): template_name = "multiple.html" - with self.assertRaises(ImproperlyConfigured): + message = "You must either specify View.tables or override View.get_tables_classes()" + with self.assertRaisesMessage(ImproperlyConfigured, message): View.as_view()(build_request("/")) + def test_get_tables_clases_list(self): + class View(tables.MultiTableMixin, TemplateView): + tables_data = (Person.objects.all(), Region.objects.all()) + template_name = "multiple.html" + + def get_tables_classes(self): + return [TableA, TableB] + + response = View.as_view()(build_request("/")) + response.render() + + html = response.rendered_content + self.assertEqual(html.count(""), 2) + def test_with_empty_get_tables_list(self): class View(tables.MultiTableMixin, TemplateView): template_name = "multiple.html" - def get_tables(self): + def get_tables(self, **kwargs): + return [] + + response = View.as_view()(build_request("/")) + response.render() + + html = response.rendered_content + self.assertIn("

Multiple tables using MultiTableMixin

", html) + + def test_with_empty_get_tables_clases_list(self): + class View(tables.MultiTableMixin, TemplateView): + template_name = "multiple.html" + + def get_tables_classes(self): return [] response = View.as_view()(build_request("/")) @@ -414,7 +472,8 @@ class View(tables.MultiTableMixin, TemplateView): tables_data = (Person.objects.all(),) template_name = "multiple.html" - with self.assertRaises(ImproperlyConfigured): + message = "len(View.tables_data) != len(View.tables)" + with self.assertRaisesMessage(ImproperlyConfigured, message): View.as_view()(build_request("/")) def test_table_pagination(self): @@ -443,6 +502,21 @@ class View(tables.MultiTableMixin, TemplateView): self.assertEqual(tableA.page.number, 1) self.assertEqual(tableB.page.number, 3) + def test_pass_table_data_to_get_paginate_by(self): + class View(tables.MultiTableMixin, TemplateView): + template_name = "multiple.html" + tables = (TableB, TableB) + tables_data = (Region.objects.all(), Region.objects.all()) + + def get_paginate_by(self, table_data) -> Optional[int]: + # Split data into 3 pages + return ceil(len(table_data) / 3) + + response = View.as_view()(build_request()) + + for table in response.context_data["tables"]: + self.assertEqual(table.paginator.num_pages, 3) + def test_get_tables_data(self): class View(tables.MultiTableMixin, TemplateView): tables = (TableA, TableB) From 09c7c72baf9a8de7b09f173ba15cba325ea21c9e Mon Sep 17 00:00:00 2001 From: Gavin Burnell Date: Sat, 21 Jan 2023 13:09:08 +0000 Subject: [PATCH 12/12] Fix misnamed test --- tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_views.py b/tests/test_views.py index 1c9faa3f..b7c0e2c9 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -415,7 +415,7 @@ class View(tables.MultiTableMixin, TemplateView): with self.assertRaisesMessage(ImproperlyConfigured, message): View.as_view()(build_request("/")) - def test_get_tables_clases_list(self): + def test_get_tables_classes_list(self): class View(tables.MultiTableMixin, TemplateView): tables_data = (Person.objects.all(), Region.objects.all()) template_name = "multiple.html"