From 60edde3bc6c4addede065f1deaff1111fddd7b3c Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Fri, 18 Oct 2019 19:45:24 +0300 Subject: [PATCH 01/14] filter without creating new object --- advanced_filters/admin.py | 34 ++++++++++++++++++- .../templates/admin/advanced_filters.html | 1 + .../templates/admin/query_filter.html | 1 + advanced_filters/tests/test_admin.py | 11 ++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 advanced_filters/templates/admin/query_filter.html diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index 5cd7f5e..bb09b2c 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -9,6 +9,7 @@ from .forms import AdvancedFilterForm from .models import AdvancedFilter +from .q_serializer import QSerializer logger = logging.getLogger('advanced_filters.admin') @@ -43,6 +44,31 @@ def queryset(self, request, queryset): return queryset +class AdvancedQueryFilters(admin.SimpleListFilter): + """Allow filtering by advanced filters query""" + title = ' ' + template = 'admin/query_filter.html' + + parameter_name = '_aquery' + + def lookups(self, request, model_admin): + return None + + def queryset(self, request, queryset): + if self.value(): + query_serializer = QSerializer(base64=True) + query = query_serializer.loads(self.value()) + return queryset.filter(query).distinct() + + return queryset + + def choices(self, changelist): + return [] + + def has_output(self): + return True + + class AdminAdvancedFiltersMixin(object): """ Generic AdvancedFilters mixin """ advanced_change_list_template = "admin/advanced_filters.html" @@ -56,9 +82,15 @@ def __init__(self, *args, **kwargs): self.original_change_list_template = "admin/change_list.html" self.change_list_template = self.advanced_change_list_template # add list filters to filters - self.list_filter = (AdvancedListFilters,) + tuple(self.list_filter) + self.list_filter = (AdvancedListFilters, AdvancedQueryFilters,) + tuple(self.list_filter) def save_advanced_filter(self, request, form): + if '_just_filter' in request.POST: + search_query = form.generate_query() + query_serializer = QSerializer(base64=True) + b64_query = query_serializer.dumps(search_query) + url = f"{request.path}?_aquery={b64_query}" + return HttpResponseRedirect(url) if form.is_valid(): afilter = form.save(commit=False) afilter.created_by = request.user diff --git a/advanced_filters/templates/admin/advanced_filters.html b/advanced_filters/templates/admin/advanced_filters.html index f1f5487..d7780dd 100644 --- a/advanced_filters/templates/admin/advanced_filters.html +++ b/advanced_filters/templates/admin/advanced_filters.html @@ -63,6 +63,7 @@

{% trans "Create advanced filter" %}:


+ {% trans "Cancel" %} diff --git a/advanced_filters/templates/admin/query_filter.html b/advanced_filters/templates/admin/query_filter.html new file mode 100644 index 0000000..9299a53 --- /dev/null +++ b/advanced_filters/templates/admin/query_filter.html @@ -0,0 +1 @@ +{# for now - make filter hidden in sidebar #} diff --git a/advanced_filters/tests/test_admin.py b/advanced_filters/tests/test_admin.py index 320d5ff..7970593 100644 --- a/advanced_filters/tests/test_admin.py +++ b/advanced_filters/tests/test_admin.py @@ -127,6 +127,17 @@ def test_create_form_valid(self): assert list(created_filter.query.children[0]) == self.query + # just filter + form_data.pop('_save_goto') + form_data['_just_filter'] = 1 + url = reverse('admin:customers_client_changelist') + res = self.client.post(url, data=form_data) + + assert res.status_code == 302 + assert AdvancedFilter.objects.count() == 2 + url = res['location'] + assert '?_aquery=' in url + class AdvancedFilterUsageTest(TestCase): """ Test filter visibility and actual filtering of a changelist """ From c4f539fb2cce75042f33abd939a6342870076e00 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Fri, 18 Oct 2019 20:04:22 +0300 Subject: [PATCH 02/14] template string removed --- advanced_filters/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index bb09b2c..9676316 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -89,7 +89,8 @@ def save_advanced_filter(self, request, form): search_query = form.generate_query() query_serializer = QSerializer(base64=True) b64_query = query_serializer.dumps(search_query) - url = f"{request.path}?_aquery={b64_query}" + url = "{path}?_aquery={query}"\ + .format(path=request.path, query=b64_query) return HttpResponseRedirect(url) if form.is_valid(): afilter = form.save(commit=False) From 31ab25e9a0f7e8ef7852683896b596b1ba54fa47 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Mon, 21 Oct 2019 19:25:00 +0300 Subject: [PATCH 03/14] Just filter without save (#2) * filter without creating new object --- advanced_filters/admin.py | 35 ++++++++++++++++++- .../templates/admin/advanced_filters.html | 1 + .../templates/admin/query_filter.html | 1 + advanced_filters/tests/test_admin.py | 11 ++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 advanced_filters/templates/admin/query_filter.html diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index 5cd7f5e..9676316 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -9,6 +9,7 @@ from .forms import AdvancedFilterForm from .models import AdvancedFilter +from .q_serializer import QSerializer logger = logging.getLogger('advanced_filters.admin') @@ -43,6 +44,31 @@ def queryset(self, request, queryset): return queryset +class AdvancedQueryFilters(admin.SimpleListFilter): + """Allow filtering by advanced filters query""" + title = ' ' + template = 'admin/query_filter.html' + + parameter_name = '_aquery' + + def lookups(self, request, model_admin): + return None + + def queryset(self, request, queryset): + if self.value(): + query_serializer = QSerializer(base64=True) + query = query_serializer.loads(self.value()) + return queryset.filter(query).distinct() + + return queryset + + def choices(self, changelist): + return [] + + def has_output(self): + return True + + class AdminAdvancedFiltersMixin(object): """ Generic AdvancedFilters mixin """ advanced_change_list_template = "admin/advanced_filters.html" @@ -56,9 +82,16 @@ def __init__(self, *args, **kwargs): self.original_change_list_template = "admin/change_list.html" self.change_list_template = self.advanced_change_list_template # add list filters to filters - self.list_filter = (AdvancedListFilters,) + tuple(self.list_filter) + self.list_filter = (AdvancedListFilters, AdvancedQueryFilters,) + tuple(self.list_filter) def save_advanced_filter(self, request, form): + if '_just_filter' in request.POST: + search_query = form.generate_query() + query_serializer = QSerializer(base64=True) + b64_query = query_serializer.dumps(search_query) + url = "{path}?_aquery={query}"\ + .format(path=request.path, query=b64_query) + return HttpResponseRedirect(url) if form.is_valid(): afilter = form.save(commit=False) afilter.created_by = request.user diff --git a/advanced_filters/templates/admin/advanced_filters.html b/advanced_filters/templates/admin/advanced_filters.html index f1f5487..d7780dd 100644 --- a/advanced_filters/templates/admin/advanced_filters.html +++ b/advanced_filters/templates/admin/advanced_filters.html @@ -63,6 +63,7 @@

{% trans "Create advanced filter" %}:


+ {% trans "Cancel" %} diff --git a/advanced_filters/templates/admin/query_filter.html b/advanced_filters/templates/admin/query_filter.html new file mode 100644 index 0000000..9299a53 --- /dev/null +++ b/advanced_filters/templates/admin/query_filter.html @@ -0,0 +1 @@ +{# for now - make filter hidden in sidebar #} diff --git a/advanced_filters/tests/test_admin.py b/advanced_filters/tests/test_admin.py index 320d5ff..7970593 100644 --- a/advanced_filters/tests/test_admin.py +++ b/advanced_filters/tests/test_admin.py @@ -127,6 +127,17 @@ def test_create_form_valid(self): assert list(created_filter.query.children[0]) == self.query + # just filter + form_data.pop('_save_goto') + form_data['_just_filter'] = 1 + url = reverse('admin:customers_client_changelist') + res = self.client.post(url, data=form_data) + + assert res.status_code == 302 + assert AdvancedFilter.objects.count() == 2 + url = res['location'] + assert '?_aquery=' in url + class AdvancedFilterUsageTest(TestCase): """ Test filter visibility and actual filtering of a changelist """ From 1541a35955386ecaa37cc31c541ee81ea02e84e8 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Mon, 21 Oct 2019 19:25:33 +0300 Subject: [PATCH 04/14] Update CI (#1) (#3) --- .travis.yml | 68 ++++++++------------- advanced_filters/tests/test_models.py | 6 +- advanced_filters/tests/test_q_serializer.py | 2 +- 3 files changed, 28 insertions(+), 48 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 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 408cf8860960a48198f2ea118765e14704ad3862 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Thu, 5 Dec 2019 11:50:43 +0200 Subject: [PATCH 05/14] FE bug fixes (#4) --- advanced_filters/forms.py | 17 ++++-- .../advanced-filters/advanced-filters.js | 56 ++++++++++++------- .../admin/advanced_filters/change_form.html | 1 - .../templates/admin/common_js_init.html | 2 +- 4 files changed, 49 insertions(+), 27 deletions(-) diff --git a/advanced_filters/forms.py b/advanced_filters/forms.py index 9068f74..b18f140 100644 --- a/advanced_filters/forms.py +++ b/advanced_filters/forms.py @@ -66,7 +66,7 @@ class AdvancedFilterQueryForm(CleanWhiteSpacesMixin, forms.Form): label=_('Operator'), required=True, choices=OPERATORS, initial="iexact", widget=forms.Select(attrs={'class': 'query-operator'})) - value = VaryingTypeCharField(required=True, widget=forms.TextInput( + value = VaryingTypeCharField(required=False, widget=forms.TextInput( attrs={'class': 'query-value'}), label=_('Value')) value_from = forms.DateTimeField(widget=forms.HiddenInput( attrs={'class': 'query-dt-from'}), required=False) @@ -134,12 +134,8 @@ def _parse_query_dict(query_data, model): elif query_data['value'] is False: query_data['operator'] = "isfalse" else: - if isinstance(mfield, DateField): - # this is a date/datetime field - query_data['operator'] = "range" # default - else: + if not query_data.get('operator') == 'range': query_data['operator'] = operator # default - if isinstance(query_data.get('value'), list) and query_data['operator'] == 'range': date_from = date_to_string(query_data.get('value_from')) @@ -168,6 +164,15 @@ def clean(self): self.set_range_value(cleaned_data) return cleaned_data + def clean_value(self): + value = self.cleaned_data['value'] + op = self.cleaned_data.get('operator', '') + list = ['istrue', 'isfalse', 'isnull'] + if op not in list: + self.fields['value'].required = True + return self.fields['value'].clean(value) + return value + def make_query(self, *args, **kwargs): """ Returns a Q object from the submitted form """ query = Q() # initial is an empty query diff --git a/advanced_filters/static/advanced-filters/advanced-filters.js b/advanced_filters/static/advanced-filters/advanced-filters.js index 3e3865a..c1fe1e1 100644 --- a/advanced_filters/static/advanced-filters/advanced-filters.js +++ b/advanced_filters/static/advanced-filters/advanced-filters.js @@ -34,10 +34,10 @@ var OperatorHandlers = function($) { } self.val_input.css({display: 'none'}); - $(".hasDatepicker").datepicker("destroy"); + try {$(".hasDatepicker").datepicker("destroy");} catch(e) {} $from.addClass('vDateField'); $to.addClass('vDateField'); - grappelli.initDateAndTimePicker(); + try {grappelli.initDateAndTimePicker();} catch(e) {} }; self.remove_datepickers = function() { @@ -45,7 +45,7 @@ var OperatorHandlers = function($) { if (self.val_input.parent().find('input.vDateField').length > 0) { var datefields = self.val_input.parent().find('input.vDateField'); datefields.each(function() { - $(this).datepicker("destroy"); + try {$(this).datepicker("destroy");} catch(e) {} }); datefields.remove(); } @@ -53,28 +53,46 @@ var OperatorHandlers = function($) { self.modify_widget = function(elm) { // pick a widget for the value field according to operator + list = ['istrue', 'isfalse', 'isnull']; self.value = $(elm).val(); self.val_input = $(elm).parents('tr').find('.query-value'); console.log("selected operator: " + self.value); + var field = $(elm).parents('tr').find('.query-field'); + self.initialize_select2(field); + if (self.value == "range") { self.add_datepickers(); } else { self.remove_datepickers(); } + + var input = $(elm).parents('tr').find('input.query-value'); + if (list.includes(self.value)) { + input.prop('readonly', true); + } else { + input.prop('readonly', false); + } }; self.initialize_select2 = function(elm) { // initialize select2 widget and populate field choices var field = $(elm).val(); - var choices_url = ADVANCED_FILTER_CHOICES_LOOKUP_URL + (FORM_MODEL || - MODEL_LABEL) + '/' + field; - var input = $(elm).parents('tr').find('input.query-value'); - input.select2("destroy"); - $.get(choices_url, function(data) { - input.select2({'data': data, 'createSearchChoice': function(term) { - return { 'id': term, 'text': term }; - }}); - }); + var op = $(elm).parents('tr').find('.query-operator'); + if (field.includes('__') && op.val() == 'iexact') { + var choices_url = ADVANCED_FILTER_CHOICES_LOOKUP_URL + (FORM_MODEL || + MODEL_LABEL) + '/' + field; + var input = $(elm).parents('tr').find('input.query-value'); + input.select2("destroy"); + $.get(choices_url, function(data) { + input.select2({'data': data, 'createSearchChoice': function(term) { + return { 'id': term, 'text': term }; + }}); + }); + } + else { + var input = $(elm).parents('tr').find('input.query-value'); + input.select2("destroy"); + } }; self.field_selected = function(elm) { @@ -108,15 +126,16 @@ var OperatorHandlers = function($) { // if only 1 form and it's empty, add first extra formset $('[data-rules-formset] .add-row a').click(); } + $('.form-row select.query-operator').each(function() { $(this).off("change"); - $(this).data('pre_change', $(this).val()); + // $(this).data('pre_change', $(this).val()); $(this).on("change", function() { var before_change = $(this).data('pre_change'); if ($(this).val() != before_change) self.modify_widget(this); $(this).data('pre_change', $(this).val()); }).change(); - self.modify_widget(this); + // self.modify_widget(this); }); $('.form-row select.query-field').each(function() { $(this).off("change"); @@ -127,18 +146,17 @@ var OperatorHandlers = function($) { $(this).data('pre_change', $(this).val()); }).change(); }); - self.field_selected($('.form-row select.query-field').first()); - + // self.field_selected($('.form-row select.query-field').first()); }; self.destroy = function() { - $('.form-row select.query-operator').each(function() { + $('.form-row select.query-operator:last').each(function() { $(this).off("change"); }); - $('.form-row select.query-field').each(function() { + $('.form-row select.query-field:last').each(function() { $(this).off("change"); }); - $('.form-row input.query-value').each(function() { + $('.form-row input.query-value:last').each(function() { $(this).select2("destroy"); }); }; diff --git a/advanced_filters/templates/admin/advanced_filters/change_form.html b/advanced_filters/templates/admin/advanced_filters/change_form.html index e64920d..76266c0 100644 --- a/advanced_filters/templates/admin/advanced_filters/change_form.html +++ b/advanced_filters/templates/admin/advanced_filters/change_form.html @@ -11,7 +11,6 @@
{% csrf_token %}{% block form_top %}{% endblock %} {% with adminform.form.fields_formset as formset %}
-

{% trans "Change advanced filter" %}:

{% csrf_token %} {{ formset.management_form }} diff --git a/advanced_filters/templates/admin/common_js_init.html b/advanced_filters/templates/admin/common_js_init.html index 509cd5e..049d140 100644 --- a/advanced_filters/templates/admin/common_js_init.html +++ b/advanced_filters/templates/admin/common_js_init.html @@ -24,4 +24,4 @@ } }); })(django.jQuery); - \ No newline at end of file + From 19366715ff2993bc34c7ace9b583f767614aa4ed Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Thu, 5 Dec 2019 15:22:21 +0200 Subject: [PATCH 06/14] select2 widgets not initializing (#6) * Fixed select2 widgets not initializing * fixed bad placement of initialize_select2 call * Changes URL declaration to avoid deprecated pattern This commit changes the way the URLs are declared to avoid RemovedInDjango110Warning messages. The old way, using django.conf.urls.patterns(), is deprecated and will be removed in Django 1.10 --- advanced_filters/static/advanced-filters/advanced-filters.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/advanced_filters/static/advanced-filters/advanced-filters.js b/advanced_filters/static/advanced-filters/advanced-filters.js index c1fe1e1..3c1edfb 100644 --- a/advanced_filters/static/advanced-filters/advanced-filters.js +++ b/advanced_filters/static/advanced-filters/advanced-filters.js @@ -82,8 +82,12 @@ var OperatorHandlers = function($) { var choices_url = ADVANCED_FILTER_CHOICES_LOOKUP_URL + (FORM_MODEL || MODEL_LABEL) + '/' + field; var input = $(elm).parents('tr').find('input.query-value'); + var value = input.val(); input.select2("destroy"); $.get(choices_url, function(data) { + if (value) { + data.results.push({'id': value, 'text': value}) + } input.select2({'data': data, 'createSearchChoice': function(term) { return { 'id': term, 'text': term }; }}); @@ -145,6 +149,7 @@ var OperatorHandlers = function($) { if ($(this).val() != before_change) self.field_selected(this); $(this).data('pre_change', $(this).val()); }).change(); + self.initialize_select2(this) }); // self.field_selected($('.form-row select.query-field').first()); }; From 41d96330c215a120f92164fca4e928e9292dadfc Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Sun, 8 Dec 2019 13:38:17 +0200 Subject: [PATCH 07/14] save form data when filtering --- advanced_filters/admin.py | 43 ++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index 9676316..feb8d17 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -7,7 +7,7 @@ from django.shortcuts import resolve_url from django.utils.translation import ugettext_lazy as _ -from .forms import AdvancedFilterForm +from .forms import AdvancedFilterForm, AdvancedFilterQueryForm from .models import AdvancedFilter from .q_serializer import QSerializer @@ -84,7 +84,7 @@ def __init__(self, *args, **kwargs): # add list filters to filters self.list_filter = (AdvancedListFilters, AdvancedQueryFilters,) + tuple(self.list_filter) - def save_advanced_filter(self, request, form): + def save_advanced_filter(self, request, form, save_instance): if '_just_filter' in request.POST: search_query = form.generate_query() query_serializer = QSerializer(base64=True) @@ -96,8 +96,10 @@ def save_advanced_filter(self, request, form): afilter = form.save(commit=False) afilter.created_by = request.user afilter.query = form.generate_query() - afilter.save() - afilter.users.add(request.user) + + if save_instance: + afilter.save() + afilter.users.add(request.user) messages.add_message( request, messages.SUCCESS, _('Advanced filter added successfully.') @@ -110,9 +112,36 @@ def save_advanced_filter(self, request, form): elif request.method == "POST": logger.info('Failed saving advanced filter, params: %s', form.data) + def get_advanced_filter_data(self, request): + if request.POST.get('action') == 'advanced_filters': + return request.POST, True + + search_query = request.GET.get('_aquery') + if search_query: + query_serializer = QSerializer(base64=True) + raw_query = query_serializer.loads(search_query, raw=True) + query_list = query_serializer.get_field_values_list(raw_query) + + data = { + 'form-TOTAL_FORMS': 0, + 'form-INITIAL_FORMS': 0, + 'title': 'unsaved filter' + } + + for idx, query in enumerate(query_list): + query_dict = AdvancedFilterQueryForm._parse_query_dict(query, self.model) + + for key, value in query_dict.items(): + data[f'form-{idx}-{key}'] = value + + data['form-TOTAL_FORMS'] += 1 + + return data, False + + return None, False + def adv_filters_handle(self, request, extra_context={}): - data = request.POST if request.POST.get( - 'action') == 'advanced_filters' else None + data, save_instance = self.get_advanced_filter_data(request) adv_filters_form = self.advanced_filter_form( data=data, model_admin=self, extra_form=True) extra_context.update({ @@ -121,7 +150,7 @@ def adv_filters_handle(self, request, extra_context={}): 'current_afilter': request.GET.get('_afilter'), 'app_label': self.opts.app_label, }) - return self.save_advanced_filter(request, adv_filters_form) + return self.save_advanced_filter(request, adv_filters_form, save_instance) def changelist_view(self, request, extra_context=None): """Add advanced_filters form to changelist context""" From d4acf38a411d99b5cf843daad9ad43977c97ceee Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Sun, 8 Dec 2019 13:43:37 +0200 Subject: [PATCH 08/14] save form data when filtering --- advanced_filters/admin.py | 43 ++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index 9676316..feb8d17 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -7,7 +7,7 @@ from django.shortcuts import resolve_url from django.utils.translation import ugettext_lazy as _ -from .forms import AdvancedFilterForm +from .forms import AdvancedFilterForm, AdvancedFilterQueryForm from .models import AdvancedFilter from .q_serializer import QSerializer @@ -84,7 +84,7 @@ def __init__(self, *args, **kwargs): # add list filters to filters self.list_filter = (AdvancedListFilters, AdvancedQueryFilters,) + tuple(self.list_filter) - def save_advanced_filter(self, request, form): + def save_advanced_filter(self, request, form, save_instance): if '_just_filter' in request.POST: search_query = form.generate_query() query_serializer = QSerializer(base64=True) @@ -96,8 +96,10 @@ def save_advanced_filter(self, request, form): afilter = form.save(commit=False) afilter.created_by = request.user afilter.query = form.generate_query() - afilter.save() - afilter.users.add(request.user) + + if save_instance: + afilter.save() + afilter.users.add(request.user) messages.add_message( request, messages.SUCCESS, _('Advanced filter added successfully.') @@ -110,9 +112,36 @@ def save_advanced_filter(self, request, form): elif request.method == "POST": logger.info('Failed saving advanced filter, params: %s', form.data) + def get_advanced_filter_data(self, request): + if request.POST.get('action') == 'advanced_filters': + return request.POST, True + + search_query = request.GET.get('_aquery') + if search_query: + query_serializer = QSerializer(base64=True) + raw_query = query_serializer.loads(search_query, raw=True) + query_list = query_serializer.get_field_values_list(raw_query) + + data = { + 'form-TOTAL_FORMS': 0, + 'form-INITIAL_FORMS': 0, + 'title': 'unsaved filter' + } + + for idx, query in enumerate(query_list): + query_dict = AdvancedFilterQueryForm._parse_query_dict(query, self.model) + + for key, value in query_dict.items(): + data[f'form-{idx}-{key}'] = value + + data['form-TOTAL_FORMS'] += 1 + + return data, False + + return None, False + def adv_filters_handle(self, request, extra_context={}): - data = request.POST if request.POST.get( - 'action') == 'advanced_filters' else None + data, save_instance = self.get_advanced_filter_data(request) adv_filters_form = self.advanced_filter_form( data=data, model_admin=self, extra_form=True) extra_context.update({ @@ -121,7 +150,7 @@ def adv_filters_handle(self, request, extra_context={}): 'current_afilter': request.GET.get('_afilter'), 'app_label': self.opts.app_label, }) - return self.save_advanced_filter(request, adv_filters_form) + return self.save_advanced_filter(request, adv_filters_form, save_instance) def changelist_view(self, request, extra_context=None): """Add advanced_filters form to changelist context""" From 9309aa970a710c2f220e6de5a8366799c5640ef2 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Fri, 18 Oct 2019 19:45:24 +0300 Subject: [PATCH 09/14] filter without creating new object --- advanced_filters/admin.py | 34 ++++++++++++++++++- .../templates/admin/advanced_filters.html | 1 + .../templates/admin/query_filter.html | 1 + advanced_filters/tests/test_admin.py | 11 ++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 advanced_filters/templates/admin/query_filter.html diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index 5cd7f5e..bb09b2c 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -9,6 +9,7 @@ from .forms import AdvancedFilterForm from .models import AdvancedFilter +from .q_serializer import QSerializer logger = logging.getLogger('advanced_filters.admin') @@ -43,6 +44,31 @@ def queryset(self, request, queryset): return queryset +class AdvancedQueryFilters(admin.SimpleListFilter): + """Allow filtering by advanced filters query""" + title = ' ' + template = 'admin/query_filter.html' + + parameter_name = '_aquery' + + def lookups(self, request, model_admin): + return None + + def queryset(self, request, queryset): + if self.value(): + query_serializer = QSerializer(base64=True) + query = query_serializer.loads(self.value()) + return queryset.filter(query).distinct() + + return queryset + + def choices(self, changelist): + return [] + + def has_output(self): + return True + + class AdminAdvancedFiltersMixin(object): """ Generic AdvancedFilters mixin """ advanced_change_list_template = "admin/advanced_filters.html" @@ -56,9 +82,15 @@ def __init__(self, *args, **kwargs): self.original_change_list_template = "admin/change_list.html" self.change_list_template = self.advanced_change_list_template # add list filters to filters - self.list_filter = (AdvancedListFilters,) + tuple(self.list_filter) + self.list_filter = (AdvancedListFilters, AdvancedQueryFilters,) + tuple(self.list_filter) def save_advanced_filter(self, request, form): + if '_just_filter' in request.POST: + search_query = form.generate_query() + query_serializer = QSerializer(base64=True) + b64_query = query_serializer.dumps(search_query) + url = f"{request.path}?_aquery={b64_query}" + return HttpResponseRedirect(url) if form.is_valid(): afilter = form.save(commit=False) afilter.created_by = request.user diff --git a/advanced_filters/templates/admin/advanced_filters.html b/advanced_filters/templates/admin/advanced_filters.html index f1f5487..d7780dd 100644 --- a/advanced_filters/templates/admin/advanced_filters.html +++ b/advanced_filters/templates/admin/advanced_filters.html @@ -63,6 +63,7 @@

{% trans "Create advanced filter" %}:


+ {% trans "Cancel" %} diff --git a/advanced_filters/templates/admin/query_filter.html b/advanced_filters/templates/admin/query_filter.html new file mode 100644 index 0000000..9299a53 --- /dev/null +++ b/advanced_filters/templates/admin/query_filter.html @@ -0,0 +1 @@ +{# for now - make filter hidden in sidebar #} diff --git a/advanced_filters/tests/test_admin.py b/advanced_filters/tests/test_admin.py index 320d5ff..7970593 100644 --- a/advanced_filters/tests/test_admin.py +++ b/advanced_filters/tests/test_admin.py @@ -127,6 +127,17 @@ def test_create_form_valid(self): assert list(created_filter.query.children[0]) == self.query + # just filter + form_data.pop('_save_goto') + form_data['_just_filter'] = 1 + url = reverse('admin:customers_client_changelist') + res = self.client.post(url, data=form_data) + + assert res.status_code == 302 + assert AdvancedFilter.objects.count() == 2 + url = res['location'] + assert '?_aquery=' in url + class AdvancedFilterUsageTest(TestCase): """ Test filter visibility and actual filtering of a changelist """ From 3a5feaa040ba0bea57be116c43cf2404393e64e9 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Fri, 18 Oct 2019 20:04:22 +0300 Subject: [PATCH 10/14] template string removed --- advanced_filters/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index bb09b2c..9676316 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -89,7 +89,8 @@ def save_advanced_filter(self, request, form): search_query = form.generate_query() query_serializer = QSerializer(base64=True) b64_query = query_serializer.dumps(search_query) - url = f"{request.path}?_aquery={b64_query}" + url = "{path}?_aquery={query}"\ + .format(path=request.path, query=b64_query) return HttpResponseRedirect(url) if form.is_valid(): afilter = form.save(commit=False) From e8a027f002707a0c18a04dce96b98a558c8485f9 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Sun, 8 Dec 2019 13:38:17 +0200 Subject: [PATCH 11/14] save form data when filtering --- advanced_filters/admin.py | 43 ++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index 9676316..feb8d17 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -7,7 +7,7 @@ from django.shortcuts import resolve_url from django.utils.translation import ugettext_lazy as _ -from .forms import AdvancedFilterForm +from .forms import AdvancedFilterForm, AdvancedFilterQueryForm from .models import AdvancedFilter from .q_serializer import QSerializer @@ -84,7 +84,7 @@ def __init__(self, *args, **kwargs): # add list filters to filters self.list_filter = (AdvancedListFilters, AdvancedQueryFilters,) + tuple(self.list_filter) - def save_advanced_filter(self, request, form): + def save_advanced_filter(self, request, form, save_instance): if '_just_filter' in request.POST: search_query = form.generate_query() query_serializer = QSerializer(base64=True) @@ -96,8 +96,10 @@ def save_advanced_filter(self, request, form): afilter = form.save(commit=False) afilter.created_by = request.user afilter.query = form.generate_query() - afilter.save() - afilter.users.add(request.user) + + if save_instance: + afilter.save() + afilter.users.add(request.user) messages.add_message( request, messages.SUCCESS, _('Advanced filter added successfully.') @@ -110,9 +112,36 @@ def save_advanced_filter(self, request, form): elif request.method == "POST": logger.info('Failed saving advanced filter, params: %s', form.data) + def get_advanced_filter_data(self, request): + if request.POST.get('action') == 'advanced_filters': + return request.POST, True + + search_query = request.GET.get('_aquery') + if search_query: + query_serializer = QSerializer(base64=True) + raw_query = query_serializer.loads(search_query, raw=True) + query_list = query_serializer.get_field_values_list(raw_query) + + data = { + 'form-TOTAL_FORMS': 0, + 'form-INITIAL_FORMS': 0, + 'title': 'unsaved filter' + } + + for idx, query in enumerate(query_list): + query_dict = AdvancedFilterQueryForm._parse_query_dict(query, self.model) + + for key, value in query_dict.items(): + data[f'form-{idx}-{key}'] = value + + data['form-TOTAL_FORMS'] += 1 + + return data, False + + return None, False + def adv_filters_handle(self, request, extra_context={}): - data = request.POST if request.POST.get( - 'action') == 'advanced_filters' else None + data, save_instance = self.get_advanced_filter_data(request) adv_filters_form = self.advanced_filter_form( data=data, model_admin=self, extra_form=True) extra_context.update({ @@ -121,7 +150,7 @@ def adv_filters_handle(self, request, extra_context={}): 'current_afilter': request.GET.get('_afilter'), 'app_label': self.opts.app_label, }) - return self.save_advanced_filter(request, adv_filters_form) + return self.save_advanced_filter(request, adv_filters_form, save_instance) def changelist_view(self, request, extra_context=None): """Add advanced_filters form to changelist context""" From b69545661ac6bab3d4caf763a0f46f23186d56b5 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Tue, 28 Apr 2020 11:52:00 +0300 Subject: [PATCH 12/14] template string removed --- advanced_filters/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index feb8d17..460c6a5 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -132,7 +132,7 @@ def get_advanced_filter_data(self, request): query_dict = AdvancedFilterQueryForm._parse_query_dict(query, self.model) for key, value in query_dict.items(): - data[f'form-{idx}-{key}'] = value + data['form-{idx}-{key}'.format(idx=idx, key=key)] = value data['form-TOTAL_FORMS'] += 1 From fb38ffc5791cae1d781d0d6c5d4c4e06fd062fce Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Sun, 16 May 2021 12:01:41 +0300 Subject: [PATCH 13/14] Modlinltd master (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update README remove confusion from the installation instruction * test: refactor unittest test cases to pytest * chore: replace deprecated assertEquals * chore: replace deprecated logger.warn with warning * style: correct small code style bugs * 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 * Update CI (#1) * fixup! test: refactor unittest test cases to pytest * 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/ * Django 3.0 fix #110 * fix: switch deprecated force_text to force_str * 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 * fixup! test: refactor unittest test cases to pytest * chore: update python and add Django classifiers * docs: update dependencies stated in the README + update link to vtiger ref * 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 * fix: allow choices sort on None fields ; Conflicts: ; advanced_filters/views.py * add release workflow * test: test the CleanWhiteSpacesMixin helper * chore: update changelog and bump version to 1.2.0 * 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 * fix: correct travis.yml deprecated/dupe keywords * Update django-braces * import FieldDoesNotExist from django.core.exceptions * fix: update test matrix to include Django 3.1 Ref: https://docs.djangoproject.com/en/3.1/faq/install/ * chore(setup.py): add Django 3.1 to classifiers + sort imports * Add Turkish translation * Update Admin to show model * Add support for python 3.9 and django 3.1 * chore: update changelog and bump version to 1.3.0 Co-authored-by: Pavel Savchenko Co-authored-by: Arpit Co-authored-by: Petr Dlouhý Co-authored-by: Hugo Maingonnat Co-authored-by: João Batista Co-authored-by: predatell Co-authored-by: Özcan YARIMDÜNYA Co-authored-by: Thu Trang Pham --- .github/workflows/pythonpublish.yml | 31 +++ .gitignore | 1 + .travis.yml | 42 ++-- CHANGELOG.rst | 84 ++++++++ CONTRIBUTING.rst | 6 +- README.rst | 10 +- advanced_filters/__init__.py | 2 +- advanced_filters/admin.py | 3 +- advanced_filters/form_helpers.py | 4 +- advanced_filters/forms.py | 28 +-- .../locale/tr/LC_MESSAGES/django.po | 156 +++++++++++++++ advanced_filters/q_serializer.py | 2 +- .../admin/advanced_filters/change_form.html | 2 +- advanced_filters/tests/__init__.py | 3 - advanced_filters/tests/conftest.py | 13 ++ advanced_filters/tests/factories.py | 10 + advanced_filters/tests/test_admin.py | 187 ------------------ .../tests/test_admin_change_form.py | 57 ++++++ advanced_filters/tests/test_creation.py | 88 +++++++++ advanced_filters/tests/test_forms.py | 3 +- .../tests/test_get_field_choices_view.py | 170 ++++++++++++++++ advanced_filters/tests/test_helpers.py | 17 ++ advanced_filters/tests/test_usage.py | 79 ++++++++ advanced_filters/tests/test_views.py | 125 ------------ advanced_filters/views.py | 11 +- setup.py | 24 ++- test-reqs.txt | 7 +- tests/customers/migrations/0001_initial.py | 2 +- tests/customers/models.py | 2 +- tests/factories.py | 7 +- tox.ini | 57 ++++-- 31 files changed, 822 insertions(+), 411 deletions(-) create mode 100644 .github/workflows/pythonpublish.yml create mode 100644 advanced_filters/locale/tr/LC_MESSAGES/django.po 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_helpers.py create mode 100644 advanced_filters/tests/test_usage.py delete mode 100644 advanced_filters/tests/test_views.py 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 cf40e2d..53fcf2f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,43 +1,25 @@ language: python -sudo: false -dist: xenial 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" + - DJANGO="3.1" -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/CHANGELOG.rst b/CHANGELOG.rst index 3a296d1..40cadc7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,90 @@ Changelog ========= +1.3.0 - Django 3.1 and more +--------------------------- + +Apologies for the late release, and thanks to everyone that contributed. + +Bug fixes +~~~~~~~~~ + +- Add support for python 3.9 and django 3.1 +- import FieldDoesNotExist from django.core.exceptions + +Misc +~~~~ + +- Update django-braces +- Update Admin to show model +- Add Turkish translation +- Correct travis.yml deprecated/dupe keywords + +Contributors +~~~~~~~~~~~~ + +- Pavel Savchenko +- predatell +- Özcan YARIMDÜNYA +- Thu Trang Pham +- João Batista + + +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..622b068 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.1 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..19b4f1d 100644 --- a/advanced_filters/__init__.py +++ b/advanced_filters/__init__.py @@ -1 +1 @@ -__version__ = '1.1.1' +__version__ = '1.3.0' diff --git a/advanced_filters/admin.py b/advanced_filters/admin.py index feb8d17..5d6a119 100644 --- a/advanced_filters/admin.py +++ b/advanced_filters/admin.py @@ -169,8 +169,9 @@ class AdvancedFilterAdmin(admin.ModelAdmin): form = AdvancedFilterForm extra = 0 - list_display = ('title', 'created_by', ) + list_display = ('title', 'model', 'created_by', ) readonly_fields = ('created_by', 'model', 'created_at', ) + list_filter = ('model', ) def has_add_permission(self, obj=None): return False 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 b18f140..a67ce8b 100644 --- a/advanced_filters/forms.py +++ b/advanced_filters/forms.py @@ -10,12 +10,13 @@ from django.conf import settings from django.contrib import admin from django.contrib.admin.utils import get_fields_from_path -from django.db.models import Q, FieldDoesNotExist +from django.core.exceptions import FieldDoesNotExist +from django.db.models import Q from django.db.models.fields import DateField 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 @@ -258,17 +259,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/locale/tr/LC_MESSAGES/django.po b/advanced_filters/locale/tr/LC_MESSAGES/django.po new file mode 100644 index 0000000..87b424b --- /dev/null +++ b/advanced_filters/locale/tr/LC_MESSAGES/django.po @@ -0,0 +1,156 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-08-09 01:42+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Ozcan Yarimdunya ozcanyd@gmail.com\n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: admin.py:19 +msgid "Advanced filters" +msgstr "Gelişmiş filtreler" + +#: admin.py:70 +msgid "Advanced filter added successfully." +msgstr "Gelişmiş filtre başarıyla eklendi." + +#: forms.py:47 +msgid "Equals" +msgstr "Eşittir" + +#: forms.py:48 +msgid "Contains" +msgstr "İçerir" + +#: forms.py:49 +msgid "One of" +msgstr "Herhangi biri" + +#: forms.py:50 +msgid "DateTime Range" +msgstr "TarihSaat Aralığı" + +#: forms.py:51 +msgid "Is NULL" +msgstr "BOŞ Mu" + +#: forms.py:52 +msgid "Is TRUE" +msgstr "Doğru Mu" + +#: forms.py:53 +msgid "Is FALSE" +msgstr "YANLIŞ Mı" + +#: forms.py:54 +msgid "Less Than" +msgstr "Küçüktür" + +#: forms.py:55 +msgid "Greater Than" +msgstr "Büyüktür" + +#: forms.py:56 +msgid "Less Than or Equal To" +msgstr "Küçük veya Eşittir" + +#: forms.py:57 +msgid "Greater Than or Equal To" +msgstr "Büyük veya Eşittir" + +#: forms.py:61 +msgid "Or (mark an or between blocks)" +msgstr "Veya (bloklar arasından bir tane veya işaretleyin)" + +#: forms.py:65 +msgid "Field" +msgstr "Alan" + +#: forms.py:67 +msgid "Operator" +msgstr "Operatör" + +#: forms.py:71 +msgid "Value" +msgstr "Değer" + +#: forms.py:76 +msgid "Negate" +msgstr "Tersi" + +#: models.py:18 templates/admin/advanced_filters.html:14 +msgid "Advanced Filter" +msgstr "Gelişmiş Filtre" + +#: models.py:19 +msgid "Advanced Filters" +msgstr "Gelişmiş Filtreler" + +#: models.py:21 +msgid "Title" +msgstr "Başlık" + +#: models.py:25 +msgid "Created by" +msgstr "Oluşturan" + +#: models.py:28 +msgid "Created at" +msgstr "Oluşturulma tarihi" + +#: models.py:29 +msgid "URL" +msgstr "URL" + +#: models.py:30 +msgid "Users" +msgstr "Kullanıcılar" + +#: models.py:31 +msgid "Groups" +msgstr "Gruplar" + +#: templates/admin/advanced_filters.html:14 +msgid "Edit" +msgstr "Düzenle" + +#: templates/admin/advanced_filters.html:26 +msgid "Create advanced filter" +msgstr "Gelişmiş filtre oluştur" + +#: templates/admin/advanced_filters.html:64 +msgid "Save" +msgstr "Kaydet" + +#: templates/admin/advanced_filters.html:65 +#: templates/admin/advanced_filters/change_form.html:50 +msgid "Save & Filter Now!" +msgstr "Kaydet & Filtrele" + +#: templates/admin/advanced_filters.html:66 +msgid "Cancel" +msgstr "İptal Et" + +#: templates/admin/advanced_filters/change_form.html:14 +msgid "Change advanced filter" +msgstr "Gelişmiş filtreyi değiştir" + +#: templates/admin/common_js_init.html:14 +msgid "Add another filter" +msgstr "Başka bir filtre ekle" + +#: templates/admin/common_js_init.html:15 +msgid "Remove" +msgstr "Kaldır" 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 76266c0..63a6eab 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 7970593..0000000 --- a/advanced_filters/tests/test_admin.py +++ /dev/null @@ -1,187 +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 - - # just filter - form_data.pop('_save_goto') - form_data['_just_filter'] = 1 - url = reverse('admin:customers_client_changelist') - res = self.client.post(url, data=form_data) - - assert res.status_code == 302 - assert AdvancedFilter.objects.count() == 2 - url = res['location'] - assert '?_aquery=' in url - - -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_forms.py b/advanced_filters/tests/test_forms.py index 0a407f7..7cb2e7f 100644 --- a/advanced_filters/tests/test_forms.py +++ b/advanced_filters/tests/test_forms.py @@ -3,7 +3,8 @@ from django.contrib import admin from django.contrib.auth import get_user_model -from django.db.models import Q, FieldDoesNotExist +from django.db.models import Q +from django.core.exceptions import FieldDoesNotExist from django.test import TestCase import django 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_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..080d0ef 100644 --- a/advanced_filters/views.py +++ b/advanced_filters/views.py @@ -1,12 +1,11 @@ -from operator import itemgetter import logging from django.apps import apps from django.conf import settings 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.core.exceptions import FieldDoesNotExist +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..096a6a3 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,10 @@ -from setuptools.command.test import test as TestCommand -from setuptools import setup, find_packages +import io import os import sys -import io from advanced_filters import __version__ +from setuptools import find_packages, setup +from setuptools.command.test import test as TestCommand class Tox(TestCommand): @@ -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,20 @@ 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', + 'Programming Language :: Python :: 3.9', + '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 :: 3.1', '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..d6b3487 100644 --- a/tox.ini +++ b/tox.ini @@ -1,29 +1,50 @@ [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,31} + py37-django{111,20,21,22,30,31} + py38-django{22,30,31} + py39-django{22,30,31} + pypy3-django{19,110,111,20,21,22,30,31} -[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.2,<3.0 + django30: Django>=3.0,<3.1 + django31: Django>=3.1,<3.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 + 3.1: django31 From adb40aa2a404debbc6869cf34b014e39f4ce9dc1 Mon Sep 17 00:00:00 2001 From: Alon Raizman Date: Fri, 9 Jul 2021 17:39:23 +0300 Subject: [PATCH 14/14] Update test_creation.py --- advanced_filters/tests/test_creation.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/advanced_filters/tests/test_creation.py b/advanced_filters/tests/test_creation.py index de3cab0..098591b 100644 --- a/advanced_filters/tests/test_creation.py +++ b/advanced_filters/tests/test_creation.py @@ -86,3 +86,13 @@ def test_create_form_valid(user, client, good_data, query): assert url.endswith("%s?_afilter=%s" % (URL_CLIENT_CHANGELIST, created_filter.pk)) assert list(created_filter.query.children[0]) == query + + # just filter + form_data.pop('_save_goto') + form_data['_just_filter'] = 1 + res = client.post(URL_CLIENT_CHANGELIST, data=form_data) + + assert res.status_code == 302 + assert AdvancedFilter.objects.count() == 2 + url = res['location'] + assert '?_aquery=' in url