pytest-django-admin-kit 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (95) hide show
  1. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/CHANGELOG.md +58 -0
  2. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/PKG-INFO +62 -12
  3. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/README.md +60 -10
  4. pytest_django_admin_kit-1.1.0/llms.txt +41 -0
  5. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/pyproject.toml +2 -2
  6. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/specification.md +321 -35
  7. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/fields.py +192 -14
  8. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/pages.py +122 -29
  9. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/session.py +44 -20
  10. pytest_django_admin_kit-1.1.0/src/django_admin_kit/widgets.py +313 -0
  11. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/admin.py +51 -1
  12. pytest_django_admin_kit-1.1.0/tests/project/shop/migrations/0005_product_help_text.py +26 -0
  13. pytest_django_admin_kit-1.1.0/tests/project/shop/migrations/0006_feed_section.py +17 -0
  14. pytest_django_admin_kit-1.1.0/tests/project/shop/migrations/0007_feed_categories.py +16 -0
  15. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/models.py +16 -4
  16. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/widgets.py +11 -0
  17. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_custom_user.py +20 -1
  18. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_fields.py +140 -10
  19. pytest_django_admin_kit-1.1.0/tests/test_fieldsets.py +268 -0
  20. pytest_django_admin_kit-1.1.0/tests/test_filter_widgets.py +66 -0
  21. pytest_django_admin_kit-1.1.0/tests/test_multiple_select.py +165 -0
  22. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_populate.py +7 -13
  23. pytest_django_admin_kit-1.1.0/tests/test_radio_buttons.py +164 -0
  24. pytest_django_admin_kit-1.1.0/tests/test_session.py +332 -0
  25. pytest_django_admin_kit-1.1.0/tests/test_split_date_time.py +192 -0
  26. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_submit.py +47 -1
  27. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_widgets.py +6 -4
  28. pytest_django_admin_kit-1.0.0/src/django_admin_kit/widgets.py +0 -172
  29. pytest_django_admin_kit-1.0.0/tests/test_session.py +0 -141
  30. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/.gitignore +0 -0
  31. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/LICENSE +0 -0
  32. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/RELEASING.md +0 -0
  33. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/__init__.py +0 -0
  34. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/config.py +0 -0
  35. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/errors.py +0 -0
  36. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/matching.py +0 -0
  37. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/messages.py +0 -0
  38. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/normalize.py +0 -0
  39. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/plugin.py +0 -0
  40. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/py.typed +0 -0
  41. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/rendered.py +0 -0
  42. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/results.py +0 -0
  43. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/rows.py +0 -0
  44. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/tools.py +0 -0
  45. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/src/django_admin_kit/urls.py +0 -0
  46. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/conftest.py +0 -0
  47. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/__init__.py +0 -0
  48. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/__init__.py +0 -0
  49. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/apps.py +0 -0
  50. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/migrations/0001_initial.py +0 -0
  51. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/migrations/__init__.py +0 -0
  52. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/accounts/models.py +0 -0
  53. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/ops.py +0 -0
  54. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/settings.py +0 -0
  55. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/__init__.py +0 -0
  56. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/apps.py +0 -0
  57. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/kit_widgets.py +0 -0
  58. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/0001_initial.py +0 -0
  59. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/0002_review.py +0 -0
  60. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/0003_feed_refresh.py +0 -0
  61. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/0004_feed_color.py +0 -0
  62. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/migrations/__init__.py +0 -0
  63. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/rules.py +0 -0
  64. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/templates/admin/shop/feed/submit_line.html +0 -0
  65. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/shop/templates/shop/widgets/swatch_picker.html +0 -0
  66. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/urls.py +0 -0
  67. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/urls_backoffice.py +0 -0
  68. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/project/urls_with_ops.py +0 -0
  69. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_actions.py +0 -0
  70. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_changelist.py +0 -0
  71. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_changelist_count.py +0 -0
  72. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_choices.py +0 -0
  73. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_config.py +0 -0
  74. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_confirm_delete.py +0 -0
  75. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_create.py +0 -0
  76. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_custom_prefix.py +0 -0
  77. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_custom_site.py +0 -0
  78. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_delete.py +0 -0
  79. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_delete_contents.py +0 -0
  80. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_edit.py +0 -0
  81. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_errors.py +0 -0
  82. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_index.py +0 -0
  83. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_interop.py +0 -0
  84. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_links.py +0 -0
  85. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_live.py +0 -0
  86. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_matching.py +0 -0
  87. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_messages.py +0 -0
  88. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_normalizers.py +0 -0
  89. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_package.py +0 -0
  90. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_pages.py +0 -0
  91. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_plugin.py +0 -0
  92. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_project.py +0 -0
  93. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_rows.py +0 -0
  94. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tests/test_urls.py +0 -0
  95. {pytest_django_admin_kit-1.0.0 → pytest_django_admin_kit-1.1.0}/tox.toml +0 -0
@@ -5,6 +5,62 @@ All notable changes to this project are listed in this file.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
6
6
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [1.1.0] - 2026-11-05
11
+
12
+ ### Added
13
+
14
+ - Open the login page with `admin_ui.login()`, fill it with `page.fill(username=..., password=...)`
15
+ and submit it with `page.submit()`. A refused form is the same page, so it can be filled
16
+ again. Opening the login page logs nobody out.
17
+ - `admin_ui.login(user, password=...)` returns the result of the submission, so
18
+ `result.success` says whether the admin let the user in.
19
+ - A submission that ends on the login page has the login page as its `result.page`.
20
+ - Read what a login page says with `page.errors`, the notes above the form such as why a login
21
+ was refused, and `page.field_errors`, the errors under each input.
22
+ - Read the help the admin shows with a field with `field.help_text`, `""` for a field without
23
+ any. A field the user may only read has it too.
24
+ - Get the fields the user may only read and the ones with a control to fill with
25
+ `page.readonly_fields` and `page.editable_fields`. They follow what the page shows, so a user
26
+ who may only view gets every field as read-only.
27
+ - List the fieldsets of a form with `page.fieldsets`. Each one has its `name` and its
28
+ `description`, `None` when it has none, and its `fields`, read and filled like `page.fields`.
29
+ - Tell whether a fieldset can be collapsed and whether it is collapsed now with
30
+ `fieldset.collapsible` and `fieldset.collapsed`, and open or collapse it with
31
+ `fieldset.open()` and `fieldset.close()`.
32
+ - Read and fill the admin's split date-time through the built-in `SplitDateTimeWidget`. It reads
33
+ as the date and the time as typed, such as `("2026-01-15", "09:30:00")`, and is filled from a
34
+ `datetime`, a `(date, time)` pair or `None`.
35
+ - Read and fill radio buttons, as the admin draws a field named in `radio_fields`, through the
36
+ built-in `RadioWidget`. They're read, filled and listed as a select is, and read as `None`
37
+ while none is checked.
38
+ - Read and fill a multiple select, as the admin draws a many-to-many field, through the built-in
39
+ `SelectMultipleWidget`. It reads as the list of choices made and is filled with a list of
40
+ values, each written as for a select. A lone value raises `TypeError`.
41
+ - `page.populate(instance)` fills a many-to-many field with a saved instance's related objects,
42
+ and leaves it alone for an unsaved instance, which has none yet.
43
+
44
+ ### Changed
45
+
46
+ - Asking `page.fields` for a field the form doesn't have now says
47
+ `No field named 'color' here.`, rather than `The form has no field named 'color'.`, so the
48
+ same message fits a fieldset's fields. It still lists the fields there are.
49
+
50
+ ### Fixed
51
+
52
+ - A multiple select no longer reads as one option, and filling it no longer replaces the choice
53
+ with a single option. It reads and fills as a list.
54
+ - `page.populate(instance)` no longer fails with `ValueError` on an unsaved model instance with
55
+ a many-to-many field.
56
+ - A `filter_horizontal` or `filter_vertical` field raises `LookupError` at once when read or
57
+ filled, rather than waiting for the timeout. No built-in widget class takes the two lists the
58
+ admin's script draws for it, so a project can add its own.
59
+ - `admin_ui.login(user, password="")` no longer waits 30 seconds and fails. The browser does not
60
+ send a form with an empty input, so the result is no success, at once.
61
+ - Filling a field of a collapsed fieldset, with `field.fill()` or `page.populate()`, no longer
62
+ waits for the timeout and fails. The fieldset is opened first, as the user has to.
63
+
8
64
  ## [1.0.0] - 2026-10-13
9
65
 
10
66
  The first release. Tests open the Django admin in a real browser and check it with plain
@@ -80,4 +136,6 @@ The first release. Tests open the Django admin in a real browser and check it wi
80
136
  - Tests can run in parallel, and every test starts logged out.
81
137
  - Works with Python 3.8 to 3.14 and Django 3.2 to 6.1.
82
138
 
139
+ [Unreleased]: https://github.com/JeyKip/pytest-django-admin-kit/compare/v1.1.0...HEAD
140
+ [1.1.0]: https://github.com/JeyKip/pytest-django-admin-kit/compare/v1.0.0...v1.1.0
83
141
  [1.0.0]: https://github.com/JeyKip/pytest-django-admin-kit/releases/tag/v1.0.0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytest-django-admin-kit
3
- Version: 1.0.0
4
- Summary: A pytest toolkit for writing conscious tests against the Django admin.
3
+ Version: 1.1.0
4
+ Summary: Pytest plugin for testing the Django admin in a real browser, with plain asserts.
5
5
  Project-URL: Homepage, https://github.com/JeyKip/pytest-django-admin-kit
6
6
  Project-URL: Source, https://github.com/JeyKip/pytest-django-admin-kit
7
7
  Project-URL: Changelog, https://github.com/JeyKip/pytest-django-admin-kit/blob/main/CHANGELOG.md
@@ -63,7 +63,7 @@ Description-Content-Type: text/markdown
63
63
 
64
64
  # pytest-django-admin-kit
65
65
 
66
- A pytest toolkit for writing conscious tests against the Django admin.
66
+ Pytest plugin for testing the Django admin in a real browser, with plain asserts.
67
67
 
68
68
  Tests describe admin pages, their fields, rows, permissions and errors, using ordinary `assert`
69
69
  statements, and stay unchanged across supported Django versions.
@@ -78,10 +78,19 @@ def test_product_admin(admin_ui, admin_user, product):
78
78
 
79
79
  Install it as `pytest-django-admin-kit`; import it as `django_admin_kit`.
80
80
 
81
+ AI agents can start from [llms.txt](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/llms.txt).
82
+ To point yours there, add this line to your project's `AGENTS.md` or `CLAUDE.md`:
83
+
84
+ ```markdown
85
+ Before writing admin tests, read https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/llms.txt
86
+ ```
87
+
81
88
  ## Status
82
89
 
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
90
+ Version 1.1.0 implements the API written up in
91
+ [specification.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/specification.md),
92
+ and its public API is stable. What changed in each release is in
93
+ [CHANGELOG.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/CHANGELOG.md), and what
85
94
  comes next is the roadmap in section 33 of the specification.
86
95
 
87
96
  ## What it solves today
@@ -119,10 +128,36 @@ project that mounts its admin elsewhere changes nothing.
119
128
  admin_ui.url.edit(product)
120
129
  ```
121
130
 
122
- **Testing the login page itself.** Typing into the form is an opt-in, never the default.
131
+ **Testing the login page itself.** Typing into the form is an opt-in, never the default. The
132
+ result says whether the admin let the user in.
133
+
134
+ ```python
135
+ result = admin_ui.login(user, password="secret")
136
+ assert result.success
137
+ ```
138
+
139
+ To fill the form the way the test needs, open it, fill it and submit it in separate steps. A
140
+ refused form stays the same page, so it can be filled again.
123
141
 
124
142
  ```python
125
- admin_ui.login(user, password="secret")
143
+ page = admin_ui.login()
144
+ page.fill(username="alice", password="wrong")
145
+ result = page.submit()
146
+ assert not result.success
147
+
148
+ result.page.fill(password="secret")
149
+ assert result.page.submit().success
150
+ ```
151
+
152
+ A refused page reads what the admin says: the notes above the form, and the errors under each
153
+ input.
154
+
155
+ ```python
156
+ assert result.page.errors == [
157
+ "Please enter the correct username and password for a staff account. "
158
+ "Note that both fields may be case-sensitive."
159
+ ]
160
+ assert result.page.field_errors == {}
126
161
  ```
127
162
 
128
163
  **Opening a changelist and reading its count and columns.** The count is what the page says,
@@ -196,8 +231,15 @@ assert page.fields["name"].required
196
231
  assert not page.fields["price_with_tax"].editable
197
232
  assert page.required_fields == {"name", "sku", "price", "quantity", "released_on"}
198
233
  assert page.optional_fields == {"is_active", "featured", "category"}
234
+ assert page.readonly_fields == {"price_with_tax"}
235
+ assert "name" in page.editable_fields
236
+ # The admin sets no `fieldsets`, so Django draws every field in one fieldset without a name.
237
+ assert page.fieldsets[0].name is None
238
+ assert list(page.fieldsets[0].fields) == list(page.fields)
239
+ assert not page.fieldsets[0].collapsible # no `collapse` class, so no toggle to open it
199
240
 
200
241
  assert page.fields["name"].label == "Name"
242
+ assert page.fields["sku"].help_text == "The code printed on the label."
201
243
  assert page.fields["name"].value == "Widget"
202
244
  assert page.fields["is_active"].value is True
203
245
  assert page.fields["category"].value == (str(tools.pk), "Tools")
@@ -233,7 +275,15 @@ assert page.fields["featured"].value == ("true", "Yes")
233
275
 
234
276
  A select takes back a choice it offered, `page.fields["category"].fill(choice)`, and `None`
235
277
  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`.
278
+ there are, and a field the user may only read raises `LookupError`. Radio buttons, as the admin
279
+ draws a field named in `radio_fields`, are read and filled the same way, and read as `None` while
280
+ none is checked. A multiple select reads as the list of choices made, and is filled with a list
281
+ of values, each written as for a select: `fill([tools, toys])`, and `fill([])` clears it.
282
+
283
+ A date-time, which the admin draws as a date input and a time input, reads as the two texts,
284
+ `("2026-01-15", "09:30:00")`. It's filled from a `datetime`, typed in the current time zone, from
285
+ a `(date, time)` pair whose halves may also be text or `None`, or from `None`, which empties
286
+ both.
237
287
 
238
288
  **Filling a whole form in one call.** A dictionary carries the shared data and keywords carry
239
289
  the values this one test is about. Fields that are named nowhere keep the values the admin
@@ -350,8 +400,8 @@ such as `text=`, belongs in `read` and `fill`. To combine conditions, use `:has(
350
400
  `':scope:has(input[name="image_cache_key"]) input[type="file"]'`.
351
401
 
352
402
  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:
403
+ `CheckboxWidget`, `RadioWidget`, `SelectMultipleWidget`, `SelectWidget`, `SplitDateTimeWidget` and
404
+ `TextWidget`, from `django_admin_kit.widgets`. Two rules follow from that:
355
405
 
356
406
  - A marker matches what is particular to its widget. A class marked by `input[type="file"]`
357
407
  would take every file field in the project.
@@ -912,7 +962,7 @@ So editing `tox.toml` changes CI, and the two cannot disagree.
912
962
 
913
963
  The build job refuses to run if the tag doesn't match the `version` in `pyproject.toml`, so bump
914
964
  the version in the same commit you tag. The steps of a release are in
915
- [RELEASING.md](RELEASING.md).
965
+ [RELEASING.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/RELEASING.md).
916
966
 
917
967
  ## Building
918
968
 
@@ -948,4 +998,4 @@ specification.md the public API this is built against
948
998
 
949
999
  ## License
950
1000
 
951
- MIT. See [LICENSE](LICENSE).
1001
+ MIT. See [LICENSE](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/LICENSE).
@@ -1,6 +1,6 @@
1
1
  # pytest-django-admin-kit
2
2
 
3
- A pytest toolkit for writing conscious tests against the Django admin.
3
+ Pytest plugin for testing the Django admin in a real browser, with plain asserts.
4
4
 
5
5
  Tests describe admin pages, their fields, rows, permissions and errors, using ordinary `assert`
6
6
  statements, and stay unchanged across supported Django versions.
@@ -15,10 +15,19 @@ def test_product_admin(admin_ui, admin_user, product):
15
15
 
16
16
  Install it as `pytest-django-admin-kit`; import it as `django_admin_kit`.
17
17
 
18
+ AI agents can start from [llms.txt](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/llms.txt).
19
+ To point yours there, add this line to your project's `AGENTS.md` or `CLAUDE.md`:
20
+
21
+ ```markdown
22
+ Before writing admin tests, read https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/llms.txt
23
+ ```
24
+
18
25
  ## Status
19
26
 
20
- Version 1.0.0 implements the API written up in [specification.md](specification.md), and its
21
- public API is stable. What changed in each release is in [CHANGELOG.md](CHANGELOG.md), and what
27
+ Version 1.1.0 implements the API written up in
28
+ [specification.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/specification.md),
29
+ and its public API is stable. What changed in each release is in
30
+ [CHANGELOG.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/CHANGELOG.md), and what
22
31
  comes next is the roadmap in section 33 of the specification.
23
32
 
24
33
  ## What it solves today
@@ -56,10 +65,36 @@ project that mounts its admin elsewhere changes nothing.
56
65
  admin_ui.url.edit(product)
57
66
  ```
58
67
 
59
- **Testing the login page itself.** Typing into the form is an opt-in, never the default.
68
+ **Testing the login page itself.** Typing into the form is an opt-in, never the default. The
69
+ result says whether the admin let the user in.
70
+
71
+ ```python
72
+ result = admin_ui.login(user, password="secret")
73
+ assert result.success
74
+ ```
75
+
76
+ To fill the form the way the test needs, open it, fill it and submit it in separate steps. A
77
+ refused form stays the same page, so it can be filled again.
60
78
 
61
79
  ```python
62
- admin_ui.login(user, password="secret")
80
+ page = admin_ui.login()
81
+ page.fill(username="alice", password="wrong")
82
+ result = page.submit()
83
+ assert not result.success
84
+
85
+ result.page.fill(password="secret")
86
+ assert result.page.submit().success
87
+ ```
88
+
89
+ A refused page reads what the admin says: the notes above the form, and the errors under each
90
+ input.
91
+
92
+ ```python
93
+ assert result.page.errors == [
94
+ "Please enter the correct username and password for a staff account. "
95
+ "Note that both fields may be case-sensitive."
96
+ ]
97
+ assert result.page.field_errors == {}
63
98
  ```
64
99
 
65
100
  **Opening a changelist and reading its count and columns.** The count is what the page says,
@@ -133,8 +168,15 @@ assert page.fields["name"].required
133
168
  assert not page.fields["price_with_tax"].editable
134
169
  assert page.required_fields == {"name", "sku", "price", "quantity", "released_on"}
135
170
  assert page.optional_fields == {"is_active", "featured", "category"}
171
+ assert page.readonly_fields == {"price_with_tax"}
172
+ assert "name" in page.editable_fields
173
+ # The admin sets no `fieldsets`, so Django draws every field in one fieldset without a name.
174
+ assert page.fieldsets[0].name is None
175
+ assert list(page.fieldsets[0].fields) == list(page.fields)
176
+ assert not page.fieldsets[0].collapsible # no `collapse` class, so no toggle to open it
136
177
 
137
178
  assert page.fields["name"].label == "Name"
179
+ assert page.fields["sku"].help_text == "The code printed on the label."
138
180
  assert page.fields["name"].value == "Widget"
139
181
  assert page.fields["is_active"].value is True
140
182
  assert page.fields["category"].value == (str(tools.pk), "Tools")
@@ -170,7 +212,15 @@ assert page.fields["featured"].value == ("true", "Yes")
170
212
 
171
213
  A select takes back a choice it offered, `page.fields["category"].fill(choice)`, and `None`
172
214
  puts it on its blank option. A value no option has raises `ValueError` naming the options
173
- there are, and a field the user may only read raises `LookupError`.
215
+ there are, and a field the user may only read raises `LookupError`. Radio buttons, as the admin
216
+ draws a field named in `radio_fields`, are read and filled the same way, and read as `None` while
217
+ none is checked. A multiple select reads as the list of choices made, and is filled with a list
218
+ of values, each written as for a select: `fill([tools, toys])`, and `fill([])` clears it.
219
+
220
+ A date-time, which the admin draws as a date input and a time input, reads as the two texts,
221
+ `("2026-01-15", "09:30:00")`. It's filled from a `datetime`, typed in the current time zone, from
222
+ a `(date, time)` pair whose halves may also be text or `None`, or from `None`, which empties
223
+ both.
174
224
 
175
225
  **Filling a whole form in one call.** A dictionary carries the shared data and keywords carry
176
226
  the values this one test is about. Fields that are named nowhere keep the values the admin
@@ -287,8 +337,8 @@ such as `text=`, belongs in `read` and `fill`. To combine conditions, use `:has(
287
337
  `':scope:has(input[name="image_cache_key"]) input[type="file"]'`.
288
338
 
289
339
  Your classes are tried first, in the order you list them, then the package's own:
290
- `CheckboxWidget`, `SelectWidget` and `TextWidget`, from `django_admin_kit.widgets`. Two rules
291
- follow from that:
340
+ `CheckboxWidget`, `RadioWidget`, `SelectMultipleWidget`, `SelectWidget`, `SplitDateTimeWidget` and
341
+ `TextWidget`, from `django_admin_kit.widgets`. Two rules follow from that:
292
342
 
293
343
  - A marker matches what is particular to its widget. A class marked by `input[type="file"]`
294
344
  would take every file field in the project.
@@ -849,7 +899,7 @@ So editing `tox.toml` changes CI, and the two cannot disagree.
849
899
 
850
900
  The build job refuses to run if the tag doesn't match the `version` in `pyproject.toml`, so bump
851
901
  the version in the same commit you tag. The steps of a release are in
852
- [RELEASING.md](RELEASING.md).
902
+ [RELEASING.md](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/RELEASING.md).
853
903
 
854
904
  ## Building
855
905
 
@@ -885,4 +935,4 @@ specification.md the public API this is built against
885
935
 
886
936
  ## License
887
937
 
888
- MIT. See [LICENSE](LICENSE).
938
+ MIT. See [LICENSE](https://github.com/JeyKip/pytest-django-admin-kit/blob/main/LICENSE).
@@ -0,0 +1,41 @@
1
+ # pytest-django-admin-kit
2
+
3
+ > Pytest plugin for testing the Django admin in a real browser, with plain asserts. Install it as `pytest-django-admin-kit` and import it as `django_admin_kit`. It needs Python 3.8+ and Django 3.2+.
4
+
5
+ Installing the package registers the plugin and the `admin_ui` fixture. The project also needs pytest-django configured and a browser, from `playwright install chromium`.
6
+
7
+ ```python
8
+ def test_product_admin(admin_ui, admin_user, product):
9
+ admin_ui.login(admin_user)
10
+
11
+ assert admin_ui.list(Product).count == 1
12
+
13
+ page = admin_ui.edit(product)
14
+ page.populate(name="Renamed")
15
+ result = page.save()
16
+
17
+ assert result.success
18
+ assert result.page.messages.of_level("success")
19
+ ```
20
+
21
+ Before writing tests:
22
+
23
+ - Open pages with `admin_ui.index()`, `list(Model)`, `create(Model)`, `edit(obj)`, `delete(obj)` and `open(path)`. Get URLs from `admin_ui.url` rather than hardcoding `/admin/`.
24
+ - `admin_ui.login(user)` needs no password. Passing `password=` types into the login form, so use it only to test that form.
25
+ - Reads are live. After a submission, read `result.page`, because reading a page the browser has left raises `LookupError`.
26
+ - Configure everything under the `DJANGO_ADMIN_KIT` setting. An unknown key is an error.
27
+ - A field no widget class recognizes raises `LookupError`. Add a class to `DJANGO_ADMIN_KIT["widgets"]`, or override the `admin_ui_widgets` fixture for some tests. `filter_horizontal` and `filter_vertical` fields need this today.
28
+ - For what the package doesn't model yet, use `.native`, the Playwright object behind a page, field, row or cell. That currently includes inlines and changelist search, filters, sorting, pagination and actions.
29
+ - pytest-playwright deletes `test-results/` at the start of every session, so keep other output elsewhere or pass `--output`.
30
+ - Don't set a fixed live server address, such as `--liveserver=localhost:8081`. It breaks parallel runs under `pytest-xdist`.
31
+
32
+ The links below follow `main`, which can be ahead of the latest release. Check a feature's version in the changelog against the version installed.
33
+
34
+ ## Docs
35
+
36
+ - [README](https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/README.md): "What it solves today" and "Settings" cover usage, with examples. The sections after them are for contributors.
37
+ - [Changelog](https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/CHANGELOG.md): what each release added, and what is still unreleased.
38
+
39
+ ## Optional
40
+
41
+ - [Specification](https://raw.githubusercontent.com/JeyKip/pytest-django-admin-kit/main/specification.md): the full design, over 3,000 lines. Section 27 covers fixtures, section 28 configuration and extension, and section 33 the roadmap. Parts not marked "(done)" may not exist yet.
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-django-admin-kit"
7
- version = "1.0.0"
8
- description = "A pytest toolkit for writing conscious tests against the Django admin."
7
+ version = "1.1.0"
8
+ description = "Pytest plugin for testing the Django admin in a real browser, with plain asserts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
11
11
  license = { file = "LICENSE" }