Skip to content

Commit 68fb97c

Browse files
committed
Add new upload file functionality (without backward compatibility). See documentation for details. Add example for flask with sqlalchemy. Removed ADMIN_DISABLE_CROP_IMAGE setting. Use disableCropImage prop in UploadImage widget instead.
1 parent 28ccc31 commit 68fb97c

61 files changed

Lines changed: 2384 additions & 842 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ venv.bak/
130130
.dmypy.json
131131
dmypy.json
132132

133+
# ruff
134+
.ruff_cache/
135+
133136
# Pyre type checker
134137
.pyre/
135138

README.md

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -477,15 +477,14 @@ class UserAdmin(TortoiseModelAdmin):
477477
list_display_links = ("id", "username")
478478
list_filter = ("id", "username", "is_superuser", "is_active")
479479
search_fields = ("username",)
480-
formfield_overrides = { # noqa: RUF012
480+
formfield_overrides: tp.Any = { # noqa: RUF012
481481
"username": (WidgetType.SlugInput, {"required": True}),
482482
"password": (WidgetType.PasswordInput, {"passwordModalForm": True}),
483483
"avatar_url": (
484-
WidgetType.Upload,
484+
WidgetType.UploadImage,
485485
{
486486
"required": False,
487-
# Disable crop image for upload field
488-
# "disableCropImage": True,
487+
# "disableCropImage": True, # optional: disable image cropping
489488
},
490489
),
491490
}
@@ -505,10 +504,9 @@ class UserAdmin(TortoiseModelAdmin):
505504
user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
506505
await user.save(update_fields=("hash_password",))
507506

508-
async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
509-
# convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
510-
setattr(obj, field, base64)
511-
await obj.save(update_fields=(field,))
507+
async def upload_file(self, obj: tp.Any, field_name: str, file_name: str, file_content: bytes) -> str:
508+
# save file to media directory or s3/filestorage, then return the file url
509+
return f"/media/{file_name}"
512510

513511
```
514512

@@ -531,6 +529,8 @@ class UserAdmin(TortoiseModelAdmin):
531529

532530

533531
```python
532+
import typing as tp
533+
534534
from django.db import models
535535

536536
from fastadmin import DjangoModelAdmin, register
@@ -541,6 +541,7 @@ class User(models.Model):
541541
hash_password = models.CharField(max_length=255)
542542
is_superuser = models.BooleanField(default=False)
543543
is_active = models.BooleanField(default=False)
544+
avatar_url = models.ImageField(null=True)
544545

545546
def __str__(self):
546547
return self.username
@@ -562,6 +563,10 @@ class UserAdmin(DjangoModelAdmin):
562563
return None
563564
return obj.id
564565

566+
def upload_file(self, obj: tp.Any, field_name: str, file_name: str, file_content: bytes) -> str: # type: ignore[override]
567+
# save file to media directory or s3/filestorage, then return the file url
568+
return f"/media/{file_name}"
569+
565570
```
566571

567572

@@ -591,7 +596,7 @@ from sqlalchemy import Boolean, Integer, String, Text, select, update
591596
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
592597
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
593598

594-
from fastadmin import SqlAlchemyModelAdmin, register
599+
from fastadmin import SqlAlchemyModelAdmin, WidgetType, register
595600

596601
sqlalchemy_engine = create_async_engine(
597602
"sqlite+aiosqlite:///:memory:",
@@ -625,6 +630,9 @@ class UserAdmin(SqlAlchemyModelAdmin):
625630
list_display_links = ("id", "username")
626631
list_filter = ("id", "username", "is_superuser", "is_active")
627632
search_fields = ("username",)
633+
formfield_overrides = { # noqa: RUF012
634+
"avatar_url": (WidgetType.UploadImage, {"required": False}),
635+
}
628636

629637
async def authenticate(self, username: str, password: str) -> uuid.UUID | int | None:
630638
sessionmaker = self.get_sessionmaker()
@@ -646,13 +654,9 @@ class UserAdmin(SqlAlchemyModelAdmin):
646654
await session.execute(query)
647655
await session.commit()
648656

649-
async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
650-
sessionmaker = self.get_sessionmaker()
651-
async with sessionmaker() as session:
652-
# convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
653-
query = update(self.model_cls).where(User.id.in_([obj.id])).values(**{field: base64})
654-
await session.execute(query)
655-
await session.commit()
657+
async def upload_file(self, obj: tp.Any, field_name: str, file_name: str, file_content: bytes) -> str:
658+
# save file to media directory or s3/filestorage, then return the file url
659+
return f"/media/{file_name}"
656660

657661
```
658662

@@ -681,7 +685,7 @@ import uuid
681685
import bcrypt
682686
from pony.orm import Database, LongStr, Optional, PrimaryKey, Required, commit, db_session
683687

684-
from fastadmin import PonyORMModelAdmin, register
688+
from fastadmin import PonyORMModelAdmin, WidgetType, register
685689

686690
db = Database()
687691
db.bind(provider="sqlite", filename=":memory:", create_db=True)
@@ -707,6 +711,9 @@ class UserAdmin(PonyORMModelAdmin):
707711
list_display_links = ("id", "username")
708712
list_filter = ("id", "username", "is_superuser", "is_active")
709713
search_fields = ("username",)
714+
formfield_overrides = { # noqa: RUF012
715+
"avatar_url": (WidgetType.UploadImage, {"required": False}),
716+
}
710717

711718
@db_session
712719
def authenticate(self, username: str, password: str) -> uuid.UUID | int | None:
@@ -726,14 +733,9 @@ class UserAdmin(PonyORMModelAdmin):
726733
obj.hash_password = hash_password
727734
commit()
728735

729-
@db_session
730-
def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
731-
obj = next((f for f in self.model_cls.select(id=obj.id)), None)
732-
if not obj:
733-
return
734-
# convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
735-
setattr(obj, field, base64)
736-
commit()
736+
def upload_file(self, obj: tp.Any, field_name: str, file_name: str, file_content: bytes) -> str: # type: ignore[override]
737+
# save file to media directory or s3/filestorage, then return the file url
738+
return f"/media/{file_name}"
737739

738740
```
739741

docs/build.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ def read_cls_docstring(cls):
4242

4343
def get_versions():
4444
return [
45+
{
46+
"version": "0.4.0",
47+
"changes": [
48+
"Add new upload file functionality (without backward compatibility). See documentation for details.",
49+
"Add example for flask with sqlalchemy",
50+
"Removed ADMIN_DISABLE_CROP_IMAGE setting. Use disableCropImage prop in UploadImage widget instead.",
51+
],
52+
},
4553
{
4654
"version": "0.3.11",
4755
"changes": [
@@ -676,6 +684,10 @@ async def save_model(self, id, payload):
676684
"type": "alert-warning",
677685
"content": "See <a href='https://ant.design/components/overview' target='_blank'>antd components</a> for more details (e.g. how they look).",
678686
},
687+
{
688+
"type": "text",
689+
"content": "For file and image fields use <code>UploadFile</code> and <code>UploadImage</code> widgets in <code>formfield_overrides</code>. Implement <code>upload_file(obj, field_name, file_name, file_content)</code> on the model admin to handle uploads; it must return the file URL (e.g. after saving to disk or S3).",
690+
},
679691
{
680692
"type": "text",
681693
"content": "Use <code>formfield_overrides</code> to customize widget props per field. You can set <code>label</code> for a custom field label and <code>help</code> for description text below the field:",

docs/code/models/tortoise.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,10 @@ class UserAdmin(TortoiseModelAdmin):
4141
"username": (WidgetType.SlugInput, {"required": True}),
4242
"password": (WidgetType.PasswordInput, {"passwordModalForm": True}),
4343
"avatar_url": (
44-
WidgetType.Upload,
44+
WidgetType.UploadImage,
4545
{
4646
"required": False,
47-
# Disable crop image for upload field
48-
# "disableCropImage": True,
47+
# "disableCropImage": True, # optional: disable image cropping
4948
},
5049
),
5150
}
@@ -78,7 +77,9 @@ async def activate(self, ids: list[int]) -> None:
7877
async def deactivate(self, ids: list[int]) -> None:
7978
await self.model_cls.filter(id__in=ids).update(is_active=False)
8079

81-
async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
82-
# convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
83-
setattr(obj, field, base64)
84-
await obj.save(update_fields=(field,))
80+
async def upload_file(self, obj: tp.Any, field_name: str, file_name: str, file_content: bytes) -> str:
81+
# save file to media directory or s3/filestorage, then return the file url
82+
url = f"/media/{file_name}"
83+
setattr(obj, field_name, url)
84+
await obj.save(update_fields=(field_name,))
85+
return url

docs/code/quick_tutorial/djangoorm.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import typing as tp
2+
13
from django.db import models
24

35
from fastadmin import DjangoModelAdmin, register
@@ -8,6 +10,7 @@ class User(models.Model):
810
hash_password = models.CharField(max_length=255)
911
is_superuser = models.BooleanField(default=False)
1012
is_active = models.BooleanField(default=False)
13+
avatar_url = models.ImageField(null=True)
1114

1215
def __str__(self):
1316
return self.username
@@ -28,3 +31,7 @@ def authenticate(self, username, password):
2831
if not obj.check_password(password):
2932
return None
3033
return obj.id
34+
35+
def upload_file(self, obj: tp.Any, field_name: str, file_name: str, file_content: bytes) -> str: # type: ignore[override]
36+
# save file to media directory or s3/filestorage, then return the file url
37+
return f"/media/{file_name}"

docs/code/quick_tutorial/ponyorm.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import bcrypt
55
from pony.orm import Database, LongStr, Optional, PrimaryKey, Required, commit, db_session
66

7-
from fastadmin import PonyORMModelAdmin, register
7+
from fastadmin import PonyORMModelAdmin, WidgetType, register
88

99
db = Database()
1010
db.bind(provider="sqlite", filename=":memory:", create_db=True)
@@ -30,6 +30,9 @@ class UserAdmin(PonyORMModelAdmin):
3030
list_display_links = ("id", "username")
3131
list_filter = ("id", "username", "is_superuser", "is_active")
3232
search_fields = ("username",)
33+
formfield_overrides = { # noqa: RUF012
34+
"avatar_url": (WidgetType.UploadImage, {"required": False}),
35+
}
3336

3437
@db_session
3538
def authenticate(self, username: str, password: str) -> uuid.UUID | int | None:
@@ -49,11 +52,6 @@ def change_password(self, id: uuid.UUID | int, password: str) -> None:
4952
obj.hash_password = hash_password
5053
commit()
5154

52-
@db_session
53-
def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
54-
obj = next((f for f in self.model_cls.select(id=obj.id)), None)
55-
if not obj:
56-
return
57-
# convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
58-
setattr(obj, field, base64)
59-
commit()
55+
def upload_file(self, obj: tp.Any, field_name: str, file_name: str, file_content: bytes) -> str: # type: ignore[override]
56+
# save file to media directory or s3/filestorage, then return the file url
57+
return f"/media/{file_name}"

docs/code/quick_tutorial/sqlalchemy.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
77
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
88

9-
from fastadmin import SqlAlchemyModelAdmin, register
9+
from fastadmin import SqlAlchemyModelAdmin, WidgetType, register
1010

1111
sqlalchemy_engine = create_async_engine(
1212
"sqlite+aiosqlite:///:memory:",
@@ -40,6 +40,9 @@ class UserAdmin(SqlAlchemyModelAdmin):
4040
list_display_links = ("id", "username")
4141
list_filter = ("id", "username", "is_superuser", "is_active")
4242
search_fields = ("username",)
43+
formfield_overrides = { # noqa: RUF012
44+
"avatar_url": (WidgetType.UploadImage, {"required": False}),
45+
}
4346

4447
async def authenticate(self, username: str, password: str) -> uuid.UUID | int | None:
4548
sessionmaker = self.get_sessionmaker()
@@ -61,10 +64,6 @@ async def change_password(self, id: uuid.UUID | int | str, password: str) -> Non
6164
await session.execute(query)
6265
await session.commit()
6366

64-
async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
65-
sessionmaker = self.get_sessionmaker()
66-
async with sessionmaker() as session:
67-
# convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
68-
query = update(self.model_cls).where(User.id.in_([obj.id])).values(**{field: base64})
69-
await session.execute(query)
70-
await session.commit()
67+
async def upload_file(self, obj: tp.Any, field_name: str, file_name: str, file_content: bytes) -> str:
68+
# save file to media directory or s3/filestorage, then return the file url
69+
return f"/media/{file_name}"

docs/code/quick_tutorial/tortoise.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,14 @@ class UserAdmin(TortoiseModelAdmin):
2626
list_display_links = ("id", "username")
2727
list_filter = ("id", "username", "is_superuser", "is_active")
2828
search_fields = ("username",)
29-
formfield_overrides = { # noqa: RUF012
29+
formfield_overrides: tp.Any = { # noqa: RUF012
3030
"username": (WidgetType.SlugInput, {"required": True}),
3131
"password": (WidgetType.PasswordInput, {"passwordModalForm": True}),
3232
"avatar_url": (
33-
WidgetType.Upload,
33+
WidgetType.UploadImage,
3434
{
3535
"required": False,
36-
# Disable crop image for upload field
37-
# "disableCropImage": True,
36+
# "disableCropImage": True, # optional: disable image cropping
3837
},
3938
),
4039
}
@@ -54,7 +53,6 @@ async def change_password(self, id: UUID | int | str, password: str) -> None:
5453
user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
5554
await user.save(update_fields=("hash_password",))
5655

57-
async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
58-
# convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
59-
setattr(obj, field, base64)
60-
await obj.save(update_fields=(field,))
56+
async def upload_file(self, obj: tp.Any, field_name: str, file_name: str, file_content: bytes) -> str:
57+
# save file to media directory or s3/filestorage, then return the file url
58+
return f"/media/{file_name}"

0 commit comments

Comments
 (0)