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.
Files changed (54) hide show
  1. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/PKG-INFO +3 -3
  2. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/README.md +1 -1
  3. dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/__init__.py +1 -0
  4. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/backends/django_orm.py +157 -44
  5. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/collection_viewset.py +50 -21
  6. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/conf.py +5 -5
  7. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/__init__.py +26 -8
  8. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/auth/jwt.py +19 -7
  9. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/build_schema.py +16 -1
  10. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +20 -6
  11. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/celery_viewset/client.py +36 -16
  12. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +72 -15
  13. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/celery_viewset/server.py +63 -18
  14. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/lifecycle_runner.py +19 -14
  15. dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/decorators/primary_key_model_helper.py +164 -0
  16. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/route_viewset.py +41 -22
  17. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/endpoint_docs.py +2 -2
  18. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/exceptions.py +28 -5
  19. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/__init__.py +2 -2
  20. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/declaration.py +14 -13
  21. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/list_query.py +1 -1
  22. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/list_shapes.py +1 -1
  23. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/middleware/__init__.py +15 -11
  24. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/middleware/rate_limiter.py +24 -7
  25. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mixins.py +9 -11
  26. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/asgi.py +8 -2
  27. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/protocol.py +3 -11
  28. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/registry.py +4 -5
  29. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/server.py +19 -4
  30. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/save_state/serialize_state.py +6 -3
  31. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/pyproject.toml +1 -1
  32. dynamicforms_fastapi_viewsets-0.7.3/fastapi_viewsets/__init__.py +0 -1
  33. dynamicforms_fastapi_viewsets-0.7.3/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -53
  34. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/.gitignore +0 -0
  35. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/LICENSE +0 -0
  36. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/action_configuration.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/backends/__init__.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/auth/django.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/context/auth/static.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/cursor.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/__init__.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/decorators/conftest.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/base.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/operators.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/filters/registry.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/py.typed +0 -0
  52. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/response_classes.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.7.3 → dynamicforms_fastapi_viewsets-0.8.0}/fastapi_viewsets/save_state/__init__.py +0 -0
  54. {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.7.3
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.1
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 v2.
102
+ Requires Python 3.10+, FastAPI and Pydantic 2.11+.
103
103
 
104
104
  ## Quick example
105
105
 
@@ -48,7 +48,7 @@ pip install "dynamicforms-fastapi-viewsets[muxws]"
48
48
  pip install "dynamicforms-fastapi-viewsets[django]"
49
49
  ```
50
50
 
51
- Requires Python 3.10+, FastAPI and Pydantic v2.
51
+ Requires Python 3.10+, FastAPI and Pydantic 2.11+.
52
52
 
53
53
  ## Quick example
54
54
 
@@ -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 pydantic import BaseModel
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` offers two such names, and the first that `schema` declares wins: `name`, and
97
- `attname` for a relation primary key, which is `owner` on the model and `owner_id` on the
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 older filter_list path. Nothing declarative to
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
- Every name that names a column in a query, both halves of a relation's pair: a foreign key
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
- instance = await self._get_or_404(context, pk)
277
- fields = self._writable_fields(data, exclude_unset=partial)
278
- for name, value in fields.items():
279
- setattr(instance, name, value)
280
- await instance.asave()
281
- return self.to_record(instance)
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
- return [await self.perform_update(context, pk, data, partial=partial) for pk, data in records.items()]
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 model's own fields, minus the primary key - the database assigns that, and letting a
307
- request body set it is how you overwrite somebody else's row.
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._meta.get_fields()
312
- if getattr(field, "concrete", False) and not getattr(field, "primary_key", False)
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(_viewset, fltr, queryset):
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
- nullable = {field.name for field in viewset.model._meta.get_fields() if getattr(field, "null", False)}
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, fltr.position, fltr.backwards)
532
+ return _row_value_condition(keys, position, fltr.backwards)
420
533
  return _segment_condition(
421
534
  keys,
422
- fltr.position,
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 = container.remove
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 Exception("Provided container is not mutable")
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
- if isinstance(item, dict):
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
- self,
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) # Sets don't really support partial update easily without mutation
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
- item = self.container[pk]
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
+ )
@@ -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". Left at "plain" so that adding this setting changed nothing for anybody;
39
- an application that pages everything sets it once here instead of on every viewset.
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 `muxws_endpoint(register_muxws=...)` overrides it again per endpoint - each
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 (same existing constraint as
55
- disable_response_model=bool(settings.viewsets_command_middleware) already has).
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 list/tuple is recursed into element-wise -
322
- anything else passes through unchanged. The one shared implementation of a conversion several
323
- call sites need on their own single (non-Context, non-dict) return/body value - ViewSetResult's
324
- own (de)serialization, and a celery_viewset worker's task result - rather than each keeping a
325
- slightly different copy that drifts out of sync with what the others handle.
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 exactly as before - no
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, secret: str, algorithm: str):
25
+ def __init__(self, token: str, backend: "JWTAuthBackend | None"):
20
26
  super().__init__()
21
27
  self._token = token
22
- self._secret = secret
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._secret, algorithms=[self._algorithm])
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
- return {"token": self._token, "secret": self._secret, "algorithm": self._algorithm}
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
- return cls(data["token"], data["secret"], data["algorithm"])
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.secret, self.algorithm)
79
+ return _JWTUserLazy(header[len("bearer ") :].strip(), self)
68
80
 
69
81
 
70
82
  def encode_jwt(