arthexis 0.1.17__py3-none-any.whl → 0.1.19__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.

core/views.py CHANGED
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
43
43
  PYPI_REQUEST_TIMEOUT = 10
44
44
 
45
45
  from . import changelog as changelog_utils
46
+ from . import temp_passwords
46
47
  from .models import OdooProfile, Product, EnergyAccount, PackageRelease, Todo
47
48
  from .models import RFID
48
49
 
@@ -336,6 +337,35 @@ def odoo_quote_report(request):
336
337
  return TemplateResponse(request, "admin/core/odoo_quote_report.html", context)
337
338
 
338
339
 
340
+ @staff_member_required
341
+ @require_GET
342
+ def request_temp_password(request):
343
+ """Generate a temporary password for the authenticated staff member."""
344
+
345
+ user = request.user
346
+ username = user.get_username()
347
+ password = temp_passwords.generate_password()
348
+ entry = temp_passwords.store_temp_password(
349
+ username,
350
+ password,
351
+ allow_change=True,
352
+ )
353
+ context = {
354
+ **admin_site.each_context(request),
355
+ "title": _("Temporary password"),
356
+ "username": username,
357
+ "password": password,
358
+ "expires_at": timezone.localtime(entry.expires_at),
359
+ "allow_change": entry.allow_change,
360
+ "return_url": reverse("admin:password_change"),
361
+ }
362
+ return TemplateResponse(
363
+ request,
364
+ "admin/core/request_temp_password.html",
365
+ context,
366
+ )
367
+
368
+
339
369
  @require_GET
340
370
  def version_info(request):
341
371
  """Return the running application version and Git revision."""
@@ -706,6 +736,34 @@ def _ensure_release_todo(
706
736
  return todo, fixture_path
707
737
 
708
738
 
739
+ def _todo_blocks_publish(todo: Todo, release: PackageRelease) -> bool:
740
+ """Return ``True`` when ``todo`` should block the release workflow."""
741
+
742
+ request = (todo.request or "").strip()
743
+ release_name = (release.package.name or "").strip()
744
+ if not request or not release_name:
745
+ return True
746
+
747
+ prefix = f"create release {release_name.lower()} "
748
+ if not request.lower().startswith(prefix):
749
+ return True
750
+
751
+ release_version = (release.version or "").strip()
752
+ generated_version = (todo.generated_for_version or "").strip()
753
+ if not release_version or release_version != generated_version:
754
+ return True
755
+
756
+ generated_revision = (todo.generated_for_revision or "").strip()
757
+ release_revision = (release.revision or "").strip()
758
+ if generated_revision and release_revision and generated_revision != release_revision:
759
+ return True
760
+
761
+ if not todo.is_seed_data:
762
+ return True
763
+
764
+ return False
765
+
766
+
709
767
  def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
710
768
  """Ensure ``release`` matches the repository revision and version.
711
769
 
@@ -1855,26 +1913,37 @@ def release_progress(request, pk: int, action: str):
1855
1913
 
1856
1914
  pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
1857
1915
  pending_items = list(pending_qs)
1858
- if ack_todos_requested:
1859
- if pending_items:
1860
- failures = []
1861
- for todo in pending_items:
1862
- result = todo.check_on_done_condition()
1863
- if not result.passed:
1864
- failures.append((todo, result))
1865
- if failures:
1866
- ctx.pop("todos_ack", None)
1867
- for todo, result in failures:
1868
- messages.error(request, _format_condition_failure(todo, result))
1869
- else:
1870
- ctx["todos_ack"] = True
1916
+ blocking_todos = [
1917
+ todo for todo in pending_items if _todo_blocks_publish(todo, release)
1918
+ ]
1919
+ if not blocking_todos:
1920
+ ctx["todos_ack"] = True
1921
+ ctx["todos_ack_auto"] = True
1922
+ elif ack_todos_requested:
1923
+ failures = []
1924
+ for todo in blocking_todos:
1925
+ result = todo.check_on_done_condition()
1926
+ if not result.passed:
1927
+ failures.append((todo, result))
1928
+ if failures:
1929
+ ctx["todos_ack"] = False
1930
+ ctx.pop("todos_ack_auto", None)
1931
+ for todo, result in failures:
1932
+ messages.error(request, _format_condition_failure(todo, result))
1871
1933
  else:
1872
1934
  ctx["todos_ack"] = True
1935
+ ctx.pop("todos_ack_auto", None)
1936
+ else:
1937
+ if ctx.pop("todos_ack_auto", None):
1938
+ ctx["todos_ack"] = False
1939
+ else:
1940
+ ctx.setdefault("todos_ack", False)
1873
1941
 
1874
1942
  if ctx.get("todos_ack"):
1875
1943
  ctx.pop("todos_block_logged", None)
1876
-
1877
- if not ctx.get("todos_ack"):
1944
+ ctx.pop("todos", None)
1945
+ ctx.pop("todos_required", None)
1946
+ else:
1878
1947
  ctx["todos"] = [
1879
1948
  {
1880
1949
  "id": todo.pk,
@@ -1882,12 +1951,9 @@ def release_progress(request, pk: int, action: str):
1882
1951
  "url": todo.url,
1883
1952
  "request_details": todo.request_details,
1884
1953
  }
1885
- for todo in pending_items
1954
+ for todo in blocking_todos
1886
1955
  ]
1887
1956
  ctx["todos_required"] = True
1888
- else:
1889
- ctx.pop("todos", None)
1890
- ctx.pop("todos_required", None)
1891
1957
 
1892
1958
  log_name = _release_log_name(release.package.name, release.version)
1893
1959
  if ctx.get("log") != log_name:
@@ -1897,6 +1963,8 @@ def release_progress(request, pk: int, action: str):
1897
1963
  "started": ctx.get("started", False),
1898
1964
  }
1899
1965
  step_count = 0
1966
+ if not blocking_todos:
1967
+ ctx["todos_ack"] = True
1900
1968
  log_path = log_dir / log_name
1901
1969
  ctx.setdefault("log", log_name)
1902
1970
  ctx.setdefault("paused", False)
nodes/admin.py CHANGED
@@ -1565,7 +1565,7 @@ class NetMessageAdmin(EntityModelAdmin):
1565
1565
  search_fields = ("subject", "body")
1566
1566
  list_filter = ("complete", "filter_node_role", "filter_current_relation")
1567
1567
  ordering = ("-created",)
1568
- readonly_fields = ("complete", "confirmed_peers")
1568
+ readonly_fields = ("complete",)
1569
1569
  actions = ["send_messages"]
1570
1570
  fieldsets = (
1571
1571
  (None, {"fields": ("subject", "body")}),
@@ -1590,7 +1590,6 @@ class NetMessageAdmin(EntityModelAdmin):
1590
1590
  "node_origin",
1591
1591
  "target_limit",
1592
1592
  "propagated_to",
1593
- "confirmed_peers",
1594
1593
  "complete",
1595
1594
  )
1596
1595
  },
nodes/models.py CHANGED
@@ -293,7 +293,13 @@ class Node(Entity):
293
293
  @classmethod
294
294
  def register_current(cls):
295
295
  """Create or update the :class:`Node` entry for this host."""
296
- hostname = socket.gethostname()
296
+ hostname_override = (
297
+ os.environ.get("NODE_HOSTNAME")
298
+ or os.environ.get("HOSTNAME")
299
+ or ""
300
+ )
301
+ hostname_override = hostname_override.strip()
302
+ hostname = hostname_override or socket.gethostname()
297
303
  try:
298
304
  address = socket.gethostbyname(hostname)
299
305
  except OSError:
@@ -305,7 +311,11 @@ class Node(Entity):
305
311
  rev_value = revision.get_revision()
306
312
  installed_revision = rev_value if rev_value else ""
307
313
  mac = cls.get_current_mac()
308
- slug = slugify(hostname)
314
+ endpoint_override = os.environ.get("NODE_PUBLIC_ENDPOINT", "").strip()
315
+ slug_source = endpoint_override or hostname
316
+ slug = slugify(slug_source)
317
+ if not slug:
318
+ slug = cls._generate_unique_public_endpoint(hostname or mac)
309
319
  node = cls.objects.filter(mac_address=mac).first()
310
320
  if not node:
311
321
  node = cls.objects.filter(public_endpoint=slug).first()
@@ -1410,7 +1420,6 @@ class NetMessage(Entity):
1410
1420
  propagated_to = models.ManyToManyField(
1411
1421
  Node, blank=True, related_name="received_net_messages"
1412
1422
  )
1413
- confirmed_peers = models.JSONField(default=dict, blank=True)
1414
1423
  created = models.DateTimeField(auto_now_add=True)
1415
1424
  complete = models.BooleanField(default=False, editable=False)
1416
1425
 
@@ -1640,10 +1649,7 @@ class NetMessage(Entity):
1640
1649
  seen_list = seen.copy()
1641
1650
  selected_ids = [str(n.uuid) for n in selected]
1642
1651
  payload_seen = seen_list + selected_ids
1643
- confirmed_peers = dict(self.confirmed_peers or {})
1644
-
1645
1652
  for node in selected:
1646
- now = timezone.now().isoformat()
1647
1653
  payload = {
1648
1654
  "uuid": str(self.uuid),
1649
1655
  "subject": self.subject,
@@ -1679,33 +1685,22 @@ class NetMessage(Entity):
1679
1685
  headers["X-Signature"] = base64.b64encode(signature).decode()
1680
1686
  except Exception:
1681
1687
  pass
1682
- status_entry = {
1683
- "status": "pending",
1684
- "status_code": None,
1685
- "updated": now,
1686
- }
1687
1688
  try:
1688
- response = requests.post(
1689
+ requests.post(
1689
1690
  f"http://{node.address}:{node.port}/nodes/net-message/",
1690
1691
  data=payload_json,
1691
1692
  headers=headers,
1692
1693
  timeout=1,
1693
1694
  )
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"
1699
1695
  except Exception:
1700
- status_entry["status"] = "error"
1696
+ logger.exception(
1697
+ "Failed to propagate NetMessage %s to node %s",
1698
+ self.pk,
1699
+ node.pk,
1700
+ )
1701
1701
  self.propagated_to.add(node)
1702
- confirmed_peers[str(node.uuid)] = status_entry
1703
1702
 
1704
1703
  save_fields: list[str] = []
1705
- if confirmed_peers != (self.confirmed_peers or {}):
1706
- self.confirmed_peers = confirmed_peers
1707
- save_fields.append("confirmed_peers")
1708
-
1709
1704
  if total_known and self.propagated_to.count() >= total_known:
1710
1705
  self.complete = True
1711
1706
  save_fields.append("complete")
nodes/tests.py CHANGED
@@ -223,6 +223,46 @@ class NodeGetLocalTests(TestCase):
223
223
  node.refresh_from_db()
224
224
  self.assertEqual(node.role.name, "Constellation")
225
225
 
226
+ def test_register_current_respects_node_hostname_env(self):
227
+ with TemporaryDirectory() as tmp:
228
+ base = Path(tmp)
229
+ with override_settings(BASE_DIR=base):
230
+ with (
231
+ patch.dict(os.environ, {"NODE_HOSTNAME": "gway-002"}, clear=False),
232
+ patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
233
+ patch("nodes.models.socket.gethostname", return_value="localhost"),
234
+ patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
235
+ patch("nodes.models.revision.get_revision", return_value="rev"),
236
+ patch.object(Node, "ensure_keys"),
237
+ patch.object(Node, "notify_peers_of_update"),
238
+ ):
239
+ node, created = Node.register_current()
240
+ self.assertTrue(created)
241
+ self.assertEqual(node.hostname, "gway-002")
242
+ self.assertEqual(node.public_endpoint, "gway-002")
243
+
244
+ def test_register_current_respects_public_endpoint_env(self):
245
+ with TemporaryDirectory() as tmp:
246
+ base = Path(tmp)
247
+ with override_settings(BASE_DIR=base):
248
+ with (
249
+ patch.dict(
250
+ os.environ,
251
+ {"NODE_HOSTNAME": "gway-alpha", "NODE_PUBLIC_ENDPOINT": "gway-002"},
252
+ clear=False,
253
+ ),
254
+ patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
255
+ patch("nodes.models.socket.gethostname", return_value="localhost"),
256
+ patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
257
+ patch("nodes.models.revision.get_revision", return_value="rev"),
258
+ patch.object(Node, "ensure_keys"),
259
+ patch.object(Node, "notify_peers_of_update"),
260
+ ):
261
+ node, created = Node.register_current()
262
+ self.assertTrue(created)
263
+ self.assertEqual(node.hostname, "gway-alpha")
264
+ self.assertEqual(node.public_endpoint, "gway-002")
265
+
226
266
  def test_register_and_list_node(self):
227
267
  response = self.client.post(
228
268
  reverse("register-node"),
@@ -2315,29 +2355,6 @@ class NetMessageAdminTests(TransactionTestCase):
2315
2355
  self.assertEqual(form["subject"].value(), "Re: Ping")
2316
2356
  self.assertEqual(str(form["filter_node"].value()), str(node.pk))
2317
2357
 
2318
-
2319
- class LastNetMessageViewTests(TestCase):
2320
- def setUp(self):
2321
- self.client = Client()
2322
- NodeRole.objects.get_or_create(name="Terminal")
2323
-
2324
- def test_returns_latest_message(self):
2325
- NetMessage.objects.create(subject="old", body="msg1")
2326
- latest = NetMessage.objects.create(subject="new", body="msg2")
2327
- resp = self.client.get(reverse("last-net-message"))
2328
- self.assertEqual(resp.status_code, 200)
2329
- self.assertEqual(
2330
- resp.json(),
2331
- {
2332
- "subject": "new",
2333
- "body": "msg2",
2334
- "admin_url": reverse(
2335
- "admin:nodes_netmessage_change", args=[latest.pk]
2336
- ),
2337
- },
2338
- )
2339
-
2340
-
2341
2358
  class NetMessageReachTests(TestCase):
2342
2359
  def setUp(self):
2343
2360
  self.roles = {}
@@ -2597,13 +2614,6 @@ class NetMessagePropagationTests(TestCase):
2597
2614
  self.assertNotIn(sender_addr, targets)
2598
2615
  self.assertEqual(msg.propagated_to.count(), 4)
2599
2616
  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
- )
2607
2617
 
2608
2618
  @patch("requests.post")
2609
2619
  @patch("core.notifications.notify", return_value=False)
@@ -2689,10 +2699,8 @@ class NetMessagePropagationTests(TestCase):
2689
2699
  ):
2690
2700
  msg.propagate()
2691
2701
 
2692
- self.assertTrue(msg.confirmed_peers)
2693
- self.assertTrue(
2694
- all(entry["status"] == "error" for entry in msg.confirmed_peers.values())
2695
- )
2702
+ self.assertEqual(msg.propagated_to.count(), len(self.remotes))
2703
+ self.assertTrue(msg.complete)
2696
2704
 
2697
2705
 
2698
2706
  class NetMessageSignatureTests(TestCase):
nodes/urls.py CHANGED
@@ -8,7 +8,6 @@ urlpatterns = [
8
8
  path("register/", views.register_node, name="register-node"),
9
9
  path("screenshot/", views.capture, name="node-screenshot"),
10
10
  path("net-message/", views.net_message, name="net-message"),
11
- path("last-message/", views.last_net_message, name="last-net-message"),
12
11
  path("rfid/export/", views.export_rfids, name="node-rfid-export"),
13
12
  path("rfid/import/", views.import_rfids, name="node-rfid-import"),
14
13
  path("<slug:endpoint>/", views.public_node_endpoint, name="node-public-endpoint"),
nodes/views.py CHANGED
@@ -104,6 +104,8 @@ def _get_host_domain(request) -> str:
104
104
  domain, _ = split_domain_port(host)
105
105
  if not domain:
106
106
  return ""
107
+ if domain.lower() == "localhost":
108
+ return ""
107
109
  try:
108
110
  ipaddress.ip_address(domain)
109
111
  except ValueError:
@@ -666,18 +668,3 @@ def net_message(request):
666
668
  msg.apply_attachments(attachments)
667
669
  msg.propagate(seen=seen)
668
670
  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
- )
ocpp/admin.py CHANGED
@@ -6,7 +6,7 @@ from datetime import timedelta
6
6
  import json
7
7
 
8
8
  from django.shortcuts import redirect
9
- from django.utils import timezone
9
+ from django.utils import formats, timezone, translation
10
10
  from django.urls import path
11
11
  from django.http import HttpResponse, HttpResponseRedirect
12
12
  from django.template.response import TemplateResponse
@@ -163,6 +163,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
163
163
  "charger_id",
164
164
  "display_name",
165
165
  "connector_id",
166
+ "language",
166
167
  "location",
167
168
  "last_path",
168
169
  "last_heartbeat",
@@ -719,7 +720,7 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
719
720
  "ws_port",
720
721
  "ws_url",
721
722
  "interval",
722
- "kw_max",
723
+ "kw_max_display",
723
724
  "running",
724
725
  "log_link",
725
726
  )
@@ -759,6 +760,26 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
759
760
 
760
761
  log_type = "simulator"
761
762
 
763
+ @admin.display(description="kW Max", ordering="kw_max")
764
+ def kw_max_display(self, obj):
765
+ """Display ``kw_max`` with a dot decimal separator for Spanish locales."""
766
+
767
+ language = translation.get_language() or ""
768
+ if language.startswith("es"):
769
+ return formats.number_format(
770
+ obj.kw_max,
771
+ decimal_pos=2,
772
+ use_l10n=False,
773
+ force_grouping=False,
774
+ )
775
+
776
+ return formats.number_format(
777
+ obj.kw_max,
778
+ decimal_pos=2,
779
+ use_l10n=True,
780
+ force_grouping=False,
781
+ )
782
+
762
783
  def save_model(self, request, obj, form, change):
763
784
  previous_door_open = False
764
785
  if change and obj.pk:
ocpp/consumers.py CHANGED
@@ -5,6 +5,7 @@ from datetime import datetime
5
5
  import asyncio
6
6
  import inspect
7
7
  import json
8
+ import logging
8
9
  from urllib.parse import parse_qs
9
10
  from django.utils import timezone
10
11
  from core.models import EnergyAccount, Reference, RFID as CoreRFID
@@ -32,6 +33,9 @@ from .evcs_discovery import (
32
33
  FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
33
34
 
34
35
 
36
+ logger = logging.getLogger(__name__)
37
+
38
+
35
39
  # Query parameter keys that may contain the charge point serial. Keys are
36
40
  # matched case-insensitively and trimmed before use.
37
41
  SERIAL_QUERY_PARAM_NAMES = (
@@ -309,6 +313,19 @@ class CSMSConsumer(AsyncWebsocketConsumer):
309
313
 
310
314
  return await database_sync_to_async(_ensure)()
311
315
 
316
+ def _log_unlinked_rfid(self, rfid: str) -> None:
317
+ """Record a warning when an RFID is authorized without an account."""
318
+
319
+ message = (
320
+ f"Authorized RFID {rfid} on charger {self.charger_id} without linked energy account"
321
+ )
322
+ logger.warning(message)
323
+ store.add_log(
324
+ store.pending_key(self.charger_id),
325
+ message,
326
+ log_type="charger",
327
+ )
328
+
312
329
  async def _assign_connector(self, connector: int | str | None) -> None:
313
330
  """Ensure ``self.charger`` matches the provided connector id."""
314
331
  if connector in (None, "", "-"):
@@ -1395,13 +1412,25 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1395
1412
  elif action == "Authorize":
1396
1413
  id_tag = payload.get("idTag")
1397
1414
  account = await self._get_account(id_tag)
1415
+ status = "Invalid"
1398
1416
  if self.charger.require_rfid:
1399
- status = (
1400
- "Accepted"
1401
- if account
1402
- and await database_sync_to_async(account.can_authorize)()
1403
- else "Invalid"
1404
- )
1417
+ tag = None
1418
+ tag_created = False
1419
+ if id_tag:
1420
+ tag, tag_created = await database_sync_to_async(
1421
+ CoreRFID.register_scan
1422
+ )(id_tag)
1423
+ if account:
1424
+ if await database_sync_to_async(account.can_authorize)():
1425
+ status = "Accepted"
1426
+ elif (
1427
+ id_tag
1428
+ and tag
1429
+ and not tag_created
1430
+ and tag.allowed
1431
+ ):
1432
+ status = "Accepted"
1433
+ self._log_unlinked_rfid(tag.rfid)
1405
1434
  else:
1406
1435
  await self._ensure_rfid_seen(id_tag)
1407
1436
  status = "Accepted"
@@ -1475,23 +1504,38 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1475
1504
  reply_payload = {}
1476
1505
  elif action == "StartTransaction":
1477
1506
  id_tag = payload.get("idTag")
1478
- account = await self._get_account(id_tag)
1507
+ tag = None
1508
+ tag_created = False
1479
1509
  if id_tag:
1480
- if self.charger.require_rfid:
1481
- await database_sync_to_async(CoreRFID.register_scan)(
1482
- id_tag.upper()
1483
- )
1484
- else:
1485
- await self._ensure_rfid_seen(id_tag)
1510
+ tag, tag_created = await database_sync_to_async(
1511
+ CoreRFID.register_scan
1512
+ )(id_tag)
1513
+ account = await self._get_account(id_tag)
1514
+ if id_tag and not self.charger.require_rfid:
1515
+ seen_tag = await self._ensure_rfid_seen(id_tag)
1516
+ if seen_tag:
1517
+ tag = seen_tag
1486
1518
  await self._assign_connector(payload.get("connectorId"))
1519
+ authorized = True
1520
+ authorized_via_tag = False
1487
1521
  if self.charger.require_rfid:
1488
- authorized = (
1489
- account is not None
1490
- and await database_sync_to_async(account.can_authorize)()
1491
- )
1492
- else:
1493
- authorized = True
1522
+ if account is not None:
1523
+ authorized = await database_sync_to_async(
1524
+ account.can_authorize
1525
+ )()
1526
+ elif (
1527
+ id_tag
1528
+ and tag
1529
+ and not tag_created
1530
+ and getattr(tag, "allowed", False)
1531
+ ):
1532
+ authorized = True
1533
+ authorized_via_tag = True
1534
+ else:
1535
+ authorized = False
1494
1536
  if authorized:
1537
+ if authorized_via_tag and tag:
1538
+ self._log_unlinked_rfid(tag.rfid)
1495
1539
  start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1496
1540
  received_start = timezone.now()
1497
1541
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
ocpp/models.py CHANGED
@@ -82,6 +82,13 @@ class Charger(Entity):
82
82
  default=True,
83
83
  help_text="Display this charger on the public status dashboard.",
84
84
  )
85
+ language = models.CharField(
86
+ _("Language"),
87
+ max_length=12,
88
+ choices=settings.LANGUAGES,
89
+ default="es",
90
+ help_text=_("Preferred language for the public landing page."),
91
+ )
85
92
  require_rfid = models.BooleanField(
86
93
  _("Require RFID Authorization"),
87
94
  default=False,
ocpp/store.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from datetime import datetime
6
+ from datetime import datetime, timezone
7
7
  import json
8
8
  from pathlib import Path
9
9
  import re
@@ -427,7 +427,7 @@ def _file_path(cid: str, log_type: str = "charger") -> Path:
427
427
  def add_log(cid: str, entry: str, log_type: str = "charger") -> None:
428
428
  """Append a timestamped log entry for the given id and log type."""
429
429
 
430
- timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
430
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
431
431
  entry = f"{timestamp} {entry}"
432
432
 
433
433
  store = logs[log_type]
@@ -454,7 +454,7 @@ def start_session_log(cid: str, tx_id: int) -> None:
454
454
 
455
455
  history[cid] = {
456
456
  "transaction": tx_id,
457
- "start": datetime.utcnow(),
457
+ "start": datetime.now(timezone.utc),
458
458
  "messages": [],
459
459
  }
460
460
 
@@ -467,7 +467,9 @@ def add_session_message(cid: str, message: str) -> None:
467
467
  return
468
468
  sess["messages"].append(
469
469
  {
470
- "timestamp": datetime.utcnow().isoformat() + "Z",
470
+ "timestamp": datetime.now(timezone.utc)
471
+ .isoformat()
472
+ .replace("+00:00", "Z"),
471
473
  "message": message,
472
474
  }
473
475
  )