filedepot 0.12.0__py3-none-any.whl
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.
- depot/__init__.py +0 -0
- depot/fields/__init__.py +0 -0
- depot/fields/filters/__init__.py +1 -0
- depot/fields/filters/thumbnails.py +54 -0
- depot/fields/interfaces.py +108 -0
- depot/fields/ming.py +168 -0
- depot/fields/specialized/__init__.py +0 -0
- depot/fields/specialized/image.py +76 -0
- depot/fields/sqlalchemy.py +193 -0
- depot/fields/upload.py +73 -0
- depot/io/__init__.py +1 -0
- depot/io/awss3.py +244 -0
- depot/io/boto3.py +254 -0
- depot/io/gcs.py +203 -0
- depot/io/gridfs.py +128 -0
- depot/io/interfaces.py +192 -0
- depot/io/local.py +176 -0
- depot/io/memory.py +122 -0
- depot/io/utils.py +114 -0
- depot/manager.py +178 -0
- depot/middleware.py +209 -0
- depot/utils.py +18 -0
- depot/validators.py +41 -0
- filedepot-0.12.0.dist-info/LICENSE +21 -0
- filedepot-0.12.0.dist-info/METADATA +301 -0
- filedepot-0.12.0.dist-info/RECORD +28 -0
- filedepot-0.12.0.dist-info/WHEEL +5 -0
- filedepot-0.12.0.dist-info/top_level.txt +1 -0
depot/__init__.py
ADDED
|
File without changes
|
depot/fields/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from depot.io import utils
|
|
2
|
+
from depot.manager import DepotManager
|
|
3
|
+
from ..interfaces import FileFilter
|
|
4
|
+
from PIL import Image
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WithThumbnailFilter(FileFilter):
|
|
9
|
+
"""Uploads a thumbnail together with the file.
|
|
10
|
+
|
|
11
|
+
Takes for granted that the file is an image.
|
|
12
|
+
The resulting uploaded file will provide three additional
|
|
13
|
+
properties named:
|
|
14
|
+
|
|
15
|
+
- ``thumb_X_id`` -> The depot file id
|
|
16
|
+
- ``thumb_X_path`` -> Where the file is available in depot
|
|
17
|
+
- ``thumb_X_url`` -> Where the file is served.
|
|
18
|
+
|
|
19
|
+
Where ``X`` is the resolution specified as ``size`` in the
|
|
20
|
+
filter initialization. By default this is ``(128, 128)``?so
|
|
21
|
+
you will get ``thumb_128x128_id``, ``thumb_128x128_url`` and
|
|
22
|
+
so on.
|
|
23
|
+
|
|
24
|
+
.. warning::
|
|
25
|
+
|
|
26
|
+
Requires Pillow library
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
def __init__(self, size=(128,128), format='PNG'):
|
|
30
|
+
self.thumbnail_size = size
|
|
31
|
+
self.thumbnail_format = format
|
|
32
|
+
|
|
33
|
+
def on_save(self, uploaded_file):
|
|
34
|
+
close_content, content = utils.file_from_content(uploaded_file.original_content)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
thumbnail = Image.open(content)
|
|
38
|
+
thumbnail.thumbnail(self.thumbnail_size, Image.BILINEAR)
|
|
39
|
+
thumbnail = thumbnail.convert('RGBA')
|
|
40
|
+
thumbnail.format = self.thumbnail_format
|
|
41
|
+
|
|
42
|
+
output = BytesIO()
|
|
43
|
+
thumbnail.save(output, self.thumbnail_format)
|
|
44
|
+
output.seek(0)
|
|
45
|
+
|
|
46
|
+
thumb_name = 'thumb_%sx%s' % self.thumbnail_size
|
|
47
|
+
thumb_file_name = '%s.%s' % (thumb_name, self.thumbnail_format.lower())
|
|
48
|
+
thumb_path, thumb_id = uploaded_file.store_content(output, thumb_file_name)
|
|
49
|
+
uploaded_file[thumb_name + '_id'] = thumb_id
|
|
50
|
+
uploaded_file[thumb_name + '_path'] = thumb_path
|
|
51
|
+
uploaded_file[thumb_name + '_url'] = DepotManager.get_middleware().url_for(thumb_path)
|
|
52
|
+
finally:
|
|
53
|
+
if close_content:
|
|
54
|
+
content.close()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from abc import ABCMeta, abstractmethod
|
|
2
|
+
from depot.manager import DepotManager
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FileFilter(object, metaclass=ABCMeta):
|
|
6
|
+
"""Interface that must be implemented by file filters.
|
|
7
|
+
|
|
8
|
+
File filters get executed whenever a file is stored on the database
|
|
9
|
+
using one of the supported fields. Can be used to add additional data
|
|
10
|
+
to the stored file or change it. When file filters are run the file
|
|
11
|
+
has already been stored.
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def on_save(self, uploaded_file): # pragma: no cover
|
|
16
|
+
"""Filters are required to provide their own implementation"""
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DepotFileInfo(dict, metaclass=ABCMeta):
|
|
21
|
+
"""Keeps information on a content related to a specific depot.
|
|
22
|
+
|
|
23
|
+
By itself the DepotFileInfo does nothing, it is required to implement
|
|
24
|
+
a :meth:`process_content` method that actually saves inside the
|
|
25
|
+
file info the information related to the content. The only information
|
|
26
|
+
which is saved by default is the depot name itself.
|
|
27
|
+
|
|
28
|
+
It is a specialized dictionary that provides also attribute style access,
|
|
29
|
+
the dictionary parent permits easy encoding/decoding to most marshalling
|
|
30
|
+
systems.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
def __init__(self, content, depot_name=None):
|
|
34
|
+
super(DepotFileInfo, self).__init__()
|
|
35
|
+
self._thaw()
|
|
36
|
+
|
|
37
|
+
if isinstance(content, dict):
|
|
38
|
+
object.__setattr__(self, 'original_content', None)
|
|
39
|
+
self.update(content)
|
|
40
|
+
else:
|
|
41
|
+
object.__setattr__(self, 'original_content', content)
|
|
42
|
+
if depot_name is None:
|
|
43
|
+
depot_name = DepotManager.get_default()
|
|
44
|
+
|
|
45
|
+
depot_name = DepotManager.resolve_alias(depot_name)
|
|
46
|
+
if not depot_name:
|
|
47
|
+
raise ValueError('Storage has not been found in DEPOT')
|
|
48
|
+
|
|
49
|
+
self['depot_name'] = depot_name
|
|
50
|
+
self['files'] = []
|
|
51
|
+
self.process_content(content)
|
|
52
|
+
|
|
53
|
+
self._freeze()
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def process_content(self, content, filename=None, content_type=None): # pragma: no cover
|
|
57
|
+
"""Process content in the given depot.
|
|
58
|
+
|
|
59
|
+
This is implemented by subclasses to provide some kind of behaviour on the
|
|
60
|
+
content in the related Depot. The default implementation is provided by
|
|
61
|
+
:class:`depot.fields.upload.UploadedFile` which stores the content into
|
|
62
|
+
the depot.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
def __getitem__(self, key):
|
|
68
|
+
return dict.__getitem__(self, key)
|
|
69
|
+
|
|
70
|
+
def __getattr__(self, name):
|
|
71
|
+
try:
|
|
72
|
+
return self[name]
|
|
73
|
+
except KeyError:
|
|
74
|
+
raise AttributeError(name)
|
|
75
|
+
|
|
76
|
+
def __setitem__(self, key, value):
|
|
77
|
+
if object.__getattribute__(self, '_frozen'):
|
|
78
|
+
raise TypeError('Already saved files are immutable')
|
|
79
|
+
return dict.__setitem__(self, key, value)
|
|
80
|
+
|
|
81
|
+
__setattr__ = __setitem__
|
|
82
|
+
|
|
83
|
+
def __delattr__(self, name):
|
|
84
|
+
if object.__getattribute__(self, '_frozen'):
|
|
85
|
+
raise TypeError('Already saved files are immutable')
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
del self[name]
|
|
89
|
+
except KeyError:
|
|
90
|
+
raise AttributeError(name)
|
|
91
|
+
|
|
92
|
+
def __delitem__(self, key):
|
|
93
|
+
if object.__getattribute__(self, '_frozen'):
|
|
94
|
+
raise TypeError('Already saved files are immutable')
|
|
95
|
+
dict.__delitem__(self, key)
|
|
96
|
+
|
|
97
|
+
def _apply_filters(self, filters):
|
|
98
|
+
if self.original_content is not None:
|
|
99
|
+
self._thaw()
|
|
100
|
+
for filt in filters:
|
|
101
|
+
filt.on_save(self)
|
|
102
|
+
self._freeze()
|
|
103
|
+
|
|
104
|
+
def _freeze(self):
|
|
105
|
+
object.__setattr__(self, '_frozen', True)
|
|
106
|
+
|
|
107
|
+
def _thaw(self):
|
|
108
|
+
object.__setattr__(self, '_frozen', False)
|
depot/fields/ming.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from ming.odm import mapper
|
|
2
|
+
from ming.odm.property import FieldProperty
|
|
3
|
+
from ming.odm.base import session, state, ObjectState
|
|
4
|
+
from ming.odm.odmsession import SessionExtension
|
|
5
|
+
from ming.schema import Anything
|
|
6
|
+
|
|
7
|
+
from depot.manager import DepotManager
|
|
8
|
+
from .upload import UploadedFile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _UploadedFileSchema(Anything):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UploadedFileProperty(FieldProperty):
|
|
16
|
+
"""Provides support for storing attachments to **Ming** MongoDB models.
|
|
17
|
+
|
|
18
|
+
``UploadedFileProperty`` can be used as a field type to store files
|
|
19
|
+
into the model. The actual file itself will be uploaded to the
|
|
20
|
+
default Storage, and only the :class:`depot.fields.upload.UploadedFile`
|
|
21
|
+
information will be stored on the database.
|
|
22
|
+
|
|
23
|
+
The ``UploadedFileProperty`` is UnitOfWork aware, so it will delete
|
|
24
|
+
every uploaded file whenever unit of work is flushed and deletes a Document
|
|
25
|
+
that stored files or changes the field of a document storing files. This is
|
|
26
|
+
the reason you should never associate the same :class:`depot.fields.upload.UploadedFile`
|
|
27
|
+
to two different ``UploadedFileProperty``, otherwise you might delete a file
|
|
28
|
+
already used by another document. It is usually best to just set the ``file``
|
|
29
|
+
or ``bytes`` as content of the column and let the ``UploadedFileProperty``
|
|
30
|
+
create the :class:`depot.fields.upload.UploadedFile` by itself whenever it's content is set.
|
|
31
|
+
|
|
32
|
+
.. warning::
|
|
33
|
+
|
|
34
|
+
As the Ming UnitOfWork does not notify any event in case it gets cleared instead
|
|
35
|
+
of being flushed all the files uploaded before clearing the unit of work will be
|
|
36
|
+
already uploaded but won't have a document referencing them anymore, so DEPOT will
|
|
37
|
+
be unable to delete them for you.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
def __init__(self, filters=tuple(), upload_type=UploadedFile, upload_storage=None):
|
|
41
|
+
FieldProperty.__init__(self, _UploadedFileSchema())
|
|
42
|
+
self._filters = filters
|
|
43
|
+
self._upload_type = upload_type
|
|
44
|
+
self._upload_storage = upload_storage
|
|
45
|
+
|
|
46
|
+
def __set__(self, instance, value):
|
|
47
|
+
if value is not None and not isinstance(value, UploadedFile):
|
|
48
|
+
upload_type = self._upload_type
|
|
49
|
+
value = upload_type(value, self._upload_storage)
|
|
50
|
+
|
|
51
|
+
if isinstance(value, UploadedFile):
|
|
52
|
+
value._apply_filters(self._filters)
|
|
53
|
+
|
|
54
|
+
old_value = self.__get__(instance, instance.__class__)
|
|
55
|
+
DepotExtension.get_depot_history(instance).swap(old_value, value)
|
|
56
|
+
return FieldProperty.__set__(self, instance, value)
|
|
57
|
+
|
|
58
|
+
def __get__(self, instance, owner=None):
|
|
59
|
+
try:
|
|
60
|
+
value = FieldProperty.__get__(self, instance, owner)
|
|
61
|
+
except AttributeError:
|
|
62
|
+
value = None
|
|
63
|
+
|
|
64
|
+
if not value:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
if instance is None:
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
return self._upload_type(value)
|
|
71
|
+
|
|
72
|
+
"""
|
|
73
|
+
# Looks like this should do nothing on ming.
|
|
74
|
+
def __delete__(self, instance, owner=None):
|
|
75
|
+
old_value = self.__get__(instance, instance.__class__)
|
|
76
|
+
DepotExtension.get_depot_history(instance).delete(old_value)
|
|
77
|
+
return FieldProperty.__delete__(self, instance, owner)
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DepotExtension(SessionExtension):
|
|
82
|
+
"""Extends the Ming Session to track files.
|
|
83
|
+
|
|
84
|
+
Deletes old files when an entry gets removed or replaced,
|
|
85
|
+
apply this as a Ming ``SessionExtension`` according to Ming
|
|
86
|
+
documentation.
|
|
87
|
+
"""
|
|
88
|
+
@classmethod
|
|
89
|
+
def get_depot_history(cls, instance):
|
|
90
|
+
istate = state(instance)
|
|
91
|
+
if not hasattr(istate, '_depot_history'):
|
|
92
|
+
istate._depot_history = _DepotHistory()
|
|
93
|
+
return istate._depot_history
|
|
94
|
+
|
|
95
|
+
def _check_object_deleted(self, obj):
|
|
96
|
+
hist = self.get_depot_history(obj)
|
|
97
|
+
if state(obj).status == ObjectState.deleted:
|
|
98
|
+
for prop in mapper(obj).properties:
|
|
99
|
+
if isinstance(prop, UploadedFileProperty):
|
|
100
|
+
current_value = prop.__get__(obj, obj.__class__)
|
|
101
|
+
hist.delete(current_value)
|
|
102
|
+
self._flush_object(obj)
|
|
103
|
+
|
|
104
|
+
def _flush_object(self, obj):
|
|
105
|
+
history = self.get_depot_history(obj)
|
|
106
|
+
for entry in history.deleted:
|
|
107
|
+
depot, fileid = entry.split('/', 1)
|
|
108
|
+
depot = DepotManager.get(depot)
|
|
109
|
+
depot.delete(fileid)
|
|
110
|
+
history.clear()
|
|
111
|
+
|
|
112
|
+
def before_flush(self, obj=None):
|
|
113
|
+
if obj:
|
|
114
|
+
self._check_object_deleted(obj)
|
|
115
|
+
else:
|
|
116
|
+
for class_, id_, obj in self.session.imap:
|
|
117
|
+
self._check_object_deleted(obj)
|
|
118
|
+
|
|
119
|
+
def after_flush(self, obj=None):
|
|
120
|
+
if obj:
|
|
121
|
+
self._flush_object(obj)
|
|
122
|
+
else:
|
|
123
|
+
for class_, id_, obj in self.session.imap:
|
|
124
|
+
self._flush_object(obj)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _DepotHistory(object):
|
|
128
|
+
def __init__(self):
|
|
129
|
+
self.clear()
|
|
130
|
+
|
|
131
|
+
def _extract_files(self, obj):
|
|
132
|
+
return obj['files']
|
|
133
|
+
|
|
134
|
+
def add(self, obj):
|
|
135
|
+
if obj is None:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
files = self._extract_files(obj)
|
|
139
|
+
self.deleted.difference_update(obj)
|
|
140
|
+
self.new.update(files)
|
|
141
|
+
|
|
142
|
+
def delete(self, obj):
|
|
143
|
+
if obj is None:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
files = self._extract_files(obj)
|
|
147
|
+
self.new.difference_update(obj)
|
|
148
|
+
self.deleted.update(files)
|
|
149
|
+
|
|
150
|
+
def swap(self, old, new):
|
|
151
|
+
self.delete(old)
|
|
152
|
+
self.add(new)
|
|
153
|
+
|
|
154
|
+
def clear(self):
|
|
155
|
+
self.deleted = set()
|
|
156
|
+
self.new = set()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
try: # pragma: no cover
|
|
160
|
+
from sprox.mg.widgetselector import MingWidgetSelector
|
|
161
|
+
from tw2.forms import FileField as TW2FileField
|
|
162
|
+
MingWidgetSelector.default_widgets.setdefault(_UploadedFileSchema, TW2FileField)
|
|
163
|
+
|
|
164
|
+
from sprox.mg.validatorselector import MingValidatorSelector
|
|
165
|
+
from ..validators import TW2FileIntentValidator
|
|
166
|
+
MingValidatorSelector.default_validators.setdefault(_UploadedFileSchema, TW2FileIntentValidator)
|
|
167
|
+
except ImportError: # pragma: no cover
|
|
168
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from depot.io import utils
|
|
2
|
+
from depot.manager import DepotManager
|
|
3
|
+
from ..upload import UploadedFile
|
|
4
|
+
from depot.io.interfaces import FileStorage
|
|
5
|
+
from PIL import Image
|
|
6
|
+
from depot.io.utils import INMEMORY_FILESIZE
|
|
7
|
+
from tempfile import SpooledTemporaryFile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UploadedImageWithThumb(UploadedFile):
|
|
11
|
+
"""Uploads an Image with thumbnail.
|
|
12
|
+
|
|
13
|
+
The default thumbnail format and size are PNG@128x128, those can be changed
|
|
14
|
+
by inheriting the ``UploadedImageWithThumb`` and replacing the
|
|
15
|
+
``thumbnail_format`` and ``thumbnail_size`` class properties.
|
|
16
|
+
|
|
17
|
+
The Thumbnail file is accessible as ``.thumb_file`` while the
|
|
18
|
+
thumbnail url is ``.thumb_url``.
|
|
19
|
+
|
|
20
|
+
.. warning::
|
|
21
|
+
|
|
22
|
+
Requires Pillow library
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
max_size = 1024
|
|
27
|
+
thumbnail_format = 'PNG'
|
|
28
|
+
thumbnail_size = (128, 128)
|
|
29
|
+
|
|
30
|
+
def process_content(self, content, filename=None, content_type=None):
|
|
31
|
+
orig_content = content
|
|
32
|
+
temporary_content = False
|
|
33
|
+
__, filename, content_type = FileStorage.fileinfo(orig_content)
|
|
34
|
+
close_content, content = utils.file_from_content(content)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
uploaded_image = Image.open(content)
|
|
38
|
+
if max(uploaded_image.size) >= self.max_size:
|
|
39
|
+
uploaded_image.thumbnail((self.max_size, self.max_size), Image.BILINEAR)
|
|
40
|
+
content = SpooledTemporaryFile(INMEMORY_FILESIZE)
|
|
41
|
+
close_content = True
|
|
42
|
+
uploaded_image.save(content, uploaded_image.format)
|
|
43
|
+
|
|
44
|
+
content.seek(0)
|
|
45
|
+
super(UploadedImageWithThumb, self).process_content(content, filename, content_type)
|
|
46
|
+
finally:
|
|
47
|
+
if close_content:
|
|
48
|
+
content.close()
|
|
49
|
+
|
|
50
|
+
thumbnail = uploaded_image.copy()
|
|
51
|
+
thumbnail.thumbnail(self.thumbnail_size, Image.LANCZOS)
|
|
52
|
+
thumbnail = thumbnail.convert('RGBA')
|
|
53
|
+
thumbnail.format = self.thumbnail_format
|
|
54
|
+
|
|
55
|
+
with SpooledTemporaryFile(INMEMORY_FILESIZE) as output:
|
|
56
|
+
thumbnail.save(output, self.thumbnail_format)
|
|
57
|
+
output.seek(0)
|
|
58
|
+
|
|
59
|
+
thumb_path, thumb_id = self.store_content(output,
|
|
60
|
+
'thumb.%s' % self.thumbnail_format.lower())
|
|
61
|
+
self['thumb_id'] = thumb_id
|
|
62
|
+
self['thumb_path'] = thumb_path
|
|
63
|
+
|
|
64
|
+
thumbnail_file = self.thumb_file
|
|
65
|
+
self['_thumb_public_url'] = thumbnail_file.public_url
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def thumb_file(self):
|
|
69
|
+
return self.depot.get(self.thumb_id)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def thumb_url(self):
|
|
73
|
+
public_url = self['_thumb_public_url']
|
|
74
|
+
if public_url:
|
|
75
|
+
return public_url
|
|
76
|
+
return DepotManager.get_middleware().url_for(self['thumb_path'])
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import types
|
|
4
|
+
from sqlalchemy import event
|
|
5
|
+
from sqlalchemy import orm
|
|
6
|
+
from sqlalchemy import inspect
|
|
7
|
+
from sqlalchemy.orm import ColumnProperty
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
from sqlalchemy.orm.attributes import get_history
|
|
10
|
+
|
|
11
|
+
from depot.manager import DepotManager
|
|
12
|
+
from .upload import UploadedFile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UploadedFileField(types.TypeDecorator):
|
|
16
|
+
"""Provides support for storing attachments to **SQLAlchemy** models.
|
|
17
|
+
|
|
18
|
+
``UploadedFileField`` can be used as a Column type to store files
|
|
19
|
+
into the model. The actual file itself will be uploaded to the
|
|
20
|
+
default Storage, and only the :class:`depot.fields.upload.UploadedFile`
|
|
21
|
+
information will be stored on the database.
|
|
22
|
+
|
|
23
|
+
The ``UploadedFileField`` is transaction aware, so it will delete
|
|
24
|
+
every uploaded file whenever the transaction is rolled back and will
|
|
25
|
+
delete any old file whenever the transaction is committed. This is
|
|
26
|
+
the reason you should never associate the same :class:`depot.fields.upload.UploadedFile`
|
|
27
|
+
to two different ``UploadedFileField``, otherwise you might delete a file
|
|
28
|
+
already used by another model. It is usually best to just set the ``file``
|
|
29
|
+
or ``bytes`` as content of the column and let the ``UploadedFileField``
|
|
30
|
+
create the :class:`depot.fields.upload.UploadedFile` by itself whenever it's content is set.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
impl = types.Unicode
|
|
34
|
+
cache_ok = False
|
|
35
|
+
|
|
36
|
+
def __init__(self, filters=tuple(), upload_type=UploadedFile, upload_storage=None, *args, **kw):
|
|
37
|
+
super(UploadedFileField, self).__init__(*args, **kw)
|
|
38
|
+
self._filters = filters
|
|
39
|
+
self._upload_type = upload_type
|
|
40
|
+
self._upload_storage = upload_storage
|
|
41
|
+
|
|
42
|
+
def load_dialect_impl(self, dialect):
|
|
43
|
+
return dialect.type_descriptor(types.VARCHAR(4000))
|
|
44
|
+
|
|
45
|
+
def process_bind_param(self, value, dialect):
|
|
46
|
+
if not value:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
if not isinstance(value, self._upload_type):
|
|
50
|
+
raise ValueError('AttachmentField requires %s, '
|
|
51
|
+
'got %s instead' % (self._upload_type, type(value)))
|
|
52
|
+
|
|
53
|
+
return value.encode()
|
|
54
|
+
|
|
55
|
+
def process_result_value(self, value, dialect):
|
|
56
|
+
if not value:
|
|
57
|
+
return None
|
|
58
|
+
return self._upload_type.decode(value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _SQLAMutationTracker(object):
|
|
62
|
+
mapped_entities = {}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def _field_set(cls, target, value, oldvalue, initiator):
|
|
66
|
+
if value is None or isinstance(value, UploadedFile):
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
inspection = inspect(target)
|
|
70
|
+
set_property = inspection.mapper.get_property(initiator.key)
|
|
71
|
+
column_type = set_property.columns[0].type
|
|
72
|
+
assert(isinstance(column_type, UploadedFileField))
|
|
73
|
+
|
|
74
|
+
upload_type = column_type._upload_type
|
|
75
|
+
value = upload_type(value, column_type._upload_storage)
|
|
76
|
+
value._apply_filters(column_type._filters)
|
|
77
|
+
|
|
78
|
+
# Mark newly added files on assignment, so that in case of a rollback
|
|
79
|
+
# without going through flush we have track of the files
|
|
80
|
+
# that need to be rolled back.
|
|
81
|
+
session = inspection.session
|
|
82
|
+
if session is not None:
|
|
83
|
+
session._depot_new = getattr(session, '_depot_new', set())
|
|
84
|
+
session._depot_new.update(value.files)
|
|
85
|
+
else:
|
|
86
|
+
# If object is not attached to a session preserve the files
|
|
87
|
+
# until it gets added to one.
|
|
88
|
+
target._depot_new = getattr(target, '_depot_new', set())
|
|
89
|
+
target._depot_new.update(value.files)
|
|
90
|
+
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def _mapper_configured(cls, mapper, class_):
|
|
95
|
+
for mapper_property in mapper.iterate_properties:
|
|
96
|
+
if isinstance(mapper_property, ColumnProperty):
|
|
97
|
+
for idx, col in enumerate(mapper_property.columns):
|
|
98
|
+
if isinstance(col.type, UploadedFileField):
|
|
99
|
+
if idx > 0:
|
|
100
|
+
# Not clear when this might happen, but ColumnProperty can have
|
|
101
|
+
# multiple columns assigned. We should probably take the first one
|
|
102
|
+
# as is done by ColumnProperty.expression but there is no guarantee
|
|
103
|
+
# that it would be the right thing to do.
|
|
104
|
+
raise TypeError('UploadedFileField currently supports a single column')
|
|
105
|
+
cls.mapped_entities.setdefault(class_, []).append(mapper_property.key)
|
|
106
|
+
event.listen(mapper_property, 'set', cls._field_set, retval=True, propagate=True)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def _session_rollback(cls, session, previous_transaction):
|
|
110
|
+
if hasattr(session, '_depot_old'):
|
|
111
|
+
del session._depot_old
|
|
112
|
+
if hasattr(session, '_depot_new'):
|
|
113
|
+
for entry in session._depot_new:
|
|
114
|
+
depot, fileid = entry.split('/', 1)
|
|
115
|
+
depot = DepotManager.get(depot)
|
|
116
|
+
depot.delete(fileid)
|
|
117
|
+
del session._depot_new
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def _session_committed(cls, session):
|
|
121
|
+
if hasattr(session, '_depot_old'):
|
|
122
|
+
for entry in session._depot_old:
|
|
123
|
+
depot, fileid = entry.split('/', 1)
|
|
124
|
+
depot = DepotManager.get(depot)
|
|
125
|
+
depot.delete(fileid)
|
|
126
|
+
del session._depot_old
|
|
127
|
+
if hasattr(session, '_depot_new'):
|
|
128
|
+
del session._depot_new
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def _session_flush(cls, session, flush_context, instances):
|
|
132
|
+
for obj in session.deleted:
|
|
133
|
+
class_ = obj.__class__
|
|
134
|
+
tracked_columns = cls.mapped_entities.get(class_, tuple())
|
|
135
|
+
for col in tracked_columns:
|
|
136
|
+
value = getattr(obj, col)
|
|
137
|
+
if value is not None:
|
|
138
|
+
session._depot_old = getattr(session, '_depot_old', set())
|
|
139
|
+
session._depot_old.update(value.files)
|
|
140
|
+
|
|
141
|
+
for obj in session.new.union(session.dirty):
|
|
142
|
+
class_ = obj.__class__
|
|
143
|
+
tracked_columns = cls.mapped_entities.get(class_, tuple())
|
|
144
|
+
for col in tracked_columns:
|
|
145
|
+
history = get_history(obj, col)
|
|
146
|
+
added_files = itertools.chain(*(f.files for f in history.added
|
|
147
|
+
if f is not None))
|
|
148
|
+
deleted_files = itertools.chain(*(f.files for f in history.deleted
|
|
149
|
+
if f is not None))
|
|
150
|
+
|
|
151
|
+
session._depot_new = getattr(session, '_depot_new', set())
|
|
152
|
+
session._depot_new.update(added_files)
|
|
153
|
+
session._depot_old = getattr(session, '_depot_old', set())
|
|
154
|
+
session._depot_old.update(deleted_files)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def _session_deleted_obj(cls, session, obj):
|
|
158
|
+
# Tracking indirectly deleted objects
|
|
159
|
+
# a relationship.remove, or a query().filter().delete()
|
|
160
|
+
class_ = obj.__class__
|
|
161
|
+
tracked_columns = cls.mapped_entities.get(class_, tuple())
|
|
162
|
+
for col in tracked_columns:
|
|
163
|
+
value = getattr(obj, col)
|
|
164
|
+
if value is not None:
|
|
165
|
+
session._depot_old = getattr(session, '_depot_old', set())
|
|
166
|
+
session._depot_old.update(value.files)
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def _session_attached(cls, session, instance):
|
|
170
|
+
session._depot_new = getattr(session, '_depot_new', set())
|
|
171
|
+
session._depot_new.update(getattr(instance, '_depot_new', set()))
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def setup(cls):
|
|
175
|
+
event.listen(orm.Mapper, 'mapper_configured', cls._mapper_configured)
|
|
176
|
+
event.listen(Session, 'after_soft_rollback', cls._session_rollback)
|
|
177
|
+
event.listen(Session, 'after_commit', cls._session_committed)
|
|
178
|
+
event.listen(Session, 'before_attach', cls._session_attached)
|
|
179
|
+
event.listen(Session, 'before_flush', cls._session_flush)
|
|
180
|
+
event.listen(Session, 'persistent_to_deleted', cls._session_deleted_obj)
|
|
181
|
+
|
|
182
|
+
_SQLAMutationTracker.setup()
|
|
183
|
+
|
|
184
|
+
try: # pragma: no cover
|
|
185
|
+
from sprox.sa.widgetselector import SAWidgetSelector
|
|
186
|
+
from tw2.forms import FileField as TW2FileField
|
|
187
|
+
SAWidgetSelector.default_widgets.setdefault(UploadedFileField, TW2FileField)
|
|
188
|
+
|
|
189
|
+
from sprox.sa.validatorselector import SAValidatorSelector
|
|
190
|
+
from ..validators import TW2FileIntentValidator
|
|
191
|
+
SAValidatorSelector.default_validators.setdefault(UploadedFileField, TW2FileIntentValidator)
|
|
192
|
+
except ImportError: # pragma: no cover
|
|
193
|
+
pass
|
depot/fields/upload.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from depot.manager import DepotManager
|
|
2
|
+
from .interfaces import DepotFileInfo
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UploadedFile(DepotFileInfo):
|
|
7
|
+
"""Simple :class:`depot.fields.interfaces.DepotFileInfo` implementation that stores files.
|
|
8
|
+
|
|
9
|
+
Takes a file as content and uploads it to the depot while saving around
|
|
10
|
+
most file information. Pay attention that if the file gets replaced
|
|
11
|
+
through depot manually the ``UploadedFile`` will continue to have the old data.
|
|
12
|
+
|
|
13
|
+
Also provides support for encoding/decoding using JSON for storage inside
|
|
14
|
+
databases as a plain string.
|
|
15
|
+
|
|
16
|
+
Default attributes provided for all ``UploadedFile`` include:
|
|
17
|
+
- filename - This is the name of the uploaded file
|
|
18
|
+
- file_id - This is the ID of the uploaded file
|
|
19
|
+
- path - This is a depot_name/file_id path which can
|
|
20
|
+
be used with :meth:`DepotManager.get_file` to retrieve the file
|
|
21
|
+
- content_type - This is the content type of the uploaded file
|
|
22
|
+
- uploaded_at - This is the upload date in YYYY-MM-DD HH:MM:SS format
|
|
23
|
+
- url - Public url of the uploaded file
|
|
24
|
+
- file - The :class:`depot.io.interfaces.StoredFile` instance of the stored file
|
|
25
|
+
"""
|
|
26
|
+
def process_content(self, content, filename=None, content_type=None):
|
|
27
|
+
"""Standard implementation of :meth:`.DepotFileInfo.process_content`
|
|
28
|
+
|
|
29
|
+
This is the standard depot implementation of files upload, it will
|
|
30
|
+
store the file on the default depot and will provide the standard
|
|
31
|
+
attributes.
|
|
32
|
+
|
|
33
|
+
Subclasses will need to call this method to ensure the standard
|
|
34
|
+
set of attributes is provided.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
file_path, file_id = self.store_content(content, filename, content_type)
|
|
38
|
+
self['file_id'] = file_id
|
|
39
|
+
self['path'] = file_path
|
|
40
|
+
|
|
41
|
+
saved_file = self.file
|
|
42
|
+
self['filename'] = saved_file.filename
|
|
43
|
+
self['content_type'] = saved_file.content_type
|
|
44
|
+
self['uploaded_at'] = saved_file.last_modified.strftime('%Y-%m-%d %H:%M:%S')
|
|
45
|
+
self['_public_url'] = saved_file.public_url
|
|
46
|
+
|
|
47
|
+
def store_content(self, content, filename=None, content_type=None):
|
|
48
|
+
file_id = self.depot.create(content, filename, content_type)
|
|
49
|
+
file_path = '%s/%s' % (self.depot_name, file_id)
|
|
50
|
+
self.files.append(file_path)
|
|
51
|
+
return file_path, file_id
|
|
52
|
+
|
|
53
|
+
def encode(self):
|
|
54
|
+
return json.dumps(self)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def decode(cls, data):
|
|
58
|
+
return cls(json.loads(data))
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def url(self):
|
|
62
|
+
public_url = self['_public_url']
|
|
63
|
+
if public_url:
|
|
64
|
+
return public_url
|
|
65
|
+
return DepotManager.get_middleware().url_for(self['path'])
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def depot(self):
|
|
69
|
+
return DepotManager.get(self.depot_name)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def file(self):
|
|
73
|
+
return self.depot.get(self.file_id)
|
depot/io/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|