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,841 @@
|
|
|
1
|
+
"""Admin pages after they have been opened: where the browser ended up, and whether
|
|
2
|
+
it got in.
|
|
3
|
+
|
|
4
|
+
The admin answers a page request in more ways than a status code shows. A user who
|
|
5
|
+
is not logged in or not staff is redirected to the login page. A staff user without
|
|
6
|
+
permission for a model is refused in place with a 403. A user who has permission but
|
|
7
|
+
asks for an object that does not exist is sent to the index with a message, and a
|
|
8
|
+
URL the admin does not serve is a 404. One resolver turns the requested path, the
|
|
9
|
+
final path and the status into ``works``, ``denied``, ``missing`` and ``redirected``,
|
|
10
|
+
so that reading is written once.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, Dict
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
from django.apps import apps
|
|
22
|
+
from django.db.models import Model
|
|
23
|
+
from django.urls import Resolver404, resolve
|
|
24
|
+
from playwright.sync_api import Locator, Page, Response
|
|
25
|
+
|
|
26
|
+
from . import matching
|
|
27
|
+
from .errors import ValidationErrors
|
|
28
|
+
from .fields import Fields, FormField
|
|
29
|
+
from .messages import Messages
|
|
30
|
+
from .normalize import integer
|
|
31
|
+
from .rendered import text_of
|
|
32
|
+
from .results import SubmissionResult
|
|
33
|
+
from .rows import Row
|
|
34
|
+
from .tools import PageTools
|
|
35
|
+
from .urls import model_admin
|
|
36
|
+
|
|
37
|
+
_NOTHING = object()
|
|
38
|
+
"""What a source has for a field it says nothing about, which `None` cannot stand for."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PagePopulationMode(str, Enum):
|
|
42
|
+
"""Which of a form's fields ``FormPage.populate`` fills.
|
|
43
|
+
|
|
44
|
+
A member is its own word, so ``PagePopulationMode.REQUIRED`` and ``"required"`` are
|
|
45
|
+
the same thing to pass and the same thing to read in a failure.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
REQUIRED = "required"
|
|
49
|
+
OPTIONAL = "optional"
|
|
50
|
+
ALL = "all"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FormAction(str, Enum):
|
|
54
|
+
"""The actions the admin puts on every create and edit form, by the names they post.
|
|
55
|
+
|
|
56
|
+
A member is the name itself, so it compares and hashes as that string and mixes with
|
|
57
|
+
the name of an action a project adds: ``{FormAction.SAVE, "_approve"}``.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
SAVE = "_save"
|
|
61
|
+
SAVE_AND_CONTINUE = "_continue"
|
|
62
|
+
SAVE_AND_ADD_ANOTHER = "_addanother"
|
|
63
|
+
SAVE_AS_NEW = "_saveasnew"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AdminPage:
|
|
67
|
+
"""One opened admin page."""
|
|
68
|
+
|
|
69
|
+
# The page type the admin declares in the `body` class, or None for pages the
|
|
70
|
+
# package does not model. The path and the status are usually enough to tell
|
|
71
|
+
# whether the browser still shows this page. The class is needed when the admin
|
|
72
|
+
# answers with a different page at the same URL and status 200: for example, a
|
|
73
|
+
# project's `response_add` or `response_change` that renders a confirmation page
|
|
74
|
+
# instead of redirecting, or, once changelist actions are supported, the
|
|
75
|
+
# confirmation page of "Delete selected".
|
|
76
|
+
_kind: str | None = None
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
page: Page,
|
|
81
|
+
status_code: int,
|
|
82
|
+
requested: str,
|
|
83
|
+
tools: PageTools,
|
|
84
|
+
) -> None:
|
|
85
|
+
self._page = page
|
|
86
|
+
self._status_code = status_code
|
|
87
|
+
self._requested = requested
|
|
88
|
+
self._tools = tools
|
|
89
|
+
self._landed()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def native(self) -> Page:
|
|
93
|
+
"""The browser page, unwrapped, for anything the package does not model."""
|
|
94
|
+
return self._page
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def status_code(self) -> int:
|
|
98
|
+
"""The status of the response the browser ended up on, after any redirects."""
|
|
99
|
+
self._check()
|
|
100
|
+
return self._status_code
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def destination(self) -> str:
|
|
104
|
+
"""The path the browser ended up on, comparable with ``admin_ui.url``.
|
|
105
|
+
|
|
106
|
+
Query string and fragment are dropped, so a redirect to the login page compares
|
|
107
|
+
equal to ``admin_ui.url.login()`` whatever ``next`` it carries.
|
|
108
|
+
"""
|
|
109
|
+
self._check()
|
|
110
|
+
return self._destination
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def redirected(self) -> bool:
|
|
114
|
+
"""Ended up somewhere other than the page asked for."""
|
|
115
|
+
self._check()
|
|
116
|
+
return self._redirected
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def works(self) -> bool:
|
|
120
|
+
"""Loaded where it was asked for."""
|
|
121
|
+
self._check()
|
|
122
|
+
return self._works
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def denied(self) -> bool:
|
|
126
|
+
"""Refused, whether by redirect to the login page or in place."""
|
|
127
|
+
self._check()
|
|
128
|
+
return self._status_code == 403 or self._destination == self._tools.urls.login()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def missing(self) -> bool:
|
|
132
|
+
"""Not there: a URL the admin does not serve, or an object that does not exist.
|
|
133
|
+
|
|
134
|
+
The admin reports a missing object to a user with permission by redirecting
|
|
135
|
+
to the index with a message, so a redirect there is read as missing. A user
|
|
136
|
+
without permission is refused before the object is looked up. The one other
|
|
137
|
+
page that redirects to the index is the login page, when the user is already
|
|
138
|
+
logged in; that is a plain redirect.
|
|
139
|
+
"""
|
|
140
|
+
self._check()
|
|
141
|
+
if self._status_code == 404:
|
|
142
|
+
return True
|
|
143
|
+
return (
|
|
144
|
+
self._redirected
|
|
145
|
+
and self._destination == self._tools.urls.index()
|
|
146
|
+
and self._requested != self._tools.urls.login()
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def title(self) -> str:
|
|
151
|
+
"""The title the admin renders for the page, or ``""`` when it renders none."""
|
|
152
|
+
return _text(self._shown().locator("#content h1").first)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def subtitle(self) -> str:
|
|
156
|
+
"""The subtitle under the title, such as the object's name on a change page."""
|
|
157
|
+
# The subtitle is the heading right after the title. The content area holds
|
|
158
|
+
# other h2 elements, for filters and the like, but none next to the h1.
|
|
159
|
+
return _text(self._shown().locator("#content h1 + h2"))
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def messages(self) -> Messages:
|
|
163
|
+
"""The messages the page shows, in order, each with its level and its text.
|
|
164
|
+
|
|
165
|
+
Unlike everything else on a page, they are read even when the page did not open
|
|
166
|
+
where it was asked. A missing object is the case in point: the admin sends the user
|
|
167
|
+
to the index with a warning, and Django shows a message only once.
|
|
168
|
+
"""
|
|
169
|
+
self._check()
|
|
170
|
+
return Messages(self._message_items)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def _destination(self) -> str:
|
|
174
|
+
return urlsplit(self._page.url).path
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def _redirected(self) -> bool:
|
|
178
|
+
return self._destination != self._requested
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def _works(self) -> bool:
|
|
182
|
+
return self._status_code == 200 and not self._redirected
|
|
183
|
+
|
|
184
|
+
def _shown(self) -> Page:
|
|
185
|
+
"""The page, once it is known to be the one that was asked for.
|
|
186
|
+
|
|
187
|
+
A page that did not open shows nothing to read, and reading it anyway would
|
|
188
|
+
let a test pass for a user who never saw it.
|
|
189
|
+
"""
|
|
190
|
+
self._check()
|
|
191
|
+
if not self._works:
|
|
192
|
+
raise LookupError(
|
|
193
|
+
"The page did not open, so there is nothing to read from it. "
|
|
194
|
+
f"Status {self._status_code}, at {self._destination}."
|
|
195
|
+
)
|
|
196
|
+
return self._page
|
|
197
|
+
|
|
198
|
+
def _message_items(self) -> Locator:
|
|
199
|
+
# `_check` and not `_shown`: the messages of a page that did not open are where the
|
|
200
|
+
# admin says why, so they are read from wherever the page landed. A page the browser
|
|
201
|
+
# has left still fails, so held messages never read the next page's.
|
|
202
|
+
self._check()
|
|
203
|
+
return self._page.locator("ul.messagelist > li")
|
|
204
|
+
|
|
205
|
+
def _landed(self) -> None:
|
|
206
|
+
"""Remember where the browser is now, as the page this object stands for.
|
|
207
|
+
|
|
208
|
+
A page that did not open, such as one refused with a redirect to the login page,
|
|
209
|
+
stands for wherever the browser landed, so it can still say how it was refused.
|
|
210
|
+
"""
|
|
211
|
+
self._path = self._destination
|
|
212
|
+
self._of_kind = _is_kind(self._page, self._kind)
|
|
213
|
+
|
|
214
|
+
def _still_shown(self) -> bool:
|
|
215
|
+
"""Whether the browser still shows this page: the same path and, if the page was
|
|
216
|
+
of its type, still that type."""
|
|
217
|
+
if self._destination != self._path:
|
|
218
|
+
return False
|
|
219
|
+
return not self._of_kind or _is_kind(self._page, self._kind)
|
|
220
|
+
|
|
221
|
+
def _check(self) -> None:
|
|
222
|
+
"""Fail at once if the browser has moved on to another page.
|
|
223
|
+
|
|
224
|
+
Every reader runs this first, and so does every object the page hands out, so
|
|
225
|
+
nothing reads a page the browser no longer shows.
|
|
226
|
+
"""
|
|
227
|
+
if not self._still_shown():
|
|
228
|
+
raise LookupError(
|
|
229
|
+
f"The browser no longer shows this page; it is at {self._destination}. "
|
|
230
|
+
"Read the page it shows now, such as a submission's result.page."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _submitted(self, response: Response | None) -> SubmissionResult:
|
|
234
|
+
"""The result of a submission from this page, given the response it ended on."""
|
|
235
|
+
# A navigation of the page always comes with a response.
|
|
236
|
+
assert response is not None
|
|
237
|
+
redirected = response.request.redirected_from is not None
|
|
238
|
+
if self._still_shown():
|
|
239
|
+
# The browser loaded a new document here, so report its status.
|
|
240
|
+
self._status_code = response.status
|
|
241
|
+
return SubmissionResult(self, redirected, self._tools.urls)
|
|
242
|
+
landed = _page_at(self._page, response.status, self._tools)
|
|
243
|
+
return SubmissionResult(landed, redirected, self._tools.urls)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class IndexPage(AdminPage):
|
|
247
|
+
"""The admin index: which models it lists for the current user."""
|
|
248
|
+
|
|
249
|
+
_kind = "dashboard"
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def apps(self) -> list[str]:
|
|
253
|
+
"""The apps the index shows, by the name shown, in the order shown.
|
|
254
|
+
|
|
255
|
+
An app is shown only when the user may see at least one of its models.
|
|
256
|
+
"""
|
|
257
|
+
return list(self._app_list)
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def models(self) -> list[type]:
|
|
261
|
+
"""The registered models the index shows, in the order shown.
|
|
262
|
+
|
|
263
|
+
A model the user may not see is not on the page, so it is not here either.
|
|
264
|
+
"""
|
|
265
|
+
return [model for models in self._app_list.values() for model in models]
|
|
266
|
+
|
|
267
|
+
def models_for(self, app: str) -> list[type]:
|
|
268
|
+
"""The models the index shows under one app, named as ``apps`` names it."""
|
|
269
|
+
try:
|
|
270
|
+
return list(self._app_list[app])
|
|
271
|
+
except KeyError:
|
|
272
|
+
raise LookupError(
|
|
273
|
+
f"The index shows no app named {app!r}. Shown: "
|
|
274
|
+
f"{', '.join(repr(name) for name in self._app_list) or 'none'}."
|
|
275
|
+
) from None
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def _app_list(self) -> dict[str, list[type]]:
|
|
279
|
+
"""App name to the registered models under it, as the page shows them."""
|
|
280
|
+
# Django renders the app list twice, the second time in the navigation
|
|
281
|
+
# sidebar, so reading stays inside the content area. Each app is a `module`
|
|
282
|
+
# block with an `app-<label>` class; each model a row with `model-<name>`,
|
|
283
|
+
# which also leaves out the header row newer Django versions add. The names
|
|
284
|
+
# are read from the class list rather than by a substring match because
|
|
285
|
+
# Django 6.1 puts a class named `app-list` on the content area itself.
|
|
286
|
+
app_list: dict[str, list[type]] = {}
|
|
287
|
+
for app in self._shown().locator("#content-main div.module").all():
|
|
288
|
+
# `text_content`, not `inner_text`: the admin's stylesheet upper-cases
|
|
289
|
+
# captions, and the name is what the document says, not how it is drawn.
|
|
290
|
+
name = _text(app.locator("caption"))
|
|
291
|
+
app_label = _token(app, "app-")
|
|
292
|
+
app_list[name] = [
|
|
293
|
+
model
|
|
294
|
+
for row in app.locator("tr[class*='model-']").all()
|
|
295
|
+
if (model := self._registered_model(app_label, row)) is not None
|
|
296
|
+
]
|
|
297
|
+
return app_list
|
|
298
|
+
|
|
299
|
+
def _registered_model(self, app_label: str, row: Locator) -> type | None:
|
|
300
|
+
# Matching the registry rather than the changelist link finds a model the
|
|
301
|
+
# user may only add, which the admin lists without a link. A row that is no
|
|
302
|
+
# registered model, which a third-party app may add, is left out.
|
|
303
|
+
try:
|
|
304
|
+
model = apps.get_model(app_label, _token(row, "model-"))
|
|
305
|
+
except LookupError:
|
|
306
|
+
return None
|
|
307
|
+
return model if self._tools.urls.site.is_registered(model) else None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class ModelPage(AdminPage):
|
|
311
|
+
"""An admin page about one model, which it keeps for what reads it."""
|
|
312
|
+
|
|
313
|
+
def __init__(
|
|
314
|
+
self,
|
|
315
|
+
page: Page,
|
|
316
|
+
status_code: int,
|
|
317
|
+
requested: str,
|
|
318
|
+
tools: PageTools,
|
|
319
|
+
model: type[Model],
|
|
320
|
+
) -> None:
|
|
321
|
+
super().__init__(page, status_code, requested, tools)
|
|
322
|
+
self._model = model
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class ChangelistPage(ModelPage):
|
|
326
|
+
"""A model's changelist: its columns, its rows, and how many records it reports."""
|
|
327
|
+
|
|
328
|
+
_kind = "change-list"
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def headers(self) -> list[str]:
|
|
332
|
+
"""The column labels the page shows, in order.
|
|
333
|
+
|
|
334
|
+
An empty changelist shows no table, so it has no headers either.
|
|
335
|
+
"""
|
|
336
|
+
return [_text(cell.locator("div.text")) for cell in self._header_cells]
|
|
337
|
+
|
|
338
|
+
def has_header(self, label: str) -> bool:
|
|
339
|
+
return label in self.headers
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def columns(self) -> list[str]:
|
|
343
|
+
"""The same columns by the names the admin is configured with, in the same order."""
|
|
344
|
+
return [_token(cell, "column-") for cell in self._header_cells]
|
|
345
|
+
|
|
346
|
+
def has_column(self, name: str) -> bool:
|
|
347
|
+
return name in self.columns
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def rows(self) -> list[Row]:
|
|
351
|
+
"""The rows the changelist shows, in order.
|
|
352
|
+
|
|
353
|
+
An empty changelist shows no table, so it has no rows either.
|
|
354
|
+
"""
|
|
355
|
+
columns = self.columns
|
|
356
|
+
elements = self._shown().locator("#result_list tbody tr").all()
|
|
357
|
+
return [
|
|
358
|
+
Row(
|
|
359
|
+
element,
|
|
360
|
+
index,
|
|
361
|
+
columns=columns,
|
|
362
|
+
model=self._model,
|
|
363
|
+
urls=self._tools.urls,
|
|
364
|
+
check=self._check,
|
|
365
|
+
normalizers=self._tools.normalizers,
|
|
366
|
+
)
|
|
367
|
+
for index, element in enumerate(elements)
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
def contains(self, pattern: Any) -> bool:
|
|
371
|
+
"""Whether some row matches ``pattern``, a cell pattern per column in order,
|
|
372
|
+
or ``ANY_ROW``.
|
|
373
|
+
|
|
374
|
+
Returns ``True``; when no row matches, raises ``AssertionError`` showing the
|
|
375
|
+
pattern and every row the changelist has.
|
|
376
|
+
"""
|
|
377
|
+
__tracebackhide__ = True
|
|
378
|
+
return matching.contains(pattern, self.rows)
|
|
379
|
+
|
|
380
|
+
def match(self, patterns: Any) -> bool:
|
|
381
|
+
"""Whether the changelist is exactly ``patterns``: as many rows as patterns, in a
|
|
382
|
+
list or tuple, each row matching the pattern at its position; ``ANY_ROW`` holds
|
|
383
|
+
a position.
|
|
384
|
+
|
|
385
|
+
Returns ``True``; otherwise raises ``AssertionError`` showing the patterns, what
|
|
386
|
+
went wrong and every row the changelist has.
|
|
387
|
+
"""
|
|
388
|
+
__tracebackhide__ = True
|
|
389
|
+
return matching.match(patterns, self.rows)
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def _header_cells(self) -> list[Locator]:
|
|
393
|
+
# The checkbox Django adds for actions is a column only for users who have an
|
|
394
|
+
# action to run, and it has no label, so it is not one here. The label is read
|
|
395
|
+
# from `div.text` because the cell also holds sorting controls.
|
|
396
|
+
return self._shown().locator("#result_list thead th:not(.action-checkbox-column)").all()
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def count(self) -> int:
|
|
400
|
+
"""The number of records the changelist reports, across all of its pages."""
|
|
401
|
+
return integer(self._count_line.group("number"))
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def summary(self) -> str:
|
|
405
|
+
"""The count as the page words it, such as ``"3 products"``."""
|
|
406
|
+
return self._count_line.group(0)
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def empty(self) -> bool:
|
|
410
|
+
"""Reports no records at all."""
|
|
411
|
+
return self.count == 0
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def _count_line(self) -> re.Match[str]:
|
|
415
|
+
# The count is the paginator's own text. Page links, "Show all" and, from
|
|
416
|
+
# Django 6.0, a heading for screen readers are all inside child elements, so
|
|
417
|
+
# only the element's direct text nodes are read.
|
|
418
|
+
paginator = self._shown().locator("#changelist .paginator")
|
|
419
|
+
text = paginator.evaluate(
|
|
420
|
+
"el => Array.from(el.childNodes)"
|
|
421
|
+
".filter(node => node.nodeType === Node.TEXT_NODE)"
|
|
422
|
+
".map(node => node.textContent).join(' ')"
|
|
423
|
+
)
|
|
424
|
+
# The number may carry grouping characters when the project localizes it,
|
|
425
|
+
# which is why the name is required to start with something other than a
|
|
426
|
+
# digit.
|
|
427
|
+
match = re.search(r"(?P<number>\d[\d,.\s]*?)\s+[^\d\s].*", " ".join(str(text).split()))
|
|
428
|
+
assert match is not None, f"unexpected paginator text {text!r}"
|
|
429
|
+
return match
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class FormPage(ModelPage):
|
|
433
|
+
"""An admin page with the model's form on it: which fields the form shows."""
|
|
434
|
+
|
|
435
|
+
_kind = "change-form"
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def fields(self) -> Fields:
|
|
439
|
+
"""The fields the form shows, by name, in the order the admin presents them.
|
|
440
|
+
|
|
441
|
+
A field the admin renders hidden is not shown to the user, so it is not here
|
|
442
|
+
either; the browser posts it with the form all the same.
|
|
443
|
+
"""
|
|
444
|
+
# The form's own fieldsets are direct children of the form's one div, which
|
|
445
|
+
# leaves out the inline formsets next to them. Each line of a fieldset is a
|
|
446
|
+
# `form-row` carrying a `field-<name>` class per field on it: a line with one
|
|
447
|
+
# field is that field's box, a line with several holds a `fieldBox` per field.
|
|
448
|
+
page = self._shown()
|
|
449
|
+
# The admin says which language it rendered the page in, and the fields need
|
|
450
|
+
# it to know what the form put after each label.
|
|
451
|
+
language = page.locator("html").get_attribute("lang") or ""
|
|
452
|
+
admin = model_admin(self._tools.urls.site, self._model)
|
|
453
|
+
fields = Fields()
|
|
454
|
+
for row in page.locator("form > div > fieldset.module .form-row").all():
|
|
455
|
+
boxes = [row] if len(_field_names(row)) == 1 else row.locator(".fieldBox").all()
|
|
456
|
+
for box in boxes:
|
|
457
|
+
classes = (box.get_attribute("class") or "").split()
|
|
458
|
+
if "hidden" in classes:
|
|
459
|
+
continue
|
|
460
|
+
for name in _field_names(box):
|
|
461
|
+
fields[name] = FormField(
|
|
462
|
+
box,
|
|
463
|
+
name,
|
|
464
|
+
language=language,
|
|
465
|
+
check=self._check,
|
|
466
|
+
normalizers=self._tools.normalizers,
|
|
467
|
+
model_admin=admin,
|
|
468
|
+
widgets=self._tools.widgets,
|
|
469
|
+
)
|
|
470
|
+
return fields
|
|
471
|
+
|
|
472
|
+
@property
|
|
473
|
+
def required_fields(self) -> set[str]:
|
|
474
|
+
"""The names of the fields the form requires."""
|
|
475
|
+
return {name for name, field in self.fields.items() if field.required}
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def optional_fields(self) -> set[str]:
|
|
479
|
+
"""The names of the fields the user may fill or leave alone.
|
|
480
|
+
|
|
481
|
+
A field the user may only read is in neither set: there is nothing to fill.
|
|
482
|
+
"""
|
|
483
|
+
return {
|
|
484
|
+
name for name, field in self.fields.items() if field.editable and not field.required
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
@property
|
|
488
|
+
def errors(self) -> ValidationErrors:
|
|
489
|
+
"""The validation errors the form shows; none until the admin rejects it."""
|
|
490
|
+
self._shown()
|
|
491
|
+
return ValidationErrors(
|
|
492
|
+
lambda: self._shown().locator("#content-main form"), lambda: self.fields
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
@property
|
|
496
|
+
def actions(self) -> set[str]:
|
|
497
|
+
"""The names of the actions the form offers: what each of its buttons posts.
|
|
498
|
+
|
|
499
|
+
The names are the buttons' own, as the admin's view checks for them, so the admin's
|
|
500
|
+
are ``"_save"`` and the like, also found in ``FormAction``, and a project's are what
|
|
501
|
+
its template writes. A link submits nothing, so the delete link is not an action,
|
|
502
|
+
and neither is the "Close" link shown to a user who may not save.
|
|
503
|
+
"""
|
|
504
|
+
# A button without a name cannot be told apart by the view, so it is no action.
|
|
505
|
+
buttons = self._submit_row.locator(
|
|
506
|
+
'input[type="submit"][name], button[type="submit"][name], button:not([type])[name]'
|
|
507
|
+
)
|
|
508
|
+
return {str(button.get_attribute("name")) for button in buttons.all()}
|
|
509
|
+
|
|
510
|
+
def has_action(self, name: str) -> bool:
|
|
511
|
+
return name in self.actions
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def can_save(self) -> bool:
|
|
515
|
+
return self.has_action(FormAction.SAVE)
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def can_save_and_continue(self) -> bool:
|
|
519
|
+
return self.has_action(FormAction.SAVE_AND_CONTINUE)
|
|
520
|
+
|
|
521
|
+
@property
|
|
522
|
+
def can_save_and_add_another(self) -> bool:
|
|
523
|
+
return self.has_action(FormAction.SAVE_AND_ADD_ANOTHER)
|
|
524
|
+
|
|
525
|
+
@property
|
|
526
|
+
def can_save_as_new(self) -> bool:
|
|
527
|
+
return self.has_action(FormAction.SAVE_AS_NEW)
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def can_delete(self) -> bool:
|
|
531
|
+
"""Whether the form links to the page that deletes its object."""
|
|
532
|
+
return self._submit_row.locator("a.deletelink").count() > 0
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def _submit_row(self) -> Locator:
|
|
536
|
+
# An admin with `save_on_top` draws the same row above the form as well.
|
|
537
|
+
return self._shown().locator("#content-main form .submit-row").first
|
|
538
|
+
|
|
539
|
+
def populate(
|
|
540
|
+
self,
|
|
541
|
+
source: Any = None,
|
|
542
|
+
mode: PagePopulationMode = PagePopulationMode.ALL,
|
|
543
|
+
/,
|
|
544
|
+
**values: Any,
|
|
545
|
+
) -> None:
|
|
546
|
+
"""Fill the form from ``source``, from ``values``, or from both.
|
|
547
|
+
|
|
548
|
+
``source`` is a dictionary read by field name, or any object read by attribute:
|
|
549
|
+
a model instance, a namespace, or something the project wrote. A keyword adds a
|
|
550
|
+
field the source lacks or overrides what it holds for one, so a test states the
|
|
551
|
+
value it cares about next to the data it reuses. A field neither names is left
|
|
552
|
+
as the page rendered it, and a name the form does not have fills nothing.
|
|
553
|
+
|
|
554
|
+
``mode`` narrows which fields are filled to the ones the form requires or to
|
|
555
|
+
the ones it leaves to the user, so one set of data serves a test about either.
|
|
556
|
+
Whatever the mode, the source is not checked against it: what it says about a
|
|
557
|
+
field outside the mode is simply not used.
|
|
558
|
+
|
|
559
|
+
The form decides what may be filled, not the source: the fields are filled in
|
|
560
|
+
the order the form shows them, and a field the user may only read is passed
|
|
561
|
+
over, as is one the admin renders hidden.
|
|
562
|
+
"""
|
|
563
|
+
wanted = PagePopulationMode(mode)
|
|
564
|
+
for name, field in self.fields.items():
|
|
565
|
+
if not _within(wanted, field):
|
|
566
|
+
continue
|
|
567
|
+
value = values[name] if name in values else _value_of(source, name)
|
|
568
|
+
if value is not _NOTHING:
|
|
569
|
+
field.fill(value)
|
|
570
|
+
|
|
571
|
+
def submit(self, action: str) -> SubmissionResult:
|
|
572
|
+
"""Click the button that posts ``action``, and wait for the next page.
|
|
573
|
+
|
|
574
|
+
``action`` is the button's ``name``, or a ``FormAction``. If the page has no such
|
|
575
|
+
button, nothing is clicked and ``LookupError`` lists the actions it has.
|
|
576
|
+
|
|
577
|
+
The result's page is this same object if the browser stayed on this page, for
|
|
578
|
+
example when the admin rejects the form and shows it again.
|
|
579
|
+
"""
|
|
580
|
+
# Use the plain name, so an error shows "_save" rather than the enum member.
|
|
581
|
+
name = action.value if isinstance(action, FormAction) else action
|
|
582
|
+
# Read the actions once, for both the check and the error message.
|
|
583
|
+
offered = self.actions
|
|
584
|
+
if name not in offered:
|
|
585
|
+
raise LookupError(
|
|
586
|
+
f"The page offers no action {name!r}. Actions: "
|
|
587
|
+
f"{', '.join(repr(each) for each in sorted(offered)) or 'none'}."
|
|
588
|
+
)
|
|
589
|
+
with self._page.expect_navigation() as navigation:
|
|
590
|
+
self._submit_row.locator(f'[name="{name}"]').first.click()
|
|
591
|
+
return self._submitted(navigation.value)
|
|
592
|
+
|
|
593
|
+
def save(self) -> SubmissionResult:
|
|
594
|
+
return self.submit(FormAction.SAVE)
|
|
595
|
+
|
|
596
|
+
def save_and_continue(self) -> SubmissionResult:
|
|
597
|
+
return self.submit(FormAction.SAVE_AND_CONTINUE)
|
|
598
|
+
|
|
599
|
+
def save_and_add_another(self) -> SubmissionResult:
|
|
600
|
+
return self.submit(FormAction.SAVE_AND_ADD_ANOTHER)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class CreatePage(FormPage):
|
|
604
|
+
"""The page that adds a new instance of a model."""
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
class EditPage(FormPage):
|
|
608
|
+
"""The change page of one instance, read only for a user who may only view it."""
|
|
609
|
+
|
|
610
|
+
# Both of these need an object that already exists, so the admin offers them on an
|
|
611
|
+
# edit page and never on a create page.
|
|
612
|
+
def save_as_new(self) -> SubmissionResult:
|
|
613
|
+
return self.submit(FormAction.SAVE_AS_NEW)
|
|
614
|
+
|
|
615
|
+
def delete(self) -> DeletePage:
|
|
616
|
+
"""Follow the delete link to the page that asks to confirm the deletion.
|
|
617
|
+
|
|
618
|
+
Deleting takes two steps in the admin, and this is the first: nothing is deleted
|
|
619
|
+
until ``confirm_deleting`` on the returned page. The link submits nothing, so the
|
|
620
|
+
confirmation page comes back on its own, as opening it directly would give it. If
|
|
621
|
+
the page has no delete link, nothing is clicked and ``LookupError`` says so.
|
|
622
|
+
"""
|
|
623
|
+
link = self._submit_row.locator("a.deletelink")
|
|
624
|
+
if not link.count():
|
|
625
|
+
raise LookupError("The page offers no delete link.")
|
|
626
|
+
# The link may carry the changelist's filters in its query; the page is its path.
|
|
627
|
+
path = urlsplit(link.get_attribute("href") or "").path
|
|
628
|
+
with self._page.expect_navigation() as navigation:
|
|
629
|
+
link.click()
|
|
630
|
+
response = navigation.value
|
|
631
|
+
# A navigation of the page always comes with a response.
|
|
632
|
+
assert response is not None
|
|
633
|
+
return DeletePage(self._page, response.status, path, self._tools, self._model)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class DeletePage(ModelPage):
|
|
637
|
+
"""The page that asks whether to delete one instance."""
|
|
638
|
+
|
|
639
|
+
_kind = "delete-confirmation"
|
|
640
|
+
|
|
641
|
+
@property
|
|
642
|
+
def can_confirm_deleting(self) -> bool:
|
|
643
|
+
"""Whether the page lets the user confirm the deletion.
|
|
644
|
+
|
|
645
|
+
The admin offers no confirmation when deleting the object would also delete
|
|
646
|
+
objects that are protected, or that the user may not delete; the page then lists
|
|
647
|
+
them instead.
|
|
648
|
+
"""
|
|
649
|
+
return self._confirm_button.count() > 0
|
|
650
|
+
|
|
651
|
+
@property
|
|
652
|
+
def deletions(self) -> list[str]:
|
|
653
|
+
"""What the deletion will remove, as the page lists it: the object itself, then every
|
|
654
|
+
related object it takes with it, in the order shown.
|
|
655
|
+
|
|
656
|
+
Each entry is the text the page shows, such as ``"Product: Widget"``, whether or not
|
|
657
|
+
the admin links it. The page draws the list nested; it is read top to bottom. A page
|
|
658
|
+
that cannot be confirmed removes nothing, so it has none. On Django 6.1, an admin
|
|
659
|
+
that sets ``delete_confirmation_max_display`` shows only part of the list, ending in
|
|
660
|
+
an entry such as "…and 2 more objects.", which is read as shown too.
|
|
661
|
+
"""
|
|
662
|
+
# The list of what is removed follows its heading, which follows the summary's list.
|
|
663
|
+
# The lists a page shows when it cannot be confirmed follow the opening sentence,
|
|
664
|
+
# and before Django 4.2 none of the lists has an id to tell them apart.
|
|
665
|
+
items = self._shown().locator("#content > ul + h2 + ul li").all()
|
|
666
|
+
return [_own_text(item) for item in items]
|
|
667
|
+
|
|
668
|
+
@property
|
|
669
|
+
def intro(self) -> str:
|
|
670
|
+
"""The sentence the page starts with, as shown, or ``""`` when it has none.
|
|
671
|
+
|
|
672
|
+
It asks to confirm the deletion, or says why the page cannot. Its wording depends on
|
|
673
|
+
which, on the language, and on the Django version: 6.0 changed its quote marks.
|
|
674
|
+
"""
|
|
675
|
+
sentence = self._shown().locator("#content > p").first
|
|
676
|
+
return text_of(sentence) if sentence.count() else ""
|
|
677
|
+
|
|
678
|
+
@property
|
|
679
|
+
def blockers(self) -> list[str]:
|
|
680
|
+
"""What keeps the deletion from being confirmed, as the page lists it in place of the
|
|
681
|
+
deletions: the objects that protect this one, such as ``"Product: Widget"``, or the
|
|
682
|
+
kinds of object it would take with it that the user may not delete, such as
|
|
683
|
+
``"product"``.
|
|
684
|
+
|
|
685
|
+
The page draws both lists alike, so they are one list here; ``intro`` says which it is.
|
|
686
|
+
When both apply, the admin lists only the kinds. A page that can be confirmed has none.
|
|
687
|
+
Django gathers both lists without an order, so several entries come in the admin's.
|
|
688
|
+
"""
|
|
689
|
+
# The list follows the opening sentence. On a page that can be confirmed, the
|
|
690
|
+
# summary's heading follows it instead.
|
|
691
|
+
items = self._shown().locator("#content > p + ul > li").all()
|
|
692
|
+
return [text_of(item) for item in items]
|
|
693
|
+
|
|
694
|
+
@property
|
|
695
|
+
def deletion_counts(self) -> DeletionCounts:
|
|
696
|
+
"""How many objects of each model the deletion will remove, as the page's "Summary"
|
|
697
|
+
counts them, such as ``{"Products": 1, "Reviews": 2}``.
|
|
698
|
+
|
|
699
|
+
The keys are the labels the page shows, in the order shown; the page names a model by
|
|
700
|
+
nothing else. A model the deletion removes none of is not counted, so it is not a key.
|
|
701
|
+
A page that cannot be confirmed removes nothing, so it counts nothing. The counts stay
|
|
702
|
+
whole when Django 6.1 shows only part of the deletions.
|
|
703
|
+
"""
|
|
704
|
+
# The summary's list follows its heading, which follows the opening sentence. A page
|
|
705
|
+
# that cannot be confirmed has a list right after the sentence, and no heading.
|
|
706
|
+
counts = DeletionCounts()
|
|
707
|
+
for item in self._shown().locator("#content > p + h2 + ul > li").all():
|
|
708
|
+
label, _, count = text_of(item).rpartition(": ")
|
|
709
|
+
counts[label] = integer(count)
|
|
710
|
+
return counts
|
|
711
|
+
|
|
712
|
+
def confirm_deleting(self) -> SubmissionResult:
|
|
713
|
+
"""Click "Yes, I'm sure", and wait for the next page.
|
|
714
|
+
|
|
715
|
+
If the page offers no confirmation, nothing is clicked and ``LookupError`` says
|
|
716
|
+
so.
|
|
717
|
+
"""
|
|
718
|
+
button = self._confirm_button
|
|
719
|
+
if not button.count():
|
|
720
|
+
raise LookupError("The page offers no way to confirm the deletion.")
|
|
721
|
+
with self._page.expect_navigation() as navigation:
|
|
722
|
+
button.click()
|
|
723
|
+
return self._submitted(navigation.value)
|
|
724
|
+
|
|
725
|
+
@property
|
|
726
|
+
def _confirm_button(self) -> Locator:
|
|
727
|
+
return self._shown().locator('#content form input[type="submit"]')
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
class DeletionCounts(Dict[str, int]):
|
|
731
|
+
"""How many objects of each model a deletion removes, by the label the page shows.
|
|
732
|
+
|
|
733
|
+
A plain dict, so membership, ``set()`` and ``list()`` are what they always are; only a
|
|
734
|
+
miss says more than a bare ``KeyError`` would.
|
|
735
|
+
"""
|
|
736
|
+
|
|
737
|
+
def __missing__(self, key: str) -> int:
|
|
738
|
+
raise KeyError(
|
|
739
|
+
f"The page counts no model {key!r}. Models: "
|
|
740
|
+
f"{', '.join(repr(label) for label in self) or 'none'}."
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# The page class for each admin view of a model, by the end of the view's URL name.
|
|
745
|
+
_MODEL_PAGES: dict[str, type[ModelPage]] = {
|
|
746
|
+
"changelist": ChangelistPage,
|
|
747
|
+
"add": CreatePage,
|
|
748
|
+
"change": EditPage,
|
|
749
|
+
"delete": DeletePage,
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _page_at(page: Page, status_code: int, tools: PageTools) -> AdminPage:
|
|
754
|
+
"""A page object for the page the browser is on.
|
|
755
|
+
|
|
756
|
+
The class comes from the URL, and is used only if the page's ``body`` class confirms
|
|
757
|
+
it; otherwise the result is a plain ``AdminPage``. The page counts as requested at its
|
|
758
|
+
current path, so it reads like a page the test opened directly.
|
|
759
|
+
"""
|
|
760
|
+
path = urlsplit(page.url).path
|
|
761
|
+
try:
|
|
762
|
+
match = resolve(path)
|
|
763
|
+
except Resolver404:
|
|
764
|
+
return AdminPage(page, status_code, path, tools)
|
|
765
|
+
# A view added to the admin without a name has no URL name.
|
|
766
|
+
url_name = match.url_name or ""
|
|
767
|
+
if match.namespace != tools.urls.site.name:
|
|
768
|
+
return AdminPage(page, status_code, path, tools)
|
|
769
|
+
# A view can render a different page at its own URL, so the URL alone is not trusted.
|
|
770
|
+
if url_name == "index" and _is_kind(page, IndexPage._kind):
|
|
771
|
+
return IndexPage(page, status_code, path, tools)
|
|
772
|
+
# Find the model by the full prefix of its URL names, such as "shop_product_",
|
|
773
|
+
# because an app label may itself contain underscores.
|
|
774
|
+
for model in apps.get_models():
|
|
775
|
+
if not tools.urls.site.is_registered(model):
|
|
776
|
+
continue
|
|
777
|
+
prefix = f"{model._meta.app_label}_{model._meta.model_name}_"
|
|
778
|
+
if url_name.startswith(prefix):
|
|
779
|
+
page_class = _MODEL_PAGES.get(url_name[len(prefix) :])
|
|
780
|
+
if page_class is not None and _is_kind(page, page_class._kind):
|
|
781
|
+
return page_class(page, status_code, path, tools, model)
|
|
782
|
+
return AdminPage(page, status_code, path, tools)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _is_kind(page: Page, kind: str | None) -> bool:
|
|
786
|
+
"""Whether ``page`` has the page type ``kind`` in its ``body`` class. ``None``
|
|
787
|
+
matches any page."""
|
|
788
|
+
if kind is None:
|
|
789
|
+
return True
|
|
790
|
+
return kind in (page.locator("body").get_attribute("class") or "").split()
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _within(mode: PagePopulationMode, field: FormField) -> bool:
|
|
794
|
+
"""Whether ``mode`` covers ``field``, which must have something to fill at all."""
|
|
795
|
+
if not field.editable:
|
|
796
|
+
return False
|
|
797
|
+
if mode is PagePopulationMode.REQUIRED:
|
|
798
|
+
return field.required
|
|
799
|
+
if mode is PagePopulationMode.OPTIONAL:
|
|
800
|
+
return not field.required
|
|
801
|
+
return True
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _value_of(source: Any, name: str) -> Any:
|
|
805
|
+
"""What ``source`` holds for the field named ``name``, or ``_NOTHING``.
|
|
806
|
+
|
|
807
|
+
A dictionary is read by key and anything else by attribute, so a model instance, a
|
|
808
|
+
namespace and a project's own object are all sources, and only the form's own field
|
|
809
|
+
names are ever asked for.
|
|
810
|
+
"""
|
|
811
|
+
if source is None:
|
|
812
|
+
return _NOTHING
|
|
813
|
+
if isinstance(source, Mapping):
|
|
814
|
+
return source.get(name, _NOTHING)
|
|
815
|
+
return getattr(source, name, _NOTHING)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _text(element: Locator) -> str:
|
|
819
|
+
return (element.text_content() or "").strip() if element.count() else ""
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _own_text(item: Locator) -> str:
|
|
823
|
+
"""The text of a list entry without the entries nested in it, whitespace collapsed."""
|
|
824
|
+
text = item.evaluate(
|
|
825
|
+
"el => Array.from(el.childNodes)"
|
|
826
|
+
".filter(node => node.nodeName !== 'UL')"
|
|
827
|
+
".map(node => node.textContent).join('')"
|
|
828
|
+
)
|
|
829
|
+
return " ".join(str(text).split())
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _token(element: Locator, prefix: str) -> str:
|
|
833
|
+
classes = (element.get_attribute("class") or "").split()
|
|
834
|
+
return next(c[len(prefix) :] for c in classes if c.startswith(prefix))
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _field_names(element: Locator) -> list[str]:
|
|
838
|
+
# A field's box carries one `field-<name>` class; a line with several fields
|
|
839
|
+
# carries all of theirs. For example, class="form-row field-sku field-price".
|
|
840
|
+
classes = (element.get_attribute("class") or "").split()
|
|
841
|
+
return [c[len("field-") :] for c in classes if c.startswith("field-")]
|