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.
- inventree_explosives/__init__.py +3 -0
- inventree_explosives/constants.py +43 -0
- inventree_explosives/core.py +518 -0
- inventree_explosives/exports.py +152 -0
- inventree_explosives/hazard.py +101 -0
- inventree_explosives/neq.py +408 -0
- inventree_explosives/parameters.py +279 -0
- inventree_explosives/report_templates/magazine_register.html +137 -0
- inventree_explosives/report_templates/transport_manifest.html +87 -0
- inventree_explosives/serializers.py +69 -0
- inventree_explosives/static/.vite/manifest.json +49 -0
- inventree_explosives/static/Dashboard-Bs2xwbAu.js +2 -0
- inventree_explosives/static/Dashboard-Bs2xwbAu.js.map +1 -0
- inventree_explosives/static/Dashboard.js +2 -0
- inventree_explosives/static/Dashboard.js.map +1 -0
- inventree_explosives/static/LocationPanel-DgH9cN4Z.js +2 -0
- inventree_explosives/static/LocationPanel-DgH9cN4Z.js.map +1 -0
- inventree_explosives/static/LocationPanel.js +2 -0
- inventree_explosives/static/LocationPanel.js.map +1 -0
- inventree_explosives/static/Panel-DVfAY1c4.js +2 -0
- inventree_explosives/static/Panel-DVfAY1c4.js.map +1 -0
- inventree_explosives/static/Panel.js +2 -0
- inventree_explosives/static/Panel.js.map +1 -0
- inventree_explosives/static/Settings-CkxS6q5N.js +2 -0
- inventree_explosives/static/Settings-CkxS6q5N.js.map +1 -0
- inventree_explosives/static/Settings.js +2 -0
- inventree_explosives/static/Settings.js.map +1 -0
- inventree_explosives/static/assets/api-DRKNArex.js +2 -0
- inventree_explosives/static/assets/api-DRKNArex.js.map +1 -0
- inventree_explosives/static/assets/useQuery-bj4Z4fgS.js +125 -0
- inventree_explosives/static/assets/useQuery-bj4Z4fgS.js.map +1 -0
- inventree_explosives/test_api.py +288 -0
- inventree_explosives/test_hazard.py +127 -0
- inventree_explosives/test_limits.py +252 -0
- inventree_explosives/test_neq.py +395 -0
- inventree_explosives/test_parameters.py +123 -0
- inventree_explosives/test_ui.py +104 -0
- inventree_explosives/test_validation.py +274 -0
- inventree_explosives/validation.py +197 -0
- inventree_explosives/views.py +86 -0
- inventree_plugin_explosives-0.1.0.dist-info/METADATA +252 -0
- inventree_plugin_explosives-0.1.0.dist-info/RECORD +46 -0
- inventree_plugin_explosives-0.1.0.dist-info/WHEEL +5 -0
- inventree_plugin_explosives-0.1.0.dist-info/entry_points.txt +2 -0
- inventree_plugin_explosives-0.1.0.dist-info/licenses/LICENSE +21 -0
- inventree_plugin_explosives-0.1.0.dist-info/top_level.txt +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
|