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
@@ -0,0 +1,404 @@
1
+ import os
2
+ import io
3
+ import subprocess
4
+ import logging
5
+ import mimetypes
6
+ import tempfile
7
+ from typing import Dict, Optional, Tuple, Union, BinaryIO, List
8
+ import shutil
9
+
10
+ from mojo.apps.fileman.models import File, FileRendition
11
+ from mojo.apps.fileman.renderer.base import BaseRenderer, RenditionRole
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ class DocumentRenderer(BaseRenderer):
16
+ """
17
+ Renderer for document files
18
+
19
+ Creates various renditions like thumbnails and previews for document files
20
+ such as PDFs, Word documents, Excel spreadsheets, etc.
21
+ """
22
+
23
+ # Document file categories
24
+ supported_categories = ['document', 'pdf', 'spreadsheet', 'presentation']
25
+
26
+ # Default rendition definitions with options
27
+ default_renditions = {
28
+ RenditionRole.DOCUMENT_THUMBNAIL: {
29
+ 'width': 300,
30
+ 'height': 424, # Roughly A4 proportions
31
+ 'format': 'jpg',
32
+ 'page': 1
33
+ },
34
+ RenditionRole.THUMBNAIL: {
35
+ 'width': 200,
36
+ 'height': 283, # Roughly A4 proportions
37
+ 'format': 'jpg',
38
+ 'page': 1
39
+ },
40
+ RenditionRole.DOCUMENT_PREVIEW: {
41
+ 'format': 'pdf',
42
+ 'quality': 'medium',
43
+ 'max_pages': 20, # Limit preview to first 20 pages
44
+ },
45
+ RenditionRole.DOCUMENT_PDF: {
46
+ 'format': 'pdf',
47
+ 'quality': 'high',
48
+ },
49
+ }
50
+
51
+ # Document format conversions supported
52
+ conversion_map = {
53
+ # Office formats
54
+ '.doc': 'pdf',
55
+ '.docx': 'pdf',
56
+ '.xls': 'pdf',
57
+ '.xlsx': 'pdf',
58
+ '.ppt': 'pdf',
59
+ '.pptx': 'pdf',
60
+ '.odt': 'pdf',
61
+ '.ods': 'pdf',
62
+ '.odp': 'pdf',
63
+ # Text formats
64
+ '.txt': 'pdf',
65
+ '.rtf': 'pdf',
66
+ '.md': 'pdf',
67
+ # Other formats
68
+ '.epub': 'pdf',
69
+ }
70
+
71
+ def __init__(self, file: File):
72
+ super().__init__(file)
73
+ # Check if required tools are available
74
+ self._check_dependencies()
75
+
76
+ def _check_dependencies(self):
77
+ """Check if required tools are available in the system"""
78
+ # Check for pdftoppm (for PDF thumbnails)
79
+ try:
80
+ subprocess.run(["pdftoppm", "-v"],
81
+ stdout=subprocess.PIPE,
82
+ stderr=subprocess.PIPE,
83
+ check=True)
84
+ except (subprocess.SubprocessError, FileNotFoundError):
85
+ logger.warning("pdftoppm is not available. PDF thumbnail generation may not work properly.")
86
+
87
+ # Check for LibreOffice (for document conversion)
88
+ try:
89
+ subprocess.run(["libreoffice", "--version"],
90
+ stdout=subprocess.PIPE,
91
+ stderr=subprocess.PIPE,
92
+ check=True)
93
+ except (subprocess.SubprocessError, FileNotFoundError):
94
+ logger.warning("LibreOffice is not available. Document conversion may not work properly.")
95
+
96
+ def _download_original(self) -> Union[str, None]:
97
+ """
98
+ Download the original file to a temporary location
99
+
100
+ Returns:
101
+ str: Path to the downloaded file, or None if download failed
102
+ """
103
+ try:
104
+ file_manager = self.file.file_manager
105
+ backend = file_manager.backend
106
+
107
+ # Get file extension
108
+ _, ext = os.path.splitext(self.file.filename)
109
+ temp_path = self.get_temp_path(ext)
110
+
111
+ # Download file from storage
112
+ with open(temp_path, 'wb') as f:
113
+ backend.download(self.file.storage_file_path, f)
114
+
115
+ return temp_path
116
+ except Exception as e:
117
+ logger.error(f"Failed to download original document file: {str(e)}")
118
+ return None
119
+
120
+ def _convert_to_pdf(self, source_path: str) -> Tuple[str, bool]:
121
+ """
122
+ Convert document to PDF using LibreOffice
123
+
124
+ Args:
125
+ source_path: Path to the source document
126
+
127
+ Returns:
128
+ Tuple[str, bool]: (Output PDF path, success status)
129
+ """
130
+ _, ext = os.path.splitext(source_path.lower())
131
+
132
+ # If already PDF, just return the path
133
+ if ext == '.pdf':
134
+ return source_path, True
135
+
136
+ # Check if we support converting this format
137
+ if ext not in self.conversion_map:
138
+ logger.warning(f"Unsupported document format for conversion: {ext}")
139
+ return None, False
140
+
141
+ # Create a temporary directory for the conversion
142
+ temp_dir = tempfile.mkdtemp()
143
+ try:
144
+ # Copy the source file to the temp directory
145
+ temp_input = os.path.join(temp_dir, os.path.basename(source_path))
146
+ shutil.copy2(source_path, temp_input)
147
+
148
+ # Use LibreOffice to convert to PDF
149
+ cmd = [
150
+ "libreoffice",
151
+ "--headless",
152
+ "--convert-to", "pdf",
153
+ "--outdir", temp_dir,
154
+ temp_input
155
+ ]
156
+
157
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
158
+
159
+ # Find the output PDF
160
+ base_name = os.path.splitext(os.path.basename(source_path))[0]
161
+ output_pdf = os.path.join(temp_dir, f"{base_name}.pdf")
162
+
163
+ if not os.path.exists(output_pdf):
164
+ logger.error("PDF conversion failed - output file not found")
165
+ return None, False
166
+
167
+ # Copy to a location outside the temp dir
168
+ final_pdf = self.get_temp_path(".pdf")
169
+ shutil.copy2(output_pdf, final_pdf)
170
+
171
+ return final_pdf, True
172
+
173
+ except subprocess.SubprocessError as e:
174
+ logger.error(f"Document conversion failed: {str(e)}")
175
+ return None, False
176
+ finally:
177
+ # Clean up temp directory
178
+ shutil.rmtree(temp_dir, ignore_errors=True)
179
+
180
+ def _create_pdf_thumbnail(self, pdf_path: str, width: int, height: int,
181
+ page: int, output_format: str) -> Tuple[str, str, int]:
182
+ """
183
+ Create a thumbnail from a PDF
184
+
185
+ Args:
186
+ pdf_path: Path to the PDF
187
+ width: Target width
188
+ height: Target height
189
+ page: Page number to use (1-based)
190
+ output_format: Output format (jpg, png)
191
+
192
+ Returns:
193
+ Tuple[str, str, int]: (Output path, mime type, file size)
194
+ """
195
+ temp_prefix = self.get_temp_path("")
196
+
197
+ try:
198
+ # Use pdftoppm to extract page as image
199
+ cmd = [
200
+ "pdftoppm",
201
+ "-f", str(page), # First page
202
+ "-l", str(page), # Last page (same as first)
203
+ "-scale-to-x", str(width),
204
+ "-scale-to-y", str(height),
205
+ "-singlefile", # Output a single file
206
+ ]
207
+
208
+ # Set format
209
+ if output_format.lower() == 'jpg':
210
+ cmd.append("-jpeg")
211
+ elif output_format.lower() == 'png':
212
+ cmd.append("-png")
213
+
214
+ # Add input and output paths
215
+ cmd.extend([pdf_path, temp_prefix])
216
+
217
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
218
+
219
+ # pdftoppm adds the format as suffix
220
+ output_file = f"{temp_prefix}.{output_format}"
221
+
222
+ if not os.path.exists(output_file):
223
+ logger.error("PDF thumbnail generation failed - output file not found")
224
+ return None, None, 0
225
+
226
+ # Get file size
227
+ file_size = os.path.getsize(output_file)
228
+
229
+ # Get mime type
230
+ mime_type = mimetypes.guess_type(f"file.{output_format}")[0]
231
+
232
+ return output_file, mime_type, file_size
233
+
234
+ except subprocess.SubprocessError as e:
235
+ logger.error(f"Failed to create PDF thumbnail: {str(e)}")
236
+ return None, None, 0
237
+
238
+ def _optimize_pdf(self, pdf_path: str, quality: str = 'medium') -> Tuple[str, int]:
239
+ """
240
+ Optimize a PDF file to reduce size
241
+
242
+ Args:
243
+ pdf_path: Path to the PDF
244
+ quality: Quality level ('low', 'medium', 'high')
245
+
246
+ Returns:
247
+ Tuple[str, int]: (Output path, file size)
248
+ """
249
+ output_path = self.get_temp_path(".pdf")
250
+
251
+ try:
252
+ # Set Ghostscript parameters based on quality
253
+ if quality == 'low':
254
+ params = ["-dPDFSETTINGS=/screen"] # lowest quality, smallest size
255
+ elif quality == 'medium':
256
+ params = ["-dPDFSETTINGS=/ebook"] # medium quality, medium size
257
+ else: # high
258
+ params = ["-dPDFSETTINGS=/prepress"] # high quality, larger size
259
+
260
+ # Use Ghostscript to optimize
261
+ cmd = [
262
+ "gs",
263
+ "-sDEVICE=pdfwrite",
264
+ "-dCompatibilityLevel=1.4",
265
+ "-dNOPAUSE",
266
+ "-dQUIET",
267
+ "-dBATCH",
268
+ ] + params + [
269
+ f"-sOutputFile={output_path}",
270
+ pdf_path
271
+ ]
272
+
273
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
274
+
275
+ if not os.path.exists(output_path):
276
+ logger.error("PDF optimization failed - output file not found")
277
+ return pdf_path, os.path.getsize(pdf_path)
278
+
279
+ # Get file size
280
+ file_size = os.path.getsize(output_path)
281
+
282
+ return output_path, file_size
283
+
284
+ except subprocess.SubprocessError as e:
285
+ logger.error(f"Failed to optimize PDF: {str(e)}")
286
+ return pdf_path, os.path.getsize(pdf_path)
287
+
288
+ def create_rendition(self, role: str, options: Dict = None) -> Optional[FileRendition]:
289
+ """
290
+ Create a document rendition for the specified role
291
+
292
+ Args:
293
+ role: The role of the rendition
294
+ options: Additional options for creating the rendition
295
+
296
+ Returns:
297
+ FileRendition: The created rendition, or None if creation failed
298
+ """
299
+ try:
300
+ # Get rendition settings
301
+ settings = self.default_renditions.get(role, {})
302
+ if options:
303
+ settings.update(options)
304
+
305
+ # Download the original file
306
+ source_path = self._download_original()
307
+ if not source_path:
308
+ return None
309
+
310
+ temp_files = [source_path] # Track temporary files to clean up
311
+
312
+ try:
313
+ # First convert to PDF if needed
314
+ if role in [RenditionRole.DOCUMENT_PDF, RenditionRole.DOCUMENT_PREVIEW]:
315
+ pdf_path, success = self._convert_to_pdf(source_path)
316
+ if not success:
317
+ return None
318
+
319
+ temp_files.append(pdf_path)
320
+
321
+ # For preview or PDF rendition
322
+ quality = settings.get('quality', 'medium')
323
+
324
+ # Optimize the PDF
325
+ optimized_pdf, file_size = self._optimize_pdf(pdf_path, quality)
326
+ temp_files.append(optimized_pdf)
327
+
328
+ # Set output details
329
+ temp_output = optimized_pdf
330
+ mime_type = "application/pdf"
331
+
332
+ # Set filename
333
+ name, _ = os.path.splitext(self.file.filename)
334
+ filename = f"{name}_{role}.pdf"
335
+ category = 'document'
336
+
337
+ elif role in [RenditionRole.THUMBNAIL, RenditionRole.DOCUMENT_THUMBNAIL]:
338
+ # First make sure we have a PDF
339
+ pdf_path, success = self._convert_to_pdf(source_path)
340
+ if not success:
341
+ return None
342
+
343
+ temp_files.append(pdf_path)
344
+
345
+ # Create thumbnail image
346
+ width = settings.get('width', 200)
347
+ height = settings.get('height', 283)
348
+ page = settings.get('page', 1)
349
+ output_format = settings.get('format', 'jpg')
350
+
351
+ temp_output, mime_type, file_size = self._create_pdf_thumbnail(
352
+ pdf_path, width, height, page, output_format
353
+ )
354
+
355
+ if not temp_output:
356
+ return None
357
+
358
+ temp_files.append(temp_output)
359
+
360
+ # Set filename
361
+ name, _ = os.path.splitext(self.file.filename)
362
+ filename = f"{name}_{role}.{output_format}"
363
+ category = 'image' # Thumbnails are images
364
+
365
+ else:
366
+ logger.warning(f"Unsupported rendition role for documents: {role}")
367
+ return None
368
+
369
+ # Save to storage
370
+ file_manager = self.file.file_manager
371
+ backend = file_manager.backend
372
+ storage_path = os.path.join(
373
+ os.path.dirname(self.file.storage_file_path),
374
+ filename
375
+ )
376
+
377
+ # Upload to storage
378
+ with open(temp_output, 'rb') as f:
379
+ backend.save(f, storage_path, mime_type)
380
+
381
+ # Create rendition record
382
+ rendition = self._create_rendition_object(
383
+ role=role,
384
+ filename=filename,
385
+ storage_path=storage_path,
386
+ content_type=mime_type,
387
+ category=category,
388
+ file_size=file_size
389
+ )
390
+
391
+ return rendition
392
+
393
+ finally:
394
+ # Clean up temporary files
395
+ for temp_file in temp_files:
396
+ if temp_file and os.path.exists(temp_file):
397
+ try:
398
+ os.unlink(temp_file)
399
+ except:
400
+ pass
401
+
402
+ except Exception as e:
403
+ logger.error(f"Failed to create document rendition '{role}': {str(e)}")
404
+ return None
@@ -0,0 +1,222 @@
1
+ import os
2
+ import io
3
+ from typing import Dict, Optional, Tuple, Union, BinaryIO
4
+ from PIL import Image, ImageOps
5
+ import mimetypes
6
+ import logging
7
+
8
+ from mojo.apps.fileman.models import File, FileRendition
9
+ from mojo.apps.fileman.renderer.base import BaseRenderer, RenditionRole
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class ImageRenderer(BaseRenderer):
14
+ """
15
+ Renderer for image files
16
+
17
+ Creates various renditions like thumbnails, square crops, and resized versions
18
+ """
19
+
20
+ # Image file categories
21
+ supported_categories = ['image']
22
+
23
+ # Default rendition definitions with sizes and options
24
+ default_renditions = {
25
+ RenditionRole.THUMBNAIL: {'width': 150, 'height': 150, 'mode': 'contain'},
26
+ RenditionRole.THUMBNAIL_SM: {'width': 32, 'height': 32, 'mode': 'contain'},
27
+ RenditionRole.THUMBNAIL_MD: {'width': 64, 'height': 64, 'mode': 'contain'},
28
+ RenditionRole.THUMBNAIL_LG: {'width': 300, 'height': 300, 'mode': 'contain'},
29
+ RenditionRole.SQUARE_SM: {'width': 100, 'height': 100, 'mode': 'crop'}
30
+ }
31
+
32
+ # Default output format settings
33
+ default_format = 'JPEG'
34
+ default_quality = 85
35
+
36
+ def __init__(self, file: File):
37
+ super().__init__(file)
38
+ # Map of file extensions to PIL format names
39
+ self.format_map = {
40
+ '.jpg': 'JPEG',
41
+ '.jpeg': 'JPEG',
42
+ '.png': 'PNG',
43
+ '.gif': 'GIF',
44
+ '.bmp': 'BMP',
45
+ '.webp': 'WEBP',
46
+ '.tiff': 'TIFF',
47
+ }
48
+
49
+ def _download_original(self) -> Union[str, None]:
50
+ """
51
+ Download the original file to a temporary location
52
+
53
+ Returns:
54
+ str: Path to the downloaded file, or None if download failed
55
+ """
56
+ try:
57
+ file_manager = self.file.file_manager
58
+ backend = file_manager.backend
59
+
60
+ # Get file extension
61
+ _, ext = os.path.splitext(self.file.filename)
62
+ temp_path = self.get_temp_path(ext)
63
+
64
+ # Download file from storage
65
+ backend.download(self.file.storage_file_path, temp_path)
66
+ return temp_path
67
+ except Exception as e:
68
+ logger.error(f"Failed to download original file: {str(e)}")
69
+ return None
70
+
71
+ def _get_output_format(self, source_path: str, options: Dict = None) -> Tuple[str, str]:
72
+ """
73
+ Determine the output format and file extension
74
+
75
+ Args:
76
+ source_path: Path to the source image
77
+ options: Additional options for format selection
78
+
79
+ Returns:
80
+ Tuple[str, str]: (PIL format name, file extension)
81
+ """
82
+ _, ext = os.path.splitext(source_path.lower())
83
+
84
+ # Use specified format if provided
85
+ if options and 'format' in options:
86
+ format_name = options['format'].upper()
87
+ if format_name == 'JPEG':
88
+ return format_name, '.jpg'
89
+ return format_name, f".{options['format'].lower()}"
90
+
91
+ # Use original format if supported
92
+ if ext in self.format_map:
93
+ return self.format_map[ext], ext
94
+
95
+ # Default to JPEG
96
+ return self.default_format, '.jpg'
97
+
98
+ def _process_image(self, source_path: str, width: int, height: int,
99
+ mode: str = 'contain', options: Dict = None) -> Tuple[BinaryIO, str, str]:
100
+ """
101
+ Process image to create a rendition
102
+
103
+ Args:
104
+ source_path: Path to the source image
105
+ width: Target width
106
+ height: Target height
107
+ mode: Resize mode ('contain', 'crop', 'stretch')
108
+ options: Additional processing options
109
+
110
+ Returns:
111
+ Tuple[BinaryIO, str, str]: (Image data, format, mime type)
112
+ """
113
+ options = options or {}
114
+ quality = options.get('quality', self.default_quality)
115
+
116
+ try:
117
+ # Open the image
118
+ with Image.open(source_path) as img:
119
+ # Convert to RGB if RGBA (unless PNG or format with alpha support)
120
+ if img.mode == 'RGBA' and options.get('format', '').upper() != 'PNG':
121
+ img = img.convert('RGB')
122
+
123
+ # Process based on mode
124
+ if mode == 'crop':
125
+ # Square crop (centered)
126
+ img = ImageOps.fit(img, (width, height), Image.Resampling.LANCZOS)
127
+ elif mode == 'contain':
128
+ # Resize to fit within dimensions while maintaining aspect ratio
129
+ img.thumbnail((width, height), Image.Resampling.LANCZOS)
130
+ elif mode == 'stretch':
131
+ # Stretch to fill dimensions
132
+ img = img.resize((width, height), Image.Resampling.LANCZOS)
133
+
134
+ # Determine output format
135
+ format_name, extension = self._get_output_format(source_path, options)
136
+
137
+ # Save to buffer
138
+ buffer = io.BytesIO()
139
+ if format_name == 'JPEG':
140
+ img.save(buffer, format=format_name, quality=quality, optimize=True)
141
+ else:
142
+ img.save(buffer, format=format_name)
143
+
144
+ buffer.seek(0)
145
+ mime_type = mimetypes.guess_type(f"file{extension}")[0]
146
+
147
+ return buffer, extension, mime_type
148
+ except Exception as e:
149
+ logger.error(f"Image processing error: {str(e)}")
150
+ raise
151
+
152
+ def create_rendition(self, role: str, options: Dict = None) -> Optional[FileRendition]:
153
+ """
154
+ Create an image rendition for the specified role
155
+
156
+ Args:
157
+ role: The role of the rendition
158
+ options: Additional options for creating the rendition
159
+
160
+ Returns:
161
+ FileRendition: The created rendition, or None if creation failed
162
+ """
163
+ try:
164
+ # Get rendition settings
165
+ settings = self.default_renditions.get(role, {})
166
+ if options:
167
+ settings.update(options)
168
+
169
+ # Default dimensions and mode
170
+ width = settings.get('width', 150)
171
+ height = settings.get('height', 150)
172
+ mode = settings.get('mode', 'contain')
173
+
174
+ # Download the original file
175
+ source_path = self._download_original()
176
+ if not source_path:
177
+ return None
178
+
179
+ try:
180
+ # Process the image
181
+ buffer, extension, mime_type = self._process_image(
182
+ source_path, width, height, mode, settings
183
+ )
184
+
185
+ # Generate output filename
186
+ name, _ = os.path.splitext(self.file.storage_filename)
187
+ filename = f"{name}_renditions/{role}{extension}"
188
+
189
+ # Save to storage
190
+ file_manager = self.file.file_manager
191
+ backend = file_manager.backend
192
+ storage_path = os.path.join(
193
+ os.path.dirname(self.file.storage_file_path),
194
+ filename
195
+ )
196
+
197
+ # Upload to storage
198
+ # print(storage_path)
199
+ backend.save(buffer, storage_path, mime_type)
200
+
201
+ # Get file size
202
+ file_size = buffer.getbuffer().nbytes
203
+
204
+ # Create rendition record
205
+ rendition = self._create_rendition_object(
206
+ role=role,
207
+ filename=filename,
208
+ storage_path=storage_path,
209
+ content_type=mime_type,
210
+ category='image',
211
+ file_size=file_size
212
+ )
213
+
214
+ return rendition
215
+ finally:
216
+ # Clean up temporary file
217
+ if os.path.exists(source_path):
218
+ os.unlink(source_path)
219
+
220
+ except Exception as e:
221
+ logger.error(f"Failed to create image rendition '{role}': {str(e)}")
222
+ return None