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.
Files changed (120) hide show
  1. django_nativemojo-0.1.15.dist-info/METADATA +136 -0
  2. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/RECORD +105 -65
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/__init__.py +5 -0
  5. mojo/apps/account/management/commands/__init__.py +6 -0
  6. mojo/apps/account/management/commands/serializer_admin.py +531 -0
  7. mojo/apps/account/migrations/0004_user_avatar.py +20 -0
  8. mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
  9. mojo/apps/account/models/group.py +25 -7
  10. mojo/apps/account/models/member.py +15 -4
  11. mojo/apps/account/models/user.py +197 -20
  12. mojo/apps/account/rest/group.py +1 -0
  13. mojo/apps/account/rest/user.py +6 -2
  14. mojo/apps/aws/rest/__init__.py +1 -0
  15. mojo/apps/aws/rest/s3.py +64 -0
  16. mojo/apps/fileman/README.md +8 -8
  17. mojo/apps/fileman/backends/base.py +76 -70
  18. mojo/apps/fileman/backends/filesystem.py +86 -86
  19. mojo/apps/fileman/backends/s3.py +200 -108
  20. mojo/apps/fileman/migrations/0001_initial.py +106 -0
  21. mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
  22. mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
  23. mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
  24. mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
  25. mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
  26. mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
  27. mojo/apps/fileman/migrations/0008_file_category.py +18 -0
  28. mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
  29. mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
  30. mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
  31. mojo/apps/fileman/models/__init__.py +1 -5
  32. mojo/apps/fileman/models/file.py +204 -58
  33. mojo/apps/fileman/models/manager.py +161 -31
  34. mojo/apps/fileman/models/rendition.py +118 -0
  35. mojo/apps/fileman/renderer/__init__.py +111 -0
  36. mojo/apps/fileman/renderer/audio.py +403 -0
  37. mojo/apps/fileman/renderer/base.py +205 -0
  38. mojo/apps/fileman/renderer/document.py +404 -0
  39. mojo/apps/fileman/renderer/image.py +222 -0
  40. mojo/apps/fileman/renderer/utils.py +297 -0
  41. mojo/apps/fileman/renderer/video.py +304 -0
  42. mojo/apps/fileman/rest/__init__.py +1 -18
  43. mojo/apps/fileman/rest/upload.py +22 -32
  44. mojo/apps/fileman/signals.py +58 -0
  45. mojo/apps/fileman/tasks.py +254 -0
  46. mojo/apps/fileman/utils/__init__.py +40 -16
  47. mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
  48. mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
  49. mojo/apps/incident/models/__init__.py +1 -0
  50. mojo/apps/incident/models/history.py +36 -0
  51. mojo/apps/incident/models/incident.py +1 -1
  52. mojo/apps/incident/reporter.py +3 -1
  53. mojo/apps/incident/rest/event.py +7 -1
  54. mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
  55. mojo/apps/logit/models/log.py +4 -1
  56. mojo/apps/metrics/utils.py +2 -2
  57. mojo/apps/notify/handlers/ses/message.py +1 -1
  58. mojo/apps/notify/providers/aws.py +2 -2
  59. mojo/apps/tasks/__init__.py +34 -1
  60. mojo/apps/tasks/manager.py +200 -45
  61. mojo/apps/tasks/rest/tasks.py +24 -10
  62. mojo/apps/tasks/runner.py +283 -18
  63. mojo/apps/tasks/task.py +99 -0
  64. mojo/apps/tasks/tq_handlers.py +118 -0
  65. mojo/decorators/auth.py +6 -1
  66. mojo/decorators/http.py +7 -2
  67. mojo/helpers/aws/__init__.py +41 -0
  68. mojo/helpers/aws/ec2.py +804 -0
  69. mojo/helpers/aws/iam.py +748 -0
  70. mojo/helpers/aws/s3.py +451 -11
  71. mojo/helpers/aws/ses.py +483 -0
  72. mojo/helpers/aws/sns.py +461 -0
  73. mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
  74. mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
  75. mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
  76. mojo/helpers/dates.py +18 -0
  77. mojo/helpers/response.py +6 -2
  78. mojo/helpers/settings/__init__.py +2 -0
  79. mojo/helpers/{settings.py → settings/helper.py} +1 -37
  80. mojo/helpers/settings/parser.py +132 -0
  81. mojo/middleware/logging.py +1 -1
  82. mojo/middleware/mojo.py +5 -0
  83. mojo/models/rest.py +261 -46
  84. mojo/models/secrets.py +13 -4
  85. mojo/serializers/__init__.py +100 -0
  86. mojo/serializers/advanced/README.md +363 -0
  87. mojo/serializers/advanced/__init__.py +247 -0
  88. mojo/serializers/advanced/formats/__init__.py +28 -0
  89. mojo/serializers/advanced/formats/csv.py +416 -0
  90. mojo/serializers/advanced/formats/excel.py +516 -0
  91. mojo/serializers/advanced/formats/json.py +239 -0
  92. mojo/serializers/advanced/formats/localizers.py +509 -0
  93. mojo/serializers/advanced/formats/response.py +485 -0
  94. mojo/serializers/advanced/serializer.py +568 -0
  95. mojo/serializers/manager.py +501 -0
  96. mojo/serializers/optimized.py +618 -0
  97. mojo/serializers/settings_example.py +322 -0
  98. mojo/serializers/{models.py → simple.py} +38 -15
  99. testit/helpers.py +21 -4
  100. django_nativemojo-0.1.10.dist-info/METADATA +0 -96
  101. mojo/apps/metrics/rest/db.py +0 -0
  102. mojo/helpers/aws/setup_email.py +0 -0
  103. mojo/ws4redis/README.md +0 -174
  104. mojo/ws4redis/__init__.py +0 -2
  105. mojo/ws4redis/client.py +0 -283
  106. mojo/ws4redis/connection.py +0 -327
  107. mojo/ws4redis/exceptions.py +0 -32
  108. mojo/ws4redis/redis.py +0 -183
  109. mojo/ws4redis/servers/base.py +0 -86
  110. mojo/ws4redis/servers/django.py +0 -171
  111. mojo/ws4redis/servers/uwsgi.py +0 -63
  112. mojo/ws4redis/settings.py +0 -45
  113. mojo/ws4redis/utf8validator.py +0 -128
  114. mojo/ws4redis/websocket.py +0 -403
  115. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/LICENSE +0 -0
  116. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/NOTICE +0 -0
  117. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/WHEEL +0 -0
  118. /mojo/{ws4redis/servers → apps/aws}/__init__.py +0 -0
  119. /mojo/apps/{fileman/models/render.py → aws/models/__init__.py} +0 -0
  120. /mojo/apps/fileman/{rest/__init__ → migrations/__init__.py} +0 -0
@@ -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.bucket_name = self.get_setting('bucket_name')
23
- self.region_name = self.get_setting('region_name', 'us-east-1')
24
- self.access_key_id = self.get_setting('access_key_id')
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, filename: str, **kwargs) -> str:
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
- try:
93
- # Generate file path
94
- file_path = self.generate_file_path(filename, kwargs.get('group_id'))
95
-
96
- # Prepare upload parameters
97
- upload_params = {
98
- 'Bucket': self.bucket_name,
99
- 'Key': file_path,
100
- 'Body': file_obj
101
- }
102
-
103
- # Add content type if provided
104
- content_type = kwargs.get('content_type')
105
- if content_type:
106
- upload_params['ContentType'] = content_type
107
-
108
- # Add server-side encryption if configured
109
- if self.server_side_encryption:
110
- upload_params['ServerSideEncryption'] = self.server_side_encryption
111
- if self.kms_key_id:
112
- upload_params['SSEKMSKeyId'] = self.kms_key_id
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 pre-signed URL to access the file"""
174
+ """Get a URL to access the file, either public or pre-signed based on expiration"""
162
175
  if expires_in is None:
163
- expires_in = self.download_expires_in
164
-
165
- try:
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
- return url
175
- except ClientError as e:
176
- raise Exception(f"Failed to generate download URL: {e}")
177
-
178
- def generate_upload_url(self, file_path: str, content_type: str,
179
- file_size: Optional[int] = None,
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
+ ]