drf-react-by-schema 0.28.0__tar.gz → 0.28.2__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 (51) hide show
  1. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/PKG-INFO +71 -1
  2. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/README.rst +70 -0
  3. drf_react_by_schema-0.28.2/drf_react_by_schema/filters.py +112 -0
  4. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/metadata.py +2 -1
  5. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/schema_bundle.py +6 -1
  6. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/serializers.py +90 -10
  7. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/utils.py +22 -0
  8. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/viewsets.py +51 -40
  9. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema.egg-info/PKG-INFO +71 -1
  10. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/setup.cfg +1 -1
  11. drf_react_by_schema-0.28.0/drf_react_by_schema/filters.py +0 -24
  12. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/LICENSE +0 -0
  13. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/MANIFEST.in +0 -0
  14. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/__init__.py +0 -0
  15. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/admin.py +0 -0
  16. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/apps.py +0 -0
  17. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/auth.py +0 -0
  18. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/autocomplete_serializers.py +0 -0
  19. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/autocomplete_viewsets.py +0 -0
  20. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/checks.py +0 -0
  21. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/date_utils.py +0 -0
  22. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/__init__.py +0 -0
  23. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/array.py +0 -0
  24. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/boolean.py +0 -0
  25. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/date.py +0 -0
  26. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/file.py +0 -0
  27. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/geometry.py +0 -0
  28. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/number.py +0 -0
  29. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/relational.py +0 -0
  30. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/serializers.py +0 -0
  31. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/fields/text.py +0 -0
  32. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/management/__init__.py +0 -0
  33. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/management/commands/__init__.py +0 -0
  34. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/management/commands/sync_react.py +0 -0
  35. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/migrations/__init__.py +0 -0
  36. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/mixins.py +0 -0
  37. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/models.py +0 -0
  38. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/pagination.py +0 -0
  39. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/patches/__init__.py +0 -0
  40. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/patches/drf_serializers.py +0 -0
  41. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/renderers.py +0 -0
  42. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/tests.py +0 -0
  43. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/typescript_generator.py +0 -0
  44. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/urls.py +0 -0
  45. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/utils_cache.py +0 -0
  46. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema/views.py +0 -0
  47. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema.egg-info/SOURCES.txt +0 -0
  48. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema.egg-info/dependency_links.txt +0 -0
  49. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema.egg-info/requires.txt +0 -0
  50. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/drf_react_by_schema.egg-info/top_level.txt +0 -0
  51. {drf_react_by_schema-0.28.0 → drf_react_by_schema-0.28.2}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: drf_react_by_schema
3
- Version: 0.28.0
3
+ Version: 0.28.2
4
4
  Summary: A Django app to easily integrate with React from models.
5
5
  Home-page: https://gitlab.com/eita/drf-react-by-schema/drf-react-by-schema-django
6
6
  Author: Cooperativa EITA
@@ -115,6 +115,43 @@ single release line. The supported combinations are exercised in CI (see
115
115
  drf-react-by-schema
116
116
  drf-excel<2.6
117
117
 
118
+ Django 3.2 is kept as **legacy support**: the 3.2-only pin above and its CI job
119
+ will be dropped when the supported floor moves to 4.2. New code should target
120
+ 4.2-5.2; Django-3.2-only branches must stay isolated so the migration is a single
121
+ clean-up pass.
122
+
123
+ Accent-insensitive search
124
+ -------------------------
125
+ On PostgreSQL with the ``unaccent`` extension enabled (and
126
+ ``django.contrib.postgres`` in ``INSTALLED_APPS``), replace DRF's search filter
127
+ with the package's::
128
+
129
+ REST_FRAMEWORK = {
130
+ "DEFAULT_FILTER_BACKENDS": [
131
+ "drf_react_by_schema.filters.DRFReactBySchemaSearchFilter",
132
+ "drf_react_by_schema.filters.DRFReactBySchemaOrderingFilter",
133
+ ],
134
+ }
135
+
136
+ ``unaccent`` is applied only to text fields; dates, numbers and booleans keep
137
+ DRF's default lookup, because ``unaccent`` raises on them.
138
+
139
+ The filter is **opt-in**: apps that keep DRF's ``SearchFilter`` are unaffected
140
+ and do not need the extension. It probes the backend, ``INSTALLED_APPS`` and the
141
+ database extension once per connection alias. If the extension is missing — or
142
+ the database is not PostgreSQL, or ``django.contrib.postgres`` is not installed
143
+ — it falls back to DRF's normal lookups: search keeps working (accent-sensitive)
144
+ instead of raising ``FieldError``. Call
145
+ ``drf_react_by_schema.filters.reset_unaccent_cache()`` if the extension is
146
+ installed while the process is running.
147
+
148
+ To create the extension, add a migration in the consuming app::
149
+
150
+ from django.contrib.postgres.operations import UnaccentExtension
151
+
152
+ class Migration(migrations.Migration):
153
+ operations = [UnaccentExtension()]
154
+
118
155
  Optional Dependencies
119
156
  ---------------------
120
157
  For enhanced caching capabilities in the automatically generated viewsets, you can install optional dependencies:
@@ -252,6 +289,39 @@ Quick start
252
289
 
253
290
  You can customize ``viewsets`` and ``serializers`` to annotate other attributes.
254
291
 
292
+ List vs. detail serializers
293
+ ---------------------------
294
+ The generated ``XSerializer`` is the canonical serializer and serves both ``retrieve``
295
+ and ``list``/``excel``. When a detail-only field would be expensive in lists, declare it
296
+ in ``Meta.list_exclude`` on the app's general serializer:
297
+
298
+ .. code-block:: python
299
+
300
+ from drf_react_by_schema.serializers import (
301
+ ClienteSerializer as BaseClienteSerializer,
302
+ )
303
+
304
+ class ClienteSerializer(BaseClienteSerializer):
305
+ contratacao = drf_serializers.SerializerMethodField() # detail only
306
+ nome = drf_serializers.CharField(read_only=True) # both
307
+
308
+ class Meta(BaseClienteSerializer.Meta):
309
+ list_exclude = ("contratacao",)
310
+
311
+ The package derives (and caches) a list projection that keeps every other field,
312
+ including future additions to the general serializer. ``list_exclude`` takes serializer
313
+ field names, not model ``source`` paths; ``()`` means "the list is identical to the
314
+ detail response". An explicit list serializer remains available as an escape hatch:
315
+
316
+ .. code-block:: python
317
+
318
+ class ClienteViewSet(GenericViewSet):
319
+ serializer_class = ClienteSerializer
320
+ serializer_list_class = ClienteListSerializer # optional, full override
321
+
322
+ ``XListSerializer`` classes are no longer generated automatically: the default is the
323
+ general serializer, so app-added fields can never silently disappear from lists.
324
+
255
325
  Global permission-aware queryset filtering
256
326
  ------------------------------------------
257
327
  Every generated viewset already supports a ``filter_by_callback`` (and a list-only
@@ -83,6 +83,43 @@ single release line. The supported combinations are exercised in CI (see
83
83
  drf-react-by-schema
84
84
  drf-excel<2.6
85
85
 
86
+ Django 3.2 is kept as **legacy support**: the 3.2-only pin above and its CI job
87
+ will be dropped when the supported floor moves to 4.2. New code should target
88
+ 4.2-5.2; Django-3.2-only branches must stay isolated so the migration is a single
89
+ clean-up pass.
90
+
91
+ Accent-insensitive search
92
+ -------------------------
93
+ On PostgreSQL with the ``unaccent`` extension enabled (and
94
+ ``django.contrib.postgres`` in ``INSTALLED_APPS``), replace DRF's search filter
95
+ with the package's::
96
+
97
+ REST_FRAMEWORK = {
98
+ "DEFAULT_FILTER_BACKENDS": [
99
+ "drf_react_by_schema.filters.DRFReactBySchemaSearchFilter",
100
+ "drf_react_by_schema.filters.DRFReactBySchemaOrderingFilter",
101
+ ],
102
+ }
103
+
104
+ ``unaccent`` is applied only to text fields; dates, numbers and booleans keep
105
+ DRF's default lookup, because ``unaccent`` raises on them.
106
+
107
+ The filter is **opt-in**: apps that keep DRF's ``SearchFilter`` are unaffected
108
+ and do not need the extension. It probes the backend, ``INSTALLED_APPS`` and the
109
+ database extension once per connection alias. If the extension is missing — or
110
+ the database is not PostgreSQL, or ``django.contrib.postgres`` is not installed
111
+ — it falls back to DRF's normal lookups: search keeps working (accent-sensitive)
112
+ instead of raising ``FieldError``. Call
113
+ ``drf_react_by_schema.filters.reset_unaccent_cache()`` if the extension is
114
+ installed while the process is running.
115
+
116
+ To create the extension, add a migration in the consuming app::
117
+
118
+ from django.contrib.postgres.operations import UnaccentExtension
119
+
120
+ class Migration(migrations.Migration):
121
+ operations = [UnaccentExtension()]
122
+
86
123
  Optional Dependencies
87
124
  ---------------------
88
125
  For enhanced caching capabilities in the automatically generated viewsets, you can install optional dependencies:
@@ -220,6 +257,39 @@ Quick start
220
257
 
221
258
  You can customize ``viewsets`` and ``serializers`` to annotate other attributes.
222
259
 
260
+ List vs. detail serializers
261
+ ---------------------------
262
+ The generated ``XSerializer`` is the canonical serializer and serves both ``retrieve``
263
+ and ``list``/``excel``. When a detail-only field would be expensive in lists, declare it
264
+ in ``Meta.list_exclude`` on the app's general serializer:
265
+
266
+ .. code-block:: python
267
+
268
+ from drf_react_by_schema.serializers import (
269
+ ClienteSerializer as BaseClienteSerializer,
270
+ )
271
+
272
+ class ClienteSerializer(BaseClienteSerializer):
273
+ contratacao = drf_serializers.SerializerMethodField() # detail only
274
+ nome = drf_serializers.CharField(read_only=True) # both
275
+
276
+ class Meta(BaseClienteSerializer.Meta):
277
+ list_exclude = ("contratacao",)
278
+
279
+ The package derives (and caches) a list projection that keeps every other field,
280
+ including future additions to the general serializer. ``list_exclude`` takes serializer
281
+ field names, not model ``source`` paths; ``()`` means "the list is identical to the
282
+ detail response". An explicit list serializer remains available as an escape hatch:
283
+
284
+ .. code-block:: python
285
+
286
+ class ClienteViewSet(GenericViewSet):
287
+ serializer_class = ClienteSerializer
288
+ serializer_list_class = ClienteListSerializer # optional, full override
289
+
290
+ ``XListSerializer`` classes are no longer generated automatically: the default is the
291
+ general serializer, so app-added fields can never silently disappear from lists.
292
+
223
293
  Global permission-aware queryset filtering
224
294
  ------------------------------------------
225
295
  Every generated viewset already supports a ``filter_by_callback`` (and a list-only
@@ -0,0 +1,112 @@
1
+ from django.apps import apps
2
+ from django.db import connections
3
+ from django.db.models import CharField, TextField
4
+ from rest_framework.filters import OrderingFilter, SearchFilter
5
+
6
+ from . import utils
7
+
8
+ # Only these field types support the unaccent lookup.
9
+ TEXT_FIELD_CLASSES = (CharField, TextField)
10
+
11
+ # DRF lookups the filter converts to their unaccent-aware counterparts.
12
+ UNACCENT_LOOKUPS = ("icontains", "istartswith", "iexact")
13
+
14
+ # Per-database result of the unaccent probe (one query per alias per process).
15
+ _UNACCENT_AVAILABLE = {}
16
+
17
+
18
+ def reset_unaccent_cache():
19
+ """Forget the unaccent probe, e.g. after installing the extension."""
20
+ _UNACCENT_AVAILABLE.clear()
21
+
22
+
23
+ def unaccent_available(connection):
24
+ """Whether ``connection`` can run the ``unaccent`` lookup.
25
+
26
+ False on non-PostgreSQL backends, without ``django.contrib.postgres`` (which
27
+ registers the lookup), or when the extension is missing from the database.
28
+ """
29
+ if connection.vendor != "postgresql":
30
+ return False
31
+ if not apps.is_installed("django.contrib.postgres"):
32
+ return False
33
+ alias = connection.alias
34
+ if alias not in _UNACCENT_AVAILABLE:
35
+ try:
36
+ with connection.cursor() as cursor:
37
+ cursor.execute(
38
+ "SELECT 1 FROM pg_extension WHERE extname = 'unaccent'"
39
+ )
40
+ _UNACCENT_AVAILABLE[alias] = cursor.fetchone() is not None
41
+ except Exception:
42
+ _UNACCENT_AVAILABLE[alias] = False
43
+ return _UNACCENT_AVAILABLE[alias]
44
+
45
+
46
+ class DRFReactBySchemaSearchFilter(SearchFilter):
47
+ """``SearchFilter`` with accent-insensitive matches on text fields.
48
+
49
+ Non-text fields keep DRF's default lookup: ``unaccent`` only accepts text,
50
+ so applying it to a date, number or boolean raises ``FieldError``.
51
+
52
+ Accent-insensitive search needs PostgreSQL with the ``unaccent`` extension
53
+ enabled and ``django.contrib.postgres`` in ``INSTALLED_APPS``. When either
54
+ is missing (or the database is not PostgreSQL), the filter silently falls
55
+ back to DRF's lookups, so search still works (accent-sensitive).
56
+ """
57
+
58
+ # Resolved per request by ``filter_queryset``; None on direct calls.
59
+ _unaccent_enabled = None
60
+
61
+ def filter_queryset(self, request, queryset, view):
62
+ self._unaccent_enabled = unaccent_available(connections[queryset.db])
63
+ return super().filter_queryset(request, queryset, view)
64
+
65
+ def construct_search(self, field_name, queryset):
66
+ lookup = super().construct_search(field_name, queryset)
67
+ unaccent_enabled = self._unaccent_enabled
68
+ if unaccent_enabled is None:
69
+ unaccent_enabled = unaccent_available(connections[queryset.db])
70
+ if not unaccent_enabled:
71
+ return lookup
72
+ if not self._is_text_field(queryset.model, field_name):
73
+ return lookup
74
+ for standard_lookup in UNACCENT_LOOKUPS:
75
+ if lookup.endswith(f"__{standard_lookup}"):
76
+ return lookup[: -len(standard_lookup)] + f"unaccent__{standard_lookup}"
77
+ return lookup
78
+
79
+ @staticmethod
80
+ def _is_text_field(model, field_path):
81
+ """Whether the terminal field in ``field_path`` is a text field."""
82
+ # Strip DRF prefix modifiers: ^, =, @, $
83
+ parts = str(field_path).lstrip("^=@$").split("__")
84
+ try:
85
+ for part in parts[:-1]:
86
+ model = model._meta.get_field(part).related_model
87
+ return isinstance(model._meta.get_field(parts[-1]), TEXT_FIELD_CLASSES)
88
+ except Exception:
89
+ # Unknown paths and non-relational lookups keep the standard lookup.
90
+ return False
91
+
92
+
93
+ class DRFReactBySchemaOrderingFilter(OrderingFilter):
94
+ def get_ordering(self, request, queryset, view):
95
+ """
96
+ Ordering is set by a comma delimited ?ordering=... query parameter.
97
+
98
+ The `ordering` query parameter can be overridden by setting
99
+ the `ordering_param` value on the OrderingFilter or by
100
+ specifying an `ORDERING_PARAM` value in the API settings.
101
+ """
102
+ params = request.query_params.get(self.ordering_param)
103
+ if params:
104
+ fields_raw = [param.strip() for param in params.split(",")]
105
+ fields = utils.serializer_to_model_fields(view, fields_raw)
106
+ if fields is not None:
107
+ ordering = self.remove_invalid_fields(queryset, fields, view, request)
108
+ if ordering:
109
+ return ordering
110
+
111
+ # No ordering was included, or all the ordering fields were invalid
112
+ return self.get_default_ordering(view)
@@ -15,7 +15,8 @@ class _PinnedSerializerClass:
15
15
 
16
16
  ``SimpleMetadata.determine_metadata`` derives the POST fields from
17
17
  ``view.get_serializer()``, which is action-dependent (``list``/``excel``
18
- use ``serializer_list_class``). Pinning keeps the schema canonical.
18
+ may use an explicit list serializer or a ``Meta.list_exclude`` projection).
19
+ Pinning keeps the schema canonical.
19
20
  """
20
21
 
21
22
  def __init__(self, view, serializer_class):
@@ -53,6 +53,7 @@ from .utils import (
53
53
  DATE_FILTER_OPERATORS,
54
54
  GRID_FILTER_OPERATORS,
55
55
  camel_to_snake,
56
+ is_searchable_model_field,
56
57
  sum_rows_capabilities,
57
58
  )
58
59
 
@@ -543,7 +544,11 @@ def _search_ordering_fields(view_instance) -> tuple[list, list]:
543
544
  except Exception:
544
545
  return [], []
545
546
  return (
546
- [str(field) for field in (view_instance.search_fields or [])],
547
+ [
548
+ str(field)
549
+ for field in (view_instance.search_fields or [])
550
+ if is_searchable_model_field(view_instance, str(field))
551
+ ],
547
552
  [str(field) for field in (view_instance.ordering_fields or [])],
548
553
  )
549
554
 
@@ -1,8 +1,11 @@
1
1
  import traceback
2
+ from functools import lru_cache
3
+
2
4
  from django.apps import apps
3
5
  from django.contrib.auth import get_user_model
4
6
  from django.contrib.auth import password_validation
5
7
  from django.contrib.auth.tokens import default_token_generator
8
+ from django.core.exceptions import ImproperlyConfigured
6
9
  from django.core.exceptions import ValidationError as DjangoValidationError
7
10
  from django.utils.encoding import force_str
8
11
  from django.utils.http import urlsafe_base64_decode
@@ -148,6 +151,11 @@ class GenericSerializerMetaclass(SerializerMetaclass):
148
151
  # First, let DRF's SerializerMetaclass do its work
149
152
  new_class = super().__new__(mcs, name, bases, attrs)
150
153
 
154
+ # List projections inherit the detail serializer's related-field setup;
155
+ # re-running it would rebuild update_related/model_fields unnecessarily.
156
+ if attrs.get("_drbs_projection"):
157
+ return new_class
158
+
151
159
  # Only process if this class has a Meta with a model
152
160
  if hasattr(new_class, "Meta") and hasattr(new_class.Meta, "model"):
153
161
  mcs._populate_related_fields(new_class)
@@ -494,6 +502,88 @@ class GenericSerializer(
494
502
  fields = "__all__"
495
503
 
496
504
 
505
+ class _ListProjectionMixin:
506
+ """Drop the detail-only fields from a projected list serializer."""
507
+
508
+ def get_fields(self):
509
+ fields = super().get_fields()
510
+ for name in getattr(self.Meta, "list_exclude", ()) or ():
511
+ fields.pop(name, None)
512
+ return fields
513
+
514
+
515
+ def _known_serializer_field_names(detail_class):
516
+ """Field names that ``Meta.list_exclude`` may legitimately reference."""
517
+ known = set(getattr(detail_class, "_declared_fields", {}) or {})
518
+ meta = getattr(detail_class, "Meta", None)
519
+ model = getattr(meta, "model", None)
520
+ if model is not None:
521
+ known.update(field.name for field in model._meta.get_fields())
522
+ meta_fields = getattr(meta, "fields", None)
523
+ if isinstance(meta_fields, (list, tuple)):
524
+ known.update(meta_fields)
525
+ elif isinstance(meta_fields, dict):
526
+ known.update(meta_fields)
527
+ return known
528
+
529
+
530
+ def _list_serializer_class_name(detail_class):
531
+ suffix = "Serializer"
532
+ name = detail_class.__name__
533
+ base = name[: -len(suffix)] if name.endswith(suffix) else name
534
+ return f"{base}ListSerializer"
535
+
536
+
537
+ @lru_cache(maxsize=None)
538
+ def _build_list_projection(detail_class, excludes):
539
+ meta = type("Meta", (detail_class.Meta,), {"list_exclude": excludes})
540
+ return type(
541
+ _list_serializer_class_name(detail_class),
542
+ (_ListProjectionMixin, detail_class),
543
+ {
544
+ "Meta": meta,
545
+ "__module__": detail_class.__module__,
546
+ "_drbs_projection": True,
547
+ },
548
+ )
549
+
550
+
551
+ def list_serializer_for(detail_class):
552
+ """List serializer derived from ``detail_class``, or ``None``.
553
+
554
+ The general serializer is the canonical shape and is used for list/excel by
555
+ default. Setting ``Meta.list_exclude`` opts into a lighter list that keeps
556
+ every other field (including app-added ones) and drops the listed names.
557
+ """
558
+ if detail_class is None:
559
+ return None
560
+ meta = getattr(detail_class, "Meta", None)
561
+ excludes = getattr(meta, "list_exclude", None)
562
+ if excludes is None:
563
+ return None
564
+ if isinstance(excludes, str) or not isinstance(
565
+ excludes, (list, tuple, set, frozenset)
566
+ ):
567
+ raise ImproperlyConfigured(
568
+ f"{detail_class.__name__}.Meta.list_exclude must be a list or tuple "
569
+ "of serializer field names."
570
+ )
571
+ if isinstance(excludes, (set, frozenset)):
572
+ excludes = tuple(sorted(excludes))
573
+ else:
574
+ excludes = tuple(excludes)
575
+ if not excludes:
576
+ return None
577
+
578
+ unknown = set(excludes) - _known_serializer_field_names(detail_class)
579
+ if unknown:
580
+ raise ImproperlyConfigured(
581
+ f"{detail_class.__name__}.Meta.list_exclude references unknown "
582
+ f"field(s): {', '.join(sorted(unknown))}."
583
+ )
584
+ return _build_list_projection(detail_class, excludes)
585
+
586
+
497
587
  ######################
498
588
 
499
589
 
@@ -501,19 +591,9 @@ for app in settings["APPS"]:
501
591
  for name, model in apps.all_models[app].items():
502
592
  model_name = model.__name__
503
593
  if isinstance(model, type):
504
- serializer_list_name = f"{model_name}ListSerializer"
505
594
  serializer_name = f"{model_name}Serializer"
506
595
  if not serializer_name in dir():
507
596
  meta = type("Meta", (), {"fields": "__all__", "model": model})
508
- generated_class = type(
509
- serializer_list_name,
510
- (GenericSerializer,),
511
- {
512
- "Meta": meta,
513
- },
514
- )
515
- globals()[serializer_list_name] = generated_class
516
-
517
597
  generated_class = type(
518
598
  serializer_name, (GenericSerializer,), {"Meta": meta}
519
599
  )
@@ -159,6 +159,17 @@ def to_pascal_case(s):
159
159
  return s.replace("_", " ").title().replace(" ", "")
160
160
 
161
161
 
162
+ def list_serializer_name(serializer_name):
163
+ """Conventional list-serializer name for a detail serializer class name."""
164
+ suffix = "Serializer"
165
+ base = (
166
+ serializer_name[: -len(suffix)]
167
+ if serializer_name.endswith(suffix)
168
+ else serializer_name
169
+ )
170
+ return f"{base}ListSerializer"
171
+
172
+
162
173
  def get_target_field(view, field, prefix=None):
163
174
  field_name = getattr(field, "name", None)
164
175
  related_model = getattr(field, "related_model", None)
@@ -321,6 +332,17 @@ def _resolve_model_field(view, path):
321
332
  return field
322
333
 
323
334
 
335
+ def is_searchable_model_field(view, path):
336
+ """Whether ``path`` accepts DRF's default ``icontains`` search lookup.
337
+
338
+ A path ending on a relation (FK, reverse or m2m) has no ``icontains``
339
+ lookup: putting it in ``search_fields`` makes every ``?search=`` request
340
+ raise ``FieldError``. Unknown paths (e.g. annotations) are left untouched.
341
+ """
342
+ field = _resolve_model_field(view, path)
343
+ return field is None or not field.is_relation
344
+
345
+
324
346
  def _is_non_string_field(view, target_field, column_field_serializer):
325
347
  """Whether the grid filter must treat the target as non-string.
326
348
 
@@ -78,53 +78,66 @@ class DRFReactBySchemaBaseModelViewSet(
78
78
 
79
79
  def __init__(self, *args, **kwargs):
80
80
  super().__init__(*args, **kwargs)
81
- if not self.model:
81
+ if not self.model and self.model_name:
82
82
  self.model = utils.get_model(self.app, self.model_name)
83
83
  if self.app and self.serializer_class is not None:
84
- threshold = 2 if self.serializer_list_class else 1
85
- found = 0
84
+ module_name = (
85
+ "autocomplete_serializers"
86
+ if self.is_autocomplete
87
+ else "serializers"
88
+ )
86
89
  try:
87
90
  app_serializers = importlib.import_module(
88
- f"{self.app}.serializers"
89
- if not self.is_autocomplete
90
- else f"{self.app}.autocomplete_serializers"
91
+ f"{self.app}.{module_name}"
91
92
  )
92
- except:
93
+ except ImportError:
93
94
  return
94
95
 
95
- for app_serializer_name, app_serializer in inspect.getmembers(
96
- app_serializers, predicate=inspect.isclass
97
- ):
98
- if app_serializer_name == self.serializer_class.__name__:
99
- self.serializer_class = app_serializer
100
- found += 1
101
-
102
- if (
103
- self.serializer_list_class
104
- and app_serializer_name == self.serializer_list_class.__name__
105
- ):
106
- self.serializer_list_class = app_serializer
107
- found += 1
96
+ self.serializer_class = getattr(
97
+ app_serializers,
98
+ self.serializer_class.__name__,
99
+ self.serializer_class,
100
+ )
108
101
 
109
- if found == threshold:
110
- break
102
+ # The list serializer is optional: list/excel default to the general
103
+ # serializer, and an app-defined ``XListSerializer`` is honored when
104
+ # the viewset does not point at one explicitly.
105
+ list_name = (
106
+ self.serializer_list_class.__name__
107
+ if self.serializer_list_class is not None
108
+ else utils.list_serializer_name(self.serializer_class.__name__)
109
+ )
110
+ resolved_list_class = getattr(app_serializers, list_name, None)
111
+ if resolved_list_class is not None:
112
+ self.serializer_list_class = resolved_list_class
111
113
 
112
114
  def get_schema_serializer_class(self):
113
115
  """Serializer describing the model shape (OPTIONS/TS generation).
114
116
 
115
117
  Always the canonical ``serializer_class``: ``get_serializer_class`` may
116
- return the lighter ``serializer_list_class`` for ``list``/``excel``,
117
- but the schema must not change with the action.
118
+ return an explicit list serializer or a ``Meta.list_exclude`` projection
119
+ for ``list``/``excel``, but the schema must not change with the action.
118
120
  """
119
121
  return self.serializer_class
120
122
 
121
- def get_serializer_class(self):
122
- """Use ``serializer_list_class`` for ``list``/``excel``, if defined."""
123
- if (
124
- getattr(self, "action", None) in ("list", "excel")
125
- and self.serializer_list_class
126
- ):
123
+ def get_list_serializer_class(self):
124
+ """Serializer used by ``list``/``excel``.
125
+
126
+ An explicit ``serializer_list_class`` wins; otherwise a
127
+ ``Meta.list_exclude`` projection of the general serializer is used; when
128
+ neither exists the general serializer itself serves the list.
129
+ """
130
+ if self.serializer_list_class is not None:
127
131
  return self.serializer_list_class
132
+ return (
133
+ serializers.list_serializer_for(self.serializer_class)
134
+ or self.serializer_class
135
+ )
136
+
137
+ def get_serializer_class(self):
138
+ """Use the list serializer for ``list``/``excel``, if one applies."""
139
+ if getattr(self, "action", None) in ("list", "excel"):
140
+ return self.get_list_serializer_class()
128
141
  return super().get_serializer_class()
129
142
 
130
143
  def resolve_filter_callback(self, callback_attr):
@@ -537,8 +550,14 @@ class DRFReactBySchemaBaseModelViewSet(
537
550
  self, list(serializer_fields.keys())
538
551
  )
539
552
 
540
- if not self.search_fields:
541
- self.search_fields = target_fields
553
+ if not self.search_fields and target_fields:
554
+ # Relation-terminated paths have no ``icontains`` lookup; DRF
555
+ # would raise on every ``?search=``. Ordering them is valid.
556
+ self.search_fields = [
557
+ target_field
558
+ for target_field in target_fields
559
+ if utils.is_searchable_model_field(self, target_field)
560
+ ]
542
561
  if not self.ordering_fields:
543
562
  self.ordering_fields = target_fields
544
563
 
@@ -791,9 +810,6 @@ for app in settings["APPS"]:
791
810
  "serializer_class": getattr(
792
811
  serializers, f"{model_name}Serializer"
793
812
  ),
794
- "serializer_list_class": getattr(
795
- serializers, f"{model_name}ListSerializer"
796
- ),
797
813
  },
798
814
  )
799
815
  globals()[viewset_name] = generated_class
@@ -824,11 +840,6 @@ for app in settings["APPS"]:
824
840
  "serializer_class": getattr(
825
841
  serializers, f"{related_model_name}Serializer"
826
842
  ),
827
- "serializer_list_class": getattr(
828
- serializers,
829
- f"{related_model_name}ListSerializer",
830
- None,
831
- ),
832
843
  "related_name": field.name,
833
844
  "many": True,
834
845
  "parent_model": model,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: drf-react-by-schema
3
- Version: 0.28.0
3
+ Version: 0.28.2
4
4
  Summary: A Django app to easily integrate with React from models.
5
5
  Home-page: https://gitlab.com/eita/drf-react-by-schema/drf-react-by-schema-django
6
6
  Author: Cooperativa EITA
@@ -115,6 +115,43 @@ single release line. The supported combinations are exercised in CI (see
115
115
  drf-react-by-schema
116
116
  drf-excel<2.6
117
117
 
118
+ Django 3.2 is kept as **legacy support**: the 3.2-only pin above and its CI job
119
+ will be dropped when the supported floor moves to 4.2. New code should target
120
+ 4.2-5.2; Django-3.2-only branches must stay isolated so the migration is a single
121
+ clean-up pass.
122
+
123
+ Accent-insensitive search
124
+ -------------------------
125
+ On PostgreSQL with the ``unaccent`` extension enabled (and
126
+ ``django.contrib.postgres`` in ``INSTALLED_APPS``), replace DRF's search filter
127
+ with the package's::
128
+
129
+ REST_FRAMEWORK = {
130
+ "DEFAULT_FILTER_BACKENDS": [
131
+ "drf_react_by_schema.filters.DRFReactBySchemaSearchFilter",
132
+ "drf_react_by_schema.filters.DRFReactBySchemaOrderingFilter",
133
+ ],
134
+ }
135
+
136
+ ``unaccent`` is applied only to text fields; dates, numbers and booleans keep
137
+ DRF's default lookup, because ``unaccent`` raises on them.
138
+
139
+ The filter is **opt-in**: apps that keep DRF's ``SearchFilter`` are unaffected
140
+ and do not need the extension. It probes the backend, ``INSTALLED_APPS`` and the
141
+ database extension once per connection alias. If the extension is missing — or
142
+ the database is not PostgreSQL, or ``django.contrib.postgres`` is not installed
143
+ — it falls back to DRF's normal lookups: search keeps working (accent-sensitive)
144
+ instead of raising ``FieldError``. Call
145
+ ``drf_react_by_schema.filters.reset_unaccent_cache()`` if the extension is
146
+ installed while the process is running.
147
+
148
+ To create the extension, add a migration in the consuming app::
149
+
150
+ from django.contrib.postgres.operations import UnaccentExtension
151
+
152
+ class Migration(migrations.Migration):
153
+ operations = [UnaccentExtension()]
154
+
118
155
  Optional Dependencies
119
156
  ---------------------
120
157
  For enhanced caching capabilities in the automatically generated viewsets, you can install optional dependencies:
@@ -252,6 +289,39 @@ Quick start
252
289
 
253
290
  You can customize ``viewsets`` and ``serializers`` to annotate other attributes.
254
291
 
292
+ List vs. detail serializers
293
+ ---------------------------
294
+ The generated ``XSerializer`` is the canonical serializer and serves both ``retrieve``
295
+ and ``list``/``excel``. When a detail-only field would be expensive in lists, declare it
296
+ in ``Meta.list_exclude`` on the app's general serializer:
297
+
298
+ .. code-block:: python
299
+
300
+ from drf_react_by_schema.serializers import (
301
+ ClienteSerializer as BaseClienteSerializer,
302
+ )
303
+
304
+ class ClienteSerializer(BaseClienteSerializer):
305
+ contratacao = drf_serializers.SerializerMethodField() # detail only
306
+ nome = drf_serializers.CharField(read_only=True) # both
307
+
308
+ class Meta(BaseClienteSerializer.Meta):
309
+ list_exclude = ("contratacao",)
310
+
311
+ The package derives (and caches) a list projection that keeps every other field,
312
+ including future additions to the general serializer. ``list_exclude`` takes serializer
313
+ field names, not model ``source`` paths; ``()`` means "the list is identical to the
314
+ detail response". An explicit list serializer remains available as an escape hatch:
315
+
316
+ .. code-block:: python
317
+
318
+ class ClienteViewSet(GenericViewSet):
319
+ serializer_class = ClienteSerializer
320
+ serializer_list_class = ClienteListSerializer # optional, full override
321
+
322
+ ``XListSerializer`` classes are no longer generated automatically: the default is the
323
+ general serializer, so app-added fields can never silently disappear from lists.
324
+
255
325
  Global permission-aware queryset filtering
256
326
  ------------------------------------------
257
327
  Every generated viewset already supports a ``filter_by_callback`` (and a list-only
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = drf_react_by_schema
3
- version = 0.28.0
3
+ version = 0.28.2
4
4
  description = A Django app to easily integrate with React from models.
5
5
  long_description = file: README.rst
6
6
  url = https://gitlab.com/eita/drf-react-by-schema/drf-react-by-schema-django
@@ -1,24 +0,0 @@
1
- from rest_framework.filters import OrderingFilter, SearchFilter
2
- from . import utils
3
-
4
-
5
- class DRFReactBySchemaOrderingFilter(OrderingFilter):
6
- def get_ordering(self, request, queryset, view):
7
- """
8
- Ordering is set by a comma delimited ?ordering=... query parameter.
9
-
10
- The `ordering` query parameter can be overridden by setting
11
- the `ordering_param` value on the OrderingFilter or by
12
- specifying an `ORDERING_PARAM` value in the API settings.
13
- """
14
- params = request.query_params.get(self.ordering_param)
15
- if params:
16
- fields_raw = [param.strip() for param in params.split(",")]
17
- fields = utils.serializer_to_model_fields(view, fields_raw)
18
- if fields is not None:
19
- ordering = self.remove_invalid_fields(queryset, fields, view, request)
20
- if ordering:
21
- return ordering
22
-
23
- # No ordering was included, or all the ordering fields were invalid
24
- return self.get_default_ordering(view)