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.
Files changed (96) hide show
  1. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/CHANGELOG.md +15 -0
  2. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/PKG-INFO +6 -6
  3. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/README.md +5 -5
  4. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/auth-and-permissions.md +35 -0
  5. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/customizing.md +2 -0
  6. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/getting-started.md +2 -2
  7. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/model-admin.md +37 -0
  8. fastapi_panel-0.2.0/docs/screenshots/activity.png +0 -0
  9. fastapi_panel-0.2.0/docs/screenshots/changelist-dark.png +0 -0
  10. fastapi_panel-0.2.0/docs/screenshots/changelist.png +0 -0
  11. fastapi_panel-0.2.0/docs/screenshots/dashboard.png +0 -0
  12. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/screenshots/delete.png +0 -0
  13. fastapi_panel-0.2.0/docs/screenshots/deleted.png +0 -0
  14. fastapi_panel-0.2.0/docs/screenshots/history.png +0 -0
  15. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/screenshots/import-preview.png +0 -0
  16. fastapi_panel-0.2.0/docs/screenshots/login.png +0 -0
  17. fastapi_panel-0.2.0/docs/screenshots/mobile.png +0 -0
  18. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/screenshots/order-inline.png +0 -0
  19. fastapi_panel-0.2.0/docs/screenshots/product-form.png +0 -0
  20. fastapi_panel-0.2.0/docs/screenshots/related-popup.png +0 -0
  21. fastapi_panel-0.2.0/docs/screenshots/rich-text.png +0 -0
  22. fastapi_panel-0.2.0/docs/screenshots/saved-filters.png +0 -0
  23. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/docs/security.md +7 -1
  24. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/examples/shop/README.md +1 -1
  25. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/examples/shop/admin.py +3 -0
  26. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/examples/shop/main.py +13 -1
  27. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/scripts/screenshots.py +57 -3
  28. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/__init__.py +5 -1
  29. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/__main__.py +50 -9
  30. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/forms.py +35 -1
  31. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/log.py +12 -1
  32. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/options.py +26 -0
  33. fastapi_panel-0.2.0/src/fastapi_panel/richtext.py +107 -0
  34. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/site.py +100 -11
  35. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/static/panel.css +31 -1
  36. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/static/panel.js +114 -0
  37. fastapi_panel-0.2.0/src/fastapi_panel/templates/fastapi_panel/activity.html +58 -0
  38. fastapi_panel-0.2.0/src/fastapi_panel/templates/fastapi_panel/forgot_password.html +17 -0
  39. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/list.html +25 -0
  40. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/login.html +2 -0
  41. fastapi_panel-0.2.0/src/fastapi_panel/templates/fastapi_panel/reset_password.html +16 -0
  42. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/views.py +129 -5
  43. fastapi_panel-0.2.0/tests/test_history_and_filters.py +371 -0
  44. fastapi_panel-0.2.0/tests/test_password_reset.py +218 -0
  45. fastapi_panel-0.1.0/docs/screenshots/changelist-dark.png +0 -0
  46. fastapi_panel-0.1.0/docs/screenshots/changelist.png +0 -0
  47. fastapi_panel-0.1.0/docs/screenshots/dashboard.png +0 -0
  48. fastapi_panel-0.1.0/docs/screenshots/deleted.png +0 -0
  49. fastapi_panel-0.1.0/docs/screenshots/history.png +0 -0
  50. fastapi_panel-0.1.0/docs/screenshots/login.png +0 -0
  51. fastapi_panel-0.1.0/docs/screenshots/mobile.png +0 -0
  52. fastapi_panel-0.1.0/docs/screenshots/product-form.png +0 -0
  53. fastapi_panel-0.1.0/docs/screenshots/related-popup.png +0 -0
  54. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  55. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  56. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/dependabot.yml +0 -0
  57. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/pull_request_template.md +0 -0
  58. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/workflows/ci.yml +0 -0
  59. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.github/workflows/publish.yml +0 -0
  60. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/.gitignore +0 -0
  61. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  62. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/CONTRIBUTING.md +0 -0
  63. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/LICENSE +0 -0
  64. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/SECURITY.md +0 -0
  65. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/examples/shop/models.py +0 -0
  66. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/pyproject.toml +0 -0
  67. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/auth.py +0 -0
  68. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/db.py +0 -0
  69. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/filters.py +0 -0
  70. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/imports.py +0 -0
  71. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/introspect.py +0 -0
  72. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/py.typed +0 -0
  73. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/security.py +0 -0
  74. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/static/theme.js +0 -0
  75. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/storage.py +0 -0
  76. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/base.html +0 -0
  77. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/delete.html +0 -0
  78. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/error.html +0 -0
  79. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/form.html +0 -0
  80. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/history.html +0 -0
  81. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/import.html +0 -0
  82. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/index.html +0 -0
  83. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/password.html +0 -0
  84. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/popup_done.html +0 -0
  85. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/src/fastapi_panel/templates/fastapi_panel/widgets.html +0 -0
  86. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/conftest.py +0 -0
  87. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_async_and_sqlmodel.py +0 -0
  88. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_csv_import.py +0 -0
  89. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_extending.py +0 -0
  90. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_files.py +0 -0
  91. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_panel.py +0 -0
  92. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_related_popup.py +0 -0
  93. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_security.py +0 -0
  94. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_soft_delete.py +0 -0
  95. {fastapi_panel-0.1.0 → fastapi_panel-0.2.0}/tests/test_timezone_and_cli.py +0 -0
  96. {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.1.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
- | **Permissions** | Per model and per action, row-level through `get_queryset` and `has_*_permission` |
102
- | **Audit log** | Who changed which fields and when, shown on each record's history page |
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, a saved-filters UI, password reset by email, 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).
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
- | **Permissions** | Per model and per action, row-level through `get_queryset` and `has_*_permission` |
69
- | **Audit log** | Who changed which fields and when, shown on each record's history page |
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, a saved-filters UI, password reset by email, 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).
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 audit log uses one table, `fastapi_panel_log`. It's created on the first request if it doesn'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:
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 the table by hand and don't include that metadata, tell Alembic to leave it alone with `include_name`, or autogenerate will want to drop it. To turn the log off, use `AdminSite(audit_log=False)`.
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:
@@ -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
- site.mount(app, engine=engine, auth=ModelAuth(Staff, superuser_field="is_superuser"), secret_key=SECRET_KEY)
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.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 save_user(panel: AdminSite, *args: Any, **kwargs: Any) -> Any:
41
+ async def on_database(panel: AdminSite, fn: Any) -> Any:
42
42
  try:
43
- return await panel.db.run(lambda session: panel.auth.create_user(session, *args, **kwargs))
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 create_user(args: argparse.Namespace) -> int:
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 = os.environ.get("FASTAPI_PANEL_PASSWORD") or read_password()
69
- if len(password) < MIN_PASSWORD_LENGTH:
70
- raise SystemExit(f"Use at least {MIN_PASSWORD_LENGTH} characters.")
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(save_user(panel, username, password, superuser=not args.no_superuser, **extra))
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
- if self.persisted and before != field.format(value):
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