diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63b0e49b..58dd08e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,10 +28,6 @@ jobs: uses: actions/setup-python@v4 with: python-version: "3.9" - cache: pip - cache-dependency-path: | - requirements/base.txt - requirements/dev.txt - name: Run pre-commit uses: pre-commit/action@v3.0.0 @@ -41,7 +37,7 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - max-parallel: 5 + max-parallel: 10 matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] @@ -59,7 +55,15 @@ jobs: python -m pip install --upgrade tox tox-gh-actions - name: Test with tox - run: tox + # Sometimes tox fails to build the package correctly and intermittently throws an OSError or BadZipFile error. + # Upon retry, it seems to work. + uses: nick-fields/retry@v2 + id: retry-sqlite + with: + timeout_minutes: 10 + max_attempts: 3 + retry_on: error + command: tox env: DBBACKEND: sqlite3 DBNAME: ":memory:" @@ -75,10 +79,10 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - max-parallel: 5 + max-parallel: 10 matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] - mariadb-version: ["10.3", "10.5"] + mariadb-version: ["10.4"] services: database: @@ -103,19 +107,16 @@ jobs: python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - - name: Test with tox - 10.3 - if: ${{ matrix.mariadb-version == '10.3' }} - run: tox --skip-env ".+?-django42-mysql" - env: - DBBACKEND: mysql - DBNAME: test - DBUSER: root - DBPASSWORD: rootpw - DBHOST: 127.0.0.1 - - - name: Test with tox - not 10.3 - if: ${{ matrix.mariadb-version != '10.3' }} - run: tox + - name: Test with tox + # Sometimes tox fails to build the package correctly and intermittently throws an OSError or BadZipFile error. + # Upon retry, it seems to work. + uses: nick-fields/retry@v2 + id: retry-mysql + with: + timeout_minutes: 10 + max_attempts: 3 + retry_on: error + command: tox env: DBBACKEND: mysql DBNAME: test @@ -143,7 +144,6 @@ jobs: - run: python -m pip install --upgrade coverage[toml] django==3.2.16 django-coverage-plugin - - name: Download coverage data. uses: actions/download-artifact@v3 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6a6798..9b03d490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change log +## Devel + +* Add filtering in list views. + ## 0.18 (2023-10-03) * Include a workspace_data_object context variable for the `WorkspaceDetail` and `WorkspaceUpdate` views. diff --git a/anvil_consortium_manager/__init__.py b/anvil_consortium_manager/__init__.py index a506fc88..c59266c2 100644 --- a/anvil_consortium_manager/__init__.py +++ b/anvil_consortium_manager/__init__.py @@ -1 +1 @@ -__version__ = "0.18" +__version__ = "0.19dev1" diff --git a/anvil_consortium_manager/adapters/account.py b/anvil_consortium_manager/adapters/account.py index 3d35f9db..097d090d 100644 --- a/anvil_consortium_manager/adapters/account.py +++ b/anvil_consortium_manager/adapters/account.py @@ -5,6 +5,9 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string +from django_filters import FilterSet + +from .. import models class BaseAccountAdapter(ABC): @@ -15,6 +18,11 @@ def list_table_class(self): """Table class to use in a list of Accounts.""" ... + @abstractproperty + def list_filterset_class(self): + """FilterSet subclass to use for Account filtering in the AccountList view.""" + ... + def get_list_table_class(self): """Return the table class to use for the AccountList view.""" if not self.list_table_class: @@ -23,6 +31,23 @@ def get_list_table_class(self): ) return self.list_table_class + def get_list_filterset_class(self): + """Return the FilterSet subclass to use for Account filtering in the AccountList view.""" + if not self.list_filterset_class: + raise ImproperlyConfigured( + "Set `list_filterset_class` in `{}`.".format(type(self)) + ) + if not issubclass(self.list_filterset_class, FilterSet): + raise ImproperlyConfigured( + "list_filterset_class must be a subclass of FilterSet." + ) + # Make sure it has the correct model set. + if self.list_filterset_class.Meta.model != models.Account: + raise ImproperlyConfigured( + "list_filterset_class Meta model field must be anvil_consortium_manager.models.Account." + ) + return self.list_filterset_class + def get_autocomplete_queryset(self, queryset, q): """Filter the Account `queryset` using the query `q` for use in the autocomplete.""" queryset = queryset.filter(email__icontains=q) diff --git a/anvil_consortium_manager/adapters/default.py b/anvil_consortium_manager/adapters/default.py index 10cce327..fbd985f3 100644 --- a/anvil_consortium_manager/adapters/default.py +++ b/anvil_consortium_manager/adapters/default.py @@ -1,6 +1,6 @@ """Default adapters for the app.""" -from .. import forms, models, tables +from .. import filters, forms, models, tables from .account import BaseAccountAdapter from .workspace import BaseWorkspaceAdapter @@ -9,6 +9,7 @@ class DefaultAccountAdapter(BaseAccountAdapter): """Default account adapter for use with the app.""" list_table_class = tables.AccountTable + list_filterset_class = filters.AccountListFilter class DefaultWorkspaceAdapter(BaseWorkspaceAdapter): diff --git a/anvil_consortium_manager/filters.py b/anvil_consortium_manager/filters.py new file mode 100644 index 00000000..97f97f34 --- /dev/null +++ b/anvil_consortium_manager/filters.py @@ -0,0 +1,31 @@ +from django_filters import FilterSet + +from . import forms, models + + +class AccountListFilter(FilterSet): + class Meta: + model = models.Account + fields = {"email": ["icontains"]} + form = forms.FilterForm + + +class BillingProjectListFilter(FilterSet): + class Meta: + model = models.BillingProject + fields = {"name": ["icontains"]} + form = forms.FilterForm + + +class ManagedGroupListFilter(FilterSet): + class Meta: + model = models.ManagedGroup + fields = {"name": ["icontains"]} + form = forms.FilterForm + + +class WorkspaceListFilter(FilterSet): + class Meta: + model = models.Workspace + fields = {"name": ["icontains"]} + form = forms.FilterForm diff --git a/anvil_consortium_manager/forms.py b/anvil_consortium_manager/forms.py index f7ea1742..14828136 100644 --- a/anvil_consortium_manager/forms.py +++ b/anvil_consortium_manager/forms.py @@ -1,5 +1,8 @@ """Forms classes for the anvil_consortium_manager app.""" +from crispy_bootstrap5.bootstrap5 import FloatingField +from crispy_forms import layout +from crispy_forms.helper import FormHelper from dal import autocomplete from django import VERSION as DJANGO_VERSION from django import forms @@ -22,6 +25,40 @@ class Media: } +class FilterForm(forms.Form): + """Custom form to pass to Filters defined in filters.py. + + This form displays the fields with floating fields in a single row. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.form_class = "form-floating" + self.helper.form_method = "get" + # Wrap all inputs in a FloatingField and Div with the correct class. + self.helper.all().wrap(FloatingField) + self.helper.all().wrap(layout.Div, css_class="col") + # Save the original layout so we can insert it into the form as desired. + tmp = self.helper.layout.copy() + # Modify the layout to wrap everything in a row div. + # This is necessary because wrap_together does not include the Submit field, but we want it wrapped as well. + self.helper.layout = layout.Layout( + layout.Div( + *tmp, + # Add a submit button with col-auto. This makes auto-sizes the column to just fit the submit button. + layout.Div( + # mb-3 to match what is done in FloatingField - this centers the button vertically. + layout.Submit( + "submit", "Filter", css_class="btn btn-secondary mb-3" + ), + css_class="col-auto", + ), + css_class="row align-items-center" + ), + ) + + class BillingProjectImportForm(forms.ModelForm): """Form to import a BillingProject from AnVIL""" diff --git a/anvil_consortium_manager/templates/anvil_consortium_manager/account_list.html b/anvil_consortium_manager/templates/anvil_consortium_manager/account_list.html index b1e353d4..4e3b2b17 100644 --- a/anvil_consortium_manager/templates/anvil_consortium_manager/account_list.html +++ b/anvil_consortium_manager/templates/anvil_consortium_manager/account_list.html @@ -2,6 +2,7 @@ {% load static %} {% load render_table from django_tables2 %} +{% load crispy_forms_tags %} {% block title %}Accounts{% endblock %} @@ -13,6 +14,10 @@

Accounts

+
+ {% crispy filter.form %} +
+ {% render_table table %} diff --git a/anvil_consortium_manager/templates/anvil_consortium_manager/billingproject_list.html b/anvil_consortium_manager/templates/anvil_consortium_manager/billingproject_list.html index a16b62f5..06538314 100644 --- a/anvil_consortium_manager/templates/anvil_consortium_manager/billingproject_list.html +++ b/anvil_consortium_manager/templates/anvil_consortium_manager/billingproject_list.html @@ -2,6 +2,7 @@ {% load static %} {% load render_table from django_tables2 %} +{% load crispy_forms_tags %} {% block title %}Billing Projects{% endblock %} @@ -13,6 +14,10 @@

Billing Projects

+
+ {% crispy filter.form %} +
+ {% render_table table %} diff --git a/anvil_consortium_manager/templates/anvil_consortium_manager/managedgroup_list.html b/anvil_consortium_manager/templates/anvil_consortium_manager/managedgroup_list.html index ed24b93b..77407e97 100644 --- a/anvil_consortium_manager/templates/anvil_consortium_manager/managedgroup_list.html +++ b/anvil_consortium_manager/templates/anvil_consortium_manager/managedgroup_list.html @@ -2,17 +2,21 @@ {% load static %} {% load render_table from django_tables2 %} +{% load crispy_forms_tags %} {% block title %}Managed Groups{% endblock %} {% block content %} -

Managed Groups

+
+ {% crispy filter.form %} +
+ {% render_table table %}
diff --git a/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_list.html b/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_list.html index 0b0df05a..d3d12631 100644 --- a/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_list.html +++ b/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_list.html @@ -2,17 +2,21 @@ {% load static %} {% load render_table from django_tables2 %} +{% load crispy_forms_tags %} {% block title %}{{workspace_type_display_name}}s{% endblock %} {% block content %} -

{{workspace_type_display_name}}s

+
+ {% crispy filter.form %} +
+ {% render_table table %}
diff --git a/anvil_consortium_manager/tests/test_adapters.py b/anvil_consortium_manager/tests/test_adapters.py index 27ea8fb7..f5a54456 100644 --- a/anvil_consortium_manager/tests/test_adapters.py +++ b/anvil_consortium_manager/tests/test_adapters.py @@ -10,11 +10,12 @@ BaseWorkspaceAdapter, WorkspaceAdapterRegistry, ) +from ..filters import AccountListFilter, BillingProjectListFilter from ..forms import DefaultWorkspaceDataForm, WorkspaceForm from ..models import Account, DefaultWorkspaceData from ..tables import AccountTable, WorkspaceTable from . import factories -from .test_app import forms, models, tables +from .test_app import filters, forms, models, tables from .test_app.adapters import TestWorkspaceAdapter @@ -26,6 +27,7 @@ def get_test_adapter(self): class TestAdapter(BaseAccountAdapter): list_table_class = tables.TestAccountTable + list_filterset_class = filters.TestAccountListFilter return TestAdapter @@ -46,6 +48,45 @@ def test_list_table_class_none(self): with self.assertRaises(ImproperlyConfigured): TestAdapter().get_list_table_class() + def test_list_filterset_class_default(self): + """get_list_filterset_class returns the correct filter when using the default adapter.""" + self.assertEqual( + DefaultAccountAdapter().get_list_filterset_class(), AccountListFilter + ) + + def test_list_filterset_class_custom(self): + """get_list_filterset_class returns the correct filter when using a custom adapter.""" + TestAdapter = self.get_test_adapter() + setattr(TestAdapter, "list_filterset_class", filters.TestAccountListFilter) + self.assertEqual( + TestAdapter().get_list_filterset_class(), filters.TestAccountListFilter + ) + + def test_list_filterset_class_none(self): + """get_list_filterset_class raises ImproperlyConfigured when get_list_filterset_class is not set.""" + TestAdapter = self.get_test_adapter() + setattr(TestAdapter, "list_filterset_class", None) + with self.assertRaises(ImproperlyConfigured): + TestAdapter().get_list_filterset_class() + + def test_list_filterset_class_different_model(self): + """get_list_filterset_class raises ImproperlyConfigured when incorrect model is used.""" + TestAdapter = self.get_test_adapter() + setattr(TestAdapter, "list_filterset_class", BillingProjectListFilter) + with self.assertRaises(ImproperlyConfigured): + TestAdapter().get_list_filterset_class() + + def test_list_filterset_class_not_filterset(self): + """get_list_filterset_class raises ImproperlyConfigured when not a subclass of FilterSet.""" + + class Foo: + pass + + TestAdapter = self.get_test_adapter() + setattr(TestAdapter, "list_filterset_class", Foo) + with self.assertRaises(ImproperlyConfigured): + TestAdapter().get_list_filterset_class() + def test_get_autocomplete_queryset_default(self): """get_autocomplete_queryset returns the correct queryset when using the default adapter.""" account_1 = factories.AccountFactory.create(email="test@test.com") diff --git a/anvil_consortium_manager/tests/test_app/adapters.py b/anvil_consortium_manager/tests/test_app/adapters.py index 0a8428ce..50ad0591 100644 --- a/anvil_consortium_manager/tests/test_app/adapters.py +++ b/anvil_consortium_manager/tests/test_app/adapters.py @@ -1,7 +1,7 @@ from anvil_consortium_manager.adapters.account import BaseAccountAdapter from anvil_consortium_manager.adapters.workspace import BaseWorkspaceAdapter -from . import forms, models, tables +from . import filters, forms, models, tables class TestWorkspaceAdapter(BaseWorkspaceAdapter): @@ -30,6 +30,7 @@ class TestAccountAdapter(BaseAccountAdapter): """Test adapter for accounts.""" list_table_class = tables.TestAccountTable + list_filterset_class = filters.TestAccountListFilter def get_autocomplete_queryset(self, queryset, q): if q: diff --git a/anvil_consortium_manager/tests/test_app/filters.py b/anvil_consortium_manager/tests/test_app/filters.py new file mode 100644 index 00000000..99ab7755 --- /dev/null +++ b/anvil_consortium_manager/tests/test_app/filters.py @@ -0,0 +1,13 @@ +from django_filters import FilterSet + +from anvil_consortium_manager.forms import FilterForm +from anvil_consortium_manager.models import Account + + +class TestAccountListFilter(FilterSet): + """Test filter for Accounts.""" + + class Meta: + model = Account + fields = {"email": ["icontains"], "is_service_account": ["exact"]} + form = FilterForm diff --git a/anvil_consortium_manager/tests/test_views.py b/anvil_consortium_manager/tests/test_views.py index 0cf8e6f4..46fa24fc 100644 --- a/anvil_consortium_manager/tests/test_views.py +++ b/anvil_consortium_manager/tests/test_views.py @@ -20,7 +20,7 @@ from faker import Faker from freezegun import freeze_time -from .. import __version__, anvil_api, forms, models, tables, views +from .. import __version__, anvil_api, filters, forms, models, tables, views from ..adapters.default import DefaultWorkspaceAdapter from ..adapters.workspace import workspace_adapter_registry from ..audit import audit @@ -31,6 +31,7 @@ from .test_app import tables as app_tables from .test_app.adapters import TestWorkspaceAdapter from .test_app.factories import TestWorkspaceDataFactory +from .test_app.filters import TestAccountListFilter from .utils import ( # Redefined to work with Django < 4.2 and Django=4.2. AnVILAPIMockTestMixin, TestCase, @@ -1005,6 +1006,58 @@ def test_view_with_two_objects(self): self.assertIn("table", response.context_data) self.assertEqual(len(response.context_data["table"].rows), 2) + def test_view_with_filter_return_no_object(self): + factories.BillingProjectFactory.create(name="Billing_project") + factories.BillingProjectFactory.create(name="Project") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "abc"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 0) + + def test_view_with_filter_returns_one_object_exact(self): + instance = factories.BillingProjectFactory.create(name="billing_project") + factories.BillingProjectFactory.create(name="project") + self.client.force_login(self.user) + response = self.client.get( + self.get_url(), {"name__icontains": "billing_project"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_insensitive(self): + instance = factories.BillingProjectFactory.create(name="Billing_project") + factories.BillingProjectFactory.create(name="Project") + self.client.force_login(self.user) + response = self.client.get( + self.get_url(), {"name__icontains": "billing_project"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_contains(self): + instance = factories.BillingProjectFactory.create(name="Billing_project") + factories.BillingProjectFactory.create(name="Project") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "illing"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_mutiple_objects(self): + factories.BillingProjectFactory.create(name="project1") + factories.BillingProjectFactory.create(name="project_2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "project"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 2) + class BillingProjectAutocompleteTest(TestCase): def setUp(self): @@ -3135,6 +3188,81 @@ def test_view_with_two_objects(self): self.assertIn("table", response.context_data) self.assertEqual(len(response.context_data["table"].rows), 2) + def test_filterset_class(self): + factories.AccountFactory.create(email="account_test1@example.com") + factories.AccountFactory.create(email="account@example.com") + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("filter", response.context_data) + self.assertIsInstance( + response.context_data["filter"], filters.AccountListFilter + ) + + def test_view_with_filter_return_no_object(self): + factories.AccountFactory.create(email="account_test1@example.com") + factories.AccountFactory.create(email="account@example.com") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "abc"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 0) + + def test_view_with_filter_returns_one_object_exact(self): + instance = factories.AccountFactory.create(email="account_test1@example.com") + factories.AccountFactory.create(email="account@example.com") + self.client.force_login(self.user) + response = self.client.get( + self.get_url(), {"email__icontains": "account_test1@example.com"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_insensitive(self): + instance = factories.AccountFactory.create(email="account_Test1@example.com") + factories.AccountFactory.create(email="account@example.com") + self.client.force_login(self.user) + response = self.client.get( + self.get_url(), {"email__icontains": "account_test1@example.com"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_contains(self): + instance = factories.AccountFactory.create(email="account_test1@example.com") + factories.AccountFactory.create(email="account@example.com") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "test1"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_status(self): + factories.AccountFactory.create( + email="account1@example.com", status=models.Account.ACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account2@example.com", status=models.Account.INACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "account"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 2) + + def test_view_with_filter_returns_all_objects(self): + factories.AccountFactory.create(email="account1@example.com") + factories.AccountFactory.create(email="account2@example.com") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "example"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 2) + def test_view_with_service_account(self): factories.AccountFactory.create(is_service_account=True) factories.AccountFactory.create(is_service_account=False) @@ -3168,6 +3296,7 @@ def test_adapter(self): self.assertIsInstance( response.context_data["table"], app_tables.TestAccountTable ) + self.assertIsInstance(response.context_data["filter"], TestAccountListFilter) class AccountActiveListTest(TestCase): @@ -3235,6 +3364,16 @@ def test_view_has_correct_table_class(self): self.assertIn("table", response.context_data) self.assertIsInstance(response.context_data["table"], tables.AccountTable) + def test_filterset_class(self): + factories.AccountFactory.create(email="account_test1@example.com") + factories.AccountFactory.create(email="account@example.com") + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("filter", response.context_data) + self.assertIsInstance( + response.context_data["filter"], filters.AccountListFilter + ) + def test_view_with_no_objects(self): self.client.force_login(self.user) response = self.client.get(self.get_url()) @@ -3258,6 +3397,92 @@ def test_view_with_two_objects(self): self.assertIn("table", response.context_data) self.assertEqual(len(response.context_data["table"].rows), 2) + def test_view_with_filter_return_no_object(self): + factories.AccountFactory.create( + email="account_test1@example.com", status=models.Account.ACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account@example.com", status=models.Account.ACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "abc"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 0) + + def test_view_with_filter_returns_one_object_exact(self): + instance = factories.AccountFactory.create( + email="account_test1@example.com", status=models.Account.ACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account@example.com", status=models.Account.ACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get( + self.get_url(), {"email__icontains": "account_test1@example.com"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_insensitive(self): + instance = factories.AccountFactory.create( + email="account_Test1@example.com", status=models.Account.ACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account@example.com", status=models.Account.ACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get( + self.get_url(), {"email__icontains": "account_test1@example.com"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_active_only(self): + instance = factories.AccountFactory.create( + email="account1@example.com", status=models.Account.ACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account2@example.com", status=models.Account.INACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "account"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_contains(self): + instance = factories.AccountFactory.create( + email="account_test1@example.com", status=models.Account.ACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account@example.com", status=models.Account.ACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "test1"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_all_objects(self): + factories.AccountFactory.create( + email="account1@example.com", status=models.Account.ACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account2@example.com", status=models.Account.ACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "example"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 2) + def test_view_with_service_account(self): factories.AccountFactory.create(is_service_account=True) factories.AccountFactory.create(is_service_account=False) @@ -3283,7 +3508,7 @@ def test_view_with_active_and_inactive_accounts(self): @override_settings( ANVIL_ACCOUNT_ADAPTER="anvil_consortium_manager.tests.test_app.adapters.TestAccountAdapter" ) - def test_adapter_table_class(self): + def test_adapter(self): """Displays the correct table if specified in the adapter.""" self.client.force_login(self.user) response = self.client.get(self.get_url()) @@ -3291,6 +3516,7 @@ def test_adapter_table_class(self): self.assertIsInstance( response.context_data["table"], app_tables.TestAccountTable ) + self.assertIsInstance(response.context_data["filter"], TestAccountListFilter) class AccountInactiveListTest(TestCase): @@ -3358,6 +3584,16 @@ def test_view_has_correct_table_class(self): self.assertIn("table", response.context_data) self.assertIsInstance(response.context_data["table"], tables.AccountTable) + def test_filterset_class(self): + factories.AccountFactory.create(email="account_test1@example.com") + factories.AccountFactory.create(email="account@example.com") + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("filter", response.context_data) + self.assertIsInstance( + response.context_data["filter"], filters.AccountListFilter + ) + def test_view_with_no_objects(self): self.client.force_login(self.user) response = self.client.get(self.get_url()) @@ -3381,6 +3617,92 @@ def test_view_with_two_objects(self): self.assertIn("table", response.context_data) self.assertEqual(len(response.context_data["table"].rows), 2) + def test_view_with_filter_return_no_object(self): + factories.AccountFactory.create( + email="account1@example.com", status=models.Account.INACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account2@example.com", status=models.Account.INACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "abc"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 0) + + def test_view_with_filter_returns_one_object_exact(self): + instance = factories.AccountFactory.create( + email="account1@example.com", status=models.Account.INACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account2@example.com", status=models.Account.ACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get( + self.get_url(), {"email__icontains": "account1@example.com"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_insensitive(self): + instance = factories.AccountFactory.create( + email="account1@example.com", status=models.Account.INACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account2@example.com", status=models.Account.INACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get( + self.get_url(), {"email__icontains": "Account1@example.com"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_inactive_only(self): + instance = factories.AccountFactory.create( + email="account1@example.com", status=models.Account.INACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account2@example.com", status=models.Account.ACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "account"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_contains(self): + instance = factories.AccountFactory.create( + email="account1@example.com", status=models.Account.INACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account2@example.com", status=models.Account.INACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "account1"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_all_objects(self): + factories.AccountFactory.create( + email="account1@example.com", status=models.Account.INACTIVE_STATUS + ) + factories.AccountFactory.create( + email="account2@example.com", status=models.Account.INACTIVE_STATUS + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"email__icontains": "example"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 2) + def test_view_with_service_account(self): factories.AccountFactory.create( status=models.Account.INACTIVE_STATUS, is_service_account=True @@ -3418,6 +3740,7 @@ def test_adapter(self): self.assertIsInstance( response.context_data["table"], app_tables.TestAccountTable ) + self.assertIsInstance(response.context_data["filter"], TestAccountListFilter) class AccountDeleteTest(AnVILAPIMockTestMixin, TestCase): @@ -5497,6 +5820,54 @@ def test_view_with_two_objects(self): self.assertIn("table", response.context_data) self.assertEqual(len(response.context_data["table"].rows), 2) + def test_view_with_filter_return_no_object(self): + factories.ManagedGroupFactory.create(name="group1") + factories.ManagedGroupFactory.create(name="group2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "abc"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 0) + + def test_view_with_filter_returns_one_object_exact(self): + instance = factories.ManagedGroupFactory.create(name="group1") + factories.ManagedGroupFactory.create(name="group2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "group1"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_insensitive(self): + instance = factories.ManagedGroupFactory.create(name="group1") + factories.ManagedGroupFactory.create(name="group2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "Group1"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_contains(self): + instance = factories.ManagedGroupFactory.create(name="group1") + factories.ManagedGroupFactory.create(name="group2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "roup1"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_mutiple_objects(self): + factories.ManagedGroupFactory.create(name="group1") + factories.ManagedGroupFactory.create(name="gRouP2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "Group"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 2) + class ManagedGroupDeleteTest(AnVILAPIMockTestMixin, TestCase): @@ -11307,6 +11678,54 @@ def test_context_workspace_type_display_name(self): response.context_data["workspace_type_display_name"], "All workspace" ) + def test_view_with_filter_return_no_object(self): + factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="workspace2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "abc"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 0) + + def test_view_with_filter_returns_one_object_exact(self): + instance = factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="workspace2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "workspace1"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_insensitive(self): + instance = factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="workspace2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "Workspace1"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_contains(self): + instance = factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="workspace2") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "orkspace1"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_mutiple_objects(self): + factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="wOrkspace1") + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"name__icontains": "Workspace"}) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 2) + class WorkspaceListByTypeTest(TestCase): def setUp(self): @@ -11447,6 +11866,78 @@ def test_only_shows_workspaces_with_correct_type(self): self.assertIn("table", response.context_data) self.assertEqual(len(response.context_data["table"].rows), 0) + def test_view_with_filter_return_no_object(self): + factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="workspace2") + self.client.force_login(self.user) + response = self.client.get( + self.get_url(self.workspace_type), {"name__icontains": "abc"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 0) + + def test_view_with_filter_returns_one_object_exact(self): + instance = factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="workspace2") + self.client.force_login(self.user) + response = self.client.get( + self.get_url(self.workspace_type), {"name__icontains": "workspace1"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_insensitive(self): + instance = factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="workspace2") + self.client.force_login(self.user) + response = self.client.get( + self.get_url(self.workspace_type), {"name__icontains": "Workspace1"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_one_object_case_contains(self): + instance = factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="workspace2") + self.client.force_login(self.user) + response = self.client.get( + self.get_url(self.workspace_type), {"name__icontains": "orkspace1"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_workspace_type(self): + instance = factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create( + name="workspace2", workspace_type=TestWorkspaceAdapter().get_type() + ) + self.client.force_login(self.user) + response = self.client.get( + self.get_url(self.workspace_type), {"name__icontains": "workspace"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 1) + self.assertIn(instance, response.context_data["table"].data) + + def test_view_with_filter_returns_mutiple_objects(self): + factories.WorkspaceFactory.create(name="workspace1") + factories.WorkspaceFactory.create(name="wOrkspace1") + self.client.force_login(self.user) + response = self.client.get( + self.get_url(self.workspace_type), {"name__icontains": "Workspace"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("table", response.context_data) + self.assertEqual(len(response.context_data["table"].rows), 2) + class WorkspaceDeleteTest(AnVILAPIMockTestMixin, TestCase): diff --git a/anvil_consortium_manager/viewmixins.py b/anvil_consortium_manager/viewmixins.py index 5fdcc7c1..5b74097d 100644 --- a/anvil_consortium_manager/viewmixins.py +++ b/anvil_consortium_manager/viewmixins.py @@ -12,6 +12,7 @@ from django.views.generic.detail import SingleObjectMixin from . import models +from .adapters.account import get_account_adapter from .adapters.workspace import workspace_adapter_registry from .audit import audit @@ -55,6 +56,21 @@ def get_context_data(self, *args, **kwargs): return context +class AccountAdapterMixin: + """Class for handling account adapters.""" + + def get(self, request, *args, **kwargs): + self.adapter = get_account_adapter() + return super().get(request, *args, **kwargs) + + def get_filterset_class(self): + return self.adapter().get_list_filterset_class() + # return filters.AccountListFilter + + def get_table_class(self): + return self.adapter().get_list_table_class() + + class ManagedGroupGraphMixin: """Mixin to add a plotly graph of group structure to context data.""" diff --git a/anvil_consortium_manager/views.py b/anvil_consortium_manager/views.py index a2196417..76c786ca 100644 --- a/anvil_consortium_manager/views.py +++ b/anvil_consortium_manager/views.py @@ -27,6 +27,7 @@ ) from django.views.generic.edit import BaseDeleteView as DjangoBaseDeleteView from django.views.generic.edit import DeletionMixin, FormMixin +from django_filters.views import FilterView from django_tables2 import SingleTableMixin, SingleTableView from . import ( @@ -34,6 +35,7 @@ anvil_api, auth, exceptions, + filters, forms, models, tables, @@ -204,10 +206,15 @@ def get_context_data(self, **kwargs): return context -class BillingProjectList(auth.AnVILConsortiumManagerViewRequired, SingleTableView): +class BillingProjectList( + auth.AnVILConsortiumManagerViewRequired, SingleTableMixin, FilterView +): model = models.BillingProject table_class = tables.BillingProjectTable ordering = ("name",) + template_name = "anvil_consortium_manager/billingproject_list.html" + + filterset_class = filters.BillingProjectListFilter class BillingProjectAutocomplete( @@ -504,38 +511,44 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) -class AccountList(auth.AnVILConsortiumManagerViewRequired, SingleTableView): +class AccountList( + auth.AnVILConsortiumManagerViewRequired, + viewmixins.AccountAdapterMixin, + SingleTableMixin, + FilterView, +): """View to display a list of Accounts. The table class can be customized using in a custom Account adapter.""" model = models.Account ordering = ("email",) - - def get_table_class(self): - adapter = get_account_adapter() - return adapter().get_list_table_class() + template_name = "anvil_consortium_manager/account_list.html" -class AccountActiveList(auth.AnVILConsortiumManagerViewRequired, SingleTableView): +class AccountActiveList( + auth.AnVILConsortiumManagerViewRequired, + viewmixins.AccountAdapterMixin, + SingleTableMixin, + FilterView, +): model = models.Account ordering = ("email",) - - def get_table_class(self): - adapter = get_account_adapter() - return adapter().get_list_table_class() + template_name = "anvil_consortium_manager/account_list.html" def get_queryset(self): return self.model.objects.active() -class AccountInactiveList(auth.AnVILConsortiumManagerViewRequired, SingleTableView): +class AccountInactiveList( + auth.AnVILConsortiumManagerViewRequired, + viewmixins.AccountAdapterMixin, + SingleTableMixin, + FilterView, +): model = models.Account ordering = ("email",) - - def get_table_class(self): - adapter = get_account_adapter() - return adapter().get_list_table_class() + template_name = "anvil_consortium_manager/account_list.html" def get_queryset(self): return self.model.objects.inactive() @@ -839,10 +852,15 @@ class ManagedGroupUpdate( success_message = "Successfully updated ManagedGroup." -class ManagedGroupList(auth.AnVILConsortiumManagerViewRequired, SingleTableView): +class ManagedGroupList( + auth.AnVILConsortiumManagerViewRequired, SingleTableMixin, FilterView +): model = models.ManagedGroup table_class = tables.ManagedGroupTable ordering = ("name",) + template_name = "anvil_consortium_manager/managedgroup_list.html" + + filterset_class = filters.ManagedGroupListFilter class ManagedGroupVisualization( @@ -1652,7 +1670,9 @@ def get_success_url(self): return self.object.get_absolute_url() -class WorkspaceList(auth.AnVILConsortiumManagerViewRequired, SingleTableView): +class WorkspaceList( + auth.AnVILConsortiumManagerViewRequired, SingleTableMixin, FilterView +): """Display a list of all workspaces using the default table.""" model = models.Workspace @@ -1661,6 +1681,8 @@ class WorkspaceList(auth.AnVILConsortiumManagerViewRequired, SingleTableView): "billing_project__name", "name", ) + template_name = "anvil_consortium_manager/workspace_list.html" + filterset_class = filters.WorkspaceListFilter def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -1671,7 +1693,8 @@ def get_context_data(self, **kwargs): class WorkspaceListByType( auth.AnVILConsortiumManagerViewRequired, viewmixins.WorkspaceAdapterMixin, - SingleTableView, + SingleTableMixin, + FilterView, ): """Display a list of workspaces of the given ``workspace_type``.""" @@ -1680,6 +1703,8 @@ class WorkspaceListByType( "billing_project__name", "name", ) + template_name = "anvil_consortium_manager/workspace_list.html" + filterset_class = filters.WorkspaceListFilter def get_queryset(self): return self.model.objects.filter(workspace_type=self.adapter.get_type()) diff --git a/docs/advanced.rst b/docs/advanced.rst index eecbe82e..8d00319a 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -9,9 +9,14 @@ The account adapter The app provides an adapter that you can use to customize behavior for Accounts. By default, the app uses :class:`~anvil_consortium_manager.adapters.default.DefaultAccountAdapter`. -The default adapter provides the following settings and methods: +To customize app behavior for accounts, you must subclass :class:`~anvil_consortium_manager.adapters.account.BaseAccountAdapter` +and set the following attributes: + +- ``list_table_class``: an attribute set to the class of the table used to display accounts in the :class:`~anvil_consortium_manager.views.AccountList` view. The default adapter uses :class:`anvil_consortium_manager.tables.AccountTable`. +- ``list_filterset_class``: an attribute set to the class of the table used to filter accounts in the :class:`~anvil_consortium_manager.views.AccountList` view. The default adapter uses :class:`anvil_consortium_manager.filters.AccountListFilter`. This must subclass ``FilterSet`` from `django-filter `_. + +Optionally, you can override the following methods: -- ``list_table_class``: an attribute set to the class of the table used to display accounts in the `AccountList` view. The default is :class:`anvil_consortium_manager.tables.AccountTable`. - ``get_autocomplete_queryset(self, queryset, q)``: a method that allows the user to provide custom filtering for the autocomplete view. By default, this filters to Accounts whose email contains the case-insensitive search string in ``q``. - ``get_autocomplete_label(self, account)``: a method that allows the user to set the label for an account shown in forms using the autocomplete widget. diff --git a/docs/api/anvil_consortium_manager.adapters.account.rst b/docs/api/anvil_consortium_manager.adapters.account.rst new file mode 100644 index 00000000..4c82ef13 --- /dev/null +++ b/docs/api/anvil_consortium_manager.adapters.account.rst @@ -0,0 +1,7 @@ +anvil\_consortium\_manager.adapters.account module +================================================== + +.. automodule:: anvil_consortium_manager.adapters.account + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/anvil_consortium_manager.adapters.rst b/docs/api/anvil_consortium_manager.adapters.rst index 9a4c5141..c1f2b6ef 100644 --- a/docs/api/anvil_consortium_manager.adapters.rst +++ b/docs/api/anvil_consortium_manager.adapters.rst @@ -7,6 +7,7 @@ Submodules .. toctree:: :maxdepth: 4 + anvil_consortium_manager.adapters.account anvil_consortium_manager.adapters.default anvil_consortium_manager.adapters.workspace diff --git a/docs/api/anvil_consortium_manager.anvil_audit.rst b/docs/api/anvil_consortium_manager.anvil_audit.rst deleted file mode 100644 index 53356f5e..00000000 --- a/docs/api/anvil_consortium_manager.anvil_audit.rst +++ /dev/null @@ -1,7 +0,0 @@ -anvil\_consortium\_manager.anvil\_audit module -============================================== - -.. automodule:: anvil_consortium_manager.anvil_audit - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/api/anvil_consortium_manager.audit.audit.rst b/docs/api/anvil_consortium_manager.audit.audit.rst new file mode 100644 index 00000000..47ece9d3 --- /dev/null +++ b/docs/api/anvil_consortium_manager.audit.audit.rst @@ -0,0 +1,7 @@ +anvil\_consortium\_manager.audit.audit module +============================================= + +.. automodule:: anvil_consortium_manager.audit.audit + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/anvil_consortium_manager.audit.rst b/docs/api/anvil_consortium_manager.audit.rst new file mode 100644 index 00000000..68eab8fd --- /dev/null +++ b/docs/api/anvil_consortium_manager.audit.rst @@ -0,0 +1,18 @@ +anvil\_consortium\_manager.audit package +======================================== + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + anvil_consortium_manager.audit.audit + +Module contents +--------------- + +.. automodule:: anvil_consortium_manager.audit + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/anvil_consortium_manager.context_processors.rst b/docs/api/anvil_consortium_manager.context_processors.rst deleted file mode 100644 index 81fa8539..00000000 --- a/docs/api/anvil_consortium_manager.context_processors.rst +++ /dev/null @@ -1,7 +0,0 @@ -anvil\_consortium\_manager.context\_processors module -===================================================== - -.. automodule:: anvil_consortium_manager.context_processors - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/api/anvil_consortium_manager.filters.rst b/docs/api/anvil_consortium_manager.filters.rst new file mode 100644 index 00000000..b69a7a52 --- /dev/null +++ b/docs/api/anvil_consortium_manager.filters.rst @@ -0,0 +1,7 @@ +anvil\_consortium\_manager.filters module +========================================= + +.. automodule:: anvil_consortium_manager.filters + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/anvil_consortium_manager.management.commands.rst b/docs/api/anvil_consortium_manager.management.commands.rst new file mode 100644 index 00000000..9dc1e85c --- /dev/null +++ b/docs/api/anvil_consortium_manager.management.commands.rst @@ -0,0 +1,18 @@ +anvil\_consortium\_manager.management.commands package +====================================================== + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + anvil_consortium_manager.management.commands.run_anvil_audit + +Module contents +--------------- + +.. automodule:: anvil_consortium_manager.management.commands + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/anvil_consortium_manager.management.commands.run_anvil_audit.rst b/docs/api/anvil_consortium_manager.management.commands.run_anvil_audit.rst new file mode 100644 index 00000000..7fb9ce47 --- /dev/null +++ b/docs/api/anvil_consortium_manager.management.commands.run_anvil_audit.rst @@ -0,0 +1,7 @@ +anvil\_consortium\_manager.management.commands.run\_anvil\_audit module +======================================================================= + +.. automodule:: anvil_consortium_manager.management.commands.run_anvil_audit + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/anvil_consortium_manager.management.rst b/docs/api/anvil_consortium_manager.management.rst new file mode 100644 index 00000000..5d126a78 --- /dev/null +++ b/docs/api/anvil_consortium_manager.management.rst @@ -0,0 +1,18 @@ +anvil\_consortium\_manager.management package +============================================= + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + anvil_consortium_manager.management.commands + +Module contents +--------------- + +.. automodule:: anvil_consortium_manager.management + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/anvil_consortium_manager.rst b/docs/api/anvil_consortium_manager.rst index 46315269..139e6671 100644 --- a/docs/api/anvil_consortium_manager.rst +++ b/docs/api/anvil_consortium_manager.rst @@ -8,6 +8,8 @@ Subpackages :maxdepth: 4 anvil_consortium_manager.adapters + anvil_consortium_manager.audit + anvil_consortium_manager.management Submodules ---------- @@ -17,16 +19,16 @@ Submodules anvil_consortium_manager.admin anvil_consortium_manager.anvil_api - anvil_consortium_manager.anvil_audit anvil_consortium_manager.apps anvil_consortium_manager.auth - anvil_consortium_manager.context_processors anvil_consortium_manager.exceptions + anvil_consortium_manager.filters anvil_consortium_manager.forms anvil_consortium_manager.models anvil_consortium_manager.tables anvil_consortium_manager.tokens anvil_consortium_manager.urls + anvil_consortium_manager.viewmixins anvil_consortium_manager.views Module contents diff --git a/docs/api/anvil_consortium_manager.viewmixins.rst b/docs/api/anvil_consortium_manager.viewmixins.rst new file mode 100644 index 00000000..8b29dbbc --- /dev/null +++ b/docs/api/anvil_consortium_manager.viewmixins.rst @@ -0,0 +1,7 @@ +anvil\_consortium\_manager.viewmixins module +============================================ + +.. automodule:: anvil_consortium_manager.viewmixins + :members: + :undoc-members: + :show-inheritance: diff --git a/example_site/settings.py b/example_site/settings.py index 64353505..92253f91 100644 --- a/example_site/settings.py +++ b/example_site/settings.py @@ -81,6 +81,7 @@ "debug_toolbar", "django_extensions", # useful extensions "simple_history", # model history tracking - required for viewing in admin. + "django_filters", # This app. "anvil_consortium_manager", # Autocomplete. diff --git a/requirements/base.txt b/requirements/base.txt index faa5646b..646e0a2e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -4,6 +4,7 @@ django==3.2.12 # pyup: < 4.0 # https://www.djangoproject.com/ crispy-bootstrap5==0.6 # https://github.com/django-crispy-forms/crispy-bootstrap5 django-crispy-forms==2.0 # https://github.com/django-crispy-forms/django-crispy-forms django-extensions==3.1.5 # https://github.com/django-extensions/django-extensions +django-filter==23.2 django-tables2==2.4.1 google-auth==2.16.0 fontawesomefree==6.1.1 diff --git a/setup.cfg b/setup.cfg index 4edf6958..1699276c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,6 +35,7 @@ install_requires = google-auth >= 2.6 fontawesomefree >= 6.1 django-autocomplete-light >= 3.9 + django-filter >= 23.0 django-tables2 >= 2.4 django-simple-history >= 3.1.1 django-extensions >= 3.1.5 diff --git a/tox.ini b/tox.ini index 171fd34a..f4fe49de 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ # content of: tox.ini , put in same dir as setup.py [tox] -envlist = py{38,39,310}-django{32,41,42}-{sqlite,mysql} +envlist = py{38,39,310,311}-django{32,41,42}-{sqlite,mysql} isolated_build = true # We need 4.4.0 for constrain_package_deps. min_version = 4.4.0 @@ -27,15 +27,13 @@ passenv = DBBACKEND DBPORT commands = - pip freeze - coverage run -p ./manage.py test anvil_consortium_manager --settings=anvil_consortium_manager.tests.settings.test + python -m coverage run -p ./manage.py test anvil_consortium_manager --settings=anvil_consortium_manager.tests.settings.test -[testenv:py{38,39,310}-django{32,41,42}-mysql] +[testenv:py{38,39,310,311}-django{32,41,42}-mysql] setenv = DBBACKEND = mysql - -[testenv:py{38,39,310}-django{32,41,42}-sqlite] +[testenv:py{38,39,310,311}-django{32,41,42}-sqlite] setenv = DBBACKEND = sqlite3 DBNAME = ":memory:" @@ -45,6 +43,7 @@ python = 3.8: py38 3.9: py39 3.10: py310 + 3.11: py311 [gh-actions:env] DBBACKEND =