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.
Files changed (70) hide show
  1. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/PKG-INFO +1 -1
  2. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/__init__.py +1 -1
  3. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/route_viewset.py +7 -3
  4. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/redis.py +3 -4
  5. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mixins.py +132 -38
  6. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/.gitignore +0 -0
  7. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/LICENSE +0 -0
  8. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/README.md +0 -0
  9. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/action_configuration.py +0 -0
  10. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/backends/__init__.py +0 -0
  11. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/backends/django_orm.py +0 -0
  12. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/collection_viewset.py +0 -0
  13. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/conf.py +0 -0
  14. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/__init__.py +0 -0
  15. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
  16. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/django.py +0 -0
  17. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
  18. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/static.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/cursor.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/__init__.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/build_schema.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/conftest.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/endpoint_docs.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/error_namespace.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/error_responses.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/__init__.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/authorizers.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/changes.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/conftest.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/delivery.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/event.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/lifecycle.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/scopes.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/sources.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/store.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/topics.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/viewset.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/exceptions.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/__init__.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/base.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/declaration.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/operators.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/registry.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/list_query.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/list_shapes.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/__init__.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/idempotency.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/registry.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/server.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/py.typed +0 -0
  65. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/response_classes.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/routing.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/save_state/__init__.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/save_state/save_state.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.12.1 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  70. {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.12.1
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
@@ -1,4 +1,4 @@
1
- __version__ = "0.12.1"
1
+ __version__ = "0.13.1"
2
2
 
3
3
  from .error_namespace import ERROR_NAMESPACE, ERROR_NAMESPACE_PYDANTIC
4
4
 
@@ -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 LookupItem objects — useful for populating
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
- The mixin is generic over TLookupFilter (default: LookupFilter). When no type argument is
788
- given, the endpoint exposes a single 'q' query parameter and filter_lookup filters by
789
- case-insensitive title match. Supply a custom filter model as a type argument to add extra
790
- query parameters and override filter_lookup.
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
- When the filter is active (any field is non-None), the following two-phase pipeline runs:
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
- 1. Pre-filter: setup_lookup_filter is called before perform_lookup.
795
- Override to apply server-side filtering (e.g., narrow a DB query).
796
- 2. Post-filter: filter_lookup is called after perform_lookup.
797
- The default implementation filters by fltr.q (case-insensitive title match).
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) -> list[LookupItem]:
855
+ async def perform_lookup(self, context: Context) -> ListRecords:
805
856
  """
806
- Subclasses should implement this method to return lookup items.
807
- This method is intentionally NOT in ImplMixin because it's expected to be a simple
808
- transformation of perform_list.
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 setup_lookup_filter(self, fltr: TLookupFilter) -> None:
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
- Optional pre-filter hook called before perform_lookup when the filter is active.
815
- Subclasses can override to set up server-side filtering (e.g., build a DB query).
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
- async def filter_lookup(self, fltr: TLookupFilter, items: list[LookupItem]) -> list[LookupItem]:
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
- Post-filter hook called after perform_lookup when the filter is active.
821
- Default implementation filters by fltr.q (case-insensitive substring of title).
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
- return [item for item in items if q.lower() in item.title.lower()]
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: "ImplMixin[Any, LookupItem] | LookupMixin",
934
+ self: "LookupMixin",
833
935
  context: Context,
834
936
  fltr: Annotated[TLookupFilter, Query()] = None,
835
- ) -> list[LookupItem]:
836
- has_filter = (
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
  ###################################################################################################