django-ninja-aio-crud 2.10.0__tar.gz → 2.11.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.
Files changed (104) hide show
  1. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/.github/workflows/docs.yml +2 -0
  2. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/PKG-INFO +1 -1
  3. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/views/mixins.md +83 -0
  4. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/__init__.py +1 -1
  5. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/schemas/__init__.py +15 -1
  6. django_ninja_aio_crud-2.11.0/ninja_aio/schemas/api.py +24 -0
  7. django_ninja_aio_crud-2.11.0/ninja_aio/schemas/filters.py +73 -0
  8. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/views/api.py +11 -0
  9. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/views/mixins.py +81 -6
  10. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_app/models.py +1 -0
  11. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_app/views.py +65 -1
  12. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/views/test_viewset.py +160 -0
  13. django_ninja_aio_crud-2.10.0/ninja_aio/schemas/api.py +0 -43
  14. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/.github/dependabot.yml +0 -0
  15. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/.github/workflows/coverage.yml +0 -0
  16. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/.github/workflows/publish.yml +0 -0
  17. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/.gitignore +0 -0
  18. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/.pre-commit-config.yaml +0 -0
  19. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/LICENSE +0 -0
  20. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/README.md +0 -0
  21. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/CNAME +0 -0
  22. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/authentication.md +0 -0
  23. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/models/model_serializer.md +0 -0
  24. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/models/model_util.md +0 -0
  25. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/models/serializers.md +0 -0
  26. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/pagination.md +0 -0
  27. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/renderers/orjson_renderer.md +0 -0
  28. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/views/api_view.md +0 -0
  29. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/views/api_view_set.md +0 -0
  30. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/api/views/decorators.md +0 -0
  31. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/auth.md +0 -0
  32. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/contributing.md +0 -0
  33. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/extra.css +0 -0
  34. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/getting_started/images/index/foo-index-create-swagger.png +0 -0
  35. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/getting_started/images/index/foo-index-delete-swagger.png +0 -0
  36. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/getting_started/images/index/foo-index-list-swagger.png +0 -0
  37. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/getting_started/images/index/foo-index-retrieve-swagger.png +0 -0
  38. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/getting_started/images/index/foo-index-swagger.png +0 -0
  39. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/getting_started/images/index/foo-index-update-swagger.png +0 -0
  40. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/getting_started/installation.md +0 -0
  41. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/getting_started/quick_start.md +0 -0
  42. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/images/bar-swagger.png +0 -0
  43. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/images/favicon.ico +0 -0
  44. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/images/foo-swagger.png +0 -0
  45. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/images/logo.png +0 -0
  46. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/images/model_util/foo-reverse-relations-swagger.png +0 -0
  47. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/index.md +0 -0
  48. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/release_notes.md +0 -0
  49. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/requirements.txt +0 -0
  50. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/tutorial/authentication.md +0 -0
  51. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/tutorial/crud.md +0 -0
  52. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/tutorial/filtering.md +0 -0
  53. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/docs/tutorial/model.md +0 -0
  54. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/main.py +0 -0
  55. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/mkdocs.yml +0 -0
  56. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/api.py +0 -0
  57. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/auth.py +0 -0
  58. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/decorators/__init__.py +0 -0
  59. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/decorators/operations.py +0 -0
  60. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/decorators/views.py +0 -0
  61. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/exceptions.py +0 -0
  62. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/factory/__init__.py +0 -0
  63. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/factory/operations.py +0 -0
  64. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/helpers/__init__.py +0 -0
  65. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/helpers/api.py +0 -0
  66. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/helpers/query.py +0 -0
  67. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/models/__init__.py +0 -0
  68. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/models/serializers.py +0 -0
  69. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/models/utils.py +0 -0
  70. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/parsers.py +0 -0
  71. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/renders.py +0 -0
  72. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/schemas/generics.py +0 -0
  73. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/schemas/helpers.py +0 -0
  74. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/types.py +0 -0
  75. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/ninja_aio/views/__init__.py +0 -0
  76. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/pyproject.toml +0 -0
  77. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/requirements.dev.txt +0 -0
  78. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/run-local-coverage.sh +0 -0
  79. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/__init__.py +0 -0
  80. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/core/__init__.py +0 -0
  81. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/core/test_decorators.py +0 -0
  82. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/core/test_exceptions_api.py +0 -0
  83. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/core/test_renderer_parser.py +0 -0
  84. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/generics/__init__.py +0 -0
  85. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/generics/literals.py +0 -0
  86. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/generics/models.py +0 -0
  87. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/generics/request.py +0 -0
  88. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/generics/views.py +0 -0
  89. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/helpers/__init__.py +0 -0
  90. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/helpers/test_many_to_many_api.py +0 -0
  91. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/models/__init__.py +0 -0
  92. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/models/test_model_util.py +0 -0
  93. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/models/test_models_extra.py +0 -0
  94. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_app/__init__.py +0 -0
  95. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_app/schema.py +0 -0
  96. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_app/serializers.py +0 -0
  97. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_auth.py +0 -0
  98. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_decorators.py +0 -0
  99. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_exceptions.py +0 -0
  100. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_query_util.py +0 -0
  101. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_serializers.py +0 -0
  102. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/test_settings.py +0 -0
  103. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/views/__init__.py +0 -0
  104. {django_ninja_aio_crud-2.10.0 → django_ninja_aio_crud-2.11.0}/tests/views/test_views.py +0 -0
@@ -23,6 +23,7 @@ on:
23
23
  - "2.8"
24
24
  - "2.9"
25
25
  - "2.10"
26
+ - "2.11"
26
27
  make_latest:
27
28
  description: 'Set as "latest" and default?'
28
29
  type: boolean
@@ -49,6 +50,7 @@ on:
49
50
  - "2.8"
50
51
  - "2.9"
51
52
  - "2.10"
53
+ - "2.11"
52
54
  delete_confirm:
53
55
  description: 'Confirm deletion of the selected version'
54
56
  type: boolean
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.10.0
3
+ Version: 2.11.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -183,6 +183,89 @@ This enables:
183
183
  - `GET /books?author=5` → `queryset.filter(author__id=5)`
184
184
  - `GET /books?category_name=fiction` → `queryset.filter(category__name__icontains="fiction")`
185
185
 
186
+ ## MatchCaseFilterViewSetMixin
187
+
188
+ Applies conditional filtering based on boolean query parameters, where different filter conditions are applied for `True` and `False` values. This is useful when you need to map a simple boolean API parameter to complex underlying filter logic.
189
+
190
+ - Behavior: For each `MatchCaseFilterSchema` entry, applies different Django ORM filters based on the boolean value of the query parameter.
191
+ - Configuration: Define `filters_match_cases` as a list of `MatchCaseFilterSchema` objects.
192
+ - Query params are automatically registered from `filters_match_cases`.
193
+ - Supports both `filter()` (include) and `exclude()` operations via the `include` attribute.
194
+
195
+ Each `MatchCaseFilterSchema` requires:
196
+
197
+ - `query_param`: The API query parameter name exposed to clients.
198
+ - `cases`: A `BooleanMatchFilterSchema` defining the filter conditions for `True` and `False` cases.
199
+
200
+ Each `MatchConditionFilterSchema` (used within `BooleanMatchFilterSchema`) requires:
201
+
202
+ - `query_filter`: A dictionary of Django ORM lookups to apply (e.g., `{"status": "active"}`).
203
+ - `include`: Boolean indicating whether to use `filter()` (True) or `exclude()` (False). Defaults to `True`.
204
+
205
+ Example - Simple status filtering:
206
+
207
+ ```python
208
+ from ninja_aio.views.mixins import MatchCaseFilterViewSetMixin
209
+ from ninja_aio.views.api import APIViewSet
210
+ from ninja_aio.schemas import (
211
+ MatchCaseFilterSchema,
212
+ MatchConditionFilterSchema,
213
+ BooleanMatchFilterSchema,
214
+ )
215
+
216
+ class OrderViewSet(MatchCaseFilterViewSetMixin, APIViewSet):
217
+ model = models.Order
218
+ api = api
219
+ filters_match_cases = [
220
+ MatchCaseFilterSchema(
221
+ query_param="is_completed",
222
+ cases=BooleanMatchFilterSchema(
223
+ true=MatchConditionFilterSchema(
224
+ query_filter={"status": "completed"},
225
+ include=True,
226
+ ),
227
+ false=MatchConditionFilterSchema(
228
+ query_filter={"status": "completed"},
229
+ include=False, # excludes completed orders
230
+ ),
231
+ ),
232
+ ),
233
+ ]
234
+ ```
235
+
236
+ This enables:
237
+
238
+ - `GET /orders?is_completed=true` → `queryset.filter(status="completed")`
239
+ - `GET /orders?is_completed=false` → `queryset.exclude(status="completed")`
240
+
241
+ Example - Complex filtering with multiple conditions:
242
+
243
+ ```python
244
+ class TaskViewSet(MatchCaseFilterViewSetMixin, APIViewSet):
245
+ model = models.Task
246
+ api = api
247
+ filters_match_cases = [
248
+ MatchCaseFilterSchema(
249
+ query_param="show_active",
250
+ cases=BooleanMatchFilterSchema(
251
+ true=MatchConditionFilterSchema(
252
+ query_filter={"status__in": ["pending", "in_progress"]},
253
+ include=True,
254
+ ),
255
+ false=MatchConditionFilterSchema(
256
+ query_filter={"status__in": ["completed", "cancelled"]},
257
+ include=True,
258
+ ),
259
+ ),
260
+ ),
261
+ ]
262
+ ```
263
+
264
+ This enables:
265
+
266
+ - `GET /tasks?show_active=true` → `queryset.filter(status__in=["pending", "in_progress"])`
267
+ - `GET /tasks?show_active=false` → `queryset.filter(status__in=["completed", "cancelled"])`
268
+
186
269
  ## Tips
187
270
 
188
271
  - Align `query_params` types with expected filter values; prefer Pydantic `date`/`datetime` for date filters so values implement `isoformat`.
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.10.0"
3
+ __version__ = "2.11.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -5,9 +5,20 @@ from .api import (
5
5
  M2MAddSchemaIn,
6
6
  M2MRemoveSchemaIn,
7
7
  M2MSchemaIn,
8
+ )
9
+ from .filters import (
8
10
  RelationFilterSchema,
11
+ MatchCaseFilterSchema,
12
+ MatchConditionFilterSchema,
13
+ BooleanMatchFilterSchema,
14
+ )
15
+ from .helpers import (
16
+ M2MRelationSchema,
17
+ QuerySchema,
18
+ ModelQuerySetSchema,
19
+ ObjectQuerySchema,
20
+ ObjectsQuerySchema,
9
21
  )
10
- from .helpers import M2MRelationSchema, QuerySchema, ModelQuerySetSchema, ObjectQuerySchema, ObjectsQuerySchema
11
22
 
12
23
  __all__ = [
13
24
  "GenericMessageSchema",
@@ -22,4 +33,7 @@ __all__ = [
22
33
  "ObjectQuerySchema",
23
34
  "ObjectsQuerySchema",
24
35
  "RelationFilterSchema",
36
+ "MatchCaseFilterSchema",
37
+ "MatchConditionFilterSchema",
38
+ "BooleanMatchFilterSchema",
25
39
  ]
@@ -0,0 +1,24 @@
1
+ from ninja import Schema
2
+
3
+
4
+ class M2MDetailSchema(Schema):
5
+ count: int
6
+ details: list[str]
7
+
8
+
9
+ class M2MSchemaOut(Schema):
10
+ errors: M2MDetailSchema
11
+ results: M2MDetailSchema
12
+
13
+
14
+ class M2MAddSchemaIn(Schema):
15
+ add: list = []
16
+
17
+
18
+ class M2MRemoveSchemaIn(Schema):
19
+ remove: list = []
20
+
21
+
22
+ class M2MSchemaIn(Schema):
23
+ add: list = []
24
+ remove: list = []
@@ -0,0 +1,73 @@
1
+ from typing import Any
2
+
3
+ from ninja import Schema
4
+
5
+
6
+ class FilterSchema(Schema):
7
+ """
8
+ Schema for configuring basic filters in FilterViewSetMixin.
9
+
10
+ Attributes:
11
+ filter_type: A tuple of (type, default_value) used to generate the query parameter
12
+ field in the filters schema. Example: (str, None) for optional string filter.
13
+ query_param: The name of the query parameter exposed in the API endpoint.
14
+ This is what clients will use in requests (e.g., ?name=example).
15
+ """
16
+
17
+ filter_type: tuple[type, Any]
18
+ query_param: str
19
+
20
+
21
+ class MatchConditionFilterSchema(Schema):
22
+ """
23
+ Schema for configuring match condition filters in MatchConditionFilterViewSetMixin.
24
+ Attributes:
25
+ query_filter: The Django ORM lookup to apply (e.g., "status", "category__name").
26
+ include: Whether to include records matching the condition (default: True).
27
+ """
28
+ query_filter: dict[str, Any]
29
+ include: bool = True
30
+
31
+
32
+ class BooleanMatchFilterSchema(Schema):
33
+ """
34
+ Schema for configuring boolean match filters in BooleanMatchFilterViewSetMixin.
35
+
36
+ Attributes:
37
+ true: MatchConditionFilterSchema for when the boolean filter is True.
38
+ false: MatchConditionFilterSchema for when the boolean filter is False.
39
+ """
40
+
41
+ true: MatchConditionFilterSchema
42
+ false: MatchConditionFilterSchema
43
+
44
+
45
+ class RelationFilterSchema(FilterSchema):
46
+ """
47
+ Schema for configuring relation-based filters in RelationFilterViewSetMixin.
48
+
49
+ Attributes:
50
+ filter_type: A tuple of (type, default_value) used to generate the query parameter
51
+ field in the filters schema. Example: (int, None) for optional integer filter.
52
+ query_param: The name of the query parameter exposed in the API endpoint.
53
+ This is what clients will use in requests (e.g., ?author_id=5).
54
+ query_filter: The Django ORM lookup to apply (e.g., "author__id", "category__slug").
55
+ """
56
+
57
+ query_filter: str
58
+
59
+
60
+ class MatchCaseFilterSchema(FilterSchema):
61
+ """
62
+ Schema for configuring match-case filters in MatchCaseFilterViewSetMixin.
63
+
64
+ Attributes:
65
+ filter_type: A tuple of (type, default_value) used to generate the query parameter
66
+ field in the filters schema. Defaults to (bool, None) for optional boolean filter.
67
+ query_param: The name of the query parameter exposed in the API endpoint.
68
+ This is what clients will use in requests (e.g., ?is_active=true).
69
+ cases: A BooleanMatchFilterSchema defining the filter conditions for True and False cases.
70
+ """
71
+
72
+ filter_type: tuple[type, Any] = (bool, None)
73
+ cases: BooleanMatchFilterSchema
@@ -311,6 +311,17 @@ class APIViewSet(API):
311
311
  "delete": (None, self.delete_view),
312
312
  }
313
313
 
314
+ def _check_relations_filters(self, filter: str):
315
+ return filter in getattr(self, "relations_filters_fields", [])
316
+
317
+ def _check_match_cases_filters(self, filter: str):
318
+ return filter in getattr(self, "filters_match_cases_fields", [])
319
+
320
+ def _is_special_filter(self, filter: str):
321
+ return self._check_relations_filters(filter) or self._check_match_cases_filters(
322
+ filter
323
+ )
324
+
314
325
  def _auth_view(self, view_type: str):
315
326
  """
316
327
  Resolve auth for a specific HTTP verb; falls back to self.auth if NOT_SET.
@@ -1,5 +1,5 @@
1
1
  from ninja_aio.views.api import APIViewSet
2
- from ninja_aio.schemas import RelationFilterSchema
2
+ from ninja_aio.schemas import RelationFilterSchema, MatchCaseFilterSchema
3
3
 
4
4
 
5
5
  class IcontainsFilterViewSetMixin(APIViewSet):
@@ -56,7 +56,7 @@ class IcontainsFilterViewSetMixin(APIViewSet):
56
56
  **{
57
57
  f"{key}__icontains": value
58
58
  for key, value in filters.items()
59
- if isinstance(value, str)
59
+ if isinstance(value, str) and not self._is_special_filter(key)
60
60
  }
61
61
  )
62
62
 
@@ -95,7 +95,11 @@ class BooleanFilterViewSetMixin(APIViewSet):
95
95
  """
96
96
  base_qs = await super().query_params_handler(queryset, filters)
97
97
  return base_qs.filter(
98
- **{key: value for key, value in filters.items() if isinstance(value, bool)}
98
+ **{
99
+ key: value
100
+ for key, value in filters.items()
101
+ if isinstance(value, bool) and not self._is_special_filter(key)
102
+ }
99
103
  )
100
104
 
101
105
 
@@ -136,7 +140,7 @@ class NumericFilterViewSetMixin(APIViewSet):
136
140
  **{
137
141
  key: value
138
142
  for key, value in filters.items()
139
- if isinstance(value, (int, float))
143
+ if isinstance(value, (int, float)) and not self._is_special_filter(key)
140
144
  }
141
145
  )
142
146
 
@@ -178,7 +182,7 @@ class DateFilterViewSetMixin(APIViewSet):
178
182
  **{
179
183
  f"{key}{self._compare_attr}": value
180
184
  for key, value in filters.items()
181
- if hasattr(value, "isoformat")
185
+ if hasattr(value, "isoformat") and not self._is_special_filter(key)
182
186
  }
183
187
  )
184
188
 
@@ -325,6 +329,10 @@ class RelationFilterViewSetMixin(APIViewSet):
325
329
  },
326
330
  }
327
331
 
332
+ @property
333
+ def relations_filters_fields(self):
334
+ return [rel_filter.query_param for rel_filter in self.relations_filters]
335
+
328
336
  async def query_params_handler(self, queryset, filters):
329
337
  """
330
338
  Apply relation filters to the queryset based on configured relations_filters.
@@ -335,4 +343,71 @@ class RelationFilterViewSetMixin(APIViewSet):
335
343
  value = filters.get(rel_filter.query_param)
336
344
  if value is not None:
337
345
  rel_filters[rel_filter.query_filter] = value
338
- return base_qs.filter(**rel_filters) if rel_filters else base_qs
346
+ return base_qs.filter(**rel_filters) if rel_filters else base_qs
347
+
348
+
349
+ class MatchCaseFilterViewSetMixin(APIViewSet):
350
+ """
351
+ Mixin providing match-case filtering for Django QuerySets.
352
+ This mixin applies filters based on boolean query parameters defined in
353
+ MatchCaseFilterSchema entries. Each entry specifies different filter conditions
354
+ for True and False cases.
355
+ Attributes:
356
+ filters_match_cases: List of MatchCaseFilterSchema defining the match-case filters.
357
+ Each schema specifies:
358
+ - query_param: The API query parameter name (e.g., "is_active")
359
+ - cases: A BooleanMatchFilterSchema with 'true' and 'false' MatchConditionFilterSchema
360
+ Example:
361
+ class UserViewSet(MatchCaseFilterViewSetMixin, APIViewSet):
362
+ filters_match_cases = [
363
+ MatchCaseFilterSchema(
364
+ query_param="is_active",
365
+ cases=BooleanMatchFilterSchema(
366
+ true=MatchConditionFilterSchema(
367
+ query_filter={"status": "active"},
368
+ include=True,
369
+ ),
370
+ false=MatchConditionFilterSchema(
371
+ query_filter={"status": "inactive"},
372
+ include=True,
373
+ ),
374
+ ),
375
+ ),
376
+ ]
377
+ # GET /users?is_active=true -> queryset.filter(status="active")
378
+ # GET /users?is_active=false -> queryset.filter(status="inactive")
379
+ Notes:
380
+ - If the query parameter is not provided, no filtering is applied for that case.
381
+ - This mixin automatically registers query_params from filters_match_cases.
382
+ """
383
+
384
+ filters_match_cases: list[MatchCaseFilterSchema] = []
385
+
386
+ def __init_subclass__(cls, **kwargs):
387
+ super().__init_subclass__(**kwargs)
388
+ cls.query_params = {
389
+ **cls.query_params,
390
+ **{
391
+ filter_match.query_param: filter_match.filter_type
392
+ for filter_match in cls.filters_match_cases
393
+ },
394
+ }
395
+
396
+ @property
397
+ def filters_match_cases_fields(self):
398
+ return [filter_match.query_param for filter_match in self.filters_match_cases]
399
+
400
+ async def query_params_handler(self, queryset, filters):
401
+ base_qs = await super().query_params_handler(queryset, filters)
402
+ for filter_match in self.filters_match_cases:
403
+ value = filters.get(filter_match.query_param)
404
+ if value is not None:
405
+ case_filter = (
406
+ filter_match.cases.true if value else filter_match.cases.false
407
+ )
408
+ lookup = case_filter.query_filter
409
+ if case_filter.include:
410
+ base_qs = base_qs.filter(**lookup)
411
+ else:
412
+ base_qs = base_qs.exclude(**lookup)
413
+ return base_qs
@@ -82,6 +82,7 @@ class TestModelSerializer(BaseTestModelSerializer):
82
82
  age = models.PositiveIntegerField(default=0)
83
83
  active = models.BooleanField(default=True)
84
84
  active_from = models.DateTimeField(auto_now_add=True)
85
+ status = models.CharField(max_length=20, default="pending")
85
86
 
86
87
 
87
88
  class TestModelSerializerReverseForeignKey(BaseTestModelSerializer):
@@ -1,6 +1,11 @@
1
1
  import datetime
2
2
  from ninja_aio.views import mixins
3
- from ninja_aio.schemas import RelationFilterSchema
3
+ from ninja_aio.schemas import (
4
+ RelationFilterSchema,
5
+ MatchCaseFilterSchema,
6
+ MatchConditionFilterSchema,
7
+ BooleanMatchFilterSchema,
8
+ )
4
9
 
5
10
  from tests.generics.views import GenericAPIViewSet
6
11
  from tests.test_app import models, schema, serializers
@@ -178,3 +183,62 @@ class TestModelSerializerForeignKeyRelationFilterAPI(
178
183
  filter_type=(str, None),
179
184
  ),
180
185
  ]
186
+
187
+
188
+ # ==========================================================
189
+ # MATCH CASE FILTER MIXIN APIS
190
+ # ==========================================================
191
+
192
+
193
+ class TestModelSerializerMatchCaseFilterAPI(
194
+ GenericAPIViewSet,
195
+ mixins.MatchCaseFilterViewSetMixin,
196
+ ):
197
+ """
198
+ ViewSet for testing MatchCaseFilterViewSetMixin.
199
+ Uses status field to filter by 'is_approved' boolean query param.
200
+ """
201
+
202
+ model = models.TestModelSerializer
203
+ filters_match_cases = [
204
+ MatchCaseFilterSchema(
205
+ query_param="is_approved",
206
+ cases=BooleanMatchFilterSchema(
207
+ true=MatchConditionFilterSchema(
208
+ query_filter={"status": "approved"},
209
+ include=True,
210
+ ),
211
+ false=MatchConditionFilterSchema(
212
+ query_filter={"status": "approved"},
213
+ include=False,
214
+ ),
215
+ ),
216
+ ),
217
+ ]
218
+
219
+
220
+ class TestModelSerializerMatchCaseExcludeFilterAPI(
221
+ GenericAPIViewSet,
222
+ mixins.MatchCaseFilterViewSetMixin,
223
+ ):
224
+ """
225
+ ViewSet for testing MatchCaseFilterViewSetMixin with exclude behavior.
226
+ Uses status field to filter by 'hide_pending' boolean query param.
227
+ """
228
+
229
+ model = models.TestModelSerializer
230
+ filters_match_cases = [
231
+ MatchCaseFilterSchema(
232
+ query_param="hide_pending",
233
+ cases=BooleanMatchFilterSchema(
234
+ true=MatchConditionFilterSchema(
235
+ query_filter={"status": "pending"},
236
+ include=False, # exclude pending when True
237
+ ),
238
+ false=MatchConditionFilterSchema(
239
+ query_filter={"status": "pending"},
240
+ include=True, # include only pending when False
241
+ ),
242
+ ),
243
+ ),
244
+ ]
@@ -971,3 +971,163 @@ class DetailSchemaFallbackTestCase(TestCase):
971
971
  retrieve_schema = self.viewset._get_retrieve_schema()
972
972
  # The retrieve schema should be the detail schema (which is the fallback)
973
973
  self.assertEqual(retrieve_schema, self.viewset.schema_detail)
974
+
975
+
976
+ # ==========================================================
977
+ # MATCH CASE FILTER MIXIN TESTS
978
+ # ==========================================================
979
+
980
+
981
+ @tag("match_case_filter_mixin")
982
+ class MatchCaseFilterViewSetMixinTestCase(
983
+ BaseTests.ModelSerializerViewSetTestCaseBase,
984
+ Tests.ViewSetTestCase,
985
+ ):
986
+ """Test MatchCaseFilterViewSetMixin functionality."""
987
+
988
+ namespace = "test_match_case_filter_mixin_viewset"
989
+ model = models.TestModelSerializer
990
+ viewset = views.TestModelSerializerMatchCaseFilterAPI()
991
+
992
+ async def _drop_all_objects(self):
993
+ await self.model.objects.all().adelete()
994
+
995
+ async def test_match_case_filter_true_includes(self):
996
+ """Test filtering with True value includes matching records."""
997
+ await self._drop_all_objects()
998
+ obj_approved = await self.model.objects.acreate(
999
+ name="approved_item", description="desc", status="approved"
1000
+ )
1001
+ await self.model.objects.acreate(
1002
+ name="pending_item", description="desc", status="pending"
1003
+ )
1004
+ await self.model.objects.acreate(
1005
+ name="rejected_item", description="desc", status="rejected"
1006
+ )
1007
+ # Filter with is_approved=True should return only approved items
1008
+ res = await self.viewset.query_params_handler(
1009
+ self.model.objects.all(),
1010
+ {"is_approved": True},
1011
+ )
1012
+ self.assertEqual(await res.acount(), 1)
1013
+ self.assertEqual(await res.afirst(), obj_approved)
1014
+
1015
+ async def test_match_case_filter_false_excludes(self):
1016
+ """Test filtering with False value excludes matching records."""
1017
+ await self._drop_all_objects()
1018
+ obj_approved = await self.model.objects.acreate(
1019
+ name="approved_item", description="desc", status="approved"
1020
+ )
1021
+ obj_pending = await self.model.objects.acreate(
1022
+ name="pending_item", description="desc", status="pending"
1023
+ )
1024
+ obj_rejected = await self.model.objects.acreate(
1025
+ name="rejected_item", description="desc", status="rejected"
1026
+ )
1027
+ # Filter with is_approved=False should exclude approved items
1028
+ res = await self.viewset.query_params_handler(
1029
+ self.model.objects.all(),
1030
+ {"is_approved": False},
1031
+ )
1032
+ self.assertEqual(await res.acount(), 2)
1033
+ results = [obj async for obj in res]
1034
+ self.assertIn(obj_pending, results)
1035
+ self.assertIn(obj_rejected, results)
1036
+ self.assertNotIn(obj_approved, results)
1037
+
1038
+ async def test_match_case_filter_none_no_filtering(self):
1039
+ """Test that None filter value doesn't apply any filtering."""
1040
+ await self._drop_all_objects()
1041
+ await self.model.objects.acreate(
1042
+ name="approved_item", description="desc", status="approved"
1043
+ )
1044
+ await self.model.objects.acreate(
1045
+ name="pending_item", description="desc", status="pending"
1046
+ )
1047
+ await self.model.objects.acreate(
1048
+ name="rejected_item", description="desc", status="rejected"
1049
+ )
1050
+ # Filter with None should return all objects
1051
+ res = await self.viewset.query_params_handler(
1052
+ self.model.objects.all(),
1053
+ {"is_approved": None},
1054
+ )
1055
+ self.assertEqual(await res.acount(), 3)
1056
+
1057
+ async def test_match_case_filter_empty_filters(self):
1058
+ """Test that empty filters dict returns all objects."""
1059
+ await self._drop_all_objects()
1060
+ await self.model.objects.acreate(
1061
+ name="approved_item", description="desc", status="approved"
1062
+ )
1063
+ await self.model.objects.acreate(
1064
+ name="pending_item", description="desc", status="pending"
1065
+ )
1066
+ res = await self.viewset.query_params_handler(
1067
+ self.model.objects.all(),
1068
+ {},
1069
+ )
1070
+ self.assertEqual(await res.acount(), 2)
1071
+
1072
+ def test_query_params_registered(self):
1073
+ """Test that filters_match_cases are properly registered in query_params."""
1074
+ self.assertIn("is_approved", self.viewset.query_params)
1075
+ self.assertEqual(self.viewset.query_params["is_approved"], (bool, None))
1076
+
1077
+ def test_filters_match_cases_fields_property(self):
1078
+ """Test that filters_match_cases_fields property returns correct list."""
1079
+ self.assertEqual(self.viewset.filters_match_cases_fields, ["is_approved"])
1080
+
1081
+
1082
+ @tag("match_case_filter_mixin_exclude")
1083
+ class MatchCaseFilterViewSetMixinExcludeTestCase(TestCase):
1084
+ """Test MatchCaseFilterViewSetMixin with exclude behavior."""
1085
+
1086
+ model = models.TestModelSerializer
1087
+ viewset = views.TestModelSerializerMatchCaseExcludeFilterAPI()
1088
+
1089
+ async def _drop_all_objects(self):
1090
+ await self.model.objects.all().adelete()
1091
+
1092
+ async def test_match_case_exclude_true(self):
1093
+ """Test filtering with True value excludes matching records."""
1094
+ await self._drop_all_objects()
1095
+ obj_approved = await self.model.objects.acreate(
1096
+ name="approved_item", description="desc", status="approved"
1097
+ )
1098
+ obj_pending = await self.model.objects.acreate(
1099
+ name="pending_item", description="desc", status="pending"
1100
+ )
1101
+ obj_rejected = await self.model.objects.acreate(
1102
+ name="rejected_item", description="desc", status="rejected"
1103
+ )
1104
+ # Filter with hide_pending=True should exclude pending items
1105
+ res = await self.viewset.query_params_handler(
1106
+ self.model.objects.all(),
1107
+ {"hide_pending": True},
1108
+ )
1109
+ self.assertEqual(await res.acount(), 2)
1110
+ results = [obj async for obj in res]
1111
+ self.assertIn(obj_approved, results)
1112
+ self.assertIn(obj_rejected, results)
1113
+ self.assertNotIn(obj_pending, results)
1114
+
1115
+ async def test_match_case_exclude_false_includes_only(self):
1116
+ """Test filtering with False value includes only matching records."""
1117
+ await self._drop_all_objects()
1118
+ await self.model.objects.acreate(
1119
+ name="approved_item", description="desc", status="approved"
1120
+ )
1121
+ obj_pending = await self.model.objects.acreate(
1122
+ name="pending_item", description="desc", status="pending"
1123
+ )
1124
+ await self.model.objects.acreate(
1125
+ name="rejected_item", description="desc", status="rejected"
1126
+ )
1127
+ # Filter with hide_pending=False should include only pending items
1128
+ res = await self.viewset.query_params_handler(
1129
+ self.model.objects.all(),
1130
+ {"hide_pending": False},
1131
+ )
1132
+ self.assertEqual(await res.acount(), 1)
1133
+ self.assertEqual(await res.afirst(), obj_pending)
@@ -1,43 +0,0 @@
1
- from typing import Any
2
-
3
- from ninja import Schema
4
-
5
-
6
- class M2MDetailSchema(Schema):
7
- count: int
8
- details: list[str]
9
-
10
-
11
- class M2MSchemaOut(Schema):
12
- errors: M2MDetailSchema
13
- results: M2MDetailSchema
14
-
15
-
16
- class M2MAddSchemaIn(Schema):
17
- add: list = []
18
-
19
-
20
- class M2MRemoveSchemaIn(Schema):
21
- remove: list = []
22
-
23
-
24
- class M2MSchemaIn(Schema):
25
- add: list = []
26
- remove: list = []
27
-
28
-
29
- class RelationFilterSchema(Schema):
30
- """
31
- Schema for configuring relation-based filters in RelationFilterViewSetMixin.
32
-
33
- Attributes:
34
- filter_type: A tuple of (type, default_value) used to generate the query parameter
35
- field in the filters schema. Example: (int, None) for optional integer filter.
36
- query_param: The name of the query parameter exposed in the API endpoint.
37
- This is what clients will use in requests (e.g., ?author_id=5).
38
- query_filter: The Django ORM lookup to apply (e.g., "author__id", "category__slug").
39
- """
40
-
41
- filter_type: tuple[type, Any]
42
- query_param: str
43
- query_filter: str