rest-filters 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rest_filters-0.1.0/.gitignore +3 -0
- rest_filters-0.1.0/.idea/.gitignore +8 -0
- rest_filters-0.1.0/.idea/drf-filter.iml +10 -0
- rest_filters-0.1.0/.idea/inspectionProfiles/Project_Default.xml +54 -0
- rest_filters-0.1.0/.idea/inspectionProfiles/profiles_settings.xml +6 -0
- rest_filters-0.1.0/.idea/misc.xml +7 -0
- rest_filters-0.1.0/.idea/modules.xml +8 -0
- rest_filters-0.1.0/.idea/ruff.xml +9 -0
- rest_filters-0.1.0/.idea/vcs.xml +6 -0
- rest_filters-0.1.0/.idea/workspace.xml +83 -0
- rest_filters-0.1.0/.pre-commit-config.yaml +14 -0
- rest_filters-0.1.0/.suayip +24 -0
- rest_filters-0.1.0/LICENSE +27 -0
- rest_filters-0.1.0/PKG-INFO +9 -0
- rest_filters-0.1.0/README.md +1 -0
- rest_filters-0.1.0/pyproject.toml +60 -0
- rest_filters-0.1.0/rest_filters/__init__.py +0 -0
- rest_filters-0.1.0/rest_filters/backends.py +455 -0
- rest_filters-0.1.0/rest_filters/py.typed +0 -0
- rest_filters-0.1.0/rest_filters/utils.py +36 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<module type="PYTHON_MODULE" version="4">
|
|
3
|
+
<component name="NewModuleRootManager">
|
|
4
|
+
<content url="file://$MODULE_DIR$">
|
|
5
|
+
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
|
6
|
+
</content>
|
|
7
|
+
<orderEntry type="jdk" jdkName="Python 3.12 (drf-filter)" jdkType="Python SDK" />
|
|
8
|
+
<orderEntry type="sourceFolder" forTests="false" />
|
|
9
|
+
</component>
|
|
10
|
+
</module>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<component name="InspectionProjectProfileManager">
|
|
2
|
+
<profile version="1.0">
|
|
3
|
+
<option name="myName" value="Project Default" />
|
|
4
|
+
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
|
|
5
|
+
<option name="myCustomPropertiesEnabled" value="true" />
|
|
6
|
+
<option name="myIgnoreVendorSpecificProperties" value="false" />
|
|
7
|
+
<option name="myCustomPropertiesList">
|
|
8
|
+
<value>
|
|
9
|
+
<list size="1">
|
|
10
|
+
<item index="0" class="java.lang.String" itemvalue="paddint-top" />
|
|
11
|
+
</list>
|
|
12
|
+
</value>
|
|
13
|
+
</option>
|
|
14
|
+
</inspection_tool>
|
|
15
|
+
<inspection_tool class="DockerFileAssignments" enabled="false" level="ERROR" enabled_by_default="false" />
|
|
16
|
+
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
|
17
|
+
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
|
|
18
|
+
<option name="myValues">
|
|
19
|
+
<value>
|
|
20
|
+
<list size="1">
|
|
21
|
+
<item index="0" class="java.lang.String" itemvalue="value" />
|
|
22
|
+
</list>
|
|
23
|
+
</value>
|
|
24
|
+
</option>
|
|
25
|
+
<option name="myCustomValuesEnabled" value="true" />
|
|
26
|
+
</inspection_tool>
|
|
27
|
+
<inspection_tool class="PyAttributeOutsideInitInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
|
28
|
+
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
|
29
|
+
<option name="ourVersions">
|
|
30
|
+
<value>
|
|
31
|
+
<list size="2">
|
|
32
|
+
<item index="0" class="java.lang.String" itemvalue="3.12" />
|
|
33
|
+
<item index="1" class="java.lang.String" itemvalue="3.11" />
|
|
34
|
+
</list>
|
|
35
|
+
</value>
|
|
36
|
+
</option>
|
|
37
|
+
</inspection_tool>
|
|
38
|
+
<inspection_tool class="PyMethodMayBeStaticInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
|
39
|
+
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
|
40
|
+
<option name="ignoredPackages">
|
|
41
|
+
<value>
|
|
42
|
+
<list size="3">
|
|
43
|
+
<item index="0" class="java.lang.String" itemvalue="zope-interface" />
|
|
44
|
+
<item index="1" class="java.lang.String" itemvalue="prompt-toolkit" />
|
|
45
|
+
<item index="2" class="java.lang.String" itemvalue="typing-extensions" />
|
|
46
|
+
</list>
|
|
47
|
+
</value>
|
|
48
|
+
</option>
|
|
49
|
+
</inspection_tool>
|
|
50
|
+
<inspection_tool class="PyRedundantParenthesesInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
|
51
|
+
<option name="myIgnoreTupleInReturn" value="true" />
|
|
52
|
+
</inspection_tool>
|
|
53
|
+
</profile>
|
|
54
|
+
</component>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="Black">
|
|
4
|
+
<option name="sdkName" value="Python 3.11 (django-sozluk)" />
|
|
5
|
+
</component>
|
|
6
|
+
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (drf-filter)" project-jdk-type="Python SDK" />
|
|
7
|
+
</project>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="ProjectModuleManager">
|
|
4
|
+
<modules>
|
|
5
|
+
<module fileurl="file://$PROJECT_DIR$/.idea/drf-filter.iml" filepath="$PROJECT_DIR$/.idea/drf-filter.iml" />
|
|
6
|
+
</modules>
|
|
7
|
+
</component>
|
|
8
|
+
</project>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="RuffConfigService">
|
|
4
|
+
<option name="globalRuffExecutablePath" value="$PROJECT_DIR$/../pypackages/bin/ruff" />
|
|
5
|
+
<option name="projectRuffExecutablePath" value="$PROJECT_DIR$/.venv/bin/ruff" />
|
|
6
|
+
<option name="runRuffOnSave" value="true" />
|
|
7
|
+
<option name="useRuffFormat" value="true" />
|
|
8
|
+
</component>
|
|
9
|
+
</project>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="AutoImportSettings">
|
|
4
|
+
<option name="autoReloadType" value="SELECTIVE" />
|
|
5
|
+
</component>
|
|
6
|
+
<component name="ChangeListManager">
|
|
7
|
+
<list default="true" id="036fc8c0-6a78-4c9e-8607-871c8fafbce2" name="Changes" comment="" />
|
|
8
|
+
<option name="SHOW_DIALOG" value="false" />
|
|
9
|
+
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
|
10
|
+
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
|
11
|
+
<option name="LAST_RESOLUTION" value="IGNORE" />
|
|
12
|
+
</component>
|
|
13
|
+
<component name="FileTemplateManagerImpl">
|
|
14
|
+
<option name="RECENT_TEMPLATES">
|
|
15
|
+
<list>
|
|
16
|
+
<option value="Python Script" />
|
|
17
|
+
</list>
|
|
18
|
+
</option>
|
|
19
|
+
</component>
|
|
20
|
+
<component name="Git.Settings">
|
|
21
|
+
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
|
22
|
+
</component>
|
|
23
|
+
<component name="ProjectColorInfo">{
|
|
24
|
+
"associatedIndex": 0
|
|
25
|
+
}</component>
|
|
26
|
+
<component name="ProjectId" id="2plHSYRbsl55LSV0DzU8zKTArqh" />
|
|
27
|
+
<component name="ProjectViewState">
|
|
28
|
+
<option name="hideEmptyMiddlePackages" value="true" />
|
|
29
|
+
<option name="showLibraryContents" value="true" />
|
|
30
|
+
<option name="sortByType" value="true" />
|
|
31
|
+
<option name="sortKey" value="BY_TYPE" />
|
|
32
|
+
</component>
|
|
33
|
+
<component name="PropertiesComponent">{
|
|
34
|
+
"keyToString": {
|
|
35
|
+
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
36
|
+
"git-widget-placeholder": "main",
|
|
37
|
+
"last_opened_file_path": "/Users/suayip/Projects/drf-filter",
|
|
38
|
+
"node.js.detected.package.eslint": "true",
|
|
39
|
+
"node.js.detected.package.tslint": "true",
|
|
40
|
+
"node.js.selected.package.eslint": "(autodetect)",
|
|
41
|
+
"node.js.selected.package.tslint": "(autodetect)",
|
|
42
|
+
"nodejs_package_manager_path": "npm",
|
|
43
|
+
"settings.editor.selected.configurable": "com.koxudaxi.ruff.RuffConfigurable",
|
|
44
|
+
"vue.rearranger.settings.migration": "true"
|
|
45
|
+
}
|
|
46
|
+
}</component>
|
|
47
|
+
<component name="SharedIndexes">
|
|
48
|
+
<attachedChunks>
|
|
49
|
+
<set>
|
|
50
|
+
<option value="bundled-js-predefined-1d06a55b98c1-0b3e54e931b4-JavaScript-PY-241.19072.16" />
|
|
51
|
+
<option value="bundled-python-sdk-8336bb23522e-2767605e8bc2-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-241.19072.16" />
|
|
52
|
+
</set>
|
|
53
|
+
</attachedChunks>
|
|
54
|
+
</component>
|
|
55
|
+
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
|
56
|
+
<component name="TaskManager">
|
|
57
|
+
<task active="true" id="Default" summary="Default task">
|
|
58
|
+
<changelist id="036fc8c0-6a78-4c9e-8607-871c8fafbce2" name="Changes" comment="" />
|
|
59
|
+
<created>1733333741225</created>
|
|
60
|
+
<option name="number" value="Default" />
|
|
61
|
+
<option name="presentableId" value="Default" />
|
|
62
|
+
<updated>1733333741225</updated>
|
|
63
|
+
<workItem from="1733333742261" duration="9945000" />
|
|
64
|
+
<workItem from="1733412373669" duration="11445000" />
|
|
65
|
+
<workItem from="1733509131420" duration="6023000" />
|
|
66
|
+
<workItem from="1733569377429" duration="1942000" />
|
|
67
|
+
<workItem from="1733775621847" duration="6629000" />
|
|
68
|
+
<workItem from="1733863470564" duration="4487000" />
|
|
69
|
+
<workItem from="1734120683127" duration="4258000" />
|
|
70
|
+
<workItem from="1734270684001" duration="1921000" />
|
|
71
|
+
<workItem from="1734273029203" duration="2179000" />
|
|
72
|
+
<workItem from="1734291472802" duration="5672000" />
|
|
73
|
+
<workItem from="1734373962690" duration="2451000" />
|
|
74
|
+
<workItem from="1734378243117" duration="3884000" />
|
|
75
|
+
<workItem from="1734459938142" duration="12963000" />
|
|
76
|
+
<workItem from="1734629741407" duration="2081000" />
|
|
77
|
+
</task>
|
|
78
|
+
<servers />
|
|
79
|
+
</component>
|
|
80
|
+
<component name="TypeScriptGeneratedFilesManager">
|
|
81
|
+
<option name="version" value="3" />
|
|
82
|
+
</component>
|
|
83
|
+
</project>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v5.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: trailing-whitespace
|
|
6
|
+
- id: mixed-line-ending
|
|
7
|
+
args: [ '--fix=lf' ]
|
|
8
|
+
- id: end-of-file-fixer
|
|
9
|
+
|
|
10
|
+
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
|
11
|
+
rev: 'v0.8.4'
|
|
12
|
+
hooks:
|
|
13
|
+
- id: ruff
|
|
14
|
+
- id: ruff-format
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Django Settings ve What can be be a setting? (blank, known params)
|
|
2
|
+
# modelviewset factory oluşturup ordan kaymaca?
|
|
3
|
+
|
|
4
|
+
# 2.
|
|
5
|
+
# sort
|
|
6
|
+
# OrderingFilter() ? (idk)
|
|
7
|
+
# maybe just do all that in Meta?
|
|
8
|
+
# e.g., ordering = {"default": "field", } idk.. []
|
|
9
|
+
|
|
10
|
+
# -1.
|
|
11
|
+
# big todo: filteset metaclass for Meta>Options creation
|
|
12
|
+
# where we do checks to ensure integrity. we also need to
|
|
13
|
+
# implement introspect() {param: serializer} thing so that
|
|
14
|
+
# we can derive fields and also serializer for sanity checks
|
|
15
|
+
# and also schema generation.
|
|
16
|
+
# ^^ we need to do this because we would like to introspect filterset
|
|
17
|
+
# before initiating it. that how Options.fields wil work too
|
|
18
|
+
|
|
19
|
+
# 0. BONUS: `invalid_query_param` or `%s_query_param`
|
|
20
|
+
# code usage? and maybe add as a setting as well
|
|
21
|
+
|
|
22
|
+
# help_text, start writing tests
|
|
23
|
+
|
|
24
|
+
# extend_known_parameters?
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Copyright (c) 2025, Şuayip Üzülmez
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
8
|
+
list of conditions and the following disclaimer.
|
|
9
|
+
|
|
10
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
11
|
+
this list of conditions and the following disclaimer in the documentation
|
|
12
|
+
and/or other materials provided with the distribution.
|
|
13
|
+
|
|
14
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
15
|
+
contributors may be used to endorse or promote products derived from
|
|
16
|
+
this software without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
19
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
20
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
21
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
22
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
23
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
24
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
25
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
26
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
27
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rest-filters
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Project-URL: Homepage, https://github.com/realsuayip/rest-filters
|
|
5
|
+
Project-URL: Bug Tracker, https://github.com/realsuayip/rest-filters/issues
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: django>=3.2
|
|
9
|
+
Requires-Dist: djangorestframework>=3.14.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
WIP
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[tool.hatch.build.targets.wheel]
|
|
6
|
+
packages = ["rest_filters"]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "rest-filters"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"django>=3.2",
|
|
14
|
+
"djangorestframework>=3.14.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
"Homepage" = "https://github.com/realsuayip/rest-filters"
|
|
19
|
+
"Bug Tracker" = "https://github.com/realsuayip/rest-filters/issues"
|
|
20
|
+
|
|
21
|
+
[tool.ruff]
|
|
22
|
+
target-version = "py312"
|
|
23
|
+
line-length = 88
|
|
24
|
+
|
|
25
|
+
[tool.ruff.lint]
|
|
26
|
+
select = [
|
|
27
|
+
"E", # pycodestyle errors
|
|
28
|
+
"W", # pycodestyle warnings
|
|
29
|
+
"F", # pyflakes
|
|
30
|
+
"C", # flake8-comprehensions
|
|
31
|
+
"B", # flake8-bugbear
|
|
32
|
+
"RUF", # Ruff-specific
|
|
33
|
+
"C4", # flake8-comprehensions
|
|
34
|
+
"C90", # mccabe
|
|
35
|
+
"I", # isort
|
|
36
|
+
]
|
|
37
|
+
fixable = ["I"]
|
|
38
|
+
ignore = ["B904"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint.isort]
|
|
41
|
+
combine-as-imports = true
|
|
42
|
+
section-order = [
|
|
43
|
+
"future",
|
|
44
|
+
"standard-library",
|
|
45
|
+
"django",
|
|
46
|
+
"rest_framework",
|
|
47
|
+
"third-party",
|
|
48
|
+
"first-party",
|
|
49
|
+
"local-folder",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.isort.sections]
|
|
53
|
+
django = ["django"]
|
|
54
|
+
rest_framework = ["rest_framework"]
|
|
55
|
+
|
|
56
|
+
[tool.lint.mccabe]
|
|
57
|
+
max-complexity = 10
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
strict = true
|
|
File without changes
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import operator
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from difflib import get_close_matches
|
|
8
|
+
from typing import Any, Generic, Literal, TypeVar
|
|
9
|
+
|
|
10
|
+
from django.db import models
|
|
11
|
+
from django.db.models import Q, QuerySet
|
|
12
|
+
from django.http.request import QueryDict
|
|
13
|
+
from django.utils.translation import gettext
|
|
14
|
+
|
|
15
|
+
from rest_framework import filters, serializers
|
|
16
|
+
from rest_framework.exceptions import ValidationError
|
|
17
|
+
from rest_framework.fields import SkipField, empty
|
|
18
|
+
from rest_framework.request import Request
|
|
19
|
+
from rest_framework.settings import api_settings
|
|
20
|
+
from rest_framework.views import APIView
|
|
21
|
+
|
|
22
|
+
from rest_filters.utils import fill_q_template, merge_errors
|
|
23
|
+
|
|
24
|
+
_MT_co = TypeVar("_MT_co", bound=models.Model, covariant=True)
|
|
25
|
+
|
|
26
|
+
StrOrPromise = str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Entry:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
group: str = "chain",
|
|
34
|
+
aliases: dict[str, Any] | None = None,
|
|
35
|
+
value: Any,
|
|
36
|
+
expression: Any,
|
|
37
|
+
):
|
|
38
|
+
self.group = group
|
|
39
|
+
self.aliases = aliases
|
|
40
|
+
self.value = value
|
|
41
|
+
self.expression = expression
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
return "Entry(group=%r, aliases=%r, value=%r, expression=%r)" % (
|
|
45
|
+
self.group,
|
|
46
|
+
self.aliases,
|
|
47
|
+
self.value,
|
|
48
|
+
self.expression,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Filter:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
f: serializers.Field = None,
|
|
56
|
+
/,
|
|
57
|
+
*,
|
|
58
|
+
field: str | None = None,
|
|
59
|
+
lookup: str = "exact",
|
|
60
|
+
template: Q | None = None,
|
|
61
|
+
group: str | None = None,
|
|
62
|
+
distinct: bool = False,
|
|
63
|
+
negate: bool = False,
|
|
64
|
+
method: str | None = None,
|
|
65
|
+
aliases: dict[str, Any] | None = None,
|
|
66
|
+
param: str | None = None,
|
|
67
|
+
children: list[Filter] | None = None,
|
|
68
|
+
namespace: bool = False,
|
|
69
|
+
blank: Literal["keep", "omit"] = "omit",
|
|
70
|
+
) -> None:
|
|
71
|
+
self._field = field
|
|
72
|
+
self.lookup = lookup or "exact"
|
|
73
|
+
self.template = template
|
|
74
|
+
|
|
75
|
+
# todo needs tons of other checks
|
|
76
|
+
if group is not None and not group.isidentifier():
|
|
77
|
+
raise ValueError("Group names must be valid Python identifiers")
|
|
78
|
+
|
|
79
|
+
if blank not in ("keep", "omit"):
|
|
80
|
+
raise ValueError("blank must either be 'keep' or 'omit'")
|
|
81
|
+
|
|
82
|
+
self._group = group
|
|
83
|
+
self.aliases = aliases # not functional
|
|
84
|
+
|
|
85
|
+
self.negate = negate
|
|
86
|
+
self.distinct = distinct # not functional, todo allow list<str> also
|
|
87
|
+
|
|
88
|
+
self.blank = blank
|
|
89
|
+
self.method = method
|
|
90
|
+
self._param = param
|
|
91
|
+
self._serializer = f
|
|
92
|
+
self._filterset: FilterSet | None = None
|
|
93
|
+
|
|
94
|
+
self.namespace = namespace
|
|
95
|
+
self.parent: Filter | None = None
|
|
96
|
+
self.children = children or []
|
|
97
|
+
for child in self.children:
|
|
98
|
+
child.bind(self)
|
|
99
|
+
|
|
100
|
+
if not self.children and self.namespace:
|
|
101
|
+
raise ValueError("Namespace filters are required to have child filters")
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return "%s(param=%r, group=%r, serializer=%r)" % (
|
|
105
|
+
self.__class__.__name__,
|
|
106
|
+
self.get_param_name(),
|
|
107
|
+
self.get_group(),
|
|
108
|
+
self.get_serializer(),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def __set_name__(self, owner: FilterSet, name: str) -> None:
|
|
112
|
+
self.name = name
|
|
113
|
+
|
|
114
|
+
def bind(self, parent: Filter) -> None:
|
|
115
|
+
self.parent = parent
|
|
116
|
+
|
|
117
|
+
if self._param is None:
|
|
118
|
+
raise ValueError("param needs to be set for child filters")
|
|
119
|
+
|
|
120
|
+
def get_group(self) -> str:
|
|
121
|
+
if self._group is not None:
|
|
122
|
+
return self._group
|
|
123
|
+
elif self.parent is not None:
|
|
124
|
+
return self.parent.get_group()
|
|
125
|
+
return "chain"
|
|
126
|
+
|
|
127
|
+
def get_field_name(self):
|
|
128
|
+
if self.parent and self._field is None:
|
|
129
|
+
return self.parent.get_field_name()
|
|
130
|
+
return self._field or self.name
|
|
131
|
+
|
|
132
|
+
def get_param_name(self) -> str:
|
|
133
|
+
name = self._param or self.name
|
|
134
|
+
if self.parent is not None:
|
|
135
|
+
namespace = self.parent.get_param_name()
|
|
136
|
+
return f"{namespace}.{name}"
|
|
137
|
+
return name
|
|
138
|
+
|
|
139
|
+
def get_query_value(self, query_dict: QueryDict) -> str | empty:
|
|
140
|
+
param = self.get_param_name()
|
|
141
|
+
return query_dict.get(param, empty)
|
|
142
|
+
|
|
143
|
+
def get_serializer(self) -> serializers.Field:
|
|
144
|
+
if self._serializer is not None:
|
|
145
|
+
return self._serializer
|
|
146
|
+
elif self.parent is not None:
|
|
147
|
+
return self.parent.get_serializer()
|
|
148
|
+
raise ValueError("Serializer field is not set for this filter")
|
|
149
|
+
|
|
150
|
+
def get_filterset(self) -> FilterSet:
|
|
151
|
+
if self.parent:
|
|
152
|
+
return self.parent.get_filterset()
|
|
153
|
+
return self._filterset
|
|
154
|
+
|
|
155
|
+
def parse_value(self, value: str | empty) -> Any:
|
|
156
|
+
if value is not empty:
|
|
157
|
+
value = serializers.CharField(allow_blank=True).run_validation(value)
|
|
158
|
+
if self.blank == "omit" and value == "":
|
|
159
|
+
value = empty
|
|
160
|
+
filterset, param = self.get_filterset(), self.get_param_name()
|
|
161
|
+
serializer = filterset.get_serializer(param) or self.get_serializer()
|
|
162
|
+
serializer.default = filterset.get_default(param, serializer.default)
|
|
163
|
+
return serializer.run_validation(value)
|
|
164
|
+
|
|
165
|
+
def resolve_entry_attrs(self, value: Any) -> Entry:
|
|
166
|
+
if self.template is not None:
|
|
167
|
+
expression = fill_q_template(self.template, value=value)
|
|
168
|
+
else:
|
|
169
|
+
field = self.get_field_name()
|
|
170
|
+
lookup = f"{field}__{self.lookup}"
|
|
171
|
+
expression = Q(**{lookup: value})
|
|
172
|
+
if self.negate:
|
|
173
|
+
expression = ~expression
|
|
174
|
+
return Entry(
|
|
175
|
+
group=self.get_group(),
|
|
176
|
+
aliases=self.aliases,
|
|
177
|
+
value=value,
|
|
178
|
+
expression=expression,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def resolve_entry(self, query_dict: QueryDict) -> Entry | None:
|
|
182
|
+
try:
|
|
183
|
+
value = self.parse_value(self.get_query_value(query_dict))
|
|
184
|
+
except SkipField:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
if self.method is not None:
|
|
188
|
+
param = self.get_param_name()
|
|
189
|
+
result = getattr(self.get_filterset(), self.method)(param, value)
|
|
190
|
+
if isinstance(result, Entry):
|
|
191
|
+
return result
|
|
192
|
+
return Entry(group=self.get_group(), value=value, expression=result)
|
|
193
|
+
return self.resolve_entry_attrs(value)
|
|
194
|
+
|
|
195
|
+
def get_all_children(self) -> list[Filter]:
|
|
196
|
+
children = []
|
|
197
|
+
for child in self.children:
|
|
198
|
+
children.append(child)
|
|
199
|
+
children.extend(child.get_all_children())
|
|
200
|
+
return children
|
|
201
|
+
|
|
202
|
+
def resolve(
|
|
203
|
+
self, query_dict: QueryDict
|
|
204
|
+
) -> tuple[dict[str, Entry | None], dict[str, Any]]:
|
|
205
|
+
entries, errors = {}, {}
|
|
206
|
+
for instance in [self, *self.get_all_children()]:
|
|
207
|
+
if instance.namespace:
|
|
208
|
+
continue
|
|
209
|
+
param = instance.get_param_name()
|
|
210
|
+
try:
|
|
211
|
+
entry = instance.resolve_entry(query_dict)
|
|
212
|
+
except ValidationError as err:
|
|
213
|
+
errors[param] = err.detail
|
|
214
|
+
else:
|
|
215
|
+
entries[param] = entry
|
|
216
|
+
return entries, errors
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Constraint:
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
fields: Sequence[str],
|
|
224
|
+
message: StrOrPromise = "",
|
|
225
|
+
method: str | None = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
self.fields = fields
|
|
228
|
+
self.message = message
|
|
229
|
+
self.method = method
|
|
230
|
+
self.filterset: FilterSet | None = None
|
|
231
|
+
|
|
232
|
+
def get_message(self, **kwargs: Any) -> dict[str, Any]:
|
|
233
|
+
message = self.message or gettext(
|
|
234
|
+
"%(constraint)s failed for fields: %(fields)s"
|
|
235
|
+
) % {
|
|
236
|
+
"constraint": self.__class__.__name__,
|
|
237
|
+
"fields": ", ".join(f'"{field}"' for field in self.fields),
|
|
238
|
+
}
|
|
239
|
+
return {api_settings.NON_FIELD_ERRORS_KEY: [message]}
|
|
240
|
+
|
|
241
|
+
def check(self, **kwargs: Any) -> bool:
|
|
242
|
+
assert self.method, "Missing method for constraint"
|
|
243
|
+
assert self.filterset, "Missing filterset for constraint"
|
|
244
|
+
return getattr(self.filterset, self.method)(**kwargs)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class MutuallyExclusive(Constraint):
|
|
248
|
+
def __init__(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
fields: Sequence[str],
|
|
252
|
+
**kwargs: Any,
|
|
253
|
+
) -> None:
|
|
254
|
+
assert len(fields) > 1, "Provide 2 or more fields for this constraint"
|
|
255
|
+
super().__init__(fields=fields, **kwargs)
|
|
256
|
+
|
|
257
|
+
def get_message(self, **kwargs: Any) -> dict[str, Any]:
|
|
258
|
+
if self.message:
|
|
259
|
+
return super().get_message(**kwargs)
|
|
260
|
+
return {
|
|
261
|
+
api_settings.NON_FIELD_ERRORS_KEY: [
|
|
262
|
+
gettext(
|
|
263
|
+
"Following fields are mutually exclusive,"
|
|
264
|
+
" you may only provide one of them: %(fields)s"
|
|
265
|
+
)
|
|
266
|
+
% {
|
|
267
|
+
"fields": ", ".join(
|
|
268
|
+
f'"{field}"' for field in self.fields if field in kwargs
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
def check(self, **kwargs: Any) -> bool:
|
|
275
|
+
return sum(field in kwargs for field in self.fields) <= 1
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class MutuallyInclusive(Constraint):
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
fields: Sequence[str],
|
|
283
|
+
**kwargs: Any,
|
|
284
|
+
) -> None:
|
|
285
|
+
assert len(fields) > 1, "Provide 2 or more fields for this constraint"
|
|
286
|
+
super().__init__(fields=fields, **kwargs)
|
|
287
|
+
|
|
288
|
+
def get_message(self, **kwargs: Any) -> dict[str, Any]:
|
|
289
|
+
if self.message:
|
|
290
|
+
return super().get_message(**kwargs)
|
|
291
|
+
return {
|
|
292
|
+
api_settings.NON_FIELD_ERRORS_KEY: [
|
|
293
|
+
gettext(
|
|
294
|
+
"Following fields are mutually inclusive,"
|
|
295
|
+
" you must provide them all at once or none of them: %(fields)s"
|
|
296
|
+
)
|
|
297
|
+
% {"fields": ", ".join(f'"{field}"' for field in self.fields)}
|
|
298
|
+
]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
def check(self, **kwargs: Any) -> bool:
|
|
302
|
+
fields = [field in kwargs for field in self.fields]
|
|
303
|
+
return all(fields) if any(fields) else True
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class Options:
|
|
307
|
+
def __init__(
|
|
308
|
+
self,
|
|
309
|
+
*,
|
|
310
|
+
fields: Sequence[str] | None = None,
|
|
311
|
+
known_parameters: Sequence[str] | None = None,
|
|
312
|
+
constraints: Sequence[Constraint] | None = None,
|
|
313
|
+
combinators: dict[str, Any] | None = None,
|
|
314
|
+
) -> None:
|
|
315
|
+
self.fields = fields # todo not functional
|
|
316
|
+
self.known_parameters = known_parameters or []
|
|
317
|
+
self.constraints = constraints or []
|
|
318
|
+
self.combinators = combinators or {}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class FilterSet(Generic[_MT_co]):
|
|
322
|
+
def __init__(
|
|
323
|
+
self, request: Request, queryset: QuerySet[_MT_co], view: APIView
|
|
324
|
+
) -> None:
|
|
325
|
+
self.request = request
|
|
326
|
+
self.queryset = queryset
|
|
327
|
+
self.view = view
|
|
328
|
+
self.options = self.get_options()
|
|
329
|
+
|
|
330
|
+
def get_options(self) -> Options:
|
|
331
|
+
if meta := getattr(self, "Meta", None):
|
|
332
|
+
args = ("fields", "known_parameters", "constraints", "combinators")
|
|
333
|
+
return Options(**{arg: getattr(meta, arg, None) for arg in args})
|
|
334
|
+
return Options()
|
|
335
|
+
|
|
336
|
+
def get_groups(self) -> dict[str, dict[str, Entry]]:
|
|
337
|
+
params = self.request.query_params
|
|
338
|
+
fields = self.get_fields()
|
|
339
|
+
groupdict, valuedict, errordict = defaultdict(dict), {}, {}
|
|
340
|
+
known = [*self.options.known_parameters]
|
|
341
|
+
for _, field in fields.items():
|
|
342
|
+
field._filterset = self
|
|
343
|
+
entries, errors = field.resolve(params)
|
|
344
|
+
known.extend((*entries, *errors))
|
|
345
|
+
for param, entry in entries.items():
|
|
346
|
+
if entry is not None:
|
|
347
|
+
groupdict[entry.group][param] = entry
|
|
348
|
+
valuedict[param] = entry.value
|
|
349
|
+
for param, error in errors.items():
|
|
350
|
+
errordict[param] = error
|
|
351
|
+
valuedict[param] = empty
|
|
352
|
+
unknown = [field for field in params if field not in known]
|
|
353
|
+
merge_errors(errordict, self.handle_constraints(valuedict))
|
|
354
|
+
errordict |= self.handle_unknown_parameters(unknown, known)
|
|
355
|
+
self.handle_errors(errordict)
|
|
356
|
+
return groupdict
|
|
357
|
+
|
|
358
|
+
def filter_group(
|
|
359
|
+
self,
|
|
360
|
+
queryset: QuerySet[_MT_co],
|
|
361
|
+
name: str,
|
|
362
|
+
entries: dict[str, Entry],
|
|
363
|
+
) -> QuerySet[_MT_co]:
|
|
364
|
+
merge = getattr(self, f"merge_{name}", None)
|
|
365
|
+
if merge is not None:
|
|
366
|
+
entry = merge(entries)
|
|
367
|
+
return queryset.filter(entry.expression)
|
|
368
|
+
return queryset.filter(self.get_default_group_expression(name, entries))
|
|
369
|
+
|
|
370
|
+
def get_default_group_expression(
|
|
371
|
+
self, group: str, entries: dict[str, Entry]
|
|
372
|
+
) -> Any:
|
|
373
|
+
combinator = self.options.combinators.get(group, operator.and_)
|
|
374
|
+
expressions = (entry.expression for entry in entries.values())
|
|
375
|
+
return functools.reduce(combinator, expressions)
|
|
376
|
+
|
|
377
|
+
def get_queryset(self) -> QuerySet[_MT_co]:
|
|
378
|
+
queryset = self.queryset
|
|
379
|
+
groupdict = self.get_groups()
|
|
380
|
+
|
|
381
|
+
for entry in groupdict.pop("chain", {}).values():
|
|
382
|
+
queryset = queryset.filter(entry.expression)
|
|
383
|
+
|
|
384
|
+
for name, entries in groupdict.items():
|
|
385
|
+
queryset = self.filter_group(queryset, name, entries)
|
|
386
|
+
return queryset
|
|
387
|
+
|
|
388
|
+
@classmethod
|
|
389
|
+
def get_fields(cls) -> dict[str, Filter]:
|
|
390
|
+
return {
|
|
391
|
+
name: attr for name, attr in vars(cls).items() if isinstance(attr, Filter)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
def get_default(self, param: str, default: Any) -> Any:
|
|
395
|
+
return default
|
|
396
|
+
|
|
397
|
+
def get_serializer(self, param: str) -> serializers.Field | None:
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
def get_constraints(self) -> Sequence[Constraint]:
|
|
401
|
+
return self.options.constraints
|
|
402
|
+
|
|
403
|
+
def handle_constraints(self, valuedict: dict[str, Any]) -> dict[str, Any]:
|
|
404
|
+
errors = {}
|
|
405
|
+
constraints = self.get_constraints()
|
|
406
|
+
for constraint in constraints:
|
|
407
|
+
constraint.filterset = self
|
|
408
|
+
if not constraint.check(**valuedict):
|
|
409
|
+
message = constraint.get_message(**valuedict)
|
|
410
|
+
merge_errors(errors, message)
|
|
411
|
+
return errors
|
|
412
|
+
|
|
413
|
+
def handle_unknown_parameters(
|
|
414
|
+
self, unknown: list[str], known: list[str]
|
|
415
|
+
) -> dict[str, Any]:
|
|
416
|
+
fields = {}
|
|
417
|
+
for param in unknown:
|
|
418
|
+
matches = get_close_matches(param, known)
|
|
419
|
+
if not matches:
|
|
420
|
+
fields[param] = [gettext("This query parameter does not exist.")]
|
|
421
|
+
elif len(matches) == 1:
|
|
422
|
+
fields[param] = [
|
|
423
|
+
gettext(
|
|
424
|
+
"This query parameter does not exist."
|
|
425
|
+
' Did you mean "%(param)s"?'
|
|
426
|
+
)
|
|
427
|
+
% {"param": matches[0]}
|
|
428
|
+
]
|
|
429
|
+
else:
|
|
430
|
+
possibilities = ", ".join(f'"{match}"' for match in matches)
|
|
431
|
+
fields[param] = [
|
|
432
|
+
gettext(
|
|
433
|
+
"This query parameter does not exist."
|
|
434
|
+
" Did you mean one of these: %(possibilities)s?"
|
|
435
|
+
)
|
|
436
|
+
% {"possibilities": possibilities}
|
|
437
|
+
]
|
|
438
|
+
return fields
|
|
439
|
+
|
|
440
|
+
def handle_errors(self, errordict: dict[str, Any]) -> None:
|
|
441
|
+
raise serializers.ValidationError(errordict)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class FilterBackend(filters.BaseFilterBackend):
|
|
445
|
+
def get_filterset_class(self, request, queryset, view):
|
|
446
|
+
# todo def get_filterset_class
|
|
447
|
+
return view.filterset_classes.get(view.action)
|
|
448
|
+
|
|
449
|
+
def get_filterset(self, request, queryset, view):
|
|
450
|
+
klass = self.get_filterset_class(request, queryset, view)
|
|
451
|
+
return klass(request, queryset, view)
|
|
452
|
+
|
|
453
|
+
def filter_queryset(self, request, queryset, view):
|
|
454
|
+
filterset = self.get_filterset(request, queryset, view)
|
|
455
|
+
return filterset.get_queryset()
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from django.db.models import Q
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fill_q_template(template: Q, *, value: Any) -> Q:
|
|
7
|
+
conditions = []
|
|
8
|
+
for child in template.children:
|
|
9
|
+
if isinstance(child, Q):
|
|
10
|
+
conditions.append(fill_q_template(child, value=value))
|
|
11
|
+
else:
|
|
12
|
+
if isinstance(child, tuple):
|
|
13
|
+
field, value = child
|
|
14
|
+
raise ValueError(
|
|
15
|
+
"Q objects should not specify values in templates, got"
|
|
16
|
+
" Q(%(field)s=%(value)r), expected Q(%(field)r)."
|
|
17
|
+
" If you would like to do more complex queries, use `method`"
|
|
18
|
+
" argument." % {"field": field, "value": value}
|
|
19
|
+
)
|
|
20
|
+
conditions.append((child, value))
|
|
21
|
+
return Q(
|
|
22
|
+
*conditions,
|
|
23
|
+
_connector=template.connector,
|
|
24
|
+
_negated=template.negated,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def merge_errors(
|
|
29
|
+
source: dict[str, Any],
|
|
30
|
+
errors: dict[str, Any],
|
|
31
|
+
) -> None:
|
|
32
|
+
for key, detail in errors.items():
|
|
33
|
+
if isinstance(source.get(key), list):
|
|
34
|
+
source[key].extend(detail)
|
|
35
|
+
else:
|
|
36
|
+
source[key] = detail
|