django-ninja-aio-crud 2.9.0__tar.gz → 2.10.1__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.

Potentially problematic release.


This version of django-ninja-aio-crud might be problematic. Click here for more details.

Files changed (105) hide show
  1. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/.github/workflows/docs.yml +4 -0
  2. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/PKG-INFO +1 -1
  3. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/views/mixins.md +43 -0
  4. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/__init__.py +1 -1
  5. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/schemas/__init__.py +2 -0
  6. django_ninja_aio_crud-2.10.1/ninja_aio/schemas/api.py +43 -0
  7. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/views/api.py +3 -0
  8. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/views/mixins.py +75 -2
  9. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/core/test_renderer_parser.py +25 -0
  10. django_ninja_aio_crud-2.10.1/tests/models/test_model_util.py +152 -0
  11. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_app/serializers.py +16 -0
  12. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_app/views.py +25 -0
  13. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_auth.py +51 -0
  14. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_query_util.py +22 -0
  15. django_ninja_aio_crud-2.10.1/tests/views/test_views.py +301 -0
  16. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/views/test_viewset.py +167 -0
  17. django_ninja_aio_crud-2.9.0/ninja_aio/schemas/api.py +0 -24
  18. django_ninja_aio_crud-2.9.0/tests/models/test_model_util.py +0 -68
  19. django_ninja_aio_crud-2.9.0/tests/views/test_views.py +0 -144
  20. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/.github/dependabot.yml +0 -0
  21. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/.github/workflows/coverage.yml +0 -0
  22. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/.github/workflows/publish.yml +0 -0
  23. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/.gitignore +0 -0
  24. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/.pre-commit-config.yaml +0 -0
  25. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/LICENSE +0 -0
  26. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/README.md +0 -0
  27. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/CNAME +0 -0
  28. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/authentication.md +0 -0
  29. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/models/model_serializer.md +0 -0
  30. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/models/model_util.md +0 -0
  31. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/models/serializers.md +0 -0
  32. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/pagination.md +0 -0
  33. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/renderers/orjson_renderer.md +0 -0
  34. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/views/api_view.md +0 -0
  35. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/views/api_view_set.md +0 -0
  36. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/api/views/decorators.md +0 -0
  37. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/auth.md +0 -0
  38. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/contributing.md +0 -0
  39. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/extra.css +0 -0
  40. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/getting_started/images/index/foo-index-create-swagger.png +0 -0
  41. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/getting_started/images/index/foo-index-delete-swagger.png +0 -0
  42. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/getting_started/images/index/foo-index-list-swagger.png +0 -0
  43. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/getting_started/images/index/foo-index-retrieve-swagger.png +0 -0
  44. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/getting_started/images/index/foo-index-swagger.png +0 -0
  45. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/getting_started/images/index/foo-index-update-swagger.png +0 -0
  46. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/getting_started/installation.md +0 -0
  47. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/getting_started/quick_start.md +0 -0
  48. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/images/bar-swagger.png +0 -0
  49. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/images/favicon.ico +0 -0
  50. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/images/foo-swagger.png +0 -0
  51. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/images/logo.png +0 -0
  52. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/images/model_util/foo-reverse-relations-swagger.png +0 -0
  53. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/index.md +0 -0
  54. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/release_notes.md +0 -0
  55. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/requirements.txt +0 -0
  56. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/tutorial/authentication.md +0 -0
  57. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/tutorial/crud.md +0 -0
  58. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/tutorial/filtering.md +0 -0
  59. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/docs/tutorial/model.md +0 -0
  60. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/main.py +0 -0
  61. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/mkdocs.yml +0 -0
  62. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/api.py +0 -0
  63. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/auth.py +0 -0
  64. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/decorators/__init__.py +0 -0
  65. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/decorators/operations.py +0 -0
  66. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/decorators/views.py +0 -0
  67. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/exceptions.py +0 -0
  68. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/factory/__init__.py +0 -0
  69. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/factory/operations.py +0 -0
  70. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/helpers/__init__.py +0 -0
  71. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/helpers/api.py +0 -0
  72. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/helpers/query.py +0 -0
  73. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/models/__init__.py +0 -0
  74. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/models/serializers.py +0 -0
  75. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/models/utils.py +2 -2
  76. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/parsers.py +0 -0
  77. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/renders.py +0 -0
  78. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/schemas/generics.py +0 -0
  79. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/schemas/helpers.py +0 -0
  80. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/types.py +0 -0
  81. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/ninja_aio/views/__init__.py +0 -0
  82. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/pyproject.toml +0 -0
  83. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/requirements.dev.txt +0 -0
  84. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/run-local-coverage.sh +0 -0
  85. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/__init__.py +0 -0
  86. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/core/__init__.py +0 -0
  87. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/core/test_decorators.py +0 -0
  88. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/core/test_exceptions_api.py +0 -0
  89. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/generics/__init__.py +0 -0
  90. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/generics/literals.py +0 -0
  91. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/generics/models.py +0 -0
  92. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/generics/request.py +0 -0
  93. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/generics/views.py +0 -0
  94. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/helpers/__init__.py +0 -0
  95. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/helpers/test_many_to_many_api.py +0 -0
  96. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/models/__init__.py +0 -0
  97. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/models/test_models_extra.py +0 -0
  98. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_app/__init__.py +0 -0
  99. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_app/models.py +0 -0
  100. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_app/schema.py +0 -0
  101. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_decorators.py +0 -0
  102. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_exceptions.py +0 -0
  103. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_serializers.py +0 -0
  104. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/test_settings.py +0 -0
  105. {django_ninja_aio_crud-2.9.0 → django_ninja_aio_crud-2.10.1}/tests/views/__init__.py +0 -0
@@ -21,6 +21,8 @@ on:
21
21
  - "2.6.1"
22
22
  - "2.7.0"
23
23
  - "2.8"
24
+ - "2.9"
25
+ - "2.10"
24
26
  make_latest:
25
27
  description: 'Set as "latest" and default?'
26
28
  type: boolean
@@ -45,6 +47,8 @@ on:
45
47
  - "2.6.1"
46
48
  - "2.7.0"
47
49
  - "2.8"
50
+ - "2.9"
51
+ - "2.10"
48
52
  delete_confirm:
49
53
  description: 'Confirm deletion of the selected version'
50
54
  type: boolean
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.9.0
3
+ Version: 2.10.1
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -140,6 +140,49 @@ class EventViewSet(LessEqualDateFilterViewSetMixin, APIViewSet):
140
140
  query_params = {"created_at": (datetime, None)}
141
141
  ```
142
142
 
143
+ ## RelationFilterViewSetMixin
144
+
145
+ Filters by related model fields using configurable `RelationFilterSchema` entries.
146
+
147
+ - Behavior: Maps query parameters to Django ORM lookups on related models.
148
+ - Configuration: Define `relations_filters` as a list of `RelationFilterSchema` objects.
149
+ - Query params are automatically registered from `relations_filters`.
150
+
151
+ Each `RelationFilterSchema` requires:
152
+
153
+ - `query_param`: The API query parameter name exposed to clients.
154
+ - `query_filter`: The Django ORM lookup path (e.g., `author__id`, `category__name__icontains`).
155
+ - `filter_type`: Tuple of `(type, default)` for schema generation (e.g., `(int, None)`).
156
+
157
+ Example:
158
+
159
+ ```python
160
+ from ninja_aio.views.mixins import RelationFilterViewSetMixin
161
+ from ninja_aio.views.api import APIViewSet
162
+ from ninja_aio.schemas import RelationFilterSchema
163
+
164
+ class BookViewSet(RelationFilterViewSetMixin, APIViewSet):
165
+ model = models.Book
166
+ api = api
167
+ relations_filters = [
168
+ RelationFilterSchema(
169
+ query_param="author",
170
+ query_filter="author__id",
171
+ filter_type=(int, None),
172
+ ),
173
+ RelationFilterSchema(
174
+ query_param="category_name",
175
+ query_filter="category__name__icontains",
176
+ filter_type=(str, None),
177
+ ),
178
+ ]
179
+ ```
180
+
181
+ This enables:
182
+
183
+ - `GET /books?author=5` → `queryset.filter(author__id=5)`
184
+ - `GET /books?category_name=fiction` → `queryset.filter(category__name__icontains="fiction")`
185
+
143
186
  ## Tips
144
187
 
145
188
  - 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.9.0"
3
+ __version__ = "2.10.1"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -5,6 +5,7 @@ from .api import (
5
5
  M2MAddSchemaIn,
6
6
  M2MRemoveSchemaIn,
7
7
  M2MSchemaIn,
8
+ RelationFilterSchema,
8
9
  )
9
10
  from .helpers import M2MRelationSchema, QuerySchema, ModelQuerySetSchema, ObjectQuerySchema, ObjectsQuerySchema
10
11
 
@@ -20,4 +21,5 @@ __all__ = [
20
21
  "ModelQuerySetSchema",
21
22
  "ObjectQuerySchema",
22
23
  "ObjectsQuerySchema",
24
+ "RelationFilterSchema",
23
25
  ]
@@ -0,0 +1,43 @@
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
@@ -311,6 +311,9 @@ 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
+
314
317
  def _auth_view(self, view_type: str):
315
318
  """
316
319
  Resolve auth for a specific HTTP verb; falls back to self.auth if NOT_SET.
@@ -1,4 +1,5 @@
1
1
  from ninja_aio.views.api import APIViewSet
2
+ from ninja_aio.schemas import RelationFilterSchema
2
3
 
3
4
 
4
5
  class IcontainsFilterViewSetMixin(APIViewSet):
@@ -55,7 +56,7 @@ class IcontainsFilterViewSetMixin(APIViewSet):
55
56
  **{
56
57
  f"{key}__icontains": value
57
58
  for key, value in filters.items()
58
- if isinstance(value, str)
59
+ if isinstance(value, str) and not self._check_relations_filters(key)
59
60
  }
60
61
  )
61
62
 
@@ -94,7 +95,11 @@ class BooleanFilterViewSetMixin(APIViewSet):
94
95
  """
95
96
  base_qs = await super().query_params_handler(queryset, filters)
96
97
  return base_qs.filter(
97
- **{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._check_relations_filters(key)
102
+ }
98
103
  )
99
104
 
100
105
 
@@ -136,6 +141,7 @@ class NumericFilterViewSetMixin(APIViewSet):
136
141
  key: value
137
142
  for key, value in filters.items()
138
143
  if isinstance(value, (int, float))
144
+ and not self._check_relations_filters(key)
139
145
  }
140
146
  )
141
147
 
@@ -178,6 +184,7 @@ class DateFilterViewSetMixin(APIViewSet):
178
184
  f"{key}{self._compare_attr}": value
179
185
  for key, value in filters.items()
180
186
  if hasattr(value, "isoformat")
187
+ and not self._check_relations_filters(key)
181
188
  }
182
189
  )
183
190
 
@@ -273,3 +280,69 @@ class LessEqualDateFilterViewSetMixin(DateFilterViewSetMixin):
273
280
  """
274
281
 
275
282
  _compare_attr = "__lte"
283
+
284
+
285
+ class RelationFilterViewSetMixin(APIViewSet):
286
+ """
287
+ Mixin providing filtering for related fields in Django QuerySets.
288
+
289
+ This mixin applies filters on related fields based on configured RelationFilterSchema
290
+ entries. Each entry maps a query parameter name to a Django ORM lookup path.
291
+
292
+ Attributes:
293
+ relations_filters: List of RelationFilterSchema defining the relation filters.
294
+ Each schema specifies:
295
+ - query_param: The API query parameter name (e.g., "author_id")
296
+ - query_filter: The Django ORM lookup (e.g., "author__id")
297
+ - filter_type: Tuple of (type, default) for schema generation
298
+
299
+ Example:
300
+ class BookViewSet(RelationFilterViewSetMixin, APIViewSet):
301
+ relations_filters = [
302
+ RelationFilterSchema(
303
+ query_param="author_id",
304
+ query_filter="author__id",
305
+ filter_type=(int, None),
306
+ ),
307
+ RelationFilterSchema(
308
+ query_param="category_slug",
309
+ query_filter="category__slug",
310
+ filter_type=(str, None),
311
+ ),
312
+ ]
313
+
314
+ # GET /books?author_id=5 -> queryset.filter(author__id=5)
315
+ # GET /books?category_slug=fiction -> queryset.filter(category__slug="fiction")
316
+
317
+ Notes:
318
+ - Filter values that are None or falsy are skipped.
319
+ - This mixin automatically registers query_params from relations_filters.
320
+ """
321
+
322
+ relations_filters: list[RelationFilterSchema] = []
323
+
324
+ def __init_subclass__(cls, **kwargs):
325
+ super().__init_subclass__(**kwargs)
326
+ cls.query_params = {
327
+ **cls.query_params,
328
+ **{
329
+ rel_filter.query_param: rel_filter.filter_type
330
+ for rel_filter in cls.relations_filters
331
+ },
332
+ }
333
+
334
+ @property
335
+ def relations_filters_fields(self):
336
+ return [rel_filter.query_param for rel_filter in self.relations_filters]
337
+
338
+ async def query_params_handler(self, queryset, filters):
339
+ """
340
+ Apply relation filters to the queryset based on configured relations_filters.
341
+ """
342
+ base_qs = await super().query_params_handler(queryset, filters)
343
+ rel_filters = {}
344
+ for rel_filter in self.relations_filters:
345
+ value = filters.get(rel_filter.query_param)
346
+ if value is not None:
347
+ rel_filters[rel_filter.query_filter] = value
348
+ return base_qs.filter(**rel_filters) if rel_filters else base_qs
@@ -48,3 +48,28 @@ class ORJSONRendererParserTestCase(TestCase):
48
48
  [base64.b64encode(b"a").decode(), base64.b64encode(b"b").decode()],
49
49
  )
50
50
  self.assertEqual(decoded["plain"], "value")
51
+
52
+ def test_renderer_non_dict_data(self):
53
+ """Test that renderer handles non-dict data (covers lines 23-24)."""
54
+ # When data is not a dict, it triggers the AttributeError branch
55
+ # and falls back to self.dumps(data)
56
+ non_dict_data = "plain string"
57
+ rendered = self.renderer.render(DummyRequest(), non_dict_data, response_status=200)
58
+ decoded = orjson.loads(rendered)
59
+ self.assertEqual(decoded, "plain string")
60
+
61
+ def test_renderer_list_data(self):
62
+ """Test that renderer handles list data directly."""
63
+ # A list doesn't have .items() method
64
+ list_data = [1, 2, 3]
65
+ rendered = self.renderer.render(DummyRequest(), list_data, response_status=200)
66
+ decoded = orjson.loads(rendered)
67
+ self.assertEqual(decoded, [1, 2, 3])
68
+
69
+ def test_renderer_primitive_data(self):
70
+ """Test that renderer handles primitive data."""
71
+ # An integer doesn't have .items() method
72
+ int_data = 42
73
+ rendered = self.renderer.render(DummyRequest(), int_data, response_status=200)
74
+ decoded = orjson.loads(rendered)
75
+ self.assertEqual(decoded, 42)
@@ -0,0 +1,152 @@
1
+ from django.test import tag, TestCase
2
+ from unittest import mock
3
+
4
+ from ninja.errors import ConfigError
5
+ from ninja_aio.models import ModelUtil
6
+ from tests.test_app import models, schema
7
+ from tests.generics.models import Tests
8
+
9
+
10
+ class BaseTests:
11
+ class ModelUtilTestCaseBase(Tests.GenericModelUtilTestCase):
12
+ @property
13
+ def create_data(self):
14
+ return {"name": "test", "description": "test"}
15
+
16
+ @property
17
+ def parsed_input_data(self):
18
+ return {"payload": self.create_data}
19
+
20
+ @property
21
+ def read_data(self):
22
+ return {"id": 1, "name": "test", "description": "test"}
23
+
24
+ @property
25
+ def additional_getters(self):
26
+ return {"description": "test"}
27
+
28
+ @property
29
+ def additional_filters(self):
30
+ return {"name": "test"}
31
+
32
+ @tag("model_util_model_serializer")
33
+ class ModelUtilModelSerializerTestCase(ModelUtilTestCaseBase):
34
+ @property
35
+ def serializable_fields(self):
36
+ return self.model.ReadSerializer.fields
37
+
38
+ @tag("model_util_model")
39
+ class ModelUtilModelBaseTestCase(ModelUtilTestCaseBase):
40
+ @property
41
+ def serializable_fields(self):
42
+ return ["id", "name", "description"]
43
+
44
+
45
+ @tag("model_util_model_serializer_base")
46
+ class ModelUtilModelSerializerBaseTestCase(BaseTests.ModelUtilModelSerializerTestCase):
47
+ model = models.TestModelSerializer
48
+
49
+ @property
50
+ def model_verbose_name_path(self):
51
+ return "test-model-serializers"
52
+
53
+ @property
54
+ def model_verbose_name_view(self):
55
+ return "testmodelserializers"
56
+
57
+
58
+ @tag("model_util_model_base")
59
+ class ModelUtilModelBaseTestCase(BaseTests.ModelUtilModelBaseTestCase):
60
+ model = models.TestModel
61
+ schema_in = schema.TestModelSchemaIn
62
+ schema_out = schema.TestModelSchemaOut
63
+ schema_patch = schema.TestModelSchemaPatch
64
+
65
+ @property
66
+ def model_verbose_name_path(self):
67
+ return "test-models"
68
+
69
+ @property
70
+ def model_verbose_name_view(self):
71
+ return "testmodels"
72
+
73
+
74
+ @tag("model_util_config_error")
75
+ class ModelUtilConfigErrorTestCase(TestCase):
76
+ """Test ModelUtil ConfigError edge cases (covers lines 113-115)."""
77
+
78
+ def test_model_util_raises_config_error_for_model_serializer_with_serializer_class(
79
+ self,
80
+ ):
81
+ """Test that ModelUtil raises ConfigError when both model is ModelSerializer and serializer_class is provided."""
82
+ from tests.test_app.serializers import TestModelForeignKeySerializer
83
+
84
+ with self.assertRaises(ConfigError) as ctx:
85
+ ModelUtil(
86
+ models.TestModelSerializer,
87
+ serializer_class=TestModelForeignKeySerializer,
88
+ )
89
+
90
+ self.assertIn(
91
+ "cannot accept both model and serializer_class", str(ctx.exception)
92
+ )
93
+
94
+
95
+ @tag("model_util_pk_field_type")
96
+ class ModelUtilPkFieldTypeTestCase(TestCase):
97
+ """Test ModelUtil pk_field_type error handling (covers lines 152-158)."""
98
+
99
+ def test_pk_field_type_raises_config_error_for_unknown_type(self):
100
+ """Test that pk_field_type raises ConfigError for unknown field types."""
101
+ from ninja.orm import fields
102
+
103
+ # Create a mock model with an unknown pk field type
104
+ mock_pk_field = mock.Mock()
105
+ mock_pk_field.get_internal_type.return_value = "UnknownFieldType"
106
+
107
+ mock_meta = mock.Mock()
108
+ mock_meta.pk = mock_pk_field
109
+
110
+ mock_model = mock.Mock()
111
+ mock_model._meta = mock_meta
112
+
113
+ util = ModelUtil(mock_model)
114
+
115
+ # Temporarily remove the key if it exists to ensure KeyError
116
+ original_types = fields.TYPES.copy()
117
+ fields.TYPES.pop("UnknownFieldType", None)
118
+
119
+ try:
120
+ with self.assertRaises(ConfigError) as ctx:
121
+ _ = util.pk_field_type
122
+
123
+ self.assertIn("Do not know how to convert", str(ctx.exception))
124
+ self.assertIn("UnknownFieldType", str(ctx.exception))
125
+ finally:
126
+ # Restore original types
127
+ fields.TYPES.update(original_types)
128
+
129
+
130
+ @tag("model_util_objects_query_default")
131
+ class ModelUtilObjectsQueryDefaultTestCase(TestCase):
132
+ """Test ModelUtil get_objects with default ObjectsQuerySchema (covers line 351)."""
133
+
134
+ async def test_get_objects_with_none_query_data_uses_default(self):
135
+ """Test that get_objects uses default ObjectsQuerySchema when query_data is None."""
136
+ # Create a test object
137
+ obj = await models.TestModel.objects.acreate(name="test", description="desc")
138
+
139
+ util = ModelUtil(models.TestModel)
140
+
141
+ # Create a mock request
142
+ request = mock.Mock()
143
+
144
+ # Call get_objects with query_data=None (will use default ObjectsQuerySchema)
145
+ qs = await util.get_objects(request, query_data=None)
146
+
147
+ # Should return a queryset
148
+ count = await qs.acount()
149
+ self.assertGreaterEqual(count, 1)
150
+
151
+ # Cleanup
152
+ await obj.adelete()
@@ -29,3 +29,19 @@ class TestModelReverseForeignKeySerializer(serializers.Serializer):
29
29
  relations_serializers = {
30
30
  "test_model_foreign_keys": TestModelForeignKeySerializer,
31
31
  }
32
+
33
+
34
+ class TestModelOneToOneSerializer(serializers.Serializer):
35
+ """Serializer for TestModelOneToOne to test ForwardOneToOneDescriptor coverage."""
36
+
37
+ class Meta:
38
+ model = models.TestModelOneToOne
39
+ schema_in = serializers.SchemaModelConfig(
40
+ fields=["name", "description", "test_model"]
41
+ )
42
+ schema_out = serializers.SchemaModelConfig(
43
+ fields=["id", "name", "description", "test_model"]
44
+ )
45
+ schema_update = serializers.SchemaModelConfig(
46
+ optionals=[("name", str), ("description", str)]
47
+ )
@@ -1,5 +1,6 @@
1
1
  import datetime
2
2
  from ninja_aio.views import mixins
3
+ from ninja_aio.schemas import RelationFilterSchema
3
4
 
4
5
  from tests.generics.views import GenericAPIViewSet
5
6
  from tests.test_app import models, schema, serializers
@@ -153,3 +154,27 @@ class TestModelForeignKeySerializerAPI(GenericAPIViewSet):
153
154
  class TestModelReverseForeignKeySerializerAPI(GenericAPIViewSet):
154
155
  model = models.TestModelReverseForeignKey
155
156
  serializer_class = serializers.TestModelReverseForeignKeySerializer
157
+
158
+
159
+ # ==========================================================
160
+ # RELATION FILTER MIXIN APIS
161
+ # ==========================================================
162
+
163
+
164
+ class TestModelSerializerForeignKeyRelationFilterAPI(
165
+ GenericAPIViewSet,
166
+ mixins.RelationFilterViewSetMixin,
167
+ ):
168
+ model = models.TestModelSerializerForeignKey
169
+ relations_filters = [
170
+ RelationFilterSchema(
171
+ query_param="test_model_serializer",
172
+ query_filter="test_model_serializer__id",
173
+ filter_type=(int, None),
174
+ ),
175
+ RelationFilterSchema(
176
+ query_param="test_model_serializer_name",
177
+ query_filter="test_model_serializer__name__icontains",
178
+ filter_type=(str, None),
179
+ ),
180
+ ]
@@ -122,3 +122,54 @@ class JwtAuthTests(TestCase):
122
122
  bearer = TBWrongAud()
123
123
  result = async_to_sync(bearer.authenticate)(HttpRequest(), token)
124
124
  self.assertFalse(result)
125
+
126
+ def test_async_bearer_authenticate_invalid_token_returns_false(self):
127
+ """Test that a malformed/invalid token returns False (covers lines 110-112)."""
128
+ pub = self.public_jwk
129
+
130
+ class TB(AsyncJwtBearer):
131
+ jwt_public = pub
132
+ claims = {
133
+ "iss": {"value": "test-issuer"},
134
+ "aud": {"value": "test-audience"},
135
+ }
136
+
137
+ async def auth_handler(self, request):
138
+ return "should-not-happen"
139
+
140
+ bearer = TB()
141
+ # The ValueError branch at 110-112 is for when jwt.decode raises ValueError
142
+ # Mock jwt.decode to raise ValueError to test that branch
143
+ import unittest.mock as mock
144
+
145
+ with mock.patch("ninja_aio.auth.jwt.decode") as mock_decode:
146
+ mock_decode.side_effect = ValueError("invalid token")
147
+ result = async_to_sync(bearer.authenticate)(HttpRequest(), "fake-token")
148
+ self.assertFalse(result)
149
+
150
+ def test_async_bearer_base_auth_handler_returns_none(self):
151
+ """Test that the base auth_handler returns None (covers line 101)."""
152
+ pub = self.public_jwk
153
+
154
+ class TB(AsyncJwtBearer):
155
+ jwt_public = pub
156
+ claims = {
157
+ "iss": {"value": "test-issuer"},
158
+ "aud": {"value": "test-audience"},
159
+ }
160
+ # Don't override auth_handler, use base implementation
161
+
162
+ token = encode_jwt({"sub": "42"}, duration=60)
163
+ bearer = TB()
164
+ result = async_to_sync(bearer.authenticate)(HttpRequest(), token)
165
+ # Base auth_handler returns None (pass statement)
166
+ self.assertIsNone(result)
167
+
168
+ def test_validate_mandatory_claims_skips_preset_claims(self):
169
+ """Test that validate_mandatory_claims skips claims already present (covers line 137)."""
170
+ # Pre-set both mandatory claims
171
+ claims = {"iss": "custom-issuer", "aud": "custom-audience"}
172
+ result = validate_mandatory_claims(claims)
173
+ # Should keep the pre-set values, not override with settings
174
+ self.assertEqual(result["iss"], "custom-issuer")
175
+ self.assertEqual(result["aud"], "custom-audience")
@@ -1,6 +1,7 @@
1
1
  from unittest import mock
2
2
  from django.test import TestCase, tag
3
3
  from tests.test_app import models as app_models
4
+ from ninja_aio.helpers.query import ScopeNamespace
4
5
 
5
6
 
6
7
  @tag("query_util_dedicated")
@@ -93,3 +94,24 @@ class QueryUtilDedicatedTestCase(TestCase):
93
94
  _ = util_simple.apply_queryset_optimizations(qs, util_simple.SCOPES.READ)
94
95
  m_sel.assert_not_called()
95
96
  m_pref.assert_not_called()
97
+
98
+
99
+ @tag("scope_namespace")
100
+ class ScopeNamespaceTestCase(TestCase):
101
+ """Tests for ScopeNamespace class (covers line 17 - __iter__)."""
102
+
103
+ def test_scope_namespace_iter(self):
104
+ """Test that ScopeNamespace is iterable (covers line 17)."""
105
+ namespace = ScopeNamespace(READ="read", WRITE="write", CUSTOM="custom")
106
+ # Test iteration
107
+ values = list(namespace)
108
+ self.assertEqual(len(values), 3)
109
+ self.assertIn("read", values)
110
+ self.assertIn("write", values)
111
+ self.assertIn("custom", values)
112
+
113
+ def test_scope_namespace_attributes(self):
114
+ """Test that ScopeNamespace sets attributes correctly."""
115
+ namespace = ScopeNamespace(FOO="foo", BAR="bar")
116
+ self.assertEqual(namespace.FOO, "foo")
117
+ self.assertEqual(namespace.BAR, "bar")