arthexis 0.1.16__py3-none-any.whl → 0.1.26__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 (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {arthexis-0.1.16.dist-info → arthexis-0.1.26.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,6 +27,8 @@ 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
33
  from .models import Transaction, Charger, DataTransferMessage, RFID
30
34
  from .evcs import (
@@ -43,6 +47,7 @@ CALL_ACTION_LABELS = {
43
47
  "DataTransfer": _("Data transfer"),
44
48
  "Reset": _("Reset"),
45
49
  "TriggerMessage": _("Trigger message"),
50
+ "ReserveNow": _("Reserve connector"),
46
51
  }
47
52
 
48
53
  CALL_EXPECTED_STATUSES: dict[str, set[str]] = {
@@ -52,6 +57,7 @@ CALL_EXPECTED_STATUSES: dict[str, set[str]] = {
52
57
  "DataTransfer": {"Accepted"},
53
58
  "Reset": {"Accepted"},
54
59
  "TriggerMessage": {"Accepted"},
60
+ "ReserveNow": {"Accepted"},
55
61
  }
56
62
 
57
63
 
@@ -223,24 +229,75 @@ def _transaction_rfid_details(
223
229
  if not tx_obj:
224
230
  return None
225
231
  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:
232
+ normalized = str(rfid_value or "").strip().upper()
233
+ cache_key = normalized
234
+ if normalized:
235
+ if cache is not None and cache_key in cache:
236
+ return cache[cache_key]
237
+ tag = (
238
+ RFID.matching_queryset(normalized)
239
+ .only("pk", "label_id", "custom_label")
240
+ .first()
241
+ )
242
+ rfid_url = None
243
+ label_value = None
244
+ canonical_value = normalized
245
+ if tag:
246
+ try:
247
+ rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
248
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
249
+ rfid_url = None
250
+ custom_label = (tag.custom_label or "").strip()
251
+ if custom_label:
252
+ label_value = custom_label
253
+ elif tag.label_id is not None:
254
+ label_value = str(tag.label_id)
255
+ canonical_value = tag.rfid or canonical_value
256
+ display_value = label_value or canonical_value
257
+ details = {
258
+ "value": display_value,
259
+ "url": rfid_url,
260
+ "uid": canonical_value,
261
+ "type": "rfid",
262
+ "display_label": gettext("RFID"),
263
+ }
264
+ if label_value:
265
+ details["label"] = label_value
266
+ if cache is not None:
267
+ cache[cache_key] = details
268
+ return details
269
+
270
+ identifier_value = getattr(tx_obj, "vehicle_identifier", None)
271
+ normalized_identifier = str(identifier_value or "").strip()
272
+ if not normalized_identifier:
273
+ vid_value = getattr(tx_obj, "vid", None)
274
+ vin_value = getattr(tx_obj, "vin", None)
275
+ normalized_identifier = str(vid_value or vin_value or "").strip()
276
+ if not normalized_identifier:
230
277
  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}
278
+ source = getattr(tx_obj, "vehicle_identifier_source", "") or "vid"
279
+ if source not in {"vid", "vin"}:
280
+ vid_raw = getattr(tx_obj, "vid", None)
281
+ vin_raw = getattr(tx_obj, "vin", None)
282
+ if str(vid_raw or "").strip():
283
+ source = "vid"
284
+ elif str(vin_raw or "").strip():
285
+ source = "vin"
286
+ else:
287
+ source = "vid"
288
+ cache_key = f"{source}:{normalized_identifier}"
289
+ if cache is not None and cache_key in cache:
290
+ return cache[cache_key]
291
+ label = gettext("VID") if source == "vid" else gettext("VIN")
292
+ details = {
293
+ "value": normalized_identifier,
294
+ "url": None,
295
+ "uid": None,
296
+ "type": source,
297
+ "display_label": label,
298
+ }
242
299
  if cache is not None:
243
- cache[normalized] = details
300
+ cache[cache_key] = details
244
301
  return details
245
302
 
246
303
 
@@ -284,6 +341,272 @@ def _connector_overview(
284
341
  return overview
285
342
 
286
343
 
344
+ def _normalize_timeline_status(value: str | None) -> str | None:
345
+ """Normalize raw charger status strings into timeline buckets."""
346
+
347
+ normalized = (value or "").strip().lower()
348
+ if not normalized:
349
+ return None
350
+ charging_states = {
351
+ "charging",
352
+ "finishing",
353
+ "suspendedev",
354
+ "suspendedevse",
355
+ "occupied",
356
+ }
357
+ available_states = {"available", "preparing", "reserved"}
358
+ offline_states = {"faulted", "unavailable", "outofservice"}
359
+ if normalized in charging_states:
360
+ return "charging"
361
+ if normalized in offline_states:
362
+ return "offline"
363
+ if normalized in available_states:
364
+ return "available"
365
+ # Treat other states as available for the initial implementation.
366
+ return "available"
367
+
368
+
369
+ def _timeline_labels() -> dict[str, str]:
370
+ """Return translated labels for timeline statuses."""
371
+
372
+ return {
373
+ "offline": gettext("Offline"),
374
+ "available": gettext("Available"),
375
+ "charging": gettext("Charging"),
376
+ }
377
+
378
+
379
+ def _format_segment_range(start: datetime, end: datetime) -> tuple[str, str]:
380
+ """Return localized display values for a timeline range."""
381
+
382
+ start_display = formats.date_format(
383
+ timezone.localtime(start), "SHORT_DATETIME_FORMAT"
384
+ )
385
+ end_display = formats.date_format(timezone.localtime(end), "SHORT_DATETIME_FORMAT")
386
+ return start_display, end_display
387
+
388
+
389
+ def _collect_status_events(
390
+ charger: Charger,
391
+ connector: Charger,
392
+ window_start: datetime,
393
+ window_end: datetime,
394
+ ) -> tuple[list[tuple[datetime, str]], tuple[datetime, str] | None]:
395
+ """Parse log entries into ordered status events for the connector."""
396
+
397
+ connector_id = connector.connector_id
398
+ serial = connector.charger_id
399
+ keys = [store.identity_key(serial, connector_id)]
400
+ if connector_id is not None:
401
+ keys.append(store.identity_key(serial, None))
402
+ keys.append(store.pending_key(serial))
403
+
404
+ seen_entries: set[str] = set()
405
+ events: list[tuple[datetime, str]] = []
406
+ latest_before_window: tuple[datetime, str] | None = None
407
+
408
+ for key in keys:
409
+ for entry in store.get_logs(key, log_type="charger"):
410
+ if entry in seen_entries:
411
+ continue
412
+ seen_entries.add(entry)
413
+ if len(entry) < 24:
414
+ continue
415
+ timestamp_raw = entry[:23]
416
+ message = entry[24:].strip()
417
+ try:
418
+ log_timestamp = datetime.strptime(
419
+ timestamp_raw, "%Y-%m-%d %H:%M:%S.%f"
420
+ ).replace(tzinfo=dt_timezone.utc)
421
+ except ValueError:
422
+ continue
423
+
424
+ event_time = log_timestamp
425
+ status_bucket: str | None = None
426
+
427
+ if message.startswith("StatusNotification processed:"):
428
+ payload_text = message.split(":", 1)[1].strip()
429
+ try:
430
+ payload = json.loads(payload_text)
431
+ except json.JSONDecodeError:
432
+ continue
433
+ target_id = payload.get("connectorId")
434
+ if connector_id is not None:
435
+ try:
436
+ normalized_target = int(target_id)
437
+ except (TypeError, ValueError):
438
+ normalized_target = None
439
+ if normalized_target not in {connector_id, None}:
440
+ continue
441
+ raw_status = payload.get("status")
442
+ status_bucket = _normalize_timeline_status(
443
+ raw_status if isinstance(raw_status, str) else None
444
+ )
445
+ payload_timestamp = payload.get("timestamp")
446
+ if isinstance(payload_timestamp, str):
447
+ parsed = parse_datetime(payload_timestamp)
448
+ if parsed is not None:
449
+ if timezone.is_naive(parsed):
450
+ parsed = timezone.make_aware(parsed, timezone=dt_timezone.utc)
451
+ event_time = parsed
452
+ elif message.startswith("Connected"):
453
+ status_bucket = "available"
454
+ elif message.startswith("Closed"):
455
+ status_bucket = "offline"
456
+
457
+ if not status_bucket:
458
+ continue
459
+
460
+ if event_time < window_start:
461
+ if (
462
+ latest_before_window is None
463
+ or event_time > latest_before_window[0]
464
+ ):
465
+ latest_before_window = (event_time, status_bucket)
466
+ continue
467
+ if event_time > window_end:
468
+ continue
469
+ events.append((event_time, status_bucket))
470
+
471
+ events.sort(key=lambda item: item[0])
472
+
473
+ deduped_events: list[tuple[datetime, str]] = []
474
+ for event_time, state in events:
475
+ if deduped_events and deduped_events[-1][1] == state:
476
+ continue
477
+ deduped_events.append((event_time, state))
478
+
479
+ return deduped_events, latest_before_window
480
+
481
+
482
+ def _usage_timeline(
483
+ charger: Charger,
484
+ connector_overview: list[dict],
485
+ *,
486
+ now: datetime | None = None,
487
+ ) -> tuple[list[dict], tuple[str, str] | None]:
488
+ """Build usage timeline data for inactive chargers."""
489
+
490
+ if now is None:
491
+ now = timezone.now()
492
+ window_end = now
493
+ window_start = now - timedelta(days=7)
494
+
495
+ if charger.connector_id is not None:
496
+ connectors = [charger]
497
+ else:
498
+ connectors = [
499
+ item["charger"]
500
+ for item in connector_overview
501
+ if item.get("charger") and item["charger"].connector_id is not None
502
+ ]
503
+ if not connectors:
504
+ connectors = [
505
+ sibling
506
+ for sibling in _connector_set(charger)
507
+ if sibling.connector_id is not None
508
+ ]
509
+
510
+ seen_ids: set[int] = set()
511
+ labels = _timeline_labels()
512
+ timeline_entries: list[dict] = []
513
+ window_display: tuple[str, str] | None = None
514
+
515
+ if window_start < window_end:
516
+ window_display = _format_segment_range(window_start, window_end)
517
+
518
+ for connector in connectors:
519
+ if connector.connector_id is None:
520
+ continue
521
+ if connector.connector_id in seen_ids:
522
+ continue
523
+ seen_ids.add(connector.connector_id)
524
+
525
+ events, prior_event = _collect_status_events(
526
+ charger, connector, window_start, window_end
527
+ )
528
+ fallback_state = _normalize_timeline_status(connector.last_status)
529
+ if fallback_state is None:
530
+ fallback_state = (
531
+ "available"
532
+ if store.is_connected(connector.charger_id, connector.connector_id)
533
+ else "offline"
534
+ )
535
+ current_state = fallback_state
536
+ if prior_event is not None:
537
+ current_state = prior_event[1]
538
+ segments: list[dict] = []
539
+ previous_time = window_start
540
+ total_seconds = (window_end - window_start).total_seconds()
541
+
542
+ for event_time, state in events:
543
+ if event_time <= window_start:
544
+ current_state = state
545
+ continue
546
+ if event_time > window_end:
547
+ break
548
+ if state == current_state:
549
+ continue
550
+ segment_start = max(previous_time, window_start)
551
+ segment_end = min(event_time, window_end)
552
+ if segment_end > segment_start:
553
+ duration = (segment_end - segment_start).total_seconds()
554
+ start_display, end_display = _format_segment_range(
555
+ segment_start, segment_end
556
+ )
557
+ segments.append(
558
+ {
559
+ "status": current_state,
560
+ "label": labels.get(current_state, current_state.title()),
561
+ "start_display": start_display,
562
+ "end_display": end_display,
563
+ "duration": max(duration, 1.0),
564
+ }
565
+ )
566
+ current_state = state
567
+ previous_time = max(event_time, window_start)
568
+
569
+ if previous_time < window_end:
570
+ segment_start = max(previous_time, window_start)
571
+ segment_end = window_end
572
+ if segment_end > segment_start:
573
+ duration = (segment_end - segment_start).total_seconds()
574
+ start_display, end_display = _format_segment_range(
575
+ segment_start, segment_end
576
+ )
577
+ segments.append(
578
+ {
579
+ "status": current_state,
580
+ "label": labels.get(current_state, current_state.title()),
581
+ "start_display": start_display,
582
+ "end_display": end_display,
583
+ "duration": max(duration, 1.0),
584
+ }
585
+ )
586
+
587
+ if not segments and total_seconds > 0:
588
+ start_display, end_display = _format_segment_range(window_start, window_end)
589
+ segments.append(
590
+ {
591
+ "status": current_state,
592
+ "label": labels.get(current_state, current_state.title()),
593
+ "start_display": start_display,
594
+ "end_display": end_display,
595
+ "duration": max(total_seconds, 1.0),
596
+ }
597
+ )
598
+
599
+ if segments:
600
+ timeline_entries.append(
601
+ {
602
+ "label": connector.connector_label,
603
+ "segments": segments,
604
+ }
605
+ )
606
+
607
+ return timeline_entries, window_display
608
+
609
+
287
610
  def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
288
611
  """Return active sessions grouped by connector for the charger."""
289
612
 
@@ -309,9 +632,14 @@ def _landing_page_translations() -> dict[str, dict[str, str]]:
309
632
  """Return static translations used by the charger public landing page."""
310
633
 
311
634
  catalog: dict[str, dict[str, str]] = {}
312
- for code in ("en", "es"):
313
- with translation.override(code):
314
- catalog[code] = {
635
+ seen_codes: set[str] = set()
636
+ for code, _name in settings.LANGUAGES:
637
+ normalized = str(code).strip()
638
+ if not normalized or normalized in seen_codes:
639
+ continue
640
+ seen_codes.add(normalized)
641
+ with translation.override(normalized):
642
+ catalog[normalized] = {
315
643
  "serial_number_label": gettext("Serial Number"),
316
644
  "connector_label": gettext("Connector"),
317
645
  "advanced_view_label": gettext("Advanced View"),
@@ -370,10 +698,6 @@ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
370
698
  )
371
699
  statuses: list[str] = []
372
700
  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
701
  tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
378
702
  if not tx_obj:
379
703
  tx_obj = (
@@ -381,9 +705,22 @@ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
381
705
  .order_by("-start_time")
382
706
  .first()
383
707
  )
384
- if _has_active_session(tx_obj):
708
+ has_session = _has_active_session(tx_obj)
709
+ status_value = (sibling.last_status or "").strip()
710
+ normalized_status = status_value.casefold() if status_value else ""
711
+ error_code_lower = (sibling.last_error_code or "").strip().lower()
712
+ if has_session:
385
713
  statuses.append("charging")
386
714
  continue
715
+ if (
716
+ normalized_status in {"charging", "finishing"}
717
+ and error_code_lower in ERROR_OK_VALUES
718
+ ):
719
+ statuses.append("available")
720
+ continue
721
+ if normalized_status:
722
+ statuses.append(normalized_status)
723
+ continue
387
724
  if store.is_connected(sibling.charger_id, sibling.connector_id):
388
725
  statuses.append("available")
389
726
 
@@ -424,6 +761,15 @@ def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
424
761
  # while a session is active. Override the badge so the user can see
425
762
  # the charger is actually busy.
426
763
  label, color = STATUS_BADGE_MAP.get("charging", (_("Charging"), "#198754"))
764
+ elif (
765
+ not has_session
766
+ and key in {"charging", "finishing"}
767
+ and error_code_lower in ERROR_OK_VALUES
768
+ ):
769
+ # Some chargers continue reporting "Charging" after a session ends.
770
+ # When no active transaction exists, surface the state as available
771
+ # so the UI reflects the actual behaviour at the site.
772
+ label, color = STATUS_BADGE_MAP.get("available", (_("Available"), "#0d6efd"))
427
773
  elif error_code and error_code_lower not in ERROR_OK_VALUES:
428
774
  label = _("%(status)s (%(error)s)") % {
429
775
  "status": label,
@@ -485,8 +831,12 @@ def charger_list(request):
485
831
  "meterStart": tx_obj.meter_start,
486
832
  "startTime": tx_obj.start_time.isoformat(),
487
833
  }
488
- if tx_obj.vin:
489
- tx_data["vin"] = tx_obj.vin
834
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
835
+ if identifier:
836
+ tx_data["vid"] = identifier
837
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
838
+ if legacy_vin:
839
+ tx_data["vin"] = legacy_vin
490
840
  if tx_obj.meter_stop is not None:
491
841
  tx_data["meterStop"] = tx_obj.meter_stop
492
842
  if tx_obj.stop_time is not None:
@@ -501,8 +851,12 @@ def charger_list(request):
501
851
  "meterStart": session_tx.meter_start,
502
852
  "startTime": session_tx.start_time.isoformat(),
503
853
  }
504
- if session_tx.vin:
505
- active_payload["vin"] = session_tx.vin
854
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
855
+ if identifier:
856
+ active_payload["vid"] = identifier
857
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
858
+ if legacy_vin:
859
+ active_payload["vin"] = legacy_vin
506
860
  if session_tx.meter_stop is not None:
507
861
  active_payload["meterStop"] = session_tx.meter_stop
508
862
  if session_tx.stop_time is not None:
@@ -582,8 +936,12 @@ def charger_detail(request, cid, connector=None):
582
936
  "meterStart": tx_obj.meter_start,
583
937
  "startTime": tx_obj.start_time.isoformat(),
584
938
  }
585
- if tx_obj.vin:
586
- tx_data["vin"] = tx_obj.vin
939
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
940
+ if identifier:
941
+ tx_data["vid"] = identifier
942
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
943
+ if legacy_vin:
944
+ tx_data["vin"] = legacy_vin
587
945
  if tx_obj.meter_stop is not None:
588
946
  tx_data["meterStop"] = tx_obj.meter_stop
589
947
  if tx_obj.stop_time is not None:
@@ -599,8 +957,12 @@ def charger_detail(request, cid, connector=None):
599
957
  "meterStart": session_tx.meter_start,
600
958
  "startTime": session_tx.start_time.isoformat(),
601
959
  }
602
- if session_tx.vin:
603
- payload["vin"] = session_tx.vin
960
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
961
+ if identifier:
962
+ payload["vid"] = identifier
963
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
964
+ if legacy_vin:
965
+ payload["vin"] = legacy_vin
604
966
  if session_tx.meter_stop is not None:
605
967
  payload["meterStop"] = session_tx.meter_stop
606
968
  if session_tx.stop_time is not None:
@@ -655,14 +1017,54 @@ def dashboard(request):
655
1017
  node = Node.get_local()
656
1018
  role = node.role if node else None
657
1019
  role_name = role.name if role else ""
658
- allow_anonymous_roles = {"Constellation", "Satellite"}
1020
+ allow_anonymous_roles = {"Watchtower", "Constellation", "Satellite"}
659
1021
  if not request.user.is_authenticated and role_name not in allow_anonymous_roles:
660
1022
  return redirect_to_login(
661
1023
  request.get_full_path(), login_url=reverse("pages:login")
662
1024
  )
663
- is_constellation = role_name == "Constellation"
664
- chargers = []
665
- for charger in _visible_chargers(request.user):
1025
+ is_watchtower = role_name in {"Watchtower", "Constellation"}
1026
+ visible_chargers = (
1027
+ _visible_chargers(request.user)
1028
+ .select_related("location")
1029
+ .order_by("charger_id", "connector_id")
1030
+ )
1031
+ stats_cache: dict[int, dict[str, float]] = {}
1032
+
1033
+ def _charger_display_name(charger: Charger) -> str:
1034
+ if charger.display_name:
1035
+ return charger.display_name
1036
+ if charger.location:
1037
+ return charger.location.name
1038
+ return charger.charger_id
1039
+
1040
+ today = timezone.localdate()
1041
+ tz = timezone.get_current_timezone()
1042
+ day_start = datetime.combine(today, time.min)
1043
+ if timezone.is_naive(day_start):
1044
+ day_start = timezone.make_aware(day_start, tz)
1045
+ day_end = day_start + timedelta(days=1)
1046
+
1047
+ def _charger_stats(charger: Charger) -> dict[str, float]:
1048
+ cache_key = charger.pk or id(charger)
1049
+ if cache_key not in stats_cache:
1050
+ stats_cache[cache_key] = {
1051
+ "total_kw": charger.total_kw,
1052
+ "today_kw": charger.total_kw_for_range(day_start, day_end),
1053
+ }
1054
+ return stats_cache[cache_key]
1055
+
1056
+ def _status_url(charger: Charger) -> str:
1057
+ return _reverse_connector_url(
1058
+ "charger-status",
1059
+ charger.charger_id,
1060
+ charger.connector_slug,
1061
+ )
1062
+
1063
+ chargers: list[dict[str, object]] = []
1064
+ charger_groups: list[dict[str, object]] = []
1065
+ group_lookup: dict[str, dict[str, object]] = {}
1066
+
1067
+ for charger in visible_chargers:
666
1068
  tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
667
1069
  if not tx_obj:
668
1070
  tx_obj = (
@@ -670,17 +1072,63 @@ def dashboard(request):
670
1072
  .order_by("-start_time")
671
1073
  .first()
672
1074
  )
1075
+ has_session = _has_active_session(tx_obj)
673
1076
  state, color = _charger_state(charger, tx_obj)
674
- chargers.append({"charger": charger, "state": state, "color": color})
1077
+ if (
1078
+ charger.connector_id is not None
1079
+ and not has_session
1080
+ and (charger.last_status or "").strip().casefold() == "charging"
1081
+ ):
1082
+ state, color = STATUS_BADGE_MAP["charging"]
1083
+ entry = {
1084
+ "charger": charger,
1085
+ "state": state,
1086
+ "color": color,
1087
+ "display_name": _charger_display_name(charger),
1088
+ "stats": _charger_stats(charger),
1089
+ "status_url": _status_url(charger),
1090
+ }
1091
+ chargers.append(entry)
1092
+ if charger.connector_id is None:
1093
+ group = {"parent": entry, "children": []}
1094
+ charger_groups.append(group)
1095
+ group_lookup[charger.charger_id] = group
1096
+ else:
1097
+ group = group_lookup.get(charger.charger_id)
1098
+ if group is None:
1099
+ group = {"parent": None, "children": []}
1100
+ charger_groups.append(group)
1101
+ group_lookup[charger.charger_id] = group
1102
+ group["children"].append(entry)
1103
+
1104
+ for group in charger_groups:
1105
+ parent_entry = group.get("parent")
1106
+ if not parent_entry or not group["children"]:
1107
+ continue
1108
+ connector_statuses = [
1109
+ (child["charger"].last_status or "").strip().casefold()
1110
+ for child in group["children"]
1111
+ if child["charger"].connector_id is not None
1112
+ ]
1113
+ if connector_statuses and all(status == "charging" for status in connector_statuses):
1114
+ label, badge_color = STATUS_BADGE_MAP["charging"]
1115
+ parent_entry["state"] = label
1116
+ parent_entry["color"] = badge_color
675
1117
  scheme = "wss" if request.is_secure() else "ws"
676
1118
  host = request.get_host()
677
1119
  ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
678
1120
  context = {
679
1121
  "chargers": chargers,
680
- "show_demo_notice": is_constellation,
1122
+ "charger_groups": charger_groups,
1123
+ "show_demo_notice": is_watchtower,
681
1124
  "demo_ws_url": ws_url,
682
1125
  "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
683
1126
  }
1127
+ if request.headers.get("x-requested-with") == "XMLHttpRequest" or request.GET.get("partial") == "table":
1128
+ html = render_to_string(
1129
+ "ocpp/includes/dashboard_table_rows.html", context, request=request
1130
+ )
1131
+ return JsonResponse({"html": html})
684
1132
  return render(request, "ocpp/dashboard.html", context)
685
1133
 
686
1134
 
@@ -824,11 +1272,30 @@ def charger_page(request, cid, connector=None):
824
1272
  state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
825
1273
  state, color = _charger_state(charger, state_source)
826
1274
  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()
1275
+ available_languages = [
1276
+ str(code).strip()
1277
+ for code, _ in settings.LANGUAGES
1278
+ if str(code).strip()
1279
+ ]
1280
+ supported_languages = set(available_languages)
1281
+ charger_language = (charger.language or "es").strip()
1282
+ if charger_language not in supported_languages:
1283
+ fallback = "es" if "es" in supported_languages else ""
1284
+ if not fallback and available_languages:
1285
+ fallback = available_languages[0]
1286
+ charger_language = fallback
1287
+ if (
1288
+ charger_language
1289
+ and (
1290
+ not language_cookie
1291
+ or language_cookie not in supported_languages
1292
+ or language_cookie != charger_language
1293
+ )
1294
+ ):
1295
+ translation.activate(charger_language)
1296
+ current_language = translation.get_language()
1297
+ request.LANGUAGE_CODE = current_language
1298
+ preferred_language = charger_language or current_language
832
1299
  connector_links = [
833
1300
  {
834
1301
  "slug": item["slug"],
@@ -856,6 +1323,7 @@ def charger_page(request, cid, connector=None):
856
1323
  "active_connector_count": active_connector_count,
857
1324
  "status_url": status_url,
858
1325
  "landing_translations": _landing_page_translations(),
1326
+ "preferred_language": preferred_language,
859
1327
  "state": state,
860
1328
  "color": color,
861
1329
  },
@@ -1005,7 +1473,10 @@ def charger_status(request, cid, connector=None):
1005
1473
  "connector_id": connector_id,
1006
1474
  }
1007
1475
  )
1008
- overview = _connector_overview(charger, request.user)
1476
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1477
+ overview = _connector_overview(
1478
+ charger, request.user, rfid_cache=rfid_cache
1479
+ )
1009
1480
  connector_links = [
1010
1481
  {
1011
1482
  "slug": item["slug"],
@@ -1018,6 +1489,9 @@ def charger_status(request, cid, connector=None):
1018
1489
  connector_overview = [
1019
1490
  item for item in overview if item["charger"].connector_id is not None
1020
1491
  ]
1492
+ usage_timeline, usage_timeline_window = _usage_timeline(
1493
+ charger, connector_overview
1494
+ )
1021
1495
  search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
1022
1496
  configuration_url = None
1023
1497
  if request.user.is_staff:
@@ -1046,12 +1520,15 @@ def charger_status(request, cid, connector=None):
1046
1520
  action_url = _reverse_connector_url("charger-action", cid, connector_slug)
1047
1521
  chart_should_animate = bool(has_active_session and not past_session)
1048
1522
 
1523
+ tx_rfid_details = _transaction_rfid_details(tx_obj, cache=rfid_cache)
1524
+
1049
1525
  return render(
1050
1526
  request,
1051
1527
  "ocpp/charger_status.html",
1052
1528
  {
1053
1529
  "charger": charger,
1054
1530
  "tx": tx_obj,
1531
+ "tx_rfid_details": tx_rfid_details,
1055
1532
  "state": state,
1056
1533
  "color": color,
1057
1534
  "transactions": transactions,
@@ -1081,6 +1558,8 @@ def charger_status(request, cid, connector=None):
1081
1558
  "pagination_query": pagination_query,
1082
1559
  "session_query": session_query,
1083
1560
  "chart_should_animate": chart_should_animate,
1561
+ "usage_timeline": usage_timeline,
1562
+ "usage_timeline_window": usage_timeline_window,
1084
1563
  },
1085
1564
  )
1086
1565
 
@@ -1132,6 +1611,15 @@ def charger_session_search(request, cid, connector=None):
1132
1611
  transactions = qs.order_by("-start_time")
1133
1612
  except ValueError:
1134
1613
  transactions = []
1614
+ if transactions is not None:
1615
+ transactions = list(transactions)
1616
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1617
+ for tx in transactions:
1618
+ details = _transaction_rfid_details(tx, cache=rfid_cache)
1619
+ label_value = None
1620
+ if details:
1621
+ label_value = str(details.get("label") or "").strip() or None
1622
+ tx.rfid_label = label_value
1135
1623
  overview = _connector_overview(charger, request.user)
1136
1624
  connector_links = [
1137
1625
  {
@@ -1191,17 +1679,67 @@ def charger_log_page(request, cid, connector=None):
1191
1679
  charger_id=cid
1192
1680
  )
1193
1681
  target_id = cid
1194
- log = store.get_logs(target_id, log_type=log_type)
1682
+
1683
+ slug_source = slugify(target_id) or slugify(cid) or "log"
1684
+ filename_parts = [log_type, slug_source]
1685
+ download_filename = f"{'-'.join(part for part in filename_parts if part)}.log"
1686
+ limit_options = [
1687
+ {"value": "20", "label": "20"},
1688
+ {"value": "40", "label": "40"},
1689
+ {"value": "100", "label": "100"},
1690
+ {"value": "all", "label": gettext("All")},
1691
+ ]
1692
+ allowed_values = [item["value"] for item in limit_options]
1693
+ limit_choice = request.GET.get("limit", "20")
1694
+ if limit_choice not in allowed_values:
1695
+ limit_choice = "20"
1696
+ limit_index = allowed_values.index(limit_choice)
1697
+
1698
+ log_entries_all = list(store.get_logs(target_id, log_type=log_type) or [])
1699
+ download_requested = request.GET.get("download") == "1"
1700
+ if download_requested:
1701
+ download_content = "\n".join(log_entries_all)
1702
+ if download_content and not download_content.endswith("\n"):
1703
+ download_content = f"{download_content}\n"
1704
+ response = HttpResponse(download_content, content_type="text/plain; charset=utf-8")
1705
+ response["Content-Disposition"] = f'attachment; filename="{download_filename}"'
1706
+ return response
1707
+
1708
+ log_entries = log_entries_all
1709
+ if limit_choice != "all":
1710
+ try:
1711
+ limit_value = int(limit_choice)
1712
+ except (TypeError, ValueError):
1713
+ limit_value = 20
1714
+ limit_choice = "20"
1715
+ limit_index = allowed_values.index(limit_choice)
1716
+ log_entries = log_entries[-limit_value:]
1717
+
1718
+ download_params = request.GET.copy()
1719
+ download_params["download"] = "1"
1720
+ download_params.pop("limit", None)
1721
+ download_query = download_params.urlencode()
1722
+ log_download_url = f"{request.path}?{download_query}" if download_query else request.path
1723
+
1724
+ limit_label = limit_options[limit_index]["label"]
1725
+ log_content = "\n".join(log_entries)
1195
1726
  return render(
1196
1727
  request,
1197
1728
  "ocpp/charger_logs.html",
1198
1729
  {
1199
1730
  "charger": charger,
1200
- "log": log,
1731
+ "log": log_entries,
1732
+ "log_content": log_content,
1201
1733
  "log_type": log_type,
1202
1734
  "connector_slug": connector_slug,
1203
1735
  "connector_links": connector_links,
1204
1736
  "status_url": status_url,
1737
+ "log_limit_options": limit_options,
1738
+ "log_limit_index": limit_index,
1739
+ "log_limit_choice": limit_choice,
1740
+ "log_limit_label": limit_label,
1741
+ "log_download_url": log_download_url,
1742
+ "log_filename": download_filename,
1205
1743
  },
1206
1744
  )
1207
1745
 
@@ -1209,7 +1747,7 @@ def charger_log_page(request, cid, connector=None):
1209
1747
  @csrf_exempt
1210
1748
  @api_login_required
1211
1749
  def dispatch_action(request, cid, connector=None):
1212
- connector_value, _ = _normalize_connector_slug(connector)
1750
+ connector_value, _normalized_slug = _normalize_connector_slug(connector)
1213
1751
  log_key = store.identity_key(cid, connector_value)
1214
1752
  if connector_value is None:
1215
1753
  charger_obj = (
@@ -1225,11 +1763,11 @@ def dispatch_action(request, cid, connector=None):
1225
1763
  )
1226
1764
  if charger_obj is None:
1227
1765
  if connector_value is None:
1228
- charger_obj, _ = Charger.objects.get_or_create(
1766
+ charger_obj, _created = Charger.objects.get_or_create(
1229
1767
  charger_id=cid, connector_id=None
1230
1768
  )
1231
1769
  else:
1232
- charger_obj, _ = Charger.objects.get_or_create(
1770
+ charger_obj, _created = Charger.objects.get_or_create(
1233
1771
  charger_id=cid, connector_id=connector_value
1234
1772
  )
1235
1773
 
@@ -1400,6 +1938,13 @@ def dispatch_action(request, cid, connector=None):
1400
1938
  },
1401
1939
  )
1402
1940
  elif action == "reset":
1941
+ tx_obj = store.get_transaction(cid, connector_value)
1942
+ if tx_obj is not None:
1943
+ detail = _(
1944
+ "Reset is blocked while a charging session is active. "
1945
+ "Stop the session first."
1946
+ )
1947
+ return JsonResponse({"detail": detail}, status=409)
1403
1948
  message_id = uuid.uuid4().hex
1404
1949
  ocpp_action = "Reset"
1405
1950
  expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)