arthexis 0.1.13__py3-none-any.whl → 0.1.14__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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
- arthexis-0.1.14.dist-info/RECORD +109 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +43 -43
- config/auth_app.py +7 -7
- config/celery.py +32 -32
- config/context_processors.py +67 -69
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +25 -25
- config/offline.py +49 -49
- config/settings.py +691 -682
- config/settings_helpers.py +109 -109
- config/urls.py +171 -166
- config/wsgi.py +17 -17
- core/admin.py +3771 -2809
- core/admin_history.py +50 -50
- core/admindocs.py +151 -151
- core/apps.py +356 -272
- core/auto_upgrade.py +57 -57
- core/backends.py +265 -236
- core/changelog.py +342 -0
- core/entity.py +133 -133
- core/environment.py +61 -61
- core/fields.py +168 -168
- core/form_fields.py +75 -75
- core/github_helper.py +188 -25
- core/github_issues.py +178 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +100 -100
- core/mailer.py +85 -85
- core/middleware.py +91 -91
- core/models.py +3609 -2795
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +108 -108
- core/release.py +721 -368
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -149
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +315 -315
- core/system.py +752 -493
- core/tasks.py +408 -394
- core/temp_passwords.py +181 -181
- core/test_system_info.py +186 -139
- core/tests.py +2095 -1521
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +641 -633
- core/views.py +2175 -1417
- core/widgets.py +213 -94
- core/workgroup_urls.py +17 -17
- core/workgroup_views.py +94 -94
- nodes/admin.py +1720 -1161
- nodes/apps.py +87 -85
- nodes/backends.py +160 -160
- nodes/dns.py +203 -203
- nodes/feature_checks.py +133 -133
- nodes/lcd.py +165 -165
- nodes/models.py +1737 -1597
- nodes/reports.py +411 -411
- nodes/rfid_sync.py +195 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +46 -46
- nodes/tests.py +3810 -3116
- nodes/urls.py +15 -14
- nodes/utils.py +121 -105
- nodes/views.py +683 -619
- ocpp/admin.py +948 -948
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1565 -1459
- ocpp/evcs.py +844 -844
- ocpp/evcs_discovery.py +158 -158
- ocpp/models.py +917 -917
- ocpp/reference_utils.py +42 -42
- ocpp/routing.py +11 -11
- ocpp/simulator.py +745 -745
- ocpp/status_display.py +26 -26
- ocpp/store.py +601 -541
- ocpp/tasks.py +31 -31
- ocpp/test_export_import.py +130 -130
- ocpp/test_rfid.py +913 -702
- ocpp/tests.py +4445 -4094
- ocpp/transactions_io.py +189 -189
- ocpp/urls.py +50 -50
- ocpp/views.py +1479 -1251
- pages/admin.py +708 -539
- pages/apps.py +10 -10
- pages/checks.py +40 -40
- pages/context_processors.py +127 -119
- pages/defaults.py +13 -13
- pages/forms.py +198 -198
- pages/middleware.py +205 -153
- pages/models.py +607 -426
- pages/tests.py +2612 -2200
- pages/urls.py +25 -25
- pages/utils.py +12 -12
- pages/views.py +1165 -1128
- arthexis-0.1.13.dist-info/RECORD +0 -105
- nodes/actions.py +0 -70
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
nodes/views.py
CHANGED
|
@@ -1,619 +1,683 @@
|
|
|
1
|
-
import base64
|
|
2
|
-
import ipaddress
|
|
3
|
-
import json
|
|
4
|
-
import socket
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
from django.http
|
|
8
|
-
from django.
|
|
9
|
-
from django.
|
|
10
|
-
from django.
|
|
11
|
-
from django.
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
from cryptography.hazmat.primitives
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if not
|
|
84
|
-
return ""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if not
|
|
103
|
-
return ""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
node.
|
|
331
|
-
node.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if
|
|
337
|
-
node.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
node.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
return JsonResponse(
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
if
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1
|
+
import base64
|
|
2
|
+
import ipaddress
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from django.http import JsonResponse
|
|
8
|
+
from django.http.request import split_domain_port
|
|
9
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
10
|
+
from django.shortcuts import get_object_or_404
|
|
11
|
+
from django.conf import settings
|
|
12
|
+
from django.urls import reverse
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from django.utils.cache import patch_vary_headers
|
|
15
|
+
|
|
16
|
+
from utils.api import api_login_required
|
|
17
|
+
|
|
18
|
+
from cryptography.hazmat.primitives import serialization, hashes
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
20
|
+
|
|
21
|
+
from core.models import RFID
|
|
22
|
+
|
|
23
|
+
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
24
|
+
|
|
25
|
+
from .models import (
|
|
26
|
+
Node,
|
|
27
|
+
NetMessage,
|
|
28
|
+
NodeFeature,
|
|
29
|
+
NodeRole,
|
|
30
|
+
node_information_updated,
|
|
31
|
+
)
|
|
32
|
+
from .utils import capture_screenshot, save_screenshot
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_client_ip(request):
|
|
36
|
+
"""Return the client IP from the request headers."""
|
|
37
|
+
|
|
38
|
+
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
|
39
|
+
if forwarded_for:
|
|
40
|
+
for value in forwarded_for.split(","):
|
|
41
|
+
candidate = value.strip()
|
|
42
|
+
if candidate:
|
|
43
|
+
return candidate
|
|
44
|
+
return request.META.get("REMOTE_ADDR", "")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_route_address(remote_ip: str, port: int) -> str:
|
|
48
|
+
"""Return the local address used to reach ``remote_ip``."""
|
|
49
|
+
|
|
50
|
+
if not remote_ip:
|
|
51
|
+
return ""
|
|
52
|
+
try:
|
|
53
|
+
parsed = ipaddress.ip_address(remote_ip)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
target_port = int(port)
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
target_port = 1
|
|
61
|
+
if target_port <= 0 or target_port > 65535:
|
|
62
|
+
target_port = 1
|
|
63
|
+
|
|
64
|
+
family = socket.AF_INET6 if parsed.version == 6 else socket.AF_INET
|
|
65
|
+
try:
|
|
66
|
+
with socket.socket(family, socket.SOCK_DGRAM) as sock:
|
|
67
|
+
if family == socket.AF_INET6:
|
|
68
|
+
sock.connect((remote_ip, target_port, 0, 0))
|
|
69
|
+
else:
|
|
70
|
+
sock.connect((remote_ip, target_port))
|
|
71
|
+
return sock.getsockname()[0]
|
|
72
|
+
except OSError:
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_host_ip(request) -> str:
|
|
77
|
+
"""Return the IP address from the host header if available."""
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
host = request.get_host()
|
|
81
|
+
except Exception: # pragma: no cover - defensive
|
|
82
|
+
return ""
|
|
83
|
+
if not host:
|
|
84
|
+
return ""
|
|
85
|
+
domain, _ = split_domain_port(host)
|
|
86
|
+
if not domain:
|
|
87
|
+
return ""
|
|
88
|
+
try:
|
|
89
|
+
ipaddress.ip_address(domain)
|
|
90
|
+
except ValueError:
|
|
91
|
+
return ""
|
|
92
|
+
return domain
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _get_host_domain(request) -> str:
|
|
96
|
+
"""Return the domain from the host header when it isn't an IP."""
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
host = request.get_host()
|
|
100
|
+
except Exception: # pragma: no cover - defensive
|
|
101
|
+
return ""
|
|
102
|
+
if not host:
|
|
103
|
+
return ""
|
|
104
|
+
domain, _ = split_domain_port(host)
|
|
105
|
+
if not domain:
|
|
106
|
+
return ""
|
|
107
|
+
try:
|
|
108
|
+
ipaddress.ip_address(domain)
|
|
109
|
+
except ValueError:
|
|
110
|
+
return domain
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _get_advertised_address(request, node) -> str:
|
|
115
|
+
"""Return the best address for the client to reach this node."""
|
|
116
|
+
|
|
117
|
+
client_ip = _get_client_ip(request)
|
|
118
|
+
route_address = _get_route_address(client_ip, node.port)
|
|
119
|
+
if route_address:
|
|
120
|
+
return route_address
|
|
121
|
+
host_ip = _get_host_ip(request)
|
|
122
|
+
if host_ip:
|
|
123
|
+
return host_ip
|
|
124
|
+
return node.address
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@api_login_required
|
|
128
|
+
def node_list(request):
|
|
129
|
+
"""Return a JSON list of all known nodes."""
|
|
130
|
+
|
|
131
|
+
nodes = [
|
|
132
|
+
{
|
|
133
|
+
"hostname": node.hostname,
|
|
134
|
+
"address": node.address,
|
|
135
|
+
"port": node.port,
|
|
136
|
+
"last_seen": node.last_seen,
|
|
137
|
+
"features": list(node.features.values_list("slug", flat=True)),
|
|
138
|
+
}
|
|
139
|
+
for node in Node.objects.prefetch_related("features")
|
|
140
|
+
]
|
|
141
|
+
return JsonResponse({"nodes": nodes})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@csrf_exempt
|
|
145
|
+
def node_info(request):
|
|
146
|
+
"""Return information about the local node and sign ``token`` if provided."""
|
|
147
|
+
|
|
148
|
+
node = Node.get_local()
|
|
149
|
+
if node is None:
|
|
150
|
+
node, _ = Node.register_current()
|
|
151
|
+
|
|
152
|
+
token = request.GET.get("token", "")
|
|
153
|
+
host_domain = _get_host_domain(request)
|
|
154
|
+
advertised_address = _get_advertised_address(request, node)
|
|
155
|
+
if host_domain:
|
|
156
|
+
hostname = host_domain
|
|
157
|
+
if advertised_address and advertised_address != node.address:
|
|
158
|
+
address = advertised_address
|
|
159
|
+
else:
|
|
160
|
+
address = host_domain
|
|
161
|
+
else:
|
|
162
|
+
hostname = node.hostname
|
|
163
|
+
address = advertised_address
|
|
164
|
+
data = {
|
|
165
|
+
"hostname": hostname,
|
|
166
|
+
"address": address,
|
|
167
|
+
"port": node.port,
|
|
168
|
+
"mac_address": node.mac_address,
|
|
169
|
+
"public_key": node.public_key,
|
|
170
|
+
"features": list(node.features.values_list("slug", flat=True)),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if token:
|
|
174
|
+
try:
|
|
175
|
+
priv_path = (
|
|
176
|
+
Path(node.base_path or settings.BASE_DIR)
|
|
177
|
+
/ "security"
|
|
178
|
+
/ f"{node.public_endpoint}"
|
|
179
|
+
)
|
|
180
|
+
private_key = serialization.load_pem_private_key(
|
|
181
|
+
priv_path.read_bytes(), password=None
|
|
182
|
+
)
|
|
183
|
+
signature = private_key.sign(
|
|
184
|
+
token.encode(),
|
|
185
|
+
padding.PKCS1v15(),
|
|
186
|
+
hashes.SHA256(),
|
|
187
|
+
)
|
|
188
|
+
data["token_signature"] = base64.b64encode(signature).decode()
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
response = JsonResponse(data)
|
|
193
|
+
response["Access-Control-Allow-Origin"] = "*"
|
|
194
|
+
return response
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _add_cors_headers(request, response):
|
|
198
|
+
origin = request.headers.get("Origin")
|
|
199
|
+
if origin:
|
|
200
|
+
response["Access-Control-Allow-Origin"] = origin
|
|
201
|
+
response["Access-Control-Allow-Credentials"] = "true"
|
|
202
|
+
allow_headers = request.headers.get(
|
|
203
|
+
"Access-Control-Request-Headers", "Content-Type"
|
|
204
|
+
)
|
|
205
|
+
response["Access-Control-Allow-Headers"] = allow_headers
|
|
206
|
+
response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
|
207
|
+
patch_vary_headers(response, ["Origin"])
|
|
208
|
+
return response
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _node_display_name(node: Node) -> str:
|
|
212
|
+
"""Return a human-friendly name for ``node`` suitable for messaging."""
|
|
213
|
+
|
|
214
|
+
for attr in ("hostname", "public_endpoint", "address"):
|
|
215
|
+
value = getattr(node, attr, "") or ""
|
|
216
|
+
value = value.strip()
|
|
217
|
+
if value:
|
|
218
|
+
return value
|
|
219
|
+
identifier = getattr(node, "pk", None)
|
|
220
|
+
return str(identifier or node)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _announce_visitor_join(new_node: Node, relation: Node.Relation | None) -> None:
|
|
224
|
+
"""Emit a network message when the visitor node links to a host."""
|
|
225
|
+
|
|
226
|
+
if relation != Node.Relation.UPSTREAM:
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
local_node = Node.get_local()
|
|
230
|
+
if not local_node:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
visitor_name = _node_display_name(local_node)
|
|
234
|
+
host_name = _node_display_name(new_node)
|
|
235
|
+
NetMessage.broadcast(subject=f"NODE {visitor_name}", body=f"JOINS {host_name}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@csrf_exempt
|
|
239
|
+
def register_node(request):
|
|
240
|
+
"""Register or update a node from POSTed JSON data."""
|
|
241
|
+
|
|
242
|
+
if request.method == "OPTIONS":
|
|
243
|
+
response = JsonResponse({"detail": "ok"})
|
|
244
|
+
return _add_cors_headers(request, response)
|
|
245
|
+
|
|
246
|
+
if request.method != "POST":
|
|
247
|
+
response = JsonResponse({"detail": "POST required"}, status=400)
|
|
248
|
+
return _add_cors_headers(request, response)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
data = json.loads(request.body.decode())
|
|
252
|
+
except json.JSONDecodeError:
|
|
253
|
+
data = request.POST
|
|
254
|
+
|
|
255
|
+
if hasattr(data, "getlist"):
|
|
256
|
+
raw_features = data.getlist("features")
|
|
257
|
+
if not raw_features:
|
|
258
|
+
features = None
|
|
259
|
+
elif len(raw_features) == 1:
|
|
260
|
+
features = raw_features[0]
|
|
261
|
+
else:
|
|
262
|
+
features = raw_features
|
|
263
|
+
else:
|
|
264
|
+
features = data.get("features")
|
|
265
|
+
|
|
266
|
+
hostname = data.get("hostname")
|
|
267
|
+
address = data.get("address")
|
|
268
|
+
port = data.get("port", 8000)
|
|
269
|
+
mac_address = data.get("mac_address")
|
|
270
|
+
public_key = data.get("public_key")
|
|
271
|
+
token = data.get("token")
|
|
272
|
+
signature = data.get("signature")
|
|
273
|
+
installed_version = data.get("installed_version")
|
|
274
|
+
installed_revision = data.get("installed_revision")
|
|
275
|
+
relation_present = False
|
|
276
|
+
if hasattr(data, "getlist"):
|
|
277
|
+
relation_present = "current_relation" in data
|
|
278
|
+
else:
|
|
279
|
+
relation_present = "current_relation" in data
|
|
280
|
+
raw_relation = data.get("current_relation")
|
|
281
|
+
relation_value = (
|
|
282
|
+
Node.normalize_relation(raw_relation) if relation_present else None
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if not hostname or not address or not mac_address:
|
|
286
|
+
response = JsonResponse(
|
|
287
|
+
{"detail": "hostname, address and mac_address required"}, status=400
|
|
288
|
+
)
|
|
289
|
+
return _add_cors_headers(request, response)
|
|
290
|
+
|
|
291
|
+
verified = False
|
|
292
|
+
if public_key and token and signature:
|
|
293
|
+
try:
|
|
294
|
+
pub = serialization.load_pem_public_key(public_key.encode())
|
|
295
|
+
pub.verify(
|
|
296
|
+
base64.b64decode(signature),
|
|
297
|
+
token.encode(),
|
|
298
|
+
padding.PKCS1v15(),
|
|
299
|
+
hashes.SHA256(),
|
|
300
|
+
)
|
|
301
|
+
verified = True
|
|
302
|
+
except Exception:
|
|
303
|
+
response = JsonResponse({"detail": "invalid signature"}, status=403)
|
|
304
|
+
return _add_cors_headers(request, response)
|
|
305
|
+
|
|
306
|
+
if not verified and not request.user.is_authenticated:
|
|
307
|
+
response = JsonResponse({"detail": "authentication required"}, status=401)
|
|
308
|
+
return _add_cors_headers(request, response)
|
|
309
|
+
|
|
310
|
+
mac_address = mac_address.lower()
|
|
311
|
+
defaults = {
|
|
312
|
+
"hostname": hostname,
|
|
313
|
+
"address": address,
|
|
314
|
+
"port": port,
|
|
315
|
+
}
|
|
316
|
+
if verified:
|
|
317
|
+
defaults["public_key"] = public_key
|
|
318
|
+
if installed_version is not None:
|
|
319
|
+
defaults["installed_version"] = str(installed_version)[:20]
|
|
320
|
+
if installed_revision is not None:
|
|
321
|
+
defaults["installed_revision"] = str(installed_revision)[:40]
|
|
322
|
+
if relation_value is not None:
|
|
323
|
+
defaults["current_relation"] = relation_value
|
|
324
|
+
|
|
325
|
+
node, created = Node.objects.get_or_create(
|
|
326
|
+
mac_address=mac_address,
|
|
327
|
+
defaults=defaults,
|
|
328
|
+
)
|
|
329
|
+
if not created:
|
|
330
|
+
previous_version = (node.installed_version or "").strip()
|
|
331
|
+
previous_revision = (node.installed_revision or "").strip()
|
|
332
|
+
node.hostname = hostname
|
|
333
|
+
node.address = address
|
|
334
|
+
node.port = port
|
|
335
|
+
update_fields = ["hostname", "address", "port"]
|
|
336
|
+
if verified:
|
|
337
|
+
node.public_key = public_key
|
|
338
|
+
update_fields.append("public_key")
|
|
339
|
+
if installed_version is not None:
|
|
340
|
+
node.installed_version = str(installed_version)[:20]
|
|
341
|
+
if "installed_version" not in update_fields:
|
|
342
|
+
update_fields.append("installed_version")
|
|
343
|
+
if installed_revision is not None:
|
|
344
|
+
node.installed_revision = str(installed_revision)[:40]
|
|
345
|
+
if "installed_revision" not in update_fields:
|
|
346
|
+
update_fields.append("installed_revision")
|
|
347
|
+
if relation_value is not None and node.current_relation != relation_value:
|
|
348
|
+
node.current_relation = relation_value
|
|
349
|
+
update_fields.append("current_relation")
|
|
350
|
+
node.save(update_fields=update_fields)
|
|
351
|
+
current_version = (node.installed_version or "").strip()
|
|
352
|
+
current_revision = (node.installed_revision or "").strip()
|
|
353
|
+
node_information_updated.send(
|
|
354
|
+
sender=Node,
|
|
355
|
+
node=node,
|
|
356
|
+
previous_version=previous_version,
|
|
357
|
+
previous_revision=previous_revision,
|
|
358
|
+
current_version=current_version,
|
|
359
|
+
current_revision=current_revision,
|
|
360
|
+
request=request,
|
|
361
|
+
)
|
|
362
|
+
if features is not None and (verified or request.user.is_authenticated):
|
|
363
|
+
if isinstance(features, (str, bytes)):
|
|
364
|
+
feature_list = [features]
|
|
365
|
+
else:
|
|
366
|
+
feature_list = list(features)
|
|
367
|
+
node.update_manual_features(feature_list)
|
|
368
|
+
response = JsonResponse(
|
|
369
|
+
{"id": node.id, "detail": f"Node already exists (id: {node.id})"}
|
|
370
|
+
)
|
|
371
|
+
return _add_cors_headers(request, response)
|
|
372
|
+
|
|
373
|
+
if features is not None and (verified or request.user.is_authenticated):
|
|
374
|
+
if isinstance(features, (str, bytes)):
|
|
375
|
+
feature_list = [features]
|
|
376
|
+
else:
|
|
377
|
+
feature_list = list(features)
|
|
378
|
+
node.update_manual_features(feature_list)
|
|
379
|
+
|
|
380
|
+
current_version = (node.installed_version or "").strip()
|
|
381
|
+
current_revision = (node.installed_revision or "").strip()
|
|
382
|
+
node_information_updated.send(
|
|
383
|
+
sender=Node,
|
|
384
|
+
node=node,
|
|
385
|
+
previous_version="",
|
|
386
|
+
previous_revision="",
|
|
387
|
+
current_version=current_version,
|
|
388
|
+
current_revision=current_revision,
|
|
389
|
+
request=request,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
_announce_visitor_join(node, relation_value)
|
|
393
|
+
|
|
394
|
+
response = JsonResponse({"id": node.id})
|
|
395
|
+
return _add_cors_headers(request, response)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@api_login_required
|
|
399
|
+
def capture(request):
|
|
400
|
+
"""Capture a screenshot of the site's root URL and record it."""
|
|
401
|
+
|
|
402
|
+
url = request.build_absolute_uri("/")
|
|
403
|
+
try:
|
|
404
|
+
path = capture_screenshot(url)
|
|
405
|
+
except Exception as exc: # pragma: no cover - depends on selenium setup
|
|
406
|
+
return JsonResponse({"detail": str(exc)}, status=500)
|
|
407
|
+
node = Node.get_local()
|
|
408
|
+
screenshot = save_screenshot(path, node=node, method=request.method)
|
|
409
|
+
node_id = screenshot.node.id if screenshot and screenshot.node else None
|
|
410
|
+
return JsonResponse({"screenshot": str(path), "node": node_id})
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@csrf_exempt
|
|
414
|
+
def export_rfids(request):
|
|
415
|
+
"""Return serialized RFID records for authenticated peers."""
|
|
416
|
+
|
|
417
|
+
if request.method != "POST":
|
|
418
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
payload = json.loads(request.body.decode() or "{}")
|
|
422
|
+
except json.JSONDecodeError:
|
|
423
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
424
|
+
|
|
425
|
+
requester = payload.get("requester")
|
|
426
|
+
signature = request.headers.get("X-Signature")
|
|
427
|
+
if not requester:
|
|
428
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
429
|
+
if not signature:
|
|
430
|
+
return JsonResponse({"detail": "signature required"}, status=403)
|
|
431
|
+
|
|
432
|
+
node = Node.objects.filter(uuid=requester).first()
|
|
433
|
+
if not node or not node.public_key:
|
|
434
|
+
return JsonResponse({"detail": "unknown requester"}, status=403)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
public_key = serialization.load_pem_public_key(node.public_key.encode())
|
|
438
|
+
public_key.verify(
|
|
439
|
+
base64.b64decode(signature),
|
|
440
|
+
request.body,
|
|
441
|
+
padding.PKCS1v15(),
|
|
442
|
+
hashes.SHA256(),
|
|
443
|
+
)
|
|
444
|
+
except Exception:
|
|
445
|
+
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
446
|
+
|
|
447
|
+
tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
|
|
448
|
+
|
|
449
|
+
return JsonResponse({"rfids": tags})
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@csrf_exempt
|
|
453
|
+
def import_rfids(request):
|
|
454
|
+
"""Import RFID payloads from a trusted peer."""
|
|
455
|
+
|
|
456
|
+
if request.method != "POST":
|
|
457
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
payload = json.loads(request.body.decode() or "{}")
|
|
461
|
+
except json.JSONDecodeError:
|
|
462
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
463
|
+
|
|
464
|
+
requester = payload.get("requester")
|
|
465
|
+
signature = request.headers.get("X-Signature")
|
|
466
|
+
if not requester:
|
|
467
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
468
|
+
if not signature:
|
|
469
|
+
return JsonResponse({"detail": "signature required"}, status=403)
|
|
470
|
+
|
|
471
|
+
node = Node.objects.filter(uuid=requester).first()
|
|
472
|
+
if not node or not node.public_key:
|
|
473
|
+
return JsonResponse({"detail": "unknown requester"}, status=403)
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
public_key = serialization.load_pem_public_key(node.public_key.encode())
|
|
477
|
+
public_key.verify(
|
|
478
|
+
base64.b64decode(signature),
|
|
479
|
+
request.body,
|
|
480
|
+
padding.PKCS1v15(),
|
|
481
|
+
hashes.SHA256(),
|
|
482
|
+
)
|
|
483
|
+
except Exception:
|
|
484
|
+
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
485
|
+
|
|
486
|
+
rfids = payload.get("rfids", [])
|
|
487
|
+
if not isinstance(rfids, list):
|
|
488
|
+
return JsonResponse({"detail": "rfids must be a list"}, status=400)
|
|
489
|
+
|
|
490
|
+
created = 0
|
|
491
|
+
updated = 0
|
|
492
|
+
linked_accounts = 0
|
|
493
|
+
missing_accounts: list[str] = []
|
|
494
|
+
errors = 0
|
|
495
|
+
|
|
496
|
+
for entry in rfids:
|
|
497
|
+
if not isinstance(entry, Mapping):
|
|
498
|
+
errors += 1
|
|
499
|
+
continue
|
|
500
|
+
outcome = apply_rfid_payload(entry, origin_node=node)
|
|
501
|
+
if not outcome.ok:
|
|
502
|
+
errors += 1
|
|
503
|
+
if outcome.error:
|
|
504
|
+
missing_accounts.append(outcome.error)
|
|
505
|
+
continue
|
|
506
|
+
if outcome.created:
|
|
507
|
+
created += 1
|
|
508
|
+
else:
|
|
509
|
+
updated += 1
|
|
510
|
+
linked_accounts += outcome.accounts_linked
|
|
511
|
+
missing_accounts.extend(outcome.missing_accounts)
|
|
512
|
+
|
|
513
|
+
return JsonResponse(
|
|
514
|
+
{
|
|
515
|
+
"processed": len(rfids),
|
|
516
|
+
"created": created,
|
|
517
|
+
"updated": updated,
|
|
518
|
+
"accounts_linked": linked_accounts,
|
|
519
|
+
"missing_accounts": missing_accounts,
|
|
520
|
+
"errors": errors,
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@csrf_exempt
|
|
526
|
+
@api_login_required
|
|
527
|
+
def public_node_endpoint(request, endpoint):
|
|
528
|
+
"""Public API endpoint for a node.
|
|
529
|
+
|
|
530
|
+
- ``GET`` returns information about the node.
|
|
531
|
+
- ``POST`` broadcasts the request body as a :class:`NetMessage`.
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
node = get_object_or_404(Node, public_endpoint=endpoint, enable_public_api=True)
|
|
535
|
+
|
|
536
|
+
if request.method == "GET":
|
|
537
|
+
data = {
|
|
538
|
+
"hostname": node.hostname,
|
|
539
|
+
"address": node.address,
|
|
540
|
+
"port": node.port,
|
|
541
|
+
"badge_color": node.badge_color,
|
|
542
|
+
"last_seen": node.last_seen,
|
|
543
|
+
"features": list(node.features.values_list("slug", flat=True)),
|
|
544
|
+
}
|
|
545
|
+
return JsonResponse(data)
|
|
546
|
+
|
|
547
|
+
if request.method == "POST":
|
|
548
|
+
NetMessage.broadcast(
|
|
549
|
+
subject=request.method,
|
|
550
|
+
body=request.body.decode("utf-8") if request.body else "",
|
|
551
|
+
seen=[str(node.uuid)],
|
|
552
|
+
)
|
|
553
|
+
return JsonResponse({"status": "stored"})
|
|
554
|
+
|
|
555
|
+
return JsonResponse({"detail": "Method not allowed"}, status=405)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@csrf_exempt
|
|
559
|
+
def net_message(request):
|
|
560
|
+
"""Receive a network message and continue propagation."""
|
|
561
|
+
|
|
562
|
+
if request.method != "POST":
|
|
563
|
+
return JsonResponse({"detail": "POST required"}, status=400)
|
|
564
|
+
try:
|
|
565
|
+
data = json.loads(request.body.decode())
|
|
566
|
+
except json.JSONDecodeError:
|
|
567
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
568
|
+
|
|
569
|
+
signature = request.headers.get("X-Signature")
|
|
570
|
+
sender_id = data.get("sender")
|
|
571
|
+
if not signature or not sender_id:
|
|
572
|
+
return JsonResponse({"detail": "signature required"}, status=403)
|
|
573
|
+
node = Node.objects.filter(uuid=sender_id).first()
|
|
574
|
+
if not node or not node.public_key:
|
|
575
|
+
return JsonResponse({"detail": "unknown sender"}, status=403)
|
|
576
|
+
try:
|
|
577
|
+
public_key = serialization.load_pem_public_key(node.public_key.encode())
|
|
578
|
+
public_key.verify(
|
|
579
|
+
base64.b64decode(signature),
|
|
580
|
+
request.body,
|
|
581
|
+
padding.PKCS1v15(),
|
|
582
|
+
hashes.SHA256(),
|
|
583
|
+
)
|
|
584
|
+
except Exception:
|
|
585
|
+
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
586
|
+
|
|
587
|
+
msg_uuid = data.get("uuid")
|
|
588
|
+
subject = data.get("subject", "")
|
|
589
|
+
body = data.get("body", "")
|
|
590
|
+
attachments = NetMessage.normalize_attachments(data.get("attachments"))
|
|
591
|
+
reach_name = data.get("reach")
|
|
592
|
+
reach_role = None
|
|
593
|
+
if reach_name:
|
|
594
|
+
reach_role = NodeRole.objects.filter(name=reach_name).first()
|
|
595
|
+
filter_node_uuid = data.get("filter_node")
|
|
596
|
+
filter_node = None
|
|
597
|
+
if filter_node_uuid:
|
|
598
|
+
filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
|
|
599
|
+
filter_feature_slug = data.get("filter_node_feature")
|
|
600
|
+
filter_feature = None
|
|
601
|
+
if filter_feature_slug:
|
|
602
|
+
filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
|
|
603
|
+
filter_role_name = data.get("filter_node_role")
|
|
604
|
+
filter_role = None
|
|
605
|
+
if filter_role_name:
|
|
606
|
+
filter_role = NodeRole.objects.filter(name=filter_role_name).first()
|
|
607
|
+
filter_relation_value = data.get("filter_current_relation")
|
|
608
|
+
filter_relation = ""
|
|
609
|
+
if filter_relation_value:
|
|
610
|
+
relation = Node.normalize_relation(filter_relation_value)
|
|
611
|
+
filter_relation = relation.value if relation else ""
|
|
612
|
+
filter_installed_version = (data.get("filter_installed_version") or "")[:20]
|
|
613
|
+
filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
|
|
614
|
+
seen = data.get("seen", [])
|
|
615
|
+
origin_id = data.get("origin")
|
|
616
|
+
origin_node = None
|
|
617
|
+
if origin_id:
|
|
618
|
+
origin_node = Node.objects.filter(uuid=origin_id).first()
|
|
619
|
+
if not origin_node:
|
|
620
|
+
origin_node = node
|
|
621
|
+
if not msg_uuid:
|
|
622
|
+
return JsonResponse({"detail": "uuid required"}, status=400)
|
|
623
|
+
msg, created = NetMessage.objects.get_or_create(
|
|
624
|
+
uuid=msg_uuid,
|
|
625
|
+
defaults={
|
|
626
|
+
"subject": subject[:64],
|
|
627
|
+
"body": body[:256],
|
|
628
|
+
"reach": reach_role,
|
|
629
|
+
"node_origin": origin_node,
|
|
630
|
+
"attachments": attachments or None,
|
|
631
|
+
"filter_node": filter_node,
|
|
632
|
+
"filter_node_feature": filter_feature,
|
|
633
|
+
"filter_node_role": filter_role,
|
|
634
|
+
"filter_current_relation": filter_relation,
|
|
635
|
+
"filter_installed_version": filter_installed_version,
|
|
636
|
+
"filter_installed_revision": filter_installed_revision,
|
|
637
|
+
},
|
|
638
|
+
)
|
|
639
|
+
if not created:
|
|
640
|
+
msg.subject = subject[:64]
|
|
641
|
+
msg.body = body[:256]
|
|
642
|
+
update_fields = ["subject", "body"]
|
|
643
|
+
if reach_role and msg.reach_id != reach_role.id:
|
|
644
|
+
msg.reach = reach_role
|
|
645
|
+
update_fields.append("reach")
|
|
646
|
+
if msg.node_origin_id is None and origin_node:
|
|
647
|
+
msg.node_origin = origin_node
|
|
648
|
+
update_fields.append("node_origin")
|
|
649
|
+
if attachments and msg.attachments != attachments:
|
|
650
|
+
msg.attachments = attachments
|
|
651
|
+
update_fields.append("attachments")
|
|
652
|
+
field_updates = {
|
|
653
|
+
"filter_node": filter_node,
|
|
654
|
+
"filter_node_feature": filter_feature,
|
|
655
|
+
"filter_node_role": filter_role,
|
|
656
|
+
"filter_current_relation": filter_relation,
|
|
657
|
+
"filter_installed_version": filter_installed_version,
|
|
658
|
+
"filter_installed_revision": filter_installed_revision,
|
|
659
|
+
}
|
|
660
|
+
for field, value in field_updates.items():
|
|
661
|
+
if getattr(msg, field) != value:
|
|
662
|
+
setattr(msg, field, value)
|
|
663
|
+
update_fields.append(field)
|
|
664
|
+
msg.save(update_fields=update_fields)
|
|
665
|
+
if attachments:
|
|
666
|
+
msg.apply_attachments(attachments)
|
|
667
|
+
msg.propagate(seen=seen)
|
|
668
|
+
return JsonResponse({"status": "propagated", "complete": msg.complete})
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def last_net_message(request):
|
|
672
|
+
"""Return the most recent :class:`NetMessage`."""
|
|
673
|
+
|
|
674
|
+
msg = NetMessage.objects.order_by("-created").first()
|
|
675
|
+
if not msg:
|
|
676
|
+
return JsonResponse({"subject": "", "body": "", "admin_url": ""})
|
|
677
|
+
return JsonResponse(
|
|
678
|
+
{
|
|
679
|
+
"subject": msg.subject,
|
|
680
|
+
"body": msg.body,
|
|
681
|
+
"admin_url": reverse("admin:nodes_netmessage_change", args=[msg.pk]),
|
|
682
|
+
}
|
|
683
|
+
)
|