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,951 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-django-admin-kit
3
+ Version: 1.0.0
4
+ Summary: A pytest toolkit for writing conscious tests against the Django admin.
5
+ Project-URL: Homepage, https://github.com/JeyKip/pytest-django-admin-kit
6
+ Project-URL: Source, https://github.com/JeyKip/pytest-django-admin-kit
7
+ Project-URL: Changelog, https://github.com/JeyKip/pytest-django-admin-kit/blob/main/CHANGELOG.md
8
+ Author: Yevhen Nosolenko
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Yevhen Nosolenko
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: admin,django,pytest,testing
32
+ Classifier: Development Status :: 5 - Production/Stable
33
+ Classifier: Framework :: Django
34
+ Classifier: Framework :: Django :: 3.2
35
+ Classifier: Framework :: Django :: 4.0
36
+ Classifier: Framework :: Django :: 4.1
37
+ Classifier: Framework :: Django :: 4.2
38
+ Classifier: Framework :: Django :: 5.0
39
+ Classifier: Framework :: Django :: 5.1
40
+ Classifier: Framework :: Django :: 5.2
41
+ Classifier: Framework :: Django :: 6.0
42
+ Classifier: Framework :: Django :: 6.1
43
+ Classifier: Framework :: Pytest
44
+ Classifier: Intended Audience :: Developers
45
+ Classifier: Operating System :: OS Independent
46
+ Classifier: Programming Language :: Python :: 3
47
+ Classifier: Programming Language :: Python :: 3.8
48
+ Classifier: Programming Language :: Python :: 3.9
49
+ Classifier: Programming Language :: Python :: 3.10
50
+ Classifier: Programming Language :: Python :: 3.11
51
+ Classifier: Programming Language :: Python :: 3.12
52
+ Classifier: Programming Language :: Python :: 3.13
53
+ Classifier: Programming Language :: Python :: 3.14
54
+ Classifier: Topic :: Software Development :: Testing
55
+ Classifier: Typing :: Typed
56
+ Requires-Python: >=3.8
57
+ Requires-Dist: django>=3.2
58
+ Requires-Dist: playwright>=1.48
59
+ Requires-Dist: pytest-django>=4.5
60
+ Requires-Dist: pytest-playwright>=0.5.2
61
+ Requires-Dist: pytest>=7.0
62
+ Description-Content-Type: text/markdown
63
+
64
+ # pytest-django-admin-kit
65
+
66
+ A pytest toolkit for writing conscious tests against the Django admin.
67
+
68
+ Tests describe admin pages, their fields, rows, permissions and errors, using ordinary `assert`
69
+ statements, and stay unchanged across supported Django versions.
70
+
71
+ ```python
72
+ def test_product_admin(admin_ui, admin_user, product):
73
+ admin_ui.login(admin_user)
74
+
75
+ assert admin_ui.list(Product).works
76
+ assert admin_ui.edit(product).works
77
+ ```
78
+
79
+ Install it as `pytest-django-admin-kit`; import it as `django_admin_kit`.
80
+
81
+ ## Status
82
+
83
+ Version 1.0.0 implements the API written up in [specification.md](specification.md), and its
84
+ public API is stable. What changed in each release is in [CHANGELOG.md](CHANGELOG.md), and what
85
+ comes next is the roadmap in section 33 of the specification.
86
+
87
+ ## What it solves today
88
+
89
+ **Logging in as any user, without knowing a password.** Passwords are stored hashed, and a
90
+ user created for a test may have none at all.
91
+
92
+ ```python
93
+ admin_ui.login(user)
94
+ ```
95
+
96
+ **Telling whether a page opened, was refused, or is not there.** The admin refuses in several
97
+ ways, and only some of them change the URL.
98
+
99
+ ```python
100
+ page = admin_ui.edit(product)
101
+
102
+ assert page.works # or page.denied, page.missing, page.redirected
103
+ ```
104
+
105
+ **Reading what a page calls itself.** The title and subtitle the admin renders, so a test
106
+ can say which page it is looking at without matching markup.
107
+
108
+ ```python
109
+ page = admin_ui.edit(product)
110
+
111
+ assert page.title == "Change product"
112
+ assert page.subtitle == "Widget"
113
+ ```
114
+
115
+ **Getting admin URLs without hardcoding `/admin/`.** URLs come from the site under test, so a
116
+ project that mounts its admin elsewhere changes nothing.
117
+
118
+ ```python
119
+ admin_ui.url.edit(product)
120
+ ```
121
+
122
+ **Testing the login page itself.** Typing into the form is an opt-in, never the default.
123
+
124
+ ```python
125
+ admin_ui.login(user, password="secret")
126
+ ```
127
+
128
+ **Opening a changelist and reading its count and columns.** The count is what the page says,
129
+ not the rows on one page of it; columns come by label and by the name the admin configures
130
+ them with; and a refused user gets `denied` rather than a count.
131
+
132
+ ```python
133
+ page = admin_ui.list(Product)
134
+
135
+ assert page.works
136
+ assert page.count == 3
137
+ assert page.summary == "3 products"
138
+ assert not page.empty
139
+ assert page.headers == ["Name", "Sku", "Price", "Is active", "Released on"]
140
+ assert page.columns == ["name", "sku", "price", "is_active", "released_on"]
141
+ assert page.has_header("Price")
142
+ assert page.has_column("is_active")
143
+ ```
144
+
145
+ **Reading and matching changelist rows.** A row is addressed by position and a cell by the
146
+ name its column is configured with. A cell's `value` is what the user reads, normalized only
147
+ where the admin drew something other than text; `contains` checks one row is there and
148
+ `match` checks the whole changelist in order, and both print the expected pattern and the
149
+ actual rows when they fail.
150
+
151
+ ```python
152
+ from django_admin_kit.matching import ANY, ANY_ROW
153
+ from django_admin_kit.rendered import Link
154
+
155
+ page = admin_ui.list(Product)
156
+
157
+ assert page.rows[0]["price"].value == "10.00"
158
+ assert page.rows[0]["is_active"].value is True
159
+ assert page.rows[0]["name"].links == [("Bolt", admin_ui.url.edit(bolt))]
160
+ assert page.rows[0].object == bolt
161
+
162
+ assert page.contains(("Bolt", "SKU-1", "10.00", True))
163
+ assert page.contains({"name": Link("Bolt", admin_ui.url.edit(bolt)), "is_active": ANY})
164
+ assert page.match([
165
+ ("Bolt", "SKU-1", "10.00", True),
166
+ ANY_ROW,
167
+ {"name": "Washer", "price": lambda row, cell: cell.value.endswith(".00")},
168
+ ])
169
+ ```
170
+
171
+ **Reading form fields.** The create and edit pages list their fields by name, in the order the
172
+ admin presents them, whether the user may fill them or only read them. A field the admin renders
173
+ hidden is not shown, so it is not listed. Whether a field is required is read the way the user
174
+ sees it, from the form the admin renders, so a `ModelForm` that disagrees with the model wins.
175
+ A field's `label` is what the user sees next to it, without the colon. Its `value` is what its
176
+ control holds: text as typed, a checkbox as a boolean, a select as the choice made, with the
177
+ value the form posts and the label the user reads; a rendered-only field reads as a changelist
178
+ cell does.
179
+
180
+ ```python
181
+ page = admin_ui.edit(product)
182
+
183
+ assert list(page.fields) == [
184
+ "name",
185
+ "sku",
186
+ "price",
187
+ "quantity",
188
+ "is_active",
189
+ "featured",
190
+ "released_on",
191
+ "category",
192
+ "price_with_tax",
193
+ ]
194
+ assert "name" in page.fields
195
+ assert page.fields["name"].required
196
+ assert not page.fields["price_with_tax"].editable
197
+ assert page.required_fields == {"name", "sku", "price", "quantity", "released_on"}
198
+ assert page.optional_fields == {"is_active", "featured", "category"}
199
+
200
+ assert page.fields["name"].label == "Name"
201
+ assert page.fields["name"].value == "Widget"
202
+ assert page.fields["is_active"].value is True
203
+ assert page.fields["category"].value == (str(tools.pk), "Tools")
204
+ assert page.fields["category"].value.label == "Tools"
205
+ assert page.fields["category"].choices == [("", "---------"), (str(tools.pk), "Tools")]
206
+ assert page.fields["price_with_tax"].value == "12.000"
207
+ page.fields["name"].native.locator("input").fill("Gadget")
208
+
209
+ admin_ui.login(viewer) # may only view, so every field is rendered only
210
+ page = admin_ui.edit(product)
211
+
212
+ assert page.fields["released_on"].value == "Jan. 15, 2026"
213
+ assert page.fields["category"].links == [("Tools", admin_ui.url.edit(tools))]
214
+ ```
215
+
216
+ **Filling a field the way a user would.** Each control is written in the terms it is read
217
+ back in, so a value goes in as the test has it rather than as the markup wants it.
218
+
219
+ ```python
220
+ page = admin_ui.create(Product)
221
+
222
+ page.fields["name"].fill("Widget")
223
+ page.fields["price"].fill(Decimal("19.99"))
224
+ page.fields["quantity"].fill(3)
225
+ page.fields["released_on"].fill(date(2026, 1, 15))
226
+ page.fields["is_active"].fill(False)
227
+ page.fields["featured"].fill(True)
228
+ page.fields["category"].fill(str(tools.pk))
229
+
230
+ assert page.fields["price"].value == "19.99"
231
+ assert page.fields["featured"].value == ("true", "Yes")
232
+ ```
233
+
234
+ A select takes back a choice it offered, `page.fields["category"].fill(choice)`, and `None`
235
+ puts it on its blank option. A value no option has raises `ValueError` naming the options
236
+ there are, and a field the user may only read raises `LookupError`.
237
+
238
+ **Filling a whole form in one call.** A dictionary carries the shared data and keywords carry
239
+ the values this one test is about. Fields that are named nowhere keep the values the admin
240
+ rendered them with.
241
+
242
+ ```python
243
+ page = admin_ui.create(Product)
244
+
245
+ page.populate({
246
+ "name": "Widget",
247
+ "sku": "SKU-1",
248
+ "price": "19.99",
249
+ })
250
+ page.populate(name="Gadget", is_active=False)
251
+
252
+ assert page.fields["name"].value == "Gadget"
253
+ assert page.fields["quantity"].value == "1" # untouched, as the admin rendered it
254
+ ```
255
+
256
+ Only the form's own fields are filled, so a key the form has no field for changes nothing,
257
+ and `price_with_tax`, which the admin renders read-only, is passed over.
258
+
259
+ A mode, `PagePopulationMode` from `django_admin_kit.pages`, narrows what is filled to the
260
+ fields the form requires or to the ones it leaves open, so one data set serves a test about
261
+ either:
262
+
263
+ ```python
264
+ page.populate(data, PagePopulationMode.REQUIRED) # name, sku, price, quantity, released_on
265
+ page.populate(data, PagePopulationMode.OPTIONAL) # is_active, featured, category
266
+ ```
267
+
268
+ The source can be an object instead, read by attribute, which makes a saved record a form's
269
+ worth of data:
270
+
271
+ ```python
272
+ page.populate(product) # its category picks that option
273
+ page.populate(product, name="Renamed") # everything it has, with one value replaced
274
+ page.populate(SimpleNamespace(name="Widget", price="19.99"))
275
+ ```
276
+
277
+ **Teaching the package a widget.** The package reads and fills checkboxes, selects, and any
278
+ single control named after its field. For any other widget, such as one your project draws
279
+ itself, write a widget class and list it in your settings. From then on `value`, `fill`,
280
+ `choices` and `populate` work on that field as on any other.
281
+
282
+ ```python
283
+ # shop/kit_widgets.py
284
+ from django_admin_kit.fields import FieldChoice
285
+
286
+
287
+ class SwatchPickerWidget:
288
+ def marker(self, field):
289
+ return ".swatch-picker"
290
+
291
+ def read(self, field):
292
+ pressed = field.native.locator('button[aria-pressed="true"]')
293
+ return self._choice(pressed) if pressed.count() else None
294
+
295
+ def fill(self, field, value):
296
+ field.native.locator(f'button[data-value="{value}"]').click()
297
+
298
+ def choices(self, field):
299
+ return [self._choice(button) for button in field.native.locator("button").all()]
300
+
301
+ def _choice(self, button):
302
+ return FieldChoice(button.get_attribute("data-value"), button.inner_text())
303
+ ```
304
+
305
+ ```python
306
+ DJANGO_ADMIN_KIT = {
307
+ "widgets": [
308
+ "shop.kit_widgets.SwatchPickerWidget",
309
+ ],
310
+ }
311
+ ```
312
+
313
+ ```python
314
+ page.fields["color"].fill("green")
315
+
316
+ assert page.fields["color"].value == ("green", "Green")
317
+ assert page.fields["color"].choices == [("red", "Red"), ("green", "Green"), ("blue", "Blue")]
318
+ ```
319
+
320
+ The package handles a field in two steps:
321
+
322
+ 1. **Which class handles the field?** The package sends every class's marker to the browser in
323
+ one call. The first one that matches something inside the field's box picks the class. What
324
+ it matched is not kept.
325
+ 2. **Read or fill it.** The package calls `read(field)` or `fill(field, value)` on that class.
326
+ `field.native` is the whole box, label included, and each method finds the parts it needs
327
+ there.
328
+
329
+ So a marker only has to recognize the widget. It can be a wrapper, or a part that only some
330
+ renderings have. Take django-file-resubmit: its image widget looks like a plain file input until
331
+ a save fails. Then it adds a hidden `image_cache_key` input and the cached file's name, so the
332
+ user doesn't have to pick the file again. A class for that case is marked by the hidden input,
333
+ and its `fill` works on the file input next to it:
334
+
335
+ ```python
336
+ class ResubmitFileWidget:
337
+ def marker(self, field): # only a form rendered back with a cached upload
338
+ return f'input[type="hidden"][name="{field.name}_cache_key"]'
339
+
340
+ def read(self, field): # the cached file's name
341
+ ...
342
+
343
+ def fill(self, field, value): # a new upload replaces the cached one
344
+ field.native.locator(f'input[type="file"][name="{field.name}"]').set_input_files(value)
345
+ ```
346
+
347
+ Here `field.native` holds the label, the file input, the "Currently" link, the cached file's name
348
+ and the hidden input. A marker is plain CSS as the browser reads it, so Playwright's own syntax,
349
+ such as `text=`, belongs in `read` and `fill`. To combine conditions, use `:has()`:
350
+ `':scope:has(input[name="image_cache_key"]) input[type="file"]'`.
351
+
352
+ Your classes are tried first, in the order you list them, then the package's own:
353
+ `CheckboxWidget`, `SelectWidget` and `TextWidget`, from `django_admin_kit.widgets`. Two rules
354
+ follow from that:
355
+
356
+ - A marker matches what is particular to its widget. A class marked by `input[type="file"]`
357
+ would take every file field in the project.
358
+ - Narrower classes come before broader ones, since the first match wins. `ResubmitFileWidget`
359
+ goes before a class for any file input.
360
+
361
+ To read a control the package knows differently, put a subclass of its class in front of it:
362
+
363
+ ```python
364
+ class BareSelectWidget(SelectWidget):
365
+ def read(self, field):
366
+ return super().read(field).value # "3" rather than ("3", "Tools")
367
+ ```
368
+
369
+ `marker`, `read` and `fill` are required. `choices` is optional, and `field.choices` is `[]`
370
+ without it. A class never calls `value`, `fill` or `choices` on the field it gets, since that
371
+ asks the widget classes again and never ends. `marker` runs for every field read or filled, so
372
+ it builds a selector and doesn't read the page.
373
+
374
+ A widget made of several inputs, such as the admin's split date-time, is one field and one class.
375
+ Parts drawn outside the box are reached through `field.native.page`, and parts in a frame
376
+ through `field.native.frame_locator`. A field no class recognizes raises `LookupError` right
377
+ away, naming the `widgets` setting. Widget classes only apply to fields the user can edit; a
378
+ field the user may only read is read by the rules described below.
379
+
380
+ **Knowing what a form lets the user do.** An action is a button that submits the form,
381
+ named exactly by what it posts, which is what the admin's view checks for. The admin's own
382
+ are constants in `FormAction`, from `django_admin_kit.pages`, and each has its own check.
383
+
384
+ ```python
385
+ page = admin_ui.edit(product)
386
+
387
+ assert page.actions == {
388
+ FormAction.SAVE,
389
+ FormAction.SAVE_AND_CONTINUE,
390
+ FormAction.SAVE_AND_ADD_ANOTHER,
391
+ }
392
+ assert page.has_action("_continue")
393
+ assert page.can_save
394
+ assert not page.can_save_as_new
395
+ assert page.can_delete
396
+ ```
397
+
398
+ A button a project adds is an action under the name it posts, such as `"_refresh"` on the
399
+ test project's feeds. Deleting is a link rather than a button, so it is not in `actions` and
400
+ has its own check.
401
+
402
+ **Submitting a form.** Filling a form and submitting it are separate steps. Each of the
403
+ admin's buttons has a method, `save()`, `save_and_continue()`, `save_and_add_another()`, and
404
+ `save_as_new()` on an edit page, and `submit` presses any button by the name it posts. An
405
+ action the page does not offer raises `LookupError` naming the ones it does, and submits
406
+ nothing.
407
+
408
+ ```python
409
+ page = admin_ui.create(Product)
410
+ page.populate(
411
+ name="Widget",
412
+ sku="SKU-1",
413
+ price="10.00",
414
+ released_on=date(2026, 1, 15),
415
+ )
416
+
417
+ result = page.save()
418
+
419
+ assert result.success
420
+ assert result.redirected_to_list(Product)
421
+ assert result.page.count == 1
422
+ ```
423
+
424
+ `success` is whether the admin redirected, which it does after every save it accepts.
425
+ `redirected_to(url)` takes any `admin_ui.url` path, and `redirected_to_index()`,
426
+ `redirected_to_list(model)`, `redirected_to_create(model)` and `redirected_to_edit(instance)`
427
+ are shorthands for the usual ones. A form the admin rejects is shown again at its own URL
428
+ without a redirect, so it is never redirected there.
429
+
430
+ `result.page` is the page the browser shows afterwards. When the browser stays on the
431
+ submitted page, as with a rejected form or "Save and continue", it is that same page object;
432
+ otherwise it is a new page of the type its URL names, or a plain `AdminPage` for a page the
433
+ package does not model.
434
+
435
+ ```python
436
+ page = admin_ui.create(Product)
437
+ page.populate(name="", price="10.00")
438
+
439
+ result = page.save()
440
+
441
+ assert not result.success
442
+ assert result.page is page
443
+ assert page.fields["price"].value == "10.00"
444
+ ```
445
+
446
+ **Finding out why a form was rejected.** The admin shows errors at three levels, and each is
447
+ read on its own: the notice above the form, the errors of the form as a whole, and the errors
448
+ of each field. A field can fail several rules at once, so its errors are always a list.
449
+
450
+ ```python
451
+ page = admin_ui.create(Product)
452
+ page.populate(
453
+ name="Widget",
454
+ price="10.00",
455
+ )
456
+
457
+ result = page.save()
458
+
459
+ assert result.page.errors.banner == "Please correct the errors below."
460
+ assert result.page.errors.non_field == []
461
+ assert result.page.fields["sku"].errors == ["This field is required."]
462
+ assert result.page.errors.fields == {
463
+ "sku": ["This field is required."],
464
+ "released_on": ["This field is required."],
465
+ }
466
+ ```
467
+
468
+ A page the admin has not rejected shows no errors, so `assert not page.errors` holds on one
469
+ that was never submitted. The notice's wording depends on how many errors there are, so a test
470
+ that only cares that the form was rejected asserts `result.page.errors.banner` is there.
471
+
472
+ **Reading what the admin reported.** Every page reads the messages the admin shows after an
473
+ operation, each with its level and its text. A level is the tag Django draws the message
474
+ with, so a project that renames or adds levels through `MESSAGE_TAGS` reads them by its own
475
+ tags.
476
+
477
+ ```python
478
+ page = admin_ui.create(Product)
479
+ page.populate(
480
+ name="Widget",
481
+ sku="SKU-1",
482
+ price="10.00",
483
+ released_on=date(2026, 1, 15),
484
+ )
485
+
486
+ result = page.save()
487
+
488
+ assert result.page.messages == [
489
+ ("success", "The product “Widget” was added successfully."),
490
+ ]
491
+ assert result.page.messages[0].level == "success"
492
+ assert result.page.messages.of_level("success") == [
493
+ "The product “Widget” was added successfully.",
494
+ ]
495
+ assert not result.page.messages.of_level("error")
496
+ ```
497
+
498
+ `of_level` raises `KeyError` listing the project's levels for a name it does not have, so a
499
+ level the project renamed never reads as "no messages". Messages are also read from a page
500
+ that did not open, since the admin says there why: an object that no longer exists sends
501
+ the user to the index with a warning, and `page.messages` on that page reads it.
502
+
503
+ **Reading is live.** Nothing read from a page is kept, so every read is the page as it is
504
+ now, including what the admin's own scripts changed in place. A field, row or cell taken from
505
+ a page is an address, read each time it is used: a field by its name, a row by its position.
506
+ Once the browser shows another page, reading the old page object, or anything taken from it,
507
+ fails at once and says where the browser is.
508
+
509
+ ```python
510
+ result = page.save() # the admin redirects to the changelist
511
+
512
+ page.fields["name"].value # LookupError: The browser no longer shows this page; ...
513
+ result.page.rows[0]["name"].value # read the page the browser shows now
514
+ ```
515
+
516
+ **Deleting.** The admin deletes in two steps, and so does the package: `delete()` follows an
517
+ edit page's delete link to the confirmation, and `confirm_deleting()` there deletes.
518
+
519
+ ```python
520
+ confirmation = admin_ui.edit(product).delete()
521
+
522
+ assert confirmation.can_confirm_deleting
523
+
524
+ result = confirmation.confirm_deleting()
525
+
526
+ assert result.redirected_to_list(Product)
527
+ ```
528
+
529
+ When deleting would also remove objects that are protected, or that the user may not delete,
530
+ the admin offers no confirmation: a category a product uses reads `can_confirm_deleting` as
531
+ `False`, and `confirm_deleting()` raises `LookupError`.
532
+
533
+ If the admin refuses a confirmation it offered, because the object became protected or the user
534
+ may no longer delete it after the page opened, the result reads `not result.success`.
535
+
536
+ **Knowing what a deletion will remove, and what stops it.** The confirmation lists the object
537
+ and every related object it takes with it, as the page writes them, and counts them per model.
538
+
539
+ ```python
540
+ page = admin_ui.delete(product)
541
+
542
+ assert page.deletions == [
543
+ "Product: Widget",
544
+ "Review: Great",
545
+ "Review: Fine",
546
+ ]
547
+ assert page.deletion_counts == {
548
+ "Products": 1,
549
+ "Reviews": 2,
550
+ }
551
+ ```
552
+
553
+ A page that cannot be confirmed lists what blocks the deletion instead: the objects that
554
+ protect it, or the kinds of object the user may not delete. `intro` is the sentence the page
555
+ starts with, which says which of the two it is.
556
+
557
+ ```python
558
+ page = admin_ui.delete(category)
559
+
560
+ assert page.blockers == ["Product: Widget"]
561
+ assert page.deletions == []
562
+ ```
563
+
564
+ Entries are texts, since the admin links an object only when its model is registered.
565
+ `deletion_counts` is keyed by the labels the page shows, and reading a model the deletion
566
+ removes none of raises `KeyError` naming the models it does count. The wording of `intro`
567
+ depends on the language and the Django version, which changed its quote marks in 6.0.
568
+
569
+ **Knowing what the index shows a user.** Models and apps a user may not see are not listed,
570
+ and the lists come back in the admin's order.
571
+
572
+ ```python
573
+ page = admin_ui.index()
574
+
575
+ assert Product in page.models
576
+ assert page.models == [Group, User, Product]
577
+ assert page.apps == ["Authentication and Authorization", "Shop"]
578
+ assert page.models_for("Shop") == [Product]
579
+ ```
580
+
581
+ **Reading values as your project wants them.** A cell, and a field the user may only read, is
582
+ read by a rule for its kind. Every default returns the text the user reads, except `boolean`,
583
+ which reads the admin's icon as `True`, `False` or `None`. Replace a rule in your settings and it
584
+ receives only the values of its kind.
585
+
586
+ ```python
587
+ DJANGO_ADMIN_KIT = {
588
+ "normalizers": {
589
+ "date": "shop.rules.as_date",
590
+ },
591
+ }
592
+ ```
593
+
594
+ ```python
595
+ # shop/rules.py, for a project whose DATE_FORMAT is "Y-m-d"
596
+ from datetime import date
597
+
598
+
599
+ def as_date(rendered):
600
+ return date.fromisoformat(rendered.text)
601
+ ```
602
+
603
+ A missing date never reaches it: the admin shows its empty display there, which the `empty` rule
604
+ reads.
605
+
606
+ ```python
607
+ assert page.rows[0]["released_on"].value == date(2026, 1, 15)
608
+ ```
609
+
610
+ The kind comes from the model field behind the value, then from what the admin drew. The first
611
+ that applies wins:
612
+
613
+ - `empty`: the text the admin shows for a value it has none of there;
614
+ - `choice`: a field with choices, shown as its label;
615
+ - `boolean`, `date`, `datetime`, `time` or `number`: by the field's class, the most specific
616
+ first, so a date-time field is a `datetime`;
617
+ - `boolean` for a lone boolean icon, then `link` for a value that renders links;
618
+ - `text`.
619
+
620
+ A rule gets the rendered value: its `text`, `links`, `native`, `field` (the model field, or
621
+ `None` for a method) and `empty_display`. A rule is named by its dotted path, and an unknown
622
+ name or a path that does not import is an error. A form field the user can edit is read from
623
+ its control and goes through no rule.
624
+
625
+ **Reaching anything the package does not model.** Open any admin path, then use the Playwright
626
+ page directly.
627
+
628
+ ```python
629
+ page = admin_ui.open(reverse("admin:shop_product_report"))
630
+
631
+ assert page.works
632
+ page.native.click("#download")
633
+ ```
634
+
635
+ `admin_ui.native` is the Playwright context, and `admin_ui.absolute(path)` builds a full URL on
636
+ the live server when something outside the package needs one.
637
+
638
+ ## Settings
639
+
640
+ Everything lives under one key in Django settings, and a key it does not know is an error:
641
+
642
+ ```python
643
+ DJANGO_ADMIN_KIT = {
644
+ "site": "project.ops.ops_site", # dotted path to the AdminSite; default: admin.site
645
+ "timeout": 30_000, # ms for any single browser operation
646
+ "timezone": "Europe/Kyiv", # default: TIME_ZONE
647
+ "locale": "uk", # default: LANGUAGE_CODE
648
+ "normalizers": { # rules that read values, by kind; see above
649
+ "date": "shop.rules.as_date",
650
+ },
651
+ "widgets": [ # widget classes, tried before the package's own
652
+ "shop.kit_widgets.SwatchPickerWidget",
653
+ ],
654
+ }
655
+ ```
656
+
657
+ `timezone` and `locale` pin what the browser reports, so a date or a label reads the same on
658
+ every machine.
659
+
660
+ If your project already sets `timezone_id` or `locale` in pytest-playwright's
661
+ `browser_context_args`, those win. The package only fills in what `browser_context_args` leaves
662
+ unset, whether the value under `DJANGO_ADMIN_KIT` was written by you or defaulted from
663
+ `TIME_ZONE` and `LANGUAGE_CODE`. So if you want admin tests to run with the same zone and
664
+ language as the rest of your browser tests, keep `timezone` and `locale` out of
665
+ `DJANGO_ADMIN_KIT` and set them in `browser_context_args` only.
666
+
667
+ `admin_ui` is put together from five session fixtures. Override one and the others stay as
668
+ they are:
669
+
670
+ - `admin_ui_config`: the `DJANGO_ADMIN_KIT` settings, read and checked once. An override must
671
+ be session-scoped too.
672
+ - `admin_ui_urls`: the admin URLs for the site under test. Override it to point the session at
673
+ another admin site.
674
+ - `admin_ui_normalizers`: the rules that read values, built once from `normalizers`. Override
675
+ it in a `conftest.py` or a test module to read those tests' values differently, or
676
+ parametrize it for one test, with `Normalizers` from `django_admin_kit.normalize`:
677
+ `@pytest.mark.parametrize("admin_ui_normalizers", [Normalizers({"date": as_date})])`.
678
+ - `admin_ui_widgets`: the widget classes, built once from `widgets`. Override it in a
679
+ `conftest.py` or a test module with `Widgets([...])` of instances to use other classes there,
680
+ or parametrize it for one test the same way; the package's own are added behind them.
681
+ - `admin_ui_driving`: keeps Django's database access working while a browser runs. Leave it
682
+ alone.
683
+
684
+ ## Requirements
685
+
686
+ Python 3.8 or newer, Django 3.2 or newer.
687
+
688
+ Development uses [uv](https://docs.astral.sh/uv/) for environments, dependencies and builds. If
689
+ you don't have it:
690
+
691
+ ```bash
692
+ curl -LsSf https://astral.sh/uv/install.sh | sh
693
+ ```
694
+
695
+ ## Setting up
696
+
697
+ ```bash
698
+ git clone https://github.com/<your-fork>/pytest-django-admin-kit.git
699
+ cd pytest-django-admin-kit
700
+
701
+ uv sync
702
+ uv run pre-commit install
703
+ ```
704
+
705
+ `uv sync` creates `.venv`, installs the package in editable mode, and installs the dev tooling
706
+ from `uv.lock`. That lock file is committed, so everyone gets the same ruff and mypy and
707
+ therefore the same results.
708
+
709
+ The tests drive real browsers, so you need the binaries too:
710
+
711
+ ```bash
712
+ uv run playwright install chromium # enough for everyday work
713
+ uv run playwright install firefox webkit # the other two the package supports
714
+ sudo uv run playwright install-deps webkit # webkit also needs system libraries
715
+ ```
716
+
717
+ Only chromium is required. A test that needs another browser skips itself by name when that
718
+ browser isn't downloaded, so a partial install costs you coverage rather than a wall of red. CI
719
+ installs all three and fails if anything skips.
720
+
721
+ ## Which Python to develop on
722
+
723
+ `uv sync` picks an interpreter for you. If you want to choose, use 3.10, and add a 3.12
724
+ environment when you need to check Django 6.x:
725
+
726
+ ```bash
727
+ uv sync --python 3.10
728
+ ```
729
+
730
+ Two reasons this matters more than usual here.
731
+
732
+ First, don't develop on 3.8. It's been end of life since October 2024, and resolution caps you
733
+ there at Django 4.2, playwright 1.48 and pytest 8.3.5. You couldn't run Django 5.x or 6.x locally
734
+ at all. CI proves the 3.8 floor for you.
735
+
736
+ Second, no single interpreter covers every Django version we support. Django 3.2 stops at Python
737
+ 3.10 and Django 6.1 starts at 3.12, so there's no overlap:
738
+
739
+ | Python | Django versions it can run |
740
+ |---|---|
741
+ | 3.8, 3.9 | 3.2 to 4.2 |
742
+ | 3.10 | 3.2 to 5.2 |
743
+ | 3.11 | 4.1 to 5.2 |
744
+ | 3.12 | 4.2 to 6.1 |
745
+ | 3.13, 3.14 | 5.1 to 6.1 |
746
+
747
+ 3.10 is the better default because it reaches the Django 3.2 floor, where most version-specific
748
+ breakage turns up. You don't need it installed already; uv will fetch it.
749
+
750
+ ## Everyday commands
751
+
752
+ While you're working, this is the whole loop:
753
+
754
+ ```bash
755
+ uv run pytest
756
+ uv run pytest -n auto # the same, on every core
757
+ ```
758
+
759
+ Before you push:
760
+
761
+ ```bash
762
+ uv run ruff check . # lint
763
+ uv run ruff format . # format in place
764
+ uv run mypy # strict, src/ only
765
+ uv run pytest
766
+ ```
767
+
768
+ `pre-commit install` already runs ruff and the whitespace checks on every commit, so those first
769
+ two mostly buy you faster feedback. To run every hook over the whole tree:
770
+
771
+ ```bash
772
+ uv run pre-commit run --all-files
773
+ ```
774
+
775
+ ## Driving the browser
776
+
777
+ The browser comes from `pytest-playwright`, so its flags work here and mean what they mean
778
+ anywhere else:
779
+
780
+ ```bash
781
+ uv run pytest --headed # watch it happen
782
+ uv run pytest --slowmo 200 # and slow it down enough to follow
783
+ uv run pytest --browser firefox # one browser
784
+ uv run pytest --browser chromium --browser firefox # the suite, twice
785
+ uv run pytest --video on --tracing on # artifacts under test-results/
786
+ ```
787
+
788
+ Because the browser is theirs, a test's id carries the browser it ran on:
789
+ `test_a_superuser_reaches_the_index[chromium]`. Asking for two browsers runs every admin test
790
+ twice.
791
+
792
+ **One thing to know before you install this package anywhere.** `pytest-playwright` registers an
793
+ autouse fixture that deletes the `--output` directory, `test-results/` by default, at the start
794
+ of **every** pytest session, whether or not the session opens a browser. If your project keeps
795
+ JUnit XML or coverage output there, move it or pass `--output` somewhere else. We inherit that
796
+ behavior by depending on the plugin, and there's no way to switch it off.
797
+
798
+ **Don't pin the live server's port.** `admin_ui` is built on pytest-django's `live_server`, which
799
+ binds a free port for each process, so running the suite in parallel with `pytest-xdist` works
800
+ out of the box. Setting a fixed address with `--liveserver=localhost:8081` or the
801
+ `DJANGO_LIVE_TEST_SERVER_ADDRESS` environment variable makes every worker fight for the same
802
+ port. It passes under a single process and fails under `-n`, looking like a flaky port bug
803
+ rather than a configuration one.
804
+
805
+ ## The test project
806
+
807
+ The suite runs against a small Django project in `tests/project/`, wired up by the
808
+ `[tool.pytest.ini_options]` table in `pyproject.toml`. You don't have to do anything to use it —
809
+ no environment variables, no `manage.py`.
810
+
811
+ It uses Django's defaults: the admin is at `/admin/` and the user model is `auth.User`. The
812
+ things a project can customize are each proved by their own tests, which swap the custom
813
+ setup in per test:
814
+
815
+ - `tests/test_custom_user.py` sets `AUTH_USER_MODEL` to `accounts.User`, a model with no
816
+ `username` field that authenticates by email;
817
+ - `tests/test_custom_prefix.py` uses `@pytest.mark.urls("project.urls_backoffice")`, which
818
+ serves the admin at `/backoffice/`;
819
+ - `tests/test_custom_site.py` overrides the `admin_ui_urls` fixture to point at `ops_site`, a
820
+ second `AdminSite` mounted at `/ops/` that registers only `Product`.
821
+
822
+ The swap only works in this direction. `auth.User` is swappable, so a project whose default is
823
+ a custom model never creates the `auth_user` table, and no test could swap the default back in.
824
+
825
+ The database is in-memory SQLite. That is worth knowing when a test feels slow later on: any
826
+ test that uses `live_server` is forced onto `transactional_db` by pytest-django, whatever mark
827
+ it carries, so the database is rebuilt rather than rolled back. SQLite keeps that cheap.
828
+
829
+ If you change a model in `tests/project/`, regenerate its migration:
830
+
831
+ ```bash
832
+ PYTHONPATH=tests DJANGO_SETTINGS_MODULE=project.settings uv run django-admin makemigrations
833
+ ```
834
+
835
+ Those migration files are excluded from ruff so that what's committed stays byte-identical to
836
+ what Django generates.
837
+
838
+ ## Changing dependencies
839
+
840
+ Edit `pyproject.toml`, then:
841
+
842
+ ```bash
843
+ uv lock # update uv.lock
844
+ uv sync # apply it to .venv
845
+ ```
846
+
847
+ Commit `pyproject.toml` and `uv.lock` together.
848
+
849
+ ## The version matrix
850
+
851
+ `tox.toml` defines 18 cells: nine Django minors, each on its oldest supported Python at or above
852
+ the 3.8 floor and on its newest.
853
+
854
+ ```bash
855
+ uv run tox # every cell
856
+ uv run tox -p 6 # every cell, six at a time
857
+ uv run tox -e 3.8-dj32 # one cell
858
+ uv run tox list -d # what the cells are called
859
+ ```
860
+
861
+ Anything after a bare `--` is passed straight through to pytest, so a failing cell can be
862
+ picked apart without touching `tox.toml`:
863
+
864
+ ```bash
865
+ uv run tox -e 3.10-dj52 -- -x -vv # stop at the first failure, verbosely
866
+ uv run tox -e 3.10-dj52 -- tests/test_project.py # just one file
867
+ ```
868
+
869
+ The whole sweep takes well under a minute on a warm cache, so there's no reason to leave it to
870
+ CI. On a cold one, budget for downloading five interpreters and a copy of Django and playwright
871
+ per cell.
872
+
873
+ tox-uv means uv builds those environments, which matters most at the floor: `python3.8 -m venv`
874
+ fails with `ensurepip` errors on a lot of systems, and uv fetches its own 3.8 instead.
875
+
876
+ Only `pytest-django` is pinned per cell, because no single release spans Django 3.2 to 6.1.
877
+ Everything else is left to the resolver, which caps itself per interpreter — 3.8 cells end up on
878
+ pytest 8.3.5 and playwright 1.48.0, newer cells on current. That cap is the constraint to design
879
+ against: code has to work on playwright 1.48.
880
+
881
+ There is a second, larger matrix behind the `full` label: the whole cartesian, every Django minor
882
+ against every interpreter it supports, 33 cells. It's too slow to sit in front of every pull
883
+ request, so it gates releases instead. Run it before tagging one:
884
+
885
+ ```bash
886
+ uv run tox -m full -p 8
887
+ uv run tox list -m full # the 33 cell names
888
+ ```
889
+
890
+ ## CI
891
+
892
+ Two workflows, both in `.github/workflows/`.
893
+
894
+ `ci.yml` runs on pull requests and pushes to `main`. It lints, type-checks, runs the 18 cells,
895
+ runs one extra job that exercises firefox and webkit, and one that runs the whole suite under
896
+ `pytest-xdist` with four workers, because parallel safety is something the package promises. `release.yml` runs on a `v*` tag: it
897
+ runs all 33 cells, then builds, then publishes to PyPI, each job gated on the one before it, so
898
+ a matrix failure stops the release rather than merely being recorded next to it.
899
+
900
+ Browsers are not a Django-compatibility dimension, so they don't multiply the matrix. Each cell
901
+ installs chromium — the binary is keyed to that cell's playwright version, which is why tox does
902
+ it rather than the workflow — and the separate job covers the other two once.
903
+
904
+ Neither workflow lists its cells. Both ask tox for them:
905
+
906
+ ```bash
907
+ uvx --from 'tox>=4.61' --with tox-uv tox list -d --no-desc -q # what ci.yml runs
908
+ uvx --from 'tox>=4.61' --with tox-uv tox list -m full --no-desc -q # what release.yml runs
909
+ ```
910
+
911
+ So editing `tox.toml` changes CI, and the two cannot disagree.
912
+
913
+ The build job refuses to run if the tag doesn't match the `version` in `pyproject.toml`, so bump
914
+ the version in the same commit you tag. The steps of a release are in
915
+ [RELEASING.md](RELEASING.md).
916
+
917
+ ## Building
918
+
919
+ ```bash
920
+ uv build
921
+ ```
922
+
923
+ That produces a wheel and an sdist in `dist/`, using the hatchling backend declared in
924
+ `pyproject.toml`. uv builds the wheel from the sdist, so a broken sdist fails the build rather
925
+ than shipping quietly.
926
+
927
+ ## PyCharm
928
+
929
+ Recent PyCharm versions support uv directly: point the interpreter at the project and pick uv, or
930
+ select the existing `.venv` uv created.
931
+
932
+ Mark `tests` as a test source root (right-click it, *Mark Directory as* → *Test Sources Root*).
933
+ PyCharm doesn't read pytest's `pythonpath` setting, so without this it reports `No module named
934
+ project` on the test project's own imports even though everything runs.
935
+
936
+ One thing to leave alone: `pyproject.toml` carries a static `version`, and `__version__` reads it
937
+ back from the installed distribution metadata. There's still only one place to change it, and
938
+ some IDE integrations reject a dynamic version outright.
939
+
940
+ ## Layout
941
+
942
+ ```
943
+ src/django_admin_kit/ the package
944
+ tests/ its own test suite
945
+ tests/project/ the Django project those tests run against
946
+ specification.md the public API this is built against
947
+ ```
948
+
949
+ ## License
950
+
951
+ MIT. See [LICENSE](LICENSE).