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.
@@ -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-")]