From bb97677a4726bfddf7af7dcff02efa8effb3c439 Mon Sep 17 00:00:00 2001 From: Arpit Date: Fri, 14 Feb 2020 12:11:31 +0530 Subject: [PATCH 01/21] update README remove confusion from the installation instruction --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index db24ca4..b8b36ed 100644 --- a/README.rst +++ b/README.rst @@ -35,7 +35,7 @@ Installation & Set up ===================== 1. Install from pypi: ``pip install django-advanced-filters`` -2. Add both ``'advanced_filters'`` to ``INSTALLED_APPS``. +2. Add ``'advanced_filters'`` to ``INSTALLED_APPS``. 3. Add ``url(r'^advanced_filters/', include('advanced_filters.urls'))`` to your project's urlconf. 4. Run ``python manage.py syncdb`` or ``python manage.py migrate`` (for django >= 1.7) From 6902b993b02264b0ff407f27bb88d070f3b9bc57 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 14:00:47 +0200 Subject: [PATCH 02/21] test: refactor unittest test cases to pytest --- advanced_filters/tests/__init__.py | 3 - advanced_filters/tests/conftest.py | 13 ++ advanced_filters/tests/factories.py | 10 + advanced_filters/tests/test_admin.py | 176 ------------------ .../tests/test_admin_change_form.py | 57 ++++++ advanced_filters/tests/test_creation.py | 88 +++++++++ .../tests/test_get_field_choices_view.py | 136 ++++++++++++++ advanced_filters/tests/test_usage.py | 79 ++++++++ advanced_filters/tests/test_views.py | 125 ------------- tests/factories.py | 6 +- 10 files changed, 386 insertions(+), 307 deletions(-) create mode 100644 advanced_filters/tests/conftest.py create mode 100644 advanced_filters/tests/factories.py delete mode 100644 advanced_filters/tests/test_admin.py create mode 100644 advanced_filters/tests/test_admin_change_form.py create mode 100644 advanced_filters/tests/test_creation.py create mode 100644 advanced_filters/tests/test_get_field_choices_view.py create mode 100644 advanced_filters/tests/test_usage.py delete mode 100644 advanced_filters/tests/test_views.py diff --git a/advanced_filters/tests/__init__.py b/advanced_filters/tests/__init__.py index 8d26a51..e69de29 100644 --- a/advanced_filters/tests/__init__.py +++ b/advanced_filters/tests/__init__.py @@ -1,3 +0,0 @@ -from .test_models import * -from .test_q_serializer import * -from .test_views import * diff --git a/advanced_filters/tests/conftest.py b/advanced_filters/tests/conftest.py new file mode 100644 index 0000000..6db2510 --- /dev/null +++ b/advanced_filters/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest +from tests.factories import SalesRepFactory + + +@pytest.fixture +def user(db): + return SalesRepFactory() + + +@pytest.fixture() +def client(client, user): + client.force_login(user) + return client diff --git a/advanced_filters/tests/factories.py b/advanced_filters/tests/factories.py new file mode 100644 index 0000000..f551702 --- /dev/null +++ b/advanced_filters/tests/factories.py @@ -0,0 +1,10 @@ +import factory + +from tests.factories import SalesRepFactory + + +class AdvancedFilterFactory(factory.django.DjangoModelFactory): + model = 'customers.Client' + + class Meta: + model = 'advanced_filters.AdvancedFilter' diff --git a/advanced_filters/tests/test_admin.py b/advanced_filters/tests/test_admin.py deleted file mode 100644 index 320d5ff..0000000 --- a/advanced_filters/tests/test_admin.py +++ /dev/null @@ -1,176 +0,0 @@ -try: - from django.urls import reverse -except ImportError: # Django < 2.0 - from django.core.urlresolvers import reverse -from django.contrib.auth.models import Permission -from django.db.models import Q -from django.test import TestCase - -from ..models import AdvancedFilter -from ..admin import AdvancedListFilters -from tests import factories - - -class ChageFormAdminTest(TestCase): - """ Test the AdvancedFilter admin change page """ - def setUp(self): - self.user = factories.SalesRep() - assert self.client.login(username='user', password='test') - self.a = AdvancedFilter(title='test', url='test', created_by=self.user, - model='customers.Client') - self.a.query = Q(email__iexact='a@a.com') - self.a.save() - - def test_change_page_requires_perms(self): - url = reverse('admin:advanced_filters_advancedfilter_change', - args=(self.a.pk,)) - res = self.client.get(url) - assert res.status_code == 403 - - def test_change_page_renders(self): - self.user.user_permissions.add(Permission.objects.get( - codename='change_advancedfilter')) - url = reverse('admin:advanced_filters_advancedfilter_change', - args=(self.a.pk,)) - - with self.settings(ADVANCED_FILTER_EDIT_BY_USER=False): - res = self.client.get(url) - assert res.status_code == 200 - - def test_change_and_goto(self): - self.user.user_permissions.add(Permission.objects.get( - codename='change_advancedfilter')) - url = reverse('admin:advanced_filters_advancedfilter_change', - args=(self.a.pk,)) - form_data = {'form-TOTAL_FORMS': 1, 'form-INITIAL_FORMS': 0, - '_save_goto': 1} - with self.settings(ADVANCED_FILTER_EDIT_BY_USER=False): - res = self.client.post(url, data=form_data) - assert res.status_code == 302 - url = res['location'] - assert url.endswith('admin/customers/client/?_afilter=1') - - def test_create_page_disabled(self): - self.user.user_permissions.add(Permission.objects.get( - codename='add_advancedfilter')) - url = reverse('admin:advanced_filters_advancedfilter_add') - res = self.client.get(url) - assert res.status_code == 403 - - -class AdvancedFilterCreationTest(TestCase): - """ Test creation of AdvancedFilter in target model changelist """ - form_data = {'form-TOTAL_FORMS': 1, 'form-INITIAL_FORMS': 0, - 'action': 'advanced_filters'} - good_data = {'title': 'Test title', 'form-0-field': 'language', - 'form-0-operator': 'iexact', 'form-0-value': 'ru', } - query = ['language__iexact', 'ru'] - - def setUp(self): - self.user = factories.SalesRep() - assert self.client.login(username='user', password='test') - - def test_changelist_includes_form(self): - self.user.user_permissions.add(Permission.objects.get( - codename='change_client')) - url = reverse('admin:customers_client_changelist') - with self.settings(ADVANCED_FILTER_EDIT_BY_USER=False): - res = self.client.get(url) - assert res.status_code == 200 - title = ['Create advanced filter'] - fields = ['First name', 'Language', 'Sales Rep'] - # python >= 3.3 support - response_content = res.content.decode('utf-8') - for part in title + fields: - assert part in response_content - - def test_create_form_validation(self): - self.user.user_permissions.add(Permission.objects.get( - codename='change_client')) - url = reverse('admin:customers_client_changelist') - form_data = self.form_data.copy() - res = self.client.post(url, data=form_data) - assert res.status_code == 200 - form = res.context_data['advanced_filters'] - assert 'title' in form.errors - assert '__all__' in form.errors - assert form.errors['title'] == ['This field is required.'] - assert form.errors['__all__'] == ['Error validating filter forms'] - - def test_create_form_valid(self): - self.user.user_permissions.add(Permission.objects.get( - codename='change_client')) - url = reverse('admin:customers_client_changelist') - form_data = self.form_data.copy() - form_data.update(self.good_data) - res = self.client.post(url, data=form_data) - assert res.status_code == 200 - form = res.context_data['advanced_filters'] - assert form.is_valid() - assert AdvancedFilter.objects.count() == 1 - - created_filter = AdvancedFilter.objects.order_by('-pk')[0] - - assert created_filter.title == self.good_data['title'] - assert list(created_filter.query.children[0]) == self.query - - # save with redirect to filter - form_data['_save_goto'] = 1 - res = self.client.post(url, data=form_data) - assert res.status_code == 302 - assert AdvancedFilter.objects.count() == 2 - - created_filter = AdvancedFilter.objects.order_by('-pk')[0] - url = res['location'] - assert url.endswith('admin/customers/client/?_afilter=%s' % - created_filter.pk) - - assert list(created_filter.query.children[0]) == self.query - - -class AdvancedFilterUsageTest(TestCase): - """ Test filter visibility and actual filtering of a changelist """ - def setUp(self): - self.user = factories.SalesRep() - assert self.client.login(username='user', password='test') - factories.Client.create_batch(8, assigned_to=self.user, language='en') - factories.Client.create_batch(2, assigned_to=self.user, language='ru') - self.user.user_permissions.add(Permission.objects.get( - codename='change_client')) - self.a = AdvancedFilter(title='Russian speakers', url='foo', - created_by=self.user, model='customers.Client') - self.a.query = Q(language='ru') - self.a.save() - - def test_filters_not_available(self): - url = reverse('admin:customers_client_changelist') - res = self.client.get(url, data={'_afilter': self.a.pk}) - assert res.status_code == 200 - cl = res.context_data['cl'] - assert not any(isinstance(f, AdvancedListFilters) - for f in cl.filter_specs) - # filter not applied due to user not being in list - if hasattr(cl, 'queryset'): - assert cl.queryset.count() == 10 - - def test_filters_available_to_users(self): - self.a.users.add(self.user) - url = reverse('admin:customers_client_changelist') - res = self.client.get(url, data={'_afilter': self.a.pk}) - assert res.status_code == 200 - cl = res.context_data['cl'] - assert any(isinstance(f, AdvancedListFilters) - for f in cl.filter_specs) - if hasattr(cl, 'queryset'): - assert cl.queryset.count() == 2 - - def test_filters_available_to_groups(self): - group = self.user.groups.create() - self.a.groups.add(group) - url = reverse('admin:customers_client_changelist') - res = self.client.get(url, data={'_afilter': self.a.pk}) - assert res.status_code == 200 - cl = res.context_data['cl'] - assert cl.filter_specs - if hasattr(cl, 'queryset'): - assert cl.queryset.count() == 2 diff --git a/advanced_filters/tests/test_admin_change_form.py b/advanced_filters/tests/test_admin_change_form.py new file mode 100644 index 0000000..540376e --- /dev/null +++ b/advanced_filters/tests/test_admin_change_form.py @@ -0,0 +1,57 @@ +import pytest +from django.contrib.auth.models import Permission +from django.db.models import Q + +from ..models import AdvancedFilter +from .factories import AdvancedFilterFactory + +try: + from django.urls import reverse +except ImportError: # Django < 2.0 + from django.core.urlresolvers import reverse + +URL_NAME_CHANGE = "admin:advanced_filters_advancedfilter_change" +URL_NAME_ADD = "admin:advanced_filters_advancedfilter_add" +URL_NAME_CLIENT_CHANGELIST = "admin:customers_client_changelist" + + +@pytest.fixture +def advanced_filter(user): + af = AdvancedFilterFactory.build(created_by=user) + af.query = Q(email__iexact="a@a.com") + af.save() + return af + + +def test_change_page_requires_perms(client, advanced_filter): + url = reverse(URL_NAME_CHANGE, args=(advanced_filter.pk,)) + res = client.get(url) + assert res.status_code == 403 + + +def test_change_page_renders(client, user, settings, advanced_filter): + user.user_permissions.add(Permission.objects.get(codename="change_advancedfilter")) + url = reverse(URL_NAME_CHANGE, args=(advanced_filter.pk,)) + + settings.ADVANCED_FILTER_EDIT_BY_USER = False + res = client.get(url) + assert res.status_code == 200 + + +def test_change_and_goto(client, user, settings, advanced_filter): + user.user_permissions.add(Permission.objects.get(codename="change_advancedfilter")) + url = reverse(URL_NAME_CHANGE, args=(advanced_filter.pk,)) + form_data = {"form-TOTAL_FORMS": 1, "form-INITIAL_FORMS": 0, "_save_goto": 1} + settings.ADVANCED_FILTER_EDIT_BY_USER = False + res = client.post(url, data=form_data) + assert res.status_code == 302 + url = res["location"] + assert url.endswith("%s?_afilter=1" % reverse(URL_NAME_CLIENT_CHANGELIST)) + + +def test_create_page_disabled(client, user): + user.user_permissions.add(Permission.objects.get(codename="add_advancedfilter")) + url = reverse(URL_NAME_ADD) + res = client.get(url) + assert res.status_code == 403 + assert AdvancedFilter.objects.count() == 0 diff --git a/advanced_filters/tests/test_creation.py b/advanced_filters/tests/test_creation.py new file mode 100644 index 0000000..de3cab0 --- /dev/null +++ b/advanced_filters/tests/test_creation.py @@ -0,0 +1,88 @@ +import pytest +from django.contrib.auth.models import Permission + +from ..models import AdvancedFilter + +try: + from django.urls import reverse_lazy +except ImportError: # Django < 2.0 + from django.core.urlresolvers import reverse_lazy + +URL_CLIENT_CHANGELIST = reverse_lazy("admin:customers_client_changelist") + + +def test_changelist_includes_form(user, settings, client): + user.user_permissions.add(Permission.objects.get(codename="change_client")) + settings.ADVANCED_FILTER_EDIT_BY_USER = False + res = client.get(URL_CLIENT_CHANGELIST) + assert res.status_code == 200 + title = ["Create advanced filter"] + fields = ["First name", "Language", "Sales Rep"] + response_content = res.content.decode("utf-8") + for part in title + fields: + assert part in response_content + + +@pytest.fixture +def form_data(): + return { + "form-TOTAL_FORMS": 1, + "form-INITIAL_FORMS": 0, + "action": "advanced_filters", + } + + +def test_create_form_validation(user, client, form_data): + user.user_permissions.add(Permission.objects.get(codename="change_client")) + res = client.post(URL_CLIENT_CHANGELIST, data=form_data) + assert res.status_code == 200 + form = res.context_data["advanced_filters"] + assert "title" in form.errors + assert "__all__" in form.errors + assert form.errors["title"] == ["This field is required."] + assert form.errors["__all__"] == ["Error validating filter forms"] + + +@pytest.fixture() +def good_data(form_data): + form_data.update( + { + "title": "Test title", + "form-0-field": "language", + "form-0-operator": "iexact", + "form-0-value": "ru", + } + ) + return form_data + + +@pytest.fixture() +def query(): + return ["language__iexact", "ru"] + + +def test_create_form_valid(user, client, good_data, query): + assert AdvancedFilter.objects.count() == 0 + user.user_permissions.add(Permission.objects.get(codename="change_client")) + res = client.post(URL_CLIENT_CHANGELIST, data=good_data) + assert res.status_code == 200 + form = res.context_data["advanced_filters"] + assert form.is_valid() + assert AdvancedFilter.objects.count() == 1 + + created_filter = AdvancedFilter.objects.order_by("pk").last() + + assert created_filter.title == good_data["title"] + assert list(created_filter.query.children[0]) == query + + # save with redirect to filter + good_data["_save_goto"] = 1 + res = client.post(URL_CLIENT_CHANGELIST, data=good_data) + assert res.status_code == 302 + assert AdvancedFilter.objects.count() == 2 + + created_filter = AdvancedFilter.objects.order_by("pk").last() + url = res["location"] + assert url.endswith("%s?_afilter=%s" % (URL_CLIENT_CHANGELIST, created_filter.pk)) + + assert list(created_filter.query.children[0]) == query diff --git a/advanced_filters/tests/test_get_field_choices_view.py b/advanced_filters/tests/test_get_field_choices_view.py new file mode 100644 index 0000000..ceda1d0 --- /dev/null +++ b/advanced_filters/tests/test_get_field_choices_view.py @@ -0,0 +1,136 @@ +import json +import sys + +import django +import pytest +from django.utils.encoding import force_text +from tests.factories import ClientFactory + +try: + from django.urls import reverse +except ImportError: # Django < 2.0 + from django.core.urlresolvers import reverse + + +URL_NAME = "afilters_get_field_choices" + + +def assert_json(content, expect): + assert json.loads(force_text(content)) == expect + + +def assert_view_error(client, error, exception=None, **view_kwargs): + """ Ensure view either raises exception or returns a 400 json error """ + view_url = reverse(URL_NAME, kwargs=view_kwargs) + + if exception is not None: + with pytest.raises(exception) as excinfo: + client.get(view_url) + assert error == str(excinfo.value) + return + + response = client.get(view_url) + assert response.status_code == 400 + assert_json(response.content, dict(error=error)) + + +if django.VERSION < (1, 7): + NO_APP_INSTALLED_ERROR = "No installed app/model: foo.test" + NO_MODEL_ERROR = "No installed app/model: reps.Foo" +else: + NO_APP_INSTALLED_ERROR = "No installed app with label 'foo'." + NO_MODEL_ERROR = "App 'reps' doesn't have a 'Foo' model." + + +if "PyPy" in getattr(sys, "subversion", ()): + ARGUMENT_LENGTH_ERROR = "expected length 2, got 1" +elif sys.version_info >= (3, 5): + ARGUMENT_LENGTH_ERROR = "not enough values to unpack (expected 2, got 1)" +else: + ARGUMENT_LENGTH_ERROR = "need more than 1 value to unpack" + +MISSING_FIELD_ERROR = "SalesRep has no field named 'baz'" + + +def test_invalid_view_kwargs(client): + assert_view_error(client, "GetFieldChoices view requires 2 arguments") + assert_view_error( + client, ARGUMENT_LENGTH_ERROR, model="a", field_name="b", exception=ValueError + ) + assert_view_error( + client, NO_APP_INSTALLED_ERROR, model="foo.test", field_name="baz" + ) + assert_view_error(client, NO_MODEL_ERROR, model="reps.Foo", field_name="b") + assert_view_error( + client, MISSING_FIELD_ERROR, model="reps.SalesRep", field_name="baz" + ) + + +def test_field_with_choices(client): + view_url = reverse( + URL_NAME, kwargs=dict(model="customers.Client", field_name="language") + ) + response = client.get(view_url) + assert_json( + response.content, + { + "results": [ + {"id": "en", "text": "English"}, + {"id": "it", "text": "Italian"}, + {"id": "sp", "text": "Spanish"}, + ] + }, + ) + + +@pytest.fixture +def three_clients(user): + return ClientFactory.create_batch(3, assigned_to=user) + + +def test_disabled_field(three_clients, client, settings): + settings.ADVANCED_FILTERS_DISABLE_FOR_FIELDS = ("email",) + view_url = reverse( + URL_NAME, kwargs=dict(model="customers.Client", field_name="email") + ) + response = client.get(view_url) + assert_json(response.content, {"results": []}) + + +def test_disabled_field_types(three_clients, client): + view_url = reverse( + URL_NAME, kwargs=dict(model="customers.Client", field_name="is_active") + ) + response = client.get(view_url) + assert_json(response.content, {"results": []}) + + +def test_database_choices(three_clients, client): + view_url = reverse( + URL_NAME, kwargs=dict(model="customers.Client", field_name="email") + ) + response = client.get(view_url) + assert_json( + response.content, + {"results": [dict(id=e.email, text=e.email) for e in three_clients]}, + ) + + +def test_more_than_max_database_choices(user, client, settings): + settings.ADVANCED_FILTERS_MAX_CHOICES = 4 + ClientFactory.create_batch(5, assigned_to=user) + view_url = reverse(URL_NAME, kwargs=dict(model="customers.Client", field_name="id")) + response = client.get(view_url) + assert_json(response.content, {"results": []}) + + +def test_distinct_database_choices(user, client, settings): + settings.ADVANCED_FILTERS_MAX_CHOICES = 4 + ClientFactory.create_batch(5, assigned_to=user, email="foo@bar.com") + view_url = reverse( + URL_NAME, kwargs=dict(model="customers.Client", field_name="email") + ) + response = client.get(view_url) + assert_json( + response.content, {"results": [{"id": "foo@bar.com", "text": "foo@bar.com"}]} + ) diff --git a/advanced_filters/tests/test_usage.py b/advanced_filters/tests/test_usage.py new file mode 100644 index 0000000..0155093 --- /dev/null +++ b/advanced_filters/tests/test_usage.py @@ -0,0 +1,79 @@ +import pytest +from django.contrib.auth.models import Permission +from django.db.models import Q +from tests.factories import ClientFactory, SalesRepFactory + +from ..admin import AdvancedListFilters +from ..models import AdvancedFilter +from .factories import AdvancedFilterFactory + +try: + from django.urls import reverse +except ImportError: # Django < 2.0 + from django.core.urlresolvers import reverse + + +URL_NAME_CLIENT_CHANGELIST = "admin:customers_client_changelist" + + +@pytest.fixture +def user(db): + user = SalesRepFactory() + user.user_permissions.add(Permission.objects.get(codename="change_client")) + return user + + +@pytest.fixture() +def client(client, user): + client.force_login(user) + return client + + +@pytest.fixture +def advanced_filter(user): + af = AdvancedFilterFactory.build( + title="Russian speakers", url="foo", model="customers.Client", created_by=user + ) + af.query = Q(language="ru") + af.save() + return af + + +@pytest.fixture(autouse=True) +def clients(user): + ClientFactory.create_batch(8, assigned_to=user, language="en") + ClientFactory.create_batch(2, assigned_to=user, language="ru") + + +def test_filters_not_available(client, advanced_filter): + url = reverse(URL_NAME_CLIENT_CHANGELIST) + res = client.get(url, data={"_afilter": advanced_filter.pk}) + assert res.status_code == 200 + cl = res.context_data["cl"] + assert not any(isinstance(f, AdvancedListFilters) for f in cl.filter_specs) + # filter not applied due to user not being in list + if hasattr(cl, "queryset"): + assert cl.queryset.count() == 10 + + +def test_filters_available_to_users(client, user, advanced_filter): + advanced_filter.users.add(user) + url = reverse(URL_NAME_CLIENT_CHANGELIST) + res = client.get(url, data={"_afilter": advanced_filter.pk}) + assert res.status_code == 200 + cl = res.context_data["cl"] + assert any(isinstance(f, AdvancedListFilters) for f in cl.filter_specs) + if hasattr(cl, "queryset"): + assert cl.queryset.count() == 2 + + +def test_filters_available_to_groups(client, user, advanced_filter): + group = user.groups.create() + advanced_filter.groups.add(group) + url = reverse(URL_NAME_CLIENT_CHANGELIST) + res = client.get(url, data={"_afilter": advanced_filter.pk}) + assert res.status_code == 200 + cl = res.context_data["cl"] + assert cl.filter_specs + if hasattr(cl, "queryset"): + assert cl.queryset.count() == 2 diff --git a/advanced_filters/tests/test_views.py b/advanced_filters/tests/test_views.py deleted file mode 100644 index f264a59..0000000 --- a/advanced_filters/tests/test_views.py +++ /dev/null @@ -1,125 +0,0 @@ -import sys - -from django.test import TestCase -try: - from django.test import override_settings -except ImportError: - from django.test.utils import override_settings -from django.utils.encoding import force_text -try: - from django.urls import reverse -except ImportError: # Django < 2.0 - from django.core.urlresolvers import reverse -import django - -from tests import factories - - -class TestGetFieldChoicesView(TestCase): - url_name = 'afilters_get_field_choices' - - def setUp(self): - self.user = factories.SalesRep() - assert self.client.login(username='user', password='test') - - def assert_json(self, response, expect): - self.assertJSONEqual(force_text(response.content), expect) - - def assert_view_error(self, error, exception=None, **view_kwargs): - """ Ensure view either raises exception or returns a 400 json error """ - view_url = reverse(self.url_name, kwargs=view_kwargs) - if exception is not None: - self.assertRaisesMessage( - exception, error, self.client.get, view_url) - return - res = self.client.get(view_url) - assert res.status_code == 400 - self.assert_json(res, dict(error=error)) - - def test_invalid_args(self): - self.assert_view_error("GetFieldChoices view requires 2 arguments") - if 'PyPy' in getattr(sys, 'subversion', ()): - self.assert_view_error( - 'expected length 2, got 1', - model='a', field_name='b', exception=ValueError) - elif sys.version_info >= (3, 5): - self.assert_view_error( - 'not enough values to unpack (expected 2, got 1)', model='a', - field_name='b', exception=ValueError) - else: - self.assert_view_error( - 'need more than 1 value to unpack', model='a', - field_name='b', exception=ValueError) - if django.VERSION >= (1, 11): - self.assert_view_error("No installed app with label 'Foo'.", - model='Foo.test', field_name='baz') - self.assert_view_error("App 'reps' doesn't have a 'Foo' model.", - model='reps.Foo', field_name='b') - elif django.VERSION >= (1, 7): - self.assert_view_error("No installed app with label 'foo'.", - model='foo.test', field_name='baz') - self.assert_view_error("App 'reps' doesn't have a 'foo' model.", - model='reps.Foo', field_name='b') - else: - self.assert_view_error("No installed app/model: foo.test", - model='foo.test', field_name='baz') - self.assert_view_error("No installed app/model: reps.Foo", - model='reps.Foo', field_name='b') - if sys.version_info >= (3, 3) or django.VERSION >= (1, 11): - expected_exception = "SalesRep has no field named 'baz'" - else: - expected_exception = "SalesRep has no field named u'baz'" - self.assert_view_error(expected_exception, - model='reps.SalesRep', field_name='baz') - - def test_field_with_choices(self): - view_url = reverse(self.url_name, kwargs=dict( - model='customers.Client', field_name='language')) - res = self.client.get(view_url) - self.assert_json(res, { - 'results': [ - {'id': 'en', 'text': 'English'}, - {'id': 'it', 'text': 'Italian'}, - {'id': 'sp', 'text': 'Spanish'} - ] - }) - - @override_settings(ADVANCED_FILTERS_DISABLE_FOR_FIELDS=('email',)) - def test_disabled_field(self): - factories.Client.create_batch(3, assigned_to=self.user) - view_url = reverse(self.url_name, kwargs=dict( - model='customers.Client', field_name='email')) - res = self.client.get(view_url) - self.assert_json(res, {'results': []}) - - def test_disabled_field_types(self): - factories.Client.create_batch(3, assigned_to=self.user) - view_url = reverse(self.url_name, kwargs=dict( - model='customers.Client', field_name='is_active')) - res = self.client.get(view_url) - self.assert_json(res, {'results': []}) - - def test_database_choices(self): - clients = factories.Client.create_batch(3, assigned_to=self.user) - view_url = reverse(self.url_name, kwargs=dict( - model='customers.Client', field_name='email')) - res = self.client.get(view_url) - self.assert_json(res, { - 'results': [dict(id=e.email, text=e.email) for e in clients] - }) - - @override_settings(ADVANCED_FILTERS_MAX_CHOICES=4) - def test_more_than_max_database_choices(self): - factories.Client.create_batch(5, assigned_to=self.user) - view_url = reverse(self.url_name, kwargs=dict( - model='customers.Client', field_name='id')) - res = self.client.get(view_url) - self.assert_json(res, {'results': []}) - - @override_settings(ADVANCED_FILTERS_MAX_CHOICES=4) - def test_distinct_database_choices(self): - factories.Client.create_batch(5, assigned_to=self.user, email="foo@bar.com") - view_url = reverse(self.url_name, kwargs=dict( - model='customers.Client', field_name='email')) - res = self.client.get(view_url) - self.assert_json(res, {'results': [{'id': 'foo@bar.com', 'text': 'foo@bar.com'}]}) diff --git a/tests/factories.py b/tests/factories.py index b977a14..81f5ff3 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,7 +1,7 @@ import factory -class SalesRep(factory.django.DjangoModelFactory): +class SalesRepFactory(factory.django.DjangoModelFactory): class Meta: model = 'reps.SalesRep' django_get_or_create = ('username',) @@ -15,7 +15,7 @@ class Meta: @classmethod def _prepare(cls, create, **kwargs): password = kwargs.pop('password', None) - user = super(SalesRep, cls)._prepare(create, **kwargs) + user = super(SalesRepFactory, cls)._prepare(create, **kwargs) if password: user.set_password(password) if create: @@ -23,7 +23,7 @@ def _prepare(cls, create, **kwargs): return user -class Client(factory.django.DjangoModelFactory): +class ClientFactory(factory.django.DjangoModelFactory): class Meta: model = 'customers.Client' From 4e5df9faf9c13c623eb3a04f226aa7557fe18fb8 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 14:36:34 +0200 Subject: [PATCH 03/21] chore: replace deprecated assertEquals --- advanced_filters/tests/test_models.py | 6 +++--- advanced_filters/tests/test_q_serializer.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/advanced_filters/tests/test_models.py b/advanced_filters/tests/test_models.py index 955cb16..f6c777d 100644 --- a/advanced_filters/tests/test_models.py +++ b/advanced_filters/tests/test_models.py @@ -25,21 +25,21 @@ def setUp(self): def test_filter_by_user_empty(self): qs = AdvancedFilter.objects.filter_by_user(user=self.user) - self.assertEquals(qs.count(), 0) + self.assertEqual(qs.count(), 0) def test_filter_by_user_users(self): self.advancedfilter.users.add(self.user) qs = AdvancedFilter.objects.filter_by_user(user=self.user) - self.assertEquals(qs.count(), 1) + self.assertEqual(qs.count(), 1) def test_filter_by_user_groups(self): self.advancedfilter.groups.add(self.group) qs = AdvancedFilter.objects.filter_by_user(user=self.user) - self.assertEquals(qs.count(), 1) + self.assertEqual(qs.count(), 1) def test_list_fields(self): self.advancedfilter.query = Q(some_field__iexact='some_value') diff --git a/advanced_filters/tests/test_q_serializer.py b/advanced_filters/tests/test_q_serializer.py index a098e8d..41bae32 100644 --- a/advanced_filters/tests/test_q_serializer.py +++ b/advanced_filters/tests/test_q_serializer.py @@ -19,7 +19,7 @@ def setUp(self): def test_serialize_q(self): res = self.s.serialize(self.query_a) - self.assertEquals(res, self.correct_query) + self.assertEqual(res, self.correct_query) def test_jsondump_q(self): jres = self.s.dumps(self.query_a) From ddb311f5bc9e5fd188483a73939c6f78710e4b64 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 14:37:57 +0200 Subject: [PATCH 04/21] chore: replace deprecated logger.warn with warning --- advanced_filters/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/advanced_filters/forms.py b/advanced_filters/forms.py index 9068f74..3b13396 100644 --- a/advanced_filters/forms.py +++ b/advanced_filters/forms.py @@ -260,7 +260,7 @@ def get_fields_from_model(self, model, fields): model_field = get_fields_from_path(model, field)[-1] verbose_name = model_field.verbose_name except (FieldDoesNotExist, IndexError, TypeError) as e: - logger.warn("AdvancedFilterForm: skip invalid field " + logger.warning("AdvancedFilterForm: skip invalid field " "- %s", e) continue model_fields[field] = verbose_name From 916eef2910cea9cda8cefd39cd9c3c34738e64b2 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 15:37:34 +0200 Subject: [PATCH 05/21] style: correct small code style bugs --- .gitignore | 1 + advanced_filters/form_helpers.py | 2 +- advanced_filters/forms.py | 23 ++++++++++++----------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 2bdfe29..c1f58df 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ tests/db.sqlite* .DS_Store .pytest_cache /tests/local.db +/.venv diff --git a/advanced_filters/form_helpers.py b/advanced_filters/form_helpers.py index 7bb405b..2523e52 100644 --- a/advanced_filters/form_helpers.py +++ b/advanced_filters/form_helpers.py @@ -7,7 +7,7 @@ logger = logging.getLogger('advanced_filters.form_helpers') -extra_spaces_pattern = re.compile('\s+') +extra_spaces_pattern = re.compile(r'\s+') class VaryingTypeCharField(forms.CharField): diff --git a/advanced_filters/forms.py b/advanced_filters/forms.py index 3b13396..ce1adc1 100644 --- a/advanced_filters/forms.py +++ b/advanced_filters/forms.py @@ -253,17 +253,18 @@ def get_fields_from_model(self, model, fields): """ model_fields = {} for field in fields: - if isinstance(field, tuple) and len(field) == 2: - field, verbose_name = field[0], field[1] - else: - try: - model_field = get_fields_from_path(model, field)[-1] - verbose_name = model_field.verbose_name - except (FieldDoesNotExist, IndexError, TypeError) as e: - logger.warning("AdvancedFilterForm: skip invalid field " - "- %s", e) - continue - model_fields[field] = verbose_name + if isinstance(field, tuple) and len(field) == 2: + field, verbose_name = field[0], field[1] + else: + try: + model_field = get_fields_from_path(model, field)[-1] + verbose_name = model_field.verbose_name + except (FieldDoesNotExist, IndexError, TypeError) as e: + logger.warning( + "AdvancedFilterForm: skip invalid field - %s", e + ) + continue + model_fields[field] = verbose_name return model_fields def __init__(self, *args, **kwargs): From 422a1b8818dde3f081de85219e18e7d5e2ce1f16 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 15:38:50 +0200 Subject: [PATCH 06/21] chore: bump test dependencies * use soft version for coveralls to support python 2.7 * use pycodestyle instead of older pep8 * bump to latest versions of factory-boy and pytest-django --- test-reqs.txt | 7 ++++--- tox.ini | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/test-reqs.txt b/test-reqs.txt index 05f2926..e11e70d 100644 --- a/test-reqs.txt +++ b/test-reqs.txt @@ -1,3 +1,4 @@ -coveralls==0.5 -factory-boy==2.5.2 -pep8==1.6.2 +coveralls +factory-boy==2.12.0 +pycodestyle==2.5.0 +pytest-django==3.9.0 diff --git a/tox.ini b/tox.ini index 58d2dc5..675e98d 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ envlist = py37-d{20,21} pypy-d{17,18,19,110,111} -[pep8] +[pycodestyle] max-line-length = 120 [testenv] @@ -26,4 +26,4 @@ deps = commands = pip install -e . coverage run -m py.test advanced_filters - pep8 --exclude=urls.py,migrations,.ropeproject -v advanced_filters + pycodestyle --exclude=urls.py,migrations,.ropeproject -v advanced_filters From ff67321529df06ea7f42340fd435389d546a5de7 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Sat, 19 Oct 2019 17:37:49 +0300 Subject: [PATCH 07/21] Update CI (#1) --- .travis.yml | 68 +++++++++++++++++++---------------------------------- 1 file changed, 24 insertions(+), 44 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9c43239..cf40e2d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,58 +1,38 @@ language: python sudo: false +dist: xenial cache: pip +os: linux python: - - "2.7" - - "3.3" - - "3.4" - "3.5" - - "pypy" -matrix: + - "3.6" + - "3.7" + - "3.8-dev" + - "pypy3" + - "nightly" +jobs: include: - - python: "3.6" - env: DJANGO="Django>=1.11,<1.12" - - python: "3.6" - env: DJANGO="Django>=2.0,<2.1" - - python: "3.6" - env: DJANGO="Django>=2.1,<2.2" PYTEST_DJANGO="pytest-django==3.4.2" - # - python: "3.7" - # env: DJANGO="Django>=2.0,<2.1" - # - python: "3.7" - # env: DJANGO="Django>=2.1,<2.2" PYTEST_DJANGO="pytest-django==3.4.2" - exclude: - - python: "2.7" - env: DJANGO="Django>=2.0,<2.1" - - python: "2.7" - env: DJANGO="Django>=2.1,<2.2" PYTEST_DJANGO="pytest-django==3.4.2" - - python: "3.5" - env: DJANGO="Django>=1.7,<1.8" - - python: "3.3" - env: DJANGO="Django>=1.9,<1.10" - - python: "3.3" - env: DJANGO="Django>=1.10,<1.11" - - python: "3.3" - env: DJANGO="Django>=1.11,<1.12" - - python: "3.3" - env: DJANGO="Django>=2.0,<2.1" - - python: "pypy" - env: DJANGO="Django>=2.0,<2.1" - - python: "3.3" - env: DJANGO="Django>=2.1,<2.2" PYTEST_DJANGO="pytest-django==3.4.2" - - python: "3.4" - env: DJANGO="Django>=2.1,<2.2" PYTEST_DJANGO="pytest-django==3.4.2" - - python: "pypy" - env: DJANGO="Django>=2.1,<2.2" PYTEST_DJANGO="pytest-django==3.4.2" + - { python: "2.7", matrix: DJANGO="Django>=1.7,<1.8" } + - { python: "2.7", matrix: DJANGO=Django>=1.8,<1.9" } + - { python: "2.7", matrix: DJANGO="Django>=1.9,<1.10" } + - { python: "2.7", matrix: DJANGO="Django>=1.10,<1.11" } + - { python: "pypy", matrix: DJANGO="Django>=1.7,<1.8" } + - { python: "pypy", matrix: DJANGO=Django>=1.8,<1.9" } + - { python: "pypy", matrix: DJANGO="Django>=1.9,<1.10" } + - { python: "pypy", matrix: DJANGO="Django>=1.10,<1.11" } + allow_failures: + - python: "nightly" + - python: "3.8-dev" + - python: "pypy3" + env: global: - - PYTEST_DJANGO=pytest-django==2.9.1 + - PYTEST_DJANGO=pytest-django==3.6.0 matrix: - - DJANGO="Django>=1.7,<1.8" - - DJANGO="Django>=1.8,<1.9" - - DJANGO="Django>=1.9,<1.10" - - DJANGO="Django>=1.10,<1.11" - DJANGO="Django>=1.11,<1.12" - DJANGO="Django>=2.0,<2.1" - - DJANGO="Django>=2.1,<2.2" PYTEST_DJANGO="pytest-django==3.4.2" + - DJANGO="Django>=2.1,<2.2" + install: - pip install $DJANGO - pip install -e .[test] $PYTEST_DJANGO From f7c7f9d02943479de9d1d4884f7c6510bf74daa6 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 16:45:38 +0200 Subject: [PATCH 08/21] fixup! test: refactor unittest test cases to pytest --- advanced_filters/tests/test_get_field_choices_view.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/advanced_filters/tests/test_get_field_choices_view.py b/advanced_filters/tests/test_get_field_choices_view.py index ceda1d0..df836bd 100644 --- a/advanced_filters/tests/test_get_field_choices_view.py +++ b/advanced_filters/tests/test_get_field_choices_view.py @@ -42,9 +42,7 @@ def assert_view_error(client, error, exception=None, **view_kwargs): NO_MODEL_ERROR = "App 'reps' doesn't have a 'Foo' model." -if "PyPy" in getattr(sys, "subversion", ()): - ARGUMENT_LENGTH_ERROR = "expected length 2, got 1" -elif sys.version_info >= (3, 5): +if sys.version_info >= (3, 5): ARGUMENT_LENGTH_ERROR = "not enough values to unpack (expected 2, got 1)" else: ARGUMENT_LENGTH_ERROR = "need more than 1 value to unpack" From 5b124fa436f24bf82c04d8dc286f47cbefc4af7f Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 13:13:30 +0200 Subject: [PATCH 09/21] fix: update requirements for new test deps matrix * use tox-travis to keep the travis and tox requirements fairly clean * drop versions <3.5 and add released 3.8 * use travis python config and specify pypy3.6 * allow any version of coveralls (easier support for python 2.7) * add up to django 3.0 version according to official dep matrix: Refs: - https://www.djangoproject.com/download/#supported-versions - https://docs.djangoproject.com/en/dev/faq/install/ --- .travis.yml | 41 ++++++++++++----------------------------- tox.ini | 50 ++++++++++++++++++++++++++++++++++---------------- 2 files changed, 46 insertions(+), 45 deletions(-) diff --git a/.travis.yml b/.travis.yml index cf40e2d..b878951 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,43 +1,26 @@ language: python sudo: false -dist: xenial +language: python cache: pip -os: linux python: + - "2.7" - "3.5" - "3.6" - "3.7" - - "3.8-dev" + - "3.8" - "pypy3" - - "nightly" -jobs: - include: - - { python: "2.7", matrix: DJANGO="Django>=1.7,<1.8" } - - { python: "2.7", matrix: DJANGO=Django>=1.8,<1.9" } - - { python: "2.7", matrix: DJANGO="Django>=1.9,<1.10" } - - { python: "2.7", matrix: DJANGO="Django>=1.10,<1.11" } - - { python: "pypy", matrix: DJANGO="Django>=1.7,<1.8" } - - { python: "pypy", matrix: DJANGO=Django>=1.8,<1.9" } - - { python: "pypy", matrix: DJANGO="Django>=1.9,<1.10" } - - { python: "pypy", matrix: DJANGO="Django>=1.10,<1.11" } - allow_failures: - - python: "nightly" - - python: "3.8-dev" - - python: "pypy3" env: - global: - - PYTEST_DJANGO=pytest-django==3.6.0 matrix: - - DJANGO="Django>=1.11,<1.12" - - DJANGO="Django>=2.0,<2.1" - - DJANGO="Django>=2.1,<2.2" + - DJANGO="1.9" + - DJANGO="1.10" + - DJANGO="1.11" + - DJANGO="2.0" + - DJANGO="2.1" + - DJANGO="2.2" + - DJANGO="3.0" -install: - - pip install $DJANGO - - pip install -e .[test] $PYTEST_DJANGO -script: - - coverage run -m py.test advanced_filters - - pep8 --exclude=*urls.py --exclude=*migrations advanced_filters -v +install: pip install tox-travis coveralls +script: tox after_success: coveralls diff --git a/tox.ini b/tox.ini index 675e98d..13c3216 100644 --- a/tox.ini +++ b/tox.ini @@ -1,29 +1,47 @@ [tox] envlist = - py27-d{17,18,19,110} - py34-d{17,18,19,110,111,20} - py35-d{18,19,110,111,20,21} - py36-d{111,20,21} - py37-d{20,21} - pypy-d{17,18,19,110,111} + py27-django{19,110,111} + py35-django{19,110,111,20,21,22} + py36-django{111,20,21,22,30} + py37-django{111,20,21,22,30} + py38-django{22,30} + pypy3-django{19,110,111,20,21,22,30} [pycodestyle] max-line-length = 120 [testenv] +passenv = TRAVIS TRAVIS_* deps = - d17: Django>=1.7,<1.8 - d18: Django>=1.8,<1.9 - d19: Django>=1.9,<1.10 - d110: Django>=1.10,<1.11 - d111: Django>=1.11,<1.12 - d20: Django>=2.0,<2.1 - d21: Django>=2.1,<2.2 - !d21: pytest-django==2.9.1 - d21: pytest-django==3.4.2 -rtest-reqs.txt + django19: Django>=1.9,<1.10 + django110: Django>=1.10,<1.11 + django111: Django>=1.11,<1.12 + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + django22: Django>=2.1,<2.2 + django30: Django>=2.1,<2.2 commands = pip install -e . - coverage run -m py.test advanced_filters + coverage run -m pytest advanced_filters pycodestyle --exclude=urls.py,migrations,.ropeproject -v advanced_filters + +[travis] +python = + 2.7: py27 + 3.5: py35 + 3.6: py36 + 3.7: py37 + 3.8: py38 + pypy3: pypy3 + +[travis:env] +DJANGO = + 1.9: django19 + 1.10: django110 + 1.11: django111 + 2.0: django20 + 2.1: django21 + 2.2: django22 + 3.0: django30 From bbb38f7f6140705b694b762ea58b5ae8eeb3778a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Dlouh=C3=BD?= Date: Tue, 4 Feb 2020 15:56:23 +0100 Subject: [PATCH 10/21] Django 3.0 fix #110 --- advanced_filters/form_helpers.py | 2 +- advanced_filters/forms.py | 2 +- advanced_filters/q_serializer.py | 2 +- .../templates/admin/advanced_filters/change_form.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/advanced_filters/form_helpers.py b/advanced_filters/form_helpers.py index 2523e52..681be77 100644 --- a/advanced_filters/form_helpers.py +++ b/advanced_filters/form_helpers.py @@ -3,7 +3,7 @@ from django import forms -from django.utils import six +import six logger = logging.getLogger('advanced_filters.form_helpers') diff --git a/advanced_filters/forms.py b/advanced_filters/forms.py index ce1adc1..726622b 100644 --- a/advanced_filters/forms.py +++ b/advanced_filters/forms.py @@ -15,7 +15,7 @@ from django.forms.formsets import formset_factory, BaseFormSet from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ -from django.utils.six.moves import range, reduce +from six.moves import range, reduce from django.utils.text import capfirst import django diff --git a/advanced_filters/q_serializer.py b/advanced_filters/q_serializer.py index a30a6a0..c0bc668 100644 --- a/advanced_filters/q_serializer.py +++ b/advanced_filters/q_serializer.py @@ -3,7 +3,7 @@ import base64 import time -from django.utils import six +import six from django.db.models import Q from django.core.serializers.base import SerializationError diff --git a/advanced_filters/templates/admin/advanced_filters/change_form.html b/advanced_filters/templates/admin/advanced_filters/change_form.html index e64920d..5504945 100644 --- a/advanced_filters/templates/admin/advanced_filters/change_form.html +++ b/advanced_filters/templates/admin/advanced_filters/change_form.html @@ -1,6 +1,6 @@ {% extends "admin/change_form.html" %} -{% load i18n admin_static admin_modify admin_urls %} +{% load i18n static admin_modify admin_urls %} {% block extrastyle %} {{ adminform.media.css }} From 23629ef394d3f8001a463175285105b3d4a8cefa Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 19:32:21 +0200 Subject: [PATCH 11/21] fix: switch deprecated force_text to force_str --- .../tests/test_get_field_choices_view.py | 12 ++++++------ advanced_filters/views.py | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/advanced_filters/tests/test_get_field_choices_view.py b/advanced_filters/tests/test_get_field_choices_view.py index df836bd..89d254e 100644 --- a/advanced_filters/tests/test_get_field_choices_view.py +++ b/advanced_filters/tests/test_get_field_choices_view.py @@ -3,7 +3,7 @@ import django import pytest -from django.utils.encoding import force_text +from django.utils.encoding import force_str from tests.factories import ClientFactory try: @@ -16,7 +16,7 @@ def assert_json(content, expect): - assert json.loads(force_text(content)) == expect + assert json.loads(force_str(content)) == expect def assert_view_error(client, error, exception=None, **view_kwargs): @@ -34,11 +34,11 @@ def assert_view_error(client, error, exception=None, **view_kwargs): assert_json(response.content, dict(error=error)) -if django.VERSION < (1, 7): - NO_APP_INSTALLED_ERROR = "No installed app/model: foo.test" - NO_MODEL_ERROR = "No installed app/model: reps.Foo" +NO_APP_INSTALLED_ERROR = "No installed app with label 'foo'." + +if django.VERSION < (1, 11): + NO_MODEL_ERROR = "App 'reps' doesn't have a 'foo' model." else: - NO_APP_INSTALLED_ERROR = "No installed app with label 'foo'." NO_MODEL_ERROR = "App 'reps' doesn't have a 'Foo' model." diff --git a/advanced_filters/views.py b/advanced_filters/views.py index feb265f..088f862 100644 --- a/advanced_filters/views.py +++ b/advanced_filters/views.py @@ -6,7 +6,7 @@ from django.contrib.admin.utils import get_fields_from_path from django.db import models from django.db.models.fields import FieldDoesNotExist -from django.utils.encoding import force_text +from django.utils.encoding import force_str from django.views.generic import View from braces.views import (CsrfExemptMixin, StaffuserRequiredMixin, @@ -44,7 +44,7 @@ def get(self, request, model=None, field_name=None): except (LookupError, FieldDoesNotExist) as e: logger.debug("Invalid kwargs passed to view: %s", e) return self.render_json_response( - {'error': force_text(e)}, status=400) + {'error': force_str(e)}, status=400) choices = field.choices # if no choices, populate with distinct values from instances @@ -71,7 +71,7 @@ def get(self, request, model=None, field_name=None): else: choices = [] - results = [{'id': c[0], 'text': force_text(c[1])} for c in sorted( + results = [{'id': c[0], 'text': force_str(c[1])} for c in sorted( choices, key=itemgetter(0))] return self.render_json_response({'results': results}) From 4480128bf19cb07ac2e6be752c09a3346669822c Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 19:33:12 +0200 Subject: [PATCH 12/21] fix: avoid installing newer braces due to hard requirement of Django 1.11, it's incompatible with our wish to still support django versions 1.9 and 1.10 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1eaff0c..cfa2337 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ def get_full_description(): packages=find_packages(exclude=['tests*', 'tests.*', '*.tests']), include_package_data=True, install_requires=[ - 'django-braces>=1.4.0,<2', + 'django-braces>=1.4.0,<1.14.0', 'simplejson>=3.6.5,<4', ], extras_require=dict(test=TEST_REQS), From 9dbcc91e00942b4616454f86adf323c5f35542e7 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 19:51:01 +0200 Subject: [PATCH 13/21] fixup! test: refactor unittest test cases to pytest --- advanced_filters/tests/test_get_field_choices_view.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/advanced_filters/tests/test_get_field_choices_view.py b/advanced_filters/tests/test_get_field_choices_view.py index 89d254e..2deb8db 100644 --- a/advanced_filters/tests/test_get_field_choices_view.py +++ b/advanced_filters/tests/test_get_field_choices_view.py @@ -47,7 +47,10 @@ def assert_view_error(client, error, exception=None, **view_kwargs): else: ARGUMENT_LENGTH_ERROR = "need more than 1 value to unpack" -MISSING_FIELD_ERROR = "SalesRep has no field named 'baz'" +if sys.version_info < (3, ) and django.VERSION < (1, 11): + MISSING_FIELD_ERROR = "SalesRep has no field named u'baz'" +else: + MISSING_FIELD_ERROR = "SalesRep has no field named 'baz'" def test_invalid_view_kwargs(client): From ecfdf417f64dadf658b9862fe26b597b1ed96d45 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 20:23:51 +0200 Subject: [PATCH 14/21] chore: update python and add Django classifiers --- setup.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index cfa2337..ff349b2 100644 --- a/setup.py +++ b/setup.py @@ -81,8 +81,18 @@ def get_full_description(): 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Framework :: Django', + 'Framework :: Django :: 1.9' + 'Framework :: Django :: 1.10' + 'Framework :: Django :: 1.11' + 'Framework :: Django :: 2.0' + 'Framework :: Django :: 2.1' + 'Framework :: Django :: 2.2' + 'Framework :: Django :: 3.0' 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', ], From 85c282b0e4078af8972d88fab44857f95597ed74 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 20:27:40 +0200 Subject: [PATCH 15/21] docs: update dependencies stated in the README + update link to vtiger ref --- README.rst | 8 ++++---- setup.py | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index b8b36ed..1200ab2 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ admin. Mimics the advanced search feature in `VTiger `__, `see here for more -info `__ +info `__ .. figure:: https://raw.githubusercontent.com/modlinltd/django-advanced-filters/develop/screenshot.png :alt: Creating via a modal @@ -26,9 +26,9 @@ For release notes, see `Changelog = 1.7 (Django 1.7 - 2.1 on Python 2/3/PyPy2) -- django-braces == 1.4.0 -- simplejson == 3.6.5 +- Django >= 1.9 (Django 1.9 - 3.0 on Python 2/3/PyPy3) +- django-braces >= 1.4, < 1.14.0 +- simplejson >= 3.6.5, < 4 Installation & Set up diff --git a/setup.py b/setup.py index ff349b2..6fa3bf3 100644 --- a/setup.py +++ b/setup.py @@ -86,13 +86,13 @@ def get_full_description(): 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Framework :: Django', - 'Framework :: Django :: 1.9' - 'Framework :: Django :: 1.10' - 'Framework :: Django :: 1.11' - 'Framework :: Django :: 2.0' - 'Framework :: Django :: 2.1' - 'Framework :: Django :: 2.2' - 'Framework :: Django :: 3.0' + 'Framework :: Django :: 1.9', + 'Framework :: Django :: 1.10', + 'Framework :: Django :: 1.11', + 'Framework :: Django :: 2.0', + 'Framework :: Django :: 2.1', + 'Framework :: Django :: 2.2', + 'Framework :: Django :: 3.0', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', ], From f3e2405e30f51b35977cf631ddad4456d8a7f949 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sun, 26 Apr 2020 21:28:28 +0200 Subject: [PATCH 16/21] test: add a failing test_choices_has_null required setting a string field to nullable in the Client test model + test that datetime field choices are being excluded --- .../tests/test_get_field_choices_view.py | 35 ++++++++++++++++++- tests/customers/migrations/0001_initial.py | 2 +- tests/customers/models.py | 2 +- tests/factories.py | 1 + 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/advanced_filters/tests/test_get_field_choices_view.py b/advanced_filters/tests/test_get_field_choices_view.py index 2deb8db..28947b3 100644 --- a/advanced_filters/tests/test_get_field_choices_view.py +++ b/advanced_filters/tests/test_get_field_choices_view.py @@ -1,8 +1,12 @@ import json import sys +from datetime import timedelta +from operator import attrgetter import django +import factory import pytest +from django.utils import timezone from django.utils.encoding import force_str from tests.factories import ClientFactory @@ -47,7 +51,7 @@ def assert_view_error(client, error, exception=None, **view_kwargs): else: ARGUMENT_LENGTH_ERROR = "need more than 1 value to unpack" -if sys.version_info < (3, ) and django.VERSION < (1, 11): +if sys.version_info < (3,) and django.VERSION < (1, 11): MISSING_FIELD_ERROR = "SalesRep has no field named u'baz'" else: MISSING_FIELD_ERROR = "SalesRep has no field named 'baz'" @@ -135,3 +139,32 @@ def test_distinct_database_choices(user, client, settings): assert_json( response.content, {"results": [{"id": "foo@bar.com", "text": "foo@bar.com"}]} ) + + +def test_choices_no_date_fields_support(user, client, settings): + settings.ADVANCED_FILTERS_MAX_CHOICES = 4 + logins = [timezone.now(), timezone.now() - timedelta(days=1), None] + ClientFactory.create_batch( + 3, assigned_to=user, email="foo@bar.com", last_login=factory.Iterator(logins) + ) + view_url = reverse( + URL_NAME, kwargs=dict(model="customers.Client", field_name="last_login") + ) + response = client.get(view_url) + assert_json(response.content, {"results": []}) + + +def test_choices_has_null(user, client, settings): + settings.ADVANCED_FILTERS_MAX_CHOICES = 4 + named_users = ClientFactory.create_batch(2, assigned_to=user) + names = [None] + sorted(set([nu.first_name for nu in named_users])) + assert len(named_users) == 2 + ClientFactory.create_batch(2, assigned_to=user, first_name=None) + view_url = reverse( + URL_NAME, kwargs=dict(model="customers.Client", field_name="first_name") + ) + response = client.get(view_url) + assert_json( + response.content, + {"results": [{"id": name, "text": str(name)} for name in names]}, + ) diff --git a/tests/customers/migrations/0001_initial.py b/tests/customers/migrations/0001_initial.py index 5cbf642..e6f6867 100644 --- a/tests/customers/migrations/0001_initial.py +++ b/tests/customers/migrations/0001_initial.py @@ -25,7 +25,7 @@ class Migration(migrations.Migration): ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('language', models.CharField(choices=[(b'en', b'English'), (b'sp', b'Spanish'), (b'it', b'Italian')], default=b'en', max_length=8)), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('first_name', models.CharField(null=True, max_length=30, verbose_name='first name')), ('last_name', models.CharField(blank=True, max_length=30, verbose_name='last name')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), diff --git a/tests/customers/models.py b/tests/customers/models.py index 295191b..680242f 100644 --- a/tests/customers/models.py +++ b/tests/customers/models.py @@ -15,7 +15,7 @@ class Client(AbstractBaseUser): language = models.CharField(max_length=8, choices=VALID_LANGUAGES, default='en') email = models.EmailField(_('email address'), blank=True) - first_name = models.CharField(_('first name'), max_length=30, blank=True) + first_name = models.CharField(_('first name'), max_length=30, null=True) last_name = models.CharField(_('last name'), max_length=30, blank=True) is_active = models.BooleanField( _('active'), default=True, diff --git a/tests/factories.py b/tests/factories.py index 81f5ff3..8fb039d 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -27,4 +27,5 @@ class ClientFactory(factory.django.DjangoModelFactory): class Meta: model = 'customers.Client' + first_name = factory.faker.Faker('first_name') email = factory.Sequence(lambda n: 'c%d@foo.com' % n) From 4029e12d5e50203c19a18afa5991de3c1e0a7713 Mon Sep 17 00:00:00 2001 From: Hugo Maingonnat Date: Wed, 14 Nov 2018 19:01:12 +0100 Subject: [PATCH 17/21] fix: allow choices sort on None fields ; Conflicts: ; advanced_filters/views.py --- advanced_filters/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/advanced_filters/views.py b/advanced_filters/views.py index 088f862..72e41f9 100644 --- a/advanced_filters/views.py +++ b/advanced_filters/views.py @@ -1,4 +1,3 @@ -from operator import itemgetter import logging from django.apps import apps @@ -72,6 +71,6 @@ def get(self, request, model=None, field_name=None): choices = [] results = [{'id': c[0], 'text': force_str(c[1])} for c in sorted( - choices, key=itemgetter(0))] + choices, key=lambda x: (x[0] is not None, x[0]))] return self.render_json_response({'results': results}) From 190db4adc1e85a8b89161969a39bf5eadd90fc7e Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sat, 16 May 2020 19:41:52 +0200 Subject: [PATCH 18/21] add release workflow --- .github/workflows/pythonpublish.yml | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/pythonpublish.yml diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml new file mode 100644 index 0000000..4e1ef42 --- /dev/null +++ b/.github/workflows/pythonpublish.yml @@ -0,0 +1,31 @@ +# This workflows will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* From d4561018dcd21f2be685eb9f27c0b7b9bbe1201b Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sat, 16 May 2020 19:26:12 +0200 Subject: [PATCH 19/21] test: test the CleanWhiteSpacesMixin helper --- advanced_filters/tests/test_helpers.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 advanced_filters/tests/test_helpers.py diff --git a/advanced_filters/tests/test_helpers.py b/advanced_filters/tests/test_helpers.py new file mode 100644 index 0000000..7e75669 --- /dev/null +++ b/advanced_filters/tests/test_helpers.py @@ -0,0 +1,17 @@ +from ..form_helpers import CleanWhiteSpacesMixin + +import django.forms + + +class FormToTest(CleanWhiteSpacesMixin, django.forms.Form): + some_field = django.forms.CharField() + + +def test_spaces_removed(): + form = FormToTest(data={'some_field': ' a weird value '}) + assert form.is_valid() + assert form.cleaned_data == {'some_field': 'a weird value'} + + form = FormToTest(data={'some_field': ' \n\r \n '}) + assert not form.is_valid() + assert form.cleaned_data == {} From b2e4fe52d1870b7f74524ad428dfc555f7939039 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sat, 16 May 2020 19:34:23 +0200 Subject: [PATCH 20/21] chore: update changelog and bump version to 1.2.0 --- CHANGELOG.rst | 55 ++++++++++++++++++++++++++++++++++++ advanced_filters/__init__.py | 2 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3a296d1..d23d00e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,61 @@ Changelog ========= +1.2.0 - Django 3 and more +------------------------- + +It's finally time to drop the dirty old rags and don some fresh colors. + +Thanks to effort from multiple contributors, this version includes support +for newest Django version. + +Breaking Changes +~~~~~~~~~~~~~~~~ + +* Add support for Django 2.2 and 3.0 +* Drop support for Django < 1.9 +* Drop support for Python 3.3-3.4 + +*django-advanced-filters now support only* **python 2.7, and 3.5 - 3.8.** + +Features +~~~~~~~~ + +- Switch deprecated force_text to force_str (Merge 0427d11) + +Bug fixes +~~~~~~~~~ + +- Avoid installing newer braces (Merge 0427d11) +- Allow choices sort on None fields (Merge 142ecd0) + +Docs / Tests +~~~~~~~~~~~~ + +- Update dependencies stated in the README +- Refactor some unittest test cases into pytest (Merge 41271b7) +- Test the CleanWhiteSpacesMixin helper + +Misc +~~~~ + +- Update requirements for new test deps matrix (Merge 0427d11) +- Replace deprecated assertEquals (Merge 41271b7) +- Replace deprecated logger.warn with warning (Merge 41271b7) +- Bump test dependencies (Merge 41271b7) +- Update python and add Django classifiers + + +Contributors +~~~~~~~~~~~~ + +- Petr DlouhĂ˝ +- Alon Raizman +- Hugo Maingonnat +- Arpit +- Pavel Savchenko + + 1.1.1 - CHANGELOG rendering is hard ----------------------------------- diff --git a/advanced_filters/__init__.py b/advanced_filters/__init__.py index b3ddbc4..58d478a 100644 --- a/advanced_filters/__init__.py +++ b/advanced_filters/__init__.py @@ -1 +1 @@ -__version__ = '1.1.1' +__version__ = '1.2.0' From f866234e8f934e29a1c0c5003d4aad91aa0d2f33 Mon Sep 17 00:00:00 2001 From: Pavel Savchenko Date: Sat, 16 May 2020 19:41:12 +0200 Subject: [PATCH 21/21] docs: fix typo and clarify title Switching to using the python publish github action which uses twine to push source and wheel to pypi whenever a release is created in GitHub --- CONTRIBUTING.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 9414602..a405398 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -26,10 +26,10 @@ Pull Request Process other developer, or if you do not have permission to do that, you may request the reviewer to merge it for you. -Release process ---------------- +Manual Release process +---------------------- -1. Prepeare the changelog and amend the CHANGELOG.rst +1. Prepare the changelog and amend the CHANGELOG.rst 2. Increase the version numbers in any examples files and the README.rst to the new version that this Pull Request would represent. The versioning scheme we use is `SemVer `__.