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.
- vueda/__init__.py +5 -0
- vueda/__main__.py +89 -0
- vueda/cli.py +62 -0
- vueda/core/__init__.py +3 -0
- vueda/core/apps.py +22 -0
- vueda/core/audit.py +80 -0
- vueda/core/checks.py +367 -0
- vueda/core/config.py +445 -0
- vueda/core/db.py +10 -0
- vueda/core/decorators.py +93 -0
- vueda/core/default_settings.py +523 -0
- vueda/core/exceptions.py +225 -0
- vueda/core/features.py +102 -0
- vueda/core/fields/__init__.py +3 -0
- vueda/core/fields/form.py +69 -0
- vueda/core/fields/serializers.py +104 -0
- vueda/core/filters.py +618 -0
- vueda/core/formatted_name.py +239 -0
- vueda/core/installed_apps.py +31 -0
- vueda/core/logging_filters.py +58 -0
- vueda/core/middleware.py +70 -0
- vueda/core/models.py +479 -0
- vueda/core/open_api.py +509 -0
- vueda/core/options.py +358 -0
- vueda/core/ordering.py +453 -0
- vueda/core/pagination.py +79 -0
- vueda/core/patch_django.py +140 -0
- vueda/core/permissions.py +389 -0
- vueda/core/routers.py +136 -0
- vueda/core/serializers/__init__.py +953 -0
- vueda/core/serializers/fields.py +147 -0
- vueda/core/spectacular_hooks.py +204 -0
- vueda/core/tokens.py +9 -0
- vueda/core/utils.py +49 -0
- vueda/core/validators.py +33 -0
- vueda/core/views.py +43 -0
- vueda/core/viewsets/__init__.py +1090 -0
- vueda/core/widgets.py +64 -0
- vueda/history/__init__.py +3 -0
- vueda/history/actions.py +169 -0
- vueda/history/apps.py +167 -0
- vueda/history/checks.py +65 -0
- vueda/history/fields.py +52 -0
- vueda/history/middleware.py +20 -0
- vueda/history/models.py +42 -0
- vueda/history/queries.py +77 -0
- vueda/history/revision.py +103 -0
- vueda/history/serializers/__init__.py +1 -0
- vueda/history/serializers/actions.py +62 -0
- vueda/history/serializers/mixins.py +298 -0
- vueda/history/serializers/users.py +50 -0
- vueda/history/snapshots.py +30 -0
- vueda/history/urls.py +16 -0
- vueda/history/utils.py +43 -0
- vueda/history/views.py +86 -0
- vueda/history/viewsets.py +128 -0
- vueda/history/visibility.py +79 -0
- vueda/info/__init__.py +9 -0
- vueda/info/apps.py +58 -0
- vueda/info/checks.py +824 -0
- vueda/info/field_resolution.py +153 -0
- vueda/info/jinja2/info/overview.jinja2 +183 -0
- vueda/info/open_api_tracebacks.py +85 -0
- vueda/info/registration.py +190 -0
- vueda/info/routers.py +25 -0
- vueda/info/schema.py +8 -0
- vueda/info/serializers.py +3220 -0
- vueda/info/static/info/overview.css +496 -0
- vueda/info/static/info/overview.js +7 -0
- vueda/info/urls.py +29 -0
- vueda/info/views.py +289 -0
- vueda/info/viewsets.py +545 -0
- vueda/release/__init__.py +9 -0
- vueda/release/apps.py +19 -0
- vueda/release/filtersets.py +12 -0
- vueda/release/migrations/0001_initial.py +34 -0
- vueda/release/migrations/0002_releasenoteevent_releasenote_insert_insert_and_more.py +110 -0
- vueda/release/migrations/__init__.py +0 -0
- vueda/release/models.py +18 -0
- vueda/release/routers.py +14 -0
- vueda/release/serializers.py +17 -0
- vueda/release/urls.py +11 -0
- vueda/release/viewsets.py +18 -0
- vueda/update.py +650 -0
- vueda/user/__init__.py +3 -0
- vueda/user/adapters.py +221 -0
- vueda/user/apps.py +32 -0
- vueda/user/decorators.py +41 -0
- vueda/user/fields.py +20 -0
- vueda/user/filtersets.py +14 -0
- vueda/user/globals.py +37 -0
- vueda/user/jinja2/permissions/overview.jinja2 +79 -0
- vueda/user/management/__init__.py +0 -0
- vueda/user/management/commands/__init__.py +0 -0
- vueda/user/management/commands/createuser.py +75 -0
- vueda/user/management/commands/makegroupmigrations.py +583 -0
- vueda/user/management/commands/sync_group_changes.py +54 -0
- vueda/user/management/commands/updategroupmigrations.py +169 -0
- vueda/user/management/commands/utils.py +499 -0
- vueda/user/migrations/0001_initial.py +39 -0
- vueda/user/migrations/0002_initial.py +44 -0
- vueda/user/migrations/0003_totpdevice.py +74 -0
- vueda/user/migrations/0004_alter_totpdevice_options.py +20 -0
- vueda/user/migrations/0005_sync_group_changes.py +24 -0
- vueda/user/migrations/0006_totpdeviceevent_totpdevice_insert_insert_and_more.py +149 -0
- vueda/user/migrations/__init__.py +0 -0
- vueda/user/mixins.py +82 -0
- vueda/user/models.py +231 -0
- vueda/user/permissions.py +13 -0
- vueda/user/routers.py +14 -0
- vueda/user/serializers.py +303 -0
- vueda/user/static/permissions/overview.css +210 -0
- vueda/user/static/permissions/overview.js +184 -0
- vueda/user/templates/email/forgot_password_message.html +10 -0
- vueda/user/templates/email/forgot_password_subject.txt +1 -0
- vueda/user/templates/email/totp_code_message.txt +1 -0
- vueda/user/templates/email/totp_code_subject.txt +1 -0
- vueda/user/templates/email/welcome_user_message.txt +17 -0
- vueda/user/templates/email/welcome_user_subject.txt +1 -0
- vueda/user/templates/registration/logged_out.html +11 -0
- vueda/user/templates/registration/login.html +39 -0
- vueda/user/templates/registration/logout_form.html +6 -0
- vueda/user/urls.py +52 -0
- vueda/user/utils.py +30 -0
- vueda/user/views.py +729 -0
- vueda/user/viewsets.py +195 -0
- vueda/vdq/__init__.py +5 -0
- vueda/vdq/apps.py +29 -0
- vueda/vdq/celery.py +43 -0
- vueda/vdq/constants.py +5 -0
- vueda/vdq/exceptions.py +9 -0
- vueda/vdq/filtersets.py +23 -0
- vueda/vdq/handlers.py +314 -0
- vueda/vdq/migrations/0001_initial.py +380 -0
- vueda/vdq/migrations/0002_workflow_migrations_2025_09_22.py +1409 -0
- vueda/vdq/migrations/0003_alter_queueitem_content_type.py +24 -0
- vueda/vdq/migrations/0004_alter_queueitem_done_since_alter_queueitem_object_id.py +24 -0
- vueda/vdq/migrations/0005_workflow_migrations_2025_11_21.py +947 -0
- vueda/vdq/migrations/0006_alter_sentitem_options.py +19 -0
- vueda/vdq/migrations/0007_queueitemevent_receiverevent_senderevent_and_more.py +351 -0
- vueda/vdq/migrations/__init__.py +0 -0
- vueda/vdq/migrations/__pycache__/0002_workflow_migrations_2025_09_22.cpython-314.pyc +0 -0
- vueda/vdq/migrations/__pycache__/0005_workflow_migrations_2025_11_21.cpython-314.pyc +0 -0
- vueda/vdq/models.py +409 -0
- vueda/vdq/permissions.py +19 -0
- vueda/vdq/routers.py +16 -0
- vueda/vdq/schedulers.py +175 -0
- vueda/vdq/serializers.py +162 -0
- vueda/vdq/tasks.py +183 -0
- vueda/vdq/urls.py +30 -0
- vueda/vdq/utils.py +21 -0
- vueda/vdq/views.py +104 -0
- vueda/vdq/viewsets.py +96 -0
- vueda/workflow/__init__.py +3 -0
- vueda/workflow/apps.py +55 -0
- vueda/workflow/exceptions.py +7 -0
- vueda/workflow/fields.py +33 -0
- vueda/workflow/filtersets.py +36 -0
- vueda/workflow/forms.py +410 -0
- vueda/workflow/globals.py +24 -0
- vueda/workflow/jinja2/workflow/add.jinja2 +49 -0
- vueda/workflow/jinja2/workflow/edit.jinja2 +63 -0
- vueda/workflow/jinja2/workflow/formset.jinja2 +30 -0
- vueda/workflow/jinja2/workflow/overview.jinja2 +172 -0
- vueda/workflow/management/__init__.py +0 -0
- vueda/workflow/management/commands/__init__.py +0 -0
- vueda/workflow/management/commands/makeworkflowmigrations.py +2255 -0
- vueda/workflow/management/commands/updateworkflowmigrations.py +196 -0
- vueda/workflow/migrations/0001_initial.py +295 -0
- vueda/workflow/migrations/0002_statepermission_historical_group_name_and_more.py +576 -0
- vueda/workflow/migrations/0003_alter_initialstate_options_alter_state_options_and_more.py +88 -0
- vueda/workflow/migrations/0004_alter_statepermission_options_and_more.py +36 -0
- vueda/workflow/migrations/0005_historicaltransitionsource_fail_with_silent_and_more.py +23 -0
- vueda/workflow/migrations/0006_rename_fail_with_silent_historicaltransitionsource_ignored_and_more.py +22 -0
- vueda/workflow/migrations/0007_workflowevent_workflow_insert_insert_and_more.py +123 -0
- vueda/workflow/migrations/0008_initialstateevent_objectstateevent_stateevent_and_more.py +931 -0
- vueda/workflow/migrations/0009_remove_historicalobjectstate_history_relation_and_more.py +163 -0
- vueda/workflow/migrations/__init__.py +0 -0
- vueda/workflow/mixins.py +98 -0
- vueda/workflow/models.py +1047 -0
- vueda/workflow/open_api_tracebacks.py +88 -0
- vueda/workflow/permissions.py +24 -0
- vueda/workflow/routers.py +15 -0
- vueda/workflow/serializers.py +756 -0
- vueda/workflow/sql/view-vueda_workflow_objectstateproxy.sql +24 -0
- vueda/workflow/static/forms/forms.css +428 -0
- vueda/workflow/static/forms/forms.js +104 -0
- vueda/workflow/templates/workflow/add_button.html +1 -0
- vueda/workflow/templates/workflow/delete_button.html +4 -0
- vueda/workflow/templates/workflow/edit_button.html +1 -0
- vueda/workflow/templates/workflow/view_button.html +1 -0
- vueda/workflow/urls.py +32 -0
- vueda/workflow/views.py +373 -0
- vueda/workflow/viewsets.py +346 -0
- vueda-3.0.0a1.dist-info/METADATA +208 -0
- vueda-3.0.0a1.dist-info/RECORD +200 -0
- vueda-3.0.0a1.dist-info/WHEEL +5 -0
- vueda-3.0.0a1.dist-info/entry_points.txt +2 -0
- vueda-3.0.0a1.dist-info/licenses/LICENSE +29 -0
- vueda-3.0.0a1.dist-info/top_level.txt +1 -0
vueda/__init__.py
ADDED
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
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
|