arthexis 0.1.13__py3-none-any.whl → 0.1.14__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 (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
nodes/views.py CHANGED
@@ -1,619 +1,683 @@
1
- import base64
2
- import ipaddress
3
- import json
4
- import socket
5
-
6
- from django.http import JsonResponse
7
- from django.http.request import split_domain_port
8
- from django.views.decorators.csrf import csrf_exempt
9
- from django.shortcuts import get_object_or_404
10
- from django.conf import settings
11
- from django.urls import reverse
12
- from pathlib import Path
13
- from django.utils.cache import patch_vary_headers
14
-
15
- from utils.api import api_login_required
16
-
17
- from cryptography.hazmat.primitives import serialization, hashes
18
- from cryptography.hazmat.primitives.asymmetric import padding
19
-
20
- from core.models import RFID
21
-
22
- from .models import (
23
- Node,
24
- NetMessage,
25
- NodeFeature,
26
- NodeRole,
27
- node_information_updated,
28
- )
29
- from .utils import capture_screenshot, save_screenshot
30
-
31
-
32
- def _get_client_ip(request):
33
- """Return the client IP from the request headers."""
34
-
35
- forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
36
- if forwarded_for:
37
- for value in forwarded_for.split(","):
38
- candidate = value.strip()
39
- if candidate:
40
- return candidate
41
- return request.META.get("REMOTE_ADDR", "")
42
-
43
-
44
- def _get_route_address(remote_ip: str, port: int) -> str:
45
- """Return the local address used to reach ``remote_ip``."""
46
-
47
- if not remote_ip:
48
- return ""
49
- try:
50
- parsed = ipaddress.ip_address(remote_ip)
51
- except ValueError:
52
- return ""
53
-
54
- try:
55
- target_port = int(port)
56
- except (TypeError, ValueError):
57
- target_port = 1
58
- if target_port <= 0 or target_port > 65535:
59
- target_port = 1
60
-
61
- family = socket.AF_INET6 if parsed.version == 6 else socket.AF_INET
62
- try:
63
- with socket.socket(family, socket.SOCK_DGRAM) as sock:
64
- if family == socket.AF_INET6:
65
- sock.connect((remote_ip, target_port, 0, 0))
66
- else:
67
- sock.connect((remote_ip, target_port))
68
- return sock.getsockname()[0]
69
- except OSError:
70
- return ""
71
-
72
-
73
- def _get_host_ip(request) -> str:
74
- """Return the IP address from the host header if available."""
75
-
76
- try:
77
- host = request.get_host()
78
- except Exception: # pragma: no cover - defensive
79
- return ""
80
- if not host:
81
- return ""
82
- domain, _ = split_domain_port(host)
83
- if not domain:
84
- return ""
85
- try:
86
- ipaddress.ip_address(domain)
87
- except ValueError:
88
- return ""
89
- return domain
90
-
91
-
92
- def _get_host_domain(request) -> str:
93
- """Return the domain from the host header when it isn't an IP."""
94
-
95
- try:
96
- host = request.get_host()
97
- except Exception: # pragma: no cover - defensive
98
- return ""
99
- if not host:
100
- return ""
101
- domain, _ = split_domain_port(host)
102
- if not domain:
103
- return ""
104
- try:
105
- ipaddress.ip_address(domain)
106
- except ValueError:
107
- return domain
108
- return ""
109
-
110
-
111
- def _get_advertised_address(request, node) -> str:
112
- """Return the best address for the client to reach this node."""
113
-
114
- client_ip = _get_client_ip(request)
115
- route_address = _get_route_address(client_ip, node.port)
116
- if route_address:
117
- return route_address
118
- host_ip = _get_host_ip(request)
119
- if host_ip:
120
- return host_ip
121
- return node.address
122
-
123
-
124
- @api_login_required
125
- def node_list(request):
126
- """Return a JSON list of all known nodes."""
127
-
128
- nodes = [
129
- {
130
- "hostname": node.hostname,
131
- "address": node.address,
132
- "port": node.port,
133
- "last_seen": node.last_seen,
134
- "features": list(node.features.values_list("slug", flat=True)),
135
- }
136
- for node in Node.objects.prefetch_related("features")
137
- ]
138
- return JsonResponse({"nodes": nodes})
139
-
140
-
141
- @csrf_exempt
142
- def node_info(request):
143
- """Return information about the local node and sign ``token`` if provided."""
144
-
145
- node = Node.get_local()
146
- if node is None:
147
- node, _ = Node.register_current()
148
-
149
- token = request.GET.get("token", "")
150
- host_domain = _get_host_domain(request)
151
- advertised_address = _get_advertised_address(request, node)
152
- if host_domain:
153
- hostname = host_domain
154
- if advertised_address and advertised_address != node.address:
155
- address = advertised_address
156
- else:
157
- address = host_domain
158
- else:
159
- hostname = node.hostname
160
- address = advertised_address
161
- data = {
162
- "hostname": hostname,
163
- "address": address,
164
- "port": node.port,
165
- "mac_address": node.mac_address,
166
- "public_key": node.public_key,
167
- "features": list(node.features.values_list("slug", flat=True)),
168
- }
169
-
170
- if token:
171
- try:
172
- priv_path = (
173
- Path(node.base_path or settings.BASE_DIR)
174
- / "security"
175
- / f"{node.public_endpoint}"
176
- )
177
- private_key = serialization.load_pem_private_key(
178
- priv_path.read_bytes(), password=None
179
- )
180
- signature = private_key.sign(
181
- token.encode(),
182
- padding.PKCS1v15(),
183
- hashes.SHA256(),
184
- )
185
- data["token_signature"] = base64.b64encode(signature).decode()
186
- except Exception:
187
- pass
188
-
189
- response = JsonResponse(data)
190
- response["Access-Control-Allow-Origin"] = "*"
191
- return response
192
-
193
-
194
- def _add_cors_headers(request, response):
195
- origin = request.headers.get("Origin")
196
- if origin:
197
- response["Access-Control-Allow-Origin"] = origin
198
- response["Access-Control-Allow-Credentials"] = "true"
199
- allow_headers = request.headers.get(
200
- "Access-Control-Request-Headers", "Content-Type"
201
- )
202
- response["Access-Control-Allow-Headers"] = allow_headers
203
- response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
204
- patch_vary_headers(response, ["Origin"])
205
- return response
206
-
207
-
208
- def _node_display_name(node: Node) -> str:
209
- """Return a human-friendly name for ``node`` suitable for messaging."""
210
-
211
- for attr in ("hostname", "public_endpoint", "address"):
212
- value = getattr(node, attr, "") or ""
213
- value = value.strip()
214
- if value:
215
- return value
216
- identifier = getattr(node, "pk", None)
217
- return str(identifier or node)
218
-
219
-
220
- def _announce_visitor_join(new_node: Node, relation: Node.Relation | None) -> None:
221
- """Emit a network message when the visitor node links to a host."""
222
-
223
- if relation != Node.Relation.UPSTREAM:
224
- return
225
-
226
- local_node = Node.get_local()
227
- if not local_node:
228
- return
229
-
230
- visitor_name = _node_display_name(local_node)
231
- host_name = _node_display_name(new_node)
232
- NetMessage.broadcast(subject=f"NODE {visitor_name}", body=f"JOINS {host_name}")
233
-
234
-
235
- @csrf_exempt
236
- def register_node(request):
237
- """Register or update a node from POSTed JSON data."""
238
-
239
- if request.method == "OPTIONS":
240
- response = JsonResponse({"detail": "ok"})
241
- return _add_cors_headers(request, response)
242
-
243
- if request.method != "POST":
244
- response = JsonResponse({"detail": "POST required"}, status=400)
245
- return _add_cors_headers(request, response)
246
-
247
- try:
248
- data = json.loads(request.body.decode())
249
- except json.JSONDecodeError:
250
- data = request.POST
251
-
252
- if hasattr(data, "getlist"):
253
- raw_features = data.getlist("features")
254
- if not raw_features:
255
- features = None
256
- elif len(raw_features) == 1:
257
- features = raw_features[0]
258
- else:
259
- features = raw_features
260
- else:
261
- features = data.get("features")
262
-
263
- hostname = data.get("hostname")
264
- address = data.get("address")
265
- port = data.get("port", 8000)
266
- mac_address = data.get("mac_address")
267
- public_key = data.get("public_key")
268
- token = data.get("token")
269
- signature = data.get("signature")
270
- installed_version = data.get("installed_version")
271
- installed_revision = data.get("installed_revision")
272
- relation_present = False
273
- if hasattr(data, "getlist"):
274
- relation_present = "current_relation" in data
275
- else:
276
- relation_present = "current_relation" in data
277
- raw_relation = data.get("current_relation")
278
- relation_value = (
279
- Node.normalize_relation(raw_relation) if relation_present else None
280
- )
281
-
282
- if not hostname or not address or not mac_address:
283
- response = JsonResponse(
284
- {"detail": "hostname, address and mac_address required"}, status=400
285
- )
286
- return _add_cors_headers(request, response)
287
-
288
- verified = False
289
- if public_key and token and signature:
290
- try:
291
- pub = serialization.load_pem_public_key(public_key.encode())
292
- pub.verify(
293
- base64.b64decode(signature),
294
- token.encode(),
295
- padding.PKCS1v15(),
296
- hashes.SHA256(),
297
- )
298
- verified = True
299
- except Exception:
300
- response = JsonResponse({"detail": "invalid signature"}, status=403)
301
- return _add_cors_headers(request, response)
302
-
303
- if not verified and not request.user.is_authenticated:
304
- response = JsonResponse({"detail": "authentication required"}, status=401)
305
- return _add_cors_headers(request, response)
306
-
307
- mac_address = mac_address.lower()
308
- defaults = {
309
- "hostname": hostname,
310
- "address": address,
311
- "port": port,
312
- }
313
- if verified:
314
- defaults["public_key"] = public_key
315
- if installed_version is not None:
316
- defaults["installed_version"] = str(installed_version)[:20]
317
- if installed_revision is not None:
318
- defaults["installed_revision"] = str(installed_revision)[:40]
319
- if relation_value is not None:
320
- defaults["current_relation"] = relation_value
321
-
322
- node, created = Node.objects.get_or_create(
323
- mac_address=mac_address,
324
- defaults=defaults,
325
- )
326
- if not created:
327
- previous_version = (node.installed_version or "").strip()
328
- previous_revision = (node.installed_revision or "").strip()
329
- node.hostname = hostname
330
- node.address = address
331
- node.port = port
332
- update_fields = ["hostname", "address", "port"]
333
- if verified:
334
- node.public_key = public_key
335
- update_fields.append("public_key")
336
- if installed_version is not None:
337
- node.installed_version = str(installed_version)[:20]
338
- if "installed_version" not in update_fields:
339
- update_fields.append("installed_version")
340
- if installed_revision is not None:
341
- node.installed_revision = str(installed_revision)[:40]
342
- if "installed_revision" not in update_fields:
343
- update_fields.append("installed_revision")
344
- if relation_value is not None and node.current_relation != relation_value:
345
- node.current_relation = relation_value
346
- update_fields.append("current_relation")
347
- node.save(update_fields=update_fields)
348
- current_version = (node.installed_version or "").strip()
349
- current_revision = (node.installed_revision or "").strip()
350
- node_information_updated.send(
351
- sender=Node,
352
- node=node,
353
- previous_version=previous_version,
354
- previous_revision=previous_revision,
355
- current_version=current_version,
356
- current_revision=current_revision,
357
- request=request,
358
- )
359
- if features is not None and (verified or request.user.is_authenticated):
360
- if isinstance(features, (str, bytes)):
361
- feature_list = [features]
362
- else:
363
- feature_list = list(features)
364
- node.update_manual_features(feature_list)
365
- response = JsonResponse(
366
- {"id": node.id, "detail": f"Node already exists (id: {node.id})"}
367
- )
368
- return _add_cors_headers(request, response)
369
-
370
- if features is not None and (verified or request.user.is_authenticated):
371
- if isinstance(features, (str, bytes)):
372
- feature_list = [features]
373
- else:
374
- feature_list = list(features)
375
- node.update_manual_features(feature_list)
376
-
377
- current_version = (node.installed_version or "").strip()
378
- current_revision = (node.installed_revision or "").strip()
379
- node_information_updated.send(
380
- sender=Node,
381
- node=node,
382
- previous_version="",
383
- previous_revision="",
384
- current_version=current_version,
385
- current_revision=current_revision,
386
- request=request,
387
- )
388
-
389
- _announce_visitor_join(node, relation_value)
390
-
391
- response = JsonResponse({"id": node.id})
392
- return _add_cors_headers(request, response)
393
-
394
-
395
- @api_login_required
396
- def capture(request):
397
- """Capture a screenshot of the site's root URL and record it."""
398
-
399
- url = request.build_absolute_uri("/")
400
- try:
401
- path = capture_screenshot(url)
402
- except Exception as exc: # pragma: no cover - depends on selenium setup
403
- return JsonResponse({"detail": str(exc)}, status=500)
404
- node = Node.get_local()
405
- screenshot = save_screenshot(path, node=node, method=request.method)
406
- node_id = screenshot.node.id if screenshot and screenshot.node else None
407
- return JsonResponse({"screenshot": str(path), "node": node_id})
408
-
409
-
410
- @csrf_exempt
411
- def export_rfids(request):
412
- """Return serialized RFID records for authenticated peers."""
413
-
414
- if request.method != "POST":
415
- return JsonResponse({"detail": "POST required"}, status=405)
416
-
417
- try:
418
- payload = json.loads(request.body.decode() or "{}")
419
- except json.JSONDecodeError:
420
- return JsonResponse({"detail": "invalid json"}, status=400)
421
-
422
- requester = payload.get("requester")
423
- signature = request.headers.get("X-Signature")
424
- if not requester:
425
- return JsonResponse({"detail": "requester required"}, status=400)
426
- if not signature:
427
- return JsonResponse({"detail": "signature required"}, status=403)
428
-
429
- node = Node.objects.filter(uuid=requester).first()
430
- if not node or not node.public_key:
431
- return JsonResponse({"detail": "unknown requester"}, status=403)
432
-
433
- try:
434
- public_key = serialization.load_pem_public_key(node.public_key.encode())
435
- public_key.verify(
436
- base64.b64decode(signature),
437
- request.body,
438
- padding.PKCS1v15(),
439
- hashes.SHA256(),
440
- )
441
- except Exception:
442
- return JsonResponse({"detail": "invalid signature"}, status=403)
443
-
444
- tags = []
445
- for tag in RFID.objects.all().order_by("label_id"):
446
- tags.append(
447
- {
448
- "rfid": tag.rfid,
449
- "custom_label": tag.custom_label,
450
- "key_a": tag.key_a,
451
- "key_b": tag.key_b,
452
- "data": tag.data,
453
- "key_a_verified": tag.key_a_verified,
454
- "key_b_verified": tag.key_b_verified,
455
- "allowed": tag.allowed,
456
- "color": tag.color,
457
- "kind": tag.kind,
458
- "released": tag.released,
459
- "last_seen_on": tag.last_seen_on.isoformat()
460
- if tag.last_seen_on
461
- else None,
462
- }
463
- )
464
-
465
- return JsonResponse({"rfids": tags})
466
-
467
-
468
- @csrf_exempt
469
- @api_login_required
470
- def public_node_endpoint(request, endpoint):
471
- """Public API endpoint for a node.
472
-
473
- - ``GET`` returns information about the node.
474
- - ``POST`` broadcasts the request body as a :class:`NetMessage`.
475
- """
476
-
477
- node = get_object_or_404(Node, public_endpoint=endpoint, enable_public_api=True)
478
-
479
- if request.method == "GET":
480
- data = {
481
- "hostname": node.hostname,
482
- "address": node.address,
483
- "port": node.port,
484
- "badge_color": node.badge_color,
485
- "last_seen": node.last_seen,
486
- "features": list(node.features.values_list("slug", flat=True)),
487
- }
488
- return JsonResponse(data)
489
-
490
- if request.method == "POST":
491
- NetMessage.broadcast(
492
- subject=request.method,
493
- body=request.body.decode("utf-8") if request.body else "",
494
- seen=[str(node.uuid)],
495
- )
496
- return JsonResponse({"status": "stored"})
497
-
498
- return JsonResponse({"detail": "Method not allowed"}, status=405)
499
-
500
-
501
- @csrf_exempt
502
- def net_message(request):
503
- """Receive a network message and continue propagation."""
504
-
505
- if request.method != "POST":
506
- return JsonResponse({"detail": "POST required"}, status=400)
507
- try:
508
- data = json.loads(request.body.decode())
509
- except json.JSONDecodeError:
510
- return JsonResponse({"detail": "invalid json"}, status=400)
511
-
512
- signature = request.headers.get("X-Signature")
513
- sender_id = data.get("sender")
514
- if not signature or not sender_id:
515
- return JsonResponse({"detail": "signature required"}, status=403)
516
- node = Node.objects.filter(uuid=sender_id).first()
517
- if not node or not node.public_key:
518
- return JsonResponse({"detail": "unknown sender"}, status=403)
519
- try:
520
- public_key = serialization.load_pem_public_key(node.public_key.encode())
521
- public_key.verify(
522
- base64.b64decode(signature),
523
- request.body,
524
- padding.PKCS1v15(),
525
- hashes.SHA256(),
526
- )
527
- except Exception:
528
- return JsonResponse({"detail": "invalid signature"}, status=403)
529
-
530
- msg_uuid = data.get("uuid")
531
- subject = data.get("subject", "")
532
- body = data.get("body", "")
533
- reach_name = data.get("reach")
534
- reach_role = None
535
- if reach_name:
536
- reach_role = NodeRole.objects.filter(name=reach_name).first()
537
- filter_node_uuid = data.get("filter_node")
538
- filter_node = None
539
- if filter_node_uuid:
540
- filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
541
- filter_feature_slug = data.get("filter_node_feature")
542
- filter_feature = None
543
- if filter_feature_slug:
544
- filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
545
- filter_role_name = data.get("filter_node_role")
546
- filter_role = None
547
- if filter_role_name:
548
- filter_role = NodeRole.objects.filter(name=filter_role_name).first()
549
- filter_relation_value = data.get("filter_current_relation")
550
- filter_relation = ""
551
- if filter_relation_value:
552
- relation = Node.normalize_relation(filter_relation_value)
553
- filter_relation = relation.value if relation else ""
554
- filter_installed_version = (data.get("filter_installed_version") or "")[:20]
555
- filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
556
- seen = data.get("seen", [])
557
- origin_id = data.get("origin")
558
- origin_node = None
559
- if origin_id:
560
- origin_node = Node.objects.filter(uuid=origin_id).first()
561
- if not origin_node:
562
- origin_node = node
563
- if not msg_uuid:
564
- return JsonResponse({"detail": "uuid required"}, status=400)
565
- msg, created = NetMessage.objects.get_or_create(
566
- uuid=msg_uuid,
567
- defaults={
568
- "subject": subject[:64],
569
- "body": body[:256],
570
- "reach": reach_role,
571
- "node_origin": origin_node,
572
- "filter_node": filter_node,
573
- "filter_node_feature": filter_feature,
574
- "filter_node_role": filter_role,
575
- "filter_current_relation": filter_relation,
576
- "filter_installed_version": filter_installed_version,
577
- "filter_installed_revision": filter_installed_revision,
578
- },
579
- )
580
- if not created:
581
- msg.subject = subject[:64]
582
- msg.body = body[:256]
583
- update_fields = ["subject", "body"]
584
- if reach_role and msg.reach_id != reach_role.id:
585
- msg.reach = reach_role
586
- update_fields.append("reach")
587
- if msg.node_origin_id is None and origin_node:
588
- msg.node_origin = origin_node
589
- update_fields.append("node_origin")
590
- field_updates = {
591
- "filter_node": filter_node,
592
- "filter_node_feature": filter_feature,
593
- "filter_node_role": filter_role,
594
- "filter_current_relation": filter_relation,
595
- "filter_installed_version": filter_installed_version,
596
- "filter_installed_revision": filter_installed_revision,
597
- }
598
- for field, value in field_updates.items():
599
- if getattr(msg, field) != value:
600
- setattr(msg, field, value)
601
- update_fields.append(field)
602
- msg.save(update_fields=update_fields)
603
- msg.propagate(seen=seen)
604
- return JsonResponse({"status": "propagated", "complete": msg.complete})
605
-
606
-
607
- def last_net_message(request):
608
- """Return the most recent :class:`NetMessage`."""
609
-
610
- msg = NetMessage.objects.order_by("-created").first()
611
- if not msg:
612
- return JsonResponse({"subject": "", "body": "", "admin_url": ""})
613
- return JsonResponse(
614
- {
615
- "subject": msg.subject,
616
- "body": msg.body,
617
- "admin_url": reverse("admin:nodes_netmessage_change", args=[msg.pk]),
618
- }
619
- )
1
+ import base64
2
+ import ipaddress
3
+ import json
4
+ import socket
5
+ from collections.abc import Mapping
6
+
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.conf import settings
12
+ from django.urls import reverse
13
+ from pathlib import Path
14
+ from django.utils.cache import patch_vary_headers
15
+
16
+ from utils.api import api_login_required
17
+
18
+ from cryptography.hazmat.primitives import serialization, hashes
19
+ from cryptography.hazmat.primitives.asymmetric import padding
20
+
21
+ from core.models import RFID
22
+
23
+ from .rfid_sync import apply_rfid_payload, serialize_rfid
24
+
25
+ from .models import (
26
+ Node,
27
+ NetMessage,
28
+ NodeFeature,
29
+ NodeRole,
30
+ node_information_updated,
31
+ )
32
+ from .utils import capture_screenshot, save_screenshot
33
+
34
+
35
+ def _get_client_ip(request):
36
+ """Return the client IP from the request headers."""
37
+
38
+ forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
39
+ if forwarded_for:
40
+ for value in forwarded_for.split(","):
41
+ candidate = value.strip()
42
+ if candidate:
43
+ return candidate
44
+ return request.META.get("REMOTE_ADDR", "")
45
+
46
+
47
+ def _get_route_address(remote_ip: str, port: int) -> str:
48
+ """Return the local address used to reach ``remote_ip``."""
49
+
50
+ if not remote_ip:
51
+ return ""
52
+ try:
53
+ parsed = ipaddress.ip_address(remote_ip)
54
+ except ValueError:
55
+ return ""
56
+
57
+ try:
58
+ target_port = int(port)
59
+ except (TypeError, ValueError):
60
+ target_port = 1
61
+ if target_port <= 0 or target_port > 65535:
62
+ target_port = 1
63
+
64
+ family = socket.AF_INET6 if parsed.version == 6 else socket.AF_INET
65
+ try:
66
+ with socket.socket(family, socket.SOCK_DGRAM) as sock:
67
+ if family == socket.AF_INET6:
68
+ sock.connect((remote_ip, target_port, 0, 0))
69
+ else:
70
+ sock.connect((remote_ip, target_port))
71
+ return sock.getsockname()[0]
72
+ except OSError:
73
+ return ""
74
+
75
+
76
+ def _get_host_ip(request) -> str:
77
+ """Return the IP address from the host header if available."""
78
+
79
+ try:
80
+ host = request.get_host()
81
+ except Exception: # pragma: no cover - defensive
82
+ return ""
83
+ if not host:
84
+ return ""
85
+ domain, _ = split_domain_port(host)
86
+ if not domain:
87
+ return ""
88
+ try:
89
+ ipaddress.ip_address(domain)
90
+ except ValueError:
91
+ return ""
92
+ return domain
93
+
94
+
95
+ def _get_host_domain(request) -> str:
96
+ """Return the domain from the host header when it isn't an IP."""
97
+
98
+ try:
99
+ host = request.get_host()
100
+ except Exception: # pragma: no cover - defensive
101
+ return ""
102
+ if not host:
103
+ return ""
104
+ domain, _ = split_domain_port(host)
105
+ if not domain:
106
+ return ""
107
+ try:
108
+ ipaddress.ip_address(domain)
109
+ except ValueError:
110
+ return domain
111
+ return ""
112
+
113
+
114
+ def _get_advertised_address(request, node) -> str:
115
+ """Return the best address for the client to reach this node."""
116
+
117
+ client_ip = _get_client_ip(request)
118
+ route_address = _get_route_address(client_ip, node.port)
119
+ if route_address:
120
+ return route_address
121
+ host_ip = _get_host_ip(request)
122
+ if host_ip:
123
+ return host_ip
124
+ return node.address
125
+
126
+
127
+ @api_login_required
128
+ def node_list(request):
129
+ """Return a JSON list of all known nodes."""
130
+
131
+ nodes = [
132
+ {
133
+ "hostname": node.hostname,
134
+ "address": node.address,
135
+ "port": node.port,
136
+ "last_seen": node.last_seen,
137
+ "features": list(node.features.values_list("slug", flat=True)),
138
+ }
139
+ for node in Node.objects.prefetch_related("features")
140
+ ]
141
+ return JsonResponse({"nodes": nodes})
142
+
143
+
144
+ @csrf_exempt
145
+ def node_info(request):
146
+ """Return information about the local node and sign ``token`` if provided."""
147
+
148
+ node = Node.get_local()
149
+ if node is None:
150
+ node, _ = Node.register_current()
151
+
152
+ token = request.GET.get("token", "")
153
+ host_domain = _get_host_domain(request)
154
+ advertised_address = _get_advertised_address(request, node)
155
+ if host_domain:
156
+ hostname = host_domain
157
+ if advertised_address and advertised_address != node.address:
158
+ address = advertised_address
159
+ else:
160
+ address = host_domain
161
+ else:
162
+ hostname = node.hostname
163
+ address = advertised_address
164
+ data = {
165
+ "hostname": hostname,
166
+ "address": address,
167
+ "port": node.port,
168
+ "mac_address": node.mac_address,
169
+ "public_key": node.public_key,
170
+ "features": list(node.features.values_list("slug", flat=True)),
171
+ }
172
+
173
+ if token:
174
+ try:
175
+ priv_path = (
176
+ Path(node.base_path or settings.BASE_DIR)
177
+ / "security"
178
+ / f"{node.public_endpoint}"
179
+ )
180
+ private_key = serialization.load_pem_private_key(
181
+ priv_path.read_bytes(), password=None
182
+ )
183
+ signature = private_key.sign(
184
+ token.encode(),
185
+ padding.PKCS1v15(),
186
+ hashes.SHA256(),
187
+ )
188
+ data["token_signature"] = base64.b64encode(signature).decode()
189
+ except Exception:
190
+ pass
191
+
192
+ response = JsonResponse(data)
193
+ response["Access-Control-Allow-Origin"] = "*"
194
+ return response
195
+
196
+
197
+ def _add_cors_headers(request, response):
198
+ origin = request.headers.get("Origin")
199
+ if origin:
200
+ response["Access-Control-Allow-Origin"] = origin
201
+ response["Access-Control-Allow-Credentials"] = "true"
202
+ allow_headers = request.headers.get(
203
+ "Access-Control-Request-Headers", "Content-Type"
204
+ )
205
+ response["Access-Control-Allow-Headers"] = allow_headers
206
+ response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
207
+ patch_vary_headers(response, ["Origin"])
208
+ return response
209
+
210
+
211
+ def _node_display_name(node: Node) -> str:
212
+ """Return a human-friendly name for ``node`` suitable for messaging."""
213
+
214
+ for attr in ("hostname", "public_endpoint", "address"):
215
+ value = getattr(node, attr, "") or ""
216
+ value = value.strip()
217
+ if value:
218
+ return value
219
+ identifier = getattr(node, "pk", None)
220
+ return str(identifier or node)
221
+
222
+
223
+ def _announce_visitor_join(new_node: Node, relation: Node.Relation | None) -> None:
224
+ """Emit a network message when the visitor node links to a host."""
225
+
226
+ if relation != Node.Relation.UPSTREAM:
227
+ return
228
+
229
+ local_node = Node.get_local()
230
+ if not local_node:
231
+ return
232
+
233
+ visitor_name = _node_display_name(local_node)
234
+ host_name = _node_display_name(new_node)
235
+ NetMessage.broadcast(subject=f"NODE {visitor_name}", body=f"JOINS {host_name}")
236
+
237
+
238
+ @csrf_exempt
239
+ def register_node(request):
240
+ """Register or update a node from POSTed JSON data."""
241
+
242
+ if request.method == "OPTIONS":
243
+ response = JsonResponse({"detail": "ok"})
244
+ return _add_cors_headers(request, response)
245
+
246
+ if request.method != "POST":
247
+ response = JsonResponse({"detail": "POST required"}, status=400)
248
+ return _add_cors_headers(request, response)
249
+
250
+ try:
251
+ data = json.loads(request.body.decode())
252
+ except json.JSONDecodeError:
253
+ data = request.POST
254
+
255
+ if hasattr(data, "getlist"):
256
+ raw_features = data.getlist("features")
257
+ if not raw_features:
258
+ features = None
259
+ elif len(raw_features) == 1:
260
+ features = raw_features[0]
261
+ else:
262
+ features = raw_features
263
+ else:
264
+ features = data.get("features")
265
+
266
+ hostname = data.get("hostname")
267
+ address = data.get("address")
268
+ port = data.get("port", 8000)
269
+ mac_address = data.get("mac_address")
270
+ public_key = data.get("public_key")
271
+ token = data.get("token")
272
+ signature = data.get("signature")
273
+ installed_version = data.get("installed_version")
274
+ installed_revision = data.get("installed_revision")
275
+ relation_present = False
276
+ if hasattr(data, "getlist"):
277
+ relation_present = "current_relation" in data
278
+ else:
279
+ relation_present = "current_relation" in data
280
+ raw_relation = data.get("current_relation")
281
+ relation_value = (
282
+ Node.normalize_relation(raw_relation) if relation_present else None
283
+ )
284
+
285
+ if not hostname or not address or not mac_address:
286
+ response = JsonResponse(
287
+ {"detail": "hostname, address and mac_address required"}, status=400
288
+ )
289
+ return _add_cors_headers(request, response)
290
+
291
+ verified = False
292
+ if public_key and token and signature:
293
+ try:
294
+ pub = serialization.load_pem_public_key(public_key.encode())
295
+ pub.verify(
296
+ base64.b64decode(signature),
297
+ token.encode(),
298
+ padding.PKCS1v15(),
299
+ hashes.SHA256(),
300
+ )
301
+ verified = True
302
+ except Exception:
303
+ response = JsonResponse({"detail": "invalid signature"}, status=403)
304
+ return _add_cors_headers(request, response)
305
+
306
+ if not verified and not request.user.is_authenticated:
307
+ response = JsonResponse({"detail": "authentication required"}, status=401)
308
+ return _add_cors_headers(request, response)
309
+
310
+ mac_address = mac_address.lower()
311
+ defaults = {
312
+ "hostname": hostname,
313
+ "address": address,
314
+ "port": port,
315
+ }
316
+ if verified:
317
+ defaults["public_key"] = public_key
318
+ if installed_version is not None:
319
+ defaults["installed_version"] = str(installed_version)[:20]
320
+ if installed_revision is not None:
321
+ defaults["installed_revision"] = str(installed_revision)[:40]
322
+ if relation_value is not None:
323
+ defaults["current_relation"] = relation_value
324
+
325
+ node, created = Node.objects.get_or_create(
326
+ mac_address=mac_address,
327
+ defaults=defaults,
328
+ )
329
+ if not created:
330
+ previous_version = (node.installed_version or "").strip()
331
+ 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"]
336
+ if verified:
337
+ node.public_key = public_key
338
+ update_fields.append("public_key")
339
+ if installed_version is not None:
340
+ node.installed_version = str(installed_version)[:20]
341
+ if "installed_version" not in update_fields:
342
+ update_fields.append("installed_version")
343
+ if installed_revision is not None:
344
+ node.installed_revision = str(installed_revision)[:40]
345
+ if "installed_revision" not in update_fields:
346
+ update_fields.append("installed_revision")
347
+ if relation_value is not None and node.current_relation != relation_value:
348
+ node.current_relation = relation_value
349
+ update_fields.append("current_relation")
350
+ node.save(update_fields=update_fields)
351
+ current_version = (node.installed_version or "").strip()
352
+ current_revision = (node.installed_revision or "").strip()
353
+ node_information_updated.send(
354
+ sender=Node,
355
+ node=node,
356
+ previous_version=previous_version,
357
+ previous_revision=previous_revision,
358
+ current_version=current_version,
359
+ current_revision=current_revision,
360
+ request=request,
361
+ )
362
+ if features is not None and (verified or request.user.is_authenticated):
363
+ if isinstance(features, (str, bytes)):
364
+ feature_list = [features]
365
+ else:
366
+ feature_list = list(features)
367
+ node.update_manual_features(feature_list)
368
+ response = JsonResponse(
369
+ {"id": node.id, "detail": f"Node already exists (id: {node.id})"}
370
+ )
371
+ return _add_cors_headers(request, response)
372
+
373
+ if features is not None and (verified or request.user.is_authenticated):
374
+ if isinstance(features, (str, bytes)):
375
+ feature_list = [features]
376
+ else:
377
+ feature_list = list(features)
378
+ node.update_manual_features(feature_list)
379
+
380
+ current_version = (node.installed_version or "").strip()
381
+ current_revision = (node.installed_revision or "").strip()
382
+ node_information_updated.send(
383
+ sender=Node,
384
+ node=node,
385
+ previous_version="",
386
+ previous_revision="",
387
+ current_version=current_version,
388
+ current_revision=current_revision,
389
+ request=request,
390
+ )
391
+
392
+ _announce_visitor_join(node, relation_value)
393
+
394
+ response = JsonResponse({"id": node.id})
395
+ return _add_cors_headers(request, response)
396
+
397
+
398
+ @api_login_required
399
+ def capture(request):
400
+ """Capture a screenshot of the site's root URL and record it."""
401
+
402
+ url = request.build_absolute_uri("/")
403
+ try:
404
+ path = capture_screenshot(url)
405
+ except Exception as exc: # pragma: no cover - depends on selenium setup
406
+ return JsonResponse({"detail": str(exc)}, status=500)
407
+ node = Node.get_local()
408
+ screenshot = save_screenshot(path, node=node, method=request.method)
409
+ node_id = screenshot.node.id if screenshot and screenshot.node else None
410
+ return JsonResponse({"screenshot": str(path), "node": node_id})
411
+
412
+
413
+ @csrf_exempt
414
+ def export_rfids(request):
415
+ """Return serialized RFID records for authenticated peers."""
416
+
417
+ if request.method != "POST":
418
+ return JsonResponse({"detail": "POST required"}, status=405)
419
+
420
+ try:
421
+ payload = json.loads(request.body.decode() or "{}")
422
+ except json.JSONDecodeError:
423
+ return JsonResponse({"detail": "invalid json"}, status=400)
424
+
425
+ requester = payload.get("requester")
426
+ signature = request.headers.get("X-Signature")
427
+ if not requester:
428
+ return JsonResponse({"detail": "requester required"}, status=400)
429
+ if not signature:
430
+ return JsonResponse({"detail": "signature required"}, status=403)
431
+
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)
446
+
447
+ tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
448
+
449
+ return JsonResponse({"rfids": tags})
450
+
451
+
452
+ @csrf_exempt
453
+ def import_rfids(request):
454
+ """Import RFID payloads from a trusted peer."""
455
+
456
+ if request.method != "POST":
457
+ return JsonResponse({"detail": "POST required"}, status=405)
458
+
459
+ try:
460
+ payload = json.loads(request.body.decode() or "{}")
461
+ except json.JSONDecodeError:
462
+ return JsonResponse({"detail": "invalid json"}, status=400)
463
+
464
+ requester = payload.get("requester")
465
+ signature = request.headers.get("X-Signature")
466
+ if not requester:
467
+ 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
+
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)
485
+
486
+ rfids = payload.get("rfids", [])
487
+ if not isinstance(rfids, list):
488
+ return JsonResponse({"detail": "rfids must be a list"}, status=400)
489
+
490
+ created = 0
491
+ updated = 0
492
+ linked_accounts = 0
493
+ missing_accounts: list[str] = []
494
+ errors = 0
495
+
496
+ for entry in rfids:
497
+ if not isinstance(entry, Mapping):
498
+ errors += 1
499
+ continue
500
+ outcome = apply_rfid_payload(entry, origin_node=node)
501
+ if not outcome.ok:
502
+ errors += 1
503
+ if outcome.error:
504
+ missing_accounts.append(outcome.error)
505
+ continue
506
+ if outcome.created:
507
+ created += 1
508
+ else:
509
+ updated += 1
510
+ linked_accounts += outcome.accounts_linked
511
+ missing_accounts.extend(outcome.missing_accounts)
512
+
513
+ return JsonResponse(
514
+ {
515
+ "processed": len(rfids),
516
+ "created": created,
517
+ "updated": updated,
518
+ "accounts_linked": linked_accounts,
519
+ "missing_accounts": missing_accounts,
520
+ "errors": errors,
521
+ }
522
+ )
523
+
524
+
525
+ @csrf_exempt
526
+ @api_login_required
527
+ def public_node_endpoint(request, endpoint):
528
+ """Public API endpoint for a node.
529
+
530
+ - ``GET`` returns information about the node.
531
+ - ``POST`` broadcasts the request body as a :class:`NetMessage`.
532
+ """
533
+
534
+ node = get_object_or_404(Node, public_endpoint=endpoint, enable_public_api=True)
535
+
536
+ if request.method == "GET":
537
+ data = {
538
+ "hostname": node.hostname,
539
+ "address": node.address,
540
+ "port": node.port,
541
+ "badge_color": node.badge_color,
542
+ "last_seen": node.last_seen,
543
+ "features": list(node.features.values_list("slug", flat=True)),
544
+ }
545
+ return JsonResponse(data)
546
+
547
+ if request.method == "POST":
548
+ NetMessage.broadcast(
549
+ subject=request.method,
550
+ body=request.body.decode("utf-8") if request.body else "",
551
+ seen=[str(node.uuid)],
552
+ )
553
+ return JsonResponse({"status": "stored"})
554
+
555
+ return JsonResponse({"detail": "Method not allowed"}, status=405)
556
+
557
+
558
+ @csrf_exempt
559
+ def net_message(request):
560
+ """Receive a network message and continue propagation."""
561
+
562
+ if request.method != "POST":
563
+ return JsonResponse({"detail": "POST required"}, status=400)
564
+ try:
565
+ data = json.loads(request.body.decode())
566
+ except json.JSONDecodeError:
567
+ return JsonResponse({"detail": "invalid json"}, status=400)
568
+
569
+ signature = request.headers.get("X-Signature")
570
+ sender_id = data.get("sender")
571
+ if not signature or not sender_id:
572
+ return JsonResponse({"detail": "signature required"}, status=403)
573
+ node = Node.objects.filter(uuid=sender_id).first()
574
+ if not node or not node.public_key:
575
+ return JsonResponse({"detail": "unknown sender"}, status=403)
576
+ try:
577
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
578
+ public_key.verify(
579
+ base64.b64decode(signature),
580
+ request.body,
581
+ padding.PKCS1v15(),
582
+ hashes.SHA256(),
583
+ )
584
+ except Exception:
585
+ return JsonResponse({"detail": "invalid signature"}, status=403)
586
+
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)
668
+ return JsonResponse({"status": "propagated", "complete": msg.complete})
669
+
670
+
671
+ def last_net_message(request):
672
+ """Return the most recent :class:`NetMessage`."""
673
+
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
+ }
683
+ )