arthexis 0.1.16__py3-none-any.whl → 0.1.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/views.py CHANGED
@@ -1,19 +1,21 @@
1
1
  import json
2
2
  import uuid
3
- from datetime import datetime, timedelta, timezone as dt_timezone
3
+ from datetime import datetime, time, timedelta, timezone as dt_timezone
4
4
  from types import SimpleNamespace
5
5
 
6
6
  from django.http import Http404, HttpResponse, JsonResponse
7
7
  from django.http.request import split_domain_port
8
8
  from django.views.decorators.csrf import csrf_exempt
9
9
  from django.shortcuts import get_object_or_404, render, resolve_url
10
+ from django.template.loader import render_to_string
10
11
  from django.core.paginator import Paginator
11
12
  from django.contrib.auth.decorators import login_required
12
13
  from django.contrib.auth.views import redirect_to_login
13
14
  from django.utils.translation import gettext_lazy as _, gettext, ngettext
15
+ from django.utils.text import slugify
14
16
  from django.urls import NoReverseMatch, reverse
15
17
  from django.conf import settings
16
- from django.utils import translation, timezone
18
+ from django.utils import translation, timezone, formats
17
19
  from django.core.exceptions import ValidationError
18
20
 
19
21
  from asgiref.sync import async_to_sync
@@ -25,8 +27,16 @@ from nodes.models import Node
25
27
  from pages.utils import landing
26
28
  from core.liveupdate import live_update
27
29
 
30
+ from django.utils.dateparse import parse_datetime
31
+
28
32
  from . import store
29
- from .models import Transaction, Charger, DataTransferMessage, RFID
33
+ from .models import (
34
+ Transaction,
35
+ Charger,
36
+ DataTransferMessage,
37
+ RFID,
38
+ CPFirmwareDeployment,
39
+ )
30
40
  from .evcs import (
31
41
  _start_simulator,
32
42
  _stop_simulator,
@@ -40,21 +50,53 @@ CALL_ACTION_LABELS = {
40
50
  "RemoteStartTransaction": _("Remote start transaction"),
41
51
  "RemoteStopTransaction": _("Remote stop transaction"),
42
52
  "ChangeAvailability": _("Change availability"),
53
+ "ChangeConfiguration": _("Change configuration"),
43
54
  "DataTransfer": _("Data transfer"),
44
55
  "Reset": _("Reset"),
45
56
  "TriggerMessage": _("Trigger message"),
57
+ "ReserveNow": _("Reserve connector"),
58
+ "ClearCache": _("Clear cache"),
46
59
  }
47
60
 
48
61
  CALL_EXPECTED_STATUSES: dict[str, set[str]] = {
49
62
  "RemoteStartTransaction": {"Accepted"},
50
63
  "RemoteStopTransaction": {"Accepted"},
51
64
  "ChangeAvailability": {"Accepted", "Scheduled"},
65
+ "ChangeConfiguration": {"Accepted", "Rejected", "RebootRequired"},
52
66
  "DataTransfer": {"Accepted"},
53
67
  "Reset": {"Accepted"},
54
68
  "TriggerMessage": {"Accepted"},
69
+ "ReserveNow": {"Accepted"},
70
+ "ClearCache": {"Accepted", "Rejected"},
55
71
  }
56
72
 
57
73
 
74
+ def firmware_download(request, deployment_id: int, token: str):
75
+ deployment = get_object_or_404(
76
+ CPFirmwareDeployment,
77
+ pk=deployment_id,
78
+ download_token=token,
79
+ )
80
+ expires = deployment.download_token_expires_at
81
+ if expires and timezone.now() > expires:
82
+ return HttpResponse(status=403)
83
+ firmware = deployment.firmware
84
+ if firmware is None:
85
+ raise Http404
86
+ payload = firmware.get_payload_bytes()
87
+ if not payload:
88
+ raise Http404
89
+ content_type = firmware.content_type or "application/octet-stream"
90
+ response = HttpResponse(payload, content_type=content_type)
91
+ filename = firmware.filename or f"firmware_{firmware.pk or deployment.pk}"
92
+ safe_filename = filename.replace("\r", "").replace("\n", "").replace("\"", "")
93
+ response["Content-Disposition"] = f'attachment; filename="{safe_filename}"'
94
+ response["Content-Length"] = str(len(payload))
95
+ deployment.downloaded_at = timezone.now()
96
+ deployment.save(update_fields=["downloaded_at", "updated_at"])
97
+ return response
98
+
99
+
58
100
  def _format_details(value: object) -> str:
59
101
  """Return a JSON representation of ``value`` suitable for error messages."""
60
102
 
@@ -113,6 +155,7 @@ def _evaluate_pending_call_result(
113
155
  if expected_statuses is not None:
114
156
  status_value = str(payload_dict.get("status") or "").strip()
115
157
  normalized_expected = {value.casefold() for value in expected_statuses if value}
158
+ remaining = {k: v for k, v in payload_dict.items() if k != "status"}
116
159
  if not status_value:
117
160
  detail = _("%(action)s response did not include a status.") % {
118
161
  "action": action_label,
@@ -123,7 +166,15 @@ def _evaluate_pending_call_result(
123
166
  "action": action_label,
124
167
  "status": status_value,
125
168
  }
126
- remaining = {k: v for k, v in payload_dict.items() if k != "status"}
169
+ extra = _format_details(remaining)
170
+ if extra:
171
+ detail += " " + _("Details: %(details)s") % {"details": extra}
172
+ return False, detail, 400
173
+ if status_value.casefold() == "rejected":
174
+ detail = _("%(action)s rejected with status %(status)s.") % {
175
+ "action": action_label,
176
+ "status": status_value,
177
+ }
127
178
  extra = _format_details(remaining)
128
179
  if extra:
129
180
  detail += " " + _("Details: %(details)s") % {"details": extra}
@@ -223,24 +274,75 @@ def _transaction_rfid_details(
223
274
  if not tx_obj:
224
275
  return None
225
276
  rfid_value = getattr(tx_obj, "rfid", None)
226
- if not rfid_value:
227
- return None
228
- normalized = str(rfid_value).strip()
229
- if not normalized:
277
+ normalized = str(rfid_value or "").strip().upper()
278
+ cache_key = normalized
279
+ if normalized:
280
+ if cache is not None and cache_key in cache:
281
+ return cache[cache_key]
282
+ tag = (
283
+ RFID.matching_queryset(normalized)
284
+ .only("pk", "label_id", "custom_label")
285
+ .first()
286
+ )
287
+ rfid_url = None
288
+ label_value = None
289
+ canonical_value = normalized
290
+ if tag:
291
+ try:
292
+ rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
293
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
294
+ rfid_url = None
295
+ custom_label = (tag.custom_label or "").strip()
296
+ if custom_label:
297
+ label_value = custom_label
298
+ elif tag.label_id is not None:
299
+ label_value = str(tag.label_id)
300
+ canonical_value = tag.rfid or canonical_value
301
+ display_value = label_value or canonical_value
302
+ details = {
303
+ "value": display_value,
304
+ "url": rfid_url,
305
+ "uid": canonical_value,
306
+ "type": "rfid",
307
+ "display_label": gettext("RFID"),
308
+ }
309
+ if label_value:
310
+ details["label"] = label_value
311
+ if cache is not None:
312
+ cache[cache_key] = details
313
+ return details
314
+
315
+ identifier_value = getattr(tx_obj, "vehicle_identifier", None)
316
+ normalized_identifier = str(identifier_value or "").strip()
317
+ if not normalized_identifier:
318
+ vid_value = getattr(tx_obj, "vid", None)
319
+ vin_value = getattr(tx_obj, "vin", None)
320
+ normalized_identifier = str(vid_value or vin_value or "").strip()
321
+ if not normalized_identifier:
230
322
  return None
231
- normalized = normalized.upper()
232
- if cache is not None and normalized in cache:
233
- return cache[normalized]
234
- tag = RFID.objects.filter(rfid=normalized).only("pk").first()
235
- rfid_url = None
236
- if tag:
237
- try:
238
- rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
239
- except NoReverseMatch: # pragma: no cover - admin may be disabled
240
- rfid_url = None
241
- details = {"value": normalized, "url": rfid_url}
323
+ source = getattr(tx_obj, "vehicle_identifier_source", "") or "vid"
324
+ if source not in {"vid", "vin"}:
325
+ vid_raw = getattr(tx_obj, "vid", None)
326
+ vin_raw = getattr(tx_obj, "vin", None)
327
+ if str(vid_raw or "").strip():
328
+ source = "vid"
329
+ elif str(vin_raw or "").strip():
330
+ source = "vin"
331
+ else:
332
+ source = "vid"
333
+ cache_key = f"{source}:{normalized_identifier}"
334
+ if cache is not None and cache_key in cache:
335
+ return cache[cache_key]
336
+ label = gettext("VID") if source == "vid" else gettext("VIN")
337
+ details = {
338
+ "value": normalized_identifier,
339
+ "url": None,
340
+ "uid": None,
341
+ "type": source,
342
+ "display_label": label,
343
+ }
242
344
  if cache is not None:
243
- cache[normalized] = details
345
+ cache[cache_key] = details
244
346
  return details
245
347
 
246
348
 
@@ -284,6 +386,261 @@ def _connector_overview(
284
386
  return overview
285
387
 
286
388
 
389
+ def _normalize_timeline_status(value: str | None) -> str | None:
390
+ """Normalize raw charger status strings into timeline buckets."""
391
+
392
+ normalized = (value or "").strip().lower()
393
+ if not normalized:
394
+ return None
395
+ charging_states = {
396
+ "charging",
397
+ "finishing",
398
+ "suspendedev",
399
+ "suspendedevse",
400
+ "occupied",
401
+ }
402
+ available_states = {"available", "preparing", "reserved"}
403
+ offline_states = {"faulted", "unavailable", "outofservice"}
404
+ if normalized in charging_states:
405
+ return "charging"
406
+ if normalized in offline_states:
407
+ return "offline"
408
+ if normalized in available_states:
409
+ return "available"
410
+ # Treat other states as available for the initial implementation.
411
+ return "available"
412
+
413
+
414
+ def _timeline_labels() -> dict[str, str]:
415
+ """Return translated labels for timeline statuses."""
416
+
417
+ return {
418
+ "offline": gettext("Offline"),
419
+ "available": gettext("Available"),
420
+ "charging": gettext("Charging"),
421
+ }
422
+
423
+
424
+ def _format_segment_range(start: datetime, end: datetime) -> tuple[str, str]:
425
+ """Return localized display values for a timeline range."""
426
+
427
+ start_display = formats.date_format(
428
+ timezone.localtime(start), "SHORT_DATETIME_FORMAT"
429
+ )
430
+ end_display = formats.date_format(timezone.localtime(end), "SHORT_DATETIME_FORMAT")
431
+ return start_display, end_display
432
+
433
+
434
+ def _collect_status_events(
435
+ charger: Charger,
436
+ connector: Charger,
437
+ window_start: datetime,
438
+ window_end: datetime,
439
+ ) -> tuple[list[tuple[datetime, str]], tuple[datetime, str] | None]:
440
+ """Parse log entries into ordered status events for the connector."""
441
+
442
+ connector_id = connector.connector_id
443
+ serial = connector.charger_id
444
+ keys = [store.identity_key(serial, connector_id)]
445
+ if connector_id is not None:
446
+ keys.append(store.identity_key(serial, None))
447
+ keys.append(store.pending_key(serial))
448
+
449
+ events: list[tuple[datetime, str]] = []
450
+ latest_before_window: tuple[datetime, str] | None = None
451
+
452
+ for entry in store.iter_log_entries(keys, log_type="charger", since=window_start):
453
+ if len(entry.text) < 24:
454
+ continue
455
+ message = entry.text[24:].strip()
456
+ log_timestamp = entry.timestamp
457
+
458
+ event_time = log_timestamp
459
+ status_bucket: str | None = None
460
+
461
+ if message.startswith("StatusNotification processed:"):
462
+ payload_text = message.split(":", 1)[1].strip()
463
+ try:
464
+ payload = json.loads(payload_text)
465
+ except json.JSONDecodeError:
466
+ continue
467
+ target_id = payload.get("connectorId")
468
+ if connector_id is not None:
469
+ try:
470
+ normalized_target = int(target_id)
471
+ except (TypeError, ValueError):
472
+ normalized_target = None
473
+ if normalized_target not in {connector_id, None}:
474
+ continue
475
+ raw_status = payload.get("status")
476
+ status_bucket = _normalize_timeline_status(
477
+ raw_status if isinstance(raw_status, str) else None
478
+ )
479
+ payload_timestamp = payload.get("timestamp")
480
+ if isinstance(payload_timestamp, str):
481
+ parsed = parse_datetime(payload_timestamp)
482
+ if parsed is not None:
483
+ if timezone.is_naive(parsed):
484
+ parsed = timezone.make_aware(parsed, timezone=dt_timezone.utc)
485
+ event_time = parsed
486
+ elif message.startswith("Connected"):
487
+ status_bucket = "available"
488
+ elif message.startswith("Closed"):
489
+ status_bucket = "offline"
490
+
491
+ if not status_bucket:
492
+ continue
493
+
494
+ if event_time < window_start:
495
+ if (
496
+ latest_before_window is None
497
+ or event_time > latest_before_window[0]
498
+ ):
499
+ latest_before_window = (event_time, status_bucket)
500
+ break
501
+ if event_time > window_end:
502
+ continue
503
+ events.append((event_time, status_bucket))
504
+
505
+ events.sort(key=lambda item: item[0])
506
+
507
+ deduped_events: list[tuple[datetime, str]] = []
508
+ for event_time, state in events:
509
+ if deduped_events and deduped_events[-1][1] == state:
510
+ continue
511
+ deduped_events.append((event_time, state))
512
+
513
+ return deduped_events, latest_before_window
514
+
515
+
516
+ def _usage_timeline(
517
+ charger: Charger,
518
+ connector_overview: list[dict],
519
+ *,
520
+ now: datetime | None = None,
521
+ ) -> tuple[list[dict], tuple[str, str] | None]:
522
+ """Build usage timeline data for inactive chargers."""
523
+
524
+ if now is None:
525
+ now = timezone.now()
526
+ window_end = now
527
+ window_start = now - timedelta(days=7)
528
+
529
+ if charger.connector_id is not None:
530
+ connectors = [charger]
531
+ else:
532
+ connectors = [
533
+ item["charger"]
534
+ for item in connector_overview
535
+ if item.get("charger") and item["charger"].connector_id is not None
536
+ ]
537
+ if not connectors:
538
+ connectors = [
539
+ sibling
540
+ for sibling in _connector_set(charger)
541
+ if sibling.connector_id is not None
542
+ ]
543
+
544
+ seen_ids: set[int] = set()
545
+ labels = _timeline_labels()
546
+ timeline_entries: list[dict] = []
547
+ window_display: tuple[str, str] | None = None
548
+
549
+ if window_start < window_end:
550
+ window_display = _format_segment_range(window_start, window_end)
551
+
552
+ for connector in connectors:
553
+ if connector.connector_id is None:
554
+ continue
555
+ if connector.connector_id in seen_ids:
556
+ continue
557
+ seen_ids.add(connector.connector_id)
558
+
559
+ events, prior_event = _collect_status_events(
560
+ charger, connector, window_start, window_end
561
+ )
562
+ fallback_state = _normalize_timeline_status(connector.last_status)
563
+ if fallback_state is None:
564
+ fallback_state = (
565
+ "available"
566
+ if store.is_connected(connector.charger_id, connector.connector_id)
567
+ else "offline"
568
+ )
569
+ current_state = fallback_state
570
+ if prior_event is not None:
571
+ current_state = prior_event[1]
572
+ segments: list[dict] = []
573
+ previous_time = window_start
574
+ total_seconds = (window_end - window_start).total_seconds()
575
+
576
+ for event_time, state in events:
577
+ if event_time <= window_start:
578
+ current_state = state
579
+ continue
580
+ if event_time > window_end:
581
+ break
582
+ if state == current_state:
583
+ continue
584
+ segment_start = max(previous_time, window_start)
585
+ segment_end = min(event_time, window_end)
586
+ if segment_end > segment_start:
587
+ duration = (segment_end - segment_start).total_seconds()
588
+ start_display, end_display = _format_segment_range(
589
+ segment_start, segment_end
590
+ )
591
+ segments.append(
592
+ {
593
+ "status": current_state,
594
+ "label": labels.get(current_state, current_state.title()),
595
+ "start_display": start_display,
596
+ "end_display": end_display,
597
+ "duration": max(duration, 1.0),
598
+ }
599
+ )
600
+ current_state = state
601
+ previous_time = max(event_time, window_start)
602
+
603
+ if previous_time < window_end:
604
+ segment_start = max(previous_time, window_start)
605
+ segment_end = window_end
606
+ if segment_end > segment_start:
607
+ duration = (segment_end - segment_start).total_seconds()
608
+ start_display, end_display = _format_segment_range(
609
+ segment_start, segment_end
610
+ )
611
+ segments.append(
612
+ {
613
+ "status": current_state,
614
+ "label": labels.get(current_state, current_state.title()),
615
+ "start_display": start_display,
616
+ "end_display": end_display,
617
+ "duration": max(duration, 1.0),
618
+ }
619
+ )
620
+
621
+ if not segments and total_seconds > 0:
622
+ start_display, end_display = _format_segment_range(window_start, window_end)
623
+ segments.append(
624
+ {
625
+ "status": current_state,
626
+ "label": labels.get(current_state, current_state.title()),
627
+ "start_display": start_display,
628
+ "end_display": end_display,
629
+ "duration": max(total_seconds, 1.0),
630
+ }
631
+ )
632
+
633
+ if segments:
634
+ timeline_entries.append(
635
+ {
636
+ "label": connector.connector_label,
637
+ "segments": segments,
638
+ }
639
+ )
640
+
641
+ return timeline_entries, window_display
642
+
643
+
287
644
  def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
288
645
  """Return active sessions grouped by connector for the charger."""
289
646
 
@@ -309,9 +666,14 @@ def _landing_page_translations() -> dict[str, dict[str, str]]:
309
666
  """Return static translations used by the charger public landing page."""
310
667
 
311
668
  catalog: dict[str, dict[str, str]] = {}
312
- for code in ("en", "es"):
313
- with translation.override(code):
314
- catalog[code] = {
669
+ seen_codes: set[str] = set()
670
+ for code, _name in settings.LANGUAGES:
671
+ normalized = str(code).strip()
672
+ if not normalized or normalized in seen_codes:
673
+ continue
674
+ seen_codes.add(normalized)
675
+ with translation.override(normalized):
676
+ catalog[normalized] = {
315
677
  "serial_number_label": gettext("Serial Number"),
316
678
  "connector_label": gettext("Connector"),
317
679
  "advanced_view_label": gettext("Advanced View"),
@@ -370,10 +732,6 @@ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
370
732
  )
371
733
  statuses: list[str] = []
372
734
  for sibling in siblings:
373
- status_value = (sibling.last_status or "").strip()
374
- if status_value:
375
- statuses.append(status_value.casefold())
376
- continue
377
735
  tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
378
736
  if not tx_obj:
379
737
  tx_obj = (
@@ -381,9 +739,22 @@ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
381
739
  .order_by("-start_time")
382
740
  .first()
383
741
  )
384
- if _has_active_session(tx_obj):
742
+ has_session = _has_active_session(tx_obj)
743
+ status_value = (sibling.last_status or "").strip()
744
+ normalized_status = status_value.casefold() if status_value else ""
745
+ error_code_lower = (sibling.last_error_code or "").strip().lower()
746
+ if has_session:
385
747
  statuses.append("charging")
386
748
  continue
749
+ if (
750
+ normalized_status in {"charging", "finishing"}
751
+ and error_code_lower in ERROR_OK_VALUES
752
+ ):
753
+ statuses.append("available")
754
+ continue
755
+ if normalized_status:
756
+ statuses.append(normalized_status)
757
+ continue
387
758
  if store.is_connected(sibling.charger_id, sibling.connector_id):
388
759
  statuses.append("available")
389
760
 
@@ -424,6 +795,15 @@ def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
424
795
  # while a session is active. Override the badge so the user can see
425
796
  # the charger is actually busy.
426
797
  label, color = STATUS_BADGE_MAP.get("charging", (_("Charging"), "#198754"))
798
+ elif (
799
+ not has_session
800
+ and key in {"charging", "finishing"}
801
+ and error_code_lower in ERROR_OK_VALUES
802
+ ):
803
+ # Some chargers continue reporting "Charging" after a session ends.
804
+ # When no active transaction exists, surface the state as available
805
+ # so the UI reflects the actual behaviour at the site.
806
+ label, color = STATUS_BADGE_MAP.get("available", (_("Available"), "#0d6efd"))
427
807
  elif error_code and error_code_lower not in ERROR_OK_VALUES:
428
808
  label = _("%(status)s (%(error)s)") % {
429
809
  "status": label,
@@ -485,8 +865,12 @@ def charger_list(request):
485
865
  "meterStart": tx_obj.meter_start,
486
866
  "startTime": tx_obj.start_time.isoformat(),
487
867
  }
488
- if tx_obj.vin:
489
- tx_data["vin"] = tx_obj.vin
868
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
869
+ if identifier:
870
+ tx_data["vid"] = identifier
871
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
872
+ if legacy_vin:
873
+ tx_data["vin"] = legacy_vin
490
874
  if tx_obj.meter_stop is not None:
491
875
  tx_data["meterStop"] = tx_obj.meter_stop
492
876
  if tx_obj.stop_time is not None:
@@ -501,8 +885,12 @@ def charger_list(request):
501
885
  "meterStart": session_tx.meter_start,
502
886
  "startTime": session_tx.start_time.isoformat(),
503
887
  }
504
- if session_tx.vin:
505
- active_payload["vin"] = session_tx.vin
888
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
889
+ if identifier:
890
+ active_payload["vid"] = identifier
891
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
892
+ if legacy_vin:
893
+ active_payload["vin"] = legacy_vin
506
894
  if session_tx.meter_stop is not None:
507
895
  active_payload["meterStop"] = session_tx.meter_stop
508
896
  if session_tx.stop_time is not None:
@@ -582,8 +970,12 @@ def charger_detail(request, cid, connector=None):
582
970
  "meterStart": tx_obj.meter_start,
583
971
  "startTime": tx_obj.start_time.isoformat(),
584
972
  }
585
- if tx_obj.vin:
586
- tx_data["vin"] = tx_obj.vin
973
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
974
+ if identifier:
975
+ tx_data["vid"] = identifier
976
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
977
+ if legacy_vin:
978
+ tx_data["vin"] = legacy_vin
587
979
  if tx_obj.meter_stop is not None:
588
980
  tx_data["meterStop"] = tx_obj.meter_stop
589
981
  if tx_obj.stop_time is not None:
@@ -599,8 +991,12 @@ def charger_detail(request, cid, connector=None):
599
991
  "meterStart": session_tx.meter_start,
600
992
  "startTime": session_tx.start_time.isoformat(),
601
993
  }
602
- if session_tx.vin:
603
- payload["vin"] = session_tx.vin
994
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
995
+ if identifier:
996
+ payload["vid"] = identifier
997
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
998
+ if legacy_vin:
999
+ payload["vin"] = legacy_vin
604
1000
  if session_tx.meter_stop is not None:
605
1001
  payload["meterStop"] = session_tx.meter_stop
606
1002
  if session_tx.stop_time is not None:
@@ -655,14 +1051,54 @@ def dashboard(request):
655
1051
  node = Node.get_local()
656
1052
  role = node.role if node else None
657
1053
  role_name = role.name if role else ""
658
- allow_anonymous_roles = {"Constellation", "Satellite"}
1054
+ allow_anonymous_roles = {"Watchtower", "Constellation", "Satellite"}
659
1055
  if not request.user.is_authenticated and role_name not in allow_anonymous_roles:
660
1056
  return redirect_to_login(
661
1057
  request.get_full_path(), login_url=reverse("pages:login")
662
1058
  )
663
- is_constellation = role_name == "Constellation"
664
- chargers = []
665
- for charger in _visible_chargers(request.user):
1059
+ is_watchtower = role_name in {"Watchtower", "Constellation"}
1060
+ visible_chargers = (
1061
+ _visible_chargers(request.user)
1062
+ .select_related("location")
1063
+ .order_by("charger_id", "connector_id")
1064
+ )
1065
+ stats_cache: dict[int, dict[str, float]] = {}
1066
+
1067
+ def _charger_display_name(charger: Charger) -> str:
1068
+ if charger.display_name:
1069
+ return charger.display_name
1070
+ if charger.location:
1071
+ return charger.location.name
1072
+ return charger.charger_id
1073
+
1074
+ today = timezone.localdate()
1075
+ tz = timezone.get_current_timezone()
1076
+ day_start = datetime.combine(today, time.min)
1077
+ if timezone.is_naive(day_start):
1078
+ day_start = timezone.make_aware(day_start, tz)
1079
+ day_end = day_start + timedelta(days=1)
1080
+
1081
+ def _charger_stats(charger: Charger) -> dict[str, float]:
1082
+ cache_key = charger.pk or id(charger)
1083
+ if cache_key not in stats_cache:
1084
+ stats_cache[cache_key] = {
1085
+ "total_kw": charger.total_kw,
1086
+ "today_kw": charger.total_kw_for_range(day_start, day_end),
1087
+ }
1088
+ return stats_cache[cache_key]
1089
+
1090
+ def _status_url(charger: Charger) -> str:
1091
+ return _reverse_connector_url(
1092
+ "charger-status",
1093
+ charger.charger_id,
1094
+ charger.connector_slug,
1095
+ )
1096
+
1097
+ chargers: list[dict[str, object]] = []
1098
+ charger_groups: list[dict[str, object]] = []
1099
+ group_lookup: dict[str, dict[str, object]] = {}
1100
+
1101
+ for charger in visible_chargers:
666
1102
  tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
667
1103
  if not tx_obj:
668
1104
  tx_obj = (
@@ -670,17 +1106,63 @@ def dashboard(request):
670
1106
  .order_by("-start_time")
671
1107
  .first()
672
1108
  )
1109
+ has_session = _has_active_session(tx_obj)
673
1110
  state, color = _charger_state(charger, tx_obj)
674
- chargers.append({"charger": charger, "state": state, "color": color})
1111
+ if (
1112
+ charger.connector_id is not None
1113
+ and not has_session
1114
+ and (charger.last_status or "").strip().casefold() == "charging"
1115
+ ):
1116
+ state, color = STATUS_BADGE_MAP["charging"]
1117
+ entry = {
1118
+ "charger": charger,
1119
+ "state": state,
1120
+ "color": color,
1121
+ "display_name": _charger_display_name(charger),
1122
+ "stats": _charger_stats(charger),
1123
+ "status_url": _status_url(charger),
1124
+ }
1125
+ chargers.append(entry)
1126
+ if charger.connector_id is None:
1127
+ group = {"parent": entry, "children": []}
1128
+ charger_groups.append(group)
1129
+ group_lookup[charger.charger_id] = group
1130
+ else:
1131
+ group = group_lookup.get(charger.charger_id)
1132
+ if group is None:
1133
+ group = {"parent": None, "children": []}
1134
+ charger_groups.append(group)
1135
+ group_lookup[charger.charger_id] = group
1136
+ group["children"].append(entry)
1137
+
1138
+ for group in charger_groups:
1139
+ parent_entry = group.get("parent")
1140
+ if not parent_entry or not group["children"]:
1141
+ continue
1142
+ connector_statuses = [
1143
+ (child["charger"].last_status or "").strip().casefold()
1144
+ for child in group["children"]
1145
+ if child["charger"].connector_id is not None
1146
+ ]
1147
+ if connector_statuses and all(status == "charging" for status in connector_statuses):
1148
+ label, badge_color = STATUS_BADGE_MAP["charging"]
1149
+ parent_entry["state"] = label
1150
+ parent_entry["color"] = badge_color
675
1151
  scheme = "wss" if request.is_secure() else "ws"
676
1152
  host = request.get_host()
677
1153
  ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
678
1154
  context = {
679
1155
  "chargers": chargers,
680
- "show_demo_notice": is_constellation,
1156
+ "charger_groups": charger_groups,
1157
+ "show_demo_notice": is_watchtower,
681
1158
  "demo_ws_url": ws_url,
682
1159
  "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
683
1160
  }
1161
+ if request.headers.get("x-requested-with") == "XMLHttpRequest" or request.GET.get("partial") == "table":
1162
+ html = render_to_string(
1163
+ "ocpp/includes/dashboard_table_rows.html", context, request=request
1164
+ )
1165
+ return JsonResponse({"html": html})
684
1166
  return render(request, "ocpp/dashboard.html", context)
685
1167
 
686
1168
 
@@ -824,11 +1306,30 @@ def charger_page(request, cid, connector=None):
824
1306
  state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
825
1307
  state, color = _charger_state(charger, state_source)
826
1308
  language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
827
- preferred_language = "es"
828
- supported_languages = {code for code, _ in settings.LANGUAGES}
829
- if preferred_language in supported_languages and not language_cookie:
830
- translation.activate(preferred_language)
831
- request.LANGUAGE_CODE = translation.get_language()
1309
+ available_languages = [
1310
+ str(code).strip()
1311
+ for code, _ in settings.LANGUAGES
1312
+ if str(code).strip()
1313
+ ]
1314
+ supported_languages = set(available_languages)
1315
+ charger_language = (charger.language or "es").strip()
1316
+ if charger_language not in supported_languages:
1317
+ fallback = "es" if "es" in supported_languages else ""
1318
+ if not fallback and available_languages:
1319
+ fallback = available_languages[0]
1320
+ charger_language = fallback
1321
+ if (
1322
+ charger_language
1323
+ and (
1324
+ not language_cookie
1325
+ or language_cookie not in supported_languages
1326
+ or language_cookie != charger_language
1327
+ )
1328
+ ):
1329
+ translation.activate(charger_language)
1330
+ current_language = translation.get_language()
1331
+ request.LANGUAGE_CODE = current_language
1332
+ preferred_language = charger_language or current_language
832
1333
  connector_links = [
833
1334
  {
834
1335
  "slug": item["slug"],
@@ -856,6 +1357,7 @@ def charger_page(request, cid, connector=None):
856
1357
  "active_connector_count": active_connector_count,
857
1358
  "status_url": status_url,
858
1359
  "landing_translations": _landing_page_translations(),
1360
+ "preferred_language": preferred_language,
859
1361
  "state": state,
860
1362
  "color": color,
861
1363
  },
@@ -1005,7 +1507,10 @@ def charger_status(request, cid, connector=None):
1005
1507
  "connector_id": connector_id,
1006
1508
  }
1007
1509
  )
1008
- overview = _connector_overview(charger, request.user)
1510
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1511
+ overview = _connector_overview(
1512
+ charger, request.user, rfid_cache=rfid_cache
1513
+ )
1009
1514
  connector_links = [
1010
1515
  {
1011
1516
  "slug": item["slug"],
@@ -1018,6 +1523,9 @@ def charger_status(request, cid, connector=None):
1018
1523
  connector_overview = [
1019
1524
  item for item in overview if item["charger"].connector_id is not None
1020
1525
  ]
1526
+ usage_timeline, usage_timeline_window = _usage_timeline(
1527
+ charger, connector_overview
1528
+ )
1021
1529
  search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
1022
1530
  configuration_url = None
1023
1531
  if request.user.is_staff:
@@ -1046,12 +1554,15 @@ def charger_status(request, cid, connector=None):
1046
1554
  action_url = _reverse_connector_url("charger-action", cid, connector_slug)
1047
1555
  chart_should_animate = bool(has_active_session and not past_session)
1048
1556
 
1557
+ tx_rfid_details = _transaction_rfid_details(tx_obj, cache=rfid_cache)
1558
+
1049
1559
  return render(
1050
1560
  request,
1051
1561
  "ocpp/charger_status.html",
1052
1562
  {
1053
1563
  "charger": charger,
1054
1564
  "tx": tx_obj,
1565
+ "tx_rfid_details": tx_rfid_details,
1055
1566
  "state": state,
1056
1567
  "color": color,
1057
1568
  "transactions": transactions,
@@ -1081,6 +1592,8 @@ def charger_status(request, cid, connector=None):
1081
1592
  "pagination_query": pagination_query,
1082
1593
  "session_query": session_query,
1083
1594
  "chart_should_animate": chart_should_animate,
1595
+ "usage_timeline": usage_timeline,
1596
+ "usage_timeline_window": usage_timeline_window,
1084
1597
  },
1085
1598
  )
1086
1599
 
@@ -1132,6 +1645,15 @@ def charger_session_search(request, cid, connector=None):
1132
1645
  transactions = qs.order_by("-start_time")
1133
1646
  except ValueError:
1134
1647
  transactions = []
1648
+ if transactions is not None:
1649
+ transactions = list(transactions)
1650
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1651
+ for tx in transactions:
1652
+ details = _transaction_rfid_details(tx, cache=rfid_cache)
1653
+ label_value = None
1654
+ if details:
1655
+ label_value = str(details.get("label") or "").strip() or None
1656
+ tx.rfid_label = label_value
1135
1657
  overview = _connector_overview(charger, request.user)
1136
1658
  connector_links = [
1137
1659
  {
@@ -1191,17 +1713,71 @@ def charger_log_page(request, cid, connector=None):
1191
1713
  charger_id=cid
1192
1714
  )
1193
1715
  target_id = cid
1194
- log = store.get_logs(target_id, log_type=log_type)
1716
+
1717
+ slug_source = slugify(target_id) or slugify(cid) or "log"
1718
+ filename_parts = [log_type, slug_source]
1719
+ download_filename = f"{'-'.join(part for part in filename_parts if part)}.log"
1720
+ limit_options = [
1721
+ {"value": "20", "label": "20"},
1722
+ {"value": "40", "label": "40"},
1723
+ {"value": "100", "label": "100"},
1724
+ {"value": "all", "label": gettext("All")},
1725
+ ]
1726
+ allowed_values = [item["value"] for item in limit_options]
1727
+ limit_choice = request.GET.get("limit", "20")
1728
+ if limit_choice not in allowed_values:
1729
+ limit_choice = "20"
1730
+ limit_index = allowed_values.index(limit_choice)
1731
+
1732
+ download_requested = request.GET.get("download") == "1"
1733
+
1734
+ limit_value: int | None = None
1735
+ if limit_choice != "all":
1736
+ try:
1737
+ limit_value = int(limit_choice)
1738
+ except (TypeError, ValueError):
1739
+ limit_value = 20
1740
+ limit_choice = "20"
1741
+ limit_index = allowed_values.index(limit_choice)
1742
+ log_entries: list[str]
1743
+ if download_requested:
1744
+ log_entries = list(store.get_logs(target_id, log_type=log_type) or [])
1745
+ download_content = "\n".join(log_entries)
1746
+ if download_content and not download_content.endswith("\n"):
1747
+ download_content = f"{download_content}\n"
1748
+ response = HttpResponse(download_content, content_type="text/plain; charset=utf-8")
1749
+ response["Content-Disposition"] = f'attachment; filename="{download_filename}"'
1750
+ return response
1751
+
1752
+ log_entries = list(
1753
+ store.get_logs(target_id, log_type=log_type, limit=limit_value) or []
1754
+ )
1755
+
1756
+ download_params = request.GET.copy()
1757
+ download_params["download"] = "1"
1758
+ download_params.pop("limit", None)
1759
+ download_query = download_params.urlencode()
1760
+ log_download_url = f"{request.path}?{download_query}" if download_query else request.path
1761
+
1762
+ limit_label = limit_options[limit_index]["label"]
1763
+ log_content = "\n".join(log_entries)
1195
1764
  return render(
1196
1765
  request,
1197
1766
  "ocpp/charger_logs.html",
1198
1767
  {
1199
1768
  "charger": charger,
1200
- "log": log,
1769
+ "log": log_entries,
1770
+ "log_content": log_content,
1201
1771
  "log_type": log_type,
1202
1772
  "connector_slug": connector_slug,
1203
1773
  "connector_links": connector_links,
1204
1774
  "status_url": status_url,
1775
+ "log_limit_options": limit_options,
1776
+ "log_limit_index": limit_index,
1777
+ "log_limit_choice": limit_choice,
1778
+ "log_limit_label": limit_label,
1779
+ "log_download_url": log_download_url,
1780
+ "log_filename": download_filename,
1205
1781
  },
1206
1782
  )
1207
1783
 
@@ -1209,7 +1785,7 @@ def charger_log_page(request, cid, connector=None):
1209
1785
  @csrf_exempt
1210
1786
  @api_login_required
1211
1787
  def dispatch_action(request, cid, connector=None):
1212
- connector_value, _ = _normalize_connector_slug(connector)
1788
+ connector_value, _normalized_slug = _normalize_connector_slug(connector)
1213
1789
  log_key = store.identity_key(cid, connector_value)
1214
1790
  if connector_value is None:
1215
1791
  charger_obj = (
@@ -1225,11 +1801,11 @@ def dispatch_action(request, cid, connector=None):
1225
1801
  )
1226
1802
  if charger_obj is None:
1227
1803
  if connector_value is None:
1228
- charger_obj, _ = Charger.objects.get_or_create(
1804
+ charger_obj, _created = Charger.objects.get_or_create(
1229
1805
  charger_id=cid, connector_id=None
1230
1806
  )
1231
1807
  else:
1232
- charger_obj, _ = Charger.objects.get_or_create(
1808
+ charger_obj, _created = Charger.objects.get_or_create(
1233
1809
  charger_id=cid, connector_id=connector_value
1234
1810
  )
1235
1811
 
@@ -1358,6 +1934,70 @@ def dispatch_action(request, cid, connector=None):
1358
1934
  Charger.objects.filter(pk=charger_obj.pk).update(**updates)
1359
1935
  for field, value in updates.items():
1360
1936
  setattr(charger_obj, field, value)
1937
+ elif action == "change_configuration":
1938
+ raw_key = data.get("key")
1939
+ if not isinstance(raw_key, str) or not raw_key.strip():
1940
+ return JsonResponse({"detail": "key required"}, status=400)
1941
+ key_value = raw_key.strip()
1942
+ raw_value = data.get("value", None)
1943
+ value_included = False
1944
+ value_text: str | None = None
1945
+ if raw_value is not None:
1946
+ if isinstance(raw_value, (str, int, float, bool)):
1947
+ value_included = True
1948
+ if isinstance(raw_value, str):
1949
+ value_text = raw_value
1950
+ else:
1951
+ value_text = str(raw_value)
1952
+ else:
1953
+ return JsonResponse(
1954
+ {"detail": "value must be a string, number, or boolean"},
1955
+ status=400,
1956
+ )
1957
+ payload = {"key": key_value}
1958
+ if value_included:
1959
+ payload["value"] = value_text
1960
+ message_id = uuid.uuid4().hex
1961
+ ocpp_action = "ChangeConfiguration"
1962
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1963
+ msg = json.dumps([2, message_id, "ChangeConfiguration", payload])
1964
+ elif action == "clear_cache":
1965
+ message_id = uuid.uuid4().hex
1966
+ ocpp_action = "ClearCache"
1967
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1968
+ msg = json.dumps([2, message_id, "ClearCache", {}])
1969
+ async_to_sync(ws.send)(msg)
1970
+ requested_at = timezone.now()
1971
+ store.register_pending_call(
1972
+ message_id,
1973
+ {
1974
+ "action": "ChangeConfiguration",
1975
+ "charger_id": cid,
1976
+ "connector_id": connector_value,
1977
+ "log_key": log_key,
1978
+ "key": key_value,
1979
+ "value": value_text,
1980
+ "requested_at": requested_at,
1981
+ },
1982
+ )
1983
+ timeout_message = str(_("Change configuration request timed out."))
1984
+ store.schedule_call_timeout(
1985
+ message_id,
1986
+ action="ChangeConfiguration",
1987
+ log_key=log_key,
1988
+ message=timeout_message,
1989
+ )
1990
+ if value_included and value_text is not None:
1991
+ change_message = str(
1992
+ _("Requested configuration change for %(key)s to %(value)s")
1993
+ % {"key": key_value, "value": value_text}
1994
+ )
1995
+ else:
1996
+ change_message = str(
1997
+ _("Requested configuration change for %(key)s")
1998
+ % {"key": key_value}
1999
+ )
2000
+ store.add_log(log_key, change_message, log_type="charger")
1361
2001
  elif action == "data_transfer":
1362
2002
  vendor_id = data.get("vendorId")
1363
2003
  if not isinstance(vendor_id, str) or not vendor_id.strip():
@@ -1400,6 +2040,13 @@ def dispatch_action(request, cid, connector=None):
1400
2040
  },
1401
2041
  )
1402
2042
  elif action == "reset":
2043
+ tx_obj = store.get_transaction(cid, connector_value)
2044
+ if tx_obj is not None:
2045
+ detail = _(
2046
+ "Reset is blocked while a charging session is active. "
2047
+ "Stop the session first."
2048
+ )
2049
+ return JsonResponse({"detail": detail}, status=409)
1403
2050
  message_id = uuid.uuid4().hex
1404
2051
  ocpp_action = "Reset"
1405
2052
  expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)