arthexis 0.1.20__py3-none-any.whl → 0.1.22__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.20.dist-info → arthexis-0.1.22.dist-info}/METADATA +10 -11
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/RECORD +34 -36
- config/asgi.py +1 -15
- config/settings.py +4 -26
- config/urls.py +5 -1
- core/admin.py +140 -252
- core/apps.py +0 -6
- core/environment.py +2 -220
- core/models.py +425 -77
- core/system.py +76 -0
- core/tests.py +153 -15
- core/views.py +35 -97
- nodes/admin.py +165 -32
- nodes/apps.py +11 -0
- nodes/models.py +26 -6
- nodes/tests.py +263 -1
- nodes/views.py +61 -1
- ocpp/admin.py +68 -7
- ocpp/consumers.py +1 -0
- ocpp/models.py +71 -1
- ocpp/tasks.py +99 -1
- ocpp/tests.py +310 -2
- ocpp/views.py +365 -5
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/context_processors.py +0 -12
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +361 -63
- pages/urls.py +5 -1
- pages/views.py +264 -16
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/WHEEL +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/top_level.txt +0 -0
ocpp/tasks.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
3
|
+
import uuid
|
|
2
4
|
from datetime import date, datetime, time, timedelta
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
|
|
7
|
+
from asgiref.sync import async_to_sync
|
|
5
8
|
from celery import shared_task
|
|
6
9
|
from django.conf import settings
|
|
7
10
|
from django.contrib.auth import get_user_model
|
|
@@ -11,11 +14,106 @@ from django.db.models import Q
|
|
|
11
14
|
from core import mailer
|
|
12
15
|
from nodes.models import Node
|
|
13
16
|
|
|
14
|
-
from .
|
|
17
|
+
from . import store
|
|
18
|
+
from .models import Charger, MeterValue, Transaction
|
|
15
19
|
|
|
16
20
|
logger = logging.getLogger(__name__)
|
|
17
21
|
|
|
18
22
|
|
|
23
|
+
@shared_task
|
|
24
|
+
def check_charge_point_configuration(charger_pk: int) -> bool:
|
|
25
|
+
"""Request the latest configuration from a connected charge point."""
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
charger = Charger.objects.get(pk=charger_pk)
|
|
29
|
+
except Charger.DoesNotExist:
|
|
30
|
+
logger.warning(
|
|
31
|
+
"Unable to request configuration for missing charger %s",
|
|
32
|
+
charger_pk,
|
|
33
|
+
)
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
connector_value = charger.connector_id
|
|
37
|
+
if connector_value is not None:
|
|
38
|
+
logger.debug(
|
|
39
|
+
"Skipping charger %s: connector %s is not eligible for automatic configuration checks",
|
|
40
|
+
charger.charger_id,
|
|
41
|
+
connector_value,
|
|
42
|
+
)
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
46
|
+
if ws is None:
|
|
47
|
+
logger.info(
|
|
48
|
+
"Charge point %s is not connected; configuration request skipped",
|
|
49
|
+
charger.charger_id,
|
|
50
|
+
)
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
message_id = uuid.uuid4().hex
|
|
54
|
+
payload: dict[str, object] = {}
|
|
55
|
+
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
async_to_sync(ws.send)(msg)
|
|
59
|
+
except Exception as exc: # pragma: no cover - network error
|
|
60
|
+
logger.warning(
|
|
61
|
+
"Failed to send GetConfiguration to %s (%s)",
|
|
62
|
+
charger.charger_id,
|
|
63
|
+
exc,
|
|
64
|
+
)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
68
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
69
|
+
store.register_pending_call(
|
|
70
|
+
message_id,
|
|
71
|
+
{
|
|
72
|
+
"action": "GetConfiguration",
|
|
73
|
+
"charger_id": charger.charger_id,
|
|
74
|
+
"connector_id": connector_value,
|
|
75
|
+
"log_key": log_key,
|
|
76
|
+
"requested_at": timezone.now(),
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
store.schedule_call_timeout(
|
|
80
|
+
message_id,
|
|
81
|
+
timeout=5.0,
|
|
82
|
+
action="GetConfiguration",
|
|
83
|
+
log_key=log_key,
|
|
84
|
+
message=(
|
|
85
|
+
"GetConfiguration timed out: charger did not respond"
|
|
86
|
+
" (operation may not be supported)"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
logger.info(
|
|
90
|
+
"Requested configuration from charge point %s",
|
|
91
|
+
charger.charger_id,
|
|
92
|
+
)
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@shared_task
|
|
97
|
+
def schedule_daily_charge_point_configuration_checks() -> int:
|
|
98
|
+
"""Dispatch configuration requests for eligible charge points."""
|
|
99
|
+
|
|
100
|
+
charger_ids = list(
|
|
101
|
+
Charger.objects.filter(connector_id__isnull=True).values_list("pk", flat=True)
|
|
102
|
+
)
|
|
103
|
+
if not charger_ids:
|
|
104
|
+
logger.debug("No eligible charge points available for configuration check")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
scheduled = 0
|
|
108
|
+
for charger_pk in charger_ids:
|
|
109
|
+
check_charge_point_configuration.delay(charger_pk)
|
|
110
|
+
scheduled += 1
|
|
111
|
+
logger.info(
|
|
112
|
+
"Scheduled configuration checks for %s charge point(s)", scheduled
|
|
113
|
+
)
|
|
114
|
+
return scheduled
|
|
115
|
+
|
|
116
|
+
|
|
19
117
|
@shared_task
|
|
20
118
|
def purge_meter_values() -> int:
|
|
21
119
|
"""Delete meter values older than 7 days.
|
ocpp/tests.py
CHANGED
|
@@ -55,6 +55,7 @@ from django.contrib.sites.models import Site
|
|
|
55
55
|
from django.core.exceptions import ValidationError
|
|
56
56
|
from pages.models import Application, Module
|
|
57
57
|
from nodes.models import Node, NodeRole
|
|
58
|
+
from django.contrib.admin.sites import AdminSite
|
|
58
59
|
|
|
59
60
|
from config.asgi import application
|
|
60
61
|
|
|
@@ -67,8 +68,9 @@ from .models import (
|
|
|
67
68
|
Location,
|
|
68
69
|
DataTransferMessage,
|
|
69
70
|
)
|
|
71
|
+
from .admin import ChargerConfigurationAdmin
|
|
70
72
|
from .consumers import CSMSConsumer
|
|
71
|
-
from .views import dispatch_action, _transaction_rfid_details
|
|
73
|
+
from .views import dispatch_action, _transaction_rfid_details, _usage_timeline
|
|
72
74
|
from .status_display import STATUS_BADGE_MAP
|
|
73
75
|
from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
|
|
74
76
|
from . import store
|
|
@@ -81,7 +83,12 @@ from .simulator import SimulatorConfig, ChargePointSimulator
|
|
|
81
83
|
from .evcs import simulate, SimulatorState, _simulators
|
|
82
84
|
import re
|
|
83
85
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
84
|
-
from .tasks import
|
|
86
|
+
from .tasks import (
|
|
87
|
+
purge_meter_readings,
|
|
88
|
+
send_daily_session_report,
|
|
89
|
+
check_charge_point_configuration,
|
|
90
|
+
schedule_daily_charge_point_configuration_checks,
|
|
91
|
+
)
|
|
85
92
|
from django.db import close_old_connections
|
|
86
93
|
from django.db.utils import OperationalError
|
|
87
94
|
from urllib.parse import unquote, urlparse
|
|
@@ -757,6 +764,7 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
757
764
|
)()
|
|
758
765
|
self.assertIsNotNone(configuration)
|
|
759
766
|
self.assertEqual(configuration.charger_identifier, "CFGRES")
|
|
767
|
+
self.assertIsNotNone(configuration.evcs_snapshot_at)
|
|
760
768
|
self.assertEqual(
|
|
761
769
|
configuration.configuration_keys,
|
|
762
770
|
[
|
|
@@ -2335,6 +2343,36 @@ class ChargerAdminTests(TestCase):
|
|
|
2335
2343
|
resp = self.client.get(url)
|
|
2336
2344
|
self.assertContains(resp, "AdminLoc")
|
|
2337
2345
|
|
|
2346
|
+
def test_admin_changelist_displays_quick_stats(self):
|
|
2347
|
+
charger = Charger.objects.create(charger_id="STATMAIN", display_name="Main EVCS")
|
|
2348
|
+
connector = Charger.objects.create(
|
|
2349
|
+
charger_id="STATMAIN", connector_id=1, display_name="Connector 1"
|
|
2350
|
+
)
|
|
2351
|
+
start = timezone.now() - timedelta(minutes=30)
|
|
2352
|
+
Transaction.objects.create(
|
|
2353
|
+
charger=connector,
|
|
2354
|
+
start_time=start,
|
|
2355
|
+
stop_time=start + timedelta(minutes=10),
|
|
2356
|
+
meter_start=1000,
|
|
2357
|
+
meter_stop=6000,
|
|
2358
|
+
)
|
|
2359
|
+
|
|
2360
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2361
|
+
resp = self.client.get(url)
|
|
2362
|
+
|
|
2363
|
+
self.assertContains(resp, "Total kW")
|
|
2364
|
+
self.assertContains(resp, "Today kW")
|
|
2365
|
+
self.assertContains(resp, "5.00")
|
|
2366
|
+
|
|
2367
|
+
def test_admin_changelist_does_not_indent_connectors(self):
|
|
2368
|
+
Charger.objects.create(charger_id="INDENTMAIN")
|
|
2369
|
+
Charger.objects.create(charger_id="INDENTMAIN", connector_id=1)
|
|
2370
|
+
|
|
2371
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2372
|
+
resp = self.client.get(url)
|
|
2373
|
+
|
|
2374
|
+
self.assertNotContains(resp, 'class="charger-connector-entry"')
|
|
2375
|
+
|
|
2338
2376
|
def test_last_fields_are_read_only(self):
|
|
2339
2377
|
now = timezone.now()
|
|
2340
2378
|
charger = Charger.objects.create(
|
|
@@ -2584,6 +2622,81 @@ class ChargerAdminTests(TestCase):
|
|
|
2584
2622
|
store.clear_log(pending_key, log_type="charger")
|
|
2585
2623
|
|
|
2586
2624
|
|
|
2625
|
+
class ChargerConfigurationAdminUnitTests(TestCase):
|
|
2626
|
+
def setUp(self):
|
|
2627
|
+
self.admin = ChargerConfigurationAdmin(ChargerConfiguration, AdminSite())
|
|
2628
|
+
self.request_factory = RequestFactory()
|
|
2629
|
+
|
|
2630
|
+
def test_origin_display_returns_evcs_when_snapshot_present(self):
|
|
2631
|
+
configuration = ChargerConfiguration.objects.create(
|
|
2632
|
+
charger_identifier="CFG-EVCS",
|
|
2633
|
+
evcs_snapshot_at=timezone.now(),
|
|
2634
|
+
)
|
|
2635
|
+
self.assertEqual(self.admin.origin_display(configuration), "EVCS")
|
|
2636
|
+
|
|
2637
|
+
def test_origin_display_returns_local_without_snapshot(self):
|
|
2638
|
+
configuration = ChargerConfiguration.objects.create(
|
|
2639
|
+
charger_identifier="CFG-LOCAL",
|
|
2640
|
+
)
|
|
2641
|
+
self.assertEqual(self.admin.origin_display(configuration), "Local")
|
|
2642
|
+
|
|
2643
|
+
def test_save_model_resets_snapshot_timestamp(self):
|
|
2644
|
+
configuration = ChargerConfiguration.objects.create(
|
|
2645
|
+
charger_identifier="CFG-SAVE",
|
|
2646
|
+
evcs_snapshot_at=timezone.now(),
|
|
2647
|
+
)
|
|
2648
|
+
request = self.request_factory.post("/admin/ocpp/chargerconfiguration/")
|
|
2649
|
+
self.admin.save_model(request, configuration, form=None, change=True)
|
|
2650
|
+
configuration.refresh_from_db()
|
|
2651
|
+
self.assertIsNone(configuration.evcs_snapshot_at)
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
class ConfigurationTaskTests(TestCase):
|
|
2655
|
+
def tearDown(self):
|
|
2656
|
+
store.pending_calls.clear()
|
|
2657
|
+
|
|
2658
|
+
def test_check_charge_point_configuration_dispatches_request(self):
|
|
2659
|
+
charger = Charger.objects.create(charger_id="TASKCFG")
|
|
2660
|
+
ws = DummyWebSocket()
|
|
2661
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2662
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
2663
|
+
store.clear_log(log_key, log_type="charger")
|
|
2664
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2665
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2666
|
+
try:
|
|
2667
|
+
result = check_charge_point_configuration.run(charger.pk)
|
|
2668
|
+
self.assertTrue(result)
|
|
2669
|
+
self.assertEqual(len(ws.sent), 1)
|
|
2670
|
+
frame = json.loads(ws.sent[0])
|
|
2671
|
+
self.assertEqual(frame[0], 2)
|
|
2672
|
+
self.assertEqual(frame[2], "GetConfiguration")
|
|
2673
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
2674
|
+
finally:
|
|
2675
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2676
|
+
store.pending_calls.clear()
|
|
2677
|
+
store.clear_log(log_key, log_type="charger")
|
|
2678
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2679
|
+
|
|
2680
|
+
def test_check_charge_point_configuration_without_connection(self):
|
|
2681
|
+
charger = Charger.objects.create(charger_id="TASKNOCONN")
|
|
2682
|
+
result = check_charge_point_configuration.run(charger.pk)
|
|
2683
|
+
self.assertFalse(result)
|
|
2684
|
+
|
|
2685
|
+
def test_schedule_daily_checks_only_includes_root_chargers(self):
|
|
2686
|
+
eligible = Charger.objects.create(charger_id="TASKROOT")
|
|
2687
|
+
Charger.objects.create(charger_id="TASKCONN", connector_id=1)
|
|
2688
|
+
with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
|
|
2689
|
+
scheduled = schedule_daily_charge_point_configuration_checks.run()
|
|
2690
|
+
self.assertEqual(scheduled, 1)
|
|
2691
|
+
mock_delay.assert_called_once_with(eligible.pk)
|
|
2692
|
+
|
|
2693
|
+
def test_schedule_daily_checks_returns_zero_without_chargers(self):
|
|
2694
|
+
with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
|
|
2695
|
+
scheduled = schedule_daily_charge_point_configuration_checks.run()
|
|
2696
|
+
self.assertEqual(scheduled, 0)
|
|
2697
|
+
mock_delay.assert_not_called()
|
|
2698
|
+
|
|
2699
|
+
|
|
2587
2700
|
class LocationAdminTests(TestCase):
|
|
2588
2701
|
def setUp(self):
|
|
2589
2702
|
self.client = Client()
|
|
@@ -2857,6 +2970,28 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2857
2970
|
|
|
2858
2971
|
await communicator.disconnect()
|
|
2859
2972
|
|
|
2973
|
+
def test_auto_registered_charger_location_name_sanitized(self):
|
|
2974
|
+
async def exercise():
|
|
2975
|
+
communicator = WebsocketCommunicator(
|
|
2976
|
+
application, "/?cid=ACME%20Charger%20%231"
|
|
2977
|
+
)
|
|
2978
|
+
connected, _ = await communicator.connect()
|
|
2979
|
+
self.assertTrue(connected)
|
|
2980
|
+
|
|
2981
|
+
await communicator.disconnect()
|
|
2982
|
+
|
|
2983
|
+
def fetch_location_name() -> str:
|
|
2984
|
+
charger = (
|
|
2985
|
+
Charger.objects.select_related("location")
|
|
2986
|
+
.get(charger_id="ACME Charger #1")
|
|
2987
|
+
)
|
|
2988
|
+
return charger.location.name
|
|
2989
|
+
|
|
2990
|
+
location_name = await database_sync_to_async(fetch_location_name)()
|
|
2991
|
+
self.assertEqual(location_name, "ACME_Charger_1")
|
|
2992
|
+
|
|
2993
|
+
async_to_sync(exercise)()
|
|
2994
|
+
|
|
2860
2995
|
async def test_query_string_cid_supported(self):
|
|
2861
2996
|
communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
|
|
2862
2997
|
connected, _ = await communicator.connect()
|
|
@@ -3294,6 +3429,10 @@ class ChargerLocationTests(TestCase):
|
|
|
3294
3429
|
second = Charger.objects.create(charger_id="SHARE", connector_id=2)
|
|
3295
3430
|
self.assertEqual(second.location, first.location)
|
|
3296
3431
|
|
|
3432
|
+
def test_location_name_sanitized_when_auto_created(self):
|
|
3433
|
+
charger = Charger.objects.create(charger_id="Name With spaces!#1")
|
|
3434
|
+
self.assertEqual(charger.location.name, "Name_With_spaces_1")
|
|
3435
|
+
|
|
3297
3436
|
|
|
3298
3437
|
class MeterReadingTests(TransactionTestCase):
|
|
3299
3438
|
async def test_meter_values_saved_as_readings(self):
|
|
@@ -4398,6 +4537,122 @@ class ChargerStatusViewTests(TestCase):
|
|
|
4398
4537
|
self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
|
|
4399
4538
|
store.transactions.pop(key, None)
|
|
4400
4539
|
|
|
4540
|
+
def test_usage_timeline_rendered_when_chart_unavailable(self):
|
|
4541
|
+
original_logs = store.logs["charger"]
|
|
4542
|
+
store.logs["charger"] = {}
|
|
4543
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
4544
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
4545
|
+
charger = Charger.objects.create(charger_id="TL1", connector_id=1)
|
|
4546
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
4547
|
+
|
|
4548
|
+
def build_entry(delta, status):
|
|
4549
|
+
timestamp = fixed_now - delta
|
|
4550
|
+
payload = {
|
|
4551
|
+
"connectorId": 1,
|
|
4552
|
+
"status": status,
|
|
4553
|
+
"timestamp": timestamp.isoformat(),
|
|
4554
|
+
}
|
|
4555
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4556
|
+
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4557
|
+
|
|
4558
|
+
store.logs["charger"][log_key] = [
|
|
4559
|
+
build_entry(timedelta(days=2), "Available"),
|
|
4560
|
+
build_entry(timedelta(days=1), "Charging"),
|
|
4561
|
+
build_entry(timedelta(hours=12), "Available"),
|
|
4562
|
+
]
|
|
4563
|
+
|
|
4564
|
+
data, _window = _usage_timeline(charger, [], now=fixed_now)
|
|
4565
|
+
self.assertEqual(len(data), 1)
|
|
4566
|
+
statuses = {segment["status"] for segment in data[0]["segments"]}
|
|
4567
|
+
self.assertIn("charging", statuses)
|
|
4568
|
+
self.assertIn("available", statuses)
|
|
4569
|
+
|
|
4570
|
+
with patch("ocpp.views.timezone.now", return_value=fixed_now):
|
|
4571
|
+
resp = self.client.get(
|
|
4572
|
+
reverse(
|
|
4573
|
+
"charger-status-connector",
|
|
4574
|
+
args=[charger.charger_id, charger.connector_slug],
|
|
4575
|
+
)
|
|
4576
|
+
)
|
|
4577
|
+
|
|
4578
|
+
self.assertContains(resp, "Usage (last 7 days)")
|
|
4579
|
+
self.assertContains(resp, "usage-timeline-segment usage-charging")
|
|
4580
|
+
|
|
4581
|
+
def test_usage_timeline_includes_multiple_connectors(self):
|
|
4582
|
+
original_logs = store.logs["charger"]
|
|
4583
|
+
store.logs["charger"] = {}
|
|
4584
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
4585
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
4586
|
+
aggregate = Charger.objects.create(charger_id="TLAGG")
|
|
4587
|
+
connector_one = Charger.objects.create(charger_id="TLAGG", connector_id=1)
|
|
4588
|
+
connector_two = Charger.objects.create(charger_id="TLAGG", connector_id=2)
|
|
4589
|
+
|
|
4590
|
+
def build_entry(connector_id, delta, status):
|
|
4591
|
+
timestamp = fixed_now - delta
|
|
4592
|
+
payload = {
|
|
4593
|
+
"connectorId": connector_id,
|
|
4594
|
+
"status": status,
|
|
4595
|
+
"timestamp": timestamp.isoformat(),
|
|
4596
|
+
}
|
|
4597
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4598
|
+
key = store.identity_key(aggregate.charger_id, connector_id)
|
|
4599
|
+
store.logs["charger"].setdefault(key, []).append(
|
|
4600
|
+
f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4601
|
+
)
|
|
4602
|
+
|
|
4603
|
+
build_entry(1, timedelta(days=3), "Available")
|
|
4604
|
+
build_entry(2, timedelta(days=2), "Charging")
|
|
4605
|
+
|
|
4606
|
+
overview = [{"charger": connector_one}, {"charger": connector_two}]
|
|
4607
|
+
data, _window = _usage_timeline(aggregate, overview, now=fixed_now)
|
|
4608
|
+
self.assertEqual(len(data), 2)
|
|
4609
|
+
self.assertTrue(all(entry["segments"] for entry in data))
|
|
4610
|
+
|
|
4611
|
+
with patch("ocpp.views.timezone.now", return_value=fixed_now):
|
|
4612
|
+
resp = self.client.get(reverse("charger-status", args=[aggregate.charger_id]))
|
|
4613
|
+
|
|
4614
|
+
self.assertContains(resp, "Usage (last 7 days)")
|
|
4615
|
+
self.assertContains(resp, connector_one.connector_label)
|
|
4616
|
+
self.assertContains(resp, connector_two.connector_label)
|
|
4617
|
+
|
|
4618
|
+
def test_usage_timeline_merges_repeated_status_entries(self):
|
|
4619
|
+
original_logs = store.logs["charger"]
|
|
4620
|
+
store.logs["charger"] = {}
|
|
4621
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
4622
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
4623
|
+
charger = Charger.objects.create(
|
|
4624
|
+
charger_id="TLDEDUP",
|
|
4625
|
+
connector_id=1,
|
|
4626
|
+
last_status="Available",
|
|
4627
|
+
)
|
|
4628
|
+
|
|
4629
|
+
def build_entry(delta, status):
|
|
4630
|
+
timestamp = fixed_now - delta
|
|
4631
|
+
payload = {
|
|
4632
|
+
"connectorId": 1,
|
|
4633
|
+
"status": status,
|
|
4634
|
+
"timestamp": timestamp.isoformat(),
|
|
4635
|
+
}
|
|
4636
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4637
|
+
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4638
|
+
|
|
4639
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
4640
|
+
store.logs["charger"][log_key] = [
|
|
4641
|
+
build_entry(timedelta(days=6, hours=12), "Available"),
|
|
4642
|
+
build_entry(timedelta(days=5), "Available"),
|
|
4643
|
+
build_entry(timedelta(days=3, hours=6), "Charging"),
|
|
4644
|
+
build_entry(timedelta(days=2), "Charging"),
|
|
4645
|
+
build_entry(timedelta(days=1), "Available"),
|
|
4646
|
+
]
|
|
4647
|
+
|
|
4648
|
+
data, window = _usage_timeline(charger, [], now=fixed_now)
|
|
4649
|
+
self.assertIsNotNone(window)
|
|
4650
|
+
self.assertEqual(len(data), 1)
|
|
4651
|
+
segments = data[0]["segments"]
|
|
4652
|
+
self.assertGreaterEqual(len(segments), 1)
|
|
4653
|
+
statuses = [segment["status"] for segment in segments]
|
|
4654
|
+
self.assertEqual(statuses, ["available", "charging", "available"])
|
|
4655
|
+
|
|
4401
4656
|
def test_diagnostics_status_displayed(self):
|
|
4402
4657
|
reported_at = timezone.now().replace(microsecond=0)
|
|
4403
4658
|
charger = Charger.objects.create(
|
|
@@ -4795,6 +5050,59 @@ class LiveUpdateViewTests(TestCase):
|
|
|
4795
5050
|
)
|
|
4796
5051
|
self.assertEqual(aggregate_entry["state"], available_label)
|
|
4797
5052
|
|
|
5053
|
+
def test_dashboard_groups_connectors_under_parent(self):
|
|
5054
|
+
aggregate = Charger.objects.create(charger_id="GROUPED")
|
|
5055
|
+
first = Charger.objects.create(
|
|
5056
|
+
charger_id=aggregate.charger_id, connector_id=1
|
|
5057
|
+
)
|
|
5058
|
+
second = Charger.objects.create(
|
|
5059
|
+
charger_id=aggregate.charger_id, connector_id=2
|
|
5060
|
+
)
|
|
5061
|
+
|
|
5062
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
5063
|
+
self.assertEqual(resp.status_code, 200)
|
|
5064
|
+
groups = resp.context["charger_groups"]
|
|
5065
|
+
target = next(
|
|
5066
|
+
group
|
|
5067
|
+
for group in groups
|
|
5068
|
+
if group.get("parent")
|
|
5069
|
+
and group["parent"]["charger"].pk == aggregate.pk
|
|
5070
|
+
)
|
|
5071
|
+
child_ids = [item["charger"].pk for item in target["children"]]
|
|
5072
|
+
self.assertEqual(child_ids, [first.pk, second.pk])
|
|
5073
|
+
|
|
5074
|
+
def test_dashboard_includes_energy_totals(self):
|
|
5075
|
+
aggregate = Charger.objects.create(charger_id="KWSTATS")
|
|
5076
|
+
now = timezone.now()
|
|
5077
|
+
Transaction.objects.create(
|
|
5078
|
+
charger=aggregate,
|
|
5079
|
+
start_time=now - timedelta(hours=1),
|
|
5080
|
+
stop_time=now,
|
|
5081
|
+
meter_start=0,
|
|
5082
|
+
meter_stop=3000,
|
|
5083
|
+
)
|
|
5084
|
+
past_start = now - timedelta(days=2)
|
|
5085
|
+
Transaction.objects.create(
|
|
5086
|
+
charger=aggregate,
|
|
5087
|
+
start_time=past_start,
|
|
5088
|
+
stop_time=past_start + timedelta(hours=1),
|
|
5089
|
+
meter_start=0,
|
|
5090
|
+
meter_stop=1000,
|
|
5091
|
+
)
|
|
5092
|
+
|
|
5093
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
5094
|
+
self.assertEqual(resp.status_code, 200)
|
|
5095
|
+
groups = resp.context["charger_groups"]
|
|
5096
|
+
target = next(
|
|
5097
|
+
group
|
|
5098
|
+
for group in groups
|
|
5099
|
+
if group.get("parent")
|
|
5100
|
+
and group["parent"]["charger"].pk == aggregate.pk
|
|
5101
|
+
)
|
|
5102
|
+
stats = target["parent"]["stats"]
|
|
5103
|
+
self.assertAlmostEqual(stats["total_kw"], 4.0, places=2)
|
|
5104
|
+
self.assertAlmostEqual(stats["today_kw"], 3.0, places=2)
|
|
5105
|
+
|
|
4798
5106
|
def test_cp_simulator_includes_interval(self):
|
|
4799
5107
|
resp = self.client.get(reverse("cp-simulator"))
|
|
4800
5108
|
self.assertEqual(resp.context["request"].live_update_interval, 5)
|