inventree-plugin-explosives 0.1.0__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 (46) hide show
  1. inventree_explosives/__init__.py +3 -0
  2. inventree_explosives/constants.py +43 -0
  3. inventree_explosives/core.py +518 -0
  4. inventree_explosives/exports.py +152 -0
  5. inventree_explosives/hazard.py +101 -0
  6. inventree_explosives/neq.py +408 -0
  7. inventree_explosives/parameters.py +279 -0
  8. inventree_explosives/report_templates/magazine_register.html +137 -0
  9. inventree_explosives/report_templates/transport_manifest.html +87 -0
  10. inventree_explosives/serializers.py +69 -0
  11. inventree_explosives/static/.vite/manifest.json +49 -0
  12. inventree_explosives/static/Dashboard-Bs2xwbAu.js +2 -0
  13. inventree_explosives/static/Dashboard-Bs2xwbAu.js.map +1 -0
  14. inventree_explosives/static/Dashboard.js +2 -0
  15. inventree_explosives/static/Dashboard.js.map +1 -0
  16. inventree_explosives/static/LocationPanel-DgH9cN4Z.js +2 -0
  17. inventree_explosives/static/LocationPanel-DgH9cN4Z.js.map +1 -0
  18. inventree_explosives/static/LocationPanel.js +2 -0
  19. inventree_explosives/static/LocationPanel.js.map +1 -0
  20. inventree_explosives/static/Panel-DVfAY1c4.js +2 -0
  21. inventree_explosives/static/Panel-DVfAY1c4.js.map +1 -0
  22. inventree_explosives/static/Panel.js +2 -0
  23. inventree_explosives/static/Panel.js.map +1 -0
  24. inventree_explosives/static/Settings-CkxS6q5N.js +2 -0
  25. inventree_explosives/static/Settings-CkxS6q5N.js.map +1 -0
  26. inventree_explosives/static/Settings.js +2 -0
  27. inventree_explosives/static/Settings.js.map +1 -0
  28. inventree_explosives/static/assets/api-DRKNArex.js +2 -0
  29. inventree_explosives/static/assets/api-DRKNArex.js.map +1 -0
  30. inventree_explosives/static/assets/useQuery-bj4Z4fgS.js +125 -0
  31. inventree_explosives/static/assets/useQuery-bj4Z4fgS.js.map +1 -0
  32. inventree_explosives/test_api.py +288 -0
  33. inventree_explosives/test_hazard.py +127 -0
  34. inventree_explosives/test_limits.py +252 -0
  35. inventree_explosives/test_neq.py +395 -0
  36. inventree_explosives/test_parameters.py +123 -0
  37. inventree_explosives/test_ui.py +104 -0
  38. inventree_explosives/test_validation.py +274 -0
  39. inventree_explosives/validation.py +197 -0
  40. inventree_explosives/views.py +86 -0
  41. inventree_plugin_explosives-0.1.0.dist-info/METADATA +252 -0
  42. inventree_plugin_explosives-0.1.0.dist-info/RECORD +46 -0
  43. inventree_plugin_explosives-0.1.0.dist-info/WHEEL +5 -0
  44. inventree_plugin_explosives-0.1.0.dist-info/entry_points.txt +2 -0
  45. inventree_plugin_explosives-0.1.0.dist-info/licenses/LICENSE +21 -0
  46. inventree_plugin_explosives-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Explosives inventory management plugin for InvenTree."""
2
+
3
+ PLUGIN_VERSION = "0.1.0"
@@ -0,0 +1,43 @@
1
+ """Names and vocabularies used by the explosives plugin.
2
+
3
+ Everything the plugin looks up by name lives here, so that a rename is a
4
+ one-line change rather than a grep.
5
+ """
6
+
7
+ # ParameterTemplate.name is globally unique and case-insensitive across the whole
8
+ # InvenTree instance, so these can collide with templates a site already has.
9
+
10
+ TPL_EXPLOSIVE = "Explosive"
11
+ TPL_NEQ = "Net Explosive Quantity"
12
+ TPL_GROSS_MASS = "Explosive Gross Mass"
13
+ TPL_DIVISION = "UN Hazard Division"
14
+ TPL_COMPAT = "UN Compatibility Group"
15
+ TPL_UN_NUMBER = "UN Number"
16
+ TPL_PSN = "Proper Shipping Name"
17
+
18
+ # Applied to StockLocation, not Part.
19
+ TPL_MAX_NEQ = "Maximum Net Explosive Quantity"
20
+
21
+ PART_TEMPLATES = [
22
+ TPL_EXPLOSIVE,
23
+ TPL_NEQ,
24
+ TPL_GROSS_MASS,
25
+ TPL_DIVISION,
26
+ TPL_COMPAT,
27
+ TPL_UN_NUMBER,
28
+ TPL_PSN,
29
+ ]
30
+
31
+ LOCATION_TEMPLATES = [TPL_MAX_NEQ]
32
+
33
+ TEMPLATE_COUNT_EXPECTED = len(PART_TEMPLATES) + len(LOCATION_TEMPLATES)
34
+
35
+ # Mass templates are declared in kg so that Parameter.data_numeric is itself in
36
+ # kg and can be summed directly; pint converts user input ("500 g" -> 0.5) on the
37
+ # way in. Changing this changes the meaning of every stored value.
38
+ MASS_UNIT = "kg"
39
+
40
+ DIVISIONS = ["1.1", "1.2", "1.3", "1.4", "1.5", "1.6"]
41
+
42
+ # The letters are not contiguous: there is no I, M, O, P, Q or R.
43
+ COMPATIBILITY_GROUPS = ["A", "B", "C", "D", "E", "F", "G", "H", "J", "K", "L", "N", "S"]
@@ -0,0 +1,518 @@
1
+ """Explosives inventory management for InvenTree.
2
+
3
+ Adds the properties required to keep a lawful inventory of explosives — net
4
+ explosive quantity, gross mass and UN classification — and enforces the licensed
5
+ NEQ limit of each magazine.
6
+
7
+ Kept deliberately thin: this module is hook wiring. The logic lives in
8
+ neq.py (aggregation), validation.py (rules), hazard.py (UN classification) and
9
+ parameters.py (template bootstrap).
10
+ """
11
+
12
+ import logging
13
+
14
+ from django.core.exceptions import ValidationError
15
+
16
+ from plugin import InvenTreePlugin
17
+ from plugin.mixins import (
18
+ DataExportMixin,
19
+ EventMixin,
20
+ ReportMixin,
21
+ ScheduleMixin,
22
+ SettingsMixin,
23
+ UrlsMixin,
24
+ UserInterfaceMixin,
25
+ ValidationMixin,
26
+ )
27
+
28
+ from . import PLUGIN_VERSION
29
+ from . import exports, neq, parameters, validation
30
+ from .constants import TPL_COMPAT, TPL_DIVISION, TPL_GROSS_MASS, TPL_NEQ, TPL_PSN, TPL_UN_NUMBER
31
+ from .hazard import classification_code
32
+
33
+ logger = logging.getLogger("inventree")
34
+
35
+ # Passes the model class from export_data() to update_headers(), which InvenTree
36
+ # otherwise gives no way to determine.
37
+ MODEL_CONTEXT_KEY = "_explosives_model_class"
38
+
39
+ # Bulk paths that use QuerySet.update() bypass Model.save() and never fire
40
+ # validation, so these events are the only way a breach they cause is noticed.
41
+ WATCHED_EVENTS = [
42
+ "stockitem.moved",
43
+ "stockitem.quantityupdated",
44
+ "stockitem.counted",
45
+ "stockitem.split",
46
+ "stock_stockitem.saved",
47
+ "stock_stockitem.created",
48
+ "stock_stockitem.deleted",
49
+ ]
50
+
51
+
52
+ class ExplosivesPlugin(
53
+ SettingsMixin,
54
+ UrlsMixin,
55
+ UserInterfaceMixin,
56
+ ValidationMixin,
57
+ EventMixin,
58
+ ReportMixin,
59
+ ScheduleMixin,
60
+ DataExportMixin,
61
+ InvenTreePlugin,
62
+ ):
63
+ """Track net explosive quantity and enforce magazine licence limits."""
64
+
65
+ TITLE = "Explosives"
66
+ NAME = "Explosives"
67
+ SLUG = "explosives"
68
+ DESCRIPTION = (
69
+ "Net explosive quantity, gross mass and UN classification for explosive "
70
+ "parts, with per-magazine licensed NEQ limits and compliance reporting."
71
+ )
72
+ VERSION = PLUGIN_VERSION
73
+
74
+ AUTHOR = "Sinn Development Ltd"
75
+ WEBSITE = "https://github.com/sinndevelopment/inventree-plugin-explosives"
76
+ LICENSE = "MIT"
77
+
78
+ # The generic Parameter model this plugin is built on landed in 1.0.
79
+ MIN_VERSION = "1.0.0"
80
+
81
+ ADMIN_SOURCE = "Settings.js:RenderPluginSettings"
82
+
83
+ SETTINGS = {
84
+ "LIMIT_ACTION": {
85
+ "name": "Licence limit action",
86
+ "description": (
87
+ "What to do when a stock movement would push a magazine over its "
88
+ "licensed net explosive quantity"
89
+ ),
90
+ "choices": [
91
+ ("off", "Ignore"),
92
+ ("warn", "Warn only"),
93
+ ("block", "Block the movement"),
94
+ ],
95
+ "default": "warn",
96
+ },
97
+ "INCLUDE_SUBLOCATIONS": {
98
+ "name": "Include sublocations in magazine totals",
99
+ "description": (
100
+ "Count stock held in sublocations toward a location's NEQ total"
101
+ ),
102
+ "validator": bool,
103
+ "default": True,
104
+ },
105
+ "COUNT_ALL_PRESENT_STOCK": {
106
+ "name": "Count quarantined and rejected stock",
107
+ "description": (
108
+ "Count stock that is physically present but not available "
109
+ "(quarantined, rejected) toward magazine totals. "
110
+ "Such stock still occupies the magazine and counts against a licence."
111
+ ),
112
+ "validator": bool,
113
+ "default": True,
114
+ },
115
+ "REQUIRE_NEQ": {
116
+ "name": "Require a net explosive quantity on explosive parts",
117
+ "validator": bool,
118
+ "default": True,
119
+ },
120
+ "ENFORCE_COMPAT_GROUP": {
121
+ "name": "Enforce the UN classification-code table",
122
+ "description": (
123
+ "Reject division/compatibility-group combinations that are not "
124
+ "valid UN classification codes (e.g. 1.1S)"
125
+ ),
126
+ "validator": bool,
127
+ "default": True,
128
+ },
129
+ "EXPLOSIVE_CATEGORIES": {
130
+ "name": "Explosive part categories",
131
+ "description": (
132
+ "Comma-separated PartCategory IDs whose parts are expected to be "
133
+ "flagged as explosive. Used for integrity reporting only."
134
+ ),
135
+ "default": "",
136
+ },
137
+ }
138
+
139
+ SCHEDULED_TASKS = {
140
+ "ensure_templates": {
141
+ "func": "ensure_templates",
142
+ "schedule": "D",
143
+ },
144
+ }
145
+
146
+ def __init__(self, *args, **kwargs):
147
+ """Bootstrap parameter templates on registry load.
148
+
149
+ InvenTree has no plugin activation hook, so this is the earliest place we
150
+ can create the templates. It is a no-op when the database is not ready
151
+ (migrations, imports), which is why the scheduled task and the manual
152
+ repair endpoint also exist.
153
+ """
154
+ super().__init__(*args, **kwargs)
155
+
156
+ try:
157
+ parameters.ensure_parameter_templates()
158
+ except Exception:
159
+ # A plugin that raises on load takes the whole registry down.
160
+ logger.exception("explosives: parameter template bootstrap failed")
161
+
162
+ def _include_sublocations(self) -> bool:
163
+ return bool(self.get_setting("INCLUDE_SUBLOCATIONS"))
164
+
165
+ def _count_all_present(self) -> bool:
166
+ return bool(self.get_setting("COUNT_ALL_PRESENT_STOCK"))
167
+
168
+ def location_summary(self, location) -> dict:
169
+ """Summary for a location, honouring this plugin's settings."""
170
+ summary = neq.location_summary(
171
+ location,
172
+ include_sublocations=self._include_sublocations(),
173
+ count_all_present=self._count_all_present(),
174
+ )
175
+ summary["config_errors"] = parameters.config_errors()
176
+ return summary
177
+
178
+ def licensed_locations(self) -> list[dict]:
179
+ """Every licensed magazine, honouring this plugin's settings.
180
+
181
+ Reporting surfaces must go through here rather than calling neq.*
182
+ directly, or they pick up the module defaults instead of the site's
183
+ settings and report totals that contradict the location panel.
184
+ """
185
+ return neq.licensed_locations(
186
+ include_sublocations=self._include_sublocations(),
187
+ count_all_present=self._count_all_present(),
188
+ )
189
+
190
+ # --- ScheduleMixin -------------------------------------------------------
191
+
192
+ def ensure_templates(self):
193
+ """Daily self-heal, in case bootstrap ran before the database was ready."""
194
+ result = parameters.ensure_parameter_templates()
195
+
196
+ if result["created"]:
197
+ logger.info(
198
+ "explosives: created missing parameter templates: %s",
199
+ ", ".join(result["created"]),
200
+ )
201
+
202
+ # --- ValidationMixin -----------------------------------------------------
203
+
204
+ def validate_model_instance(self, instance, deltas=None, **kwargs):
205
+ """Validate explosive parts, and check magazine limits on stock moves."""
206
+ from part.models import Part
207
+ from stock.models import StockItem
208
+
209
+ if isinstance(instance, Part):
210
+ validation.validate_part(
211
+ instance,
212
+ require_neq=bool(self.get_setting("REQUIRE_NEQ")),
213
+ enforce_combo=bool(self.get_setting("ENFORCE_COMPAT_GROUP")),
214
+ )
215
+ return
216
+
217
+ if isinstance(instance, StockItem):
218
+ self._check_stock_limit(instance)
219
+
220
+ def validate_parameter(self, parameter, data, **kwargs):
221
+ """Validate a single parameter value (Part or StockLocation)."""
222
+ validation.validate_parameter_value(
223
+ parameter,
224
+ data,
225
+ enforce_combo=bool(self.get_setting("ENFORCE_COMPAT_GROUP")),
226
+ )
227
+
228
+ def _check_stock_limit(self, stock_item):
229
+ """Warn or block if this save would push a magazine over its licence."""
230
+ action = self.get_setting("LIMIT_ACTION")
231
+
232
+ if action == "off":
233
+ return
234
+
235
+ # This hook fires on EVERY StockItem.save() in the system, so bail out
236
+ # cheaply for the overwhelming majority of items, which are not explosive.
237
+ if not stock_item.part_id or not validation.is_explosive_part(stock_item.part):
238
+ return
239
+
240
+ breach = neq.check_prospective_limit(
241
+ stock_item,
242
+ include_sublocations=self._include_sublocations(),
243
+ count_all_present=self._count_all_present(),
244
+ )
245
+
246
+ if breach is None:
247
+ return
248
+
249
+ if action == "block":
250
+ raise ValidationError(str(breach))
251
+
252
+ logger.warning("explosives: %s", breach)
253
+
254
+ # --- EventMixin ----------------------------------------------------------
255
+
256
+ def wants_process_event(self, event: str) -> bool:
257
+ return event in WATCHED_EVENTS
258
+
259
+ def process_event(self, event: str, *args, **kwargs):
260
+ """Audit affected magazines after the fact.
261
+
262
+ Validation prevents breaches on the normal save path; this notices the
263
+ ones that arrive via bulk updates, which never call save() at all.
264
+ """
265
+ if self.get_setting("LIMIT_ACTION") == "off":
266
+ return
267
+
268
+ for summary in self.licensed_locations():
269
+ if summary["over_limit"]:
270
+ self._notify_breach(summary)
271
+
272
+ def _notify_breach(self, summary: dict):
273
+ from stock.models import StockLocation
274
+
275
+ logger.warning(
276
+ "explosives: magazine '%s' holds %.3f kg NEQ against a licensed limit "
277
+ "of %.3f kg",
278
+ summary["location_name"],
279
+ summary["neq_kg"],
280
+ summary["limit_kg"],
281
+ )
282
+
283
+ try:
284
+ from common.notifications import trigger_notification
285
+
286
+ location = StockLocation.objects.filter(pk=summary["location_id"]).first()
287
+
288
+ if location is None:
289
+ return
290
+
291
+ trigger_notification(
292
+ location,
293
+ "explosives.limit_breach",
294
+ context={
295
+ "name": f"NEQ limit exceeded: {summary['location_name']}",
296
+ "message": (
297
+ f"{summary['neq_kg']:.3f} kg NEQ held against a licensed "
298
+ f"limit of {summary['limit_kg']:.3f} kg."
299
+ ),
300
+ },
301
+ )
302
+ except Exception:
303
+ logger.exception("explosives: failed to send limit-breach notification")
304
+
305
+ # --- UrlsMixin -----------------------------------------------------------
306
+
307
+ def setup_urls(self):
308
+ from django.urls import path
309
+
310
+ from .views import (
311
+ BootstrapView,
312
+ LocationNEQView,
313
+ LocationSummaryView,
314
+ PartExplosiveView,
315
+ )
316
+
317
+ return [
318
+ path(
319
+ "api/location/<int:pk>/neq/",
320
+ LocationNEQView.as_view(plugin=self),
321
+ name="location-neq",
322
+ ),
323
+ path(
324
+ "api/location/summary/",
325
+ LocationSummaryView.as_view(plugin=self),
326
+ name="location-summary",
327
+ ),
328
+ path(
329
+ "api/part/<int:pk>/",
330
+ PartExplosiveView.as_view(plugin=self),
331
+ name="part-explosive",
332
+ ),
333
+ path(
334
+ "api/bootstrap/",
335
+ BootstrapView.as_view(plugin=self),
336
+ name="bootstrap",
337
+ ),
338
+ ]
339
+
340
+ # --- UserInterfaceMixin --------------------------------------------------
341
+
342
+ def get_ui_panels(self, request, context: dict, **kwargs):
343
+ panels = []
344
+
345
+ target_model = context.get("target_model")
346
+ target_id = context.get("target_id")
347
+
348
+ if target_model == "stocklocation" and target_id:
349
+ # Shown on every location, not just licensed ones: otherwise there is
350
+ # no affordance to *set* a limit on a location that lacks one.
351
+ panels.append(
352
+ {
353
+ "key": "explosives-location",
354
+ "title": "Explosives / NEQ",
355
+ "icon": "ti:flame:outline",
356
+ "source": self.plugin_static_file(
357
+ "LocationPanel.js:RenderLocationPanel"
358
+ ),
359
+ "context": {
360
+ "location_id": target_id,
361
+ "settings": self.get_settings_dict(),
362
+ },
363
+ }
364
+ )
365
+
366
+ if target_model == "part" and target_id and self._is_explosive_part_id(target_id):
367
+ panels.append(
368
+ {
369
+ "key": "explosives-part",
370
+ "title": "Explosive Data",
371
+ "icon": "ti:alert-triangle:outline",
372
+ "source": self.plugin_static_file("Panel.js:RenderPartPanel"),
373
+ "context": {
374
+ "part_id": target_id,
375
+ "settings": self.get_settings_dict(),
376
+ },
377
+ }
378
+ )
379
+
380
+ return panels
381
+
382
+ def get_ui_dashboard_items(self, request, context: dict, **kwargs):
383
+ return [
384
+ {
385
+ "key": "explosives-magazines",
386
+ "title": "Magazine NEQ",
387
+ "description": "Licensed magazines by net explosive quantity",
388
+ "icon": "ti:flame:outline",
389
+ "source": self.plugin_static_file("Dashboard.js:RenderMagazineDashboard"),
390
+ "options": {"width": 4, "height": 4},
391
+ "context": {"settings": self.get_settings_dict()},
392
+ }
393
+ ]
394
+
395
+ def _is_explosive_part_id(self, part_id) -> bool:
396
+ from part.models import Part
397
+
398
+ part = Part.objects.filter(pk=part_id).first()
399
+
400
+ return bool(part and validation.is_explosive_part(part))
401
+
402
+ # --- ReportMixin ---------------------------------------------------------
403
+
404
+ def add_report_context(self, report_instance, model_instance, user, context, **kwargs):
405
+ """Inject explosive data into report templates.
406
+
407
+ The third positional argument is the User, not a request.
408
+
409
+ Everything lands under a single `explosives` key so report templates have
410
+ a stable surface: {{ explosives.neq_kg }}, {{ explosives.items }} etc.
411
+ """
412
+ from part.models import Part
413
+ from stock.models import StockItem, StockLocation
414
+
415
+ if isinstance(model_instance, StockLocation):
416
+ context["explosives"] = self.location_summary(model_instance)
417
+ elif isinstance(model_instance, StockItem):
418
+ context["explosives"] = self.stock_item_context(model_instance)
419
+ elif isinstance(model_instance, Part):
420
+ context["explosives"] = self.part_context(model_instance)
421
+
422
+ def add_label_context(self, label_instance, model_instance, user, context, **kwargs):
423
+ self.add_report_context(label_instance, model_instance, user, context, **kwargs)
424
+
425
+ def part_context(self, part) -> dict:
426
+ """The explosive properties of a part, for reports, labels and the API."""
427
+ division = parameters.get_parameter_value(part, TPL_DIVISION)
428
+ group = parameters.get_parameter_value(part, TPL_COMPAT)
429
+
430
+ return {
431
+ "is_explosive": validation.is_explosive_part(part),
432
+ "neq_per_unit_kg": parameters.get_parameter_numeric(part, TPL_NEQ),
433
+ "gross_mass_per_unit_kg": parameters.get_parameter_numeric(
434
+ part, TPL_GROSS_MASS
435
+ ),
436
+ "division": division,
437
+ "compatibility_group": group,
438
+ "classification_code": classification_code(division, group),
439
+ "un_number": parameters.get_parameter_value(part, TPL_UN_NUMBER),
440
+ "proper_shipping_name": parameters.get_parameter_value(part, TPL_PSN),
441
+ "issues": validation.part_issues(
442
+ part,
443
+ require_neq=bool(self.get_setting("REQUIRE_NEQ")),
444
+ enforce_combo=bool(self.get_setting("ENFORCE_COMPAT_GROUP")),
445
+ ),
446
+ }
447
+
448
+ def stock_item_context(self, stock_item) -> dict:
449
+ """Explosive data for one stock item — a transport manifest line."""
450
+ data = self.part_context(stock_item.part)
451
+
452
+ neq_per_unit = data["neq_per_unit_kg"]
453
+ gross_per_unit = data["gross_mass_per_unit_kg"]
454
+ quantity = float(stock_item.quantity)
455
+
456
+ data.update(
457
+ {
458
+ "quantity": quantity,
459
+ "neq_total_kg": (neq_per_unit * quantity) if neq_per_unit else None,
460
+ "gross_mass_total_kg": (
461
+ (gross_per_unit * quantity) if gross_per_unit else None
462
+ ),
463
+ }
464
+ )
465
+
466
+ return data
467
+
468
+ # --- DataExportMixin -----------------------------------------------------
469
+
470
+ def supports_export(self, model_class, user, *args, **kwargs) -> bool:
471
+ """Only offer this exporter for models that can carry explosive data."""
472
+ return bool(exports.columns_for(model_class))
473
+
474
+ def export_data(
475
+ self, queryset, serializer_class, headers, context, output, *args, **kwargs
476
+ ):
477
+ """Export the standard rows, with the explosive columns merged in."""
478
+ rows = super().export_data(
479
+ queryset, serializer_class, headers, context, output, *args, **kwargs
480
+ )
481
+
482
+ # InvenTree calls export_data() before update_headers() with the same
483
+ # context dict, which is the only per-export channel between them. The
484
+ # plugin instance is shared, so stashing the model on `self` would race
485
+ # between concurrent exports.
486
+ if context is not None:
487
+ context[MODEL_CONTEXT_KEY] = queryset.model
488
+
489
+ include_sublocations = self._include_sublocations()
490
+ count_all_present = self._count_all_present()
491
+
492
+ # Serializer rows come back in queryset order.
493
+ for row, instance in zip(rows, queryset):
494
+ try:
495
+ row.update(
496
+ exports.row_for(
497
+ instance,
498
+ include_sublocations=include_sublocations,
499
+ count_all_present=count_all_present,
500
+ )
501
+ )
502
+ except Exception:
503
+ logger.exception(
504
+ "explosives: failed to add export columns for %s", instance
505
+ )
506
+
507
+ return rows
508
+
509
+ def update_headers(self, headers, context, **kwargs):
510
+ """Append the explosive columns to the standard export headers."""
511
+ model_class = (context or {}).get(MODEL_CONTEXT_KEY)
512
+
513
+ if model_class is None:
514
+ return headers
515
+
516
+ headers.update(exports.columns_for(model_class))
517
+
518
+ return headers