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/* 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/.travis.yml b/.travis.yml index 9c43239..b878951 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,63 +1,26 @@ language: python sudo: false +language: python cache: pip python: - "2.7" - - "3.3" - - "3.4" - "3.5" - - "pypy" -matrix: - 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" + - "3.6" + - "3.7" + - "3.8" + - "pypy3" + env: - global: - - PYTEST_DJANGO=pytest-django==2.9.1 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" -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 + - DJANGO="1.9" + - DJANGO="1.10" + - DJANGO="1.11" + - DJANGO="2.0" + - DJANGO="2.1" + - DJANGO="2.2" + - DJANGO="3.0" + +install: pip install tox-travis coveralls +script: tox after_success: coveralls 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/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 `__. diff --git a/README.rst b/README.rst index db24ca4..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,16 +26,16 @@ 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 ===================== 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) 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' diff --git a/advanced_filters/form_helpers.py b/advanced_filters/form_helpers.py index 7bb405b..681be77 100644 --- a/advanced_filters/form_helpers.py +++ b/advanced_filters/form_helpers.py @@ -3,11 +3,11 @@ from django import forms -from django.utils import six +import six 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 9068f74..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 @@ -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.warn("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): 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 }} 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..28947b3 --- /dev/null +++ b/advanced_filters/tests/test_get_field_choices_view.py @@ -0,0 +1,170 @@ +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 + +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_str(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)) + + +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_MODEL_ERROR = "App 'reps' doesn't have a 'Foo' model." + + +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" + +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): + 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"}]} + ) + + +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/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 == {} 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) 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/advanced_filters/views.py b/advanced_filters/views.py index feb265f..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 @@ -6,7 +5,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 +43,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 +70,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( - choices, key=itemgetter(0))] + results = [{'id': c[0], 'text': force_str(c[1])} for c in sorted( + choices, key=lambda x: (x[0] is not None, x[0]))] return self.render_json_response({'results': results}) diff --git a/setup.py b/setup.py index 1eaff0c..6fa3bf3 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), @@ -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', ], 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/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 b977a14..8fb039d 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,8 +23,9 @@ def _prepare(cls, create, **kwargs): return user -class Client(factory.django.DjangoModelFactory): +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) diff --git a/tox.ini b/tox.ini index 58d2dc5..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} -[pep8] +[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 - pep8 --exclude=urls.py,migrations,.ropeproject -v 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