fastadmin 0.4.8__tar.gz → 0.5.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.
- fastadmin-0.5.0/PKG-INFO +215 -0
- fastadmin-0.5.0/README.md +169 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/django/app/api.py +3 -1
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/fastapi/api.py +4 -1
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/flask/api.py +4 -2
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/service.py +13 -5
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/base.py +45 -5
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/decorators.py +1 -1
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/helpers.py +1 -1
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/orms/ponyorm.py +1 -1
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/orms/sqlalchemy.py +1 -1
- fastadmin-0.5.0/fastadmin/static/index.min.js +653 -0
- fastadmin-0.5.0/pyproject.toml +153 -0
- fastadmin-0.4.8/PKG-INFO +0 -877
- fastadmin-0.4.8/README.md +0 -834
- fastadmin-0.4.8/fastadmin/static/index.min.js +0 -680
- fastadmin-0.4.8/pyproject.toml +0 -147
- /fastadmin-0.4.8/LICENSE → /fastadmin-0.5.0/LICENSE.md +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/__init__.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/__init__.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/exceptions.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/__init__.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/django/__init__.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/django/app/urls.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/django/app/views.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/fastapi/app.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/fastapi/views.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/flask/__init__.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/flask/app.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/frameworks/flask/views.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/helpers.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/api/schemas.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/__init__.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/orms/__init__.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/orms/django.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/orms/tortoise.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/models/schemas.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/settings.py +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/images/favicon.png +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/images/header-logo.svg +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/images/sign-in-logo.svg +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/index.html +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/static/index.min.css +0 -0
- {fastadmin-0.4.8 → fastadmin-0.5.0}/fastadmin/templates/index.html +0 -0
fastadmin-0.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastadmin
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin.
|
|
5
|
+
Keywords: admin,dashboard,fastapi,flask,django,tortoise-orm,sqlalchemy,pony,admin-dashboard,django-admin
|
|
6
|
+
Author: Seva D
|
|
7
|
+
Author-email: Seva D <vsdudakov@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE.md
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Flask
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Software Development :: Widget Sets
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Dist: pyjwt>=2.6
|
|
25
|
+
Requires-Dist: asgiref>=3.11
|
|
26
|
+
Requires-Dist: django>=6,<7 ; extra == 'django'
|
|
27
|
+
Requires-Dist: fastapi>=0.100,<1 ; extra == 'fastapi'
|
|
28
|
+
Requires-Dist: python-multipart>=0.0.5,<1 ; extra == 'fastapi'
|
|
29
|
+
Requires-Dist: flask>=3,<4 ; extra == 'flask'
|
|
30
|
+
Requires-Dist: pony>=0.7,<1 ; extra == 'pony'
|
|
31
|
+
Requires-Dist: sqlalchemy[asyncio]>=2,<3 ; extra == 'sqlalchemy'
|
|
32
|
+
Requires-Dist: greenlet>=3 ; extra == 'sqlalchemy'
|
|
33
|
+
Requires-Dist: tortoise-orm>=1,<2 ; extra == 'tortoise-orm'
|
|
34
|
+
Requires-Python: >=3.12
|
|
35
|
+
Project-URL: Homepage, https://vsdudakov.github.io/fastadmin/
|
|
36
|
+
Project-URL: Repository, https://github.com/vsdudakov/fastadmin
|
|
37
|
+
Project-URL: Documentation, https://vsdudakov.github.io/fastadmin/
|
|
38
|
+
Project-URL: Issues, https://github.com/vsdudakov/fastadmin/issues
|
|
39
|
+
Provides-Extra: django
|
|
40
|
+
Provides-Extra: fastapi
|
|
41
|
+
Provides-Extra: flask
|
|
42
|
+
Provides-Extra: pony
|
|
43
|
+
Provides-Extra: sqlalchemy
|
|
44
|
+
Provides-Extra: tortoise-orm
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
<h1 align="center">FastAdmin — Admin Dashboard for FastAPI, Flask and Django</h1>
|
|
48
|
+
|
|
49
|
+
<p align="center">
|
|
50
|
+
<a href="https://github.com/vsdudakov/fastadmin/actions/workflows/ci.yml"><img src="https://github.com/vsdudakov/fastadmin/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
51
|
+
<a href="https://app.codecov.io/gh/vsdudakov/fastadmin"><img src="https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T" alt="codecov"></a>
|
|
52
|
+
<a href="https://pypi.org/project/fastadmin/"><img src="https://badgen.net/pypi/v/fastadmin" alt="PyPI"></a>
|
|
53
|
+
<a href="https://pypi.org/project/fastadmin/"><img src="https://img.shields.io/badge/python-3.12%2B-blue.svg" alt="Python 3.12+"></a>
|
|
54
|
+
<a href="https://github.com/vsdudakov/fastadmin/blob/main/LICENSE.md"><img src="https://img.shields.io/github/license/vsdudakov/fastadmin" alt="License: MIT"></a>
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
**FastAdmin** is an easy-to-use **admin dashboard (admin panel) for FastAPI, Flask and
|
|
58
|
+
Django**, inspired by Django Admin. It gives your Python web application a
|
|
59
|
+
production-ready **CRUD admin interface** in minutes — on top of **Tortoise ORM,
|
|
60
|
+
Django ORM, SQLAlchemy or Pony ORM** — with authentication, filters, search,
|
|
61
|
+
inline editing, file uploads, CSV/JSON export and dashboard charts out of the box.
|
|
62
|
+
|
|
63
|
+
FastAdmin is built with relationships in mind and admiration for Django Admin.
|
|
64
|
+
It aims to be minimal, functional and familiar: if you know Django Admin, you
|
|
65
|
+
already know FastAdmin.
|
|
66
|
+
|
|
67
|
+
📚 **Documentation: [https://vsdudakov.github.io/fastadmin/](https://vsdudakov.github.io/fastadmin/)**
|
|
68
|
+
|
|
69
|
+
## Demo
|
|
70
|
+
|
|
71
|
+

|
|
72
|
+
|
|
73
|
+
## Features
|
|
74
|
+
|
|
75
|
+
- **Familiar Django-Admin-style API** — `list_display`, `list_filter`,
|
|
76
|
+
`search_fields`, `fieldsets`, `readonly_fields`, inlines, actions and more.
|
|
77
|
+
- **Any web framework** — mount as a FastAPI sub-app, a Flask blueprint or
|
|
78
|
+
Django urlpatterns.
|
|
79
|
+
- **Any ORM** — first-class admin classes for Tortoise ORM, Django ORM,
|
|
80
|
+
SQLAlchemy (async) and Pony ORM.
|
|
81
|
+
- **Authentication & permissions** — pluggable sign-in against your own user
|
|
82
|
+
model, per-action permission hooks, request/user context in every admin method.
|
|
83
|
+
- **Rich form widgets** — 20+ antd-based widgets (rich text, JSON, async
|
|
84
|
+
select, phone, slug, date/time pickers, switches, radio groups, …) via
|
|
85
|
+
`formfield_overrides`.
|
|
86
|
+
- **File & image uploads** — storage-agnostic `upload_file` hook and
|
|
87
|
+
presigned-URL support via `get_file_url` (local disk, S3, …).
|
|
88
|
+
- **Dashboard widgets** — declarative line/area/column/bar/pie charts and
|
|
89
|
+
action widgets with filters, powered by antd charts.
|
|
90
|
+
- **Bulk actions & export** — custom bulk actions, CSV/JSON export.
|
|
91
|
+
- **Quality** — fully typed and linted (ruff + ty), 100% backend test coverage,
|
|
92
|
+
modern React (Vite + antd) frontend bundled with the package — no Node.js
|
|
93
|
+
needed at install time.
|
|
94
|
+
|
|
95
|
+
## Installation
|
|
96
|
+
|
|
97
|
+
Install with the extras matching your web framework and ORM (each combination
|
|
98
|
+
works standalone):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install fastadmin[fastapi,django] # FastAPI with Django ORM
|
|
102
|
+
pip install fastadmin[fastapi,tortoise-orm] # FastAPI with Tortoise ORM
|
|
103
|
+
pip install fastadmin[fastapi,pony] # FastAPI with Pony ORM
|
|
104
|
+
pip install fastadmin[fastapi,sqlalchemy] # FastAPI with SQLAlchemy (includes greenlet)
|
|
105
|
+
pip install fastadmin[django] # Django with Django ORM
|
|
106
|
+
pip install fastadmin[django,pony] # Django with Pony ORM
|
|
107
|
+
pip install fastadmin[flask,sqlalchemy] # Flask with SQLAlchemy (includes greenlet)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
> **Note:** on zsh (the default macOS shell) quote the extras: `pip install 'fastadmin[fastapi,django]'`.
|
|
111
|
+
|
|
112
|
+
Configure the required settings:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
export ADMIN_USER_MODEL=User
|
|
116
|
+
export ADMIN_USER_MODEL_USERNAME_FIELD=username
|
|
117
|
+
export ADMIN_SECRET_KEY=secret_key
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Quick start
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import bcrypt
|
|
124
|
+
from fastapi import FastAPI
|
|
125
|
+
|
|
126
|
+
from fastadmin import TortoiseModelAdmin, register
|
|
127
|
+
from fastadmin import fastapi_app as admin_app
|
|
128
|
+
|
|
129
|
+
from models import User
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@register(User)
|
|
133
|
+
class UserAdmin(TortoiseModelAdmin):
|
|
134
|
+
exclude = ("hash_password",)
|
|
135
|
+
list_display = ("id", "username", "is_superuser", "is_active")
|
|
136
|
+
list_display_links = ("id", "username")
|
|
137
|
+
list_filter = ("id", "username", "is_superuser", "is_active")
|
|
138
|
+
search_fields = ("username",)
|
|
139
|
+
|
|
140
|
+
async def authenticate(self, username: str, password: str) -> int | None:
|
|
141
|
+
user = await User.filter(username=username, is_superuser=True).first()
|
|
142
|
+
if not user:
|
|
143
|
+
return None
|
|
144
|
+
if not bcrypt.checkpw(password.encode(), user.hash_password.encode()):
|
|
145
|
+
return None
|
|
146
|
+
return user.id
|
|
147
|
+
|
|
148
|
+
async def change_password(self, id: int, password: str) -> None:
|
|
149
|
+
user = await User.filter(id=id).first()
|
|
150
|
+
if not user:
|
|
151
|
+
return
|
|
152
|
+
user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
153
|
+
await user.save(update_fields=("hash_password",))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
app = FastAPI()
|
|
157
|
+
app.mount("/admin", admin_app)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Run `uvicorn example:app` and open `http://localhost:8000/admin`.
|
|
161
|
+
|
|
162
|
+
Mounting for the other frameworks:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# Flask
|
|
166
|
+
from fastadmin import flask_app as admin_app
|
|
167
|
+
app.register_blueprint(admin_app, url_prefix="/admin")
|
|
168
|
+
|
|
169
|
+
# Django (urls.py)
|
|
170
|
+
from fastadmin import get_django_admin_urls as get_admin_urls
|
|
171
|
+
urlpatterns = [path("admin/", get_admin_urls())]
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Documentation
|
|
175
|
+
|
|
176
|
+
Full documentation lives at
|
|
177
|
+
**[vsdudakov.github.io/fastadmin](https://vsdudakov.github.io/fastadmin/)**:
|
|
178
|
+
|
|
179
|
+
- [Installation](https://vsdudakov.github.io/fastadmin/getting-started/installation/)
|
|
180
|
+
and [Quick start](https://vsdudakov.github.io/fastadmin/getting-started/quickstart/)
|
|
181
|
+
- [Settings](https://vsdudakov.github.io/fastadmin/guides/settings/) — all environment variables
|
|
182
|
+
- [Registering models](https://vsdudakov.github.io/fastadmin/guides/registering-models/) — complete runnable examples for all four ORMs
|
|
183
|
+
- [Authentication](https://vsdudakov.github.io/fastadmin/guides/authentication/) and permissions
|
|
184
|
+
- [Model admins](https://vsdudakov.github.io/fastadmin/guides/model-admins/) — attributes, actions, display fields, exports
|
|
185
|
+
- [Form widgets & file uploads](https://vsdudakov.github.io/fastadmin/guides/form-widgets/)
|
|
186
|
+
- [Inline admins](https://vsdudakov.github.io/fastadmin/guides/inline-admins/)
|
|
187
|
+
- [Dashboard widgets](https://vsdudakov.github.io/fastadmin/guides/dashboard-widgets/) — charts and actions
|
|
188
|
+
- [API reference](https://vsdudakov.github.io/fastadmin/api-reference/)
|
|
189
|
+
|
|
190
|
+
Runnable example apps for every framework/ORM combination are in
|
|
191
|
+
[`examples/`](https://github.com/vsdudakov/fastadmin/tree/main/examples).
|
|
192
|
+
|
|
193
|
+
## Why FastAdmin?
|
|
194
|
+
|
|
195
|
+
If you are looking for a **Django-Admin-like admin panel for FastAPI**, an
|
|
196
|
+
**admin interface for SQLAlchemy or Tortoise ORM**, or a lightweight
|
|
197
|
+
**alternative to building a custom back office**, FastAdmin gives you a
|
|
198
|
+
batteries-included, themeable admin UI without code generation, without tying
|
|
199
|
+
your app to a specific framework, and without writing a single React component.
|
|
200
|
+
|
|
201
|
+
## Contributing
|
|
202
|
+
|
|
203
|
+
Contributions are welcome — see the
|
|
204
|
+
[contributing guide](https://vsdudakov.github.io/fastadmin/contributing/).
|
|
205
|
+
Development uses `uv` and `make`: `make dev`, `make lint`, `make test`.
|
|
206
|
+
See [CHANGELOG.md](https://github.com/vsdudakov/fastadmin/blob/main/CHANGELOG.md)
|
|
207
|
+
for release history.
|
|
208
|
+
|
|
209
|
+
If you have questions beyond the documentation, feel free to
|
|
210
|
+
[email us](mailto:vsdudakov@gmail.com).
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
FastAdmin is released under the
|
|
215
|
+
[MIT License](https://github.com/vsdudakov/fastadmin/blob/main/LICENSE.md).
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<h1 align="center">FastAdmin — Admin Dashboard for FastAPI, Flask and Django</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://github.com/vsdudakov/fastadmin/actions/workflows/ci.yml"><img src="https://github.com/vsdudakov/fastadmin/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
5
|
+
<a href="https://app.codecov.io/gh/vsdudakov/fastadmin"><img src="https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T" alt="codecov"></a>
|
|
6
|
+
<a href="https://pypi.org/project/fastadmin/"><img src="https://badgen.net/pypi/v/fastadmin" alt="PyPI"></a>
|
|
7
|
+
<a href="https://pypi.org/project/fastadmin/"><img src="https://img.shields.io/badge/python-3.12%2B-blue.svg" alt="Python 3.12+"></a>
|
|
8
|
+
<a href="https://github.com/vsdudakov/fastadmin/blob/main/LICENSE.md"><img src="https://img.shields.io/github/license/vsdudakov/fastadmin" alt="License: MIT"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
**FastAdmin** is an easy-to-use **admin dashboard (admin panel) for FastAPI, Flask and
|
|
12
|
+
Django**, inspired by Django Admin. It gives your Python web application a
|
|
13
|
+
production-ready **CRUD admin interface** in minutes — on top of **Tortoise ORM,
|
|
14
|
+
Django ORM, SQLAlchemy or Pony ORM** — with authentication, filters, search,
|
|
15
|
+
inline editing, file uploads, CSV/JSON export and dashboard charts out of the box.
|
|
16
|
+
|
|
17
|
+
FastAdmin is built with relationships in mind and admiration for Django Admin.
|
|
18
|
+
It aims to be minimal, functional and familiar: if you know Django Admin, you
|
|
19
|
+
already know FastAdmin.
|
|
20
|
+
|
|
21
|
+
📚 **Documentation: [https://vsdudakov.github.io/fastadmin/](https://vsdudakov.github.io/fastadmin/)**
|
|
22
|
+
|
|
23
|
+
## Demo
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Familiar Django-Admin-style API** — `list_display`, `list_filter`,
|
|
30
|
+
`search_fields`, `fieldsets`, `readonly_fields`, inlines, actions and more.
|
|
31
|
+
- **Any web framework** — mount as a FastAPI sub-app, a Flask blueprint or
|
|
32
|
+
Django urlpatterns.
|
|
33
|
+
- **Any ORM** — first-class admin classes for Tortoise ORM, Django ORM,
|
|
34
|
+
SQLAlchemy (async) and Pony ORM.
|
|
35
|
+
- **Authentication & permissions** — pluggable sign-in against your own user
|
|
36
|
+
model, per-action permission hooks, request/user context in every admin method.
|
|
37
|
+
- **Rich form widgets** — 20+ antd-based widgets (rich text, JSON, async
|
|
38
|
+
select, phone, slug, date/time pickers, switches, radio groups, …) via
|
|
39
|
+
`formfield_overrides`.
|
|
40
|
+
- **File & image uploads** — storage-agnostic `upload_file` hook and
|
|
41
|
+
presigned-URL support via `get_file_url` (local disk, S3, …).
|
|
42
|
+
- **Dashboard widgets** — declarative line/area/column/bar/pie charts and
|
|
43
|
+
action widgets with filters, powered by antd charts.
|
|
44
|
+
- **Bulk actions & export** — custom bulk actions, CSV/JSON export.
|
|
45
|
+
- **Quality** — fully typed and linted (ruff + ty), 100% backend test coverage,
|
|
46
|
+
modern React (Vite + antd) frontend bundled with the package — no Node.js
|
|
47
|
+
needed at install time.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Install with the extras matching your web framework and ORM (each combination
|
|
52
|
+
works standalone):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install fastadmin[fastapi,django] # FastAPI with Django ORM
|
|
56
|
+
pip install fastadmin[fastapi,tortoise-orm] # FastAPI with Tortoise ORM
|
|
57
|
+
pip install fastadmin[fastapi,pony] # FastAPI with Pony ORM
|
|
58
|
+
pip install fastadmin[fastapi,sqlalchemy] # FastAPI with SQLAlchemy (includes greenlet)
|
|
59
|
+
pip install fastadmin[django] # Django with Django ORM
|
|
60
|
+
pip install fastadmin[django,pony] # Django with Pony ORM
|
|
61
|
+
pip install fastadmin[flask,sqlalchemy] # Flask with SQLAlchemy (includes greenlet)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> **Note:** on zsh (the default macOS shell) quote the extras: `pip install 'fastadmin[fastapi,django]'`.
|
|
65
|
+
|
|
66
|
+
Configure the required settings:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
export ADMIN_USER_MODEL=User
|
|
70
|
+
export ADMIN_USER_MODEL_USERNAME_FIELD=username
|
|
71
|
+
export ADMIN_SECRET_KEY=secret_key
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import bcrypt
|
|
78
|
+
from fastapi import FastAPI
|
|
79
|
+
|
|
80
|
+
from fastadmin import TortoiseModelAdmin, register
|
|
81
|
+
from fastadmin import fastapi_app as admin_app
|
|
82
|
+
|
|
83
|
+
from models import User
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@register(User)
|
|
87
|
+
class UserAdmin(TortoiseModelAdmin):
|
|
88
|
+
exclude = ("hash_password",)
|
|
89
|
+
list_display = ("id", "username", "is_superuser", "is_active")
|
|
90
|
+
list_display_links = ("id", "username")
|
|
91
|
+
list_filter = ("id", "username", "is_superuser", "is_active")
|
|
92
|
+
search_fields = ("username",)
|
|
93
|
+
|
|
94
|
+
async def authenticate(self, username: str, password: str) -> int | None:
|
|
95
|
+
user = await User.filter(username=username, is_superuser=True).first()
|
|
96
|
+
if not user:
|
|
97
|
+
return None
|
|
98
|
+
if not bcrypt.checkpw(password.encode(), user.hash_password.encode()):
|
|
99
|
+
return None
|
|
100
|
+
return user.id
|
|
101
|
+
|
|
102
|
+
async def change_password(self, id: int, password: str) -> None:
|
|
103
|
+
user = await User.filter(id=id).first()
|
|
104
|
+
if not user:
|
|
105
|
+
return
|
|
106
|
+
user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
107
|
+
await user.save(update_fields=("hash_password",))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
app = FastAPI()
|
|
111
|
+
app.mount("/admin", admin_app)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Run `uvicorn example:app` and open `http://localhost:8000/admin`.
|
|
115
|
+
|
|
116
|
+
Mounting for the other frameworks:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
# Flask
|
|
120
|
+
from fastadmin import flask_app as admin_app
|
|
121
|
+
app.register_blueprint(admin_app, url_prefix="/admin")
|
|
122
|
+
|
|
123
|
+
# Django (urls.py)
|
|
124
|
+
from fastadmin import get_django_admin_urls as get_admin_urls
|
|
125
|
+
urlpatterns = [path("admin/", get_admin_urls())]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Documentation
|
|
129
|
+
|
|
130
|
+
Full documentation lives at
|
|
131
|
+
**[vsdudakov.github.io/fastadmin](https://vsdudakov.github.io/fastadmin/)**:
|
|
132
|
+
|
|
133
|
+
- [Installation](https://vsdudakov.github.io/fastadmin/getting-started/installation/)
|
|
134
|
+
and [Quick start](https://vsdudakov.github.io/fastadmin/getting-started/quickstart/)
|
|
135
|
+
- [Settings](https://vsdudakov.github.io/fastadmin/guides/settings/) — all environment variables
|
|
136
|
+
- [Registering models](https://vsdudakov.github.io/fastadmin/guides/registering-models/) — complete runnable examples for all four ORMs
|
|
137
|
+
- [Authentication](https://vsdudakov.github.io/fastadmin/guides/authentication/) and permissions
|
|
138
|
+
- [Model admins](https://vsdudakov.github.io/fastadmin/guides/model-admins/) — attributes, actions, display fields, exports
|
|
139
|
+
- [Form widgets & file uploads](https://vsdudakov.github.io/fastadmin/guides/form-widgets/)
|
|
140
|
+
- [Inline admins](https://vsdudakov.github.io/fastadmin/guides/inline-admins/)
|
|
141
|
+
- [Dashboard widgets](https://vsdudakov.github.io/fastadmin/guides/dashboard-widgets/) — charts and actions
|
|
142
|
+
- [API reference](https://vsdudakov.github.io/fastadmin/api-reference/)
|
|
143
|
+
|
|
144
|
+
Runnable example apps for every framework/ORM combination are in
|
|
145
|
+
[`examples/`](https://github.com/vsdudakov/fastadmin/tree/main/examples).
|
|
146
|
+
|
|
147
|
+
## Why FastAdmin?
|
|
148
|
+
|
|
149
|
+
If you are looking for a **Django-Admin-like admin panel for FastAPI**, an
|
|
150
|
+
**admin interface for SQLAlchemy or Tortoise ORM**, or a lightweight
|
|
151
|
+
**alternative to building a custom back office**, FastAdmin gives you a
|
|
152
|
+
batteries-included, themeable admin UI without code generation, without tying
|
|
153
|
+
your app to a specific framework, and without writing a single React component.
|
|
154
|
+
|
|
155
|
+
## Contributing
|
|
156
|
+
|
|
157
|
+
Contributions are welcome — see the
|
|
158
|
+
[contributing guide](https://vsdudakov.github.io/fastadmin/contributing/).
|
|
159
|
+
Development uses `uv` and `make`: `make dev`, `make lint`, `make test`.
|
|
160
|
+
See [CHANGELOG.md](https://github.com/vsdudakov/fastadmin/blob/main/CHANGELOG.md)
|
|
161
|
+
for release history.
|
|
162
|
+
|
|
163
|
+
If you have questions beyond the documentation, feel free to
|
|
164
|
+
[email us](mailto:vsdudakov@gmail.com).
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
FastAdmin is released under the
|
|
169
|
+
[MIT License](https://github.com/vsdudakov/fastadmin/blob/main/LICENSE.md).
|
|
@@ -301,12 +301,14 @@ async def upload_file(
|
|
|
301
301
|
return JsonResponse({"error": "File not found"}, status=400)
|
|
302
302
|
file_name = file.name
|
|
303
303
|
file_content = file.read()
|
|
304
|
+
obj_id = request.GET.get("id") or None
|
|
304
305
|
file_url = await api_service.upload_file(
|
|
305
306
|
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
306
307
|
model,
|
|
307
308
|
field_name,
|
|
308
309
|
file_name,
|
|
309
310
|
file_content,
|
|
311
|
+
id=obj_id,
|
|
310
312
|
request=request,
|
|
311
313
|
)
|
|
312
314
|
return JsonResponse(file_url, safe=False)
|
|
@@ -315,7 +317,7 @@ async def upload_file(
|
|
|
315
317
|
|
|
316
318
|
|
|
317
319
|
@csrf_exempt
|
|
318
|
-
async def export(request: HttpRequest, model: str) -> JsonResponse:
|
|
320
|
+
async def export(request: HttpRequest, model: str) -> JsonResponse | StreamingHttpResponse:
|
|
319
321
|
"""This method is used to export a list of objects.
|
|
320
322
|
|
|
321
323
|
:params request: a request object.
|
|
@@ -244,6 +244,7 @@ async def upload_file(
|
|
|
244
244
|
model: str,
|
|
245
245
|
field_name: str,
|
|
246
246
|
file: UploadFile,
|
|
247
|
+
id: str | None = None,
|
|
247
248
|
) -> str:
|
|
248
249
|
"""This method is used to upload files.
|
|
249
250
|
|
|
@@ -251,6 +252,7 @@ async def upload_file(
|
|
|
251
252
|
:params model: a name of model.
|
|
252
253
|
:params field_name: a name of field.
|
|
253
254
|
:params file: a file object.
|
|
255
|
+
:params id: an optional id of an existing object (change page). None on the add page.
|
|
254
256
|
:return: A file url.
|
|
255
257
|
"""
|
|
256
258
|
try:
|
|
@@ -264,6 +266,7 @@ async def upload_file(
|
|
|
264
266
|
field_name,
|
|
265
267
|
file_name,
|
|
266
268
|
file_content,
|
|
269
|
+
id=id,
|
|
267
270
|
request=request,
|
|
268
271
|
)
|
|
269
272
|
except AdminApiException as e:
|
|
@@ -304,7 +307,7 @@ async def export(
|
|
|
304
307
|
)
|
|
305
308
|
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
|
|
306
309
|
return StreamingResponse(
|
|
307
|
-
stream, #
|
|
310
|
+
stream, # ty: ignore[invalid-argument-type]
|
|
308
311
|
headers=headers,
|
|
309
312
|
media_type=content_type,
|
|
310
313
|
)
|
|
@@ -188,7 +188,7 @@ async def add(model: str) -> dict:
|
|
|
188
188
|
raise http_exception from e
|
|
189
189
|
|
|
190
190
|
|
|
191
|
-
@api_router.route("/change-password/<string:id>", methods=["PATCH"]) #
|
|
191
|
+
@api_router.route("/change-password/<string:id>", methods=["PATCH"]) # ty: ignore[invalid-argument-type]
|
|
192
192
|
async def change_password(id: UUID | int | str) -> UUID | int | str:
|
|
193
193
|
"""This method is used to change password.
|
|
194
194
|
|
|
@@ -267,12 +267,14 @@ async def upload_file(
|
|
|
267
267
|
http_exception.code = 422
|
|
268
268
|
raise http_exception from None
|
|
269
269
|
file_content = file.read()
|
|
270
|
+
obj_id = request.args.get("id") or None
|
|
270
271
|
return await api_service.upload_file(
|
|
271
272
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
272
273
|
model,
|
|
273
274
|
field_name,
|
|
274
275
|
file_name,
|
|
275
276
|
file_content,
|
|
277
|
+
id=obj_id,
|
|
276
278
|
request=request,
|
|
277
279
|
)
|
|
278
280
|
except AdminApiException as e:
|
|
@@ -320,7 +322,7 @@ async def export(model: str) -> Response:
|
|
|
320
322
|
raise http_exception from e
|
|
321
323
|
|
|
322
324
|
|
|
323
|
-
@api_router.route("/delete/<string:model>/<string:id>", methods=["DELETE"]) #
|
|
325
|
+
@api_router.route("/delete/<string:model>/<string:id>", methods=["DELETE"]) # ty: ignore[invalid-argument-type]
|
|
324
326
|
async def delete(
|
|
325
327
|
model: str,
|
|
326
328
|
id: UUID | int | str,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
import logging
|
|
3
3
|
from collections.abc import Sequence
|
|
4
|
-
from datetime import datetime, timedelta
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
5
|
from io import BytesIO, StringIO
|
|
6
6
|
from typing import Any, cast
|
|
7
7
|
from uuid import UUID
|
|
@@ -68,7 +68,7 @@ async def get_user_id_from_session_id(session_id: str | None) -> UUID | int | No
|
|
|
68
68
|
if not session_expired_at:
|
|
69
69
|
return None
|
|
70
70
|
|
|
71
|
-
if datetime.fromisoformat(session_expired_at).replace(tzinfo=
|
|
71
|
+
if datetime.fromisoformat(session_expired_at).replace(tzinfo=UTC) < datetime.now(UTC):
|
|
72
72
|
return None
|
|
73
73
|
|
|
74
74
|
user_id = token_payload.get("user_id")
|
|
@@ -125,7 +125,7 @@ class ApiService:
|
|
|
125
125
|
if not user_id or not isinstance(user_id, int | UUID):
|
|
126
126
|
raise AdminApiException(401, detail="Invalid credentials.")
|
|
127
127
|
|
|
128
|
-
now = datetime.now(
|
|
128
|
+
now = datetime.now(UTC)
|
|
129
129
|
session_expired_at = now + timedelta(seconds=settings.ADMIN_SESSION_EXPIRED_AT)
|
|
130
130
|
if isinstance(user_id, UUID):
|
|
131
131
|
user_id = str(user_id)
|
|
@@ -254,7 +254,7 @@ class ApiService:
|
|
|
254
254
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
255
255
|
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
256
256
|
try:
|
|
257
|
-
return await admin_model.save_model(None, payload) #
|
|
257
|
+
return await admin_model.save_model(None, payload) # ty: ignore[invalid-return-type]
|
|
258
258
|
except Exception as e:
|
|
259
259
|
logger.error("Error adding %s: %s", model, e)
|
|
260
260
|
raise AdminApiException(500, detail=f"Error adding {model}: {e}") from e
|
|
@@ -329,6 +329,7 @@ class ApiService:
|
|
|
329
329
|
field_name: str,
|
|
330
330
|
file_name: str,
|
|
331
331
|
file_content: bytes,
|
|
332
|
+
id: UUID | int | str | None = None,
|
|
332
333
|
request: Any | None = None,
|
|
333
334
|
) -> str:
|
|
334
335
|
_current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
@@ -339,16 +340,23 @@ class ApiService:
|
|
|
339
340
|
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
340
341
|
|
|
341
342
|
try:
|
|
343
|
+
obj = await admin_model.orm_get_obj(id) if id is not None else None
|
|
344
|
+
if id is not None and obj is None:
|
|
345
|
+
raise AdminApiException(404, detail=f"{model} with id {id} not found.")
|
|
346
|
+
|
|
342
347
|
if inspect.iscoroutinefunction(admin_model.upload_file):
|
|
343
348
|
upload_file_fn = admin_model.upload_file
|
|
344
349
|
else:
|
|
345
350
|
upload_file_fn = sync_to_async(admin_model.upload_file) # type: ignore [arg-type]
|
|
346
351
|
|
|
347
|
-
return await upload_file_fn(
|
|
352
|
+
return await upload_file_fn( # ty: ignore[invalid-return-type]
|
|
348
353
|
field_name,
|
|
349
354
|
file_name,
|
|
350
355
|
file_content,
|
|
356
|
+
obj=obj,
|
|
351
357
|
)
|
|
358
|
+
except AdminApiException:
|
|
359
|
+
raise
|
|
352
360
|
except Exception as e:
|
|
353
361
|
raise AdminApiException(500, detail=f"Error uploading file for {model}: {e}") from e
|
|
354
362
|
|
|
@@ -14,6 +14,8 @@ from asgiref.sync import sync_to_async
|
|
|
14
14
|
from fastadmin.api.schemas import ExportFormat
|
|
15
15
|
from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType
|
|
16
16
|
|
|
17
|
+
Model = Any
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
class BaseModelAdmin:
|
|
19
21
|
"""Base class for model admin"""
|
|
@@ -373,7 +375,7 @@ class BaseModelAdmin:
|
|
|
373
375
|
return sort_by
|
|
374
376
|
|
|
375
377
|
async def serialize_obj_attributes(
|
|
376
|
-
self, obj: Any, attributes_to_serizalize: list[ModelFieldWidgetSchema]
|
|
378
|
+
self, obj: Any, attributes_to_serizalize: list[ModelFieldWidgetSchema], list_view: bool = False
|
|
377
379
|
) -> dict[str, Any]:
|
|
378
380
|
"""Serialize orm model obj attribute to dict.
|
|
379
381
|
|
|
@@ -388,7 +390,10 @@ class BaseModelAdmin:
|
|
|
388
390
|
# Avoid scientific notation for Decimal values in API responses,
|
|
389
391
|
# e.g. 3.75E+3 -> "3750"
|
|
390
392
|
value = format(value, "f")
|
|
393
|
+
|
|
391
394
|
serialized_dict[field.name] = value
|
|
395
|
+
if not list_view and field.form_widget_type in (WidgetType.UploadFile, WidgetType.UploadImage) and value:
|
|
396
|
+
serialized_dict[f"{field.name}__url"] = await self.get_file_url(field.name, value, obj)
|
|
392
397
|
if inspect.iscoroutinefunction(obj.__str__):
|
|
393
398
|
str_fn = obj.__str__
|
|
394
399
|
else:
|
|
@@ -435,7 +440,7 @@ class BaseModelAdmin:
|
|
|
435
440
|
else:
|
|
436
441
|
attributes_to_serizalize.append(field)
|
|
437
442
|
|
|
438
|
-
obj_dict.update(await self.serialize_obj_attributes(obj, attributes_to_serizalize))
|
|
443
|
+
obj_dict.update(await self.serialize_obj_attributes(obj, attributes_to_serizalize, list_view=list_view))
|
|
439
444
|
|
|
440
445
|
for field_name in fields_for_serialize:
|
|
441
446
|
display_field_function = getattr(self, field_name, None)
|
|
@@ -464,6 +469,7 @@ class BaseModelAdmin:
|
|
|
464
469
|
return datetime.datetime.fromisoformat(value).date()
|
|
465
470
|
case WidgetType.DateTimePicker:
|
|
466
471
|
return datetime.datetime.fromisoformat(value)
|
|
472
|
+
|
|
467
473
|
case _:
|
|
468
474
|
return value
|
|
469
475
|
|
|
@@ -541,17 +547,51 @@ class BaseModelAdmin:
|
|
|
541
547
|
"""
|
|
542
548
|
await self.orm_delete_obj(id)
|
|
543
549
|
|
|
550
|
+
async def get_file_url(self, field_name: str, value: str, obj: Any | None = None) -> str:
|
|
551
|
+
"""Return the display URL for an uploaded file field.
|
|
552
|
+
|
|
553
|
+
Called during serialization for every ``UploadFile`` and ``UploadImage``
|
|
554
|
+
field that has a non-empty stored value. The result is emitted alongside
|
|
555
|
+
the stored value as ``{field_name}__url`` and forwarded to the upload
|
|
556
|
+
widget as ``valueRepr`` so the clickable link can differ from the raw
|
|
557
|
+
stored key (e.g. an S3 presigned URL instead of an ``s3://`` URI).
|
|
558
|
+
|
|
559
|
+
The stored ``value`` is never modified — it is still what the form
|
|
560
|
+
submits on save — so there is no risk of data corruption.
|
|
561
|
+
|
|
562
|
+
Override to return a dynamic URL. Example using aiobotocore:
|
|
563
|
+
|
|
564
|
+
.. code-block:: python
|
|
565
|
+
|
|
566
|
+
async def get_file_url(self, field_name: str, value: str, obj=None) -> str:
|
|
567
|
+
bucket, key = value.replace("s3://", "").split("/", 1)
|
|
568
|
+
async with aiobotocore.session.get_session().create_client("s3") as s3:
|
|
569
|
+
return await s3.generate_presigned_url(
|
|
570
|
+
"get_object",
|
|
571
|
+
Params={"Bucket": bucket, "Key": key},
|
|
572
|
+
ExpiresIn=3600,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
:params field_name: name of the upload field being serialized.
|
|
576
|
+
:params value: raw stored value (e.g. ``"s3://bucket/key"`` or ``"/media/file.pdf"``).
|
|
577
|
+
:params obj: the orm/db model instance being serialized, or None.
|
|
578
|
+
:return: URL string to expose to the frontend for display/download.
|
|
579
|
+
"""
|
|
580
|
+
return value
|
|
581
|
+
|
|
544
582
|
async def upload_file(
|
|
545
583
|
self,
|
|
546
584
|
field_name: str,
|
|
547
585
|
file_name: str,
|
|
548
586
|
file_content: bytes,
|
|
587
|
+
obj: Any | None = None,
|
|
549
588
|
) -> str:
|
|
550
589
|
"""This method is used to upload files.
|
|
551
590
|
|
|
552
591
|
:params field_name: a name of field.
|
|
553
592
|
:params file_name: a name of file.
|
|
554
593
|
:params file_content: a content of file.
|
|
594
|
+
:params obj: an orm/db model object. None on the add (create) page; the existing model instance on the change (edit) page.
|
|
555
595
|
:return: A file url.
|
|
556
596
|
"""
|
|
557
597
|
raise NotImplementedError
|
|
@@ -597,11 +637,11 @@ class BaseModelAdmin:
|
|
|
597
637
|
case ExportFormat.JSON:
|
|
598
638
|
|
|
599
639
|
class JSONEncoder(json.JSONEncoder):
|
|
600
|
-
def default(self,
|
|
640
|
+
def default(self, o):
|
|
601
641
|
try:
|
|
602
|
-
return super().default(
|
|
642
|
+
return super().default(o)
|
|
603
643
|
except TypeError:
|
|
604
|
-
return str(
|
|
644
|
+
return str(o)
|
|
605
645
|
|
|
606
646
|
output = StringIO()
|
|
607
647
|
json.dump([await self.serialize_obj(obj, list_view=True) for obj in objs], output, cls=JSONEncoder)
|
|
@@ -200,7 +200,7 @@ def register(*orm_model_classes, **kwargs):
|
|
|
200
200
|
if not issubclass(model_admin_cls, ModelAdmin):
|
|
201
201
|
raise ValueError("Wrapped class must subclass ModelAdmin.")
|
|
202
202
|
|
|
203
|
-
register_admin_model_class(model_admin_cls, orm_model_classes, **kwargs)
|
|
203
|
+
register_admin_model_class(model_admin_cls, list(orm_model_classes), **kwargs)
|
|
204
204
|
|
|
205
205
|
return model_admin_cls
|
|
206
206
|
|
|
@@ -284,7 +284,7 @@ async def generate_models_schema(
|
|
|
284
284
|
save_as=admin_model_obj.save_as,
|
|
285
285
|
save_as_continue=admin_model_obj.save_as_continue,
|
|
286
286
|
view_on_site=admin_model_obj.view_on_site,
|
|
287
|
-
inlines=await generate_models_schema(
|
|
287
|
+
inlines=await generate_models_schema( # ty: ignore[invalid-argument-type]
|
|
288
288
|
{inline.model: inline(inline.model) for inline in admin_model_obj.inlines},
|
|
289
289
|
user=user,
|
|
290
290
|
request=request,
|