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.
@@ -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))