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.

Files changed (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/views.py CHANGED
@@ -1,37 +1,222 @@
1
1
  import base64
2
2
  import ipaddress
3
3
  import json
4
+ import re
5
+ import secrets
4
6
  import socket
7
+ import uuid
5
8
  from collections.abc import Mapping
9
+ from datetime import timedelta
6
10
 
7
- from django.http import JsonResponse
8
- from django.http.request import split_domain_port
9
- from django.views.decorators.csrf import csrf_exempt
10
- from django.shortcuts import get_object_or_404
11
+ from django.apps import apps
11
12
  from django.conf import settings
13
+ from django.contrib.auth import authenticate, get_user_model, login
14
+ from django.contrib.auth.models import Group, Permission
15
+ from django.core import serializers
16
+ from django.core.cache import cache
17
+ from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
18
+ from django.http import HttpResponse, JsonResponse
19
+ from django.http.request import split_domain_port
20
+ from django.shortcuts import get_object_or_404, redirect
12
21
  from django.urls import reverse
13
- from pathlib import Path
22
+ from django.utils import timezone
23
+ from django.utils.dateparse import parse_datetime
14
24
  from django.utils.cache import patch_vary_headers
25
+ from django.utils.http import url_has_allowed_host_and_scheme
26
+ from django.views.decorators.csrf import csrf_exempt
27
+ from pathlib import Path
28
+ from urllib.parse import urlsplit
15
29
 
16
30
  from utils.api import api_login_required
17
31
 
18
32
  from cryptography.hazmat.primitives import serialization, hashes
19
33
  from cryptography.hazmat.primitives.asymmetric import padding
20
34
 
35
+ from django.db.models import Q
36
+
21
37
  from core.models import RFID
38
+ from ocpp import store
39
+ from ocpp.models import Charger
40
+ from ocpp.network import (
41
+ apply_remote_charger_payload,
42
+ serialize_charger_for_network,
43
+ sync_transactions_payload,
44
+ )
45
+ from ocpp.transactions_io import export_transactions
46
+ from asgiref.sync import async_to_sync
22
47
 
23
48
  from .rfid_sync import apply_rfid_payload, serialize_rfid
24
49
 
25
50
  from .models import (
26
51
  Node,
27
52
  NetMessage,
28
- NodeFeature,
53
+ PendingNetMessage,
29
54
  NodeRole,
30
55
  node_information_updated,
31
56
  )
32
57
  from .utils import capture_screenshot, save_screenshot
33
58
 
34
59
 
60
+ PROXY_TOKEN_SALT = "nodes.proxy.session"
61
+ PROXY_TOKEN_TIMEOUT = 300
62
+ PROXY_CACHE_PREFIX = "nodes:proxy-session:"
63
+
64
+
65
+ def _load_signed_node(
66
+ request,
67
+ requester_id: str,
68
+ *,
69
+ mac_address: str | None = None,
70
+ public_key: str | None = None,
71
+ ):
72
+ signature = request.headers.get("X-Signature")
73
+ if not signature:
74
+ return None, JsonResponse({"detail": "signature required"}, status=403)
75
+ try:
76
+ signature_bytes = base64.b64decode(signature)
77
+ except Exception:
78
+ return None, JsonResponse({"detail": "invalid signature"}, status=403)
79
+
80
+ candidates: list[Node] = []
81
+ seen: set[int] = set()
82
+
83
+ lookup_values: list[tuple[str, str]] = []
84
+ if requester_id:
85
+ lookup_values.append(("uuid", requester_id))
86
+ if mac_address:
87
+ lookup_values.append(("mac_address__iexact", mac_address))
88
+ if public_key:
89
+ lookup_values.append(("public_key", public_key))
90
+
91
+ for field, value in lookup_values:
92
+ node = Node.objects.filter(**{field: value}).first()
93
+ if not node or not node.public_key:
94
+ continue
95
+ if node.pk is not None and node.pk in seen:
96
+ continue
97
+ if node.pk is not None:
98
+ seen.add(node.pk)
99
+ candidates.append(node)
100
+
101
+ if not candidates:
102
+ return None, JsonResponse({"detail": "unknown requester"}, status=403)
103
+
104
+ for node in candidates:
105
+ try:
106
+ loaded_key = serialization.load_pem_public_key(node.public_key.encode())
107
+ loaded_key.verify(
108
+ signature_bytes,
109
+ request.body,
110
+ padding.PKCS1v15(),
111
+ hashes.SHA256(),
112
+ )
113
+ except Exception:
114
+ continue
115
+ return node, None
116
+
117
+ return None, JsonResponse({"detail": "invalid signature"}, status=403)
118
+
119
+
120
+ def _clean_requester_hint(value, *, strip: bool = True) -> str | None:
121
+ if not isinstance(value, str):
122
+ return None
123
+ cleaned = value.strip() if strip else value
124
+ if not cleaned:
125
+ return None
126
+ return cleaned
127
+
128
+
129
+ def _sanitize_proxy_target(target: str | None, request) -> str:
130
+ default_target = reverse("admin:index")
131
+ if not target:
132
+ return default_target
133
+ candidate = str(target).strip()
134
+ if not candidate:
135
+ return default_target
136
+ if candidate.startswith(("http://", "https://")):
137
+ parsed = urlsplit(candidate)
138
+ if not parsed.path:
139
+ return default_target
140
+ allowed = url_has_allowed_host_and_scheme(
141
+ candidate,
142
+ allowed_hosts={request.get_host()},
143
+ require_https=request.is_secure(),
144
+ )
145
+ if not allowed:
146
+ return default_target
147
+ path = parsed.path
148
+ if parsed.query:
149
+ path = f"{path}?{parsed.query}"
150
+ return path
151
+ if not candidate.startswith("/"):
152
+ candidate = f"/{candidate}"
153
+ return candidate
154
+
155
+
156
+ def _assign_groups_and_permissions(user, payload: Mapping) -> None:
157
+ groups = payload.get("groups", [])
158
+ group_objs: list[Group] = []
159
+ if isinstance(groups, (list, tuple)):
160
+ for name in groups:
161
+ if not isinstance(name, str):
162
+ continue
163
+ cleaned = name.strip()
164
+ if not cleaned:
165
+ continue
166
+ group, _ = Group.objects.get_or_create(name=cleaned)
167
+ group_objs.append(group)
168
+ if group_objs or user.groups.exists():
169
+ user.groups.set(group_objs)
170
+
171
+ permissions = payload.get("permissions", [])
172
+ perm_objs: list[Permission] = []
173
+ if isinstance(permissions, (list, tuple)):
174
+ for label in permissions:
175
+ if not isinstance(label, str):
176
+ continue
177
+ app_label, _, codename = label.partition(".")
178
+ if not app_label or not codename:
179
+ continue
180
+ perm = Permission.objects.filter(
181
+ content_type__app_label=app_label, codename=codename
182
+ ).first()
183
+ if perm:
184
+ perm_objs.append(perm)
185
+ if perm_objs:
186
+ user.user_permissions.set(perm_objs)
187
+
188
+
189
+ def _normalize_requested_chargers(values) -> list[tuple[str, int | None, object]]:
190
+ if not isinstance(values, list):
191
+ return []
192
+
193
+ normalized: list[tuple[str, int | None, object]] = []
194
+ for entry in values:
195
+ if not isinstance(entry, Mapping):
196
+ continue
197
+ serial = Charger.normalize_serial(entry.get("charger_id"))
198
+ if not serial or Charger.is_placeholder_serial(serial):
199
+ continue
200
+ connector = entry.get("connector_id")
201
+ if connector in ("", None):
202
+ connector_value = None
203
+ elif isinstance(connector, int):
204
+ connector_value = connector
205
+ else:
206
+ try:
207
+ connector_value = int(str(connector))
208
+ except (TypeError, ValueError):
209
+ connector_value = None
210
+ since_raw = entry.get("since")
211
+ since_dt = None
212
+ if isinstance(since_raw, str):
213
+ since_dt = parse_datetime(since_raw)
214
+ if since_dt is not None and timezone.is_naive(since_dt):
215
+ since_dt = timezone.make_aware(since_dt, timezone.get_current_timezone())
216
+ normalized.append((serial, connector_value, since_dt))
217
+ return normalized
218
+
219
+
35
220
  def _get_client_ip(request):
36
221
  """Return the client IP from the request headers."""
37
222
 
@@ -104,6 +289,8 @@ def _get_host_domain(request) -> str:
104
289
  domain, _ = split_domain_port(host)
105
290
  if not domain:
106
291
  return ""
292
+ if domain.lower() == "localhost":
293
+ return ""
107
294
  try:
108
295
  ipaddress.ip_address(domain)
109
296
  except ValueError:
@@ -111,6 +298,60 @@ def _get_host_domain(request) -> str:
111
298
  return ""
112
299
 
113
300
 
301
+ def _normalize_port(value: str | int | None) -> int | None:
302
+ """Return ``value`` as an integer port number when valid."""
303
+
304
+ if value in (None, ""):
305
+ return None
306
+ try:
307
+ port = int(value)
308
+ except (TypeError, ValueError):
309
+ return None
310
+ if port <= 0 or port > 65535:
311
+ return None
312
+ return port
313
+
314
+
315
+ def _get_host_port(request) -> int | None:
316
+ """Return the port implied by the current request if available."""
317
+
318
+ forwarded_port = request.headers.get("X-Forwarded-Port") or request.META.get(
319
+ "HTTP_X_FORWARDED_PORT"
320
+ )
321
+ port = _normalize_port(forwarded_port)
322
+ if port:
323
+ return port
324
+
325
+ try:
326
+ host = request.get_host()
327
+ except Exception: # pragma: no cover - defensive
328
+ host = ""
329
+ if host:
330
+ _, host_port = split_domain_port(host)
331
+ port = _normalize_port(host_port)
332
+ if port:
333
+ return port
334
+
335
+ forwarded_proto = request.headers.get("X-Forwarded-Proto", "")
336
+ if forwarded_proto:
337
+ scheme = forwarded_proto.split(",")[0].strip().lower()
338
+ if scheme == "https":
339
+ return 443
340
+ if scheme == "http":
341
+ return 80
342
+
343
+ if request.is_secure():
344
+ return 443
345
+
346
+ scheme = getattr(request, "scheme", "")
347
+ if scheme.lower() == "https":
348
+ return 443
349
+ if scheme.lower() == "http":
350
+ return 80
351
+
352
+ return None
353
+
354
+
114
355
  def _get_advertised_address(request, node) -> str:
115
356
  """Return the best address for the client to reach this node."""
116
357
 
@@ -121,7 +362,7 @@ def _get_advertised_address(request, node) -> str:
121
362
  host_ip = _get_host_ip(request)
122
363
  if host_ip:
123
364
  return host_ip
124
- return node.address
365
+ return node.get_primary_contact() or node.address or node.hostname
125
366
 
126
367
 
127
368
  @api_login_required
@@ -131,7 +372,10 @@ def node_list(request):
131
372
  nodes = [
132
373
  {
133
374
  "hostname": node.hostname,
375
+ "network_hostname": node.network_hostname,
134
376
  "address": node.address,
377
+ "ipv4_address": node.ipv4_address,
378
+ "ipv6_address": node.ipv6_address,
135
379
  "port": node.port,
136
380
  "last_seen": node.last_seen,
137
381
  "features": list(node.features.values_list("slug", flat=True)),
@@ -152,22 +396,29 @@ def node_info(request):
152
396
  token = request.GET.get("token", "")
153
397
  host_domain = _get_host_domain(request)
154
398
  advertised_address = _get_advertised_address(request, node)
399
+ advertised_port = node.port
400
+ if host_domain:
401
+ host_port = _get_host_port(request)
402
+ if host_port:
403
+ advertised_port = host_port
155
404
  if host_domain:
156
405
  hostname = host_domain
157
- if advertised_address and advertised_address != node.address:
158
- address = advertised_address
159
- else:
160
- address = host_domain
406
+ address = advertised_address or host_domain
161
407
  else:
162
408
  hostname = node.hostname
163
- address = advertised_address
409
+ address = advertised_address or node.address or node.network_hostname or ""
164
410
  data = {
165
411
  "hostname": hostname,
412
+ "network_hostname": node.network_hostname,
166
413
  "address": address,
167
- "port": node.port,
414
+ "ipv4_address": node.ipv4_address,
415
+ "ipv6_address": node.ipv6_address,
416
+ "port": advertised_port,
168
417
  "mac_address": node.mac_address,
169
418
  "public_key": node.public_key,
170
419
  "features": list(node.features.values_list("slug", flat=True)),
420
+ "role": node.role.name if node.role_id else "",
421
+ "contact_hosts": node.get_remote_host_candidates(),
171
422
  }
172
423
 
173
424
  if token:
@@ -211,7 +462,14 @@ def _add_cors_headers(request, response):
211
462
  def _node_display_name(node: Node) -> str:
212
463
  """Return a human-friendly name for ``node`` suitable for messaging."""
213
464
 
214
- for attr in ("hostname", "public_endpoint", "address"):
465
+ for attr in (
466
+ "hostname",
467
+ "network_hostname",
468
+ "public_endpoint",
469
+ "address",
470
+ "ipv6_address",
471
+ "ipv4_address",
472
+ ):
215
473
  value = getattr(node, attr, "") or ""
216
474
  value = value.strip()
217
475
  if value:
@@ -263,10 +521,13 @@ def register_node(request):
263
521
  else:
264
522
  features = data.get("features")
265
523
 
266
- hostname = data.get("hostname")
267
- address = data.get("address")
524
+ hostname = (data.get("hostname") or "").strip()
525
+ address = (data.get("address") or "").strip()
526
+ network_hostname = (data.get("network_hostname") or "").strip()
527
+ ipv4_address = (data.get("ipv4_address") or "").strip()
528
+ ipv6_address = (data.get("ipv6_address") or "").strip()
268
529
  port = data.get("port", 8000)
269
- mac_address = data.get("mac_address")
530
+ mac_address = (data.get("mac_address") or "").strip()
270
531
  public_key = data.get("public_key")
271
532
  token = data.get("token")
272
533
  signature = data.get("signature")
@@ -282,12 +543,27 @@ def register_node(request):
282
543
  Node.normalize_relation(raw_relation) if relation_present else None
283
544
  )
284
545
 
285
- if not hostname or not address or not mac_address:
546
+ if not hostname or not mac_address:
547
+ response = JsonResponse(
548
+ {"detail": "hostname and mac_address required"}, status=400
549
+ )
550
+ return _add_cors_headers(request, response)
551
+
552
+ if not any([address, network_hostname, ipv4_address, ipv6_address]):
286
553
  response = JsonResponse(
287
- {"detail": "hostname, address and mac_address required"}, status=400
554
+ {
555
+ "detail": "at least one of address, network_hostname, "
556
+ "ipv4_address or ipv6_address must be provided",
557
+ },
558
+ status=400,
288
559
  )
289
560
  return _add_cors_headers(request, response)
290
561
 
562
+ try:
563
+ port = int(port)
564
+ except (TypeError, ValueError):
565
+ port = 8000
566
+
291
567
  verified = False
292
568
  if public_key and token and signature:
293
569
  try:
@@ -308,11 +584,36 @@ def register_node(request):
308
584
  return _add_cors_headers(request, response)
309
585
 
310
586
  mac_address = mac_address.lower()
587
+ address_value = address or None
588
+ ipv4_value = ipv4_address or None
589
+ ipv6_value = ipv6_address or None
590
+
591
+ for candidate in (address, network_hostname, hostname):
592
+ candidate = (candidate or "").strip()
593
+ if not candidate:
594
+ continue
595
+ try:
596
+ parsed_ip = ipaddress.ip_address(candidate)
597
+ except ValueError:
598
+ continue
599
+ if parsed_ip.version == 4 and not ipv4_value:
600
+ ipv4_value = str(parsed_ip)
601
+ elif parsed_ip.version == 6 and not ipv6_value:
602
+ ipv6_value = str(parsed_ip)
311
603
  defaults = {
312
604
  "hostname": hostname,
313
- "address": address,
605
+ "network_hostname": network_hostname,
606
+ "address": address_value,
607
+ "ipv4_address": ipv4_value,
608
+ "ipv6_address": ipv6_value,
314
609
  "port": port,
315
610
  }
611
+ role_name = str(data.get("role") or data.get("role_name") or "").strip()
612
+ desired_role = None
613
+ if role_name and (verified or request.user.is_authenticated):
614
+ desired_role = NodeRole.objects.filter(name=role_name).first()
615
+ if desired_role:
616
+ defaults["role"] = desired_role
316
617
  if verified:
317
618
  defaults["public_key"] = public_key
318
619
  if installed_version is not None:
@@ -329,10 +630,18 @@ def register_node(request):
329
630
  if not created:
330
631
  previous_version = (node.installed_version or "").strip()
331
632
  previous_revision = (node.installed_revision or "").strip()
332
- node.hostname = hostname
333
- node.address = address
334
- node.port = port
335
- update_fields = ["hostname", "address", "port"]
633
+ update_fields = []
634
+ for field, value in (
635
+ ("hostname", hostname),
636
+ ("network_hostname", network_hostname),
637
+ ("address", address_value),
638
+ ("ipv4_address", ipv4_value),
639
+ ("ipv6_address", ipv6_value),
640
+ ("port", port),
641
+ ):
642
+ if getattr(node, field) != value:
643
+ setattr(node, field, value)
644
+ update_fields.append(field)
336
645
  if verified:
337
646
  node.public_key = public_key
338
647
  update_fields.append("public_key")
@@ -347,7 +656,11 @@ def register_node(request):
347
656
  if relation_value is not None and node.current_relation != relation_value:
348
657
  node.current_relation = relation_value
349
658
  update_fields.append("current_relation")
350
- node.save(update_fields=update_fields)
659
+ if desired_role and node.role_id != desired_role.id:
660
+ node.role = desired_role
661
+ update_fields.append("role")
662
+ if update_fields:
663
+ node.save(update_fields=update_fields)
351
664
  current_version = (node.installed_version or "").strip()
352
665
  current_revision = (node.installed_revision or "").strip()
353
666
  node_information_updated.send(
@@ -366,7 +679,11 @@ def register_node(request):
366
679
  feature_list = list(features)
367
680
  node.update_manual_features(feature_list)
368
681
  response = JsonResponse(
369
- {"id": node.id, "detail": f"Node already exists (id: {node.id})"}
682
+ {
683
+ "id": node.id,
684
+ "uuid": str(node.uuid),
685
+ "detail": f"Node already exists (id: {node.id})",
686
+ }
370
687
  )
371
688
  return _add_cors_headers(request, response)
372
689
 
@@ -391,7 +708,7 @@ def register_node(request):
391
708
 
392
709
  _announce_visitor_join(node, relation_value)
393
710
 
394
- response = JsonResponse({"id": node.id})
711
+ response = JsonResponse({"id": node.id, "uuid": str(node.uuid)})
395
712
  return _add_cors_headers(request, response)
396
713
 
397
714
 
@@ -423,26 +740,21 @@ def export_rfids(request):
423
740
  return JsonResponse({"detail": "invalid json"}, status=400)
424
741
 
425
742
  requester = payload.get("requester")
426
- signature = request.headers.get("X-Signature")
427
743
  if not requester:
428
744
  return JsonResponse({"detail": "requester required"}, status=400)
429
- if not signature:
430
- return JsonResponse({"detail": "signature required"}, status=403)
431
745
 
432
- node = Node.objects.filter(uuid=requester).first()
433
- if not node or not node.public_key:
434
- return JsonResponse({"detail": "unknown requester"}, status=403)
435
-
436
- try:
437
- public_key = serialization.load_pem_public_key(node.public_key.encode())
438
- public_key.verify(
439
- base64.b64decode(signature),
440
- request.body,
441
- padding.PKCS1v15(),
442
- hashes.SHA256(),
443
- )
444
- except Exception:
445
- return JsonResponse({"detail": "invalid signature"}, status=403)
746
+ requester_mac = _clean_requester_hint(payload.get("requester_mac"))
747
+ requester_public_key = _clean_requester_hint(
748
+ payload.get("requester_public_key"), strip=False
749
+ )
750
+ node, error_response = _load_signed_node(
751
+ request,
752
+ requester,
753
+ mac_address=requester_mac,
754
+ public_key=requester_public_key,
755
+ )
756
+ if error_response is not None:
757
+ return error_response
446
758
 
447
759
  tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
448
760
 
@@ -462,26 +774,21 @@ def import_rfids(request):
462
774
  return JsonResponse({"detail": "invalid json"}, status=400)
463
775
 
464
776
  requester = payload.get("requester")
465
- signature = request.headers.get("X-Signature")
466
777
  if not requester:
467
778
  return JsonResponse({"detail": "requester required"}, status=400)
468
- if not signature:
469
- return JsonResponse({"detail": "signature required"}, status=403)
470
-
471
- node = Node.objects.filter(uuid=requester).first()
472
- if not node or not node.public_key:
473
- return JsonResponse({"detail": "unknown requester"}, status=403)
474
779
 
475
- try:
476
- public_key = serialization.load_pem_public_key(node.public_key.encode())
477
- public_key.verify(
478
- base64.b64decode(signature),
479
- request.body,
480
- padding.PKCS1v15(),
481
- hashes.SHA256(),
482
- )
483
- except Exception:
484
- return JsonResponse({"detail": "invalid signature"}, status=403)
780
+ requester_mac = _clean_requester_hint(payload.get("requester_mac"))
781
+ requester_public_key = _clean_requester_hint(
782
+ payload.get("requester_public_key"), strip=False
783
+ )
784
+ node, error_response = _load_signed_node(
785
+ request,
786
+ requester,
787
+ mac_address=requester_mac,
788
+ public_key=requester_public_key,
789
+ )
790
+ if error_response is not None:
791
+ return error_response
485
792
 
486
793
  rfids = payload.get("rfids", [])
487
794
  if not isinstance(rfids, list):
@@ -522,6 +829,782 @@ def import_rfids(request):
522
829
  )
523
830
 
524
831
 
832
+ @csrf_exempt
833
+ def network_chargers(request):
834
+ """Return serialized charger information for trusted peers."""
835
+
836
+ if request.method != "POST":
837
+ return JsonResponse({"detail": "POST required"}, status=405)
838
+
839
+ try:
840
+ body = json.loads(request.body.decode() or "{}")
841
+ except json.JSONDecodeError:
842
+ return JsonResponse({"detail": "invalid json"}, status=400)
843
+
844
+ requester = body.get("requester")
845
+ if not requester:
846
+ return JsonResponse({"detail": "requester required"}, status=400)
847
+
848
+ requester_mac = _clean_requester_hint(body.get("requester_mac"))
849
+ requester_public_key = _clean_requester_hint(
850
+ body.get("requester_public_key"), strip=False
851
+ )
852
+
853
+ node, error_response = _load_signed_node(
854
+ request,
855
+ requester,
856
+ mac_address=requester_mac,
857
+ public_key=requester_public_key,
858
+ )
859
+ if error_response is not None:
860
+ return error_response
861
+
862
+ requested = _normalize_requested_chargers(body.get("chargers") or [])
863
+
864
+ qs = Charger.objects.all()
865
+ local_node = Node.get_local()
866
+ if local_node:
867
+ qs = qs.filter(Q(node_origin=local_node) | Q(node_origin__isnull=True))
868
+
869
+ if requested:
870
+ filters = Q()
871
+ for serial, connector_value, _ in requested:
872
+ if connector_value is None:
873
+ filters |= Q(charger_id=serial, connector_id__isnull=True)
874
+ else:
875
+ filters |= Q(charger_id=serial, connector_id=connector_value)
876
+ qs = qs.filter(filters)
877
+
878
+ chargers = [serialize_charger_for_network(charger) for charger in qs]
879
+
880
+ include_transactions = bool(body.get("include_transactions"))
881
+ response_data: dict[str, object] = {"chargers": chargers}
882
+
883
+ if include_transactions:
884
+ serials = [serial for serial, _, _ in requested] or list(
885
+ {charger["charger_id"] for charger in chargers}
886
+ )
887
+ since_values = [since for _, _, since in requested if since]
888
+ start = min(since_values) if since_values else None
889
+ tx_payload = export_transactions(start=start, chargers=serials or None)
890
+ response_data["transactions"] = tx_payload
891
+
892
+ return JsonResponse(response_data)
893
+
894
+
895
+ @csrf_exempt
896
+ def forward_chargers(request):
897
+ """Receive forwarded charger metadata and transactions from trusted peers."""
898
+
899
+ if request.method != "POST":
900
+ return JsonResponse({"detail": "POST required"}, status=405)
901
+
902
+ try:
903
+ body = json.loads(request.body.decode() or "{}")
904
+ except json.JSONDecodeError:
905
+ return JsonResponse({"detail": "invalid json"}, status=400)
906
+
907
+ requester = body.get("requester")
908
+ if not requester:
909
+ return JsonResponse({"detail": "requester required"}, status=400)
910
+
911
+ requester_mac = _clean_requester_hint(body.get("requester_mac"))
912
+ requester_public_key = _clean_requester_hint(
913
+ body.get("requester_public_key"), strip=False
914
+ )
915
+
916
+ node, error_response = _load_signed_node(
917
+ request,
918
+ requester,
919
+ mac_address=requester_mac,
920
+ public_key=requester_public_key,
921
+ )
922
+ if error_response is not None:
923
+ return error_response
924
+
925
+ processed = 0
926
+ chargers_payload = body.get("chargers", [])
927
+ if not isinstance(chargers_payload, list):
928
+ chargers_payload = []
929
+ for entry in chargers_payload:
930
+ if not isinstance(entry, Mapping):
931
+ continue
932
+ charger = apply_remote_charger_payload(node, entry)
933
+ if charger:
934
+ processed += 1
935
+
936
+ imported = 0
937
+ transactions_payload = body.get("transactions")
938
+ if isinstance(transactions_payload, Mapping):
939
+ imported = sync_transactions_payload(transactions_payload)
940
+
941
+ return JsonResponse({"status": "ok", "chargers": processed, "transactions": imported})
942
+
943
+
944
+ def _require_local_origin(charger: Charger) -> bool:
945
+ local = Node.get_local()
946
+ if not local:
947
+ return charger.node_origin_id is None
948
+ if charger.node_origin_id is None:
949
+ return True
950
+ return charger.node_origin_id == local.pk
951
+
952
+
953
+ def _send_trigger_status(
954
+ charger: Charger, payload: Mapping | None = None
955
+ ) -> tuple[bool, str, dict[str, object]]:
956
+ connector_value = charger.connector_id
957
+ ws = store.get_connection(charger.charger_id, connector_value)
958
+ if ws is None:
959
+ return False, "no active connection", {}
960
+ payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
961
+ if connector_value is not None:
962
+ payload["connectorId"] = connector_value
963
+ message_id = uuid.uuid4().hex
964
+ msg = json.dumps([2, message_id, "TriggerMessage", payload])
965
+ try:
966
+ async_to_sync(ws.send)(msg)
967
+ except Exception as exc:
968
+ return False, f"failed to send TriggerMessage ({exc})", {}
969
+ log_key = store.identity_key(charger.charger_id, connector_value)
970
+ store.add_log(log_key, f"< {msg}", log_type="charger")
971
+ store.register_pending_call(
972
+ message_id,
973
+ {
974
+ "action": "TriggerMessage",
975
+ "charger_id": charger.charger_id,
976
+ "connector_id": connector_value,
977
+ "log_key": log_key,
978
+ "trigger_target": "StatusNotification",
979
+ "trigger_connector": connector_value,
980
+ "requested_at": timezone.now(),
981
+ },
982
+ )
983
+ store.schedule_call_timeout(
984
+ message_id,
985
+ timeout=5.0,
986
+ action="TriggerMessage",
987
+ log_key=log_key,
988
+ message="TriggerMessage StatusNotification timed out",
989
+ )
990
+ return True, "requested status update", {}
991
+
992
+
993
+ def _send_get_configuration(
994
+ charger: Charger, payload: Mapping | None = None
995
+ ) -> tuple[bool, str, dict[str, object]]:
996
+ connector_value = charger.connector_id
997
+ ws = store.get_connection(charger.charger_id, connector_value)
998
+ if ws is None:
999
+ return False, "no active connection", {}
1000
+ message_id = uuid.uuid4().hex
1001
+ msg = json.dumps([2, message_id, "GetConfiguration", {}])
1002
+ try:
1003
+ async_to_sync(ws.send)(msg)
1004
+ except Exception as exc:
1005
+ return False, f"failed to send GetConfiguration ({exc})", {}
1006
+ log_key = store.identity_key(charger.charger_id, connector_value)
1007
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1008
+ store.register_pending_call(
1009
+ message_id,
1010
+ {
1011
+ "action": "GetConfiguration",
1012
+ "charger_id": charger.charger_id,
1013
+ "connector_id": connector_value,
1014
+ "log_key": log_key,
1015
+ "requested_at": timezone.now(),
1016
+ },
1017
+ )
1018
+ store.schedule_call_timeout(
1019
+ message_id,
1020
+ timeout=5.0,
1021
+ action="GetConfiguration",
1022
+ log_key=log_key,
1023
+ message=(
1024
+ "GetConfiguration timed out: charger did not respond"
1025
+ " (operation may not be supported)"
1026
+ ),
1027
+ )
1028
+ return True, "requested configuration update", {}
1029
+
1030
+
1031
+ def _send_reset(
1032
+ charger: Charger, payload: Mapping | None = None
1033
+ ) -> tuple[bool, str, dict[str, object]]:
1034
+ connector_value = charger.connector_id
1035
+ tx = store.get_transaction(charger.charger_id, connector_value)
1036
+ if tx:
1037
+ return False, "active session in progress", {}
1038
+ message_id = uuid.uuid4().hex
1039
+ reset_type = None
1040
+ if payload:
1041
+ reset_type = payload.get("reset_type")
1042
+ msg = json.dumps(
1043
+ [2, message_id, "Reset", {"type": (reset_type or "Soft")}]
1044
+ )
1045
+ ws = store.get_connection(charger.charger_id, connector_value)
1046
+ if ws is None:
1047
+ return False, "no active connection", {}
1048
+ try:
1049
+ async_to_sync(ws.send)(msg)
1050
+ except Exception as exc:
1051
+ return False, f"failed to send Reset ({exc})", {}
1052
+ log_key = store.identity_key(charger.charger_id, connector_value)
1053
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1054
+ store.register_pending_call(
1055
+ message_id,
1056
+ {
1057
+ "action": "Reset",
1058
+ "charger_id": charger.charger_id,
1059
+ "connector_id": connector_value,
1060
+ "log_key": log_key,
1061
+ "requested_at": timezone.now(),
1062
+ },
1063
+ )
1064
+ store.schedule_call_timeout(
1065
+ message_id,
1066
+ timeout=5.0,
1067
+ action="Reset",
1068
+ log_key=log_key,
1069
+ message="Reset timed out: charger did not respond",
1070
+ )
1071
+ return True, "reset requested", {}
1072
+
1073
+
1074
+ def _toggle_rfid(
1075
+ charger: Charger, payload: Mapping | None = None
1076
+ ) -> tuple[bool, str, dict[str, object]]:
1077
+ enable = None
1078
+ if payload is not None:
1079
+ enable = payload.get("enable")
1080
+ if isinstance(enable, str):
1081
+ enable = enable.lower() in {"1", "true", "yes", "on"}
1082
+ elif isinstance(enable, (int, bool)):
1083
+ enable = bool(enable)
1084
+ if enable is None:
1085
+ enable = not charger.require_rfid
1086
+ enable_bool = bool(enable)
1087
+ Charger.objects.filter(pk=charger.pk).update(require_rfid=enable_bool)
1088
+ charger.require_rfid = enable_bool
1089
+ detail = "RFID authentication enabled" if enable_bool else "RFID authentication disabled"
1090
+ return True, detail, {"require_rfid": enable_bool}
1091
+
1092
+
1093
+ def _change_availability_remote(
1094
+ charger: Charger, payload: Mapping | None = None
1095
+ ) -> tuple[bool, str, dict[str, object]]:
1096
+ availability_type = None
1097
+ if payload is not None:
1098
+ availability_type = payload.get("availability_type")
1099
+ availability_label = str(availability_type or "").strip()
1100
+ if availability_label not in {"Operative", "Inoperative"}:
1101
+ return False, "invalid availability type", {}
1102
+ connector_value = charger.connector_id
1103
+ ws = store.get_connection(charger.charger_id, connector_value)
1104
+ if ws is None:
1105
+ return False, "no active connection", {}
1106
+ connector_id = connector_value if connector_value is not None else 0
1107
+ message_id = uuid.uuid4().hex
1108
+ msg = json.dumps(
1109
+ [
1110
+ 2,
1111
+ message_id,
1112
+ "ChangeAvailability",
1113
+ {"connectorId": connector_id, "type": availability_label},
1114
+ ]
1115
+ )
1116
+ try:
1117
+ async_to_sync(ws.send)(msg)
1118
+ except Exception as exc:
1119
+ return False, f"failed to send ChangeAvailability ({exc})", {}
1120
+ log_key = store.identity_key(charger.charger_id, connector_value)
1121
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1122
+ timestamp = timezone.now()
1123
+ store.register_pending_call(
1124
+ message_id,
1125
+ {
1126
+ "action": "ChangeAvailability",
1127
+ "charger_id": charger.charger_id,
1128
+ "connector_id": connector_value,
1129
+ "availability_type": availability_label,
1130
+ "requested_at": timestamp,
1131
+ },
1132
+ )
1133
+ updates = {
1134
+ "availability_requested_state": availability_label,
1135
+ "availability_requested_at": timestamp,
1136
+ "availability_request_status": "",
1137
+ "availability_request_status_at": None,
1138
+ "availability_request_details": "",
1139
+ }
1140
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1141
+ for field, value in updates.items():
1142
+ setattr(charger, field, value)
1143
+ return True, f"requested ChangeAvailability {availability_label}", updates
1144
+
1145
+
1146
+ def _set_availability_state_remote(
1147
+ charger: Charger, payload: Mapping | None = None
1148
+ ) -> tuple[bool, str, dict[str, object]]:
1149
+ availability_state = None
1150
+ if payload is not None:
1151
+ availability_state = payload.get("availability_state")
1152
+ availability_label = str(availability_state or "").strip()
1153
+ if availability_label not in {"Operative", "Inoperative"}:
1154
+ return False, "invalid availability state", {}
1155
+ timestamp = timezone.now()
1156
+ updates = {
1157
+ "availability_state": availability_label,
1158
+ "availability_state_updated_at": timestamp,
1159
+ }
1160
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1161
+ for field, value in updates.items():
1162
+ setattr(charger, field, value)
1163
+ return True, f"availability marked {availability_label}", updates
1164
+
1165
+
1166
+ def _remote_stop_transaction_remote(
1167
+ charger: Charger, payload: Mapping | None = None
1168
+ ) -> tuple[bool, str, dict[str, object]]:
1169
+ connector_value = charger.connector_id
1170
+ ws = store.get_connection(charger.charger_id, connector_value)
1171
+ if ws is None:
1172
+ return False, "no active connection", {}
1173
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
1174
+ if tx_obj is None:
1175
+ return False, "no active transaction", {}
1176
+ message_id = uuid.uuid4().hex
1177
+ msg = json.dumps(
1178
+ [
1179
+ 2,
1180
+ message_id,
1181
+ "RemoteStopTransaction",
1182
+ {"transactionId": tx_obj.pk},
1183
+ ]
1184
+ )
1185
+ try:
1186
+ async_to_sync(ws.send)(msg)
1187
+ except Exception as exc:
1188
+ return False, f"failed to send RemoteStopTransaction ({exc})", {}
1189
+ log_key = store.identity_key(charger.charger_id, connector_value)
1190
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1191
+ store.register_pending_call(
1192
+ message_id,
1193
+ {
1194
+ "action": "RemoteStopTransaction",
1195
+ "charger_id": charger.charger_id,
1196
+ "connector_id": connector_value,
1197
+ "transaction_id": tx_obj.pk,
1198
+ "log_key": log_key,
1199
+ "requested_at": timezone.now(),
1200
+ },
1201
+ )
1202
+ return True, "remote stop requested", {}
1203
+
1204
+
1205
+ REMOTE_ACTIONS = {
1206
+ "trigger-status": _send_trigger_status,
1207
+ "get-configuration": _send_get_configuration,
1208
+ "reset": _send_reset,
1209
+ "toggle-rfid": _toggle_rfid,
1210
+ "change-availability": _change_availability_remote,
1211
+ "set-availability-state": _set_availability_state_remote,
1212
+ "remote-stop": _remote_stop_transaction_remote,
1213
+ }
1214
+
1215
+
1216
+ @csrf_exempt
1217
+ def network_charger_action(request):
1218
+ """Execute remote admin actions on behalf of trusted nodes."""
1219
+
1220
+ if request.method != "POST":
1221
+ return JsonResponse({"detail": "POST required"}, status=405)
1222
+
1223
+ try:
1224
+ body = json.loads(request.body.decode() or "{}")
1225
+ except json.JSONDecodeError:
1226
+ return JsonResponse({"detail": "invalid json"}, status=400)
1227
+
1228
+ requester = body.get("requester")
1229
+ if not requester:
1230
+ return JsonResponse({"detail": "requester required"}, status=400)
1231
+
1232
+ requester_mac = _clean_requester_hint(body.get("requester_mac"))
1233
+ requester_public_key = _clean_requester_hint(
1234
+ body.get("requester_public_key"), strip=False
1235
+ )
1236
+
1237
+ node, error_response = _load_signed_node(
1238
+ request,
1239
+ requester,
1240
+ mac_address=requester_mac,
1241
+ public_key=requester_public_key,
1242
+ )
1243
+ if error_response is not None:
1244
+ return error_response
1245
+
1246
+ serial = Charger.normalize_serial(body.get("charger_id"))
1247
+ if not serial or Charger.is_placeholder_serial(serial):
1248
+ return JsonResponse({"detail": "invalid charger"}, status=400)
1249
+
1250
+ connector = body.get("connector_id")
1251
+ if connector in ("", None):
1252
+ connector_value = None
1253
+ elif isinstance(connector, int):
1254
+ connector_value = connector
1255
+ else:
1256
+ try:
1257
+ connector_value = int(str(connector))
1258
+ except (TypeError, ValueError):
1259
+ return JsonResponse({"detail": "invalid connector"}, status=400)
1260
+
1261
+ charger = Charger.objects.filter(
1262
+ charger_id=serial, connector_id=connector_value
1263
+ ).first()
1264
+ if not charger:
1265
+ return JsonResponse({"detail": "charger not found"}, status=404)
1266
+
1267
+ if not charger.allow_remote:
1268
+ return JsonResponse({"detail": "remote actions disabled"}, status=403)
1269
+
1270
+ if not _require_local_origin(charger):
1271
+ return JsonResponse({"detail": "charger is not managed by this node"}, status=403)
1272
+
1273
+ authorized_node_ids = {
1274
+ pk for pk in (charger.manager_node_id, charger.node_origin_id) if pk
1275
+ }
1276
+ if authorized_node_ids and node and node.pk not in authorized_node_ids:
1277
+ return JsonResponse(
1278
+ {"detail": "requester does not manage this charger"}, status=403
1279
+ )
1280
+
1281
+ action = body.get("action")
1282
+ handler = REMOTE_ACTIONS.get(action or "")
1283
+ if handler is None:
1284
+ return JsonResponse({"detail": "unsupported action"}, status=400)
1285
+
1286
+ success, message, updates = handler(charger, body)
1287
+
1288
+ status_code = 200 if success else 409
1289
+ status_label = "ok" if success else "error"
1290
+ serialized_updates: dict[str, object] = {}
1291
+ if isinstance(updates, Mapping):
1292
+ for key, value in updates.items():
1293
+ if hasattr(value, "isoformat"):
1294
+ serialized_updates[key] = value.isoformat()
1295
+ else:
1296
+ serialized_updates[key] = value
1297
+ return JsonResponse(
1298
+ {"status": status_label, "detail": message, "updates": serialized_updates},
1299
+ status=status_code,
1300
+ )
1301
+
1302
+
1303
+ @csrf_exempt
1304
+ def proxy_session(request):
1305
+ """Create a proxy login session for a remote administrator."""
1306
+
1307
+ if request.method != "POST":
1308
+ return JsonResponse({"detail": "POST required"}, status=405)
1309
+
1310
+ try:
1311
+ payload = json.loads(request.body.decode() or "{}")
1312
+ except json.JSONDecodeError:
1313
+ return JsonResponse({"detail": "invalid json"}, status=400)
1314
+
1315
+ requester = payload.get("requester")
1316
+ if not requester:
1317
+ return JsonResponse({"detail": "requester required"}, status=400)
1318
+
1319
+ requester_mac = _clean_requester_hint(payload.get("requester_mac"))
1320
+ requester_public_key = _clean_requester_hint(
1321
+ payload.get("requester_public_key"), strip=False
1322
+ )
1323
+ node, error_response = _load_signed_node(
1324
+ request,
1325
+ requester,
1326
+ mac_address=requester_mac,
1327
+ public_key=requester_public_key,
1328
+ )
1329
+ if error_response is not None:
1330
+ return error_response
1331
+
1332
+ user_payload = payload.get("user") or {}
1333
+ username = str(user_payload.get("username", "")).strip()
1334
+ if not username:
1335
+ return JsonResponse({"detail": "username required"}, status=400)
1336
+
1337
+ User = get_user_model()
1338
+ user, created = User.objects.get_or_create(
1339
+ username=username,
1340
+ defaults={
1341
+ "email": user_payload.get("email", ""),
1342
+ "first_name": user_payload.get("first_name", ""),
1343
+ "last_name": user_payload.get("last_name", ""),
1344
+ },
1345
+ )
1346
+
1347
+ updates: list[str] = []
1348
+ for field in ("first_name", "last_name", "email"):
1349
+ value = user_payload.get(field)
1350
+ if isinstance(value, str) and getattr(user, field) != value:
1351
+ setattr(user, field, value)
1352
+ updates.append(field)
1353
+
1354
+ if created:
1355
+ user.set_unusable_password()
1356
+ updates.append("password")
1357
+
1358
+ staff_flag = user_payload.get("is_staff")
1359
+ if staff_flag is not None:
1360
+ is_staff = bool(staff_flag)
1361
+ else:
1362
+ is_staff = True
1363
+ if user.is_staff != is_staff:
1364
+ user.is_staff = is_staff
1365
+ updates.append("is_staff")
1366
+
1367
+ superuser_flag = user_payload.get("is_superuser")
1368
+ if superuser_flag is not None:
1369
+ is_superuser = bool(superuser_flag)
1370
+ if user.is_superuser != is_superuser:
1371
+ user.is_superuser = is_superuser
1372
+ updates.append("is_superuser")
1373
+
1374
+ if not user.is_active:
1375
+ user.is_active = True
1376
+ updates.append("is_active")
1377
+
1378
+ if updates:
1379
+ user.save(update_fields=updates)
1380
+
1381
+ _assign_groups_and_permissions(user, user_payload)
1382
+
1383
+ target_path = _sanitize_proxy_target(payload.get("target"), request)
1384
+ nonce = secrets.token_urlsafe(24)
1385
+ cache_key = f"{PROXY_CACHE_PREFIX}{nonce}"
1386
+ cache.set(cache_key, {"user_id": user.pk}, PROXY_TOKEN_TIMEOUT)
1387
+
1388
+ signer = TimestampSigner(salt=PROXY_TOKEN_SALT)
1389
+ token = signer.sign_object({"user": user.pk, "next": target_path, "nonce": nonce})
1390
+ login_url = request.build_absolute_uri(
1391
+ reverse("node-proxy-login", args=[token])
1392
+ )
1393
+ expires = timezone.now() + timedelta(seconds=PROXY_TOKEN_TIMEOUT)
1394
+
1395
+ return JsonResponse({"login_url": login_url, "expires": expires.isoformat()})
1396
+
1397
+
1398
+ @csrf_exempt
1399
+ def proxy_login(request, token):
1400
+ """Redeem a proxy login token and redirect to the target path."""
1401
+
1402
+ signer = TimestampSigner(salt=PROXY_TOKEN_SALT)
1403
+ try:
1404
+ payload = signer.unsign_object(token, max_age=PROXY_TOKEN_TIMEOUT)
1405
+ except SignatureExpired:
1406
+ return HttpResponse(status=410)
1407
+ except BadSignature:
1408
+ return HttpResponse(status=400)
1409
+
1410
+ nonce = payload.get("nonce")
1411
+ if not nonce:
1412
+ return HttpResponse(status=400)
1413
+
1414
+ cache_key = f"{PROXY_CACHE_PREFIX}{nonce}"
1415
+ cache_payload = cache.get(cache_key)
1416
+ if not cache_payload:
1417
+ return HttpResponse(status=410)
1418
+ cache.delete(cache_key)
1419
+
1420
+ user_id = cache_payload.get("user_id")
1421
+ if not user_id:
1422
+ return HttpResponse(status=403)
1423
+
1424
+ User = get_user_model()
1425
+ user = User.objects.filter(pk=user_id).first()
1426
+ if not user or not user.is_active:
1427
+ return HttpResponse(status=403)
1428
+
1429
+ backend = getattr(user, "backend", "")
1430
+ if not backend:
1431
+ backends = getattr(settings, "AUTHENTICATION_BACKENDS", None) or ()
1432
+ backend = backends[0] if backends else "django.contrib.auth.backends.ModelBackend"
1433
+ login(request, user, backend=backend)
1434
+
1435
+ next_path = payload.get("next") or reverse("admin:index")
1436
+ if not url_has_allowed_host_and_scheme(
1437
+ next_path,
1438
+ allowed_hosts={request.get_host()},
1439
+ require_https=request.is_secure(),
1440
+ ):
1441
+ next_path = reverse("admin:index")
1442
+
1443
+ return redirect(next_path)
1444
+
1445
+
1446
+ def _suite_model_name(meta) -> str:
1447
+ base = str(meta.verbose_name_plural or meta.verbose_name or meta.object_name)
1448
+ normalized = re.sub(r"[^0-9A-Za-z]+", " ", base).title().replace(" ", "")
1449
+ return normalized or meta.object_name
1450
+
1451
+
1452
+ @csrf_exempt
1453
+ def proxy_execute(request):
1454
+ """Execute model operations on behalf of a remote interface node."""
1455
+
1456
+ if request.method != "POST":
1457
+ return JsonResponse({"detail": "POST required"}, status=405)
1458
+
1459
+ try:
1460
+ payload = json.loads(request.body.decode() or "{}")
1461
+ except json.JSONDecodeError:
1462
+ return JsonResponse({"detail": "invalid json"}, status=400)
1463
+
1464
+ requester = payload.get("requester")
1465
+ if not requester:
1466
+ return JsonResponse({"detail": "requester required"}, status=400)
1467
+
1468
+ requester_mac = _clean_requester_hint(payload.get("requester_mac"))
1469
+ requester_public_key = _clean_requester_hint(
1470
+ payload.get("requester_public_key"), strip=False
1471
+ )
1472
+ node, error_response = _load_signed_node(
1473
+ request,
1474
+ requester,
1475
+ mac_address=requester_mac,
1476
+ public_key=requester_public_key,
1477
+ )
1478
+ if error_response is not None:
1479
+ return error_response
1480
+
1481
+ action = str(payload.get("action", "")).strip().lower()
1482
+ if not action:
1483
+ return JsonResponse({"detail": "action required"}, status=400)
1484
+
1485
+ credentials = payload.get("credentials") or {}
1486
+ username = str(credentials.get("username", "")).strip()
1487
+ password_value = credentials.get("password")
1488
+ password = password_value if isinstance(password_value, str) else str(password_value or "")
1489
+ if not username or not password:
1490
+ return JsonResponse({"detail": "credentials required"}, status=401)
1491
+
1492
+ User = get_user_model()
1493
+ existing_user = User.objects.filter(username=username).first()
1494
+ auth_user = authenticate(request=None, username=username, password=password)
1495
+
1496
+ if auth_user is None:
1497
+ if existing_user is not None:
1498
+ return JsonResponse({"detail": "authentication failed"}, status=403)
1499
+ auth_user = User.objects.create_user(
1500
+ username=username,
1501
+ password=password,
1502
+ email=str(credentials.get("email", "")),
1503
+ )
1504
+ auth_user.is_staff = True
1505
+ auth_user.is_superuser = True
1506
+ auth_user.first_name = str(credentials.get("first_name", ""))
1507
+ auth_user.last_name = str(credentials.get("last_name", ""))
1508
+ auth_user.save()
1509
+ else:
1510
+ updates: list[str] = []
1511
+ for field in ("first_name", "last_name", "email"):
1512
+ value = credentials.get(field)
1513
+ if isinstance(value, str) and getattr(auth_user, field) != value:
1514
+ setattr(auth_user, field, value)
1515
+ updates.append(field)
1516
+ for flag in ("is_staff", "is_superuser"):
1517
+ if flag in credentials:
1518
+ desired = bool(credentials.get(flag))
1519
+ if getattr(auth_user, flag) != desired:
1520
+ setattr(auth_user, flag, desired)
1521
+ updates.append(flag)
1522
+ if updates:
1523
+ auth_user.save(update_fields=updates)
1524
+
1525
+ if not auth_user.is_active:
1526
+ return JsonResponse({"detail": "user inactive"}, status=403)
1527
+
1528
+ _assign_groups_and_permissions(auth_user, credentials)
1529
+
1530
+ model_label = payload.get("model")
1531
+ model = None
1532
+ if action != "schema":
1533
+ if not isinstance(model_label, str) or "." not in model_label:
1534
+ return JsonResponse({"detail": "model required"}, status=400)
1535
+ app_label, model_name = model_label.split(".", 1)
1536
+ model = apps.get_model(app_label, model_name)
1537
+ if model is None:
1538
+ return JsonResponse({"detail": "model not found"}, status=404)
1539
+
1540
+ if action == "schema":
1541
+ models_payload = []
1542
+ for registered_model in apps.get_models():
1543
+ meta = registered_model._meta
1544
+ models_payload.append(
1545
+ {
1546
+ "app_label": meta.app_label,
1547
+ "model": meta.model_name,
1548
+ "object_name": meta.object_name,
1549
+ "verbose_name": str(meta.verbose_name),
1550
+ "verbose_name_plural": str(meta.verbose_name_plural),
1551
+ "suite_name": _suite_model_name(meta),
1552
+ }
1553
+ )
1554
+ return JsonResponse({"models": models_payload})
1555
+
1556
+ action_perm = {
1557
+ "list": "view",
1558
+ "get": "view",
1559
+ "create": "add",
1560
+ "update": "change",
1561
+ "delete": "delete",
1562
+ }.get(action)
1563
+
1564
+ if action_perm and not auth_user.is_superuser:
1565
+ perm_codename = f"{model._meta.app_label}.{action_perm}_{model._meta.model_name}"
1566
+ if not auth_user.has_perm(perm_codename):
1567
+ return JsonResponse({"detail": "forbidden"}, status=403)
1568
+
1569
+ try:
1570
+ if action == "list":
1571
+ filters = payload.get("filters") or {}
1572
+ if filters and not isinstance(filters, Mapping):
1573
+ return JsonResponse({"detail": "filters must be a mapping"}, status=400)
1574
+ queryset = model._default_manager.all()
1575
+ if filters:
1576
+ queryset = queryset.filter(**filters)
1577
+ limit = payload.get("limit")
1578
+ if limit is not None:
1579
+ try:
1580
+ limit_value = int(limit)
1581
+ if limit_value > 0:
1582
+ queryset = queryset[:limit_value]
1583
+ except (TypeError, ValueError):
1584
+ pass
1585
+ data = serializers.serialize("python", queryset)
1586
+ return JsonResponse({"objects": data})
1587
+
1588
+ if action == "get":
1589
+ filters = payload.get("filters") or {}
1590
+ if filters and not isinstance(filters, Mapping):
1591
+ return JsonResponse({"detail": "filters must be a mapping"}, status=400)
1592
+ lookup = dict(filters)
1593
+ if not lookup and "pk" in payload:
1594
+ lookup = {"pk": payload.get("pk")}
1595
+ if not lookup:
1596
+ return JsonResponse({"detail": "lookup required"}, status=400)
1597
+ obj = model._default_manager.get(**lookup)
1598
+ data = serializers.serialize("python", [obj])[0]
1599
+ return JsonResponse({"object": data})
1600
+ except model.DoesNotExist:
1601
+ return JsonResponse({"detail": "not found"}, status=404)
1602
+ except Exception as exc:
1603
+ return JsonResponse({"detail": str(exc)}, status=400)
1604
+
1605
+ return JsonResponse({"detail": "unsupported action"}, status=400)
1606
+
1607
+
525
1608
  @csrf_exempt
526
1609
  @api_login_required
527
1610
  def public_node_endpoint(request, endpoint):
@@ -536,7 +1619,10 @@ def public_node_endpoint(request, endpoint):
536
1619
  if request.method == "GET":
537
1620
  data = {
538
1621
  "hostname": node.hostname,
539
- "address": node.address,
1622
+ "network_hostname": node.network_hostname,
1623
+ "address": node.address or node.get_primary_contact(),
1624
+ "ipv4_address": node.ipv4_address,
1625
+ "ipv6_address": node.ipv6_address,
540
1626
  "port": node.port,
541
1627
  "badge_color": node.badge_color,
542
1628
  "last_seen": node.last_seen,
@@ -584,100 +1670,99 @@ def net_message(request):
584
1670
  except Exception:
585
1671
  return JsonResponse({"detail": "invalid signature"}, status=403)
586
1672
 
587
- msg_uuid = data.get("uuid")
588
- subject = data.get("subject", "")
589
- body = data.get("body", "")
590
- attachments = NetMessage.normalize_attachments(data.get("attachments"))
591
- reach_name = data.get("reach")
592
- reach_role = None
593
- if reach_name:
594
- reach_role = NodeRole.objects.filter(name=reach_name).first()
595
- filter_node_uuid = data.get("filter_node")
596
- filter_node = None
597
- if filter_node_uuid:
598
- filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
599
- filter_feature_slug = data.get("filter_node_feature")
600
- filter_feature = None
601
- if filter_feature_slug:
602
- filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
603
- filter_role_name = data.get("filter_node_role")
604
- filter_role = None
605
- if filter_role_name:
606
- filter_role = NodeRole.objects.filter(name=filter_role_name).first()
607
- filter_relation_value = data.get("filter_current_relation")
608
- filter_relation = ""
609
- if filter_relation_value:
610
- relation = Node.normalize_relation(filter_relation_value)
611
- filter_relation = relation.value if relation else ""
612
- filter_installed_version = (data.get("filter_installed_version") or "")[:20]
613
- filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
614
- seen = data.get("seen", [])
615
- origin_id = data.get("origin")
616
- origin_node = None
617
- if origin_id:
618
- origin_node = Node.objects.filter(uuid=origin_id).first()
619
- if not origin_node:
620
- origin_node = node
621
- if not msg_uuid:
622
- return JsonResponse({"detail": "uuid required"}, status=400)
623
- msg, created = NetMessage.objects.get_or_create(
624
- uuid=msg_uuid,
625
- defaults={
626
- "subject": subject[:64],
627
- "body": body[:256],
628
- "reach": reach_role,
629
- "node_origin": origin_node,
630
- "attachments": attachments or None,
631
- "filter_node": filter_node,
632
- "filter_node_feature": filter_feature,
633
- "filter_node_role": filter_role,
634
- "filter_current_relation": filter_relation,
635
- "filter_installed_version": filter_installed_version,
636
- "filter_installed_revision": filter_installed_revision,
637
- },
638
- )
639
- if not created:
640
- msg.subject = subject[:64]
641
- msg.body = body[:256]
642
- update_fields = ["subject", "body"]
643
- if reach_role and msg.reach_id != reach_role.id:
644
- msg.reach = reach_role
645
- update_fields.append("reach")
646
- if msg.node_origin_id is None and origin_node:
647
- msg.node_origin = origin_node
648
- update_fields.append("node_origin")
649
- if attachments and msg.attachments != attachments:
650
- msg.attachments = attachments
651
- update_fields.append("attachments")
652
- field_updates = {
653
- "filter_node": filter_node,
654
- "filter_node_feature": filter_feature,
655
- "filter_node_role": filter_role,
656
- "filter_current_relation": filter_relation,
657
- "filter_installed_version": filter_installed_version,
658
- "filter_installed_revision": filter_installed_revision,
659
- }
660
- for field, value in field_updates.items():
661
- if getattr(msg, field) != value:
662
- setattr(msg, field, value)
663
- update_fields.append(field)
664
- msg.save(update_fields=update_fields)
665
- if attachments:
666
- msg.apply_attachments(attachments)
667
- msg.propagate(seen=seen)
1673
+ try:
1674
+ msg = NetMessage.receive_payload(data, sender=node)
1675
+ except ValueError as exc:
1676
+ return JsonResponse({"detail": str(exc)}, status=400)
668
1677
  return JsonResponse({"status": "propagated", "complete": msg.complete})
669
1678
 
670
1679
 
671
- def last_net_message(request):
672
- """Return the most recent :class:`NetMessage`."""
1680
+ @csrf_exempt
1681
+ def net_message_pull(request):
1682
+ """Allow downstream nodes to retrieve queued network messages."""
673
1683
 
674
- msg = NetMessage.objects.order_by("-created").first()
675
- if not msg:
676
- return JsonResponse({"subject": "", "body": "", "admin_url": ""})
677
- return JsonResponse(
678
- {
679
- "subject": msg.subject,
680
- "body": msg.body,
681
- "admin_url": reverse("admin:nodes_netmessage_change", args=[msg.pk]),
682
- }
1684
+ if request.method != "POST":
1685
+ return JsonResponse({"detail": "POST required"}, status=405)
1686
+ try:
1687
+ data = json.loads(request.body.decode() or "{}")
1688
+ except json.JSONDecodeError:
1689
+ return JsonResponse({"detail": "invalid json"}, status=400)
1690
+
1691
+ requester = data.get("requester")
1692
+ if not requester:
1693
+ return JsonResponse({"detail": "requester required"}, status=400)
1694
+ signature = request.headers.get("X-Signature")
1695
+ if not signature:
1696
+ return JsonResponse({"detail": "signature required"}, status=403)
1697
+
1698
+ node = Node.objects.filter(uuid=requester).first()
1699
+ if not node or not node.public_key:
1700
+ return JsonResponse({"detail": "unknown requester"}, status=403)
1701
+ try:
1702
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
1703
+ public_key.verify(
1704
+ base64.b64decode(signature),
1705
+ request.body,
1706
+ padding.PKCS1v15(),
1707
+ hashes.SHA256(),
1708
+ )
1709
+ except Exception:
1710
+ return JsonResponse({"detail": "invalid signature"}, status=403)
1711
+
1712
+ local = Node.get_local()
1713
+ if not local:
1714
+ return JsonResponse({"detail": "local node unavailable"}, status=503)
1715
+ private_key = local.get_private_key()
1716
+ if not private_key:
1717
+ return JsonResponse({"detail": "signing unavailable"}, status=503)
1718
+
1719
+ entries = (
1720
+ PendingNetMessage.objects.select_related(
1721
+ "message",
1722
+ "message__filter_node",
1723
+ "message__filter_node_feature",
1724
+ "message__filter_node_role",
1725
+ "message__node_origin",
1726
+ )
1727
+ .filter(node=node)
1728
+ .order_by("queued_at")
683
1729
  )
1730
+ messages: list[dict[str, object]] = []
1731
+ expired_ids: list[int] = []
1732
+ delivered_ids: list[int] = []
1733
+
1734
+ origin_fallback = str(local.uuid)
1735
+
1736
+ for entry in entries:
1737
+ if entry.is_stale:
1738
+ expired_ids.append(entry.pk)
1739
+ continue
1740
+ message = entry.message
1741
+ reach_source = message.filter_node_role or message.reach
1742
+ reach_name = reach_source.name if reach_source else None
1743
+ origin_node = message.node_origin
1744
+ origin_uuid = str(origin_node.uuid) if origin_node else origin_fallback
1745
+ sender_id = str(local.uuid)
1746
+ seen = [str(value) for value in entry.seen]
1747
+ payload = message._build_payload(
1748
+ sender_id=sender_id,
1749
+ origin_uuid=origin_uuid,
1750
+ reach_name=reach_name,
1751
+ seen=seen,
1752
+ )
1753
+ payload_json = message._serialize_payload(payload)
1754
+ payload_signature = message._sign_payload(payload_json, private_key)
1755
+ if not payload_signature:
1756
+ logger.warning(
1757
+ "Unable to sign queued NetMessage %s for node %s", message.pk, node.pk
1758
+ )
1759
+ continue
1760
+ messages.append({"payload": payload, "signature": payload_signature})
1761
+ delivered_ids.append(entry.pk)
1762
+
1763
+ if expired_ids:
1764
+ PendingNetMessage.objects.filter(pk__in=expired_ids).delete()
1765
+ if delivered_ids:
1766
+ PendingNetMessage.objects.filter(pk__in=delivered_ids).delete()
1767
+
1768
+ return JsonResponse({"messages": messages})