drf-react-by-schema 0.27.0__tar.gz → 0.27.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 (48) hide show
  1. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/PKG-INFO +31 -5
  2. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/README.rst +30 -4
  3. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/array.py +5 -1
  4. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/boolean.py +5 -1
  5. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/date.py +14 -2
  6. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/file.py +6 -0
  7. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/geometry.py +5 -1
  8. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/number.py +6 -0
  9. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/relational.py +9 -0
  10. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/serializers.py +10 -1
  11. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/text.py +13 -2
  12. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/schema_bundle.py +108 -3
  13. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/tests.py +117 -0
  14. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/views.py +5 -0
  15. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema.egg-info/PKG-INFO +31 -5
  16. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/setup.cfg +1 -1
  17. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/LICENSE +0 -0
  18. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/MANIFEST.in +0 -0
  19. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/__init__.py +0 -0
  20. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/admin.py +0 -0
  21. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/apps.py +0 -0
  22. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/auth.py +0 -0
  23. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/autocomplete_serializers.py +0 -0
  24. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/autocomplete_viewsets.py +0 -0
  25. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/fields/__init__.py +0 -0
  26. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/filters.py +0 -0
  27. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/management/__init__.py +0 -0
  28. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/management/commands/__init__.py +0 -0
  29. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/management/commands/sync_react.py +0 -0
  30. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/metadata.py +0 -0
  31. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/migrations/__init__.py +0 -0
  32. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/mixins.py +0 -0
  33. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/models.py +0 -0
  34. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/pagination.py +0 -0
  35. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/patches/__init__.py +0 -0
  36. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/patches/drf_serializers.py +0 -0
  37. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/renderers.py +0 -0
  38. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/serializers.py +0 -0
  39. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/typescript_generator.py +0 -0
  40. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/urls.py +0 -0
  41. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/utils.py +0 -0
  42. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/utils_cache.py +0 -0
  43. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema/viewsets.py +0 -0
  44. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema.egg-info/SOURCES.txt +0 -0
  45. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema.egg-info/dependency_links.txt +0 -0
  46. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema.egg-info/requires.txt +0 -0
  47. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.2}/drf_react_by_schema.egg-info/top_level.txt +0 -0
  48. {drf_react_by_schema-0.27.0 → drf_react_by_schema-0.27.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.27.0
3
+ Version: 0.27.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
@@ -317,16 +317,42 @@ object with:
317
317
  version changes;
318
318
  * ``drbs_version`` — the installed ``drf-react-by-schema`` version;
319
319
  * ``system`` — apps, ``AUTH_USER_MODEL``, the ``DRF_REACT_BY_SCHEMA`` summary,
320
- static permission hints (DRF defaults, global, per-model) and request
320
+ static permission hints (DRF defaults, global, per-model), request
321
321
  ``conventions`` (API base, pagination, ``ordering``/``search``/``sum_rows``
322
- params, grid-filter operators);
322
+ params, grid-filter operators) and ``about`` (the project's ``ABOUT.md``,
323
+ trimmed — see below);
323
324
  * ``endpoints`` — every generated route (list and nested) with its
324
325
  ``path``/``kind``/``verbose_name``/``parent_model``/``related_model``;
325
326
  * ``models`` — each model's field ``schema`` (the same ``OPTIONS`` POST payload
326
- the React app consumes), verbose names and generated TypeScript
327
- ``interface``;
327
+ the React app consumes), verbose names, generated TypeScript ``interface`` and
328
+ the model class ``docstring`` (trimmed);
328
329
  * ``interfaces`` and ``enums`` — the generated TypeScript declarations.
329
330
 
331
+ Documentation in the bundle
332
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
333
+ Free-form, non-UI documentation is folded into the bundle so an assistant can
334
+ *understand* the system. It is kept out of the React artifacts on purpose:
335
+ ``sync_react``'s ``schemas/*.json`` stay **byte-identical** to before.
336
+
337
+ * **Model docstrings** — a model class's *own* docstring (inherited docs are
338
+ ignored) is reported as ``models[<Model>].docstring``, trimmed at a word
339
+ boundary to ``schema_bundle.MODEL_DOCSTRING_MAX`` characters.
340
+ * **Field descriptions** — pass ``description="..."`` to any package field
341
+ (``CharField``, ``ForeignKey``, ``DecimalField``, ...); it is reported as
342
+ ``models[<Model>].schema[<field>].description`` (trimmed to
343
+ ``FIELD_DESCRIPTION_MAX``). This is **not** ``help_text``: ``help_text`` is
344
+ rendered to end users by ``drf-react-by-schema-js``, while ``description``
345
+ documents the field for the API client / assistant. Only values passed
346
+ explicitly are reported — the field type's own ``description`` is never.
347
+ * **Project ``ABOUT.md``** — a project-root ``ABOUT.md`` (``settings.BASE_DIR``)
348
+ is reported as ``system.about`` (trimmed to ``ABOUT_MAX``). Override the path
349
+ with ``DRF_REACT_BY_SCHEMA['ABOUT_PATH']``; a missing file yields ``null``.
350
+ Use it for *what the project is about* — not deploy/contribute instructions.
351
+
352
+ All three feed the ``stamp``, so changing them invalidates an MCP client's
353
+ cached copy; because the bundle is cached per process, a change also needs a
354
+ process restart to be picked up.
355
+
330
356
  The endpoint sits behind the project's **existing auth** (the same permission
331
357
  source used by the generated viewsets — ``DRF_REACT_BY_SCHEMA['PERMISSIONS']``,
332
358
  defaulting to ``IsAuthenticated``; authentication comes from your DRF defaults,
@@ -290,16 +290,42 @@ object with:
290
290
  version changes;
291
291
  * ``drbs_version`` — the installed ``drf-react-by-schema`` version;
292
292
  * ``system`` — apps, ``AUTH_USER_MODEL``, the ``DRF_REACT_BY_SCHEMA`` summary,
293
- static permission hints (DRF defaults, global, per-model) and request
293
+ static permission hints (DRF defaults, global, per-model), request
294
294
  ``conventions`` (API base, pagination, ``ordering``/``search``/``sum_rows``
295
- params, grid-filter operators);
295
+ params, grid-filter operators) and ``about`` (the project's ``ABOUT.md``,
296
+ trimmed — see below);
296
297
  * ``endpoints`` — every generated route (list and nested) with its
297
298
  ``path``/``kind``/``verbose_name``/``parent_model``/``related_model``;
298
299
  * ``models`` — each model's field ``schema`` (the same ``OPTIONS`` POST payload
299
- the React app consumes), verbose names and generated TypeScript
300
- ``interface``;
300
+ the React app consumes), verbose names, generated TypeScript ``interface`` and
301
+ the model class ``docstring`` (trimmed);
301
302
  * ``interfaces`` and ``enums`` — the generated TypeScript declarations.
302
303
 
304
+ Documentation in the bundle
305
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
306
+ Free-form, non-UI documentation is folded into the bundle so an assistant can
307
+ *understand* the system. It is kept out of the React artifacts on purpose:
308
+ ``sync_react``'s ``schemas/*.json`` stay **byte-identical** to before.
309
+
310
+ * **Model docstrings** — a model class's *own* docstring (inherited docs are
311
+ ignored) is reported as ``models[<Model>].docstring``, trimmed at a word
312
+ boundary to ``schema_bundle.MODEL_DOCSTRING_MAX`` characters.
313
+ * **Field descriptions** — pass ``description="..."`` to any package field
314
+ (``CharField``, ``ForeignKey``, ``DecimalField``, ...); it is reported as
315
+ ``models[<Model>].schema[<field>].description`` (trimmed to
316
+ ``FIELD_DESCRIPTION_MAX``). This is **not** ``help_text``: ``help_text`` is
317
+ rendered to end users by ``drf-react-by-schema-js``, while ``description``
318
+ documents the field for the API client / assistant. Only values passed
319
+ explicitly are reported — the field type's own ``description`` is never.
320
+ * **Project ``ABOUT.md``** — a project-root ``ABOUT.md`` (``settings.BASE_DIR``)
321
+ is reported as ``system.about`` (trimmed to ``ABOUT_MAX``). Override the path
322
+ with ``DRF_REACT_BY_SCHEMA['ABOUT_PATH']``; a missing file yields ``null``.
323
+ Use it for *what the project is about* — not deploy/contribute instructions.
324
+
325
+ All three feed the ``stamp``, so changing them invalidates an MCP client's
326
+ cached copy; because the bundle is cached per process, a change also needs a
327
+ process restart to be picked up.
328
+
303
329
  The endpoint sits behind the project's **existing auth** (the same permission
304
330
  source used by the generated viewsets — ``DRF_REACT_BY_SCHEMA['PERMISSIONS']``,
305
331
  defaulting to ``IsAuthenticated``; authentication comes from your DRF defaults,
@@ -4,7 +4,11 @@ from django.contrib.postgres.fields import ArrayField
4
4
  class ArrayField(ArrayField):
5
5
  description = "Extended PostgreSQL ArrayField"
6
6
 
7
- def __init__(self, *args, ui_required=False, conditional_visible=None, **kwargs):
7
+ def __init__(
8
+ self, *args, ui_required=False, conditional_visible=None, description=None, **kwargs
9
+ ):
8
10
  super().__init__(*args, **kwargs)
11
+ if description is not None:
12
+ self.description = description
9
13
  self.ui_required = ui_required
10
14
  self.conditional_visible = conditional_visible
@@ -4,7 +4,11 @@ from django.db import models
4
4
  class BooleanField(models.BooleanField):
5
5
  description = "Extended BooleanField"
6
6
 
7
- def __init__(self, *args, ui_required=False, conditional_visible=None, **kwargs):
7
+ def __init__(
8
+ self, *args, ui_required=False, conditional_visible=None, description=None, **kwargs
9
+ ):
8
10
  super().__init__(*args, **kwargs)
11
+ if description is not None:
12
+ self.description = description
9
13
  self.ui_required = ui_required
10
14
  self.conditional_visible = conditional_visible
@@ -5,9 +5,17 @@ class DateField(models.DateField):
5
5
  description = "Extended DateField"
6
6
 
7
7
  def __init__(
8
- self, *args, views=None, ui_required=False, conditional_visible=None, **kwargs
8
+ self,
9
+ *args,
10
+ views=None,
11
+ ui_required=False,
12
+ conditional_visible=None,
13
+ description=None,
14
+ **kwargs
9
15
  ):
10
16
  super().__init__(*args, **kwargs)
17
+ if description is not None:
18
+ self.description = description
11
19
  if views:
12
20
  self.views = views
13
21
  self.ui_required = ui_required
@@ -17,7 +25,11 @@ class DateField(models.DateField):
17
25
  class DateTimeField(models.DateTimeField):
18
26
  description = "Extended DateTimeField"
19
27
 
20
- def __init__(self, *args, ui_required=False, conditional_visible=None, **kwargs):
28
+ def __init__(
29
+ self, *args, ui_required=False, conditional_visible=None, description=None, **kwargs
30
+ ):
21
31
  super().__init__(*args, **kwargs)
32
+ if description is not None:
33
+ self.description = description
22
34
  self.ui_required = ui_required
23
35
  self.conditional_visible = conditional_visible
@@ -12,9 +12,12 @@ class ImageField(models.ImageField):
12
12
  max_file_size=None,
13
13
  allowed_mime_types=None,
14
14
  conditional_visible=None,
15
+ description=None,
15
16
  **kwargs
16
17
  ):
17
18
  super().__init__(*args, **kwargs)
19
+ if description is not None:
20
+ self.description = description
18
21
  self.max_file_size = max_file_size
19
22
  self.allowed_mime_types = allowed_mime_types
20
23
 
@@ -45,9 +48,12 @@ class FileField(models.FileField):
45
48
  allowed_mime_types=None,
46
49
  ui_required=False,
47
50
  conditional_visible=None,
51
+ description=None,
48
52
  **kwargs
49
53
  ):
50
54
  super().__init__(*args, **kwargs)
55
+ if description is not None:
56
+ self.description = description
51
57
  self.max_file_size = max_file_size
52
58
  self.allowed_mime_types = allowed_mime_types
53
59
  self.ui_required = ui_required
@@ -25,9 +25,13 @@ SRID = 4326
25
25
  def _geometry_field(cls):
26
26
  """Build an extended GIS model field class adding ui_required support."""
27
27
 
28
- def __init__(self, *args, ui_required=False, conditional_visible=None, **kwargs):
28
+ def __init__(
29
+ self, *args, ui_required=False, conditional_visible=None, description=None, **kwargs
30
+ ):
29
31
  kwargs.setdefault("srid", SRID)
30
32
  cls.__init__(self, *args, **kwargs)
33
+ if description is not None:
34
+ self.description = description
31
35
  self.ui_required = ui_required
32
36
  self.conditional_visible = conditional_visible
33
37
 
@@ -12,9 +12,12 @@ class DecimalField(models.DecimalField):
12
12
  suffix="",
13
13
  ui_required=False,
14
14
  conditional_visible=None,
15
+ description=None,
15
16
  **kwargs
16
17
  ):
17
18
  super().__init__(*args, **kwargs)
19
+ if description is not None:
20
+ self.description = description
18
21
  self.is_currency = is_currency and prefix == "" and suffix == ""
19
22
  self.prefix = prefix
20
23
  self.suffix = suffix
@@ -31,9 +34,12 @@ class IntegerField(models.IntegerField):
31
34
  pattern_format=None,
32
35
  ui_required=False,
33
36
  conditional_visible=None,
37
+ description=None,
34
38
  **kwargs
35
39
  ):
36
40
  super().__init__(*args, **kwargs)
41
+ if description is not None:
42
+ self.description = description
37
43
  self.pattern_format = pattern_format
38
44
  self.ui_required = ui_required
39
45
  self.conditional_visible = conditional_visible
@@ -29,9 +29,12 @@ class ForeignKey(models.ForeignKey):
29
29
  verbose_name="",
30
30
  ui_required=False,
31
31
  conditional_visible=None,
32
+ description=None,
32
33
  **kwargs
33
34
  ):
34
35
  super().__init__(*args, **kwargs)
36
+ if description is not None:
37
+ self.description = description
35
38
  self.related_editable = related_editable
36
39
  self.label = label # deprecated! Remove in jan2024
37
40
  self.verbose_name = verbose_name
@@ -49,9 +52,12 @@ class ManyToManyField(models.ManyToManyField):
49
52
  label="",
50
53
  ui_required=False,
51
54
  conditional_visible=None,
55
+ description=None,
52
56
  **kwargs
53
57
  ):
54
58
  super().__init__(*args, **kwargs)
59
+ if description is not None:
60
+ self.description = description
55
61
  self.related_editable = related_editable
56
62
  self.label = label # deprecated! Remove in jan2024
57
63
  self.ui_required = ui_required
@@ -68,9 +74,12 @@ class OneToOneField(models.OneToOneField):
68
74
  label="",
69
75
  ui_required=False,
70
76
  conditional_visible=None,
77
+ description=None,
71
78
  **kwargs
72
79
  ):
73
80
  super().__init__(*args, **kwargs)
81
+ if description is not None:
82
+ self.description = description
74
83
  self.related_editable = related_editable
75
84
  self.label = label # deprecated! Remove in jan2024
76
85
  self.ui_required = ui_required
@@ -4,7 +4,16 @@ from rest_framework import serializers
4
4
  class TypedSerializerMethodField(serializers.SerializerMethodField):
5
5
  description = "Extended SerializerMethodField"
6
6
 
7
- def __init__(self, *args, return_type="string", conditional_visible=None, **kwargs):
7
+ def __init__(
8
+ self,
9
+ *args,
10
+ return_type="string",
11
+ conditional_visible=None,
12
+ description=None,
13
+ **kwargs
14
+ ):
8
15
  super().__init__(*args, **kwargs)
16
+ if description is not None:
17
+ self.description = description
9
18
  self.return_type = return_type
10
19
  self.conditional_visible = conditional_visible
@@ -15,9 +15,12 @@ class CharField(models.CharField):
15
15
  pattern_format=None,
16
16
  ui_required=False,
17
17
  conditional_visible=None,
18
+ description=None,
18
19
  **kwargs,
19
20
  ):
20
21
  super().__init__(*args, **kwargs)
22
+ if description is not None:
23
+ self.description = description
21
24
  self.pattern_format = pattern_format
22
25
  self.ui_required = ui_required
23
26
  self.conditional_visible = conditional_visible
@@ -41,8 +44,12 @@ class CharField(models.CharField):
41
44
  class TextField(models.TextField):
42
45
  description = "Extended TextField allowing ui required"
43
46
 
44
- def __init__(self, *args, ui_required=False, conditional_visible=None, **kwargs):
47
+ def __init__(
48
+ self, *args, ui_required=False, conditional_visible=None, description=None, **kwargs
49
+ ):
45
50
  super().__init__(*args, **kwargs)
51
+ if description is not None:
52
+ self.description = description
46
53
  self.ui_required = ui_required
47
54
  self.conditional_visible = conditional_visible
48
55
 
@@ -50,7 +57,11 @@ class TextField(models.TextField):
50
57
  class EmailField(models.EmailField):
51
58
  description = "Extended EmailField allowing ui required"
52
59
 
53
- def __init__(self, *args, ui_required=False, conditional_visible=None, **kwargs):
60
+ def __init__(
61
+ self, *args, ui_required=False, conditional_visible=None, description=None, **kwargs
62
+ ):
54
63
  super().__init__(*args, **kwargs)
64
+ if description is not None:
65
+ self.description = description
55
66
  self.ui_required = ui_required
56
67
  self.conditional_visible = conditional_visible
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
 
26
26
  import hashlib
27
27
  import importlib
28
+ import inspect
28
29
  import json
29
30
  import os
30
31
  import traceback
@@ -80,6 +81,14 @@ GRID_FILTER_OPERATORS = (
80
81
  #: Number of hex characters kept from the bundle SHA-256.
81
82
  STAMP_LENGTH = 16
82
83
 
84
+ #: Caps for free-form documentation folded into the MCP bundle (kept short so the
85
+ #: schema an assistant receives stays lean). Longer values are truncated at a
86
+ #: word boundary with an ellipsis. These docs are MCP-only: they are NOT written
87
+ #: to the React artifacts (``write_react_artifacts``), which stay byte-identical.
88
+ MODEL_DOCSTRING_MAX = 800
89
+ FIELD_DESCRIPTION_MAX = 300
90
+ ABOUT_MAX = 2000
91
+
83
92
 
84
93
  def configured_apps() -> list[str]:
85
94
  """Return the apps to introspect (``DRF_REACT_BY_SCHEMA["APPS"]``)."""
@@ -100,6 +109,10 @@ class SchemaArtifacts:
100
109
  * ``interfaces_by_model`` — the interface string keyed by model name.
101
110
  * ``permissions`` — the static (class-level) DRF permission classes per model.
102
111
  * ``index_exports`` — the ``index.ts`` export lines.
112
+ * ``model_docstrings`` — the model class's own docstring per model (trimmed),
113
+ for the MCP bundle only.
114
+ * ``field_descriptions`` — per model, the ``description=`` kwarg of each field
115
+ that carries one, for the MCP bundle only.
103
116
  """
104
117
 
105
118
  models: dict = field(default_factory=dict)
@@ -109,6 +122,8 @@ class SchemaArtifacts:
109
122
  interfaces_by_model: dict = field(default_factory=dict)
110
123
  permissions: dict = field(default_factory=dict)
111
124
  index_exports: list = field(default_factory=list)
125
+ model_docstrings: dict = field(default_factory=dict)
126
+ field_descriptions: dict = field(default_factory=dict)
112
127
 
113
128
 
114
129
  def _mock_request() -> Request:
@@ -207,6 +222,11 @@ def generate_artifacts(apps_to_sync: list[str] | None = None) -> SchemaArtifacts
207
222
  artifacts.permissions[model_name] = _describe_permissions(
208
223
  getattr(viewset, "permission_classes", None)
209
224
  )
225
+ model = getattr(view_instance, "model", None)
226
+ artifacts.model_docstrings[model_name] = _class_docstring(model)
227
+ artifacts.field_descriptions[model_name] = _field_descriptions(
228
+ model, view_instance
229
+ )
210
230
 
211
231
  # B. Generate the TypeScript interface.
212
232
  interface_code = artifacts.ts_gen.generate_interface(
@@ -244,14 +264,27 @@ def build_schema_bundle(
244
264
  models = {}
245
265
  for model_name in artifacts.model_order:
246
266
  payload = artifacts.models[model_name]
247
- schema = payload["schema"]
248
- if structural:
249
- schema = _strip_data_defaults(schema)
267
+ schema = (
268
+ _strip_data_defaults(payload["schema"])
269
+ if structural
270
+ else dict(payload["schema"])
271
+ )
272
+ # Field ``description`` kwargs are documentation-only; fold them into the
273
+ # MCP bundle's copy of the schema (never the React artifact's).
274
+ for field_name, description in (
275
+ artifacts.field_descriptions.get(model_name) or {}
276
+ ).items():
277
+ entry = schema.get(field_name)
278
+ if isinstance(entry, dict):
279
+ entry = dict(entry)
280
+ entry.setdefault("description", description)
281
+ schema[field_name] = entry
250
282
  models[model_name] = {
251
283
  "schema": schema,
252
284
  "verbose_name": payload["verbose_name"],
253
285
  "verbose_name_plural": payload["verbose_name_plural"],
254
286
  "interface": artifacts.interfaces_by_model.get(model_name),
287
+ "docstring": artifacts.model_docstrings.get(model_name),
255
288
  }
256
289
 
257
290
  body = {
@@ -335,6 +368,7 @@ def _system_metadata(apps_to_sync, drf_settings, permissions) -> dict:
335
368
  "per_model": permissions,
336
369
  },
337
370
  "conventions": _conventions(drf_settings),
371
+ "about": _project_about(drf_settings),
338
372
  }
339
373
 
340
374
 
@@ -362,6 +396,77 @@ def _conventions(drf_settings) -> dict:
362
396
  }
363
397
 
364
398
 
399
+ def _trim_text(text, limit: int) -> str | None:
400
+ """Collapse a docstring's indentation and truncate it at a word boundary."""
401
+ if not text:
402
+ return None
403
+ cleaned = inspect.cleandoc(str(text)).strip()
404
+ if not cleaned:
405
+ return None
406
+ if len(cleaned) <= limit:
407
+ return cleaned
408
+ clipped = cleaned[:limit].rstrip()
409
+ space = clipped.rfind(" ")
410
+ if space > limit // 2:
411
+ clipped = clipped[:space].rstrip()
412
+ return clipped + "…"
413
+
414
+
415
+ def _class_docstring(model) -> str | None:
416
+ """The model class's own docstring (trimmed). Inherited docs are ignored."""
417
+ if model is None:
418
+ return None
419
+ return _trim_text(
420
+ getattr(model, "__dict__", {}).get("__doc__"), MODEL_DOCSTRING_MAX
421
+ )
422
+
423
+
424
+ def _field_descriptions(model, view_instance) -> dict:
425
+ """Map each field carrying a ``description=`` kwarg to its trimmed text.
426
+
427
+ Only ``description`` values the author passed explicitly are reported; a
428
+ field's class-level ``Field.description`` (the Django field-type label) is
429
+ not, because the lookup reads the instance ``__dict__``. Model fields are
430
+ read first, then declared serializer fields (e.g.
431
+ ``TypedSerializerMethodField``) fill any name the model did not cover.
432
+ """
433
+ descriptions: dict = {}
434
+ if model is not None:
435
+ for field in model._meta.get_fields():
436
+ name = getattr(field, "name", None)
437
+ value = getattr(field, "__dict__", {}).get("description")
438
+ if name and value:
439
+ descriptions[name] = _trim_text(value, FIELD_DESCRIPTION_MAX)
440
+ try:
441
+ serializer = view_instance.get_serializer()
442
+ except Exception:
443
+ serializer = None
444
+ if serializer is not None:
445
+ for name, field in serializer.fields.items():
446
+ value = getattr(field, "__dict__", {}).get("description")
447
+ if name not in descriptions and value:
448
+ descriptions[name] = _trim_text(value, FIELD_DESCRIPTION_MAX)
449
+ return descriptions
450
+
451
+
452
+ def _project_about(drf_settings) -> str | None:
453
+ """The project's ``ABOUT.md`` — ``BASE_DIR/ABOUT.md`` or ``ABOUT_PATH``.
454
+
455
+ Returns ``None`` when neither path resolves or the file is missing/empty.
456
+ """
457
+ path = drf_settings.get("ABOUT_PATH")
458
+ if not path:
459
+ base_dir = getattr(settings, "BASE_DIR", None)
460
+ if not base_dir:
461
+ return None
462
+ path = os.path.join(str(base_dir), "ABOUT.md")
463
+ try:
464
+ with open(path, encoding="utf-8") as handle:
465
+ return _trim_text(handle.read(), ABOUT_MAX)
466
+ except OSError:
467
+ return None
468
+
469
+
365
470
  def _describe_permissions(permission_classes) -> list | None:
366
471
  """Render static permission classes as dotted paths (or ``None``)."""
367
472
  if not permission_classes:
@@ -159,6 +159,17 @@ class SchemaBundleEndpointTest(TestCase):
159
159
  ):
160
160
  self.assertIn(key, response.data)
161
161
 
162
+ def test_viewset_style_permission_reading_action(self):
163
+ """A viewset permission branching on ``view.action`` must not crash."""
164
+ from rest_framework.permissions import BasePermission
165
+
166
+ class ActionAware(BasePermission):
167
+ def has_permission(self, request, view):
168
+ return view.action == "list"
169
+
170
+ with self.settings(DRF_REACT_BY_SCHEMA={"PERMISSIONS": [ActionAware]}):
171
+ self.assertEqual(self._get().status_code, 200)
172
+
162
173
  def test_if_none_match_returns_304(self):
163
174
  etag = self._get()["ETag"]
164
175
 
@@ -208,6 +219,112 @@ class SchemaBundleEndpointTest(TestCase):
208
219
  self.assertTrue(schema_endpoint_enabled())
209
220
 
210
221
 
222
+ class SchemaDocumentationTest(TestCase):
223
+ """Model docstrings, field ``description`` and ``ABOUT.md`` in the bundle."""
224
+
225
+ def tearDown(self):
226
+ from drf_react_by_schema.views import reset_schema_bundle_cache
227
+
228
+ reset_schema_bundle_cache()
229
+
230
+ def test_trim_text_collapses_and_truncates(self):
231
+ from drf_react_by_schema.schema_bundle import _trim_text
232
+
233
+ self.assertIsNone(_trim_text(None, 10))
234
+ self.assertIsNone(_trim_text(" ", 10))
235
+ self.assertEqual(_trim_text("short", 10), "short")
236
+ self.assertEqual(_trim_text("Title.\n\n body", 50), "Title.\n\nbody")
237
+ trimmed = _trim_text("palavra " * 50, 30)
238
+ self.assertLessEqual(len(trimmed), 31)
239
+ self.assertTrue(trimmed.endswith("…"))
240
+
241
+ def test_class_docstring_uses_own_docs_only(self):
242
+ from drf_react_by_schema.schema_bundle import _class_docstring
243
+
244
+ class Documented:
245
+ """Real docs."""
246
+
247
+ class Inheriting(Documented):
248
+ pass
249
+
250
+ class ExplicitlyEmpty(Documented):
251
+ __doc__ = None
252
+
253
+ self.assertEqual(_class_docstring(Documented), "Real docs.")
254
+ self.assertIsNone(_class_docstring(Inheriting))
255
+ self.assertIsNone(_class_docstring(ExplicitlyEmpty))
256
+ self.assertIsNone(_class_docstring(None))
257
+
258
+ def test_field_descriptions_reads_explicit_kwarg_only(self):
259
+ from types import SimpleNamespace
260
+
261
+ from drf_react_by_schema import fields
262
+ from drf_react_by_schema.schema_bundle import _field_descriptions
263
+
264
+ described = fields.CharField("N", max_length=10, description="Do campo")
265
+ described.set_attributes_from_name("nome")
266
+ # A plain package field must not surface its class-level ``description``.
267
+ plain = fields.CharField("X", max_length=3)
268
+ plain.set_attributes_from_name("sem_descricao")
269
+ model = SimpleNamespace(
270
+ _meta=SimpleNamespace(get_fields=lambda: [described, plain])
271
+ )
272
+ no_serializer = SimpleNamespace(
273
+ get_serializer=lambda: SimpleNamespace(fields={})
274
+ )
275
+ self.assertEqual(
276
+ _field_descriptions(model, no_serializer), {"nome": "Do campo"}
277
+ )
278
+
279
+ # A declared serializer field (not a model field) is picked up too.
280
+ method = fields.TypedSerializerMethodField(description="Calculado")
281
+ with_serializer = SimpleNamespace(
282
+ get_serializer=lambda: SimpleNamespace(fields={"extra": method})
283
+ )
284
+ self.assertEqual(
285
+ _field_descriptions(None, with_serializer), {"extra": "Calculado"}
286
+ )
287
+
288
+ def test_project_about_from_base_dir_and_path(self):
289
+ import os
290
+ import tempfile
291
+
292
+ from django.test import override_settings
293
+
294
+ from drf_react_by_schema.schema_bundle import _project_about
295
+
296
+ with tempfile.TemporaryDirectory() as tmp:
297
+ with open(os.path.join(tmp, "ABOUT.md"), "w", encoding="utf-8") as handle:
298
+ handle.write("# Sobre\n\nProjeto de teste.")
299
+ with override_settings(BASE_DIR=tmp):
300
+ self.assertEqual(_project_about({}), "# Sobre\n\nProjeto de teste.")
301
+ self.assertIsNone(_project_about({"ABOUT_PATH": "/no/such/ABOUT.md"}))
302
+
303
+ def test_bundle_exposes_documentation(self):
304
+ import os
305
+ import tempfile
306
+
307
+ from django.test import override_settings
308
+
309
+ from drf_react_by_schema.views import (
310
+ get_schema_bundle,
311
+ reset_schema_bundle_cache,
312
+ )
313
+
314
+ with tempfile.TemporaryDirectory() as tmp:
315
+ with open(os.path.join(tmp, "ABOUT.md"), "w", encoding="utf-8") as handle:
316
+ handle.write("# Meu projeto\n\nDescrição curta.")
317
+ with override_settings(BASE_DIR=tmp):
318
+ reset_schema_bundle_cache()
319
+ bundle = get_schema_bundle()
320
+
321
+ self.assertEqual(
322
+ bundle["system"]["about"], "# Meu projeto\n\nDescrição curta."
323
+ )
324
+ for payload in bundle["models"].values():
325
+ self.assertIn("docstring", payload)
326
+
327
+
211
328
  class PasswordResetFlowTest(TestCase):
212
329
  """End-to-end password reset (request -> confirm) and authenticated change."""
213
330
 
@@ -65,6 +65,11 @@ def _etag_matches(if_none_match: str | None, etag: str) -> bool:
65
65
  class SchemaBundleView(APIView):
66
66
  """``GET /drbs/schema/`` — serve the cached bundle with ETag/304 handling."""
67
67
 
68
+ #: Project ``PERMISSIONS`` are written for the generated viewsets and may
69
+ #: branch on ``view.action``; this plain ``APIView`` exposes the read
70
+ #: action so such permission classes (e.g. CTA's) do not raise here.
71
+ action = "list"
72
+
68
73
  def get_permissions(self):
69
74
  # Re-read the setting per request so hosts can toggle PERMISSIONS in tests
70
75
  # (mirrors the permission source used by the generated viewsets).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: drf-react-by-schema
3
- Version: 0.27.0
3
+ Version: 0.27.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
@@ -317,16 +317,42 @@ object with:
317
317
  version changes;
318
318
  * ``drbs_version`` — the installed ``drf-react-by-schema`` version;
319
319
  * ``system`` — apps, ``AUTH_USER_MODEL``, the ``DRF_REACT_BY_SCHEMA`` summary,
320
- static permission hints (DRF defaults, global, per-model) and request
320
+ static permission hints (DRF defaults, global, per-model), request
321
321
  ``conventions`` (API base, pagination, ``ordering``/``search``/``sum_rows``
322
- params, grid-filter operators);
322
+ params, grid-filter operators) and ``about`` (the project's ``ABOUT.md``,
323
+ trimmed — see below);
323
324
  * ``endpoints`` — every generated route (list and nested) with its
324
325
  ``path``/``kind``/``verbose_name``/``parent_model``/``related_model``;
325
326
  * ``models`` — each model's field ``schema`` (the same ``OPTIONS`` POST payload
326
- the React app consumes), verbose names and generated TypeScript
327
- ``interface``;
327
+ the React app consumes), verbose names, generated TypeScript ``interface`` and
328
+ the model class ``docstring`` (trimmed);
328
329
  * ``interfaces`` and ``enums`` — the generated TypeScript declarations.
329
330
 
331
+ Documentation in the bundle
332
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
333
+ Free-form, non-UI documentation is folded into the bundle so an assistant can
334
+ *understand* the system. It is kept out of the React artifacts on purpose:
335
+ ``sync_react``'s ``schemas/*.json`` stay **byte-identical** to before.
336
+
337
+ * **Model docstrings** — a model class's *own* docstring (inherited docs are
338
+ ignored) is reported as ``models[<Model>].docstring``, trimmed at a word
339
+ boundary to ``schema_bundle.MODEL_DOCSTRING_MAX`` characters.
340
+ * **Field descriptions** — pass ``description="..."`` to any package field
341
+ (``CharField``, ``ForeignKey``, ``DecimalField``, ...); it is reported as
342
+ ``models[<Model>].schema[<field>].description`` (trimmed to
343
+ ``FIELD_DESCRIPTION_MAX``). This is **not** ``help_text``: ``help_text`` is
344
+ rendered to end users by ``drf-react-by-schema-js``, while ``description``
345
+ documents the field for the API client / assistant. Only values passed
346
+ explicitly are reported — the field type's own ``description`` is never.
347
+ * **Project ``ABOUT.md``** — a project-root ``ABOUT.md`` (``settings.BASE_DIR``)
348
+ is reported as ``system.about`` (trimmed to ``ABOUT_MAX``). Override the path
349
+ with ``DRF_REACT_BY_SCHEMA['ABOUT_PATH']``; a missing file yields ``null``.
350
+ Use it for *what the project is about* — not deploy/contribute instructions.
351
+
352
+ All three feed the ``stamp``, so changing them invalidates an MCP client's
353
+ cached copy; because the bundle is cached per process, a change also needs a
354
+ process restart to be picked up.
355
+
330
356
  The endpoint sits behind the project's **existing auth** (the same permission
331
357
  source used by the generated viewsets — ``DRF_REACT_BY_SCHEMA['PERMISSIONS']``,
332
358
  defaulting to ``IsAuthenticated``; authentication comes from your DRF defaults,
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = drf_react_by_schema
3
- version = 0.27.0
3
+ version = 0.27.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