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,516 @@
1
+ try:
2
+ import openpyxl
3
+ from openpyxl.styles import Font, Alignment, PatternFill
4
+ from openpyxl.utils import get_column_letter
5
+ HAS_OPENPYXL = True
6
+ except ImportError:
7
+ openpyxl = None
8
+ HAS_OPENPYXL = False
9
+
10
+ import io
11
+ from decimal import Decimal
12
+ from datetime import datetime, date, time
13
+ from django.http import HttpResponse
14
+ from django.db.models import QuerySet
15
+ from mojo.helpers import logit
16
+
17
+ logger = logit.get_logger("excel_formatter", "excel_formatter.log")
18
+
19
+
20
+ class ExcelFormatter:
21
+ """
22
+ Advanced Excel formatter with openpyxl integration and RestMeta.GRAPHS support.
23
+ """
24
+
25
+ def __init__(self, sheet_name="Sheet1", freeze_panes=True, auto_width=True,
26
+ header_style=True, date_format="YYYY-MM-DD", datetime_format="YYYY-MM-DD HH:MM:SS"):
27
+ """
28
+ Initialize Excel formatter.
29
+
30
+ :param sheet_name: Name for the Excel worksheet
31
+ :param freeze_panes: Freeze header row for easier navigation
32
+ :param auto_width: Auto-adjust column widths
33
+ :param header_style: Apply styling to header row
34
+ :param date_format: Format for date cells
35
+ :param datetime_format: Format for datetime cells
36
+ """
37
+ if not HAS_OPENPYXL:
38
+ raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
39
+
40
+ self.sheet_name = sheet_name
41
+ self.freeze_panes = freeze_panes
42
+ self.auto_width = auto_width
43
+ self.header_style = header_style
44
+ self.date_format = date_format
45
+ self.datetime_format = datetime_format
46
+
47
+ # Style configurations
48
+ self.header_font = Font(bold=True, color="FFFFFF")
49
+ self.header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
50
+ self.header_alignment = Alignment(horizontal="center", vertical="center")
51
+
52
+ def serialize_queryset(self, queryset, fields=None, graph=None, filename="export.xlsx",
53
+ headers=None, localize=None):
54
+ """
55
+ Serialize a Django QuerySet to Excel format.
56
+
57
+ :param queryset: Django QuerySet to serialize
58
+ :param fields: List of field names or tuples (field_name, display_name)
59
+ :param graph: RestMeta graph name to use for field configuration
60
+ :param filename: Output filename
61
+ :param headers: Custom header names (overrides field names)
62
+ :param localize: Localization configuration
63
+ :return: HttpResponse with Excel file
64
+ """
65
+ # Get fields configuration
66
+ field_config = self._get_field_config(queryset, fields, graph)
67
+
68
+ # Create workbook and worksheet
69
+ workbook = openpyxl.Workbook()
70
+ worksheet = workbook.active
71
+ worksheet.title = self.sheet_name
72
+
73
+ # Write header row
74
+ self._write_header_row(worksheet, field_config['headers'])
75
+
76
+ # Write data rows
77
+ row_num = 2 # Start after header
78
+ for obj in queryset:
79
+ try:
80
+ row_data = self._extract_row_data(obj, field_config['field_names'], localize)
81
+ self._write_data_row(worksheet, row_num, row_data)
82
+ row_num += 1
83
+ except Exception as e:
84
+ logger.error(f"Error processing row for object {obj.pk}: {e}")
85
+ continue
86
+
87
+ # Apply formatting
88
+ self._apply_formatting(worksheet, len(field_config['headers']), row_num - 1)
89
+
90
+ # Create HTTP response
91
+ return self._create_excel_response(workbook, filename)
92
+
93
+ def serialize_data(self, data, fields=None, filename="export.xlsx", headers=None):
94
+ """
95
+ Serialize list of dictionaries or objects to Excel.
96
+
97
+ :param data: List of dictionaries or objects
98
+ :param fields: Field names to include
99
+ :param filename: Output filename
100
+ :param headers: Custom header names
101
+ :return: HttpResponse with Excel file
102
+ """
103
+ if not data:
104
+ return self._create_empty_excel_response(filename)
105
+
106
+ # Auto-detect fields if not provided
107
+ if not fields:
108
+ fields = self._auto_detect_fields(data[0])
109
+
110
+ # Prepare field configuration
111
+ field_config = self._prepare_field_config(fields, headers)
112
+
113
+ # Create workbook and worksheet
114
+ workbook = openpyxl.Workbook()
115
+ worksheet = workbook.active
116
+ worksheet.title = self.sheet_name
117
+
118
+ # Write header row
119
+ self._write_header_row(worksheet, field_config['headers'])
120
+
121
+ # Write data rows
122
+ for row_num, item in enumerate(data, start=2):
123
+ try:
124
+ row_data = self._extract_row_data(item, field_config['field_names'])
125
+ self._write_data_row(worksheet, row_num, row_data)
126
+ except Exception as e:
127
+ logger.error(f"Error processing row {row_num}: {e}")
128
+ continue
129
+
130
+ # Apply formatting
131
+ self._apply_formatting(worksheet, len(field_config['headers']), len(data) + 1)
132
+
133
+ return self._create_excel_response(workbook, filename)
134
+
135
+ def _get_field_config(self, queryset, fields, graph):
136
+ """
137
+ Get field configuration from various sources.
138
+ """
139
+ if fields:
140
+ return self._prepare_field_config(fields)
141
+
142
+ # Try to get from RestMeta.GRAPHS
143
+ if graph and hasattr(queryset.model, 'RestMeta'):
144
+ rest_meta = queryset.model.RestMeta
145
+ if hasattr(rest_meta, 'GRAPHS') and graph in rest_meta.GRAPHS:
146
+ graph_config = rest_meta.GRAPHS[graph]
147
+ graph_fields = graph_config.get('fields', [])
148
+ if graph_fields:
149
+ return self._prepare_field_config(graph_fields)
150
+
151
+ # Fallback to model fields
152
+ model_fields = [f.name for f in queryset.model._meta.fields]
153
+ return self._prepare_field_config(model_fields)
154
+
155
+ def _prepare_field_config(self, fields, headers=None):
156
+ """
157
+ Prepare field configuration for Excel generation.
158
+ """
159
+ field_names = []
160
+ field_headers = []
161
+
162
+ for field in fields:
163
+ if isinstance(field, (tuple, list)):
164
+ field_name, display_name = field
165
+ field_names.append(field_name)
166
+ field_headers.append(display_name)
167
+ else:
168
+ field_names.append(field)
169
+ field_headers.append(field.replace('_', ' ').title())
170
+
171
+ # Override with custom headers if provided
172
+ if headers:
173
+ field_headers = headers[:len(field_names)]
174
+
175
+ return {
176
+ 'field_names': field_names,
177
+ 'headers': field_headers
178
+ }
179
+
180
+ def _write_header_row(self, worksheet, headers):
181
+ """
182
+ Write header row to worksheet with styling.
183
+ """
184
+ for col_num, header in enumerate(headers, start=1):
185
+ cell = worksheet.cell(row=1, column=col_num, value=header)
186
+
187
+ if self.header_style:
188
+ cell.font = self.header_font
189
+ cell.fill = self.header_fill
190
+ cell.alignment = self.header_alignment
191
+
192
+ def _write_data_row(self, worksheet, row_num, row_data):
193
+ """
194
+ Write data row to worksheet with proper type handling.
195
+ """
196
+ for col_num, value in enumerate(row_data, start=1):
197
+ cell = worksheet.cell(row=row_num, column=col_num)
198
+
199
+ # Handle different data types
200
+ if isinstance(value, datetime):
201
+ cell.value = value
202
+ cell.number_format = self.datetime_format
203
+ elif isinstance(value, date):
204
+ cell.value = value
205
+ cell.number_format = self.date_format
206
+ elif isinstance(value, time):
207
+ cell.value = value
208
+ cell.number_format = "HH:MM:SS"
209
+ elif isinstance(value, (int, float, Decimal)):
210
+ try:
211
+ cell.value = float(value) if isinstance(value, Decimal) else value
212
+ except (ValueError, TypeError):
213
+ cell.value = str(value)
214
+ elif isinstance(value, bool):
215
+ cell.value = value
216
+ else:
217
+ cell.value = str(value) if value is not None else ""
218
+
219
+ def _apply_formatting(self, worksheet, num_cols, num_rows):
220
+ """
221
+ Apply formatting to the worksheet.
222
+ """
223
+ # Freeze panes (header row)
224
+ if self.freeze_panes and num_rows > 1:
225
+ worksheet.freeze_panes = 'A2'
226
+
227
+ # Auto-adjust column widths
228
+ if self.auto_width:
229
+ self._auto_adjust_column_widths(worksheet, num_cols, num_rows)
230
+
231
+ def _auto_adjust_column_widths(self, worksheet, num_cols, num_rows):
232
+ """
233
+ Auto-adjust column widths based on content.
234
+ """
235
+ for col_num in range(1, num_cols + 1):
236
+ column_letter = get_column_letter(col_num)
237
+ max_length = 0
238
+
239
+ # Check all cells in the column
240
+ for row_num in range(1, min(num_rows + 1, 100)): # Limit to first 100 rows for performance
241
+ try:
242
+ cell_value = worksheet[f"{column_letter}{row_num}"].value
243
+ if cell_value:
244
+ max_length = max(max_length, len(str(cell_value)))
245
+ except Exception:
246
+ continue
247
+
248
+ # Set column width with some padding
249
+ adjusted_width = min(max_length + 2, 50) # Cap at 50 characters
250
+ worksheet.column_dimensions[column_letter].width = max(adjusted_width, 10)
251
+
252
+ def _extract_row_data(self, obj, field_names, localize=None):
253
+ """
254
+ Extract row data from an object based on field names.
255
+ """
256
+ row = []
257
+
258
+ for field_name in field_names:
259
+ try:
260
+ value = self._get_field_value(obj, field_name)
261
+ value = self._process_field_value(value, field_name, localize)
262
+ row.append(value)
263
+ except Exception as e:
264
+ logger.warning(f"Error extracting field '{field_name}': {e}")
265
+ row.append(None)
266
+
267
+ return row
268
+
269
+ def _get_field_value(self, obj, field_name):
270
+ """
271
+ Get field value from object, supporting nested field access.
272
+ """
273
+ # Handle nested field access (e.g., "user.email", "profile.address.city")
274
+ if '.' in field_name:
275
+ return self._get_nested_field_value(obj, field_name)
276
+
277
+ # Handle special metadata fields
278
+ if field_name.startswith('metadata.') and hasattr(obj, 'getProperty'):
279
+ parts = field_name.split('.', 2)
280
+ if len(parts) == 3:
281
+ return obj.getProperty(parts[2], category=parts[1])
282
+ elif len(parts) == 2:
283
+ return obj.getProperty(parts[1])
284
+
285
+ # Standard field access
286
+ if hasattr(obj, field_name):
287
+ value = getattr(obj, field_name)
288
+ return value() if callable(value) else value
289
+
290
+ # Dictionary-style access
291
+ if isinstance(obj, dict):
292
+ return obj.get(field_name, None)
293
+
294
+ return None
295
+
296
+ def _get_nested_field_value(self, obj, field_path):
297
+ """
298
+ Get value from nested field path like "user.profile.name".
299
+ """
300
+ try:
301
+ current = obj
302
+ for field_part in field_path.split('.'):
303
+ if current is None:
304
+ return None
305
+
306
+ if hasattr(current, field_part):
307
+ current = getattr(current, field_part)
308
+ elif isinstance(current, dict):
309
+ current = current.get(field_part)
310
+ else:
311
+ return None
312
+
313
+ # Handle callable attributes
314
+ if callable(current):
315
+ current = current()
316
+
317
+ return current
318
+ except Exception as e:
319
+ logger.warning(f"Error accessing nested field '{field_path}': {e}")
320
+ return None
321
+
322
+ def _process_field_value(self, value, field_name, localize=None):
323
+ """
324
+ Process field value with localization and special handling.
325
+ """
326
+ if value is None:
327
+ return None
328
+
329
+ # Apply localization if configured
330
+ if localize and field_name in localize:
331
+ try:
332
+ localizer_config = localize[field_name]
333
+ if '|' in localizer_config:
334
+ localizer_name, extra = localizer_config.split('|', 1)
335
+ else:
336
+ localizer_name, extra = localizer_config, None
337
+
338
+ # Import and apply localizer
339
+ from mojo.serializers.formats.localizers import get_localizer
340
+ localizer = get_localizer(localizer_name)
341
+ if localizer:
342
+ return localizer(value, extra)
343
+ except Exception as e:
344
+ logger.warning(f"Localization failed for field '{field_name}': {e}")
345
+
346
+ # Handle model instances
347
+ if hasattr(value, 'pk'):
348
+ return str(value)
349
+
350
+ # Handle collections (flatten to string for Excel)
351
+ elif isinstance(value, (list, tuple)):
352
+ return '; '.join(str(item) for item in value)
353
+ elif isinstance(value, dict):
354
+ return str(value)
355
+
356
+ return value
357
+
358
+ def _auto_detect_fields(self, sample_item):
359
+ """
360
+ Auto-detect fields from a sample data item.
361
+ """
362
+ if isinstance(sample_item, dict):
363
+ return list(sample_item.keys())
364
+ elif hasattr(sample_item, '_meta'):
365
+ return [f.name for f in sample_item._meta.fields]
366
+ elif hasattr(sample_item, '__dict__'):
367
+ return list(sample_item.__dict__.keys())
368
+ else:
369
+ return ['value'] # Fallback for primitive types
370
+
371
+ def _create_excel_response(self, workbook, filename):
372
+ """
373
+ Create HTTP response with Excel file.
374
+ """
375
+ # Save workbook to BytesIO buffer
376
+ excel_buffer = io.BytesIO()
377
+ workbook.save(excel_buffer)
378
+ excel_buffer.seek(0)
379
+
380
+ # Create HTTP response
381
+ response = HttpResponse(
382
+ excel_buffer.getvalue(),
383
+ content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
384
+ )
385
+
386
+ # Ensure filename has .xlsx extension
387
+ if not filename.endswith('.xlsx'):
388
+ filename += '.xlsx'
389
+
390
+ response['Content-Disposition'] = f'attachment; filename="{filename}"'
391
+ response['Content-Length'] = str(len(excel_buffer.getvalue()))
392
+
393
+ return response
394
+
395
+ def _create_empty_excel_response(self, filename):
396
+ """
397
+ Create response for empty dataset.
398
+ """
399
+ workbook = openpyxl.Workbook()
400
+ worksheet = workbook.active
401
+ worksheet.title = self.sheet_name
402
+ worksheet['A1'] = "No data available"
403
+
404
+ return self._create_excel_response(workbook, filename)
405
+
406
+
407
+ # Convenience functions for backwards compatibility
408
+ def generate_excel(queryset, fields, filename, headers=None, localize=None, sheet_name="Sheet1"):
409
+ """
410
+ Generate Excel file from queryset.
411
+
412
+ :param queryset: Django QuerySet
413
+ :param fields: List of field names
414
+ :param filename: Output filename
415
+ :param headers: Custom header names
416
+ :param localize: Localization config
417
+ :param sheet_name: Excel sheet name
418
+ :return: HttpResponse with Excel file
419
+ """
420
+ formatter = ExcelFormatter(sheet_name=sheet_name)
421
+ return formatter.serialize_queryset(
422
+ queryset=queryset,
423
+ fields=fields,
424
+ filename=filename,
425
+ headers=headers,
426
+ localize=localize
427
+ )
428
+
429
+
430
+ def qsetToExcel(request, queryset, fields, filename, localize=None):
431
+ """
432
+ Legacy function name for backwards compatibility.
433
+ """
434
+ return generate_excel(queryset, fields, filename, localize=localize)
435
+
436
+
437
+ def serialize_to_excel(data, fields=None, filename="export.xlsx", headers=None, sheet_name="Sheet1"):
438
+ """
439
+ Serialize list of data to Excel.
440
+
441
+ :param data: List of dictionaries or objects
442
+ :param fields: Field names to include
443
+ :param filename: Output filename
444
+ :param headers: Custom header names
445
+ :param sheet_name: Excel sheet name
446
+ :return: HttpResponse with Excel file
447
+ """
448
+ formatter = ExcelFormatter(sheet_name=sheet_name)
449
+ return formatter.serialize_data(
450
+ data=data,
451
+ fields=fields,
452
+ filename=filename,
453
+ headers=headers
454
+ )
455
+
456
+
457
+ def create_multi_sheet_excel(data_sets, filename="export.xlsx"):
458
+ """
459
+ Create Excel file with multiple sheets.
460
+
461
+ :param data_sets: List of tuples (sheet_name, queryset_or_data, fields, headers)
462
+ :param filename: Output filename
463
+ :return: HttpResponse with Excel file
464
+ """
465
+ if not HAS_OPENPYXL:
466
+ raise ImportError("openpyxl is required for Excel export")
467
+
468
+ workbook = openpyxl.Workbook()
469
+ # Remove default sheet
470
+ workbook.remove(workbook.active)
471
+
472
+ for sheet_name, data, fields, headers in data_sets:
473
+ formatter = ExcelFormatter(sheet_name=sheet_name)
474
+
475
+ # Create new worksheet
476
+ worksheet = workbook.create_sheet(title=sheet_name)
477
+
478
+ if isinstance(data, QuerySet):
479
+ field_config = formatter._get_field_config(data, fields, None)
480
+ else:
481
+ if not fields and data:
482
+ fields = formatter._auto_detect_fields(data[0])
483
+ field_config = formatter._prepare_field_config(fields, headers)
484
+
485
+ # Write header
486
+ formatter._write_header_row(worksheet, field_config['headers'])
487
+
488
+ # Write data
489
+ row_num = 2
490
+ for item in data:
491
+ try:
492
+ row_data = formatter._extract_row_data(item, field_config['field_names'])
493
+ formatter._write_data_row(worksheet, row_num, row_data)
494
+ row_num += 1
495
+ except Exception as e:
496
+ logger.error(f"Error processing row in sheet '{sheet_name}': {e}")
497
+ continue
498
+
499
+ # Apply formatting
500
+ formatter._apply_formatting(worksheet, len(field_config['headers']), row_num - 1)
501
+
502
+ # Create response
503
+ excel_buffer = io.BytesIO()
504
+ workbook.save(excel_buffer)
505
+ excel_buffer.seek(0)
506
+
507
+ response = HttpResponse(
508
+ excel_buffer.getvalue(),
509
+ content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
510
+ )
511
+
512
+ if not filename.endswith('.xlsx'):
513
+ filename += '.xlsx'
514
+
515
+ response['Content-Disposition'] = f'attachment; filename="{filename}"'
516
+ return response