django-nativemojo 0.1.10__py3-none-any.whl → 0.1.15__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.
- django_nativemojo-0.1.15.dist-info/METADATA +136 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/RECORD +105 -65
- mojo/__init__.py +1 -1
- mojo/apps/account/management/__init__.py +5 -0
- mojo/apps/account/management/commands/__init__.py +6 -0
- mojo/apps/account/management/commands/serializer_admin.py +531 -0
- mojo/apps/account/migrations/0004_user_avatar.py +20 -0
- mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
- mojo/apps/account/models/group.py +25 -7
- mojo/apps/account/models/member.py +15 -4
- mojo/apps/account/models/user.py +197 -20
- mojo/apps/account/rest/group.py +1 -0
- mojo/apps/account/rest/user.py +6 -2
- mojo/apps/aws/rest/__init__.py +1 -0
- mojo/apps/aws/rest/s3.py +64 -0
- mojo/apps/fileman/README.md +8 -8
- mojo/apps/fileman/backends/base.py +76 -70
- mojo/apps/fileman/backends/filesystem.py +86 -86
- mojo/apps/fileman/backends/s3.py +200 -108
- mojo/apps/fileman/migrations/0001_initial.py +106 -0
- mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
- mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
- mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
- mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
- mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
- mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
- mojo/apps/fileman/migrations/0008_file_category.py +18 -0
- mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
- mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
- mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
- mojo/apps/fileman/models/__init__.py +1 -5
- mojo/apps/fileman/models/file.py +204 -58
- mojo/apps/fileman/models/manager.py +161 -31
- mojo/apps/fileman/models/rendition.py +118 -0
- mojo/apps/fileman/renderer/__init__.py +111 -0
- mojo/apps/fileman/renderer/audio.py +403 -0
- mojo/apps/fileman/renderer/base.py +205 -0
- mojo/apps/fileman/renderer/document.py +404 -0
- mojo/apps/fileman/renderer/image.py +222 -0
- mojo/apps/fileman/renderer/utils.py +297 -0
- mojo/apps/fileman/renderer/video.py +304 -0
- mojo/apps/fileman/rest/__init__.py +1 -18
- mojo/apps/fileman/rest/upload.py +22 -32
- mojo/apps/fileman/signals.py +58 -0
- mojo/apps/fileman/tasks.py +254 -0
- mojo/apps/fileman/utils/__init__.py +40 -16
- mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
- mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
- mojo/apps/incident/models/__init__.py +1 -0
- mojo/apps/incident/models/history.py +36 -0
- mojo/apps/incident/models/incident.py +1 -1
- mojo/apps/incident/reporter.py +3 -1
- mojo/apps/incident/rest/event.py +7 -1
- mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
- mojo/apps/logit/models/log.py +4 -1
- mojo/apps/metrics/utils.py +2 -2
- mojo/apps/notify/handlers/ses/message.py +1 -1
- mojo/apps/notify/providers/aws.py +2 -2
- mojo/apps/tasks/__init__.py +34 -1
- mojo/apps/tasks/manager.py +200 -45
- mojo/apps/tasks/rest/tasks.py +24 -10
- mojo/apps/tasks/runner.py +283 -18
- mojo/apps/tasks/task.py +99 -0
- mojo/apps/tasks/tq_handlers.py +118 -0
- mojo/decorators/auth.py +6 -1
- mojo/decorators/http.py +7 -2
- mojo/helpers/aws/__init__.py +41 -0
- mojo/helpers/aws/ec2.py +804 -0
- mojo/helpers/aws/iam.py +748 -0
- mojo/helpers/aws/s3.py +451 -11
- mojo/helpers/aws/ses.py +483 -0
- mojo/helpers/aws/sns.py +461 -0
- mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
- mojo/helpers/dates.py +18 -0
- mojo/helpers/response.py +6 -2
- mojo/helpers/settings/__init__.py +2 -0
- mojo/helpers/{settings.py → settings/helper.py} +1 -37
- mojo/helpers/settings/parser.py +132 -0
- mojo/middleware/logging.py +1 -1
- mojo/middleware/mojo.py +5 -0
- mojo/models/rest.py +261 -46
- mojo/models/secrets.py +13 -4
- mojo/serializers/__init__.py +100 -0
- mojo/serializers/advanced/README.md +363 -0
- mojo/serializers/advanced/__init__.py +247 -0
- mojo/serializers/advanced/formats/__init__.py +28 -0
- mojo/serializers/advanced/formats/csv.py +416 -0
- mojo/serializers/advanced/formats/excel.py +516 -0
- mojo/serializers/advanced/formats/json.py +239 -0
- mojo/serializers/advanced/formats/localizers.py +509 -0
- mojo/serializers/advanced/formats/response.py +485 -0
- mojo/serializers/advanced/serializer.py +568 -0
- mojo/serializers/manager.py +501 -0
- mojo/serializers/optimized.py +618 -0
- mojo/serializers/settings_example.py +322 -0
- mojo/serializers/{models.py → simple.py} +38 -15
- testit/helpers.py +21 -4
- django_nativemojo-0.1.10.dist-info/METADATA +0 -96
- mojo/apps/metrics/rest/db.py +0 -0
- mojo/helpers/aws/setup_email.py +0 -0
- mojo/ws4redis/README.md +0 -174
- mojo/ws4redis/__init__.py +0 -2
- mojo/ws4redis/client.py +0 -283
- mojo/ws4redis/connection.py +0 -327
- mojo/ws4redis/exceptions.py +0 -32
- mojo/ws4redis/redis.py +0 -183
- mojo/ws4redis/servers/base.py +0 -86
- mojo/ws4redis/servers/django.py +0 -171
- mojo/ws4redis/servers/uwsgi.py +0 -63
- mojo/ws4redis/settings.py +0 -45
- mojo/ws4redis/utf8validator.py +0 -128
- mojo/ws4redis/websocket.py +0 -403
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/WHEEL +0 -0
- /mojo/{ws4redis/servers → apps/aws}/__init__.py +0 -0
- /mojo/apps/{fileman/models/render.py → aws/models/__init__.py} +0 -0
- /mojo/apps/fileman/{rest/__init__ → migrations/__init__.py} +0 -0
mojo/apps/fileman/backends/s3.py
CHANGED
@@ -6,6 +6,8 @@ from datetime import datetime, timedelta
|
|
6
6
|
import os
|
7
7
|
import uuid
|
8
8
|
import hashlib
|
9
|
+
from urllib.parse import urlparse
|
10
|
+
import json
|
9
11
|
|
10
12
|
from .base import StorageBackend
|
11
13
|
|
@@ -14,33 +16,39 @@ class S3StorageBackend(StorageBackend):
|
|
14
16
|
"""
|
15
17
|
AWS S3 storage backend implementation
|
16
18
|
"""
|
17
|
-
|
19
|
+
|
18
20
|
def __init__(self, file_manager, **kwargs):
|
19
21
|
super().__init__(file_manager, **kwargs)
|
20
|
-
|
22
|
+
|
23
|
+
# Parse bucket name and folder path
|
24
|
+
purl = urlparse(file_manager.backend_url)
|
25
|
+
if purl.scheme != "s3":
|
26
|
+
raise ValueError("Invalid scheme for S3 backend")
|
27
|
+
self.bucket_name = purl.netloc
|
28
|
+
self.folder_path = purl.path.lstrip('/')
|
29
|
+
|
21
30
|
# S3 configuration
|
22
|
-
self.
|
23
|
-
self.
|
24
|
-
self.
|
25
|
-
self.secret_access_key = self.get_setting('secret_access_key')
|
31
|
+
self.region_name = self.get_setting('aws_region', 'us-east-1')
|
32
|
+
self.access_key_id = self.get_setting('aws_key')
|
33
|
+
self.secret_access_key = self.get_setting('aws_secret')
|
26
34
|
self.endpoint_url = self.get_setting('endpoint_url') # For S3-compatible services
|
27
35
|
self.signature_version = self.get_setting('signature_version', 's3v4')
|
28
36
|
self.addressing_style = self.get_setting('addressing_style', 'auto')
|
29
|
-
|
37
|
+
|
30
38
|
# Upload configuration
|
31
39
|
self.upload_expires_in = self.get_setting('upload_expires_in', 3600) # 1 hour
|
32
40
|
self.download_expires_in = self.get_setting('download_expires_in', 3600) # 1 hour
|
33
41
|
self.multipart_threshold = self.get_setting('multipart_threshold', 8 * 1024 * 1024) # 8MB
|
34
42
|
self.max_concurrency = self.get_setting('max_concurrency', 10)
|
35
|
-
|
43
|
+
|
36
44
|
# Security settings
|
37
45
|
self.server_side_encryption = self.get_setting('server_side_encryption')
|
38
46
|
self.kms_key_id = self.get_setting('kms_key_id')
|
39
|
-
|
47
|
+
|
40
48
|
# Initialize S3 client
|
41
49
|
self._client = None
|
42
50
|
self._resource = None
|
43
|
-
|
51
|
+
|
44
52
|
@property
|
45
53
|
def client(self):
|
46
54
|
"""Lazy initialization of S3 client"""
|
@@ -50,9 +58,11 @@ class S3StorageBackend(StorageBackend):
|
|
50
58
|
aws_secret_access_key=self.secret_access_key,
|
51
59
|
region_name=self.region_name
|
52
60
|
)
|
53
|
-
|
61
|
+
|
54
62
|
config = Config(
|
55
63
|
signature_version=self.signature_version,
|
64
|
+
connect_timeout=3,
|
65
|
+
read_timeout=3,
|
56
66
|
s3={
|
57
67
|
'addressing_style': self.addressing_style
|
58
68
|
},
|
@@ -61,15 +71,15 @@ class S3StorageBackend(StorageBackend):
|
|
61
71
|
'mode': 'adaptive'
|
62
72
|
}
|
63
73
|
)
|
64
|
-
|
74
|
+
|
65
75
|
self._client = session.client(
|
66
76
|
's3',
|
67
77
|
endpoint_url=self.endpoint_url,
|
68
78
|
config=config
|
69
79
|
)
|
70
|
-
|
80
|
+
|
71
81
|
return self._client
|
72
|
-
|
82
|
+
|
73
83
|
@property
|
74
84
|
def resource(self):
|
75
85
|
"""Lazy initialization of S3 resource"""
|
@@ -79,51 +89,38 @@ class S3StorageBackend(StorageBackend):
|
|
79
89
|
aws_secret_access_key=self.secret_access_key,
|
80
90
|
region_name=self.region_name
|
81
91
|
)
|
82
|
-
|
92
|
+
|
83
93
|
self._resource = session.resource(
|
84
94
|
's3',
|
85
95
|
endpoint_url=self.endpoint_url
|
86
96
|
)
|
87
|
-
|
97
|
+
|
88
98
|
return self._resource
|
89
|
-
|
90
|
-
def save(self, file_obj,
|
99
|
+
|
100
|
+
def save(self, file_obj, file_path: str, content_type: Optional[str] = None, metadata: Optional[dict] = None) -> str:
|
91
101
|
"""Save a file to S3"""
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
# Add metadata
|
115
|
-
metadata = kwargs.get('metadata', {})
|
116
|
-
if metadata:
|
117
|
-
upload_params['Metadata'] = {k: str(v) for k, v in metadata.items()}
|
118
|
-
|
119
|
-
# Upload the file
|
120
|
-
self.client.put_object(**upload_params)
|
121
|
-
|
122
|
-
return file_path
|
123
|
-
|
124
|
-
except ClientError as e:
|
125
|
-
raise Exception(f"Failed to save file to S3: {e}")
|
126
|
-
|
102
|
+
# Prepare upload parameters
|
103
|
+
upload_params = {
|
104
|
+
'Bucket': self.bucket_name,
|
105
|
+
'Key': file_path,
|
106
|
+
'ContentType': content_type,
|
107
|
+
'Body': file_obj
|
108
|
+
}
|
109
|
+
|
110
|
+
# Add server-side encryption if configured
|
111
|
+
if self.server_side_encryption:
|
112
|
+
upload_params['ServerSideEncryption'] = self.server_side_encryption
|
113
|
+
if self.kms_key_id:
|
114
|
+
upload_params['SSEKMSKeyId'] = self.kms_key_id
|
115
|
+
|
116
|
+
if metadata:
|
117
|
+
upload_params['Metadata'] = metadata
|
118
|
+
|
119
|
+
# Upload the file
|
120
|
+
self.client.put_object(**upload_params)
|
121
|
+
|
122
|
+
return file_path
|
123
|
+
|
127
124
|
def delete(self, file_path: str) -> bool:
|
128
125
|
"""Delete a file from S3"""
|
129
126
|
try:
|
@@ -134,7 +131,23 @@ class S3StorageBackend(StorageBackend):
|
|
134
131
|
return True
|
135
132
|
except ClientError:
|
136
133
|
return False
|
137
|
-
|
134
|
+
|
135
|
+
def delete_folder(self, folder_path: str) -> bool:
|
136
|
+
"""Delete a folder from S3"""
|
137
|
+
# List all objects under the prefix
|
138
|
+
response = self.client.list_objects_v2(Bucket=self.bucket_name, Prefix=folder_path)
|
139
|
+
if 'Contents' not in response:
|
140
|
+
return True # Folder is already empty or doesn't exist
|
141
|
+
# Prepare delete batch
|
142
|
+
objects_to_delete = [{'Key': obj['Key']} for obj in response['Contents']]
|
143
|
+
|
144
|
+
# Delete in batch
|
145
|
+
self.client.delete_objects(
|
146
|
+
Bucket=self.bucket_name,
|
147
|
+
Delete={'Objects': objects_to_delete}
|
148
|
+
)
|
149
|
+
return True
|
150
|
+
|
138
151
|
def exists(self, file_path: str) -> bool:
|
139
152
|
"""Check if a file exists in S3"""
|
140
153
|
try:
|
@@ -145,7 +158,7 @@ class S3StorageBackend(StorageBackend):
|
|
145
158
|
return True
|
146
159
|
except ClientError:
|
147
160
|
return False
|
148
|
-
|
161
|
+
|
149
162
|
def get_file_size(self, file_path: str) -> Optional[int]:
|
150
163
|
"""Get the size of a file in S3"""
|
151
164
|
try:
|
@@ -156,13 +169,14 @@ class S3StorageBackend(StorageBackend):
|
|
156
169
|
return response['ContentLength']
|
157
170
|
except ClientError:
|
158
171
|
return None
|
159
|
-
|
172
|
+
|
160
173
|
def get_url(self, file_path: str, expires_in: Optional[int] = None) -> str:
|
161
|
-
"""Get a
|
174
|
+
"""Get a URL to access the file, either public or pre-signed based on expiration"""
|
162
175
|
if expires_in is None:
|
163
|
-
|
164
|
-
|
165
|
-
|
176
|
+
# Assume the bucket is public and generate a public URL
|
177
|
+
url = f"https://{self.bucket_name}.s3.amazonaws.com/{file_path}"
|
178
|
+
else:
|
179
|
+
# Generate a pre-signed URL
|
166
180
|
url = self.client.generate_presigned_url(
|
167
181
|
'get_object',
|
168
182
|
Params={
|
@@ -171,33 +185,45 @@ class S3StorageBackend(StorageBackend):
|
|
171
185
|
},
|
172
186
|
ExpiresIn=expires_in
|
173
187
|
)
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
188
|
+
return url
|
189
|
+
|
190
|
+
def supports_direct_upload(self) -> bool:
|
191
|
+
"""
|
192
|
+
Check if this backend supports direct uploads
|
193
|
+
|
194
|
+
Returns:
|
195
|
+
bool: True if direct uploads are supported
|
196
|
+
"""
|
197
|
+
return True
|
198
|
+
|
199
|
+
def generate_upload_url(self, file_path: str, content_type: str,
|
200
|
+
file_size: Optional[int] = None,
|
180
201
|
expires_in: int = 3600) -> Dict[str, Any]:
|
181
202
|
"""Generate a pre-signed URL for direct upload to S3"""
|
182
203
|
try:
|
183
204
|
# Conditions for the upload
|
184
205
|
conditions = []
|
185
|
-
|
206
|
+
|
186
207
|
# Content type condition
|
187
208
|
if content_type:
|
188
209
|
conditions.append({"Content-Type": content_type})
|
189
|
-
|
210
|
+
|
190
211
|
# File size conditions
|
191
212
|
if file_size:
|
192
213
|
# Allow some variance in file size (±1KB)
|
193
214
|
conditions.append(["content-length-range", max(0, file_size - 1024), file_size + 1024])
|
194
|
-
|
215
|
+
|
195
216
|
# Server-side encryption conditions
|
196
217
|
if self.server_side_encryption:
|
197
218
|
conditions.append({"x-amz-server-side-encryption": self.server_side_encryption})
|
198
219
|
if self.kms_key_id:
|
199
220
|
conditions.append({"x-amz-server-side-encryption-aws-kms-key-id": self.kms_key_id})
|
200
|
-
|
221
|
+
else:
|
222
|
+
params = dict(Bucket=self.bucket_name, Key=file_path, ContentType=content_type)
|
223
|
+
return self.client.generate_presigned_url(
|
224
|
+
'put_object',
|
225
|
+
ExpiresIn=expires_in,
|
226
|
+
Params=params)
|
201
227
|
# Generate the presigned POST
|
202
228
|
response = self.client.generate_presigned_post(
|
203
229
|
Bucket=self.bucket_name,
|
@@ -208,13 +234,13 @@ class S3StorageBackend(StorageBackend):
|
|
208
234
|
Conditions=conditions,
|
209
235
|
ExpiresIn=expires_in
|
210
236
|
)
|
211
|
-
|
237
|
+
|
212
238
|
# Add server-side encryption fields if configured
|
213
239
|
if self.server_side_encryption:
|
214
240
|
response['fields']['x-amz-server-side-encryption'] = self.server_side_encryption
|
215
241
|
if self.kms_key_id:
|
216
242
|
response['fields']['x-amz-server-side-encryption-aws-kms-key-id'] = self.kms_key_id
|
217
|
-
|
243
|
+
|
218
244
|
return {
|
219
245
|
'upload_url': response['url'],
|
220
246
|
'method': 'POST',
|
@@ -223,10 +249,10 @@ class S3StorageBackend(StorageBackend):
|
|
223
249
|
'Content-Type': content_type
|
224
250
|
}
|
225
251
|
}
|
226
|
-
|
252
|
+
|
227
253
|
except ClientError as e:
|
228
254
|
raise Exception(f"Failed to generate upload URL: {e}")
|
229
|
-
|
255
|
+
|
230
256
|
def get_file_checksum(self, file_path: str, algorithm: str = 'md5') -> Optional[str]:
|
231
257
|
"""Get file checksum from S3 metadata or calculate it"""
|
232
258
|
try:
|
@@ -235,52 +261,52 @@ class S3StorageBackend(StorageBackend):
|
|
235
261
|
Bucket=self.bucket_name,
|
236
262
|
Key=file_path
|
237
263
|
)
|
238
|
-
|
264
|
+
|
239
265
|
if algorithm.lower() == 'md5':
|
240
266
|
etag = response.get('ETag', '').strip('"')
|
241
267
|
# ETag is MD5 only for non-multipart uploads (no hyphens)
|
242
268
|
if etag and '-' not in etag:
|
243
269
|
return etag
|
244
|
-
|
270
|
+
|
245
271
|
# If ETag is not usable, download and calculate checksum
|
246
272
|
return super().get_file_checksum(file_path, algorithm)
|
247
|
-
|
273
|
+
|
248
274
|
except ClientError:
|
249
275
|
return None
|
250
|
-
|
276
|
+
|
251
277
|
def open(self, file_path: str, mode: str = 'rb'):
|
252
278
|
"""Open a file from S3"""
|
253
279
|
if 'w' in mode or 'a' in mode:
|
254
280
|
raise ValueError("S3 backend only supports read-only file access")
|
255
|
-
|
281
|
+
|
256
282
|
try:
|
257
283
|
obj = self.resource.Object(self.bucket_name, file_path)
|
258
284
|
return obj.get()['Body']
|
259
285
|
except ClientError as e:
|
260
286
|
raise FileNotFoundError(f"File not found in S3: {e}")
|
261
|
-
|
287
|
+
|
262
288
|
def list_files(self, path_prefix: str = "", limit: int = 1000) -> List[str]:
|
263
289
|
"""List files in S3 with optional path prefix"""
|
264
290
|
try:
|
265
291
|
paginator = self.client.get_paginator('list_objects_v2')
|
266
|
-
|
292
|
+
|
267
293
|
page_iterator = paginator.paginate(
|
268
294
|
Bucket=self.bucket_name,
|
269
295
|
Prefix=path_prefix,
|
270
296
|
PaginationConfig={'MaxItems': limit}
|
271
297
|
)
|
272
|
-
|
298
|
+
|
273
299
|
files = []
|
274
300
|
for page in page_iterator:
|
275
301
|
if 'Contents' in page:
|
276
302
|
for obj in page['Contents']:
|
277
303
|
files.append(obj['Key'])
|
278
|
-
|
304
|
+
|
279
305
|
return files
|
280
|
-
|
306
|
+
|
281
307
|
except ClientError:
|
282
308
|
return []
|
283
|
-
|
309
|
+
|
284
310
|
def copy_file(self, source_path: str, dest_path: str) -> bool:
|
285
311
|
"""Copy a file within S3"""
|
286
312
|
try:
|
@@ -288,24 +314,24 @@ class S3StorageBackend(StorageBackend):
|
|
288
314
|
'Bucket': self.bucket_name,
|
289
315
|
'Key': source_path
|
290
316
|
}
|
291
|
-
|
317
|
+
|
292
318
|
self.client.copy_object(
|
293
319
|
CopySource=copy_source,
|
294
320
|
Bucket=self.bucket_name,
|
295
321
|
Key=dest_path
|
296
322
|
)
|
297
|
-
|
323
|
+
|
298
324
|
return True
|
299
|
-
|
325
|
+
|
300
326
|
except ClientError:
|
301
327
|
return False
|
302
|
-
|
328
|
+
|
303
329
|
def move_file(self, source_path: str, dest_path: str) -> bool:
|
304
330
|
"""Move a file within S3"""
|
305
331
|
if self.copy_file(source_path, dest_path):
|
306
332
|
return self.delete(source_path)
|
307
333
|
return False
|
308
|
-
|
334
|
+
|
309
335
|
def get_file_metadata(self, file_path: str) -> Dict[str, Any]:
|
310
336
|
"""Get comprehensive metadata for a file in S3"""
|
311
337
|
try:
|
@@ -313,7 +339,7 @@ class S3StorageBackend(StorageBackend):
|
|
313
339
|
Bucket=self.bucket_name,
|
314
340
|
Key=file_path
|
315
341
|
)
|
316
|
-
|
342
|
+
|
317
343
|
metadata = {
|
318
344
|
'exists': True,
|
319
345
|
'size': response.get('ContentLength'),
|
@@ -326,22 +352,22 @@ class S3StorageBackend(StorageBackend):
|
|
326
352
|
'server_side_encryption': response.get('ServerSideEncryption'),
|
327
353
|
'version_id': response.get('VersionId')
|
328
354
|
}
|
329
|
-
|
355
|
+
|
330
356
|
return metadata
|
331
|
-
|
357
|
+
|
332
358
|
except ClientError:
|
333
359
|
return {'exists': False, 'path': file_path}
|
334
|
-
|
360
|
+
|
335
361
|
def cleanup_expired_uploads(self, before_date: Optional[datetime] = None):
|
336
362
|
"""Clean up incomplete multipart uploads"""
|
337
363
|
if before_date is None:
|
338
364
|
before_date = datetime.now() - timedelta(days=1)
|
339
|
-
|
365
|
+
|
340
366
|
try:
|
341
367
|
paginator = self.client.get_paginator('list_multipart_uploads')
|
342
|
-
|
368
|
+
|
343
369
|
page_iterator = paginator.paginate(Bucket=self.bucket_name)
|
344
|
-
|
370
|
+
|
345
371
|
for page in page_iterator:
|
346
372
|
if 'Uploads' in page:
|
347
373
|
for upload in page['Uploads']:
|
@@ -351,35 +377,37 @@ class S3StorageBackend(StorageBackend):
|
|
351
377
|
Key=upload['Key'],
|
352
378
|
UploadId=upload['UploadId']
|
353
379
|
)
|
354
|
-
|
380
|
+
|
355
381
|
except ClientError:
|
356
382
|
pass # Silently ignore cleanup errors
|
357
|
-
|
383
|
+
|
358
384
|
def get_available_space(self) -> Optional[int]:
|
359
385
|
"""S3 has virtually unlimited space"""
|
360
386
|
return None
|
361
|
-
|
387
|
+
|
362
388
|
def generate_file_path(self, filename: str, group_id: Optional[int] = None) -> str:
|
363
389
|
"""Generate an S3 key for the file"""
|
364
390
|
# Use the base implementation but ensure S3-compatible paths
|
365
391
|
path = super().generate_file_path(filename, group_id)
|
366
|
-
|
392
|
+
|
367
393
|
# Ensure no leading slash for S3 keys
|
368
394
|
return path.lstrip('/')
|
369
|
-
|
395
|
+
|
396
|
+
|
397
|
+
|
370
398
|
def validate_configuration(self) -> Tuple[bool, List[str]]:
|
371
399
|
"""Validate S3 configuration"""
|
372
400
|
errors = []
|
373
|
-
|
401
|
+
|
374
402
|
if not self.bucket_name:
|
375
403
|
errors.append("S3 bucket name is required")
|
376
|
-
|
404
|
+
|
377
405
|
if not self.access_key_id:
|
378
406
|
errors.append("AWS access key ID is required")
|
379
|
-
|
407
|
+
|
380
408
|
if not self.secret_access_key:
|
381
409
|
errors.append("AWS secret access key is required")
|
382
|
-
|
410
|
+
|
383
411
|
# Test connection if configuration looks valid
|
384
412
|
if not errors:
|
385
413
|
try:
|
@@ -394,5 +422,69 @@ class S3StorageBackend(StorageBackend):
|
|
394
422
|
errors.append(f"Access denied to S3 bucket '{self.bucket_name}'")
|
395
423
|
else:
|
396
424
|
errors.append(f"S3 connection error: {e}")
|
397
|
-
|
398
|
-
return len(errors) == 0, errors
|
425
|
+
|
426
|
+
return len(errors) == 0, errors
|
427
|
+
|
428
|
+
def make_path_public(self):
|
429
|
+
# Get the current bucket policy (if any)
|
430
|
+
try:
|
431
|
+
current_policy = json.loads(self.client.get_bucket_policy(Bucket=self.bucket_name)["Policy"])
|
432
|
+
statements = current_policy.get("Statement", [])
|
433
|
+
except self.client.exceptions.from_code('NoSuchBucketPolicy'):
|
434
|
+
current_policy = {"Version": "2012-10-17", "Statement": []}
|
435
|
+
statements = []
|
436
|
+
|
437
|
+
# Check if our public-read rule for the prefix already exists
|
438
|
+
public_read_sid = f"AllowPublicReadForPrefix_{self.folder_path.replace('/', '_')}"
|
439
|
+
already_exists = any(stmt.get("Sid") == public_read_sid for stmt in statements)
|
440
|
+
|
441
|
+
if already_exists:
|
442
|
+
return
|
443
|
+
|
444
|
+
# Construct the public read statement for the given prefix
|
445
|
+
new_statement = {
|
446
|
+
"Sid": public_read_sid,
|
447
|
+
"Effect": "Allow",
|
448
|
+
"Principal": "*",
|
449
|
+
"Action": "s3:GetObject",
|
450
|
+
"Resource": f"arn:aws:s3:::{self.bucket_name}/{self.folder_path}*"
|
451
|
+
}
|
452
|
+
|
453
|
+
# Add and apply the new policy
|
454
|
+
current_policy["Statement"].append(new_statement)
|
455
|
+
self.client.put_bucket_policy(
|
456
|
+
Bucket=self.bucket_name,
|
457
|
+
Policy=json.dumps(current_policy))
|
458
|
+
|
459
|
+
def make_path_private(self):
|
460
|
+
# Get the current bucket policy (if any)
|
461
|
+
try:
|
462
|
+
current_policy = json.loads(self.client.get_bucket_policy(Bucket=self.bucket_name)["Policy"])
|
463
|
+
statements = current_policy.get("Statement", [])
|
464
|
+
except self.client.exceptions.from_code('NoSuchBucketPolicy'):
|
465
|
+
current_policy = {"Version": "2012-10-17", "Statement": []}
|
466
|
+
statements = []
|
467
|
+
|
468
|
+
# Check if our public-read rule for the prefix exists
|
469
|
+
public_read_sid = f"AllowPublicReadForPrefix_{self.folder_path.replace('/', '_')}"
|
470
|
+
exists = any(stmt.get("Sid") == public_read_sid for stmt in statements)
|
471
|
+
|
472
|
+
if not exists:
|
473
|
+
return
|
474
|
+
|
475
|
+
# Remove the public read statement for the given prefix
|
476
|
+
statements = [stmt for stmt in statements if stmt.get("Sid") != public_read_sid]
|
477
|
+
|
478
|
+
# Apply the updated policy
|
479
|
+
current_policy["Statement"] = statements
|
480
|
+
self.client.put_bucket_policy(
|
481
|
+
Bucket=self.bucket_name,
|
482
|
+
Policy=json.dumps(current_policy))
|
483
|
+
|
484
|
+
def download(self, file_path: str, local_path: str) -> None:
|
485
|
+
"""Download a file from S3 to a local path"""
|
486
|
+
try:
|
487
|
+
with open(local_path, 'wb') as local_file:
|
488
|
+
self.client.download_fileobj(self.bucket_name, file_path, local_file)
|
489
|
+
except ClientError as e:
|
490
|
+
raise Exception(f"Failed to download file from S3: {e}")
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# Generated by Django 4.2.21 on 2025-06-07 13:32
|
2
|
+
|
3
|
+
from django.conf import settings
|
4
|
+
from django.db import migrations, models
|
5
|
+
import django.db.models.deletion
|
6
|
+
import mojo.models.rest
|
7
|
+
|
8
|
+
|
9
|
+
class Migration(migrations.Migration):
|
10
|
+
|
11
|
+
initial = True
|
12
|
+
|
13
|
+
dependencies = [
|
14
|
+
('account', '0003_group_mojo_secrets_user_mojo_secrets'),
|
15
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
16
|
+
]
|
17
|
+
|
18
|
+
operations = [
|
19
|
+
migrations.CreateModel(
|
20
|
+
name='FileManager',
|
21
|
+
fields=[
|
22
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
23
|
+
('mojo_secrets', models.TextField(blank=True, default=None, null=True)),
|
24
|
+
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
|
25
|
+
('modified', models.DateTimeField(auto_now=True)),
|
26
|
+
('name', models.CharField(db_index=True, help_text='Descriptive name for this file manager configuration', max_length=255)),
|
27
|
+
('description', models.TextField(blank=True, default='', help_text="Optional description of this file manager's purpose")),
|
28
|
+
('backend_type', models.CharField(choices=[('file', 'File System'), ('s3', 'AWS S3'), ('azure', 'Azure Blob Storage'), ('gcs', 'Google Cloud Storage'), ('custom', 'Custom Backend')], db_index=True, help_text='Type of storage backend (file, s3, azure, gcs, custom)', max_length=32)),
|
29
|
+
('backend_url', models.CharField(help_text='Base URL or connection string for the storage backend', max_length=500)),
|
30
|
+
('supports_direct_upload', models.BooleanField(default=False, help_text='Whether this backend supports direct upload (pre-signed URLs)')),
|
31
|
+
('max_file_size', models.BigIntegerField(default=104857600, help_text='Maximum file size in bytes (0 for unlimited)')),
|
32
|
+
('allowed_extensions', models.JSONField(blank=True, default=list, help_text='List of allowed file extensions (empty for all)')),
|
33
|
+
('allowed_mime_types', models.JSONField(blank=True, default=list, help_text='List of allowed MIME types (empty for all)')),
|
34
|
+
('is_active', models.BooleanField(default=True, help_text='Whether this file manager is active and can be used')),
|
35
|
+
('is_default', models.BooleanField(default=False, help_text='Whether this is the default file manager for the group or user')),
|
36
|
+
('group', models.ForeignKey(blank=True, default=None, help_text='Group that owns this file manager configuration', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='file_managers', to='account.group')),
|
37
|
+
('user', models.ForeignKey(blank=True, default=None, help_text='User that owns this file manager configuration', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='file_managers', to=settings.AUTH_USER_MODEL)),
|
38
|
+
],
|
39
|
+
bases=(models.Model, mojo.models.rest.MojoModel),
|
40
|
+
),
|
41
|
+
migrations.CreateModel(
|
42
|
+
name='File',
|
43
|
+
fields=[
|
44
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
45
|
+
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
|
46
|
+
('modified', models.DateTimeField(auto_now=True)),
|
47
|
+
('filename', models.CharField(db_index=True, help_text='Final filename used for storage', max_length=255)),
|
48
|
+
('original_filename', models.CharField(help_text='Original filename as uploaded by user', max_length=255)),
|
49
|
+
('file_path', models.TextField(help_text='Full path to file in storage backend')),
|
50
|
+
('file_size', models.BigIntegerField(blank=True, help_text='File size in bytes', null=True)),
|
51
|
+
('content_type', models.CharField(db_index=True, help_text='MIME type of the file', max_length=255)),
|
52
|
+
('checksum', models.CharField(blank=True, default='', help_text='File checksum (MD5, SHA256, etc.)', max_length=128)),
|
53
|
+
('upload_token', models.CharField(db_index=True, help_text='Unique token for tracking direct uploads', max_length=64, unique=True)),
|
54
|
+
('upload_status', models.CharField(choices=[('pending', 'Pending Upload'), ('uploading', 'Uploading'), ('completed', 'Upload Completed'), ('failed', 'Upload Failed'), ('expired', 'Upload Expired')], db_index=True, default='pending', help_text='Current status of the file upload', max_length=32)),
|
55
|
+
('upload_url', models.TextField(blank=True, default='', help_text='Pre-signed URL for direct upload (temporary)')),
|
56
|
+
('upload_expires_at', models.DateTimeField(blank=True, help_text='When the upload URL expires', null=True)),
|
57
|
+
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional file metadata and custom properties')),
|
58
|
+
('is_active', models.BooleanField(default=True, help_text='Whether this file is active and accessible')),
|
59
|
+
('is_public', models.BooleanField(default=False, help_text='Whether this file can be accessed without authentication')),
|
60
|
+
('file_manager', models.ForeignKey(help_text='File manager configuration used for this file', on_delete=django.db.models.deletion.CASCADE, related_name='files', to='fileman.filemanager')),
|
61
|
+
('group', models.ForeignKey(blank=True, default=None, help_text='Group that owns this file', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='files', to='account.group')),
|
62
|
+
('uploaded_by', models.ForeignKey(blank=True, default=None, help_text='User who uploaded this file', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_files', to=settings.AUTH_USER_MODEL)),
|
63
|
+
],
|
64
|
+
bases=(models.Model, mojo.models.rest.MojoModel),
|
65
|
+
),
|
66
|
+
migrations.AddIndex(
|
67
|
+
model_name='filemanager',
|
68
|
+
index=models.Index(fields=['backend_type', 'is_active'], name='fileman_fil_backend_d27c68_idx'),
|
69
|
+
),
|
70
|
+
migrations.AddIndex(
|
71
|
+
model_name='filemanager',
|
72
|
+
index=models.Index(fields=['group', 'is_default'], name='fileman_fil_group_i_c1c47d_idx'),
|
73
|
+
),
|
74
|
+
migrations.AddIndex(
|
75
|
+
model_name='filemanager',
|
76
|
+
index=models.Index(fields=['user', 'is_default'], name='fileman_fil_user_id_525e54_idx'),
|
77
|
+
),
|
78
|
+
migrations.AddIndex(
|
79
|
+
model_name='filemanager',
|
80
|
+
index=models.Index(fields=['group', 'backend_type'], name='fileman_fil_group_i_3f1fc6_idx'),
|
81
|
+
),
|
82
|
+
migrations.AlterUniqueTogether(
|
83
|
+
name='filemanager',
|
84
|
+
unique_together={('group', 'name')},
|
85
|
+
),
|
86
|
+
migrations.AddIndex(
|
87
|
+
model_name='file',
|
88
|
+
index=models.Index(fields=['upload_status', 'created'], name='fileman_fil_upload__64e176_idx'),
|
89
|
+
),
|
90
|
+
migrations.AddIndex(
|
91
|
+
model_name='file',
|
92
|
+
index=models.Index(fields=['file_manager', 'upload_status'], name='fileman_fil_file_ma_765f1a_idx'),
|
93
|
+
),
|
94
|
+
migrations.AddIndex(
|
95
|
+
model_name='file',
|
96
|
+
index=models.Index(fields=['group', 'is_active'], name='fileman_fil_group_i_4d4a8a_idx'),
|
97
|
+
),
|
98
|
+
migrations.AddIndex(
|
99
|
+
model_name='file',
|
100
|
+
index=models.Index(fields=['content_type', 'is_active'], name='fileman_fil_content_c5b4a6_idx'),
|
101
|
+
),
|
102
|
+
migrations.AddIndex(
|
103
|
+
model_name='file',
|
104
|
+
index=models.Index(fields=['upload_expires_at'], name='fileman_fil_upload__c4bc35_idx'),
|
105
|
+
),
|
106
|
+
]
|