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.
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- 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 +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- 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.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
227
|
-
|
|
228
|
-
normalized
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
if
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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[
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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,
|
|
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,
|
|
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,
|
|
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)
|