arthexis 0.1.16__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.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -8,27 +8,31 @@ from django.contrib.admin import helpers
|
|
|
8
8
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
9
9
|
from django.core.exceptions import PermissionDenied
|
|
10
10
|
from django.db.models import Count
|
|
11
|
-
from django.http import HttpResponse, JsonResponse
|
|
11
|
+
from django.http import Http404, HttpResponse, JsonResponse
|
|
12
12
|
from django.shortcuts import redirect, render
|
|
13
13
|
from django.template.response import TemplateResponse
|
|
14
|
+
from django.test import signals
|
|
14
15
|
from django.urls import NoReverseMatch, path, reverse
|
|
15
16
|
from django.utils import timezone
|
|
16
17
|
from django.utils.dateparse import parse_datetime
|
|
17
18
|
from django.utils.html import format_html, format_html_join
|
|
18
|
-
from django.utils.translation import gettext_lazy as _
|
|
19
|
+
from django.utils.translation import gettext_lazy as _, ngettext
|
|
19
20
|
from pathlib import Path
|
|
20
|
-
from
|
|
21
|
+
from types import SimpleNamespace
|
|
22
|
+
from urllib.parse import urlsplit, urlunsplit, quote
|
|
21
23
|
import base64
|
|
22
24
|
import json
|
|
23
25
|
import subprocess
|
|
24
26
|
import uuid
|
|
25
27
|
|
|
28
|
+
import asyncio
|
|
26
29
|
import pyperclip
|
|
27
30
|
import requests
|
|
28
31
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
29
32
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
30
33
|
from pyperclip import PyperclipException
|
|
31
34
|
from requests import RequestException
|
|
35
|
+
import websockets
|
|
32
36
|
|
|
33
37
|
from .classifiers import run_default_classifiers, suppress_default_classifiers
|
|
34
38
|
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
@@ -57,6 +61,9 @@ from .models import (
|
|
|
57
61
|
)
|
|
58
62
|
from . import dns as dns_utils
|
|
59
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
|
|
60
67
|
from core.user_data import EntityModelAdmin
|
|
61
68
|
|
|
62
69
|
|
|
@@ -84,7 +91,7 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
|
|
|
84
91
|
|
|
85
92
|
class DeployDNSRecordsForm(forms.Form):
|
|
86
93
|
manager = forms.ModelChoiceField(
|
|
87
|
-
label="Node
|
|
94
|
+
label="Node Profile",
|
|
88
95
|
queryset=NodeManager.objects.none(),
|
|
89
96
|
help_text="Credentials used to authenticate with the DNS provider.",
|
|
90
97
|
)
|
|
@@ -227,31 +234,35 @@ class DNSRecordAdmin(EntityModelAdmin):
|
|
|
227
234
|
class NodeAdmin(EntityModelAdmin):
|
|
228
235
|
list_display = (
|
|
229
236
|
"hostname",
|
|
230
|
-
"
|
|
231
|
-
"address",
|
|
237
|
+
"primary_ip",
|
|
232
238
|
"port",
|
|
233
239
|
"role",
|
|
234
240
|
"relation",
|
|
235
241
|
"last_seen",
|
|
242
|
+
"visit_link",
|
|
236
243
|
)
|
|
237
|
-
search_fields = ("hostname", "address", "mac_address")
|
|
244
|
+
search_fields = ("hostname", "network_hostname", "address", "mac_address")
|
|
238
245
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
239
246
|
change_form_template = "admin/nodes/node/change_form.html"
|
|
240
247
|
form = NodeAdminForm
|
|
241
248
|
fieldsets = (
|
|
242
249
|
(
|
|
243
|
-
_("
|
|
250
|
+
_("Network"),
|
|
244
251
|
{
|
|
245
252
|
"fields": (
|
|
246
253
|
"hostname",
|
|
254
|
+
"network_hostname",
|
|
255
|
+
"ipv4_address",
|
|
256
|
+
"ipv6_address",
|
|
247
257
|
"address",
|
|
248
258
|
"mac_address",
|
|
249
259
|
"port",
|
|
250
|
-
"
|
|
260
|
+
"message_queue_length",
|
|
251
261
|
"current_relation",
|
|
252
262
|
)
|
|
253
263
|
},
|
|
254
264
|
),
|
|
265
|
+
(_("Role"), {"fields": ("role",)}),
|
|
255
266
|
(
|
|
256
267
|
_("Public endpoint"),
|
|
257
268
|
{
|
|
@@ -281,15 +292,87 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
281
292
|
"register_visitor",
|
|
282
293
|
"run_task",
|
|
283
294
|
"take_screenshots",
|
|
295
|
+
"start_charge_point_forwarding",
|
|
296
|
+
"stop_charge_point_forwarding",
|
|
284
297
|
"import_rfids_from_selected",
|
|
285
298
|
"export_rfids_to_selected",
|
|
299
|
+
"send_net_message",
|
|
286
300
|
]
|
|
287
301
|
inlines = [NodeFeatureAssignmentInline]
|
|
288
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
|
+
|
|
289
328
|
@admin.display(description=_("Relation"), ordering="current_relation")
|
|
290
329
|
def relation(self, obj):
|
|
291
330
|
return obj.get_current_relation_display()
|
|
292
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
|
+
|
|
293
376
|
def get_urls(self):
|
|
294
377
|
urls = super().get_urls()
|
|
295
378
|
custom = [
|
|
@@ -330,7 +413,20 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
330
413
|
"token": token,
|
|
331
414
|
"register_url": reverse("register-node"),
|
|
332
415
|
}
|
|
333
|
-
|
|
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
|
|
334
430
|
|
|
335
431
|
@admin.action(description="Register Visitor")
|
|
336
432
|
def register_visitor(self, request, queryset=None):
|
|
@@ -354,6 +450,75 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
354
450
|
request, "admin/nodes/node/update_selected.html", context
|
|
355
451
|
)
|
|
356
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
|
+
|
|
357
522
|
def update_selected_progress(self, request):
|
|
358
523
|
if request.method != "POST":
|
|
359
524
|
return JsonResponse({"detail": "POST required"}, status=405)
|
|
@@ -398,6 +563,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
398
563
|
}
|
|
399
564
|
|
|
400
565
|
last_error = ""
|
|
566
|
+
host_candidates = node.get_remote_host_candidates()
|
|
401
567
|
for url in self._iter_remote_urls(node, "/nodes/info/"):
|
|
402
568
|
try:
|
|
403
569
|
response = requests.get(url, timeout=5)
|
|
@@ -424,13 +590,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
424
590
|
"updated_fields": updated,
|
|
425
591
|
"message": message,
|
|
426
592
|
}
|
|
427
|
-
return {
|
|
593
|
+
return {
|
|
594
|
+
"ok": False,
|
|
595
|
+
"message": self._build_connectivity_hint(last_error, host_candidates),
|
|
596
|
+
}
|
|
428
597
|
|
|
429
598
|
def _apply_remote_node_info(self, node, payload):
|
|
430
599
|
changed = []
|
|
431
600
|
field_map = {
|
|
432
601
|
"hostname": payload.get("hostname"),
|
|
602
|
+
"network_hostname": payload.get("network_hostname"),
|
|
433
603
|
"address": payload.get("address"),
|
|
604
|
+
"ipv4_address": payload.get("ipv4_address"),
|
|
605
|
+
"ipv6_address": payload.get("ipv6_address"),
|
|
434
606
|
"public_key": payload.get("public_key"),
|
|
435
607
|
}
|
|
436
608
|
port_value = payload.get("port")
|
|
@@ -451,6 +623,17 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
451
623
|
setattr(node, field, value)
|
|
452
624
|
changed.append(field)
|
|
453
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
|
+
|
|
454
637
|
node.last_seen = timezone.now()
|
|
455
638
|
if "last_seen" not in changed:
|
|
456
639
|
changed.append("last_seen")
|
|
@@ -497,7 +680,10 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
497
680
|
|
|
498
681
|
payload = {
|
|
499
682
|
"hostname": local_node.hostname,
|
|
683
|
+
"network_hostname": local_node.network_hostname,
|
|
500
684
|
"address": local_node.address,
|
|
685
|
+
"ipv4_address": local_node.ipv4_address,
|
|
686
|
+
"ipv6_address": local_node.ipv6_address,
|
|
501
687
|
"port": local_node.port,
|
|
502
688
|
"mac_address": local_node.mac_address,
|
|
503
689
|
"public_key": local_node.public_key,
|
|
@@ -513,6 +699,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
513
699
|
headers = {"Content-Type": "application/json"}
|
|
514
700
|
|
|
515
701
|
last_error = ""
|
|
702
|
+
host_candidates = node.get_remote_host_candidates()
|
|
516
703
|
for url in self._iter_remote_urls(node, "/nodes/register/"):
|
|
517
704
|
try:
|
|
518
705
|
response = requests.post(
|
|
@@ -527,46 +714,52 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
527
714
|
if response.ok:
|
|
528
715
|
return {"ok": True, "url": url, "message": "Remote updated."}
|
|
529
716
|
last_error = f"{response.status_code} {response.text}"
|
|
530
|
-
return {
|
|
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
|
|
531
747
|
|
|
532
748
|
def _iter_remote_urls(self, node, path):
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
value = value.strip()
|
|
537
|
-
if value and value not in host_candidates:
|
|
538
|
-
host_candidates.append(value)
|
|
539
|
-
|
|
540
|
-
port = node.port or 8000
|
|
541
|
-
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
542
|
-
seen = set()
|
|
543
|
-
|
|
544
|
-
for host in host_candidates:
|
|
545
|
-
formatted_host = host
|
|
546
|
-
if ":" in host and not host.startswith("["):
|
|
547
|
-
formatted_host = f"[{host}]"
|
|
548
|
-
|
|
549
|
-
candidates = []
|
|
550
|
-
if port == 80:
|
|
551
|
-
candidates = [
|
|
552
|
-
f"http://{formatted_host}{normalized_path}",
|
|
553
|
-
f"https://{formatted_host}{normalized_path}",
|
|
554
|
-
]
|
|
555
|
-
elif port == 443:
|
|
556
|
-
candidates = [
|
|
557
|
-
f"https://{formatted_host}{normalized_path}",
|
|
558
|
-
f"http://{formatted_host}:{port}{normalized_path}",
|
|
559
|
-
]
|
|
560
|
-
else:
|
|
561
|
-
candidates = [
|
|
562
|
-
f"http://{formatted_host}:{port}{normalized_path}",
|
|
563
|
-
f"https://{formatted_host}:{port}{normalized_path}",
|
|
564
|
-
]
|
|
749
|
+
if hasattr(node, "iter_remote_urls"):
|
|
750
|
+
yield from node.iter_remote_urls(path)
|
|
751
|
+
return
|
|
565
752
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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)
|
|
570
763
|
|
|
571
764
|
def register_visitor_view(self, request):
|
|
572
765
|
"""Exchange registration data with the visiting node."""
|
|
@@ -640,11 +833,28 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
640
833
|
for node in queryset:
|
|
641
834
|
for source in sources:
|
|
642
835
|
try:
|
|
643
|
-
|
|
836
|
+
contact_host = node.get_primary_contact()
|
|
837
|
+
url = source.format(
|
|
838
|
+
node=node, address=contact_host, port=node.port
|
|
839
|
+
)
|
|
644
840
|
except Exception:
|
|
645
841
|
url = source
|
|
646
842
|
if not url.startswith("http"):
|
|
647
|
-
|
|
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
|
|
648
858
|
try:
|
|
649
859
|
path = capture_screenshot(url)
|
|
650
860
|
except Exception as exc: # pragma: no cover - selenium issues
|
|
@@ -742,6 +952,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
742
952
|
def _render_rfid_sync(self, request, operation, results, setup_error=None):
|
|
743
953
|
titles = {
|
|
744
954
|
"import": _("Import RFID results"),
|
|
955
|
+
"fetch": _("Fetch RFID results"),
|
|
745
956
|
"export": _("Export RFID results"),
|
|
746
957
|
}
|
|
747
958
|
summary = self._summarize_rfid_results(results)
|
|
@@ -763,12 +974,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
763
974
|
|
|
764
975
|
def _process_import_from_node(self, node, payload, headers):
|
|
765
976
|
result = self._init_rfid_result(node)
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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:
|
|
770
983
|
result["status"] = "error"
|
|
771
|
-
|
|
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
|
+
)
|
|
772
990
|
return result
|
|
773
991
|
|
|
774
992
|
if response.status_code != 200:
|
|
@@ -808,12 +1026,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
808
1026
|
|
|
809
1027
|
def _post_export_to_node(self, node, payload, headers):
|
|
810
1028
|
result = self._init_rfid_result(node)
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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:
|
|
815
1035
|
result["status"] = "error"
|
|
816
|
-
|
|
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
|
+
)
|
|
817
1042
|
return result
|
|
818
1043
|
|
|
819
1044
|
if response.status_code != 200:
|
|
@@ -851,13 +1076,14 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
851
1076
|
result["status"] = self._status_from_result(result)
|
|
852
1077
|
return result
|
|
853
1078
|
|
|
854
|
-
|
|
855
|
-
def import_rfids_from_selected(self, request, queryset):
|
|
1079
|
+
def _run_rfid_import(self, request, queryset):
|
|
856
1080
|
nodes = list(queryset)
|
|
857
1081
|
local_node, private_key, error = self._load_local_node_credentials()
|
|
858
1082
|
if error:
|
|
859
1083
|
results = [self._skip_result(node, error) for node in nodes]
|
|
860
|
-
return self._render_rfid_sync(
|
|
1084
|
+
return self._render_rfid_sync(
|
|
1085
|
+
request, "import", results, setup_error=error
|
|
1086
|
+
)
|
|
861
1087
|
|
|
862
1088
|
if not nodes:
|
|
863
1089
|
return self._render_rfid_sync(
|
|
@@ -887,6 +1113,10 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
887
1113
|
|
|
888
1114
|
return self._render_rfid_sync(request, "import", results)
|
|
889
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
|
+
|
|
890
1120
|
@admin.action(description=_("Export RFIDs to selected"))
|
|
891
1121
|
def export_rfids_to_selected(self, request, queryset):
|
|
892
1122
|
nodes = list(queryset)
|
|
@@ -924,6 +1154,269 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
924
1154
|
|
|
925
1155
|
return self._render_rfid_sync(request, "export", results)
|
|
926
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
|
+
|
|
927
1420
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
928
1421
|
extra_context = extra_context or {}
|
|
929
1422
|
if object_id:
|
|
@@ -1565,7 +2058,7 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1565
2058
|
search_fields = ("subject", "body")
|
|
1566
2059
|
list_filter = ("complete", "filter_node_role", "filter_current_relation")
|
|
1567
2060
|
ordering = ("-created",)
|
|
1568
|
-
readonly_fields = ("complete",
|
|
2061
|
+
readonly_fields = ("complete",)
|
|
1569
2062
|
actions = ["send_messages"]
|
|
1570
2063
|
fieldsets = (
|
|
1571
2064
|
(None, {"fields": ("subject", "body")}),
|
|
@@ -1590,7 +2083,6 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1590
2083
|
"node_origin",
|
|
1591
2084
|
"target_limit",
|
|
1592
2085
|
"propagated_to",
|
|
1593
|
-
"confirmed_peers",
|
|
1594
2086
|
"complete",
|
|
1595
2087
|
)
|
|
1596
2088
|
},
|