fastapi-lite-admin 0.1.6__tar.gz → 0.1.8__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_lite_admin-0.1.6/fastapi_lite_admin.egg-info → fastapi_lite_admin-0.1.8}/PKG-INFO +66 -1
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/README.md +65 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/main.py +38 -1
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/routers/admin.py +46 -1
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/model_detail.html +33 -2
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/model_form.html +301 -1
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/model_list.html +13 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8/fastapi_lite_admin.egg-info}/PKG-INFO +66 -1
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/pyproject.toml +1 -1
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/LICENSE +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/__init__.py +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/core/config.py +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/core/crud.py +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/core/registry.py +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/core/schema.py +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/dependencies/db.py +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/integrations/sqlalchemy.py +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/dashboard.html +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/layout.html +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/views.py +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/SOURCES.txt +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/dependency_links.txt +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/requires.txt +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/top_level.txt +0 -0
- {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-lite-admin
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: A lightweight, pluggable admin panel for FastAPI
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/rishiqwerty/admin-panel-fast-api
|
|
@@ -122,6 +122,7 @@ When calling `admin.register()`, you can configure how each model is represented
|
|
|
122
122
|
| **`date_field`** | `str` | No | Name of the datetime field (e.g. `created_at`). Required to show the "24h Activity" count cards on the dashboard and lists. |
|
|
123
123
|
| **`attention_filter`** | `SQLAlchemy Expression` | No | A SQLAlchemy binary filter expression (e.g., `User.is_active == False` or `Product.stock < 10`) used to calculate and flag rows that require moderator attention. |
|
|
124
124
|
| **`readonly_fields`** | `List[str]` | No | List of columns that cannot be modified or set via creation or updates (e.g., auto-generated columns or timestamps like `id`, `created_at`). |
|
|
125
|
+
| **`file_fields`** | `List[str]` | No | List of column names that should be treated as file upload fields, rendering a drag-and-drop zone. |
|
|
125
126
|
| **`config`** | `Dict[str, Any]` | No | Dictionary containing extra settings. Supports `"display_name"` to override the sidebar label. |
|
|
126
127
|
|
|
127
128
|
---
|
|
@@ -141,6 +142,10 @@ The `Admin` class constructor supports the following parameters for customizatio
|
|
|
141
142
|
| **`dashboard_models`** | `List[str]` | `None` | List of registered model names to display on the dashboard (if you want to restrict which registered models show on the home dashboard). |
|
|
142
143
|
| **`get_logs`** | `Callable` | `None` | An optional callable (async or sync) returning system logs to display on the dashboard activity log feed. |
|
|
143
144
|
| **`logs_config`** | `Dict[str, Any]` | `{"title": "System Activity", "columns": ["level", "timestamp", "message"]}` | Config dictionary to customize dashboard log columns and activity title. |
|
|
145
|
+
| **`upload_dir`** | `str` | `"uploads"` | Local directory path where uploaded files will be stored. |
|
|
146
|
+
| **`upload_url`** | `str` | `"/uploads"` | URL prefix used to serve uploaded files statically. |
|
|
147
|
+
| **`upload_handler`** | `Callable` | `None` | Optional custom upload handler callback for buckets (S3, GCS, Azure). |
|
|
148
|
+
| **`url_resolver`** | `Callable` | `None` | Optional custom URL resolver callback to resolve database keys to presigned URLs (S3, GCS). |
|
|
144
149
|
|
|
145
150
|
---
|
|
146
151
|
|
|
@@ -204,6 +209,66 @@ admin = Admin(
|
|
|
204
209
|
)
|
|
205
210
|
```
|
|
206
211
|
|
|
212
|
+
## File Uploads & Cloud Storage
|
|
213
|
+
|
|
214
|
+
`FastAPI Lite Admin` supports rendering drag-and-drop file uploads for designated string fields (e.g., image paths or document URLs).
|
|
215
|
+
|
|
216
|
+
### 1. Default Local Storage
|
|
217
|
+
By default, uploaded files are stored locally in the `uploads/` directory and served statically:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
admin = Admin(
|
|
221
|
+
title="My Admin",
|
|
222
|
+
upload_dir="my_uploads", # Stored in project-root/my_uploads
|
|
223
|
+
upload_url="/static/files" # Served statically at http://localhost:8000/static/files
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### 2. Cloud Storage / Buckets (S3, GCS, Azure, etc.)
|
|
228
|
+
If you are deploying to production and storing files in a cloud bucket, you can plug in a custom `upload_handler`:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from fastapi import UploadFile
|
|
232
|
+
|
|
233
|
+
async def my_s3_upload_handler(file: UploadFile) -> str:
|
|
234
|
+
# 1. Upload file.file to S3, GCS, Cloudinary, etc.
|
|
235
|
+
# 2. Return the public URL to be stored in the database
|
|
236
|
+
return f"https://my-bucket.s3.amazonaws.com/{file.filename}"
|
|
237
|
+
|
|
238
|
+
admin = Admin(
|
|
239
|
+
title="Cloud Admin",
|
|
240
|
+
upload_handler=my_s3_upload_handler
|
|
241
|
+
)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 3. Storage URL Resolution (Presigned URLs)
|
|
245
|
+
If the database stores relative paths/keys (e.g. `generated/uuid.jpg` in GCS or S3) rather than full absolute URLs, browser requests will fail. You can provide a custom `url_resolver` callback:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
async def get_s3_presigned_url(path: str) -> str:
|
|
249
|
+
# 1. Generate temporary presigned GET URL for GCS/S3 key
|
|
250
|
+
# 2. Return URL
|
|
251
|
+
return s3_client.generate_presigned_url('get_object', Params={'Bucket': 'my-bucket', 'Key': path})
|
|
252
|
+
|
|
253
|
+
admin = Admin(
|
|
254
|
+
title="Cloud Admin",
|
|
255
|
+
url_resolver=get_s3_presigned_url
|
|
256
|
+
)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The admin panel routes all file rendering and download links through the `/admin/api/media?path=...` redirect proxy, which executes `url_resolver` to redirect the browser to the temporary accessible URL safely, keeping your database values clean.
|
|
260
|
+
|
|
261
|
+
### 4. Enabling File Upload in Models
|
|
262
|
+
Pass the `file_fields` parameter when registering your model:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
admin.register(
|
|
266
|
+
model=Product,
|
|
267
|
+
get_db=get_db,
|
|
268
|
+
file_fields=["image_url"] # These will render as drag-and-drop zones
|
|
269
|
+
)
|
|
270
|
+
```
|
|
271
|
+
|
|
207
272
|
---
|
|
208
273
|
|
|
209
274
|
## Running the Example Application
|
|
@@ -101,6 +101,7 @@ When calling `admin.register()`, you can configure how each model is represented
|
|
|
101
101
|
| **`date_field`** | `str` | No | Name of the datetime field (e.g. `created_at`). Required to show the "24h Activity" count cards on the dashboard and lists. |
|
|
102
102
|
| **`attention_filter`** | `SQLAlchemy Expression` | No | A SQLAlchemy binary filter expression (e.g., `User.is_active == False` or `Product.stock < 10`) used to calculate and flag rows that require moderator attention. |
|
|
103
103
|
| **`readonly_fields`** | `List[str]` | No | List of columns that cannot be modified or set via creation or updates (e.g., auto-generated columns or timestamps like `id`, `created_at`). |
|
|
104
|
+
| **`file_fields`** | `List[str]` | No | List of column names that should be treated as file upload fields, rendering a drag-and-drop zone. |
|
|
104
105
|
| **`config`** | `Dict[str, Any]` | No | Dictionary containing extra settings. Supports `"display_name"` to override the sidebar label. |
|
|
105
106
|
|
|
106
107
|
---
|
|
@@ -120,6 +121,10 @@ The `Admin` class constructor supports the following parameters for customizatio
|
|
|
120
121
|
| **`dashboard_models`** | `List[str]` | `None` | List of registered model names to display on the dashboard (if you want to restrict which registered models show on the home dashboard). |
|
|
121
122
|
| **`get_logs`** | `Callable` | `None` | An optional callable (async or sync) returning system logs to display on the dashboard activity log feed. |
|
|
122
123
|
| **`logs_config`** | `Dict[str, Any]` | `{"title": "System Activity", "columns": ["level", "timestamp", "message"]}` | Config dictionary to customize dashboard log columns and activity title. |
|
|
124
|
+
| **`upload_dir`** | `str` | `"uploads"` | Local directory path where uploaded files will be stored. |
|
|
125
|
+
| **`upload_url`** | `str` | `"/uploads"` | URL prefix used to serve uploaded files statically. |
|
|
126
|
+
| **`upload_handler`** | `Callable` | `None` | Optional custom upload handler callback for buckets (S3, GCS, Azure). |
|
|
127
|
+
| **`url_resolver`** | `Callable` | `None` | Optional custom URL resolver callback to resolve database keys to presigned URLs (S3, GCS). |
|
|
123
128
|
|
|
124
129
|
---
|
|
125
130
|
|
|
@@ -183,6 +188,66 @@ admin = Admin(
|
|
|
183
188
|
)
|
|
184
189
|
```
|
|
185
190
|
|
|
191
|
+
## File Uploads & Cloud Storage
|
|
192
|
+
|
|
193
|
+
`FastAPI Lite Admin` supports rendering drag-and-drop file uploads for designated string fields (e.g., image paths or document URLs).
|
|
194
|
+
|
|
195
|
+
### 1. Default Local Storage
|
|
196
|
+
By default, uploaded files are stored locally in the `uploads/` directory and served statically:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
admin = Admin(
|
|
200
|
+
title="My Admin",
|
|
201
|
+
upload_dir="my_uploads", # Stored in project-root/my_uploads
|
|
202
|
+
upload_url="/static/files" # Served statically at http://localhost:8000/static/files
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### 2. Cloud Storage / Buckets (S3, GCS, Azure, etc.)
|
|
207
|
+
If you are deploying to production and storing files in a cloud bucket, you can plug in a custom `upload_handler`:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from fastapi import UploadFile
|
|
211
|
+
|
|
212
|
+
async def my_s3_upload_handler(file: UploadFile) -> str:
|
|
213
|
+
# 1. Upload file.file to S3, GCS, Cloudinary, etc.
|
|
214
|
+
# 2. Return the public URL to be stored in the database
|
|
215
|
+
return f"https://my-bucket.s3.amazonaws.com/{file.filename}"
|
|
216
|
+
|
|
217
|
+
admin = Admin(
|
|
218
|
+
title="Cloud Admin",
|
|
219
|
+
upload_handler=my_s3_upload_handler
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### 3. Storage URL Resolution (Presigned URLs)
|
|
224
|
+
If the database stores relative paths/keys (e.g. `generated/uuid.jpg` in GCS or S3) rather than full absolute URLs, browser requests will fail. You can provide a custom `url_resolver` callback:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
async def get_s3_presigned_url(path: str) -> str:
|
|
228
|
+
# 1. Generate temporary presigned GET URL for GCS/S3 key
|
|
229
|
+
# 2. Return URL
|
|
230
|
+
return s3_client.generate_presigned_url('get_object', Params={'Bucket': 'my-bucket', 'Key': path})
|
|
231
|
+
|
|
232
|
+
admin = Admin(
|
|
233
|
+
title="Cloud Admin",
|
|
234
|
+
url_resolver=get_s3_presigned_url
|
|
235
|
+
)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The admin panel routes all file rendering and download links through the `/admin/api/media?path=...` redirect proxy, which executes `url_resolver` to redirect the browser to the temporary accessible URL safely, keeping your database values clean.
|
|
239
|
+
|
|
240
|
+
### 4. Enabling File Upload in Models
|
|
241
|
+
Pass the `file_fields` parameter when registering your model:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
admin.register(
|
|
245
|
+
model=Product,
|
|
246
|
+
get_db=get_db,
|
|
247
|
+
file_fields=["image_url"] # These will render as drag-and-drop zones
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
186
251
|
---
|
|
187
252
|
|
|
188
253
|
## Running the Example Application
|
|
@@ -16,7 +16,11 @@ class Admin:
|
|
|
16
16
|
dashboard_template: Optional[str] = None,
|
|
17
17
|
dashboard_models: Optional[List[str]] = None,
|
|
18
18
|
get_logs: Optional[Callable] = None,
|
|
19
|
-
logs_config: Optional[Dict[str, Any]] = None
|
|
19
|
+
logs_config: Optional[Dict[str, Any]] = None,
|
|
20
|
+
upload_dir: str = "uploads",
|
|
21
|
+
upload_url: str = "/uploads",
|
|
22
|
+
upload_handler: Optional[Callable[[Any], Any]] = None,
|
|
23
|
+
url_resolver: Optional[Callable[[str], Any]] = None
|
|
20
24
|
):
|
|
21
25
|
self.title = title
|
|
22
26
|
self.base_url = base_url
|
|
@@ -30,10 +34,16 @@ class Admin:
|
|
|
30
34
|
"columns": ["level", "timestamp", "message"],
|
|
31
35
|
"title": "System Activity"
|
|
32
36
|
}
|
|
37
|
+
self.upload_dir = upload_dir
|
|
38
|
+
self.upload_url = upload_url
|
|
39
|
+
self.upload_handler = upload_handler
|
|
33
40
|
|
|
34
41
|
# Auth & Permissions
|
|
35
42
|
self.auth_dependency = auth_dependency
|
|
36
43
|
self.permission_checker = permission_checker or self.default_permission
|
|
44
|
+
|
|
45
|
+
# Media resolver
|
|
46
|
+
self.url_resolver = url_resolver or self.default_url_resolver
|
|
37
47
|
|
|
38
48
|
# Print warnings if not configured
|
|
39
49
|
if not auth_dependency:
|
|
@@ -45,6 +55,21 @@ class Admin:
|
|
|
45
55
|
"""Default permission checker that allows everything."""
|
|
46
56
|
return True
|
|
47
57
|
|
|
58
|
+
def default_url_resolver(self, path: str) -> str:
|
|
59
|
+
"""Default URL resolver that resolves relative paths to the local static uploads URL."""
|
|
60
|
+
if not path:
|
|
61
|
+
return ""
|
|
62
|
+
if path.startswith("http://") or path.startswith("https://") or path.startswith("data:"):
|
|
63
|
+
return path
|
|
64
|
+
|
|
65
|
+
clean_path = path.lstrip("/")
|
|
66
|
+
clean_prefix = self.upload_url.strip("/")
|
|
67
|
+
|
|
68
|
+
if clean_path.startswith(clean_prefix):
|
|
69
|
+
return f"/{clean_path}"
|
|
70
|
+
|
|
71
|
+
return f"{self.upload_url}/{clean_path}"
|
|
72
|
+
|
|
48
73
|
def register(
|
|
49
74
|
self,
|
|
50
75
|
model: Type[Any],
|
|
@@ -53,6 +78,7 @@ class Admin:
|
|
|
53
78
|
date_field: Optional[str] = None,
|
|
54
79
|
attention_filter: Optional[Any] = None,
|
|
55
80
|
readonly_fields: Optional[List[str]] = None,
|
|
81
|
+
file_fields: Optional[List[str]] = None,
|
|
56
82
|
config: Optional[Dict[str, Any]] = None
|
|
57
83
|
):
|
|
58
84
|
"""
|
|
@@ -73,6 +99,9 @@ class Admin:
|
|
|
73
99
|
|
|
74
100
|
if readonly_fields:
|
|
75
101
|
config["readonly_fields"] = readonly_fields
|
|
102
|
+
|
|
103
|
+
if file_fields:
|
|
104
|
+
config["file_fields"] = file_fields
|
|
76
105
|
|
|
77
106
|
self.registry.register(model, get_db, config)
|
|
78
107
|
|
|
@@ -80,6 +109,14 @@ class Admin:
|
|
|
80
109
|
"""
|
|
81
110
|
Mount the admin router to the FastAPI application.
|
|
82
111
|
"""
|
|
112
|
+
# Mount upload directory for static access if using default storage
|
|
113
|
+
if not self.upload_handler:
|
|
114
|
+
import os
|
|
115
|
+
from fastapi.staticfiles import StaticFiles
|
|
116
|
+
if not os.path.exists(self.upload_dir):
|
|
117
|
+
os.makedirs(self.upload_dir)
|
|
118
|
+
app.mount(self.upload_url, StaticFiles(directory=self.upload_dir), name="admin_uploads")
|
|
119
|
+
|
|
83
120
|
# Collect all dependencies
|
|
84
121
|
all_deps = list(self.dependencies)
|
|
85
122
|
if self.auth_dependency:
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from typing import Any, Dict, List
|
|
2
|
-
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
2
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
|
3
|
+
from fastapi.responses import RedirectResponse
|
|
4
|
+
import shutil
|
|
5
|
+
import uuid
|
|
6
|
+
import os
|
|
7
|
+
import asyncio
|
|
3
8
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
9
|
from ..core.schema import generate_admin_schema
|
|
5
10
|
from ..core.crud import CRUDEngine
|
|
@@ -11,6 +16,46 @@ def create_admin_router(admin: Any) -> APIRouter:
|
|
|
11
16
|
async def get_schema():
|
|
12
17
|
return generate_admin_schema(admin.registry)
|
|
13
18
|
|
|
19
|
+
@router.post("/upload", name="admin_upload_file")
|
|
20
|
+
async def upload_file(file: UploadFile = File(...)):
|
|
21
|
+
if admin.upload_handler:
|
|
22
|
+
try:
|
|
23
|
+
if asyncio.iscoroutinefunction(admin.upload_handler):
|
|
24
|
+
url = await admin.upload_handler(file)
|
|
25
|
+
else:
|
|
26
|
+
url = admin.upload_handler(file)
|
|
27
|
+
return {"url": url}
|
|
28
|
+
except Exception as e:
|
|
29
|
+
raise HTTPException(status_code=500, detail=f"Custom upload failed: {str(e)}")
|
|
30
|
+
else:
|
|
31
|
+
if not os.path.exists(admin.upload_dir):
|
|
32
|
+
os.makedirs(admin.upload_dir)
|
|
33
|
+
|
|
34
|
+
ext = os.path.splitext(file.filename)[1]
|
|
35
|
+
filename = f"{uuid.uuid4()}{ext}"
|
|
36
|
+
filepath = os.path.join(admin.upload_dir, filename)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
with open(filepath, "wb") as buffer:
|
|
40
|
+
shutil.copyfileobj(file.file, buffer)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
raise HTTPException(status_code=500, detail=f"Could not save file: {str(e)}")
|
|
43
|
+
|
|
44
|
+
return {"url": f"{admin.upload_url}/{filename}"}
|
|
45
|
+
|
|
46
|
+
@router.get("/media", name="admin_resolve_media")
|
|
47
|
+
async def resolve_media(path: str = Query(...)):
|
|
48
|
+
if not path:
|
|
49
|
+
raise HTTPException(status_code=400, detail="Path parameter is required")
|
|
50
|
+
try:
|
|
51
|
+
if asyncio.iscoroutinefunction(admin.url_resolver):
|
|
52
|
+
resolved_url = await admin.url_resolver(path)
|
|
53
|
+
else:
|
|
54
|
+
resolved_url = admin.url_resolver(path)
|
|
55
|
+
return RedirectResponse(url=resolved_url)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise HTTPException(status_code=500, detail=f"Failed to resolve media URL: {str(e)}")
|
|
58
|
+
|
|
14
59
|
@router.get("/models")
|
|
15
60
|
async def list_models():
|
|
16
61
|
return list(admin.registry.get_models().keys())
|
|
@@ -142,6 +142,12 @@
|
|
|
142
142
|
|
|
143
143
|
async function loadDetail() {
|
|
144
144
|
try {
|
|
145
|
+
// Fetch schema to check file fields
|
|
146
|
+
const schemaResponse = await fetch(`${apiBaseUrl}/schema`);
|
|
147
|
+
const schema = await schemaResponse.json();
|
|
148
|
+
const modelSchema = schema.models.find(m => m.name === modelName);
|
|
149
|
+
const fileFields = modelSchema?.config?.file_fields || [];
|
|
150
|
+
|
|
145
151
|
const response = await fetch(`${apiBaseUrl}/${modelName}/${recordId}`);
|
|
146
152
|
const data = await response.json();
|
|
147
153
|
|
|
@@ -150,10 +156,35 @@
|
|
|
150
156
|
|
|
151
157
|
container.innerHTML = keys.map(key => {
|
|
152
158
|
const val = data[key];
|
|
153
|
-
|
|
159
|
+
|
|
160
|
+
const isFile = fileFields.includes(key);
|
|
161
|
+
const isFullWidth = (typeof val === 'string' && val.length > 50) || isFile;
|
|
154
162
|
|
|
155
163
|
let displayVal = val;
|
|
156
|
-
if (
|
|
164
|
+
if (isFile) {
|
|
165
|
+
if (!val) {
|
|
166
|
+
displayVal = '<span style="color: var(--text-muted);">None</span>';
|
|
167
|
+
} else {
|
|
168
|
+
const displayUrl = val.startsWith('http://') || val.startsWith('https://') ? val : `${apiBaseUrl}/media?path=${encodeURIComponent(val)}`;
|
|
169
|
+
const isImg = val.match(/\.(jpeg|jpg|gif|png|webp)$/i);
|
|
170
|
+
if (isImg) {
|
|
171
|
+
displayVal = `
|
|
172
|
+
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 8px;">
|
|
173
|
+
<img src="${displayUrl}" alt="${key}" style="max-width: 300px; max-height: 300px; object-fit: contain; border-radius: 8px; border: 1px solid var(--border);">
|
|
174
|
+
<a href="${displayUrl}" target="_blank" class="file-preview-link" style="color: var(--primary); text-decoration: none; font-size: 0.85rem; font-weight: 500;">
|
|
175
|
+
<i class="fas fa-external-link-alt"></i> View full size
|
|
176
|
+
</a>
|
|
177
|
+
</div>
|
|
178
|
+
`;
|
|
179
|
+
} else {
|
|
180
|
+
displayVal = `
|
|
181
|
+
<a href="${displayUrl}" target="_blank" class="file-preview-link" style="display: inline-flex; align-items: center; gap: 8px; color: var(--primary); text-decoration: none; font-weight: 500; margin-top: 4px;">
|
|
182
|
+
<i class="fas fa-file-alt" style="font-size: 1.25rem;"></i> View File (${val.split('/').pop()})
|
|
183
|
+
</a>
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} else if (typeof val === 'boolean' || key === 'is_active') {
|
|
157
188
|
const isActive = val === true || val === 'true';
|
|
158
189
|
displayVal = `<span class="status-pill ${isActive ? 'active' : 'inactive'}">
|
|
159
190
|
<i class="fas ${isActive ? 'fa-check-circle' : 'fa-times-circle'}"></i>
|
|
@@ -188,6 +188,148 @@
|
|
|
188
188
|
font-weight: 600;
|
|
189
189
|
color: var(--text-main);
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
/* File Upload Styling */
|
|
193
|
+
.file-upload-wrapper {
|
|
194
|
+
position: relative;
|
|
195
|
+
width: 100%;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.file-upload-dragzone {
|
|
199
|
+
border: 2px dashed var(--border);
|
|
200
|
+
border-radius: 12px;
|
|
201
|
+
padding: 32px 20px;
|
|
202
|
+
text-align: center;
|
|
203
|
+
background-color: var(--bg-card);
|
|
204
|
+
cursor: pointer;
|
|
205
|
+
transition: all 0.3s ease;
|
|
206
|
+
display: flex;
|
|
207
|
+
flex-direction: column;
|
|
208
|
+
align-items: center;
|
|
209
|
+
justify-content: center;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.file-upload-dragzone:hover {
|
|
213
|
+
border-color: var(--primary);
|
|
214
|
+
background-color: rgba(99, 102, 241, 0.02);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.file-upload-dragzone.dragover {
|
|
218
|
+
border-color: var(--primary);
|
|
219
|
+
background-color: rgba(99, 102, 241, 0.06);
|
|
220
|
+
transform: scale(0.99);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.file-upload-dragzone.hidden {
|
|
224
|
+
display: none;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.upload-zone-content {
|
|
228
|
+
display: flex;
|
|
229
|
+
flex-direction: column;
|
|
230
|
+
align-items: center;
|
|
231
|
+
gap: 8px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.upload-icon {
|
|
235
|
+
font-size: 2rem;
|
|
236
|
+
color: var(--text-muted);
|
|
237
|
+
transition: color 0.3s ease;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.file-upload-dragzone:hover .upload-icon {
|
|
241
|
+
color: var(--primary);
|
|
242
|
+
transform: translateY(-2px);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.upload-text {
|
|
246
|
+
font-size: 0.9rem;
|
|
247
|
+
font-weight: 500;
|
|
248
|
+
color: var(--text-main);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.browse-text {
|
|
252
|
+
color: var(--primary);
|
|
253
|
+
font-weight: 600;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.upload-hint {
|
|
257
|
+
font-size: 0.75rem;
|
|
258
|
+
color: var(--text-muted);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* File Indicator (No Preview) */
|
|
262
|
+
.file-preview-container {
|
|
263
|
+
display: flex;
|
|
264
|
+
align-items: center;
|
|
265
|
+
gap: 16px;
|
|
266
|
+
padding: 16px;
|
|
267
|
+
background-color: var(--bg-card);
|
|
268
|
+
border: 1px solid var(--border);
|
|
269
|
+
border-radius: 12px;
|
|
270
|
+
position: relative;
|
|
271
|
+
margin-top: 8px;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.file-preview-icon {
|
|
275
|
+
width: 44px;
|
|
276
|
+
height: 44px;
|
|
277
|
+
border-radius: 8px;
|
|
278
|
+
background-color: var(--bg-surface);
|
|
279
|
+
display: flex;
|
|
280
|
+
align-items: center;
|
|
281
|
+
justify-content: center;
|
|
282
|
+
font-size: 1.25rem;
|
|
283
|
+
color: var(--primary);
|
|
284
|
+
border: 1px solid var(--border);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.file-preview-info {
|
|
288
|
+
display: flex;
|
|
289
|
+
flex-direction: column;
|
|
290
|
+
gap: 4px;
|
|
291
|
+
flex: 1;
|
|
292
|
+
min-width: 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.file-preview-name {
|
|
296
|
+
font-size: 0.9rem;
|
|
297
|
+
font-weight: 600;
|
|
298
|
+
color: var(--text-main);
|
|
299
|
+
white-space: nowrap;
|
|
300
|
+
overflow: hidden;
|
|
301
|
+
text-overflow: ellipsis;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.file-preview-link {
|
|
305
|
+
font-size: 0.75rem;
|
|
306
|
+
color: var(--primary);
|
|
307
|
+
text-decoration: none;
|
|
308
|
+
font-weight: 500;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.file-preview-link:hover {
|
|
312
|
+
text-decoration: underline;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.btn-remove-file {
|
|
316
|
+
background: none;
|
|
317
|
+
border: none;
|
|
318
|
+
color: var(--text-muted);
|
|
319
|
+
cursor: pointer;
|
|
320
|
+
padding: 8px;
|
|
321
|
+
font-size: 1rem;
|
|
322
|
+
transition: color 0.2s;
|
|
323
|
+
border-radius: 50%;
|
|
324
|
+
display: flex;
|
|
325
|
+
align-items: center;
|
|
326
|
+
justify-content: center;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.btn-remove-file:hover {
|
|
330
|
+
color: var(--error);
|
|
331
|
+
background-color: rgba(239, 68, 68, 0.1);
|
|
332
|
+
}
|
|
191
333
|
{% endblock %}
|
|
192
334
|
|
|
193
335
|
{% block content %}
|
|
@@ -274,6 +416,8 @@
|
|
|
274
416
|
|
|
275
417
|
const container = document.getElementById('form-fields');
|
|
276
418
|
const readonlyFields = modelSchema.config?.readonly_fields || [];
|
|
419
|
+
const fileFields = modelSchema.config?.file_fields || [];
|
|
420
|
+
|
|
277
421
|
container.innerHTML = modelSchema.fields.map(field => {
|
|
278
422
|
const name = field.name;
|
|
279
423
|
const type = field.type.toLowerCase();
|
|
@@ -285,6 +429,7 @@
|
|
|
285
429
|
if (name === 'id') return `<input type="hidden" name="id" value="${initialData[name]}">`;
|
|
286
430
|
|
|
287
431
|
const val = initialData[name] !== undefined ? initialData[name] : '';
|
|
432
|
+
const isRequired = field.required;
|
|
288
433
|
|
|
289
434
|
if (isBoolean) {
|
|
290
435
|
const checked = val === true || val === 'true' ? 'checked' : '';
|
|
@@ -304,8 +449,47 @@
|
|
|
304
449
|
`;
|
|
305
450
|
}
|
|
306
451
|
|
|
452
|
+
const isFile = fileFields.includes(name);
|
|
453
|
+
if (isFile) {
|
|
454
|
+
const displayUrl = val ? (val.startsWith('http://') || val.startsWith('https://') ? val : `${apiBaseUrl}/media?path=${encodeURIComponent(val)}`) : '';
|
|
455
|
+
const previewHtml = val ? `
|
|
456
|
+
<div class="file-preview-container" id="preview-${name}">
|
|
457
|
+
<div class="file-preview-icon"><i class="fas fa-file-alt"></i></div>
|
|
458
|
+
<div class="file-preview-info">
|
|
459
|
+
<span class="file-preview-name">${val.split('/').pop()}</span>
|
|
460
|
+
<a href="${displayUrl}" target="_blank" class="file-preview-link">View file</a>
|
|
461
|
+
</div>
|
|
462
|
+
<button type="button" class="btn-remove-file" onclick="clearFile('${name}')">
|
|
463
|
+
<i class="fas fa-times"></i>
|
|
464
|
+
</button>
|
|
465
|
+
</div>
|
|
466
|
+
` : '';
|
|
467
|
+
|
|
468
|
+
return `
|
|
469
|
+
<div class="form-group full-width">
|
|
470
|
+
<label class="form-label">
|
|
471
|
+
${name.replace('_', ' ')}
|
|
472
|
+
${isRequired ? '<span style="color: var(--error); margin-left: 4px;">*</span>' : ''}
|
|
473
|
+
</label>
|
|
474
|
+
<div class="file-upload-wrapper">
|
|
475
|
+
<input type="hidden" id="input-hidden-${name}" name="${name}" value="${val}" ${isRequired ? 'required' : ''}>
|
|
476
|
+
<div class="file-upload-dragzone ${val ? 'hidden' : ''}" id="dragzone-${name}" onclick="document.getElementById('input-file-${name}').click()">
|
|
477
|
+
<div class="upload-zone-content">
|
|
478
|
+
<i class="fas fa-cloud-upload-alt upload-icon"></i>
|
|
479
|
+
<div class="upload-text">Drag & drop a file here, or <span class="browse-text">browse</span></div>
|
|
480
|
+
<div class="upload-hint">Supports any file type</div>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
<input type="file" id="input-file-${name}" class="hidden-file-input" style="display: none;" onchange="handleFileSelect(event, '${name}')" ${disabled}>
|
|
484
|
+
<div id="preview-wrapper-${name}">
|
|
485
|
+
${previewHtml}
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
`;
|
|
490
|
+
}
|
|
491
|
+
|
|
307
492
|
const isFullWidth = name.includes('description') || name.includes('content');
|
|
308
|
-
const isRequired = field.required;
|
|
309
493
|
|
|
310
494
|
return `
|
|
311
495
|
<div class="form-group ${isFullWidth ? 'full-width' : ''}">
|
|
@@ -320,6 +504,11 @@
|
|
|
320
504
|
</div>
|
|
321
505
|
`;
|
|
322
506
|
}).join('');
|
|
507
|
+
|
|
508
|
+
// Setup drag & drop for file fields
|
|
509
|
+
fileFields.forEach(name => {
|
|
510
|
+
setupDragAndDrop(name);
|
|
511
|
+
});
|
|
323
512
|
} catch (error) {
|
|
324
513
|
console.error('Error loading form:', error);
|
|
325
514
|
}
|
|
@@ -364,6 +553,117 @@
|
|
|
364
553
|
}
|
|
365
554
|
};
|
|
366
555
|
|
|
556
|
+
async function handleFileSelect(event, fieldName) {
|
|
557
|
+
const file = event.target.files[0];
|
|
558
|
+
if (!file) return;
|
|
559
|
+
await uploadFile(file, fieldName);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function uploadFile(file, fieldName) {
|
|
563
|
+
const dragzone = document.getElementById(`dragzone-${fieldName}`);
|
|
564
|
+
const previewWrapper = document.getElementById(`preview-wrapper-${fieldName}`);
|
|
565
|
+
const hiddenInput = document.getElementById(`input-hidden-${fieldName}`);
|
|
566
|
+
|
|
567
|
+
// Show uploading state in the dragzone
|
|
568
|
+
const originalContent = dragzone.innerHTML;
|
|
569
|
+
dragzone.innerHTML = `
|
|
570
|
+
<div class="upload-zone-content">
|
|
571
|
+
<i class="fas fa-spinner fa-spin upload-icon" style="color: var(--primary);"></i>
|
|
572
|
+
<div class="upload-text">Uploading ${file.name}...</div>
|
|
573
|
+
</div>
|
|
574
|
+
`;
|
|
575
|
+
|
|
576
|
+
const formData = new FormData();
|
|
577
|
+
formData.append('file', file);
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
const response = await fetch(`${apiBaseUrl}/upload`, {
|
|
581
|
+
method: 'POST',
|
|
582
|
+
body: formData
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
if (!response.ok) {
|
|
586
|
+
throw new Error('Upload failed');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const result = await response.json();
|
|
590
|
+
const fileUrl = result.url;
|
|
591
|
+
|
|
592
|
+
// Set value in hidden input
|
|
593
|
+
hiddenInput.value = fileUrl;
|
|
594
|
+
|
|
595
|
+
const displayUrl = fileUrl.startsWith('http://') || fileUrl.startsWith('https://') ? fileUrl : `${apiBaseUrl}/media?path=${encodeURIComponent(fileUrl)}`;
|
|
596
|
+
|
|
597
|
+
// Update preview
|
|
598
|
+
previewWrapper.innerHTML = `
|
|
599
|
+
<div class="file-preview-container" id="preview-${fieldName}">
|
|
600
|
+
<div class="file-preview-icon"><i class="fas fa-file-alt"></i></div>
|
|
601
|
+
<div class="file-preview-info">
|
|
602
|
+
<span class="file-preview-name">${file.name}</span>
|
|
603
|
+
<a href="${displayUrl}" target="_blank" class="file-preview-link">View file</a>
|
|
604
|
+
</div>
|
|
605
|
+
<button type="button" class="btn-remove-file" onclick="clearFile('${fieldName}')">
|
|
606
|
+
<i class="fas fa-times"></i>
|
|
607
|
+
</button>
|
|
608
|
+
</div>
|
|
609
|
+
`;
|
|
610
|
+
|
|
611
|
+
// Hide dragzone
|
|
612
|
+
dragzone.classList.add('hidden');
|
|
613
|
+
} catch (error) {
|
|
614
|
+
console.error('Error uploading file:', error);
|
|
615
|
+
alert('Failed to upload file: ' + error.message);
|
|
616
|
+
// Restore dragzone
|
|
617
|
+
dragzone.innerHTML = originalContent;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function clearFile(fieldName) {
|
|
622
|
+
const dragzone = document.getElementById(`dragzone-${fieldName}`);
|
|
623
|
+
const previewWrapper = document.getElementById(`preview-wrapper-${fieldName}`);
|
|
624
|
+
const hiddenInput = document.getElementById(`input-hidden-${fieldName}`);
|
|
625
|
+
|
|
626
|
+
hiddenInput.value = '';
|
|
627
|
+
previewWrapper.innerHTML = '';
|
|
628
|
+
dragzone.classList.remove('hidden');
|
|
629
|
+
|
|
630
|
+
// Reset file input value so same file can be selected again
|
|
631
|
+
const fileInput = document.getElementById(`input-file-${fieldName}`);
|
|
632
|
+
if (fileInput) {
|
|
633
|
+
fileInput.value = '';
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Setup drag and drop for a dragzone
|
|
638
|
+
function setupDragAndDrop(fieldName) {
|
|
639
|
+
const dragzone = document.getElementById(`dragzone-${fieldName}`);
|
|
640
|
+
if (!dragzone) return;
|
|
641
|
+
|
|
642
|
+
['dragenter', 'dragover'].forEach(eventName => {
|
|
643
|
+
dragzone.addEventListener(eventName, (e) => {
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
e.stopPropagation();
|
|
646
|
+
dragzone.classList.add('dragover');
|
|
647
|
+
}, false);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
['dragleave', 'drop'].forEach(eventName => {
|
|
651
|
+
dragzone.addEventListener(eventName, (e) => {
|
|
652
|
+
e.preventDefault();
|
|
653
|
+
e.stopPropagation();
|
|
654
|
+
dragzone.classList.remove('dragover');
|
|
655
|
+
}, false);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
dragzone.addEventListener('drop', async (e) => {
|
|
659
|
+
const dt = e.dataTransfer;
|
|
660
|
+
const file = dt.files[0];
|
|
661
|
+
if (file) {
|
|
662
|
+
await uploadFile(file, fieldName);
|
|
663
|
+
}
|
|
664
|
+
}, false);
|
|
665
|
+
}
|
|
666
|
+
|
|
367
667
|
loadForm();
|
|
368
668
|
</script>
|
|
369
669
|
{% endblock %}
|
|
@@ -303,6 +303,7 @@
|
|
|
303
303
|
const schema = await schemaResponse.json();
|
|
304
304
|
const modelSchema = schema.models.find(m => m.name === modelName);
|
|
305
305
|
const listDisplay = modelSchema ? modelSchema.list_display : null;
|
|
306
|
+
const fileFields = modelSchema?.config?.file_fields || [];
|
|
306
307
|
|
|
307
308
|
// 2. Fetch Data
|
|
308
309
|
const skip = (currentPage - 1) * pageSize;
|
|
@@ -385,6 +386,18 @@
|
|
|
385
386
|
`;
|
|
386
387
|
}
|
|
387
388
|
|
|
389
|
+
if (fileFields.includes(col)) {
|
|
390
|
+
if (!val) return '<td>--</td>';
|
|
391
|
+
const displayUrl = val.startsWith('http://') || val.startsWith('https://') ? val : `${apiBaseUrl}/media?path=${encodeURIComponent(val)}`;
|
|
392
|
+
return `
|
|
393
|
+
<td>
|
|
394
|
+
<a href="${displayUrl}" target="_blank" class="file-preview-link" style="color: var(--primary); text-decoration: none; font-weight: 500; display: inline-flex; align-items: center; gap: 6px;">
|
|
395
|
+
<i class="fas fa-external-link-alt"></i> View File
|
|
396
|
+
</a>
|
|
397
|
+
</td>
|
|
398
|
+
`;
|
|
399
|
+
}
|
|
400
|
+
|
|
388
401
|
return `<td>${val}</td>`;
|
|
389
402
|
}).join('')}
|
|
390
403
|
<td>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-lite-admin
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: A lightweight, pluggable admin panel for FastAPI
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/rishiqwerty/admin-panel-fast-api
|
|
@@ -122,6 +122,7 @@ When calling `admin.register()`, you can configure how each model is represented
|
|
|
122
122
|
| **`date_field`** | `str` | No | Name of the datetime field (e.g. `created_at`). Required to show the "24h Activity" count cards on the dashboard and lists. |
|
|
123
123
|
| **`attention_filter`** | `SQLAlchemy Expression` | No | A SQLAlchemy binary filter expression (e.g., `User.is_active == False` or `Product.stock < 10`) used to calculate and flag rows that require moderator attention. |
|
|
124
124
|
| **`readonly_fields`** | `List[str]` | No | List of columns that cannot be modified or set via creation or updates (e.g., auto-generated columns or timestamps like `id`, `created_at`). |
|
|
125
|
+
| **`file_fields`** | `List[str]` | No | List of column names that should be treated as file upload fields, rendering a drag-and-drop zone. |
|
|
125
126
|
| **`config`** | `Dict[str, Any]` | No | Dictionary containing extra settings. Supports `"display_name"` to override the sidebar label. |
|
|
126
127
|
|
|
127
128
|
---
|
|
@@ -141,6 +142,10 @@ The `Admin` class constructor supports the following parameters for customizatio
|
|
|
141
142
|
| **`dashboard_models`** | `List[str]` | `None` | List of registered model names to display on the dashboard (if you want to restrict which registered models show on the home dashboard). |
|
|
142
143
|
| **`get_logs`** | `Callable` | `None` | An optional callable (async or sync) returning system logs to display on the dashboard activity log feed. |
|
|
143
144
|
| **`logs_config`** | `Dict[str, Any]` | `{"title": "System Activity", "columns": ["level", "timestamp", "message"]}` | Config dictionary to customize dashboard log columns and activity title. |
|
|
145
|
+
| **`upload_dir`** | `str` | `"uploads"` | Local directory path where uploaded files will be stored. |
|
|
146
|
+
| **`upload_url`** | `str` | `"/uploads"` | URL prefix used to serve uploaded files statically. |
|
|
147
|
+
| **`upload_handler`** | `Callable` | `None` | Optional custom upload handler callback for buckets (S3, GCS, Azure). |
|
|
148
|
+
| **`url_resolver`** | `Callable` | `None` | Optional custom URL resolver callback to resolve database keys to presigned URLs (S3, GCS). |
|
|
144
149
|
|
|
145
150
|
---
|
|
146
151
|
|
|
@@ -204,6 +209,66 @@ admin = Admin(
|
|
|
204
209
|
)
|
|
205
210
|
```
|
|
206
211
|
|
|
212
|
+
## File Uploads & Cloud Storage
|
|
213
|
+
|
|
214
|
+
`FastAPI Lite Admin` supports rendering drag-and-drop file uploads for designated string fields (e.g., image paths or document URLs).
|
|
215
|
+
|
|
216
|
+
### 1. Default Local Storage
|
|
217
|
+
By default, uploaded files are stored locally in the `uploads/` directory and served statically:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
admin = Admin(
|
|
221
|
+
title="My Admin",
|
|
222
|
+
upload_dir="my_uploads", # Stored in project-root/my_uploads
|
|
223
|
+
upload_url="/static/files" # Served statically at http://localhost:8000/static/files
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### 2. Cloud Storage / Buckets (S3, GCS, Azure, etc.)
|
|
228
|
+
If you are deploying to production and storing files in a cloud bucket, you can plug in a custom `upload_handler`:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from fastapi import UploadFile
|
|
232
|
+
|
|
233
|
+
async def my_s3_upload_handler(file: UploadFile) -> str:
|
|
234
|
+
# 1. Upload file.file to S3, GCS, Cloudinary, etc.
|
|
235
|
+
# 2. Return the public URL to be stored in the database
|
|
236
|
+
return f"https://my-bucket.s3.amazonaws.com/{file.filename}"
|
|
237
|
+
|
|
238
|
+
admin = Admin(
|
|
239
|
+
title="Cloud Admin",
|
|
240
|
+
upload_handler=my_s3_upload_handler
|
|
241
|
+
)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 3. Storage URL Resolution (Presigned URLs)
|
|
245
|
+
If the database stores relative paths/keys (e.g. `generated/uuid.jpg` in GCS or S3) rather than full absolute URLs, browser requests will fail. You can provide a custom `url_resolver` callback:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
async def get_s3_presigned_url(path: str) -> str:
|
|
249
|
+
# 1. Generate temporary presigned GET URL for GCS/S3 key
|
|
250
|
+
# 2. Return URL
|
|
251
|
+
return s3_client.generate_presigned_url('get_object', Params={'Bucket': 'my-bucket', 'Key': path})
|
|
252
|
+
|
|
253
|
+
admin = Admin(
|
|
254
|
+
title="Cloud Admin",
|
|
255
|
+
url_resolver=get_s3_presigned_url
|
|
256
|
+
)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The admin panel routes all file rendering and download links through the `/admin/api/media?path=...` redirect proxy, which executes `url_resolver` to redirect the browser to the temporary accessible URL safely, keeping your database values clean.
|
|
260
|
+
|
|
261
|
+
### 4. Enabling File Upload in Models
|
|
262
|
+
Pass the `file_fields` parameter when registering your model:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
admin.register(
|
|
266
|
+
model=Product,
|
|
267
|
+
get_db=get_db,
|
|
268
|
+
file_fields=["image_url"] # These will render as drag-and-drop zones
|
|
269
|
+
)
|
|
270
|
+
```
|
|
271
|
+
|
|
207
272
|
---
|
|
208
273
|
|
|
209
274
|
## Running the Example Application
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/integrations/sqlalchemy.py
RENAMED
|
File without changes
|
{fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/dashboard.html
RENAMED
|
File without changes
|
{fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/layout.html
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/requires.txt
RENAMED
|
File without changes
|
{fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|