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/models.py
CHANGED
|
@@ -1,1597 +1,1737 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from collections.abc import Iterable
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from django.db
|
|
7
|
-
from django.db.
|
|
8
|
-
from django.
|
|
9
|
-
from
|
|
10
|
-
from core.
|
|
11
|
-
from core.
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
from django.utils
|
|
17
|
-
from django.
|
|
18
|
-
from django.
|
|
19
|
-
from
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
import
|
|
25
|
-
import
|
|
26
|
-
|
|
27
|
-
from
|
|
28
|
-
from
|
|
29
|
-
from
|
|
30
|
-
from
|
|
31
|
-
from cryptography.hazmat.primitives import
|
|
32
|
-
from cryptography.hazmat.primitives
|
|
33
|
-
from
|
|
34
|
-
from django.
|
|
35
|
-
from core import
|
|
36
|
-
import
|
|
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
|
-
def
|
|
64
|
-
return self.name
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def
|
|
126
|
-
return self.
|
|
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
|
-
actions
|
|
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
|
-
default=
|
|
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
|
-
node
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
node
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
node.save(update_fields=
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
if
|
|
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
|
-
|
|
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
|
-
if
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
if not
|
|
575
|
-
return False
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
if
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
if
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
)
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
)
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
help_text="
|
|
932
|
-
)
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
)
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
help_text="
|
|
1029
|
-
)
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
)
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
self.
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
"
|
|
1131
|
-
|
|
1132
|
-
"
|
|
1133
|
-
"
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
)
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
help_text="
|
|
1170
|
-
)
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
return
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
)
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
"
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
)
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
def
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
if
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
)
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
)
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
if
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
)
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
self.
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from django.db import models
|
|
7
|
+
from django.db.utils import DatabaseError
|
|
8
|
+
from django.db.models.signals import post_delete
|
|
9
|
+
from django.dispatch import Signal, receiver
|
|
10
|
+
from core.entity import Entity
|
|
11
|
+
from core.models import PackageRelease, Profile
|
|
12
|
+
from core.fields import SigilLongAutoField, SigilShortAutoField
|
|
13
|
+
import re
|
|
14
|
+
import json
|
|
15
|
+
import base64
|
|
16
|
+
from django.utils import timezone
|
|
17
|
+
from django.utils.text import slugify
|
|
18
|
+
from django.conf import settings
|
|
19
|
+
from django.contrib.sites.models import Site
|
|
20
|
+
from datetime import timedelta
|
|
21
|
+
import uuid
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import socket
|
|
25
|
+
import stat
|
|
26
|
+
import subprocess
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from utils import revision
|
|
29
|
+
from core.notifications import notify_async
|
|
30
|
+
from django.core.exceptions import ValidationError
|
|
31
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
32
|
+
from cryptography.hazmat.primitives import serialization, hashes
|
|
33
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
34
|
+
from django.contrib.auth import get_user_model
|
|
35
|
+
from django.core import serializers
|
|
36
|
+
from django.core.mail import get_connection
|
|
37
|
+
from django.core.serializers.base import DeserializationError
|
|
38
|
+
from core import mailer
|
|
39
|
+
import logging
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NodeRoleManager(models.Manager):
|
|
46
|
+
def get_by_natural_key(self, name: str):
|
|
47
|
+
return self.get(name=name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NodeRole(Entity):
|
|
51
|
+
"""Assignable role for a :class:`Node`."""
|
|
52
|
+
|
|
53
|
+
name = models.CharField(max_length=50, unique=True)
|
|
54
|
+
description = models.CharField(max_length=200, blank=True)
|
|
55
|
+
|
|
56
|
+
objects = NodeRoleManager()
|
|
57
|
+
|
|
58
|
+
class Meta:
|
|
59
|
+
ordering = ["name"]
|
|
60
|
+
verbose_name = "Node Role"
|
|
61
|
+
verbose_name_plural = "Node Roles"
|
|
62
|
+
|
|
63
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
64
|
+
return (self.name,)
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
67
|
+
return self.name
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NodeFeatureManager(models.Manager):
|
|
71
|
+
def get_by_natural_key(self, slug: str):
|
|
72
|
+
return self.get(slug=slug)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class NodeFeatureDefaultAction:
|
|
77
|
+
label: str
|
|
78
|
+
url_name: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class NodeFeature(Entity):
|
|
82
|
+
"""Feature that may be enabled on nodes and roles."""
|
|
83
|
+
|
|
84
|
+
slug = models.SlugField(max_length=50, unique=True)
|
|
85
|
+
display = models.CharField(max_length=50)
|
|
86
|
+
roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
|
|
87
|
+
|
|
88
|
+
objects = NodeFeatureManager()
|
|
89
|
+
|
|
90
|
+
DEFAULT_ACTIONS: dict[str, tuple[NodeFeatureDefaultAction, ...]] = {
|
|
91
|
+
"rfid-scanner": (
|
|
92
|
+
NodeFeatureDefaultAction(
|
|
93
|
+
label="Scan RFIDs", url_name="admin:core_rfid_scan"
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
"celery-queue": (
|
|
97
|
+
NodeFeatureDefaultAction(
|
|
98
|
+
label="Celery Report",
|
|
99
|
+
url_name="admin:nodes_nodefeature_celery_report",
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
"screenshot-poll": (
|
|
103
|
+
NodeFeatureDefaultAction(
|
|
104
|
+
label="Take Screenshot",
|
|
105
|
+
url_name="admin:nodes_nodefeature_take_screenshot",
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
"rpi-camera": (
|
|
109
|
+
NodeFeatureDefaultAction(
|
|
110
|
+
label="Take a Snapshot",
|
|
111
|
+
url_name="admin:nodes_nodefeature_take_snapshot",
|
|
112
|
+
),
|
|
113
|
+
NodeFeatureDefaultAction(
|
|
114
|
+
label="View stream",
|
|
115
|
+
url_name="admin:nodes_nodefeature_view_stream",
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class Meta:
|
|
121
|
+
ordering = ["display"]
|
|
122
|
+
verbose_name = "Node Feature"
|
|
123
|
+
verbose_name_plural = "Node Features"
|
|
124
|
+
|
|
125
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
126
|
+
return (self.slug,)
|
|
127
|
+
|
|
128
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
129
|
+
return self.display
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def is_enabled(self) -> bool:
|
|
133
|
+
from django.conf import settings
|
|
134
|
+
from pathlib import Path
|
|
135
|
+
|
|
136
|
+
node = Node.get_local()
|
|
137
|
+
if not node:
|
|
138
|
+
return False
|
|
139
|
+
if node.features.filter(pk=self.pk).exists():
|
|
140
|
+
return True
|
|
141
|
+
if self.slug == "gway-runner":
|
|
142
|
+
return Node._has_gway_runner()
|
|
143
|
+
if self.slug == "gui-toast":
|
|
144
|
+
from core.notifications import supports_gui_toast
|
|
145
|
+
|
|
146
|
+
return supports_gui_toast()
|
|
147
|
+
if self.slug == "rpi-camera":
|
|
148
|
+
return Node._has_rpi_camera()
|
|
149
|
+
lock_map = {
|
|
150
|
+
"lcd-screen": "lcd_screen.lck",
|
|
151
|
+
"rfid-scanner": "rfid.lck",
|
|
152
|
+
"celery-queue": "celery.lck",
|
|
153
|
+
"nginx-server": "nginx_mode.lck",
|
|
154
|
+
}
|
|
155
|
+
lock = lock_map.get(self.slug)
|
|
156
|
+
if lock:
|
|
157
|
+
base_path = Path(node.base_path or settings.BASE_DIR)
|
|
158
|
+
return (base_path / "locks" / lock).exists()
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def get_default_actions(self) -> tuple[NodeFeatureDefaultAction, ...]:
|
|
162
|
+
"""Return the configured default actions for this feature."""
|
|
163
|
+
|
|
164
|
+
actions = self.DEFAULT_ACTIONS.get(self.slug, ())
|
|
165
|
+
if isinstance(actions, NodeFeatureDefaultAction): # pragma: no cover - legacy
|
|
166
|
+
return (actions,)
|
|
167
|
+
return actions
|
|
168
|
+
|
|
169
|
+
def get_default_action(self) -> NodeFeatureDefaultAction | None:
|
|
170
|
+
"""Return the first configured default action for this feature if any."""
|
|
171
|
+
|
|
172
|
+
actions = self.get_default_actions()
|
|
173
|
+
return actions[0] if actions else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_terminal_role():
|
|
177
|
+
"""Return the NodeRole representing a Terminal if it exists."""
|
|
178
|
+
return NodeRole.objects.filter(name="Terminal").first()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Node(Entity):
|
|
182
|
+
"""Information about a running node in the network."""
|
|
183
|
+
|
|
184
|
+
DEFAULT_BADGE_COLOR = "#28a745"
|
|
185
|
+
ROLE_BADGE_COLORS = {
|
|
186
|
+
"Constellation": "#daa520", # goldenrod
|
|
187
|
+
"Control": "#673ab7", # deep purple
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
class Relation(models.TextChoices):
|
|
191
|
+
UPSTREAM = "UPSTREAM", "Upstream"
|
|
192
|
+
DOWNSTREAM = "DOWNSTREAM", "Downstream"
|
|
193
|
+
PEER = "PEER", "Peer"
|
|
194
|
+
SELF = "SELF", "Self"
|
|
195
|
+
|
|
196
|
+
hostname = models.CharField(max_length=100)
|
|
197
|
+
address = models.GenericIPAddressField()
|
|
198
|
+
mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
|
|
199
|
+
port = models.PositiveIntegerField(default=8000)
|
|
200
|
+
badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
|
|
201
|
+
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
202
|
+
current_relation = models.CharField(
|
|
203
|
+
max_length=10,
|
|
204
|
+
choices=Relation.choices,
|
|
205
|
+
default=Relation.PEER,
|
|
206
|
+
)
|
|
207
|
+
last_seen = models.DateTimeField(auto_now=True)
|
|
208
|
+
enable_public_api = models.BooleanField(
|
|
209
|
+
default=False,
|
|
210
|
+
verbose_name="enable public API",
|
|
211
|
+
)
|
|
212
|
+
public_endpoint = models.SlugField(blank=True, unique=True)
|
|
213
|
+
uuid = models.UUIDField(
|
|
214
|
+
default=uuid.uuid4,
|
|
215
|
+
unique=True,
|
|
216
|
+
editable=False,
|
|
217
|
+
verbose_name="UUID",
|
|
218
|
+
)
|
|
219
|
+
public_key = models.TextField(blank=True)
|
|
220
|
+
base_path = models.CharField(max_length=255, blank=True)
|
|
221
|
+
installed_version = models.CharField(max_length=20, blank=True)
|
|
222
|
+
installed_revision = models.CharField(max_length=40, blank=True)
|
|
223
|
+
features = models.ManyToManyField(
|
|
224
|
+
NodeFeature,
|
|
225
|
+
through="NodeFeatureAssignment",
|
|
226
|
+
related_name="nodes",
|
|
227
|
+
blank=True,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
FEATURE_LOCK_MAP = {
|
|
231
|
+
"lcd-screen": "lcd_screen.lck",
|
|
232
|
+
"rfid-scanner": "rfid.lck",
|
|
233
|
+
"celery-queue": "celery.lck",
|
|
234
|
+
"nginx-server": "nginx_mode.lck",
|
|
235
|
+
}
|
|
236
|
+
RPI_CAMERA_DEVICE = Path("/dev/video0")
|
|
237
|
+
RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
|
|
238
|
+
AP_ROUTER_SSID = "gelectriic-ap"
|
|
239
|
+
NMCLI_TIMEOUT = 5
|
|
240
|
+
GWAY_RUNNER_COMMAND = "gway"
|
|
241
|
+
GWAY_RUNNER_CANDIDATES = ("~/.local/bin/gway", "/usr/local/bin/gway")
|
|
242
|
+
AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
|
|
243
|
+
"gui-toast",
|
|
244
|
+
"rpi-camera",
|
|
245
|
+
"ap-router",
|
|
246
|
+
"gway-runner",
|
|
247
|
+
}
|
|
248
|
+
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
|
|
249
|
+
|
|
250
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
251
|
+
return f"{self.hostname}:{self.port}"
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def get_current_mac() -> str:
|
|
255
|
+
"""Return the MAC address of the current host."""
|
|
256
|
+
return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def normalize_relation(cls, value):
|
|
260
|
+
"""Normalize ``value`` to a valid :class:`Relation`."""
|
|
261
|
+
|
|
262
|
+
if isinstance(value, cls.Relation):
|
|
263
|
+
return value
|
|
264
|
+
if value is None:
|
|
265
|
+
return cls.Relation.PEER
|
|
266
|
+
text = str(value).strip()
|
|
267
|
+
if not text:
|
|
268
|
+
return cls.Relation.PEER
|
|
269
|
+
for relation in cls.Relation:
|
|
270
|
+
if text.lower() == relation.label.lower():
|
|
271
|
+
return relation
|
|
272
|
+
if text.upper() == relation.name:
|
|
273
|
+
return relation
|
|
274
|
+
if text.lower() == relation.value.lower():
|
|
275
|
+
return relation
|
|
276
|
+
return cls.Relation.PEER
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
def get_local(cls):
|
|
280
|
+
"""Return the node representing the current host if it exists."""
|
|
281
|
+
mac = cls.get_current_mac()
|
|
282
|
+
try:
|
|
283
|
+
return cls.objects.filter(mac_address=mac).first()
|
|
284
|
+
except DatabaseError:
|
|
285
|
+
logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
@classmethod
|
|
289
|
+
def register_current(cls):
|
|
290
|
+
"""Create or update the :class:`Node` entry for this host."""
|
|
291
|
+
hostname = socket.gethostname()
|
|
292
|
+
try:
|
|
293
|
+
address = socket.gethostbyname(hostname)
|
|
294
|
+
except OSError:
|
|
295
|
+
address = "127.0.0.1"
|
|
296
|
+
port = int(os.environ.get("PORT", 8000))
|
|
297
|
+
base_path = str(settings.BASE_DIR)
|
|
298
|
+
ver_path = Path(settings.BASE_DIR) / "VERSION"
|
|
299
|
+
installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
|
|
300
|
+
rev_value = revision.get_revision()
|
|
301
|
+
installed_revision = rev_value if rev_value else ""
|
|
302
|
+
mac = cls.get_current_mac()
|
|
303
|
+
slug = slugify(hostname)
|
|
304
|
+
node = cls.objects.filter(mac_address=mac).first()
|
|
305
|
+
if not node:
|
|
306
|
+
node = cls.objects.filter(public_endpoint=slug).first()
|
|
307
|
+
defaults = {
|
|
308
|
+
"hostname": hostname,
|
|
309
|
+
"address": address,
|
|
310
|
+
"port": port,
|
|
311
|
+
"base_path": base_path,
|
|
312
|
+
"installed_version": installed_version,
|
|
313
|
+
"installed_revision": installed_revision,
|
|
314
|
+
"public_endpoint": slug,
|
|
315
|
+
"mac_address": mac,
|
|
316
|
+
"current_relation": cls.Relation.SELF,
|
|
317
|
+
}
|
|
318
|
+
role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
|
|
319
|
+
role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
|
|
320
|
+
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
321
|
+
|
|
322
|
+
if node:
|
|
323
|
+
update_fields = []
|
|
324
|
+
for field, value in defaults.items():
|
|
325
|
+
if getattr(node, field) != value:
|
|
326
|
+
setattr(node, field, value)
|
|
327
|
+
update_fields.append(field)
|
|
328
|
+
if desired_role and node.role_id != desired_role.id:
|
|
329
|
+
node.role = desired_role
|
|
330
|
+
update_fields.append("role")
|
|
331
|
+
if update_fields:
|
|
332
|
+
node.save(update_fields=update_fields)
|
|
333
|
+
else:
|
|
334
|
+
node.refresh_features()
|
|
335
|
+
created = False
|
|
336
|
+
else:
|
|
337
|
+
node = cls.objects.create(**defaults)
|
|
338
|
+
created = True
|
|
339
|
+
if desired_role:
|
|
340
|
+
node.role = desired_role
|
|
341
|
+
node.save(update_fields=["role"])
|
|
342
|
+
if created and node.role is None:
|
|
343
|
+
terminal = NodeRole.objects.filter(name="Terminal").first()
|
|
344
|
+
if terminal:
|
|
345
|
+
node.role = terminal
|
|
346
|
+
node.save(update_fields=["role"])
|
|
347
|
+
Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
|
|
348
|
+
node.ensure_keys()
|
|
349
|
+
node.notify_peers_of_update()
|
|
350
|
+
return node, created
|
|
351
|
+
|
|
352
|
+
def notify_peers_of_update(self):
|
|
353
|
+
"""Attempt to update this node's registration with known peers."""
|
|
354
|
+
|
|
355
|
+
from secrets import token_hex
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
import requests
|
|
359
|
+
except Exception: # pragma: no cover - requests should be available
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
|
|
363
|
+
priv_path = security_dir / f"{self.public_endpoint}"
|
|
364
|
+
if not priv_path.exists():
|
|
365
|
+
logger.debug("Private key for %s not found; skipping peer update", self)
|
|
366
|
+
return
|
|
367
|
+
try:
|
|
368
|
+
private_key = serialization.load_pem_private_key(
|
|
369
|
+
priv_path.read_bytes(), password=None
|
|
370
|
+
)
|
|
371
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
372
|
+
logger.warning("Failed to load private key for %s: %s", self, exc)
|
|
373
|
+
return
|
|
374
|
+
token = token_hex(16)
|
|
375
|
+
try:
|
|
376
|
+
signature = private_key.sign(
|
|
377
|
+
token.encode(),
|
|
378
|
+
padding.PKCS1v15(),
|
|
379
|
+
hashes.SHA256(),
|
|
380
|
+
)
|
|
381
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
382
|
+
logger.warning("Failed to sign peer update for %s: %s", self, exc)
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
payload = {
|
|
386
|
+
"hostname": self.hostname,
|
|
387
|
+
"address": self.address,
|
|
388
|
+
"port": self.port,
|
|
389
|
+
"mac_address": self.mac_address,
|
|
390
|
+
"public_key": self.public_key,
|
|
391
|
+
"token": token,
|
|
392
|
+
"signature": base64.b64encode(signature).decode(),
|
|
393
|
+
}
|
|
394
|
+
if self.installed_version:
|
|
395
|
+
payload["installed_version"] = self.installed_version
|
|
396
|
+
if self.installed_revision:
|
|
397
|
+
payload["installed_revision"] = self.installed_revision
|
|
398
|
+
|
|
399
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
400
|
+
headers = {"Content-Type": "application/json"}
|
|
401
|
+
|
|
402
|
+
peers = Node.objects.exclude(pk=self.pk)
|
|
403
|
+
for peer in peers:
|
|
404
|
+
host_candidates: list[str] = []
|
|
405
|
+
if peer.address:
|
|
406
|
+
host_candidates.append(peer.address)
|
|
407
|
+
if peer.hostname and peer.hostname not in host_candidates:
|
|
408
|
+
host_candidates.append(peer.hostname)
|
|
409
|
+
port = peer.port or 8000
|
|
410
|
+
urls: list[str] = []
|
|
411
|
+
for host in host_candidates:
|
|
412
|
+
host = host.strip()
|
|
413
|
+
if not host:
|
|
414
|
+
continue
|
|
415
|
+
if ":" in host and not host.startswith("["):
|
|
416
|
+
host = f"[{host}]"
|
|
417
|
+
http_url = (
|
|
418
|
+
f"http://{host}/nodes/register/"
|
|
419
|
+
if port == 80
|
|
420
|
+
else f"http://{host}:{port}/nodes/register/"
|
|
421
|
+
)
|
|
422
|
+
https_url = (
|
|
423
|
+
f"https://{host}/nodes/register/"
|
|
424
|
+
if port in {80, 443}
|
|
425
|
+
else f"https://{host}:{port}/nodes/register/"
|
|
426
|
+
)
|
|
427
|
+
for url in (https_url, http_url):
|
|
428
|
+
if url not in urls:
|
|
429
|
+
urls.append(url)
|
|
430
|
+
if not urls:
|
|
431
|
+
continue
|
|
432
|
+
for url in urls:
|
|
433
|
+
try:
|
|
434
|
+
response = requests.post(
|
|
435
|
+
url, data=payload_json, headers=headers, timeout=2
|
|
436
|
+
)
|
|
437
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
438
|
+
logger.debug("Failed to update %s via %s: %s", peer, url, exc)
|
|
439
|
+
continue
|
|
440
|
+
if response.ok:
|
|
441
|
+
version_display = _format_upgrade_body(
|
|
442
|
+
self.installed_version,
|
|
443
|
+
self.installed_revision,
|
|
444
|
+
)
|
|
445
|
+
version_suffix = f" ({version_display})" if version_display else ""
|
|
446
|
+
logger.info(
|
|
447
|
+
"Announced startup to %s%s",
|
|
448
|
+
peer,
|
|
449
|
+
version_suffix,
|
|
450
|
+
)
|
|
451
|
+
break
|
|
452
|
+
else:
|
|
453
|
+
logger.warning("Unable to notify node %s of startup", peer)
|
|
454
|
+
|
|
455
|
+
def ensure_keys(self):
|
|
456
|
+
security_dir = Path(settings.BASE_DIR) / "security"
|
|
457
|
+
security_dir.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
priv_path = security_dir / f"{self.public_endpoint}"
|
|
459
|
+
pub_path = security_dir / f"{self.public_endpoint}.pub"
|
|
460
|
+
if not priv_path.exists() or not pub_path.exists():
|
|
461
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
462
|
+
private_bytes = private_key.private_bytes(
|
|
463
|
+
encoding=serialization.Encoding.PEM,
|
|
464
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
465
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
466
|
+
)
|
|
467
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
468
|
+
encoding=serialization.Encoding.PEM,
|
|
469
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
470
|
+
)
|
|
471
|
+
priv_path.write_bytes(private_bytes)
|
|
472
|
+
pub_path.write_bytes(public_bytes)
|
|
473
|
+
self.public_key = public_bytes.decode()
|
|
474
|
+
self.save(update_fields=["public_key"])
|
|
475
|
+
elif not self.public_key:
|
|
476
|
+
self.public_key = pub_path.read_text()
|
|
477
|
+
self.save(update_fields=["public_key"])
|
|
478
|
+
|
|
479
|
+
@property
|
|
480
|
+
def is_local(self):
|
|
481
|
+
"""Determine if this node represents the current host."""
|
|
482
|
+
return self.mac_address == self.get_current_mac()
|
|
483
|
+
|
|
484
|
+
@classmethod
|
|
485
|
+
def _generate_unique_public_endpoint(
|
|
486
|
+
cls, value: str | None, *, exclude_pk: int | None = None
|
|
487
|
+
) -> str:
|
|
488
|
+
"""Return a unique public endpoint slug for ``value``."""
|
|
489
|
+
|
|
490
|
+
field = cls._meta.get_field("public_endpoint")
|
|
491
|
+
max_length = getattr(field, "max_length", None) or 50
|
|
492
|
+
base_slug = slugify(value or "") or "node"
|
|
493
|
+
if len(base_slug) > max_length:
|
|
494
|
+
base_slug = base_slug[:max_length]
|
|
495
|
+
slug = base_slug
|
|
496
|
+
queryset = cls.objects.all()
|
|
497
|
+
if exclude_pk is not None:
|
|
498
|
+
queryset = queryset.exclude(pk=exclude_pk)
|
|
499
|
+
counter = 2
|
|
500
|
+
while queryset.filter(public_endpoint=slug).exists():
|
|
501
|
+
suffix = f"-{counter}"
|
|
502
|
+
available = max_length - len(suffix)
|
|
503
|
+
if available <= 0:
|
|
504
|
+
slug = suffix[-max_length:]
|
|
505
|
+
else:
|
|
506
|
+
slug = f"{base_slug[:available]}{suffix}"
|
|
507
|
+
counter += 1
|
|
508
|
+
return slug
|
|
509
|
+
|
|
510
|
+
def save(self, *args, **kwargs):
|
|
511
|
+
update_fields = kwargs.get("update_fields")
|
|
512
|
+
|
|
513
|
+
def include_update_field(field: str):
|
|
514
|
+
nonlocal update_fields
|
|
515
|
+
if update_fields is None:
|
|
516
|
+
return
|
|
517
|
+
fields = set(update_fields)
|
|
518
|
+
if field in fields:
|
|
519
|
+
return
|
|
520
|
+
fields.add(field)
|
|
521
|
+
update_fields = tuple(fields)
|
|
522
|
+
kwargs["update_fields"] = update_fields
|
|
523
|
+
|
|
524
|
+
role_name = None
|
|
525
|
+
role = getattr(self, "role", None)
|
|
526
|
+
if role and getattr(role, "name", None):
|
|
527
|
+
role_name = role.name
|
|
528
|
+
elif self.role_id:
|
|
529
|
+
role_name = (
|
|
530
|
+
NodeRole.objects.filter(pk=self.role_id)
|
|
531
|
+
.values_list("name", flat=True)
|
|
532
|
+
.first()
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
role_color = self.ROLE_BADGE_COLORS.get(role_name)
|
|
536
|
+
if role_color and (
|
|
537
|
+
not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
|
|
538
|
+
):
|
|
539
|
+
self.badge_color = role_color
|
|
540
|
+
include_update_field("badge_color")
|
|
541
|
+
|
|
542
|
+
if self.mac_address:
|
|
543
|
+
self.mac_address = self.mac_address.lower()
|
|
544
|
+
endpoint_value = slugify(self.public_endpoint or "")
|
|
545
|
+
if not endpoint_value:
|
|
546
|
+
endpoint_value = self._generate_unique_public_endpoint(
|
|
547
|
+
self.hostname, exclude_pk=self.pk
|
|
548
|
+
)
|
|
549
|
+
else:
|
|
550
|
+
queryset = (
|
|
551
|
+
self.__class__.objects.exclude(pk=self.pk)
|
|
552
|
+
if self.pk
|
|
553
|
+
else self.__class__.objects.all()
|
|
554
|
+
)
|
|
555
|
+
if queryset.filter(public_endpoint=endpoint_value).exists():
|
|
556
|
+
endpoint_value = self._generate_unique_public_endpoint(
|
|
557
|
+
self.hostname or endpoint_value, exclude_pk=self.pk
|
|
558
|
+
)
|
|
559
|
+
if self.public_endpoint != endpoint_value:
|
|
560
|
+
self.public_endpoint = endpoint_value
|
|
561
|
+
include_update_field("public_endpoint")
|
|
562
|
+
super().save(*args, **kwargs)
|
|
563
|
+
if self.pk:
|
|
564
|
+
self.refresh_features()
|
|
565
|
+
|
|
566
|
+
def has_feature(self, slug: str) -> bool:
|
|
567
|
+
return self.features.filter(slug=slug).exists()
|
|
568
|
+
|
|
569
|
+
@classmethod
|
|
570
|
+
def _has_rpi_camera(cls) -> bool:
|
|
571
|
+
"""Return ``True`` when the Raspberry Pi camera stack is available."""
|
|
572
|
+
|
|
573
|
+
device = cls.RPI_CAMERA_DEVICE
|
|
574
|
+
if not device.exists():
|
|
575
|
+
return False
|
|
576
|
+
device_path = str(device)
|
|
577
|
+
try:
|
|
578
|
+
mode = os.stat(device_path).st_mode
|
|
579
|
+
except OSError:
|
|
580
|
+
return False
|
|
581
|
+
if not stat.S_ISCHR(mode):
|
|
582
|
+
return False
|
|
583
|
+
if not os.access(device_path, os.R_OK | os.W_OK):
|
|
584
|
+
return False
|
|
585
|
+
for binary in cls.RPI_CAMERA_BINARIES:
|
|
586
|
+
tool_path = shutil.which(binary)
|
|
587
|
+
if not tool_path:
|
|
588
|
+
return False
|
|
589
|
+
try:
|
|
590
|
+
result = subprocess.run(
|
|
591
|
+
[tool_path, "--help"],
|
|
592
|
+
capture_output=True,
|
|
593
|
+
text=True,
|
|
594
|
+
check=False,
|
|
595
|
+
timeout=5,
|
|
596
|
+
)
|
|
597
|
+
except Exception:
|
|
598
|
+
return False
|
|
599
|
+
if result.returncode != 0:
|
|
600
|
+
return False
|
|
601
|
+
return True
|
|
602
|
+
|
|
603
|
+
@classmethod
|
|
604
|
+
def _hosts_gelectriic_ap(cls) -> bool:
|
|
605
|
+
"""Return ``True`` when the node is hosting the gelectriic access point."""
|
|
606
|
+
|
|
607
|
+
nmcli_path = shutil.which("nmcli")
|
|
608
|
+
if not nmcli_path:
|
|
609
|
+
return False
|
|
610
|
+
try:
|
|
611
|
+
result = subprocess.run(
|
|
612
|
+
[
|
|
613
|
+
nmcli_path,
|
|
614
|
+
"-t",
|
|
615
|
+
"-f",
|
|
616
|
+
"NAME,DEVICE,TYPE",
|
|
617
|
+
"connection",
|
|
618
|
+
"show",
|
|
619
|
+
"--active",
|
|
620
|
+
],
|
|
621
|
+
capture_output=True,
|
|
622
|
+
text=True,
|
|
623
|
+
check=False,
|
|
624
|
+
timeout=cls.NMCLI_TIMEOUT,
|
|
625
|
+
)
|
|
626
|
+
except Exception:
|
|
627
|
+
return False
|
|
628
|
+
if result.returncode != 0:
|
|
629
|
+
return False
|
|
630
|
+
for line in result.stdout.splitlines():
|
|
631
|
+
if not line:
|
|
632
|
+
continue
|
|
633
|
+
parts = line.split(":", 2)
|
|
634
|
+
if not parts:
|
|
635
|
+
continue
|
|
636
|
+
name = parts[0]
|
|
637
|
+
conn_type = ""
|
|
638
|
+
if len(parts) == 3:
|
|
639
|
+
conn_type = parts[2]
|
|
640
|
+
elif len(parts) > 1:
|
|
641
|
+
conn_type = parts[1]
|
|
642
|
+
if name != cls.AP_ROUTER_SSID:
|
|
643
|
+
continue
|
|
644
|
+
conn_type_normalized = conn_type.strip().lower()
|
|
645
|
+
if conn_type_normalized not in {"wifi", "802-11-wireless"}:
|
|
646
|
+
continue
|
|
647
|
+
try:
|
|
648
|
+
mode_result = subprocess.run(
|
|
649
|
+
[
|
|
650
|
+
nmcli_path,
|
|
651
|
+
"-g",
|
|
652
|
+
"802-11-wireless.mode",
|
|
653
|
+
"connection",
|
|
654
|
+
"show",
|
|
655
|
+
name,
|
|
656
|
+
],
|
|
657
|
+
capture_output=True,
|
|
658
|
+
text=True,
|
|
659
|
+
check=False,
|
|
660
|
+
timeout=cls.NMCLI_TIMEOUT,
|
|
661
|
+
)
|
|
662
|
+
except Exception:
|
|
663
|
+
continue
|
|
664
|
+
if mode_result.returncode != 0:
|
|
665
|
+
continue
|
|
666
|
+
if mode_result.stdout.strip() == "ap":
|
|
667
|
+
return True
|
|
668
|
+
return False
|
|
669
|
+
|
|
670
|
+
@classmethod
|
|
671
|
+
def _find_gway_runner_command(cls) -> str | None:
|
|
672
|
+
command = shutil.which(cls.GWAY_RUNNER_COMMAND)
|
|
673
|
+
if command:
|
|
674
|
+
return command
|
|
675
|
+
for candidate in cls.GWAY_RUNNER_CANDIDATES:
|
|
676
|
+
expanded = os.path.expanduser(candidate)
|
|
677
|
+
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
|
|
678
|
+
return expanded
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
@classmethod
|
|
682
|
+
def _has_gway_runner(cls) -> bool:
|
|
683
|
+
return cls._find_gway_runner_command() is not None
|
|
684
|
+
|
|
685
|
+
def refresh_features(self):
|
|
686
|
+
if not self.pk:
|
|
687
|
+
return
|
|
688
|
+
if not self.is_local:
|
|
689
|
+
self.sync_feature_tasks()
|
|
690
|
+
return
|
|
691
|
+
detected_slugs = set()
|
|
692
|
+
base_path = Path(self.base_path or settings.BASE_DIR)
|
|
693
|
+
locks_dir = base_path / "locks"
|
|
694
|
+
for slug, filename in self.FEATURE_LOCK_MAP.items():
|
|
695
|
+
if (locks_dir / filename).exists():
|
|
696
|
+
detected_slugs.add(slug)
|
|
697
|
+
if self._has_rpi_camera():
|
|
698
|
+
detected_slugs.add("rpi-camera")
|
|
699
|
+
if self._has_gway_runner():
|
|
700
|
+
detected_slugs.add("gway-runner")
|
|
701
|
+
if self._hosts_gelectriic_ap():
|
|
702
|
+
detected_slugs.add("ap-router")
|
|
703
|
+
try:
|
|
704
|
+
from core.notifications import supports_gui_toast
|
|
705
|
+
except Exception:
|
|
706
|
+
pass
|
|
707
|
+
else:
|
|
708
|
+
try:
|
|
709
|
+
if supports_gui_toast():
|
|
710
|
+
detected_slugs.add("gui-toast")
|
|
711
|
+
except Exception:
|
|
712
|
+
pass
|
|
713
|
+
current_slugs = set(
|
|
714
|
+
self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
|
|
715
|
+
"slug", flat=True
|
|
716
|
+
)
|
|
717
|
+
)
|
|
718
|
+
add_slugs = detected_slugs - current_slugs
|
|
719
|
+
if add_slugs:
|
|
720
|
+
for feature in NodeFeature.objects.filter(slug__in=add_slugs):
|
|
721
|
+
NodeFeatureAssignment.objects.update_or_create(
|
|
722
|
+
node=self, feature=feature
|
|
723
|
+
)
|
|
724
|
+
remove_slugs = current_slugs - detected_slugs
|
|
725
|
+
if remove_slugs:
|
|
726
|
+
NodeFeatureAssignment.objects.filter(
|
|
727
|
+
node=self, feature__slug__in=remove_slugs
|
|
728
|
+
).delete()
|
|
729
|
+
self.sync_feature_tasks()
|
|
730
|
+
|
|
731
|
+
def update_manual_features(self, slugs: Iterable[str]):
|
|
732
|
+
desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
|
|
733
|
+
remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
|
|
734
|
+
if remove_slugs:
|
|
735
|
+
NodeFeatureAssignment.objects.filter(
|
|
736
|
+
node=self, feature__slug__in=remove_slugs
|
|
737
|
+
).delete()
|
|
738
|
+
if desired:
|
|
739
|
+
for feature in NodeFeature.objects.filter(slug__in=desired):
|
|
740
|
+
NodeFeatureAssignment.objects.update_or_create(
|
|
741
|
+
node=self, feature=feature
|
|
742
|
+
)
|
|
743
|
+
self.sync_feature_tasks()
|
|
744
|
+
|
|
745
|
+
def sync_feature_tasks(self):
|
|
746
|
+
clipboard_enabled = self.has_feature("clipboard-poll")
|
|
747
|
+
screenshot_enabled = self.has_feature("screenshot-poll")
|
|
748
|
+
self._sync_clipboard_task(clipboard_enabled)
|
|
749
|
+
self._sync_screenshot_task(screenshot_enabled)
|
|
750
|
+
|
|
751
|
+
def _sync_clipboard_task(self, enabled: bool):
|
|
752
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
753
|
+
|
|
754
|
+
task_name = f"poll_clipboard_node_{self.pk}"
|
|
755
|
+
if enabled:
|
|
756
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
757
|
+
every=5, period=IntervalSchedule.SECONDS
|
|
758
|
+
)
|
|
759
|
+
PeriodicTask.objects.update_or_create(
|
|
760
|
+
name=task_name,
|
|
761
|
+
defaults={
|
|
762
|
+
"interval": schedule,
|
|
763
|
+
"task": "nodes.tasks.sample_clipboard",
|
|
764
|
+
},
|
|
765
|
+
)
|
|
766
|
+
else:
|
|
767
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
768
|
+
|
|
769
|
+
def _sync_screenshot_task(self, enabled: bool):
|
|
770
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
771
|
+
import json
|
|
772
|
+
|
|
773
|
+
task_name = f"capture_screenshot_node_{self.pk}"
|
|
774
|
+
if enabled:
|
|
775
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
776
|
+
every=1, period=IntervalSchedule.MINUTES
|
|
777
|
+
)
|
|
778
|
+
PeriodicTask.objects.update_or_create(
|
|
779
|
+
name=task_name,
|
|
780
|
+
defaults={
|
|
781
|
+
"interval": schedule,
|
|
782
|
+
"task": "nodes.tasks.capture_node_screenshot",
|
|
783
|
+
"kwargs": json.dumps(
|
|
784
|
+
{
|
|
785
|
+
"url": f"http://localhost:{self.port}",
|
|
786
|
+
"port": self.port,
|
|
787
|
+
"method": "AUTO",
|
|
788
|
+
}
|
|
789
|
+
),
|
|
790
|
+
},
|
|
791
|
+
)
|
|
792
|
+
else:
|
|
793
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
794
|
+
|
|
795
|
+
def send_mail(
|
|
796
|
+
self,
|
|
797
|
+
subject: str,
|
|
798
|
+
message: str,
|
|
799
|
+
recipient_list: list[str],
|
|
800
|
+
from_email: str | None = None,
|
|
801
|
+
**kwargs,
|
|
802
|
+
):
|
|
803
|
+
"""Send an email using this node's configured outbox if available."""
|
|
804
|
+
outbox = getattr(self, "email_outbox", None)
|
|
805
|
+
logger.info(
|
|
806
|
+
"Node %s queueing email to %s using %s backend",
|
|
807
|
+
self.pk,
|
|
808
|
+
recipient_list,
|
|
809
|
+
"outbox" if outbox else "default",
|
|
810
|
+
)
|
|
811
|
+
return mailer.send(
|
|
812
|
+
subject,
|
|
813
|
+
message,
|
|
814
|
+
recipient_list,
|
|
815
|
+
from_email,
|
|
816
|
+
outbox=outbox,
|
|
817
|
+
**kwargs,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
node_information_updated = Signal()
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _format_upgrade_body(version: str, revision: str) -> str:
|
|
825
|
+
version = (version or "").strip()
|
|
826
|
+
revision = (revision or "").strip()
|
|
827
|
+
parts: list[str] = []
|
|
828
|
+
if version:
|
|
829
|
+
normalized = version.lstrip("vV") or version
|
|
830
|
+
base_version = normalized.rstrip("+")
|
|
831
|
+
display_version = normalized
|
|
832
|
+
if (
|
|
833
|
+
base_version
|
|
834
|
+
and revision
|
|
835
|
+
and not PackageRelease.matches_revision(base_version, revision)
|
|
836
|
+
and not normalized.endswith("+")
|
|
837
|
+
):
|
|
838
|
+
display_version = f"{display_version}+"
|
|
839
|
+
parts.append(f"v{display_version}")
|
|
840
|
+
if revision:
|
|
841
|
+
rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
|
|
842
|
+
rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
|
|
843
|
+
parts.append(f"r{rev_short}")
|
|
844
|
+
return " ".join(parts).strip()
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@receiver(node_information_updated)
|
|
848
|
+
def _announce_peer_startup(
|
|
849
|
+
sender,
|
|
850
|
+
*,
|
|
851
|
+
node: "Node",
|
|
852
|
+
previous_version: str = "",
|
|
853
|
+
previous_revision: str = "",
|
|
854
|
+
current_version: str = "",
|
|
855
|
+
current_revision: str = "",
|
|
856
|
+
**_: object,
|
|
857
|
+
) -> None:
|
|
858
|
+
current_version = (current_version or "").strip()
|
|
859
|
+
current_revision = (current_revision or "").strip()
|
|
860
|
+
previous_version = (previous_version or "").strip()
|
|
861
|
+
previous_revision = (previous_revision or "").strip()
|
|
862
|
+
|
|
863
|
+
local = Node.get_local()
|
|
864
|
+
if local and node.pk == local.pk:
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
body = _format_upgrade_body(current_version, current_revision)
|
|
868
|
+
if not body:
|
|
869
|
+
body = "Online"
|
|
870
|
+
|
|
871
|
+
hostname = (node.hostname or "Node").strip() or "Node"
|
|
872
|
+
subject = f"UP {hostname}"
|
|
873
|
+
notify_async(subject, body)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
class NodeFeatureAssignment(Entity):
|
|
877
|
+
"""Bridge between :class:`Node` and :class:`NodeFeature`."""
|
|
878
|
+
|
|
879
|
+
node = models.ForeignKey(
|
|
880
|
+
Node, on_delete=models.CASCADE, related_name="feature_assignments"
|
|
881
|
+
)
|
|
882
|
+
feature = models.ForeignKey(
|
|
883
|
+
NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
|
|
884
|
+
)
|
|
885
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
886
|
+
|
|
887
|
+
class Meta:
|
|
888
|
+
unique_together = ("node", "feature")
|
|
889
|
+
verbose_name = "Node Feature Assignment"
|
|
890
|
+
verbose_name_plural = "Node Feature Assignments"
|
|
891
|
+
|
|
892
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
893
|
+
return f"{self.node} -> {self.feature}"
|
|
894
|
+
|
|
895
|
+
def save(self, *args, **kwargs):
|
|
896
|
+
super().save(*args, **kwargs)
|
|
897
|
+
self.node.sync_feature_tasks()
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@receiver(post_delete, sender=NodeFeatureAssignment)
|
|
901
|
+
def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
|
|
902
|
+
node_id = getattr(instance, "node_id", None)
|
|
903
|
+
if not node_id:
|
|
904
|
+
return
|
|
905
|
+
node = Node.objects.filter(pk=node_id).first()
|
|
906
|
+
if node:
|
|
907
|
+
node.sync_feature_tasks()
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
class NodeManager(Profile):
|
|
911
|
+
"""Credentials for interacting with external DNS providers."""
|
|
912
|
+
|
|
913
|
+
class Provider(models.TextChoices):
|
|
914
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
915
|
+
|
|
916
|
+
profile_fields = (
|
|
917
|
+
"provider",
|
|
918
|
+
"api_key",
|
|
919
|
+
"api_secret",
|
|
920
|
+
"customer_id",
|
|
921
|
+
"default_domain",
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
provider = models.CharField(
|
|
925
|
+
max_length=20,
|
|
926
|
+
choices=Provider.choices,
|
|
927
|
+
default=Provider.GODADDY,
|
|
928
|
+
)
|
|
929
|
+
api_key = SigilShortAutoField(
|
|
930
|
+
max_length=255,
|
|
931
|
+
help_text="API key issued by the DNS provider.",
|
|
932
|
+
)
|
|
933
|
+
api_secret = SigilShortAutoField(
|
|
934
|
+
max_length=255,
|
|
935
|
+
help_text="API secret issued by the DNS provider.",
|
|
936
|
+
)
|
|
937
|
+
customer_id = SigilShortAutoField(
|
|
938
|
+
max_length=100,
|
|
939
|
+
blank=True,
|
|
940
|
+
help_text="Optional GoDaddy customer identifier for the account.",
|
|
941
|
+
)
|
|
942
|
+
default_domain = SigilShortAutoField(
|
|
943
|
+
max_length=253,
|
|
944
|
+
blank=True,
|
|
945
|
+
help_text="Fallback domain when records omit one.",
|
|
946
|
+
)
|
|
947
|
+
use_sandbox = models.BooleanField(
|
|
948
|
+
default=False,
|
|
949
|
+
help_text="Use the GoDaddy OTE (test) environment.",
|
|
950
|
+
)
|
|
951
|
+
is_enabled = models.BooleanField(
|
|
952
|
+
default=True,
|
|
953
|
+
help_text="Disable to prevent deployments with this manager.",
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
class Meta:
|
|
957
|
+
verbose_name = "Node Manager"
|
|
958
|
+
verbose_name_plural = "Node Managers"
|
|
959
|
+
|
|
960
|
+
def __str__(self) -> str:
|
|
961
|
+
owner = self.owner_display()
|
|
962
|
+
provider = self.get_provider_display()
|
|
963
|
+
if owner:
|
|
964
|
+
return f"{provider} ({owner})"
|
|
965
|
+
return provider
|
|
966
|
+
|
|
967
|
+
def clean(self):
|
|
968
|
+
if self.user_id or self.group_id:
|
|
969
|
+
super().clean()
|
|
970
|
+
else:
|
|
971
|
+
super(Profile, self).clean()
|
|
972
|
+
|
|
973
|
+
def get_base_url(self) -> str:
|
|
974
|
+
if self.provider != self.Provider.GODADDY:
|
|
975
|
+
raise ValueError("Unsupported DNS provider")
|
|
976
|
+
if self.use_sandbox:
|
|
977
|
+
return "https://api.ote-godaddy.com"
|
|
978
|
+
return "https://api.godaddy.com"
|
|
979
|
+
|
|
980
|
+
def get_auth_header(self) -> str:
|
|
981
|
+
key = (self.resolve_sigils("api_key") or "").strip()
|
|
982
|
+
secret = (self.resolve_sigils("api_secret") or "").strip()
|
|
983
|
+
if not key or not secret:
|
|
984
|
+
raise ValueError("API credentials are required for DNS deployment")
|
|
985
|
+
return f"sso-key {key}:{secret}"
|
|
986
|
+
|
|
987
|
+
def get_customer_id(self) -> str:
|
|
988
|
+
return (self.resolve_sigils("customer_id") or "").strip()
|
|
989
|
+
|
|
990
|
+
def get_default_domain(self) -> str:
|
|
991
|
+
return (self.resolve_sigils("default_domain") or "").strip()
|
|
992
|
+
|
|
993
|
+
def publish_dns_records(self, records: Iterable["DNSRecord"]):
|
|
994
|
+
from . import dns as dns_utils
|
|
995
|
+
|
|
996
|
+
return dns_utils.deploy_records(self, records)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
class DNSRecord(Entity):
|
|
1000
|
+
"""Stored DNS configuration ready for deployment."""
|
|
1001
|
+
|
|
1002
|
+
class Type(models.TextChoices):
|
|
1003
|
+
A = "A", "A"
|
|
1004
|
+
AAAA = "AAAA", "AAAA"
|
|
1005
|
+
CNAME = "CNAME", "CNAME"
|
|
1006
|
+
MX = "MX", "MX"
|
|
1007
|
+
NS = "NS", "NS"
|
|
1008
|
+
SRV = "SRV", "SRV"
|
|
1009
|
+
TXT = "TXT", "TXT"
|
|
1010
|
+
|
|
1011
|
+
class Provider(models.TextChoices):
|
|
1012
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
1013
|
+
|
|
1014
|
+
provider = models.CharField(
|
|
1015
|
+
max_length=20,
|
|
1016
|
+
choices=Provider.choices,
|
|
1017
|
+
default=Provider.GODADDY,
|
|
1018
|
+
)
|
|
1019
|
+
node_manager = models.ForeignKey(
|
|
1020
|
+
"NodeManager",
|
|
1021
|
+
on_delete=models.SET_NULL,
|
|
1022
|
+
null=True,
|
|
1023
|
+
blank=True,
|
|
1024
|
+
related_name="dns_records",
|
|
1025
|
+
)
|
|
1026
|
+
domain = SigilShortAutoField(
|
|
1027
|
+
max_length=253,
|
|
1028
|
+
help_text="Base domain such as example.com.",
|
|
1029
|
+
)
|
|
1030
|
+
name = SigilShortAutoField(
|
|
1031
|
+
max_length=253,
|
|
1032
|
+
help_text="Record host. Use @ for the zone apex.",
|
|
1033
|
+
)
|
|
1034
|
+
record_type = models.CharField(
|
|
1035
|
+
max_length=10,
|
|
1036
|
+
choices=Type.choices,
|
|
1037
|
+
default=Type.A,
|
|
1038
|
+
verbose_name="Type",
|
|
1039
|
+
)
|
|
1040
|
+
data = SigilLongAutoField(
|
|
1041
|
+
help_text="Record value such as an IP address or hostname.",
|
|
1042
|
+
)
|
|
1043
|
+
ttl = models.PositiveIntegerField(
|
|
1044
|
+
default=600,
|
|
1045
|
+
help_text="Time to live in seconds.",
|
|
1046
|
+
)
|
|
1047
|
+
priority = models.PositiveIntegerField(
|
|
1048
|
+
null=True,
|
|
1049
|
+
blank=True,
|
|
1050
|
+
help_text="Priority for MX and SRV records.",
|
|
1051
|
+
)
|
|
1052
|
+
port = models.PositiveIntegerField(
|
|
1053
|
+
null=True,
|
|
1054
|
+
blank=True,
|
|
1055
|
+
help_text="Port for SRV records.",
|
|
1056
|
+
)
|
|
1057
|
+
weight = models.PositiveIntegerField(
|
|
1058
|
+
null=True,
|
|
1059
|
+
blank=True,
|
|
1060
|
+
help_text="Weight for SRV records.",
|
|
1061
|
+
)
|
|
1062
|
+
service = SigilShortAutoField(
|
|
1063
|
+
max_length=50,
|
|
1064
|
+
blank=True,
|
|
1065
|
+
help_text="Service label for SRV records (for example _sip).",
|
|
1066
|
+
)
|
|
1067
|
+
protocol = SigilShortAutoField(
|
|
1068
|
+
max_length=10,
|
|
1069
|
+
blank=True,
|
|
1070
|
+
help_text="Protocol label for SRV records (for example _tcp).",
|
|
1071
|
+
)
|
|
1072
|
+
last_synced_at = models.DateTimeField(null=True, blank=True)
|
|
1073
|
+
last_verified_at = models.DateTimeField(null=True, blank=True)
|
|
1074
|
+
last_error = models.TextField(blank=True)
|
|
1075
|
+
|
|
1076
|
+
class Meta:
|
|
1077
|
+
verbose_name = "DNS Record"
|
|
1078
|
+
verbose_name_plural = "DNS Records"
|
|
1079
|
+
|
|
1080
|
+
def __str__(self) -> str:
|
|
1081
|
+
return f"{self.record_type} {self.fqdn()}"
|
|
1082
|
+
|
|
1083
|
+
def get_domain(self, manager: "NodeManager" | None = None) -> str:
|
|
1084
|
+
domain = (self.resolve_sigils("domain") or "").strip()
|
|
1085
|
+
if domain:
|
|
1086
|
+
return domain.rstrip(".")
|
|
1087
|
+
if manager:
|
|
1088
|
+
fallback = manager.get_default_domain()
|
|
1089
|
+
if fallback:
|
|
1090
|
+
return fallback.rstrip(".")
|
|
1091
|
+
return ""
|
|
1092
|
+
|
|
1093
|
+
def get_name(self) -> str:
|
|
1094
|
+
name = (self.resolve_sigils("name") or "").strip()
|
|
1095
|
+
return name or "@"
|
|
1096
|
+
|
|
1097
|
+
def fqdn(self, manager: "NodeManager" | None = None) -> str:
|
|
1098
|
+
domain = self.get_domain(manager)
|
|
1099
|
+
name = self.get_name()
|
|
1100
|
+
if name in {"@", ""}:
|
|
1101
|
+
return domain
|
|
1102
|
+
if name.endswith("."):
|
|
1103
|
+
return name.rstrip(".")
|
|
1104
|
+
if domain:
|
|
1105
|
+
return f"{name}.{domain}".rstrip(".")
|
|
1106
|
+
return name.rstrip(".")
|
|
1107
|
+
|
|
1108
|
+
def to_godaddy_payload(self) -> dict[str, object]:
|
|
1109
|
+
payload: dict[str, object] = {
|
|
1110
|
+
"data": (self.resolve_sigils("data") or "").strip(),
|
|
1111
|
+
"ttl": self.ttl,
|
|
1112
|
+
}
|
|
1113
|
+
if self.priority is not None:
|
|
1114
|
+
payload["priority"] = self.priority
|
|
1115
|
+
if self.port is not None:
|
|
1116
|
+
payload["port"] = self.port
|
|
1117
|
+
if self.weight is not None:
|
|
1118
|
+
payload["weight"] = self.weight
|
|
1119
|
+
service = (self.resolve_sigils("service") or "").strip()
|
|
1120
|
+
if service:
|
|
1121
|
+
payload["service"] = service
|
|
1122
|
+
protocol = (self.resolve_sigils("protocol") or "").strip()
|
|
1123
|
+
if protocol:
|
|
1124
|
+
payload["protocol"] = protocol
|
|
1125
|
+
return payload
|
|
1126
|
+
|
|
1127
|
+
def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
|
|
1128
|
+
if timestamp is None:
|
|
1129
|
+
timestamp = timezone.now()
|
|
1130
|
+
update_fields = ["last_synced_at", "last_error"]
|
|
1131
|
+
self.last_synced_at = timestamp
|
|
1132
|
+
self.last_error = ""
|
|
1133
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
1134
|
+
self.node_manager = manager
|
|
1135
|
+
update_fields.append("node_manager")
|
|
1136
|
+
self.save(update_fields=update_fields)
|
|
1137
|
+
|
|
1138
|
+
def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
|
|
1139
|
+
update_fields = ["last_error"]
|
|
1140
|
+
self.last_error = message
|
|
1141
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
1142
|
+
self.node_manager = manager
|
|
1143
|
+
update_fields.append("node_manager")
|
|
1144
|
+
self.save(update_fields=update_fields)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
class EmailOutbox(Profile):
|
|
1148
|
+
"""SMTP credentials for sending mail."""
|
|
1149
|
+
|
|
1150
|
+
profile_fields = (
|
|
1151
|
+
"host",
|
|
1152
|
+
"port",
|
|
1153
|
+
"username",
|
|
1154
|
+
"password",
|
|
1155
|
+
"use_tls",
|
|
1156
|
+
"use_ssl",
|
|
1157
|
+
"from_email",
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
node = models.OneToOneField(
|
|
1161
|
+
Node,
|
|
1162
|
+
on_delete=models.CASCADE,
|
|
1163
|
+
related_name="email_outbox",
|
|
1164
|
+
null=True,
|
|
1165
|
+
blank=True,
|
|
1166
|
+
)
|
|
1167
|
+
host = SigilShortAutoField(
|
|
1168
|
+
max_length=100,
|
|
1169
|
+
help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
|
|
1170
|
+
)
|
|
1171
|
+
port = models.PositiveIntegerField(
|
|
1172
|
+
default=587,
|
|
1173
|
+
help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
|
|
1174
|
+
)
|
|
1175
|
+
username = SigilShortAutoField(
|
|
1176
|
+
max_length=100,
|
|
1177
|
+
blank=True,
|
|
1178
|
+
help_text="Full email address for Gmail or GoDaddy",
|
|
1179
|
+
)
|
|
1180
|
+
password = SigilShortAutoField(
|
|
1181
|
+
max_length=100,
|
|
1182
|
+
blank=True,
|
|
1183
|
+
help_text="Email account password or app password",
|
|
1184
|
+
)
|
|
1185
|
+
use_tls = models.BooleanField(
|
|
1186
|
+
default=True,
|
|
1187
|
+
help_text="Check for Gmail or GoDaddy on port 587",
|
|
1188
|
+
)
|
|
1189
|
+
use_ssl = models.BooleanField(
|
|
1190
|
+
default=False,
|
|
1191
|
+
help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
|
|
1192
|
+
)
|
|
1193
|
+
from_email = SigilShortAutoField(
|
|
1194
|
+
blank=True,
|
|
1195
|
+
verbose_name="From Email",
|
|
1196
|
+
max_length=254,
|
|
1197
|
+
help_text="Default From address; usually the same as username",
|
|
1198
|
+
)
|
|
1199
|
+
is_enabled = models.BooleanField(
|
|
1200
|
+
default=True,
|
|
1201
|
+
help_text="Disable to remove this outbox from automatic selection.",
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
class Meta:
|
|
1205
|
+
verbose_name = "Email Outbox"
|
|
1206
|
+
verbose_name_plural = "Email Outboxes"
|
|
1207
|
+
|
|
1208
|
+
def __str__(self) -> str:
|
|
1209
|
+
address = (self.from_email or "").strip()
|
|
1210
|
+
if address:
|
|
1211
|
+
return address
|
|
1212
|
+
|
|
1213
|
+
username = (self.username or "").strip()
|
|
1214
|
+
host = (self.host or "").strip()
|
|
1215
|
+
if username:
|
|
1216
|
+
local, sep, domain = username.partition("@")
|
|
1217
|
+
if sep and domain:
|
|
1218
|
+
return username
|
|
1219
|
+
if host:
|
|
1220
|
+
sanitized = username.rstrip("@")
|
|
1221
|
+
if sanitized:
|
|
1222
|
+
return f"{sanitized}@{host}"
|
|
1223
|
+
return host
|
|
1224
|
+
return username
|
|
1225
|
+
if host:
|
|
1226
|
+
return host
|
|
1227
|
+
|
|
1228
|
+
owner = self.owner_display()
|
|
1229
|
+
if owner:
|
|
1230
|
+
return owner
|
|
1231
|
+
|
|
1232
|
+
return super().__str__()
|
|
1233
|
+
|
|
1234
|
+
def clean(self):
|
|
1235
|
+
if self.user_id or self.group_id:
|
|
1236
|
+
super().clean()
|
|
1237
|
+
else:
|
|
1238
|
+
super(Profile, self).clean()
|
|
1239
|
+
|
|
1240
|
+
def get_connection(self):
|
|
1241
|
+
return get_connection(
|
|
1242
|
+
"django.core.mail.backends.smtp.EmailBackend",
|
|
1243
|
+
host=self.host,
|
|
1244
|
+
port=self.port,
|
|
1245
|
+
username=self.username or None,
|
|
1246
|
+
password=self.password or None,
|
|
1247
|
+
use_tls=self.use_tls,
|
|
1248
|
+
use_ssl=self.use_ssl,
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
|
|
1252
|
+
from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
|
|
1253
|
+
logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
|
|
1254
|
+
return mailer.send(
|
|
1255
|
+
subject,
|
|
1256
|
+
message,
|
|
1257
|
+
recipient_list,
|
|
1258
|
+
from_email,
|
|
1259
|
+
outbox=self,
|
|
1260
|
+
**kwargs,
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
def owner_display(self):
|
|
1264
|
+
owner = super().owner_display()
|
|
1265
|
+
if owner:
|
|
1266
|
+
return owner
|
|
1267
|
+
return str(self.node) if self.node_id else ""
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
class NetMessage(Entity):
|
|
1271
|
+
"""Message propagated across nodes."""
|
|
1272
|
+
|
|
1273
|
+
uuid = models.UUIDField(
|
|
1274
|
+
default=uuid.uuid4,
|
|
1275
|
+
unique=True,
|
|
1276
|
+
editable=False,
|
|
1277
|
+
verbose_name="UUID",
|
|
1278
|
+
)
|
|
1279
|
+
node_origin = models.ForeignKey(
|
|
1280
|
+
"Node",
|
|
1281
|
+
on_delete=models.SET_NULL,
|
|
1282
|
+
null=True,
|
|
1283
|
+
blank=True,
|
|
1284
|
+
related_name="originated_net_messages",
|
|
1285
|
+
)
|
|
1286
|
+
subject = models.CharField(max_length=64, blank=True)
|
|
1287
|
+
body = models.CharField(max_length=256, blank=True)
|
|
1288
|
+
attachments = models.JSONField(blank=True, null=True)
|
|
1289
|
+
filter_node = models.ForeignKey(
|
|
1290
|
+
"Node",
|
|
1291
|
+
on_delete=models.SET_NULL,
|
|
1292
|
+
null=True,
|
|
1293
|
+
blank=True,
|
|
1294
|
+
related_name="filtered_net_messages",
|
|
1295
|
+
verbose_name="Node",
|
|
1296
|
+
)
|
|
1297
|
+
filter_node_feature = models.ForeignKey(
|
|
1298
|
+
"NodeFeature",
|
|
1299
|
+
on_delete=models.SET_NULL,
|
|
1300
|
+
null=True,
|
|
1301
|
+
blank=True,
|
|
1302
|
+
verbose_name="Node feature",
|
|
1303
|
+
)
|
|
1304
|
+
filter_node_role = models.ForeignKey(
|
|
1305
|
+
NodeRole,
|
|
1306
|
+
on_delete=models.SET_NULL,
|
|
1307
|
+
null=True,
|
|
1308
|
+
blank=True,
|
|
1309
|
+
related_name="filtered_net_messages",
|
|
1310
|
+
verbose_name="Node role",
|
|
1311
|
+
)
|
|
1312
|
+
filter_current_relation = models.CharField(
|
|
1313
|
+
max_length=10,
|
|
1314
|
+
blank=True,
|
|
1315
|
+
choices=Node.Relation.choices,
|
|
1316
|
+
verbose_name="Current relation",
|
|
1317
|
+
)
|
|
1318
|
+
filter_installed_version = models.CharField(
|
|
1319
|
+
max_length=20,
|
|
1320
|
+
blank=True,
|
|
1321
|
+
verbose_name="Installed version",
|
|
1322
|
+
)
|
|
1323
|
+
filter_installed_revision = models.CharField(
|
|
1324
|
+
max_length=40,
|
|
1325
|
+
blank=True,
|
|
1326
|
+
verbose_name="Installed revision",
|
|
1327
|
+
)
|
|
1328
|
+
reach = models.ForeignKey(
|
|
1329
|
+
NodeRole,
|
|
1330
|
+
on_delete=models.SET_NULL,
|
|
1331
|
+
null=True,
|
|
1332
|
+
blank=True,
|
|
1333
|
+
)
|
|
1334
|
+
target_limit = models.PositiveSmallIntegerField(
|
|
1335
|
+
default=6,
|
|
1336
|
+
blank=True,
|
|
1337
|
+
null=True,
|
|
1338
|
+
help_text="Maximum number of peers to contact when propagating.",
|
|
1339
|
+
)
|
|
1340
|
+
propagated_to = models.ManyToManyField(
|
|
1341
|
+
Node, blank=True, related_name="received_net_messages"
|
|
1342
|
+
)
|
|
1343
|
+
created = models.DateTimeField(auto_now_add=True)
|
|
1344
|
+
complete = models.BooleanField(default=False, editable=False)
|
|
1345
|
+
|
|
1346
|
+
class Meta:
|
|
1347
|
+
ordering = ["-created"]
|
|
1348
|
+
verbose_name = "Net Message"
|
|
1349
|
+
verbose_name_plural = "Net Messages"
|
|
1350
|
+
|
|
1351
|
+
@classmethod
|
|
1352
|
+
def broadcast(
|
|
1353
|
+
cls,
|
|
1354
|
+
subject: str,
|
|
1355
|
+
body: str,
|
|
1356
|
+
reach: NodeRole | str | None = None,
|
|
1357
|
+
seen: list[str] | None = None,
|
|
1358
|
+
attachments: list[dict[str, object]] | None = None,
|
|
1359
|
+
):
|
|
1360
|
+
role = None
|
|
1361
|
+
if reach:
|
|
1362
|
+
if isinstance(reach, NodeRole):
|
|
1363
|
+
role = reach
|
|
1364
|
+
else:
|
|
1365
|
+
role = NodeRole.objects.filter(name=reach).first()
|
|
1366
|
+
else:
|
|
1367
|
+
role = NodeRole.objects.filter(name="Terminal").first()
|
|
1368
|
+
origin = Node.get_local()
|
|
1369
|
+
normalized_attachments = cls.normalize_attachments(attachments)
|
|
1370
|
+
msg = cls.objects.create(
|
|
1371
|
+
subject=subject[:64],
|
|
1372
|
+
body=body[:256],
|
|
1373
|
+
reach=role,
|
|
1374
|
+
node_origin=origin,
|
|
1375
|
+
attachments=normalized_attachments or None,
|
|
1376
|
+
)
|
|
1377
|
+
if normalized_attachments:
|
|
1378
|
+
msg.apply_attachments(normalized_attachments)
|
|
1379
|
+
msg.propagate(seen=seen or [])
|
|
1380
|
+
return msg
|
|
1381
|
+
|
|
1382
|
+
@staticmethod
|
|
1383
|
+
def normalize_attachments(
|
|
1384
|
+
attachments: object,
|
|
1385
|
+
) -> list[dict[str, object]]:
|
|
1386
|
+
if not attachments or not isinstance(attachments, list):
|
|
1387
|
+
return []
|
|
1388
|
+
normalized: list[dict[str, object]] = []
|
|
1389
|
+
for item in attachments:
|
|
1390
|
+
if not isinstance(item, dict):
|
|
1391
|
+
continue
|
|
1392
|
+
model_label = item.get("model")
|
|
1393
|
+
fields = item.get("fields")
|
|
1394
|
+
if not isinstance(model_label, str) or not isinstance(fields, dict):
|
|
1395
|
+
continue
|
|
1396
|
+
normalized_item: dict[str, object] = {
|
|
1397
|
+
"model": model_label,
|
|
1398
|
+
"fields": deepcopy(fields),
|
|
1399
|
+
}
|
|
1400
|
+
if "pk" in item:
|
|
1401
|
+
normalized_item["pk"] = item["pk"]
|
|
1402
|
+
normalized.append(normalized_item)
|
|
1403
|
+
return normalized
|
|
1404
|
+
|
|
1405
|
+
def apply_attachments(
|
|
1406
|
+
self, attachments: list[dict[str, object]] | None = None
|
|
1407
|
+
) -> None:
|
|
1408
|
+
payload = attachments if attachments is not None else self.attachments or []
|
|
1409
|
+
if not payload:
|
|
1410
|
+
return
|
|
1411
|
+
try:
|
|
1412
|
+
objects = list(
|
|
1413
|
+
serializers.deserialize(
|
|
1414
|
+
"python", deepcopy(payload), ignorenonexistent=True
|
|
1415
|
+
)
|
|
1416
|
+
)
|
|
1417
|
+
except DeserializationError:
|
|
1418
|
+
logger.exception("Failed to deserialize attachments for NetMessage %s", self.pk)
|
|
1419
|
+
return
|
|
1420
|
+
for obj in objects:
|
|
1421
|
+
try:
|
|
1422
|
+
obj.save()
|
|
1423
|
+
except Exception:
|
|
1424
|
+
logger.exception(
|
|
1425
|
+
"Failed to save attachment %s for NetMessage %s",
|
|
1426
|
+
getattr(obj, "object", obj),
|
|
1427
|
+
self.pk,
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
def propagate(self, seen: list[str] | None = None):
|
|
1431
|
+
from core.notifications import notify
|
|
1432
|
+
import random
|
|
1433
|
+
import requests
|
|
1434
|
+
|
|
1435
|
+
displayed = notify(self.subject, self.body)
|
|
1436
|
+
local = Node.get_local()
|
|
1437
|
+
if displayed:
|
|
1438
|
+
cutoff = timezone.now() - timedelta(days=7)
|
|
1439
|
+
prune_qs = type(self).objects.filter(created__lt=cutoff)
|
|
1440
|
+
if local:
|
|
1441
|
+
prune_qs = prune_qs.filter(
|
|
1442
|
+
models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
|
|
1443
|
+
)
|
|
1444
|
+
else:
|
|
1445
|
+
prune_qs = prune_qs.filter(node_origin__isnull=True)
|
|
1446
|
+
if self.pk:
|
|
1447
|
+
prune_qs = prune_qs.exclude(pk=self.pk)
|
|
1448
|
+
prune_qs.delete()
|
|
1449
|
+
if local and not self.node_origin_id:
|
|
1450
|
+
self.node_origin = local
|
|
1451
|
+
self.save(update_fields=["node_origin"])
|
|
1452
|
+
origin_uuid = None
|
|
1453
|
+
if self.node_origin_id:
|
|
1454
|
+
origin_uuid = str(self.node_origin.uuid)
|
|
1455
|
+
elif local:
|
|
1456
|
+
origin_uuid = str(local.uuid)
|
|
1457
|
+
private_key = None
|
|
1458
|
+
seen = list(seen or [])
|
|
1459
|
+
local_id = None
|
|
1460
|
+
if local:
|
|
1461
|
+
local_id = str(local.uuid)
|
|
1462
|
+
if local_id not in seen:
|
|
1463
|
+
seen.append(local_id)
|
|
1464
|
+
priv_path = (
|
|
1465
|
+
Path(local.base_path or settings.BASE_DIR)
|
|
1466
|
+
/ "security"
|
|
1467
|
+
/ f"{local.public_endpoint}"
|
|
1468
|
+
)
|
|
1469
|
+
try:
|
|
1470
|
+
private_key = serialization.load_pem_private_key(
|
|
1471
|
+
priv_path.read_bytes(), password=None
|
|
1472
|
+
)
|
|
1473
|
+
except Exception:
|
|
1474
|
+
private_key = None
|
|
1475
|
+
for node_id in seen:
|
|
1476
|
+
node = Node.objects.filter(uuid=node_id).first()
|
|
1477
|
+
if node and (not local or node.pk != local.pk):
|
|
1478
|
+
self.propagated_to.add(node)
|
|
1479
|
+
|
|
1480
|
+
filtered_nodes = Node.objects.all()
|
|
1481
|
+
if self.filter_node_id:
|
|
1482
|
+
filtered_nodes = filtered_nodes.filter(pk=self.filter_node_id)
|
|
1483
|
+
if self.filter_node_feature_id:
|
|
1484
|
+
filtered_nodes = filtered_nodes.filter(
|
|
1485
|
+
features__pk=self.filter_node_feature_id
|
|
1486
|
+
)
|
|
1487
|
+
if self.filter_node_role_id:
|
|
1488
|
+
filtered_nodes = filtered_nodes.filter(role_id=self.filter_node_role_id)
|
|
1489
|
+
if self.filter_current_relation:
|
|
1490
|
+
filtered_nodes = filtered_nodes.filter(
|
|
1491
|
+
current_relation=self.filter_current_relation
|
|
1492
|
+
)
|
|
1493
|
+
if self.filter_installed_version:
|
|
1494
|
+
filtered_nodes = filtered_nodes.filter(
|
|
1495
|
+
installed_version=self.filter_installed_version
|
|
1496
|
+
)
|
|
1497
|
+
if self.filter_installed_revision:
|
|
1498
|
+
filtered_nodes = filtered_nodes.filter(
|
|
1499
|
+
installed_revision=self.filter_installed_revision
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
filtered_nodes = filtered_nodes.distinct()
|
|
1503
|
+
|
|
1504
|
+
if local:
|
|
1505
|
+
filtered_nodes = filtered_nodes.exclude(pk=local.pk)
|
|
1506
|
+
total_known = filtered_nodes.count()
|
|
1507
|
+
|
|
1508
|
+
remaining = list(
|
|
1509
|
+
filtered_nodes.exclude(
|
|
1510
|
+
pk__in=self.propagated_to.values_list("pk", flat=True)
|
|
1511
|
+
)
|
|
1512
|
+
)
|
|
1513
|
+
if not remaining:
|
|
1514
|
+
self.complete = True
|
|
1515
|
+
self.save(update_fields=["complete"])
|
|
1516
|
+
return
|
|
1517
|
+
|
|
1518
|
+
limit = self.target_limit or 6
|
|
1519
|
+
target_limit = min(limit, len(remaining))
|
|
1520
|
+
|
|
1521
|
+
reach_source = self.filter_node_role or self.reach
|
|
1522
|
+
reach_name = reach_source.name if reach_source else None
|
|
1523
|
+
role_map = {
|
|
1524
|
+
"Terminal": ["Terminal"],
|
|
1525
|
+
"Control": ["Control", "Terminal"],
|
|
1526
|
+
"Satellite": ["Satellite", "Control", "Terminal"],
|
|
1527
|
+
"Constellation": [
|
|
1528
|
+
"Constellation",
|
|
1529
|
+
"Satellite",
|
|
1530
|
+
"Control",
|
|
1531
|
+
"Terminal",
|
|
1532
|
+
],
|
|
1533
|
+
}
|
|
1534
|
+
selected: list[Node] = []
|
|
1535
|
+
if self.filter_node_id:
|
|
1536
|
+
target = next((n for n in remaining if n.pk == self.filter_node_id), None)
|
|
1537
|
+
if target:
|
|
1538
|
+
selected = [target]
|
|
1539
|
+
else:
|
|
1540
|
+
self.complete = True
|
|
1541
|
+
self.save(update_fields=["complete"])
|
|
1542
|
+
return
|
|
1543
|
+
else:
|
|
1544
|
+
if self.filter_node_role_id:
|
|
1545
|
+
role_order = [reach_name]
|
|
1546
|
+
else:
|
|
1547
|
+
role_order = role_map.get(reach_name, [None])
|
|
1548
|
+
for role_name in role_order:
|
|
1549
|
+
if role_name is None:
|
|
1550
|
+
role_nodes = remaining[:]
|
|
1551
|
+
else:
|
|
1552
|
+
role_nodes = [
|
|
1553
|
+
n for n in remaining if n.role and n.role.name == role_name
|
|
1554
|
+
]
|
|
1555
|
+
random.shuffle(role_nodes)
|
|
1556
|
+
for n in role_nodes:
|
|
1557
|
+
selected.append(n)
|
|
1558
|
+
remaining.remove(n)
|
|
1559
|
+
if len(selected) >= target_limit:
|
|
1560
|
+
break
|
|
1561
|
+
if len(selected) >= target_limit:
|
|
1562
|
+
break
|
|
1563
|
+
|
|
1564
|
+
if not selected:
|
|
1565
|
+
self.complete = True
|
|
1566
|
+
self.save(update_fields=["complete"])
|
|
1567
|
+
return
|
|
1568
|
+
|
|
1569
|
+
seen_list = seen.copy()
|
|
1570
|
+
selected_ids = [str(n.uuid) for n in selected]
|
|
1571
|
+
payload_seen = seen_list + selected_ids
|
|
1572
|
+
for node in selected:
|
|
1573
|
+
payload = {
|
|
1574
|
+
"uuid": str(self.uuid),
|
|
1575
|
+
"subject": self.subject,
|
|
1576
|
+
"body": self.body,
|
|
1577
|
+
"seen": payload_seen,
|
|
1578
|
+
"reach": reach_name,
|
|
1579
|
+
"sender": local_id,
|
|
1580
|
+
"origin": origin_uuid,
|
|
1581
|
+
}
|
|
1582
|
+
if self.attachments:
|
|
1583
|
+
payload["attachments"] = self.attachments
|
|
1584
|
+
if self.filter_node:
|
|
1585
|
+
payload["filter_node"] = str(self.filter_node.uuid)
|
|
1586
|
+
if self.filter_node_feature:
|
|
1587
|
+
payload["filter_node_feature"] = self.filter_node_feature.slug
|
|
1588
|
+
if self.filter_node_role:
|
|
1589
|
+
payload["filter_node_role"] = self.filter_node_role.name
|
|
1590
|
+
if self.filter_current_relation:
|
|
1591
|
+
payload["filter_current_relation"] = self.filter_current_relation
|
|
1592
|
+
if self.filter_installed_version:
|
|
1593
|
+
payload["filter_installed_version"] = self.filter_installed_version
|
|
1594
|
+
if self.filter_installed_revision:
|
|
1595
|
+
payload["filter_installed_revision"] = self.filter_installed_revision
|
|
1596
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1597
|
+
headers = {"Content-Type": "application/json"}
|
|
1598
|
+
if private_key:
|
|
1599
|
+
try:
|
|
1600
|
+
signature = private_key.sign(
|
|
1601
|
+
payload_json.encode(),
|
|
1602
|
+
padding.PKCS1v15(),
|
|
1603
|
+
hashes.SHA256(),
|
|
1604
|
+
)
|
|
1605
|
+
headers["X-Signature"] = base64.b64encode(signature).decode()
|
|
1606
|
+
except Exception:
|
|
1607
|
+
pass
|
|
1608
|
+
try:
|
|
1609
|
+
requests.post(
|
|
1610
|
+
f"http://{node.address}:{node.port}/nodes/net-message/",
|
|
1611
|
+
data=payload_json,
|
|
1612
|
+
headers=headers,
|
|
1613
|
+
timeout=1,
|
|
1614
|
+
)
|
|
1615
|
+
except Exception:
|
|
1616
|
+
pass
|
|
1617
|
+
self.propagated_to.add(node)
|
|
1618
|
+
|
|
1619
|
+
if total_known and self.propagated_to.count() >= total_known:
|
|
1620
|
+
self.complete = True
|
|
1621
|
+
self.save(update_fields=["complete"] if self.complete else [])
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
class ContentSample(Entity):
|
|
1625
|
+
"""Collected content such as text snippets or screenshots."""
|
|
1626
|
+
|
|
1627
|
+
TEXT = "TEXT"
|
|
1628
|
+
IMAGE = "IMAGE"
|
|
1629
|
+
KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
|
|
1630
|
+
|
|
1631
|
+
name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
1632
|
+
kind = models.CharField(max_length=10, choices=KIND_CHOICES)
|
|
1633
|
+
content = models.TextField(blank=True)
|
|
1634
|
+
path = models.CharField(max_length=255, blank=True)
|
|
1635
|
+
method = models.CharField(max_length=10, default="", blank=True)
|
|
1636
|
+
hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
|
|
1637
|
+
transaction_uuid = models.UUIDField(
|
|
1638
|
+
default=uuid.uuid4,
|
|
1639
|
+
editable=True,
|
|
1640
|
+
db_index=True,
|
|
1641
|
+
verbose_name="transaction UUID",
|
|
1642
|
+
)
|
|
1643
|
+
node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
|
|
1644
|
+
user = models.ForeignKey(
|
|
1645
|
+
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
|
|
1646
|
+
)
|
|
1647
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
1648
|
+
|
|
1649
|
+
class Meta:
|
|
1650
|
+
ordering = ["-created_at"]
|
|
1651
|
+
verbose_name = "Content Sample"
|
|
1652
|
+
verbose_name_plural = "Content Samples"
|
|
1653
|
+
|
|
1654
|
+
def save(self, *args, **kwargs):
|
|
1655
|
+
if self.pk:
|
|
1656
|
+
original = type(self).all_objects.get(pk=self.pk)
|
|
1657
|
+
if original.transaction_uuid != self.transaction_uuid:
|
|
1658
|
+
raise ValidationError(
|
|
1659
|
+
{"transaction_uuid": "Cannot modify transaction UUID"}
|
|
1660
|
+
)
|
|
1661
|
+
if self.node_id is None:
|
|
1662
|
+
self.node = Node.get_local()
|
|
1663
|
+
super().save(*args, **kwargs)
|
|
1664
|
+
|
|
1665
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1666
|
+
return str(self.name)
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
class ContentClassifier(Entity):
|
|
1670
|
+
"""Configured callable that classifies :class:`ContentSample` objects."""
|
|
1671
|
+
|
|
1672
|
+
slug = models.SlugField(max_length=100, unique=True)
|
|
1673
|
+
label = models.CharField(max_length=150)
|
|
1674
|
+
kind = models.CharField(max_length=10, choices=ContentSample.KIND_CHOICES)
|
|
1675
|
+
entrypoint = models.CharField(max_length=255, help_text="Dotted path to classifier callable")
|
|
1676
|
+
run_by_default = models.BooleanField(default=True)
|
|
1677
|
+
active = models.BooleanField(default=True)
|
|
1678
|
+
|
|
1679
|
+
class Meta:
|
|
1680
|
+
ordering = ["label"]
|
|
1681
|
+
verbose_name = "Content Classifier"
|
|
1682
|
+
verbose_name_plural = "Content Classifiers"
|
|
1683
|
+
|
|
1684
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1685
|
+
return self.label
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
class ContentTag(Entity):
|
|
1689
|
+
"""Tag that can be attached to classified content samples."""
|
|
1690
|
+
|
|
1691
|
+
slug = models.SlugField(max_length=100, unique=True)
|
|
1692
|
+
label = models.CharField(max_length=150)
|
|
1693
|
+
|
|
1694
|
+
class Meta:
|
|
1695
|
+
ordering = ["label"]
|
|
1696
|
+
verbose_name = "Content Tag"
|
|
1697
|
+
verbose_name_plural = "Content Tags"
|
|
1698
|
+
|
|
1699
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1700
|
+
return self.label
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
class ContentClassification(Entity):
|
|
1704
|
+
"""Link between a sample, classifier, and assigned tag."""
|
|
1705
|
+
|
|
1706
|
+
sample = models.ForeignKey(
|
|
1707
|
+
ContentSample, on_delete=models.CASCADE, related_name="classifications"
|
|
1708
|
+
)
|
|
1709
|
+
classifier = models.ForeignKey(
|
|
1710
|
+
ContentClassifier, on_delete=models.CASCADE, related_name="classifications"
|
|
1711
|
+
)
|
|
1712
|
+
tag = models.ForeignKey(
|
|
1713
|
+
ContentTag, on_delete=models.CASCADE, related_name="classifications"
|
|
1714
|
+
)
|
|
1715
|
+
confidence = models.FloatField(null=True, blank=True)
|
|
1716
|
+
metadata = models.JSONField(blank=True, null=True)
|
|
1717
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
1718
|
+
|
|
1719
|
+
class Meta:
|
|
1720
|
+
unique_together = ("sample", "classifier", "tag")
|
|
1721
|
+
ordering = ["-created_at"]
|
|
1722
|
+
verbose_name = "Content Classification"
|
|
1723
|
+
verbose_name_plural = "Content Classifications"
|
|
1724
|
+
|
|
1725
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1726
|
+
return f"{self.sample} → {self.tag}"
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
UserModel = get_user_model()
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
class User(UserModel):
|
|
1733
|
+
class Meta:
|
|
1734
|
+
proxy = True
|
|
1735
|
+
app_label = "nodes"
|
|
1736
|
+
verbose_name = UserModel._meta.verbose_name
|
|
1737
|
+
verbose_name_plural = UserModel._meta.verbose_name_plural
|