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.
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
normalized
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
if
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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[
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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,
|
|
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,
|
|
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,
|
|
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)
|