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.
Files changed (25) hide show
  1. {fastapi_lite_admin-0.1.6/fastapi_lite_admin.egg-info → fastapi_lite_admin-0.1.8}/PKG-INFO +66 -1
  2. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/README.md +65 -0
  3. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/main.py +38 -1
  4. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/routers/admin.py +46 -1
  5. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/model_detail.html +33 -2
  6. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/model_form.html +301 -1
  7. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/model_list.html +13 -0
  8. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8/fastapi_lite_admin.egg-info}/PKG-INFO +66 -1
  9. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/pyproject.toml +1 -1
  10. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/LICENSE +0 -0
  11. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/__init__.py +0 -0
  12. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/core/config.py +0 -0
  13. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/core/crud.py +0 -0
  14. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/core/registry.py +0 -0
  15. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/core/schema.py +0 -0
  16. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/dependencies/db.py +0 -0
  17. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/integrations/sqlalchemy.py +0 -0
  18. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/dashboard.html +0 -0
  19. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/templates/layout.html +0 -0
  20. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_admin_lite/ui/views.py +0 -0
  21. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/SOURCES.txt +0 -0
  22. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/dependency_links.txt +0 -0
  23. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/requires.txt +0 -0
  24. {fastapi_lite_admin-0.1.6 → fastapi_lite_admin-0.1.8}/fastapi_lite_admin.egg-info/top_level.txt +0 -0
  25. {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.6
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
- const isFullWidth = typeof val === 'string' && val.length > 50;
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 (typeof val === 'boolean' || key === 'is_active') {
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.6
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-lite-admin"
7
- version = "0.1.6"
7
+ version = "0.1.8"
8
8
  description = "A lightweight, pluggable admin panel for FastAPI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"