django-file-tools 0.1.0__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.
- django_file_tools-0.1.0/LICENSE +21 -0
- django_file_tools-0.1.0/PKG-INFO +35 -0
- django_file_tools-0.1.0/README.md +13 -0
- django_file_tools-0.1.0/django_file_tools/__init__.py +0 -0
- django_file_tools-0.1.0/django_file_tools/constants.py +1 -0
- django_file_tools-0.1.0/django_file_tools/model_fields.py +66 -0
- django_file_tools-0.1.0/django_file_tools/pathers.py +55 -0
- django_file_tools-0.1.0/django_file_tools/s3.py +278 -0
- django_file_tools-0.1.0/django_file_tools/serializer_fields.py +46 -0
- django_file_tools-0.1.0/django_file_tools/serializers.py +11 -0
- django_file_tools-0.1.0/django_file_tools/storage_backends.py +61 -0
- django_file_tools-0.1.0/django_file_tools/views.py +58 -0
- django_file_tools-0.1.0/pyproject.toml +26 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 enricobarzetti
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-file-tools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tools for working with files in Django
|
|
5
|
+
Home-page: https://github.com/enricobarzetti/django-file-tools
|
|
6
|
+
License: MIT
|
|
7
|
+
Author: Enrico Barzetti
|
|
8
|
+
Requires-Python: >=3.9,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Dist: boto3 (>=1.34.12,<2.0.0)
|
|
16
|
+
Requires-Dist: django (>=3.0)
|
|
17
|
+
Requires-Dist: django-environ (>=0.11.2,<0.12.0)
|
|
18
|
+
Requires-Dist: django-storages (>=1.14,<2.0)
|
|
19
|
+
Requires-Dist: djangorestframework (>=3.12.0,<4.0.0)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
Run MinIO:
|
|
23
|
+
|
|
24
|
+
`MINIO_ROOT_USER=admin MINIO_ROOT_PASSWORD=password ./minio server temp --address ":9000" --console-address ":9001"`
|
|
25
|
+
|
|
26
|
+
Go to the MinIO console and generate access keys. Set values in .env:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
MINIO_ENDPOINT=http://127.0.0.1:9000
|
|
30
|
+
MINIO_ACCESS_KEY=value
|
|
31
|
+
MINIO_SECRET_ACCESS_KEY=value
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When `MINIO_ENDPOINT` is set, MinIO will be used instead of S3.
|
|
35
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Run MinIO:
|
|
2
|
+
|
|
3
|
+
`MINIO_ROOT_USER=admin MINIO_ROOT_PASSWORD=password ./minio server temp --address ":9000" --console-address ":9001"`
|
|
4
|
+
|
|
5
|
+
Go to the MinIO console and generate access keys. Set values in .env:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
MINIO_ENDPOINT=http://127.0.0.1:9000
|
|
9
|
+
MINIO_ACCESS_KEY=value
|
|
10
|
+
MINIO_SECRET_ACCESS_KEY=value
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
When `MINIO_ENDPOINT` is set, MinIO will be used instead of S3.
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
TEMP_MARKER = 'temp'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from pathlib import PurePath
|
|
2
|
+
|
|
3
|
+
from django.db.models.fields import files
|
|
4
|
+
|
|
5
|
+
from django_file_tools.constants import TEMP_MARKER
|
|
6
|
+
from django_file_tools.s3 import get_file_without_prefix
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FieldFile(files.FieldFile):
|
|
10
|
+
def copy(self, original, save=True, tags=None):
|
|
11
|
+
path = PurePath(original)
|
|
12
|
+
new_name = path.parts[-1]
|
|
13
|
+
new_name = self.field.generate_filename(self.instance, new_name)
|
|
14
|
+
self.name = self.storage.copy(original, new_name, max_length=self.field.max_length, tags=tags)
|
|
15
|
+
setattr(self.instance, self.field.name, self.name)
|
|
16
|
+
|
|
17
|
+
# Save the object because it has changed, unless save is False
|
|
18
|
+
if save:
|
|
19
|
+
self.instance.save()
|
|
20
|
+
|
|
21
|
+
def copy_from_other_bucket(self, bucket_name, key, prefix=None, save=True, tags=None):
|
|
22
|
+
new_name = get_file_without_prefix(key, prefix)
|
|
23
|
+
new_name = self.field.generate_filename(self.instance, new_name)
|
|
24
|
+
self.name = self.storage.copy_from_other_bucket(bucket_name, key, new_name, max_length=self.field.max_length, tags=tags)
|
|
25
|
+
setattr(self.instance, self.field.name, self.name)
|
|
26
|
+
|
|
27
|
+
# Save the object because it has changed, unless save is False
|
|
28
|
+
if save:
|
|
29
|
+
self.instance.save()
|
|
30
|
+
|
|
31
|
+
def replace(self, save=True, tags=None):
|
|
32
|
+
original = self.name
|
|
33
|
+
self.copy(original, save, tags)
|
|
34
|
+
self.storage.delete(original)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def url(self):
|
|
38
|
+
self._require_file()
|
|
39
|
+
method_name = f'get_{self.field.name}_filename'
|
|
40
|
+
parameters = None
|
|
41
|
+
if hasattr(self.instance, method_name):
|
|
42
|
+
filename = getattr(self.instance, method_name)()
|
|
43
|
+
filename = self.storage.generate_filename(filename)
|
|
44
|
+
parameters = {'ResponseContentDisposition': f'attachment; filename={filename}'}
|
|
45
|
+
return self.storage.url(self.name, parameters=parameters)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FileField(files.FileField):
|
|
49
|
+
attr_class = FieldFile
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def copy_from_temp_storage(instance, tags=None):
|
|
53
|
+
# If tags is not passed then clear them
|
|
54
|
+
if tags is None:
|
|
55
|
+
tags = {}
|
|
56
|
+
|
|
57
|
+
save = False
|
|
58
|
+
for field in instance._meta.fields:
|
|
59
|
+
if isinstance(field, FileField):
|
|
60
|
+
file = getattr(instance, field.name)
|
|
61
|
+
if file.name != '':
|
|
62
|
+
if PurePath(file.name).parts[0].startswith(TEMP_MARKER):
|
|
63
|
+
file.replace(save=False, tags=tags)
|
|
64
|
+
save = True
|
|
65
|
+
if save:
|
|
66
|
+
instance.save()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from pathlib import PurePath
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Pather:
|
|
6
|
+
"""
|
|
7
|
+
class AnalyzeEditingInputFilePather(AnalyzeEditingFilesPather):
|
|
8
|
+
@staticmethod
|
|
9
|
+
def get_constructor_kwargs(instance):
|
|
10
|
+
return {'instance': instance}
|
|
11
|
+
|
|
12
|
+
def __init__(self, instance):
|
|
13
|
+
self.instance = instance
|
|
14
|
+
|
|
15
|
+
def base(self):
|
|
16
|
+
return PurePath(f'my_model/{self.instance.pk}/')
|
|
17
|
+
|
|
18
|
+
def input_dir(self):
|
|
19
|
+
return self.base() / PurePath('inputs')
|
|
20
|
+
|
|
21
|
+
def input_file(self, instance, filename):
|
|
22
|
+
return self.input_dir() / PurePath(filename)
|
|
23
|
+
|
|
24
|
+
@AnalyzeEditingInputFilePather.upload_to('input_file')
|
|
25
|
+
def location_input():
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
class UploadedFile(models.Model):
|
|
29
|
+
content = FileField(blank=True, upload_to=location_input, max_length=1000)
|
|
30
|
+
"""
|
|
31
|
+
@classmethod
|
|
32
|
+
def make_upload_to_callable(cls, method):
|
|
33
|
+
def f(instance, filename):
|
|
34
|
+
kwargs = cls.get_constructor_kwargs(instance)
|
|
35
|
+
pather = cls(**kwargs)
|
|
36
|
+
path = getattr(pather, method)(instance, filename)
|
|
37
|
+
return str(path)
|
|
38
|
+
return f
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def upload_to(cls, method):
|
|
42
|
+
def decorator(h):
|
|
43
|
+
@wraps(h)
|
|
44
|
+
def f(instance, filename):
|
|
45
|
+
callable = cls.make_upload_to_callable(method)
|
|
46
|
+
return callable(instance, filename)
|
|
47
|
+
return f
|
|
48
|
+
return decorator
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def get_constructor_kwargs(instance):
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def base(self):
|
|
55
|
+
return PurePath('/')
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import errno
|
|
2
|
+
import os
|
|
3
|
+
import posixpath
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import PurePath
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
from botocore.exceptions import ClientError
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
|
|
11
|
+
from django_file_tools.constants import TEMP_MARKER
|
|
12
|
+
|
|
13
|
+
RETENTION = 'retention'
|
|
14
|
+
EXPIRE_FAST = 'expire_fast'
|
|
15
|
+
EXPIRE_SLOW = 'expire_slow'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_client_resource():
|
|
19
|
+
client = boto3.client(
|
|
20
|
+
service_name='s3',
|
|
21
|
+
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
|
22
|
+
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
|
23
|
+
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
|
|
24
|
+
)
|
|
25
|
+
resource = boto3.resource(
|
|
26
|
+
service_name='s3',
|
|
27
|
+
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
|
28
|
+
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
|
29
|
+
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
|
|
30
|
+
)
|
|
31
|
+
return client, resource
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
client, resource = get_client_resource()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def reset_bucket(bucket):
|
|
38
|
+
bucket = resource.Bucket(bucket)
|
|
39
|
+
bucket.objects.all().delete()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bucket_exists(bucket_name):
|
|
43
|
+
try:
|
|
44
|
+
client.head_bucket(Bucket=bucket_name)
|
|
45
|
+
return True
|
|
46
|
+
except ClientError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def assert_dir_exists(path):
|
|
50
|
+
try:
|
|
51
|
+
os.makedirs(path)
|
|
52
|
+
except OSError as e:
|
|
53
|
+
if e.errno != errno.EEXIST:
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def download_dir(bucket, path, target):
|
|
58
|
+
# Handle missing / at end of prefix
|
|
59
|
+
if not path.endswith('/'):
|
|
60
|
+
path += '/'
|
|
61
|
+
|
|
62
|
+
paginator = client.get_paginator('list_objects_v2')
|
|
63
|
+
for result in paginator.paginate(Bucket=bucket, Prefix=path):
|
|
64
|
+
# Download each file individually
|
|
65
|
+
for key in result['Contents']:
|
|
66
|
+
# Calculate relative path
|
|
67
|
+
rel_path = key['Key'][len(path):]
|
|
68
|
+
# Skip paths ending in /
|
|
69
|
+
if not key['Key'].endswith('/'):
|
|
70
|
+
local_file_path = os.path.join(target, rel_path)
|
|
71
|
+
# Make sure directories exist
|
|
72
|
+
local_file_dir = os.path.dirname(local_file_path)
|
|
73
|
+
assert_dir_exists(local_file_dir)
|
|
74
|
+
client.download_file(bucket, key['Key'], local_file_path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def path_is_file_or_directory(bucket, path):
|
|
78
|
+
paginator = client.get_paginator('list_objects_v2')
|
|
79
|
+
total = 0
|
|
80
|
+
for result in paginator.paginate(Bucket=bucket, Prefix=path):
|
|
81
|
+
total += result['KeyCount']
|
|
82
|
+
|
|
83
|
+
if not path.endswith('/'):
|
|
84
|
+
path += '/'
|
|
85
|
+
total_with_slash = 0
|
|
86
|
+
for result in paginator.paginate(Bucket=bucket, Prefix=path):
|
|
87
|
+
total_with_slash += result['KeyCount']
|
|
88
|
+
if total == total_with_slash and total > 1:
|
|
89
|
+
return 'directory'
|
|
90
|
+
else:
|
|
91
|
+
return 'file'
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def download(bucket, path, target):
|
|
95
|
+
is_directory = path_is_file_or_directory(bucket, path) == 'directory'
|
|
96
|
+
if is_directory:
|
|
97
|
+
download_dir(bucket, path, target)
|
|
98
|
+
return (target, is_directory)
|
|
99
|
+
else:
|
|
100
|
+
assert_dir_exists(target)
|
|
101
|
+
filename = os.path.split(path)[1]
|
|
102
|
+
target_path = f'{target}/{filename}'
|
|
103
|
+
client.download_file(bucket, path, target_path)
|
|
104
|
+
return (target_path, is_directory)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def normalize_prefix(prefix):
|
|
108
|
+
# The prefix needs to end with a slash, but if the root is empty, leave
|
|
109
|
+
# it.
|
|
110
|
+
if prefix and not prefix.endswith('/'):
|
|
111
|
+
prefix += '/'
|
|
112
|
+
return prefix
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_files_under_prefix(bucket_name, prefix):
|
|
116
|
+
def ls(bucket_name, prefix):
|
|
117
|
+
# Use a hash lookup instead of an array to prevent duplicate directories
|
|
118
|
+
directories = {}
|
|
119
|
+
files = []
|
|
120
|
+
paginator = client.get_paginator('list_objects')
|
|
121
|
+
pages = paginator.paginate(Bucket=bucket_name, Delimiter='/', Prefix=prefix)
|
|
122
|
+
for page in pages:
|
|
123
|
+
for entry in page.get('CommonPrefixes', ()):
|
|
124
|
+
key = posixpath.relpath(entry['Prefix'], prefix)
|
|
125
|
+
if not key in directories:
|
|
126
|
+
directories[key] = True
|
|
127
|
+
for entry in page.get('Contents', ()):
|
|
128
|
+
files.append(posixpath.relpath(entry['Key'], prefix))
|
|
129
|
+
return directories.keys(), files
|
|
130
|
+
|
|
131
|
+
def collect_files(bucket_name, prefix, ret):
|
|
132
|
+
prefix = normalize_prefix(prefix)
|
|
133
|
+
|
|
134
|
+
directories, files = ls(bucket_name, prefix)
|
|
135
|
+
for file_ in files:
|
|
136
|
+
# S3 files named . are special files that are created when the "Create Folder" button is used on S3. They
|
|
137
|
+
# show up when calling list_objects(), but will fail when head_object() is called on them.
|
|
138
|
+
if file_ == '.':
|
|
139
|
+
continue
|
|
140
|
+
ret.append(f'{prefix}{file_}')
|
|
141
|
+
for directory in directories:
|
|
142
|
+
ret = collect_files(bucket_name, f'{prefix}{directory}', ret)
|
|
143
|
+
return ret
|
|
144
|
+
|
|
145
|
+
return collect_files(bucket_name, prefix, [])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def files_exist_in_prefix(bucket_name, prefix):
|
|
149
|
+
prefix = normalize_prefix(prefix)
|
|
150
|
+
paginator = client.get_paginator('list_objects')
|
|
151
|
+
pages = paginator.paginate(Bucket=bucket_name, Delimiter='/', Prefix=prefix)
|
|
152
|
+
page = next(iter(pages))
|
|
153
|
+
if 'Contents' in page:
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def file_exists(bucket_name, key):
|
|
159
|
+
try:
|
|
160
|
+
client.get_object(Bucket=bucket_name, Key=key)
|
|
161
|
+
except ClientError as ex:
|
|
162
|
+
if ex.response['Error']['Code'] == 'NoSuchKey':
|
|
163
|
+
return False
|
|
164
|
+
else:
|
|
165
|
+
raise
|
|
166
|
+
else:
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_file_without_prefix(file_, prefix):
|
|
171
|
+
if prefix:
|
|
172
|
+
prefix = normalize_prefix(prefix)
|
|
173
|
+
return file_.split(prefix)[-1]
|
|
174
|
+
else:
|
|
175
|
+
return file_
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_s3_path(path):
|
|
179
|
+
path = PurePath(settings.AWS_STORAGE_BUCKET_NAME) / PurePath(path)
|
|
180
|
+
return f's3://{path}'
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def s3_read(key):
|
|
184
|
+
s3_object = client.get_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key)
|
|
185
|
+
body = s3_object['Body']
|
|
186
|
+
return body.read()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def s3_write(key, content):
|
|
190
|
+
client.put_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key, Body=content)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def s3_delete(key):
|
|
194
|
+
client.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def set_tags(key, tags):
|
|
198
|
+
if tags is None:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
tag_set = []
|
|
202
|
+
for k, v in tags.items():
|
|
203
|
+
tag_set.append({
|
|
204
|
+
'Key': k,
|
|
205
|
+
'Value': v,
|
|
206
|
+
})
|
|
207
|
+
client.put_object_tagging(
|
|
208
|
+
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
|
|
209
|
+
Key=key,
|
|
210
|
+
Tagging={
|
|
211
|
+
'TagSet': tag_set
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def s3_temp_folder_cleanup():
|
|
217
|
+
def dir(bucket_name, prefix):
|
|
218
|
+
# Use a hash lookup instead of an array to prevent duplicate directories
|
|
219
|
+
directories = {}
|
|
220
|
+
paginator = client.get_paginator('list_objects')
|
|
221
|
+
pages = paginator.paginate(Bucket=bucket_name, Delimiter='/', Prefix=prefix)
|
|
222
|
+
for page in pages:
|
|
223
|
+
for entry in page.get('CommonPrefixes', ()):
|
|
224
|
+
key = posixpath.relpath(entry['Prefix'], prefix)
|
|
225
|
+
if not key in directories:
|
|
226
|
+
directories[key] = True
|
|
227
|
+
return directories.keys()
|
|
228
|
+
|
|
229
|
+
delete_set = dict(Objects=[])
|
|
230
|
+
for prefix in dir(bucket_name=settings.AWS_STORAGE_BUCKET_NAME, prefix=TEMP_MARKER):
|
|
231
|
+
response = client.list_objects(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Prefix=prefix, MaxKeys=1)
|
|
232
|
+
for entry in response.get('Contents', ()):
|
|
233
|
+
diff_in_hours = (date.today() - entry['LastModified']).total_seconds() / 3600
|
|
234
|
+
# If the first file in the folder (prefix) is over a day old, delete the whole folder
|
|
235
|
+
if diff_in_hours >= 24:
|
|
236
|
+
delete_set['Objects'].append(dict(Key=entry['Key']))
|
|
237
|
+
if len(delete_set['Objects']) >= 1000:
|
|
238
|
+
client.delete_objects(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Delete=delete_set)
|
|
239
|
+
delete_set = dict(Objects=[])
|
|
240
|
+
break
|
|
241
|
+
if len(delete_set['Objects']) > 0:
|
|
242
|
+
client.delete_objects(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Delete=delete_set)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def s3_lifecycle_configuration():
|
|
246
|
+
client.put_bucket_lifecycle_configuration(
|
|
247
|
+
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
|
|
248
|
+
LifecycleConfiguration={
|
|
249
|
+
'Rules': [
|
|
250
|
+
{
|
|
251
|
+
'ID': 'Expire fast',
|
|
252
|
+
'Filter': {
|
|
253
|
+
'Tag': {
|
|
254
|
+
'Key': RETENTION,
|
|
255
|
+
'Value': EXPIRE_FAST,
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
'Status': 'Enabled',
|
|
259
|
+
'Expiration': {
|
|
260
|
+
'Days': 1,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
'ID': 'Expire slow',
|
|
265
|
+
'Filter': {
|
|
266
|
+
'Tag': {
|
|
267
|
+
'Key': RETENTION,
|
|
268
|
+
'Value': EXPIRE_SLOW,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
'Status': 'Enabled',
|
|
272
|
+
'Expiration': {
|
|
273
|
+
'Days': 180,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from django.core.files.storage import default_storage
|
|
2
|
+
from rest_framework import fields
|
|
3
|
+
from rest_framework import serializers
|
|
4
|
+
|
|
5
|
+
from django_file_tools.s3 import bucket_exists
|
|
6
|
+
from django_file_tools.s3 import file_exists
|
|
7
|
+
from django_file_tools.s3 import files_exist_in_prefix
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileField(fields.FileField):
|
|
11
|
+
"""Similar to the standard serializer FileField but takes strings as paths in the storage instead of file objects"""
|
|
12
|
+
default_error_messages = fields.FileField.default_error_messages
|
|
13
|
+
default_error_messages['does not exist'] = 'The file referenced does not exist'
|
|
14
|
+
|
|
15
|
+
def get_storage(self):
|
|
16
|
+
return default_storage
|
|
17
|
+
|
|
18
|
+
def to_internal_value(self, data):
|
|
19
|
+
storage = self.get_storage()
|
|
20
|
+
if not storage.exists(data):
|
|
21
|
+
self.fail('does not exist')
|
|
22
|
+
return data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FileFieldForModelSerializer(FileField):
|
|
26
|
+
def get_storage(self):
|
|
27
|
+
model = self.parent.Meta.model
|
|
28
|
+
return getattr(model, self.field_name).field.storage
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class S3PathField(fields.CharField):
|
|
32
|
+
def to_internal_value(self, value):
|
|
33
|
+
if value:
|
|
34
|
+
value = value.replace('s3://', '')
|
|
35
|
+
bucket_name, prefix = value.split('/', 1)
|
|
36
|
+
if not bucket_exists(bucket_name):
|
|
37
|
+
raise serializers.ValidationError(f'bucket {bucket_name} does not exist')
|
|
38
|
+
|
|
39
|
+
if_file_exists = file_exists(bucket_name, prefix)
|
|
40
|
+
if_folder_exists = files_exist_in_prefix(bucket_name, prefix)
|
|
41
|
+
if (not if_file_exists) and (not if_folder_exists):
|
|
42
|
+
if not prefix.endswith('/'):
|
|
43
|
+
raise serializers.ValidationError(f'{value} does not exist')
|
|
44
|
+
else:
|
|
45
|
+
raise serializers.ValidationError(f'{value} has no files')
|
|
46
|
+
return value
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
|
|
3
|
+
from django.db import models
|
|
4
|
+
from rest_framework import serializers
|
|
5
|
+
|
|
6
|
+
from . import serializer_fields
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ModelSerializer(serializers.ModelSerializer):
|
|
10
|
+
serializer_field_mapping = copy.deepcopy(serializers.ModelSerializer.serializer_field_mapping)
|
|
11
|
+
serializer_field_mapping[models.FileField] = serializer_fields.FileFieldForModelSerializer
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from storages.backends.s3boto3 import S3Boto3Storage
|
|
2
|
+
from storages.utils import clean_name
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StorageCopyMixin:
|
|
6
|
+
def copy(self, original, new_name, max_length=None, tags=None):
|
|
7
|
+
new_name = self.get_available_name(new_name, max_length=max_length)
|
|
8
|
+
return self._copy(original, new_name, tags)
|
|
9
|
+
|
|
10
|
+
def copy_from_other_bucket(self, bucket_name, key, new_name, max_length=None, tags=None):
|
|
11
|
+
new_name = self.get_available_name(new_name, max_length=max_length)
|
|
12
|
+
return self._copy_from_other_bucket(bucket_name, key, new_name, tags)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class S3Storage(StorageCopyMixin, S3Boto3Storage):
|
|
16
|
+
default_acl = 'private'
|
|
17
|
+
file_overwrite = False
|
|
18
|
+
custom_domain = False
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def s3_client(self):
|
|
22
|
+
return self.connection.meta.client
|
|
23
|
+
|
|
24
|
+
def set_tags(self, key, tags):
|
|
25
|
+
if tags is None:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
tag_set = []
|
|
29
|
+
for k, v in tags.items():
|
|
30
|
+
tag_set.append({
|
|
31
|
+
'Key': k,
|
|
32
|
+
'Value': v,
|
|
33
|
+
})
|
|
34
|
+
self.s3_client.put_object_tagging(
|
|
35
|
+
Bucket=self.bucket_name,
|
|
36
|
+
Key=key,
|
|
37
|
+
Tagging={
|
|
38
|
+
'TagSet': tag_set
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _copy(self, original, new_name, tags=None):
|
|
43
|
+
normalized_original = self._normalize_name(clean_name(original))
|
|
44
|
+
normalized_new_name = self._normalize_name(clean_name(new_name))
|
|
45
|
+
copy_source = {
|
|
46
|
+
'Bucket': self.bucket_name,
|
|
47
|
+
'Key': normalized_original
|
|
48
|
+
}
|
|
49
|
+
self.bucket.copy(copy_source, normalized_new_name)
|
|
50
|
+
self.set_tags(normalized_new_name, tags)
|
|
51
|
+
return normalized_new_name
|
|
52
|
+
|
|
53
|
+
def _copy_from_other_bucket(self, bucket_name, key, new_name, tags=None):
|
|
54
|
+
normalized_new_name = self._normalize_name(clean_name(new_name))
|
|
55
|
+
copy_source = {
|
|
56
|
+
'Bucket': bucket_name,
|
|
57
|
+
'Key': key
|
|
58
|
+
}
|
|
59
|
+
self.bucket.copy(copy_source, normalized_new_name)
|
|
60
|
+
self.set_tags(normalized_new_name, tags)
|
|
61
|
+
return normalized_new_name
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.contrib.auth.decorators import login_required
|
|
5
|
+
from django.http.response import Http404
|
|
6
|
+
from django.http.response import JsonResponse
|
|
7
|
+
|
|
8
|
+
from django_file_tools.model_fields import TEMP_MARKER
|
|
9
|
+
from django_file_tools.s3 import EXPIRE_FAST
|
|
10
|
+
from django_file_tools.s3 import RETENTION
|
|
11
|
+
from django_file_tools.s3 import client
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@login_required
|
|
15
|
+
def get_s3_signature(request):
|
|
16
|
+
service = 's3'
|
|
17
|
+
region = 'us-east-1'
|
|
18
|
+
t = datetime.datetime.utcnow()
|
|
19
|
+
algorithm = 'AWS4-HMAC-SHA256'
|
|
20
|
+
credential_scope = '/'.join([t.strftime('%Y%m%d'), region, service, 'aws4_request'])
|
|
21
|
+
|
|
22
|
+
name = request.GET.get('name')
|
|
23
|
+
if name is None:
|
|
24
|
+
raise Http404
|
|
25
|
+
|
|
26
|
+
def get_tag_xml(key, value):
|
|
27
|
+
return f"<Tagging><TagSet><Tag><Key>{key}</Key><Value>{value}</Value></Tag></TagSet></Tagging>"
|
|
28
|
+
|
|
29
|
+
conditions = [
|
|
30
|
+
{"x-amz-algorithm": algorithm},
|
|
31
|
+
{"x-amz-credential": credential_scope},
|
|
32
|
+
{"x-amz-date": t.isoformat()},
|
|
33
|
+
{"tagging": get_tag_xml(RETENTION, EXPIRE_FAST)},
|
|
34
|
+
{"success_action_status": "201"},
|
|
35
|
+
{"bucket": settings.AWS_STORAGE_BUCKET_NAME},
|
|
36
|
+
["starts-with", "$key", TEMP_MARKER],
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
fields = {
|
|
40
|
+
"x-amz-algorithm": algorithm,
|
|
41
|
+
"x-amz-credential": credential_scope,
|
|
42
|
+
"x-amz-date": t.isoformat(),
|
|
43
|
+
"tagging": get_tag_xml(RETENTION, EXPIRE_FAST),
|
|
44
|
+
"success_action_status": "201",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
presigned = client.generate_presigned_post(
|
|
48
|
+
settings.AWS_STORAGE_BUCKET_NAME,
|
|
49
|
+
name,
|
|
50
|
+
Fields=fields,
|
|
51
|
+
Conditions=conditions,
|
|
52
|
+
ExpiresIn=7*24*60,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return JsonResponse({
|
|
56
|
+
'signature': presigned['fields'],
|
|
57
|
+
'postEndpoint': presigned['url'],
|
|
58
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "django-file-tools"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tools for working with files in Django"
|
|
5
|
+
authors = ["Enrico Barzetti"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
homepage = "https://github.com/enricobarzetti/django-file-tools"
|
|
8
|
+
license = "mit"
|
|
9
|
+
packages = [
|
|
10
|
+
{ include = "django_file_tools" },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.poetry.dependencies]
|
|
14
|
+
python = "^3.9"
|
|
15
|
+
django = ">=3.0"
|
|
16
|
+
django-storages = "^1.14"
|
|
17
|
+
djangorestframework = "^3.12.0"
|
|
18
|
+
boto3 = "^1.34.12"
|
|
19
|
+
django-environ = "^0.11.2"
|
|
20
|
+
|
|
21
|
+
[tool.poetry.group.dev.dependencies]
|
|
22
|
+
isort = "^5.13.2"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["poetry-core"]
|
|
26
|
+
build-backend = "poetry.core.masonry.api"
|