From ae75692609ce6fab260bdc334d50d14c03d8183e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Leichtfu=C3=9F?= Date: Sat, 5 Sep 2020 00:40:58 +0200 Subject: [PATCH] setup project --- .travis.yml | 53 ++++++++ README.rst | 46 +++++++ more_filters/__init__.py | 10 ++ more_filters/filters.py | 64 --------- setup.py | 56 ++++++++ tests/manage.py | 21 +++ tests/testapp/__init__.py | 0 tests/testapp/admin.py | 37 ++++++ tests/testapp/apps.py | 7 + tests/testapp/management/__init__.py | 0 tests/testapp/management/commands/__init__.py | 0 .../management/commands/createtestdata.py | 49 +++++++ tests/testapp/migrations/0001_initial.py | 35 +++++ tests/testapp/migrations/__init__.py | 0 tests/testapp/models.py | 28 ++++ tests/testapp/settings.py | 124 ++++++++++++++++++ tests/testapp/tests/__init__.py | 0 tests/testapp/tests/test_filters.py | 22 ++++ tests/testapp/urls.py | 21 +++ tests/testapp/wsgi.py | 16 +++ tox.ini | 24 ++++ 21 files changed, 549 insertions(+), 64 deletions(-) create mode 100644 .travis.yml create mode 100644 README.rst create mode 100644 setup.py create mode 100755 tests/manage.py create mode 100644 tests/testapp/__init__.py create mode 100644 tests/testapp/admin.py create mode 100644 tests/testapp/apps.py create mode 100644 tests/testapp/management/__init__.py create mode 100644 tests/testapp/management/commands/__init__.py create mode 100644 tests/testapp/management/commands/createtestdata.py create mode 100644 tests/testapp/migrations/0001_initial.py create mode 100644 tests/testapp/migrations/__init__.py create mode 100644 tests/testapp/models.py create mode 100644 tests/testapp/settings.py create mode 100644 tests/testapp/tests/__init__.py create mode 100644 tests/testapp/tests/test_filters.py create mode 100644 tests/testapp/urls.py create mode 100644 tests/testapp/wsgi.py create mode 100644 tox.ini diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b7157a5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,53 @@ +dist: xenial +language: python +cache: pip +python: + - "3.6" +env: + - REQ="" +matrix: + include: + - python: "3.4" + env: REQ="Django>=1.11,<2.0" + - python: "3.4" + env: REQ="Django>=2.0,<2.1" + - python: "3.5" + env: REQ="Django>=1.11,<2.0" + - python: "3.5" + env: REQ="Django>=2.0,<2.1" + - python: "3.5" + env: REQ="Django>=2.1,<2.2" + - python: "3.5" + env: REQ="Django>=2.2,<3.0" + - python: "3.6" + env: REQ="Django>=1.11,<2.0" + - python: "3.6" + env: REQ="Django>=2.0,<2.1" + - python: "3.6" + env: REQ="Django>=2.1,<2.2" + - python: "3.6" + env: REQ="Django>=2.2,<3.0" + - python: "3.6" + env: REQ="Django>=3.0,<3.1" + - python: "3.7" + env: REQ="Django>=1.11,<2.0" + - python: "3.7" + env: REQ="Django>=2.0,<2.1" + - python: "3.7" + env: REQ="Django>=2.1,<2.2" + - python: "3.7" + env: REQ="Django>=2.2,<3.0" + - python: "3.7" + env: REQ="Django>=3.0,<3.1" + - python: "3.8" + env: REQ="Django>=2.2,<3.0" + - python: "3.8" + env: REQ="Django>=3.0,<3.1" +install: + - pip install -U pip setuptools coveralls + - pip install $REQ + - pip install --editable . +script: "coverage run --source more_filters/ tests/manage.py test testapp" +after_success: + - coverage report + - coveralls diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..7500a0c --- /dev/null +++ b/README.rst @@ -0,0 +1,46 @@ +===================================== +Welcome to django-admin--more-filters +===================================== + +.. image:: https://img.shields.io/badge/python-3.4%20%7C%203.5%20%7C%203.6%20%7C%203.7%20%7C%203.8-blue + :target: https://img.shields.io/badge/python-3.4%20%7C%203.5%20%7C%203.6%20%7C%203.7%20%7C%203.8-blue + :alt: python: 3.4, 3.5, 3.6, 3.7, 3.8 + +.. image:: https://img.shields.io/badge/django-1.11%20%7C%202.0%20%7C%202.1%20%7C%202.2%20%7C%203.0-orange + :target: https://img.shields.io/badge/django-1.11%20%7C%202.0%20%7C%202.1%20%7C%202.2%20%7C%203.0-orange + :alt: django: 1.11, 2.0, 2.1, 2.2, 3.0 + + +Description +=========== +Django-admin-more-filters is a collection of django admin filters with a focus +on filters allowing multiple choices and the support of dropdown widgets. + + +Installation +============ +Install from pypi.org:: + + pip install django-admin-more-filters + +Add more_filters to your installed apps:: + + INSTALLED_APPS = [ + 'more_filters', + ... + ] + +Use the filter classes with your ModelAdmin:: + + from more_filters import MultiSelectDropdownFilter + + class MyModelAdmin(admin.ModelAdmin): + ... + list_filter = [ + ('myfield', MultiSelectDropdownFilter), + ] + + +Filter classes +============== +TODO diff --git a/more_filters/__init__.py b/more_filters/__init__.py index e69de29..c25fe4b 100644 --- a/more_filters/__init__.py +++ b/more_filters/__init__.py @@ -0,0 +1,10 @@ +VERSION = (0, 1) +__version__ = ".".join(map(str, VERSION)) + + +from .filters import ( + MultiSelectFilter, MultiSelectRelatedFilter, MultiSelectDropdownFilter, + MultiSelectRelatedDropdownFilter, DropdownFilter, ChoicesDropdownFilter, + RelatedDropdownFilter, PlusMinusFilter, AnnotationListFilter, + BooleanAnnotationListFilter +) \ No newline at end of file diff --git a/more_filters/filters.py b/more_filters/filters.py index a474a83..a1c2770 100644 --- a/more_filters/filters.py +++ b/more_filters/filters.py @@ -14,70 +14,6 @@ from django.contrib.admin.filters import RelatedFieldListFilter from django.contrib.admin.filters import RelatedOnlyFieldListFilter -class SelectFilter(admin.SimpleListFilter): - title = _('Selection') - parameter_name = 'selected' - parameter_inverse = 'inverse' - template = 'selectfilter.html' - - def __init__(self, request, params, model, model_admin): - super(SelectFilter, self).__init__(request, params, model, model_admin) - self.inverse = eval(params.pop(self.parameter_inverse, 'False')) - - def has_output(self): - return True - - def lookups(self, request, model_admin): - return () - - def queryset(self, request, queryset): - if not self.value(): return - if self.inverse: - return queryset.exclude(id__in=self.value().split(',')) - else: - return queryset.filter(id__in=self.value().split(',')) - - def choices(self, changelist): - exclude = [self.parameter_name, self.parameter_inverse] - yield { - 'selected': self.value() is None, - 'query_string': changelist.get_query_string({}, exclude), - 'display': _('All'), - } - yield { - 'selected': bool(self.value()), - 'query_string': changelist.get_query_string({}, exclude), - 'display': _('Select'), - 'id': 'selectfilter', - } - if self.value() and self.inverse: - yield { - 'selected': False, - 'query_string': changelist.get_query_string({}, []), - 'display': _('* Remove'), - 'id': 'selectfilter_add' - } - exclude = [self.parameter_inverse] - yield { - 'selected': False, - 'query_string': changelist.get_query_string({}, exclude), - 'display': _('* Undo inversion'), - } - elif self.value() and not self.inverse: - yield { - 'selected': False, - 'query_string': changelist.get_query_string({}, []), - 'display': _('* Remove'), - 'id': 'selectfilter_remove' - } - include = {self.parameter_inverse: True} - yield { - 'selected': False, - 'query_string': changelist.get_query_string(include, []), - 'display': _('* Invert'), - } - - class MultiSelectMixin(object): def queryset(self, request, queryset): params = Q() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a63ad81 --- /dev/null +++ b/setup.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python + +import os +from setuptools import setup +from setuptools import find_packages + + +def read(filename): + path = os.path.join(os.path.dirname(__file__), filename) + with open(path, encoding="utf-8") as file: + return file.read() + + +version = __import__("more_filters").__version__ +if '-dev' in version: + dev_status = 'Development Status :: 3 - Alpha' +elif '-beta' in version: + dev_status = 'Development Status :: 4 - Beta' +else: + dev_status = 'Development Status :: 5 - Production/Stable' + + +setup( + name="django-admin-more-filters", + version=version, + description="Additional filters for django-admin.", + long_description=read("README.rst"), + author="Thomas Leichtfuß", + author_email="thomas.leichtfuss@posteo.de", + license="BSD License", + platforms=["OS Independent"], + packages=find_packages(exclude=["tests"]), + include_package_data=True, + install_requires=[ + "Django>=1.11,<=3.0", + ], + classifiers=[ + dev_status, + "Framework :: Django", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries :: Application Frameworks", + ], + zip_safe=True, +) diff --git a/tests/manage.py b/tests/manage.py new file mode 100755 index 0000000..8cded52 --- /dev/null +++ b/tests/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testapp.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/tests/testapp/__init__.py b/tests/testapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py new file mode 100644 index 0000000..025a4f5 --- /dev/null +++ b/tests/testapp/admin.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- + +from django.contrib import admin +from more_filters import ( + MultiSelectFilter, MultiSelectRelatedFilter, MultiSelectDropdownFilter, + MultiSelectRelatedDropdownFilter, DropdownFilter, ChoicesDropdownFilter, + RelatedDropdownFilter, PlusMinusFilter, AnnotationListFilter, + BooleanAnnotationListFilter +) +from .models import ModelA +from .models import ModelB + + +@admin.register(ModelA) +class ModelAAdmin(admin.ModelAdmin): + search_fields = ('dropdown_less_than_four',) + list_display = ( + 'dropdown_less_than_four', + 'dropdown_more_than_three', + 'multiselect', + 'multiselect_dropdown', + 'choices_dropdown', + 'related_dropdown', + 'multiselect_related', + 'multiselect_related_dropdown', + ) + + list_filter = ( + ('dropdown_less_than_four', DropdownFilter), + ('dropdown_more_than_three', DropdownFilter), + ('multiselect', MultiSelectFilter), + ('multiselect_dropdown', MultiSelectDropdownFilter), + ('choices_dropdown', ChoicesDropdownFilter), + ('related_dropdown', RelatedDropdownFilter), + ('multiselect_related', MultiSelectRelatedFilter), + ('multiselect_related_dropdown', MultiSelectRelatedDropdownFilter), + ) diff --git a/tests/testapp/apps.py b/tests/testapp/apps.py new file mode 100644 index 0000000..fadc5b7 --- /dev/null +++ b/tests/testapp/apps.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +from django.apps import AppConfig + + +class TestappConfig(AppConfig): + name = 'testapp' diff --git a/tests/testapp/management/__init__.py b/tests/testapp/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testapp/management/commands/__init__.py b/tests/testapp/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testapp/management/commands/createtestdata.py b/tests/testapp/management/commands/createtestdata.py new file mode 100644 index 0000000..4fa35b6 --- /dev/null +++ b/tests/testapp/management/commands/createtestdata.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.core.management.base import CommandError +from django.contrib.auth.models import User +from django.db.utils import IntegrityError + +from ...models import ModelA, ModelB + + +def create_test_data(): + try: + User.objects.create_superuser( + 'admin', + 'admin@testapp.org', + 'adminpassword') + except IntegrityError: + pass + + # clear existing data + ModelA.objects.all().delete() + ModelB.objects.all().delete() + + for i in range(1, 30): + model_a = ModelA() + model_b = ModelB() + + model_b.id = i + model_b.save() + + model_a.dropdown_less_than_four = i % 3 + model_a.dropdown_more_than_three = i % 4 + model_a.choices_dropdown = i % 9 +1 + model_a.multiselect = i % 4 + model_a.multiselect_dropdown = i % 4 + model_a.related_dropdown = model_b + model_a.multiselect_related = model_b + model_a.multiselect_related_dropdown = model_b + model_a.save() + + +class Command(BaseCommand): + help = 'Create test data.' + + def handle(self, *args, **options): + create_test_data() + # if options['create_test_data']: diff --git a/tests/testapp/migrations/0001_initial.py b/tests/testapp/migrations/0001_initial.py new file mode 100644 index 0000000..da38c98 --- /dev/null +++ b/tests/testapp/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 3.0.10 on 2020-09-04 22:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ModelB', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ], + ), + migrations.CreateModel( + name='ModelA', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dropdown_less_than_four', models.IntegerField()), + ('dropdown_more_than_three', models.IntegerField()), + ('multiselect', models.IntegerField()), + ('multiselect_dropdown', models.IntegerField()), + ('choices_dropdown', models.CharField(blank=True, choices=[('1', 'one'), ('2', 'two'), ('3', 'three'), ('4', 'four'), ('5', 'five'), ('6', 'six'), ('7', 'seven'), ('8', 'eight'), ('9', 'nine')], max_length=255)), + ('multiselect_related', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='multiselect_related_reverse', to='testapp.ModelB')), + ('multiselect_related_dropdown', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='multiselect_related_dropdown_reverse', to='testapp.ModelB')), + ('related_dropdown', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_dropdown_reverse', to='testapp.ModelB')), + ], + ), + ] diff --git a/tests/testapp/migrations/__init__.py b/tests/testapp/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testapp/models.py b/tests/testapp/models.py new file mode 100644 index 0000000..fb31e48 --- /dev/null +++ b/tests/testapp/models.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +from django.db import models + + +class ModelA(models.Model): + CHOICES = ( + ('1', 'one'), + ('2', 'two'), + ('3', 'three'), + ('4', 'four'), + ('5', 'five'), + ('6', 'six'), + ('7', 'seven'), + ('8', 'eight'), + ('9', 'nine'), + ) + dropdown_less_than_four = models.IntegerField() + dropdown_more_than_three = models.IntegerField() + multiselect = models.IntegerField() + multiselect_dropdown = models.IntegerField() + choices_dropdown = models.CharField(max_length=255, blank=True, choices=CHOICES) + related_dropdown = models.ForeignKey('ModelB', on_delete=models.CASCADE, related_name='related_dropdown_reverse') + multiselect_related = models.ForeignKey('ModelB', on_delete=models.CASCADE, related_name='multiselect_related_reverse') + multiselect_related_dropdown = models.ForeignKey('ModelB', on_delete=models.CASCADE, related_name='multiselect_related_dropdown_reverse') + +class ModelB(models.Model): + id = models.AutoField(primary_key=True) diff --git a/tests/testapp/settings.py b/tests/testapp/settings.py new file mode 100644 index 0000000..bf2e736 --- /dev/null +++ b/tests/testapp/settings.py @@ -0,0 +1,124 @@ +""" +Django settings for testapp project. + +Generated by 'django-admin startproject' using Django 2.2.10. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.2/ref/settings/ +""" + +import os +import sys + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '*xje--vy(__r5_*7t&z^im09#v4#auk*1!t@7!^duf=e$vuzy4' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'testapp', + 'more_filters', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'testapp.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'testapp.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/2.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + # 'NAME': ':memory:', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/2.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.2/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/tests/testapp/tests/__init__.py b/tests/testapp/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testapp/tests/test_filters.py b/tests/testapp/tests/test_filters.py new file mode 100644 index 0000000..bf07a2f --- /dev/null +++ b/tests/testapp/tests/test_filters.py @@ -0,0 +1,22 @@ + + +from django.test import TestCase +from django.contrib.auth.models import User +from django.urls import reverse + +from ..management.commands.createtestdata import create_test_data + + +class ExportTest(TestCase): + @classmethod + def setUpTestData(cls): + create_test_data() + + def setUp(self): + self.admin = User.objects.get(username='admin') + self.client.force_login(self.admin) + self.url = reverse('admin:testapp_modela_changelist') + + def test_01_load_changelist(self): + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 200) diff --git a/tests/testapp/urls.py b/tests/testapp/urls.py new file mode 100644 index 0000000..c242ced --- /dev/null +++ b/tests/testapp/urls.py @@ -0,0 +1,21 @@ +"""testapp URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/2.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.conf.urls import url + +urlpatterns = [ + url(r'^admin/', admin.site.urls), +] diff --git a/tests/testapp/wsgi.py b/tests/testapp/wsgi.py new file mode 100644 index 0000000..dd23643 --- /dev/null +++ b/tests/testapp/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for testapp project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testapp.settings') + +application = get_wsgi_application() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ac171aa --- /dev/null +++ b/tox.ini @@ -0,0 +1,24 @@ +# tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = + {py34,py35,py36,py37}-django111, + {py34,py35,py36,py37}-django20, + {py35,py36,py37}-django21, + {py35,py36,py37,py38}-django22, + {py36,py37,py38}-django30 +skip_missing_interpreters = true + +[testenv] +deps = + django111: Django>=1.11,<2.0 + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + django22: Django>=2.2,<3.0 + django30: Django>=3.0,<3.1 + +commands = {envpython} tests/manage.py test testapp {posargs} +setenv = PYTHONPATH = .:{toxworkdir}