dynamicforms-fastapi-viewsets 0.12.1__tar.gz → 0.13.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/PKG-INFO +1 -1
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/__init__.py +1 -1
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/route_viewset.py +7 -3
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/redis.py +3 -4
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mixins.py +132 -38
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/README.md +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/conf.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/build_schema.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/error_namespace.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/error_responses.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/authorizers.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/changes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/delivery.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/scopes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/sources.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/store.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/topics.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/exceptions.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/idempotency.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/asgi.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/routing.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.1
|
|
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
|
|
@@ -216,12 +216,14 @@ def route_viewset(
|
|
|
216
216
|
on a shared mixin cannot know which viewset it will end up on. Here it can: parameters
|
|
217
217
|
no allowed shape uses are dropped so they never reach the schema, the `X-List-Shape`
|
|
218
218
|
header is kept only when there is a choice to make, and the return annotation becomes
|
|
219
|
-
the union of exactly the models this viewset can produce.
|
|
219
|
+
the union of exactly the models this viewset can produce. A declared `default_sort` becomes
|
|
220
|
+
the `sort` parameter's default.
|
|
220
221
|
"""
|
|
221
222
|
from fastapi_viewsets.list_shapes import response_model, shape_examples, SHAPE_HEADER
|
|
222
|
-
from fastapi_viewsets.mixins import T as ItemVar
|
|
223
|
+
from fastapi_viewsets.mixins import check_declared_sort, T as ItemVar
|
|
223
224
|
|
|
224
225
|
default, allowed = cls.resolve_shapes()
|
|
226
|
+
item_type = type_map.get(ItemVar, ItemVar)
|
|
225
227
|
uses = {
|
|
226
228
|
"offset": "paginated" in allowed,
|
|
227
229
|
"cursor": "cursor" in allowed,
|
|
@@ -243,9 +245,11 @@ def route_viewset(
|
|
|
243
245
|
),
|
|
244
246
|
]
|
|
245
247
|
)
|
|
248
|
+
if name == "sort" and cls.default_sort is not None:
|
|
249
|
+
check_declared_sort(cls.default_sort, item_type)
|
|
250
|
+
parameter = parameter.replace(default=cls.default_sort)
|
|
246
251
|
params.append(parameter)
|
|
247
252
|
|
|
248
|
-
item_type = type_map.get(ItemVar, ItemVar)
|
|
249
253
|
nonlocal shape_openapi_extra
|
|
250
254
|
shape_openapi_extra = shape_examples(allowed, item_type)
|
|
251
255
|
return sig.replace(parameters=params, return_annotation=response_model(allowed, item_type))
|
|
@@ -102,16 +102,15 @@ class PerLoopRedis:
|
|
|
102
102
|
return client
|
|
103
103
|
|
|
104
104
|
async def _close_with_loop(self, loop: asyncio.AbstractEventLoop, client: "Redis") -> None:
|
|
105
|
+
# A task destroyed with its closed loop is finalised by the garbage collector, which can run
|
|
106
|
+
# inside `client()` while it holds `_lock`: taking the lock there deadlocks, so that loop's
|
|
107
|
+
# client is left to the next `client()` call.
|
|
105
108
|
try:
|
|
106
109
|
await loop.create_future()
|
|
107
110
|
except asyncio.CancelledError:
|
|
108
111
|
self._forget(loop, client)
|
|
109
112
|
await client.aclose()
|
|
110
113
|
raise
|
|
111
|
-
except GeneratorExit:
|
|
112
|
-
# The task is destroyed with its loop: there is no loop to close the client on.
|
|
113
|
-
self._forget(loop, client)
|
|
114
|
-
raise
|
|
115
114
|
|
|
116
115
|
def _forget(self, loop: asyncio.AbstractEventLoop, client: "Redis") -> None:
|
|
117
116
|
with self._lock:
|
|
@@ -201,6 +201,15 @@ class SortStateColumn(BaseModel):
|
|
|
201
201
|
SortState = list[SortStateColumn]
|
|
202
202
|
|
|
203
203
|
|
|
204
|
+
SORT_DESCRIPTION = (
|
|
205
|
+
"Ordering: comma-separated `column:direction` entries in priority order (`name:asc,score:desc`)."
|
|
206
|
+
" The direction is `asc` or `desc`, `asc` when omitted; an entry with any other direction is"
|
|
207
|
+
" ignored. Without the parameter the list is ordered by its default value, where the endpoint"
|
|
208
|
+
" declares one; an empty value leaves the list unordered."
|
|
209
|
+
)
|
|
210
|
+
"""The `sort` parameter's description on every list endpoint."""
|
|
211
|
+
|
|
212
|
+
|
|
204
213
|
def parse_sort_param(sort_csv: str | None) -> SortState:
|
|
205
214
|
"""
|
|
206
215
|
Parse the 'sort' query parameter (comma-separated) into a SortState.
|
|
@@ -227,6 +236,31 @@ def parse_sort_param(sort_csv: str | None) -> SortState:
|
|
|
227
236
|
return result
|
|
228
237
|
|
|
229
238
|
|
|
239
|
+
def check_declared_sort(sort_csv: str, model: Any = None) -> SortState:
|
|
240
|
+
"""
|
|
241
|
+
Parses a sort a viewset declares (`ListMixin.default_sort`) in the `sort` parameter's syntax.
|
|
242
|
+
|
|
243
|
+
Raises ValueError for an empty entry and an unrecognised direction, which `parse_sort_param`
|
|
244
|
+
skips in a request, and, when `model` is a pydantic model, for a column that is neither one of
|
|
245
|
+
its fields nor one of its computed fields.
|
|
246
|
+
"""
|
|
247
|
+
entries = [entry.strip() for entry in sort_csv.split(",")]
|
|
248
|
+
if not all(entries):
|
|
249
|
+
raise ValueError(f"default_sort {sort_csv!r} has an empty entry")
|
|
250
|
+
result: SortState = []
|
|
251
|
+
for entry in entries:
|
|
252
|
+
column, _, raw_direction = entry.rpartition(":")
|
|
253
|
+
if not column:
|
|
254
|
+
column, raw_direction = entry, SortDirection.asc.value
|
|
255
|
+
if raw_direction not in SortDirection.__members__:
|
|
256
|
+
raise ValueError(f"default_sort {sort_csv!r}: direction of {column!r} is not 'asc' or 'desc'")
|
|
257
|
+
fields = getattr(model, "model_fields", None)
|
|
258
|
+
if isinstance(fields, dict) and column not in fields and column not in model.model_computed_fields:
|
|
259
|
+
raise ValueError(f"default_sort {sort_csv!r}: {model.__name__} has no field {column!r}")
|
|
260
|
+
result.append(SortStateColumn(column_name=column, direction=SortDirection(raw_direction)))
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
230
264
|
###################################################################################################
|
|
231
265
|
# LIST
|
|
232
266
|
###################################################################################################
|
|
@@ -284,6 +318,17 @@ class ListMixin(Generic[T, TFilter], ABC):
|
|
|
284
318
|
what stops a cursor walking straight past the NULL rows.
|
|
285
319
|
"""
|
|
286
320
|
|
|
321
|
+
default_sort: str | None = None
|
|
322
|
+
"""
|
|
323
|
+
The ordering of a list request that sends no `sort` parameter, in that parameter's syntax:
|
|
324
|
+
`"name:asc"`, `"category:asc,score:desc"`. None leaves such a request unordered; so does an empty
|
|
325
|
+
`sort=` on a viewset that declares one.
|
|
326
|
+
|
|
327
|
+
`route_viewset` publishes it as the `sort` parameter's default in the schema, where the TS
|
|
328
|
+
client's `defaultSort()` reads it, and raises ValueError for an empty entry, an unrecognised
|
|
329
|
+
direction or a column the response model has neither a field nor a computed field for.
|
|
330
|
+
"""
|
|
331
|
+
|
|
287
332
|
@property
|
|
288
333
|
def nulls_first(self) -> bool:
|
|
289
334
|
return self.nulls != "last"
|
|
@@ -298,7 +343,7 @@ class ListMixin(Generic[T, TFilter], ABC):
|
|
|
298
343
|
self: "ImplMixin[Any, T] | ListMixin[T]",
|
|
299
344
|
context: Context,
|
|
300
345
|
fltr: Annotated[TFilter, Query()] = None,
|
|
301
|
-
sort: str | None = None,
|
|
346
|
+
sort: Annotated[str | None, Query(description=SORT_DESCRIPTION)] = None,
|
|
302
347
|
offset: Annotated[int, Query(ge=0)] = 0,
|
|
303
348
|
limit: Annotated[int | None, Query(ge=1)] = None,
|
|
304
349
|
cursor: str | None = None,
|
|
@@ -320,7 +365,7 @@ class ListMixin(Generic[T, TFilter], ABC):
|
|
|
320
365
|
|
|
321
366
|
query = build_list_query(
|
|
322
367
|
fltr,
|
|
323
|
-
sort,
|
|
368
|
+
self.default_sort if sort is None else sort,
|
|
324
369
|
offset=offset,
|
|
325
370
|
limit=(
|
|
326
371
|
None
|
|
@@ -775,73 +820,122 @@ class LookupFilter(BaseModel):
|
|
|
775
820
|
|
|
776
821
|
|
|
777
822
|
TLookupFilter = TypeVar("TLookupFilter", default=LookupFilter)
|
|
823
|
+
TLookupItem = TypeVar("TLookupItem", default=LookupItem)
|
|
778
824
|
|
|
779
825
|
|
|
780
|
-
class LookupMixin(Generic[TLookupFilter], ABC):
|
|
826
|
+
class LookupMixin(Generic[TLookupFilter, TLookupItem], ABC):
|
|
781
827
|
"""
|
|
782
828
|
Lookup endpoint with optional search filtering.
|
|
783
829
|
|
|
784
|
-
Adds a GET /lookup endpoint that returns a list of
|
|
785
|
-
select/autocomplete widgets.
|
|
830
|
+
Adds a GET /lookup endpoint that returns a list of TLookupItem objects (default: LookupItem) -
|
|
831
|
+
useful for populating select/autocomplete widgets.
|
|
832
|
+
|
|
833
|
+
The fields of TLookupFilter (default: LookupFilter, a single `q`) are the endpoint's query
|
|
834
|
+
parameters. The request runs through the same stages as a list:
|
|
786
835
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
836
|
+
1. `perform_lookup(context)` returns the source, lazily or not.
|
|
837
|
+
2. `apply_lookup_filter(context, query, records)` narrows it. Override it to filter in the
|
|
838
|
+
backend and mark the stage applied (`query.mark_applied("filter")`); the default filters in
|
|
839
|
+
memory: by the declaration of a filter model built with `make_filter_model`, otherwise
|
|
840
|
+
through `filter_lookup`.
|
|
841
|
+
3. `to_lookup_item(raw)` converts each remaining row to TLookupItem.
|
|
791
842
|
|
|
792
|
-
|
|
843
|
+
The filter travels in `query.fltr`, never on the viewset instance, which serves concurrent
|
|
844
|
+
requests under the default `singleton` lifecycle.
|
|
793
845
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
Override to customise in-memory filtering.
|
|
846
|
+
The endpoint answers in the order of the source; filtering and conversion keep it. The source
|
|
847
|
+
orders the items by group, then by their text, in its own collation - `order_by` in the
|
|
848
|
+
database - because a select shows each group's items together and only the source knows which
|
|
849
|
+
field the text comes from and how its language orders it.
|
|
799
850
|
"""
|
|
800
851
|
|
|
801
852
|
__router = APIRouter()
|
|
802
853
|
|
|
803
854
|
@abstractmethod
|
|
804
|
-
async def perform_lookup(self, context: Context) ->
|
|
855
|
+
async def perform_lookup(self, context: Context) -> ListRecords:
|
|
805
856
|
"""
|
|
806
|
-
Subclasses
|
|
807
|
-
|
|
808
|
-
|
|
857
|
+
Subclasses implement this to return the lookup source, in the order the endpoint answers
|
|
858
|
+
in: by group, then by text.
|
|
859
|
+
|
|
860
|
+
A lazy source - a generator, an unevaluated QuerySet - is what an overridden
|
|
861
|
+
`apply_lookup_filter` pushes the filter into. Rows are TLookupItem once `to_lookup_item` has
|
|
862
|
+
run over them.
|
|
809
863
|
"""
|
|
810
864
|
raise NotImplementedError("Method 'perform_lookup' must be implemented.")
|
|
811
865
|
|
|
812
|
-
async def
|
|
866
|
+
async def get_lookup(self, context: Context, query: ListQuery) -> list[TLookupItem]:
|
|
867
|
+
"""The lookup pipeline: source, filter, conversion. Overridable as a whole."""
|
|
868
|
+
records = await self.perform_lookup(context)
|
|
869
|
+
records = await self.apply_lookup_filter(context, query, records)
|
|
870
|
+
return await self.land_lookup(query, records)
|
|
871
|
+
|
|
872
|
+
async def apply_lookup_filter(
|
|
873
|
+
self,
|
|
874
|
+
context: Context, # noqa: ARG002 - part of the hook signature, for overrides to use
|
|
875
|
+
query: ListQuery,
|
|
876
|
+
records: ListRecords,
|
|
877
|
+
) -> ListRecords:
|
|
813
878
|
"""
|
|
814
|
-
|
|
815
|
-
|
|
879
|
+
Narrows `records` to those the filter accepts. Override to filter in the backend; mark the
|
|
880
|
+
stage applied so this default does not redo the work:
|
|
881
|
+
|
|
882
|
+
async def apply_lookup_filter(self, context, query, records):
|
|
883
|
+
if query.has_filter:
|
|
884
|
+
records = records.filter(pk__in=query.fltr.ids)
|
|
885
|
+
query.mark_applied("filter")
|
|
886
|
+
return await super().apply_lookup_filter(context, query, records)
|
|
887
|
+
|
|
888
|
+
The default filters TLookupItem records in memory: by the declaration of a filter model built
|
|
889
|
+
with `make_filter_model`, otherwise through the `filter_lookup` hook.
|
|
816
890
|
"""
|
|
891
|
+
if not query.has_filter or not query.needs("filter"):
|
|
892
|
+
return records
|
|
817
893
|
|
|
818
|
-
|
|
894
|
+
items = await self.land_lookup(query, records)
|
|
895
|
+
declared = filters_from(query.fltr)
|
|
896
|
+
if declared is not None:
|
|
897
|
+
return declared.apply(items)
|
|
898
|
+
filtered = await self.filter_lookup(query.fltr, items)
|
|
899
|
+
return items if filtered is None else filtered
|
|
900
|
+
|
|
901
|
+
async def filter_lookup(self, fltr: TLookupFilter, items: list[TLookupItem]) -> list[TLookupItem]:
|
|
819
902
|
"""
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
Subclasses can override to change the filtering behaviour.
|
|
903
|
+
In-memory filter hook of a hand-written filter model, called with the converted items.
|
|
904
|
+
The default filters by `fltr.q`: a case-insensitive substring of the item's `title`.
|
|
823
905
|
"""
|
|
824
906
|
q = getattr(fltr, "q", None)
|
|
825
907
|
if q is None:
|
|
826
908
|
return items
|
|
827
|
-
|
|
909
|
+
needle = q.lower()
|
|
910
|
+
return [item for item in items if needle in str(getattr(item, "title", "")).lower()]
|
|
911
|
+
|
|
912
|
+
def to_lookup_item(self, raw: Any) -> TLookupItem:
|
|
913
|
+
"""
|
|
914
|
+
Converts one row of the source into TLookupItem. The default is identity, for a source that
|
|
915
|
+
already yields TLookupItem.
|
|
916
|
+
"""
|
|
917
|
+
return raw
|
|
918
|
+
|
|
919
|
+
async def land_lookup(self, query: ListQuery, records: ListRecords) -> list[TLookupItem]:
|
|
920
|
+
"""
|
|
921
|
+
Materialises the source and converts it with `to_lookup_item`. Idempotent: the conversion
|
|
922
|
+
runs once per request, whichever stage lands the source first.
|
|
923
|
+
"""
|
|
924
|
+
materialized = await materialize(records)
|
|
925
|
+
if query.needs("conversion"):
|
|
926
|
+
query.mark_applied("conversion")
|
|
927
|
+
if type(self).to_lookup_item is not LookupMixin.to_lookup_item:
|
|
928
|
+
return [self.to_lookup_item(row) for row in materialized]
|
|
929
|
+
return materialized
|
|
828
930
|
|
|
829
931
|
@final
|
|
830
932
|
@__router.get("lookup")
|
|
831
933
|
async def lookup(
|
|
832
|
-
self: "
|
|
934
|
+
self: "LookupMixin",
|
|
833
935
|
context: Context,
|
|
834
936
|
fltr: Annotated[TLookupFilter, Query()] = None,
|
|
835
|
-
) -> list[
|
|
836
|
-
|
|
837
|
-
fltr is not None and hasattr(fltr, "model_dump") and any(v is not None for v in fltr.model_dump().values())
|
|
838
|
-
)
|
|
839
|
-
if has_filter:
|
|
840
|
-
await self.setup_lookup_filter(fltr)
|
|
841
|
-
res = await self.perform_lookup(context)
|
|
842
|
-
if has_filter:
|
|
843
|
-
res = await self.filter_lookup(fltr, res)
|
|
844
|
-
return res
|
|
937
|
+
) -> list[TLookupItem]:
|
|
938
|
+
return await self.get_lookup(context, build_list_query(fltr, None))
|
|
845
939
|
|
|
846
940
|
|
|
847
941
|
###################################################################################################
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/pyproject.toml
RENAMED
|
File without changes
|