vueda 3.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. vueda/__init__.py +5 -0
  2. vueda/__main__.py +89 -0
  3. vueda/cli.py +62 -0
  4. vueda/core/__init__.py +3 -0
  5. vueda/core/apps.py +22 -0
  6. vueda/core/audit.py +80 -0
  7. vueda/core/checks.py +367 -0
  8. vueda/core/config.py +445 -0
  9. vueda/core/db.py +10 -0
  10. vueda/core/decorators.py +93 -0
  11. vueda/core/default_settings.py +523 -0
  12. vueda/core/exceptions.py +225 -0
  13. vueda/core/features.py +102 -0
  14. vueda/core/fields/__init__.py +3 -0
  15. vueda/core/fields/form.py +69 -0
  16. vueda/core/fields/serializers.py +104 -0
  17. vueda/core/filters.py +618 -0
  18. vueda/core/formatted_name.py +239 -0
  19. vueda/core/installed_apps.py +31 -0
  20. vueda/core/logging_filters.py +58 -0
  21. vueda/core/middleware.py +70 -0
  22. vueda/core/models.py +479 -0
  23. vueda/core/open_api.py +509 -0
  24. vueda/core/options.py +358 -0
  25. vueda/core/ordering.py +453 -0
  26. vueda/core/pagination.py +79 -0
  27. vueda/core/patch_django.py +140 -0
  28. vueda/core/permissions.py +389 -0
  29. vueda/core/routers.py +136 -0
  30. vueda/core/serializers/__init__.py +953 -0
  31. vueda/core/serializers/fields.py +147 -0
  32. vueda/core/spectacular_hooks.py +204 -0
  33. vueda/core/tokens.py +9 -0
  34. vueda/core/utils.py +49 -0
  35. vueda/core/validators.py +33 -0
  36. vueda/core/views.py +43 -0
  37. vueda/core/viewsets/__init__.py +1090 -0
  38. vueda/core/widgets.py +64 -0
  39. vueda/history/__init__.py +3 -0
  40. vueda/history/actions.py +169 -0
  41. vueda/history/apps.py +167 -0
  42. vueda/history/checks.py +65 -0
  43. vueda/history/fields.py +52 -0
  44. vueda/history/middleware.py +20 -0
  45. vueda/history/models.py +42 -0
  46. vueda/history/queries.py +77 -0
  47. vueda/history/revision.py +103 -0
  48. vueda/history/serializers/__init__.py +1 -0
  49. vueda/history/serializers/actions.py +62 -0
  50. vueda/history/serializers/mixins.py +298 -0
  51. vueda/history/serializers/users.py +50 -0
  52. vueda/history/snapshots.py +30 -0
  53. vueda/history/urls.py +16 -0
  54. vueda/history/utils.py +43 -0
  55. vueda/history/views.py +86 -0
  56. vueda/history/viewsets.py +128 -0
  57. vueda/history/visibility.py +79 -0
  58. vueda/info/__init__.py +9 -0
  59. vueda/info/apps.py +58 -0
  60. vueda/info/checks.py +824 -0
  61. vueda/info/field_resolution.py +153 -0
  62. vueda/info/jinja2/info/overview.jinja2 +183 -0
  63. vueda/info/open_api_tracebacks.py +85 -0
  64. vueda/info/registration.py +190 -0
  65. vueda/info/routers.py +25 -0
  66. vueda/info/schema.py +8 -0
  67. vueda/info/serializers.py +3220 -0
  68. vueda/info/static/info/overview.css +496 -0
  69. vueda/info/static/info/overview.js +7 -0
  70. vueda/info/urls.py +29 -0
  71. vueda/info/views.py +289 -0
  72. vueda/info/viewsets.py +545 -0
  73. vueda/release/__init__.py +9 -0
  74. vueda/release/apps.py +19 -0
  75. vueda/release/filtersets.py +12 -0
  76. vueda/release/migrations/0001_initial.py +34 -0
  77. vueda/release/migrations/0002_releasenoteevent_releasenote_insert_insert_and_more.py +110 -0
  78. vueda/release/migrations/__init__.py +0 -0
  79. vueda/release/models.py +18 -0
  80. vueda/release/routers.py +14 -0
  81. vueda/release/serializers.py +17 -0
  82. vueda/release/urls.py +11 -0
  83. vueda/release/viewsets.py +18 -0
  84. vueda/update.py +650 -0
  85. vueda/user/__init__.py +3 -0
  86. vueda/user/adapters.py +221 -0
  87. vueda/user/apps.py +32 -0
  88. vueda/user/decorators.py +41 -0
  89. vueda/user/fields.py +20 -0
  90. vueda/user/filtersets.py +14 -0
  91. vueda/user/globals.py +37 -0
  92. vueda/user/jinja2/permissions/overview.jinja2 +79 -0
  93. vueda/user/management/__init__.py +0 -0
  94. vueda/user/management/commands/__init__.py +0 -0
  95. vueda/user/management/commands/createuser.py +75 -0
  96. vueda/user/management/commands/makegroupmigrations.py +583 -0
  97. vueda/user/management/commands/sync_group_changes.py +54 -0
  98. vueda/user/management/commands/updategroupmigrations.py +169 -0
  99. vueda/user/management/commands/utils.py +499 -0
  100. vueda/user/migrations/0001_initial.py +39 -0
  101. vueda/user/migrations/0002_initial.py +44 -0
  102. vueda/user/migrations/0003_totpdevice.py +74 -0
  103. vueda/user/migrations/0004_alter_totpdevice_options.py +20 -0
  104. vueda/user/migrations/0005_sync_group_changes.py +24 -0
  105. vueda/user/migrations/0006_totpdeviceevent_totpdevice_insert_insert_and_more.py +149 -0
  106. vueda/user/migrations/__init__.py +0 -0
  107. vueda/user/mixins.py +82 -0
  108. vueda/user/models.py +231 -0
  109. vueda/user/permissions.py +13 -0
  110. vueda/user/routers.py +14 -0
  111. vueda/user/serializers.py +303 -0
  112. vueda/user/static/permissions/overview.css +210 -0
  113. vueda/user/static/permissions/overview.js +184 -0
  114. vueda/user/templates/email/forgot_password_message.html +10 -0
  115. vueda/user/templates/email/forgot_password_subject.txt +1 -0
  116. vueda/user/templates/email/totp_code_message.txt +1 -0
  117. vueda/user/templates/email/totp_code_subject.txt +1 -0
  118. vueda/user/templates/email/welcome_user_message.txt +17 -0
  119. vueda/user/templates/email/welcome_user_subject.txt +1 -0
  120. vueda/user/templates/registration/logged_out.html +11 -0
  121. vueda/user/templates/registration/login.html +39 -0
  122. vueda/user/templates/registration/logout_form.html +6 -0
  123. vueda/user/urls.py +52 -0
  124. vueda/user/utils.py +30 -0
  125. vueda/user/views.py +729 -0
  126. vueda/user/viewsets.py +195 -0
  127. vueda/vdq/__init__.py +5 -0
  128. vueda/vdq/apps.py +29 -0
  129. vueda/vdq/celery.py +43 -0
  130. vueda/vdq/constants.py +5 -0
  131. vueda/vdq/exceptions.py +9 -0
  132. vueda/vdq/filtersets.py +23 -0
  133. vueda/vdq/handlers.py +314 -0
  134. vueda/vdq/migrations/0001_initial.py +380 -0
  135. vueda/vdq/migrations/0002_workflow_migrations_2025_09_22.py +1409 -0
  136. vueda/vdq/migrations/0003_alter_queueitem_content_type.py +24 -0
  137. vueda/vdq/migrations/0004_alter_queueitem_done_since_alter_queueitem_object_id.py +24 -0
  138. vueda/vdq/migrations/0005_workflow_migrations_2025_11_21.py +947 -0
  139. vueda/vdq/migrations/0006_alter_sentitem_options.py +19 -0
  140. vueda/vdq/migrations/0007_queueitemevent_receiverevent_senderevent_and_more.py +351 -0
  141. vueda/vdq/migrations/__init__.py +0 -0
  142. vueda/vdq/migrations/__pycache__/0002_workflow_migrations_2025_09_22.cpython-314.pyc +0 -0
  143. vueda/vdq/migrations/__pycache__/0005_workflow_migrations_2025_11_21.cpython-314.pyc +0 -0
  144. vueda/vdq/models.py +409 -0
  145. vueda/vdq/permissions.py +19 -0
  146. vueda/vdq/routers.py +16 -0
  147. vueda/vdq/schedulers.py +175 -0
  148. vueda/vdq/serializers.py +162 -0
  149. vueda/vdq/tasks.py +183 -0
  150. vueda/vdq/urls.py +30 -0
  151. vueda/vdq/utils.py +21 -0
  152. vueda/vdq/views.py +104 -0
  153. vueda/vdq/viewsets.py +96 -0
  154. vueda/workflow/__init__.py +3 -0
  155. vueda/workflow/apps.py +55 -0
  156. vueda/workflow/exceptions.py +7 -0
  157. vueda/workflow/fields.py +33 -0
  158. vueda/workflow/filtersets.py +36 -0
  159. vueda/workflow/forms.py +410 -0
  160. vueda/workflow/globals.py +24 -0
  161. vueda/workflow/jinja2/workflow/add.jinja2 +49 -0
  162. vueda/workflow/jinja2/workflow/edit.jinja2 +63 -0
  163. vueda/workflow/jinja2/workflow/formset.jinja2 +30 -0
  164. vueda/workflow/jinja2/workflow/overview.jinja2 +172 -0
  165. vueda/workflow/management/__init__.py +0 -0
  166. vueda/workflow/management/commands/__init__.py +0 -0
  167. vueda/workflow/management/commands/makeworkflowmigrations.py +2255 -0
  168. vueda/workflow/management/commands/updateworkflowmigrations.py +196 -0
  169. vueda/workflow/migrations/0001_initial.py +295 -0
  170. vueda/workflow/migrations/0002_statepermission_historical_group_name_and_more.py +576 -0
  171. vueda/workflow/migrations/0003_alter_initialstate_options_alter_state_options_and_more.py +88 -0
  172. vueda/workflow/migrations/0004_alter_statepermission_options_and_more.py +36 -0
  173. vueda/workflow/migrations/0005_historicaltransitionsource_fail_with_silent_and_more.py +23 -0
  174. vueda/workflow/migrations/0006_rename_fail_with_silent_historicaltransitionsource_ignored_and_more.py +22 -0
  175. vueda/workflow/migrations/0007_workflowevent_workflow_insert_insert_and_more.py +123 -0
  176. vueda/workflow/migrations/0008_initialstateevent_objectstateevent_stateevent_and_more.py +931 -0
  177. vueda/workflow/migrations/0009_remove_historicalobjectstate_history_relation_and_more.py +163 -0
  178. vueda/workflow/migrations/__init__.py +0 -0
  179. vueda/workflow/mixins.py +98 -0
  180. vueda/workflow/models.py +1047 -0
  181. vueda/workflow/open_api_tracebacks.py +88 -0
  182. vueda/workflow/permissions.py +24 -0
  183. vueda/workflow/routers.py +15 -0
  184. vueda/workflow/serializers.py +756 -0
  185. vueda/workflow/sql/view-vueda_workflow_objectstateproxy.sql +24 -0
  186. vueda/workflow/static/forms/forms.css +428 -0
  187. vueda/workflow/static/forms/forms.js +104 -0
  188. vueda/workflow/templates/workflow/add_button.html +1 -0
  189. vueda/workflow/templates/workflow/delete_button.html +4 -0
  190. vueda/workflow/templates/workflow/edit_button.html +1 -0
  191. vueda/workflow/templates/workflow/view_button.html +1 -0
  192. vueda/workflow/urls.py +32 -0
  193. vueda/workflow/views.py +373 -0
  194. vueda/workflow/viewsets.py +346 -0
  195. vueda-3.0.0a1.dist-info/METADATA +208 -0
  196. vueda-3.0.0a1.dist-info/RECORD +200 -0
  197. vueda-3.0.0a1.dist-info/WHEEL +5 -0
  198. vueda-3.0.0a1.dist-info/entry_points.txt +2 -0
  199. vueda-3.0.0a1.dist-info/licenses/LICENSE +29 -0
  200. vueda-3.0.0a1.dist-info/top_level.txt +1 -0
vueda/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """VUEDA server library for Django REST Framework applications."""
2
+
3
+ __all__ = ("__version__",)
4
+
5
+ __version__ = "3.0.0a1"
vueda/__main__.py ADDED
@@ -0,0 +1,89 @@
1
+ """Entry point for the `vueda` CLI command."""
2
+
3
+ __all__ = (
4
+ "main",
5
+ "setup_django_settings_module",
6
+ "version",
7
+ "version_for_main",
8
+ )
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ from signal import SIGINT
14
+ from traceback import format_exception_only
15
+
16
+ from vueda.update import update_for_main
17
+
18
+
19
+ def version(args):
20
+ from vueda import __version__
21
+
22
+ print(__version__)
23
+
24
+
25
+ def version_for_main(subparsers):
26
+ parser = subparsers.add_parser(
27
+ "version",
28
+ description="Print the version of vueda-server.",
29
+ )
30
+ return version, parser
31
+
32
+
33
+ def setup_django_settings_module():
34
+ """
35
+ Default to config.settings.local when DJANGO_SETTINGS_MODULE is unset.
36
+ """
37
+ from vueda.update import detect_package_manager
38
+
39
+ # Find the manage.py directory so Django imports resolve correctly
40
+ try:
41
+ _, _, manage_py_dir = detect_package_manager()
42
+ except Exception:
43
+ # Fallback to current directory if detection fails
44
+ manage_py_dir = os.getcwd()
45
+
46
+ try:
47
+ if not os.environ.get("DJANGO_SETTINGS_MODULE"):
48
+ os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.local"
49
+ # Add manage.py directory to path for Django imports
50
+ sys.path.append(manage_py_dir)
51
+ finally:
52
+ pass
53
+
54
+
55
+ def main():
56
+ parser = argparse.ArgumentParser(
57
+ prog="vueda",
58
+ description="VUEDA CLI Interface",
59
+ )
60
+
61
+ subparsers = parser.add_subparsers(
62
+ title="subcommands",
63
+ dest="subcommand",
64
+ required=True,
65
+ help="The sub-command to run.",
66
+ )
67
+ commands = {}
68
+ for name, setup_subparser in (
69
+ ("update", update_for_main),
70
+ ("version", version_for_main),
71
+ ):
72
+ commands[name], _parser = setup_subparser(subparsers)
73
+
74
+ args = parser.parse_args()
75
+
76
+ # Only setup Django settings when actually running commands that need it
77
+ if args.subcommand == "update":
78
+ setup_django_settings_module()
79
+
80
+ commands[args.subcommand](args)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ try:
85
+ main()
86
+ except KeyboardInterrupt:
87
+ print("\n".join(format_exception_only(*sys.exc_info()[:2])), file=sys.stderr, end="")
88
+ # exit codes higher than 128 are reserved for signals
89
+ sys.exit(128 + SIGINT)
vueda/cli.py ADDED
@@ -0,0 +1,62 @@
1
+ """Shared CLI utilities for colored output and interactive input."""
2
+
3
+ __all__ = (
4
+ "BadArgumentError",
5
+ "NoExitArgumentParser",
6
+ "blue_color",
7
+ "error_color",
8
+ "fake_arg_quoting",
9
+ "getch",
10
+ "open_orange",
11
+ "orange_color",
12
+ "reset_prompt",
13
+ )
14
+
15
+ import argparse
16
+ import sys
17
+ import termios
18
+ import tty
19
+ from functools import partial
20
+ from shlex import quote as shell_quote
21
+
22
+ from colors import color
23
+
24
+
25
+ blue_color = partial(color, fg="#0077f7", style="bold") # arrai blue
26
+ orange_color = partial(color, fg="#ff7f00", style="bold") # complimentary orange
27
+ open_orange = "\x1b[38;2;255;127;0m\x1b[1m" # for user input
28
+ reset_prompt = "\x1b[0m" # after user input
29
+ error_color = partial(color, fg="#ff0000", style="bold") # error red
30
+
31
+
32
+ def getch():
33
+ fd = sys.stdin.fileno()
34
+ old_settings = termios.tcgetattr(fd)
35
+ try:
36
+ tty.setraw(fd)
37
+ ch = sys.stdin.read(1)
38
+ if ch in ("\x03", "\x1b"):
39
+ raise KeyboardInterrupt
40
+ if ch == "\x04":
41
+ raise EOFError
42
+ finally:
43
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
44
+ return ch
45
+
46
+
47
+ class BadArgumentError(ValueError):
48
+ pass
49
+
50
+
51
+ class NoExitArgumentParser(argparse.ArgumentParser):
52
+ def error(self, message):
53
+ raise BadArgumentError(message)
54
+
55
+
56
+ def fake_arg_quoting(cl_args):
57
+ """
58
+ Fake the quoting of arguments for display purposes.
59
+ """
60
+ if issubclass(type(cl_args), str):
61
+ return cl_args
62
+ return " ".join([shell_quote(arg) for arg in cl_args])
vueda/core/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Base classes, configuration, and shared utilities for VUEDA applications."""
2
+
3
+ __all__ = ()
vueda/core/apps.py ADDED
@@ -0,0 +1,22 @@
1
+ """Django AppConfig for the vueda.core package."""
2
+
3
+ __all__ = ("CoreConfig",)
4
+
5
+ from django.apps import AppConfig
6
+
7
+
8
+ class CoreConfig(AppConfig):
9
+ name = "vueda.core"
10
+ label = "vueda_core"
11
+ verbose_name = "VUEDA Core"
12
+
13
+ def ready(self):
14
+ from django.core.checks import register
15
+
16
+ from .checks import check_exclude_fields_serializer_usage
17
+ from .checks import check_expandable_fields_configuration
18
+ from .checks import check_model_feature_policy
19
+
20
+ register(check_expandable_fields_configuration)
21
+ register(check_exclude_fields_serializer_usage)
22
+ register(check_model_feature_policy)
vueda/core/audit.py ADDED
@@ -0,0 +1,80 @@
1
+ """An explicit action context for audited writes that happen outside a request."""
2
+
3
+ __all__ = (
4
+ "audited_action",
5
+ "current_action_metadata",
6
+ )
7
+
8
+ import contextlib
9
+
10
+ import pghistory
11
+ from django.db import DatabaseError
12
+ from django.db import connection
13
+ from pghistory import runtime
14
+
15
+
16
+ @contextlib.contextmanager
17
+ def audited_action(action, kind=None, **metadata):
18
+ """Group every audited write inside this block under one action.
19
+
20
+ Request traffic takes its context from history middleware. A management command, a background
21
+ task, or a nested service operation has no request, so it names its own action here. Writes
22
+ outside any context still record events; they just carry no identity associating them with the
23
+ other writes of the same operation.
24
+
25
+ ``kind`` says what sort of source the action came from. The middleware records ``request``,
26
+ VDQ tasks record ``task``, and a management command should pass ``command``. A block that names
27
+ no kind keeps the kind of the action it runs inside, so a service operation called from a request
28
+ stays a request action; at the top level it defaults to ``system``. The history API publishes the
29
+ kind, so a project that adds one should expect clients to render it as an unrecognized value.
30
+
31
+ Nesting follows pghistory: an inner block adds its metadata to the outer action, and the outer
32
+ action keeps that metadata until it exits. Only the outermost block clears the action when it
33
+ ends, because an inner one leaving must not take the action its caller is still writing under.
34
+ """
35
+ owns_action = getattr(runtime._tracker, "value", None) is None
36
+
37
+ with pghistory.context(action=action, **metadata) as current:
38
+ if kind is not None:
39
+ current.metadata["kind"] = kind
40
+ else:
41
+ current.metadata.setdefault("kind", "system")
42
+ try:
43
+ yield
44
+ finally:
45
+ if owns_action:
46
+ _clear_action()
47
+
48
+
49
+ def current_action_metadata():
50
+ """Return the metadata of the action currently open, or an empty mapping outside one.
51
+
52
+ History middleware records the acting user on a request's action, so this is where code holding
53
+ no user of its own can find who is acting.
54
+
55
+ pghistory keeps the open action in a thread-local and exposes no reader for it. This is the one
56
+ place that reaches for it, and the one place to change if pghistory grows a supported one.
57
+ """
58
+ current = getattr(runtime._tracker, "value", None)
59
+ return getattr(current, "metadata", None) or {}
60
+
61
+
62
+ def _clear_action():
63
+ """Stop attributing writes to the action that just ended.
64
+
65
+ pghistory tells the database about an action by setting a transaction-local before each
66
+ statement. It stops setting it once the block exits, but what it set stays for the rest of the
67
+ transaction, so a later write in that transaction would still be recorded under an action that
68
+ is over. Clearing it makes the trigger record no action, which is what a write outside one is.
69
+
70
+ A failing block can leave the transaction unable to run this, and the error that got it there
71
+ matters more than the tidy-up.
72
+ """
73
+ try:
74
+ with connection.cursor() as cursor:
75
+ cursor.execute(
76
+ "SELECT set_config('pghistory.context_id', '', true), "
77
+ "set_config('pghistory.context_metadata', '', true);"
78
+ )
79
+ except DatabaseError:
80
+ pass
vueda/core/checks.py ADDED
@@ -0,0 +1,367 @@
1
+ """System checks validating VUEDA serializer configuration and model feature policy."""
2
+
3
+ import inspect
4
+
5
+ import rest_flex_fields.serializers as flex_serializers
6
+ from django.core.checks import Error
7
+ from rest_framework.fields import Field
8
+ from rest_framework.serializers import BaseSerializer
9
+ from rest_framework.serializers import ListSerializer
10
+
11
+
12
+ def _resolve_lazy_serializer_string(lazy_path):
13
+ """Resolve a flex-fields lazy serializer string, using flex-fields' own resolver.
14
+
15
+ Returns ``(resolved_class, None)`` on success, or ``(None, error_message)`` if the
16
+ string does not resolve to an importable class.
17
+ """
18
+ resolver = flex_serializers.FlexFieldsSerializerMixin.__new__(flex_serializers.FlexFieldsSerializerMixin)
19
+ try:
20
+ return resolver._get_serializer_class_from_lazy_string(lazy_path), None
21
+ except Exception as exc: # flex-fields raises a bare Exception on failed resolution
22
+ return None, str(exc)
23
+
24
+
25
+ def _unwrap_expandable_field(field_data):
26
+ """Split an ``expandable_fields`` value into ``(field_serializer, expand_options)``.
27
+
28
+ An empty tuple carries no serializer, so it unwraps to ``None``. ``_validate_expandable_field``
29
+ reports it as a malformed entry before it reaches here; graph traversal skips it.
30
+ """
31
+ if isinstance(field_data, tuple):
32
+ return (
33
+ field_data[0] if field_data else None,
34
+ field_data[1] if len(field_data) > 1 else {},
35
+ )
36
+ return field_data, {}
37
+
38
+
39
+ def _validate_expandable_field(serializer_class, field_name, field_data):
40
+ """Validate a single ``expandable_fields`` entry.
41
+
42
+ Returns ``(errors, resolved_serializer)``. ``resolved_serializer`` is the class the
43
+ entry ultimately points at (after unwrapping any tuple/lazy string), so the caller
44
+ can recurse into it, or ``None`` if the entry is invalid.
45
+ """
46
+ errors = []
47
+
48
+ if isinstance(field_data, list):
49
+ errors.append(
50
+ Error(
51
+ f"{serializer_class.__name__}.Meta.expandable_fields[{field_name!r}] is a list.",
52
+ hint="flex-fields only supports tuples for expandable_fields values, not lists.",
53
+ obj=serializer_class,
54
+ id="vueda_core.E001",
55
+ )
56
+ )
57
+ return errors, None
58
+
59
+ if isinstance(field_data, tuple) and len(field_data) != 2: # noqa: PLR2004
60
+ errors.append(
61
+ Error(
62
+ f"{serializer_class.__name__}.Meta.expandable_fields[{field_name!r}] is a {len(field_data)}-tuple.",
63
+ hint="A tuple value must be exactly (Serializer/Field class or lazy string, options dict).",
64
+ obj=serializer_class,
65
+ id="vueda_core.E002",
66
+ )
67
+ )
68
+ return errors, None
69
+
70
+ field_serializer, expand_options = _unwrap_expandable_field(field_data)
71
+
72
+ if isinstance(field_data, tuple) and not isinstance(expand_options, dict):
73
+ errors.append(
74
+ Error(
75
+ f"{serializer_class.__name__}.Meta.expandable_fields[{field_name!r}] has a "
76
+ f"{type(expand_options).__name__} options value.",
77
+ hint="A tuple value's second element must be a dict of flex-fields expand options.",
78
+ obj=serializer_class,
79
+ id="vueda_core.E005",
80
+ )
81
+ )
82
+ return errors, None
83
+
84
+ if isinstance(field_serializer, str):
85
+ resolved, error = _resolve_lazy_serializer_string(field_serializer)
86
+ if error:
87
+ errors.append(
88
+ Error(
89
+ f"{serializer_class.__name__}.Meta.expandable_fields[{field_name!r}] has an unresolvable "
90
+ f"serializer string {field_serializer!r}.",
91
+ hint=error,
92
+ obj=serializer_class,
93
+ id="vueda_core.E003",
94
+ )
95
+ )
96
+ return errors, None
97
+ field_serializer = resolved
98
+
99
+ if not inspect.isclass(field_serializer):
100
+ errors.append(
101
+ Error(
102
+ f"{serializer_class.__name__}.Meta.expandable_fields[{field_name!r}] is not a Serializer/Field "
103
+ "class, tuple, or serializer string.",
104
+ hint=(
105
+ "It must be a tuple of (Serializer/Field class or lazy string, options dict), or simply a "
106
+ "Serializer/Field class or lazy string."
107
+ ),
108
+ obj=serializer_class,
109
+ id="vueda_core.E004",
110
+ )
111
+ )
112
+ return errors, None
113
+
114
+ if not issubclass(field_serializer, Field):
115
+ errors.append(
116
+ Error(
117
+ f"{serializer_class.__name__}.Meta.expandable_fields[{field_name!r}] resolves to "
118
+ f"{field_serializer.__name__!r}, which is not a Serializer/Field subclass.",
119
+ hint=("The class must subclass rest_framework.serializers.Serializer or rest_framework.fields.Field."),
120
+ obj=serializer_class,
121
+ id="vueda_core.E006",
122
+ )
123
+ )
124
+ return errors, None
125
+
126
+ return errors, field_serializer
127
+
128
+
129
+ def _iter_expandable_fields(serializer_class):
130
+ """Yield ``(field_name, field_data)`` for each ``Meta.expandable_fields`` entry of ``serializer_class``."""
131
+ meta = getattr(serializer_class, "Meta", None)
132
+ expandable_fields = getattr(meta, "expandable_fields", {}) if meta else {}
133
+ yield from expandable_fields.items()
134
+
135
+
136
+ def _iter_nested_serializer_fields(serializer_class):
137
+ """Yield ``(field_name, child_serializer_class)`` for each declared field of ``serializer_class`` that is
138
+ (or, for a ``many=True`` declaration, wraps via ``ListSerializer.child``) another serializer.
139
+
140
+ Uses the class-level ``_declared_fields`` collected by ``SerializerMetaclass``, so nothing needs to be
141
+ instantiated (and therefore no view needs to be in context) to inspect it.
142
+ """
143
+ for field_name, field in getattr(serializer_class, "_declared_fields", {}).items():
144
+ target = field.child if isinstance(field, ListSerializer) else field
145
+ if isinstance(target, BaseSerializer):
146
+ yield field_name, type(target)
147
+
148
+
149
+ def _resolve_expandable_field_serializer_class(field_data):
150
+ """Best-effort resolve an ``expandable_fields`` entry to its serializer class.
151
+
152
+ Structural validity (tuple shape, resolvable strings, etc.) is ``check_expandable_fields_configuration``'s
153
+ job; this simply returns ``None`` when the entry can't be resolved to a class, so the caller can skip it.
154
+ """
155
+ field_serializer, _expand_options = _unwrap_expandable_field(field_data)
156
+
157
+ if isinstance(field_serializer, str):
158
+ field_serializer, _error = _resolve_lazy_serializer_string(field_serializer)
159
+
160
+ if not inspect.isclass(field_serializer):
161
+ return None
162
+
163
+ return field_serializer
164
+
165
+
166
+ def _get_routed_serializer_classes():
167
+ """Return the serializer classes used by ViewSets registered with a router, anywhere in the URL conf.
168
+
169
+ Walking the resolved URL conf (rather than vueda.info's registry) means this covers any DRF ViewSet
170
+ with a router route, not just ones registered with VUEDA's meta-info system -- the expandable_fields
171
+ format being validated comes from rest_flex_fields, not VUEDA.
172
+ """
173
+ from django.urls import URLPattern
174
+ from django.urls import URLResolver
175
+ from django.urls import get_resolver
176
+
177
+ def walk(patterns, seen_viewsets):
178
+ serializer_classes = []
179
+ for pattern in patterns:
180
+ if isinstance(pattern, URLPattern):
181
+ viewset_class = getattr(pattern.callback, "cls", None)
182
+ if viewset_class is None or viewset_class in seen_viewsets:
183
+ continue
184
+ seen_viewsets.add(viewset_class)
185
+ serializer_class = getattr(viewset_class, "serializer_class", None)
186
+ if serializer_class is not None:
187
+ serializer_classes.append(serializer_class)
188
+ elif isinstance(pattern, URLResolver):
189
+ serializer_classes.extend(walk(pattern.url_patterns, seen_viewsets))
190
+ return serializer_classes
191
+
192
+ return walk(get_resolver().url_patterns, set())
193
+
194
+
195
+ def _get_registered_serializer_classes():
196
+ """Return the canonical serializer classes held in vueda.info's registry.
197
+
198
+ Covers registrations made through both ``register()`` and ``register_serializer()``. A serializer
199
+ registered for metadata alone has no route of its own, so walking the URL conf never reaches it.
200
+ """
201
+ from vueda.info.registration import get_all_registrations
202
+
203
+ return [registration["serializer"] for registration in get_all_registrations().values()]
204
+
205
+
206
+ def _get_seed_serializer_classes():
207
+ """Return the serializer classes graph traversal starts from.
208
+
209
+ The seed is the union of routed ViewSet serializers and vueda.info registrations, routed ones first,
210
+ with duplicates dropped. A serializer that is both routed and registered appears once.
211
+ """
212
+ return list(dict.fromkeys([*_get_routed_serializer_classes(), *_get_registered_serializer_classes()]))
213
+
214
+
215
+ def _iter_serializer_graph():
216
+ """Yield every serializer class reachable from the seed, once each.
217
+
218
+ Traversal follows declared nested serializer fields and ``Meta.expandable_fields`` entries. Both
219
+ serializer system checks walk this one graph, so they cover the same serializers and cannot drift
220
+ apart on discovery.
221
+ """
222
+ seen = set()
223
+ pending = _get_seed_serializer_classes()
224
+
225
+ while pending:
226
+ serializer_class = pending.pop()
227
+ if serializer_class in seen:
228
+ continue
229
+ seen.add(serializer_class)
230
+
231
+ yield serializer_class
232
+
233
+ for _field_name, child_serializer_class in _iter_nested_serializer_fields(serializer_class):
234
+ if child_serializer_class not in seen:
235
+ pending.append(child_serializer_class)
236
+
237
+ for _field_name, field_data in _iter_expandable_fields(serializer_class):
238
+ child_serializer_class = _resolve_expandable_field_serializer_class(field_data)
239
+ if child_serializer_class is not None and child_serializer_class not in seen:
240
+ pending.append(child_serializer_class)
241
+
242
+
243
+ def check_expandable_fields_configuration(app_configs, **kwargs):
244
+ """Validate every ``Meta.expandable_fields`` entry in the discovered serializer graph."""
245
+ errors = []
246
+
247
+ for serializer_class in _iter_serializer_graph():
248
+ for field_name, field_data in _iter_expandable_fields(serializer_class):
249
+ field_errors, _resolved_serializer = _validate_expandable_field(serializer_class, field_name, field_data)
250
+ errors.extend(field_errors)
251
+
252
+ return errors
253
+
254
+
255
+ def check_exclude_fields_serializer_usage(app_configs, **kwargs):
256
+ """
257
+ ``ExcludeFieldsSerializerMixin.get_extra_kwargs()`` reads ``self.context["view"].action``. A view is only
258
+ ever present in context when the serializer is a routed ViewSet's ``serializer_class`` directly -- never
259
+ when it is reached as a nested field, an ``expandable_fields`` entry, or a ``register_serializer()``
260
+ (viewset-less) registration. Those uses raise a bare ``KeyError: 'view'`` from ``manage.py spectacular``
261
+ (and from the ``/info/`` meta-API), since the serializer is instantiated without a view in its context.
262
+ """
263
+ from vueda.core.serializers import ExcludeFieldsSerializerMixin
264
+ from vueda.info.registration import get_all_registrations
265
+
266
+ errors = []
267
+
268
+ for serializer_class in _iter_serializer_graph():
269
+ for field_name, child_serializer_class in _iter_nested_serializer_fields(serializer_class):
270
+ if issubclass(child_serializer_class, ExcludeFieldsSerializerMixin):
271
+ errors.append(
272
+ Error(
273
+ f"{child_serializer_class.__name__} is used as {serializer_class.__name__}'s "
274
+ f"{field_name!r} field, but inherits ExcludeFieldsSerializerMixin.",
275
+ hint=(
276
+ "ExcludeFieldsSerializerMixin requires a view in its context, which is only present "
277
+ "when it is a routed ViewSet's serializer_class directly -- not when nested as a "
278
+ "field on another serializer."
279
+ ),
280
+ obj=child_serializer_class,
281
+ id="vueda_core.E007",
282
+ )
283
+ )
284
+
285
+ for field_name, field_data in _iter_expandable_fields(serializer_class):
286
+ child_serializer_class = _resolve_expandable_field_serializer_class(field_data)
287
+ if child_serializer_class is None:
288
+ continue
289
+
290
+ if issubclass(child_serializer_class, ExcludeFieldsSerializerMixin):
291
+ errors.append(
292
+ Error(
293
+ f"{child_serializer_class.__name__} is used as an expandable field "
294
+ f"({serializer_class.__name__}.Meta.expandable_fields[{field_name!r}]), but inherits "
295
+ "ExcludeFieldsSerializerMixin.",
296
+ hint=(
297
+ "ExcludeFieldsSerializerMixin requires a view in its context, which is only present "
298
+ "when it is a routed ViewSet's serializer_class directly -- not when reachable "
299
+ "through another serializer's expandable_fields."
300
+ ),
301
+ obj=child_serializer_class,
302
+ id="vueda_core.E008",
303
+ )
304
+ )
305
+
306
+ for _key, registration in get_all_registrations().items():
307
+ if registration["viewset"] is not None:
308
+ continue
309
+
310
+ serializer_class = registration["serializer"]
311
+ if issubclass(serializer_class, ExcludeFieldsSerializerMixin):
312
+ errors.append(
313
+ Error(
314
+ f"{serializer_class.__name__} is registered with register_serializer() (no viewset), but "
315
+ "inherits ExcludeFieldsSerializerMixin.",
316
+ hint=(
317
+ "ExcludeFieldsSerializerMixin requires a view in its context, which is never present "
318
+ "for a serializer registered without a viewset."
319
+ ),
320
+ obj=serializer_class,
321
+ id="vueda_core.E009",
322
+ )
323
+ )
324
+
325
+ return errors
326
+
327
+
328
+ def check_model_feature_declaration(model):
329
+ """Validate one model's ``class Vueda`` declaration and its resolved values."""
330
+ from vueda.core.models import supports_vueda_feature_policy
331
+ from vueda.core.options import DECLARATION_ATTRIBUTE
332
+ from vueda.core.options import get_vueda_options
333
+
334
+ if not supports_vueda_feature_policy(model):
335
+ if DECLARATION_ATTRIBUTE in model.__dict__:
336
+ return [
337
+ Error(
338
+ f"{model.__name__} declares class Vueda but is not a VUEDA model.",
339
+ hint=(
340
+ "VUEDA reads feature policy from models built on a VUEDA base such as VuedaModel or "
341
+ "Lookup. Subclass one of those, or remove the declaration."
342
+ ),
343
+ obj=model,
344
+ id="vueda_core.E016",
345
+ )
346
+ ]
347
+ return []
348
+
349
+ options = get_vueda_options(model)
350
+ return [Error(problem.message, hint=problem.hint, obj=model, id=problem.check_id) for problem in options.problems]
351
+
352
+
353
+ def check_model_feature_policy(app_configs, **kwargs):
354
+ """Report every fault in the ``class Vueda`` feature policy of the installed models.
355
+
356
+ Resolution itself never raises, so an unknown section, an unknown option, an invalid value, a
357
+ section whose feature app is absent, or a declaration on a proxy surfaces here rather than as an
358
+ import error while Django loads models.
359
+ """
360
+ from django.apps import apps
361
+
362
+ models = apps.get_models() if app_configs is None else [m for c in app_configs for m in c.get_models()]
363
+
364
+ errors = []
365
+ for model in models:
366
+ errors.extend(check_model_feature_declaration(model))
367
+ return errors