django-nativemojo 0.1.10__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 (194) hide show
  1. django_nativemojo-0.1.10.dist-info/LICENSE +19 -0
  2. django_nativemojo-0.1.10.dist-info/METADATA +96 -0
  3. django_nativemojo-0.1.10.dist-info/NOTICE +8 -0
  4. django_nativemojo-0.1.10.dist-info/RECORD +194 -0
  5. django_nativemojo-0.1.10.dist-info/WHEEL +4 -0
  6. mojo/__init__.py +3 -0
  7. mojo/apps/account/__init__.py +1 -0
  8. mojo/apps/account/admin.py +91 -0
  9. mojo/apps/account/apps.py +16 -0
  10. mojo/apps/account/migrations/0001_initial.py +77 -0
  11. mojo/apps/account/migrations/0002_user_is_email_verified_user_is_phone_verified.py +23 -0
  12. mojo/apps/account/migrations/0003_group_mojo_secrets_user_mojo_secrets.py +23 -0
  13. mojo/apps/account/migrations/__init__.py +0 -0
  14. mojo/apps/account/models/__init__.py +3 -0
  15. mojo/apps/account/models/group.py +98 -0
  16. mojo/apps/account/models/member.py +95 -0
  17. mojo/apps/account/models/pkey.py +18 -0
  18. mojo/apps/account/models/user.py +211 -0
  19. mojo/apps/account/rest/__init__.py +3 -0
  20. mojo/apps/account/rest/group.py +25 -0
  21. mojo/apps/account/rest/user.py +47 -0
  22. mojo/apps/account/utils/__init__.py +0 -0
  23. mojo/apps/account/utils/jwtoken.py +72 -0
  24. mojo/apps/account/utils/passkeys.py +54 -0
  25. mojo/apps/fileman/README.md +549 -0
  26. mojo/apps/fileman/__init__.py +0 -0
  27. mojo/apps/fileman/apps.py +15 -0
  28. mojo/apps/fileman/backends/__init__.py +117 -0
  29. mojo/apps/fileman/backends/base.py +319 -0
  30. mojo/apps/fileman/backends/filesystem.py +397 -0
  31. mojo/apps/fileman/backends/s3.py +398 -0
  32. mojo/apps/fileman/examples/configurations.py +378 -0
  33. mojo/apps/fileman/examples/usage_example.py +665 -0
  34. mojo/apps/fileman/management/__init__.py +1 -0
  35. mojo/apps/fileman/management/commands/__init__.py +1 -0
  36. mojo/apps/fileman/management/commands/cleanup_expired_uploads.py +222 -0
  37. mojo/apps/fileman/models/__init__.py +7 -0
  38. mojo/apps/fileman/models/file.py +292 -0
  39. mojo/apps/fileman/models/manager.py +227 -0
  40. mojo/apps/fileman/models/render.py +0 -0
  41. mojo/apps/fileman/rest/__init__ +0 -0
  42. mojo/apps/fileman/rest/__init__.py +23 -0
  43. mojo/apps/fileman/rest/fileman.py +13 -0
  44. mojo/apps/fileman/rest/upload.py +92 -0
  45. mojo/apps/fileman/utils/__init__.py +19 -0
  46. mojo/apps/fileman/utils/upload.py +616 -0
  47. mojo/apps/incident/__init__.py +1 -0
  48. mojo/apps/incident/handlers/__init__.py +3 -0
  49. mojo/apps/incident/handlers/event_handlers.py +142 -0
  50. mojo/apps/incident/migrations/0001_initial.py +83 -0
  51. mojo/apps/incident/migrations/0002_rename_bundle_ruleset_bundle_minutes_event_hostname_and_more.py +44 -0
  52. mojo/apps/incident/migrations/0003_alter_event_model_id.py +18 -0
  53. mojo/apps/incident/migrations/0004_alter_incident_model_id.py +18 -0
  54. mojo/apps/incident/migrations/__init__.py +0 -0
  55. mojo/apps/incident/models/__init__.py +3 -0
  56. mojo/apps/incident/models/event.py +135 -0
  57. mojo/apps/incident/models/incident.py +33 -0
  58. mojo/apps/incident/models/rule.py +247 -0
  59. mojo/apps/incident/parsers/__init__.py +0 -0
  60. mojo/apps/incident/parsers/ossec/__init__.py +1 -0
  61. mojo/apps/incident/parsers/ossec/core.py +82 -0
  62. mojo/apps/incident/parsers/ossec/parsed.py +23 -0
  63. mojo/apps/incident/parsers/ossec/rules.py +124 -0
  64. mojo/apps/incident/parsers/ossec/utils.py +169 -0
  65. mojo/apps/incident/reporter.py +42 -0
  66. mojo/apps/incident/rest/__init__.py +2 -0
  67. mojo/apps/incident/rest/event.py +23 -0
  68. mojo/apps/incident/rest/ossec.py +22 -0
  69. mojo/apps/logit/__init__.py +0 -0
  70. mojo/apps/logit/admin.py +37 -0
  71. mojo/apps/logit/migrations/0001_initial.py +32 -0
  72. mojo/apps/logit/migrations/0002_log_duid_log_payload_log_username.py +28 -0
  73. mojo/apps/logit/migrations/0003_log_level.py +18 -0
  74. mojo/apps/logit/migrations/__init__.py +0 -0
  75. mojo/apps/logit/models/__init__.py +1 -0
  76. mojo/apps/logit/models/log.py +57 -0
  77. mojo/apps/logit/rest.py +9 -0
  78. mojo/apps/metrics/README.md +79 -0
  79. mojo/apps/metrics/__init__.py +12 -0
  80. mojo/apps/metrics/redis_metrics.py +331 -0
  81. mojo/apps/metrics/rest/__init__.py +1 -0
  82. mojo/apps/metrics/rest/base.py +152 -0
  83. mojo/apps/metrics/rest/db.py +0 -0
  84. mojo/apps/metrics/utils.py +227 -0
  85. mojo/apps/notify/README.md +91 -0
  86. mojo/apps/notify/README_NOTIFICATIONS.md +566 -0
  87. mojo/apps/notify/__init__.py +0 -0
  88. mojo/apps/notify/admin.py +52 -0
  89. mojo/apps/notify/handlers/__init__.py +0 -0
  90. mojo/apps/notify/handlers/example_handlers.py +516 -0
  91. mojo/apps/notify/handlers/ses/__init__.py +25 -0
  92. mojo/apps/notify/handlers/ses/bounce.py +0 -0
  93. mojo/apps/notify/handlers/ses/complaint.py +25 -0
  94. mojo/apps/notify/handlers/ses/message.py +86 -0
  95. mojo/apps/notify/management/__init__.py +0 -0
  96. mojo/apps/notify/management/commands/__init__.py +1 -0
  97. mojo/apps/notify/management/commands/process_notifications.py +370 -0
  98. mojo/apps/notify/mod +0 -0
  99. mojo/apps/notify/models/__init__.py +12 -0
  100. mojo/apps/notify/models/account.py +128 -0
  101. mojo/apps/notify/models/attachment.py +24 -0
  102. mojo/apps/notify/models/bounce.py +68 -0
  103. mojo/apps/notify/models/complaint.py +40 -0
  104. mojo/apps/notify/models/inbox.py +113 -0
  105. mojo/apps/notify/models/inbox_message.py +173 -0
  106. mojo/apps/notify/models/outbox.py +129 -0
  107. mojo/apps/notify/models/outbox_message.py +288 -0
  108. mojo/apps/notify/models/template.py +30 -0
  109. mojo/apps/notify/providers/__init__.py +0 -0
  110. mojo/apps/notify/providers/aws.py +73 -0
  111. mojo/apps/notify/rest/__init__.py +0 -0
  112. mojo/apps/notify/rest/ses.py +0 -0
  113. mojo/apps/notify/utils/__init__.py +2 -0
  114. mojo/apps/notify/utils/notifications.py +404 -0
  115. mojo/apps/notify/utils/parsing.py +202 -0
  116. mojo/apps/notify/utils/render.py +144 -0
  117. mojo/apps/tasks/README.md +118 -0
  118. mojo/apps/tasks/__init__.py +11 -0
  119. mojo/apps/tasks/manager.py +489 -0
  120. mojo/apps/tasks/rest/__init__.py +2 -0
  121. mojo/apps/tasks/rest/hooks.py +0 -0
  122. mojo/apps/tasks/rest/tasks.py +62 -0
  123. mojo/apps/tasks/runner.py +174 -0
  124. mojo/apps/tasks/tq_handlers.py +14 -0
  125. mojo/decorators/__init__.py +3 -0
  126. mojo/decorators/auth.py +25 -0
  127. mojo/decorators/cron.py +31 -0
  128. mojo/decorators/http.py +132 -0
  129. mojo/decorators/validate.py +14 -0
  130. mojo/errors.py +88 -0
  131. mojo/helpers/__init__.py +0 -0
  132. mojo/helpers/aws/__init__.py +0 -0
  133. mojo/helpers/aws/client.py +8 -0
  134. mojo/helpers/aws/s3.py +268 -0
  135. mojo/helpers/aws/setup_email.py +0 -0
  136. mojo/helpers/cron.py +79 -0
  137. mojo/helpers/crypto/__init__.py +4 -0
  138. mojo/helpers/crypto/aes.py +60 -0
  139. mojo/helpers/crypto/hash.py +59 -0
  140. mojo/helpers/crypto/privpub/__init__.py +1 -0
  141. mojo/helpers/crypto/privpub/hybrid.py +97 -0
  142. mojo/helpers/crypto/privpub/rsa.py +104 -0
  143. mojo/helpers/crypto/sign.py +36 -0
  144. mojo/helpers/crypto/too.l.py +25 -0
  145. mojo/helpers/crypto/utils.py +26 -0
  146. mojo/helpers/daemon.py +94 -0
  147. mojo/helpers/dates.py +69 -0
  148. mojo/helpers/dns/__init__.py +0 -0
  149. mojo/helpers/dns/godaddy.py +62 -0
  150. mojo/helpers/filetypes.py +128 -0
  151. mojo/helpers/logit.py +310 -0
  152. mojo/helpers/modules.py +95 -0
  153. mojo/helpers/paths.py +63 -0
  154. mojo/helpers/redis.py +10 -0
  155. mojo/helpers/request.py +89 -0
  156. mojo/helpers/request_parser.py +269 -0
  157. mojo/helpers/response.py +14 -0
  158. mojo/helpers/settings.py +146 -0
  159. mojo/helpers/sysinfo.py +140 -0
  160. mojo/helpers/ua.py +0 -0
  161. mojo/middleware/__init__.py +0 -0
  162. mojo/middleware/auth.py +26 -0
  163. mojo/middleware/logging.py +55 -0
  164. mojo/middleware/mojo.py +21 -0
  165. mojo/migrations/0001_initial.py +32 -0
  166. mojo/migrations/__init__.py +0 -0
  167. mojo/models/__init__.py +2 -0
  168. mojo/models/meta.py +262 -0
  169. mojo/models/rest.py +538 -0
  170. mojo/models/secrets.py +59 -0
  171. mojo/rest/__init__.py +1 -0
  172. mojo/rest/info.py +26 -0
  173. mojo/serializers/__init__.py +0 -0
  174. mojo/serializers/models.py +165 -0
  175. mojo/serializers/openapi.py +188 -0
  176. mojo/urls.py +38 -0
  177. mojo/ws4redis/README.md +174 -0
  178. mojo/ws4redis/__init__.py +2 -0
  179. mojo/ws4redis/client.py +283 -0
  180. mojo/ws4redis/connection.py +327 -0
  181. mojo/ws4redis/exceptions.py +32 -0
  182. mojo/ws4redis/redis.py +183 -0
  183. mojo/ws4redis/servers/__init__.py +0 -0
  184. mojo/ws4redis/servers/base.py +86 -0
  185. mojo/ws4redis/servers/django.py +171 -0
  186. mojo/ws4redis/servers/uwsgi.py +63 -0
  187. mojo/ws4redis/settings.py +45 -0
  188. mojo/ws4redis/utf8validator.py +128 -0
  189. mojo/ws4redis/websocket.py +403 -0
  190. testit/__init__.py +0 -0
  191. testit/client.py +147 -0
  192. testit/faker.py +20 -0
  193. testit/helpers.py +198 -0
  194. testit/runner.py +262 -0
@@ -0,0 +1,222 @@
1
+ from django.core.management.base import BaseCommand, CommandError
2
+ from django.utils import timezone
3
+ from datetime import datetime, timedelta
4
+ from django.db import transaction
5
+ import logging
6
+
7
+ from mojo.apps.fileman.models import File, FileManager
8
+ from mojo.apps.fileman.backends import get_backend
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Command(BaseCommand):
15
+ help = 'Clean up expired file uploads and temporary files'
16
+
17
+ def add_arguments(self, parser):
18
+ parser.add_argument(
19
+ '--days',
20
+ type=int,
21
+ default=1,
22
+ help='Clean up uploads older than this many days (default: 1)'
23
+ )
24
+
25
+ parser.add_argument(
26
+ '--hours',
27
+ type=int,
28
+ help='Clean up uploads older than this many hours (overrides --days)'
29
+ )
30
+
31
+ parser.add_argument(
32
+ '--dry-run',
33
+ action='store_true',
34
+ help='Show what would be cleaned up without actually doing it'
35
+ )
36
+
37
+ parser.add_argument(
38
+ '--backend-cleanup',
39
+ action='store_true',
40
+ default=True,
41
+ help='Also run backend-specific cleanup (default: True)'
42
+ )
43
+
44
+ parser.add_argument(
45
+ '--no-backend-cleanup',
46
+ action='store_false',
47
+ dest='backend_cleanup',
48
+ help='Skip backend-specific cleanup'
49
+ )
50
+
51
+ parser.add_argument(
52
+ '--status',
53
+ choices=['pending', 'uploading', 'failed', 'expired', 'all'],
54
+ default='all',
55
+ help='Which upload statuses to clean up (default: all non-completed)'
56
+ )
57
+
58
+ parser.add_argument(
59
+ '--verbose',
60
+ action='store_true',
61
+ help='Verbose output'
62
+ )
63
+
64
+ def handle(self, *args, **options):
65
+ self.dry_run = options['dry_run']
66
+ self.verbose = options['verbose']
67
+
68
+ # Calculate cutoff date
69
+ if options['hours']:
70
+ cutoff_date = timezone.now() - timedelta(hours=options['hours'])
71
+ cutoff_desc = f"{options['hours']} hours"
72
+ else:
73
+ cutoff_date = timezone.now() - timedelta(days=options['days'])
74
+ cutoff_desc = f"{options['days']} days"
75
+
76
+ if self.dry_run:
77
+ self.stdout.write(
78
+ self.style.WARNING(f"DRY RUN: Showing what would be cleaned up")
79
+ )
80
+
81
+ self.stdout.write(
82
+ f"Cleaning up uploads older than {cutoff_desc} (before {cutoff_date})"
83
+ )
84
+
85
+ # Clean up File records
86
+ files_cleaned = self.cleanup_files(cutoff_date, options['status'])
87
+
88
+ # Clean up backend temporary files
89
+ if options['backend_cleanup']:
90
+ backends_cleaned = self.cleanup_backends(cutoff_date)
91
+ else:
92
+ backends_cleaned = 0
93
+
94
+ # Summary
95
+ if self.dry_run:
96
+ self.stdout.write(
97
+ self.style.SUCCESS(
98
+ f"DRY RUN COMPLETE: Would clean up {files_cleaned} file records "
99
+ f"and {backends_cleaned} backend temporary files"
100
+ )
101
+ )
102
+ else:
103
+ self.stdout.write(
104
+ self.style.SUCCESS(
105
+ f"CLEANUP COMPLETE: Cleaned up {files_cleaned} file records "
106
+ f"and {backends_cleaned} backend temporary files"
107
+ )
108
+ )
109
+
110
+ def cleanup_files(self, cutoff_date, status_filter):
111
+ """Clean up File model records"""
112
+
113
+ # Build query based on status filter
114
+ query = File.objects.filter(created__lt=cutoff_date)
115
+
116
+ if status_filter == 'pending':
117
+ query = query.filter(upload_status=File.PENDING)
118
+ elif status_filter == 'uploading':
119
+ query = query.filter(upload_status=File.UPLOADING)
120
+ elif status_filter == 'failed':
121
+ query = query.filter(upload_status=File.FAILED)
122
+ elif status_filter == 'expired':
123
+ query = query.filter(upload_status=File.EXPIRED)
124
+ elif status_filter == 'all':
125
+ # Clean up all non-completed uploads
126
+ query = query.exclude(upload_status=File.COMPLETED)
127
+
128
+ # Also include files with expired upload URLs
129
+ expired_url_query = File.objects.filter(
130
+ upload_expires_at__lt=timezone.now(),
131
+ upload_status__in=[File.PENDING, File.UPLOADING]
132
+ )
133
+
134
+ # Combine queries
135
+ files_to_cleanup = query.union(expired_url_query).distinct()
136
+
137
+ if self.verbose:
138
+ self.stdout.write(f"Found {files_to_cleanup.count()} file records to clean up")
139
+
140
+ cleaned_count = 0
141
+
142
+ for file_obj in files_to_cleanup:
143
+ if self.verbose:
144
+ self.stdout.write(
145
+ f" File: {file_obj.original_filename} "
146
+ f"(status: {file_obj.upload_status}, "
147
+ f"created: {file_obj.created})"
148
+ )
149
+
150
+ if not self.dry_run:
151
+ try:
152
+ with transaction.atomic():
153
+ # Try to delete the actual file from storage if it exists
154
+ if file_obj.file_path and file_obj.upload_status == File.COMPLETED:
155
+ try:
156
+ backend = get_backend(file_obj.file_manager)
157
+ if backend.exists(file_obj.file_path):
158
+ backend.delete(file_obj.file_path)
159
+ if self.verbose:
160
+ self.stdout.write(f" Deleted file from storage: {file_obj.file_path}")
161
+ except Exception as e:
162
+ logger.warning(f"Failed to delete file from storage: {e}")
163
+ if self.verbose:
164
+ self.stdout.write(
165
+ self.style.WARNING(f" Warning: Could not delete from storage: {e}")
166
+ )
167
+
168
+ # Mark as expired or delete the record
169
+ if file_obj.upload_status in [File.PENDING, File.UPLOADING]:
170
+ file_obj.mark_as_expired()
171
+ if self.verbose:
172
+ self.stdout.write(f" Marked as expired")
173
+ else:
174
+ file_obj.delete()
175
+ if self.verbose:
176
+ self.stdout.write(f" Deleted record")
177
+
178
+ cleaned_count += 1
179
+
180
+ except Exception as e:
181
+ logger.error(f"Error cleaning up file {file_obj.id}: {e}")
182
+ if self.verbose:
183
+ self.stdout.write(
184
+ self.style.ERROR(f" Error: {e}")
185
+ )
186
+ else:
187
+ cleaned_count += 1
188
+
189
+ return cleaned_count
190
+
191
+ def cleanup_backends(self, cutoff_date):
192
+ """Run backend-specific cleanup"""
193
+ cleaned_count = 0
194
+
195
+ # Get all active file managers
196
+ file_managers = FileManager.objects.filter(is_active=True)
197
+
198
+ if self.verbose:
199
+ self.stdout.write(f"Running backend cleanup for {file_managers.count()} file managers")
200
+
201
+ for file_manager in file_managers:
202
+ try:
203
+ backend = get_backend(file_manager)
204
+
205
+ if self.verbose:
206
+ self.stdout.write(
207
+ f" Cleaning up {file_manager.name} ({file_manager.backend_type})"
208
+ )
209
+
210
+ if not self.dry_run:
211
+ backend.cleanup_expired_uploads(cutoff_date)
212
+
213
+ cleaned_count += 1
214
+
215
+ except Exception as e:
216
+ logger.error(f"Error running backend cleanup for {file_manager.name}: {e}")
217
+ if self.verbose:
218
+ self.stdout.write(
219
+ self.style.ERROR(f" Error: {e}")
220
+ )
221
+
222
+ return cleaned_count
@@ -0,0 +1,7 @@
1
+ from .manager import FileManager
2
+ from .file import File
3
+
4
+ __all__ = [
5
+ 'FileManager',
6
+ 'File',
7
+ ]
@@ -0,0 +1,292 @@
1
+ from django.db import models
2
+ from mojo.models import MojoModel
3
+ import uuid
4
+ import hashlib
5
+ import mimetypes
6
+ from datetime import datetime
7
+
8
+
9
+ class File(models.Model, MojoModel):
10
+ """
11
+ File model representing uploaded files with metadata and storage information
12
+ """
13
+
14
+ class RestMeta:
15
+ CAN_SAVE = CAN_CREATE = True
16
+ CAN_DELETE = True
17
+ DEFAULT_SORT = "-created"
18
+ VIEW_PERMS = ["view_fileman"]
19
+ SEARCH_FIELDS = ["filename", "original_filename", "content_type"]
20
+ SEARCH_TERMS = [
21
+ "filename", "original_filename", "content_type",
22
+ ("group", "group__name"),
23
+ ("file_manager", "file_manager__name")]
24
+
25
+ GRAPHS = {
26
+ "default": {
27
+ "graphs": {
28
+ "group": "basic",
29
+ "file_manager": "basic",
30
+ "uploaded_by": "basic"
31
+ }
32
+ },
33
+ "list": {
34
+ "graphs": {
35
+ "group": "basic",
36
+ "file_manager": "basic"
37
+ }
38
+ }
39
+ }
40
+
41
+ # Upload status choices
42
+ PENDING = 'pending'
43
+ UPLOADING = 'uploading'
44
+ COMPLETED = 'completed'
45
+ FAILED = 'failed'
46
+ EXPIRED = 'expired'
47
+
48
+ STATUS_CHOICES = [
49
+ (PENDING, 'Pending Upload'),
50
+ (UPLOADING, 'Uploading'),
51
+ (COMPLETED, 'Upload Completed'),
52
+ (FAILED, 'Upload Failed'),
53
+ (EXPIRED, 'Upload Expired'),
54
+ ]
55
+
56
+ created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
57
+ modified = models.DateTimeField(auto_now=True)
58
+
59
+ group = models.ForeignKey(
60
+ "account.Group",
61
+ related_name="files",
62
+ null=True,
63
+ blank=True,
64
+ default=None,
65
+ on_delete=models.CASCADE,
66
+ help_text="Group that owns this file"
67
+ )
68
+
69
+ uploaded_by = models.ForeignKey(
70
+ "account.User",
71
+ related_name="uploaded_files",
72
+ null=True,
73
+ blank=True,
74
+ default=None,
75
+ on_delete=models.SET_NULL,
76
+ help_text="User who uploaded this file"
77
+ )
78
+
79
+ file_manager = models.ForeignKey(
80
+ "fileman.FileManager",
81
+ related_name="files",
82
+ on_delete=models.CASCADE,
83
+ help_text="File manager configuration used for this file"
84
+ )
85
+
86
+ filename = models.CharField(
87
+ max_length=255,
88
+ db_index=True,
89
+ help_text="Final filename used for storage"
90
+ )
91
+
92
+ original_filename = models.CharField(
93
+ max_length=255,
94
+ help_text="Original filename as uploaded by user"
95
+ )
96
+
97
+ file_path = models.TextField(
98
+ help_text="Full path to file in storage backend"
99
+ )
100
+
101
+ file_size = models.BigIntegerField(
102
+ null=True,
103
+ blank=True,
104
+ help_text="File size in bytes"
105
+ )
106
+
107
+ content_type = models.CharField(
108
+ max_length=255,
109
+ db_index=True,
110
+ help_text="MIME type of the file"
111
+ )
112
+
113
+ checksum = models.CharField(
114
+ max_length=128,
115
+ blank=True,
116
+ default="",
117
+ help_text="File checksum (MD5, SHA256, etc.)"
118
+ )
119
+
120
+ upload_token = models.CharField(
121
+ max_length=64,
122
+ unique=True,
123
+ db_index=True,
124
+ help_text="Unique token for tracking direct uploads"
125
+ )
126
+
127
+ upload_status = models.CharField(
128
+ max_length=32,
129
+ choices=STATUS_CHOICES,
130
+ default=PENDING,
131
+ db_index=True,
132
+ help_text="Current status of the file upload"
133
+ )
134
+
135
+ upload_url = models.TextField(
136
+ blank=True,
137
+ default="",
138
+ help_text="Pre-signed URL for direct upload (temporary)"
139
+ )
140
+
141
+ upload_expires_at = models.DateTimeField(
142
+ null=True,
143
+ blank=True,
144
+ help_text="When the upload URL expires"
145
+ )
146
+
147
+ metadata = models.JSONField(
148
+ default=dict,
149
+ blank=True,
150
+ help_text="Additional file metadata and custom properties"
151
+ )
152
+
153
+ is_active = models.BooleanField(
154
+ default=True,
155
+ help_text="Whether this file is active and accessible"
156
+ )
157
+
158
+ is_public = models.BooleanField(
159
+ default=False,
160
+ help_text="Whether this file can be accessed without authentication"
161
+ )
162
+
163
+ class Meta:
164
+ indexes = [
165
+ models.Index(fields=['upload_status', 'created']),
166
+ models.Index(fields=['file_manager', 'upload_status']),
167
+ models.Index(fields=['group', 'is_active']),
168
+ models.Index(fields=['content_type', 'is_active']),
169
+ models.Index(fields=['upload_expires_at']),
170
+ ]
171
+
172
+ def __str__(self):
173
+ return f"{self.original_filename} ({self.get_upload_status_display()})"
174
+
175
+ def save(self, *args, **kwargs):
176
+ """Custom save to generate upload token and set defaults"""
177
+ if not self.upload_token:
178
+ self.upload_token = self.generate_upload_token()
179
+
180
+ if not self.filename:
181
+ self.filename = self.generate_unique_filename()
182
+
183
+ if not self.content_type:
184
+ self.content_type = mimetypes.guess_type(self.filename)[0] or 'application/octet-stream'
185
+
186
+ super().save(*args, **kwargs)
187
+
188
+ @classmethod
189
+ def generate_upload_token(cls):
190
+ """Generate a unique upload token"""
191
+ return hashlib.sha256(f"{uuid.uuid4()}{datetime.now()}".encode()).hexdigest()[:32]
192
+
193
+ def generate_unique_filename(self):
194
+ """Generate a unique filename for storage"""
195
+ import os
196
+ name, ext = os.path.splitext(self.original_filename)
197
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
198
+ unique_id = str(uuid.uuid4())[:8]
199
+ return f"{name}_{timestamp}_{unique_id}{ext}"
200
+
201
+ def get_metadata(self, key, default=None):
202
+ """Get a specific metadata value"""
203
+ return self.metadata.get(key, default)
204
+
205
+ def set_metadata(self, key, value):
206
+ """Set a specific metadata value"""
207
+ self.metadata[key] = value
208
+
209
+ @property
210
+ def is_pending(self):
211
+ return self.upload_status == self.PENDING
212
+
213
+ @property
214
+ def is_uploading(self):
215
+ return self.upload_status == self.UPLOADING
216
+
217
+ @property
218
+ def is_completed(self):
219
+ return self.upload_status == self.COMPLETED
220
+
221
+ @property
222
+ def is_failed(self):
223
+ return self.upload_status == self.FAILED
224
+
225
+ @property
226
+ def is_expired(self):
227
+ return self.upload_status == self.EXPIRED
228
+
229
+ @property
230
+ def is_upload_expired(self):
231
+ """Check if the upload URL has expired"""
232
+ if not self.upload_expires_at:
233
+ return False
234
+ return datetime.now() > self.upload_expires_at
235
+
236
+ def mark_as_uploading(self):
237
+ """Mark file as currently being uploaded"""
238
+ self.upload_status = self.UPLOADING
239
+ self.save(update_fields=['upload_status', 'modified'])
240
+
241
+ def mark_as_completed(self, file_size=None, checksum=None):
242
+ """Mark file upload as completed"""
243
+ self.upload_status = self.COMPLETED
244
+ if file_size:
245
+ self.file_size = file_size
246
+ if checksum:
247
+ self.checksum = checksum
248
+ self.save(update_fields=['upload_status', 'file_size', 'checksum', 'modified'])
249
+
250
+ def mark_as_failed(self, error_message=None):
251
+ """Mark file upload as failed"""
252
+ self.upload_status = self.FAILED
253
+ if error_message:
254
+ self.set_metadata('error_message', error_message)
255
+ self.save(update_fields=['upload_status', 'metadata', 'modified'])
256
+
257
+ def mark_as_expired(self):
258
+ """Mark file upload as expired"""
259
+ self.upload_status = self.EXPIRED
260
+ self.save(update_fields=['upload_status', 'modified'])
261
+
262
+ def get_file_extension(self):
263
+ """Get the file extension"""
264
+ import os
265
+ return os.path.splitext(self.original_filename)[1].lower()
266
+
267
+ def get_human_readable_size(self):
268
+ """Get human readable file size"""
269
+ if not self.file_size:
270
+ return "Unknown"
271
+
272
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
273
+ if self.file_size < 1024.0:
274
+ return f"{self.file_size:.1f} {unit}"
275
+ self.file_size /= 1024.0
276
+ return f"{self.file_size:.1f} PB"
277
+
278
+ def can_be_accessed_by(self, user=None, group=None):
279
+ """Check if file can be accessed by user/group"""
280
+ if not self.is_active:
281
+ return False
282
+
283
+ if self.is_public:
284
+ return True
285
+
286
+ if user and self.uploaded_by == user:
287
+ return True
288
+
289
+ if group and self.group == group:
290
+ return True
291
+
292
+ return False