fastapi-panel 0.1.0__tar.gz → 0.2.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.
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/CHANGELOG.md +15 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/PKG-INFO +6 -6
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/README.md +5 -5
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/auth-and-permissions.md +35 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/customizing.md +2 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/getting-started.md +2 -2
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/model-admin.md +37 -0
- fastapi_panel-0.2.0/docs/screenshots/activity.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/changelist-dark.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/changelist.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/dashboard.png +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/screenshots/delete.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/deleted.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/history.png +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/screenshots/import-preview.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/login.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/mobile.png +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/screenshots/order-inline.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/product-form.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/related-popup.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/rich-text.png +0 -0
- fastapi_panel-0.2.0/docs/screenshots/saved-filters.png +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/security.md +7 -1
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/examples/shop/README.md +1 -1
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/examples/shop/admin.py +3 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/examples/shop/main.py +13 -1
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/scripts/screenshots.py +57 -3
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/__init__.py +5 -1
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/__main__.py +50 -9
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/forms.py +35 -1
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/log.py +12 -1
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/options.py +26 -0
- fastapi_panel-0.2.0/src/fastapi_panel/richtext.py +107 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/site.py +100 -11
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/static/panel.css +31 -1
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/static/panel.js +114 -0
- fastapi_panel-0.2.0/src/fastapi_panel/templates/fastapi_panel/activity.html +58 -0
- fastapi_panel-0.2.0/src/fastapi_panel/templates/fastapi_panel/forgot_password.html +17 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/list.html +25 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/login.html +2 -0
- fastapi_panel-0.2.0/src/fastapi_panel/templates/fastapi_panel/reset_password.html +16 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/views.py +129 -5
- fastapi_panel-0.2.0/tests/test_history_and_filters.py +371 -0
- fastapi_panel-0.2.0/tests/test_password_reset.py +218 -0
- fastapi_panel-0.1.0/docs/screenshots/changelist-dark.png +0 -0
- fastapi_panel-0.1.0/docs/screenshots/changelist.png +0 -0
- fastapi_panel-0.1.0/docs/screenshots/dashboard.png +0 -0
- fastapi_panel-0.1.0/docs/screenshots/deleted.png +0 -0
- fastapi_panel-0.1.0/docs/screenshots/history.png +0 -0
- fastapi_panel-0.1.0/docs/screenshots/login.png +0 -0
- fastapi_panel-0.1.0/docs/screenshots/mobile.png +0 -0
- fastapi_panel-0.1.0/docs/screenshots/product-form.png +0 -0
- fastapi_panel-0.1.0/docs/screenshots/related-popup.png +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/dependabot.yml +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/pull_request_template.md +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/workflows/ci.yml +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/workflows/publish.yml +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.gitignore +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/CODE_OF_CONDUCT.md +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/CONTRIBUTING.md +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/LICENSE +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/SECURITY.md +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/examples/shop/models.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/pyproject.toml +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/auth.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/db.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/filters.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/imports.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/introspect.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/py.typed +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/security.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/static/theme.js +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/storage.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/base.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/delete.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/error.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/form.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/history.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/import.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/index.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/password.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/popup_done.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/widgets.html +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/conftest.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_async_and_sqlmodel.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_csv_import.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_extending.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_files.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_panel.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_related_popup.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_security.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_soft_delete.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_timezone_and_cli.py +0 -0
- {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/uv.lock +0 -0
|
@@ -4,6 +4,21 @@ All notable changes are listed here. The format follows [Keep a Changelog](https
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Password reset: `mount(password_reset=..., public_url=...)`, and a `changepassword` command
|
|
12
|
+
- `RichTextField` and `sanitize_html`
|
|
13
|
+
- Saved filters on every list, kept per user
|
|
14
|
+
- An Activity page with everyone's changes, behind the `activity.view` permission
|
|
15
|
+
- `log_values`, to keep old and new values of chosen fields in the history
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Fixed: the many-to-many picker wrapped onto two rows when it had a **+** button
|
|
20
|
+
- The panel now creates a second table, `fastapi_panel_saved_filter`. If your database user can't create tables, add it through your migrations. Lists keep working without it
|
|
21
|
+
|
|
7
22
|
## [0.1.0] - 2026-11-04
|
|
8
23
|
|
|
9
24
|
First release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fastapi-panel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: An admin panel for FastAPI and SQLAlchemy. Register a model, get a secure back office.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zaman-labs/fastapi-panel
|
|
6
6
|
Project-URL: Documentation, https://github.com/zaman-labs/fastapi-panel/tree/main/docs
|
|
@@ -92,14 +92,14 @@ Want to try it first? The [example shop](https://github.com/zaman-labs/fastapi-p
|
|
|
92
92
|
|
|
93
93
|
| | |
|
|
94
94
|
|---|---|
|
|
95
|
-
| **Change lists** | Columns, computed columns, sorting, search across relations, sidebar filters, date drill-down, pagination, editing in the list |
|
|
96
|
-
| **Forms** | Built from your models, with fieldsets, read-only fields, prefilled slugs, validation hooks, Pydantic schemas, foreign key autocomplete, many-to-many pickers, and a **+** button to create a related record on the spot |
|
|
95
|
+
| **Change lists** | Columns, computed columns, sorting, search across relations, sidebar filters, saved filters, date drill-down, pagination, editing in the list |
|
|
96
|
+
| **Forms** | Built from your models, with fieldsets, read-only fields, prefilled slugs, validation hooks, Pydantic schemas, a rich text editor, foreign key autocomplete, many-to-many pickers, and a **+** button to create a related record on the spot |
|
|
97
97
|
| **Files and images** | Uploads with previews and thumbnails, stored on disk or anywhere else through a small storage class |
|
|
98
98
|
| **Import and restore** | CSV import with a preview before anything is saved, and a Deleted page to restore soft-deleted records |
|
|
99
99
|
| **Inlines** | Tabular and stacked, with add, edit and delete on the parent's page |
|
|
100
100
|
| **Actions** | Your own bulk actions, plus delete with a summary of what cascades, CSV export and "select all across pages" |
|
|
101
|
-
| **
|
|
102
|
-
| **Audit log** | Who changed which fields and when,
|
|
101
|
+
| **Accounts and permissions** | Per model and per action, row-level through `get_queryset` and `has_*_permission`, with password reset by email |
|
|
102
|
+
| **Audit log** | Who changed which fields and when, on each record's history and a site-wide Activity page, with old and new values for the fields you choose |
|
|
103
103
|
| **Any SQLAlchemy setup** | Sync and async engines, SQLModel, composite primary keys |
|
|
104
104
|
| **Timezones** | Show and enter times in your site's timezone, while the database stays in UTC |
|
|
105
105
|
| **Interface** | Dark mode, works on a phone, keyboard friendly, no external assets |
|
|
@@ -144,7 +144,7 @@ The panel refuses to start without authentication or with a short secret key. Re
|
|
|
144
144
|
|
|
145
145
|
## Roadmap
|
|
146
146
|
|
|
147
|
-
Translations,
|
|
147
|
+
Translations, two-factor login, and a Redis option for login throttling across workers. Ideas and pull requests are welcome, see [CONTRIBUTING](https://github.com/zaman-labs/fastapi-panel/blob/main/CONTRIBUTING.md).
|
|
148
148
|
|
|
149
149
|
## License
|
|
150
150
|
|
|
@@ -59,14 +59,14 @@ Want to try it first? The [example shop](https://github.com/zaman-labs/fastapi-p
|
|
|
59
59
|
|
|
60
60
|
| | |
|
|
61
61
|
|---|---|
|
|
62
|
-
| **Change lists** | Columns, computed columns, sorting, search across relations, sidebar filters, date drill-down, pagination, editing in the list |
|
|
63
|
-
| **Forms** | Built from your models, with fieldsets, read-only fields, prefilled slugs, validation hooks, Pydantic schemas, foreign key autocomplete, many-to-many pickers, and a **+** button to create a related record on the spot |
|
|
62
|
+
| **Change lists** | Columns, computed columns, sorting, search across relations, sidebar filters, saved filters, date drill-down, pagination, editing in the list |
|
|
63
|
+
| **Forms** | Built from your models, with fieldsets, read-only fields, prefilled slugs, validation hooks, Pydantic schemas, a rich text editor, foreign key autocomplete, many-to-many pickers, and a **+** button to create a related record on the spot |
|
|
64
64
|
| **Files and images** | Uploads with previews and thumbnails, stored on disk or anywhere else through a small storage class |
|
|
65
65
|
| **Import and restore** | CSV import with a preview before anything is saved, and a Deleted page to restore soft-deleted records |
|
|
66
66
|
| **Inlines** | Tabular and stacked, with add, edit and delete on the parent's page |
|
|
67
67
|
| **Actions** | Your own bulk actions, plus delete with a summary of what cascades, CSV export and "select all across pages" |
|
|
68
|
-
| **
|
|
69
|
-
| **Audit log** | Who changed which fields and when,
|
|
68
|
+
| **Accounts and permissions** | Per model and per action, row-level through `get_queryset` and `has_*_permission`, with password reset by email |
|
|
69
|
+
| **Audit log** | Who changed which fields and when, on each record's history and a site-wide Activity page, with old and new values for the fields you choose |
|
|
70
70
|
| **Any SQLAlchemy setup** | Sync and async engines, SQLModel, composite primary keys |
|
|
71
71
|
| **Timezones** | Show and enter times in your site's timezone, while the database stays in UTC |
|
|
72
72
|
| **Interface** | Dark mode, works on a phone, keyboard friendly, no external assets |
|
|
@@ -111,7 +111,7 @@ The panel refuses to start without authentication or with a short secret key. Re
|
|
|
111
111
|
|
|
112
112
|
## Roadmap
|
|
113
113
|
|
|
114
|
-
Translations,
|
|
114
|
+
Translations, two-factor login, and a Redis option for login throttling across workers. Ideas and pull requests are welcome, see [CONTRIBUTING](https://github.com/zaman-labs/fastapi-panel/blob/main/CONTRIBUTING.md).
|
|
115
115
|
|
|
116
116
|
## License
|
|
117
117
|
|
|
@@ -87,6 +87,41 @@ class LdapAuth(AuthBackend):
|
|
|
87
87
|
def session_hash(self, user): ... # sessions end when this value changes
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
## Activity
|
|
91
|
+
|
|
92
|
+
The Activity page needs the `activity.view` permission, so with `ModelAuth` that means superusers or a `permissions_field` that lists it. It only shows entries for models the person may view. Row-level rules from `get_queryset` don't apply to the log, so don't give this permission to someone who should only see some rows of a model.
|
|
93
|
+
|
|
94
|
+
## Password reset
|
|
95
|
+
|
|
96
|
+
Pass a function that delivers the link, and the panel's public address:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
def send_reset(user, url):
|
|
100
|
+
send_email(user.email, "Reset your password", f"Open this link to choose a new password: {url}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
site.mount(
|
|
104
|
+
app,
|
|
105
|
+
engine=engine,
|
|
106
|
+
auth=ModelAuth(User),
|
|
107
|
+
secret_key=key,
|
|
108
|
+
password_reset=send_reset,
|
|
109
|
+
public_url="https://example.com/admin",
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A **Forgot your password?** link appears on the login page. `send_reset` can be sync or async, and runs after the response, so a slow mail server can't reveal which accounts exist. If it raises, the error goes to the `fastapi_panel` logger. People type their username, which is their email if that's your `username_field`.
|
|
114
|
+
|
|
115
|
+
Links work for one hour and stop working once the password changes. Changing it also ends that user's open sessions. Each username and IP can request 3 links an hour.
|
|
116
|
+
|
|
117
|
+
`public_url` is required because a link built from the request would use its `Host` header, which an attacker can set to their own site. `StaticAuth` has no reset, since its passwords live in your configuration.
|
|
118
|
+
|
|
119
|
+
Without email, or if you've lost access, use the command line:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
python -m fastapi_panel changepassword main --username ana
|
|
123
|
+
```
|
|
124
|
+
|
|
90
125
|
## Sessions
|
|
91
126
|
|
|
92
127
|
Sessions live in a signed cookie, so there's no session table. A session ends when it expires (`session_age`, 12 hours by default), on logout, when the password changes, or when the user is deactivated. The last two apply on the very next request.
|
|
@@ -77,6 +77,8 @@ Settings for `mount`:
|
|
|
77
77
|
| `secure_cookies` | automatic | Force the `Secure` flag on or off. Automatic follows the request scheme |
|
|
78
78
|
| `trusted_origins` | `()` | Extra origins allowed to POST, see [Security](security.md) |
|
|
79
79
|
| `max_body_size` | 10 MB | Largest POST body accepted |
|
|
80
|
+
| `password_reset` | `None` | A function that delivers reset links, see [Password reset](auth-and-permissions.md#password-reset) |
|
|
81
|
+
| `public_url` | `None` | The panel's public address, required with `password_reset` |
|
|
80
82
|
|
|
81
83
|
## Custom pages
|
|
82
84
|
|
|
@@ -94,7 +94,7 @@ auth = StaticAuth({"admin": settings.PANEL_PASSWORD_HASH})
|
|
|
94
94
|
|
|
95
95
|
## 4. Migrations
|
|
96
96
|
|
|
97
|
-
The
|
|
97
|
+
The panel uses two tables, `fastapi_panel_log` for the audit log and `fastapi_panel_saved_filter` for saved filters. They're created on the first request if they don't exist. If your database user can't create tables, or you want Alembic to own it, add its metadata to `env.py` and generate a migration as usual:
|
|
98
98
|
|
|
99
99
|
```python
|
|
100
100
|
from fastapi_panel.log import Base as PanelBase
|
|
@@ -102,7 +102,7 @@ from fastapi_panel.log import Base as PanelBase
|
|
|
102
102
|
target_metadata = [Base.metadata, PanelBase.metadata]
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
If you create
|
|
105
|
+
If you create them by hand and don't include that metadata, tell Alembic to leave it alone with `include_name`, or autogenerate will want to drop them. To turn the log off, use `AdminSite(audit_log=False)`.
|
|
106
106
|
|
|
107
107
|
## 5. Try the example
|
|
108
108
|
|
|
@@ -100,6 +100,43 @@ Without `field` the error shows at the top of the form. `data` holds clean value
|
|
|
100
100
|
|
|
101
101
|
Field validators work too: `CharField(validators=[check_sku])`. To validate with an existing schema, set `form_schema = ProductCreate`, and its errors appear next to the matching fields.
|
|
102
102
|
|
|
103
|
+
## Rich text
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from fastapi_panel import ModelAdmin, RichTextField
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ArticleAdmin(ModelAdmin):
|
|
110
|
+
field_overrides = {"body": RichTextField()}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The column holds HTML, edited with a small toolbar: bold, italic, underline, headings, lists, quotes and links. There's nothing to install and no external script. Without JavaScript it falls back to a plain text box.
|
|
114
|
+
|
|
115
|
+
Whatever is submitted gets rebuilt from a short allowlist of tags, and only `http`, `https` and `mailto` links survive, so a script can't get in through this field. **Sanitize when you print it on your own site too**, in case a row was written some other way: `sanitize_html(article.body)`.
|
|
116
|
+
|
|
117
|
+
Lists show the text without tags. CSV import goes through the same sanitizing. Images and tables aren't supported.
|
|
118
|
+
|
|
119
|
+
## Saved filters
|
|
120
|
+
|
|
121
|
+
Once someone has searched or filtered a list, **Save this view** keeps the search, filters and sorting under a name. Saved views appear above the list as links, and belong to that user and that model. There's nothing to configure.
|
|
122
|
+
|
|
123
|
+
## History
|
|
124
|
+
|
|
125
|
+
Each record's history lists who changed which fields. To also keep old and new values, name the fields:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
class OrderAdmin(ModelAdmin):
|
|
129
|
+
log_values = ("status", "total")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
Changed status. Status: Paid → Shipped
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This is opt-in because those values stay in the log for good, so think before listing personal data. Fields named like credentials are refused at startup. Long values are cut at 200 characters. Inlines take `log_values` too.
|
|
137
|
+
|
|
138
|
+
Everyone's changes are on the **Activity** page, filterable by user, model and action. See [who can open it](auth-and-permissions.md#activity).
|
|
139
|
+
|
|
103
140
|
## Files and images
|
|
104
141
|
|
|
105
142
|
A file lives in a string column that holds its stored name. Set a storage on the site, then use `FileField` or `ImageField` for the column:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -25,7 +25,9 @@ An admin panel can reach all your data, so its defaults are strict and don't dep
|
|
|
25
25
|
| Rows from another parent or tenant | Inline rows must belong to the parent, and all access goes through `get_queryset` |
|
|
26
26
|
| Open redirects | `next` must stay inside the panel |
|
|
27
27
|
| Secrets on screen | Columns named like credentials are hidden unless listed explicitly |
|
|
28
|
-
| Secrets in logs | The audit log records field names, not values |
|
|
28
|
+
| Secrets in logs | The audit log records field names, not values, unless you list a field in `log_values`. Fields named like credentials are refused |
|
|
29
|
+
| Scripts in rich text | `RichTextField` rebuilds submitted HTML from an allowlist, and only `http`, `https` and `mailto` links survive |
|
|
30
|
+
| Password reset abuse | Links use your configured `public_url`, never the `Host` header. They're signed, expire in an hour and stop working after use. Responses don't reveal which accounts exist, and requests are limited per username and IP |
|
|
29
31
|
| Spreadsheet formula injection | CSV cells starting with `=`, `+`, `-` or `@` are prefixed with `'` |
|
|
30
32
|
| Oversized requests | POST bodies have a size limit, counted as they arrive so chunked requests can't avoid it. Inline forms, bulk actions and CSV imports have row limits |
|
|
31
33
|
| Misconfiguration | It won't start without an auth backend, or with a secret under 32 characters. Plain text passwords are refused in `StaticAuth`, and `ModelAuth` won't guess whether someone is staff |
|
|
@@ -42,6 +44,10 @@ An admin panel can reach all your data, so its defaults are strict and don't dep
|
|
|
42
44
|
|
|
43
45
|
**Consider limiting who can reach it.** Serve the panel on a VPN or restrict it by IP if you can. There's no built-in two-factor login yet, so a reverse proxy with SSO in front is the best way to add one today.
|
|
44
46
|
|
|
47
|
+
**Sanitize rich text on your own pages.** The panel sanitizes `RichTextField` when it saves and displays it, but your site prints the column itself. Use `sanitize_html`, because rows can be written without going through the panel.
|
|
48
|
+
|
|
49
|
+
**Protect reset emails.** A reset link gives access to the account for an hour, so keep it out of your logs and error reports, and use a mail provider you trust.
|
|
50
|
+
|
|
45
51
|
**Mind the database user.** Actions run with your app's database credentials. Foreign key and unique rules still apply, and the panel shows a generic message if they fail.
|
|
46
52
|
|
|
47
53
|
## Known limits
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Example shop
|
|
2
2
|
|
|
3
|
-
A small store with products, categories, tags, customers and orders. It uses most features: list editing, custom filters, date drill-down, fieldsets, inlines, autocomplete, the **+** button, image uploads, CSV import, soft delete with restore, custom actions and permissions.
|
|
3
|
+
A small store with products, categories, tags, customers and orders. It uses most features: list editing, custom filters, date drill-down, fieldsets, inlines, autocomplete, the **+** button, image uploads, rich text, saved filters, values in the history, CSV import, soft delete with restore, custom actions and permissions. The password reset link is printed to the console instead of emailed.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
# from the repository root
|
|
@@ -8,6 +8,7 @@ from fastapi_panel import (
|
|
|
8
8
|
ImageField,
|
|
9
9
|
LocalStorage,
|
|
10
10
|
ModelAdmin,
|
|
11
|
+
RichTextField,
|
|
11
12
|
SimpleFilter,
|
|
12
13
|
TabularInline,
|
|
13
14
|
ValidationError,
|
|
@@ -52,9 +53,11 @@ class ProductAdmin(ModelAdmin):
|
|
|
52
53
|
actions = ("publish", "unpublish")
|
|
53
54
|
soft_delete_field = "deleted_at"
|
|
54
55
|
csv_import = True
|
|
56
|
+
log_values = ("price", "stock", "is_published")
|
|
55
57
|
field_overrides = {
|
|
56
58
|
"price": DecimalField(min_value=Decimal(0), max_digits=10, decimal_places=2),
|
|
57
59
|
"image": ImageField(help_text="PNG, JPG, GIF or WEBP, up to 5 MB."),
|
|
60
|
+
"description": RichTextField(),
|
|
58
61
|
}
|
|
59
62
|
fieldsets = (
|
|
60
63
|
(None, {"fields": ("name", "slug", "category", "image")}),
|
|
@@ -118,4 +118,16 @@ async def home() -> dict[str, str]:
|
|
|
118
118
|
return {"admin": "/admin"}
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
def print_reset_link(user: Staff, url: str) -> None:
|
|
122
|
+
# Demo only. A real app emails this to the user.
|
|
123
|
+
print(f"Password reset link for {user.username}: {url}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
site.mount(
|
|
127
|
+
app,
|
|
128
|
+
engine=engine,
|
|
129
|
+
auth=ModelAuth(Staff, superuser_field="is_superuser"),
|
|
130
|
+
secret_key=SECRET_KEY,
|
|
131
|
+
password_reset=print_reset_link,
|
|
132
|
+
public_url="http://127.0.0.1:8000/admin",
|
|
133
|
+
)
|
|
@@ -103,9 +103,6 @@ def main() -> int:
|
|
|
103
103
|
page.wait_for_selector(".message-success")
|
|
104
104
|
assert page.locator("[data-inline-row]").count() >= before + 1
|
|
105
105
|
|
|
106
|
-
page.goto(f"{BASE}/order/1/history")
|
|
107
|
-
shot("history")
|
|
108
|
-
|
|
109
106
|
page.goto(f"{BASE}/order/1/delete")
|
|
110
107
|
shot("delete")
|
|
111
108
|
|
|
@@ -198,6 +195,63 @@ def main() -> int:
|
|
|
198
195
|
page.wait_for_selector(".message-success")
|
|
199
196
|
assert page.locator("button[name=_restore]").count() == 1
|
|
200
197
|
|
|
198
|
+
# The rich text editor: formatting and a link go through the toolbar and survive a save.
|
|
199
|
+
page.goto(f"{BASE}/product/2/change")
|
|
200
|
+
page.locator("details.fieldset summary").click()
|
|
201
|
+
editor = page.locator(".rich-editor")
|
|
202
|
+
editor.click()
|
|
203
|
+
page.keyboard.press("ControlOrMeta+A")
|
|
204
|
+
page.keyboard.type("Made to last for years.")
|
|
205
|
+
page.keyboard.press("ControlOrMeta+A")
|
|
206
|
+
page.click("button[aria-label=Bold]")
|
|
207
|
+
page.click("button[aria-label=Link]")
|
|
208
|
+
page.fill(".rich-link input", "example.com/care")
|
|
209
|
+
page.keyboard.press("Enter")
|
|
210
|
+
page.wait_for_selector(".rich-editor a")
|
|
211
|
+
shot("rich-text", full_page=True)
|
|
212
|
+
page.click("button[name=_continue]")
|
|
213
|
+
page.wait_for_selector(".message-success")
|
|
214
|
+
saved = page.locator(".rich-editor").inner_html()
|
|
215
|
+
assert "<strong>" in saved and 'href="https://example.com/care"' in saved, saved
|
|
216
|
+
|
|
217
|
+
# Old and new values in the history, for the fields listed in log_values.
|
|
218
|
+
page.fill("#id_price", "12.50")
|
|
219
|
+
page.click("button[name=_continue]")
|
|
220
|
+
page.wait_for_selector(".message-success")
|
|
221
|
+
page.goto(f"{BASE}/product/2/history")
|
|
222
|
+
assert "→ 12.50" in page.inner_text(".card")
|
|
223
|
+
shot("history")
|
|
224
|
+
|
|
225
|
+
# A saved filter: keep this view, then open it again from the list.
|
|
226
|
+
page.goto(f"{BASE}/product?is_published=1&stock_level=low")
|
|
227
|
+
page.locator(".save-filter summary").click()
|
|
228
|
+
page.fill(".save-filter input[name=name]", "Low stock, live")
|
|
229
|
+
page.click(".save-filter button")
|
|
230
|
+
page.wait_for_selector(".message-success")
|
|
231
|
+
page.goto(f"{BASE}/product")
|
|
232
|
+
page.locator(".saved-filters").get_by_text("Low stock, live").click()
|
|
233
|
+
page.wait_for_url("**stock_level=low**")
|
|
234
|
+
assert page.locator(".chip-active").count() == 1
|
|
235
|
+
shot("saved-filters")
|
|
236
|
+
|
|
237
|
+
# Everyone's activity, with filters.
|
|
238
|
+
page.goto(f"{BASE}/activity")
|
|
239
|
+
shot("activity")
|
|
240
|
+
page.select_option("#activity-action", "delete")
|
|
241
|
+
page.click(".activity-filters button")
|
|
242
|
+
page.wait_for_url("**action=delete**")
|
|
243
|
+
assert page.locator(".tag-delete").count() >= 1 and page.locator("tbody .tag-add").count() == 0
|
|
244
|
+
|
|
245
|
+
# Forgotten password, from a browser that isn't logged in.
|
|
246
|
+
visitor = browser.new_context(locale="en-US").new_page()
|
|
247
|
+
visitor.on("console", lambda m: problems.append(m.text) if m.type == "error" else None)
|
|
248
|
+
visitor.goto(f"{BASE}/login")
|
|
249
|
+
visitor.click("text=Forgot your password?")
|
|
250
|
+
visitor.fill("#username", "admin")
|
|
251
|
+
visitor.click("button[type=submit]")
|
|
252
|
+
visitor.wait_for_selector(".message-success")
|
|
253
|
+
visitor.close()
|
|
254
|
+
|
|
201
255
|
page.goto(f"{BASE}/")
|
|
202
256
|
shot("dashboard")
|
|
203
257
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.2.0"
|
|
2
2
|
|
|
3
3
|
from .auth import AuthBackend, ModelAuth, StaticAuth
|
|
4
4
|
from .filters import Filter, SimpleFilter
|
|
@@ -16,6 +16,7 @@ from .forms import (
|
|
|
16
16
|
ImageField,
|
|
17
17
|
IntegerField,
|
|
18
18
|
JSONField,
|
|
19
|
+
RichTextField,
|
|
19
20
|
TextField,
|
|
20
21
|
TimeField,
|
|
21
22
|
UUIDField,
|
|
@@ -31,6 +32,7 @@ from .options import (
|
|
|
31
32
|
action,
|
|
32
33
|
display,
|
|
33
34
|
)
|
|
35
|
+
from .richtext import sanitize_html
|
|
34
36
|
from .security import hash_password, verify_password
|
|
35
37
|
from .site import AdminSite, autodiscover, register, site
|
|
36
38
|
from .storage import LocalStorage, Storage
|
|
@@ -58,6 +60,7 @@ __all__ = [
|
|
|
58
60
|
"LocalStorage",
|
|
59
61
|
"ModelAdmin",
|
|
60
62
|
"ModelAuth",
|
|
63
|
+
"RichTextField",
|
|
61
64
|
"SimpleFilter",
|
|
62
65
|
"StackedInline",
|
|
63
66
|
"StaticAuth",
|
|
@@ -72,6 +75,7 @@ __all__ = [
|
|
|
72
75
|
"display",
|
|
73
76
|
"hash_password",
|
|
74
77
|
"register",
|
|
78
|
+
"sanitize_html",
|
|
75
79
|
"site",
|
|
76
80
|
"verify_password",
|
|
77
81
|
]
|
|
@@ -38,9 +38,9 @@ def find_site(target: str) -> AdminSite:
|
|
|
38
38
|
return next((value for value in vars(module).values() if isinstance(value, AdminSite)), site)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
async def
|
|
41
|
+
async def on_database(panel: AdminSite, fn: Any) -> Any:
|
|
42
42
|
try:
|
|
43
|
-
return await panel.db.run(
|
|
43
|
+
return await panel.db.run(fn)
|
|
44
44
|
finally:
|
|
45
45
|
# An open async connection would keep the process from exiting.
|
|
46
46
|
bind = panel.db.maker.kw.get("bind")
|
|
@@ -48,13 +48,48 @@ async def save_user(panel: AdminSite, *args: Any, **kwargs: Any) -> Any:
|
|
|
48
48
|
await bind.dispose()
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
def
|
|
51
|
+
def mounted_site(args: argparse.Namespace) -> AdminSite:
|
|
52
52
|
panel = find_site(args.target)
|
|
53
53
|
if not hasattr(panel, "db"):
|
|
54
54
|
raise SystemExit(f"{args.target} doesn't call site.mount(), so there's no database to use.")
|
|
55
|
+
if not isinstance(panel.auth, ModelAuth):
|
|
56
|
+
raise SystemExit(f"{args.command} only works with ModelAuth. Other backends keep their users elsewhere.")
|
|
57
|
+
return panel
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def new_password() -> str:
|
|
61
|
+
password = os.environ.get("FASTAPI_PANEL_PASSWORD") or read_password()
|
|
62
|
+
if len(password) < MIN_PASSWORD_LENGTH:
|
|
63
|
+
raise SystemExit(f"Use at least {MIN_PASSWORD_LENGTH} characters.")
|
|
64
|
+
return password
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def change_password(args: argparse.Namespace) -> int:
|
|
68
|
+
panel = mounted_site(args)
|
|
69
|
+
username = args.username or input("Username: ").strip()
|
|
70
|
+
password = new_password()
|
|
71
|
+
|
|
72
|
+
def change(session: Any) -> bool:
|
|
73
|
+
user = panel.auth.find_user(session, username)
|
|
74
|
+
if user is None:
|
|
75
|
+
return False
|
|
76
|
+
panel.auth.set_password(session, user, password)
|
|
77
|
+
session.commit()
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
found = asyncio.run(on_database(panel, change))
|
|
82
|
+
except DBAPIError as exc:
|
|
83
|
+
raise SystemExit(f"Couldn't change the password: {exc.orig}") from None
|
|
84
|
+
if not found:
|
|
85
|
+
raise SystemExit(f"There's no user called {username}.")
|
|
86
|
+
print(f"Changed the password of {username}. Their open sessions have ended.")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def create_user(args: argparse.Namespace) -> int:
|
|
91
|
+
panel = mounted_site(args)
|
|
55
92
|
auth = panel.auth
|
|
56
|
-
if not isinstance(auth, ModelAuth):
|
|
57
|
-
raise SystemExit("createuser only works with ModelAuth. Other backends keep their users elsewhere.")
|
|
58
93
|
|
|
59
94
|
username = args.username or input("Username: ").strip()
|
|
60
95
|
if not username:
|
|
@@ -65,12 +100,13 @@ def create_user(args: argparse.Namespace) -> int:
|
|
|
65
100
|
extra[prop.key] = coerce_to_column(prop.columns[0], input(f"{prop.key}: ").strip())
|
|
66
101
|
except ValueError:
|
|
67
102
|
raise SystemExit(f"That's not a valid value for {prop.key}.") from None
|
|
68
|
-
password =
|
|
69
|
-
|
|
70
|
-
|
|
103
|
+
password = new_password()
|
|
104
|
+
|
|
105
|
+
def create(session: Any) -> Any:
|
|
106
|
+
return auth.create_user(session, username, password, superuser=not args.no_superuser, **extra)
|
|
71
107
|
|
|
72
108
|
try:
|
|
73
|
-
asyncio.run(
|
|
109
|
+
asyncio.run(on_database(panel, create))
|
|
74
110
|
except DBAPIError as exc:
|
|
75
111
|
raise SystemExit(f"Couldn't create the user: {exc.orig}. Do the tables exist, and is the name free?") from None
|
|
76
112
|
print(f"Created {username}.")
|
|
@@ -85,10 +121,15 @@ def main() -> int:
|
|
|
85
121
|
creating.add_argument("target", help="the module that mounts the panel, like main or app.main:panel")
|
|
86
122
|
creating.add_argument("--username")
|
|
87
123
|
creating.add_argument("--no-superuser", action="store_true", help="create a regular staff user")
|
|
124
|
+
changing = commands.add_parser("changepassword", help="set a new password for a user behind ModelAuth")
|
|
125
|
+
changing.add_argument("target", help="the module that mounts the panel, like main or app.main:panel")
|
|
126
|
+
changing.add_argument("--username")
|
|
88
127
|
args = parser.parse_args()
|
|
89
128
|
|
|
90
129
|
if args.command == "createuser":
|
|
91
130
|
return create_user(args)
|
|
131
|
+
if args.command == "changepassword":
|
|
132
|
+
return change_password(args)
|
|
92
133
|
print(hash_password(read_password()))
|
|
93
134
|
return 0
|
|
94
135
|
|
|
@@ -21,6 +21,7 @@ from sqlalchemy.orm import MANYTOMANY, MANYTOONE
|
|
|
21
21
|
from starlette.datastructures import UploadFile
|
|
22
22
|
|
|
23
23
|
from .introspect import coerce_to_column, humanize, object_label
|
|
24
|
+
from .richtext import plain_text, sanitize_html
|
|
24
25
|
|
|
25
26
|
SENSITIVE_NAME = re.compile(r"password|passwd|secret|token|api_?key|private_?key", re.I)
|
|
26
27
|
EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
@@ -109,6 +110,12 @@ class Field:
|
|
|
109
110
|
def to_python(self, raw: str) -> Any:
|
|
110
111
|
return raw
|
|
111
112
|
|
|
113
|
+
def describe(self, formatted: Any) -> str:
|
|
114
|
+
"""How a value reads in the history, when `log_values` asks to keep it."""
|
|
115
|
+
labels = dict(getattr(self, "choices", []))
|
|
116
|
+
values = formatted if isinstance(formatted, list) else [formatted]
|
|
117
|
+
return ", ".join(str(labels.get(value, value)) for value in values)
|
|
118
|
+
|
|
112
119
|
def clean(self, raw: Any) -> Any:
|
|
113
120
|
if isinstance(raw, str):
|
|
114
121
|
raw = raw.strip()
|
|
@@ -139,6 +146,27 @@ class TextField(CharField):
|
|
|
139
146
|
widget = "textarea"
|
|
140
147
|
|
|
141
148
|
|
|
149
|
+
class RichTextField(TextField):
|
|
150
|
+
"""Formatted text, stored as HTML. Only a small set of tags survives, whatever gets submitted."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
153
|
+
super().__init__(**kwargs)
|
|
154
|
+
self.attrs = {"data-richtext": "", **self.attrs}
|
|
155
|
+
|
|
156
|
+
def format(self, value: Any) -> str:
|
|
157
|
+
return sanitize_html(value) if value else ""
|
|
158
|
+
|
|
159
|
+
def extract(self, data: Any) -> str:
|
|
160
|
+
return self.format(data.get(self.html_name, ""))
|
|
161
|
+
|
|
162
|
+
def clean(self, raw: Any) -> Any:
|
|
163
|
+
# An editor left empty still holds markup such as <br>.
|
|
164
|
+
return super().clean(raw if plain_text(raw).strip() else "")
|
|
165
|
+
|
|
166
|
+
def describe(self, formatted: Any) -> str:
|
|
167
|
+
return plain_text(formatted)
|
|
168
|
+
|
|
169
|
+
|
|
142
170
|
class EmailField(CharField):
|
|
143
171
|
input_type = "email"
|
|
144
172
|
|
|
@@ -217,6 +245,9 @@ class BooleanField(Field):
|
|
|
217
245
|
def format(self, value: Any) -> str:
|
|
218
246
|
return "on" if value else ""
|
|
219
247
|
|
|
248
|
+
def describe(self, formatted: Any) -> str:
|
|
249
|
+
return "Yes" if formatted else "No"
|
|
250
|
+
|
|
220
251
|
def clean(self, raw: Any) -> bool:
|
|
221
252
|
return bool(raw)
|
|
222
253
|
|
|
@@ -551,6 +582,7 @@ class Form:
|
|
|
551
582
|
self.cleaned: dict[str, Any] = {}
|
|
552
583
|
self.fieldsets: list[dict[str, Any]] = []
|
|
553
584
|
self.delete_requested = False
|
|
585
|
+
self.diffs: list[tuple[Field, Any, Any]] = []
|
|
554
586
|
for field in self.editable:
|
|
555
587
|
source = field.read(obj) if persisted else field.initial
|
|
556
588
|
field.value = field.format(source)
|
|
@@ -604,8 +636,10 @@ class Form:
|
|
|
604
636
|
continue
|
|
605
637
|
before = field.format(field.read(obj)) if self.persisted else None
|
|
606
638
|
field.write(obj, value)
|
|
607
|
-
|
|
639
|
+
after = field.format(value)
|
|
640
|
+
if self.persisted and before != after:
|
|
608
641
|
changed.append(field.label)
|
|
642
|
+
self.diffs.append((field, before, after))
|
|
609
643
|
return changed
|
|
610
644
|
|
|
611
645
|
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import datetime as dt
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
from sqlalchemy import DateTime, Index, String, Text
|
|
6
|
+
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
|
|
7
7
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
8
8
|
|
|
9
9
|
from .introspect import object_label, pk_string
|
|
@@ -34,6 +34,17 @@ class AdminLog(Base):
|
|
|
34
34
|
message: Mapped[str] = mapped_column(Text, default="")
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
class SavedFilter(Base):
|
|
38
|
+
__tablename__ = "fastapi_panel_saved_filter"
|
|
39
|
+
__table_args__ = (UniqueConstraint("user_id", "model", "name", name="uq_fastapi_panel_saved_filter"),)
|
|
40
|
+
|
|
41
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
42
|
+
user_id: Mapped[str] = mapped_column(String(255))
|
|
43
|
+
model: Mapped[str] = mapped_column(String(100))
|
|
44
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
45
|
+
query: Mapped[str] = mapped_column(Text)
|
|
46
|
+
|
|
47
|
+
|
|
37
48
|
def record(session: Any, request: Any, admin: Any, obj: Any, action: str, message: str = "") -> None:
|
|
38
49
|
if not admin.site.audit_log:
|
|
39
50
|
return
|