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,169 @@
|
|
|
1
|
+
"""The pytest plugin: the fixtures the package adds.
|
|
2
|
+
|
|
3
|
+
Registered through a ``pytest11`` entry point, so a project that installs the package
|
|
4
|
+
gets them without listing anything in its own configuration. Every fixture is lazy and
|
|
5
|
+
none is autouse, so a project that installs the package and never uses ``admin_ui``
|
|
6
|
+
behaves exactly as it did before.
|
|
7
|
+
|
|
8
|
+
The browser comes from pytest-playwright, so its flags govern admin tests too:
|
|
9
|
+
``--browser``, ``--headed``, ``--slowmo`` and the rest mean here what they mean
|
|
10
|
+
everywhere else in the project. That also makes each admin test carry the browser it
|
|
11
|
+
ran on in its id.
|
|
12
|
+
|
|
13
|
+
The fixtures are layered so one can be overridden without rewriting the rest. Point
|
|
14
|
+
``admin_ui_urls`` at another ``AdminSite``, ``admin_ui_normalizers`` at other rules,
|
|
15
|
+
``admin_ui_widgets`` at other widget classes, or ``admin_ui_config`` at different settings,
|
|
16
|
+
and ``admin_ui`` follows.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from typing import TYPE_CHECKING, Any, Iterator, TypedDict
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
from .config import Config, from_django_settings
|
|
28
|
+
|
|
29
|
+
# The real imports happen inside the fixtures. Doing them here would add roughly 400
|
|
30
|
+
# modules and 150ms to pytest startup in every project that installs the package,
|
|
31
|
+
# most of it django.contrib.admin. mypy reads this block; Python never runs it.
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from pytest_django.live_server_helper import LiveServer
|
|
34
|
+
from pytest_playwright.pytest_playwright import CreateContextCallback
|
|
35
|
+
|
|
36
|
+
from .normalize import Normalizers
|
|
37
|
+
from .session import AdminSession
|
|
38
|
+
from .urls import AdminUrls
|
|
39
|
+
from .widgets import Widgets
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_ASYNC_UNSAFE = "DJANGO_ALLOW_ASYNC_UNSAFE"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _MachineIndependentContextArgs(TypedDict, total=False):
|
|
46
|
+
"""The context arguments the package sets so results do not depend on the machine."""
|
|
47
|
+
|
|
48
|
+
timezone_id: str
|
|
49
|
+
locale: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def _orm_allowed_while_driving() -> Iterator[None]:
|
|
54
|
+
"""Let the ORM run while a browser is live, and put the guard back afterwards.
|
|
55
|
+
|
|
56
|
+
The driver's synchronous API leaves an asyncio event loop on the test thread, and
|
|
57
|
+
Django then refuses every ORM call with ``SynchronousOnlyOperation``. Logging a
|
|
58
|
+
user in needs the ORM, so the guard has to come off.
|
|
59
|
+
"""
|
|
60
|
+
previous = os.environ.get(_ASYNC_UNSAFE)
|
|
61
|
+
os.environ[_ASYNC_UNSAFE] = "true"
|
|
62
|
+
try:
|
|
63
|
+
yield
|
|
64
|
+
finally:
|
|
65
|
+
if previous is None:
|
|
66
|
+
os.environ.pop(_ASYNC_UNSAFE, None)
|
|
67
|
+
else:
|
|
68
|
+
os.environ[_ASYNC_UNSAFE] = previous
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture(scope="session")
|
|
72
|
+
def admin_ui_config() -> Config:
|
|
73
|
+
"""Settings resolved once for the whole session."""
|
|
74
|
+
return from_django_settings()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.fixture(scope="session")
|
|
78
|
+
def admin_ui_urls(admin_ui_config: Config) -> AdminUrls:
|
|
79
|
+
"""URLs for the configured admin site, resolved once."""
|
|
80
|
+
# Imported here, not at module scope: this module loads at pytest startup in
|
|
81
|
+
# every project that installs the package, and `urls` pulls in the whole of
|
|
82
|
+
# django.contrib.admin.
|
|
83
|
+
from .urls import AdminUrls, resolve_site
|
|
84
|
+
|
|
85
|
+
return AdminUrls(resolve_site(admin_ui_config.site))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.fixture(scope="session")
|
|
89
|
+
def admin_ui_normalizers(admin_ui_config: Config) -> Normalizers:
|
|
90
|
+
"""The rules that read rendered values: the defaults, with the ones the settings
|
|
91
|
+
replace, built once."""
|
|
92
|
+
from .normalize import Normalizers
|
|
93
|
+
|
|
94
|
+
return Normalizers(admin_ui_config.normalizers)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.fixture(scope="session")
|
|
98
|
+
def admin_ui_widgets(admin_ui_config: Config) -> Widgets:
|
|
99
|
+
"""The widget classes that read and fill controls: the project's, then the package's
|
|
100
|
+
own, built once."""
|
|
101
|
+
from .widgets import Widgets
|
|
102
|
+
|
|
103
|
+
return Widgets(admin_ui_config.widgets)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.fixture(scope="session")
|
|
107
|
+
def admin_ui_driving() -> Iterator[None]:
|
|
108
|
+
"""Hold Django's async guard open around everything the admin session touches.
|
|
109
|
+
|
|
110
|
+
It depends on nothing, and in particular not on ``browser``. A guard that depends
|
|
111
|
+
on the browser is torn down when the browser is, while the driver behind it is
|
|
112
|
+
still running, and dropping the test database then fails with
|
|
113
|
+
``SynchronousOnlyOperation``. That surfaces only when the browser is torn down
|
|
114
|
+
before the end of the session, which is what a second ``--browser`` does.
|
|
115
|
+
|
|
116
|
+
Depending on nothing also lets ``admin_ui`` request it first, so it is set up
|
|
117
|
+
before the database is built and torn down after the database is destroyed.
|
|
118
|
+
|
|
119
|
+
Being a fixture rather than a session hook is what keeps it honest. A project that
|
|
120
|
+
installs the package and never asks for ``admin_ui`` never runs this, and Django's
|
|
121
|
+
guard stays where it was.
|
|
122
|
+
"""
|
|
123
|
+
with _orm_allowed_while_driving():
|
|
124
|
+
yield
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.fixture
|
|
128
|
+
def admin_ui(
|
|
129
|
+
# The guard comes first so it outlives the database at both ends. `live_server`
|
|
130
|
+
# before `new_context` follows Django's own practice of closing the browser before
|
|
131
|
+
# joining the live-server thread, since a held-open connection can deadlock it.
|
|
132
|
+
# Inverting it here did not reproduce a hang, so treat that half as precaution
|
|
133
|
+
# rather than a demonstrated fix.
|
|
134
|
+
admin_ui_driving: None,
|
|
135
|
+
live_server: LiveServer,
|
|
136
|
+
new_context: CreateContextCallback,
|
|
137
|
+
admin_ui_config: Config,
|
|
138
|
+
admin_ui_urls: AdminUrls,
|
|
139
|
+
admin_ui_normalizers: Normalizers,
|
|
140
|
+
admin_ui_widgets: Widgets,
|
|
141
|
+
browser_context_args: dict[str, Any],
|
|
142
|
+
) -> Iterator[AdminSession]:
|
|
143
|
+
"""A fresh, unauthenticated admin session per test.
|
|
144
|
+
|
|
145
|
+
Each test gets its own browsing context, which is the isolation unit for cookies
|
|
146
|
+
and storage. Sharing one would let a test that never calls ``login`` inherit the
|
|
147
|
+
previous test's user, and whether it passed would depend on file order.
|
|
148
|
+
|
|
149
|
+
The context comes from pytest-playwright's factory, so admin tests record video
|
|
150
|
+
and traces on the same flags as every other browser test in the project.
|
|
151
|
+
"""
|
|
152
|
+
from .session import AdminSession
|
|
153
|
+
from .tools import PageTools
|
|
154
|
+
|
|
155
|
+
# Only pass what the project has not set itself: the factory merges its own
|
|
156
|
+
# arguments with these, and a duplicate key raises TypeError. `base_url` is
|
|
157
|
+
# deliberately absent, since AdminSession navigates absolutely.
|
|
158
|
+
context_args: _MachineIndependentContextArgs = {}
|
|
159
|
+
if admin_ui_config.timezone is not None and "timezone_id" not in browser_context_args:
|
|
160
|
+
context_args["timezone_id"] = admin_ui_config.timezone
|
|
161
|
+
if admin_ui_config.locale is not None and "locale" not in browser_context_args:
|
|
162
|
+
context_args["locale"] = admin_ui_config.locale
|
|
163
|
+
|
|
164
|
+
context = new_context(**context_args)
|
|
165
|
+
context.set_default_timeout(admin_ui_config.timeout)
|
|
166
|
+
|
|
167
|
+
# No close here: the factory closes the contexts it made when the test ends.
|
|
168
|
+
tools = PageTools(admin_ui_urls, admin_ui_normalizers, admin_ui_widgets)
|
|
169
|
+
yield AdminSession(context, tools, live_server.url)
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""A value as the admin rendered it, wherever it stands: in a changelist cell or in a
|
|
2
|
+
form field the user may only read.
|
|
3
|
+
|
|
4
|
+
What such a value holds is read from the document's own text, so the admin's styling
|
|
5
|
+
never changes it, and the links it renders are kept next to it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
11
|
+
from urllib.parse import SplitResult, urlsplit
|
|
12
|
+
|
|
13
|
+
from django.contrib.admin import ModelAdmin
|
|
14
|
+
from django.contrib.admin.utils import NotRelationField, get_fields_from_path
|
|
15
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
16
|
+
from django.db.models import Field
|
|
17
|
+
from django.utils.html import strip_tags
|
|
18
|
+
from playwright.sync_api import Locator
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .normalize import Normalizers
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Link:
|
|
25
|
+
"""One link in a rendered value: its text, and its ``href`` exactly as rendered, split.
|
|
26
|
+
|
|
27
|
+
``href`` is a ``SplitResult``, so its parts are there by name: ``path`` to compare
|
|
28
|
+
against ``admin_ui.url``, ``query`` for what the admin added to keep a filter. A
|
|
29
|
+
link compares equal to another link and to a ``(text, href)`` pair, as a tuple or a
|
|
30
|
+
list, with ``href`` given either as a string or already split.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, text: str, href: str | SplitResult) -> None:
|
|
34
|
+
self._text = text
|
|
35
|
+
self._href = urlsplit(href) if isinstance(href, str) else href
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def text(self) -> str:
|
|
39
|
+
return self._text
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def href(self) -> SplitResult:
|
|
43
|
+
return self._href
|
|
44
|
+
|
|
45
|
+
def __eq__(self, other: object) -> bool:
|
|
46
|
+
if isinstance(other, Link):
|
|
47
|
+
return (self._text, self._href) == (other._text, other._href)
|
|
48
|
+
if isinstance(other, (tuple, list)) and len(other) == 2:
|
|
49
|
+
text, href = other
|
|
50
|
+
if isinstance(text, str) and isinstance(href, (str, SplitResult)):
|
|
51
|
+
return self == Link(text, href)
|
|
52
|
+
return NotImplemented
|
|
53
|
+
|
|
54
|
+
def __hash__(self) -> int:
|
|
55
|
+
return hash((self._text, self._href))
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
return f"Link({self._text!r}, {self._href.geturl()!r})"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RenderedValue:
|
|
62
|
+
"""One value the admin rendered, read from the element that holds it.
|
|
63
|
+
|
|
64
|
+
``check`` is run before every read, and fails when the browser no longer shows the
|
|
65
|
+
page the value is on. ``normalizers`` read its value, by what stands behind it: the
|
|
66
|
+
``name`` the admin renders it under, on a page ``model_admin`` draws.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
element: Locator,
|
|
72
|
+
name: str,
|
|
73
|
+
*,
|
|
74
|
+
check: Callable[[], None],
|
|
75
|
+
normalizers: Normalizers,
|
|
76
|
+
model_admin: ModelAdmin,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._element = element
|
|
79
|
+
self._name = name
|
|
80
|
+
self._check = check
|
|
81
|
+
self._normalizers = normalizers
|
|
82
|
+
self._model_admin = model_admin
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def native(self) -> Locator:
|
|
86
|
+
"""The element, unwrapped, for anything the package does not model."""
|
|
87
|
+
return self._element
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def field(self) -> Field | None:
|
|
91
|
+
"""The model field the admin rendered the value from, or ``None``, as for a method."""
|
|
92
|
+
return _model_field(self._model_admin.model, self._name)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def empty_display(self) -> str:
|
|
96
|
+
"""The text the admin shows here for a value it has none of: the model admin's, which
|
|
97
|
+
falls back to the site's."""
|
|
98
|
+
return display_text(self._model_admin.get_empty_value_display())
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def text(self) -> str:
|
|
102
|
+
"""What the document shows, with its whitespace collapsed."""
|
|
103
|
+
self._check()
|
|
104
|
+
return text_of(self._element)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def value(self) -> Any:
|
|
108
|
+
"""What is shown, read by the rule for its kind: by default the text, or a boolean
|
|
109
|
+
where the admin drew one as an icon."""
|
|
110
|
+
self._check()
|
|
111
|
+
return self._normalizers.read(self)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def links(self) -> list[Link]:
|
|
115
|
+
"""The links rendered, in order; ``[]`` when there is none."""
|
|
116
|
+
self._check()
|
|
117
|
+
return [
|
|
118
|
+
Link(text_of(a), a.get_attribute("href") or "")
|
|
119
|
+
for a in self._element.locator("a[href]").all()
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def text_of(element: Locator) -> str:
|
|
124
|
+
"""The document's text inside ``element``, with its whitespace collapsed."""
|
|
125
|
+
return " ".join((element.text_content() or "").split())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def display_text(display: str) -> str:
|
|
129
|
+
"""``display`` as the page shows it: without markup, its whitespace collapsed."""
|
|
130
|
+
return " ".join(strip_tags(str(display)).split())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _model_field(model: type[Any], name: str) -> Field | None:
|
|
134
|
+
"""The model field the admin renders under ``name``, or ``None`` when it renders something
|
|
135
|
+
else there, such as a method.
|
|
136
|
+
|
|
137
|
+
The admin looks a name up as a field first, following a path such as ``category__name``,
|
|
138
|
+
and only then as a callable or a method, so a name that is a field is always that field.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
return get_fields_from_path(model, name)[-1]
|
|
142
|
+
except (FieldDoesNotExist, NotRelationField):
|
|
143
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The outcome of submitting an admin form.
|
|
2
|
+
|
|
3
|
+
A result says whether the admin accepted the submission and where it sent the browser.
|
|
4
|
+
Anything shown on the page, such as fields, errors and messages, is read from
|
|
5
|
+
``result.page``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .pages import AdminPage
|
|
14
|
+
from .urls import AdminUrls
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SubmissionResult:
|
|
18
|
+
"""The outcome of one form submission."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, page: AdminPage, redirected: bool, urls: AdminUrls) -> None:
|
|
21
|
+
self._page = page
|
|
22
|
+
self._redirected = redirected
|
|
23
|
+
self._urls = urls
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def page(self) -> AdminPage:
|
|
27
|
+
"""The page the browser shows after the submission.
|
|
28
|
+
|
|
29
|
+
It is the submitted page object itself if the browser stayed on that page, and a
|
|
30
|
+
new page object otherwise.
|
|
31
|
+
"""
|
|
32
|
+
return self._page
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def success(self) -> bool:
|
|
36
|
+
"""Whether the admin accepted the submission.
|
|
37
|
+
|
|
38
|
+
Django redirects after a successful save and shows the form again when it rejects
|
|
39
|
+
one, so this is whether a redirect happened.
|
|
40
|
+
"""
|
|
41
|
+
return self._redirected
|
|
42
|
+
|
|
43
|
+
def redirected_to(self, url: str) -> bool:
|
|
44
|
+
"""Whether the admin redirected the browser to ``url``, a path such as
|
|
45
|
+
``admin_ui.url.list(Product)``.
|
|
46
|
+
|
|
47
|
+
A rejected form is shown again at its own URL without a redirect, so it is never
|
|
48
|
+
redirected to that URL.
|
|
49
|
+
"""
|
|
50
|
+
return self._redirected and self._page.destination == url
|
|
51
|
+
|
|
52
|
+
def redirected_to_index(self) -> bool:
|
|
53
|
+
return self.redirected_to(self._urls.index())
|
|
54
|
+
|
|
55
|
+
def redirected_to_list(self, model: type[Any]) -> bool:
|
|
56
|
+
return self.redirected_to(self._urls.list(model))
|
|
57
|
+
|
|
58
|
+
def redirected_to_create(self, model: type[Any]) -> bool:
|
|
59
|
+
return self.redirected_to(self._urls.create(model))
|
|
60
|
+
|
|
61
|
+
def redirected_to_edit(self, instance: Any) -> bool:
|
|
62
|
+
return self.redirected_to(self._urls.edit(instance))
|
django_admin_kit/rows.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The rows of a changelist and the cells in them.
|
|
2
|
+
|
|
3
|
+
A cell is addressed by the name its column is configured with, never by the label
|
|
4
|
+
shown over it, and a row by its position. A cell is a rendered value, as the
|
|
5
|
+
``rendered`` module reads one, that also knows its column.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable, Iterator
|
|
11
|
+
|
|
12
|
+
from django.contrib.admin import ModelAdmin
|
|
13
|
+
from django.contrib.admin.utils import unquote
|
|
14
|
+
from django.db.models import Model
|
|
15
|
+
from django.urls import Resolver404, resolve
|
|
16
|
+
from playwright.sync_api import Locator
|
|
17
|
+
|
|
18
|
+
from .normalize import Normalizers
|
|
19
|
+
from .rendered import RenderedValue, display_text
|
|
20
|
+
from .urls import AdminUrls, model_admin
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Cell(RenderedValue):
|
|
24
|
+
"""One cell of a changelist row."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
element: Locator,
|
|
29
|
+
column: str,
|
|
30
|
+
*,
|
|
31
|
+
check: Callable[[], None],
|
|
32
|
+
normalizers: Normalizers,
|
|
33
|
+
model_admin: ModelAdmin,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(
|
|
36
|
+
element, column, check=check, normalizers=normalizers, model_admin=model_admin
|
|
37
|
+
)
|
|
38
|
+
self._column = column
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def column(self) -> str:
|
|
42
|
+
"""The configured name of the column the cell is in."""
|
|
43
|
+
return self._column
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def empty_display(self) -> str:
|
|
47
|
+
"""The text the admin shows here for a value it has none of: the column's own, when
|
|
48
|
+
it is a method or a callable that sets one, else the model admin's or the site's."""
|
|
49
|
+
function = None if self.field is not None else self._display_function()
|
|
50
|
+
if function is not None and hasattr(function, "empty_value_display"):
|
|
51
|
+
return display_text(function.empty_value_display)
|
|
52
|
+
return super().empty_display
|
|
53
|
+
|
|
54
|
+
def _display_function(self) -> Any:
|
|
55
|
+
"""What the admin calls to render the column, looked up as the admin looks it up: a
|
|
56
|
+
callable in ``list_display``, then the model admin's attribute, then the model's."""
|
|
57
|
+
for entry in self._model_admin.list_display:
|
|
58
|
+
if callable(entry) and getattr(entry, "__name__", None) == self._column:
|
|
59
|
+
return entry
|
|
60
|
+
if hasattr(self._model_admin, self._column):
|
|
61
|
+
return getattr(self._model_admin, self._column)
|
|
62
|
+
return getattr(self._model_admin.model, self._column, None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Row:
|
|
66
|
+
"""One row of a changelist, indexable by column name or position.
|
|
67
|
+
|
|
68
|
+
The columns are the ones the changelist had when the row was read. A project whose
|
|
69
|
+
``list_display`` changes with the query string reads its rows again after filtering.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
element: Locator,
|
|
75
|
+
index: int,
|
|
76
|
+
*,
|
|
77
|
+
columns: list[str],
|
|
78
|
+
model: type[Model],
|
|
79
|
+
urls: AdminUrls,
|
|
80
|
+
check: Callable[[], None],
|
|
81
|
+
normalizers: Normalizers,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._element = element
|
|
84
|
+
self._index = index
|
|
85
|
+
self._columns = columns
|
|
86
|
+
self._model = model
|
|
87
|
+
self._urls = urls
|
|
88
|
+
self._check = check
|
|
89
|
+
self._normalizers = normalizers
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def index(self) -> int:
|
|
93
|
+
"""The row's 0-based position in the changelist.
|
|
94
|
+
|
|
95
|
+
The row is that position, not a record: every read goes to whichever row the page
|
|
96
|
+
shows there now, so once the changelist is filtered or sorted it may be another one.
|
|
97
|
+
"""
|
|
98
|
+
return self._index
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def native(self) -> Locator:
|
|
102
|
+
"""The row element, unwrapped, for anything the package does not model."""
|
|
103
|
+
return self._element
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def object(self) -> Any:
|
|
107
|
+
"""The instance behind the row, where the changelist links to its change page.
|
|
108
|
+
|
|
109
|
+
``None`` for a row without such a link, as under ``list_display_links = None``.
|
|
110
|
+
The database is asked each time, so the instance is as it is stored now.
|
|
111
|
+
"""
|
|
112
|
+
opts = self._model._meta
|
|
113
|
+
for cell in self._cells:
|
|
114
|
+
for link in cell.links:
|
|
115
|
+
try:
|
|
116
|
+
match = resolve(link.href.path)
|
|
117
|
+
except Resolver404:
|
|
118
|
+
continue
|
|
119
|
+
if (
|
|
120
|
+
match.namespace == self._urls.site.name
|
|
121
|
+
and match.url_name == f"{opts.app_label}_{opts.model_name}_change"
|
|
122
|
+
):
|
|
123
|
+
return self._model._default_manager.get(pk=unquote(match.kwargs["object_id"]))
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def __len__(self) -> int:
|
|
127
|
+
return len(self._cells)
|
|
128
|
+
|
|
129
|
+
def __iter__(self) -> Iterator[Cell]:
|
|
130
|
+
return iter(self._cells)
|
|
131
|
+
|
|
132
|
+
def __getitem__(self, key: int | str) -> Cell:
|
|
133
|
+
if isinstance(key, int):
|
|
134
|
+
return self._cells[key]
|
|
135
|
+
try:
|
|
136
|
+
return self._cells[self._columns.index(key)]
|
|
137
|
+
except ValueError:
|
|
138
|
+
raise KeyError(
|
|
139
|
+
f"The row has no column named {key!r}. Columns: "
|
|
140
|
+
f"{', '.join(repr(name) for name in self._columns)}."
|
|
141
|
+
) from None
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def _cells(self) -> list[Cell]:
|
|
145
|
+
self._check()
|
|
146
|
+
# The row's own cells line up with the columns by position once the
|
|
147
|
+
# checkbox Django adds for actions is skipped, as the headers skip its
|
|
148
|
+
# header cell.
|
|
149
|
+
cells = self._element.locator(":scope > th, :scope > td").all()
|
|
150
|
+
cells = [cell for cell in cells if "action-checkbox" not in _classes(cell)]
|
|
151
|
+
admin = model_admin(self._urls.site, self._model)
|
|
152
|
+
return [
|
|
153
|
+
Cell(
|
|
154
|
+
cell,
|
|
155
|
+
column,
|
|
156
|
+
check=self._check,
|
|
157
|
+
normalizers=self._normalizers,
|
|
158
|
+
model_admin=admin,
|
|
159
|
+
)
|
|
160
|
+
for cell, column in zip(cells, self._columns)
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _classes(element: Locator) -> list[str]:
|
|
165
|
+
return (element.get_attribute("class") or "").split()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The object the ``admin_ui`` fixture yields.
|
|
2
|
+
|
|
3
|
+
Login builds the session in Django and hands the browser the resulting cookie. It
|
|
4
|
+
never types a password, because passwords are stored irreversibly and a test handed an
|
|
5
|
+
arbitrary user has no way to know one. A user created with ``set_unusable_password()``
|
|
6
|
+
has no password at all and must still be able to log in.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
from urllib.parse import urljoin
|
|
13
|
+
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
from django.test import Client
|
|
16
|
+
from playwright.sync_api import BrowserContext
|
|
17
|
+
|
|
18
|
+
from .pages import AdminPage, ChangelistPage, CreatePage, DeletePage, EditPage, IndexPage
|
|
19
|
+
from .tools import PageTools
|
|
20
|
+
from .urls import AdminUrls
|
|
21
|
+
|
|
22
|
+
_P = TypeVar("_P", bound=AdminPage)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AdminSession:
|
|
26
|
+
"""One test's view of the admin: who is logged in, and where things live."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
context: BrowserContext,
|
|
31
|
+
tools: PageTools,
|
|
32
|
+
base_url: str,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._context = context
|
|
35
|
+
self._tools = tools
|
|
36
|
+
self._base_url = base_url
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def native(self) -> BrowserContext:
|
|
40
|
+
"""The browsing context, unwrapped, for anything the package does not model."""
|
|
41
|
+
return self._context
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def url(self) -> AdminUrls:
|
|
45
|
+
"""Admin URLs resolved through the site under test, as paths."""
|
|
46
|
+
return self._tools.urls
|
|
47
|
+
|
|
48
|
+
def absolute(self, path: str) -> str:
|
|
49
|
+
"""Turn an admin path into a full URL on the live server.
|
|
50
|
+
|
|
51
|
+
The package navigates absolutely rather than setting the context's
|
|
52
|
+
``base_url``, which belongs to the project: a test suite already using
|
|
53
|
+
``--base-url`` for its own pages keeps it, and neither of us can break the
|
|
54
|
+
other.
|
|
55
|
+
"""
|
|
56
|
+
return urljoin(self._base_url, path)
|
|
57
|
+
|
|
58
|
+
def login(self, user: Any, password: str | None = None) -> None:
|
|
59
|
+
"""Log in as ``user`` without knowing or needing a password.
|
|
60
|
+
|
|
61
|
+
Calling it again replaces the session rather than adding a second one, so
|
|
62
|
+
switching users mid-test works.
|
|
63
|
+
|
|
64
|
+
With ``password``, the rendered login form is driven instead: the browser
|
|
65
|
+
opens the login page, types what the user model identifies users by and the
|
|
66
|
+
password, and submits. That is for tests whose subject is the login page. A
|
|
67
|
+
wrong password leaves the browser on the form, with its error shown, and the
|
|
68
|
+
session anonymous.
|
|
69
|
+
"""
|
|
70
|
+
self.logout()
|
|
71
|
+
|
|
72
|
+
if password is None:
|
|
73
|
+
self._login_with_cookie(user)
|
|
74
|
+
else:
|
|
75
|
+
self._login_with_form(user, password)
|
|
76
|
+
|
|
77
|
+
def _login_with_cookie(self, user: Any) -> None:
|
|
78
|
+
client = Client()
|
|
79
|
+
client.force_login(user)
|
|
80
|
+
|
|
81
|
+
name = settings.SESSION_COOKIE_NAME
|
|
82
|
+
self._context.add_cookies(
|
|
83
|
+
[{"name": name, "value": client.cookies[name].value, "url": self._base_url}]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _login_with_form(self, user: Any, password: str) -> None:
|
|
87
|
+
page = self._context.new_page()
|
|
88
|
+
page.goto(self.absolute(self._tools.urls.login()))
|
|
89
|
+
|
|
90
|
+
# The admin's form names the identity input `username` whatever the user
|
|
91
|
+
# model calls the field; only the value it expects follows the model.
|
|
92
|
+
page.locator('input[name="username"]').fill(user.get_username())
|
|
93
|
+
page.locator('input[name="password"]').fill(password)
|
|
94
|
+
with page.expect_response(lambda response: response.request.method == "POST"):
|
|
95
|
+
page.locator('input[type="submit"]').click()
|
|
96
|
+
page.wait_for_load_state()
|
|
97
|
+
|
|
98
|
+
def logout(self) -> None:
|
|
99
|
+
"""Return the browser to anonymous by dropping its cookies."""
|
|
100
|
+
self._context.clear_cookies()
|
|
101
|
+
|
|
102
|
+
def open(self, path: str) -> AdminPage:
|
|
103
|
+
"""Open any admin path in a new page, including views the package does not
|
|
104
|
+
model, and report how it went."""
|
|
105
|
+
return self._open(path, AdminPage)
|
|
106
|
+
|
|
107
|
+
def index(self) -> IndexPage:
|
|
108
|
+
"""Open the admin index."""
|
|
109
|
+
return self._open(self._tools.urls.index(), IndexPage)
|
|
110
|
+
|
|
111
|
+
def list(self, model: type[Any]) -> ChangelistPage:
|
|
112
|
+
"""Open a model's changelist."""
|
|
113
|
+
return self._open(self._tools.urls.list(model), ChangelistPage, model)
|
|
114
|
+
|
|
115
|
+
def create(self, model: type[Any]) -> CreatePage:
|
|
116
|
+
"""Open the page that adds a new instance of ``model``."""
|
|
117
|
+
return self._open(self._tools.urls.create(model), CreatePage, model)
|
|
118
|
+
|
|
119
|
+
def edit(self, instance: Any) -> EditPage:
|
|
120
|
+
"""Open the change page of ``instance``."""
|
|
121
|
+
return self._open(self._tools.urls.edit(instance), EditPage, type(instance))
|
|
122
|
+
|
|
123
|
+
def delete(self, instance: Any) -> DeletePage:
|
|
124
|
+
"""Open the page that asks whether to delete ``instance``."""
|
|
125
|
+
return self._open(self._tools.urls.delete(instance), DeletePage, type(instance))
|
|
126
|
+
|
|
127
|
+
def _open(self, path: str, page_class: type[_P], *args: Any) -> _P:
|
|
128
|
+
page = self._context.new_page()
|
|
129
|
+
response = page.goto(self.absolute(path))
|
|
130
|
+
# `goto` returns None only for same-document navigations, never for a URL.
|
|
131
|
+
assert response is not None
|
|
132
|
+
return page_class(page, response.status, path, self._tools, *args)
|