Skip to content

Commit d3b00b8

Browse files
committed
Added support for FileField and ImageField from sqlalchemy-file
1 parent d400a92 commit d3b00b8

29 files changed

+1198
-919
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ clean: ## Removing cached python compiled files
1111
find . -name __pycache__ | xargs rm -rfv
1212
find . -name .pytest_cache | xargs rm -rfv
1313
find . -name .ruff_cache | xargs rm -rfv
14+
find . -name .mypy_cache | xargs rm -rfv
1415

1516
install: ## Install dependencies
1617
pip install -r requirements.txt

ellar_sql/constant.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
1414
"pk": "pk_%(table_name)s",
1515
}
16+
DEFAULT_STORAGE_PLACEHOLDER = "DEFAULT_STORAGE_PLACEHOLDER".lower()
1617

1718

1819
class DeclarativeBasePlaceHolder(sa_orm.DeclarativeBase):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from .file import File, FileField, ImageField
12
from .guid import GUID
23
from .ipaddress import GenericIP
34

45
__all__ = [
56
"GUID",
67
"GenericIP",
8+
"FileField",
9+
"ImageField",
710
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from .file import File
2+
from .file_tracker import ModifiedFileFieldSessionTracker
3+
from .processors import Processor, ThumbnailGenerator
4+
from .types import FileField, ImageField
5+
from .validators import ContentTypeValidator, ImageValidator, SizeValidator, Validator
6+
7+
__all__ = [
8+
"ImageValidator",
9+
"SizeValidator",
10+
"Validator",
11+
"ContentTypeValidator",
12+
"File",
13+
"FileField",
14+
"ImageField",
15+
"Processor",
16+
"ThumbnailGenerator",
17+
]
18+
19+
20+
ModifiedFileFieldSessionTracker.setup()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from sqlalchemy_file.exceptions import ContentTypeValidationError # noqa
2+
from sqlalchemy_file.exceptions import InvalidImageError # noqa
3+
from sqlalchemy_file.exceptions import DimensionValidationError # noqa
4+
from sqlalchemy_file.exceptions import AspectRatioValidationError # noqa
5+
from sqlalchemy_file.exceptions import SizeValidationError # noqa
6+
from sqlalchemy_file.exceptions import ValidationError # noqa
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import typing as t
2+
import uuid
3+
import warnings
4+
from datetime import datetime
5+
6+
from ellar.app import current_injector
7+
from ellar_storage import StorageService, StoredFile
8+
from sqlalchemy_file.file import File as BaseFile
9+
10+
from ellar_sql.constant import DEFAULT_STORAGE_PLACEHOLDER
11+
12+
13+
class File(BaseFile):
14+
"""Takes a file as content and uploads it to the appropriate storage
15+
according to the attached Column and file information into the
16+
database as JSON.
17+
18+
Default attributes provided for all ``File`` include:
19+
20+
Attributes:
21+
filename (str): This is the name of the uploaded file
22+
file_id: This is the generated UUID for the uploaded file
23+
upload_storage: Name of the storage used to save the uploaded file
24+
path: This is a combination of `upload_storage` and `file_id` separated by
25+
`/`. This will be use later to retrieve the file
26+
content_type: This is the content type of the uploaded file
27+
uploaded_at (datetime): This is the upload date in ISO format
28+
url (str): CDN url of the uploaded file
29+
file: Only available for saved content, internally call
30+
[StorageManager.get_file()][sqlalchemy_file.storage.StorageManager.get_file]
31+
on path and return an instance of `StoredFile`
32+
"""
33+
34+
def __init__(
35+
self,
36+
content: t.Any = None,
37+
filename: t.Optional[str] = None,
38+
content_type: t.Optional[str] = None,
39+
content_path: t.Optional[str] = None,
40+
**kwargs: t.Dict[str, t.Any],
41+
) -> None:
42+
super().__init__(
43+
content=content,
44+
filename=filename,
45+
content_path=content_path,
46+
content_type=content_type,
47+
**kwargs,
48+
)
49+
50+
def save_to_storage(self, upload_storage: t.Optional[str] = None) -> None:
51+
"""Save current file into provided `upload_storage`."""
52+
storage_service = current_injector.get(StorageService)
53+
valid_upload_storage = storage_service.get_container(
54+
upload_storage
55+
if not upload_storage == DEFAULT_STORAGE_PLACEHOLDER
56+
else None
57+
).name
58+
59+
extra = self.get("extra", {})
60+
extra.update({"content_type": self.content_type})
61+
62+
metadata = self.get("metadata", None)
63+
if metadata is not None:
64+
warnings.warn(
65+
'metadata attribute is deprecated. Use extra={"meta_data": ...} instead',
66+
DeprecationWarning,
67+
stacklevel=1,
68+
)
69+
extra.update({"meta_data": metadata})
70+
71+
if extra.get("meta_data", None) is None:
72+
extra["meta_data"] = {}
73+
74+
extra["meta_data"].update(
75+
{"filename": self.filename, "content_type": self.content_type}
76+
)
77+
stored_file = self.store_content(
78+
self.original_content,
79+
valid_upload_storage,
80+
extra=extra,
81+
headers=self.get("headers", None),
82+
content_path=self.content_path,
83+
)
84+
self["file_id"] = stored_file.name
85+
self["upload_storage"] = valid_upload_storage
86+
self["uploaded_at"] = datetime.utcnow().isoformat()
87+
self["path"] = f"{valid_upload_storage}/{stored_file.name}"
88+
self["url"] = stored_file.get_cdn_url()
89+
self["saved"] = True
90+
91+
def store_content( # type:ignore[override]
92+
self,
93+
content: t.Any,
94+
upload_storage: t.Optional[str] = None,
95+
name: t.Optional[str] = None,
96+
metadata: t.Optional[t.Dict[str, t.Any]] = None,
97+
extra: t.Optional[t.Dict[str, t.Any]] = None,
98+
headers: t.Optional[t.Dict[str, str]] = None,
99+
content_path: t.Optional[str] = None,
100+
) -> StoredFile:
101+
"""Store content into provided `upload_storage`
102+
with additional `metadata`. Can be used by processors
103+
to store additional files.
104+
"""
105+
name = name or str(uuid.uuid4())
106+
storage_service = current_injector.get(StorageService)
107+
108+
stored_file = storage_service.save_content(
109+
name=name,
110+
content=content,
111+
upload_storage=upload_storage,
112+
metadata=metadata,
113+
extra=extra,
114+
headers=headers,
115+
content_path=content_path,
116+
)
117+
self["files"].append(f"{upload_storage}/{name}")
118+
return stored_file
119+
120+
@property
121+
def file(self) -> StoredFile: # type:ignore[override]
122+
if self.get("saved", False):
123+
storage_service = current_injector.get(StorageService)
124+
return storage_service.get(self["path"])
125+
raise RuntimeError("Only available for saved file")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import typing as t
2+
3+
from ellar.app import current_injector
4+
from ellar_storage import StorageService
5+
from sqlalchemy import event, orm
6+
from sqlalchemy_file.types import FileFieldSessionTracker
7+
8+
9+
class ModifiedFileFieldSessionTracker(FileFieldSessionTracker):
10+
@classmethod
11+
def delete_files(cls, paths: t.Set[str], ctx: str) -> None:
12+
if len(paths) == 0:
13+
return
14+
15+
storage_service = current_injector.get(StorageService)
16+
17+
for path in paths:
18+
storage_service.delete(path)
19+
20+
@classmethod
21+
def unsubscribe_defaults(cls) -> None:
22+
event.remove(
23+
orm.Mapper, "mapper_configured", FileFieldSessionTracker._mapper_configured
24+
)
25+
event.remove(
26+
orm.Mapper, "after_configured", FileFieldSessionTracker._after_configured
27+
)
28+
event.remove(orm.Session, "after_commit", FileFieldSessionTracker._after_commit)
29+
event.remove(
30+
orm.Session,
31+
"after_soft_rollback",
32+
FileFieldSessionTracker._after_soft_rollback,
33+
)
34+
35+
@classmethod
36+
def setup(cls) -> None:
37+
cls.unsubscribe_defaults()
38+
event.listen(orm.Mapper, "mapper_configured", cls._mapper_configured)
39+
event.listen(orm.Mapper, "after_configured", cls._after_configured)
40+
event.listen(orm.Session, "after_commit", cls._after_commit)
41+
event.listen(orm.Session, "after_soft_rollback", cls._after_soft_rollback)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from sqlalchemy_file.processors import Processor # noqa
2+
from sqlalchemy_file.processors import ThumbnailGenerator # noqa
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import typing as t
2+
3+
from ellar.pydantic.types import Validator
4+
from sqlalchemy_file.types import FileField as BaseFileField
5+
6+
from ellar_sql.constant import DEFAULT_STORAGE_PLACEHOLDER
7+
8+
from .file import File
9+
from .processors import Processor, ThumbnailGenerator
10+
from .validators import ImageValidator
11+
12+
13+
class FileField(BaseFileField):
14+
def __init__(
15+
self,
16+
*args: t.Tuple[t.Any],
17+
upload_storage: t.Optional[str] = None,
18+
validators: t.Optional[t.List[Validator]] = None,
19+
processors: t.Optional[t.List[Processor]] = None,
20+
upload_type: t.Type[File] = File,
21+
multiple: t.Optional[bool] = False,
22+
extra: t.Optional[t.Dict[str, t.Any]] = None,
23+
headers: t.Optional[t.Dict[str, str]] = None,
24+
**kwargs: t.Dict[str, t.Any],
25+
) -> None:
26+
"""Parameters:
27+
upload_storage: storage to use
28+
validators: List of validators to apply
29+
processors: List of validators to apply
30+
upload_type: File class to use, could be
31+
used to set custom File class
32+
multiple: Use this to save multiple files
33+
extra: Extra attributes (driver specific)
34+
headers: Additional request headers,
35+
such as CORS headers. For example:
36+
headers = {'Access-Control-Allow-Origin': 'http://mozilla.com'}.
37+
"""
38+
super().__init__(
39+
*args,
40+
processors=processors,
41+
validators=validators,
42+
headers=headers,
43+
upload_type=upload_type,
44+
multiple=multiple,
45+
extra=extra,
46+
**kwargs, # type: ignore[arg-type]
47+
)
48+
self.upload_storage = upload_storage or DEFAULT_STORAGE_PLACEHOLDER
49+
50+
51+
class ImageField(FileField):
52+
def __init__(
53+
self,
54+
*args: t.Tuple[t.Any],
55+
upload_storage: t.Optional[str] = None,
56+
thumbnail_size: t.Optional[t.Tuple[int, int]] = None,
57+
image_validator: t.Optional[ImageValidator] = None,
58+
validators: t.Optional[t.List[Validator]] = None,
59+
processors: t.Optional[t.List[Processor]] = None,
60+
upload_type: t.Type[File] = File,
61+
multiple: t.Optional[bool] = False,
62+
extra: t.Optional[t.Dict[str, str]] = None,
63+
headers: t.Optional[t.Dict[str, str]] = None,
64+
**kwargs: t.Dict[str, t.Any],
65+
) -> None:
66+
"""Parameters
67+
upload_storage: storage to use
68+
image_validator: ImageField use default image
69+
validator, Use this property to customize it.
70+
thumbnail_size: If set, a thumbnail will be generated
71+
from original image using [ThumbnailGenerator]
72+
[sqlalchemy_file.processors.ThumbnailGenerator]
73+
validators: List of additional validators to apply
74+
processors: List of validators to apply
75+
upload_type: File class to use, could be
76+
used to set custom File class
77+
multiple: Use this to save multiple files
78+
extra: Extra attributes (driver specific).
79+
"""
80+
if validators is None:
81+
validators = []
82+
if image_validator is None:
83+
image_validator = ImageValidator()
84+
if thumbnail_size is not None:
85+
if processors is None:
86+
processors = []
87+
processors.append(ThumbnailGenerator(thumbnail_size))
88+
validators.append(image_validator)
89+
super().__init__(
90+
*args,
91+
upload_storage=upload_storage,
92+
validators=validators,
93+
processors=processors,
94+
upload_type=upload_type,
95+
multiple=multiple,
96+
extra=extra,
97+
headers=headers,
98+
**kwargs,
99+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from sqlalchemy_file.validators import Validator # noqa
2+
from sqlalchemy_file.validators import ImageValidator # noqa
3+
from sqlalchemy_file.validators import SizeValidator # noqa
4+
from sqlalchemy_file.validators import ContentTypeValidator # noqa

0 commit comments

Comments
 (0)