arthexis 0.1.15__py3-none-any.whl → 0.1.17__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.15.dist-info → arthexis-0.1.17.dist-info}/METADATA +1 -2
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/RECORD +40 -39
- config/settings.py +3 -0
- config/urls.py +5 -0
- core/admin.py +242 -15
- core/admindocs.py +44 -3
- core/apps.py +1 -1
- core/backends.py +46 -8
- core/changelog.py +66 -5
- core/github_issues.py +12 -7
- core/mailer.py +9 -5
- core/models.py +121 -29
- core/release.py +107 -2
- core/system.py +209 -2
- core/tasks.py +5 -7
- core/test_system_info.py +16 -0
- core/tests.py +329 -0
- core/views.py +279 -40
- nodes/admin.py +25 -1
- nodes/models.py +70 -4
- nodes/rfid_sync.py +15 -0
- nodes/tests.py +119 -0
- nodes/utils.py +3 -0
- ocpp/admin.py +92 -10
- ocpp/consumers.py +38 -0
- ocpp/models.py +19 -4
- ocpp/tasks.py +156 -2
- ocpp/test_rfid.py +92 -5
- ocpp/tests.py +243 -1
- ocpp/views.py +23 -5
- pages/admin.py +126 -4
- pages/context_processors.py +20 -1
- pages/models.py +3 -1
- pages/module_defaults.py +156 -0
- pages/tests.py +241 -8
- pages/urls.py +1 -0
- pages/views.py +61 -4
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/WHEEL +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -98,6 +98,12 @@ class NodeFeature(Entity):
|
|
|
98
98
|
url_name="admin:nodes_nodefeature_celery_report",
|
|
99
99
|
),
|
|
100
100
|
),
|
|
101
|
+
"audio-capture": (
|
|
102
|
+
NodeFeatureDefaultAction(
|
|
103
|
+
label="View Waveform",
|
|
104
|
+
url_name="admin:nodes_nodefeature_view_waveform",
|
|
105
|
+
),
|
|
106
|
+
),
|
|
101
107
|
"screenshot-poll": (
|
|
102
108
|
NodeFeatureDefaultAction(
|
|
103
109
|
label="Take Screenshot",
|
|
@@ -244,7 +250,7 @@ class Node(Entity):
|
|
|
244
250
|
"ap-router",
|
|
245
251
|
"gway-runner",
|
|
246
252
|
}
|
|
247
|
-
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
|
|
253
|
+
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll", "audio-capture"}
|
|
248
254
|
|
|
249
255
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
250
256
|
return f"{self.hostname}:{self.port}"
|
|
@@ -747,6 +753,7 @@ class Node(Entity):
|
|
|
747
753
|
self._sync_clipboard_task(clipboard_enabled)
|
|
748
754
|
self._sync_screenshot_task(screenshot_enabled)
|
|
749
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
|
|
@@ -819,6 +826,39 @@ class Node(Entity):
|
|
|
819
826
|
else:
|
|
820
827
|
PeriodicTask.objects.filter(name=task_name).delete()
|
|
821
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
|
+
|
|
822
862
|
def send_mail(
|
|
823
863
|
self,
|
|
824
864
|
subject: str,
|
|
@@ -955,15 +995,18 @@ class NodeManager(Profile):
|
|
|
955
995
|
)
|
|
956
996
|
api_key = SigilShortAutoField(
|
|
957
997
|
max_length=255,
|
|
998
|
+
verbose_name="API key",
|
|
958
999
|
help_text="API key issued by the DNS provider.",
|
|
959
1000
|
)
|
|
960
1001
|
api_secret = SigilShortAutoField(
|
|
961
1002
|
max_length=255,
|
|
1003
|
+
verbose_name="API secret",
|
|
962
1004
|
help_text="API secret issued by the DNS provider.",
|
|
963
1005
|
)
|
|
964
1006
|
customer_id = SigilShortAutoField(
|
|
965
1007
|
max_length=100,
|
|
966
1008
|
blank=True,
|
|
1009
|
+
verbose_name="Customer ID",
|
|
967
1010
|
help_text="Optional GoDaddy customer identifier for the account.",
|
|
968
1011
|
)
|
|
969
1012
|
default_domain = SigilShortAutoField(
|
|
@@ -1367,6 +1410,7 @@ class NetMessage(Entity):
|
|
|
1367
1410
|
propagated_to = models.ManyToManyField(
|
|
1368
1411
|
Node, blank=True, related_name="received_net_messages"
|
|
1369
1412
|
)
|
|
1413
|
+
confirmed_peers = models.JSONField(default=dict, blank=True)
|
|
1370
1414
|
created = models.DateTimeField(auto_now_add=True)
|
|
1371
1415
|
complete = models.BooleanField(default=False, editable=False)
|
|
1372
1416
|
|
|
@@ -1596,7 +1640,10 @@ class NetMessage(Entity):
|
|
|
1596
1640
|
seen_list = seen.copy()
|
|
1597
1641
|
selected_ids = [str(n.uuid) for n in selected]
|
|
1598
1642
|
payload_seen = seen_list + selected_ids
|
|
1643
|
+
confirmed_peers = dict(self.confirmed_peers or {})
|
|
1644
|
+
|
|
1599
1645
|
for node in selected:
|
|
1646
|
+
now = timezone.now().isoformat()
|
|
1600
1647
|
payload = {
|
|
1601
1648
|
"uuid": str(self.uuid),
|
|
1602
1649
|
"subject": self.subject,
|
|
@@ -1632,20 +1679,39 @@ class NetMessage(Entity):
|
|
|
1632
1679
|
headers["X-Signature"] = base64.b64encode(signature).decode()
|
|
1633
1680
|
except Exception:
|
|
1634
1681
|
pass
|
|
1682
|
+
status_entry = {
|
|
1683
|
+
"status": "pending",
|
|
1684
|
+
"status_code": None,
|
|
1685
|
+
"updated": now,
|
|
1686
|
+
}
|
|
1635
1687
|
try:
|
|
1636
|
-
requests.post(
|
|
1688
|
+
response = requests.post(
|
|
1637
1689
|
f"http://{node.address}:{node.port}/nodes/net-message/",
|
|
1638
1690
|
data=payload_json,
|
|
1639
1691
|
headers=headers,
|
|
1640
1692
|
timeout=1,
|
|
1641
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"
|
|
1642
1699
|
except Exception:
|
|
1643
|
-
|
|
1700
|
+
status_entry["status"] = "error"
|
|
1644
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")
|
|
1645
1708
|
|
|
1646
1709
|
if total_known and self.propagated_to.count() >= total_known:
|
|
1647
1710
|
self.complete = True
|
|
1648
|
-
|
|
1711
|
+
save_fields.append("complete")
|
|
1712
|
+
|
|
1713
|
+
if save_fields:
|
|
1714
|
+
self.save(update_fields=save_fields)
|
|
1649
1715
|
|
|
1650
1716
|
|
|
1651
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
|
@@ -1293,6 +1293,51 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1293
1293
|
PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
|
|
1294
1294
|
)
|
|
1295
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
|
+
|
|
1296
1341
|
|
|
1297
1342
|
class CheckRegistrationReadyCommandTests(TestCase):
|
|
1298
1343
|
def test_command_completes_successfully(self):
|
|
@@ -1348,6 +1393,16 @@ class NodeAdminTests(TestCase):
|
|
|
1348
1393
|
self.assertContains(response, f'href="{snapshot_url}"')
|
|
1349
1394
|
self.assertContains(response, f'href="{stream_url}"')
|
|
1350
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
|
+
|
|
1351
1406
|
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
1352
1407
|
self._create_local_node()
|
|
1353
1408
|
NodeFeature.objects.get_or_create(
|
|
@@ -1750,6 +1805,34 @@ class NodeAdminTests(TestCase):
|
|
|
1750
1805
|
self.assertEqual(response.context_data["stream_embed"], "unsupported")
|
|
1751
1806
|
self.assertContains(response, "camera-stream__unsupported")
|
|
1752
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
|
+
|
|
1753
1836
|
@patch("nodes.admin.requests.post")
|
|
1754
1837
|
def test_import_rfids_action_fetches_and_imports(self, mock_post):
|
|
1755
1838
|
local = self._create_local_node()
|
|
@@ -2496,6 +2579,8 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2496
2579
|
def test_propagate_forwards_to_three_and_notifies_local(
|
|
2497
2580
|
self, mock_notify, mock_post
|
|
2498
2581
|
):
|
|
2582
|
+
mock_post.return_value.ok = True
|
|
2583
|
+
mock_post.return_value.status_code = 200
|
|
2499
2584
|
msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
|
|
2500
2585
|
with patch.object(Node, "get_local", return_value=self.local):
|
|
2501
2586
|
msg.propagate(seen=[str(self.remotes[0].uuid)])
|
|
@@ -2512,6 +2597,13 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2512
2597
|
self.assertNotIn(sender_addr, targets)
|
|
2513
2598
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
2514
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
|
+
)
|
|
2515
2607
|
|
|
2516
2608
|
@patch("requests.post")
|
|
2517
2609
|
@patch("core.notifications.notify", return_value=False)
|
|
@@ -2587,6 +2679,21 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2587
2679
|
self.assertTrue(NetMessage.objects.filter(pk=old_remote.pk).exists())
|
|
2588
2680
|
self.assertTrue(NetMessage.objects.filter(pk=msg.pk).exists())
|
|
2589
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
|
+
|
|
2590
2697
|
|
|
2591
2698
|
class NetMessageSignatureTests(TestCase):
|
|
2592
2699
|
def setUp(self):
|
|
@@ -3196,6 +3303,18 @@ class NodeFeatureTests(TestCase):
|
|
|
3196
3303
|
self.assertIn("Take a Snapshot", labels)
|
|
3197
3304
|
self.assertIn("View stream", labels)
|
|
3198
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
|
+
|
|
3199
3318
|
def test_default_action_missing_when_unconfigured(self):
|
|
3200
3319
|
feature = NodeFeature.objects.create(
|
|
3201
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/admin.py
CHANGED
|
@@ -29,6 +29,7 @@ from .transactions_io import (
|
|
|
29
29
|
import_transactions as import_transactions_data,
|
|
30
30
|
)
|
|
31
31
|
from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
|
|
32
|
+
from .views import _charger_state, _live_sessions
|
|
32
33
|
from core.admin import SaveBeforeChangeAction
|
|
33
34
|
from core.user_data import EntityModelAdmin
|
|
34
35
|
|
|
@@ -253,6 +254,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
253
254
|
actions = [
|
|
254
255
|
"purge_data",
|
|
255
256
|
"fetch_cp_configuration",
|
|
257
|
+
"toggle_rfid_authentication",
|
|
258
|
+
"recheck_charger_status",
|
|
256
259
|
"change_availability_operative",
|
|
257
260
|
"change_availability_inoperative",
|
|
258
261
|
"set_availability_state_operative",
|
|
@@ -317,16 +320,14 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
317
320
|
"charger-status-connector",
|
|
318
321
|
args=[obj.charger_id, obj.connector_slug],
|
|
319
322
|
)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
)
|
|
328
|
-
label = STATUS_BADGE_MAP["charging"][0]
|
|
329
|
-
return format_html('<a href="{}" target="_blank">{}</a>', url, label)
|
|
323
|
+
tx_obj = store.get_transaction(obj.charger_id, obj.connector_id)
|
|
324
|
+
state, _ = _charger_state(
|
|
325
|
+
obj,
|
|
326
|
+
tx_obj
|
|
327
|
+
if obj.connector_id is not None
|
|
328
|
+
else (_live_sessions(obj) or None),
|
|
329
|
+
)
|
|
330
|
+
return format_html('<a href="{}" target="_blank">{}</a>', url, state)
|
|
330
331
|
|
|
331
332
|
status_link.short_description = "Status"
|
|
332
333
|
|
|
@@ -359,6 +360,63 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
359
360
|
|
|
360
361
|
purge_data.short_description = "Purge data"
|
|
361
362
|
|
|
363
|
+
@admin.action(description="Re-check Charger Status")
|
|
364
|
+
def recheck_charger_status(self, request, queryset):
|
|
365
|
+
requested = 0
|
|
366
|
+
for charger in queryset:
|
|
367
|
+
connector_value = charger.connector_id
|
|
368
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
369
|
+
if ws is None:
|
|
370
|
+
self.message_user(
|
|
371
|
+
request,
|
|
372
|
+
f"{charger}: no active connection",
|
|
373
|
+
level=messages.ERROR,
|
|
374
|
+
)
|
|
375
|
+
continue
|
|
376
|
+
payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
|
|
377
|
+
trigger_connector: int | None = None
|
|
378
|
+
if connector_value is not None:
|
|
379
|
+
payload["connectorId"] = connector_value
|
|
380
|
+
trigger_connector = connector_value
|
|
381
|
+
message_id = uuid.uuid4().hex
|
|
382
|
+
msg = json.dumps([2, message_id, "TriggerMessage", payload])
|
|
383
|
+
try:
|
|
384
|
+
async_to_sync(ws.send)(msg)
|
|
385
|
+
except Exception as exc: # pragma: no cover - network error
|
|
386
|
+
self.message_user(
|
|
387
|
+
request,
|
|
388
|
+
f"{charger}: failed to send TriggerMessage ({exc})",
|
|
389
|
+
level=messages.ERROR,
|
|
390
|
+
)
|
|
391
|
+
continue
|
|
392
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
393
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
394
|
+
store.register_pending_call(
|
|
395
|
+
message_id,
|
|
396
|
+
{
|
|
397
|
+
"action": "TriggerMessage",
|
|
398
|
+
"charger_id": charger.charger_id,
|
|
399
|
+
"connector_id": connector_value,
|
|
400
|
+
"log_key": log_key,
|
|
401
|
+
"trigger_target": "StatusNotification",
|
|
402
|
+
"trigger_connector": trigger_connector,
|
|
403
|
+
"requested_at": timezone.now(),
|
|
404
|
+
},
|
|
405
|
+
)
|
|
406
|
+
store.schedule_call_timeout(
|
|
407
|
+
message_id,
|
|
408
|
+
timeout=5.0,
|
|
409
|
+
action="TriggerMessage",
|
|
410
|
+
log_key=log_key,
|
|
411
|
+
message="TriggerMessage StatusNotification timed out",
|
|
412
|
+
)
|
|
413
|
+
requested += 1
|
|
414
|
+
if requested:
|
|
415
|
+
self.message_user(
|
|
416
|
+
request,
|
|
417
|
+
f"Requested status update from {requested} charger(s)",
|
|
418
|
+
)
|
|
419
|
+
|
|
362
420
|
@admin.action(description="Fetch CP configuration")
|
|
363
421
|
def fetch_cp_configuration(self, request, queryset):
|
|
364
422
|
fetched = 0
|
|
@@ -413,6 +471,30 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
413
471
|
f"Requested configuration from {fetched} charger(s)",
|
|
414
472
|
)
|
|
415
473
|
|
|
474
|
+
@admin.action(description="Toggle RFID Authentication")
|
|
475
|
+
def toggle_rfid_authentication(self, request, queryset):
|
|
476
|
+
enabled = 0
|
|
477
|
+
disabled = 0
|
|
478
|
+
for charger in queryset:
|
|
479
|
+
new_value = not charger.require_rfid
|
|
480
|
+
Charger.objects.filter(pk=charger.pk).update(require_rfid=new_value)
|
|
481
|
+
charger.require_rfid = new_value
|
|
482
|
+
if new_value:
|
|
483
|
+
enabled += 1
|
|
484
|
+
else:
|
|
485
|
+
disabled += 1
|
|
486
|
+
if enabled or disabled:
|
|
487
|
+
changes = []
|
|
488
|
+
if enabled:
|
|
489
|
+
changes.append(f"enabled for {enabled} charger(s)")
|
|
490
|
+
if disabled:
|
|
491
|
+
changes.append(f"disabled for {disabled} charger(s)")
|
|
492
|
+
summary = "; ".join(changes)
|
|
493
|
+
self.message_user(
|
|
494
|
+
request,
|
|
495
|
+
f"Updated RFID authentication: {summary}",
|
|
496
|
+
)
|
|
497
|
+
|
|
416
498
|
def _dispatch_change_availability(self, request, queryset, availability_type: str):
|
|
417
499
|
sent = 0
|
|
418
500
|
for charger in queryset:
|
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)
|