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.

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
- pass
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
- self.save(update_fields=["complete"] if self.complete else [])
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(null=True, blank=True)
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(max_length=64)
860
- vendor_id = models.CharField(max_length=255, blank=True)
861
- message_id = models.CharField(max_length=255, blank=True)
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)