Skip to content

Commit cfac450

Browse files
authored
Merge pull request #7 from heso/add-test-and-fix-init
Fix lookup_val handling on Django 4.x/5.x
2 parents a4dac81 + 4157bc1 commit cfac450

File tree

16 files changed

+437
-44
lines changed

16 files changed

+437
-44
lines changed

.github/workflows/python-publish.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ jobs:
1313
runs-on: ubuntu-latest
1414

1515
steps:
16-
- uses: actions/checkout@v3
16+
- uses: actions/checkout@v4
1717
- name: Set up Python
18-
uses: actions/setup-python@v3
18+
uses: actions/setup-python@v5
1919
with:
20-
python-version: '3.9'
20+
python-version: '3.10'
2121
- name: Install dependencies
2222
run: |
2323
python -m pip install --upgrade pip

.github/workflows/test.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: django-admin-multi-select-filter-test
2+
on: [push]
3+
env:
4+
TEST_DEPENDENCIES: "tox~=4.15.1"
5+
jobs:
6+
check-project-files:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v4
10+
- name: variables
11+
run: |
12+
PROJECT_FILES=(README.md pyproject.toml tox.ini)
13+
ERROR=0
14+
- name: test
15+
run: |
16+
for file in ${PROJECT_FILES[*]}; do
17+
if ! [ -f $file ]; then
18+
echo "$file not found in project"
19+
ERROR=1
20+
fi
21+
done
22+
exit $ERROR
23+
lint:
24+
runs-on: ubuntu-latest
25+
strategy:
26+
matrix:
27+
linters: [pyproject, lint, format]
28+
steps:
29+
- uses: actions/checkout@v4
30+
- name: Set up Python
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: '3.13'
34+
cache: 'pip'
35+
- name: install-dependencies
36+
run: |
37+
python3 -m pip install --upgrade pip
38+
pip install $TEST_DEPENDENCIES
39+
- name: test
40+
run: tox -e "${{ matrix.linters }}"
41+
test:
42+
runs-on: ubuntu-latest
43+
strategy:
44+
matrix:
45+
minor_versions: [9, 10, 11, 12, 13]
46+
steps:
47+
- uses: actions/checkout@v4
48+
- name: Set up Python
49+
uses: actions/setup-python@v5
50+
with:
51+
python-version: '3.${{ matrix.minor_versions }}'
52+
- name: install-dependencies
53+
run: |
54+
python3 -m pip install --upgrade pip
55+
pip install $TEST_DEPENDENCIES
56+
- name: test
57+
run: tox -m "py3${{ matrix.minor_versions }}"
58+
- name: Upload coverage XML
59+
if: always()
60+
uses: actions/upload-artifact@v4
61+
with:
62+
name: coverage-${{ matrix.minor_versions }}
63+
include-hidden-files: true
64+
path: |
65+
./.reports/*_coverage.xml

pyproject.toml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ description = "Django admin filter for multiple select"
55
readme = "README.md"
66
authors = [{ name = "Job Doesburg", email = "[email protected]" }]
77
license = { file = "LICENSE" }
8+
requires-python = ">=3"
89
classifiers = [
910
"License :: OSI Approved :: MIT License",
1011
"Programming Language :: Python",
@@ -20,10 +21,15 @@ classifiers = [
2021
"Intended Audience :: Developers",
2122
"Operating System :: OS Independent",
2223
]
24+
2325
dependencies = [
2426
"django>=3",
2527
]
26-
requires-python = ">=3"
28+
29+
[project.optional-dependencies]
30+
django42 = ["django~=4.2.0"]
31+
django52 = ["django~=5.2.0"]
32+
test = ["pytest", "pytest-django", "factory_boy", "pytest-cov"]
2733

2834
[project.urls]
2935
homepage = "https://github.com/JobDoesburg/django-admin-multi-select-filter"
@@ -39,3 +45,24 @@ where = ["src"]
3945

4046
[tool.setuptools.package-data]
4147
"*" = ["*.html"]
48+
49+
[tool.ruff]
50+
line-length = 119
51+
output-format = "grouped"
52+
show-fixes = true
53+
target-version = "py313"
54+
exclude = [".svn", "CVS", ".bzr", ".hg",".git", "__pycache__", ".tox", ".eggs", "*.egg", ".venv", "env", "venv", "build"]
55+
56+
[tool.ruff.lint]
57+
select = ["W", "E", "F", "I", "N", "DJ", "T20", "Q"]
58+
59+
[tool.ruff.lint.isort]
60+
combine-as-imports = true
61+
62+
[tool.ruff.lint.mccabe]
63+
max-complexity = 6
64+
65+
[tool.pytest.ini_options]
66+
DJANGO_SETTINGS_MODULE = "tests.app.settings"
67+
addopts = "-ra -q"
68+
pythonpath =". src"

src/django_admin_multi_select_filter/filters.py

Lines changed: 19 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,39 +14,31 @@ def __init__(self, field, request, params, model, model_admin, field_path):
1414
super().__init__(field, request, params, model, model_admin, field_path)
1515

1616
self.lookup_val = self.used_parameters.get(self.lookup_kwarg, [])
17-
if len(self.lookup_val) == 1 and self.lookup_val[0] == "":
17+
if len(self.lookup_val) == 1 and (self.lookup_val[0] == [""] or self.lookup_val[0] == ""):
1818
self.lookup_val = []
19-
elif len(self.lookup_val) == 1 and type(self.lookup_val[0]) != str:
20-
# In Django 5.0, we get an extra list
19+
elif len(self.lookup_val) == 1 and not isinstance(self.lookup_val[0], str):
2120
self.lookup_val = self.lookup_val[0]
2221
self.lookup_val_isnull = self.used_parameters.get(self.lookup_kwarg_isnull)
2322

2423
self.empty_value_display = model_admin.get_empty_value_display()
2524
parent_model, reverse_path = reverse_field_path(model, field_path)
26-
# Obey parent ModelAdmin queryset when deciding which options to show
25+
2726
if model == parent_model:
2827
queryset = model_admin.get_queryset(request)
2928
else:
3029
queryset = parent_model._default_manager.all()
31-
self.lookup_choices = (
32-
queryset.distinct().order_by(field.name).values_list(field.name, flat=True)
33-
)
30+
self.lookup_choices = queryset.distinct().order_by(field.name).values_list(field.name, flat=True)
3431
self.field_verboses = {}
3532
if self.field.choices:
36-
self.field_verboses = {
37-
field_value: field_verbose
38-
for field_value, field_verbose in self.field.choices
39-
}
33+
self.field_verboses = {field_value: field_verbose for field_value, field_verbose in self.field.choices}
4034

4135
def expected_parameters(self):
4236
return [self.lookup_kwarg, self.lookup_kwarg_isnull]
4337

4438
def choices(self, changelist):
4539
yield {
4640
"selected": not self.lookup_val and self.lookup_val_isnull is None,
47-
"query_string": changelist.get_query_string(
48-
remove=[self.lookup_kwarg, self.lookup_kwarg_isnull]
49-
),
41+
"query_string": changelist.get_query_string(remove=[self.lookup_kwarg, self.lookup_kwarg_isnull]),
5042
"display": _("All"),
5143
}
5244
include_none = False
@@ -73,18 +65,14 @@ def choices(self, changelist):
7365
else:
7466
yield {
7567
"selected": val in self.lookup_val,
76-
"query_string": changelist.get_query_string(
77-
remove=[self.lookup_kwarg]
78-
),
68+
"query_string": changelist.get_query_string(remove=[self.lookup_kwarg]),
7969
"display": self.field_verboses.get(val, val),
8070
}
8171

8272
if include_none:
8373
yield {
8474
"selected": bool(self.lookup_val_isnull),
85-
"query_string": changelist.get_query_string(
86-
{self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg]
87-
),
75+
"query_string": changelist.get_query_string({self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg]),
8876
"display": self.empty_value_display,
8977
}
9078

@@ -94,20 +82,18 @@ def __init__(self, field, request, params, model, model_admin, field_path):
9482
super().__init__(field, request, params, model, model_admin, field_path)
9583
self.lookup_kwarg = "%s__%s__in" % (field_path, field.target_field.name)
9684
self.lookup_kwarg_isnull = "%s__isnull" % field_path
97-
values = params.get(self.lookup_kwarg, [])
98-
if len(values) == 1 and type(values[0]) != str:
99-
# In Django 5.0, we get an extra list
100-
values = values[0]
101-
self.lookup_val = values.split(",") if values else []
85+
86+
values = request.GET.getlist(self.lookup_kwarg)
87+
if len(values) == 1 and "," in values[0]:
88+
values = values[0].split(",")
89+
self.lookup_val = [str(value) for value in values if value]
90+
10291
self.lookup_choices = self.field_choices(field, request, model_admin)
10392

10493
def choices(self, changelist):
10594
yield {
106-
"selected": (self.lookup_val is None or self.lookup_val == [])
107-
and not self.lookup_val_isnull,
108-
"query_string": changelist.get_query_string(
109-
remove=[self.lookup_kwarg, self.lookup_kwarg_isnull]
110-
),
95+
"selected": (self.lookup_val is None or self.lookup_val == []) and not self.lookup_val_isnull,
96+
"query_string": changelist.get_query_string(remove=[self.lookup_kwarg, self.lookup_kwarg_isnull]),
11197
"display": _("All"),
11298
}
11399

@@ -123,8 +109,7 @@ def choices(self, changelist):
123109
values = self.lookup_val + [str(pk_val)]
124110

125111
yield {
126-
"selected": self.lookup_val is not None
127-
and str(pk_val) in self.lookup_val,
112+
"selected": self.lookup_val is not None and str(pk_val) in self.lookup_val,
128113
"query_string": changelist.get_query_string(
129114
{self.lookup_kwarg: ",".join(values)}, [self.lookup_kwarg_isnull]
130115
),
@@ -134,9 +119,7 @@ def choices(self, changelist):
134119
if self.include_empty_choice:
135120
yield {
136121
"selected": bool(self.lookup_val_isnull),
137-
"query_string": changelist.get_query_string(
138-
{self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg]
139-
),
122+
"query_string": changelist.get_query_string({self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg]),
140123
"display": empty_title,
141124
}
142125

@@ -153,11 +136,7 @@ def queryset(self, request, queryset):
153136
return queryset
154137

155138
queryset = queryset.alias(
156-
nmatch=Count(
157-
self.field_path,
158-
filter=Q(**{f'{self.lookup_kwarg}': choices}),
159-
distinct=True
160-
)
139+
nmatch=Count(self.field_path, filter=Q(**{f"{self.lookup_kwarg}": choices}), distinct=True)
161140
).filter(nmatch=choice_len)
162141
return queryset
163142

tests/__init__.py

Whitespace-only changes.

tests/app/__init__.py

Whitespace-only changes.

tests/app/admin.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from django.contrib import admin
2+
3+
from django_admin_multi_select_filter.filters import MultiSelectFieldListFilter, MultiSelectRelatedFieldListFilter
4+
from tests.app.models import Item, Tag
5+
6+
7+
@admin.register(Item)
8+
class ItemAdmin(admin.ModelAdmin):
9+
list_display = ("name",)
10+
list_filter = (
11+
("tags", MultiSelectRelatedFieldListFilter),
12+
("status", MultiSelectFieldListFilter),
13+
)
14+
15+
16+
@admin.register(Tag)
17+
class TagAdmin(admin.ModelAdmin):
18+
list_display = ("name",)

tests/app/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class TestAppConfig(AppConfig):
5+
name = "tests.app"
6+
label = "testapp"

tests/app/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.db import models
2+
3+
4+
class Tag(models.Model):
5+
name = models.CharField(max_length=100)
6+
7+
def __str__(self):
8+
return self.name
9+
10+
11+
class Item(models.Model):
12+
name = models.CharField(max_length=100)
13+
tags = models.ManyToManyField(Tag, blank=True)
14+
status = models.CharField(
15+
max_length=10,
16+
choices=[
17+
("new", "New"),
18+
("old", "Old"),
19+
("archived", "Archived"),
20+
],
21+
blank=True,
22+
default="",
23+
)
24+
25+
def __str__(self):
26+
return self.name

tests/app/settings.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
SECRET_KEY = "test"
2+
USE_TZ = True
3+
TIME_ZONE = "UTC"
4+
5+
INSTALLED_APPS = [
6+
"django.contrib.auth",
7+
"django.contrib.contenttypes",
8+
"django.contrib.sessions",
9+
"django.contrib.messages",
10+
"django.contrib.admin",
11+
"tests.app",
12+
]
13+
14+
MIDDLEWARE = [
15+
"django.middleware.security.SecurityMiddleware",
16+
"django.contrib.sessions.middleware.SessionMiddleware",
17+
"django.middleware.common.CommonMiddleware",
18+
"django.middleware.csrf.CsrfViewMiddleware",
19+
"django.contrib.auth.middleware.AuthenticationMiddleware",
20+
"django.contrib.messages.middleware.MessageMiddleware",
21+
]
22+
23+
ROOT_URLCONF = "tests.app.urls"
24+
25+
TEMPLATES = [
26+
{
27+
"BACKEND": "django.template.backends.django.DjangoTemplates",
28+
"DIRS": [],
29+
"APP_DIRS": True,
30+
"OPTIONS": {
31+
"context_processors": [
32+
"django.template.context_processors.request",
33+
"django.contrib.auth.context_processors.auth",
34+
"django.contrib.messages.context_processors.messages",
35+
]
36+
},
37+
}
38+
]
39+
40+
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
41+
STATIC_URL = "/static/"
42+
43+
MIGRATION_MODULES = {"tests.app": None}

0 commit comments

Comments
 (0)