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
@@ -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
|