django-nativemojo 0.1.10__py3-none-any.whl → 0.1.16__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 (276) hide show
  1. django_nativemojo-0.1.16.dist-info/METADATA +138 -0
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  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 +651 -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/migrations/0006_add_device_tracking_models.py +72 -0
  10. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  11. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  12. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  13. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  14. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  15. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  16. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  17. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  18. mojo/apps/account/models/__init__.py +2 -0
  19. mojo/apps/account/models/device.py +281 -0
  20. mojo/apps/account/models/group.py +319 -15
  21. mojo/apps/account/models/member.py +29 -5
  22. mojo/apps/account/models/push/__init__.py +4 -0
  23. mojo/apps/account/models/push/config.py +112 -0
  24. mojo/apps/account/models/push/delivery.py +93 -0
  25. mojo/apps/account/models/push/device.py +66 -0
  26. mojo/apps/account/models/push/template.py +99 -0
  27. mojo/apps/account/models/user.py +369 -19
  28. mojo/apps/account/rest/__init__.py +2 -0
  29. mojo/apps/account/rest/device.py +39 -0
  30. mojo/apps/account/rest/group.py +9 -0
  31. mojo/apps/account/rest/push.py +187 -0
  32. mojo/apps/account/rest/user.py +100 -6
  33. mojo/apps/account/services/__init__.py +1 -0
  34. mojo/apps/account/services/push.py +363 -0
  35. mojo/apps/aws/migrations/0001_initial.py +206 -0
  36. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  37. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  38. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  39. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  40. mojo/apps/aws/models/__init__.py +19 -0
  41. mojo/apps/aws/models/email_attachment.py +99 -0
  42. mojo/apps/aws/models/email_domain.py +218 -0
  43. mojo/apps/aws/models/email_template.py +132 -0
  44. mojo/apps/aws/models/incoming_email.py +197 -0
  45. mojo/apps/aws/models/mailbox.py +288 -0
  46. mojo/apps/aws/models/sent_message.py +175 -0
  47. mojo/apps/aws/rest/__init__.py +7 -0
  48. mojo/apps/aws/rest/email.py +33 -0
  49. mojo/apps/aws/rest/email_ops.py +183 -0
  50. mojo/apps/aws/rest/messages.py +32 -0
  51. mojo/apps/aws/rest/s3.py +64 -0
  52. mojo/apps/aws/rest/send.py +101 -0
  53. mojo/apps/aws/rest/sns.py +403 -0
  54. mojo/apps/aws/rest/templates.py +19 -0
  55. mojo/apps/aws/services/__init__.py +32 -0
  56. mojo/apps/aws/services/email.py +390 -0
  57. mojo/apps/aws/services/email_ops.py +548 -0
  58. mojo/apps/docit/__init__.py +6 -0
  59. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  60. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  61. mojo/apps/docit/migrations/0001_initial.py +113 -0
  62. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  63. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  64. mojo/apps/docit/models/__init__.py +17 -0
  65. mojo/apps/docit/models/asset.py +231 -0
  66. mojo/apps/docit/models/book.py +227 -0
  67. mojo/apps/docit/models/page.py +319 -0
  68. mojo/apps/docit/models/page_revision.py +203 -0
  69. mojo/apps/docit/rest/__init__.py +10 -0
  70. mojo/apps/docit/rest/asset.py +17 -0
  71. mojo/apps/docit/rest/book.py +22 -0
  72. mojo/apps/docit/rest/page.py +22 -0
  73. mojo/apps/docit/rest/page_revision.py +17 -0
  74. mojo/apps/docit/services/__init__.py +11 -0
  75. mojo/apps/docit/services/docit.py +315 -0
  76. mojo/apps/docit/services/markdown.py +44 -0
  77. mojo/apps/fileman/README.md +8 -8
  78. mojo/apps/fileman/backends/base.py +76 -70
  79. mojo/apps/fileman/backends/filesystem.py +86 -86
  80. mojo/apps/fileman/backends/s3.py +409 -108
  81. mojo/apps/fileman/migrations/0001_initial.py +106 -0
  82. mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
  83. mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
  84. mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
  85. mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
  86. mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
  87. mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
  88. mojo/apps/fileman/migrations/0008_file_category.py +18 -0
  89. mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
  90. mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
  91. mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
  92. mojo/apps/fileman/models/__init__.py +1 -5
  93. mojo/apps/fileman/models/file.py +240 -58
  94. mojo/apps/fileman/models/manager.py +427 -31
  95. mojo/apps/fileman/models/rendition.py +118 -0
  96. mojo/apps/fileman/renderer/__init__.py +111 -0
  97. mojo/apps/fileman/renderer/audio.py +403 -0
  98. mojo/apps/fileman/renderer/base.py +205 -0
  99. mojo/apps/fileman/renderer/document.py +404 -0
  100. mojo/apps/fileman/renderer/image.py +222 -0
  101. mojo/apps/fileman/renderer/utils.py +297 -0
  102. mojo/apps/fileman/renderer/video.py +304 -0
  103. mojo/apps/fileman/rest/__init__.py +1 -18
  104. mojo/apps/fileman/rest/upload.py +22 -32
  105. mojo/apps/fileman/signals.py +58 -0
  106. mojo/apps/fileman/tasks.py +254 -0
  107. mojo/apps/fileman/utils/__init__.py +40 -16
  108. mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
  109. mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
  110. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  111. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  112. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  113. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  114. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  115. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  116. mojo/apps/incident/models/__init__.py +2 -0
  117. mojo/apps/incident/models/event.py +35 -0
  118. mojo/apps/incident/models/history.py +36 -0
  119. mojo/apps/incident/models/incident.py +3 -1
  120. mojo/apps/incident/models/ticket.py +62 -0
  121. mojo/apps/incident/reporter.py +21 -1
  122. mojo/apps/incident/rest/__init__.py +1 -0
  123. mojo/apps/incident/rest/event.py +7 -1
  124. mojo/apps/incident/rest/ticket.py +43 -0
  125. mojo/apps/jobs/__init__.py +489 -0
  126. mojo/apps/jobs/adapters.py +24 -0
  127. mojo/apps/jobs/cli.py +616 -0
  128. mojo/apps/jobs/daemon.py +370 -0
  129. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  130. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  131. mojo/apps/jobs/handlers/__init__.py +5 -0
  132. mojo/apps/jobs/handlers/webhook.py +317 -0
  133. mojo/apps/jobs/job_engine.py +734 -0
  134. mojo/apps/jobs/keys.py +203 -0
  135. mojo/apps/jobs/local_queue.py +363 -0
  136. mojo/apps/jobs/management/__init__.py +3 -0
  137. mojo/apps/jobs/management/commands/__init__.py +3 -0
  138. mojo/apps/jobs/manager.py +1327 -0
  139. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  140. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  141. mojo/apps/jobs/models/__init__.py +6 -0
  142. mojo/apps/jobs/models/job.py +441 -0
  143. mojo/apps/jobs/rest/__init__.py +2 -0
  144. mojo/apps/jobs/rest/control.py +466 -0
  145. mojo/apps/jobs/rest/jobs.py +421 -0
  146. mojo/apps/jobs/scheduler.py +571 -0
  147. mojo/apps/jobs/services/__init__.py +6 -0
  148. mojo/apps/jobs/services/job_actions.py +465 -0
  149. mojo/apps/jobs/settings.py +209 -0
  150. mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
  151. mojo/apps/logit/models/log.py +7 -1
  152. mojo/apps/metrics/__init__.py +8 -1
  153. mojo/apps/metrics/redis_metrics.py +198 -0
  154. mojo/apps/metrics/rest/__init__.py +3 -0
  155. mojo/apps/metrics/rest/categories.py +266 -0
  156. mojo/apps/metrics/rest/helpers.py +48 -0
  157. mojo/apps/metrics/rest/permissions.py +99 -0
  158. mojo/apps/metrics/rest/values.py +277 -0
  159. mojo/apps/metrics/utils.py +19 -2
  160. mojo/decorators/auth.py +6 -1
  161. mojo/decorators/http.py +47 -3
  162. mojo/helpers/aws/__init__.py +45 -0
  163. mojo/helpers/aws/ec2.py +804 -0
  164. mojo/helpers/aws/iam.py +748 -0
  165. mojo/helpers/aws/inbound_email.py +309 -0
  166. mojo/helpers/aws/kms.py +413 -0
  167. mojo/helpers/aws/s3.py +451 -11
  168. mojo/helpers/aws/ses.py +483 -0
  169. mojo/helpers/aws/ses_domain.py +959 -0
  170. mojo/helpers/aws/sns.py +461 -0
  171. mojo/helpers/crypto/__init__.py +1 -1
  172. mojo/helpers/crypto/utils.py +15 -0
  173. mojo/helpers/dates.py +18 -0
  174. mojo/helpers/location/__init__.py +2 -0
  175. mojo/helpers/location/countries.py +262 -0
  176. mojo/helpers/location/geolocation.py +196 -0
  177. mojo/helpers/logit.py +37 -0
  178. mojo/helpers/redis/__init__.py +2 -0
  179. mojo/helpers/redis/adapter.py +606 -0
  180. mojo/helpers/redis/client.py +48 -0
  181. mojo/helpers/redis/pool.py +225 -0
  182. mojo/helpers/request.py +8 -0
  183. mojo/helpers/response.py +14 -2
  184. mojo/helpers/settings/__init__.py +2 -0
  185. mojo/helpers/{settings.py → settings/helper.py} +1 -37
  186. mojo/helpers/settings/parser.py +132 -0
  187. mojo/middleware/auth.py +1 -1
  188. mojo/middleware/cors.py +40 -0
  189. mojo/middleware/logging.py +131 -12
  190. mojo/middleware/mojo.py +10 -0
  191. mojo/models/rest.py +494 -65
  192. mojo/models/secrets.py +98 -3
  193. mojo/serializers/__init__.py +106 -0
  194. mojo/serializers/core/__init__.py +90 -0
  195. mojo/serializers/core/cache/__init__.py +121 -0
  196. mojo/serializers/core/cache/backends.py +518 -0
  197. mojo/serializers/core/cache/base.py +102 -0
  198. mojo/serializers/core/cache/disabled.py +181 -0
  199. mojo/serializers/core/cache/memory.py +287 -0
  200. mojo/serializers/core/cache/redis.py +533 -0
  201. mojo/serializers/core/cache/utils.py +454 -0
  202. mojo/serializers/core/manager.py +550 -0
  203. mojo/serializers/core/serializer.py +475 -0
  204. mojo/serializers/examples/settings.py +322 -0
  205. mojo/serializers/formats/csv.py +393 -0
  206. mojo/serializers/formats/localizers.py +509 -0
  207. mojo/serializers/{models.py → simple.py} +38 -15
  208. mojo/serializers/suggested_improvements.md +388 -0
  209. testit/client.py +1 -1
  210. testit/helpers.py +35 -4
  211. testit/runner.py +23 -6
  212. django_nativemojo-0.1.10.dist-info/METADATA +0 -96
  213. django_nativemojo-0.1.10.dist-info/RECORD +0 -194
  214. mojo/apps/metrics/rest/db.py +0 -0
  215. mojo/apps/notify/README.md +0 -91
  216. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  217. mojo/apps/notify/admin.py +0 -52
  218. mojo/apps/notify/handlers/example_handlers.py +0 -516
  219. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  220. mojo/apps/notify/handlers/ses/bounce.py +0 -0
  221. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  222. mojo/apps/notify/handlers/ses/message.py +0 -86
  223. mojo/apps/notify/management/commands/__init__.py +0 -1
  224. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  225. mojo/apps/notify/mod +0 -0
  226. mojo/apps/notify/models/__init__.py +0 -12
  227. mojo/apps/notify/models/account.py +0 -128
  228. mojo/apps/notify/models/attachment.py +0 -24
  229. mojo/apps/notify/models/bounce.py +0 -68
  230. mojo/apps/notify/models/complaint.py +0 -40
  231. mojo/apps/notify/models/inbox.py +0 -113
  232. mojo/apps/notify/models/inbox_message.py +0 -173
  233. mojo/apps/notify/models/outbox.py +0 -129
  234. mojo/apps/notify/models/outbox_message.py +0 -288
  235. mojo/apps/notify/models/template.py +0 -30
  236. mojo/apps/notify/providers/aws.py +0 -73
  237. mojo/apps/notify/rest/ses.py +0 -0
  238. mojo/apps/notify/utils/__init__.py +0 -2
  239. mojo/apps/notify/utils/notifications.py +0 -404
  240. mojo/apps/notify/utils/parsing.py +0 -202
  241. mojo/apps/notify/utils/render.py +0 -144
  242. mojo/apps/tasks/README.md +0 -118
  243. mojo/apps/tasks/__init__.py +0 -11
  244. mojo/apps/tasks/manager.py +0 -489
  245. mojo/apps/tasks/rest/__init__.py +0 -2
  246. mojo/apps/tasks/rest/hooks.py +0 -0
  247. mojo/apps/tasks/rest/tasks.py +0 -62
  248. mojo/apps/tasks/runner.py +0 -174
  249. mojo/apps/tasks/tq_handlers.py +0 -14
  250. mojo/helpers/aws/setup_email.py +0 -0
  251. mojo/helpers/redis.py +0 -10
  252. mojo/models/meta.py +0 -262
  253. mojo/ws4redis/README.md +0 -174
  254. mojo/ws4redis/__init__.py +0 -2
  255. mojo/ws4redis/client.py +0 -283
  256. mojo/ws4redis/connection.py +0 -327
  257. mojo/ws4redis/exceptions.py +0 -32
  258. mojo/ws4redis/redis.py +0 -183
  259. mojo/ws4redis/servers/base.py +0 -86
  260. mojo/ws4redis/servers/django.py +0 -171
  261. mojo/ws4redis/servers/uwsgi.py +0 -63
  262. mojo/ws4redis/settings.py +0 -45
  263. mojo/ws4redis/utf8validator.py +0 -128
  264. mojo/ws4redis/websocket.py +0 -403
  265. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  266. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  267. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  268. /mojo/apps/{notify → aws}/__init__.py +0 -0
  269. /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
  270. /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
  271. /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
  272. /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
  273. /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
  274. /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
  275. /mojo/{serializers → rest}/openapi.py +0 -0
  276. /mojo/{apps/fileman/rest/__init__ → serializers/formats/__init__.py} +0 -0
@@ -0,0 +1,205 @@
1
+ import os
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict, List, Optional, Tuple, Any, Union
5
+ from django.conf import settings
6
+ from mojo.apps.fileman.models import File, FileRendition
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class RenditionRole:
11
+ """
12
+ Predefined roles for file renditions
13
+ """
14
+ # Common roles
15
+ ORIGINAL = 'original'
16
+ THUMBNAIL = 'thumbnail'
17
+ PREVIEW = 'preview'
18
+
19
+ # Image-specific roles
20
+ THUMBNAIL_SM = 'thumbnail_sm'
21
+ THUMBNAIL_MD = 'thumbnail_md'
22
+ THUMBNAIL_LG = 'thumbnail_lg'
23
+ SQUARE_SM = 'square_sm'
24
+ SQUARE_MD = 'square_md'
25
+ SQUARE_LG = 'square_lg'
26
+
27
+ # Video-specific roles
28
+ VIDEO_THUMBNAIL = 'video_thumbnail'
29
+ VIDEO_PREVIEW = 'video_preview'
30
+ VIDEO_MP4 = 'video_mp4'
31
+ VIDEO_WEBM = 'video_webm'
32
+
33
+ # Document-specific roles
34
+ DOCUMENT_THUMBNAIL = 'document_thumbnail'
35
+ DOCUMENT_PREVIEW = 'document_preview'
36
+ DOCUMENT_PDF = 'document_pdf'
37
+
38
+ # Audio-specific roles
39
+ AUDIO_THUMBNAIL = 'audio_thumbnail'
40
+ AUDIO_PREVIEW = 'audio_preview'
41
+ AUDIO_MP3 = 'audio_mp3'
42
+
43
+
44
+ class BaseRenderer(ABC):
45
+ """
46
+ Base class for file renderers
47
+
48
+ A renderer creates different versions (renditions) of a file based on
49
+ predefined roles. Each renderer supports specific file categories and
50
+ provides implementations for creating renditions.
51
+ """
52
+
53
+ # The file categories this renderer supports
54
+ supported_categories = []
55
+
56
+ # Default rendition definitions:
57
+ # mapping of role -> (width, height, options)
58
+ default_renditions = {}
59
+
60
+ def __init__(self, file: File):
61
+ """
62
+ Initialize renderer with a file
63
+
64
+ Args:
65
+ file: The original file to create renditions from
66
+ """
67
+ self.file = file
68
+ self.renditions = {}
69
+ self._load_existing_renditions()
70
+
71
+ def _load_existing_renditions(self):
72
+ """Load existing renditions for this file"""
73
+ for rendition in FileRendition.objects.filter(original_file=self.file):
74
+ self.renditions[rendition.role] = rendition
75
+
76
+ @classmethod
77
+ def supports_file(cls, file: File) -> bool:
78
+ """
79
+ Check if this renderer supports the given file
80
+
81
+ Args:
82
+ file: The file to check
83
+
84
+ Returns:
85
+ bool: True if this renderer supports the file, False otherwise
86
+ """
87
+ return file.category in cls.supported_categories
88
+
89
+ @abstractmethod
90
+ def create_rendition(self, role: str, options: Dict = None) -> Optional[FileRendition]:
91
+ """
92
+ Create a rendition for the specified role
93
+
94
+ Args:
95
+ role: The role of the rendition (e.g., 'thumbnail', 'preview')
96
+ options: Additional options for creating the rendition
97
+
98
+ Returns:
99
+ FileRendition: The created rendition, or None if creation failed
100
+ """
101
+ pass
102
+
103
+ def get_rendition(self, role: str, create_if_missing: bool = True) -> Optional[FileRendition]:
104
+ """
105
+ Get a rendition for the specified role
106
+
107
+ Args:
108
+ role: The role of the rendition
109
+ create_if_missing: Whether to create the rendition if it doesn't exist
110
+
111
+ Returns:
112
+ FileRendition: The rendition, or None if not found and not created
113
+ """
114
+ if role in self.renditions:
115
+ return self.renditions[role]
116
+
117
+ if create_if_missing:
118
+ options = self.default_renditions.get(role, {})
119
+ rendition = self.create_rendition(role, options)
120
+ if rendition:
121
+ self.renditions[role] = rendition
122
+ return rendition
123
+
124
+ return None
125
+
126
+ def create_all_renditions(self) -> List[FileRendition]:
127
+ """
128
+ Create all default renditions for this file
129
+
130
+ Returns:
131
+ List[FileRendition]: List of created renditions
132
+ """
133
+ results = []
134
+ for role, options in self.default_renditions.items():
135
+ rendition = self.get_rendition(role)
136
+ if rendition:
137
+ results.append(rendition)
138
+ return results
139
+
140
+ def cleanup_renditions(self):
141
+ """
142
+ Remove all renditions for this file
143
+ """
144
+ FileRendition.objects.filter(original_file=self.file).delete()
145
+ self.renditions = {}
146
+
147
+ def _create_rendition_object(self, role: str, filename: str, storage_path: str,
148
+ content_type: str, category: str, file_size: int = None) -> FileRendition:
149
+ """
150
+ Create a FileRendition object in the database
151
+
152
+ Args:
153
+ role: The role of the rendition
154
+ filename: The filename of the rendition
155
+ storage_path: The storage path of the rendition
156
+ content_type: The MIME type of the rendition
157
+ category: The category of the rendition
158
+ file_size: The size of the rendition in bytes
159
+
160
+ Returns:
161
+ FileRendition: The created rendition object
162
+ """
163
+ rendition = FileRendition(
164
+ original_file=self.file,
165
+ role=role,
166
+ filename=filename,
167
+ storage_path=storage_path,
168
+ content_type=content_type,
169
+ category=category,
170
+ file_size=file_size,
171
+ upload_status=FileRendition.COMPLETED
172
+ )
173
+ rendition.save()
174
+ return rendition
175
+
176
+ def get_temp_path(self, suffix: str = '') -> str:
177
+ """
178
+ Get a temporary file path for processing
179
+
180
+ Args:
181
+ suffix: Optional suffix for the temp file (e.g., '.jpg')
182
+
183
+ Returns:
184
+ str: Path to a temporary file
185
+ """
186
+ import tempfile
187
+ temp_dir = getattr(settings, 'MOJO_TEMP_DIR', None)
188
+ if temp_dir:
189
+ os.makedirs(temp_dir, exist_ok=True)
190
+ return os.path.join(temp_dir, f"{self.file.id}_{suffix}")
191
+ return tempfile.mktemp(suffix=suffix)
192
+
193
+ @staticmethod
194
+ def get_renderer_for_file(file: File) -> Optional['BaseRenderer']:
195
+ """
196
+ Get the appropriate renderer for a file
197
+
198
+ Args:
199
+ file: The file to get a renderer for
200
+
201
+ Returns:
202
+ BaseRenderer: The renderer instance, or None if no renderer supports the file
203
+ """
204
+ from mojo.apps.fileman.renderer import get_renderer_for_file
205
+ return get_renderer_for_file(file)
@@ -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