dynamicforms-fastapi-viewsets 0.7.3__tar.gz → 0.8.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.
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/PKG-INFO +3 -3
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/README.md +1 -1
- dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/__init__.py +1 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/backends/django_orm.py +157 -44
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/collection_viewset.py +50 -21
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/conf.py +5 -5
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/__init__.py +26 -8
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/auth/jwt.py +19 -7
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/build_schema.py +16 -1
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +20 -6
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/celery_viewset/client.py +36 -16
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +72 -15
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/celery_viewset/server.py +63 -18
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/lifecycle_runner.py +19 -14
- dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/decorators/primary_key_model_helper.py +164 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/route_viewset.py +41 -22
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/endpoint_docs.py +2 -2
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/exceptions.py +28 -5
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/__init__.py +2 -2
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/declaration.py +14 -13
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/list_query.py +1 -1
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/list_shapes.py +1 -1
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/middleware/__init__.py +15 -11
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/middleware/rate_limiter.py +24 -7
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mixins.py +9 -11
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/asgi.py +8 -2
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/protocol.py +3 -11
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/registry.py +4 -5
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/server.py +19 -4
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/save_state/serialize_state.py +6 -3
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/pyproject.toml +1 -1
- dynamicforms_fastapi_viewsets-0.7.3/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.7.3/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -53
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/save_state/save_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
|
|
5
5
|
Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
|
|
6
6
|
Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
|
|
@@ -23,7 +23,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
23
23
|
Classifier: Typing :: Typed
|
|
24
24
|
Requires-Python: >=3.10
|
|
25
25
|
Requires-Dist: fastapi>=0.100
|
|
26
|
-
Requires-Dist: pydantic>=2.
|
|
26
|
+
Requires-Dist: pydantic>=2.11
|
|
27
27
|
Requires-Dist: typing-extensions>=4.10
|
|
28
28
|
Provides-Extra: celery
|
|
29
29
|
Requires-Dist: celery>=5.3; extra == 'celery'
|
|
@@ -99,7 +99,7 @@ pip install "dynamicforms-fastapi-viewsets[muxws]"
|
|
|
99
99
|
pip install "dynamicforms-fastapi-viewsets[django]"
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
Requires Python 3.10+, FastAPI and Pydantic
|
|
102
|
+
Requires Python 3.10+, FastAPI and Pydantic 2.11+.
|
|
103
103
|
|
|
104
104
|
## Quick example
|
|
105
105
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.0"
|
|
@@ -26,8 +26,11 @@ from functools import reduce
|
|
|
26
26
|
from typing import Any, ClassVar, Generic, TYPE_CHECKING
|
|
27
27
|
|
|
28
28
|
from asgiref.sync import sync_to_async
|
|
29
|
+
from django.db import router, transaction
|
|
29
30
|
from django.db.models import BooleanField, F, Func, Q, Value
|
|
30
|
-
from
|
|
31
|
+
from django.db.models.fields import AutoFieldMixin
|
|
32
|
+
from fastapi.exceptions import RequestValidationError
|
|
33
|
+
from pydantic import BaseModel, ValidationError
|
|
31
34
|
from typing_extensions import TypeVar
|
|
32
35
|
|
|
33
36
|
from ..context import Context
|
|
@@ -68,12 +71,35 @@ T = TypeVar("T", bound=BaseModel)
|
|
|
68
71
|
|
|
69
72
|
|
|
70
73
|
class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
74
|
+
"""
|
|
75
|
+
A relation field - `ForeignKey`, `OneToOneField` - appears in `schema` under its `name` and
|
|
76
|
+
carries the related row's key: `owner: int`, not `owner_id: int` and not a nested model. The
|
|
77
|
+
backend reads and writes the `owner_id` column for it, filters and sorts on that column, and
|
|
78
|
+
never loads the related row. A `schema` that declares the column name (`owner_id`) raises
|
|
79
|
+
`TypeError` when the viewset class is defined.
|
|
80
|
+
"""
|
|
81
|
+
|
|
71
82
|
model: ClassVar[type["Model"]]
|
|
72
83
|
"""The Django model this viewset reads and writes."""
|
|
73
84
|
|
|
74
85
|
schema: ClassVar[type[BaseModel]]
|
|
75
86
|
"""The pydantic model rows are converted into on the way out."""
|
|
76
87
|
|
|
88
|
+
def __init_subclass__(cls, **kwargs):
|
|
89
|
+
super().__init_subclass__(**kwargs)
|
|
90
|
+
model = getattr(cls, "model", None)
|
|
91
|
+
declared = getattr(getattr(cls, "schema", None), "model_fields", None)
|
|
92
|
+
if model is None or declared is None:
|
|
93
|
+
return
|
|
94
|
+
columns = [field for field in _model_columns(model) if field.attname != field.name]
|
|
95
|
+
for field in columns:
|
|
96
|
+
if field.attname in declared:
|
|
97
|
+
raise TypeError(
|
|
98
|
+
f"{cls.__name__}: {cls.schema.__name__} declares `{field.attname}`, the column of "
|
|
99
|
+
f"{model.__name__}.{field.name}. Declare the relation as `{field.name}`; it carries "
|
|
100
|
+
f"the related row's key."
|
|
101
|
+
)
|
|
102
|
+
|
|
77
103
|
pk_field: ClassVar[str] = "pk"
|
|
78
104
|
"""
|
|
79
105
|
The lookup `_get_or_404` hands to `aget()` when a record is fetched by its key. `pk` is Django's
|
|
@@ -93,9 +119,8 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
|
93
119
|
client's own filters with it, to the in-memory pass, and emit a position read off a field
|
|
94
120
|
that is not there.
|
|
95
121
|
|
|
96
|
-
`_meta.pk
|
|
97
|
-
|
|
98
|
-
response model. Neither on the schema means the schema names the key something else - a
|
|
122
|
+
That name is `_meta.pk.name`, also for a relation primary key (`artist`, carrying the
|
|
123
|
+
related row's key). A `schema` without it names the key something else - a
|
|
99
124
|
multi-table-inheritance child, whose `_meta.pk` is the parent link - and the default
|
|
100
125
|
declared further along the MRO stands.
|
|
101
126
|
|
|
@@ -117,11 +142,8 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
|
117
142
|
)
|
|
118
143
|
|
|
119
144
|
declared = getattr(getattr(self, "schema", None), "model_fields", None)
|
|
120
|
-
if declared is None:
|
|
145
|
+
if declared is None or pk.name in declared:
|
|
121
146
|
return pk.name
|
|
122
|
-
for name in (pk.name, pk.attname):
|
|
123
|
-
if name in declared:
|
|
124
|
-
return name
|
|
125
147
|
return super().pk_field_name
|
|
126
148
|
|
|
127
149
|
def get_queryset(self, context: Context) -> "QuerySet": # noqa: ARG002 - for overrides to narrow per user
|
|
@@ -144,11 +166,24 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
|
144
166
|
because the in-memory pass goes through `land()`, which converts what it materialises. Every
|
|
145
167
|
one of those rows and its pydantic model are held at once, before the page is cut, so
|
|
146
168
|
`limit=10` against a table of hundreds of thousands of rows can exhaust memory.
|
|
169
|
+
|
|
170
|
+
A relation field is read from its column (`owner_id` for `owner`), so the related row is
|
|
171
|
+
never fetched.
|
|
147
172
|
"""
|
|
148
173
|
if isinstance(raw, self.schema):
|
|
149
174
|
return raw
|
|
175
|
+
if isinstance(raw, self.model):
|
|
176
|
+
raw = _ColumnView(raw, self._columns())
|
|
150
177
|
return self.schema.model_validate(raw, from_attributes=True)
|
|
151
178
|
|
|
179
|
+
def _columns(self) -> dict[str, str]:
|
|
180
|
+
"""Relation field name to the name of its column: `{"owner": "owner_id"}`."""
|
|
181
|
+
return {field.name: field.attname for field in _model_columns(self.model) if field.attname != field.name}
|
|
182
|
+
|
|
183
|
+
def _column(self, name: str) -> str:
|
|
184
|
+
"""The column a query uses for the schema field `name`."""
|
|
185
|
+
return self._columns().get(name, name)
|
|
186
|
+
|
|
152
187
|
# -----------------------------------------------------------------------
|
|
153
188
|
# List pipeline
|
|
154
189
|
# -----------------------------------------------------------------------
|
|
@@ -175,7 +210,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
|
175
210
|
|
|
176
211
|
filter_set = filter_set_for(query)
|
|
177
212
|
if not filter_set and filters_from(query.fltr) is None:
|
|
178
|
-
# A hand-written filter model, on the
|
|
213
|
+
# A hand-written filter model, on the filter_list path. Nothing declarative to
|
|
179
214
|
# translate, so the in-memory pass takes it.
|
|
180
215
|
return await super().apply_filter(context, query, records)
|
|
181
216
|
|
|
@@ -189,18 +224,8 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
|
189
224
|
return await super().apply_filter(context, query, queryset)
|
|
190
225
|
|
|
191
226
|
def _concrete_fields(self) -> set[str]:
|
|
192
|
-
"""
|
|
193
|
-
|
|
194
|
-
is `author` and `author_id`, and both are lookups Django accepts. The response model
|
|
195
|
-
carries the second, so leaving it out would decline push-down for a schema field that maps
|
|
196
|
-
to a column perfectly well.
|
|
197
|
-
"""
|
|
198
|
-
names = set()
|
|
199
|
-
for field in self.model._meta.get_fields():
|
|
200
|
-
if getattr(field, "concrete", False):
|
|
201
|
-
names.add(field.name)
|
|
202
|
-
names.add(field.attname)
|
|
203
|
-
return names
|
|
227
|
+
"""The schema field names that map to a column: every concrete field's `name`."""
|
|
228
|
+
return {field.name for field in _model_columns(self.model)}
|
|
204
229
|
|
|
205
230
|
async def apply_sort(self, context: Context, query: ListQuery, records: ListRecords) -> ListRecords:
|
|
206
231
|
"""
|
|
@@ -226,7 +251,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
|
226
251
|
descending = column.direction == SortDirection.desc
|
|
227
252
|
first = self.nulls_first != descending
|
|
228
253
|
placement = {"nulls_first": True} if first else {"nulls_last": True}
|
|
229
|
-
field = F(column.column_name)
|
|
254
|
+
field = F(self._column(column.column_name))
|
|
230
255
|
ordering.append(field.desc(**placement) if descending else field.asc(**placement))
|
|
231
256
|
queryset = queryset.order_by(*ordering)
|
|
232
257
|
query.mark_applied("sort")
|
|
@@ -264,24 +289,78 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
|
264
289
|
return self.to_record(await self._get_or_404(context, pk))
|
|
265
290
|
|
|
266
291
|
async def perform_create(self, context: Context, data: T) -> T: # noqa: ARG002
|
|
267
|
-
instance = await self.model.objects.acreate(**self._writable_fields(data))
|
|
292
|
+
instance = await self.model.objects.acreate(**self._writable_fields(data, creating=True))
|
|
268
293
|
return self.to_record(instance)
|
|
269
294
|
|
|
270
295
|
async def perform_bulk_create(self, context: Context, data: list[T]) -> list[T]: # noqa: ARG002
|
|
271
|
-
instances = [self.model(**self._writable_fields(record)) for record in data]
|
|
296
|
+
instances = [self.model(**self._writable_fields(record, creating=True)) for record in data]
|
|
272
297
|
created = await sync_to_async(self.model.objects.bulk_create)(instances)
|
|
273
298
|
return [self.to_record(instance) for instance in created]
|
|
274
299
|
|
|
275
300
|
async def perform_update(self, context: Context, pk: K, data: T, partial: bool = True) -> T:
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
await
|
|
281
|
-
return
|
|
301
|
+
"""
|
|
302
|
+
Writes `data` onto the row `pk` under a row lock; see `_update_locked`. A merged record that
|
|
303
|
+
fails `schema` raises `RequestValidationError` with `loc` `("body", *field)`.
|
|
304
|
+
"""
|
|
305
|
+
(record,) = await sync_to_async(self._update_locked)(context, {pk: data}, partial, bulk=False)
|
|
306
|
+
return record
|
|
282
307
|
|
|
283
308
|
async def perform_bulk_update(self, context: Context, records: dict[K, T], partial: bool = True) -> list[T]:
|
|
284
|
-
|
|
309
|
+
"""
|
|
310
|
+
Writes every record in one transaction, under a lock on each row; see `_update_locked`. A
|
|
311
|
+
missing row or a merged record that fails `schema` writes none of them. Every failing record
|
|
312
|
+
is reported in one `RequestValidationError`, with `loc` `("body", pk, *field)`.
|
|
313
|
+
"""
|
|
314
|
+
return await sync_to_async(self._update_locked)(context, records, partial, bulk=True)
|
|
315
|
+
|
|
316
|
+
def _update_locked(self, context: Context, records: dict[K, T], partial: bool, bulk: bool) -> list[T]:
|
|
317
|
+
"""
|
|
318
|
+
Reads each row with `SELECT ... FOR UPDATE`, sets the fields of its record on it, validates
|
|
319
|
+
the result as `schema` and saves the whole row, in one transaction.
|
|
320
|
+
|
|
321
|
+
The lock holds the row from the read to the commit, so a concurrent update of the same row
|
|
322
|
+
waits and then reads this one's result: it neither reverts the fields this one wrote nor
|
|
323
|
+
validates against values that are no longer stored. Two updates of the same field still
|
|
324
|
+
end with the later one's value.
|
|
325
|
+
|
|
326
|
+
The rows are locked in key order, so two bulk updates over the same rows do not deadlock.
|
|
327
|
+
The lock is taken through `_base_manager`, a query on the model's table alone: `FOR UPDATE`
|
|
328
|
+
refuses `distinct()`, aggregation and a nullable `select_related`, any of which
|
|
329
|
+
`get_queryset` may add. `get_queryset` decides whether the row is visible, and a row it
|
|
330
|
+
excludes is a `NotFoundError`. On SQLite, which has no row locks, the write
|
|
331
|
+
transaction locks the database instead.
|
|
332
|
+
|
|
333
|
+
`save()` and the model's signals run inside the transaction, before the commit. Work they
|
|
334
|
+
start that reads the row over another connection (a Celery task) is scheduled with
|
|
335
|
+
`transaction.on_commit`; started directly, it can read the values from before the update.
|
|
336
|
+
"""
|
|
337
|
+
locked = self.model._base_manager.db_manager(router.db_for_write(self.model)).select_for_update()
|
|
338
|
+
errors: list[dict] = []
|
|
339
|
+
rows = {}
|
|
340
|
+
with transaction.atomic(using=locked.db):
|
|
341
|
+
for pk in sorted(records):
|
|
342
|
+
try:
|
|
343
|
+
instance = locked.get(**{self.pk_field: pk})
|
|
344
|
+
except self.model.DoesNotExist:
|
|
345
|
+
raise NotFoundError(pk) from None
|
|
346
|
+
if not self.get_queryset(context).filter(pk=instance.pk).exists():
|
|
347
|
+
raise NotFoundError(pk)
|
|
348
|
+
for name, value in self._writable_fields(records[pk], exclude_unset=partial).items():
|
|
349
|
+
setattr(instance, name, value)
|
|
350
|
+
try:
|
|
351
|
+
self.to_record(instance)
|
|
352
|
+
except ValidationError as error:
|
|
353
|
+
prefix = ("body", pk) if bulk else ("body",)
|
|
354
|
+
errors.extend(
|
|
355
|
+
{**entry, "loc": (*prefix, *entry["loc"])}
|
|
356
|
+
for entry in error.errors(include_url=False, include_input=False)
|
|
357
|
+
)
|
|
358
|
+
rows[pk] = instance
|
|
359
|
+
if errors:
|
|
360
|
+
raise RequestValidationError(errors)
|
|
361
|
+
for instance in rows.values():
|
|
362
|
+
instance.save()
|
|
363
|
+
return [self.to_record(rows[pk]) for pk in records]
|
|
285
364
|
|
|
286
365
|
async def perform_destroy(self, context: Context, pk: K) -> dict:
|
|
287
366
|
instance = await self._get_or_404(context, pk)
|
|
@@ -301,18 +380,51 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
|
|
|
301
380
|
except ObjectDoesNotExist:
|
|
302
381
|
raise NotFoundError(pk) from None
|
|
303
382
|
|
|
304
|
-
def _writable_fields(self, data: T, exclude_unset: bool = False) -> dict[str, Any]:
|
|
383
|
+
def _writable_fields(self, data: T, exclude_unset: bool = False, creating: bool = False) -> dict[str, Any]:
|
|
305
384
|
"""
|
|
306
|
-
The
|
|
307
|
-
|
|
385
|
+
The fields of `data` that are concrete model fields, keyed by column (`owner_id` for
|
|
386
|
+
`owner`), so a relation is set from the key the body carries.
|
|
387
|
+
|
|
388
|
+
The primary key is left out of an update: saving an instance under a different key writes
|
|
389
|
+
to the row that key names. A create leaves out only a key the database or Django assigns -
|
|
390
|
+
an auto-incrementing field, or the parent link of a multi-table-inheritance child - and
|
|
391
|
+
keeps any other: a natural key or a relation primary key has no value unless the body
|
|
392
|
+
supplies it.
|
|
308
393
|
"""
|
|
309
394
|
editable = {
|
|
310
|
-
field.name
|
|
311
|
-
for field in self.model
|
|
312
|
-
if
|
|
395
|
+
field.name: field.attname
|
|
396
|
+
for field in _model_columns(self.model)
|
|
397
|
+
if not field.primary_key or (creating and not _assigned_key(field))
|
|
313
398
|
}
|
|
314
399
|
dumped = data.model_dump(exclude_unset=exclude_unset) if hasattr(data, "model_dump") else dict(data)
|
|
315
|
-
return {name: value for name, value in dumped.items() if name in editable}
|
|
400
|
+
return {editable[name]: value for name, value in dumped.items() if name in editable}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _model_columns(model: type["Model"]) -> list:
|
|
404
|
+
"""The model's fields that are columns, the composite primary key excluded."""
|
|
405
|
+
return [field for field in model._meta.concrete_fields if not isinstance(field, _COMPOSITE_PK)]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _assigned_key(field) -> bool:
|
|
409
|
+
"""Whether a primary key `field` gets its value from the database or from Django on save."""
|
|
410
|
+
remote = getattr(field, "remote_field", None)
|
|
411
|
+
return isinstance(field, AutoFieldMixin) or bool(remote and getattr(remote, "parent_link", False))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class _ColumnView:
|
|
415
|
+
"""
|
|
416
|
+
A model instance as `to_record` reads it: a relation field's name answers with its column's
|
|
417
|
+
value, every other attribute with the instance's own.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
__slots__ = ("_columns", "_row")
|
|
421
|
+
|
|
422
|
+
def __init__(self, row: "Model", columns: dict[str, str]):
|
|
423
|
+
self._row = row
|
|
424
|
+
self._columns = columns
|
|
425
|
+
|
|
426
|
+
def __getattr__(self, name: str) -> Any:
|
|
427
|
+
return getattr(self._row, self._columns.get(name, name))
|
|
316
428
|
|
|
317
429
|
|
|
318
430
|
def _as_queryset(records: ListRecords) -> "QuerySet | None":
|
|
@@ -358,8 +470,8 @@ viewset using it falls back to the in-memory pass - which is the contract workin
|
|
|
358
470
|
"""
|
|
359
471
|
|
|
360
472
|
|
|
361
|
-
def _compile_lookup(
|
|
362
|
-
return queryset.filter(**{f"{fltr.field}__{_LOOKUPS[type(fltr)]}": fltr.value})
|
|
473
|
+
def _compile_lookup(viewset, fltr, queryset):
|
|
474
|
+
return queryset.filter(**{f"{viewset._column(fltr.field)}__{_LOOKUPS[type(fltr)]}": fltr.value})
|
|
363
475
|
|
|
364
476
|
|
|
365
477
|
for _filter_type in _LOOKUPS:
|
|
@@ -412,14 +524,15 @@ def _cursor_condition(viewset: "DjangoORMViewSet", fltr: CursorPredicate) -> Q:
|
|
|
412
524
|
this, is Postgres-only, and a row constructor fails outright if annotated rather than used
|
|
413
525
|
directly as a condition.
|
|
414
526
|
"""
|
|
415
|
-
keys = fltr.keys
|
|
416
|
-
|
|
527
|
+
keys = [(viewset._column(name), descending) for name, descending in fltr.keys]
|
|
528
|
+
position = {viewset._column(name): value for name, value in fltr.position.items()}
|
|
529
|
+
nullable = {field.attname for field in _model_columns(viewset.model) if field.null}
|
|
417
530
|
uniform = len({descending for _, descending in keys}) == 1
|
|
418
531
|
if uniform and not fltr.inclusive and not any(name in nullable for name, _ in keys):
|
|
419
|
-
return _row_value_condition(keys,
|
|
532
|
+
return _row_value_condition(keys, position, fltr.backwards)
|
|
420
533
|
return _segment_condition(
|
|
421
534
|
keys,
|
|
422
|
-
|
|
535
|
+
position,
|
|
423
536
|
fltr.backwards,
|
|
424
537
|
fltr.inclusive,
|
|
425
538
|
fltr.nulls_first,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
|
|
1
3
|
from abc import ABC, abstractmethod
|
|
2
4
|
from collections.abc import Callable, Iterable, MutableMapping, MutableSequence, MutableSet
|
|
3
5
|
from typing import Any, Generic, TypeVar
|
|
@@ -5,7 +7,7 @@ from typing import Any, Generic, TypeVar
|
|
|
5
7
|
from pydantic import BaseModel
|
|
6
8
|
|
|
7
9
|
from .context import Context
|
|
8
|
-
from .exceptions import NotFoundError
|
|
10
|
+
from .exceptions import NotFoundError, ReadOnlyCollectionError
|
|
9
11
|
from .mixins import ImplMixin
|
|
10
12
|
|
|
11
13
|
T = TypeVar("T")
|
|
@@ -94,7 +96,7 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
|
94
96
|
elif isinstance(container, MutableSet):
|
|
95
97
|
self._add_func = container.add
|
|
96
98
|
self._update_func = self._update_set
|
|
97
|
-
self._delete_func =
|
|
99
|
+
self._delete_func = self._delete_set
|
|
98
100
|
elif isinstance(container, MutableMapping):
|
|
99
101
|
self._add_func = self._add_mapping
|
|
100
102
|
self._update_func = self._update_mapping
|
|
@@ -102,23 +104,36 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
|
102
104
|
|
|
103
105
|
def _check_mutable(self):
|
|
104
106
|
if not self.is_mutable:
|
|
105
|
-
raise
|
|
107
|
+
raise ReadOnlyCollectionError()
|
|
106
108
|
|
|
107
109
|
def _get_pk(self, item: T) -> K:
|
|
108
110
|
if isinstance(item, dict):
|
|
109
111
|
return item[self.pk_field]
|
|
110
112
|
return getattr(item, self.pk_field)
|
|
111
113
|
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _stated_fields(data: T) -> dict[str, Any]:
|
|
116
|
+
"""The fields a partial update states: those a model has set, or a dict's keys."""
|
|
117
|
+
if isinstance(data, BaseModel):
|
|
118
|
+
return {name: getattr(data, name) for name in data.model_fields_set}
|
|
119
|
+
return dict(data)
|
|
120
|
+
|
|
121
|
+
def _apply_partial(self, item: T, data: T) -> T:
|
|
122
|
+
"""`item` with the fields `data` states written onto it, in place."""
|
|
123
|
+
fields = self._stated_fields(data)
|
|
124
|
+
if isinstance(item, dict):
|
|
125
|
+
item.update(fields)
|
|
126
|
+
else:
|
|
127
|
+
for key, value in fields.items():
|
|
128
|
+
setattr(item, key, value)
|
|
129
|
+
return item
|
|
130
|
+
|
|
112
131
|
# Sequence helpers
|
|
113
132
|
def _update_sequence(self, pk: K, data: T, partial: bool):
|
|
114
133
|
for i, item in enumerate(self.container):
|
|
115
134
|
if self._get_pk(item) == pk:
|
|
116
135
|
if partial:
|
|
117
|
-
|
|
118
|
-
item.update(data)
|
|
119
|
-
else:
|
|
120
|
-
for key, value in data.items():
|
|
121
|
-
setattr(item, key, value)
|
|
136
|
+
self._apply_partial(item, data)
|
|
122
137
|
else:
|
|
123
138
|
self.container[i] = data
|
|
124
139
|
return self.container[i]
|
|
@@ -132,19 +147,24 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
|
132
147
|
raise NotFoundError(pk)
|
|
133
148
|
|
|
134
149
|
# Set helpers
|
|
135
|
-
def _update_set(
|
|
136
|
-
|
|
137
|
-
pk: K,
|
|
138
|
-
data: T,
|
|
139
|
-
partial: bool, # noqa: ARG002 - `container_update` passes `(pk, data, partial)` whichever helper is bound
|
|
140
|
-
):
|
|
150
|
+
def _update_set(self, pk: K, data: T, partial: bool):
|
|
151
|
+
# an item of a set is hashable, so it is replaced rather than changed in place
|
|
141
152
|
for item in self.container:
|
|
142
153
|
if self._get_pk(item) == pk:
|
|
154
|
+
if partial:
|
|
155
|
+
data = _replaced(item, self._stated_fields(data))
|
|
143
156
|
self.container.remove(item)
|
|
144
|
-
self.container.add(data)
|
|
157
|
+
self.container.add(data)
|
|
145
158
|
return data
|
|
146
159
|
raise NotFoundError(pk)
|
|
147
160
|
|
|
161
|
+
def _delete_set(self, pk: K):
|
|
162
|
+
for item in self.container:
|
|
163
|
+
if self._get_pk(item) == pk:
|
|
164
|
+
self.container.remove(item)
|
|
165
|
+
return
|
|
166
|
+
raise NotFoundError(pk)
|
|
167
|
+
|
|
148
168
|
# Mapping helpers
|
|
149
169
|
def _add_mapping(self, data: T):
|
|
150
170
|
pk = self._get_pk(data)
|
|
@@ -154,12 +174,7 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
|
154
174
|
if pk not in self.container:
|
|
155
175
|
raise NotFoundError(pk)
|
|
156
176
|
if partial:
|
|
157
|
-
|
|
158
|
-
if isinstance(item, dict):
|
|
159
|
-
item.update(data)
|
|
160
|
-
else:
|
|
161
|
-
for key, value in data.items():
|
|
162
|
-
setattr(item, key, value)
|
|
177
|
+
self._apply_partial(self.container[pk], data)
|
|
163
178
|
else:
|
|
164
179
|
self.container[pk] = data
|
|
165
180
|
return self.container[pk]
|
|
@@ -220,3 +235,17 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
|
220
235
|
self._delete_func(pk)
|
|
221
236
|
except KeyError as e:
|
|
222
237
|
raise NotFoundError(pk) from e
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _replaced(item: Any, fields: dict[str, Any]) -> Any:
|
|
241
|
+
"""A copy of the hashable `item` with `fields` written onto it: a pydantic model, a named tuple
|
|
242
|
+
or a dataclass."""
|
|
243
|
+
if isinstance(item, BaseModel):
|
|
244
|
+
return item.model_copy(update=fields)
|
|
245
|
+
if hasattr(item, "_replace"):
|
|
246
|
+
return item._replace(**fields)
|
|
247
|
+
if dataclasses.is_dataclass(item):
|
|
248
|
+
return dataclasses.replace(item, **fields)
|
|
249
|
+
raise TypeError(
|
|
250
|
+
f"{type(item).__name__} cannot be updated partially: it is neither a model, a named tuple nor a dataclass"
|
|
251
|
+
)
|
{dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/conf.py
RENAMED
|
@@ -35,14 +35,14 @@ class Settings:
|
|
|
35
35
|
self.default_list_shape: str = "plain"
|
|
36
36
|
"""
|
|
37
37
|
The shape a list endpoint answers in when the viewset does not say - "plain", "paginated"
|
|
38
|
-
or "cursor".
|
|
39
|
-
|
|
38
|
+
or "cursor". An application that pages everything sets it once here instead of on every
|
|
39
|
+
viewset.
|
|
40
40
|
"""
|
|
41
41
|
self.viewsets_register_muxws: bool = True
|
|
42
42
|
"""
|
|
43
43
|
Whether a viewset is reachable over the muxws transport (see fastapi_viewsets/mux_ws/) when
|
|
44
44
|
it does not say either way itself. `route_viewset(register_muxws=...)` overrides this per
|
|
45
|
-
viewset, and
|
|
45
|
+
viewset, and `@transports(muxws=...)` overrides it again per endpoint - each
|
|
46
46
|
level only decides for the levels below it that left the question open (None). Read at
|
|
47
47
|
decoration time, so it must be set before any viewset class is decorated.
|
|
48
48
|
"""
|
|
@@ -51,8 +51,8 @@ class Settings:
|
|
|
51
51
|
Purely for OpenAPI/Swagger discoverability (a lock icon + testable Authorize flow) - attached
|
|
52
52
|
as an extra sub-dependency alongside the per-route Middleware.depends() bridge (see
|
|
53
53
|
route_viewset.py), not otherwise consumed by this library. Must be set before any viewset
|
|
54
|
-
class is decorated with route_viewset
|
|
55
|
-
disable_response_model
|
|
54
|
+
class is decorated with route_viewset, as `viewsets_command_middleware` must, which decides
|
|
55
|
+
`disable_response_model` when the routes are built.
|
|
56
56
|
"""
|
|
57
57
|
|
|
58
58
|
|
|
@@ -7,6 +7,7 @@ from abc import ABC, abstractmethod
|
|
|
7
7
|
from typing import Any, TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
|
+
from pydantic_core import to_jsonable_python
|
|
10
11
|
|
|
11
12
|
from ..conf import settings
|
|
12
13
|
|
|
@@ -318,19 +319,22 @@ def serialize_value(value: Any) -> Any:
|
|
|
318
319
|
def to_jsonable(value: Any) -> Any:
|
|
319
320
|
"""
|
|
320
321
|
Recursively convert a value into a JSON-safe structure: a SerializableObject is tagged (see
|
|
321
|
-
serialize_value()), a Pydantic model is dumped, a
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
322
|
+
serialize_value()), a Pydantic model is dumped, a dict is recursed into value-wise, a
|
|
323
|
+
list/tuple element-wise, and any other value goes through pydantic_core.to_jsonable_python()
|
|
324
|
+
(datetime, Decimal, UUID, Enum, set, dataclass, ...) - the encoding FastAPI's response
|
|
325
|
+
serialization applies to the same value on REST/muxws. Shared by ViewSetResult's own
|
|
326
|
+
serialization and celery_viewset's worker/client payloads. from_jsonable() is its inverse for
|
|
327
|
+
tagged objects.
|
|
326
328
|
"""
|
|
327
329
|
if isinstance(value, SerializableObject):
|
|
328
330
|
return serialize_value(value)
|
|
329
331
|
if isinstance(value, BaseModel):
|
|
330
332
|
return value.model_dump(mode="json")
|
|
333
|
+
if isinstance(value, dict):
|
|
334
|
+
return {k: to_jsonable(v) for k, v in value.items()}
|
|
331
335
|
if isinstance(value, (list, tuple)):
|
|
332
336
|
return [to_jsonable(v) for v in value]
|
|
333
|
-
return value
|
|
337
|
+
return to_jsonable_python(value)
|
|
334
338
|
|
|
335
339
|
|
|
336
340
|
def deserialize_value(value: Any) -> Any:
|
|
@@ -340,6 +344,21 @@ def deserialize_value(value: Any) -> Any:
|
|
|
340
344
|
return value
|
|
341
345
|
|
|
342
346
|
|
|
347
|
+
def from_jsonable(value: Any) -> Any:
|
|
348
|
+
"""
|
|
349
|
+
Inverse of to_jsonable() for tagged objects: reconstructs every tagged SerializableObject,
|
|
350
|
+
at any depth of dicts and lists. A Pydantic model and any other value to_jsonable() encoded
|
|
351
|
+
stay in their JSON form.
|
|
352
|
+
"""
|
|
353
|
+
if isinstance(value, dict):
|
|
354
|
+
if _TYPE_KEY in value and _VALUE_KEY in value:
|
|
355
|
+
return deserialize_value(value)
|
|
356
|
+
return {k: from_jsonable(v) for k, v in value.items()}
|
|
357
|
+
if isinstance(value, list):
|
|
358
|
+
return [from_jsonable(v) for v in value]
|
|
359
|
+
return value
|
|
360
|
+
|
|
361
|
+
|
|
343
362
|
async def serialize_context(data: dict[str, Any]) -> dict[str, Any]:
|
|
344
363
|
"""
|
|
345
364
|
Returns a JSON-safe dict: SerializableObject values are swapped for a tagged payload (see
|
|
@@ -395,8 +414,7 @@ async def build_context(
|
|
|
395
414
|
processor that wants its own configuration slice declares a parameter literally named `config`
|
|
396
415
|
(detected by name, same convention as `context` in route_viewset.py); it's then called with
|
|
397
416
|
that keyword set to its own resolved entry from `action_configuration` (or None if nothing was
|
|
398
|
-
configured for it). A processor that doesn't declare `config` is called
|
|
399
|
-
existing processor needs to change.
|
|
417
|
+
configured for it). A processor that doesn't declare `config` is called without it.
|
|
400
418
|
"""
|
|
401
419
|
action_configuration = action_configuration or {}
|
|
402
420
|
merged: dict[str, Any] = {}
|
|
@@ -2,6 +2,7 @@ import datetime
|
|
|
2
2
|
|
|
3
3
|
from typing import Any, TYPE_CHECKING
|
|
4
4
|
|
|
5
|
+
from ...conf import settings
|
|
5
6
|
from .. import LazyObject
|
|
6
7
|
from . import AuthBackend
|
|
7
8
|
|
|
@@ -14,30 +15,41 @@ class _JWTUserLazy(LazyObject):
|
|
|
14
15
|
resolve() is sync and needs no I/O - signature+expiry verification is cheap, local computation
|
|
15
16
|
(unlike DjangoSessionAuthBackend, there's no session store to hit). Uses the default
|
|
16
17
|
recipe+resolved_value shortcut (see LazyObject) since decoded claims are already JSON-safe.
|
|
18
|
+
|
|
19
|
+
The recipe carries the token and the position of the verifying backend in
|
|
20
|
+
`settings.viewsets_auth_processors`, never the secret: a Celery worker, configured with the same
|
|
21
|
+
settings, verifies the token with its own copy of that backend. Where that position holds no
|
|
22
|
+
`JWTAuthBackend`, the token resolves to None.
|
|
17
23
|
"""
|
|
18
24
|
|
|
19
|
-
def __init__(self, token: str,
|
|
25
|
+
def __init__(self, token: str, backend: "JWTAuthBackend | None"):
|
|
20
26
|
super().__init__()
|
|
21
27
|
self._token = token
|
|
22
|
-
self.
|
|
23
|
-
self._algorithm = algorithm
|
|
28
|
+
self._backend = backend
|
|
24
29
|
|
|
25
30
|
def resolve(self) -> Any:
|
|
26
31
|
import jwt as pyjwt
|
|
27
32
|
|
|
33
|
+
if self._backend is None:
|
|
34
|
+
return None
|
|
28
35
|
try:
|
|
29
|
-
return pyjwt.decode(self._token, self.
|
|
36
|
+
return pyjwt.decode(self._token, self._backend.secret, algorithms=[self._backend.algorithm])
|
|
30
37
|
except pyjwt.PyJWTError:
|
|
31
38
|
# expired/invalid signature - same "recognized shape, invalid credential" case as
|
|
32
39
|
# DjangoSessionAuthBackend
|
|
33
40
|
return None
|
|
34
41
|
|
|
35
42
|
def serialize_recipe(self) -> Any:
|
|
36
|
-
|
|
43
|
+
processors = settings.viewsets_auth_processors
|
|
44
|
+
position = next((i for i, backend in enumerate(processors) if backend is self._backend), None)
|
|
45
|
+
return {"token": self._token, "backend": position}
|
|
37
46
|
|
|
38
47
|
@classmethod
|
|
39
48
|
def deserialize_recipe(cls, data: Any) -> "_JWTUserLazy":
|
|
40
|
-
|
|
49
|
+
processors = settings.viewsets_auth_processors
|
|
50
|
+
position = data.get("backend")
|
|
51
|
+
backend = processors[position] if isinstance(position, int) and 0 <= position < len(processors) else None
|
|
52
|
+
return cls(data["token"], backend if isinstance(backend, JWTAuthBackend) else None)
|
|
41
53
|
|
|
42
54
|
|
|
43
55
|
class JWTAuthBackend(AuthBackend):
|
|
@@ -64,7 +76,7 @@ class JWTAuthBackend(AuthBackend):
|
|
|
64
76
|
header = request.headers.get(self.header_name, "")
|
|
65
77
|
if not header.lower().startswith("bearer "):
|
|
66
78
|
return None # not a bearer token - let the next backend try it
|
|
67
|
-
return _JWTUserLazy(header[len("bearer ") :].strip(), self
|
|
79
|
+
return _JWTUserLazy(header[len("bearer ") :].strip(), self)
|
|
68
80
|
|
|
69
81
|
|
|
70
82
|
def encode_jwt(
|