arthexis 0.1.14__py3-none-any.whl → 0.1.16__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/METADATA +3 -2
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/RECORD +41 -39
- config/urls.py +5 -0
- core/admin.py +200 -9
- core/admindocs.py +44 -3
- core/apps.py +1 -1
- core/backends.py +44 -8
- core/entity.py +17 -1
- core/github_issues.py +12 -7
- core/log_paths.py +24 -10
- core/mailer.py +9 -5
- core/models.py +92 -23
- core/release.py +173 -2
- core/system.py +411 -4
- core/tasks.py +5 -1
- core/test_system_info.py +16 -0
- core/tests.py +280 -0
- core/views.py +252 -38
- nodes/admin.py +25 -1
- nodes/models.py +99 -6
- nodes/rfid_sync.py +15 -0
- nodes/tests.py +142 -3
- nodes/utils.py +3 -0
- ocpp/consumers.py +38 -0
- ocpp/models.py +19 -4
- ocpp/tasks.py +156 -2
- ocpp/test_rfid.py +44 -2
- ocpp/tests.py +111 -1
- pages/admin.py +188 -5
- pages/context_processors.py +20 -1
- pages/middleware.py +4 -0
- pages/models.py +39 -1
- pages/module_defaults.py +156 -0
- pages/tasks.py +74 -0
- pages/tests.py +629 -8
- pages/urls.py +2 -0
- pages/utils.py +11 -0
- pages/views.py +106 -38
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/WHEEL +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -16,7 +16,6 @@ import base64
|
|
|
16
16
|
from django.utils import timezone
|
|
17
17
|
from django.utils.text import slugify
|
|
18
18
|
from django.conf import settings
|
|
19
|
-
from django.contrib.sites.models import Site
|
|
20
19
|
from datetime import timedelta
|
|
21
20
|
import uuid
|
|
22
21
|
import os
|
|
@@ -99,6 +98,12 @@ class NodeFeature(Entity):
|
|
|
99
98
|
url_name="admin:nodes_nodefeature_celery_report",
|
|
100
99
|
),
|
|
101
100
|
),
|
|
101
|
+
"audio-capture": (
|
|
102
|
+
NodeFeatureDefaultAction(
|
|
103
|
+
label="View Waveform",
|
|
104
|
+
url_name="admin:nodes_nodefeature_view_waveform",
|
|
105
|
+
),
|
|
106
|
+
),
|
|
102
107
|
"screenshot-poll": (
|
|
103
108
|
NodeFeatureDefaultAction(
|
|
104
109
|
label="Take Screenshot",
|
|
@@ -245,7 +250,7 @@ class Node(Entity):
|
|
|
245
250
|
"ap-router",
|
|
246
251
|
"gway-runner",
|
|
247
252
|
}
|
|
248
|
-
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
|
|
253
|
+
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll", "audio-capture"}
|
|
249
254
|
|
|
250
255
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
251
256
|
return f"{self.hostname}:{self.port}"
|
|
@@ -344,7 +349,6 @@ class Node(Entity):
|
|
|
344
349
|
if terminal:
|
|
345
350
|
node.role = terminal
|
|
346
351
|
node.save(update_fields=["role"])
|
|
347
|
-
Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
|
|
348
352
|
node.ensure_keys()
|
|
349
353
|
node.notify_peers_of_update()
|
|
350
354
|
return node, created
|
|
@@ -745,8 +749,11 @@ class Node(Entity):
|
|
|
745
749
|
def sync_feature_tasks(self):
|
|
746
750
|
clipboard_enabled = self.has_feature("clipboard-poll")
|
|
747
751
|
screenshot_enabled = self.has_feature("screenshot-poll")
|
|
752
|
+
celery_enabled = self.is_local and self.has_feature("celery-queue")
|
|
748
753
|
self._sync_clipboard_task(clipboard_enabled)
|
|
749
754
|
self._sync_screenshot_task(screenshot_enabled)
|
|
755
|
+
self._sync_landing_lead_task(celery_enabled)
|
|
756
|
+
self._sync_ocpp_session_report_task(celery_enabled)
|
|
750
757
|
|
|
751
758
|
def _sync_clipboard_task(self, enabled: bool):
|
|
752
759
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
@@ -792,6 +799,66 @@ class Node(Entity):
|
|
|
792
799
|
else:
|
|
793
800
|
PeriodicTask.objects.filter(name=task_name).delete()
|
|
794
801
|
|
|
802
|
+
def _sync_landing_lead_task(self, enabled: bool):
|
|
803
|
+
if not self.is_local:
|
|
804
|
+
return
|
|
805
|
+
|
|
806
|
+
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
|
807
|
+
|
|
808
|
+
task_name = "pages_purge_landing_leads"
|
|
809
|
+
if enabled:
|
|
810
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
811
|
+
minute="0",
|
|
812
|
+
hour="3",
|
|
813
|
+
day_of_week="*",
|
|
814
|
+
day_of_month="*",
|
|
815
|
+
month_of_year="*",
|
|
816
|
+
)
|
|
817
|
+
PeriodicTask.objects.update_or_create(
|
|
818
|
+
name=task_name,
|
|
819
|
+
defaults={
|
|
820
|
+
"crontab": schedule,
|
|
821
|
+
"interval": None,
|
|
822
|
+
"task": "pages.tasks.purge_expired_landing_leads",
|
|
823
|
+
"enabled": True,
|
|
824
|
+
},
|
|
825
|
+
)
|
|
826
|
+
else:
|
|
827
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
828
|
+
|
|
829
|
+
def _sync_ocpp_session_report_task(self, celery_enabled: bool):
|
|
830
|
+
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
|
831
|
+
from django.db.utils import OperationalError, ProgrammingError
|
|
832
|
+
|
|
833
|
+
task_name = "ocpp_send_daily_session_report"
|
|
834
|
+
|
|
835
|
+
if not self.is_local:
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
if not celery_enabled or not mailer.can_send_email():
|
|
839
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
try:
|
|
843
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
844
|
+
minute="0",
|
|
845
|
+
hour="18",
|
|
846
|
+
day_of_week="*",
|
|
847
|
+
day_of_month="*",
|
|
848
|
+
month_of_year="*",
|
|
849
|
+
)
|
|
850
|
+
PeriodicTask.objects.update_or_create(
|
|
851
|
+
name=task_name,
|
|
852
|
+
defaults={
|
|
853
|
+
"crontab": schedule,
|
|
854
|
+
"interval": None,
|
|
855
|
+
"task": "ocpp.tasks.send_daily_session_report",
|
|
856
|
+
"enabled": True,
|
|
857
|
+
},
|
|
858
|
+
)
|
|
859
|
+
except (OperationalError, ProgrammingError):
|
|
860
|
+
logger.debug("Skipping OCPP session report task sync; tables not ready")
|
|
861
|
+
|
|
795
862
|
def send_mail(
|
|
796
863
|
self,
|
|
797
864
|
subject: str,
|
|
@@ -928,15 +995,18 @@ class NodeManager(Profile):
|
|
|
928
995
|
)
|
|
929
996
|
api_key = SigilShortAutoField(
|
|
930
997
|
max_length=255,
|
|
998
|
+
verbose_name="API key",
|
|
931
999
|
help_text="API key issued by the DNS provider.",
|
|
932
1000
|
)
|
|
933
1001
|
api_secret = SigilShortAutoField(
|
|
934
1002
|
max_length=255,
|
|
1003
|
+
verbose_name="API secret",
|
|
935
1004
|
help_text="API secret issued by the DNS provider.",
|
|
936
1005
|
)
|
|
937
1006
|
customer_id = SigilShortAutoField(
|
|
938
1007
|
max_length=100,
|
|
939
1008
|
blank=True,
|
|
1009
|
+
verbose_name="Customer ID",
|
|
940
1010
|
help_text="Optional GoDaddy customer identifier for the account.",
|
|
941
1011
|
)
|
|
942
1012
|
default_domain = SigilShortAutoField(
|
|
@@ -1340,6 +1410,7 @@ class NetMessage(Entity):
|
|
|
1340
1410
|
propagated_to = models.ManyToManyField(
|
|
1341
1411
|
Node, blank=True, related_name="received_net_messages"
|
|
1342
1412
|
)
|
|
1413
|
+
confirmed_peers = models.JSONField(default=dict, blank=True)
|
|
1343
1414
|
created = models.DateTimeField(auto_now_add=True)
|
|
1344
1415
|
complete = models.BooleanField(default=False, editable=False)
|
|
1345
1416
|
|
|
@@ -1569,7 +1640,10 @@ class NetMessage(Entity):
|
|
|
1569
1640
|
seen_list = seen.copy()
|
|
1570
1641
|
selected_ids = [str(n.uuid) for n in selected]
|
|
1571
1642
|
payload_seen = seen_list + selected_ids
|
|
1643
|
+
confirmed_peers = dict(self.confirmed_peers or {})
|
|
1644
|
+
|
|
1572
1645
|
for node in selected:
|
|
1646
|
+
now = timezone.now().isoformat()
|
|
1573
1647
|
payload = {
|
|
1574
1648
|
"uuid": str(self.uuid),
|
|
1575
1649
|
"subject": self.subject,
|
|
@@ -1605,20 +1679,39 @@ class NetMessage(Entity):
|
|
|
1605
1679
|
headers["X-Signature"] = base64.b64encode(signature).decode()
|
|
1606
1680
|
except Exception:
|
|
1607
1681
|
pass
|
|
1682
|
+
status_entry = {
|
|
1683
|
+
"status": "pending",
|
|
1684
|
+
"status_code": None,
|
|
1685
|
+
"updated": now,
|
|
1686
|
+
}
|
|
1608
1687
|
try:
|
|
1609
|
-
requests.post(
|
|
1688
|
+
response = requests.post(
|
|
1610
1689
|
f"http://{node.address}:{node.port}/nodes/net-message/",
|
|
1611
1690
|
data=payload_json,
|
|
1612
1691
|
headers=headers,
|
|
1613
1692
|
timeout=1,
|
|
1614
1693
|
)
|
|
1694
|
+
status_entry["status_code"] = getattr(response, "status_code", None)
|
|
1695
|
+
if getattr(response, "ok", False):
|
|
1696
|
+
status_entry["status"] = "acknowledged"
|
|
1697
|
+
else:
|
|
1698
|
+
status_entry["status"] = "failed"
|
|
1615
1699
|
except Exception:
|
|
1616
|
-
|
|
1700
|
+
status_entry["status"] = "error"
|
|
1617
1701
|
self.propagated_to.add(node)
|
|
1702
|
+
confirmed_peers[str(node.uuid)] = status_entry
|
|
1703
|
+
|
|
1704
|
+
save_fields: list[str] = []
|
|
1705
|
+
if confirmed_peers != (self.confirmed_peers or {}):
|
|
1706
|
+
self.confirmed_peers = confirmed_peers
|
|
1707
|
+
save_fields.append("confirmed_peers")
|
|
1618
1708
|
|
|
1619
1709
|
if total_known and self.propagated_to.count() >= total_known:
|
|
1620
1710
|
self.complete = True
|
|
1621
|
-
|
|
1711
|
+
save_fields.append("complete")
|
|
1712
|
+
|
|
1713
|
+
if save_fields:
|
|
1714
|
+
self.save(update_fields=save_fields)
|
|
1622
1715
|
|
|
1623
1716
|
|
|
1624
1717
|
class ContentSample(Entity):
|
nodes/rfid_sync.py
CHANGED
|
@@ -45,6 +45,8 @@ def serialize_rfid(tag: RFID) -> dict[str, Any]:
|
|
|
45
45
|
"color": tag.color,
|
|
46
46
|
"kind": tag.kind,
|
|
47
47
|
"released": tag.released,
|
|
48
|
+
"external_command": tag.external_command,
|
|
49
|
+
"post_auth_command": tag.post_auth_command,
|
|
48
50
|
"last_seen_on": tag.last_seen_on.isoformat() if tag.last_seen_on else None,
|
|
49
51
|
"energy_accounts": [account.id for account in accounts],
|
|
50
52
|
"energy_account_names": [
|
|
@@ -64,6 +66,17 @@ def apply_rfid_payload(
|
|
|
64
66
|
outcome.error = "Missing RFID value"
|
|
65
67
|
return outcome
|
|
66
68
|
|
|
69
|
+
external_command = entry.get("external_command")
|
|
70
|
+
if not isinstance(external_command, str):
|
|
71
|
+
external_command = ""
|
|
72
|
+
else:
|
|
73
|
+
external_command = external_command.strip()
|
|
74
|
+
post_auth_command = entry.get("post_auth_command")
|
|
75
|
+
if not isinstance(post_auth_command, str):
|
|
76
|
+
post_auth_command = ""
|
|
77
|
+
else:
|
|
78
|
+
post_auth_command = post_auth_command.strip()
|
|
79
|
+
|
|
67
80
|
defaults: dict[str, Any] = {
|
|
68
81
|
"custom_label": entry.get("custom_label", ""),
|
|
69
82
|
"key_a": entry.get("key_a", RFID._meta.get_field("key_a").default),
|
|
@@ -75,6 +88,8 @@ def apply_rfid_payload(
|
|
|
75
88
|
"color": entry.get("color", RFID.BLACK),
|
|
76
89
|
"kind": entry.get("kind", RFID.CLASSIC),
|
|
77
90
|
"released": bool(entry.get("released", False)),
|
|
91
|
+
"external_command": external_command,
|
|
92
|
+
"post_auth_command": post_auth_command,
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
if origin_node is not None:
|
nodes/tests.py
CHANGED
|
@@ -35,7 +35,6 @@ from django.urls import reverse
|
|
|
35
35
|
from django.contrib.auth import get_user_model
|
|
36
36
|
from django.contrib import admin
|
|
37
37
|
from django.contrib.auth.models import Permission
|
|
38
|
-
from django.contrib.sites.models import Site
|
|
39
38
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
40
39
|
from django.conf import settings
|
|
41
40
|
from django.utils import timezone
|
|
@@ -1271,6 +1270,74 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1271
1270
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1272
1271
|
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1273
1272
|
|
|
1273
|
+
def test_landing_lead_purge_task_syncs_with_celery_feature(self):
|
|
1274
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1275
|
+
slug="celery-queue", defaults={"display": "Celery Queue"}
|
|
1276
|
+
)
|
|
1277
|
+
node, _ = Node.objects.update_or_create(
|
|
1278
|
+
mac_address=Node.get_current_mac(),
|
|
1279
|
+
defaults={
|
|
1280
|
+
"hostname": socket.gethostname(),
|
|
1281
|
+
"address": "127.0.0.1",
|
|
1282
|
+
"port": 9300,
|
|
1283
|
+
"base_path": settings.BASE_DIR,
|
|
1284
|
+
},
|
|
1285
|
+
)
|
|
1286
|
+
PeriodicTask.objects.filter(name="pages_purge_landing_leads").delete()
|
|
1287
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1288
|
+
self.assertTrue(
|
|
1289
|
+
PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
|
|
1290
|
+
)
|
|
1291
|
+
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1292
|
+
self.assertFalse(
|
|
1293
|
+
PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
def test_ocpp_session_report_task_syncs_with_feature(self):
|
|
1297
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1298
|
+
slug="celery-queue", defaults={"display": "Celery Queue"}
|
|
1299
|
+
)
|
|
1300
|
+
node, _ = Node.objects.update_or_create(
|
|
1301
|
+
mac_address=Node.get_current_mac(),
|
|
1302
|
+
defaults={
|
|
1303
|
+
"hostname": socket.gethostname(),
|
|
1304
|
+
"address": "127.0.0.1",
|
|
1305
|
+
"port": 9400,
|
|
1306
|
+
"base_path": settings.BASE_DIR,
|
|
1307
|
+
},
|
|
1308
|
+
)
|
|
1309
|
+
task_name = "ocpp_send_daily_session_report"
|
|
1310
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
1311
|
+
|
|
1312
|
+
with patch("nodes.models.mailer.can_send_email", return_value=True):
|
|
1313
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1314
|
+
|
|
1315
|
+
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1316
|
+
|
|
1317
|
+
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1318
|
+
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1319
|
+
|
|
1320
|
+
def test_ocpp_session_report_task_requires_email(self):
|
|
1321
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1322
|
+
slug="celery-queue", defaults={"display": "Celery Queue"}
|
|
1323
|
+
)
|
|
1324
|
+
node, _ = Node.objects.update_or_create(
|
|
1325
|
+
mac_address=Node.get_current_mac(),
|
|
1326
|
+
defaults={
|
|
1327
|
+
"hostname": socket.gethostname(),
|
|
1328
|
+
"address": "127.0.0.1",
|
|
1329
|
+
"port": 9500,
|
|
1330
|
+
"base_path": settings.BASE_DIR,
|
|
1331
|
+
},
|
|
1332
|
+
)
|
|
1333
|
+
task_name = "ocpp_send_daily_session_report"
|
|
1334
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
1335
|
+
|
|
1336
|
+
with patch("nodes.models.mailer.can_send_email", return_value=False):
|
|
1337
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1338
|
+
|
|
1339
|
+
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1340
|
+
|
|
1274
1341
|
|
|
1275
1342
|
class CheckRegistrationReadyCommandTests(TestCase):
|
|
1276
1343
|
def test_command_completes_successfully(self):
|
|
@@ -1326,6 +1393,16 @@ class NodeAdminTests(TestCase):
|
|
|
1326
1393
|
self.assertContains(response, f'href="{snapshot_url}"')
|
|
1327
1394
|
self.assertContains(response, f'href="{stream_url}"')
|
|
1328
1395
|
|
|
1396
|
+
def test_node_feature_list_shows_waveform_action_when_enabled(self):
|
|
1397
|
+
node = self._create_local_node()
|
|
1398
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1399
|
+
slug="audio-capture", defaults={"display": "Audio Capture"}
|
|
1400
|
+
)
|
|
1401
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1402
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
|
|
1403
|
+
action_url = reverse("admin:nodes_nodefeature_view_waveform")
|
|
1404
|
+
self.assertContains(response, f'href="{action_url}"')
|
|
1405
|
+
|
|
1329
1406
|
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
1330
1407
|
self._create_local_node()
|
|
1331
1408
|
NodeFeature.objects.get_or_create(
|
|
@@ -1357,8 +1434,6 @@ class NodeAdminTests(TestCase):
|
|
|
1357
1434
|
self.assertTrue(priv.exists())
|
|
1358
1435
|
self.assertTrue(pub.exists())
|
|
1359
1436
|
self.assertTrue(node.public_key)
|
|
1360
|
-
self.assertTrue(Site.objects.filter(domain=hostname, name="host").exists())
|
|
1361
|
-
|
|
1362
1437
|
def test_register_current_updates_existing_node(self):
|
|
1363
1438
|
hostname = socket.gethostname()
|
|
1364
1439
|
Node.objects.create(
|
|
@@ -1730,6 +1805,34 @@ class NodeAdminTests(TestCase):
|
|
|
1730
1805
|
self.assertEqual(response.context_data["stream_embed"], "unsupported")
|
|
1731
1806
|
self.assertContains(response, "camera-stream__unsupported")
|
|
1732
1807
|
|
|
1808
|
+
def test_view_waveform_requires_enabled_feature(self):
|
|
1809
|
+
self._create_local_node()
|
|
1810
|
+
NodeFeature.objects.get_or_create(
|
|
1811
|
+
slug="audio-capture", defaults={"display": "Audio Capture"}
|
|
1812
|
+
)
|
|
1813
|
+
response = self.client.get(
|
|
1814
|
+
reverse("admin:nodes_nodefeature_view_waveform"), follow=True
|
|
1815
|
+
)
|
|
1816
|
+
self.assertEqual(response.status_code, 200)
|
|
1817
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1818
|
+
self.assertEqual(response.wsgi_request.path, changelist_url)
|
|
1819
|
+
self.assertContains(
|
|
1820
|
+
response, "Audio Capture feature is not enabled on this node."
|
|
1821
|
+
)
|
|
1822
|
+
|
|
1823
|
+
def test_view_waveform_renders_when_feature_enabled(self):
|
|
1824
|
+
node = self._create_local_node()
|
|
1825
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1826
|
+
slug="audio-capture", defaults={"display": "Audio Capture"}
|
|
1827
|
+
)
|
|
1828
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1829
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_view_waveform"))
|
|
1830
|
+
self.assertEqual(response.status_code, 200)
|
|
1831
|
+
response.render()
|
|
1832
|
+
self.assertEqual(response.context_data["feature"], feature)
|
|
1833
|
+
self.assertEqual(response.context_data["title"], "Audio Capture Waveform")
|
|
1834
|
+
self.assertContains(response, "audio-capture__canvas")
|
|
1835
|
+
|
|
1733
1836
|
@patch("nodes.admin.requests.post")
|
|
1734
1837
|
def test_import_rfids_action_fetches_and_imports(self, mock_post):
|
|
1735
1838
|
local = self._create_local_node()
|
|
@@ -2476,6 +2579,8 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2476
2579
|
def test_propagate_forwards_to_three_and_notifies_local(
|
|
2477
2580
|
self, mock_notify, mock_post
|
|
2478
2581
|
):
|
|
2582
|
+
mock_post.return_value.ok = True
|
|
2583
|
+
mock_post.return_value.status_code = 200
|
|
2479
2584
|
msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
|
|
2480
2585
|
with patch.object(Node, "get_local", return_value=self.local):
|
|
2481
2586
|
msg.propagate(seen=[str(self.remotes[0].uuid)])
|
|
@@ -2492,6 +2597,13 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2492
2597
|
self.assertNotIn(sender_addr, targets)
|
|
2493
2598
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
2494
2599
|
self.assertTrue(msg.complete)
|
|
2600
|
+
self.assertEqual(len(msg.confirmed_peers), mock_post.call_count)
|
|
2601
|
+
self.assertTrue(
|
|
2602
|
+
all(entry["status"] == "acknowledged" for entry in msg.confirmed_peers.values())
|
|
2603
|
+
)
|
|
2604
|
+
self.assertTrue(
|
|
2605
|
+
all(entry["status_code"] == 200 for entry in msg.confirmed_peers.values())
|
|
2606
|
+
)
|
|
2495
2607
|
|
|
2496
2608
|
@patch("requests.post")
|
|
2497
2609
|
@patch("core.notifications.notify", return_value=False)
|
|
@@ -2567,6 +2679,21 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2567
2679
|
self.assertTrue(NetMessage.objects.filter(pk=old_remote.pk).exists())
|
|
2568
2680
|
self.assertTrue(NetMessage.objects.filter(pk=msg.pk).exists())
|
|
2569
2681
|
|
|
2682
|
+
@patch("core.notifications.notify", return_value=False)
|
|
2683
|
+
def test_propagate_records_error_status(self, mock_notify):
|
|
2684
|
+
msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
|
|
2685
|
+
with (
|
|
2686
|
+
patch.object(Node, "get_local", return_value=self.local),
|
|
2687
|
+
patch("random.shuffle", side_effect=lambda seq: None),
|
|
2688
|
+
patch("requests.post", side_effect=Exception("boom")),
|
|
2689
|
+
):
|
|
2690
|
+
msg.propagate()
|
|
2691
|
+
|
|
2692
|
+
self.assertTrue(msg.confirmed_peers)
|
|
2693
|
+
self.assertTrue(
|
|
2694
|
+
all(entry["status"] == "error" for entry in msg.confirmed_peers.values())
|
|
2695
|
+
)
|
|
2696
|
+
|
|
2570
2697
|
|
|
2571
2698
|
class NetMessageSignatureTests(TestCase):
|
|
2572
2699
|
def setUp(self):
|
|
@@ -3176,6 +3303,18 @@ class NodeFeatureTests(TestCase):
|
|
|
3176
3303
|
self.assertIn("Take a Snapshot", labels)
|
|
3177
3304
|
self.assertIn("View stream", labels)
|
|
3178
3305
|
|
|
3306
|
+
def test_audio_capture_feature_has_view_waveform_action(self):
|
|
3307
|
+
feature = NodeFeature.objects.create(
|
|
3308
|
+
slug="audio-capture", display="Audio Capture"
|
|
3309
|
+
)
|
|
3310
|
+
actions = feature.get_default_actions()
|
|
3311
|
+
self.assertEqual(len(actions), 1)
|
|
3312
|
+
action = actions[0]
|
|
3313
|
+
self.assertEqual(action.label, "View Waveform")
|
|
3314
|
+
self.assertEqual(
|
|
3315
|
+
action.url_name, "admin:nodes_nodefeature_view_waveform"
|
|
3316
|
+
)
|
|
3317
|
+
|
|
3179
3318
|
def test_default_action_missing_when_unconfigured(self):
|
|
3180
3319
|
feature = NodeFeature.objects.create(
|
|
3181
3320
|
slug="custom-feature", display="Custom Feature"
|
nodes/utils.py
CHANGED
|
@@ -89,6 +89,7 @@ def save_screenshot(
|
|
|
89
89
|
transaction_uuid=None,
|
|
90
90
|
*,
|
|
91
91
|
content: str | None = None,
|
|
92
|
+
user=None,
|
|
92
93
|
):
|
|
93
94
|
"""Save screenshot file info if not already recorded.
|
|
94
95
|
|
|
@@ -115,6 +116,8 @@ def save_screenshot(
|
|
|
115
116
|
data["transaction_uuid"] = transaction_uuid
|
|
116
117
|
if content is not None:
|
|
117
118
|
data["content"] = content
|
|
119
|
+
if user is not None:
|
|
120
|
+
data["user"] = user
|
|
118
121
|
with suppress_default_classifiers():
|
|
119
122
|
sample = ContentSample.objects.create(**data)
|
|
120
123
|
run_default_classifiers(sample)
|
ocpp/consumers.py
CHANGED
|
@@ -321,6 +321,44 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
321
321
|
except (TypeError, ValueError):
|
|
322
322
|
return
|
|
323
323
|
if connector_value is None:
|
|
324
|
+
aggregate = self.aggregate_charger
|
|
325
|
+
if (
|
|
326
|
+
not aggregate
|
|
327
|
+
or aggregate.connector_id is not None
|
|
328
|
+
or aggregate.charger_id != self.charger_id
|
|
329
|
+
):
|
|
330
|
+
aggregate, _ = await database_sync_to_async(
|
|
331
|
+
Charger.objects.get_or_create
|
|
332
|
+
)(
|
|
333
|
+
charger_id=self.charger_id,
|
|
334
|
+
connector_id=None,
|
|
335
|
+
defaults={"last_path": self.scope.get("path", "")},
|
|
336
|
+
)
|
|
337
|
+
await database_sync_to_async(aggregate.refresh_manager_node)()
|
|
338
|
+
self.aggregate_charger = aggregate
|
|
339
|
+
self.charger = self.aggregate_charger
|
|
340
|
+
previous_key = self.store_key
|
|
341
|
+
new_key = store.identity_key(self.charger_id, None)
|
|
342
|
+
if previous_key != new_key:
|
|
343
|
+
existing_consumer = store.connections.get(new_key)
|
|
344
|
+
if existing_consumer is not None and existing_consumer is not self:
|
|
345
|
+
await existing_consumer.close()
|
|
346
|
+
store.reassign_identity(previous_key, new_key)
|
|
347
|
+
store.connections[new_key] = self
|
|
348
|
+
store.logs["charger"].setdefault(new_key, [])
|
|
349
|
+
aggregate_name = await sync_to_async(
|
|
350
|
+
lambda: self.charger.name or self.charger.charger_id
|
|
351
|
+
)()
|
|
352
|
+
friendly_name = aggregate_name or self.charger_id
|
|
353
|
+
store.register_log_name(new_key, friendly_name, log_type="charger")
|
|
354
|
+
store.register_log_name(
|
|
355
|
+
store.identity_key(self.charger_id, None),
|
|
356
|
+
friendly_name,
|
|
357
|
+
log_type="charger",
|
|
358
|
+
)
|
|
359
|
+
store.register_log_name(self.charger_id, friendly_name, log_type="charger")
|
|
360
|
+
self.store_key = new_key
|
|
361
|
+
self.connector_value = None
|
|
324
362
|
if not self._header_reference_created and self.client_ip:
|
|
325
363
|
await database_sync_to_async(self._ensure_console_reference)()
|
|
326
364
|
self._header_reference_created = True
|
ocpp/models.py
CHANGED
|
@@ -854,11 +854,26 @@ class DataTransferMessage(models.Model):
|
|
|
854
854
|
on_delete=models.CASCADE,
|
|
855
855
|
related_name="data_transfer_messages",
|
|
856
856
|
)
|
|
857
|
-
connector_id = models.PositiveIntegerField(
|
|
857
|
+
connector_id = models.PositiveIntegerField(
|
|
858
|
+
null=True,
|
|
859
|
+
blank=True,
|
|
860
|
+
verbose_name="Connector ID",
|
|
861
|
+
)
|
|
858
862
|
direction = models.CharField(max_length=16, choices=DIRECTION_CHOICES)
|
|
859
|
-
ocpp_message_id = models.CharField(
|
|
860
|
-
|
|
861
|
-
|
|
863
|
+
ocpp_message_id = models.CharField(
|
|
864
|
+
max_length=64,
|
|
865
|
+
verbose_name="OCPP message ID",
|
|
866
|
+
)
|
|
867
|
+
vendor_id = models.CharField(
|
|
868
|
+
max_length=255,
|
|
869
|
+
blank=True,
|
|
870
|
+
verbose_name="Vendor ID",
|
|
871
|
+
)
|
|
872
|
+
message_id = models.CharField(
|
|
873
|
+
max_length=255,
|
|
874
|
+
blank=True,
|
|
875
|
+
verbose_name="Message ID",
|
|
876
|
+
)
|
|
862
877
|
payload = models.JSONField(default=dict, blank=True)
|
|
863
878
|
status = models.CharField(max_length=64, blank=True)
|
|
864
879
|
response_data = models.JSONField(null=True, blank=True)
|