arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- 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 +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- 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.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -8,27 +8,33 @@ 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
|
|
24
|
+
import binascii
|
|
22
25
|
import json
|
|
23
26
|
import subprocess
|
|
24
27
|
import uuid
|
|
25
28
|
|
|
29
|
+
import asyncio
|
|
26
30
|
import pyperclip
|
|
27
31
|
import requests
|
|
28
32
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
29
33
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
30
34
|
from pyperclip import PyperclipException
|
|
31
35
|
from requests import RequestException
|
|
36
|
+
import websockets
|
|
37
|
+
from asgiref.sync import async_to_sync
|
|
32
38
|
|
|
33
39
|
from .classifiers import run_default_classifiers, suppress_default_classifiers
|
|
34
40
|
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
@@ -57,6 +63,15 @@ from .models import (
|
|
|
57
63
|
)
|
|
58
64
|
from . import dns as dns_utils
|
|
59
65
|
from core.models import RFID
|
|
66
|
+
from ocpp import store
|
|
67
|
+
from ocpp.models import (
|
|
68
|
+
Charger,
|
|
69
|
+
CPFirmware,
|
|
70
|
+
CPFirmwareDeployment,
|
|
71
|
+
DataTransferMessage,
|
|
72
|
+
)
|
|
73
|
+
from ocpp.network import serialize_charger_for_network
|
|
74
|
+
from ocpp.tasks import push_forwarded_charge_points
|
|
60
75
|
from core.user_data import EntityModelAdmin
|
|
61
76
|
|
|
62
77
|
|
|
@@ -84,7 +99,7 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
|
|
|
84
99
|
|
|
85
100
|
class DeployDNSRecordsForm(forms.Form):
|
|
86
101
|
manager = forms.ModelChoiceField(
|
|
87
|
-
label="Node
|
|
102
|
+
label="Node Profile",
|
|
88
103
|
queryset=NodeManager.objects.none(),
|
|
89
104
|
help_text="Credentials used to authenticate with the DNS provider.",
|
|
90
105
|
)
|
|
@@ -227,31 +242,35 @@ class DNSRecordAdmin(EntityModelAdmin):
|
|
|
227
242
|
class NodeAdmin(EntityModelAdmin):
|
|
228
243
|
list_display = (
|
|
229
244
|
"hostname",
|
|
230
|
-
"
|
|
231
|
-
"address",
|
|
245
|
+
"primary_ip",
|
|
232
246
|
"port",
|
|
233
247
|
"role",
|
|
234
248
|
"relation",
|
|
235
249
|
"last_seen",
|
|
250
|
+
"visit_link",
|
|
236
251
|
)
|
|
237
|
-
search_fields = ("hostname", "address", "mac_address")
|
|
252
|
+
search_fields = ("hostname", "network_hostname", "address", "mac_address")
|
|
238
253
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
239
254
|
change_form_template = "admin/nodes/node/change_form.html"
|
|
240
255
|
form = NodeAdminForm
|
|
241
256
|
fieldsets = (
|
|
242
257
|
(
|
|
243
|
-
_("
|
|
258
|
+
_("Network"),
|
|
244
259
|
{
|
|
245
260
|
"fields": (
|
|
246
261
|
"hostname",
|
|
262
|
+
"network_hostname",
|
|
263
|
+
"ipv4_address",
|
|
264
|
+
"ipv6_address",
|
|
247
265
|
"address",
|
|
248
266
|
"mac_address",
|
|
249
267
|
"port",
|
|
250
|
-
"
|
|
268
|
+
"message_queue_length",
|
|
251
269
|
"current_relation",
|
|
252
270
|
)
|
|
253
271
|
},
|
|
254
272
|
),
|
|
273
|
+
(_("Role"), {"fields": ("role",)}),
|
|
255
274
|
(
|
|
256
275
|
_("Public endpoint"),
|
|
257
276
|
{
|
|
@@ -281,15 +300,108 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
281
300
|
"register_visitor",
|
|
282
301
|
"run_task",
|
|
283
302
|
"take_screenshots",
|
|
303
|
+
"download_evcs_firmware",
|
|
304
|
+
"start_charge_point_forwarding",
|
|
305
|
+
"stop_charge_point_forwarding",
|
|
284
306
|
"import_rfids_from_selected",
|
|
285
307
|
"export_rfids_to_selected",
|
|
308
|
+
"send_net_message",
|
|
286
309
|
]
|
|
287
310
|
inlines = [NodeFeatureAssignmentInline]
|
|
288
311
|
|
|
312
|
+
class SendNetMessageForm(forms.Form):
|
|
313
|
+
subject = forms.CharField(
|
|
314
|
+
label=_("Subject"),
|
|
315
|
+
max_length=NetMessage._meta.get_field("subject").max_length,
|
|
316
|
+
required=False,
|
|
317
|
+
)
|
|
318
|
+
body = forms.CharField(
|
|
319
|
+
label=_("Body"),
|
|
320
|
+
max_length=NetMessage._meta.get_field("body").max_length,
|
|
321
|
+
required=False,
|
|
322
|
+
widget=forms.Textarea(attrs={"rows": 4}),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def clean(self):
|
|
326
|
+
cleaned = super().clean()
|
|
327
|
+
subject = (cleaned.get("subject") or "").strip()
|
|
328
|
+
body = (cleaned.get("body") or "").strip()
|
|
329
|
+
if not subject and not body:
|
|
330
|
+
raise forms.ValidationError(
|
|
331
|
+
_("Enter a subject or body to send.")
|
|
332
|
+
)
|
|
333
|
+
cleaned["subject"] = subject
|
|
334
|
+
cleaned["body"] = body
|
|
335
|
+
return cleaned
|
|
336
|
+
|
|
337
|
+
class DownloadFirmwareForm(forms.Form):
|
|
338
|
+
def __init__(self, node: Node, *args, **kwargs):
|
|
339
|
+
super().__init__(*args, **kwargs)
|
|
340
|
+
base_queryset = Charger.objects.filter(
|
|
341
|
+
node_origin=node, connector_id__isnull=True
|
|
342
|
+
).order_by("display_name", "charger_id")
|
|
343
|
+
self.fields["charger"].queryset = base_queryset
|
|
344
|
+
|
|
345
|
+
charger = forms.ModelChoiceField(
|
|
346
|
+
label=_("Charge point"),
|
|
347
|
+
queryset=Charger.objects.none(),
|
|
348
|
+
help_text=_("Select the EVCS to request firmware from."),
|
|
349
|
+
)
|
|
350
|
+
vendor_id = forms.CharField(
|
|
351
|
+
label=_("Vendor ID"),
|
|
352
|
+
max_length=255,
|
|
353
|
+
initial="org.openchargealliance.firmware",
|
|
354
|
+
help_text=_("Vendor identifier included in the DataTransfer request."),
|
|
355
|
+
)
|
|
356
|
+
|
|
289
357
|
@admin.display(description=_("Relation"), ordering="current_relation")
|
|
290
358
|
def relation(self, obj):
|
|
291
359
|
return obj.get_current_relation_display()
|
|
292
360
|
|
|
361
|
+
@admin.display(description=_("IP Address"), ordering="address")
|
|
362
|
+
def primary_ip(self, obj):
|
|
363
|
+
if not obj:
|
|
364
|
+
return ""
|
|
365
|
+
return obj.get_best_ip() or ""
|
|
366
|
+
|
|
367
|
+
@admin.display(description=_("Visit"))
|
|
368
|
+
def visit_link(self, obj):
|
|
369
|
+
if not obj:
|
|
370
|
+
return ""
|
|
371
|
+
if obj.is_local:
|
|
372
|
+
try:
|
|
373
|
+
url = reverse("admin:index")
|
|
374
|
+
except NoReverseMatch:
|
|
375
|
+
return ""
|
|
376
|
+
return format_html(
|
|
377
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
378
|
+
url,
|
|
379
|
+
_("Visit"),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
host_values = obj.get_remote_host_candidates()
|
|
383
|
+
|
|
384
|
+
remote_url = ""
|
|
385
|
+
for host in host_values:
|
|
386
|
+
temp_node = SimpleNamespace(
|
|
387
|
+
public_endpoint=host,
|
|
388
|
+
address="",
|
|
389
|
+
hostname="",
|
|
390
|
+
port=obj.port,
|
|
391
|
+
)
|
|
392
|
+
remote_url = next(self._iter_remote_urls(temp_node, "/admin/"), "")
|
|
393
|
+
if remote_url:
|
|
394
|
+
break
|
|
395
|
+
|
|
396
|
+
if not remote_url:
|
|
397
|
+
return ""
|
|
398
|
+
|
|
399
|
+
return format_html(
|
|
400
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
401
|
+
remote_url,
|
|
402
|
+
_("Visit"),
|
|
403
|
+
)
|
|
404
|
+
|
|
293
405
|
def get_urls(self):
|
|
294
406
|
urls = super().get_urls()
|
|
295
407
|
custom = [
|
|
@@ -330,7 +442,20 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
330
442
|
"token": token,
|
|
331
443
|
"register_url": reverse("register-node"),
|
|
332
444
|
}
|
|
333
|
-
|
|
445
|
+
response = TemplateResponse(
|
|
446
|
+
request, "admin/nodes/node/register_remote.html", context
|
|
447
|
+
)
|
|
448
|
+
response.render()
|
|
449
|
+
template = response.resolve_template(response.template_name)
|
|
450
|
+
if getattr(template, "name", None) in (None, ""):
|
|
451
|
+
template.name = response.template_name
|
|
452
|
+
signals.template_rendered.send(
|
|
453
|
+
sender=template.__class__,
|
|
454
|
+
template=template,
|
|
455
|
+
context=response.context_data,
|
|
456
|
+
request=request,
|
|
457
|
+
)
|
|
458
|
+
return response
|
|
334
459
|
|
|
335
460
|
@admin.action(description="Register Visitor")
|
|
336
461
|
def register_visitor(self, request, queryset=None):
|
|
@@ -354,6 +479,391 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
354
479
|
request, "admin/nodes/node/update_selected.html", context
|
|
355
480
|
)
|
|
356
481
|
|
|
482
|
+
@admin.action(description=_("Send Net Message"))
|
|
483
|
+
def send_net_message(self, request, queryset):
|
|
484
|
+
is_submit = "apply" in request.POST
|
|
485
|
+
form = self.SendNetMessageForm(request.POST if is_submit else None)
|
|
486
|
+
selected_ids = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
|
|
487
|
+
if not selected_ids:
|
|
488
|
+
selected_ids = [str(pk) for pk in queryset.values_list("pk", flat=True)]
|
|
489
|
+
nodes: list[Node] = []
|
|
490
|
+
cleaned_ids: list[int] = []
|
|
491
|
+
for value in selected_ids:
|
|
492
|
+
try:
|
|
493
|
+
cleaned_ids.append(int(value))
|
|
494
|
+
except (TypeError, ValueError):
|
|
495
|
+
continue
|
|
496
|
+
if cleaned_ids:
|
|
497
|
+
base_queryset = self.get_queryset(request).filter(pk__in=cleaned_ids)
|
|
498
|
+
nodes_by_pk = {str(node.pk): node for node in base_queryset}
|
|
499
|
+
nodes = [nodes_by_pk[value] for value in selected_ids if value in nodes_by_pk]
|
|
500
|
+
if not nodes:
|
|
501
|
+
nodes = list(queryset)
|
|
502
|
+
selected_ids = [str(node.pk) for node in nodes]
|
|
503
|
+
if not nodes:
|
|
504
|
+
self.message_user(request, _("No nodes selected."), messages.INFO)
|
|
505
|
+
return None
|
|
506
|
+
if is_submit and form.is_valid():
|
|
507
|
+
subject = form.cleaned_data["subject"]
|
|
508
|
+
body = form.cleaned_data["body"]
|
|
509
|
+
created = 0
|
|
510
|
+
for node in nodes:
|
|
511
|
+
message = NetMessage.objects.create(
|
|
512
|
+
subject=subject,
|
|
513
|
+
body=body,
|
|
514
|
+
filter_node=node,
|
|
515
|
+
)
|
|
516
|
+
message.propagate()
|
|
517
|
+
created += 1
|
|
518
|
+
if created:
|
|
519
|
+
success_message = ngettext(
|
|
520
|
+
"Sent %(count)d net message.",
|
|
521
|
+
"Sent %(count)d net messages.",
|
|
522
|
+
created,
|
|
523
|
+
) % {"count": created}
|
|
524
|
+
self.message_user(request, success_message, messages.SUCCESS)
|
|
525
|
+
else:
|
|
526
|
+
self.message_user(
|
|
527
|
+
request, _("No net messages were sent."), messages.INFO
|
|
528
|
+
)
|
|
529
|
+
return None
|
|
530
|
+
context = {
|
|
531
|
+
**self.admin_site.each_context(request),
|
|
532
|
+
"opts": self.model._meta,
|
|
533
|
+
"title": _("Send Net Message"),
|
|
534
|
+
"nodes": nodes,
|
|
535
|
+
"selected_ids": selected_ids,
|
|
536
|
+
"action_name": request.POST.get("action", "send_net_message"),
|
|
537
|
+
"select_across": request.POST.get("select_across", "0"),
|
|
538
|
+
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
|
|
539
|
+
"adminform": helpers.AdminForm(
|
|
540
|
+
form,
|
|
541
|
+
[(None, {"fields": ("subject", "body")})],
|
|
542
|
+
{},
|
|
543
|
+
),
|
|
544
|
+
"form": form,
|
|
545
|
+
"media": self.media + form.media,
|
|
546
|
+
}
|
|
547
|
+
return TemplateResponse(
|
|
548
|
+
request, "admin/nodes/node/send_net_message.html", context
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def _coerce_metadata_value(self, value):
|
|
552
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
553
|
+
return value
|
|
554
|
+
if isinstance(value, (bytes, bytearray)):
|
|
555
|
+
return base64.b64encode(bytes(value)).decode("ascii")
|
|
556
|
+
if isinstance(value, Mapping):
|
|
557
|
+
return {k: self._coerce_metadata_value(v) for k, v in value.items()}
|
|
558
|
+
if isinstance(value, (list, tuple, set)):
|
|
559
|
+
return [self._coerce_metadata_value(v) for v in value]
|
|
560
|
+
return str(value)
|
|
561
|
+
|
|
562
|
+
def _decode_payload_bytes(self, value, encoding_hint: str = ""):
|
|
563
|
+
if isinstance(value, (bytes, bytearray)):
|
|
564
|
+
return bytes(value), encoding_hint or "binary"
|
|
565
|
+
if not isinstance(value, str):
|
|
566
|
+
return None, encoding_hint
|
|
567
|
+
text = value.strip()
|
|
568
|
+
if not text:
|
|
569
|
+
return b"", encoding_hint or "binary"
|
|
570
|
+
try:
|
|
571
|
+
decoded = base64.b64decode(text, validate=True)
|
|
572
|
+
return decoded, "base64"
|
|
573
|
+
except (binascii.Error, ValueError):
|
|
574
|
+
return None, encoding_hint
|
|
575
|
+
|
|
576
|
+
def _extract_firmware_payload(self, data):
|
|
577
|
+
content_type = "application/octet-stream"
|
|
578
|
+
encoding = ""
|
|
579
|
+
filename = ""
|
|
580
|
+
json_payload = None
|
|
581
|
+
binary_payload = None
|
|
582
|
+
metadata: dict[str, object] = {}
|
|
583
|
+
|
|
584
|
+
if isinstance(data, Mapping):
|
|
585
|
+
metadata = {
|
|
586
|
+
key: self._coerce_metadata_value(value)
|
|
587
|
+
for key, value in data.items()
|
|
588
|
+
if key not in {"payload", "data", "json"}
|
|
589
|
+
}
|
|
590
|
+
filename = str(data.get("filename") or data.get("name") or "").strip()
|
|
591
|
+
if data.get("contentType"):
|
|
592
|
+
content_type_candidate = str(data.get("contentType")).strip()
|
|
593
|
+
if content_type_candidate:
|
|
594
|
+
content_type = content_type_candidate
|
|
595
|
+
encoding = str(data.get("encoding") or "").strip()
|
|
596
|
+
raw_payload = data.get("payload")
|
|
597
|
+
if raw_payload is None:
|
|
598
|
+
raw_payload = data.get("data")
|
|
599
|
+
if raw_payload is not None:
|
|
600
|
+
binary_payload, encoding = self._decode_payload_bytes(
|
|
601
|
+
raw_payload, encoding
|
|
602
|
+
)
|
|
603
|
+
json_candidate = data.get("json")
|
|
604
|
+
if json_candidate is not None:
|
|
605
|
+
if isinstance(json_candidate, str):
|
|
606
|
+
try:
|
|
607
|
+
json_payload = json.loads(json_candidate)
|
|
608
|
+
except json.JSONDecodeError:
|
|
609
|
+
metadata["json_raw"] = json_candidate
|
|
610
|
+
else:
|
|
611
|
+
json_payload = json_candidate
|
|
612
|
+
if json_payload is None and binary_payload is None:
|
|
613
|
+
remaining = {
|
|
614
|
+
key: value
|
|
615
|
+
for key, value in data.items()
|
|
616
|
+
if key
|
|
617
|
+
not in {
|
|
618
|
+
"payload",
|
|
619
|
+
"data",
|
|
620
|
+
"encoding",
|
|
621
|
+
"contentType",
|
|
622
|
+
"filename",
|
|
623
|
+
"json",
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if remaining:
|
|
627
|
+
json_payload = remaining
|
|
628
|
+
elif isinstance(data, (bytes, bytearray)):
|
|
629
|
+
binary_payload = bytes(data)
|
|
630
|
+
encoding = encoding or "binary"
|
|
631
|
+
elif isinstance(data, str):
|
|
632
|
+
metadata = {"raw": data}
|
|
633
|
+
binary_payload, encoding = self._decode_payload_bytes(data, encoding)
|
|
634
|
+
if binary_payload is None:
|
|
635
|
+
try:
|
|
636
|
+
json_payload = json.loads(data)
|
|
637
|
+
except json.JSONDecodeError:
|
|
638
|
+
binary_payload = data.encode("utf-8")
|
|
639
|
+
encoding = encoding or "utf-8"
|
|
640
|
+
elif data is not None:
|
|
641
|
+
metadata = {"raw": self._coerce_metadata_value(data)}
|
|
642
|
+
json_payload = metadata.get("raw")
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
"binary": binary_payload,
|
|
646
|
+
"json": json_payload,
|
|
647
|
+
"encoding": encoding,
|
|
648
|
+
"content_type": content_type,
|
|
649
|
+
"filename": filename,
|
|
650
|
+
"metadata": metadata,
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
def _format_pending_failure(self, action: str, result: Mapping) -> str:
|
|
654
|
+
label_map = {
|
|
655
|
+
"DataTransfer": _("Data transfer"),
|
|
656
|
+
"UpdateFirmware": _("Update firmware"),
|
|
657
|
+
}
|
|
658
|
+
action_label = label_map.get(action, action)
|
|
659
|
+
error_code = str(result.get("error_code") or "").strip()
|
|
660
|
+
error_description = str(result.get("error_description") or "").strip()
|
|
661
|
+
details = result.get("error_details")
|
|
662
|
+
parts: list[str] = []
|
|
663
|
+
if error_code:
|
|
664
|
+
parts.append(_("code=%(code)s") % {"code": error_code})
|
|
665
|
+
if error_description:
|
|
666
|
+
parts.append(
|
|
667
|
+
_("description=%(description)s")
|
|
668
|
+
% {"description": error_description}
|
|
669
|
+
)
|
|
670
|
+
if details:
|
|
671
|
+
try:
|
|
672
|
+
details_text = json.dumps(
|
|
673
|
+
details, sort_keys=True, ensure_ascii=False
|
|
674
|
+
)
|
|
675
|
+
except TypeError:
|
|
676
|
+
details_text = str(details)
|
|
677
|
+
if details_text:
|
|
678
|
+
parts.append(_("details=%(details)s") % {"details": details_text})
|
|
679
|
+
if parts:
|
|
680
|
+
return _("%(action)s failed: %(details)s") % {
|
|
681
|
+
"action": action_label,
|
|
682
|
+
"details": ", ".join(parts),
|
|
683
|
+
}
|
|
684
|
+
return _("%(action)s failed.") % {"action": action_label}
|
|
685
|
+
|
|
686
|
+
def _process_firmware_download(self, request, node: Node, cleaned_data) -> bool:
|
|
687
|
+
charger: Charger = cleaned_data["charger"]
|
|
688
|
+
vendor_id = cleaned_data.get("vendor_id", "")
|
|
689
|
+
connection = store.get_connection(charger.charger_id, charger.connector_id)
|
|
690
|
+
if connection is None:
|
|
691
|
+
self.message_user(
|
|
692
|
+
request,
|
|
693
|
+
_("%(charger)s is not currently connected to the platform.")
|
|
694
|
+
% {"charger": charger},
|
|
695
|
+
level=messages.ERROR,
|
|
696
|
+
)
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
message_id = uuid.uuid4().hex
|
|
700
|
+
payload = {
|
|
701
|
+
"vendorId": vendor_id,
|
|
702
|
+
"messageId": "DownloadFirmware",
|
|
703
|
+
}
|
|
704
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
705
|
+
message_record = DataTransferMessage.objects.create(
|
|
706
|
+
charger=charger,
|
|
707
|
+
connector_id=charger.connector_id,
|
|
708
|
+
direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
|
|
709
|
+
ocpp_message_id=message_id,
|
|
710
|
+
vendor_id=vendor_id,
|
|
711
|
+
message_id="DownloadFirmware",
|
|
712
|
+
payload=payload,
|
|
713
|
+
status="Pending",
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
frame = json.dumps([2, message_id, "DataTransfer", payload])
|
|
717
|
+
async_to_sync(connection.send)(frame)
|
|
718
|
+
store.add_log(
|
|
719
|
+
log_key,
|
|
720
|
+
_("Requested firmware download via DataTransfer."),
|
|
721
|
+
log_type="charger",
|
|
722
|
+
)
|
|
723
|
+
store.register_pending_call(
|
|
724
|
+
message_id,
|
|
725
|
+
{
|
|
726
|
+
"action": "DataTransfer",
|
|
727
|
+
"charger_id": charger.charger_id,
|
|
728
|
+
"connector_id": charger.connector_id,
|
|
729
|
+
"log_key": log_key,
|
|
730
|
+
"message_pk": message_record.pk,
|
|
731
|
+
},
|
|
732
|
+
)
|
|
733
|
+
store.schedule_call_timeout(
|
|
734
|
+
message_id, action="DataTransfer", log_key=log_key
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
result = store.wait_for_pending_call(message_id, timeout=15.0)
|
|
738
|
+
if result is None:
|
|
739
|
+
self.message_user(
|
|
740
|
+
request,
|
|
741
|
+
_("The charge point did not respond to the firmware request."),
|
|
742
|
+
level=messages.ERROR,
|
|
743
|
+
)
|
|
744
|
+
return False
|
|
745
|
+
if not result.get("success", True):
|
|
746
|
+
detail = self._format_pending_failure("DataTransfer", result)
|
|
747
|
+
self.message_user(request, detail, level=messages.ERROR)
|
|
748
|
+
return False
|
|
749
|
+
|
|
750
|
+
payload_data = result.get("payload") or {}
|
|
751
|
+
status_value = str(payload_data.get("status") or "").strip()
|
|
752
|
+
if status_value.lower() != "accepted":
|
|
753
|
+
self.message_user(
|
|
754
|
+
request,
|
|
755
|
+
_(
|
|
756
|
+
"Firmware request for %(charger)s was %(status)s."
|
|
757
|
+
)
|
|
758
|
+
% {"charger": charger, "status": status_value or "Rejected"},
|
|
759
|
+
level=messages.ERROR,
|
|
760
|
+
)
|
|
761
|
+
return False
|
|
762
|
+
|
|
763
|
+
data_section = payload_data.get("data")
|
|
764
|
+
extracted = self._extract_firmware_payload(data_section)
|
|
765
|
+
binary_payload = extracted["binary"]
|
|
766
|
+
json_payload = extracted["json"]
|
|
767
|
+
if binary_payload is None and json_payload is None:
|
|
768
|
+
self.message_user(
|
|
769
|
+
request,
|
|
770
|
+
_("The charge point did not include a firmware payload."),
|
|
771
|
+
level=messages.ERROR,
|
|
772
|
+
)
|
|
773
|
+
return False
|
|
774
|
+
|
|
775
|
+
now = timezone.now()
|
|
776
|
+
filename = extracted["filename"] or ""
|
|
777
|
+
if not filename:
|
|
778
|
+
suffix = ".bin" if binary_payload is not None else ".json"
|
|
779
|
+
filename = f"{charger.charger_id}_{now:%Y%m%d%H%M%S}{suffix}"
|
|
780
|
+
|
|
781
|
+
metadata = {
|
|
782
|
+
"vendor_id": vendor_id,
|
|
783
|
+
"response": self._coerce_metadata_value(payload_data),
|
|
784
|
+
}
|
|
785
|
+
metadata.update(extracted["metadata"])
|
|
786
|
+
|
|
787
|
+
firmware = CPFirmware(
|
|
788
|
+
name=f"{charger.charger_id} firmware {now:%Y-%m-%d %H:%M:%S}",
|
|
789
|
+
source=CPFirmware.Source.DOWNLOAD,
|
|
790
|
+
source_node=node,
|
|
791
|
+
source_charger=charger,
|
|
792
|
+
filename=filename,
|
|
793
|
+
payload_binary=binary_payload,
|
|
794
|
+
payload_json=json_payload,
|
|
795
|
+
payload_encoding=extracted["encoding"],
|
|
796
|
+
content_type=extracted["content_type"],
|
|
797
|
+
metadata=metadata,
|
|
798
|
+
download_vendor_id=vendor_id,
|
|
799
|
+
download_message_id=message_id,
|
|
800
|
+
downloaded_at=now,
|
|
801
|
+
is_user_data=True,
|
|
802
|
+
)
|
|
803
|
+
firmware.save()
|
|
804
|
+
|
|
805
|
+
self.message_user(
|
|
806
|
+
request,
|
|
807
|
+
_("Stored firmware from %(charger)s as %(firmware)s.")
|
|
808
|
+
% {"charger": charger, "firmware": firmware},
|
|
809
|
+
level=messages.SUCCESS,
|
|
810
|
+
)
|
|
811
|
+
return True
|
|
812
|
+
|
|
813
|
+
@admin.action(description=_("Download EVCS firmware"))
|
|
814
|
+
def download_evcs_firmware(self, request, queryset):
|
|
815
|
+
nodes = list(queryset)
|
|
816
|
+
if len(nodes) != 1:
|
|
817
|
+
self.message_user(
|
|
818
|
+
request,
|
|
819
|
+
_("Select a single node to request firmware."),
|
|
820
|
+
level=messages.ERROR,
|
|
821
|
+
)
|
|
822
|
+
return None
|
|
823
|
+
node = nodes[0]
|
|
824
|
+
|
|
825
|
+
if "apply" in request.POST:
|
|
826
|
+
form = self.DownloadFirmwareForm(node, request.POST)
|
|
827
|
+
if form.is_valid():
|
|
828
|
+
if self._process_firmware_download(request, node, form.cleaned_data):
|
|
829
|
+
return None
|
|
830
|
+
else:
|
|
831
|
+
form = self.DownloadFirmwareForm(node)
|
|
832
|
+
|
|
833
|
+
context = {
|
|
834
|
+
**self.admin_site.each_context(request),
|
|
835
|
+
"opts": self.model._meta,
|
|
836
|
+
"title": _("Download EVCS firmware"),
|
|
837
|
+
"node": node,
|
|
838
|
+
"nodes": [node],
|
|
839
|
+
"selected_ids": [str(node.pk)],
|
|
840
|
+
"action_name": request.POST.get(
|
|
841
|
+
"action", "download_evcs_firmware"
|
|
842
|
+
),
|
|
843
|
+
"select_across": request.POST.get("select_across", "0"),
|
|
844
|
+
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
|
|
845
|
+
"adminform": helpers.AdminForm(
|
|
846
|
+
form,
|
|
847
|
+
[
|
|
848
|
+
(
|
|
849
|
+
None,
|
|
850
|
+
{
|
|
851
|
+
"fields": (
|
|
852
|
+
"charger",
|
|
853
|
+
"vendor_id",
|
|
854
|
+
)
|
|
855
|
+
},
|
|
856
|
+
)
|
|
857
|
+
],
|
|
858
|
+
{},
|
|
859
|
+
),
|
|
860
|
+
"form": form,
|
|
861
|
+
"media": self.media + form.media,
|
|
862
|
+
}
|
|
863
|
+
return TemplateResponse(
|
|
864
|
+
request, "admin/nodes/node/download_firmware.html", context
|
|
865
|
+
)
|
|
866
|
+
|
|
357
867
|
def update_selected_progress(self, request):
|
|
358
868
|
if request.method != "POST":
|
|
359
869
|
return JsonResponse({"detail": "POST required"}, status=405)
|
|
@@ -398,6 +908,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
398
908
|
}
|
|
399
909
|
|
|
400
910
|
last_error = ""
|
|
911
|
+
host_candidates = node.get_remote_host_candidates()
|
|
401
912
|
for url in self._iter_remote_urls(node, "/nodes/info/"):
|
|
402
913
|
try:
|
|
403
914
|
response = requests.get(url, timeout=5)
|
|
@@ -424,13 +935,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
424
935
|
"updated_fields": updated,
|
|
425
936
|
"message": message,
|
|
426
937
|
}
|
|
427
|
-
return {
|
|
938
|
+
return {
|
|
939
|
+
"ok": False,
|
|
940
|
+
"message": self._build_connectivity_hint(last_error, host_candidates),
|
|
941
|
+
}
|
|
428
942
|
|
|
429
943
|
def _apply_remote_node_info(self, node, payload):
|
|
430
944
|
changed = []
|
|
431
945
|
field_map = {
|
|
432
946
|
"hostname": payload.get("hostname"),
|
|
947
|
+
"network_hostname": payload.get("network_hostname"),
|
|
433
948
|
"address": payload.get("address"),
|
|
949
|
+
"ipv4_address": payload.get("ipv4_address"),
|
|
950
|
+
"ipv6_address": payload.get("ipv6_address"),
|
|
434
951
|
"public_key": payload.get("public_key"),
|
|
435
952
|
}
|
|
436
953
|
port_value = payload.get("port")
|
|
@@ -451,6 +968,17 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
451
968
|
setattr(node, field, value)
|
|
452
969
|
changed.append(field)
|
|
453
970
|
|
|
971
|
+
role_value = payload.get("role") or payload.get("role_name")
|
|
972
|
+
if role_value is not None:
|
|
973
|
+
role_name = str(role_value).strip()
|
|
974
|
+
if role_name:
|
|
975
|
+
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
976
|
+
else:
|
|
977
|
+
desired_role = None
|
|
978
|
+
if desired_role and node.role_id != desired_role.id:
|
|
979
|
+
node.role = desired_role
|
|
980
|
+
changed.append("role")
|
|
981
|
+
|
|
454
982
|
node.last_seen = timezone.now()
|
|
455
983
|
if "last_seen" not in changed:
|
|
456
984
|
changed.append("last_seen")
|
|
@@ -497,7 +1025,10 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
497
1025
|
|
|
498
1026
|
payload = {
|
|
499
1027
|
"hostname": local_node.hostname,
|
|
1028
|
+
"network_hostname": local_node.network_hostname,
|
|
500
1029
|
"address": local_node.address,
|
|
1030
|
+
"ipv4_address": local_node.ipv4_address,
|
|
1031
|
+
"ipv6_address": local_node.ipv6_address,
|
|
501
1032
|
"port": local_node.port,
|
|
502
1033
|
"mac_address": local_node.mac_address,
|
|
503
1034
|
"public_key": local_node.public_key,
|
|
@@ -513,6 +1044,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
513
1044
|
headers = {"Content-Type": "application/json"}
|
|
514
1045
|
|
|
515
1046
|
last_error = ""
|
|
1047
|
+
host_candidates = node.get_remote_host_candidates()
|
|
516
1048
|
for url in self._iter_remote_urls(node, "/nodes/register/"):
|
|
517
1049
|
try:
|
|
518
1050
|
response = requests.post(
|
|
@@ -527,46 +1059,52 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
527
1059
|
if response.ok:
|
|
528
1060
|
return {"ok": True, "url": url, "message": "Remote updated."}
|
|
529
1061
|
last_error = f"{response.status_code} {response.text}"
|
|
530
|
-
return {
|
|
1062
|
+
return {
|
|
1063
|
+
"ok": False,
|
|
1064
|
+
"message": self._build_connectivity_hint(last_error, host_candidates),
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
def _build_connectivity_hint(self, last_error: str, hosts: list[str]) -> str:
|
|
1068
|
+
base_message = last_error or _("Unable to reach remote node.")
|
|
1069
|
+
if hosts:
|
|
1070
|
+
host_text = ", ".join(hosts)
|
|
1071
|
+
return _("%(message)s Tried hosts: %(hosts)s.") % {
|
|
1072
|
+
"message": base_message,
|
|
1073
|
+
"hosts": host_text,
|
|
1074
|
+
}
|
|
1075
|
+
return _("%(message)s No remote hosts were available for contact.") % {
|
|
1076
|
+
"message": base_message
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
def _primary_remote_url(self, node, path: str) -> str:
|
|
1080
|
+
return next(self._iter_remote_urls(node, path), "")
|
|
1081
|
+
|
|
1082
|
+
def _request_remote(self, node, path: str, request_callable):
|
|
1083
|
+
errors: list[str] = []
|
|
1084
|
+
for url in self._iter_remote_urls(node, path):
|
|
1085
|
+
try:
|
|
1086
|
+
response = request_callable(url)
|
|
1087
|
+
except RequestException as exc:
|
|
1088
|
+
errors.append(f"{url}: {exc}")
|
|
1089
|
+
continue
|
|
1090
|
+
return url, response, errors
|
|
1091
|
+
return "", None, errors
|
|
531
1092
|
|
|
532
1093
|
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
|
-
]
|
|
1094
|
+
if hasattr(node, "iter_remote_urls"):
|
|
1095
|
+
yield from node.iter_remote_urls(path)
|
|
1096
|
+
return
|
|
565
1097
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1098
|
+
temp = Node(
|
|
1099
|
+
public_endpoint=getattr(node, "public_endpoint", ""),
|
|
1100
|
+
address=getattr(node, "address", ""),
|
|
1101
|
+
hostname=getattr(node, "hostname", ""),
|
|
1102
|
+
port=getattr(node, "port", None),
|
|
1103
|
+
)
|
|
1104
|
+
temp.network_hostname = getattr(node, "network_hostname", "")
|
|
1105
|
+
temp.ipv4_address = getattr(node, "ipv4_address", "")
|
|
1106
|
+
temp.ipv6_address = getattr(node, "ipv6_address", "")
|
|
1107
|
+
yield from temp.iter_remote_urls(path)
|
|
570
1108
|
|
|
571
1109
|
def register_visitor_view(self, request):
|
|
572
1110
|
"""Exchange registration data with the visiting node."""
|
|
@@ -585,8 +1123,8 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
585
1123
|
"token": token,
|
|
586
1124
|
"info_url": reverse("node-info"),
|
|
587
1125
|
"register_url": reverse("register-node"),
|
|
588
|
-
"visitor_info_url": "http://localhost:
|
|
589
|
-
"visitor_register_url": "http://localhost:
|
|
1126
|
+
"visitor_info_url": "http://localhost:8888/nodes/info/",
|
|
1127
|
+
"visitor_register_url": "http://localhost:8888/nodes/register/",
|
|
590
1128
|
}
|
|
591
1129
|
return render(request, "admin/nodes/node/register_visitor.html", context)
|
|
592
1130
|
|
|
@@ -640,11 +1178,28 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
640
1178
|
for node in queryset:
|
|
641
1179
|
for source in sources:
|
|
642
1180
|
try:
|
|
643
|
-
|
|
1181
|
+
contact_host = node.get_primary_contact()
|
|
1182
|
+
url = source.format(
|
|
1183
|
+
node=node, address=contact_host, port=node.port
|
|
1184
|
+
)
|
|
644
1185
|
except Exception:
|
|
645
1186
|
url = source
|
|
646
1187
|
if not url.startswith("http"):
|
|
647
|
-
|
|
1188
|
+
candidate = next(
|
|
1189
|
+
self._iter_remote_urls(node, url),
|
|
1190
|
+
"",
|
|
1191
|
+
)
|
|
1192
|
+
if not candidate:
|
|
1193
|
+
self.message_user(
|
|
1194
|
+
request,
|
|
1195
|
+
_(
|
|
1196
|
+
"No reachable host was available for %(node)s while generating %(path)s"
|
|
1197
|
+
)
|
|
1198
|
+
% {"node": node, "path": url},
|
|
1199
|
+
messages.WARNING,
|
|
1200
|
+
)
|
|
1201
|
+
continue
|
|
1202
|
+
url = candidate
|
|
648
1203
|
try:
|
|
649
1204
|
path = capture_screenshot(url)
|
|
650
1205
|
except Exception as exc: # pragma: no cover - selenium issues
|
|
@@ -742,6 +1297,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
742
1297
|
def _render_rfid_sync(self, request, operation, results, setup_error=None):
|
|
743
1298
|
titles = {
|
|
744
1299
|
"import": _("Import RFID results"),
|
|
1300
|
+
"fetch": _("Fetch RFID results"),
|
|
745
1301
|
"export": _("Export RFID results"),
|
|
746
1302
|
}
|
|
747
1303
|
summary = self._summarize_rfid_results(results)
|
|
@@ -763,12 +1319,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
763
1319
|
|
|
764
1320
|
def _process_import_from_node(self, node, payload, headers):
|
|
765
1321
|
result = self._init_rfid_result(node)
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1322
|
+
_, response, attempt_errors = self._request_remote(
|
|
1323
|
+
node,
|
|
1324
|
+
"/nodes/rfid/export/",
|
|
1325
|
+
lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
|
|
1326
|
+
)
|
|
1327
|
+
if response is None:
|
|
770
1328
|
result["status"] = "error"
|
|
771
|
-
|
|
1329
|
+
if attempt_errors:
|
|
1330
|
+
result["errors"].extend(attempt_errors)
|
|
1331
|
+
else:
|
|
1332
|
+
result["errors"].append(
|
|
1333
|
+
_("No remote hosts were available for %(node)s.") % {"node": node}
|
|
1334
|
+
)
|
|
772
1335
|
return result
|
|
773
1336
|
|
|
774
1337
|
if response.status_code != 200:
|
|
@@ -808,12 +1371,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
808
1371
|
|
|
809
1372
|
def _post_export_to_node(self, node, payload, headers):
|
|
810
1373
|
result = self._init_rfid_result(node)
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1374
|
+
_, response, attempt_errors = self._request_remote(
|
|
1375
|
+
node,
|
|
1376
|
+
"/nodes/rfid/import/",
|
|
1377
|
+
lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
|
|
1378
|
+
)
|
|
1379
|
+
if response is None:
|
|
815
1380
|
result["status"] = "error"
|
|
816
|
-
|
|
1381
|
+
if attempt_errors:
|
|
1382
|
+
result["errors"].extend(attempt_errors)
|
|
1383
|
+
else:
|
|
1384
|
+
result["errors"].append(
|
|
1385
|
+
_("No remote hosts were available for %(node)s.") % {"node": node}
|
|
1386
|
+
)
|
|
817
1387
|
return result
|
|
818
1388
|
|
|
819
1389
|
if response.status_code != 200:
|
|
@@ -851,13 +1421,14 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
851
1421
|
result["status"] = self._status_from_result(result)
|
|
852
1422
|
return result
|
|
853
1423
|
|
|
854
|
-
|
|
855
|
-
def import_rfids_from_selected(self, request, queryset):
|
|
1424
|
+
def _run_rfid_import(self, request, queryset):
|
|
856
1425
|
nodes = list(queryset)
|
|
857
1426
|
local_node, private_key, error = self._load_local_node_credentials()
|
|
858
1427
|
if error:
|
|
859
1428
|
results = [self._skip_result(node, error) for node in nodes]
|
|
860
|
-
return self._render_rfid_sync(
|
|
1429
|
+
return self._render_rfid_sync(
|
|
1430
|
+
request, "import", results, setup_error=error
|
|
1431
|
+
)
|
|
861
1432
|
|
|
862
1433
|
if not nodes:
|
|
863
1434
|
return self._render_rfid_sync(
|
|
@@ -887,6 +1458,10 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
887
1458
|
|
|
888
1459
|
return self._render_rfid_sync(request, "import", results)
|
|
889
1460
|
|
|
1461
|
+
@admin.action(description=_("Import RFIDs from selected"))
|
|
1462
|
+
def import_rfids_from_selected(self, request, queryset):
|
|
1463
|
+
return self._run_rfid_import(request, queryset)
|
|
1464
|
+
|
|
890
1465
|
@admin.action(description=_("Export RFIDs to selected"))
|
|
891
1466
|
def export_rfids_to_selected(self, request, queryset):
|
|
892
1467
|
nodes = list(queryset)
|
|
@@ -924,6 +1499,269 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
924
1499
|
|
|
925
1500
|
return self._render_rfid_sync(request, "export", results)
|
|
926
1501
|
|
|
1502
|
+
async def _probe_websocket(self, url: str) -> bool:
|
|
1503
|
+
try:
|
|
1504
|
+
async with websockets.connect(url, open_timeout=3, close_timeout=1):
|
|
1505
|
+
return True
|
|
1506
|
+
except Exception:
|
|
1507
|
+
return False
|
|
1508
|
+
|
|
1509
|
+
def _attempt_forwarding_probe(self, node, charger_id: str) -> bool:
|
|
1510
|
+
if not charger_id:
|
|
1511
|
+
return False
|
|
1512
|
+
safe_id = quote(str(charger_id))
|
|
1513
|
+
candidates: list[str] = []
|
|
1514
|
+
for base in node.iter_remote_urls("/"):
|
|
1515
|
+
parsed = urlsplit(base)
|
|
1516
|
+
if parsed.scheme not in {"http", "https"}:
|
|
1517
|
+
continue
|
|
1518
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
1519
|
+
base_path = parsed.path.rstrip("/")
|
|
1520
|
+
for prefix in ("", "/ws"):
|
|
1521
|
+
path = f"{base_path}{prefix}/{safe_id}".replace("//", "/")
|
|
1522
|
+
if not path.startswith("/"):
|
|
1523
|
+
path = f"/{path}"
|
|
1524
|
+
candidates.append(urlunsplit((scheme, parsed.netloc, path, "", "")))
|
|
1525
|
+
|
|
1526
|
+
for url in candidates:
|
|
1527
|
+
loop = asyncio.new_event_loop()
|
|
1528
|
+
try:
|
|
1529
|
+
result = loop.run_until_complete(self._probe_websocket(url))
|
|
1530
|
+
except Exception:
|
|
1531
|
+
result = False
|
|
1532
|
+
finally:
|
|
1533
|
+
loop.close()
|
|
1534
|
+
if result:
|
|
1535
|
+
return True
|
|
1536
|
+
return False
|
|
1537
|
+
|
|
1538
|
+
def _send_forwarding_metadata(
|
|
1539
|
+
self,
|
|
1540
|
+
request,
|
|
1541
|
+
node: Node,
|
|
1542
|
+
chargers: list[Charger],
|
|
1543
|
+
local_node: Node,
|
|
1544
|
+
private_key,
|
|
1545
|
+
) -> bool:
|
|
1546
|
+
if not chargers:
|
|
1547
|
+
return True
|
|
1548
|
+
payload = {
|
|
1549
|
+
"requester": str(local_node.uuid),
|
|
1550
|
+
"requester_mac": local_node.mac_address,
|
|
1551
|
+
"requester_public_key": local_node.public_key,
|
|
1552
|
+
"chargers": [serialize_charger_for_network(charger) for charger in chargers],
|
|
1553
|
+
"transactions": {"chargers": [], "transactions": []},
|
|
1554
|
+
}
|
|
1555
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1556
|
+
signature = self._sign_payload(private_key, payload_json)
|
|
1557
|
+
headers = {"Content-Type": "application/json"}
|
|
1558
|
+
if signature:
|
|
1559
|
+
headers["X-Signature"] = signature
|
|
1560
|
+
|
|
1561
|
+
errors: list[str] = []
|
|
1562
|
+
for url in node.iter_remote_urls("/nodes/network/chargers/forward/"):
|
|
1563
|
+
if not url:
|
|
1564
|
+
continue
|
|
1565
|
+
try:
|
|
1566
|
+
response = requests.post(
|
|
1567
|
+
url, data=payload_json, headers=headers, timeout=5
|
|
1568
|
+
)
|
|
1569
|
+
except RequestException as exc:
|
|
1570
|
+
errors.append(
|
|
1571
|
+
_(
|
|
1572
|
+
"Failed to send forwarding metadata to %(node)s via %(url)s (%(error)s)."
|
|
1573
|
+
)
|
|
1574
|
+
% {"node": node, "url": url, "error": exc}
|
|
1575
|
+
)
|
|
1576
|
+
continue
|
|
1577
|
+
|
|
1578
|
+
try:
|
|
1579
|
+
data = response.json()
|
|
1580
|
+
except ValueError:
|
|
1581
|
+
data = {}
|
|
1582
|
+
|
|
1583
|
+
if response.ok and isinstance(data, Mapping) and data.get("status") == "ok":
|
|
1584
|
+
return True
|
|
1585
|
+
|
|
1586
|
+
detail = ""
|
|
1587
|
+
if isinstance(data, Mapping):
|
|
1588
|
+
detail = data.get("detail") or ""
|
|
1589
|
+
errors.append(
|
|
1590
|
+
_("Forwarding metadata to %(node)s via %(url)s failed: %(status)s %(detail)s")
|
|
1591
|
+
% {
|
|
1592
|
+
"node": node,
|
|
1593
|
+
"url": url,
|
|
1594
|
+
"status": response.status_code,
|
|
1595
|
+
"detail": detail,
|
|
1596
|
+
}
|
|
1597
|
+
)
|
|
1598
|
+
|
|
1599
|
+
if not errors:
|
|
1600
|
+
self.message_user(
|
|
1601
|
+
request,
|
|
1602
|
+
_("No reachable host found for %(node)s.") % {"node": node},
|
|
1603
|
+
level=messages.WARNING,
|
|
1604
|
+
)
|
|
1605
|
+
else:
|
|
1606
|
+
self.message_user(request, errors[-1].strip(), level=messages.WARNING)
|
|
1607
|
+
return False
|
|
1608
|
+
|
|
1609
|
+
@admin.action(description=_("Start Charge Point Forwarding"))
|
|
1610
|
+
def start_charge_point_forwarding(self, request, queryset):
|
|
1611
|
+
if queryset.count() != 1:
|
|
1612
|
+
self.message_user(
|
|
1613
|
+
request,
|
|
1614
|
+
_("Select a single remote node."),
|
|
1615
|
+
level=messages.ERROR,
|
|
1616
|
+
)
|
|
1617
|
+
return
|
|
1618
|
+
|
|
1619
|
+
target = queryset.first()
|
|
1620
|
+
local_node, private_key, error = self._load_local_node_credentials()
|
|
1621
|
+
if error:
|
|
1622
|
+
self.message_user(request, error, level=messages.ERROR)
|
|
1623
|
+
return
|
|
1624
|
+
|
|
1625
|
+
if local_node.pk and target.pk == local_node.pk:
|
|
1626
|
+
self.message_user(
|
|
1627
|
+
request,
|
|
1628
|
+
_("Cannot forward charge points to the local node."),
|
|
1629
|
+
level=messages.ERROR,
|
|
1630
|
+
)
|
|
1631
|
+
return
|
|
1632
|
+
|
|
1633
|
+
eligible = Charger.objects.filter(export_transactions=True)
|
|
1634
|
+
if local_node.pk:
|
|
1635
|
+
eligible = eligible.filter(
|
|
1636
|
+
Q(node_origin=local_node) | Q(node_origin__isnull=True)
|
|
1637
|
+
)
|
|
1638
|
+
|
|
1639
|
+
chargers = list(eligible.select_related("forwarded_to"))
|
|
1640
|
+
if not chargers:
|
|
1641
|
+
self.message_user(
|
|
1642
|
+
request,
|
|
1643
|
+
_("No eligible charge points available for forwarding."),
|
|
1644
|
+
level=messages.WARNING,
|
|
1645
|
+
)
|
|
1646
|
+
return
|
|
1647
|
+
|
|
1648
|
+
conflicts = [
|
|
1649
|
+
charger
|
|
1650
|
+
for charger in chargers
|
|
1651
|
+
if charger.forwarded_to_id
|
|
1652
|
+
and charger.forwarded_to_id not in {None, target.pk}
|
|
1653
|
+
]
|
|
1654
|
+
if conflicts:
|
|
1655
|
+
self.message_user(
|
|
1656
|
+
request,
|
|
1657
|
+
ngettext(
|
|
1658
|
+
"Skipped %(count)s charge point already forwarded to another node.",
|
|
1659
|
+
"Skipped %(count)s charge points already forwarded to another node.",
|
|
1660
|
+
len(conflicts),
|
|
1661
|
+
)
|
|
1662
|
+
% {"count": len(conflicts)},
|
|
1663
|
+
level=messages.WARNING,
|
|
1664
|
+
)
|
|
1665
|
+
|
|
1666
|
+
chargers_to_update = [
|
|
1667
|
+
charger
|
|
1668
|
+
for charger in chargers
|
|
1669
|
+
if charger.forwarded_to_id in (None, target.pk)
|
|
1670
|
+
]
|
|
1671
|
+
if not chargers_to_update:
|
|
1672
|
+
self.message_user(
|
|
1673
|
+
request,
|
|
1674
|
+
_("No charge points were updated."),
|
|
1675
|
+
level=messages.WARNING,
|
|
1676
|
+
)
|
|
1677
|
+
return
|
|
1678
|
+
|
|
1679
|
+
charger_pks = [c.pk for c in chargers_to_update]
|
|
1680
|
+
Charger.objects.filter(pk__in=charger_pks).update(forwarded_to=target)
|
|
1681
|
+
|
|
1682
|
+
for charger in chargers_to_update:
|
|
1683
|
+
charger.forwarded_to = target
|
|
1684
|
+
|
|
1685
|
+
sample = next((charger for charger in chargers_to_update if charger.charger_id), None)
|
|
1686
|
+
if sample and not self._attempt_forwarding_probe(target, sample.charger_id):
|
|
1687
|
+
self.message_user(
|
|
1688
|
+
request,
|
|
1689
|
+
_(
|
|
1690
|
+
"Unable to establish a websocket connection to %(node)s for charge point %(charger)s."
|
|
1691
|
+
)
|
|
1692
|
+
% {"node": target, "charger": sample.charger_id},
|
|
1693
|
+
level=messages.WARNING,
|
|
1694
|
+
)
|
|
1695
|
+
|
|
1696
|
+
success = self._send_forwarding_metadata(
|
|
1697
|
+
request, target, chargers_to_update, local_node, private_key
|
|
1698
|
+
)
|
|
1699
|
+
|
|
1700
|
+
if success:
|
|
1701
|
+
now = timezone.now()
|
|
1702
|
+
Charger.objects.filter(pk__in=charger_pks).update(
|
|
1703
|
+
forwarding_watermark=now
|
|
1704
|
+
)
|
|
1705
|
+
self.message_user(
|
|
1706
|
+
request,
|
|
1707
|
+
ngettext(
|
|
1708
|
+
"Forwarding enabled for %(count)s charge point.",
|
|
1709
|
+
"Forwarding enabled for %(count)s charge points.",
|
|
1710
|
+
len(chargers_to_update),
|
|
1711
|
+
)
|
|
1712
|
+
% {"count": len(chargers_to_update)},
|
|
1713
|
+
level=messages.SUCCESS,
|
|
1714
|
+
)
|
|
1715
|
+
else:
|
|
1716
|
+
self.message_user(
|
|
1717
|
+
request,
|
|
1718
|
+
ngettext(
|
|
1719
|
+
"Marked %(count)s charge point for forwarding; awaiting remote acknowledgment.",
|
|
1720
|
+
"Marked %(count)s charge points for forwarding; awaiting remote acknowledgment.",
|
|
1721
|
+
len(chargers_to_update),
|
|
1722
|
+
)
|
|
1723
|
+
% {"count": len(chargers_to_update)},
|
|
1724
|
+
level=messages.INFO,
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
try:
|
|
1728
|
+
push_forwarded_charge_points.delay()
|
|
1729
|
+
except Exception:
|
|
1730
|
+
pass
|
|
1731
|
+
|
|
1732
|
+
@admin.action(description=_("Stop Charge Point Forwarding"))
|
|
1733
|
+
def stop_charge_point_forwarding(self, request, queryset):
|
|
1734
|
+
node_ids = [node.pk for node in queryset if node.pk]
|
|
1735
|
+
if not node_ids:
|
|
1736
|
+
self.message_user(
|
|
1737
|
+
request,
|
|
1738
|
+
_("No remote nodes selected."),
|
|
1739
|
+
level=messages.WARNING,
|
|
1740
|
+
)
|
|
1741
|
+
return
|
|
1742
|
+
|
|
1743
|
+
cleared = Charger.objects.filter(forwarded_to_id__in=node_ids).update(
|
|
1744
|
+
forwarded_to=None, forwarding_watermark=None
|
|
1745
|
+
)
|
|
1746
|
+
|
|
1747
|
+
if cleared:
|
|
1748
|
+
self.message_user(
|
|
1749
|
+
request,
|
|
1750
|
+
ngettext(
|
|
1751
|
+
"Stopped forwarding for %(count)s charge point.",
|
|
1752
|
+
"Stopped forwarding for %(count)s charge points.",
|
|
1753
|
+
cleared,
|
|
1754
|
+
)
|
|
1755
|
+
% {"count": cleared},
|
|
1756
|
+
level=messages.SUCCESS,
|
|
1757
|
+
)
|
|
1758
|
+
else:
|
|
1759
|
+
self.message_user(
|
|
1760
|
+
request,
|
|
1761
|
+
_("No forwarded charge points were updated."),
|
|
1762
|
+
level=messages.WARNING,
|
|
1763
|
+
)
|
|
1764
|
+
|
|
927
1765
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
928
1766
|
extra_context = extra_context or {}
|
|
929
1767
|
if object_id:
|
|
@@ -1565,7 +2403,7 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1565
2403
|
search_fields = ("subject", "body")
|
|
1566
2404
|
list_filter = ("complete", "filter_node_role", "filter_current_relation")
|
|
1567
2405
|
ordering = ("-created",)
|
|
1568
|
-
readonly_fields = ("complete",
|
|
2406
|
+
readonly_fields = ("complete",)
|
|
1569
2407
|
actions = ["send_messages"]
|
|
1570
2408
|
fieldsets = (
|
|
1571
2409
|
(None, {"fields": ("subject", "body")}),
|
|
@@ -1590,7 +2428,6 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1590
2428
|
"node_origin",
|
|
1591
2429
|
"target_limit",
|
|
1592
2430
|
"propagated_to",
|
|
1593
|
-
"confirmed_peers",
|
|
1594
2431
|
"complete",
|
|
1595
2432
|
)
|
|
1596
2433
|
},
|