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,27 @@
|
|
|
1
|
+
"""What a session hands to every page it opens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .normalize import Normalizers
|
|
10
|
+
from .urls import AdminUrls
|
|
11
|
+
from .widgets import Widgets
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PageTools:
|
|
16
|
+
"""What every page of a session reads by: where the admin's pages are, the rules
|
|
17
|
+
that read the values they render, and the widget classes that read and fill their
|
|
18
|
+
controls.
|
|
19
|
+
|
|
20
|
+
Only what is built once from the settings or the session's fixtures, and is the same
|
|
21
|
+
for every page, belongs here. What belongs to one page, row, cell or field is passed
|
|
22
|
+
to it on its own. A page hands the objects it builds only the tools they use.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
urls: AdminUrls
|
|
26
|
+
normalizers: Normalizers
|
|
27
|
+
widgets: Widgets
|
django_admin_kit/urls.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Admin URLs, resolved through the site under test.
|
|
2
|
+
|
|
3
|
+
Every URL comes from Django's resolver, so a project that mounts its admin at
|
|
4
|
+
``/backoffice/`` or runs a second ``AdminSite`` gets correct URLs without changing a
|
|
5
|
+
test. Nothing here assembles a path from string pieces.
|
|
6
|
+
|
|
7
|
+
Resolution says nothing about access. Asking for the URL of a page the current user
|
|
8
|
+
could not open still returns that URL; whether it works is a separate question.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Sequence
|
|
14
|
+
|
|
15
|
+
from django.contrib.admin import ModelAdmin
|
|
16
|
+
from django.contrib.admin.sites import AdminSite
|
|
17
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
18
|
+
from django.urls import reverse
|
|
19
|
+
from django.utils.module_loading import import_string
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def registered_models(site: AdminSite) -> list[type[Any]]:
|
|
23
|
+
"""The models ``site`` registers, in the order they were registered."""
|
|
24
|
+
return list(_registry(site))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def model_admin(site: AdminSite, model: type[Any]) -> ModelAdmin:
|
|
28
|
+
"""The model admin ``site`` registers ``model`` with."""
|
|
29
|
+
# Django 5.0 added a public accessor; before it there is only the registry.
|
|
30
|
+
if hasattr(site, "get_model_admin"):
|
|
31
|
+
return site.get_model_admin(model)
|
|
32
|
+
return _registry(site)[model]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _registry(site: AdminSite) -> dict[type[Any], ModelAdmin]:
|
|
36
|
+
# The one place the site's registry is read: Django lists the registered models
|
|
37
|
+
# nowhere public on any version.
|
|
38
|
+
registry: dict[type[Any], ModelAdmin] = site._registry
|
|
39
|
+
return registry
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_site(site: str | None = None) -> AdminSite:
|
|
43
|
+
"""Turn the ``site`` setting into an ``AdminSite``.
|
|
44
|
+
|
|
45
|
+
Takes a dotted path, or ``None`` for the default ``django.contrib.admin.site``.
|
|
46
|
+
Only a path, because settings are imported before Django's app registry is ready
|
|
47
|
+
and an ``AdminSite`` written into settings raises ``AppRegistryNotReady`` there.
|
|
48
|
+
Code holding a site already can pass it straight to ``AdminUrls``.
|
|
49
|
+
"""
|
|
50
|
+
if site is None:
|
|
51
|
+
from django.contrib import admin
|
|
52
|
+
|
|
53
|
+
return admin.site
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
imported = import_string(site)
|
|
57
|
+
except ImportError as error:
|
|
58
|
+
raise ImproperlyConfigured(
|
|
59
|
+
f"DJANGO_ADMIN_KIT['site'] is {site!r}, which does not import: {error}"
|
|
60
|
+
) from error
|
|
61
|
+
|
|
62
|
+
if not isinstance(imported, AdminSite):
|
|
63
|
+
raise ImproperlyConfigured(
|
|
64
|
+
f"DJANGO_ADMIN_KIT['site'] is {site!r}, which is a "
|
|
65
|
+
f"{type(imported).__name__}, not an AdminSite."
|
|
66
|
+
)
|
|
67
|
+
return imported
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AdminUrls:
|
|
71
|
+
"""Every admin page this package knows, addressable without opening it."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, site: AdminSite) -> None:
|
|
74
|
+
self._site = site
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def site(self) -> AdminSite:
|
|
78
|
+
return self._site
|
|
79
|
+
|
|
80
|
+
def index(self) -> str:
|
|
81
|
+
return self._reverse("index")
|
|
82
|
+
|
|
83
|
+
def login(self) -> str:
|
|
84
|
+
return self._reverse("login")
|
|
85
|
+
|
|
86
|
+
def list(self, model: type[Any]) -> str:
|
|
87
|
+
return self._reverse_model(model, "changelist")
|
|
88
|
+
|
|
89
|
+
def create(self, model: type[Any]) -> str:
|
|
90
|
+
return self._reverse_model(model, "add")
|
|
91
|
+
|
|
92
|
+
def edit(self, instance: Any) -> str:
|
|
93
|
+
return self._reverse_model(type(instance), "change", [self._pk(instance, "edit")])
|
|
94
|
+
|
|
95
|
+
def delete(self, instance: Any) -> str:
|
|
96
|
+
return self._reverse_model(type(instance), "delete", [self._pk(instance, "delete")])
|
|
97
|
+
|
|
98
|
+
def _reverse(self, name: str, args: Sequence[Any] | None = None) -> str:
|
|
99
|
+
# The app namespace is always "admin"; the site's own name is the instance
|
|
100
|
+
# namespace, which is what picks between two mounted sites.
|
|
101
|
+
return str(reverse(f"admin:{name}", args=args, current_app=self._site.name))
|
|
102
|
+
|
|
103
|
+
def _reverse_model(
|
|
104
|
+
self, model: type[Any], action: str, args: Sequence[Any] | None = None
|
|
105
|
+
) -> str:
|
|
106
|
+
if not self._site.is_registered(model):
|
|
107
|
+
labels = sorted(m._meta.label for m in registered_models(self._site))
|
|
108
|
+
raise LookupError(
|
|
109
|
+
f"{model._meta.label} is not registered with the {self._site.name!r} admin "
|
|
110
|
+
f"site, so it has no admin URLs. Registered models: "
|
|
111
|
+
f"{', '.join(labels) or 'none'}."
|
|
112
|
+
)
|
|
113
|
+
return self._reverse(f"{model._meta.app_label}_{model._meta.model_name}_{action}", args)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _pk(instance: Any, action: str) -> Any:
|
|
117
|
+
if instance.pk is None:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Cannot build the {action} URL for an unsaved "
|
|
120
|
+
f"{type(instance)._meta.label}: it has no primary key yet."
|
|
121
|
+
)
|
|
122
|
+
return instance.pk
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The widget classes that read and fill a form's controls.
|
|
2
|
+
|
|
3
|
+
A widget class recognizes its widget by a marker, a CSS selector found inside a field's
|
|
4
|
+
box, and says how to read the field and how to fill it. The marker only picks the
|
|
5
|
+
class: ``read`` and ``fill`` get the whole field and find their own elements in its box.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Iterable, Protocol
|
|
11
|
+
|
|
12
|
+
from django.db.models import Model
|
|
13
|
+
from playwright.sync_api import Locator
|
|
14
|
+
|
|
15
|
+
from .fields import FieldChoice
|
|
16
|
+
from .rendered import text_of
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .fields import FormField
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Widget(Protocol):
|
|
23
|
+
"""What the package asks of a widget class. ``choices(field)`` is optional."""
|
|
24
|
+
|
|
25
|
+
def marker(self, field: FormField) -> str: ...
|
|
26
|
+
|
|
27
|
+
def read(self, field: FormField) -> Any: ...
|
|
28
|
+
|
|
29
|
+
def fill(self, field: FormField, value: Any) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CheckboxWidget:
|
|
33
|
+
"""A checkbox, read and filled as a boolean."""
|
|
34
|
+
|
|
35
|
+
def marker(self, field: FormField) -> str:
|
|
36
|
+
return f'input[type="checkbox"][name="{field.name}"]'
|
|
37
|
+
|
|
38
|
+
def read(self, field: FormField) -> bool:
|
|
39
|
+
return self._control(field).is_checked()
|
|
40
|
+
|
|
41
|
+
def fill(self, field: FormField, value: Any) -> None:
|
|
42
|
+
self._control(field).set_checked(bool(value))
|
|
43
|
+
|
|
44
|
+
def _control(self, field: FormField) -> Locator:
|
|
45
|
+
return field.native.locator(f'input[type="checkbox"][name="{field.name}"]')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SelectWidget:
|
|
49
|
+
"""A select, read as the ``FieldChoice`` chosen and filled with the option that
|
|
50
|
+
stands for a value."""
|
|
51
|
+
|
|
52
|
+
def marker(self, field: FormField) -> str:
|
|
53
|
+
return f'select[name="{field.name}"]'
|
|
54
|
+
|
|
55
|
+
def read(self, field: FormField) -> FieldChoice:
|
|
56
|
+
option = self._control(field).locator("option:checked")
|
|
57
|
+
return FieldChoice(option.get_attribute("value") or "", text_of(option))
|
|
58
|
+
|
|
59
|
+
def fill(self, field: FormField, value: Any) -> None:
|
|
60
|
+
option = self._option(field, value)
|
|
61
|
+
# An option is asked for by its value, except the blank one, whose value is the
|
|
62
|
+
# empty string: the browser layer reads that as "select nothing" on the versions
|
|
63
|
+
# that still run on Python 3.8, so it is asked for by its label.
|
|
64
|
+
if option.value:
|
|
65
|
+
self._control(field).select_option(value=option.value)
|
|
66
|
+
else:
|
|
67
|
+
self._control(field).select_option(label=option.label)
|
|
68
|
+
|
|
69
|
+
def choices(self, field: FormField) -> list[FieldChoice]:
|
|
70
|
+
"""The options the select offers, in order, the blank one included."""
|
|
71
|
+
return [
|
|
72
|
+
FieldChoice(option.get_attribute("value") or "", text_of(option))
|
|
73
|
+
for option in self._control(field).locator("option").all()
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def _option(self, field: FormField, value: Any) -> FieldChoice:
|
|
77
|
+
"""The option that stands for ``value``, among the ones the select offers."""
|
|
78
|
+
offered = {choice.value: choice for choice in self.choices(field)}
|
|
79
|
+
for spelling in _option_spellings(value):
|
|
80
|
+
if spelling in offered:
|
|
81
|
+
return offered[spelling]
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"The field {field.name!r} offers no option for {value!r}. Options: "
|
|
84
|
+
f"{', '.join(repr(option) for option in offered) or 'none'}."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _control(self, field: FormField) -> Locator:
|
|
88
|
+
return field.native.locator(f'select[name="{field.name}"]')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TextWidget:
|
|
92
|
+
"""Whatever one control carries the field's name, read and filled as text."""
|
|
93
|
+
|
|
94
|
+
def marker(self, field: FormField) -> str:
|
|
95
|
+
return f'[name="{field.name}"]'
|
|
96
|
+
|
|
97
|
+
def read(self, field: FormField) -> str:
|
|
98
|
+
return self._control(field).input_value()
|
|
99
|
+
|
|
100
|
+
def fill(self, field: FormField, value: Any) -> None:
|
|
101
|
+
self._control(field).fill("" if value is None else str(value))
|
|
102
|
+
|
|
103
|
+
def _control(self, field: FormField) -> Locator:
|
|
104
|
+
return field.native.locator(f'[name="{field.name}"]')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Widgets:
|
|
108
|
+
"""The widget classes a form's fields are read and filled through, in the order
|
|
109
|
+
they are tried: the ``custom`` ones, then the built-in ones.
|
|
110
|
+
|
|
111
|
+
A built-in class is replaced by putting a custom class in front of it that
|
|
112
|
+
recognizes the same control.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, custom: Iterable[Widget] = ()) -> None:
|
|
116
|
+
# A text control is any control with the field's name, a checkbox and a select
|
|
117
|
+
# included, so it comes last.
|
|
118
|
+
self._widgets: tuple[Widget, ...] = (
|
|
119
|
+
*custom,
|
|
120
|
+
CheckboxWidget(),
|
|
121
|
+
SelectWidget(),
|
|
122
|
+
TextWidget(),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def widget_for(self, field: FormField) -> Widget | None:
|
|
126
|
+
"""The first widget class whose marker matches inside the field's box, or
|
|
127
|
+
``None`` when none of them recognizes the field.
|
|
128
|
+
|
|
129
|
+
Every marker is tried in one browser call, since each call costs a round trip
|
|
130
|
+
whatever it asks.
|
|
131
|
+
"""
|
|
132
|
+
index = field.native.evaluate(
|
|
133
|
+
"(box, markers) => markers.findIndex(marker => box.querySelector(marker))",
|
|
134
|
+
[_marker(widget, field) for widget in self._widgets],
|
|
135
|
+
)
|
|
136
|
+
return self._widgets[index] if index >= 0 else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _marker(widget: Widget, field: FormField) -> str:
|
|
140
|
+
"""The widget class's marker for the field, checked before the browser sees it."""
|
|
141
|
+
marker = widget.marker(field)
|
|
142
|
+
if not isinstance(marker, str) or not marker:
|
|
143
|
+
raise TypeError(
|
|
144
|
+
f"{type(widget).__name__}.marker() must return a non-empty CSS selector, "
|
|
145
|
+
f"not {marker!r}."
|
|
146
|
+
)
|
|
147
|
+
return marker
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _option_spellings(value: Any) -> list[str]:
|
|
151
|
+
"""The option values ``value`` may be written as, in the order they are looked for.
|
|
152
|
+
|
|
153
|
+
A select's options are the choice values as ``str()`` renders them, with ``""`` for
|
|
154
|
+
the blank one. The exception is the widget Django gives a nullable boolean, whose
|
|
155
|
+
three options are spelled ``"unknown"``, ``"true"`` and ``"false"``. Both spellings
|
|
156
|
+
are Django's own and the page shows only which options it has, not which widget drew
|
|
157
|
+
them, so a boolean is looked for under either.
|
|
158
|
+
"""
|
|
159
|
+
if isinstance(value, FieldChoice):
|
|
160
|
+
return [value.value]
|
|
161
|
+
# A select of related objects offers each one by its primary key, which is what the
|
|
162
|
+
# form posts for it.
|
|
163
|
+
if isinstance(value, Model):
|
|
164
|
+
return [str(value.pk)]
|
|
165
|
+
# `is`, because `1 == True` and a number is no boolean here.
|
|
166
|
+
if value is True:
|
|
167
|
+
return ["True", "true"]
|
|
168
|
+
if value is False:
|
|
169
|
+
return ["False", "false"]
|
|
170
|
+
if value is None:
|
|
171
|
+
return ["", "unknown"]
|
|
172
|
+
return [str(value)]
|