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/admin.py
CHANGED
|
@@ -1,445 +1,2236 @@
|
|
|
1
|
-
from
|
|
2
|
-
from
|
|
3
|
-
|
|
4
|
-
from django
|
|
5
|
-
from django import
|
|
6
|
-
from django.contrib
|
|
7
|
-
from
|
|
8
|
-
from django.
|
|
9
|
-
from django.
|
|
10
|
-
from
|
|
11
|
-
from django.http import HttpResponse
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
from
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
from .utils import
|
|
18
|
-
from .
|
|
19
|
-
|
|
20
|
-
from
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
return
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
@admin.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
|
|
4
|
+
from django import forms
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.contrib import admin, messages
|
|
7
|
+
from django.contrib.admin import helpers
|
|
8
|
+
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
9
|
+
from django.core.exceptions import PermissionDenied
|
|
10
|
+
from django.db.models import Count
|
|
11
|
+
from django.http import Http404, HttpResponse, JsonResponse
|
|
12
|
+
from django.shortcuts import redirect, render
|
|
13
|
+
from django.template.response import TemplateResponse
|
|
14
|
+
from django.test import signals
|
|
15
|
+
from django.urls import NoReverseMatch, path, reverse
|
|
16
|
+
from django.utils import timezone
|
|
17
|
+
from django.utils.dateparse import parse_datetime
|
|
18
|
+
from django.utils.html import format_html, format_html_join
|
|
19
|
+
from django.utils.translation import gettext_lazy as _, ngettext
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from types import SimpleNamespace
|
|
22
|
+
from urllib.parse import urlsplit, urlunsplit, quote
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import subprocess
|
|
26
|
+
import uuid
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import pyperclip
|
|
30
|
+
import requests
|
|
31
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
32
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
33
|
+
from pyperclip import PyperclipException
|
|
34
|
+
from requests import RequestException
|
|
35
|
+
import websockets
|
|
36
|
+
|
|
37
|
+
from .classifiers import run_default_classifiers, suppress_default_classifiers
|
|
38
|
+
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
39
|
+
from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
|
|
40
|
+
from .reports import (
|
|
41
|
+
collect_celery_log_entries,
|
|
42
|
+
collect_scheduled_tasks,
|
|
43
|
+
iter_report_periods,
|
|
44
|
+
resolve_period,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from core.admin import EmailOutboxAdminForm
|
|
48
|
+
from .models import (
|
|
49
|
+
Node,
|
|
50
|
+
EmailOutbox,
|
|
51
|
+
NodeRole,
|
|
52
|
+
NodeFeature,
|
|
53
|
+
NodeFeatureAssignment,
|
|
54
|
+
ContentSample,
|
|
55
|
+
ContentClassifier,
|
|
56
|
+
ContentClassification,
|
|
57
|
+
ContentTag,
|
|
58
|
+
NetMessage,
|
|
59
|
+
NodeManager,
|
|
60
|
+
DNSRecord,
|
|
61
|
+
)
|
|
62
|
+
from . import dns as dns_utils
|
|
63
|
+
from core.models import RFID
|
|
64
|
+
from ocpp.models import Charger
|
|
65
|
+
from ocpp.network import serialize_charger_for_network
|
|
66
|
+
from ocpp.tasks import push_forwarded_charge_points
|
|
67
|
+
from core.user_data import EntityModelAdmin
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NodeAdminForm(forms.ModelForm):
|
|
71
|
+
class Meta:
|
|
72
|
+
model = Node
|
|
73
|
+
exclude = ("badge_color", "features")
|
|
74
|
+
|
|
75
|
+
def __init__(self, *args, **kwargs):
|
|
76
|
+
super().__init__(*args, **kwargs)
|
|
77
|
+
enable_public = self.fields.get("enable_public_api")
|
|
78
|
+
if enable_public:
|
|
79
|
+
enable_public.label = _("Enable public admin access")
|
|
80
|
+
enable_public.help_text = _(
|
|
81
|
+
"Expose the admin API through this node's public endpoint. "
|
|
82
|
+
"Only enable when trusted peers require administrative access."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class NodeFeatureAssignmentInline(admin.TabularInline):
|
|
87
|
+
model = NodeFeatureAssignment
|
|
88
|
+
extra = 0
|
|
89
|
+
autocomplete_fields = ("feature",)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DeployDNSRecordsForm(forms.Form):
|
|
93
|
+
manager = forms.ModelChoiceField(
|
|
94
|
+
label="Node Profile",
|
|
95
|
+
queryset=NodeManager.objects.none(),
|
|
96
|
+
help_text="Credentials used to authenticate with the DNS provider.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def __init__(self, *args, **kwargs):
|
|
100
|
+
super().__init__(*args, **kwargs)
|
|
101
|
+
self.fields["manager"].queryset = NodeManager.objects.filter(
|
|
102
|
+
provider=NodeManager.Provider.GODADDY, is_enabled=True
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@admin.register(NodeManager)
|
|
107
|
+
class NodeManagerAdmin(EntityModelAdmin):
|
|
108
|
+
list_display = ("__str__", "provider", "is_enabled", "default_domain")
|
|
109
|
+
list_filter = ("provider", "is_enabled")
|
|
110
|
+
search_fields = (
|
|
111
|
+
"default_domain",
|
|
112
|
+
"user__username",
|
|
113
|
+
"group__name",
|
|
114
|
+
)
|
|
115
|
+
fieldsets = (
|
|
116
|
+
(_("Owner"), {"fields": ("user", "group")}),
|
|
117
|
+
(
|
|
118
|
+
_("Credentials"),
|
|
119
|
+
{"fields": ("api_key", "api_secret", "customer_id")},
|
|
120
|
+
),
|
|
121
|
+
(
|
|
122
|
+
_("Configuration"),
|
|
123
|
+
{
|
|
124
|
+
"fields": (
|
|
125
|
+
"provider",
|
|
126
|
+
"default_domain",
|
|
127
|
+
"use_sandbox",
|
|
128
|
+
"is_enabled",
|
|
129
|
+
)
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@admin.register(DNSRecord)
|
|
136
|
+
class DNSRecordAdmin(EntityModelAdmin):
|
|
137
|
+
list_display = (
|
|
138
|
+
"record_type",
|
|
139
|
+
"fqdn",
|
|
140
|
+
"data",
|
|
141
|
+
"ttl",
|
|
142
|
+
"node_manager",
|
|
143
|
+
"last_synced_at",
|
|
144
|
+
"last_verified_at",
|
|
145
|
+
)
|
|
146
|
+
list_filter = ("record_type", "provider", "node_manager")
|
|
147
|
+
search_fields = ("domain", "name", "data")
|
|
148
|
+
autocomplete_fields = ("node_manager",)
|
|
149
|
+
actions = ["deploy_selected_records", "validate_selected_records"]
|
|
150
|
+
|
|
151
|
+
def _default_manager_for_queryset(self, queryset):
|
|
152
|
+
manager_ids = list(
|
|
153
|
+
queryset.exclude(node_manager__isnull=True)
|
|
154
|
+
.values_list("node_manager_id", flat=True)
|
|
155
|
+
.distinct()
|
|
156
|
+
)
|
|
157
|
+
if len(manager_ids) == 1:
|
|
158
|
+
return manager_ids[0]
|
|
159
|
+
available = list(
|
|
160
|
+
NodeManager.objects.filter(
|
|
161
|
+
provider=NodeManager.Provider.GODADDY, is_enabled=True
|
|
162
|
+
).values_list("pk", flat=True)
|
|
163
|
+
)
|
|
164
|
+
if len(available) == 1:
|
|
165
|
+
return available[0]
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
@admin.action(description="Deploy Selected records")
|
|
169
|
+
def deploy_selected_records(self, request, queryset):
|
|
170
|
+
unsupported = queryset.exclude(provider=DNSRecord.Provider.GODADDY)
|
|
171
|
+
for record in unsupported:
|
|
172
|
+
self.message_user(
|
|
173
|
+
request,
|
|
174
|
+
f"{record} uses unsupported provider {record.get_provider_display()}",
|
|
175
|
+
messages.WARNING,
|
|
176
|
+
)
|
|
177
|
+
queryset = queryset.filter(provider=DNSRecord.Provider.GODADDY)
|
|
178
|
+
if not queryset:
|
|
179
|
+
self.message_user(request, "No GoDaddy records selected.", messages.WARNING)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
if "apply" in request.POST:
|
|
183
|
+
form = DeployDNSRecordsForm(request.POST)
|
|
184
|
+
if form.is_valid():
|
|
185
|
+
manager = form.cleaned_data["manager"]
|
|
186
|
+
result = manager.publish_dns_records(list(queryset))
|
|
187
|
+
for record, reason in result.skipped.items():
|
|
188
|
+
self.message_user(request, f"{record}: {reason}", messages.WARNING)
|
|
189
|
+
for record, reason in result.failures.items():
|
|
190
|
+
self.message_user(request, f"{record}: {reason}", messages.ERROR)
|
|
191
|
+
if result.deployed:
|
|
192
|
+
self.message_user(
|
|
193
|
+
request,
|
|
194
|
+
f"Deployed {len(result.deployed)} DNS record(s) via {manager}.",
|
|
195
|
+
messages.SUCCESS,
|
|
196
|
+
)
|
|
197
|
+
return None
|
|
198
|
+
else:
|
|
199
|
+
initial_manager = self._default_manager_for_queryset(queryset)
|
|
200
|
+
form = DeployDNSRecordsForm(initial={"manager": initial_manager})
|
|
201
|
+
|
|
202
|
+
context = {
|
|
203
|
+
**self.admin_site.each_context(request),
|
|
204
|
+
"opts": self.model._meta,
|
|
205
|
+
"form": form,
|
|
206
|
+
"queryset": queryset,
|
|
207
|
+
"title": "Deploy DNS records",
|
|
208
|
+
}
|
|
209
|
+
return render(
|
|
210
|
+
request,
|
|
211
|
+
"admin/nodes/dnsrecord/deploy_records.html",
|
|
212
|
+
context,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
@admin.action(description="Validate Selected records")
|
|
216
|
+
def validate_selected_records(self, request, queryset):
|
|
217
|
+
resolver = dns_utils.create_resolver()
|
|
218
|
+
successes = 0
|
|
219
|
+
for record in queryset:
|
|
220
|
+
ok, message = dns_utils.validate_record(record, resolver=resolver)
|
|
221
|
+
if ok:
|
|
222
|
+
successes += 1
|
|
223
|
+
else:
|
|
224
|
+
self.message_user(request, f"{record}: {message}", messages.WARNING)
|
|
225
|
+
if successes:
|
|
226
|
+
self.message_user(
|
|
227
|
+
request,
|
|
228
|
+
f"Validated {successes} DNS record(s).",
|
|
229
|
+
messages.SUCCESS,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@admin.register(Node)
|
|
234
|
+
class NodeAdmin(EntityModelAdmin):
|
|
235
|
+
list_display = (
|
|
236
|
+
"hostname",
|
|
237
|
+
"primary_ip",
|
|
238
|
+
"port",
|
|
239
|
+
"role",
|
|
240
|
+
"relation",
|
|
241
|
+
"last_seen",
|
|
242
|
+
"visit_link",
|
|
243
|
+
)
|
|
244
|
+
search_fields = ("hostname", "network_hostname", "address", "mac_address")
|
|
245
|
+
change_list_template = "admin/nodes/node/change_list.html"
|
|
246
|
+
change_form_template = "admin/nodes/node/change_form.html"
|
|
247
|
+
form = NodeAdminForm
|
|
248
|
+
fieldsets = (
|
|
249
|
+
(
|
|
250
|
+
_("Network"),
|
|
251
|
+
{
|
|
252
|
+
"fields": (
|
|
253
|
+
"hostname",
|
|
254
|
+
"network_hostname",
|
|
255
|
+
"ipv4_address",
|
|
256
|
+
"ipv6_address",
|
|
257
|
+
"address",
|
|
258
|
+
"mac_address",
|
|
259
|
+
"port",
|
|
260
|
+
"message_queue_length",
|
|
261
|
+
"current_relation",
|
|
262
|
+
)
|
|
263
|
+
},
|
|
264
|
+
),
|
|
265
|
+
(_("Role"), {"fields": ("role",)}),
|
|
266
|
+
(
|
|
267
|
+
_("Public endpoint"),
|
|
268
|
+
{
|
|
269
|
+
"fields": (
|
|
270
|
+
"public_endpoint",
|
|
271
|
+
"public_key",
|
|
272
|
+
)
|
|
273
|
+
},
|
|
274
|
+
),
|
|
275
|
+
(
|
|
276
|
+
_("Installation"),
|
|
277
|
+
{
|
|
278
|
+
"fields": (
|
|
279
|
+
"base_path",
|
|
280
|
+
"installed_version",
|
|
281
|
+
"installed_revision",
|
|
282
|
+
)
|
|
283
|
+
},
|
|
284
|
+
),
|
|
285
|
+
(
|
|
286
|
+
_("Public admin"),
|
|
287
|
+
{"fields": ("enable_public_api",)},
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
actions = [
|
|
291
|
+
"update_selected_nodes",
|
|
292
|
+
"register_visitor",
|
|
293
|
+
"run_task",
|
|
294
|
+
"take_screenshots",
|
|
295
|
+
"start_charge_point_forwarding",
|
|
296
|
+
"stop_charge_point_forwarding",
|
|
297
|
+
"import_rfids_from_selected",
|
|
298
|
+
"export_rfids_to_selected",
|
|
299
|
+
"send_net_message",
|
|
300
|
+
]
|
|
301
|
+
inlines = [NodeFeatureAssignmentInline]
|
|
302
|
+
|
|
303
|
+
class SendNetMessageForm(forms.Form):
|
|
304
|
+
subject = forms.CharField(
|
|
305
|
+
label=_("Subject"),
|
|
306
|
+
max_length=NetMessage._meta.get_field("subject").max_length,
|
|
307
|
+
required=False,
|
|
308
|
+
)
|
|
309
|
+
body = forms.CharField(
|
|
310
|
+
label=_("Body"),
|
|
311
|
+
max_length=NetMessage._meta.get_field("body").max_length,
|
|
312
|
+
required=False,
|
|
313
|
+
widget=forms.Textarea(attrs={"rows": 4}),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def clean(self):
|
|
317
|
+
cleaned = super().clean()
|
|
318
|
+
subject = (cleaned.get("subject") or "").strip()
|
|
319
|
+
body = (cleaned.get("body") or "").strip()
|
|
320
|
+
if not subject and not body:
|
|
321
|
+
raise forms.ValidationError(
|
|
322
|
+
_("Enter a subject or body to send.")
|
|
323
|
+
)
|
|
324
|
+
cleaned["subject"] = subject
|
|
325
|
+
cleaned["body"] = body
|
|
326
|
+
return cleaned
|
|
327
|
+
|
|
328
|
+
@admin.display(description=_("Relation"), ordering="current_relation")
|
|
329
|
+
def relation(self, obj):
|
|
330
|
+
return obj.get_current_relation_display()
|
|
331
|
+
|
|
332
|
+
@admin.display(description=_("IP Address"), ordering="address")
|
|
333
|
+
def primary_ip(self, obj):
|
|
334
|
+
if not obj:
|
|
335
|
+
return ""
|
|
336
|
+
return obj.get_best_ip() or ""
|
|
337
|
+
|
|
338
|
+
@admin.display(description=_("Visit"))
|
|
339
|
+
def visit_link(self, obj):
|
|
340
|
+
if not obj:
|
|
341
|
+
return ""
|
|
342
|
+
if obj.is_local:
|
|
343
|
+
try:
|
|
344
|
+
url = reverse("admin:index")
|
|
345
|
+
except NoReverseMatch:
|
|
346
|
+
return ""
|
|
347
|
+
return format_html(
|
|
348
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
349
|
+
url,
|
|
350
|
+
_("Visit"),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
host_values = obj.get_remote_host_candidates()
|
|
354
|
+
|
|
355
|
+
remote_url = ""
|
|
356
|
+
for host in host_values:
|
|
357
|
+
temp_node = SimpleNamespace(
|
|
358
|
+
public_endpoint=host,
|
|
359
|
+
address="",
|
|
360
|
+
hostname="",
|
|
361
|
+
port=obj.port,
|
|
362
|
+
)
|
|
363
|
+
remote_url = next(self._iter_remote_urls(temp_node, "/admin/"), "")
|
|
364
|
+
if remote_url:
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
if not remote_url:
|
|
368
|
+
return ""
|
|
369
|
+
|
|
370
|
+
return format_html(
|
|
371
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
372
|
+
remote_url,
|
|
373
|
+
_("Visit"),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def get_urls(self):
|
|
377
|
+
urls = super().get_urls()
|
|
378
|
+
custom = [
|
|
379
|
+
path(
|
|
380
|
+
"register-current/",
|
|
381
|
+
self.admin_site.admin_view(self.register_current),
|
|
382
|
+
name="nodes_node_register_current",
|
|
383
|
+
),
|
|
384
|
+
path(
|
|
385
|
+
"register-visitor/",
|
|
386
|
+
self.admin_site.admin_view(self.register_visitor_view),
|
|
387
|
+
name="nodes_node_register_visitor",
|
|
388
|
+
),
|
|
389
|
+
path(
|
|
390
|
+
"<int:node_id>/public-key/",
|
|
391
|
+
self.admin_site.admin_view(self.public_key),
|
|
392
|
+
name="nodes_node_public_key",
|
|
393
|
+
),
|
|
394
|
+
path(
|
|
395
|
+
"update-selected/progress/",
|
|
396
|
+
self.admin_site.admin_view(self.update_selected_progress),
|
|
397
|
+
name="nodes_node_update_selected_progress",
|
|
398
|
+
),
|
|
399
|
+
]
|
|
400
|
+
return custom + urls
|
|
401
|
+
|
|
402
|
+
def register_current(self, request):
|
|
403
|
+
"""Create or update this host and offer browser node registration."""
|
|
404
|
+
if not request.user.is_superuser:
|
|
405
|
+
raise PermissionDenied
|
|
406
|
+
node, created = Node.register_current()
|
|
407
|
+
if created:
|
|
408
|
+
self.message_user(
|
|
409
|
+
request, f"Current host registered as {node}", messages.SUCCESS
|
|
410
|
+
)
|
|
411
|
+
token = uuid.uuid4().hex
|
|
412
|
+
context = {
|
|
413
|
+
"token": token,
|
|
414
|
+
"register_url": reverse("register-node"),
|
|
415
|
+
}
|
|
416
|
+
response = TemplateResponse(
|
|
417
|
+
request, "admin/nodes/node/register_remote.html", context
|
|
418
|
+
)
|
|
419
|
+
response.render()
|
|
420
|
+
template = response.resolve_template(response.template_name)
|
|
421
|
+
if getattr(template, "name", None) in (None, ""):
|
|
422
|
+
template.name = response.template_name
|
|
423
|
+
signals.template_rendered.send(
|
|
424
|
+
sender=template.__class__,
|
|
425
|
+
template=template,
|
|
426
|
+
context=response.context_data,
|
|
427
|
+
request=request,
|
|
428
|
+
)
|
|
429
|
+
return response
|
|
430
|
+
|
|
431
|
+
@admin.action(description="Register Visitor")
|
|
432
|
+
def register_visitor(self, request, queryset=None):
|
|
433
|
+
return self.register_visitor_view(request)
|
|
434
|
+
|
|
435
|
+
@admin.action(description=_("Update selected nodes"))
|
|
436
|
+
def update_selected_nodes(self, request, queryset):
|
|
437
|
+
node_ids = list(queryset.values_list("pk", flat=True))
|
|
438
|
+
if not node_ids:
|
|
439
|
+
self.message_user(request, _("No nodes selected."), messages.INFO)
|
|
440
|
+
return None
|
|
441
|
+
context = {
|
|
442
|
+
**self.admin_site.each_context(request),
|
|
443
|
+
"opts": self.model._meta,
|
|
444
|
+
"title": _("Update selected nodes"),
|
|
445
|
+
"nodes": list(queryset),
|
|
446
|
+
"node_ids": node_ids,
|
|
447
|
+
"progress_url": reverse("admin:nodes_node_update_selected_progress"),
|
|
448
|
+
}
|
|
449
|
+
return TemplateResponse(
|
|
450
|
+
request, "admin/nodes/node/update_selected.html", context
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
@admin.action(description=_("Send Net Message"))
|
|
454
|
+
def send_net_message(self, request, queryset):
|
|
455
|
+
is_submit = "apply" in request.POST
|
|
456
|
+
form = self.SendNetMessageForm(request.POST if is_submit else None)
|
|
457
|
+
selected_ids = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
|
|
458
|
+
if not selected_ids:
|
|
459
|
+
selected_ids = [str(pk) for pk in queryset.values_list("pk", flat=True)]
|
|
460
|
+
nodes: list[Node] = []
|
|
461
|
+
cleaned_ids: list[int] = []
|
|
462
|
+
for value in selected_ids:
|
|
463
|
+
try:
|
|
464
|
+
cleaned_ids.append(int(value))
|
|
465
|
+
except (TypeError, ValueError):
|
|
466
|
+
continue
|
|
467
|
+
if cleaned_ids:
|
|
468
|
+
base_queryset = self.get_queryset(request).filter(pk__in=cleaned_ids)
|
|
469
|
+
nodes_by_pk = {str(node.pk): node for node in base_queryset}
|
|
470
|
+
nodes = [nodes_by_pk[value] for value in selected_ids if value in nodes_by_pk]
|
|
471
|
+
if not nodes:
|
|
472
|
+
nodes = list(queryset)
|
|
473
|
+
selected_ids = [str(node.pk) for node in nodes]
|
|
474
|
+
if not nodes:
|
|
475
|
+
self.message_user(request, _("No nodes selected."), messages.INFO)
|
|
476
|
+
return None
|
|
477
|
+
if is_submit and form.is_valid():
|
|
478
|
+
subject = form.cleaned_data["subject"]
|
|
479
|
+
body = form.cleaned_data["body"]
|
|
480
|
+
created = 0
|
|
481
|
+
for node in nodes:
|
|
482
|
+
message = NetMessage.objects.create(
|
|
483
|
+
subject=subject,
|
|
484
|
+
body=body,
|
|
485
|
+
filter_node=node,
|
|
486
|
+
)
|
|
487
|
+
message.propagate()
|
|
488
|
+
created += 1
|
|
489
|
+
if created:
|
|
490
|
+
success_message = ngettext(
|
|
491
|
+
"Sent %(count)d net message.",
|
|
492
|
+
"Sent %(count)d net messages.",
|
|
493
|
+
created,
|
|
494
|
+
) % {"count": created}
|
|
495
|
+
self.message_user(request, success_message, messages.SUCCESS)
|
|
496
|
+
else:
|
|
497
|
+
self.message_user(
|
|
498
|
+
request, _("No net messages were sent."), messages.INFO
|
|
499
|
+
)
|
|
500
|
+
return None
|
|
501
|
+
context = {
|
|
502
|
+
**self.admin_site.each_context(request),
|
|
503
|
+
"opts": self.model._meta,
|
|
504
|
+
"title": _("Send Net Message"),
|
|
505
|
+
"nodes": nodes,
|
|
506
|
+
"selected_ids": selected_ids,
|
|
507
|
+
"action_name": request.POST.get("action", "send_net_message"),
|
|
508
|
+
"select_across": request.POST.get("select_across", "0"),
|
|
509
|
+
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
|
|
510
|
+
"adminform": helpers.AdminForm(
|
|
511
|
+
form,
|
|
512
|
+
[(None, {"fields": ("subject", "body")})],
|
|
513
|
+
{},
|
|
514
|
+
),
|
|
515
|
+
"form": form,
|
|
516
|
+
"media": self.media + form.media,
|
|
517
|
+
}
|
|
518
|
+
return TemplateResponse(
|
|
519
|
+
request, "admin/nodes/node/send_net_message.html", context
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def update_selected_progress(self, request):
|
|
523
|
+
if request.method != "POST":
|
|
524
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
525
|
+
if not self.has_change_permission(request):
|
|
526
|
+
raise PermissionDenied
|
|
527
|
+
try:
|
|
528
|
+
node_id = int(request.POST.get("node_id", ""))
|
|
529
|
+
except (TypeError, ValueError):
|
|
530
|
+
return JsonResponse({"detail": "Invalid node id"}, status=400)
|
|
531
|
+
node = self.get_queryset(request).filter(pk=node_id).first()
|
|
532
|
+
if not node:
|
|
533
|
+
return JsonResponse({"detail": "Node not found"}, status=404)
|
|
534
|
+
|
|
535
|
+
local_result = self._refresh_local_information(node)
|
|
536
|
+
remote_result = self._push_remote_information(node)
|
|
537
|
+
|
|
538
|
+
status = "success"
|
|
539
|
+
if not local_result.get("ok") and not remote_result.get("ok"):
|
|
540
|
+
status = "error"
|
|
541
|
+
elif not local_result.get("ok") or not remote_result.get("ok"):
|
|
542
|
+
status = "partial"
|
|
543
|
+
|
|
544
|
+
return JsonResponse(
|
|
545
|
+
{
|
|
546
|
+
"node": str(node),
|
|
547
|
+
"status": status,
|
|
548
|
+
"local": local_result,
|
|
549
|
+
"remote": remote_result,
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
def _refresh_local_information(self, node):
|
|
554
|
+
if node.is_local:
|
|
555
|
+
try:
|
|
556
|
+
_, created = Node.register_current()
|
|
557
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
558
|
+
return {"ok": False, "message": str(exc)}
|
|
559
|
+
return {
|
|
560
|
+
"ok": True,
|
|
561
|
+
"created": created,
|
|
562
|
+
"message": "Local node registration refreshed.",
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
last_error = ""
|
|
566
|
+
host_candidates = node.get_remote_host_candidates()
|
|
567
|
+
for url in self._iter_remote_urls(node, "/nodes/info/"):
|
|
568
|
+
try:
|
|
569
|
+
response = requests.get(url, timeout=5)
|
|
570
|
+
except RequestException as exc:
|
|
571
|
+
last_error = str(exc)
|
|
572
|
+
continue
|
|
573
|
+
if not response.ok:
|
|
574
|
+
last_error = f"{response.status_code} {response.reason}"
|
|
575
|
+
continue
|
|
576
|
+
try:
|
|
577
|
+
payload = response.json()
|
|
578
|
+
except ValueError:
|
|
579
|
+
last_error = "Invalid JSON response"
|
|
580
|
+
continue
|
|
581
|
+
updated = self._apply_remote_node_info(node, payload)
|
|
582
|
+
message = (
|
|
583
|
+
"Remote information applied."
|
|
584
|
+
if updated
|
|
585
|
+
else "Remote information fetched (no changes)."
|
|
586
|
+
)
|
|
587
|
+
return {
|
|
588
|
+
"ok": True,
|
|
589
|
+
"url": url,
|
|
590
|
+
"updated_fields": updated,
|
|
591
|
+
"message": message,
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
"ok": False,
|
|
595
|
+
"message": self._build_connectivity_hint(last_error, host_candidates),
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
def _apply_remote_node_info(self, node, payload):
|
|
599
|
+
changed = []
|
|
600
|
+
field_map = {
|
|
601
|
+
"hostname": payload.get("hostname"),
|
|
602
|
+
"network_hostname": payload.get("network_hostname"),
|
|
603
|
+
"address": payload.get("address"),
|
|
604
|
+
"ipv4_address": payload.get("ipv4_address"),
|
|
605
|
+
"ipv6_address": payload.get("ipv6_address"),
|
|
606
|
+
"public_key": payload.get("public_key"),
|
|
607
|
+
}
|
|
608
|
+
port_value = payload.get("port")
|
|
609
|
+
if port_value is not None:
|
|
610
|
+
try:
|
|
611
|
+
port_value = int(port_value)
|
|
612
|
+
except (TypeError, ValueError):
|
|
613
|
+
port_value = None
|
|
614
|
+
field_map["port"] = port_value
|
|
615
|
+
mac_address = payload.get("mac_address")
|
|
616
|
+
if mac_address:
|
|
617
|
+
field_map["mac_address"] = str(mac_address).lower()
|
|
618
|
+
|
|
619
|
+
for field, value in field_map.items():
|
|
620
|
+
if value is None:
|
|
621
|
+
continue
|
|
622
|
+
if getattr(node, field) != value:
|
|
623
|
+
setattr(node, field, value)
|
|
624
|
+
changed.append(field)
|
|
625
|
+
|
|
626
|
+
role_value = payload.get("role") or payload.get("role_name")
|
|
627
|
+
if role_value is not None:
|
|
628
|
+
role_name = str(role_value).strip()
|
|
629
|
+
if role_name:
|
|
630
|
+
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
631
|
+
else:
|
|
632
|
+
desired_role = None
|
|
633
|
+
if desired_role and node.role_id != desired_role.id:
|
|
634
|
+
node.role = desired_role
|
|
635
|
+
changed.append("role")
|
|
636
|
+
|
|
637
|
+
node.last_seen = timezone.now()
|
|
638
|
+
if "last_seen" not in changed:
|
|
639
|
+
changed.append("last_seen")
|
|
640
|
+
node.save(update_fields=changed)
|
|
641
|
+
return changed
|
|
642
|
+
|
|
643
|
+
def _push_remote_information(self, node):
|
|
644
|
+
if node.is_local:
|
|
645
|
+
return {
|
|
646
|
+
"ok": True,
|
|
647
|
+
"message": "Local node does not require remote update.",
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
local_node = Node.get_local()
|
|
651
|
+
if local_node is None:
|
|
652
|
+
try:
|
|
653
|
+
local_node, _ = Node.register_current()
|
|
654
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
655
|
+
return {"ok": False, "message": str(exc)}
|
|
656
|
+
|
|
657
|
+
security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
|
|
658
|
+
priv_path = security_dir / f"{local_node.public_endpoint}"
|
|
659
|
+
if not priv_path.exists():
|
|
660
|
+
return {
|
|
661
|
+
"ok": False,
|
|
662
|
+
"message": "Local node private key not found.",
|
|
663
|
+
}
|
|
664
|
+
try:
|
|
665
|
+
private_key = serialization.load_pem_private_key(
|
|
666
|
+
priv_path.read_bytes(), password=None
|
|
667
|
+
)
|
|
668
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
669
|
+
return {"ok": False, "message": f"Failed to load private key: {exc}"}
|
|
670
|
+
|
|
671
|
+
token = uuid.uuid4().hex
|
|
672
|
+
try:
|
|
673
|
+
signature = private_key.sign(
|
|
674
|
+
token.encode(),
|
|
675
|
+
padding.PKCS1v15(),
|
|
676
|
+
hashes.SHA256(),
|
|
677
|
+
)
|
|
678
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
679
|
+
return {"ok": False, "message": f"Failed to sign payload: {exc}"}
|
|
680
|
+
|
|
681
|
+
payload = {
|
|
682
|
+
"hostname": local_node.hostname,
|
|
683
|
+
"network_hostname": local_node.network_hostname,
|
|
684
|
+
"address": local_node.address,
|
|
685
|
+
"ipv4_address": local_node.ipv4_address,
|
|
686
|
+
"ipv6_address": local_node.ipv6_address,
|
|
687
|
+
"port": local_node.port,
|
|
688
|
+
"mac_address": local_node.mac_address,
|
|
689
|
+
"public_key": local_node.public_key,
|
|
690
|
+
"token": token,
|
|
691
|
+
"signature": base64.b64encode(signature).decode(),
|
|
692
|
+
}
|
|
693
|
+
if local_node.installed_version:
|
|
694
|
+
payload["installed_version"] = local_node.installed_version
|
|
695
|
+
if local_node.installed_revision:
|
|
696
|
+
payload["installed_revision"] = local_node.installed_revision
|
|
697
|
+
|
|
698
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
699
|
+
headers = {"Content-Type": "application/json"}
|
|
700
|
+
|
|
701
|
+
last_error = ""
|
|
702
|
+
host_candidates = node.get_remote_host_candidates()
|
|
703
|
+
for url in self._iter_remote_urls(node, "/nodes/register/"):
|
|
704
|
+
try:
|
|
705
|
+
response = requests.post(
|
|
706
|
+
url,
|
|
707
|
+
data=payload_json,
|
|
708
|
+
headers=headers,
|
|
709
|
+
timeout=5,
|
|
710
|
+
)
|
|
711
|
+
except RequestException as exc:
|
|
712
|
+
last_error = str(exc)
|
|
713
|
+
continue
|
|
714
|
+
if response.ok:
|
|
715
|
+
return {"ok": True, "url": url, "message": "Remote updated."}
|
|
716
|
+
last_error = f"{response.status_code} {response.text}"
|
|
717
|
+
return {
|
|
718
|
+
"ok": False,
|
|
719
|
+
"message": self._build_connectivity_hint(last_error, host_candidates),
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
def _build_connectivity_hint(self, last_error: str, hosts: list[str]) -> str:
|
|
723
|
+
base_message = last_error or _("Unable to reach remote node.")
|
|
724
|
+
if hosts:
|
|
725
|
+
host_text = ", ".join(hosts)
|
|
726
|
+
return _("%(message)s Tried hosts: %(hosts)s.") % {
|
|
727
|
+
"message": base_message,
|
|
728
|
+
"hosts": host_text,
|
|
729
|
+
}
|
|
730
|
+
return _("%(message)s No remote hosts were available for contact.") % {
|
|
731
|
+
"message": base_message
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
def _primary_remote_url(self, node, path: str) -> str:
|
|
735
|
+
return next(self._iter_remote_urls(node, path), "")
|
|
736
|
+
|
|
737
|
+
def _request_remote(self, node, path: str, request_callable):
|
|
738
|
+
errors: list[str] = []
|
|
739
|
+
for url in self._iter_remote_urls(node, path):
|
|
740
|
+
try:
|
|
741
|
+
response = request_callable(url)
|
|
742
|
+
except RequestException as exc:
|
|
743
|
+
errors.append(f"{url}: {exc}")
|
|
744
|
+
continue
|
|
745
|
+
return url, response, errors
|
|
746
|
+
return "", None, errors
|
|
747
|
+
|
|
748
|
+
def _iter_remote_urls(self, node, path):
|
|
749
|
+
if hasattr(node, "iter_remote_urls"):
|
|
750
|
+
yield from node.iter_remote_urls(path)
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
temp = Node(
|
|
754
|
+
public_endpoint=getattr(node, "public_endpoint", ""),
|
|
755
|
+
address=getattr(node, "address", ""),
|
|
756
|
+
hostname=getattr(node, "hostname", ""),
|
|
757
|
+
port=getattr(node, "port", None),
|
|
758
|
+
)
|
|
759
|
+
temp.network_hostname = getattr(node, "network_hostname", "")
|
|
760
|
+
temp.ipv4_address = getattr(node, "ipv4_address", "")
|
|
761
|
+
temp.ipv6_address = getattr(node, "ipv6_address", "")
|
|
762
|
+
yield from temp.iter_remote_urls(path)
|
|
763
|
+
|
|
764
|
+
def register_visitor_view(self, request):
|
|
765
|
+
"""Exchange registration data with the visiting node."""
|
|
766
|
+
|
|
767
|
+
node, created = Node.register_current()
|
|
768
|
+
if created:
|
|
769
|
+
self.message_user(
|
|
770
|
+
request, f"Current host registered as {node}", messages.SUCCESS
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
token = uuid.uuid4().hex
|
|
774
|
+
context = {
|
|
775
|
+
**self.admin_site.each_context(request),
|
|
776
|
+
"opts": self.model._meta,
|
|
777
|
+
"title": _("Register Visitor"),
|
|
778
|
+
"token": token,
|
|
779
|
+
"info_url": reverse("node-info"),
|
|
780
|
+
"register_url": reverse("register-node"),
|
|
781
|
+
"visitor_info_url": "http://localhost:8000/nodes/info/",
|
|
782
|
+
"visitor_register_url": "http://localhost:8000/nodes/register/",
|
|
783
|
+
}
|
|
784
|
+
return render(request, "admin/nodes/node/register_visitor.html", context)
|
|
785
|
+
|
|
786
|
+
def public_key(self, request, node_id):
|
|
787
|
+
node = self.get_object(request, node_id)
|
|
788
|
+
if not node:
|
|
789
|
+
self.message_user(request, "Unknown node", messages.ERROR)
|
|
790
|
+
return redirect("..")
|
|
791
|
+
security_dir = Path(settings.BASE_DIR) / "security"
|
|
792
|
+
pub_path = security_dir / f"{node.public_endpoint}.pub"
|
|
793
|
+
if pub_path.exists():
|
|
794
|
+
response = HttpResponse(pub_path.read_bytes(), content_type="text/plain")
|
|
795
|
+
response["Content-Disposition"] = f'attachment; filename="{pub_path.name}"'
|
|
796
|
+
return response
|
|
797
|
+
self.message_user(request, "Public key not found", messages.ERROR)
|
|
798
|
+
return redirect("..")
|
|
799
|
+
|
|
800
|
+
def run_task(self, request, queryset):
|
|
801
|
+
if "apply" in request.POST:
|
|
802
|
+
recipe_text = request.POST.get("recipe", "")
|
|
803
|
+
results = []
|
|
804
|
+
for node in queryset:
|
|
805
|
+
try:
|
|
806
|
+
if not node.is_local:
|
|
807
|
+
raise NotImplementedError(
|
|
808
|
+
"Remote node execution is not implemented"
|
|
809
|
+
)
|
|
810
|
+
command = ["/bin/sh", "-c", recipe_text]
|
|
811
|
+
result = subprocess.run(
|
|
812
|
+
command,
|
|
813
|
+
check=False,
|
|
814
|
+
capture_output=True,
|
|
815
|
+
text=True,
|
|
816
|
+
)
|
|
817
|
+
output = result.stdout + result.stderr
|
|
818
|
+
except Exception as exc:
|
|
819
|
+
output = str(exc)
|
|
820
|
+
results.append((node, output))
|
|
821
|
+
context = {"recipe": recipe_text, "results": results}
|
|
822
|
+
return render(request, "admin/nodes/task_result.html", context)
|
|
823
|
+
context = {"nodes": queryset}
|
|
824
|
+
return render(request, "admin/nodes/node/run_task.html", context)
|
|
825
|
+
|
|
826
|
+
run_task.short_description = "Run task"
|
|
827
|
+
|
|
828
|
+
@admin.action(description="Take Screenshots")
|
|
829
|
+
def take_screenshots(self, request, queryset):
|
|
830
|
+
tx = uuid.uuid4()
|
|
831
|
+
sources = getattr(settings, "SCREENSHOT_SOURCES", ["/"])
|
|
832
|
+
count = 0
|
|
833
|
+
for node in queryset:
|
|
834
|
+
for source in sources:
|
|
835
|
+
try:
|
|
836
|
+
contact_host = node.get_primary_contact()
|
|
837
|
+
url = source.format(
|
|
838
|
+
node=node, address=contact_host, port=node.port
|
|
839
|
+
)
|
|
840
|
+
except Exception:
|
|
841
|
+
url = source
|
|
842
|
+
if not url.startswith("http"):
|
|
843
|
+
candidate = next(
|
|
844
|
+
self._iter_remote_urls(node, url),
|
|
845
|
+
"",
|
|
846
|
+
)
|
|
847
|
+
if not candidate:
|
|
848
|
+
self.message_user(
|
|
849
|
+
request,
|
|
850
|
+
_(
|
|
851
|
+
"No reachable host was available for %(node)s while generating %(path)s"
|
|
852
|
+
)
|
|
853
|
+
% {"node": node, "path": url},
|
|
854
|
+
messages.WARNING,
|
|
855
|
+
)
|
|
856
|
+
continue
|
|
857
|
+
url = candidate
|
|
858
|
+
try:
|
|
859
|
+
path = capture_screenshot(url)
|
|
860
|
+
except Exception as exc: # pragma: no cover - selenium issues
|
|
861
|
+
self.message_user(request, f"{node}: {exc}", messages.ERROR)
|
|
862
|
+
continue
|
|
863
|
+
sample = save_screenshot(
|
|
864
|
+
path, node=node, method="ADMIN", transaction_uuid=tx
|
|
865
|
+
)
|
|
866
|
+
if sample:
|
|
867
|
+
count += 1
|
|
868
|
+
self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
|
|
869
|
+
|
|
870
|
+
def _init_rfid_result(self, node):
|
|
871
|
+
return {
|
|
872
|
+
"node": node,
|
|
873
|
+
"status": "success",
|
|
874
|
+
"created": 0,
|
|
875
|
+
"updated": 0,
|
|
876
|
+
"linked_accounts": 0,
|
|
877
|
+
"missing_accounts": [],
|
|
878
|
+
"errors": [],
|
|
879
|
+
"processed": 0,
|
|
880
|
+
"message": None,
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
def _skip_result(self, node, message):
|
|
884
|
+
result = self._init_rfid_result(node)
|
|
885
|
+
result["status"] = "skipped"
|
|
886
|
+
result["message"] = message
|
|
887
|
+
return result
|
|
888
|
+
|
|
889
|
+
def _load_local_node_credentials(self):
|
|
890
|
+
local_node = Node.get_local()
|
|
891
|
+
if not local_node:
|
|
892
|
+
return None, None, _("Local node is not registered.")
|
|
893
|
+
|
|
894
|
+
endpoint = (local_node.public_endpoint or "").strip()
|
|
895
|
+
if not endpoint:
|
|
896
|
+
return local_node, None, _(
|
|
897
|
+
"Local node public endpoint is not configured."
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
|
|
901
|
+
priv_path = security_dir / endpoint
|
|
902
|
+
if not priv_path.exists():
|
|
903
|
+
return local_node, None, _("Local node private key not found.")
|
|
904
|
+
|
|
905
|
+
try:
|
|
906
|
+
private_key = serialization.load_pem_private_key(
|
|
907
|
+
priv_path.read_bytes(), password=None
|
|
908
|
+
)
|
|
909
|
+
except Exception as exc: # pragma: no cover - unexpected key errors
|
|
910
|
+
return local_node, None, _("Failed to load private key: %(error)s") % {
|
|
911
|
+
"error": exc
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return local_node, private_key, None
|
|
915
|
+
|
|
916
|
+
def _sign_payload(self, private_key, payload: str) -> str:
|
|
917
|
+
return base64.b64encode(
|
|
918
|
+
private_key.sign(
|
|
919
|
+
payload.encode(),
|
|
920
|
+
padding.PKCS1v15(),
|
|
921
|
+
hashes.SHA256(),
|
|
922
|
+
)
|
|
923
|
+
).decode()
|
|
924
|
+
|
|
925
|
+
def _dedupe(self, values):
|
|
926
|
+
if not values:
|
|
927
|
+
return []
|
|
928
|
+
return list(OrderedDict.fromkeys(values))
|
|
929
|
+
|
|
930
|
+
def _status_from_result(self, result):
|
|
931
|
+
if result["errors"]:
|
|
932
|
+
return "error"
|
|
933
|
+
if result["missing_accounts"]:
|
|
934
|
+
return "partial"
|
|
935
|
+
return result.get("status") or "success"
|
|
936
|
+
|
|
937
|
+
def _summarize_rfid_results(self, results):
|
|
938
|
+
return {
|
|
939
|
+
"total": len(results),
|
|
940
|
+
"processed": sum(1 for item in results if item["status"] != "skipped"),
|
|
941
|
+
"success": sum(1 for item in results if item["status"] == "success"),
|
|
942
|
+
"partial": sum(1 for item in results if item["status"] == "partial"),
|
|
943
|
+
"error": sum(1 for item in results if item["status"] == "error"),
|
|
944
|
+
"created": sum(item["created"] for item in results),
|
|
945
|
+
"updated": sum(item["updated"] for item in results),
|
|
946
|
+
"linked_accounts": sum(item["linked_accounts"] for item in results),
|
|
947
|
+
"missing_accounts": sum(
|
|
948
|
+
len(item["missing_accounts"]) for item in results
|
|
949
|
+
),
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
def _render_rfid_sync(self, request, operation, results, setup_error=None):
|
|
953
|
+
titles = {
|
|
954
|
+
"import": _("Import RFID results"),
|
|
955
|
+
"fetch": _("Fetch RFID results"),
|
|
956
|
+
"export": _("Export RFID results"),
|
|
957
|
+
}
|
|
958
|
+
summary = self._summarize_rfid_results(results)
|
|
959
|
+
context = {
|
|
960
|
+
**self.admin_site.each_context(request),
|
|
961
|
+
"opts": self.model._meta,
|
|
962
|
+
"title": titles.get(operation, _("RFID results")),
|
|
963
|
+
"operation": operation,
|
|
964
|
+
"results": results,
|
|
965
|
+
"summary": summary,
|
|
966
|
+
"setup_error": setup_error,
|
|
967
|
+
"back_url": reverse("admin:nodes_node_changelist"),
|
|
968
|
+
}
|
|
969
|
+
return TemplateResponse(
|
|
970
|
+
request,
|
|
971
|
+
"admin/nodes/node/rfid_sync_results.html",
|
|
972
|
+
context,
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
def _process_import_from_node(self, node, payload, headers):
|
|
976
|
+
result = self._init_rfid_result(node)
|
|
977
|
+
_, response, attempt_errors = self._request_remote(
|
|
978
|
+
node,
|
|
979
|
+
"/nodes/rfid/export/",
|
|
980
|
+
lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
|
|
981
|
+
)
|
|
982
|
+
if response is None:
|
|
983
|
+
result["status"] = "error"
|
|
984
|
+
if attempt_errors:
|
|
985
|
+
result["errors"].extend(attempt_errors)
|
|
986
|
+
else:
|
|
987
|
+
result["errors"].append(
|
|
988
|
+
_("No remote hosts were available for %(node)s.") % {"node": node}
|
|
989
|
+
)
|
|
990
|
+
return result
|
|
991
|
+
|
|
992
|
+
if response.status_code != 200:
|
|
993
|
+
result["status"] = "error"
|
|
994
|
+
result["errors"].append(f"{response.status_code} {response.text}")
|
|
995
|
+
return result
|
|
996
|
+
|
|
997
|
+
try:
|
|
998
|
+
data = response.json()
|
|
999
|
+
except ValueError:
|
|
1000
|
+
result["status"] = "error"
|
|
1001
|
+
result["errors"].append(_("Invalid JSON response"))
|
|
1002
|
+
return result
|
|
1003
|
+
|
|
1004
|
+
rfids = data.get("rfids", []) or []
|
|
1005
|
+
result["processed"] = len(rfids)
|
|
1006
|
+
for entry in rfids:
|
|
1007
|
+
if not isinstance(entry, Mapping):
|
|
1008
|
+
result["errors"].append(_( "Invalid RFID payload" ))
|
|
1009
|
+
continue
|
|
1010
|
+
outcome = apply_rfid_payload(entry, origin_node=node)
|
|
1011
|
+
if not outcome.ok:
|
|
1012
|
+
result["errors"].append(
|
|
1013
|
+
outcome.error or _("RFID could not be imported")
|
|
1014
|
+
)
|
|
1015
|
+
continue
|
|
1016
|
+
if outcome.created:
|
|
1017
|
+
result["created"] += 1
|
|
1018
|
+
else:
|
|
1019
|
+
result["updated"] += 1
|
|
1020
|
+
result["linked_accounts"] += outcome.accounts_linked
|
|
1021
|
+
result["missing_accounts"].extend(outcome.missing_accounts)
|
|
1022
|
+
|
|
1023
|
+
result["missing_accounts"] = self._dedupe(result["missing_accounts"])
|
|
1024
|
+
result["status"] = self._status_from_result(result)
|
|
1025
|
+
return result
|
|
1026
|
+
|
|
1027
|
+
def _post_export_to_node(self, node, payload, headers):
|
|
1028
|
+
result = self._init_rfid_result(node)
|
|
1029
|
+
_, response, attempt_errors = self._request_remote(
|
|
1030
|
+
node,
|
|
1031
|
+
"/nodes/rfid/import/",
|
|
1032
|
+
lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
|
|
1033
|
+
)
|
|
1034
|
+
if response is None:
|
|
1035
|
+
result["status"] = "error"
|
|
1036
|
+
if attempt_errors:
|
|
1037
|
+
result["errors"].extend(attempt_errors)
|
|
1038
|
+
else:
|
|
1039
|
+
result["errors"].append(
|
|
1040
|
+
_("No remote hosts were available for %(node)s.") % {"node": node}
|
|
1041
|
+
)
|
|
1042
|
+
return result
|
|
1043
|
+
|
|
1044
|
+
if response.status_code != 200:
|
|
1045
|
+
result["status"] = "error"
|
|
1046
|
+
result["errors"].append(f"{response.status_code} {response.text}")
|
|
1047
|
+
return result
|
|
1048
|
+
|
|
1049
|
+
try:
|
|
1050
|
+
data = response.json()
|
|
1051
|
+
except ValueError:
|
|
1052
|
+
result["status"] = "error"
|
|
1053
|
+
result["errors"].append(_("Invalid JSON response"))
|
|
1054
|
+
return result
|
|
1055
|
+
|
|
1056
|
+
result["processed"] = data.get("processed", 0) or 0
|
|
1057
|
+
result["created"] = data.get("created", 0) or 0
|
|
1058
|
+
result["updated"] = data.get("updated", 0) or 0
|
|
1059
|
+
result["linked_accounts"] = data.get("accounts_linked", 0) or 0
|
|
1060
|
+
|
|
1061
|
+
missing = data.get("missing_accounts") or []
|
|
1062
|
+
if isinstance(missing, list):
|
|
1063
|
+
result["missing_accounts"].extend(str(value) for value in missing if value)
|
|
1064
|
+
elif missing:
|
|
1065
|
+
result["missing_accounts"].append(str(missing))
|
|
1066
|
+
|
|
1067
|
+
errors = data.get("errors", 0)
|
|
1068
|
+
if isinstance(errors, int) and errors:
|
|
1069
|
+
result["errors"].append(
|
|
1070
|
+
_("Remote reported %(count)s error(s).") % {"count": errors}
|
|
1071
|
+
)
|
|
1072
|
+
elif isinstance(errors, list):
|
|
1073
|
+
result["errors"].extend(str(err) for err in errors if err)
|
|
1074
|
+
|
|
1075
|
+
result["missing_accounts"] = self._dedupe(result["missing_accounts"])
|
|
1076
|
+
result["status"] = self._status_from_result(result)
|
|
1077
|
+
return result
|
|
1078
|
+
|
|
1079
|
+
def _run_rfid_import(self, request, queryset):
|
|
1080
|
+
nodes = list(queryset)
|
|
1081
|
+
local_node, private_key, error = self._load_local_node_credentials()
|
|
1082
|
+
if error:
|
|
1083
|
+
results = [self._skip_result(node, error) for node in nodes]
|
|
1084
|
+
return self._render_rfid_sync(
|
|
1085
|
+
request, "import", results, setup_error=error
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
if not nodes:
|
|
1089
|
+
return self._render_rfid_sync(
|
|
1090
|
+
request,
|
|
1091
|
+
"import",
|
|
1092
|
+
[],
|
|
1093
|
+
setup_error=_("No nodes selected."),
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
payload = json.dumps(
|
|
1097
|
+
{"requester": str(local_node.uuid)},
|
|
1098
|
+
separators=(",", ":"),
|
|
1099
|
+
sort_keys=True,
|
|
1100
|
+
)
|
|
1101
|
+
signature = self._sign_payload(private_key, payload)
|
|
1102
|
+
headers = {
|
|
1103
|
+
"Content-Type": "application/json",
|
|
1104
|
+
"X-Signature": signature,
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
results = []
|
|
1108
|
+
for node in nodes:
|
|
1109
|
+
if local_node.pk and node.pk == local_node.pk:
|
|
1110
|
+
results.append(self._skip_result(node, _("Skipped local node.")))
|
|
1111
|
+
continue
|
|
1112
|
+
results.append(self._process_import_from_node(node, payload, headers))
|
|
1113
|
+
|
|
1114
|
+
return self._render_rfid_sync(request, "import", results)
|
|
1115
|
+
|
|
1116
|
+
@admin.action(description=_("Import RFIDs from selected"))
|
|
1117
|
+
def import_rfids_from_selected(self, request, queryset):
|
|
1118
|
+
return self._run_rfid_import(request, queryset)
|
|
1119
|
+
|
|
1120
|
+
@admin.action(description=_("Export RFIDs to selected"))
|
|
1121
|
+
def export_rfids_to_selected(self, request, queryset):
|
|
1122
|
+
nodes = list(queryset)
|
|
1123
|
+
local_node, private_key, error = self._load_local_node_credentials()
|
|
1124
|
+
if error:
|
|
1125
|
+
results = [self._skip_result(node, error) for node in nodes]
|
|
1126
|
+
return self._render_rfid_sync(request, "export", results, setup_error=error)
|
|
1127
|
+
|
|
1128
|
+
if not nodes:
|
|
1129
|
+
return self._render_rfid_sync(
|
|
1130
|
+
request,
|
|
1131
|
+
"export",
|
|
1132
|
+
[],
|
|
1133
|
+
setup_error=_("No nodes selected."),
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
rfids = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
|
|
1137
|
+
payload = json.dumps(
|
|
1138
|
+
{"requester": str(local_node.uuid), "rfids": rfids},
|
|
1139
|
+
separators=(",", ":"),
|
|
1140
|
+
sort_keys=True,
|
|
1141
|
+
)
|
|
1142
|
+
signature = self._sign_payload(private_key, payload)
|
|
1143
|
+
headers = {
|
|
1144
|
+
"Content-Type": "application/json",
|
|
1145
|
+
"X-Signature": signature,
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
results = []
|
|
1149
|
+
for node in nodes:
|
|
1150
|
+
if local_node.pk and node.pk == local_node.pk:
|
|
1151
|
+
results.append(self._skip_result(node, _("Skipped local node.")))
|
|
1152
|
+
continue
|
|
1153
|
+
results.append(self._post_export_to_node(node, payload, headers))
|
|
1154
|
+
|
|
1155
|
+
return self._render_rfid_sync(request, "export", results)
|
|
1156
|
+
|
|
1157
|
+
async def _probe_websocket(self, url: str) -> bool:
|
|
1158
|
+
try:
|
|
1159
|
+
async with websockets.connect(url, open_timeout=3, close_timeout=1):
|
|
1160
|
+
return True
|
|
1161
|
+
except Exception:
|
|
1162
|
+
return False
|
|
1163
|
+
|
|
1164
|
+
def _attempt_forwarding_probe(self, node, charger_id: str) -> bool:
|
|
1165
|
+
if not charger_id:
|
|
1166
|
+
return False
|
|
1167
|
+
safe_id = quote(str(charger_id))
|
|
1168
|
+
candidates: list[str] = []
|
|
1169
|
+
for base in node.iter_remote_urls("/"):
|
|
1170
|
+
parsed = urlsplit(base)
|
|
1171
|
+
if parsed.scheme not in {"http", "https"}:
|
|
1172
|
+
continue
|
|
1173
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
1174
|
+
base_path = parsed.path.rstrip("/")
|
|
1175
|
+
for prefix in ("", "/ws"):
|
|
1176
|
+
path = f"{base_path}{prefix}/{safe_id}".replace("//", "/")
|
|
1177
|
+
if not path.startswith("/"):
|
|
1178
|
+
path = f"/{path}"
|
|
1179
|
+
candidates.append(urlunsplit((scheme, parsed.netloc, path, "", "")))
|
|
1180
|
+
|
|
1181
|
+
for url in candidates:
|
|
1182
|
+
loop = asyncio.new_event_loop()
|
|
1183
|
+
try:
|
|
1184
|
+
result = loop.run_until_complete(self._probe_websocket(url))
|
|
1185
|
+
except Exception:
|
|
1186
|
+
result = False
|
|
1187
|
+
finally:
|
|
1188
|
+
loop.close()
|
|
1189
|
+
if result:
|
|
1190
|
+
return True
|
|
1191
|
+
return False
|
|
1192
|
+
|
|
1193
|
+
def _send_forwarding_metadata(
|
|
1194
|
+
self,
|
|
1195
|
+
request,
|
|
1196
|
+
node: Node,
|
|
1197
|
+
chargers: list[Charger],
|
|
1198
|
+
local_node: Node,
|
|
1199
|
+
private_key,
|
|
1200
|
+
) -> bool:
|
|
1201
|
+
if not chargers:
|
|
1202
|
+
return True
|
|
1203
|
+
payload = {
|
|
1204
|
+
"requester": str(local_node.uuid),
|
|
1205
|
+
"requester_mac": local_node.mac_address,
|
|
1206
|
+
"requester_public_key": local_node.public_key,
|
|
1207
|
+
"chargers": [serialize_charger_for_network(charger) for charger in chargers],
|
|
1208
|
+
"transactions": {"chargers": [], "transactions": []},
|
|
1209
|
+
}
|
|
1210
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1211
|
+
signature = self._sign_payload(private_key, payload_json)
|
|
1212
|
+
headers = {"Content-Type": "application/json"}
|
|
1213
|
+
if signature:
|
|
1214
|
+
headers["X-Signature"] = signature
|
|
1215
|
+
|
|
1216
|
+
errors: list[str] = []
|
|
1217
|
+
for url in node.iter_remote_urls("/nodes/network/chargers/forward/"):
|
|
1218
|
+
if not url:
|
|
1219
|
+
continue
|
|
1220
|
+
try:
|
|
1221
|
+
response = requests.post(
|
|
1222
|
+
url, data=payload_json, headers=headers, timeout=5
|
|
1223
|
+
)
|
|
1224
|
+
except RequestException as exc:
|
|
1225
|
+
errors.append(
|
|
1226
|
+
_(
|
|
1227
|
+
"Failed to send forwarding metadata to %(node)s via %(url)s (%(error)s)."
|
|
1228
|
+
)
|
|
1229
|
+
% {"node": node, "url": url, "error": exc}
|
|
1230
|
+
)
|
|
1231
|
+
continue
|
|
1232
|
+
|
|
1233
|
+
try:
|
|
1234
|
+
data = response.json()
|
|
1235
|
+
except ValueError:
|
|
1236
|
+
data = {}
|
|
1237
|
+
|
|
1238
|
+
if response.ok and isinstance(data, Mapping) and data.get("status") == "ok":
|
|
1239
|
+
return True
|
|
1240
|
+
|
|
1241
|
+
detail = ""
|
|
1242
|
+
if isinstance(data, Mapping):
|
|
1243
|
+
detail = data.get("detail") or ""
|
|
1244
|
+
errors.append(
|
|
1245
|
+
_("Forwarding metadata to %(node)s via %(url)s failed: %(status)s %(detail)s")
|
|
1246
|
+
% {
|
|
1247
|
+
"node": node,
|
|
1248
|
+
"url": url,
|
|
1249
|
+
"status": response.status_code,
|
|
1250
|
+
"detail": detail,
|
|
1251
|
+
}
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
if not errors:
|
|
1255
|
+
self.message_user(
|
|
1256
|
+
request,
|
|
1257
|
+
_("No reachable host found for %(node)s.") % {"node": node},
|
|
1258
|
+
level=messages.WARNING,
|
|
1259
|
+
)
|
|
1260
|
+
else:
|
|
1261
|
+
self.message_user(request, errors[-1].strip(), level=messages.WARNING)
|
|
1262
|
+
return False
|
|
1263
|
+
|
|
1264
|
+
@admin.action(description=_("Start Charge Point Forwarding"))
|
|
1265
|
+
def start_charge_point_forwarding(self, request, queryset):
|
|
1266
|
+
if queryset.count() != 1:
|
|
1267
|
+
self.message_user(
|
|
1268
|
+
request,
|
|
1269
|
+
_("Select a single remote node."),
|
|
1270
|
+
level=messages.ERROR,
|
|
1271
|
+
)
|
|
1272
|
+
return
|
|
1273
|
+
|
|
1274
|
+
target = queryset.first()
|
|
1275
|
+
local_node, private_key, error = self._load_local_node_credentials()
|
|
1276
|
+
if error:
|
|
1277
|
+
self.message_user(request, error, level=messages.ERROR)
|
|
1278
|
+
return
|
|
1279
|
+
|
|
1280
|
+
if local_node.pk and target.pk == local_node.pk:
|
|
1281
|
+
self.message_user(
|
|
1282
|
+
request,
|
|
1283
|
+
_("Cannot forward charge points to the local node."),
|
|
1284
|
+
level=messages.ERROR,
|
|
1285
|
+
)
|
|
1286
|
+
return
|
|
1287
|
+
|
|
1288
|
+
eligible = Charger.objects.filter(export_transactions=True)
|
|
1289
|
+
if local_node.pk:
|
|
1290
|
+
eligible = eligible.filter(
|
|
1291
|
+
Q(node_origin=local_node) | Q(node_origin__isnull=True)
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
chargers = list(eligible.select_related("forwarded_to"))
|
|
1295
|
+
if not chargers:
|
|
1296
|
+
self.message_user(
|
|
1297
|
+
request,
|
|
1298
|
+
_("No eligible charge points available for forwarding."),
|
|
1299
|
+
level=messages.WARNING,
|
|
1300
|
+
)
|
|
1301
|
+
return
|
|
1302
|
+
|
|
1303
|
+
conflicts = [
|
|
1304
|
+
charger
|
|
1305
|
+
for charger in chargers
|
|
1306
|
+
if charger.forwarded_to_id
|
|
1307
|
+
and charger.forwarded_to_id not in {None, target.pk}
|
|
1308
|
+
]
|
|
1309
|
+
if conflicts:
|
|
1310
|
+
self.message_user(
|
|
1311
|
+
request,
|
|
1312
|
+
ngettext(
|
|
1313
|
+
"Skipped %(count)s charge point already forwarded to another node.",
|
|
1314
|
+
"Skipped %(count)s charge points already forwarded to another node.",
|
|
1315
|
+
len(conflicts),
|
|
1316
|
+
)
|
|
1317
|
+
% {"count": len(conflicts)},
|
|
1318
|
+
level=messages.WARNING,
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
chargers_to_update = [
|
|
1322
|
+
charger
|
|
1323
|
+
for charger in chargers
|
|
1324
|
+
if charger.forwarded_to_id in (None, target.pk)
|
|
1325
|
+
]
|
|
1326
|
+
if not chargers_to_update:
|
|
1327
|
+
self.message_user(
|
|
1328
|
+
request,
|
|
1329
|
+
_("No charge points were updated."),
|
|
1330
|
+
level=messages.WARNING,
|
|
1331
|
+
)
|
|
1332
|
+
return
|
|
1333
|
+
|
|
1334
|
+
charger_pks = [c.pk for c in chargers_to_update]
|
|
1335
|
+
Charger.objects.filter(pk__in=charger_pks).update(forwarded_to=target)
|
|
1336
|
+
|
|
1337
|
+
for charger in chargers_to_update:
|
|
1338
|
+
charger.forwarded_to = target
|
|
1339
|
+
|
|
1340
|
+
sample = next((charger for charger in chargers_to_update if charger.charger_id), None)
|
|
1341
|
+
if sample and not self._attempt_forwarding_probe(target, sample.charger_id):
|
|
1342
|
+
self.message_user(
|
|
1343
|
+
request,
|
|
1344
|
+
_(
|
|
1345
|
+
"Unable to establish a websocket connection to %(node)s for charge point %(charger)s."
|
|
1346
|
+
)
|
|
1347
|
+
% {"node": target, "charger": sample.charger_id},
|
|
1348
|
+
level=messages.WARNING,
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
success = self._send_forwarding_metadata(
|
|
1352
|
+
request, target, chargers_to_update, local_node, private_key
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
if success:
|
|
1356
|
+
now = timezone.now()
|
|
1357
|
+
Charger.objects.filter(pk__in=charger_pks).update(
|
|
1358
|
+
forwarding_watermark=now
|
|
1359
|
+
)
|
|
1360
|
+
self.message_user(
|
|
1361
|
+
request,
|
|
1362
|
+
ngettext(
|
|
1363
|
+
"Forwarding enabled for %(count)s charge point.",
|
|
1364
|
+
"Forwarding enabled for %(count)s charge points.",
|
|
1365
|
+
len(chargers_to_update),
|
|
1366
|
+
)
|
|
1367
|
+
% {"count": len(chargers_to_update)},
|
|
1368
|
+
level=messages.SUCCESS,
|
|
1369
|
+
)
|
|
1370
|
+
else:
|
|
1371
|
+
self.message_user(
|
|
1372
|
+
request,
|
|
1373
|
+
ngettext(
|
|
1374
|
+
"Marked %(count)s charge point for forwarding; awaiting remote acknowledgment.",
|
|
1375
|
+
"Marked %(count)s charge points for forwarding; awaiting remote acknowledgment.",
|
|
1376
|
+
len(chargers_to_update),
|
|
1377
|
+
)
|
|
1378
|
+
% {"count": len(chargers_to_update)},
|
|
1379
|
+
level=messages.INFO,
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
try:
|
|
1383
|
+
push_forwarded_charge_points.delay()
|
|
1384
|
+
except Exception:
|
|
1385
|
+
pass
|
|
1386
|
+
|
|
1387
|
+
@admin.action(description=_("Stop Charge Point Forwarding"))
|
|
1388
|
+
def stop_charge_point_forwarding(self, request, queryset):
|
|
1389
|
+
node_ids = [node.pk for node in queryset if node.pk]
|
|
1390
|
+
if not node_ids:
|
|
1391
|
+
self.message_user(
|
|
1392
|
+
request,
|
|
1393
|
+
_("No remote nodes selected."),
|
|
1394
|
+
level=messages.WARNING,
|
|
1395
|
+
)
|
|
1396
|
+
return
|
|
1397
|
+
|
|
1398
|
+
cleared = Charger.objects.filter(forwarded_to_id__in=node_ids).update(
|
|
1399
|
+
forwarded_to=None, forwarding_watermark=None
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
if cleared:
|
|
1403
|
+
self.message_user(
|
|
1404
|
+
request,
|
|
1405
|
+
ngettext(
|
|
1406
|
+
"Stopped forwarding for %(count)s charge point.",
|
|
1407
|
+
"Stopped forwarding for %(count)s charge points.",
|
|
1408
|
+
cleared,
|
|
1409
|
+
)
|
|
1410
|
+
% {"count": cleared},
|
|
1411
|
+
level=messages.SUCCESS,
|
|
1412
|
+
)
|
|
1413
|
+
else:
|
|
1414
|
+
self.message_user(
|
|
1415
|
+
request,
|
|
1416
|
+
_("No forwarded charge points were updated."),
|
|
1417
|
+
level=messages.WARNING,
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
1421
|
+
extra_context = extra_context or {}
|
|
1422
|
+
if object_id:
|
|
1423
|
+
extra_context["public_key_url"] = reverse(
|
|
1424
|
+
"admin:nodes_node_public_key", args=[object_id]
|
|
1425
|
+
)
|
|
1426
|
+
return super().changeform_view(
|
|
1427
|
+
request, object_id, form_url, extra_context=extra_context
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
@admin.register(EmailOutbox)
|
|
1432
|
+
class EmailOutboxAdmin(EntityModelAdmin):
|
|
1433
|
+
form = EmailOutboxAdminForm
|
|
1434
|
+
list_display = (
|
|
1435
|
+
"owner_label",
|
|
1436
|
+
"host",
|
|
1437
|
+
"port",
|
|
1438
|
+
"username",
|
|
1439
|
+
"use_tls",
|
|
1440
|
+
"use_ssl",
|
|
1441
|
+
"is_enabled",
|
|
1442
|
+
)
|
|
1443
|
+
change_form_template = "admin/nodes/emailoutbox/change_form.html"
|
|
1444
|
+
fieldsets = (
|
|
1445
|
+
("Owner", {"fields": ("user", "group")}),
|
|
1446
|
+
("Credentials", {"fields": ("username", "password")}),
|
|
1447
|
+
(
|
|
1448
|
+
"Configuration",
|
|
1449
|
+
{
|
|
1450
|
+
"fields": (
|
|
1451
|
+
"node",
|
|
1452
|
+
"host",
|
|
1453
|
+
"port",
|
|
1454
|
+
"use_tls",
|
|
1455
|
+
"use_ssl",
|
|
1456
|
+
"from_email",
|
|
1457
|
+
"is_enabled",
|
|
1458
|
+
)
|
|
1459
|
+
},
|
|
1460
|
+
),
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
@admin.display(description="Owner")
|
|
1464
|
+
def owner_label(self, obj):
|
|
1465
|
+
return obj.owner_display()
|
|
1466
|
+
|
|
1467
|
+
def get_urls(self):
|
|
1468
|
+
urls = super().get_urls()
|
|
1469
|
+
custom = [
|
|
1470
|
+
path(
|
|
1471
|
+
"<path:object_id>/test/",
|
|
1472
|
+
self.admin_site.admin_view(self.test_outbox),
|
|
1473
|
+
name="nodes_emailoutbox_test",
|
|
1474
|
+
)
|
|
1475
|
+
]
|
|
1476
|
+
return custom + urls
|
|
1477
|
+
|
|
1478
|
+
def test_outbox(self, request, object_id):
|
|
1479
|
+
outbox = self.get_object(request, object_id)
|
|
1480
|
+
if not outbox:
|
|
1481
|
+
self.message_user(request, "Unknown outbox", messages.ERROR)
|
|
1482
|
+
return redirect("..")
|
|
1483
|
+
recipient = request.user.email or outbox.username
|
|
1484
|
+
try:
|
|
1485
|
+
outbox.send_mail(
|
|
1486
|
+
"Test email",
|
|
1487
|
+
"This is a test email.",
|
|
1488
|
+
[recipient],
|
|
1489
|
+
)
|
|
1490
|
+
self.message_user(request, "Test email sent", messages.SUCCESS)
|
|
1491
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
1492
|
+
self.message_user(request, str(exc), messages.ERROR)
|
|
1493
|
+
return redirect("..")
|
|
1494
|
+
|
|
1495
|
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
1496
|
+
extra_context = extra_context or {}
|
|
1497
|
+
if object_id:
|
|
1498
|
+
extra_context["test_url"] = reverse(
|
|
1499
|
+
"admin:nodes_emailoutbox_test", args=[object_id]
|
|
1500
|
+
)
|
|
1501
|
+
return super().changeform_view(request, object_id, form_url, extra_context)
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
class NodeRoleAdminForm(forms.ModelForm):
|
|
1505
|
+
nodes = forms.ModelMultipleChoiceField(
|
|
1506
|
+
queryset=Node.objects.all(),
|
|
1507
|
+
required=False,
|
|
1508
|
+
widget=FilteredSelectMultiple("Nodes", False),
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
class Meta:
|
|
1512
|
+
model = NodeRole
|
|
1513
|
+
fields = ("name", "description", "nodes")
|
|
1514
|
+
|
|
1515
|
+
def __init__(self, *args, **kwargs):
|
|
1516
|
+
super().__init__(*args, **kwargs)
|
|
1517
|
+
if self.instance.pk:
|
|
1518
|
+
self.fields["nodes"].initial = self.instance.node_set.all()
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
@admin.register(NodeRole)
|
|
1522
|
+
class NodeRoleAdmin(EntityModelAdmin):
|
|
1523
|
+
form = NodeRoleAdminForm
|
|
1524
|
+
list_display = ("name", "description", "registered", "default_features")
|
|
1525
|
+
|
|
1526
|
+
def get_queryset(self, request):
|
|
1527
|
+
qs = super().get_queryset(request)
|
|
1528
|
+
return qs.annotate(_registered=Count("node", distinct=True)).prefetch_related(
|
|
1529
|
+
"features"
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
@admin.display(description="Registered", ordering="_registered")
|
|
1533
|
+
def registered(self, obj):
|
|
1534
|
+
return getattr(obj, "_registered", obj.node_set.count())
|
|
1535
|
+
|
|
1536
|
+
@admin.display(description="Default Features")
|
|
1537
|
+
def default_features(self, obj):
|
|
1538
|
+
features = [feature.display for feature in obj.features.all()]
|
|
1539
|
+
return ", ".join(features) if features else "—"
|
|
1540
|
+
|
|
1541
|
+
def save_model(self, request, obj, form, change):
|
|
1542
|
+
obj.node_set.set(form.cleaned_data.get("nodes", []))
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
@admin.register(NodeFeature)
|
|
1546
|
+
class NodeFeatureAdmin(EntityModelAdmin):
|
|
1547
|
+
filter_horizontal = ("roles",)
|
|
1548
|
+
list_display = (
|
|
1549
|
+
"display",
|
|
1550
|
+
"slug",
|
|
1551
|
+
"default_roles",
|
|
1552
|
+
"is_enabled_display",
|
|
1553
|
+
"available_actions",
|
|
1554
|
+
)
|
|
1555
|
+
actions = ["check_features_for_eligibility", "enable_selected_features"]
|
|
1556
|
+
readonly_fields = ("is_enabled",)
|
|
1557
|
+
search_fields = ("display", "slug")
|
|
1558
|
+
|
|
1559
|
+
def get_queryset(self, request):
|
|
1560
|
+
qs = super().get_queryset(request)
|
|
1561
|
+
return qs.prefetch_related("roles")
|
|
1562
|
+
|
|
1563
|
+
@admin.display(description="Default Roles")
|
|
1564
|
+
def default_roles(self, obj):
|
|
1565
|
+
roles = [role.name for role in obj.roles.all()]
|
|
1566
|
+
return ", ".join(roles) if roles else "—"
|
|
1567
|
+
|
|
1568
|
+
@admin.display(description="Is Enabled", boolean=True, ordering="is_enabled")
|
|
1569
|
+
def is_enabled_display(self, obj):
|
|
1570
|
+
return obj.is_enabled
|
|
1571
|
+
|
|
1572
|
+
@admin.display(description="Actions")
|
|
1573
|
+
def available_actions(self, obj):
|
|
1574
|
+
if not obj.is_enabled:
|
|
1575
|
+
return "—"
|
|
1576
|
+
actions = obj.get_default_actions()
|
|
1577
|
+
if not actions:
|
|
1578
|
+
return "—"
|
|
1579
|
+
|
|
1580
|
+
links = []
|
|
1581
|
+
for action in actions:
|
|
1582
|
+
try:
|
|
1583
|
+
url = reverse(action.url_name)
|
|
1584
|
+
except NoReverseMatch:
|
|
1585
|
+
links.append(action.label)
|
|
1586
|
+
else:
|
|
1587
|
+
links.append(format_html('<a href="{}">{}</a>', url, action.label))
|
|
1588
|
+
|
|
1589
|
+
if not links:
|
|
1590
|
+
return "—"
|
|
1591
|
+
return format_html_join(" | ", "{}", ((link,) for link in links))
|
|
1592
|
+
|
|
1593
|
+
def _manual_enablement_message(self, feature, node):
|
|
1594
|
+
if node is None:
|
|
1595
|
+
return (
|
|
1596
|
+
"Manual enablement is unavailable without a registered local node."
|
|
1597
|
+
)
|
|
1598
|
+
if feature.slug in Node.MANUAL_FEATURE_SLUGS:
|
|
1599
|
+
return "This feature can be enabled manually."
|
|
1600
|
+
return "This feature cannot be enabled manually."
|
|
1601
|
+
|
|
1602
|
+
@admin.action(description="Check features for eligibility")
|
|
1603
|
+
def check_features_for_eligibility(self, request, queryset):
|
|
1604
|
+
from .feature_checks import feature_checks
|
|
1605
|
+
|
|
1606
|
+
features = list(queryset)
|
|
1607
|
+
total = len(features)
|
|
1608
|
+
successes = 0
|
|
1609
|
+
node = Node.get_local()
|
|
1610
|
+
for feature in features:
|
|
1611
|
+
enablement_message = self._manual_enablement_message(feature, node)
|
|
1612
|
+
try:
|
|
1613
|
+
result = feature_checks.run(feature, node=node)
|
|
1614
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
1615
|
+
self.message_user(
|
|
1616
|
+
request,
|
|
1617
|
+
f"{feature.display}: {exc} {enablement_message}",
|
|
1618
|
+
level=messages.ERROR,
|
|
1619
|
+
)
|
|
1620
|
+
continue
|
|
1621
|
+
if result is None:
|
|
1622
|
+
self.message_user(
|
|
1623
|
+
request,
|
|
1624
|
+
f"No check is configured for {feature.display}. {enablement_message}",
|
|
1625
|
+
level=messages.WARNING,
|
|
1626
|
+
)
|
|
1627
|
+
continue
|
|
1628
|
+
message = result.message or (
|
|
1629
|
+
f"{feature.display} check {'passed' if result.success else 'failed'}."
|
|
1630
|
+
)
|
|
1631
|
+
self.message_user(
|
|
1632
|
+
request, f"{message} {enablement_message}", level=result.level
|
|
1633
|
+
)
|
|
1634
|
+
if result.success:
|
|
1635
|
+
successes += 1
|
|
1636
|
+
if total:
|
|
1637
|
+
self.message_user(
|
|
1638
|
+
request,
|
|
1639
|
+
f"Completed {successes} of {total} feature check(s) successfully.",
|
|
1640
|
+
level=messages.INFO,
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
@admin.action(description="Enable selected action")
|
|
1644
|
+
def enable_selected_features(self, request, queryset):
|
|
1645
|
+
node = Node.get_local()
|
|
1646
|
+
if node is None:
|
|
1647
|
+
self.message_user(
|
|
1648
|
+
request,
|
|
1649
|
+
"No local node is registered; unable to enable features manually.",
|
|
1650
|
+
level=messages.ERROR,
|
|
1651
|
+
)
|
|
1652
|
+
return
|
|
1653
|
+
|
|
1654
|
+
manual_features = [
|
|
1655
|
+
feature
|
|
1656
|
+
for feature in queryset
|
|
1657
|
+
if feature.slug in Node.MANUAL_FEATURE_SLUGS
|
|
1658
|
+
]
|
|
1659
|
+
non_manual_features = [
|
|
1660
|
+
feature
|
|
1661
|
+
for feature in queryset
|
|
1662
|
+
if feature.slug not in Node.MANUAL_FEATURE_SLUGS
|
|
1663
|
+
]
|
|
1664
|
+
for feature in non_manual_features:
|
|
1665
|
+
self.message_user(
|
|
1666
|
+
request,
|
|
1667
|
+
f"{feature.display} cannot be enabled manually.",
|
|
1668
|
+
level=messages.WARNING,
|
|
1669
|
+
)
|
|
1670
|
+
|
|
1671
|
+
if not manual_features:
|
|
1672
|
+
self.message_user(
|
|
1673
|
+
request,
|
|
1674
|
+
"None of the selected features can be enabled manually.",
|
|
1675
|
+
level=messages.WARNING,
|
|
1676
|
+
)
|
|
1677
|
+
return
|
|
1678
|
+
|
|
1679
|
+
current_manual = set(
|
|
1680
|
+
node.features.filter(slug__in=Node.MANUAL_FEATURE_SLUGS).values_list(
|
|
1681
|
+
"slug", flat=True
|
|
1682
|
+
)
|
|
1683
|
+
)
|
|
1684
|
+
desired_manual = current_manual | {feature.slug for feature in manual_features}
|
|
1685
|
+
newly_enabled = desired_manual - current_manual
|
|
1686
|
+
if not newly_enabled:
|
|
1687
|
+
self.message_user(
|
|
1688
|
+
request,
|
|
1689
|
+
"Selected manual features are already enabled.",
|
|
1690
|
+
level=messages.INFO,
|
|
1691
|
+
)
|
|
1692
|
+
return
|
|
1693
|
+
|
|
1694
|
+
node.update_manual_features(desired_manual)
|
|
1695
|
+
display_map = {feature.slug: feature.display for feature in manual_features}
|
|
1696
|
+
newly_enabled_names = [display_map[slug] for slug in sorted(newly_enabled)]
|
|
1697
|
+
self.message_user(
|
|
1698
|
+
request,
|
|
1699
|
+
"Enabled {} feature(s): {}".format(
|
|
1700
|
+
len(newly_enabled), ", ".join(newly_enabled_names)
|
|
1701
|
+
),
|
|
1702
|
+
level=messages.SUCCESS,
|
|
1703
|
+
)
|
|
1704
|
+
|
|
1705
|
+
def get_urls(self):
|
|
1706
|
+
urls = super().get_urls()
|
|
1707
|
+
custom = [
|
|
1708
|
+
path(
|
|
1709
|
+
"celery-report/",
|
|
1710
|
+
self.admin_site.admin_view(self.celery_report),
|
|
1711
|
+
name="nodes_nodefeature_celery_report",
|
|
1712
|
+
),
|
|
1713
|
+
path(
|
|
1714
|
+
"view-waveform/",
|
|
1715
|
+
self.admin_site.admin_view(self.view_waveform),
|
|
1716
|
+
name="nodes_nodefeature_view_waveform",
|
|
1717
|
+
),
|
|
1718
|
+
path(
|
|
1719
|
+
"take-screenshot/",
|
|
1720
|
+
self.admin_site.admin_view(self.take_screenshot),
|
|
1721
|
+
name="nodes_nodefeature_take_screenshot",
|
|
1722
|
+
),
|
|
1723
|
+
path(
|
|
1724
|
+
"take-snapshot/",
|
|
1725
|
+
self.admin_site.admin_view(self.take_snapshot),
|
|
1726
|
+
name="nodes_nodefeature_take_snapshot",
|
|
1727
|
+
),
|
|
1728
|
+
path(
|
|
1729
|
+
"view-stream/",
|
|
1730
|
+
self.admin_site.admin_view(self.view_stream),
|
|
1731
|
+
name="nodes_nodefeature_view_stream",
|
|
1732
|
+
),
|
|
1733
|
+
]
|
|
1734
|
+
return custom + urls
|
|
1735
|
+
|
|
1736
|
+
def celery_report(self, request):
|
|
1737
|
+
period = resolve_period(request.GET.get("period"))
|
|
1738
|
+
now = timezone.now()
|
|
1739
|
+
window_end = now + period.delta
|
|
1740
|
+
log_window_start = now - period.delta
|
|
1741
|
+
|
|
1742
|
+
scheduled_tasks = collect_scheduled_tasks(now, window_end)
|
|
1743
|
+
log_collection = collect_celery_log_entries(log_window_start, now)
|
|
1744
|
+
|
|
1745
|
+
period_options = [
|
|
1746
|
+
{
|
|
1747
|
+
"key": candidate.key,
|
|
1748
|
+
"label": candidate.label,
|
|
1749
|
+
"selected": candidate.key == period.key,
|
|
1750
|
+
"url": f"?period={candidate.key}",
|
|
1751
|
+
}
|
|
1752
|
+
for candidate in iter_report_periods()
|
|
1753
|
+
]
|
|
1754
|
+
|
|
1755
|
+
context = {
|
|
1756
|
+
**self.admin_site.each_context(request),
|
|
1757
|
+
"title": _("Celery Report"),
|
|
1758
|
+
"period": period,
|
|
1759
|
+
"period_options": period_options,
|
|
1760
|
+
"current_time": now,
|
|
1761
|
+
"window_end": window_end,
|
|
1762
|
+
"log_window_start": log_window_start,
|
|
1763
|
+
"scheduled_tasks": scheduled_tasks,
|
|
1764
|
+
"log_entries": log_collection.entries,
|
|
1765
|
+
"log_sources": log_collection.checked_sources,
|
|
1766
|
+
}
|
|
1767
|
+
return TemplateResponse(
|
|
1768
|
+
request,
|
|
1769
|
+
"admin/nodes/nodefeature/celery_report.html",
|
|
1770
|
+
context,
|
|
1771
|
+
)
|
|
1772
|
+
|
|
1773
|
+
def _ensure_feature_enabled(self, request, slug: str, action_label: str):
|
|
1774
|
+
try:
|
|
1775
|
+
feature = NodeFeature.objects.get(slug=slug)
|
|
1776
|
+
except NodeFeature.DoesNotExist:
|
|
1777
|
+
self.message_user(
|
|
1778
|
+
request,
|
|
1779
|
+
f"{action_label} is unavailable because the feature is not configured.",
|
|
1780
|
+
level=messages.ERROR,
|
|
1781
|
+
)
|
|
1782
|
+
return None
|
|
1783
|
+
if not feature.is_enabled:
|
|
1784
|
+
self.message_user(
|
|
1785
|
+
request,
|
|
1786
|
+
f"{feature.display} feature is not enabled on this node.",
|
|
1787
|
+
level=messages.WARNING,
|
|
1788
|
+
)
|
|
1789
|
+
return None
|
|
1790
|
+
return feature
|
|
1791
|
+
|
|
1792
|
+
def view_waveform(self, request):
|
|
1793
|
+
feature = self._ensure_feature_enabled(
|
|
1794
|
+
request, "audio-capture", "View Waveform"
|
|
1795
|
+
)
|
|
1796
|
+
if not feature:
|
|
1797
|
+
return redirect("..")
|
|
1798
|
+
|
|
1799
|
+
context = {
|
|
1800
|
+
**self.admin_site.each_context(request),
|
|
1801
|
+
"title": _("Audio Capture Waveform"),
|
|
1802
|
+
"feature": feature,
|
|
1803
|
+
}
|
|
1804
|
+
return TemplateResponse(
|
|
1805
|
+
request,
|
|
1806
|
+
"admin/nodes/nodefeature/view_waveform.html",
|
|
1807
|
+
context,
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
def take_screenshot(self, request):
|
|
1811
|
+
feature = self._ensure_feature_enabled(
|
|
1812
|
+
request, "screenshot-poll", "Take Screenshot"
|
|
1813
|
+
)
|
|
1814
|
+
if not feature:
|
|
1815
|
+
return redirect("..")
|
|
1816
|
+
url = request.build_absolute_uri("/")
|
|
1817
|
+
try:
|
|
1818
|
+
path = capture_screenshot(url)
|
|
1819
|
+
except Exception as exc: # pragma: no cover - depends on selenium setup
|
|
1820
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
1821
|
+
return redirect("..")
|
|
1822
|
+
node = Node.get_local()
|
|
1823
|
+
sample = save_screenshot(path, node=node, method="DEFAULT_ACTION")
|
|
1824
|
+
if not sample:
|
|
1825
|
+
self.message_user(
|
|
1826
|
+
request, "Duplicate screenshot; not saved", level=messages.INFO
|
|
1827
|
+
)
|
|
1828
|
+
return redirect("..")
|
|
1829
|
+
self.message_user(
|
|
1830
|
+
request, f"Screenshot saved to {sample.path}", level=messages.SUCCESS
|
|
1831
|
+
)
|
|
1832
|
+
try:
|
|
1833
|
+
change_url = reverse(
|
|
1834
|
+
"admin:nodes_contentsample_change", args=[sample.pk]
|
|
1835
|
+
)
|
|
1836
|
+
except NoReverseMatch: # pragma: no cover - admin URL always registered
|
|
1837
|
+
self.message_user(
|
|
1838
|
+
request,
|
|
1839
|
+
"Screenshot saved but the admin page could not be resolved.",
|
|
1840
|
+
level=messages.WARNING,
|
|
1841
|
+
)
|
|
1842
|
+
return redirect("..")
|
|
1843
|
+
return redirect(change_url)
|
|
1844
|
+
|
|
1845
|
+
def take_snapshot(self, request):
|
|
1846
|
+
feature = self._ensure_feature_enabled(
|
|
1847
|
+
request, "rpi-camera", "Take a Snapshot"
|
|
1848
|
+
)
|
|
1849
|
+
if not feature:
|
|
1850
|
+
return redirect("..")
|
|
1851
|
+
try:
|
|
1852
|
+
path = capture_rpi_snapshot()
|
|
1853
|
+
except Exception as exc: # pragma: no cover - depends on camera stack
|
|
1854
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
1855
|
+
return redirect("..")
|
|
1856
|
+
node = Node.get_local()
|
|
1857
|
+
sample = save_screenshot(path, node=node, method="RPI_CAMERA")
|
|
1858
|
+
if not sample:
|
|
1859
|
+
self.message_user(
|
|
1860
|
+
request, "Duplicate snapshot; not saved", level=messages.INFO
|
|
1861
|
+
)
|
|
1862
|
+
return redirect("..")
|
|
1863
|
+
self.message_user(
|
|
1864
|
+
request, f"Snapshot saved to {sample.path}", level=messages.SUCCESS
|
|
1865
|
+
)
|
|
1866
|
+
try:
|
|
1867
|
+
change_url = reverse(
|
|
1868
|
+
"admin:nodes_contentsample_change", args=[sample.pk]
|
|
1869
|
+
)
|
|
1870
|
+
except NoReverseMatch: # pragma: no cover - admin URL always registered
|
|
1871
|
+
self.message_user(
|
|
1872
|
+
request,
|
|
1873
|
+
"Snapshot saved but the admin page could not be resolved.",
|
|
1874
|
+
level=messages.WARNING,
|
|
1875
|
+
)
|
|
1876
|
+
return redirect("..")
|
|
1877
|
+
return redirect(change_url)
|
|
1878
|
+
|
|
1879
|
+
def view_stream(self, request):
|
|
1880
|
+
feature = self._ensure_feature_enabled(request, "rpi-camera", "View stream")
|
|
1881
|
+
if not feature:
|
|
1882
|
+
return redirect("..")
|
|
1883
|
+
|
|
1884
|
+
configured_stream = getattr(settings, "RPI_CAMERA_STREAM_URL", "").strip()
|
|
1885
|
+
if configured_stream:
|
|
1886
|
+
stream_url = configured_stream
|
|
1887
|
+
else:
|
|
1888
|
+
base_uri = request.build_absolute_uri("/")
|
|
1889
|
+
parsed = urlsplit(base_uri)
|
|
1890
|
+
hostname = parsed.hostname or "127.0.0.1"
|
|
1891
|
+
port = getattr(settings, "RPI_CAMERA_STREAM_PORT", 8554)
|
|
1892
|
+
scheme = getattr(settings, "RPI_CAMERA_STREAM_SCHEME", "http")
|
|
1893
|
+
netloc = f"{hostname}:{port}" if port else hostname
|
|
1894
|
+
stream_url = urlunsplit((scheme, netloc, "/", "", ""))
|
|
1895
|
+
parsed_stream = urlsplit(stream_url)
|
|
1896
|
+
path = (parsed_stream.path or "").lower()
|
|
1897
|
+
query = (parsed_stream.query or "").lower()
|
|
1898
|
+
|
|
1899
|
+
if parsed_stream.scheme in {"rtsp", "rtsps"}:
|
|
1900
|
+
embed_mode = "unsupported"
|
|
1901
|
+
elif any(path.endswith(ext) for ext in (".mjpg", ".mjpeg", ".jpeg", ".jpg", ".png")) or "action=stream" in query:
|
|
1902
|
+
embed_mode = "mjpeg"
|
|
1903
|
+
else:
|
|
1904
|
+
embed_mode = "iframe"
|
|
1905
|
+
|
|
1906
|
+
context = {
|
|
1907
|
+
**self.admin_site.each_context(request),
|
|
1908
|
+
"title": _("Raspberry Pi Camera Stream"),
|
|
1909
|
+
"stream_url": stream_url,
|
|
1910
|
+
"stream_embed": embed_mode,
|
|
1911
|
+
}
|
|
1912
|
+
return TemplateResponse(
|
|
1913
|
+
request,
|
|
1914
|
+
"admin/nodes/nodefeature/view_stream.html",
|
|
1915
|
+
context,
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
@admin.register(ContentTag)
|
|
1920
|
+
class ContentTagAdmin(EntityModelAdmin):
|
|
1921
|
+
list_display = ("label", "slug")
|
|
1922
|
+
search_fields = ("label", "slug")
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
@admin.register(ContentClassifier)
|
|
1926
|
+
class ContentClassifierAdmin(EntityModelAdmin):
|
|
1927
|
+
list_display = ("label", "slug", "kind", "run_by_default", "active")
|
|
1928
|
+
list_filter = ("kind", "run_by_default", "active")
|
|
1929
|
+
search_fields = ("label", "slug", "entrypoint")
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
class ContentClassificationInline(admin.TabularInline):
|
|
1933
|
+
model = ContentClassification
|
|
1934
|
+
extra = 0
|
|
1935
|
+
autocomplete_fields = ("classifier", "tag")
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
@admin.register(ContentSample)
|
|
1939
|
+
class ContentSampleAdmin(EntityModelAdmin):
|
|
1940
|
+
list_display = ("name", "kind", "node", "user", "created_at")
|
|
1941
|
+
readonly_fields = ("created_at", "name", "user", "image_preview")
|
|
1942
|
+
inlines = (ContentClassificationInline,)
|
|
1943
|
+
list_filter = ("kind", "classifications__tag")
|
|
1944
|
+
|
|
1945
|
+
def get_urls(self):
|
|
1946
|
+
urls = super().get_urls()
|
|
1947
|
+
custom = [
|
|
1948
|
+
path(
|
|
1949
|
+
"from-clipboard/",
|
|
1950
|
+
self.admin_site.admin_view(self.add_from_clipboard),
|
|
1951
|
+
name="nodes_contentsample_from_clipboard",
|
|
1952
|
+
),
|
|
1953
|
+
path(
|
|
1954
|
+
"capture/",
|
|
1955
|
+
self.admin_site.admin_view(self.capture_now),
|
|
1956
|
+
name="nodes_contentsample_capture",
|
|
1957
|
+
),
|
|
1958
|
+
]
|
|
1959
|
+
return custom + urls
|
|
1960
|
+
|
|
1961
|
+
def add_from_clipboard(self, request):
|
|
1962
|
+
try:
|
|
1963
|
+
content = pyperclip.paste()
|
|
1964
|
+
except PyperclipException as exc: # pragma: no cover - depends on OS clipboard
|
|
1965
|
+
self.message_user(request, f"Clipboard error: {exc}", level=messages.ERROR)
|
|
1966
|
+
return redirect("..")
|
|
1967
|
+
if not content:
|
|
1968
|
+
self.message_user(request, "Clipboard is empty.", level=messages.INFO)
|
|
1969
|
+
return redirect("..")
|
|
1970
|
+
if ContentSample.objects.filter(
|
|
1971
|
+
content=content, kind=ContentSample.TEXT
|
|
1972
|
+
).exists():
|
|
1973
|
+
self.message_user(
|
|
1974
|
+
request, "Duplicate sample not created.", level=messages.INFO
|
|
1975
|
+
)
|
|
1976
|
+
return redirect("..")
|
|
1977
|
+
user = request.user if request.user.is_authenticated else None
|
|
1978
|
+
with suppress_default_classifiers():
|
|
1979
|
+
sample = ContentSample.objects.create(
|
|
1980
|
+
content=content, user=user, kind=ContentSample.TEXT
|
|
1981
|
+
)
|
|
1982
|
+
run_default_classifiers(sample)
|
|
1983
|
+
self.message_user(
|
|
1984
|
+
request, "Text sample added from clipboard.", level=messages.SUCCESS
|
|
1985
|
+
)
|
|
1986
|
+
return redirect("..")
|
|
1987
|
+
|
|
1988
|
+
def capture_now(self, request):
|
|
1989
|
+
node = Node.get_local()
|
|
1990
|
+
url = request.build_absolute_uri("/")
|
|
1991
|
+
try:
|
|
1992
|
+
path = capture_screenshot(url)
|
|
1993
|
+
except Exception as exc: # pragma: no cover - depends on selenium setup
|
|
1994
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
1995
|
+
return redirect("..")
|
|
1996
|
+
sample = save_screenshot(path, node=node, method="ADMIN")
|
|
1997
|
+
if sample:
|
|
1998
|
+
self.message_user(request, f"Screenshot saved to {path}", messages.SUCCESS)
|
|
1999
|
+
else:
|
|
2000
|
+
self.message_user(request, "Duplicate screenshot; not saved", messages.INFO)
|
|
2001
|
+
return redirect("..")
|
|
2002
|
+
|
|
2003
|
+
@admin.display(description="Screenshot")
|
|
2004
|
+
def image_preview(self, obj):
|
|
2005
|
+
if not obj or obj.kind != ContentSample.IMAGE or not obj.path:
|
|
2006
|
+
return ""
|
|
2007
|
+
file_path = Path(obj.path)
|
|
2008
|
+
if not file_path.is_absolute():
|
|
2009
|
+
file_path = settings.LOG_DIR / file_path
|
|
2010
|
+
if not file_path.exists():
|
|
2011
|
+
return "File not found"
|
|
2012
|
+
with file_path.open("rb") as f:
|
|
2013
|
+
encoded = base64.b64encode(f.read()).decode("ascii")
|
|
2014
|
+
return format_html(
|
|
2015
|
+
'<img src="data:image/png;base64,{}" style="max-width:100%;" />',
|
|
2016
|
+
encoded,
|
|
2017
|
+
)
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
@admin.register(NetMessage)
|
|
2021
|
+
class NetMessageAdmin(EntityModelAdmin):
|
|
2022
|
+
class QuickSendForm(forms.ModelForm):
|
|
2023
|
+
class Meta:
|
|
2024
|
+
model = NetMessage
|
|
2025
|
+
fields = [
|
|
2026
|
+
"subject",
|
|
2027
|
+
"body",
|
|
2028
|
+
"attachments",
|
|
2029
|
+
"filter_node",
|
|
2030
|
+
"filter_node_feature",
|
|
2031
|
+
"filter_node_role",
|
|
2032
|
+
"filter_current_relation",
|
|
2033
|
+
"filter_installed_version",
|
|
2034
|
+
"filter_installed_revision",
|
|
2035
|
+
"target_limit",
|
|
2036
|
+
]
|
|
2037
|
+
widgets = {"body": forms.Textarea(attrs={"rows": 4})}
|
|
2038
|
+
|
|
2039
|
+
class NetMessageAdminForm(forms.ModelForm):
|
|
2040
|
+
class Meta:
|
|
2041
|
+
model = NetMessage
|
|
2042
|
+
fields = "__all__"
|
|
2043
|
+
widgets = {"body": forms.Textarea(attrs={"rows": 4})}
|
|
2044
|
+
|
|
2045
|
+
change_list_template = "admin/nodes/netmessage/change_list.html"
|
|
2046
|
+
form = NetMessageAdminForm
|
|
2047
|
+
change_form_template = "admin/nodes/netmessage/change_form.html"
|
|
2048
|
+
list_display = (
|
|
2049
|
+
"subject",
|
|
2050
|
+
"body",
|
|
2051
|
+
"filter_node",
|
|
2052
|
+
"filter_node_role_display",
|
|
2053
|
+
"node_origin",
|
|
2054
|
+
"created",
|
|
2055
|
+
"target_limit_display",
|
|
2056
|
+
"complete",
|
|
2057
|
+
)
|
|
2058
|
+
search_fields = ("subject", "body")
|
|
2059
|
+
list_filter = ("complete", "filter_node_role", "filter_current_relation")
|
|
2060
|
+
ordering = ("-created",)
|
|
2061
|
+
readonly_fields = ("complete",)
|
|
2062
|
+
actions = ["send_messages"]
|
|
2063
|
+
fieldsets = (
|
|
2064
|
+
(None, {"fields": ("subject", "body")}),
|
|
2065
|
+
(
|
|
2066
|
+
"Filters",
|
|
2067
|
+
{
|
|
2068
|
+
"fields": (
|
|
2069
|
+
"filter_node",
|
|
2070
|
+
"filter_node_feature",
|
|
2071
|
+
"filter_node_role",
|
|
2072
|
+
"filter_current_relation",
|
|
2073
|
+
"filter_installed_version",
|
|
2074
|
+
"filter_installed_revision",
|
|
2075
|
+
)
|
|
2076
|
+
},
|
|
2077
|
+
),
|
|
2078
|
+
("Attachments", {"fields": ("attachments",)}),
|
|
2079
|
+
(
|
|
2080
|
+
"Propagation",
|
|
2081
|
+
{
|
|
2082
|
+
"fields": (
|
|
2083
|
+
"node_origin",
|
|
2084
|
+
"target_limit",
|
|
2085
|
+
"propagated_to",
|
|
2086
|
+
"complete",
|
|
2087
|
+
)
|
|
2088
|
+
},
|
|
2089
|
+
),
|
|
2090
|
+
)
|
|
2091
|
+
quick_send_fieldsets = (
|
|
2092
|
+
(None, {"fields": ("subject", "body")}),
|
|
2093
|
+
(
|
|
2094
|
+
_("Filters"),
|
|
2095
|
+
{
|
|
2096
|
+
"fields": (
|
|
2097
|
+
"filter_node",
|
|
2098
|
+
"filter_node_feature",
|
|
2099
|
+
"filter_node_role",
|
|
2100
|
+
"filter_current_relation",
|
|
2101
|
+
"filter_installed_version",
|
|
2102
|
+
"filter_installed_revision",
|
|
2103
|
+
)
|
|
2104
|
+
},
|
|
2105
|
+
),
|
|
2106
|
+
(
|
|
2107
|
+
_("Propagation"),
|
|
2108
|
+
{
|
|
2109
|
+
"fields": (
|
|
2110
|
+
"target_limit",
|
|
2111
|
+
)
|
|
2112
|
+
},
|
|
2113
|
+
),
|
|
2114
|
+
)
|
|
2115
|
+
|
|
2116
|
+
def get_actions(self, request):
|
|
2117
|
+
actions = super().get_actions(request)
|
|
2118
|
+
if self.has_add_permission(request):
|
|
2119
|
+
action = getattr(self, "send", None)
|
|
2120
|
+
if action is not None and "send" not in actions:
|
|
2121
|
+
actions["send"] = (
|
|
2122
|
+
action,
|
|
2123
|
+
"send",
|
|
2124
|
+
getattr(action, "short_description", _("Send Net Message")),
|
|
2125
|
+
)
|
|
2126
|
+
return actions
|
|
2127
|
+
|
|
2128
|
+
def send(self, request, queryset=None):
|
|
2129
|
+
return redirect(
|
|
2130
|
+
reverse(
|
|
2131
|
+
f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_send"
|
|
2132
|
+
)
|
|
2133
|
+
)
|
|
2134
|
+
|
|
2135
|
+
send.label = _("Send Net Message")
|
|
2136
|
+
send.short_description = _("Send Net Message")
|
|
2137
|
+
|
|
2138
|
+
def get_urls(self):
|
|
2139
|
+
urls = super().get_urls()
|
|
2140
|
+
opts = self.model._meta
|
|
2141
|
+
custom_urls = [
|
|
2142
|
+
path(
|
|
2143
|
+
"send/",
|
|
2144
|
+
self.admin_site.admin_view(self.send_tool_view),
|
|
2145
|
+
name=f"{opts.app_label}_{opts.model_name}_send",
|
|
2146
|
+
)
|
|
2147
|
+
]
|
|
2148
|
+
return custom_urls + urls
|
|
2149
|
+
|
|
2150
|
+
def send_tool_view(self, request):
|
|
2151
|
+
if not self.has_add_permission(request):
|
|
2152
|
+
raise PermissionDenied
|
|
2153
|
+
|
|
2154
|
+
form_class = self.QuickSendForm
|
|
2155
|
+
if request.method == "POST":
|
|
2156
|
+
form = form_class(request.POST)
|
|
2157
|
+
if form.is_valid():
|
|
2158
|
+
obj = form.save(commit=False)
|
|
2159
|
+
obj.pk = None
|
|
2160
|
+
previous_skip_flag = getattr(self, "_skip_entity_user_datum", False)
|
|
2161
|
+
self._skip_entity_user_datum = True
|
|
2162
|
+
try:
|
|
2163
|
+
self.save_model(request, obj, form, change=False)
|
|
2164
|
+
self.save_related(request, form, formsets=[], change=False)
|
|
2165
|
+
finally:
|
|
2166
|
+
self._skip_entity_user_datum = previous_skip_flag
|
|
2167
|
+
self.log_addition(
|
|
2168
|
+
request,
|
|
2169
|
+
obj,
|
|
2170
|
+
self.construct_change_message(request, form, None),
|
|
2171
|
+
)
|
|
2172
|
+
obj.propagate()
|
|
2173
|
+
self.message_user(
|
|
2174
|
+
request,
|
|
2175
|
+
_("Net Message sent to the network."),
|
|
2176
|
+
level=messages.SUCCESS,
|
|
2177
|
+
)
|
|
2178
|
+
changelist_url = reverse(
|
|
2179
|
+
f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_changelist"
|
|
2180
|
+
)
|
|
2181
|
+
return redirect(changelist_url)
|
|
2182
|
+
else:
|
|
2183
|
+
form = form_class()
|
|
2184
|
+
|
|
2185
|
+
admin_form = helpers.AdminForm(form, self.quick_send_fieldsets, {})
|
|
2186
|
+
context = {
|
|
2187
|
+
**self.admin_site.each_context(request),
|
|
2188
|
+
"opts": self.model._meta,
|
|
2189
|
+
"title": _("Send Net Message"),
|
|
2190
|
+
"adminform": admin_form,
|
|
2191
|
+
"media": self.media + form.media,
|
|
2192
|
+
}
|
|
2193
|
+
return TemplateResponse(
|
|
2194
|
+
request,
|
|
2195
|
+
"admin/nodes/netmessage/send.html",
|
|
2196
|
+
context,
|
|
2197
|
+
)
|
|
2198
|
+
|
|
2199
|
+
def get_changeform_initial_data(self, request):
|
|
2200
|
+
initial = super().get_changeform_initial_data(request)
|
|
2201
|
+
initial = dict(initial) if initial else {}
|
|
2202
|
+
reply_to = request.GET.get("reply_to")
|
|
2203
|
+
if reply_to:
|
|
2204
|
+
try:
|
|
2205
|
+
message = (
|
|
2206
|
+
NetMessage.objects.select_related("node_origin__role")
|
|
2207
|
+
.get(pk=reply_to)
|
|
2208
|
+
)
|
|
2209
|
+
except (NetMessage.DoesNotExist, ValueError, TypeError):
|
|
2210
|
+
message = None
|
|
2211
|
+
if message:
|
|
2212
|
+
subject = (message.subject or "").strip()
|
|
2213
|
+
if subject:
|
|
2214
|
+
if not subject.lower().startswith("re:"):
|
|
2215
|
+
subject = f"Re: {subject}"
|
|
2216
|
+
else:
|
|
2217
|
+
subject = "Re:"
|
|
2218
|
+
initial.setdefault("subject", subject[:64])
|
|
2219
|
+
if message.node_origin and "filter_node" not in initial:
|
|
2220
|
+
initial["filter_node"] = message.node_origin.pk
|
|
2221
|
+
return initial
|
|
2222
|
+
|
|
2223
|
+
def send_messages(self, request, queryset):
|
|
2224
|
+
for msg in queryset:
|
|
2225
|
+
msg.propagate()
|
|
2226
|
+
self.message_user(request, f"{queryset.count()} messages sent")
|
|
2227
|
+
|
|
2228
|
+
send_messages.short_description = "Send selected messages"
|
|
2229
|
+
|
|
2230
|
+
@admin.display(description="Role", ordering="filter_node_role")
|
|
2231
|
+
def filter_node_role_display(self, obj):
|
|
2232
|
+
return obj.filter_node_role
|
|
2233
|
+
|
|
2234
|
+
@admin.display(description="TL", ordering="target_limit")
|
|
2235
|
+
def target_limit_display(self, obj):
|
|
2236
|
+
return obj.target_limit or ""
|