arthexis 0.1.23__py3-none-any.whl → 0.1.25__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.23.dist-info → arthexis-0.1.25.dist-info}/METADATA +39 -18
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/RECORD +31 -30
- config/settings.py +7 -0
- config/urls.py +2 -0
- core/admin.py +140 -213
- core/backends.py +3 -1
- core/models.py +612 -207
- core/system.py +67 -2
- core/tasks.py +25 -0
- core/views.py +0 -3
- nodes/admin.py +465 -292
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +291 -130
- nodes/urls.py +11 -0
- nodes/utils.py +9 -2
- nodes/views.py +588 -20
- ocpp/admin.py +729 -175
- ocpp/consumers.py +98 -0
- ocpp/models.py +299 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +177 -1
- ocpp/tests.py +179 -0
- ocpp/views.py +2 -0
- pages/middleware.py +3 -2
- pages/tests.py +40 -0
- pages/utils.py +70 -0
- pages/views.py +64 -32
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -16,22 +16,23 @@ from django.urls import NoReverseMatch, path, reverse
|
|
|
16
16
|
from django.utils import timezone
|
|
17
17
|
from django.utils.dateparse import parse_datetime
|
|
18
18
|
from django.utils.html import format_html, format_html_join
|
|
19
|
-
from django.utils.translation import gettext_lazy as _
|
|
19
|
+
from django.utils.translation import gettext_lazy as _, ngettext
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from types import SimpleNamespace
|
|
22
|
-
from urllib.parse import
|
|
22
|
+
from urllib.parse import urlsplit, urlunsplit, quote
|
|
23
23
|
import base64
|
|
24
|
-
import ipaddress
|
|
25
24
|
import json
|
|
26
25
|
import subprocess
|
|
27
26
|
import uuid
|
|
28
27
|
|
|
28
|
+
import asyncio
|
|
29
29
|
import pyperclip
|
|
30
30
|
import requests
|
|
31
31
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
32
32
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
33
33
|
from pyperclip import PyperclipException
|
|
34
34
|
from requests import RequestException
|
|
35
|
+
import websockets
|
|
35
36
|
|
|
36
37
|
from .classifiers import run_default_classifiers, suppress_default_classifiers
|
|
37
38
|
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
@@ -60,6 +61,9 @@ from .models import (
|
|
|
60
61
|
)
|
|
61
62
|
from . import dns as dns_utils
|
|
62
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
|
|
63
67
|
from core.user_data import EntityModelAdmin
|
|
64
68
|
|
|
65
69
|
|
|
@@ -230,34 +234,37 @@ class DNSRecordAdmin(EntityModelAdmin):
|
|
|
230
234
|
class NodeAdmin(EntityModelAdmin):
|
|
231
235
|
list_display = (
|
|
232
236
|
"hostname",
|
|
233
|
-
"
|
|
234
|
-
"
|
|
237
|
+
"network_hostname",
|
|
238
|
+
"ipv4_address",
|
|
239
|
+
"ipv6_address",
|
|
235
240
|
"port",
|
|
236
241
|
"role",
|
|
237
242
|
"relation",
|
|
238
243
|
"last_seen",
|
|
239
244
|
"visit_link",
|
|
240
|
-
"proxy_link",
|
|
241
245
|
)
|
|
242
|
-
search_fields = ("hostname", "address", "mac_address")
|
|
246
|
+
search_fields = ("hostname", "network_hostname", "address", "mac_address")
|
|
243
247
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
244
248
|
change_form_template = "admin/nodes/node/change_form.html"
|
|
245
249
|
form = NodeAdminForm
|
|
246
250
|
fieldsets = (
|
|
247
251
|
(
|
|
248
|
-
_("
|
|
252
|
+
_("Network"),
|
|
249
253
|
{
|
|
250
254
|
"fields": (
|
|
251
255
|
"hostname",
|
|
256
|
+
"network_hostname",
|
|
257
|
+
"ipv4_address",
|
|
258
|
+
"ipv6_address",
|
|
252
259
|
"address",
|
|
253
260
|
"mac_address",
|
|
254
261
|
"port",
|
|
255
262
|
"message_queue_length",
|
|
256
|
-
"role",
|
|
257
263
|
"current_relation",
|
|
258
264
|
)
|
|
259
265
|
},
|
|
260
266
|
),
|
|
267
|
+
(_("Role"), {"fields": ("role",)}),
|
|
261
268
|
(
|
|
262
269
|
_("Public endpoint"),
|
|
263
270
|
{
|
|
@@ -287,25 +294,43 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
287
294
|
"register_visitor",
|
|
288
295
|
"run_task",
|
|
289
296
|
"take_screenshots",
|
|
297
|
+
"start_charge_point_forwarding",
|
|
298
|
+
"stop_charge_point_forwarding",
|
|
290
299
|
"import_rfids_from_selected",
|
|
291
300
|
"export_rfids_to_selected",
|
|
301
|
+
"send_net_message",
|
|
292
302
|
]
|
|
293
303
|
inlines = [NodeFeatureAssignmentInline]
|
|
294
304
|
|
|
305
|
+
class SendNetMessageForm(forms.Form):
|
|
306
|
+
subject = forms.CharField(
|
|
307
|
+
label=_("Subject"),
|
|
308
|
+
max_length=NetMessage._meta.get_field("subject").max_length,
|
|
309
|
+
required=False,
|
|
310
|
+
)
|
|
311
|
+
body = forms.CharField(
|
|
312
|
+
label=_("Body"),
|
|
313
|
+
max_length=NetMessage._meta.get_field("body").max_length,
|
|
314
|
+
required=False,
|
|
315
|
+
widget=forms.Textarea(attrs={"rows": 4}),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def clean(self):
|
|
319
|
+
cleaned = super().clean()
|
|
320
|
+
subject = (cleaned.get("subject") or "").strip()
|
|
321
|
+
body = (cleaned.get("body") or "").strip()
|
|
322
|
+
if not subject and not body:
|
|
323
|
+
raise forms.ValidationError(
|
|
324
|
+
_("Enter a subject or body to send.")
|
|
325
|
+
)
|
|
326
|
+
cleaned["subject"] = subject
|
|
327
|
+
cleaned["body"] = body
|
|
328
|
+
return cleaned
|
|
329
|
+
|
|
295
330
|
@admin.display(description=_("Relation"), ordering="current_relation")
|
|
296
331
|
def relation(self, obj):
|
|
297
332
|
return obj.get_current_relation_display()
|
|
298
333
|
|
|
299
|
-
@admin.display(description=_("Proxy"))
|
|
300
|
-
def proxy_link(self, obj):
|
|
301
|
-
if not obj or obj.is_local:
|
|
302
|
-
return ""
|
|
303
|
-
try:
|
|
304
|
-
url = reverse("admin:nodes_node_proxy", args=[obj.pk])
|
|
305
|
-
except NoReverseMatch:
|
|
306
|
-
return ""
|
|
307
|
-
return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
|
|
308
|
-
|
|
309
334
|
@admin.display(description=_("Visit"))
|
|
310
335
|
def visit_link(self, obj):
|
|
311
336
|
if not obj:
|
|
@@ -321,12 +346,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
321
346
|
_("Visit"),
|
|
322
347
|
)
|
|
323
348
|
|
|
324
|
-
host_values
|
|
325
|
-
for attr in ("hostname", "address", "public_endpoint"):
|
|
326
|
-
value = getattr(obj, attr, "") or ""
|
|
327
|
-
cleaned = value.strip()
|
|
328
|
-
if cleaned and cleaned not in host_values:
|
|
329
|
-
host_values.append(cleaned)
|
|
349
|
+
host_values = obj.get_remote_host_candidates()
|
|
330
350
|
|
|
331
351
|
remote_url = ""
|
|
332
352
|
for host in host_values:
|
|
@@ -372,11 +392,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
372
392
|
self.admin_site.admin_view(self.update_selected_progress),
|
|
373
393
|
name="nodes_node_update_selected_progress",
|
|
374
394
|
),
|
|
375
|
-
path(
|
|
376
|
-
"<int:node_id>/proxy/",
|
|
377
|
-
self.admin_site.admin_view(self.proxy_node),
|
|
378
|
-
name="nodes_node_proxy",
|
|
379
|
-
),
|
|
380
395
|
]
|
|
381
396
|
return custom + urls
|
|
382
397
|
|
|
@@ -409,162 +424,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
409
424
|
)
|
|
410
425
|
return response
|
|
411
426
|
|
|
412
|
-
def _load_local_private_key(self, node):
|
|
413
|
-
security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
|
|
414
|
-
priv_path = security_dir / f"{node.public_endpoint}"
|
|
415
|
-
if not priv_path.exists():
|
|
416
|
-
return None, _("Local node private key not found.")
|
|
417
|
-
try:
|
|
418
|
-
return (
|
|
419
|
-
serialization.load_pem_private_key(
|
|
420
|
-
priv_path.read_bytes(), password=None
|
|
421
|
-
),
|
|
422
|
-
"",
|
|
423
|
-
)
|
|
424
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
425
|
-
return None, str(exc)
|
|
426
|
-
|
|
427
|
-
def _build_proxy_payload(self, request, local_node):
|
|
428
|
-
user = request.user
|
|
429
|
-
payload = {
|
|
430
|
-
"requester": str(local_node.uuid),
|
|
431
|
-
"user": {
|
|
432
|
-
"username": user.get_username(),
|
|
433
|
-
"email": user.email or "",
|
|
434
|
-
"first_name": user.first_name or "",
|
|
435
|
-
"last_name": user.last_name or "",
|
|
436
|
-
"is_staff": user.is_staff,
|
|
437
|
-
"is_superuser": user.is_superuser,
|
|
438
|
-
"groups": list(user.groups.values_list("name", flat=True)),
|
|
439
|
-
"permissions": sorted(user.get_all_permissions()),
|
|
440
|
-
},
|
|
441
|
-
"target": reverse("admin:index"),
|
|
442
|
-
}
|
|
443
|
-
mac_address = str(local_node.mac_address or "").strip()
|
|
444
|
-
if mac_address:
|
|
445
|
-
payload["requester_mac"] = mac_address
|
|
446
|
-
public_key = local_node.public_key
|
|
447
|
-
if public_key:
|
|
448
|
-
payload["requester_public_key"] = public_key
|
|
449
|
-
return payload
|
|
450
|
-
|
|
451
|
-
def _start_proxy_session(self, request, node):
|
|
452
|
-
if node.is_local:
|
|
453
|
-
return {"ok": False, "message": _("Local node cannot be proxied.")}
|
|
454
|
-
|
|
455
|
-
local_node = Node.get_local()
|
|
456
|
-
if local_node is None:
|
|
457
|
-
try:
|
|
458
|
-
local_node, _ = Node.register_current()
|
|
459
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
460
|
-
return {"ok": False, "message": str(exc)}
|
|
461
|
-
|
|
462
|
-
private_key, error = self._load_local_private_key(local_node)
|
|
463
|
-
if private_key is None:
|
|
464
|
-
return {"ok": False, "message": error}
|
|
465
|
-
|
|
466
|
-
payload = self._build_proxy_payload(request, local_node)
|
|
467
|
-
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
468
|
-
try:
|
|
469
|
-
signature = private_key.sign(
|
|
470
|
-
body.encode(),
|
|
471
|
-
padding.PKCS1v15(),
|
|
472
|
-
hashes.SHA256(),
|
|
473
|
-
)
|
|
474
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
475
|
-
return {"ok": False, "message": str(exc)}
|
|
476
|
-
|
|
477
|
-
headers = {
|
|
478
|
-
"Content-Type": "application/json",
|
|
479
|
-
"X-Signature": base64.b64encode(signature).decode(),
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
last_error = ""
|
|
483
|
-
redirect_codes = {301, 302, 303, 307, 308}
|
|
484
|
-
|
|
485
|
-
for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
|
|
486
|
-
candidate_url = url
|
|
487
|
-
redirects_followed = 0
|
|
488
|
-
success = False
|
|
489
|
-
|
|
490
|
-
while True:
|
|
491
|
-
try:
|
|
492
|
-
response = requests.post(
|
|
493
|
-
candidate_url,
|
|
494
|
-
data=body,
|
|
495
|
-
headers=headers,
|
|
496
|
-
timeout=5,
|
|
497
|
-
allow_redirects=False,
|
|
498
|
-
)
|
|
499
|
-
except RequestException as exc:
|
|
500
|
-
last_error = str(exc)
|
|
501
|
-
break
|
|
502
|
-
|
|
503
|
-
if response.status_code in redirect_codes:
|
|
504
|
-
location = response.headers.get("Location")
|
|
505
|
-
if not location:
|
|
506
|
-
last_error = f"{response.status_code} redirect missing Location header"
|
|
507
|
-
break
|
|
508
|
-
|
|
509
|
-
redirects_followed += 1
|
|
510
|
-
if redirects_followed > 3:
|
|
511
|
-
last_error = "Too many redirects"
|
|
512
|
-
break
|
|
513
|
-
|
|
514
|
-
candidate_url = urljoin(candidate_url, location)
|
|
515
|
-
continue
|
|
516
|
-
|
|
517
|
-
if not response.ok:
|
|
518
|
-
last_error = f"{response.status_code} {response.text}"
|
|
519
|
-
break
|
|
520
|
-
|
|
521
|
-
try:
|
|
522
|
-
data = response.json()
|
|
523
|
-
except ValueError:
|
|
524
|
-
last_error = "Invalid JSON response"
|
|
525
|
-
break
|
|
526
|
-
|
|
527
|
-
login_url = data.get("login_url")
|
|
528
|
-
if not login_url:
|
|
529
|
-
last_error = "login_url missing"
|
|
530
|
-
break
|
|
531
|
-
|
|
532
|
-
success = True
|
|
533
|
-
break
|
|
534
|
-
|
|
535
|
-
if success:
|
|
536
|
-
return {
|
|
537
|
-
"ok": True,
|
|
538
|
-
"login_url": login_url,
|
|
539
|
-
"expires": data.get("expires"),
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
return {
|
|
543
|
-
"ok": False,
|
|
544
|
-
"message": last_error or "Unable to initiate proxy.",
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
def proxy_node(self, request, node_id):
|
|
548
|
-
node = self.get_queryset(request).filter(pk=node_id).first()
|
|
549
|
-
if not node:
|
|
550
|
-
raise Http404
|
|
551
|
-
if not self.has_view_permission(request):
|
|
552
|
-
raise PermissionDenied
|
|
553
|
-
result = self._start_proxy_session(request, node)
|
|
554
|
-
if not result.get("ok"):
|
|
555
|
-
message = result.get("message") or _("Unable to proxy node.")
|
|
556
|
-
self.message_user(request, message, messages.ERROR)
|
|
557
|
-
return redirect("admin:nodes_node_changelist")
|
|
558
|
-
|
|
559
|
-
context = {
|
|
560
|
-
**self.admin_site.each_context(request),
|
|
561
|
-
"opts": self.model._meta,
|
|
562
|
-
"node": node,
|
|
563
|
-
"frame_url": result.get("login_url"),
|
|
564
|
-
"expires": result.get("expires"),
|
|
565
|
-
}
|
|
566
|
-
return TemplateResponse(request, "admin/nodes/node/proxy.html", context)
|
|
567
|
-
|
|
568
427
|
@admin.action(description="Register Visitor")
|
|
569
428
|
def register_visitor(self, request, queryset=None):
|
|
570
429
|
return self.register_visitor_view(request)
|
|
@@ -587,6 +446,75 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
587
446
|
request, "admin/nodes/node/update_selected.html", context
|
|
588
447
|
)
|
|
589
448
|
|
|
449
|
+
@admin.action(description=_("Send Net Message"))
|
|
450
|
+
def send_net_message(self, request, queryset):
|
|
451
|
+
is_submit = "apply" in request.POST
|
|
452
|
+
form = self.SendNetMessageForm(request.POST if is_submit else None)
|
|
453
|
+
selected_ids = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
|
|
454
|
+
if not selected_ids:
|
|
455
|
+
selected_ids = [str(pk) for pk in queryset.values_list("pk", flat=True)]
|
|
456
|
+
nodes: list[Node] = []
|
|
457
|
+
cleaned_ids: list[int] = []
|
|
458
|
+
for value in selected_ids:
|
|
459
|
+
try:
|
|
460
|
+
cleaned_ids.append(int(value))
|
|
461
|
+
except (TypeError, ValueError):
|
|
462
|
+
continue
|
|
463
|
+
if cleaned_ids:
|
|
464
|
+
base_queryset = self.get_queryset(request).filter(pk__in=cleaned_ids)
|
|
465
|
+
nodes_by_pk = {str(node.pk): node for node in base_queryset}
|
|
466
|
+
nodes = [nodes_by_pk[value] for value in selected_ids if value in nodes_by_pk]
|
|
467
|
+
if not nodes:
|
|
468
|
+
nodes = list(queryset)
|
|
469
|
+
selected_ids = [str(node.pk) for node in nodes]
|
|
470
|
+
if not nodes:
|
|
471
|
+
self.message_user(request, _("No nodes selected."), messages.INFO)
|
|
472
|
+
return None
|
|
473
|
+
if is_submit and form.is_valid():
|
|
474
|
+
subject = form.cleaned_data["subject"]
|
|
475
|
+
body = form.cleaned_data["body"]
|
|
476
|
+
created = 0
|
|
477
|
+
for node in nodes:
|
|
478
|
+
message = NetMessage.objects.create(
|
|
479
|
+
subject=subject,
|
|
480
|
+
body=body,
|
|
481
|
+
filter_node=node,
|
|
482
|
+
)
|
|
483
|
+
message.propagate()
|
|
484
|
+
created += 1
|
|
485
|
+
if created:
|
|
486
|
+
success_message = ngettext(
|
|
487
|
+
"Sent %(count)d net message.",
|
|
488
|
+
"Sent %(count)d net messages.",
|
|
489
|
+
created,
|
|
490
|
+
) % {"count": created}
|
|
491
|
+
self.message_user(request, success_message, messages.SUCCESS)
|
|
492
|
+
else:
|
|
493
|
+
self.message_user(
|
|
494
|
+
request, _("No net messages were sent."), messages.INFO
|
|
495
|
+
)
|
|
496
|
+
return None
|
|
497
|
+
context = {
|
|
498
|
+
**self.admin_site.each_context(request),
|
|
499
|
+
"opts": self.model._meta,
|
|
500
|
+
"title": _("Send Net Message"),
|
|
501
|
+
"nodes": nodes,
|
|
502
|
+
"selected_ids": selected_ids,
|
|
503
|
+
"action_name": request.POST.get("action", "send_net_message"),
|
|
504
|
+
"select_across": request.POST.get("select_across", "0"),
|
|
505
|
+
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
|
|
506
|
+
"adminform": helpers.AdminForm(
|
|
507
|
+
form,
|
|
508
|
+
[(None, {"fields": ("subject", "body")})],
|
|
509
|
+
{},
|
|
510
|
+
),
|
|
511
|
+
"form": form,
|
|
512
|
+
"media": self.media + form.media,
|
|
513
|
+
}
|
|
514
|
+
return TemplateResponse(
|
|
515
|
+
request, "admin/nodes/node/send_net_message.html", context
|
|
516
|
+
)
|
|
517
|
+
|
|
590
518
|
def update_selected_progress(self, request):
|
|
591
519
|
if request.method != "POST":
|
|
592
520
|
return JsonResponse({"detail": "POST required"}, status=405)
|
|
@@ -631,6 +559,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
631
559
|
}
|
|
632
560
|
|
|
633
561
|
last_error = ""
|
|
562
|
+
host_candidates = node.get_remote_host_candidates()
|
|
634
563
|
for url in self._iter_remote_urls(node, "/nodes/info/"):
|
|
635
564
|
try:
|
|
636
565
|
response = requests.get(url, timeout=5)
|
|
@@ -657,13 +586,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
657
586
|
"updated_fields": updated,
|
|
658
587
|
"message": message,
|
|
659
588
|
}
|
|
660
|
-
return {
|
|
589
|
+
return {
|
|
590
|
+
"ok": False,
|
|
591
|
+
"message": self._build_connectivity_hint(last_error, host_candidates),
|
|
592
|
+
}
|
|
661
593
|
|
|
662
594
|
def _apply_remote_node_info(self, node, payload):
|
|
663
595
|
changed = []
|
|
664
596
|
field_map = {
|
|
665
597
|
"hostname": payload.get("hostname"),
|
|
598
|
+
"network_hostname": payload.get("network_hostname"),
|
|
666
599
|
"address": payload.get("address"),
|
|
600
|
+
"ipv4_address": payload.get("ipv4_address"),
|
|
601
|
+
"ipv6_address": payload.get("ipv6_address"),
|
|
667
602
|
"public_key": payload.get("public_key"),
|
|
668
603
|
}
|
|
669
604
|
port_value = payload.get("port")
|
|
@@ -741,7 +676,10 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
741
676
|
|
|
742
677
|
payload = {
|
|
743
678
|
"hostname": local_node.hostname,
|
|
679
|
+
"network_hostname": local_node.network_hostname,
|
|
744
680
|
"address": local_node.address,
|
|
681
|
+
"ipv4_address": local_node.ipv4_address,
|
|
682
|
+
"ipv6_address": local_node.ipv6_address,
|
|
745
683
|
"port": local_node.port,
|
|
746
684
|
"mac_address": local_node.mac_address,
|
|
747
685
|
"public_key": local_node.public_key,
|
|
@@ -757,6 +695,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
757
695
|
headers = {"Content-Type": "application/json"}
|
|
758
696
|
|
|
759
697
|
last_error = ""
|
|
698
|
+
host_candidates = node.get_remote_host_candidates()
|
|
760
699
|
for url in self._iter_remote_urls(node, "/nodes/register/"):
|
|
761
700
|
try:
|
|
762
701
|
response = requests.post(
|
|
@@ -771,102 +710,52 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
771
710
|
if response.ok:
|
|
772
711
|
return {"ok": True, "url": url, "message": "Remote updated."}
|
|
773
712
|
last_error = f"{response.status_code} {response.text}"
|
|
774
|
-
return {
|
|
713
|
+
return {
|
|
714
|
+
"ok": False,
|
|
715
|
+
"message": self._build_connectivity_hint(last_error, host_candidates),
|
|
716
|
+
}
|
|
775
717
|
|
|
776
|
-
def
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
799
|
-
)
|
|
800
|
-
primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
|
|
801
|
-
if primary not in seen:
|
|
802
|
-
seen.add(primary)
|
|
803
|
-
yield primary
|
|
804
|
-
if parsed.scheme == "https":
|
|
805
|
-
fallback = urlunsplit(("http", netloc, combined_path, "", ""))
|
|
806
|
-
if fallback not in seen:
|
|
807
|
-
seen.add(fallback)
|
|
808
|
-
yield fallback
|
|
809
|
-
elif parsed.scheme == "http":
|
|
810
|
-
alternate = urlunsplit(("https", netloc, combined_path, "", ""))
|
|
811
|
-
if alternate not in seen:
|
|
812
|
-
seen.add(alternate)
|
|
813
|
-
yield alternate
|
|
718
|
+
def _build_connectivity_hint(self, last_error: str, hosts: list[str]) -> str:
|
|
719
|
+
base_message = last_error or _("Unable to reach remote node.")
|
|
720
|
+
if hosts:
|
|
721
|
+
host_text = ", ".join(hosts)
|
|
722
|
+
return _("%(message)s Tried hosts: %(hosts)s.") % {
|
|
723
|
+
"message": base_message,
|
|
724
|
+
"hosts": host_text,
|
|
725
|
+
}
|
|
726
|
+
return _("%(message)s No remote hosts were available for contact.") % {
|
|
727
|
+
"message": base_message
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
def _primary_remote_url(self, node, path: str) -> str:
|
|
731
|
+
return next(self._iter_remote_urls(node, path), "")
|
|
732
|
+
|
|
733
|
+
def _request_remote(self, node, path: str, request_callable):
|
|
734
|
+
errors: list[str] = []
|
|
735
|
+
for url in self._iter_remote_urls(node, path):
|
|
736
|
+
try:
|
|
737
|
+
response = request_callable(url)
|
|
738
|
+
except RequestException as exc:
|
|
739
|
+
errors.append(f"{url}: {exc}")
|
|
814
740
|
continue
|
|
741
|
+
return url, response, errors
|
|
742
|
+
return "", None, errors
|
|
815
743
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
_, _, path_tail = remainder.partition("/")
|
|
832
|
-
base_path = f"/{path_tail}".rstrip("/")
|
|
833
|
-
formatted_host = f"[{core_host}]"
|
|
834
|
-
else:
|
|
835
|
-
if "/" in host:
|
|
836
|
-
host_only, _, path_tail = host.partition("/")
|
|
837
|
-
formatted_host = host_only or host
|
|
838
|
-
base_path = f"/{path_tail}".rstrip("/")
|
|
839
|
-
try:
|
|
840
|
-
ip_obj = ipaddress.ip_address(formatted_host)
|
|
841
|
-
except ValueError:
|
|
842
|
-
parts = formatted_host.rsplit(":", 1)
|
|
843
|
-
if len(parts) == 2 and parts[1].isdigit():
|
|
844
|
-
formatted_host = parts[0]
|
|
845
|
-
port_override = int(parts[1])
|
|
846
|
-
try:
|
|
847
|
-
ip_obj = ipaddress.ip_address(formatted_host)
|
|
848
|
-
except ValueError:
|
|
849
|
-
ip_obj = None
|
|
850
|
-
else:
|
|
851
|
-
if ip_obj.version == 6 and not formatted_host.startswith("["):
|
|
852
|
-
formatted_host = f"[{formatted_host}]"
|
|
853
|
-
|
|
854
|
-
effective_port = port_override if port_override is not None else default_port
|
|
855
|
-
combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
856
|
-
|
|
857
|
-
for scheme, scheme_default_port in (("https", 443), ("http", 80)):
|
|
858
|
-
base = f"{scheme}://{formatted_host}"
|
|
859
|
-
if effective_port and (
|
|
860
|
-
port_override is not None or effective_port != scheme_default_port
|
|
861
|
-
):
|
|
862
|
-
explicit = f"{base}:{effective_port}{combined_path}"
|
|
863
|
-
if explicit not in seen:
|
|
864
|
-
seen.add(explicit)
|
|
865
|
-
yield explicit
|
|
866
|
-
candidate = f"{base}{combined_path}"
|
|
867
|
-
if candidate not in seen:
|
|
868
|
-
seen.add(candidate)
|
|
869
|
-
yield candidate
|
|
744
|
+
def _iter_remote_urls(self, node, path):
|
|
745
|
+
if hasattr(node, "iter_remote_urls"):
|
|
746
|
+
yield from node.iter_remote_urls(path)
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
temp = Node(
|
|
750
|
+
public_endpoint=getattr(node, "public_endpoint", ""),
|
|
751
|
+
address=getattr(node, "address", ""),
|
|
752
|
+
hostname=getattr(node, "hostname", ""),
|
|
753
|
+
port=getattr(node, "port", None),
|
|
754
|
+
)
|
|
755
|
+
temp.network_hostname = getattr(node, "network_hostname", "")
|
|
756
|
+
temp.ipv4_address = getattr(node, "ipv4_address", "")
|
|
757
|
+
temp.ipv6_address = getattr(node, "ipv6_address", "")
|
|
758
|
+
yield from temp.iter_remote_urls(path)
|
|
870
759
|
|
|
871
760
|
def register_visitor_view(self, request):
|
|
872
761
|
"""Exchange registration data with the visiting node."""
|
|
@@ -940,11 +829,28 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
940
829
|
for node in queryset:
|
|
941
830
|
for source in sources:
|
|
942
831
|
try:
|
|
943
|
-
|
|
832
|
+
contact_host = node.get_primary_contact()
|
|
833
|
+
url = source.format(
|
|
834
|
+
node=node, address=contact_host, port=node.port
|
|
835
|
+
)
|
|
944
836
|
except Exception:
|
|
945
837
|
url = source
|
|
946
838
|
if not url.startswith("http"):
|
|
947
|
-
|
|
839
|
+
candidate = next(
|
|
840
|
+
self._iter_remote_urls(node, url),
|
|
841
|
+
"",
|
|
842
|
+
)
|
|
843
|
+
if not candidate:
|
|
844
|
+
self.message_user(
|
|
845
|
+
request,
|
|
846
|
+
_(
|
|
847
|
+
"No reachable host was available for %(node)s while generating %(path)s"
|
|
848
|
+
)
|
|
849
|
+
% {"node": node, "path": url},
|
|
850
|
+
messages.WARNING,
|
|
851
|
+
)
|
|
852
|
+
continue
|
|
853
|
+
url = candidate
|
|
948
854
|
try:
|
|
949
855
|
path = capture_screenshot(url)
|
|
950
856
|
except Exception as exc: # pragma: no cover - selenium issues
|
|
@@ -1064,12 +970,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1064
970
|
|
|
1065
971
|
def _process_import_from_node(self, node, payload, headers):
|
|
1066
972
|
result = self._init_rfid_result(node)
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
973
|
+
_, response, attempt_errors = self._request_remote(
|
|
974
|
+
node,
|
|
975
|
+
"/nodes/rfid/export/",
|
|
976
|
+
lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
|
|
977
|
+
)
|
|
978
|
+
if response is None:
|
|
1071
979
|
result["status"] = "error"
|
|
1072
|
-
|
|
980
|
+
if attempt_errors:
|
|
981
|
+
result["errors"].extend(attempt_errors)
|
|
982
|
+
else:
|
|
983
|
+
result["errors"].append(
|
|
984
|
+
_("No remote hosts were available for %(node)s.") % {"node": node}
|
|
985
|
+
)
|
|
1073
986
|
return result
|
|
1074
987
|
|
|
1075
988
|
if response.status_code != 200:
|
|
@@ -1109,12 +1022,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1109
1022
|
|
|
1110
1023
|
def _post_export_to_node(self, node, payload, headers):
|
|
1111
1024
|
result = self._init_rfid_result(node)
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1025
|
+
_, response, attempt_errors = self._request_remote(
|
|
1026
|
+
node,
|
|
1027
|
+
"/nodes/rfid/import/",
|
|
1028
|
+
lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
|
|
1029
|
+
)
|
|
1030
|
+
if response is None:
|
|
1116
1031
|
result["status"] = "error"
|
|
1117
|
-
|
|
1032
|
+
if attempt_errors:
|
|
1033
|
+
result["errors"].extend(attempt_errors)
|
|
1034
|
+
else:
|
|
1035
|
+
result["errors"].append(
|
|
1036
|
+
_("No remote hosts were available for %(node)s.") % {"node": node}
|
|
1037
|
+
)
|
|
1118
1038
|
return result
|
|
1119
1039
|
|
|
1120
1040
|
if response.status_code != 200:
|
|
@@ -1230,6 +1150,259 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1230
1150
|
|
|
1231
1151
|
return self._render_rfid_sync(request, "export", results)
|
|
1232
1152
|
|
|
1153
|
+
async def _probe_websocket(self, url: str) -> bool:
|
|
1154
|
+
try:
|
|
1155
|
+
async with websockets.connect(url, open_timeout=3, close_timeout=1):
|
|
1156
|
+
return True
|
|
1157
|
+
except Exception:
|
|
1158
|
+
return False
|
|
1159
|
+
|
|
1160
|
+
def _attempt_forwarding_probe(self, node, charger_id: str) -> bool:
|
|
1161
|
+
if not charger_id:
|
|
1162
|
+
return False
|
|
1163
|
+
safe_id = quote(str(charger_id))
|
|
1164
|
+
candidates: list[str] = []
|
|
1165
|
+
for base in node.iter_remote_urls("/"):
|
|
1166
|
+
parsed = urlsplit(base)
|
|
1167
|
+
if parsed.scheme not in {"http", "https"}:
|
|
1168
|
+
continue
|
|
1169
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
1170
|
+
base_path = parsed.path.rstrip("/")
|
|
1171
|
+
for prefix in ("", "/ws"):
|
|
1172
|
+
path = f"{base_path}{prefix}/{safe_id}".replace("//", "/")
|
|
1173
|
+
if not path.startswith("/"):
|
|
1174
|
+
path = f"/{path}"
|
|
1175
|
+
candidates.append(urlunsplit((scheme, parsed.netloc, path, "", "")))
|
|
1176
|
+
|
|
1177
|
+
for url in candidates:
|
|
1178
|
+
loop = asyncio.new_event_loop()
|
|
1179
|
+
try:
|
|
1180
|
+
result = loop.run_until_complete(self._probe_websocket(url))
|
|
1181
|
+
except Exception:
|
|
1182
|
+
result = False
|
|
1183
|
+
finally:
|
|
1184
|
+
loop.close()
|
|
1185
|
+
if result:
|
|
1186
|
+
return True
|
|
1187
|
+
return False
|
|
1188
|
+
|
|
1189
|
+
def _send_forwarding_metadata(
|
|
1190
|
+
self,
|
|
1191
|
+
request,
|
|
1192
|
+
node: Node,
|
|
1193
|
+
chargers: list[Charger],
|
|
1194
|
+
local_node: Node,
|
|
1195
|
+
private_key,
|
|
1196
|
+
) -> bool:
|
|
1197
|
+
if not chargers:
|
|
1198
|
+
return True
|
|
1199
|
+
payload = {
|
|
1200
|
+
"requester": str(local_node.uuid),
|
|
1201
|
+
"requester_mac": local_node.mac_address,
|
|
1202
|
+
"requester_public_key": local_node.public_key,
|
|
1203
|
+
"chargers": [serialize_charger_for_network(charger) for charger in chargers],
|
|
1204
|
+
"transactions": {"chargers": [], "transactions": []},
|
|
1205
|
+
}
|
|
1206
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1207
|
+
signature = self._sign_payload(private_key, payload_json)
|
|
1208
|
+
headers = {"Content-Type": "application/json"}
|
|
1209
|
+
if signature:
|
|
1210
|
+
headers["X-Signature"] = signature
|
|
1211
|
+
|
|
1212
|
+
url = next(node.iter_remote_urls("/nodes/network/chargers/forward/"), "")
|
|
1213
|
+
if not url:
|
|
1214
|
+
self.message_user(
|
|
1215
|
+
request,
|
|
1216
|
+
_("No reachable host found for %(node)s.") % {"node": node},
|
|
1217
|
+
level=messages.WARNING,
|
|
1218
|
+
)
|
|
1219
|
+
return False
|
|
1220
|
+
try:
|
|
1221
|
+
response = requests.post(url, data=payload_json, headers=headers, timeout=5)
|
|
1222
|
+
except RequestException as exc:
|
|
1223
|
+
self.message_user(
|
|
1224
|
+
request,
|
|
1225
|
+
_("Failed to send forwarding metadata to %(node)s (%(error)s).")
|
|
1226
|
+
% {"node": node, "error": exc},
|
|
1227
|
+
level=messages.WARNING,
|
|
1228
|
+
)
|
|
1229
|
+
return False
|
|
1230
|
+
|
|
1231
|
+
try:
|
|
1232
|
+
data = response.json()
|
|
1233
|
+
except ValueError:
|
|
1234
|
+
data = {}
|
|
1235
|
+
|
|
1236
|
+
if not response.ok or not isinstance(data, dict) or data.get("status") != "ok":
|
|
1237
|
+
detail = ""
|
|
1238
|
+
if isinstance(data, dict):
|
|
1239
|
+
detail = data.get("detail") or ""
|
|
1240
|
+
message = _("Forwarding metadata to %(node)s failed: %(status)s %(detail)s") % {
|
|
1241
|
+
"node": node,
|
|
1242
|
+
"status": response.status_code,
|
|
1243
|
+
"detail": detail,
|
|
1244
|
+
}
|
|
1245
|
+
self.message_user(request, message.strip(), level=messages.WARNING)
|
|
1246
|
+
return False
|
|
1247
|
+
|
|
1248
|
+
return True
|
|
1249
|
+
|
|
1250
|
+
@admin.action(description=_("Start Charge Point Forwarding"))
|
|
1251
|
+
def start_charge_point_forwarding(self, request, queryset):
|
|
1252
|
+
if queryset.count() != 1:
|
|
1253
|
+
self.message_user(
|
|
1254
|
+
request,
|
|
1255
|
+
_("Select a single remote node."),
|
|
1256
|
+
level=messages.ERROR,
|
|
1257
|
+
)
|
|
1258
|
+
return
|
|
1259
|
+
|
|
1260
|
+
target = queryset.first()
|
|
1261
|
+
local_node, private_key, error = self._load_local_node_credentials()
|
|
1262
|
+
if error:
|
|
1263
|
+
self.message_user(request, error, level=messages.ERROR)
|
|
1264
|
+
return
|
|
1265
|
+
|
|
1266
|
+
if local_node.pk and target.pk == local_node.pk:
|
|
1267
|
+
self.message_user(
|
|
1268
|
+
request,
|
|
1269
|
+
_("Cannot forward charge points to the local node."),
|
|
1270
|
+
level=messages.ERROR,
|
|
1271
|
+
)
|
|
1272
|
+
return
|
|
1273
|
+
|
|
1274
|
+
eligible = Charger.objects.filter(export_transactions=True)
|
|
1275
|
+
if local_node.pk:
|
|
1276
|
+
eligible = eligible.filter(
|
|
1277
|
+
Q(node_origin=local_node) | Q(node_origin__isnull=True)
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
chargers = list(eligible.select_related("forwarded_to"))
|
|
1281
|
+
if not chargers:
|
|
1282
|
+
self.message_user(
|
|
1283
|
+
request,
|
|
1284
|
+
_("No eligible charge points available for forwarding."),
|
|
1285
|
+
level=messages.WARNING,
|
|
1286
|
+
)
|
|
1287
|
+
return
|
|
1288
|
+
|
|
1289
|
+
conflicts = [
|
|
1290
|
+
charger
|
|
1291
|
+
for charger in chargers
|
|
1292
|
+
if charger.forwarded_to_id
|
|
1293
|
+
and charger.forwarded_to_id not in {None, target.pk}
|
|
1294
|
+
]
|
|
1295
|
+
if conflicts:
|
|
1296
|
+
self.message_user(
|
|
1297
|
+
request,
|
|
1298
|
+
ngettext(
|
|
1299
|
+
"Skipped %(count)s charge point already forwarded to another node.",
|
|
1300
|
+
"Skipped %(count)s charge points already forwarded to another node.",
|
|
1301
|
+
len(conflicts),
|
|
1302
|
+
)
|
|
1303
|
+
% {"count": len(conflicts)},
|
|
1304
|
+
level=messages.WARNING,
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
chargers_to_update = [
|
|
1308
|
+
charger
|
|
1309
|
+
for charger in chargers
|
|
1310
|
+
if charger.forwarded_to_id in (None, target.pk)
|
|
1311
|
+
]
|
|
1312
|
+
if not chargers_to_update:
|
|
1313
|
+
self.message_user(
|
|
1314
|
+
request,
|
|
1315
|
+
_("No charge points were updated."),
|
|
1316
|
+
level=messages.WARNING,
|
|
1317
|
+
)
|
|
1318
|
+
return
|
|
1319
|
+
|
|
1320
|
+
charger_pks = [c.pk for c in chargers_to_update]
|
|
1321
|
+
Charger.objects.filter(pk__in=charger_pks).update(forwarded_to=target)
|
|
1322
|
+
|
|
1323
|
+
for charger in chargers_to_update:
|
|
1324
|
+
charger.forwarded_to = target
|
|
1325
|
+
|
|
1326
|
+
sample = next((charger for charger in chargers_to_update if charger.charger_id), None)
|
|
1327
|
+
if sample and not self._attempt_forwarding_probe(target, sample.charger_id):
|
|
1328
|
+
self.message_user(
|
|
1329
|
+
request,
|
|
1330
|
+
_(
|
|
1331
|
+
"Unable to establish a websocket connection to %(node)s for charge point %(charger)s."
|
|
1332
|
+
)
|
|
1333
|
+
% {"node": target, "charger": sample.charger_id},
|
|
1334
|
+
level=messages.WARNING,
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
success = self._send_forwarding_metadata(
|
|
1338
|
+
request, target, chargers_to_update, local_node, private_key
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
if success:
|
|
1342
|
+
now = timezone.now()
|
|
1343
|
+
Charger.objects.filter(pk__in=charger_pks).update(
|
|
1344
|
+
forwarding_watermark=now
|
|
1345
|
+
)
|
|
1346
|
+
self.message_user(
|
|
1347
|
+
request,
|
|
1348
|
+
ngettext(
|
|
1349
|
+
"Forwarding enabled for %(count)s charge point.",
|
|
1350
|
+
"Forwarding enabled for %(count)s charge points.",
|
|
1351
|
+
len(chargers_to_update),
|
|
1352
|
+
)
|
|
1353
|
+
% {"count": len(chargers_to_update)},
|
|
1354
|
+
level=messages.SUCCESS,
|
|
1355
|
+
)
|
|
1356
|
+
else:
|
|
1357
|
+
self.message_user(
|
|
1358
|
+
request,
|
|
1359
|
+
ngettext(
|
|
1360
|
+
"Marked %(count)s charge point for forwarding; awaiting remote acknowledgment.",
|
|
1361
|
+
"Marked %(count)s charge points for forwarding; awaiting remote acknowledgment.",
|
|
1362
|
+
len(chargers_to_update),
|
|
1363
|
+
)
|
|
1364
|
+
% {"count": len(chargers_to_update)},
|
|
1365
|
+
level=messages.INFO,
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
try:
|
|
1369
|
+
push_forwarded_charge_points.delay()
|
|
1370
|
+
except Exception:
|
|
1371
|
+
pass
|
|
1372
|
+
|
|
1373
|
+
@admin.action(description=_("Stop Charge Point Forwarding"))
|
|
1374
|
+
def stop_charge_point_forwarding(self, request, queryset):
|
|
1375
|
+
node_ids = [node.pk for node in queryset if node.pk]
|
|
1376
|
+
if not node_ids:
|
|
1377
|
+
self.message_user(
|
|
1378
|
+
request,
|
|
1379
|
+
_("No remote nodes selected."),
|
|
1380
|
+
level=messages.WARNING,
|
|
1381
|
+
)
|
|
1382
|
+
return
|
|
1383
|
+
|
|
1384
|
+
cleared = Charger.objects.filter(forwarded_to_id__in=node_ids).update(
|
|
1385
|
+
forwarded_to=None, forwarding_watermark=None
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
if cleared:
|
|
1389
|
+
self.message_user(
|
|
1390
|
+
request,
|
|
1391
|
+
ngettext(
|
|
1392
|
+
"Stopped forwarding for %(count)s charge point.",
|
|
1393
|
+
"Stopped forwarding for %(count)s charge points.",
|
|
1394
|
+
cleared,
|
|
1395
|
+
)
|
|
1396
|
+
% {"count": cleared},
|
|
1397
|
+
level=messages.SUCCESS,
|
|
1398
|
+
)
|
|
1399
|
+
else:
|
|
1400
|
+
self.message_user(
|
|
1401
|
+
request,
|
|
1402
|
+
_("No forwarded charge points were updated."),
|
|
1403
|
+
level=messages.WARNING,
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1233
1406
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
1234
1407
|
extra_context = extra_context or {}
|
|
1235
1408
|
if object_id:
|