pytest-django-admin-kit 1.0.0__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.
- django_admin_kit/__init__.py +16 -0
- django_admin_kit/config.py +205 -0
- django_admin_kit/errors.py +64 -0
- django_admin_kit/fields.py +246 -0
- django_admin_kit/matching.py +178 -0
- django_admin_kit/messages.py +121 -0
- django_admin_kit/normalize.py +153 -0
- django_admin_kit/pages.py +841 -0
- django_admin_kit/plugin.py +169 -0
- django_admin_kit/py.typed +0 -0
- django_admin_kit/rendered.py +143 -0
- django_admin_kit/results.py +62 -0
- django_admin_kit/rows.py +165 -0
- django_admin_kit/session.py +132 -0
- django_admin_kit/tools.py +27 -0
- django_admin_kit/urls.py +122 -0
- django_admin_kit/widgets.py +172 -0
- pytest_django_admin_kit-1.0.0.dist-info/METADATA +951 -0
- pytest_django_admin_kit-1.0.0.dist-info/RECORD +22 -0
- pytest_django_admin_kit-1.0.0.dist-info/WHEEL +4 -0
- pytest_django_admin_kit-1.0.0.dist-info/entry_points.txt +2 -0
- pytest_django_admin_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""A pytest toolkit for writing conscious tests against the Django admin.
|
|
2
|
+
|
|
3
|
+
Public names are imported from the module that defines them, such as
|
|
4
|
+
``django_admin_kit.pages.AdminPage``. Nothing is re-exported here: this module runs
|
|
5
|
+
at pytest startup in every project that installs the package, and importing the
|
|
6
|
+
session, page or URL modules would load the admin and the browser for all of them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("pytest-django-admin-kit")
|
|
13
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
14
|
+
__version__ = "0.0.0.dev0"
|
|
15
|
+
|
|
16
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Settings for the package, and the rules that reject a bad one loudly.
|
|
2
|
+
|
|
3
|
+
Values come from the ``DJANGO_ADMIN_KIT`` dict in Django settings, or a default. A
|
|
4
|
+
typo raises rather than being ignored, because a silently dropped key disables a
|
|
5
|
+
setting invisibly and the resulting test failure points nowhere near the cause.
|
|
6
|
+
|
|
7
|
+
Which browser to use, whether to show it, and how far to slow it down are not settings
|
|
8
|
+
here. They belong to the pytest plugin the package drives the browser through, and
|
|
9
|
+
duplicating its flags would give a project two places to say the same thing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Iterable, Mapping
|
|
16
|
+
|
|
17
|
+
from django.conf import settings as django_settings
|
|
18
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
19
|
+
from django.utils.module_loading import import_string
|
|
20
|
+
|
|
21
|
+
from .normalize import DEFAULTS, Rule
|
|
22
|
+
|
|
23
|
+
SETTINGS_NAME = "DJANGO_ADMIN_KIT"
|
|
24
|
+
|
|
25
|
+
DEFAULT_TIMEOUT = 30_000
|
|
26
|
+
|
|
27
|
+
# Settings this package owns. Anything the browser plugin already governs is absent
|
|
28
|
+
# on purpose; see the module docstring.
|
|
29
|
+
_KNOWN_KEYS = frozenset({"locale", "normalizers", "site", "timeout", "timezone", "widgets"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Config:
|
|
34
|
+
# A dotted path, resolved later by `urls.resolve_site`. It cannot be an
|
|
35
|
+
# AdminSite instance: settings are imported before the app registry is ready, so
|
|
36
|
+
# a project that wrote one here would fail with AppRegistryNotReady before this
|
|
37
|
+
# package saw the value.
|
|
38
|
+
site: str | None
|
|
39
|
+
timeout: int
|
|
40
|
+
timezone: str | None
|
|
41
|
+
locale: str | None
|
|
42
|
+
# The rules the project replaces, by kind, already imported. The kinds it leaves out
|
|
43
|
+
# keep their defaults.
|
|
44
|
+
normalizers: Mapping[str, Rule] = field(default_factory=dict)
|
|
45
|
+
# The project's widget classes, one instance of each, in the order they are tried.
|
|
46
|
+
widgets: tuple[Any, ...] = ()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _quote(values: Iterable[str]) -> str:
|
|
50
|
+
return ", ".join(repr(value) for value in values)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _positive_int(value: Any, key: str, minimum: int) -> int:
|
|
54
|
+
# bool is a subclass of int, so without the first check `"timeout": True` would
|
|
55
|
+
# pass as a 1ms timeout and every browser operation would fail for no visible
|
|
56
|
+
# reason.
|
|
57
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
58
|
+
raise ImproperlyConfigured(
|
|
59
|
+
f"{SETTINGS_NAME}['{key}'] must be an integer, not {type(value).__name__}."
|
|
60
|
+
)
|
|
61
|
+
if value < minimum:
|
|
62
|
+
raise ImproperlyConfigured(f"{SETTINGS_NAME}['{key}'] must be at least {minimum}.")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _rules(value: Any) -> dict[str, Rule]:
|
|
67
|
+
"""The project's rules by kind, each imported from its dotted path.
|
|
68
|
+
|
|
69
|
+
A rule is named by its path, as the site is, so the settings module never imports
|
|
70
|
+
project code before the app registry is ready.
|
|
71
|
+
"""
|
|
72
|
+
key = f"{SETTINGS_NAME}['normalizers']"
|
|
73
|
+
if not isinstance(value, Mapping):
|
|
74
|
+
raise ImproperlyConfigured(
|
|
75
|
+
f"{key} must be a dict of rule names to dotted paths, not {type(value).__name__}."
|
|
76
|
+
)
|
|
77
|
+
unknown = set(value) - set(DEFAULTS)
|
|
78
|
+
if unknown:
|
|
79
|
+
raise ImproperlyConfigured(
|
|
80
|
+
f"Unknown {key} rule(s): {_quote(sorted(unknown))}. "
|
|
81
|
+
f"Valid rules are: {_quote(DEFAULTS)}."
|
|
82
|
+
)
|
|
83
|
+
rules = {}
|
|
84
|
+
for name, path in value.items():
|
|
85
|
+
if not isinstance(path, str):
|
|
86
|
+
raise ImproperlyConfigured(
|
|
87
|
+
f"{key}[{name!r}] must be a dotted path to a callable, not {type(path).__name__}."
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
rule = import_string(path)
|
|
91
|
+
except ImportError as error:
|
|
92
|
+
raise ImproperlyConfigured(
|
|
93
|
+
f"{key}[{name!r}] names {path!r}, which cannot be imported: {error}"
|
|
94
|
+
) from error
|
|
95
|
+
if not callable(rule):
|
|
96
|
+
raise ImproperlyConfigured(f"{key}[{name!r}] names {path!r}, which is not callable.")
|
|
97
|
+
rules[name] = rule
|
|
98
|
+
return rules
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _widgets(value: Any) -> tuple[Any, ...]:
|
|
102
|
+
"""The project's widget classes, each imported from its dotted path and made once.
|
|
103
|
+
|
|
104
|
+
A class is named by its path, as a rule is, and made with no arguments, as Django
|
|
105
|
+
makes an authentication backend.
|
|
106
|
+
"""
|
|
107
|
+
key = f"{SETTINGS_NAME}['widgets']"
|
|
108
|
+
if not isinstance(value, (list, tuple)):
|
|
109
|
+
raise ImproperlyConfigured(
|
|
110
|
+
f"{key} must be a list of dotted paths to widget classes, not {type(value).__name__}."
|
|
111
|
+
)
|
|
112
|
+
widgets = []
|
|
113
|
+
for index, path in enumerate(value):
|
|
114
|
+
if not isinstance(path, str):
|
|
115
|
+
raise ImproperlyConfigured(
|
|
116
|
+
f"{key}[{index}] must be a dotted path to a widget class, "
|
|
117
|
+
f"not {type(path).__name__}."
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
widget_class = import_string(path)
|
|
121
|
+
except ImportError as error:
|
|
122
|
+
raise ImproperlyConfigured(
|
|
123
|
+
f"{key}[{index}] names {path!r}, which cannot be imported: {error}"
|
|
124
|
+
) from error
|
|
125
|
+
if not isinstance(widget_class, type):
|
|
126
|
+
raise ImproperlyConfigured(f"{key}[{index}] names {path!r}, which is not a class.")
|
|
127
|
+
widget = widget_class()
|
|
128
|
+
for method in ("marker", "read", "fill"):
|
|
129
|
+
if not callable(getattr(widget, method, None)):
|
|
130
|
+
raise ImproperlyConfigured(
|
|
131
|
+
f"{key}[{index}] names {path!r}, which has no {method} method."
|
|
132
|
+
)
|
|
133
|
+
# A widget class offers choices only if its widget has a fixed set of them.
|
|
134
|
+
if hasattr(widget, "choices") and not callable(widget.choices):
|
|
135
|
+
raise ImproperlyConfigured(
|
|
136
|
+
f"{key}[{index}] names {path!r}, whose choices is not a method."
|
|
137
|
+
)
|
|
138
|
+
widgets.append(widget)
|
|
139
|
+
return tuple(widgets)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_config(
|
|
143
|
+
settings: Mapping[str, Any] | None = None,
|
|
144
|
+
default_timezone: str | None = None,
|
|
145
|
+
default_locale: str | None = None,
|
|
146
|
+
) -> Config:
|
|
147
|
+
"""Resolve the settings dict into one frozen Config."""
|
|
148
|
+
given = dict(settings or {})
|
|
149
|
+
|
|
150
|
+
unknown = set(given) - _KNOWN_KEYS
|
|
151
|
+
if unknown:
|
|
152
|
+
raise ImproperlyConfigured(
|
|
153
|
+
f"Unknown {SETTINGS_NAME} setting(s): {_quote(sorted(unknown))}. "
|
|
154
|
+
f"Valid settings are: {_quote(sorted(_KNOWN_KEYS))}."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
timeout = _positive_int(given.get("timeout", DEFAULT_TIMEOUT), "timeout", minimum=1)
|
|
158
|
+
|
|
159
|
+
timezone = given.get("timezone", default_timezone)
|
|
160
|
+
if timezone is not None and not isinstance(timezone, str):
|
|
161
|
+
raise ImproperlyConfigured(
|
|
162
|
+
f"{SETTINGS_NAME}['timezone'] must be an IANA zone name or None, "
|
|
163
|
+
f"not {type(timezone).__name__}."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
locale = given.get("locale", default_locale)
|
|
167
|
+
if locale is not None and not isinstance(locale, str):
|
|
168
|
+
raise ImproperlyConfigured(
|
|
169
|
+
f"{SETTINGS_NAME}['locale'] must be a language tag such as 'en-us' or None, "
|
|
170
|
+
f"not {type(locale).__name__}."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
site = given.get("site")
|
|
174
|
+
if site is not None and not isinstance(site, str):
|
|
175
|
+
raise ImproperlyConfigured(
|
|
176
|
+
f"{SETTINGS_NAME}['site'] must be a dotted path to an AdminSite, "
|
|
177
|
+
f"not {type(site).__name__}."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
normalizers = _rules(given.get("normalizers", {}))
|
|
181
|
+
widgets = _widgets(given.get("widgets", []))
|
|
182
|
+
|
|
183
|
+
return Config(
|
|
184
|
+
site=site,
|
|
185
|
+
timeout=timeout,
|
|
186
|
+
timezone=timezone,
|
|
187
|
+
locale=locale,
|
|
188
|
+
normalizers=normalizers,
|
|
189
|
+
widgets=widgets,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def from_django_settings() -> Config:
|
|
194
|
+
"""Build the configuration from the project's own Django settings.
|
|
195
|
+
|
|
196
|
+
The time zone defaults to the project's ``TIME_ZONE`` so that a rendered date
|
|
197
|
+
reads the same in the browser as it does in a Django template. The locale
|
|
198
|
+
defaults to ``LANGUAGE_CODE``, so a project whose admin follows the browser's
|
|
199
|
+
``Accept-Language`` renders the same language and formats on every machine.
|
|
200
|
+
"""
|
|
201
|
+
return build_config(
|
|
202
|
+
getattr(django_settings, SETTINGS_NAME, None),
|
|
203
|
+
default_timezone=getattr(django_settings, "TIME_ZONE", None),
|
|
204
|
+
default_locale=getattr(django_settings, "LANGUAGE_CODE", None),
|
|
205
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The validation errors a create or edit form shows after the admin rejected it.
|
|
2
|
+
|
|
3
|
+
The admin shows them at three levels, and each is read on its own: the summary
|
|
4
|
+
notice above the form, the errors of the form as a whole, and the errors of each field.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Callable
|
|
10
|
+
|
|
11
|
+
from playwright.sync_api import Locator
|
|
12
|
+
|
|
13
|
+
from .rendered import text_of
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .fields import Fields
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationErrors:
|
|
20
|
+
"""The errors a form shows, read from the page each time they are asked for.
|
|
21
|
+
|
|
22
|
+
``form`` gives the form element and ``fields`` the form's fields; both fail when the
|
|
23
|
+
browser no longer shows the page the form is on.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, form: Callable[[], Locator], fields: Callable[[], Fields]) -> None:
|
|
27
|
+
self._form = form
|
|
28
|
+
self._fields = fields
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def banner(self) -> str:
|
|
32
|
+
"""The notice the admin shows above a rejected form, or ``""`` when it shows none.
|
|
33
|
+
|
|
34
|
+
Its wording depends on how many errors there are, so a test that only cares that
|
|
35
|
+
the form was rejected asserts that it is there.
|
|
36
|
+
"""
|
|
37
|
+
notice = self._form().locator("p.errornote")
|
|
38
|
+
return text_of(notice) if notice.count() else ""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def non_field(self) -> list[str]:
|
|
42
|
+
"""The errors of the form as a whole rather than of one field, in order.
|
|
43
|
+
|
|
44
|
+
The notice is not one of them, though the admin shows them right under it.
|
|
45
|
+
"""
|
|
46
|
+
# The form's own list is a direct child of the form's one div, which leaves out
|
|
47
|
+
# the lists of the inline formsets next to it.
|
|
48
|
+
items = self._form().locator(":scope > div > ul.errorlist.nonfield > li")
|
|
49
|
+
return [text_of(item) for item in items.all()]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def fields(self) -> dict[str, list[str]]:
|
|
53
|
+
"""The errors of each field that has any, by field name, in the order the admin
|
|
54
|
+
presents the fields. One field's errors are read from it: ``field.errors``."""
|
|
55
|
+
return {name: errors for name, field in self._fields().items() if (errors := field.errors)}
|
|
56
|
+
|
|
57
|
+
def __bool__(self) -> bool:
|
|
58
|
+
return bool(self.banner or self.non_field or self.fields)
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return (
|
|
62
|
+
f"ValidationErrors(banner={self.banner!r}, non_field={self.non_field!r}, "
|
|
63
|
+
f"fields={self.fields!r})"
|
|
64
|
+
)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""The fields of an admin form, as the page shows them.
|
|
2
|
+
|
|
3
|
+
A field is addressed by its name, never by the label shown next to it. Its element is
|
|
4
|
+
the box the admin draws around label, control and help text, which is the one thing
|
|
5
|
+
every field has whether the user may fill it or only read it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict
|
|
11
|
+
|
|
12
|
+
from django.contrib.admin import ModelAdmin
|
|
13
|
+
from django.utils import translation
|
|
14
|
+
from playwright.sync_api import Locator
|
|
15
|
+
|
|
16
|
+
from .normalize import Normalizers
|
|
17
|
+
from .rendered import Link, RenderedValue, text_of
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .widgets import Widget, Widgets
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FieldChoice:
|
|
24
|
+
"""One option a field offers: its ``value``, what the form posts, and its ``label``,
|
|
25
|
+
what the user reads.
|
|
26
|
+
|
|
27
|
+
A choice compares equal to another choice and to a ``(value, label)`` pair, as a
|
|
28
|
+
tuple or a list, and to nothing else. A bare string is neither part, so a test
|
|
29
|
+
says which one it means: ``field.value.label == "Tools"``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, value: str, label: str) -> None:
|
|
33
|
+
self._value = value
|
|
34
|
+
self._label = label
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def value(self) -> str:
|
|
38
|
+
return self._value
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def label(self) -> str:
|
|
42
|
+
return self._label
|
|
43
|
+
|
|
44
|
+
def __eq__(self, other: object) -> bool:
|
|
45
|
+
if isinstance(other, FieldChoice):
|
|
46
|
+
return (self._value, self._label) == (other._value, other._label)
|
|
47
|
+
if isinstance(other, (tuple, list)) and len(other) == 2:
|
|
48
|
+
return (self._value, self._label) == tuple(other)
|
|
49
|
+
return NotImplemented
|
|
50
|
+
|
|
51
|
+
def __hash__(self) -> int:
|
|
52
|
+
return hash((self._value, self._label))
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return f"FieldChoice({self._value!r}, {self._label!r})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FormField:
|
|
59
|
+
"""One field of an admin form.
|
|
60
|
+
|
|
61
|
+
``language`` is the one the page was rendered in, which decides what the admin put
|
|
62
|
+
after the label. ``check`` is run before every read, and fails when the browser no
|
|
63
|
+
longer shows the page the field is on. ``normalizers`` read the value of a field the
|
|
64
|
+
user may only read, by what stands behind it on the page ``model_admin`` draws, and
|
|
65
|
+
``widgets`` read and fill the field's control.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
element: Locator,
|
|
71
|
+
name: str,
|
|
72
|
+
*,
|
|
73
|
+
language: str,
|
|
74
|
+
check: Callable[[], None],
|
|
75
|
+
normalizers: Normalizers,
|
|
76
|
+
model_admin: ModelAdmin,
|
|
77
|
+
widgets: Widgets,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._element = element
|
|
80
|
+
self._name = name
|
|
81
|
+
self._language = language
|
|
82
|
+
self._check = check
|
|
83
|
+
self._normalizers = normalizers
|
|
84
|
+
self._model_admin = model_admin
|
|
85
|
+
self._widgets = widgets
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def name(self) -> str:
|
|
89
|
+
"""The name the form knows the field by."""
|
|
90
|
+
return self._name
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def native(self) -> Locator:
|
|
94
|
+
"""The field's box, unwrapped, for anything the package does not model."""
|
|
95
|
+
return self._element
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def required(self) -> bool:
|
|
99
|
+
"""Whether the form requires a value, as the admin tells the user.
|
|
100
|
+
|
|
101
|
+
The admin marks a required field on its label, from the form field as the form
|
|
102
|
+
finally has it, so a ``ModelForm`` that changes what the model says is read the
|
|
103
|
+
way the user sees it. A field the user may only read is never required.
|
|
104
|
+
"""
|
|
105
|
+
self._check()
|
|
106
|
+
return "required" in (self._label.get_attribute("class") or "").split()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def label(self) -> str:
|
|
110
|
+
"""The label the admin shows next to the field, as data and never as an address.
|
|
111
|
+
|
|
112
|
+
The suffix the form puts after every label, ``:`` in English and whatever the
|
|
113
|
+
page's language makes of it, is not part of the label and is taken off.
|
|
114
|
+
"""
|
|
115
|
+
self._check()
|
|
116
|
+
label = text_of(self._label)
|
|
117
|
+
# The suffix is Django's own translated ":", so the catalog says what the page
|
|
118
|
+
# ends each label with: "Name:" in English, "Name\xa0:" in French, a full-width
|
|
119
|
+
# colon in Traditional Chinese. It is looked up in the page's language, not the
|
|
120
|
+
# test's: a project that picks the language per request renders the page in
|
|
121
|
+
# one while the test thread is in another. Collapsing the suffix as the label
|
|
122
|
+
# was collapsed turns the French "\xa0:" into ":", so "Name :" ends with it
|
|
123
|
+
# and the space left over is dropped too.
|
|
124
|
+
with translation.override(self._language):
|
|
125
|
+
suffix = " ".join(translation.gettext(":").split())
|
|
126
|
+
return label[: -len(suffix)].rstrip() if label.endswith(suffix) else label
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def editable(self) -> bool:
|
|
130
|
+
"""Whether the field has a control to fill, rather than a value to read."""
|
|
131
|
+
self._check()
|
|
132
|
+
return self._element.locator("div.readonly").count() == 0
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def value(self) -> Any:
|
|
136
|
+
"""What the field holds right now, as the user reads it.
|
|
137
|
+
|
|
138
|
+
A control is read by its widget class: a checkbox reads as ``True`` or
|
|
139
|
+
``False``, a select as the ``FieldChoice`` chosen, anything else as the text
|
|
140
|
+
typed into it. A field the user may only read is read as a changelist cell is:
|
|
141
|
+
its text, or the boolean the admin drew as an icon. A control no widget class
|
|
142
|
+
recognizes raises ``LookupError``.
|
|
143
|
+
"""
|
|
144
|
+
self._check()
|
|
145
|
+
if not self.editable:
|
|
146
|
+
return self._rendered.value
|
|
147
|
+
return self._widget("read").read(self)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def links(self) -> list[Link]:
|
|
151
|
+
"""The links in a rendered-only field's value, in order, like ``Cell.links``.
|
|
152
|
+
|
|
153
|
+
``[]`` for an editable field: its value is what the control holds, and the
|
|
154
|
+
icons the admin may draw next to a select are not part of it.
|
|
155
|
+
"""
|
|
156
|
+
self._check()
|
|
157
|
+
return [] if self.editable else self._rendered.links
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def errors(self) -> list[str]:
|
|
161
|
+
"""The errors the admin shows for the field, in order; ``[]`` when it shows none.
|
|
162
|
+
|
|
163
|
+
A field can fail several rules at once, so this is always a list.
|
|
164
|
+
"""
|
|
165
|
+
self._check()
|
|
166
|
+
items = self._element.locator("ul.errorlist > li").all()
|
|
167
|
+
# A field that shares its line with others has a box of its own. Django 4.2 to 6.0
|
|
168
|
+
# put its errors next to that box, in a wrapper around the two; the others put
|
|
169
|
+
# them inside it. The wrapper holds one field, so its list is this field's.
|
|
170
|
+
if "fieldBox" in (self._element.get_attribute("class") or "").split():
|
|
171
|
+
wrapper = self._element.locator("xpath=..")
|
|
172
|
+
items = wrapper.locator(":scope > ul.errorlist > li").all() + items
|
|
173
|
+
return [text_of(item) for item in items]
|
|
174
|
+
|
|
175
|
+
def fill(self, value: Any) -> None:
|
|
176
|
+
"""Put ``value`` into the field, the way a user would.
|
|
177
|
+
|
|
178
|
+
The field's widget class fills it: a checkbox is checked or cleared by the
|
|
179
|
+
value's truth, a select is given the option that stands for the value, and every
|
|
180
|
+
other control is filled with the value's text, ``None`` emptying it. A field the
|
|
181
|
+
user may only read has nothing to fill, and a control no widget class recognizes
|
|
182
|
+
cannot be filled: both raise ``LookupError``.
|
|
183
|
+
"""
|
|
184
|
+
self._check()
|
|
185
|
+
if not self.editable:
|
|
186
|
+
raise LookupError(
|
|
187
|
+
f"The field {self._name!r} is rendered only, so there is nothing to fill."
|
|
188
|
+
)
|
|
189
|
+
self._widget("fill").fill(self, value)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def choices(self) -> list[FieldChoice]:
|
|
193
|
+
"""The options a select offers, in order, the blank one included.
|
|
194
|
+
|
|
195
|
+
``[]`` for any other field: a text input has no fixed set of options, and a
|
|
196
|
+
field the user may only read offers none.
|
|
197
|
+
"""
|
|
198
|
+
self._check()
|
|
199
|
+
if not self.editable:
|
|
200
|
+
return []
|
|
201
|
+
choices = getattr(self._widgets.widget_for(self), "choices", None)
|
|
202
|
+
return [] if choices is None else choices(self)
|
|
203
|
+
|
|
204
|
+
def _widget(self, verb: str) -> Widget:
|
|
205
|
+
"""The widget class that reads and fills the field. A field none of them
|
|
206
|
+
recognizes is reported at once, where waiting for a control would time out."""
|
|
207
|
+
widget = self._widgets.widget_for(self)
|
|
208
|
+
if widget is None:
|
|
209
|
+
raise LookupError(
|
|
210
|
+
f"No widget class can {verb} the field {self._name!r}. Add one for it to "
|
|
211
|
+
"DJANGO_ADMIN_KIT['widgets']."
|
|
212
|
+
)
|
|
213
|
+
return widget
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def _label(self) -> Locator:
|
|
217
|
+
# Django labels most fields with `label` and, from 6.0, a widget that groups
|
|
218
|
+
# several inputs with `legend`.
|
|
219
|
+
return self._element.locator("label, legend").first
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def _rendered(self) -> RenderedValue:
|
|
223
|
+
return RenderedValue(
|
|
224
|
+
self._element.locator("div.readonly"),
|
|
225
|
+
self._name,
|
|
226
|
+
check=self._check,
|
|
227
|
+
normalizers=self._normalizers,
|
|
228
|
+
model_admin=self._model_admin,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def __repr__(self) -> str:
|
|
232
|
+
return f"FormField({self._name!r})"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class Fields(Dict[str, FormField]):
|
|
236
|
+
"""The fields of a form by name, in the order the admin presents them.
|
|
237
|
+
|
|
238
|
+
A plain dict, so membership, ``set()`` and ``list()`` are what they always are;
|
|
239
|
+
only a miss says more than a bare ``KeyError`` would.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __missing__(self, key: str) -> FormField:
|
|
243
|
+
raise KeyError(
|
|
244
|
+
f"The form has no field named {key!r}. Fields: "
|
|
245
|
+
f"{', '.join(repr(name) for name in self)}."
|
|
246
|
+
)
|