arthexis 0.1.9__py3-none-any.whl → 0.1.26__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.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -1,870 +1,2375 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
from
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from
|
|
7
|
-
from
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
from
|
|
21
|
-
from
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
from
|
|
29
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
def
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
return
|
|
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
|
-
def
|
|
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
|
-
for
|
|
298
|
-
|
|
299
|
-
if not
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if
|
|
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
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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.models import Q
|
|
8
|
+
from django.db.utils import DatabaseError
|
|
9
|
+
from django.db.models.signals import post_delete
|
|
10
|
+
from django.dispatch import Signal, receiver
|
|
11
|
+
from core.entity import Entity
|
|
12
|
+
from core.models import PackageRelease, Profile
|
|
13
|
+
from core.fields import SigilLongAutoField, SigilShortAutoField
|
|
14
|
+
import re
|
|
15
|
+
import json
|
|
16
|
+
import base64
|
|
17
|
+
import ipaddress
|
|
18
|
+
from django.utils import timezone
|
|
19
|
+
from django.utils.text import slugify
|
|
20
|
+
from django.conf import settings
|
|
21
|
+
from datetime import datetime, timedelta, timezone as datetime_timezone
|
|
22
|
+
import uuid
|
|
23
|
+
import os
|
|
24
|
+
import socket
|
|
25
|
+
import stat
|
|
26
|
+
import subprocess
|
|
27
|
+
import shutil
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from urllib.parse import urlparse, urlunsplit
|
|
30
|
+
from utils import revision
|
|
31
|
+
from core.notifications import notify_async
|
|
32
|
+
from django.core.exceptions import ValidationError
|
|
33
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
34
|
+
from cryptography.hazmat.primitives import serialization, hashes
|
|
35
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
36
|
+
from django.contrib.auth import get_user_model
|
|
37
|
+
from django.core import serializers
|
|
38
|
+
from django.core.mail import get_connection
|
|
39
|
+
from django.core.serializers.base import DeserializationError
|
|
40
|
+
from core import mailer
|
|
41
|
+
import logging
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
ROLE_RENAMES: dict[str, str] = {"Constellation": "Watchtower"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NodeRoleManager(models.Manager):
|
|
51
|
+
def get_by_natural_key(self, name: str):
|
|
52
|
+
return self.get(name=name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NodeRole(Entity):
|
|
56
|
+
"""Assignable role for a :class:`Node`."""
|
|
57
|
+
|
|
58
|
+
name = models.CharField(max_length=50, unique=True)
|
|
59
|
+
description = models.CharField(max_length=200, blank=True)
|
|
60
|
+
|
|
61
|
+
objects = NodeRoleManager()
|
|
62
|
+
|
|
63
|
+
class Meta:
|
|
64
|
+
ordering = ["name"]
|
|
65
|
+
verbose_name = "Node Role"
|
|
66
|
+
verbose_name_plural = "Node Roles"
|
|
67
|
+
|
|
68
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
69
|
+
return (self.name,)
|
|
70
|
+
|
|
71
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
72
|
+
return self.name
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NodeFeatureManager(models.Manager):
|
|
76
|
+
def get_by_natural_key(self, slug: str):
|
|
77
|
+
return self.get(slug=slug)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class NodeFeatureDefaultAction:
|
|
82
|
+
label: str
|
|
83
|
+
url_name: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class NodeFeature(Entity):
|
|
87
|
+
"""Feature that may be enabled on nodes and roles."""
|
|
88
|
+
|
|
89
|
+
slug = models.SlugField(max_length=50, unique=True)
|
|
90
|
+
display = models.CharField(max_length=50)
|
|
91
|
+
roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
|
|
92
|
+
|
|
93
|
+
objects = NodeFeatureManager()
|
|
94
|
+
|
|
95
|
+
DEFAULT_ACTIONS: dict[str, tuple[NodeFeatureDefaultAction, ...]] = {
|
|
96
|
+
"rfid-scanner": (
|
|
97
|
+
NodeFeatureDefaultAction(
|
|
98
|
+
label="Scan RFIDs", url_name="admin:core_rfid_scan"
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
"celery-queue": (
|
|
102
|
+
NodeFeatureDefaultAction(
|
|
103
|
+
label="Celery Report",
|
|
104
|
+
url_name="admin:nodes_nodefeature_celery_report",
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
"audio-capture": (
|
|
108
|
+
NodeFeatureDefaultAction(
|
|
109
|
+
label="View Waveform",
|
|
110
|
+
url_name="admin:nodes_nodefeature_view_waveform",
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
"screenshot-poll": (
|
|
114
|
+
NodeFeatureDefaultAction(
|
|
115
|
+
label="Take Screenshot",
|
|
116
|
+
url_name="admin:nodes_nodefeature_take_screenshot",
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
"rpi-camera": (
|
|
120
|
+
NodeFeatureDefaultAction(
|
|
121
|
+
label="Take a Snapshot",
|
|
122
|
+
url_name="admin:nodes_nodefeature_take_snapshot",
|
|
123
|
+
),
|
|
124
|
+
NodeFeatureDefaultAction(
|
|
125
|
+
label="View stream",
|
|
126
|
+
url_name="admin:nodes_nodefeature_view_stream",
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
class Meta:
|
|
132
|
+
ordering = ["display"]
|
|
133
|
+
verbose_name = "Node Feature"
|
|
134
|
+
verbose_name_plural = "Node Features"
|
|
135
|
+
|
|
136
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
137
|
+
return (self.slug,)
|
|
138
|
+
|
|
139
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
140
|
+
return self.display
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def is_enabled(self) -> bool:
|
|
144
|
+
from django.conf import settings
|
|
145
|
+
from pathlib import Path
|
|
146
|
+
|
|
147
|
+
node = Node.get_local()
|
|
148
|
+
if not node:
|
|
149
|
+
return False
|
|
150
|
+
if node.features.filter(pk=self.pk).exists():
|
|
151
|
+
return True
|
|
152
|
+
if self.slug == "gui-toast":
|
|
153
|
+
from core.notifications import supports_gui_toast
|
|
154
|
+
|
|
155
|
+
return supports_gui_toast()
|
|
156
|
+
if self.slug == "rpi-camera":
|
|
157
|
+
return Node._has_rpi_camera()
|
|
158
|
+
lock_map = {
|
|
159
|
+
"lcd-screen": "lcd_screen.lck",
|
|
160
|
+
"rfid-scanner": "rfid.lck",
|
|
161
|
+
"celery-queue": "celery.lck",
|
|
162
|
+
"nginx-server": "nginx_mode.lck",
|
|
163
|
+
}
|
|
164
|
+
lock = lock_map.get(self.slug)
|
|
165
|
+
if lock:
|
|
166
|
+
base_path = Path(node.base_path or settings.BASE_DIR)
|
|
167
|
+
return (base_path / "locks" / lock).exists()
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def get_default_actions(self) -> tuple[NodeFeatureDefaultAction, ...]:
|
|
171
|
+
"""Return the configured default actions for this feature."""
|
|
172
|
+
|
|
173
|
+
actions = self.DEFAULT_ACTIONS.get(self.slug, ())
|
|
174
|
+
if isinstance(actions, NodeFeatureDefaultAction): # pragma: no cover - legacy
|
|
175
|
+
return (actions,)
|
|
176
|
+
return actions
|
|
177
|
+
|
|
178
|
+
def get_default_action(self) -> NodeFeatureDefaultAction | None:
|
|
179
|
+
"""Return the first configured default action for this feature if any."""
|
|
180
|
+
|
|
181
|
+
actions = self.get_default_actions()
|
|
182
|
+
return actions[0] if actions else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_terminal_role():
|
|
186
|
+
"""Return the NodeRole representing a Terminal if it exists."""
|
|
187
|
+
return NodeRole.objects.filter(name="Terminal").first()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Node(Entity):
|
|
191
|
+
"""Information about a running node in the network."""
|
|
192
|
+
|
|
193
|
+
DEFAULT_BADGE_COLOR = "#28a745"
|
|
194
|
+
ROLE_BADGE_COLORS = {
|
|
195
|
+
"Watchtower": "#daa520", # goldenrod
|
|
196
|
+
"Constellation": "#daa520", # legacy alias
|
|
197
|
+
"Control": "#673ab7", # deep purple
|
|
198
|
+
"Interface": "#0dcaf0", # cyan
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
class Relation(models.TextChoices):
|
|
202
|
+
UPSTREAM = "UPSTREAM", "Upstream"
|
|
203
|
+
DOWNSTREAM = "DOWNSTREAM", "Downstream"
|
|
204
|
+
PEER = "PEER", "Peer"
|
|
205
|
+
SELF = "SELF", "Self"
|
|
206
|
+
|
|
207
|
+
hostname = models.CharField(max_length=100)
|
|
208
|
+
network_hostname = models.CharField(max_length=253, blank=True)
|
|
209
|
+
ipv4_address = models.GenericIPAddressField(
|
|
210
|
+
protocol="IPv4", blank=True, null=True
|
|
211
|
+
)
|
|
212
|
+
ipv6_address = models.GenericIPAddressField(
|
|
213
|
+
protocol="IPv6", blank=True, null=True
|
|
214
|
+
)
|
|
215
|
+
address = models.GenericIPAddressField(blank=True, null=True)
|
|
216
|
+
mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
|
|
217
|
+
port = models.PositiveIntegerField(default=8000)
|
|
218
|
+
message_queue_length = models.PositiveSmallIntegerField(
|
|
219
|
+
default=10,
|
|
220
|
+
help_text="Maximum queued NetMessages to retain for this peer.",
|
|
221
|
+
)
|
|
222
|
+
badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
|
|
223
|
+
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
224
|
+
current_relation = models.CharField(
|
|
225
|
+
max_length=10,
|
|
226
|
+
choices=Relation.choices,
|
|
227
|
+
default=Relation.PEER,
|
|
228
|
+
)
|
|
229
|
+
last_seen = models.DateTimeField(auto_now=True)
|
|
230
|
+
enable_public_api = models.BooleanField(
|
|
231
|
+
default=False,
|
|
232
|
+
verbose_name="enable public API",
|
|
233
|
+
)
|
|
234
|
+
public_endpoint = models.SlugField(blank=True, unique=True)
|
|
235
|
+
uuid = models.UUIDField(
|
|
236
|
+
default=uuid.uuid4,
|
|
237
|
+
unique=True,
|
|
238
|
+
editable=False,
|
|
239
|
+
verbose_name="UUID",
|
|
240
|
+
)
|
|
241
|
+
public_key = models.TextField(blank=True)
|
|
242
|
+
base_path = models.CharField(max_length=255, blank=True)
|
|
243
|
+
installed_version = models.CharField(max_length=20, blank=True)
|
|
244
|
+
installed_revision = models.CharField(max_length=40, blank=True)
|
|
245
|
+
features = models.ManyToManyField(
|
|
246
|
+
NodeFeature,
|
|
247
|
+
through="NodeFeatureAssignment",
|
|
248
|
+
related_name="nodes",
|
|
249
|
+
blank=True,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
FEATURE_LOCK_MAP = {
|
|
253
|
+
"lcd-screen": "lcd_screen.lck",
|
|
254
|
+
"rfid-scanner": "rfid.lck",
|
|
255
|
+
"celery-queue": "celery.lck",
|
|
256
|
+
"nginx-server": "nginx_mode.lck",
|
|
257
|
+
}
|
|
258
|
+
RPI_CAMERA_DEVICE = Path("/dev/video0")
|
|
259
|
+
RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
|
|
260
|
+
AP_ROUTER_SSID = "gelectriic-ap"
|
|
261
|
+
NMCLI_TIMEOUT = 5
|
|
262
|
+
AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
|
|
263
|
+
"gui-toast",
|
|
264
|
+
"rpi-camera",
|
|
265
|
+
"ap-router",
|
|
266
|
+
}
|
|
267
|
+
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll", "audio-capture"}
|
|
268
|
+
|
|
269
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
270
|
+
return f"{self.hostname}:{self.port}"
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _ip_preference(ip_value: str) -> tuple[int, str]:
|
|
274
|
+
"""Return a sort key favouring globally routable addresses."""
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
parsed = ipaddress.ip_address(ip_value)
|
|
278
|
+
except ValueError:
|
|
279
|
+
return (3, ip_value)
|
|
280
|
+
|
|
281
|
+
if parsed.is_global:
|
|
282
|
+
return (0, ip_value)
|
|
283
|
+
|
|
284
|
+
if parsed.is_loopback or parsed.is_link_local:
|
|
285
|
+
return (2, ip_value)
|
|
286
|
+
|
|
287
|
+
if parsed.is_private:
|
|
288
|
+
return (2, ip_value)
|
|
289
|
+
|
|
290
|
+
return (1, ip_value)
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def _select_preferred_ip(cls, addresses: Iterable[str]) -> str | None:
|
|
294
|
+
"""Return the preferred IP from ``addresses`` when available."""
|
|
295
|
+
|
|
296
|
+
best: tuple[int, str] | None = None
|
|
297
|
+
for candidate in addresses:
|
|
298
|
+
candidate = (candidate or "").strip()
|
|
299
|
+
if not candidate:
|
|
300
|
+
continue
|
|
301
|
+
score = cls._ip_preference(candidate)
|
|
302
|
+
if best is None or score < best:
|
|
303
|
+
best = score
|
|
304
|
+
return best[1] if best else None
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def _resolve_ip_addresses(
|
|
308
|
+
cls, *hosts: str, include_ipv4: bool = True, include_ipv6: bool = True
|
|
309
|
+
) -> tuple[list[str], list[str]]:
|
|
310
|
+
"""Resolve ``hosts`` into IPv4 and IPv6 address lists."""
|
|
311
|
+
|
|
312
|
+
ipv4: list[str] = []
|
|
313
|
+
ipv6: list[str] = []
|
|
314
|
+
|
|
315
|
+
for host in hosts:
|
|
316
|
+
host = (host or "").strip()
|
|
317
|
+
if not host:
|
|
318
|
+
continue
|
|
319
|
+
try:
|
|
320
|
+
info = socket.getaddrinfo(
|
|
321
|
+
host,
|
|
322
|
+
None,
|
|
323
|
+
socket.AF_UNSPEC,
|
|
324
|
+
socket.SOCK_STREAM,
|
|
325
|
+
)
|
|
326
|
+
except OSError:
|
|
327
|
+
continue
|
|
328
|
+
for family, _, _, _, sockaddr in info:
|
|
329
|
+
if family == socket.AF_INET and include_ipv4:
|
|
330
|
+
value = sockaddr[0]
|
|
331
|
+
if value not in ipv4:
|
|
332
|
+
ipv4.append(value)
|
|
333
|
+
elif family == socket.AF_INET6 and include_ipv6:
|
|
334
|
+
value = sockaddr[0]
|
|
335
|
+
if value not in ipv6:
|
|
336
|
+
ipv6.append(value)
|
|
337
|
+
|
|
338
|
+
return ipv4, ipv6
|
|
339
|
+
|
|
340
|
+
def get_remote_host_candidates(self) -> list[str]:
|
|
341
|
+
"""Return host strings that may reach this node."""
|
|
342
|
+
|
|
343
|
+
values: list[str] = []
|
|
344
|
+
for attr in (
|
|
345
|
+
"network_hostname",
|
|
346
|
+
"hostname",
|
|
347
|
+
"ipv6_address",
|
|
348
|
+
"ipv4_address",
|
|
349
|
+
"address",
|
|
350
|
+
"public_endpoint",
|
|
351
|
+
):
|
|
352
|
+
value = getattr(self, attr, "") or ""
|
|
353
|
+
value = value.strip()
|
|
354
|
+
if value and value not in values:
|
|
355
|
+
values.append(value)
|
|
356
|
+
|
|
357
|
+
resolved_ipv6: list[str] = []
|
|
358
|
+
resolved_ipv4: list[str] = []
|
|
359
|
+
for host in list(values):
|
|
360
|
+
if host.startswith("http://") or host.startswith("https://"):
|
|
361
|
+
continue
|
|
362
|
+
try:
|
|
363
|
+
ipaddress.ip_address(host)
|
|
364
|
+
except ValueError:
|
|
365
|
+
ipv4, ipv6 = self._resolve_ip_addresses(host)
|
|
366
|
+
for candidate in ipv6:
|
|
367
|
+
if candidate not in values and candidate not in resolved_ipv6:
|
|
368
|
+
resolved_ipv6.append(candidate)
|
|
369
|
+
for candidate in ipv4:
|
|
370
|
+
if candidate not in values and candidate not in resolved_ipv4:
|
|
371
|
+
resolved_ipv4.append(candidate)
|
|
372
|
+
values.extend(resolved_ipv6)
|
|
373
|
+
values.extend(resolved_ipv4)
|
|
374
|
+
return values
|
|
375
|
+
|
|
376
|
+
def get_primary_contact(self) -> str:
|
|
377
|
+
"""Return the first reachable host for this node."""
|
|
378
|
+
|
|
379
|
+
for host in self.get_remote_host_candidates():
|
|
380
|
+
if host:
|
|
381
|
+
return host
|
|
382
|
+
return ""
|
|
383
|
+
|
|
384
|
+
def get_best_ip(self) -> str:
|
|
385
|
+
"""Return the preferred IP address for this node if known."""
|
|
386
|
+
|
|
387
|
+
candidates: list[str] = []
|
|
388
|
+
for value in (
|
|
389
|
+
getattr(self, "address", "") or "",
|
|
390
|
+
getattr(self, "ipv4_address", "") or "",
|
|
391
|
+
getattr(self, "ipv6_address", "") or "",
|
|
392
|
+
):
|
|
393
|
+
value = value.strip()
|
|
394
|
+
if not value:
|
|
395
|
+
continue
|
|
396
|
+
try:
|
|
397
|
+
ipaddress.ip_address(value)
|
|
398
|
+
except ValueError:
|
|
399
|
+
continue
|
|
400
|
+
candidates.append(value)
|
|
401
|
+
if not candidates:
|
|
402
|
+
return ""
|
|
403
|
+
selected = self._select_preferred_ip(candidates)
|
|
404
|
+
return selected or ""
|
|
405
|
+
|
|
406
|
+
def iter_remote_urls(self, path: str):
|
|
407
|
+
"""Yield potential remote URLs for ``path`` on this node."""
|
|
408
|
+
|
|
409
|
+
host_candidates = self.get_remote_host_candidates()
|
|
410
|
+
default_port = self.port or 8000
|
|
411
|
+
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
412
|
+
seen: set[str] = set()
|
|
413
|
+
|
|
414
|
+
for host in host_candidates:
|
|
415
|
+
host = host.strip()
|
|
416
|
+
if not host:
|
|
417
|
+
continue
|
|
418
|
+
base_path = ""
|
|
419
|
+
formatted_host = host
|
|
420
|
+
port_override: int | None = None
|
|
421
|
+
|
|
422
|
+
if "://" in host:
|
|
423
|
+
parsed = urlparse(host)
|
|
424
|
+
netloc = parsed.netloc or parsed.path
|
|
425
|
+
base_path = (parsed.path or "").rstrip("/")
|
|
426
|
+
combined_path = (
|
|
427
|
+
f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
428
|
+
)
|
|
429
|
+
primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
|
|
430
|
+
if primary not in seen:
|
|
431
|
+
seen.add(primary)
|
|
432
|
+
yield primary
|
|
433
|
+
if parsed.scheme == "https":
|
|
434
|
+
fallback = urlunsplit(("http", netloc, combined_path, "", ""))
|
|
435
|
+
if fallback not in seen:
|
|
436
|
+
seen.add(fallback)
|
|
437
|
+
yield fallback
|
|
438
|
+
elif parsed.scheme == "http":
|
|
439
|
+
alternate = urlunsplit(("https", netloc, combined_path, "", ""))
|
|
440
|
+
if alternate not in seen:
|
|
441
|
+
seen.add(alternate)
|
|
442
|
+
yield alternate
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
if host.startswith("[") and "]" in host:
|
|
446
|
+
end = host.index("]")
|
|
447
|
+
core_host = host[1:end]
|
|
448
|
+
remainder = host[end + 1 :]
|
|
449
|
+
if remainder.startswith(":"):
|
|
450
|
+
remainder = remainder[1:]
|
|
451
|
+
port_part, sep, path_tail = remainder.partition("/")
|
|
452
|
+
if port_part:
|
|
453
|
+
try:
|
|
454
|
+
port_override = int(port_part)
|
|
455
|
+
except ValueError:
|
|
456
|
+
port_override = None
|
|
457
|
+
if sep:
|
|
458
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
459
|
+
elif "/" in remainder:
|
|
460
|
+
_, _, path_tail = remainder.partition("/")
|
|
461
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
462
|
+
formatted_host = f"[{core_host}]"
|
|
463
|
+
else:
|
|
464
|
+
if "/" in host:
|
|
465
|
+
host_only, _, path_tail = host.partition("/")
|
|
466
|
+
formatted_host = host_only or host
|
|
467
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
468
|
+
try:
|
|
469
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
470
|
+
except ValueError:
|
|
471
|
+
parts = formatted_host.rsplit(":", 1)
|
|
472
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
473
|
+
formatted_host = parts[0]
|
|
474
|
+
port_override = int(parts[1])
|
|
475
|
+
try:
|
|
476
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
477
|
+
except ValueError:
|
|
478
|
+
ip_obj = None
|
|
479
|
+
else:
|
|
480
|
+
if ip_obj.version == 6 and not formatted_host.startswith("["):
|
|
481
|
+
formatted_host = f"[{formatted_host}]"
|
|
482
|
+
|
|
483
|
+
effective_port = port_override if port_override is not None else default_port
|
|
484
|
+
combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
485
|
+
|
|
486
|
+
for scheme, scheme_default_port in (("https", 443), ("http", 80)):
|
|
487
|
+
base = f"{scheme}://{formatted_host}"
|
|
488
|
+
if effective_port and (
|
|
489
|
+
port_override is not None or effective_port != scheme_default_port
|
|
490
|
+
):
|
|
491
|
+
explicit = f"{base}:{effective_port}{combined_path}"
|
|
492
|
+
if explicit not in seen:
|
|
493
|
+
seen.add(explicit)
|
|
494
|
+
yield explicit
|
|
495
|
+
candidate = f"{base}{combined_path}"
|
|
496
|
+
if candidate not in seen:
|
|
497
|
+
seen.add(candidate)
|
|
498
|
+
yield candidate
|
|
499
|
+
|
|
500
|
+
@staticmethod
|
|
501
|
+
def get_current_mac() -> str:
|
|
502
|
+
"""Return the MAC address of the current host."""
|
|
503
|
+
return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
|
|
504
|
+
|
|
505
|
+
@classmethod
|
|
506
|
+
def normalize_relation(cls, value):
|
|
507
|
+
"""Normalize ``value`` to a valid :class:`Relation`."""
|
|
508
|
+
|
|
509
|
+
if isinstance(value, cls.Relation):
|
|
510
|
+
return value
|
|
511
|
+
if value is None:
|
|
512
|
+
return cls.Relation.PEER
|
|
513
|
+
text = str(value).strip()
|
|
514
|
+
if not text:
|
|
515
|
+
return cls.Relation.PEER
|
|
516
|
+
for relation in cls.Relation:
|
|
517
|
+
if text.lower() == relation.label.lower():
|
|
518
|
+
return relation
|
|
519
|
+
if text.upper() == relation.name:
|
|
520
|
+
return relation
|
|
521
|
+
if text.lower() == relation.value.lower():
|
|
522
|
+
return relation
|
|
523
|
+
return cls.Relation.PEER
|
|
524
|
+
|
|
525
|
+
@classmethod
|
|
526
|
+
def get_local(cls):
|
|
527
|
+
"""Return the node representing the current host if it exists."""
|
|
528
|
+
mac = cls.get_current_mac()
|
|
529
|
+
try:
|
|
530
|
+
node = cls.objects.filter(mac_address__iexact=mac).first()
|
|
531
|
+
if node:
|
|
532
|
+
return node
|
|
533
|
+
return (
|
|
534
|
+
cls.objects.filter(current_relation=cls.Relation.SELF)
|
|
535
|
+
.filter(Q(mac_address__isnull=True) | Q(mac_address=""))
|
|
536
|
+
.first()
|
|
537
|
+
)
|
|
538
|
+
except DatabaseError:
|
|
539
|
+
logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
@classmethod
|
|
543
|
+
def register_current(cls):
|
|
544
|
+
"""Create or update the :class:`Node` entry for this host."""
|
|
545
|
+
hostname_override = (
|
|
546
|
+
os.environ.get("NODE_HOSTNAME")
|
|
547
|
+
or os.environ.get("HOSTNAME")
|
|
548
|
+
or ""
|
|
549
|
+
)
|
|
550
|
+
hostname_override = hostname_override.strip()
|
|
551
|
+
hostname = hostname_override or socket.gethostname()
|
|
552
|
+
|
|
553
|
+
network_hostname = os.environ.get("NODE_PUBLIC_HOSTNAME", "").strip()
|
|
554
|
+
if not network_hostname:
|
|
555
|
+
fqdn = socket.getfqdn(hostname)
|
|
556
|
+
if fqdn and "." in fqdn:
|
|
557
|
+
network_hostname = fqdn
|
|
558
|
+
|
|
559
|
+
ipv4_override = os.environ.get("NODE_PUBLIC_IPV4", "").strip()
|
|
560
|
+
ipv6_override = os.environ.get("NODE_PUBLIC_IPV6", "").strip()
|
|
561
|
+
|
|
562
|
+
ipv4_candidates: list[str] = []
|
|
563
|
+
ipv6_candidates: list[str] = []
|
|
564
|
+
|
|
565
|
+
for override, version in ((ipv4_override, 4), (ipv6_override, 6)):
|
|
566
|
+
override = override.strip()
|
|
567
|
+
if not override:
|
|
568
|
+
continue
|
|
569
|
+
try:
|
|
570
|
+
parsed = ipaddress.ip_address(override)
|
|
571
|
+
except ValueError:
|
|
572
|
+
continue
|
|
573
|
+
if parsed.version == version:
|
|
574
|
+
if version == 4 and override not in ipv4_candidates:
|
|
575
|
+
ipv4_candidates.append(override)
|
|
576
|
+
elif version == 6 and override not in ipv6_candidates:
|
|
577
|
+
ipv6_candidates.append(override)
|
|
578
|
+
|
|
579
|
+
resolve_hosts: list[str] = []
|
|
580
|
+
for value in (network_hostname, hostname_override, hostname):
|
|
581
|
+
value = (value or "").strip()
|
|
582
|
+
if value and value not in resolve_hosts:
|
|
583
|
+
resolve_hosts.append(value)
|
|
584
|
+
|
|
585
|
+
resolved_ipv4, resolved_ipv6 = cls._resolve_ip_addresses(*resolve_hosts)
|
|
586
|
+
for ip_value in resolved_ipv4:
|
|
587
|
+
if ip_value not in ipv4_candidates:
|
|
588
|
+
ipv4_candidates.append(ip_value)
|
|
589
|
+
for ip_value in resolved_ipv6:
|
|
590
|
+
if ip_value not in ipv6_candidates:
|
|
591
|
+
ipv6_candidates.append(ip_value)
|
|
592
|
+
|
|
593
|
+
try:
|
|
594
|
+
direct_address = socket.gethostbyname(hostname)
|
|
595
|
+
except OSError:
|
|
596
|
+
direct_address = ""
|
|
597
|
+
|
|
598
|
+
if direct_address and direct_address not in ipv4_candidates:
|
|
599
|
+
ipv4_candidates.append(direct_address)
|
|
600
|
+
|
|
601
|
+
ipv4_address = cls._select_preferred_ip(ipv4_candidates)
|
|
602
|
+
ipv6_address = cls._select_preferred_ip(ipv6_candidates)
|
|
603
|
+
|
|
604
|
+
preferred_contact = ipv4_address or ipv6_address or direct_address or "127.0.0.1"
|
|
605
|
+
port = int(os.environ.get("PORT", 8000))
|
|
606
|
+
base_path = str(settings.BASE_DIR)
|
|
607
|
+
ver_path = Path(settings.BASE_DIR) / "VERSION"
|
|
608
|
+
installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
|
|
609
|
+
rev_value = revision.get_revision()
|
|
610
|
+
installed_revision = rev_value if rev_value else ""
|
|
611
|
+
mac = cls.get_current_mac()
|
|
612
|
+
endpoint_override = os.environ.get("NODE_PUBLIC_ENDPOINT", "").strip()
|
|
613
|
+
slug_source = endpoint_override or hostname
|
|
614
|
+
slug = slugify(slug_source)
|
|
615
|
+
if not slug:
|
|
616
|
+
slug = cls._generate_unique_public_endpoint(hostname or mac)
|
|
617
|
+
node = cls.objects.filter(mac_address=mac).first()
|
|
618
|
+
if not node:
|
|
619
|
+
node = cls.objects.filter(public_endpoint=slug).first()
|
|
620
|
+
defaults = {
|
|
621
|
+
"hostname": hostname,
|
|
622
|
+
"network_hostname": network_hostname,
|
|
623
|
+
"ipv4_address": ipv4_address,
|
|
624
|
+
"ipv6_address": ipv6_address,
|
|
625
|
+
"address": preferred_contact,
|
|
626
|
+
"port": port,
|
|
627
|
+
"base_path": base_path,
|
|
628
|
+
"installed_version": installed_version,
|
|
629
|
+
"installed_revision": installed_revision,
|
|
630
|
+
"public_endpoint": slug,
|
|
631
|
+
"mac_address": mac,
|
|
632
|
+
"current_relation": cls.Relation.SELF,
|
|
633
|
+
}
|
|
634
|
+
role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
|
|
635
|
+
role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
|
|
636
|
+
role_name = ROLE_RENAMES.get(role_name, role_name)
|
|
637
|
+
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
638
|
+
|
|
639
|
+
if node:
|
|
640
|
+
update_fields = []
|
|
641
|
+
for field, value in defaults.items():
|
|
642
|
+
if getattr(node, field) != value:
|
|
643
|
+
setattr(node, field, value)
|
|
644
|
+
update_fields.append(field)
|
|
645
|
+
if desired_role and node.role_id != desired_role.id:
|
|
646
|
+
node.role = desired_role
|
|
647
|
+
update_fields.append("role")
|
|
648
|
+
if update_fields:
|
|
649
|
+
node.save(update_fields=update_fields)
|
|
650
|
+
else:
|
|
651
|
+
node.refresh_features()
|
|
652
|
+
created = False
|
|
653
|
+
else:
|
|
654
|
+
node = cls.objects.create(**defaults)
|
|
655
|
+
created = True
|
|
656
|
+
if desired_role:
|
|
657
|
+
node.role = desired_role
|
|
658
|
+
node.save(update_fields=["role"])
|
|
659
|
+
if created and node.role is None:
|
|
660
|
+
terminal = NodeRole.objects.filter(name="Terminal").first()
|
|
661
|
+
if terminal:
|
|
662
|
+
node.role = terminal
|
|
663
|
+
node.save(update_fields=["role"])
|
|
664
|
+
node.ensure_keys()
|
|
665
|
+
node.notify_peers_of_update()
|
|
666
|
+
return node, created
|
|
667
|
+
|
|
668
|
+
def notify_peers_of_update(self):
|
|
669
|
+
"""Attempt to update this node's registration with known peers."""
|
|
670
|
+
|
|
671
|
+
from secrets import token_hex
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
import requests
|
|
675
|
+
except Exception: # pragma: no cover - requests should be available
|
|
676
|
+
return
|
|
677
|
+
|
|
678
|
+
security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
|
|
679
|
+
priv_path = security_dir / f"{self.public_endpoint}"
|
|
680
|
+
if not priv_path.exists():
|
|
681
|
+
logger.debug("Private key for %s not found; skipping peer update", self)
|
|
682
|
+
return
|
|
683
|
+
try:
|
|
684
|
+
private_key = serialization.load_pem_private_key(
|
|
685
|
+
priv_path.read_bytes(), password=None
|
|
686
|
+
)
|
|
687
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
688
|
+
logger.warning("Failed to load private key for %s: %s", self, exc)
|
|
689
|
+
return
|
|
690
|
+
token = token_hex(16)
|
|
691
|
+
try:
|
|
692
|
+
signature = private_key.sign(
|
|
693
|
+
token.encode(),
|
|
694
|
+
padding.PKCS1v15(),
|
|
695
|
+
hashes.SHA256(),
|
|
696
|
+
)
|
|
697
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
698
|
+
logger.warning("Failed to sign peer update for %s: %s", self, exc)
|
|
699
|
+
return
|
|
700
|
+
|
|
701
|
+
payload = {
|
|
702
|
+
"hostname": self.hostname,
|
|
703
|
+
"network_hostname": self.network_hostname,
|
|
704
|
+
"address": self.address,
|
|
705
|
+
"ipv4_address": self.ipv4_address,
|
|
706
|
+
"ipv6_address": self.ipv6_address,
|
|
707
|
+
"port": self.port,
|
|
708
|
+
"mac_address": self.mac_address,
|
|
709
|
+
"public_key": self.public_key,
|
|
710
|
+
"token": token,
|
|
711
|
+
"signature": base64.b64encode(signature).decode(),
|
|
712
|
+
}
|
|
713
|
+
if self.installed_version:
|
|
714
|
+
payload["installed_version"] = self.installed_version
|
|
715
|
+
if self.installed_revision:
|
|
716
|
+
payload["installed_revision"] = self.installed_revision
|
|
717
|
+
|
|
718
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
719
|
+
headers = {"Content-Type": "application/json"}
|
|
720
|
+
|
|
721
|
+
peers = Node.objects.exclude(pk=self.pk)
|
|
722
|
+
for peer in peers:
|
|
723
|
+
host_candidates = peer.get_remote_host_candidates()
|
|
724
|
+
port = peer.port or 8000
|
|
725
|
+
urls: list[str] = []
|
|
726
|
+
for host in host_candidates:
|
|
727
|
+
host = host.strip()
|
|
728
|
+
if not host:
|
|
729
|
+
continue
|
|
730
|
+
if host.startswith("http://") or host.startswith("https://"):
|
|
731
|
+
normalized = host.rstrip("/")
|
|
732
|
+
if normalized not in urls:
|
|
733
|
+
urls.append(normalized)
|
|
734
|
+
continue
|
|
735
|
+
if ":" in host and not host.startswith("["):
|
|
736
|
+
host = f"[{host}]"
|
|
737
|
+
http_url = (
|
|
738
|
+
f"http://{host}/nodes/register/"
|
|
739
|
+
if port == 80
|
|
740
|
+
else f"http://{host}:{port}/nodes/register/"
|
|
741
|
+
)
|
|
742
|
+
https_url = (
|
|
743
|
+
f"https://{host}/nodes/register/"
|
|
744
|
+
if port in {80, 443}
|
|
745
|
+
else f"https://{host}:{port}/nodes/register/"
|
|
746
|
+
)
|
|
747
|
+
for url in (https_url, http_url):
|
|
748
|
+
if url not in urls:
|
|
749
|
+
urls.append(url)
|
|
750
|
+
if not urls:
|
|
751
|
+
continue
|
|
752
|
+
for url in urls:
|
|
753
|
+
try:
|
|
754
|
+
response = requests.post(
|
|
755
|
+
url, data=payload_json, headers=headers, timeout=2
|
|
756
|
+
)
|
|
757
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
758
|
+
logger.debug("Failed to update %s via %s: %s", peer, url, exc)
|
|
759
|
+
continue
|
|
760
|
+
if response.ok:
|
|
761
|
+
version_display = _format_upgrade_body(
|
|
762
|
+
self.installed_version,
|
|
763
|
+
self.installed_revision,
|
|
764
|
+
)
|
|
765
|
+
version_suffix = f" ({version_display})" if version_display else ""
|
|
766
|
+
logger.info(
|
|
767
|
+
"Announced startup to %s%s",
|
|
768
|
+
peer,
|
|
769
|
+
version_suffix,
|
|
770
|
+
)
|
|
771
|
+
break
|
|
772
|
+
else:
|
|
773
|
+
logger.warning("Unable to notify node %s of startup", peer)
|
|
774
|
+
|
|
775
|
+
def ensure_keys(self):
|
|
776
|
+
security_dir = Path(settings.BASE_DIR) / "security"
|
|
777
|
+
security_dir.mkdir(parents=True, exist_ok=True)
|
|
778
|
+
priv_path = security_dir / f"{self.public_endpoint}"
|
|
779
|
+
pub_path = security_dir / f"{self.public_endpoint}.pub"
|
|
780
|
+
regenerate = not priv_path.exists() or not pub_path.exists()
|
|
781
|
+
if not regenerate:
|
|
782
|
+
key_max_age = getattr(settings, "NODE_KEY_MAX_AGE", timedelta(days=90))
|
|
783
|
+
if key_max_age is not None:
|
|
784
|
+
try:
|
|
785
|
+
priv_mtime = datetime.fromtimestamp(
|
|
786
|
+
priv_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
787
|
+
)
|
|
788
|
+
pub_mtime = datetime.fromtimestamp(
|
|
789
|
+
pub_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
790
|
+
)
|
|
791
|
+
except OSError:
|
|
792
|
+
regenerate = True
|
|
793
|
+
else:
|
|
794
|
+
cutoff = timezone.now() - key_max_age
|
|
795
|
+
if priv_mtime < cutoff or pub_mtime < cutoff:
|
|
796
|
+
regenerate = True
|
|
797
|
+
if regenerate:
|
|
798
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
799
|
+
private_bytes = private_key.private_bytes(
|
|
800
|
+
encoding=serialization.Encoding.PEM,
|
|
801
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
802
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
803
|
+
)
|
|
804
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
805
|
+
encoding=serialization.Encoding.PEM,
|
|
806
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
807
|
+
)
|
|
808
|
+
priv_path.write_bytes(private_bytes)
|
|
809
|
+
pub_path.write_bytes(public_bytes)
|
|
810
|
+
public_text = public_bytes.decode()
|
|
811
|
+
if self.public_key != public_text:
|
|
812
|
+
self.public_key = public_text
|
|
813
|
+
self.save(update_fields=["public_key"])
|
|
814
|
+
elif not self.public_key:
|
|
815
|
+
self.public_key = pub_path.read_text()
|
|
816
|
+
self.save(update_fields=["public_key"])
|
|
817
|
+
|
|
818
|
+
def get_private_key(self):
|
|
819
|
+
"""Return the private key for this node if available."""
|
|
820
|
+
|
|
821
|
+
if not self.public_endpoint:
|
|
822
|
+
return None
|
|
823
|
+
try:
|
|
824
|
+
self.ensure_keys()
|
|
825
|
+
except Exception:
|
|
826
|
+
return None
|
|
827
|
+
priv_path = (
|
|
828
|
+
Path(self.base_path or settings.BASE_DIR)
|
|
829
|
+
/ "security"
|
|
830
|
+
/ f"{self.public_endpoint}"
|
|
831
|
+
)
|
|
832
|
+
try:
|
|
833
|
+
return serialization.load_pem_private_key(
|
|
834
|
+
priv_path.read_bytes(), password=None
|
|
835
|
+
)
|
|
836
|
+
except Exception:
|
|
837
|
+
return None
|
|
838
|
+
|
|
839
|
+
@property
|
|
840
|
+
def is_local(self):
|
|
841
|
+
"""Determine if this node represents the current host."""
|
|
842
|
+
return self.mac_address == self.get_current_mac()
|
|
843
|
+
|
|
844
|
+
@classmethod
|
|
845
|
+
def _generate_unique_public_endpoint(
|
|
846
|
+
cls, value: str | None, *, exclude_pk: int | None = None
|
|
847
|
+
) -> str:
|
|
848
|
+
"""Return a unique public endpoint slug for ``value``."""
|
|
849
|
+
|
|
850
|
+
field = cls._meta.get_field("public_endpoint")
|
|
851
|
+
max_length = getattr(field, "max_length", None) or 50
|
|
852
|
+
base_slug = slugify(value or "") or "node"
|
|
853
|
+
if len(base_slug) > max_length:
|
|
854
|
+
base_slug = base_slug[:max_length]
|
|
855
|
+
slug = base_slug
|
|
856
|
+
queryset = cls.objects.all()
|
|
857
|
+
if exclude_pk is not None:
|
|
858
|
+
queryset = queryset.exclude(pk=exclude_pk)
|
|
859
|
+
counter = 2
|
|
860
|
+
while queryset.filter(public_endpoint=slug).exists():
|
|
861
|
+
suffix = f"-{counter}"
|
|
862
|
+
available = max_length - len(suffix)
|
|
863
|
+
if available <= 0:
|
|
864
|
+
slug = suffix[-max_length:]
|
|
865
|
+
else:
|
|
866
|
+
slug = f"{base_slug[:available]}{suffix}"
|
|
867
|
+
counter += 1
|
|
868
|
+
return slug
|
|
869
|
+
|
|
870
|
+
def save(self, *args, **kwargs):
|
|
871
|
+
update_fields = kwargs.get("update_fields")
|
|
872
|
+
|
|
873
|
+
def include_update_field(field: str):
|
|
874
|
+
nonlocal update_fields
|
|
875
|
+
if update_fields is None:
|
|
876
|
+
return
|
|
877
|
+
fields = set(update_fields)
|
|
878
|
+
if field in fields:
|
|
879
|
+
return
|
|
880
|
+
fields.add(field)
|
|
881
|
+
update_fields = tuple(fields)
|
|
882
|
+
kwargs["update_fields"] = update_fields
|
|
883
|
+
|
|
884
|
+
role_name = None
|
|
885
|
+
role = getattr(self, "role", None)
|
|
886
|
+
if role and getattr(role, "name", None):
|
|
887
|
+
role_name = role.name
|
|
888
|
+
elif self.role_id:
|
|
889
|
+
role_name = (
|
|
890
|
+
NodeRole.objects.filter(pk=self.role_id)
|
|
891
|
+
.values_list("name", flat=True)
|
|
892
|
+
.first()
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
role_color = self.ROLE_BADGE_COLORS.get(role_name)
|
|
896
|
+
if role_color and (
|
|
897
|
+
not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
|
|
898
|
+
):
|
|
899
|
+
self.badge_color = role_color
|
|
900
|
+
include_update_field("badge_color")
|
|
901
|
+
|
|
902
|
+
if self.mac_address:
|
|
903
|
+
self.mac_address = self.mac_address.lower()
|
|
904
|
+
endpoint_value = slugify(self.public_endpoint or "")
|
|
905
|
+
if not endpoint_value:
|
|
906
|
+
endpoint_value = self._generate_unique_public_endpoint(
|
|
907
|
+
self.hostname, exclude_pk=self.pk
|
|
908
|
+
)
|
|
909
|
+
else:
|
|
910
|
+
queryset = (
|
|
911
|
+
self.__class__.objects.exclude(pk=self.pk)
|
|
912
|
+
if self.pk
|
|
913
|
+
else self.__class__.objects.all()
|
|
914
|
+
)
|
|
915
|
+
if queryset.filter(public_endpoint=endpoint_value).exists():
|
|
916
|
+
endpoint_value = self._generate_unique_public_endpoint(
|
|
917
|
+
self.hostname or endpoint_value, exclude_pk=self.pk
|
|
918
|
+
)
|
|
919
|
+
if self.public_endpoint != endpoint_value:
|
|
920
|
+
self.public_endpoint = endpoint_value
|
|
921
|
+
include_update_field("public_endpoint")
|
|
922
|
+
super().save(*args, **kwargs)
|
|
923
|
+
if self.pk:
|
|
924
|
+
self.refresh_features()
|
|
925
|
+
|
|
926
|
+
def has_feature(self, slug: str) -> bool:
|
|
927
|
+
return self.features.filter(slug=slug).exists()
|
|
928
|
+
|
|
929
|
+
@classmethod
|
|
930
|
+
def _has_rpi_camera(cls) -> bool:
|
|
931
|
+
"""Return ``True`` when the Raspberry Pi camera stack is available."""
|
|
932
|
+
|
|
933
|
+
device = cls.RPI_CAMERA_DEVICE
|
|
934
|
+
if not device.exists():
|
|
935
|
+
return False
|
|
936
|
+
device_path = str(device)
|
|
937
|
+
try:
|
|
938
|
+
mode = os.stat(device_path).st_mode
|
|
939
|
+
except OSError:
|
|
940
|
+
return False
|
|
941
|
+
if not stat.S_ISCHR(mode):
|
|
942
|
+
return False
|
|
943
|
+
if not os.access(device_path, os.R_OK | os.W_OK):
|
|
944
|
+
return False
|
|
945
|
+
for binary in cls.RPI_CAMERA_BINARIES:
|
|
946
|
+
tool_path = shutil.which(binary)
|
|
947
|
+
if not tool_path:
|
|
948
|
+
return False
|
|
949
|
+
try:
|
|
950
|
+
result = subprocess.run(
|
|
951
|
+
[tool_path, "--help"],
|
|
952
|
+
capture_output=True,
|
|
953
|
+
text=True,
|
|
954
|
+
check=False,
|
|
955
|
+
timeout=5,
|
|
956
|
+
)
|
|
957
|
+
except Exception:
|
|
958
|
+
return False
|
|
959
|
+
if result.returncode != 0:
|
|
960
|
+
return False
|
|
961
|
+
return True
|
|
962
|
+
|
|
963
|
+
@classmethod
|
|
964
|
+
def _hosts_gelectriic_ap(cls) -> bool:
|
|
965
|
+
"""Return ``True`` when the node is hosting the gelectriic access point."""
|
|
966
|
+
|
|
967
|
+
nmcli_path = shutil.which("nmcli")
|
|
968
|
+
if not nmcli_path:
|
|
969
|
+
return False
|
|
970
|
+
try:
|
|
971
|
+
result = subprocess.run(
|
|
972
|
+
[
|
|
973
|
+
nmcli_path,
|
|
974
|
+
"-t",
|
|
975
|
+
"-f",
|
|
976
|
+
"NAME,DEVICE,TYPE",
|
|
977
|
+
"connection",
|
|
978
|
+
"show",
|
|
979
|
+
"--active",
|
|
980
|
+
],
|
|
981
|
+
capture_output=True,
|
|
982
|
+
text=True,
|
|
983
|
+
check=False,
|
|
984
|
+
timeout=cls.NMCLI_TIMEOUT,
|
|
985
|
+
)
|
|
986
|
+
except Exception:
|
|
987
|
+
return False
|
|
988
|
+
if result.returncode != 0:
|
|
989
|
+
return False
|
|
990
|
+
for line in result.stdout.splitlines():
|
|
991
|
+
if not line:
|
|
992
|
+
continue
|
|
993
|
+
parts = line.split(":", 2)
|
|
994
|
+
if not parts:
|
|
995
|
+
continue
|
|
996
|
+
name = parts[0]
|
|
997
|
+
conn_type = ""
|
|
998
|
+
if len(parts) == 3:
|
|
999
|
+
conn_type = parts[2]
|
|
1000
|
+
elif len(parts) > 1:
|
|
1001
|
+
conn_type = parts[1]
|
|
1002
|
+
if name != cls.AP_ROUTER_SSID:
|
|
1003
|
+
continue
|
|
1004
|
+
conn_type_normalized = conn_type.strip().lower()
|
|
1005
|
+
if conn_type_normalized not in {"wifi", "802-11-wireless"}:
|
|
1006
|
+
continue
|
|
1007
|
+
try:
|
|
1008
|
+
mode_result = subprocess.run(
|
|
1009
|
+
[
|
|
1010
|
+
nmcli_path,
|
|
1011
|
+
"-g",
|
|
1012
|
+
"802-11-wireless.mode",
|
|
1013
|
+
"connection",
|
|
1014
|
+
"show",
|
|
1015
|
+
name,
|
|
1016
|
+
],
|
|
1017
|
+
capture_output=True,
|
|
1018
|
+
text=True,
|
|
1019
|
+
check=False,
|
|
1020
|
+
timeout=cls.NMCLI_TIMEOUT,
|
|
1021
|
+
)
|
|
1022
|
+
except Exception:
|
|
1023
|
+
continue
|
|
1024
|
+
if mode_result.returncode != 0:
|
|
1025
|
+
continue
|
|
1026
|
+
if mode_result.stdout.strip() == "ap":
|
|
1027
|
+
return True
|
|
1028
|
+
return False
|
|
1029
|
+
|
|
1030
|
+
def refresh_features(self):
|
|
1031
|
+
if not self.pk:
|
|
1032
|
+
return
|
|
1033
|
+
if not self.is_local:
|
|
1034
|
+
self.sync_feature_tasks()
|
|
1035
|
+
return
|
|
1036
|
+
detected_slugs = set()
|
|
1037
|
+
base_path = Path(self.base_path or settings.BASE_DIR)
|
|
1038
|
+
locks_dir = base_path / "locks"
|
|
1039
|
+
for slug, filename in self.FEATURE_LOCK_MAP.items():
|
|
1040
|
+
if (locks_dir / filename).exists():
|
|
1041
|
+
detected_slugs.add(slug)
|
|
1042
|
+
if self._has_rpi_camera():
|
|
1043
|
+
detected_slugs.add("rpi-camera")
|
|
1044
|
+
if self._hosts_gelectriic_ap():
|
|
1045
|
+
detected_slugs.add("ap-router")
|
|
1046
|
+
try:
|
|
1047
|
+
from core.notifications import supports_gui_toast
|
|
1048
|
+
except Exception:
|
|
1049
|
+
pass
|
|
1050
|
+
else:
|
|
1051
|
+
try:
|
|
1052
|
+
if supports_gui_toast():
|
|
1053
|
+
detected_slugs.add("gui-toast")
|
|
1054
|
+
except Exception:
|
|
1055
|
+
pass
|
|
1056
|
+
current_slugs = set(
|
|
1057
|
+
self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
|
|
1058
|
+
"slug", flat=True
|
|
1059
|
+
)
|
|
1060
|
+
)
|
|
1061
|
+
add_slugs = detected_slugs - current_slugs
|
|
1062
|
+
if add_slugs:
|
|
1063
|
+
for feature in NodeFeature.objects.filter(slug__in=add_slugs):
|
|
1064
|
+
NodeFeatureAssignment.objects.update_or_create(
|
|
1065
|
+
node=self, feature=feature
|
|
1066
|
+
)
|
|
1067
|
+
remove_slugs = current_slugs - detected_slugs
|
|
1068
|
+
if remove_slugs:
|
|
1069
|
+
NodeFeatureAssignment.objects.filter(
|
|
1070
|
+
node=self, feature__slug__in=remove_slugs
|
|
1071
|
+
).delete()
|
|
1072
|
+
self.sync_feature_tasks()
|
|
1073
|
+
|
|
1074
|
+
def update_manual_features(self, slugs: Iterable[str]):
|
|
1075
|
+
desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
|
|
1076
|
+
remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
|
|
1077
|
+
if remove_slugs:
|
|
1078
|
+
NodeFeatureAssignment.objects.filter(
|
|
1079
|
+
node=self, feature__slug__in=remove_slugs
|
|
1080
|
+
).delete()
|
|
1081
|
+
if desired:
|
|
1082
|
+
for feature in NodeFeature.objects.filter(slug__in=desired):
|
|
1083
|
+
NodeFeatureAssignment.objects.update_or_create(
|
|
1084
|
+
node=self, feature=feature
|
|
1085
|
+
)
|
|
1086
|
+
self.sync_feature_tasks()
|
|
1087
|
+
|
|
1088
|
+
def sync_feature_tasks(self):
|
|
1089
|
+
clipboard_enabled = self.has_feature("clipboard-poll")
|
|
1090
|
+
screenshot_enabled = self.has_feature("screenshot-poll")
|
|
1091
|
+
celery_enabled = self.is_local and self.has_feature("celery-queue")
|
|
1092
|
+
self._sync_clipboard_task(clipboard_enabled)
|
|
1093
|
+
self._sync_screenshot_task(screenshot_enabled)
|
|
1094
|
+
self._sync_landing_lead_task(celery_enabled)
|
|
1095
|
+
self._sync_ocpp_session_report_task(celery_enabled)
|
|
1096
|
+
self._sync_upstream_poll_task(celery_enabled)
|
|
1097
|
+
|
|
1098
|
+
def _sync_clipboard_task(self, enabled: bool):
|
|
1099
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
1100
|
+
|
|
1101
|
+
task_name = f"poll_clipboard_node_{self.pk}"
|
|
1102
|
+
if enabled:
|
|
1103
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
1104
|
+
every=5, period=IntervalSchedule.SECONDS
|
|
1105
|
+
)
|
|
1106
|
+
PeriodicTask.objects.update_or_create(
|
|
1107
|
+
name=task_name,
|
|
1108
|
+
defaults={
|
|
1109
|
+
"interval": schedule,
|
|
1110
|
+
"task": "nodes.tasks.sample_clipboard",
|
|
1111
|
+
},
|
|
1112
|
+
)
|
|
1113
|
+
else:
|
|
1114
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
1115
|
+
|
|
1116
|
+
def _sync_screenshot_task(self, enabled: bool):
|
|
1117
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
1118
|
+
import json
|
|
1119
|
+
|
|
1120
|
+
task_name = f"capture_screenshot_node_{self.pk}"
|
|
1121
|
+
if enabled:
|
|
1122
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
1123
|
+
every=1, period=IntervalSchedule.MINUTES
|
|
1124
|
+
)
|
|
1125
|
+
PeriodicTask.objects.update_or_create(
|
|
1126
|
+
name=task_name,
|
|
1127
|
+
defaults={
|
|
1128
|
+
"interval": schedule,
|
|
1129
|
+
"task": "nodes.tasks.capture_node_screenshot",
|
|
1130
|
+
"kwargs": json.dumps(
|
|
1131
|
+
{
|
|
1132
|
+
"url": f"http://localhost:{self.port}",
|
|
1133
|
+
"port": self.port,
|
|
1134
|
+
"method": "AUTO",
|
|
1135
|
+
}
|
|
1136
|
+
),
|
|
1137
|
+
},
|
|
1138
|
+
)
|
|
1139
|
+
else:
|
|
1140
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
1141
|
+
|
|
1142
|
+
def _sync_landing_lead_task(self, enabled: bool):
|
|
1143
|
+
if not self.is_local:
|
|
1144
|
+
return
|
|
1145
|
+
|
|
1146
|
+
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
|
1147
|
+
|
|
1148
|
+
task_name = "pages_purge_landing_leads"
|
|
1149
|
+
if enabled:
|
|
1150
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
1151
|
+
minute="0",
|
|
1152
|
+
hour="3",
|
|
1153
|
+
day_of_week="*",
|
|
1154
|
+
day_of_month="*",
|
|
1155
|
+
month_of_year="*",
|
|
1156
|
+
)
|
|
1157
|
+
PeriodicTask.objects.update_or_create(
|
|
1158
|
+
name=task_name,
|
|
1159
|
+
defaults={
|
|
1160
|
+
"crontab": schedule,
|
|
1161
|
+
"interval": None,
|
|
1162
|
+
"task": "pages.tasks.purge_expired_landing_leads",
|
|
1163
|
+
"enabled": True,
|
|
1164
|
+
},
|
|
1165
|
+
)
|
|
1166
|
+
else:
|
|
1167
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
1168
|
+
|
|
1169
|
+
def _sync_ocpp_session_report_task(self, celery_enabled: bool):
|
|
1170
|
+
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
|
1171
|
+
from django.db.utils import OperationalError, ProgrammingError
|
|
1172
|
+
|
|
1173
|
+
task_name = "ocpp_send_daily_session_report"
|
|
1174
|
+
|
|
1175
|
+
if not self.is_local:
|
|
1176
|
+
return
|
|
1177
|
+
|
|
1178
|
+
if not celery_enabled or not mailer.can_send_email():
|
|
1179
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
1180
|
+
return
|
|
1181
|
+
|
|
1182
|
+
try:
|
|
1183
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
1184
|
+
minute="0",
|
|
1185
|
+
hour="18",
|
|
1186
|
+
day_of_week="*",
|
|
1187
|
+
day_of_month="*",
|
|
1188
|
+
month_of_year="*",
|
|
1189
|
+
)
|
|
1190
|
+
PeriodicTask.objects.update_or_create(
|
|
1191
|
+
name=task_name,
|
|
1192
|
+
defaults={
|
|
1193
|
+
"crontab": schedule,
|
|
1194
|
+
"interval": None,
|
|
1195
|
+
"task": "ocpp.tasks.send_daily_session_report",
|
|
1196
|
+
"enabled": True,
|
|
1197
|
+
},
|
|
1198
|
+
)
|
|
1199
|
+
except (OperationalError, ProgrammingError):
|
|
1200
|
+
logger.debug("Skipping OCPP session report task sync; tables not ready")
|
|
1201
|
+
|
|
1202
|
+
def _sync_upstream_poll_task(self, celery_enabled: bool):
|
|
1203
|
+
if not self.is_local:
|
|
1204
|
+
return
|
|
1205
|
+
|
|
1206
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
1207
|
+
|
|
1208
|
+
task_name = "nodes_poll_upstream_messages"
|
|
1209
|
+
if celery_enabled:
|
|
1210
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
1211
|
+
every=1, period=IntervalSchedule.MINUTES
|
|
1212
|
+
)
|
|
1213
|
+
PeriodicTask.objects.update_or_create(
|
|
1214
|
+
name=task_name,
|
|
1215
|
+
defaults={
|
|
1216
|
+
"interval": schedule,
|
|
1217
|
+
"task": "nodes.tasks.poll_unreachable_upstream",
|
|
1218
|
+
"enabled": True,
|
|
1219
|
+
},
|
|
1220
|
+
)
|
|
1221
|
+
else:
|
|
1222
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
1223
|
+
|
|
1224
|
+
def send_mail(
|
|
1225
|
+
self,
|
|
1226
|
+
subject: str,
|
|
1227
|
+
message: str,
|
|
1228
|
+
recipient_list: list[str],
|
|
1229
|
+
from_email: str | None = None,
|
|
1230
|
+
**kwargs,
|
|
1231
|
+
):
|
|
1232
|
+
"""Send an email using this node's configured outbox if available."""
|
|
1233
|
+
outbox = getattr(self, "email_outbox", None)
|
|
1234
|
+
logger.info(
|
|
1235
|
+
"Node %s queueing email to %s using %s backend",
|
|
1236
|
+
self.pk,
|
|
1237
|
+
recipient_list,
|
|
1238
|
+
"outbox" if outbox else "default",
|
|
1239
|
+
)
|
|
1240
|
+
return mailer.send(
|
|
1241
|
+
subject,
|
|
1242
|
+
message,
|
|
1243
|
+
recipient_list,
|
|
1244
|
+
from_email,
|
|
1245
|
+
outbox=outbox,
|
|
1246
|
+
**kwargs,
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
node_information_updated = Signal()
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _format_upgrade_body(version: str, revision: str) -> str:
|
|
1254
|
+
version = (version or "").strip()
|
|
1255
|
+
revision = (revision or "").strip()
|
|
1256
|
+
parts: list[str] = []
|
|
1257
|
+
if version:
|
|
1258
|
+
normalized = version.lstrip("vV") or version
|
|
1259
|
+
base_version = normalized.rstrip("+")
|
|
1260
|
+
display_version = normalized
|
|
1261
|
+
if (
|
|
1262
|
+
base_version
|
|
1263
|
+
and revision
|
|
1264
|
+
and not PackageRelease.matches_revision(base_version, revision)
|
|
1265
|
+
and not normalized.endswith("+")
|
|
1266
|
+
):
|
|
1267
|
+
display_version = f"{display_version}+"
|
|
1268
|
+
parts.append(f"v{display_version}")
|
|
1269
|
+
if revision:
|
|
1270
|
+
rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
|
|
1271
|
+
rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
|
|
1272
|
+
parts.append(f"r{rev_short}")
|
|
1273
|
+
return " ".join(parts).strip()
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
@receiver(node_information_updated)
|
|
1277
|
+
def _announce_peer_startup(
|
|
1278
|
+
sender,
|
|
1279
|
+
*,
|
|
1280
|
+
node: "Node",
|
|
1281
|
+
previous_version: str = "",
|
|
1282
|
+
previous_revision: str = "",
|
|
1283
|
+
current_version: str = "",
|
|
1284
|
+
current_revision: str = "",
|
|
1285
|
+
**_: object,
|
|
1286
|
+
) -> None:
|
|
1287
|
+
current_version = (current_version or "").strip()
|
|
1288
|
+
current_revision = (current_revision or "").strip()
|
|
1289
|
+
previous_version = (previous_version or "").strip()
|
|
1290
|
+
previous_revision = (previous_revision or "").strip()
|
|
1291
|
+
|
|
1292
|
+
local = Node.get_local()
|
|
1293
|
+
if local and node.pk == local.pk:
|
|
1294
|
+
return
|
|
1295
|
+
|
|
1296
|
+
body = _format_upgrade_body(current_version, current_revision)
|
|
1297
|
+
if not body:
|
|
1298
|
+
body = "Online"
|
|
1299
|
+
|
|
1300
|
+
hostname = (node.hostname or "Node").strip() or "Node"
|
|
1301
|
+
subject = f"UP {hostname}"
|
|
1302
|
+
notify_async(subject, body)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
class NodeFeatureAssignment(Entity):
|
|
1306
|
+
"""Bridge between :class:`Node` and :class:`NodeFeature`."""
|
|
1307
|
+
|
|
1308
|
+
node = models.ForeignKey(
|
|
1309
|
+
Node, on_delete=models.CASCADE, related_name="feature_assignments"
|
|
1310
|
+
)
|
|
1311
|
+
feature = models.ForeignKey(
|
|
1312
|
+
NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
|
|
1313
|
+
)
|
|
1314
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
1315
|
+
|
|
1316
|
+
class Meta:
|
|
1317
|
+
unique_together = ("node", "feature")
|
|
1318
|
+
verbose_name = "Node Feature Assignment"
|
|
1319
|
+
verbose_name_plural = "Node Feature Assignments"
|
|
1320
|
+
|
|
1321
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1322
|
+
return f"{self.node} -> {self.feature}"
|
|
1323
|
+
|
|
1324
|
+
def save(self, *args, **kwargs):
|
|
1325
|
+
super().save(*args, **kwargs)
|
|
1326
|
+
self.node.sync_feature_tasks()
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
@receiver(post_delete, sender=NodeFeatureAssignment)
|
|
1330
|
+
def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
|
|
1331
|
+
node_id = getattr(instance, "node_id", None)
|
|
1332
|
+
if not node_id:
|
|
1333
|
+
return
|
|
1334
|
+
node = Node.objects.filter(pk=node_id).first()
|
|
1335
|
+
if node:
|
|
1336
|
+
node.sync_feature_tasks()
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
class NodeManager(Profile):
|
|
1340
|
+
"""Credentials for interacting with external DNS providers."""
|
|
1341
|
+
|
|
1342
|
+
class Provider(models.TextChoices):
|
|
1343
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
1344
|
+
|
|
1345
|
+
profile_fields = (
|
|
1346
|
+
"provider",
|
|
1347
|
+
"api_key",
|
|
1348
|
+
"api_secret",
|
|
1349
|
+
"customer_id",
|
|
1350
|
+
"default_domain",
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
provider = models.CharField(
|
|
1354
|
+
max_length=20,
|
|
1355
|
+
choices=Provider.choices,
|
|
1356
|
+
default=Provider.GODADDY,
|
|
1357
|
+
)
|
|
1358
|
+
api_key = SigilShortAutoField(
|
|
1359
|
+
max_length=255,
|
|
1360
|
+
verbose_name="API key",
|
|
1361
|
+
help_text="API key issued by the DNS provider.",
|
|
1362
|
+
)
|
|
1363
|
+
api_secret = SigilShortAutoField(
|
|
1364
|
+
max_length=255,
|
|
1365
|
+
verbose_name="API secret",
|
|
1366
|
+
help_text="API secret issued by the DNS provider.",
|
|
1367
|
+
)
|
|
1368
|
+
customer_id = SigilShortAutoField(
|
|
1369
|
+
max_length=100,
|
|
1370
|
+
blank=True,
|
|
1371
|
+
verbose_name="Customer ID",
|
|
1372
|
+
help_text="Optional GoDaddy customer identifier for the account.",
|
|
1373
|
+
)
|
|
1374
|
+
default_domain = SigilShortAutoField(
|
|
1375
|
+
max_length=253,
|
|
1376
|
+
blank=True,
|
|
1377
|
+
help_text="Fallback domain when records omit one.",
|
|
1378
|
+
)
|
|
1379
|
+
use_sandbox = models.BooleanField(
|
|
1380
|
+
default=False,
|
|
1381
|
+
help_text="Use the GoDaddy OTE (test) environment.",
|
|
1382
|
+
)
|
|
1383
|
+
is_enabled = models.BooleanField(
|
|
1384
|
+
default=True,
|
|
1385
|
+
help_text="Disable to prevent deployments with this manager.",
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
class Meta:
|
|
1389
|
+
verbose_name = "Node Profile"
|
|
1390
|
+
verbose_name_plural = "Node Profiles"
|
|
1391
|
+
|
|
1392
|
+
def __str__(self) -> str:
|
|
1393
|
+
owner = self.owner_display()
|
|
1394
|
+
provider = self.get_provider_display()
|
|
1395
|
+
if owner:
|
|
1396
|
+
return f"{provider} ({owner})"
|
|
1397
|
+
return provider
|
|
1398
|
+
|
|
1399
|
+
def clean(self):
|
|
1400
|
+
if self.user_id or self.group_id:
|
|
1401
|
+
super().clean()
|
|
1402
|
+
else:
|
|
1403
|
+
super(Profile, self).clean()
|
|
1404
|
+
|
|
1405
|
+
def get_base_url(self) -> str:
|
|
1406
|
+
if self.provider != self.Provider.GODADDY:
|
|
1407
|
+
raise ValueError("Unsupported DNS provider")
|
|
1408
|
+
if self.use_sandbox:
|
|
1409
|
+
return "https://api.ote-godaddy.com"
|
|
1410
|
+
return "https://api.godaddy.com"
|
|
1411
|
+
|
|
1412
|
+
def get_auth_header(self) -> str:
|
|
1413
|
+
key = (self.resolve_sigils("api_key") or "").strip()
|
|
1414
|
+
secret = (self.resolve_sigils("api_secret") or "").strip()
|
|
1415
|
+
if not key or not secret:
|
|
1416
|
+
raise ValueError("API credentials are required for DNS deployment")
|
|
1417
|
+
return f"sso-key {key}:{secret}"
|
|
1418
|
+
|
|
1419
|
+
def get_customer_id(self) -> str:
|
|
1420
|
+
return (self.resolve_sigils("customer_id") or "").strip()
|
|
1421
|
+
|
|
1422
|
+
def get_default_domain(self) -> str:
|
|
1423
|
+
return (self.resolve_sigils("default_domain") or "").strip()
|
|
1424
|
+
|
|
1425
|
+
def publish_dns_records(self, records: Iterable["DNSRecord"]):
|
|
1426
|
+
from . import dns as dns_utils
|
|
1427
|
+
|
|
1428
|
+
return dns_utils.deploy_records(self, records)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
class DNSRecord(Entity):
|
|
1432
|
+
"""Stored DNS configuration ready for deployment."""
|
|
1433
|
+
|
|
1434
|
+
class Type(models.TextChoices):
|
|
1435
|
+
A = "A", "A"
|
|
1436
|
+
AAAA = "AAAA", "AAAA"
|
|
1437
|
+
CNAME = "CNAME", "CNAME"
|
|
1438
|
+
MX = "MX", "MX"
|
|
1439
|
+
NS = "NS", "NS"
|
|
1440
|
+
SRV = "SRV", "SRV"
|
|
1441
|
+
TXT = "TXT", "TXT"
|
|
1442
|
+
|
|
1443
|
+
class Provider(models.TextChoices):
|
|
1444
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
1445
|
+
|
|
1446
|
+
provider = models.CharField(
|
|
1447
|
+
max_length=20,
|
|
1448
|
+
choices=Provider.choices,
|
|
1449
|
+
default=Provider.GODADDY,
|
|
1450
|
+
)
|
|
1451
|
+
node_manager = models.ForeignKey(
|
|
1452
|
+
"NodeManager",
|
|
1453
|
+
on_delete=models.SET_NULL,
|
|
1454
|
+
null=True,
|
|
1455
|
+
blank=True,
|
|
1456
|
+
related_name="dns_records",
|
|
1457
|
+
)
|
|
1458
|
+
domain = SigilShortAutoField(
|
|
1459
|
+
max_length=253,
|
|
1460
|
+
help_text="Base domain such as example.com.",
|
|
1461
|
+
)
|
|
1462
|
+
name = SigilShortAutoField(
|
|
1463
|
+
max_length=253,
|
|
1464
|
+
help_text="Record host. Use @ for the zone apex.",
|
|
1465
|
+
)
|
|
1466
|
+
record_type = models.CharField(
|
|
1467
|
+
max_length=10,
|
|
1468
|
+
choices=Type.choices,
|
|
1469
|
+
default=Type.A,
|
|
1470
|
+
verbose_name="Type",
|
|
1471
|
+
)
|
|
1472
|
+
data = SigilLongAutoField(
|
|
1473
|
+
help_text="Record value such as an IP address or hostname.",
|
|
1474
|
+
)
|
|
1475
|
+
ttl = models.PositiveIntegerField(
|
|
1476
|
+
default=600,
|
|
1477
|
+
help_text="Time to live in seconds.",
|
|
1478
|
+
)
|
|
1479
|
+
priority = models.PositiveIntegerField(
|
|
1480
|
+
null=True,
|
|
1481
|
+
blank=True,
|
|
1482
|
+
help_text="Priority for MX and SRV records.",
|
|
1483
|
+
)
|
|
1484
|
+
port = models.PositiveIntegerField(
|
|
1485
|
+
null=True,
|
|
1486
|
+
blank=True,
|
|
1487
|
+
help_text="Port for SRV records.",
|
|
1488
|
+
)
|
|
1489
|
+
weight = models.PositiveIntegerField(
|
|
1490
|
+
null=True,
|
|
1491
|
+
blank=True,
|
|
1492
|
+
help_text="Weight for SRV records.",
|
|
1493
|
+
)
|
|
1494
|
+
service = SigilShortAutoField(
|
|
1495
|
+
max_length=50,
|
|
1496
|
+
blank=True,
|
|
1497
|
+
help_text="Service label for SRV records (for example _sip).",
|
|
1498
|
+
)
|
|
1499
|
+
protocol = SigilShortAutoField(
|
|
1500
|
+
max_length=10,
|
|
1501
|
+
blank=True,
|
|
1502
|
+
help_text="Protocol label for SRV records (for example _tcp).",
|
|
1503
|
+
)
|
|
1504
|
+
last_synced_at = models.DateTimeField(null=True, blank=True)
|
|
1505
|
+
last_verified_at = models.DateTimeField(null=True, blank=True)
|
|
1506
|
+
last_error = models.TextField(blank=True)
|
|
1507
|
+
|
|
1508
|
+
class Meta:
|
|
1509
|
+
verbose_name = "DNS Record"
|
|
1510
|
+
verbose_name_plural = "DNS Records"
|
|
1511
|
+
|
|
1512
|
+
def __str__(self) -> str:
|
|
1513
|
+
return f"{self.record_type} {self.fqdn()}"
|
|
1514
|
+
|
|
1515
|
+
def get_domain(self, manager: "NodeManager" | None = None) -> str:
|
|
1516
|
+
domain = (self.resolve_sigils("domain") or "").strip()
|
|
1517
|
+
if domain:
|
|
1518
|
+
return domain.rstrip(".")
|
|
1519
|
+
if manager:
|
|
1520
|
+
fallback = manager.get_default_domain()
|
|
1521
|
+
if fallback:
|
|
1522
|
+
return fallback.rstrip(".")
|
|
1523
|
+
return ""
|
|
1524
|
+
|
|
1525
|
+
def get_name(self) -> str:
|
|
1526
|
+
name = (self.resolve_sigils("name") or "").strip()
|
|
1527
|
+
return name or "@"
|
|
1528
|
+
|
|
1529
|
+
def fqdn(self, manager: "NodeManager" | None = None) -> str:
|
|
1530
|
+
domain = self.get_domain(manager)
|
|
1531
|
+
name = self.get_name()
|
|
1532
|
+
if name in {"@", ""}:
|
|
1533
|
+
return domain
|
|
1534
|
+
if name.endswith("."):
|
|
1535
|
+
return name.rstrip(".")
|
|
1536
|
+
if domain:
|
|
1537
|
+
return f"{name}.{domain}".rstrip(".")
|
|
1538
|
+
return name.rstrip(".")
|
|
1539
|
+
|
|
1540
|
+
def to_godaddy_payload(self) -> dict[str, object]:
|
|
1541
|
+
payload: dict[str, object] = {
|
|
1542
|
+
"data": (self.resolve_sigils("data") or "").strip(),
|
|
1543
|
+
"ttl": self.ttl,
|
|
1544
|
+
}
|
|
1545
|
+
if self.priority is not None:
|
|
1546
|
+
payload["priority"] = self.priority
|
|
1547
|
+
if self.port is not None:
|
|
1548
|
+
payload["port"] = self.port
|
|
1549
|
+
if self.weight is not None:
|
|
1550
|
+
payload["weight"] = self.weight
|
|
1551
|
+
service = (self.resolve_sigils("service") or "").strip()
|
|
1552
|
+
if service:
|
|
1553
|
+
payload["service"] = service
|
|
1554
|
+
protocol = (self.resolve_sigils("protocol") or "").strip()
|
|
1555
|
+
if protocol:
|
|
1556
|
+
payload["protocol"] = protocol
|
|
1557
|
+
return payload
|
|
1558
|
+
|
|
1559
|
+
def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
|
|
1560
|
+
if timestamp is None:
|
|
1561
|
+
timestamp = timezone.now()
|
|
1562
|
+
update_fields = ["last_synced_at", "last_error"]
|
|
1563
|
+
self.last_synced_at = timestamp
|
|
1564
|
+
self.last_error = ""
|
|
1565
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
1566
|
+
self.node_manager = manager
|
|
1567
|
+
update_fields.append("node_manager")
|
|
1568
|
+
self.save(update_fields=update_fields)
|
|
1569
|
+
|
|
1570
|
+
def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
|
|
1571
|
+
update_fields = ["last_error"]
|
|
1572
|
+
self.last_error = message
|
|
1573
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
1574
|
+
self.node_manager = manager
|
|
1575
|
+
update_fields.append("node_manager")
|
|
1576
|
+
self.save(update_fields=update_fields)
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
class EmailOutbox(Profile):
|
|
1580
|
+
"""SMTP credentials for sending mail."""
|
|
1581
|
+
|
|
1582
|
+
profile_fields = (
|
|
1583
|
+
"host",
|
|
1584
|
+
"port",
|
|
1585
|
+
"username",
|
|
1586
|
+
"password",
|
|
1587
|
+
"use_tls",
|
|
1588
|
+
"use_ssl",
|
|
1589
|
+
"from_email",
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
node = models.OneToOneField(
|
|
1593
|
+
Node,
|
|
1594
|
+
on_delete=models.CASCADE,
|
|
1595
|
+
related_name="email_outbox",
|
|
1596
|
+
null=True,
|
|
1597
|
+
blank=True,
|
|
1598
|
+
)
|
|
1599
|
+
host = SigilShortAutoField(
|
|
1600
|
+
max_length=100,
|
|
1601
|
+
help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
|
|
1602
|
+
)
|
|
1603
|
+
port = models.PositiveIntegerField(
|
|
1604
|
+
default=587,
|
|
1605
|
+
help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
|
|
1606
|
+
)
|
|
1607
|
+
username = SigilShortAutoField(
|
|
1608
|
+
max_length=100,
|
|
1609
|
+
blank=True,
|
|
1610
|
+
help_text="Full email address for Gmail or GoDaddy",
|
|
1611
|
+
)
|
|
1612
|
+
password = SigilShortAutoField(
|
|
1613
|
+
max_length=100,
|
|
1614
|
+
blank=True,
|
|
1615
|
+
help_text="Email account password or app password",
|
|
1616
|
+
)
|
|
1617
|
+
use_tls = models.BooleanField(
|
|
1618
|
+
default=True,
|
|
1619
|
+
help_text="Check for Gmail or GoDaddy on port 587",
|
|
1620
|
+
)
|
|
1621
|
+
use_ssl = models.BooleanField(
|
|
1622
|
+
default=False,
|
|
1623
|
+
help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
|
|
1624
|
+
)
|
|
1625
|
+
from_email = SigilShortAutoField(
|
|
1626
|
+
blank=True,
|
|
1627
|
+
verbose_name="From Email",
|
|
1628
|
+
max_length=254,
|
|
1629
|
+
help_text="Default From address; usually the same as username",
|
|
1630
|
+
)
|
|
1631
|
+
is_enabled = models.BooleanField(
|
|
1632
|
+
default=True,
|
|
1633
|
+
help_text="Disable to remove this outbox from automatic selection.",
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1636
|
+
class Meta:
|
|
1637
|
+
verbose_name = "Email Outbox"
|
|
1638
|
+
verbose_name_plural = "Email Outboxes"
|
|
1639
|
+
|
|
1640
|
+
def __str__(self) -> str:
|
|
1641
|
+
address = (self.from_email or "").strip()
|
|
1642
|
+
if address:
|
|
1643
|
+
return address
|
|
1644
|
+
|
|
1645
|
+
username = (self.username or "").strip()
|
|
1646
|
+
host = (self.host or "").strip()
|
|
1647
|
+
if username:
|
|
1648
|
+
local, sep, domain = username.partition("@")
|
|
1649
|
+
if sep and domain:
|
|
1650
|
+
return username
|
|
1651
|
+
if host:
|
|
1652
|
+
sanitized = username.rstrip("@")
|
|
1653
|
+
if sanitized:
|
|
1654
|
+
return f"{sanitized}@{host}"
|
|
1655
|
+
return host
|
|
1656
|
+
return username
|
|
1657
|
+
if host:
|
|
1658
|
+
return host
|
|
1659
|
+
|
|
1660
|
+
owner = self.owner_display()
|
|
1661
|
+
if owner:
|
|
1662
|
+
return owner
|
|
1663
|
+
|
|
1664
|
+
return super().__str__()
|
|
1665
|
+
|
|
1666
|
+
def clean(self):
|
|
1667
|
+
if self.user_id or self.group_id:
|
|
1668
|
+
super().clean()
|
|
1669
|
+
else:
|
|
1670
|
+
super(Profile, self).clean()
|
|
1671
|
+
|
|
1672
|
+
def get_connection(self):
|
|
1673
|
+
return get_connection(
|
|
1674
|
+
"django.core.mail.backends.smtp.EmailBackend",
|
|
1675
|
+
host=self.host,
|
|
1676
|
+
port=self.port,
|
|
1677
|
+
username=self.username or None,
|
|
1678
|
+
password=self.password or None,
|
|
1679
|
+
use_tls=self.use_tls,
|
|
1680
|
+
use_ssl=self.use_ssl,
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
|
|
1684
|
+
from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
|
|
1685
|
+
logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
|
|
1686
|
+
return mailer.send(
|
|
1687
|
+
subject,
|
|
1688
|
+
message,
|
|
1689
|
+
recipient_list,
|
|
1690
|
+
from_email,
|
|
1691
|
+
outbox=self,
|
|
1692
|
+
**kwargs,
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
def owner_display(self):
|
|
1696
|
+
owner = super().owner_display()
|
|
1697
|
+
if owner:
|
|
1698
|
+
return owner
|
|
1699
|
+
return str(self.node) if self.node_id else ""
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
class NetMessage(Entity):
|
|
1703
|
+
"""Message propagated across nodes."""
|
|
1704
|
+
|
|
1705
|
+
uuid = models.UUIDField(
|
|
1706
|
+
default=uuid.uuid4,
|
|
1707
|
+
unique=True,
|
|
1708
|
+
editable=False,
|
|
1709
|
+
verbose_name="UUID",
|
|
1710
|
+
)
|
|
1711
|
+
node_origin = models.ForeignKey(
|
|
1712
|
+
"Node",
|
|
1713
|
+
on_delete=models.SET_NULL,
|
|
1714
|
+
null=True,
|
|
1715
|
+
blank=True,
|
|
1716
|
+
related_name="originated_net_messages",
|
|
1717
|
+
)
|
|
1718
|
+
subject = models.CharField(max_length=64, blank=True)
|
|
1719
|
+
body = models.CharField(max_length=256, blank=True)
|
|
1720
|
+
attachments = models.JSONField(blank=True, null=True)
|
|
1721
|
+
filter_node = models.ForeignKey(
|
|
1722
|
+
"Node",
|
|
1723
|
+
on_delete=models.SET_NULL,
|
|
1724
|
+
null=True,
|
|
1725
|
+
blank=True,
|
|
1726
|
+
related_name="filtered_net_messages",
|
|
1727
|
+
verbose_name="Node",
|
|
1728
|
+
)
|
|
1729
|
+
filter_node_feature = models.ForeignKey(
|
|
1730
|
+
"NodeFeature",
|
|
1731
|
+
on_delete=models.SET_NULL,
|
|
1732
|
+
null=True,
|
|
1733
|
+
blank=True,
|
|
1734
|
+
verbose_name="Node feature",
|
|
1735
|
+
)
|
|
1736
|
+
filter_node_role = models.ForeignKey(
|
|
1737
|
+
NodeRole,
|
|
1738
|
+
on_delete=models.SET_NULL,
|
|
1739
|
+
null=True,
|
|
1740
|
+
blank=True,
|
|
1741
|
+
related_name="filtered_net_messages",
|
|
1742
|
+
verbose_name="Node role",
|
|
1743
|
+
)
|
|
1744
|
+
filter_current_relation = models.CharField(
|
|
1745
|
+
max_length=10,
|
|
1746
|
+
blank=True,
|
|
1747
|
+
choices=Node.Relation.choices,
|
|
1748
|
+
verbose_name="Current relation",
|
|
1749
|
+
)
|
|
1750
|
+
filter_installed_version = models.CharField(
|
|
1751
|
+
max_length=20,
|
|
1752
|
+
blank=True,
|
|
1753
|
+
verbose_name="Installed version",
|
|
1754
|
+
)
|
|
1755
|
+
filter_installed_revision = models.CharField(
|
|
1756
|
+
max_length=40,
|
|
1757
|
+
blank=True,
|
|
1758
|
+
verbose_name="Installed revision",
|
|
1759
|
+
)
|
|
1760
|
+
reach = models.ForeignKey(
|
|
1761
|
+
NodeRole,
|
|
1762
|
+
on_delete=models.SET_NULL,
|
|
1763
|
+
null=True,
|
|
1764
|
+
blank=True,
|
|
1765
|
+
)
|
|
1766
|
+
target_limit = models.PositiveSmallIntegerField(
|
|
1767
|
+
default=6,
|
|
1768
|
+
blank=True,
|
|
1769
|
+
null=True,
|
|
1770
|
+
help_text="Maximum number of peers to contact when propagating.",
|
|
1771
|
+
)
|
|
1772
|
+
propagated_to = models.ManyToManyField(
|
|
1773
|
+
Node, blank=True, related_name="received_net_messages"
|
|
1774
|
+
)
|
|
1775
|
+
created = models.DateTimeField(auto_now_add=True)
|
|
1776
|
+
complete = models.BooleanField(default=False, editable=False)
|
|
1777
|
+
|
|
1778
|
+
class Meta:
|
|
1779
|
+
ordering = ["-created"]
|
|
1780
|
+
verbose_name = "Net Message"
|
|
1781
|
+
verbose_name_plural = "Net Messages"
|
|
1782
|
+
|
|
1783
|
+
@classmethod
|
|
1784
|
+
def broadcast(
|
|
1785
|
+
cls,
|
|
1786
|
+
subject: str,
|
|
1787
|
+
body: str,
|
|
1788
|
+
reach: NodeRole | str | None = None,
|
|
1789
|
+
seen: list[str] | None = None,
|
|
1790
|
+
attachments: list[dict[str, object]] | None = None,
|
|
1791
|
+
):
|
|
1792
|
+
role = None
|
|
1793
|
+
if reach:
|
|
1794
|
+
if isinstance(reach, NodeRole):
|
|
1795
|
+
role = reach
|
|
1796
|
+
else:
|
|
1797
|
+
role = NodeRole.objects.filter(name=reach).first()
|
|
1798
|
+
else:
|
|
1799
|
+
role = NodeRole.objects.filter(name="Terminal").first()
|
|
1800
|
+
origin = Node.get_local()
|
|
1801
|
+
normalized_attachments = cls.normalize_attachments(attachments)
|
|
1802
|
+
msg = cls.objects.create(
|
|
1803
|
+
subject=subject[:64],
|
|
1804
|
+
body=body[:256],
|
|
1805
|
+
reach=role,
|
|
1806
|
+
node_origin=origin,
|
|
1807
|
+
attachments=normalized_attachments or None,
|
|
1808
|
+
)
|
|
1809
|
+
if normalized_attachments:
|
|
1810
|
+
msg.apply_attachments(normalized_attachments)
|
|
1811
|
+
msg.propagate(seen=seen or [])
|
|
1812
|
+
return msg
|
|
1813
|
+
|
|
1814
|
+
@staticmethod
|
|
1815
|
+
def normalize_attachments(
|
|
1816
|
+
attachments: object,
|
|
1817
|
+
) -> list[dict[str, object]]:
|
|
1818
|
+
if not attachments or not isinstance(attachments, list):
|
|
1819
|
+
return []
|
|
1820
|
+
normalized: list[dict[str, object]] = []
|
|
1821
|
+
for item in attachments:
|
|
1822
|
+
if not isinstance(item, dict):
|
|
1823
|
+
continue
|
|
1824
|
+
model_label = item.get("model")
|
|
1825
|
+
fields = item.get("fields")
|
|
1826
|
+
if not isinstance(model_label, str) or not isinstance(fields, dict):
|
|
1827
|
+
continue
|
|
1828
|
+
normalized_item: dict[str, object] = {
|
|
1829
|
+
"model": model_label,
|
|
1830
|
+
"fields": deepcopy(fields),
|
|
1831
|
+
}
|
|
1832
|
+
if "pk" in item:
|
|
1833
|
+
normalized_item["pk"] = item["pk"]
|
|
1834
|
+
normalized.append(normalized_item)
|
|
1835
|
+
return normalized
|
|
1836
|
+
|
|
1837
|
+
def apply_attachments(
|
|
1838
|
+
self, attachments: list[dict[str, object]] | None = None
|
|
1839
|
+
) -> None:
|
|
1840
|
+
payload = attachments if attachments is not None else self.attachments or []
|
|
1841
|
+
if not payload:
|
|
1842
|
+
return
|
|
1843
|
+
|
|
1844
|
+
try:
|
|
1845
|
+
objects = list(
|
|
1846
|
+
serializers.deserialize(
|
|
1847
|
+
"python", deepcopy(payload), ignorenonexistent=True
|
|
1848
|
+
)
|
|
1849
|
+
)
|
|
1850
|
+
except DeserializationError:
|
|
1851
|
+
logger.exception("Failed to deserialize attachments for NetMessage %s", self.pk)
|
|
1852
|
+
return
|
|
1853
|
+
for obj in objects:
|
|
1854
|
+
try:
|
|
1855
|
+
obj.save()
|
|
1856
|
+
except Exception:
|
|
1857
|
+
logger.exception(
|
|
1858
|
+
"Failed to save attachment %s for NetMessage %s",
|
|
1859
|
+
getattr(obj, "object", obj),
|
|
1860
|
+
self.pk,
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1863
|
+
def _build_payload(
|
|
1864
|
+
self,
|
|
1865
|
+
*,
|
|
1866
|
+
sender_id: str | None,
|
|
1867
|
+
origin_uuid: str | None,
|
|
1868
|
+
reach_name: str | None,
|
|
1869
|
+
seen: list[str],
|
|
1870
|
+
) -> dict[str, object]:
|
|
1871
|
+
payload: dict[str, object] = {
|
|
1872
|
+
"uuid": str(self.uuid),
|
|
1873
|
+
"subject": self.subject,
|
|
1874
|
+
"body": self.body,
|
|
1875
|
+
"seen": list(seen),
|
|
1876
|
+
"reach": reach_name,
|
|
1877
|
+
"sender": sender_id,
|
|
1878
|
+
"origin": origin_uuid,
|
|
1879
|
+
}
|
|
1880
|
+
if self.attachments:
|
|
1881
|
+
payload["attachments"] = self.attachments
|
|
1882
|
+
if self.filter_node:
|
|
1883
|
+
payload["filter_node"] = str(self.filter_node.uuid)
|
|
1884
|
+
if self.filter_node_feature:
|
|
1885
|
+
payload["filter_node_feature"] = self.filter_node_feature.slug
|
|
1886
|
+
if self.filter_node_role:
|
|
1887
|
+
payload["filter_node_role"] = self.filter_node_role.name
|
|
1888
|
+
if self.filter_current_relation:
|
|
1889
|
+
payload["filter_current_relation"] = self.filter_current_relation
|
|
1890
|
+
if self.filter_installed_version:
|
|
1891
|
+
payload["filter_installed_version"] = self.filter_installed_version
|
|
1892
|
+
if self.filter_installed_revision:
|
|
1893
|
+
payload["filter_installed_revision"] = self.filter_installed_revision
|
|
1894
|
+
return payload
|
|
1895
|
+
|
|
1896
|
+
@staticmethod
|
|
1897
|
+
def _serialize_payload(payload: dict[str, object]) -> str:
|
|
1898
|
+
return json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1899
|
+
|
|
1900
|
+
@staticmethod
|
|
1901
|
+
def _sign_payload(payload_json: str, private_key) -> str | None:
|
|
1902
|
+
if not private_key:
|
|
1903
|
+
return None
|
|
1904
|
+
try:
|
|
1905
|
+
signature = private_key.sign(
|
|
1906
|
+
payload_json.encode(),
|
|
1907
|
+
padding.PKCS1v15(),
|
|
1908
|
+
hashes.SHA256(),
|
|
1909
|
+
)
|
|
1910
|
+
except Exception:
|
|
1911
|
+
return None
|
|
1912
|
+
return base64.b64encode(signature).decode()
|
|
1913
|
+
|
|
1914
|
+
def queue_for_node(self, node: "Node", seen: list[str]) -> None:
|
|
1915
|
+
"""Queue this message for later delivery to ``node``."""
|
|
1916
|
+
|
|
1917
|
+
if node.current_relation != Node.Relation.DOWNSTREAM:
|
|
1918
|
+
return
|
|
1919
|
+
|
|
1920
|
+
now = timezone.now()
|
|
1921
|
+
expires_at = now + timedelta(hours=1)
|
|
1922
|
+
normalized_seen = [str(value) for value in seen]
|
|
1923
|
+
entry, created = PendingNetMessage.objects.get_or_create(
|
|
1924
|
+
node=node,
|
|
1925
|
+
message=self,
|
|
1926
|
+
defaults={
|
|
1927
|
+
"seen": normalized_seen,
|
|
1928
|
+
"stale_at": expires_at,
|
|
1929
|
+
},
|
|
1930
|
+
)
|
|
1931
|
+
if created:
|
|
1932
|
+
entry.queued_at = now
|
|
1933
|
+
entry.save(update_fields=["queued_at"])
|
|
1934
|
+
else:
|
|
1935
|
+
entry.seen = normalized_seen
|
|
1936
|
+
entry.stale_at = expires_at
|
|
1937
|
+
entry.queued_at = now
|
|
1938
|
+
entry.save(update_fields=["seen", "stale_at", "queued_at"])
|
|
1939
|
+
self._trim_queue(node)
|
|
1940
|
+
|
|
1941
|
+
def clear_queue_for_node(self, node: "Node") -> None:
|
|
1942
|
+
PendingNetMessage.objects.filter(node=node, message=self).delete()
|
|
1943
|
+
|
|
1944
|
+
def _trim_queue(self, node: "Node") -> None:
|
|
1945
|
+
limit = max(int(node.message_queue_length or 0), 0)
|
|
1946
|
+
if limit == 0:
|
|
1947
|
+
PendingNetMessage.objects.filter(node=node).delete()
|
|
1948
|
+
return
|
|
1949
|
+
qs = PendingNetMessage.objects.filter(node=node).order_by("-queued_at")
|
|
1950
|
+
keep_ids = list(qs.values_list("pk", flat=True)[:limit])
|
|
1951
|
+
if keep_ids:
|
|
1952
|
+
PendingNetMessage.objects.filter(node=node).exclude(pk__in=keep_ids).delete()
|
|
1953
|
+
else:
|
|
1954
|
+
qs.delete()
|
|
1955
|
+
|
|
1956
|
+
@classmethod
|
|
1957
|
+
def receive_payload(
|
|
1958
|
+
cls,
|
|
1959
|
+
data: dict[str, object],
|
|
1960
|
+
*,
|
|
1961
|
+
sender: "Node",
|
|
1962
|
+
) -> "NetMessage":
|
|
1963
|
+
msg_uuid = data.get("uuid")
|
|
1964
|
+
if not msg_uuid:
|
|
1965
|
+
raise ValueError("uuid required")
|
|
1966
|
+
subject = (data.get("subject") or "")[:64]
|
|
1967
|
+
body = (data.get("body") or "")[:256]
|
|
1968
|
+
attachments = cls.normalize_attachments(data.get("attachments"))
|
|
1969
|
+
reach_name = data.get("reach")
|
|
1970
|
+
reach_role = None
|
|
1971
|
+
if reach_name:
|
|
1972
|
+
reach_role = NodeRole.objects.filter(name=reach_name).first()
|
|
1973
|
+
filter_node_uuid = data.get("filter_node")
|
|
1974
|
+
filter_node = None
|
|
1975
|
+
if filter_node_uuid:
|
|
1976
|
+
filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
|
|
1977
|
+
filter_feature_slug = data.get("filter_node_feature")
|
|
1978
|
+
filter_feature = None
|
|
1979
|
+
if filter_feature_slug:
|
|
1980
|
+
filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
|
|
1981
|
+
filter_role_name = data.get("filter_node_role")
|
|
1982
|
+
filter_role = None
|
|
1983
|
+
if filter_role_name:
|
|
1984
|
+
filter_role = NodeRole.objects.filter(name=filter_role_name).first()
|
|
1985
|
+
filter_relation_value = data.get("filter_current_relation")
|
|
1986
|
+
filter_relation = ""
|
|
1987
|
+
if filter_relation_value:
|
|
1988
|
+
relation = Node.normalize_relation(filter_relation_value)
|
|
1989
|
+
filter_relation = relation.value if relation else ""
|
|
1990
|
+
filter_installed_version = (data.get("filter_installed_version") or "")[:20]
|
|
1991
|
+
filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
|
|
1992
|
+
seen_values = data.get("seen", [])
|
|
1993
|
+
if not isinstance(seen_values, list):
|
|
1994
|
+
seen_values = list(seen_values) # type: ignore[arg-type]
|
|
1995
|
+
normalized_seen = [str(value) for value in seen_values if value is not None]
|
|
1996
|
+
origin_id = data.get("origin")
|
|
1997
|
+
origin_node = None
|
|
1998
|
+
if origin_id:
|
|
1999
|
+
origin_node = Node.objects.filter(uuid=origin_id).first()
|
|
2000
|
+
if not origin_node:
|
|
2001
|
+
origin_node = sender
|
|
2002
|
+
msg, created = cls.objects.get_or_create(
|
|
2003
|
+
uuid=msg_uuid,
|
|
2004
|
+
defaults={
|
|
2005
|
+
"subject": subject,
|
|
2006
|
+
"body": body,
|
|
2007
|
+
"reach": reach_role,
|
|
2008
|
+
"node_origin": origin_node,
|
|
2009
|
+
"attachments": attachments or None,
|
|
2010
|
+
"filter_node": filter_node,
|
|
2011
|
+
"filter_node_feature": filter_feature,
|
|
2012
|
+
"filter_node_role": filter_role,
|
|
2013
|
+
"filter_current_relation": filter_relation,
|
|
2014
|
+
"filter_installed_version": filter_installed_version,
|
|
2015
|
+
"filter_installed_revision": filter_installed_revision,
|
|
2016
|
+
},
|
|
2017
|
+
)
|
|
2018
|
+
if not created:
|
|
2019
|
+
msg.subject = subject
|
|
2020
|
+
msg.body = body
|
|
2021
|
+
update_fields = ["subject", "body"]
|
|
2022
|
+
if reach_role and msg.reach_id != reach_role.id:
|
|
2023
|
+
msg.reach = reach_role
|
|
2024
|
+
update_fields.append("reach")
|
|
2025
|
+
if msg.node_origin_id is None and origin_node:
|
|
2026
|
+
msg.node_origin = origin_node
|
|
2027
|
+
update_fields.append("node_origin")
|
|
2028
|
+
if attachments and msg.attachments != attachments:
|
|
2029
|
+
msg.attachments = attachments
|
|
2030
|
+
update_fields.append("attachments")
|
|
2031
|
+
field_updates = {
|
|
2032
|
+
"filter_node": filter_node,
|
|
2033
|
+
"filter_node_feature": filter_feature,
|
|
2034
|
+
"filter_node_role": filter_role,
|
|
2035
|
+
"filter_current_relation": filter_relation,
|
|
2036
|
+
"filter_installed_version": filter_installed_version,
|
|
2037
|
+
"filter_installed_revision": filter_installed_revision,
|
|
2038
|
+
}
|
|
2039
|
+
for field, value in field_updates.items():
|
|
2040
|
+
if getattr(msg, field) != value:
|
|
2041
|
+
setattr(msg, field, value)
|
|
2042
|
+
update_fields.append(field)
|
|
2043
|
+
if update_fields:
|
|
2044
|
+
msg.save(update_fields=update_fields)
|
|
2045
|
+
if attachments:
|
|
2046
|
+
msg.apply_attachments(attachments)
|
|
2047
|
+
msg.propagate(seen=normalized_seen)
|
|
2048
|
+
return msg
|
|
2049
|
+
|
|
2050
|
+
def propagate(self, seen: list[str] | None = None):
|
|
2051
|
+
from core.notifications import notify
|
|
2052
|
+
import random
|
|
2053
|
+
import requests
|
|
2054
|
+
|
|
2055
|
+
displayed = notify(self.subject, self.body)
|
|
2056
|
+
local = Node.get_local()
|
|
2057
|
+
if displayed:
|
|
2058
|
+
cutoff = timezone.now() - timedelta(days=7)
|
|
2059
|
+
prune_qs = type(self).objects.filter(created__lt=cutoff)
|
|
2060
|
+
if local:
|
|
2061
|
+
prune_qs = prune_qs.filter(
|
|
2062
|
+
models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
|
|
2063
|
+
)
|
|
2064
|
+
else:
|
|
2065
|
+
prune_qs = prune_qs.filter(node_origin__isnull=True)
|
|
2066
|
+
if self.pk:
|
|
2067
|
+
prune_qs = prune_qs.exclude(pk=self.pk)
|
|
2068
|
+
prune_qs.delete()
|
|
2069
|
+
if local and not self.node_origin_id:
|
|
2070
|
+
self.node_origin = local
|
|
2071
|
+
self.save(update_fields=["node_origin"])
|
|
2072
|
+
origin_uuid = None
|
|
2073
|
+
if self.node_origin_id:
|
|
2074
|
+
origin_uuid = str(self.node_origin.uuid)
|
|
2075
|
+
elif local:
|
|
2076
|
+
origin_uuid = str(local.uuid)
|
|
2077
|
+
private_key = None
|
|
2078
|
+
seen = list(seen or [])
|
|
2079
|
+
local_id = None
|
|
2080
|
+
if local:
|
|
2081
|
+
local_id = str(local.uuid)
|
|
2082
|
+
if local_id not in seen:
|
|
2083
|
+
seen.append(local_id)
|
|
2084
|
+
private_key = local.get_private_key()
|
|
2085
|
+
for node_id in seen:
|
|
2086
|
+
node = Node.objects.filter(uuid=node_id).first()
|
|
2087
|
+
if node and (not local or node.pk != local.pk):
|
|
2088
|
+
self.propagated_to.add(node)
|
|
2089
|
+
|
|
2090
|
+
filtered_nodes = Node.objects.all()
|
|
2091
|
+
if self.filter_node_id:
|
|
2092
|
+
filtered_nodes = filtered_nodes.filter(pk=self.filter_node_id)
|
|
2093
|
+
if self.filter_node_feature_id:
|
|
2094
|
+
filtered_nodes = filtered_nodes.filter(
|
|
2095
|
+
features__pk=self.filter_node_feature_id
|
|
2096
|
+
)
|
|
2097
|
+
if self.filter_node_role_id:
|
|
2098
|
+
filtered_nodes = filtered_nodes.filter(role_id=self.filter_node_role_id)
|
|
2099
|
+
if self.filter_current_relation:
|
|
2100
|
+
filtered_nodes = filtered_nodes.filter(
|
|
2101
|
+
current_relation=self.filter_current_relation
|
|
2102
|
+
)
|
|
2103
|
+
if self.filter_installed_version:
|
|
2104
|
+
filtered_nodes = filtered_nodes.filter(
|
|
2105
|
+
installed_version=self.filter_installed_version
|
|
2106
|
+
)
|
|
2107
|
+
if self.filter_installed_revision:
|
|
2108
|
+
filtered_nodes = filtered_nodes.filter(
|
|
2109
|
+
installed_revision=self.filter_installed_revision
|
|
2110
|
+
)
|
|
2111
|
+
|
|
2112
|
+
filtered_nodes = filtered_nodes.distinct()
|
|
2113
|
+
|
|
2114
|
+
if local:
|
|
2115
|
+
filtered_nodes = filtered_nodes.exclude(pk=local.pk)
|
|
2116
|
+
total_known = filtered_nodes.count()
|
|
2117
|
+
|
|
2118
|
+
remaining = list(
|
|
2119
|
+
filtered_nodes.exclude(
|
|
2120
|
+
pk__in=self.propagated_to.values_list("pk", flat=True)
|
|
2121
|
+
)
|
|
2122
|
+
)
|
|
2123
|
+
if not remaining:
|
|
2124
|
+
self.complete = True
|
|
2125
|
+
self.save(update_fields=["complete"])
|
|
2126
|
+
return
|
|
2127
|
+
|
|
2128
|
+
limit = self.target_limit or 6
|
|
2129
|
+
target_limit = min(limit, len(remaining))
|
|
2130
|
+
|
|
2131
|
+
reach_source = self.filter_node_role or self.reach
|
|
2132
|
+
reach_name = reach_source.name if reach_source else None
|
|
2133
|
+
role_map = {
|
|
2134
|
+
"Interface": ["Interface", "Terminal"],
|
|
2135
|
+
"Terminal": ["Terminal"],
|
|
2136
|
+
"Control": ["Control", "Terminal"],
|
|
2137
|
+
"Satellite": ["Satellite", "Control", "Terminal"],
|
|
2138
|
+
"Watchtower": [
|
|
2139
|
+
"Watchtower",
|
|
2140
|
+
"Satellite",
|
|
2141
|
+
"Control",
|
|
2142
|
+
"Terminal",
|
|
2143
|
+
],
|
|
2144
|
+
"Constellation": [
|
|
2145
|
+
"Watchtower",
|
|
2146
|
+
"Satellite",
|
|
2147
|
+
"Control",
|
|
2148
|
+
"Terminal",
|
|
2149
|
+
],
|
|
2150
|
+
}
|
|
2151
|
+
selected: list[Node] = []
|
|
2152
|
+
if self.filter_node_id:
|
|
2153
|
+
target = next((n for n in remaining if n.pk == self.filter_node_id), None)
|
|
2154
|
+
if target:
|
|
2155
|
+
selected = [target]
|
|
2156
|
+
else:
|
|
2157
|
+
self.complete = True
|
|
2158
|
+
self.save(update_fields=["complete"])
|
|
2159
|
+
return
|
|
2160
|
+
else:
|
|
2161
|
+
if self.filter_node_role_id:
|
|
2162
|
+
role_order = [reach_name]
|
|
2163
|
+
else:
|
|
2164
|
+
role_order = role_map.get(reach_name, [None])
|
|
2165
|
+
for role_name in role_order:
|
|
2166
|
+
if role_name is None:
|
|
2167
|
+
role_nodes = remaining[:]
|
|
2168
|
+
else:
|
|
2169
|
+
role_nodes = [
|
|
2170
|
+
n for n in remaining if n.role and n.role.name == role_name
|
|
2171
|
+
]
|
|
2172
|
+
random.shuffle(role_nodes)
|
|
2173
|
+
for n in role_nodes:
|
|
2174
|
+
selected.append(n)
|
|
2175
|
+
remaining.remove(n)
|
|
2176
|
+
if len(selected) >= target_limit:
|
|
2177
|
+
break
|
|
2178
|
+
if len(selected) >= target_limit:
|
|
2179
|
+
break
|
|
2180
|
+
|
|
2181
|
+
if not selected:
|
|
2182
|
+
self.complete = True
|
|
2183
|
+
self.save(update_fields=["complete"])
|
|
2184
|
+
return
|
|
2185
|
+
|
|
2186
|
+
seen_list = seen.copy()
|
|
2187
|
+
selected_ids = [str(n.uuid) for n in selected]
|
|
2188
|
+
payload_seen = seen_list + selected_ids
|
|
2189
|
+
for node in selected:
|
|
2190
|
+
payload = self._build_payload(
|
|
2191
|
+
sender_id=local_id,
|
|
2192
|
+
origin_uuid=origin_uuid,
|
|
2193
|
+
reach_name=reach_name,
|
|
2194
|
+
seen=payload_seen,
|
|
2195
|
+
)
|
|
2196
|
+
payload_json = self._serialize_payload(payload)
|
|
2197
|
+
headers = {"Content-Type": "application/json"}
|
|
2198
|
+
signature = self._sign_payload(payload_json, private_key)
|
|
2199
|
+
if signature:
|
|
2200
|
+
headers["X-Signature"] = signature
|
|
2201
|
+
success = False
|
|
2202
|
+
for url in node.iter_remote_urls("/nodes/net-message/"):
|
|
2203
|
+
try:
|
|
2204
|
+
response = requests.post(
|
|
2205
|
+
url,
|
|
2206
|
+
data=payload_json,
|
|
2207
|
+
headers=headers,
|
|
2208
|
+
timeout=1,
|
|
2209
|
+
)
|
|
2210
|
+
success = bool(response.ok)
|
|
2211
|
+
except Exception:
|
|
2212
|
+
logger.exception(
|
|
2213
|
+
"Failed to propagate NetMessage %s to node %s via %s",
|
|
2214
|
+
self.pk,
|
|
2215
|
+
node.pk,
|
|
2216
|
+
url,
|
|
2217
|
+
)
|
|
2218
|
+
continue
|
|
2219
|
+
if success:
|
|
2220
|
+
break
|
|
2221
|
+
if success:
|
|
2222
|
+
self.clear_queue_for_node(node)
|
|
2223
|
+
else:
|
|
2224
|
+
self.queue_for_node(node, payload_seen)
|
|
2225
|
+
self.propagated_to.add(node)
|
|
2226
|
+
|
|
2227
|
+
save_fields: list[str] = []
|
|
2228
|
+
if total_known and self.propagated_to.count() >= total_known:
|
|
2229
|
+
self.complete = True
|
|
2230
|
+
save_fields.append("complete")
|
|
2231
|
+
|
|
2232
|
+
if save_fields:
|
|
2233
|
+
self.save(update_fields=save_fields)
|
|
2234
|
+
|
|
2235
|
+
|
|
2236
|
+
class PendingNetMessage(models.Model):
|
|
2237
|
+
"""Queued :class:`NetMessage` awaiting delivery to a downstream node."""
|
|
2238
|
+
|
|
2239
|
+
node = models.ForeignKey(
|
|
2240
|
+
Node, on_delete=models.CASCADE, related_name="pending_net_messages"
|
|
2241
|
+
)
|
|
2242
|
+
message = models.ForeignKey(
|
|
2243
|
+
NetMessage,
|
|
2244
|
+
on_delete=models.CASCADE,
|
|
2245
|
+
related_name="pending_deliveries",
|
|
2246
|
+
)
|
|
2247
|
+
seen = models.JSONField(default=list)
|
|
2248
|
+
queued_at = models.DateTimeField(auto_now_add=True)
|
|
2249
|
+
stale_at = models.DateTimeField()
|
|
2250
|
+
|
|
2251
|
+
class Meta:
|
|
2252
|
+
unique_together = ("node", "message")
|
|
2253
|
+
ordering = ("queued_at",)
|
|
2254
|
+
|
|
2255
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
2256
|
+
return f"{self.message_id} → {self.node_id}"
|
|
2257
|
+
|
|
2258
|
+
@property
|
|
2259
|
+
def is_stale(self) -> bool:
|
|
2260
|
+
return self.stale_at <= timezone.now()
|
|
2261
|
+
|
|
2262
|
+
class ContentSample(Entity):
|
|
2263
|
+
"""Collected content such as text snippets or screenshots."""
|
|
2264
|
+
|
|
2265
|
+
TEXT = "TEXT"
|
|
2266
|
+
IMAGE = "IMAGE"
|
|
2267
|
+
KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
|
|
2268
|
+
|
|
2269
|
+
name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
2270
|
+
kind = models.CharField(max_length=10, choices=KIND_CHOICES)
|
|
2271
|
+
content = models.TextField(blank=True)
|
|
2272
|
+
path = models.CharField(max_length=255, blank=True)
|
|
2273
|
+
method = models.CharField(max_length=10, default="", blank=True)
|
|
2274
|
+
hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
|
|
2275
|
+
transaction_uuid = models.UUIDField(
|
|
2276
|
+
default=uuid.uuid4,
|
|
2277
|
+
editable=True,
|
|
2278
|
+
db_index=True,
|
|
2279
|
+
verbose_name="transaction UUID",
|
|
2280
|
+
)
|
|
2281
|
+
node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
|
|
2282
|
+
user = models.ForeignKey(
|
|
2283
|
+
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
|
|
2284
|
+
)
|
|
2285
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
2286
|
+
|
|
2287
|
+
class Meta:
|
|
2288
|
+
ordering = ["-created_at"]
|
|
2289
|
+
verbose_name = "Content Sample"
|
|
2290
|
+
verbose_name_plural = "Content Samples"
|
|
2291
|
+
|
|
2292
|
+
def save(self, *args, **kwargs):
|
|
2293
|
+
if self.pk:
|
|
2294
|
+
original = type(self).all_objects.get(pk=self.pk)
|
|
2295
|
+
if original.transaction_uuid != self.transaction_uuid:
|
|
2296
|
+
raise ValidationError(
|
|
2297
|
+
{"transaction_uuid": "Cannot modify transaction UUID"}
|
|
2298
|
+
)
|
|
2299
|
+
if self.node_id is None:
|
|
2300
|
+
self.node = Node.get_local()
|
|
2301
|
+
super().save(*args, **kwargs)
|
|
2302
|
+
|
|
2303
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
2304
|
+
return str(self.name)
|
|
2305
|
+
|
|
2306
|
+
|
|
2307
|
+
class ContentClassifier(Entity):
|
|
2308
|
+
"""Configured callable that classifies :class:`ContentSample` objects."""
|
|
2309
|
+
|
|
2310
|
+
slug = models.SlugField(max_length=100, unique=True)
|
|
2311
|
+
label = models.CharField(max_length=150)
|
|
2312
|
+
kind = models.CharField(max_length=10, choices=ContentSample.KIND_CHOICES)
|
|
2313
|
+
entrypoint = models.CharField(max_length=255, help_text="Dotted path to classifier callable")
|
|
2314
|
+
run_by_default = models.BooleanField(default=True)
|
|
2315
|
+
active = models.BooleanField(default=True)
|
|
2316
|
+
|
|
2317
|
+
class Meta:
|
|
2318
|
+
ordering = ["label"]
|
|
2319
|
+
verbose_name = "Content Classifier"
|
|
2320
|
+
verbose_name_plural = "Content Classifiers"
|
|
2321
|
+
|
|
2322
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
2323
|
+
return self.label
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
|
+
class ContentTag(Entity):
|
|
2327
|
+
"""Tag that can be attached to classified content samples."""
|
|
2328
|
+
|
|
2329
|
+
slug = models.SlugField(max_length=100, unique=True)
|
|
2330
|
+
label = models.CharField(max_length=150)
|
|
2331
|
+
|
|
2332
|
+
class Meta:
|
|
2333
|
+
ordering = ["label"]
|
|
2334
|
+
verbose_name = "Content Tag"
|
|
2335
|
+
verbose_name_plural = "Content Tags"
|
|
2336
|
+
|
|
2337
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
2338
|
+
return self.label
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
class ContentClassification(Entity):
|
|
2342
|
+
"""Link between a sample, classifier, and assigned tag."""
|
|
2343
|
+
|
|
2344
|
+
sample = models.ForeignKey(
|
|
2345
|
+
ContentSample, on_delete=models.CASCADE, related_name="classifications"
|
|
2346
|
+
)
|
|
2347
|
+
classifier = models.ForeignKey(
|
|
2348
|
+
ContentClassifier, on_delete=models.CASCADE, related_name="classifications"
|
|
2349
|
+
)
|
|
2350
|
+
tag = models.ForeignKey(
|
|
2351
|
+
ContentTag, on_delete=models.CASCADE, related_name="classifications"
|
|
2352
|
+
)
|
|
2353
|
+
confidence = models.FloatField(null=True, blank=True)
|
|
2354
|
+
metadata = models.JSONField(blank=True, null=True)
|
|
2355
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
2356
|
+
|
|
2357
|
+
class Meta:
|
|
2358
|
+
unique_together = ("sample", "classifier", "tag")
|
|
2359
|
+
ordering = ["-created_at"]
|
|
2360
|
+
verbose_name = "Content Classification"
|
|
2361
|
+
verbose_name_plural = "Content Classifications"
|
|
2362
|
+
|
|
2363
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
2364
|
+
return f"{self.sample} → {self.tag}"
|
|
2365
|
+
|
|
2366
|
+
|
|
2367
|
+
UserModel = get_user_model()
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
class User(UserModel):
|
|
2371
|
+
class Meta:
|
|
2372
|
+
proxy = True
|
|
2373
|
+
app_label = "nodes"
|
|
2374
|
+
verbose_name = UserModel._meta.verbose_name
|
|
2375
|
+
verbose_name_plural = UserModel._meta.verbose_name_plural
|