dynamicforms-fastapi-viewsets 0.1.0__py3-none-any.whl
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.1.0.dist-info/METADATA +105 -0
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/RECORD +22 -0
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/WHEEL +4 -0
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastapi_viewsets/__init__.py +1 -0
- fastapi_viewsets/celery_viewset.py +50 -0
- fastapi_viewsets/collection_viewset.py +216 -0
- fastapi_viewsets/decorators/__init__.py +2 -0
- fastapi_viewsets/decorators/build_schema.py +161 -0
- fastapi_viewsets/decorators/celery_viewset/__init__.py +51 -0
- fastapi_viewsets/decorators/celery_viewset/client.py +88 -0
- fastapi_viewsets/decorators/celery_viewset/result_reader.py +99 -0
- fastapi_viewsets/decorators/celery_viewset/server.py +138 -0
- fastapi_viewsets/decorators/lifecycle_runner.py +64 -0
- fastapi_viewsets/decorators/primary_key_model_helper.py +33 -0
- fastapi_viewsets/decorators/route_viewset.py +216 -0
- fastapi_viewsets/mixins.py +476 -0
- fastapi_viewsets/py.typed +0 -0
- fastapi_viewsets/response_classes.py +15 -0
- fastapi_viewsets/save_state/__init__.py +0 -0
- fastapi_viewsets/save_state/save_state.py +36 -0
- fastapi_viewsets/save_state/serialize_state.py +30 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Annotated, Any, final, Generic, get_args, get_origin, Union
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Query
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, create_model
|
|
7
|
+
from pydantic.alias_generators import to_camel
|
|
8
|
+
from typing_extensions import TypeVar
|
|
9
|
+
|
|
10
|
+
from fastapi_viewsets.response_classes import NOT_FOUND_RESPONSE
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
K = TypeVar("K")
|
|
14
|
+
TFilter = TypeVar("TFilter", default=None)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImplMixin(Generic[K, T], ABC):
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def perform_create(self, data: T) -> T:
|
|
20
|
+
"""
|
|
21
|
+
Subclasses should implement this method to perform the actual creation.
|
|
22
|
+
"""
|
|
23
|
+
raise NotImplementedError("Method 'perform_create' must be implemented.")
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def perform_bulk_create(self, data: list[T]) -> list[T]:
|
|
27
|
+
"""
|
|
28
|
+
Subclasses should implement this method to perform the actual bulk creation.
|
|
29
|
+
"""
|
|
30
|
+
raise NotImplementedError("Method 'perform_bulk_create' must be implemented.")
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def perform_list(self) -> list[T]:
|
|
34
|
+
"""
|
|
35
|
+
Subclasses should implement this method to perform the actual listing.
|
|
36
|
+
"""
|
|
37
|
+
raise NotImplementedError("Method 'perform_list' must be implemented.")
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
async def perform_retrieve(self, pk: K) -> T:
|
|
41
|
+
"""
|
|
42
|
+
Subclasses should implement this method to perform the actual retrieval.
|
|
43
|
+
"""
|
|
44
|
+
raise NotImplementedError("Method 'perform_retrieve' must be implemented.")
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def perform_update(self, pk: K, data: T, partial: bool = True) -> T:
|
|
48
|
+
"""
|
|
49
|
+
Subclasses should implement this method to perform the actual update.
|
|
50
|
+
"""
|
|
51
|
+
raise NotImplementedError("Method 'perform_update' must be implemented.")
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def perform_bulk_update(self, records: dict[K, T], partial: bool = True) -> list[T]:
|
|
55
|
+
"""
|
|
56
|
+
Subclasses should implement this method to perform the actual bulk update.
|
|
57
|
+
"""
|
|
58
|
+
raise NotImplementedError("Method 'perform_bulk_update' must be implemented.")
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def perform_destroy(self, pk: K) -> dict[K, Any]:
|
|
62
|
+
"""
|
|
63
|
+
Subclasses should implement this method to perform the actual destruction.
|
|
64
|
+
"""
|
|
65
|
+
raise NotImplementedError("Method 'perform_destroy' must be implemented.")
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def perform_bulk_destroy(self, pk: list[K]) -> list[dict[K, Any]]:
|
|
69
|
+
"""
|
|
70
|
+
Subclasses should implement this method to perform the actual bulk destruction.
|
|
71
|
+
"""
|
|
72
|
+
raise NotImplementedError("Method 'perform_bulk_destroy' must be implemented.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
###################################################################################################
|
|
76
|
+
# CREATE
|
|
77
|
+
###################################################################################################
|
|
78
|
+
class CreateMixin(Generic[K, T], ABC):
|
|
79
|
+
"""
|
|
80
|
+
Create a model instance.
|
|
81
|
+
"""
|
|
82
|
+
__router = APIRouter()
|
|
83
|
+
|
|
84
|
+
@final
|
|
85
|
+
@__router.post("")
|
|
86
|
+
async def create(self: "ImplMixin[K, T] | CreateMixin[K ,T]", data: T) -> T:
|
|
87
|
+
return await self.perform_create(data)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BulkOnlyCreateMixin(Generic[K, T], ABC):
|
|
91
|
+
"""
|
|
92
|
+
Create model instances in bulk.
|
|
93
|
+
"""
|
|
94
|
+
__router = APIRouter()
|
|
95
|
+
|
|
96
|
+
@final
|
|
97
|
+
@__router.post("bulk")
|
|
98
|
+
async def bulk_create(self: "ImplMixin[K, T] | BulkOnlyCreateMixin[K ,T]", data: list[T]) -> list[T]:
|
|
99
|
+
return await self.perform_bulk_create(data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BulkCreateMixin(CreateMixin[K, T], BulkOnlyCreateMixin[K, T]):
|
|
103
|
+
"""
|
|
104
|
+
Create model instances (single or bulk).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
###################################################################################################
|
|
109
|
+
# LIST
|
|
110
|
+
###################################################################################################
|
|
111
|
+
def make_all_optional(model: type[BaseModel]) -> type[BaseModel]:
|
|
112
|
+
"""
|
|
113
|
+
Adjusts all fields of the provided BaseModel class to be optional, effectively creating
|
|
114
|
+
a new model where all attributes are nullable and default to None. This can be useful
|
|
115
|
+
when creating filter models or scenarios where optional attributes are required.
|
|
116
|
+
|
|
117
|
+
:param model: The input Pydantic BaseModel class to process.
|
|
118
|
+
:type model: type[BaseModel]
|
|
119
|
+
:return: A new Pydantic model with all fields converted to optional.
|
|
120
|
+
:rtype: type[BaseModel]
|
|
121
|
+
"""
|
|
122
|
+
fields = {}
|
|
123
|
+
for field_name, field_info in model.model_fields.items():
|
|
124
|
+
ann = field_info.annotation
|
|
125
|
+
if not (get_origin(ann) is Union and type(None) in get_args(ann)):
|
|
126
|
+
ann = ann | None
|
|
127
|
+
fields[field_name] = (ann, None)
|
|
128
|
+
return create_model(f"{model.__name__}Filter", **fields)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FilterParam:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
###################################################################################################
|
|
136
|
+
# SORT
|
|
137
|
+
###################################################################################################
|
|
138
|
+
class SortDirection(str, Enum):
|
|
139
|
+
asc = "asc"
|
|
140
|
+
desc = "desc"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SortStateColumn(BaseModel):
|
|
144
|
+
"""
|
|
145
|
+
Mirrors the FE SortStateColumn interface: one column in the current sort order.
|
|
146
|
+
Python attribute names use snake_case; JSON serialization uses camelCase (columnName).
|
|
147
|
+
"""
|
|
148
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
149
|
+
|
|
150
|
+
column_name: str
|
|
151
|
+
direction: SortDirection = SortDirection.asc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
SortState = list[SortStateColumn]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_sort_param(sort_csv: str | None) -> SortState:
|
|
158
|
+
"""
|
|
159
|
+
Parse the 'sort' query parameter (comma-separated) into a SortState.
|
|
160
|
+
|
|
161
|
+
The value must be a comma-separated list of 'columnName:direction' or just 'columnName'
|
|
162
|
+
(direction defaults to asc). Entries with an unrecognised direction are silently skipped.
|
|
163
|
+
|
|
164
|
+
Example: 'title:asc,artist:desc' → [SortStateColumn(column_name='title', direction=asc), ...]
|
|
165
|
+
"""
|
|
166
|
+
if not sort_csv:
|
|
167
|
+
return []
|
|
168
|
+
result: SortState = []
|
|
169
|
+
for param in sort_csv.split(","):
|
|
170
|
+
param = param.strip()
|
|
171
|
+
if not param:
|
|
172
|
+
continue
|
|
173
|
+
column, _, raw_direction = param.rpartition(":")
|
|
174
|
+
if not column:
|
|
175
|
+
column, raw_direction = param, SortDirection.asc.value
|
|
176
|
+
try:
|
|
177
|
+
result.append(SortStateColumn(column_name=column, direction=SortDirection(raw_direction)))
|
|
178
|
+
except ValueError:
|
|
179
|
+
pass # skip entries with an unrecognised direction
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
###################################################################################################
|
|
184
|
+
# LIST
|
|
185
|
+
###################################################################################################
|
|
186
|
+
class ListMixin(Generic[T, TFilter], ABC):
|
|
187
|
+
"""
|
|
188
|
+
List a queryset.
|
|
189
|
+
"""
|
|
190
|
+
__router = APIRouter()
|
|
191
|
+
|
|
192
|
+
@final
|
|
193
|
+
@__router.get("")
|
|
194
|
+
async def list_items(
|
|
195
|
+
self: "ImplMixin[Any, T] | ListMixin[T]",
|
|
196
|
+
fltr: Annotated[TFilter, Query()] = None,
|
|
197
|
+
sort: str | None = None,
|
|
198
|
+
) -> list[T]:
|
|
199
|
+
has_filter = (
|
|
200
|
+
fltr is not None
|
|
201
|
+
and hasattr(fltr, "model_dump")
|
|
202
|
+
and any(v is not None for v in fltr.model_dump().values())
|
|
203
|
+
)
|
|
204
|
+
sort_state: SortState = parse_sort_param(sort)
|
|
205
|
+
|
|
206
|
+
if has_filter:
|
|
207
|
+
await self.setup_filter(fltr)
|
|
208
|
+
if sort_state:
|
|
209
|
+
await self.setup_sort(sort_state)
|
|
210
|
+
res = await self.perform_list()
|
|
211
|
+
if has_filter:
|
|
212
|
+
res = await self.filter_list(fltr, res)
|
|
213
|
+
if sort_state:
|
|
214
|
+
res = await self.sort_list(sort_state, res)
|
|
215
|
+
return res
|
|
216
|
+
|
|
217
|
+
async def setup_filter(self, fltr: TFilter) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Optional pre-filter hook called before perform_list when a filter is active.
|
|
220
|
+
Subclasses can override to set up server-side filtering (e.g., build a DB query).
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
async def filter_list(self, fltr: TFilter, records: list[T]) -> list[T]:
|
|
224
|
+
"""
|
|
225
|
+
Post-filter hook called after perform_list when a filter is active.
|
|
226
|
+
Subclasses implement this to filter records in-memory.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
async def setup_sort(self, sort: SortState) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Optional pre-sort hook called before perform_list when a sort order is active.
|
|
232
|
+
Subclasses can override to apply server-side ordering (e.g., add ORDER BY to a DB query).
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
async def sort_list(self, sort: SortState, records: list[T]) -> list[T]:
|
|
236
|
+
"""
|
|
237
|
+
Post-sort hook called after perform_list (and filter_list) when a sort order is active.
|
|
238
|
+
Default implementation performs a stable in-memory multi-key sort. Null values sort last
|
|
239
|
+
for asc, first for desc. Override for custom behaviour.
|
|
240
|
+
"""
|
|
241
|
+
import functools
|
|
242
|
+
|
|
243
|
+
def compare(a: T, b: T) -> int:
|
|
244
|
+
for col in sort:
|
|
245
|
+
val_a = getattr(a, col.column_name, None)
|
|
246
|
+
val_b = getattr(b, col.column_name, None)
|
|
247
|
+
if val_a is None and val_b is None:
|
|
248
|
+
continue
|
|
249
|
+
if val_a is None:
|
|
250
|
+
return 1 if col.direction == SortDirection.asc else -1
|
|
251
|
+
if val_b is None:
|
|
252
|
+
return -1 if col.direction == SortDirection.asc else 1
|
|
253
|
+
try:
|
|
254
|
+
cmp = (val_a > val_b) - (val_a < val_b)
|
|
255
|
+
except TypeError:
|
|
256
|
+
continue
|
|
257
|
+
if col.direction == SortDirection.desc:
|
|
258
|
+
cmp = -cmp
|
|
259
|
+
if cmp != 0:
|
|
260
|
+
return cmp
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
return sorted(records, key=functools.cmp_to_key(compare))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
###################################################################################################
|
|
267
|
+
# RETRIEVE
|
|
268
|
+
###################################################################################################
|
|
269
|
+
class RetrieveMixin(Generic[K, T], ABC):
|
|
270
|
+
"""
|
|
271
|
+
Retrieve a model instance.
|
|
272
|
+
"""
|
|
273
|
+
__router = APIRouter()
|
|
274
|
+
|
|
275
|
+
@final
|
|
276
|
+
@__router.get("/{pk}", responses=NOT_FOUND_RESPONSE)
|
|
277
|
+
async def retrieve(self: "ImplMixin[K, T] | RetrieveMixin[K, T]", pk: K) -> T:
|
|
278
|
+
return await self.perform_retrieve(pk)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
###################################################################################################
|
|
282
|
+
# UPDATE
|
|
283
|
+
###################################################################################################
|
|
284
|
+
class UpdateMixin(Generic[K, T], ABC):
|
|
285
|
+
"""
|
|
286
|
+
Update a model instance.
|
|
287
|
+
"""
|
|
288
|
+
__router = APIRouter()
|
|
289
|
+
|
|
290
|
+
@final
|
|
291
|
+
@__router.put("/{pk}", responses=NOT_FOUND_RESPONSE)
|
|
292
|
+
async def update(self: "ImplMixin[K, T] | UpdateMixin[K, T]", pk: K, data: T) -> T:
|
|
293
|
+
return await self.perform_update(pk, data, partial=False)
|
|
294
|
+
|
|
295
|
+
@final
|
|
296
|
+
@__router.patch("/{pk}", name="partial_update", responses=NOT_FOUND_RESPONSE)
|
|
297
|
+
async def partial_update(self: "ImplMixin[K, T] | UpdateMixin[K, T]", pk: K, data: T) -> T:
|
|
298
|
+
return await self.perform_update(pk, data, partial=True)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class BulkOnlyUpdateMixin(Generic[K, T], ABC):
|
|
302
|
+
"""
|
|
303
|
+
Update model instances in bulk.
|
|
304
|
+
"""
|
|
305
|
+
__router = APIRouter()
|
|
306
|
+
|
|
307
|
+
@final
|
|
308
|
+
@__router.put("bulk")
|
|
309
|
+
async def bulk_update(self: "ImplMixin[K, T] | BulkOnlyUpdateMixin[K, T]", records: dict[K, T]) -> list[T]:
|
|
310
|
+
return await self.perform_bulk_update(records, partial=False)
|
|
311
|
+
|
|
312
|
+
@final
|
|
313
|
+
@__router.patch("bulk", name="bulk_partial_update")
|
|
314
|
+
async def bulk_partial_update(self: "ImplMixin[K, T] | BulkOnlyUpdateMixin[K, T]", records: dict[K, T]) -> list[T]:
|
|
315
|
+
return await self.perform_bulk_update(records, partial=True)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class BulkUpdateMixin(UpdateMixin[K, T], BulkOnlyUpdateMixin[K, T]):
|
|
319
|
+
"""
|
|
320
|
+
Update model instances (single or bulk).
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
###################################################################################################
|
|
325
|
+
# DELETE
|
|
326
|
+
###################################################################################################
|
|
327
|
+
class DestroyMixin(Generic[K, T], ABC):
|
|
328
|
+
"""
|
|
329
|
+
Destroy a model instance. Return the destroyed key and any additional data about its destruction
|
|
330
|
+
"""
|
|
331
|
+
__router = APIRouter()
|
|
332
|
+
|
|
333
|
+
@final
|
|
334
|
+
@__router.delete("/{pk}", responses=NOT_FOUND_RESPONSE)
|
|
335
|
+
async def destroy(self: "ImplMixin[K, T] | DestroyMixin[K, T]", pk: K) -> dict[K, Any]:
|
|
336
|
+
return await self.perform_destroy(pk)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class BulkOnlyDestroyMixin(Generic[K, T], ABC):
|
|
340
|
+
"""
|
|
341
|
+
Destroy model instances in bulk.
|
|
342
|
+
"""
|
|
343
|
+
__router = APIRouter()
|
|
344
|
+
|
|
345
|
+
@final
|
|
346
|
+
@__router.delete("bulk")
|
|
347
|
+
async def bulk_destroy(self: "ImplMixin[K, T] | BulkOnlyDestroyMixin[K, T]", pk: list[K]) -> list[dict[K, Any]]:
|
|
348
|
+
return await self.perform_bulk_destroy(pk)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class BulkDestroyMixin(DestroyMixin[K, T], BulkOnlyDestroyMixin[K, T]):
|
|
352
|
+
"""
|
|
353
|
+
Destroy model instances (single or bulk).
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
###################################################################################################
|
|
357
|
+
# LOOKUP
|
|
358
|
+
###################################################################################################
|
|
359
|
+
class LookupItem(BaseModel):
|
|
360
|
+
group: Any = None
|
|
361
|
+
pk: object
|
|
362
|
+
title: str
|
|
363
|
+
icon: str | None = None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class LookupFilter(BaseModel):
|
|
367
|
+
"""Default filter model for LookupMixin. Provides case-insensitive title search via q."""
|
|
368
|
+
q: str | None = None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
TLookupFilter = TypeVar("TLookupFilter", default=LookupFilter)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class LookupMixin(Generic[TLookupFilter], ABC):
|
|
375
|
+
"""
|
|
376
|
+
Lookup endpoint with optional search filtering.
|
|
377
|
+
|
|
378
|
+
Adds a GET /lookup endpoint that returns a list of LookupItem objects — useful for populating
|
|
379
|
+
select/autocomplete widgets.
|
|
380
|
+
|
|
381
|
+
The mixin is generic over TLookupFilter (default: LookupFilter). When no type argument is
|
|
382
|
+
given, the endpoint exposes a single 'q' query parameter and filter_lookup filters by
|
|
383
|
+
case-insensitive title match. Supply a custom filter model as a type argument to add extra
|
|
384
|
+
query parameters and override filter_lookup.
|
|
385
|
+
|
|
386
|
+
When the filter is active (any field is non-None), the following two-phase pipeline runs:
|
|
387
|
+
|
|
388
|
+
1. Pre-filter: setup_lookup_filter is called before perform_lookup.
|
|
389
|
+
Override to apply server-side filtering (e.g., narrow a DB query).
|
|
390
|
+
2. Post-filter: filter_lookup is called after perform_lookup.
|
|
391
|
+
The default implementation filters by fltr.q (case-insensitive title match).
|
|
392
|
+
Override to customise in-memory filtering.
|
|
393
|
+
"""
|
|
394
|
+
__router = APIRouter()
|
|
395
|
+
|
|
396
|
+
@abstractmethod
|
|
397
|
+
async def perform_lookup(self) -> list[LookupItem]:
|
|
398
|
+
"""
|
|
399
|
+
Subclasses should implement this method to return lookup items.
|
|
400
|
+
This method is intentionally NOT in ImplMixin because it's expected to be a simple
|
|
401
|
+
transformation of perform_list.
|
|
402
|
+
"""
|
|
403
|
+
raise NotImplementedError("Method 'perform_lookup' must be implemented.")
|
|
404
|
+
|
|
405
|
+
async def setup_lookup_filter(self, fltr: TLookupFilter) -> None:
|
|
406
|
+
"""
|
|
407
|
+
Optional pre-filter hook called before perform_lookup when the filter is active.
|
|
408
|
+
Subclasses can override to set up server-side filtering (e.g., build a DB query).
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
async def filter_lookup(self, fltr: TLookupFilter, items: list[LookupItem]) -> list[LookupItem]:
|
|
412
|
+
"""
|
|
413
|
+
Post-filter hook called after perform_lookup when the filter is active.
|
|
414
|
+
Default implementation filters by fltr.q (case-insensitive substring of title).
|
|
415
|
+
Subclasses can override to change the filtering behaviour.
|
|
416
|
+
"""
|
|
417
|
+
q = getattr(fltr, "q", None)
|
|
418
|
+
if q is None:
|
|
419
|
+
return items
|
|
420
|
+
return [item for item in items if q.lower() in item.title.lower()]
|
|
421
|
+
|
|
422
|
+
@final
|
|
423
|
+
@__router.get("lookup")
|
|
424
|
+
async def lookup(
|
|
425
|
+
self: "ImplMixin[Any, LookupItem] | LookupMixin",
|
|
426
|
+
fltr: Annotated[TLookupFilter, Query()] = None,
|
|
427
|
+
) -> list[LookupItem]:
|
|
428
|
+
has_filter = (
|
|
429
|
+
fltr is not None
|
|
430
|
+
and hasattr(fltr, "model_dump")
|
|
431
|
+
and any(v is not None for v in fltr.model_dump().values())
|
|
432
|
+
)
|
|
433
|
+
if has_filter:
|
|
434
|
+
await self.setup_lookup_filter(fltr)
|
|
435
|
+
res = await self.perform_lookup()
|
|
436
|
+
if has_filter:
|
|
437
|
+
res = await self.filter_lookup(fltr, res)
|
|
438
|
+
return res
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
###################################################################################################
|
|
442
|
+
# COMBINED VIEWSET MIXINS
|
|
443
|
+
###################################################################################################
|
|
444
|
+
class ReadOnlyViewSetMixin(ListMixin[T], RetrieveMixin[K, T], Generic[K, T], ABC):
|
|
445
|
+
"""
|
|
446
|
+
Read-only viewset. Provides 'list' and 'retrieve' actions.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class ViewSetMixin(
|
|
451
|
+
Generic[K, T, TFilter],
|
|
452
|
+
CreateMixin[K, T],
|
|
453
|
+
ListMixin[T, TFilter],
|
|
454
|
+
RetrieveMixin[K, T],
|
|
455
|
+
UpdateMixin[K, T],
|
|
456
|
+
DestroyMixin[K, T],
|
|
457
|
+
ABC,
|
|
458
|
+
):
|
|
459
|
+
"""
|
|
460
|
+
Standard full viewset. Provides 'create', 'list', 'retrieve', 'update', 'partial_update' and 'destroy' actions.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class BulkViewSetMixin(
|
|
465
|
+
Generic[K, T, TFilter],
|
|
466
|
+
BulkCreateMixin[K, T],
|
|
467
|
+
ListMixin[T, TFilter],
|
|
468
|
+
RetrieveMixin[K, T],
|
|
469
|
+
BulkUpdateMixin[K, T],
|
|
470
|
+
BulkDestroyMixin[K, T],
|
|
471
|
+
ABC,
|
|
472
|
+
):
|
|
473
|
+
"""
|
|
474
|
+
Full viewset with bulk support. Provides 'create', 'bulk_create', 'list', 'retrieve', 'update', 'partial_update',
|
|
475
|
+
'bulk_update', 'bulk_partial_update', 'destroy' and 'bulk_destroy' actions.
|
|
476
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NotFoundResponse(BaseModel):
|
|
8
|
+
detail: str = "Item with pk {pk} not found"
|
|
9
|
+
|
|
10
|
+
NOT_FOUND_RESPONSE= { "404": { "model": NotFoundResponse, "message": NotFoundResponse().detail } }
|
|
11
|
+
|
|
12
|
+
class NotFoundError(HTTPException):
|
|
13
|
+
def __init__(self, pk: Any):
|
|
14
|
+
# NotFoundResponse has only "detail", so it deconstructs into exactly the same parameter for HTTPException
|
|
15
|
+
super().__init__(status_code=404, **NotFoundResponse(detail=f"Item with pk {pk} not found").model_dump())
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from .serialize_state import SerializeState
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from redis.asyncio import Redis
|
|
8
|
+
|
|
9
|
+
class SaveState(SerializeState, ABC):
|
|
10
|
+
def __init__(self, instance_id: str):
|
|
11
|
+
super().__init__()
|
|
12
|
+
self.instance_id = instance_id
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def load_state(self): # noqa: B027
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def save_state(self):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SaveStateRedis(SaveState, ABC):
|
|
24
|
+
save_state_redis_key: str = None
|
|
25
|
+
|
|
26
|
+
def __init__(self, instance_id: str, redis: "Redis"):
|
|
27
|
+
super().__init__(instance_id)
|
|
28
|
+
self.redis = redis
|
|
29
|
+
if self.save_state_redis_key is None:
|
|
30
|
+
raise ValueError("save_state_redis_key class variable must be set")
|
|
31
|
+
|
|
32
|
+
async def load_state(self):
|
|
33
|
+
return await self.deserialize_state(await self.redis.get(self.save_state_redis_key))
|
|
34
|
+
|
|
35
|
+
async def save_state(self):
|
|
36
|
+
return await self.redis.set(self.save_state_redis_key, await self.serialize_state())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SerializeState(ABC):
|
|
7
|
+
def __init__(self, custom_json_encoder=None, custom_json_decoder=None):
|
|
8
|
+
self.custom_json_encoder = custom_json_encoder
|
|
9
|
+
self.custom_json_decoder = custom_json_decoder
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def serialize_state(self) -> str: # noqa: B027
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def deserialize_state(self, state: str):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
class SerializeStateSlots(SerializeState):
|
|
20
|
+
|
|
21
|
+
async def serialize_state(self) -> str: # noqa: B027
|
|
22
|
+
return json.dumps(
|
|
23
|
+
{slot: getattr(self, slot) for slot in self.__slots__},
|
|
24
|
+
cls=self.custom_json_encoder,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
async def deserialize_state(self, state: str):
|
|
28
|
+
state = json.loads(state, cls=self.custom_json_decoder)
|
|
29
|
+
for slot in self.__slots__:
|
|
30
|
+
setattr(self, slot, state.get(slot))
|