arthexis 0.1.19__py3-none-any.whl → 0.1.21__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.
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/METADATA +5 -6
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/RECORD +42 -44
- config/asgi.py +1 -15
- config/settings.py +0 -26
- config/urls.py +0 -1
- core/admin.py +143 -234
- core/apps.py +0 -6
- core/backends.py +8 -2
- core/environment.py +240 -4
- core/models.py +132 -102
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/sigil_builder.py +2 -2
- core/tasks.py +24 -1
- core/tests.py +2 -7
- core/views.py +70 -132
- nodes/admin.py +162 -7
- nodes/models.py +294 -48
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +581 -15
- nodes/urls.py +4 -0
- nodes/views.py +560 -96
- ocpp/admin.py +144 -4
- ocpp/consumers.py +106 -9
- ocpp/models.py +131 -1
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +183 -9
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +186 -31
- pages/context_processors.py +15 -21
- pages/defaults.py +1 -1
- pages/module_defaults.py +5 -5
- pages/tests.py +110 -79
- pages/urls.py +1 -1
- pages/views.py +108 -13
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/WHEEL +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/top_level.txt +0 -0
ocpp/tests.py
CHANGED
|
@@ -61,13 +61,14 @@ from config.asgi import application
|
|
|
61
61
|
from .models import (
|
|
62
62
|
Transaction,
|
|
63
63
|
Charger,
|
|
64
|
+
ChargerConfiguration,
|
|
64
65
|
Simulator,
|
|
65
66
|
MeterReading,
|
|
66
67
|
Location,
|
|
67
68
|
DataTransferMessage,
|
|
68
69
|
)
|
|
69
70
|
from .consumers import CSMSConsumer
|
|
70
|
-
from .views import dispatch_action
|
|
71
|
+
from .views import dispatch_action, _transaction_rfid_details
|
|
71
72
|
from .status_display import STATUS_BADGE_MAP
|
|
72
73
|
from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
|
|
73
74
|
from . import store
|
|
@@ -714,6 +715,13 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
714
715
|
connected, _ = await communicator.connect()
|
|
715
716
|
self.assertTrue(connected)
|
|
716
717
|
|
|
718
|
+
await database_sync_to_async(Charger.objects.get_or_create)(
|
|
719
|
+
charger_id="CFGRES", connector_id=1
|
|
720
|
+
)
|
|
721
|
+
await database_sync_to_async(Charger.objects.get_or_create)(
|
|
722
|
+
charger_id="CFGRES", connector_id=2
|
|
723
|
+
)
|
|
724
|
+
|
|
717
725
|
message_id = "cfg-result"
|
|
718
726
|
payload = {
|
|
719
727
|
"configurationKey": [
|
|
@@ -744,6 +752,31 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
744
752
|
)
|
|
745
753
|
self.assertNotIn(message_id, store.pending_calls)
|
|
746
754
|
|
|
755
|
+
configuration = await database_sync_to_async(
|
|
756
|
+
lambda: ChargerConfiguration.objects.order_by("-created_at").first()
|
|
757
|
+
)()
|
|
758
|
+
self.assertIsNotNone(configuration)
|
|
759
|
+
self.assertEqual(configuration.charger_identifier, "CFGRES")
|
|
760
|
+
self.assertEqual(
|
|
761
|
+
configuration.configuration_keys,
|
|
762
|
+
[
|
|
763
|
+
{
|
|
764
|
+
"key": "AllowOfflineTxForUnknownId",
|
|
765
|
+
"value": "false",
|
|
766
|
+
"readonly": True,
|
|
767
|
+
}
|
|
768
|
+
],
|
|
769
|
+
)
|
|
770
|
+
self.assertEqual(configuration.unknown_keys, [])
|
|
771
|
+
config_ids = await database_sync_to_async(
|
|
772
|
+
lambda: set(
|
|
773
|
+
Charger.objects.filter(charger_id="CFGRES").values_list(
|
|
774
|
+
"configuration_id", flat=True
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
)()
|
|
778
|
+
self.assertEqual(config_ids, {configuration.pk})
|
|
779
|
+
|
|
747
780
|
await communicator.disconnect()
|
|
748
781
|
store.clear_log(log_key, log_type="charger")
|
|
749
782
|
store.clear_log(pending_key, log_type="charger")
|
|
@@ -1109,7 +1142,7 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1109
1142
|
|
|
1110
1143
|
await communicator.disconnect()
|
|
1111
1144
|
|
|
1112
|
-
async def
|
|
1145
|
+
async def test_vid_populated_from_vin(self):
|
|
1113
1146
|
await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
|
|
1114
1147
|
communicator = WebsocketCommunicator(application, "/VINREC/")
|
|
1115
1148
|
connected, _ = await communicator.connect()
|
|
@@ -1124,7 +1157,29 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1124
1157
|
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1125
1158
|
pk=tx_id, charger__charger_id="VINREC"
|
|
1126
1159
|
)
|
|
1127
|
-
self.assertEqual(tx.
|
|
1160
|
+
self.assertEqual(tx.vid, "WP0ZZZ11111111111")
|
|
1161
|
+
self.assertEqual(tx.vehicle_identifier, "WP0ZZZ11111111111")
|
|
1162
|
+
self.assertEqual(tx.vehicle_identifier_source, "vid")
|
|
1163
|
+
|
|
1164
|
+
await communicator.disconnect()
|
|
1165
|
+
|
|
1166
|
+
async def test_vid_recorded(self):
|
|
1167
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="VIDREC")
|
|
1168
|
+
communicator = WebsocketCommunicator(application, "/VIDREC/")
|
|
1169
|
+
connected, _ = await communicator.connect()
|
|
1170
|
+
self.assertTrue(connected)
|
|
1171
|
+
|
|
1172
|
+
await communicator.send_json_to(
|
|
1173
|
+
[2, "1", "StartTransaction", {"meterStart": 1, "vid": "VID123456"}]
|
|
1174
|
+
)
|
|
1175
|
+
response = await communicator.receive_json_from()
|
|
1176
|
+
tx_id = response[2]["transactionId"]
|
|
1177
|
+
|
|
1178
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1179
|
+
pk=tx_id, charger__charger_id="VIDREC"
|
|
1180
|
+
)
|
|
1181
|
+
self.assertEqual(tx.vid, "VID123456")
|
|
1182
|
+
self.assertEqual(tx.rfid, "")
|
|
1128
1183
|
|
|
1129
1184
|
await communicator.disconnect()
|
|
1130
1185
|
|
|
@@ -2126,12 +2181,12 @@ class SimulatorLandingTests(TestCase):
|
|
|
2126
2181
|
@skip("Navigation links unavailable in test environment")
|
|
2127
2182
|
def test_simulator_app_link_in_nav(self):
|
|
2128
2183
|
resp = self.client.get(reverse("pages:index"))
|
|
2129
|
-
self.assertContains(resp, "/ocpp/")
|
|
2130
|
-
self.assertNotContains(resp, "/ocpp/simulator/")
|
|
2184
|
+
self.assertContains(resp, "/ocpp/cpms/dashboard/")
|
|
2185
|
+
self.assertNotContains(resp, "/ocpp/evcs/simulator/")
|
|
2131
2186
|
self.client.force_login(self.user)
|
|
2132
2187
|
resp = self.client.get(reverse("pages:index"))
|
|
2133
|
-
self.assertContains(resp, "/ocpp/")
|
|
2134
|
-
self.assertContains(resp, "/ocpp/simulator/")
|
|
2188
|
+
self.assertContains(resp, "/ocpp/cpms/dashboard/")
|
|
2189
|
+
self.assertContains(resp, "/ocpp/evcs/simulator/")
|
|
2135
2190
|
|
|
2136
2191
|
def test_cp_simulator_redirects_to_login(self):
|
|
2137
2192
|
response = self.client.get(reverse("cp-simulator"))
|
|
@@ -2280,6 +2335,36 @@ class ChargerAdminTests(TestCase):
|
|
|
2280
2335
|
resp = self.client.get(url)
|
|
2281
2336
|
self.assertContains(resp, "AdminLoc")
|
|
2282
2337
|
|
|
2338
|
+
def test_admin_changelist_displays_quick_stats(self):
|
|
2339
|
+
charger = Charger.objects.create(charger_id="STATMAIN", display_name="Main EVCS")
|
|
2340
|
+
connector = Charger.objects.create(
|
|
2341
|
+
charger_id="STATMAIN", connector_id=1, display_name="Connector 1"
|
|
2342
|
+
)
|
|
2343
|
+
start = timezone.now() - timedelta(minutes=30)
|
|
2344
|
+
Transaction.objects.create(
|
|
2345
|
+
charger=connector,
|
|
2346
|
+
start_time=start,
|
|
2347
|
+
stop_time=start + timedelta(minutes=10),
|
|
2348
|
+
meter_start=1000,
|
|
2349
|
+
meter_stop=6000,
|
|
2350
|
+
)
|
|
2351
|
+
|
|
2352
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2353
|
+
resp = self.client.get(url)
|
|
2354
|
+
|
|
2355
|
+
self.assertContains(resp, "Total kW")
|
|
2356
|
+
self.assertContains(resp, "Today kW")
|
|
2357
|
+
self.assertContains(resp, "5.00")
|
|
2358
|
+
|
|
2359
|
+
def test_admin_changelist_does_not_indent_connectors(self):
|
|
2360
|
+
Charger.objects.create(charger_id="INDENTMAIN")
|
|
2361
|
+
Charger.objects.create(charger_id="INDENTMAIN", connector_id=1)
|
|
2362
|
+
|
|
2363
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2364
|
+
resp = self.client.get(url)
|
|
2365
|
+
|
|
2366
|
+
self.assertNotContains(resp, 'class="charger-connector-entry"')
|
|
2367
|
+
|
|
2283
2368
|
def test_last_fields_are_read_only(self):
|
|
2284
2369
|
now = timezone.now()
|
|
2285
2370
|
charger = Charger.objects.create(
|
|
@@ -3212,8 +3297,7 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
3212
3297
|
self.assertIsNotNone(aggregate.last_heartbeat)
|
|
3213
3298
|
if previous_heartbeat:
|
|
3214
3299
|
self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
|
|
3215
|
-
|
|
3216
|
-
self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
|
|
3300
|
+
self.assertEqual(connector.last_heartbeat, aggregate.last_heartbeat)
|
|
3217
3301
|
|
|
3218
3302
|
await communicator.disconnect()
|
|
3219
3303
|
|
|
@@ -4009,6 +4093,43 @@ class TransactionKwTests(TestCase):
|
|
|
4009
4093
|
self.assertEqual(tx.kw, 0.0)
|
|
4010
4094
|
|
|
4011
4095
|
|
|
4096
|
+
class TransactionIdentifierTests(TestCase):
|
|
4097
|
+
def test_vehicle_identifier_prefers_vid(self):
|
|
4098
|
+
charger = Charger.objects.create(charger_id="VIDPREF")
|
|
4099
|
+
tx = Transaction.objects.create(
|
|
4100
|
+
charger=charger,
|
|
4101
|
+
start_time=timezone.now(),
|
|
4102
|
+
vid="VID-123",
|
|
4103
|
+
vin="VIN-456",
|
|
4104
|
+
)
|
|
4105
|
+
self.assertEqual(tx.vehicle_identifier, "VID-123")
|
|
4106
|
+
self.assertEqual(tx.vehicle_identifier_source, "vid")
|
|
4107
|
+
|
|
4108
|
+
def test_vehicle_identifier_falls_back_to_vin(self):
|
|
4109
|
+
charger = Charger.objects.create(charger_id="VINONLY")
|
|
4110
|
+
tx = Transaction.objects.create(
|
|
4111
|
+
charger=charger,
|
|
4112
|
+
start_time=timezone.now(),
|
|
4113
|
+
vin="WP0ZZZ00000000001",
|
|
4114
|
+
)
|
|
4115
|
+
self.assertEqual(tx.vehicle_identifier, "WP0ZZZ00000000001")
|
|
4116
|
+
self.assertEqual(tx.vehicle_identifier_source, "vin")
|
|
4117
|
+
|
|
4118
|
+
def test_transaction_rfid_details_handles_vin(self):
|
|
4119
|
+
charger = Charger.objects.create(charger_id="VINDET")
|
|
4120
|
+
tx = Transaction.objects.create(
|
|
4121
|
+
charger=charger,
|
|
4122
|
+
start_time=timezone.now(),
|
|
4123
|
+
vin="WAUZZZ00000000002",
|
|
4124
|
+
)
|
|
4125
|
+
details = _transaction_rfid_details(tx, cache={})
|
|
4126
|
+
self.assertIsNotNone(details)
|
|
4127
|
+
assert details is not None # for type checkers
|
|
4128
|
+
self.assertEqual(details["value"], "WAUZZZ00000000002")
|
|
4129
|
+
self.assertEqual(details["display_label"], "VIN")
|
|
4130
|
+
self.assertEqual(details["type"], "vin")
|
|
4131
|
+
|
|
4132
|
+
|
|
4012
4133
|
class DispatchActionViewTests(TestCase):
|
|
4013
4134
|
def setUp(self):
|
|
4014
4135
|
self.client = Client()
|
|
@@ -4704,6 +4825,59 @@ class LiveUpdateViewTests(TestCase):
|
|
|
4704
4825
|
)
|
|
4705
4826
|
self.assertEqual(aggregate_entry["state"], available_label)
|
|
4706
4827
|
|
|
4828
|
+
def test_dashboard_groups_connectors_under_parent(self):
|
|
4829
|
+
aggregate = Charger.objects.create(charger_id="GROUPED")
|
|
4830
|
+
first = Charger.objects.create(
|
|
4831
|
+
charger_id=aggregate.charger_id, connector_id=1
|
|
4832
|
+
)
|
|
4833
|
+
second = Charger.objects.create(
|
|
4834
|
+
charger_id=aggregate.charger_id, connector_id=2
|
|
4835
|
+
)
|
|
4836
|
+
|
|
4837
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
4838
|
+
self.assertEqual(resp.status_code, 200)
|
|
4839
|
+
groups = resp.context["charger_groups"]
|
|
4840
|
+
target = next(
|
|
4841
|
+
group
|
|
4842
|
+
for group in groups
|
|
4843
|
+
if group.get("parent")
|
|
4844
|
+
and group["parent"]["charger"].pk == aggregate.pk
|
|
4845
|
+
)
|
|
4846
|
+
child_ids = [item["charger"].pk for item in target["children"]]
|
|
4847
|
+
self.assertEqual(child_ids, [first.pk, second.pk])
|
|
4848
|
+
|
|
4849
|
+
def test_dashboard_includes_energy_totals(self):
|
|
4850
|
+
aggregate = Charger.objects.create(charger_id="KWSTATS")
|
|
4851
|
+
now = timezone.now()
|
|
4852
|
+
Transaction.objects.create(
|
|
4853
|
+
charger=aggregate,
|
|
4854
|
+
start_time=now - timedelta(hours=1),
|
|
4855
|
+
stop_time=now,
|
|
4856
|
+
meter_start=0,
|
|
4857
|
+
meter_stop=3000,
|
|
4858
|
+
)
|
|
4859
|
+
past_start = now - timedelta(days=2)
|
|
4860
|
+
Transaction.objects.create(
|
|
4861
|
+
charger=aggregate,
|
|
4862
|
+
start_time=past_start,
|
|
4863
|
+
stop_time=past_start + timedelta(hours=1),
|
|
4864
|
+
meter_start=0,
|
|
4865
|
+
meter_stop=1000,
|
|
4866
|
+
)
|
|
4867
|
+
|
|
4868
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
4869
|
+
self.assertEqual(resp.status_code, 200)
|
|
4870
|
+
groups = resp.context["charger_groups"]
|
|
4871
|
+
target = next(
|
|
4872
|
+
group
|
|
4873
|
+
for group in groups
|
|
4874
|
+
if group.get("parent")
|
|
4875
|
+
and group["parent"]["charger"].pk == aggregate.pk
|
|
4876
|
+
)
|
|
4877
|
+
stats = target["parent"]["stats"]
|
|
4878
|
+
self.assertAlmostEqual(stats["total_kw"], 4.0, places=2)
|
|
4879
|
+
self.assertAlmostEqual(stats["today_kw"], 3.0, places=2)
|
|
4880
|
+
|
|
4707
4881
|
def test_cp_simulator_includes_interval(self):
|
|
4708
4882
|
resp = self.client.get(reverse("cp-simulator"))
|
|
4709
4883
|
self.assertEqual(resp.context["request"].live_update_interval, 5)
|
ocpp/transactions_io.py
CHANGED
|
@@ -46,6 +46,7 @@ def export_transactions(
|
|
|
46
46
|
"charger": tx.charger.charger_id if tx.charger else None,
|
|
47
47
|
"account": tx.account_id,
|
|
48
48
|
"rfid": tx.rfid,
|
|
49
|
+
"vid": tx.vehicle_identifier,
|
|
49
50
|
"vin": tx.vin,
|
|
50
51
|
"meter_start": tx.meter_start,
|
|
51
52
|
"meter_stop": tx.meter_stop,
|
|
@@ -144,11 +145,18 @@ def import_transactions(data: dict) -> int:
|
|
|
144
145
|
except ValidationError:
|
|
145
146
|
continue
|
|
146
147
|
charger_map[serial] = charger
|
|
148
|
+
vid_value = tx.get("vid")
|
|
149
|
+
vin_value = tx.get("vin")
|
|
150
|
+
vid_text = str(vid_value).strip() if vid_value is not None else ""
|
|
151
|
+
vin_text = str(vin_value).strip() if vin_value is not None else ""
|
|
152
|
+
if not vid_text and vin_text:
|
|
153
|
+
vid_text = vin_text
|
|
147
154
|
transaction = Transaction.objects.create(
|
|
148
155
|
charger=charger,
|
|
149
156
|
account_id=tx.get("account"),
|
|
150
157
|
rfid=tx.get("rfid", ""),
|
|
151
|
-
|
|
158
|
+
vid=vid_text,
|
|
159
|
+
vin=vin_text,
|
|
152
160
|
meter_start=tx.get("meter_start"),
|
|
153
161
|
meter_stop=tx.get("meter_stop"),
|
|
154
162
|
voltage_start=tx.get("voltage_start"),
|
ocpp/urls.py
CHANGED
|
@@ -3,8 +3,8 @@ from django.urls import include, path
|
|
|
3
3
|
from . import views
|
|
4
4
|
|
|
5
5
|
urlpatterns = [
|
|
6
|
-
path("", views.dashboard, name="ocpp-dashboard"),
|
|
7
|
-
path("simulator/", views.cp_simulator, name="cp-simulator"),
|
|
6
|
+
path("cpms/dashboard/", views.dashboard, name="ocpp-dashboard"),
|
|
7
|
+
path("evcs/simulator/", views.cp_simulator, name="cp-simulator"),
|
|
8
8
|
path("chargers/", views.charger_list, name="charger-list"),
|
|
9
9
|
path("chargers/<str:cid>/", views.charger_detail, name="charger-detail"),
|
|
10
10
|
path(
|
|
@@ -46,5 +46,5 @@ urlpatterns = [
|
|
|
46
46
|
views.charger_status,
|
|
47
47
|
name="charger-status-connector",
|
|
48
48
|
),
|
|
49
|
-
path("rfid/", include("ocpp.rfid.urls")),
|
|
49
|
+
path("rfid/validator/", include("ocpp.rfid.urls")),
|
|
50
50
|
]
|
ocpp/views.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import uuid
|
|
3
3
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
4
|
+
from datetime import datetime, time, timedelta
|
|
4
5
|
from types import SimpleNamespace
|
|
5
6
|
|
|
6
7
|
from django.http import Http404, HttpResponse, JsonResponse
|
|
@@ -224,24 +225,75 @@ def _transaction_rfid_details(
|
|
|
224
225
|
if not tx_obj:
|
|
225
226
|
return None
|
|
226
227
|
rfid_value = getattr(tx_obj, "rfid", None)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
normalized
|
|
230
|
-
|
|
228
|
+
normalized = str(rfid_value or "").strip().upper()
|
|
229
|
+
cache_key = normalized
|
|
230
|
+
if normalized:
|
|
231
|
+
if cache is not None and cache_key in cache:
|
|
232
|
+
return cache[cache_key]
|
|
233
|
+
tag = (
|
|
234
|
+
RFID.matching_queryset(normalized)
|
|
235
|
+
.only("pk", "label_id", "custom_label")
|
|
236
|
+
.first()
|
|
237
|
+
)
|
|
238
|
+
rfid_url = None
|
|
239
|
+
label_value = None
|
|
240
|
+
canonical_value = normalized
|
|
241
|
+
if tag:
|
|
242
|
+
try:
|
|
243
|
+
rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
|
|
244
|
+
except NoReverseMatch: # pragma: no cover - admin may be disabled
|
|
245
|
+
rfid_url = None
|
|
246
|
+
custom_label = (tag.custom_label or "").strip()
|
|
247
|
+
if custom_label:
|
|
248
|
+
label_value = custom_label
|
|
249
|
+
elif tag.label_id is not None:
|
|
250
|
+
label_value = str(tag.label_id)
|
|
251
|
+
canonical_value = tag.rfid or canonical_value
|
|
252
|
+
display_value = label_value or canonical_value
|
|
253
|
+
details = {
|
|
254
|
+
"value": display_value,
|
|
255
|
+
"url": rfid_url,
|
|
256
|
+
"uid": canonical_value,
|
|
257
|
+
"type": "rfid",
|
|
258
|
+
"display_label": gettext("RFID"),
|
|
259
|
+
}
|
|
260
|
+
if label_value:
|
|
261
|
+
details["label"] = label_value
|
|
262
|
+
if cache is not None:
|
|
263
|
+
cache[cache_key] = details
|
|
264
|
+
return details
|
|
265
|
+
|
|
266
|
+
identifier_value = getattr(tx_obj, "vehicle_identifier", None)
|
|
267
|
+
normalized_identifier = str(identifier_value or "").strip()
|
|
268
|
+
if not normalized_identifier:
|
|
269
|
+
vid_value = getattr(tx_obj, "vid", None)
|
|
270
|
+
vin_value = getattr(tx_obj, "vin", None)
|
|
271
|
+
normalized_identifier = str(vid_value or vin_value or "").strip()
|
|
272
|
+
if not normalized_identifier:
|
|
231
273
|
return None
|
|
232
|
-
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
274
|
+
source = getattr(tx_obj, "vehicle_identifier_source", "") or "vid"
|
|
275
|
+
if source not in {"vid", "vin"}:
|
|
276
|
+
vid_raw = getattr(tx_obj, "vid", None)
|
|
277
|
+
vin_raw = getattr(tx_obj, "vin", None)
|
|
278
|
+
if str(vid_raw or "").strip():
|
|
279
|
+
source = "vid"
|
|
280
|
+
elif str(vin_raw or "").strip():
|
|
281
|
+
source = "vin"
|
|
282
|
+
else:
|
|
283
|
+
source = "vid"
|
|
284
|
+
cache_key = f"{source}:{normalized_identifier}"
|
|
285
|
+
if cache is not None and cache_key in cache:
|
|
286
|
+
return cache[cache_key]
|
|
287
|
+
label = gettext("VID") if source == "vid" else gettext("VIN")
|
|
288
|
+
details = {
|
|
289
|
+
"value": normalized_identifier,
|
|
290
|
+
"url": None,
|
|
291
|
+
"uid": None,
|
|
292
|
+
"type": source,
|
|
293
|
+
"display_label": label,
|
|
294
|
+
}
|
|
243
295
|
if cache is not None:
|
|
244
|
-
cache[
|
|
296
|
+
cache[cache_key] = details
|
|
245
297
|
return details
|
|
246
298
|
|
|
247
299
|
|
|
@@ -509,8 +561,12 @@ def charger_list(request):
|
|
|
509
561
|
"meterStart": tx_obj.meter_start,
|
|
510
562
|
"startTime": tx_obj.start_time.isoformat(),
|
|
511
563
|
}
|
|
512
|
-
|
|
513
|
-
|
|
564
|
+
identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
|
|
565
|
+
if identifier:
|
|
566
|
+
tx_data["vid"] = identifier
|
|
567
|
+
legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
|
|
568
|
+
if legacy_vin:
|
|
569
|
+
tx_data["vin"] = legacy_vin
|
|
514
570
|
if tx_obj.meter_stop is not None:
|
|
515
571
|
tx_data["meterStop"] = tx_obj.meter_stop
|
|
516
572
|
if tx_obj.stop_time is not None:
|
|
@@ -525,8 +581,12 @@ def charger_list(request):
|
|
|
525
581
|
"meterStart": session_tx.meter_start,
|
|
526
582
|
"startTime": session_tx.start_time.isoformat(),
|
|
527
583
|
}
|
|
528
|
-
|
|
529
|
-
|
|
584
|
+
identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
|
|
585
|
+
if identifier:
|
|
586
|
+
active_payload["vid"] = identifier
|
|
587
|
+
legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
|
|
588
|
+
if legacy_vin:
|
|
589
|
+
active_payload["vin"] = legacy_vin
|
|
530
590
|
if session_tx.meter_stop is not None:
|
|
531
591
|
active_payload["meterStop"] = session_tx.meter_stop
|
|
532
592
|
if session_tx.stop_time is not None:
|
|
@@ -606,8 +666,12 @@ def charger_detail(request, cid, connector=None):
|
|
|
606
666
|
"meterStart": tx_obj.meter_start,
|
|
607
667
|
"startTime": tx_obj.start_time.isoformat(),
|
|
608
668
|
}
|
|
609
|
-
|
|
610
|
-
|
|
669
|
+
identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
|
|
670
|
+
if identifier:
|
|
671
|
+
tx_data["vid"] = identifier
|
|
672
|
+
legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
|
|
673
|
+
if legacy_vin:
|
|
674
|
+
tx_data["vin"] = legacy_vin
|
|
611
675
|
if tx_obj.meter_stop is not None:
|
|
612
676
|
tx_data["meterStop"] = tx_obj.meter_stop
|
|
613
677
|
if tx_obj.stop_time is not None:
|
|
@@ -623,8 +687,12 @@ def charger_detail(request, cid, connector=None):
|
|
|
623
687
|
"meterStart": session_tx.meter_start,
|
|
624
688
|
"startTime": session_tx.start_time.isoformat(),
|
|
625
689
|
}
|
|
626
|
-
|
|
627
|
-
|
|
690
|
+
identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
|
|
691
|
+
if identifier:
|
|
692
|
+
payload["vid"] = identifier
|
|
693
|
+
legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
|
|
694
|
+
if legacy_vin:
|
|
695
|
+
payload["vin"] = legacy_vin
|
|
628
696
|
if session_tx.meter_stop is not None:
|
|
629
697
|
payload["meterStop"] = session_tx.meter_stop
|
|
630
698
|
if session_tx.stop_time is not None:
|
|
@@ -679,14 +747,54 @@ def dashboard(request):
|
|
|
679
747
|
node = Node.get_local()
|
|
680
748
|
role = node.role if node else None
|
|
681
749
|
role_name = role.name if role else ""
|
|
682
|
-
allow_anonymous_roles = {"Constellation", "Satellite"}
|
|
750
|
+
allow_anonymous_roles = {"Watchtower", "Constellation", "Satellite"}
|
|
683
751
|
if not request.user.is_authenticated and role_name not in allow_anonymous_roles:
|
|
684
752
|
return redirect_to_login(
|
|
685
753
|
request.get_full_path(), login_url=reverse("pages:login")
|
|
686
754
|
)
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
755
|
+
is_watchtower = role_name in {"Watchtower", "Constellation"}
|
|
756
|
+
visible_chargers = (
|
|
757
|
+
_visible_chargers(request.user)
|
|
758
|
+
.select_related("location")
|
|
759
|
+
.order_by("charger_id", "connector_id")
|
|
760
|
+
)
|
|
761
|
+
stats_cache: dict[int, dict[str, float]] = {}
|
|
762
|
+
|
|
763
|
+
def _charger_display_name(charger: Charger) -> str:
|
|
764
|
+
if charger.display_name:
|
|
765
|
+
return charger.display_name
|
|
766
|
+
if charger.location:
|
|
767
|
+
return charger.location.name
|
|
768
|
+
return charger.charger_id
|
|
769
|
+
|
|
770
|
+
today = timezone.localdate()
|
|
771
|
+
tz = timezone.get_current_timezone()
|
|
772
|
+
day_start = datetime.combine(today, time.min)
|
|
773
|
+
if timezone.is_naive(day_start):
|
|
774
|
+
day_start = timezone.make_aware(day_start, tz)
|
|
775
|
+
day_end = day_start + timedelta(days=1)
|
|
776
|
+
|
|
777
|
+
def _charger_stats(charger: Charger) -> dict[str, float]:
|
|
778
|
+
cache_key = charger.pk or id(charger)
|
|
779
|
+
if cache_key not in stats_cache:
|
|
780
|
+
stats_cache[cache_key] = {
|
|
781
|
+
"total_kw": charger.total_kw,
|
|
782
|
+
"today_kw": charger.total_kw_for_range(day_start, day_end),
|
|
783
|
+
}
|
|
784
|
+
return stats_cache[cache_key]
|
|
785
|
+
|
|
786
|
+
def _status_url(charger: Charger) -> str:
|
|
787
|
+
return _reverse_connector_url(
|
|
788
|
+
"charger-status",
|
|
789
|
+
charger.charger_id,
|
|
790
|
+
charger.connector_slug,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
chargers: list[dict[str, object]] = []
|
|
794
|
+
charger_groups: list[dict[str, object]] = []
|
|
795
|
+
group_lookup: dict[str, dict[str, object]] = {}
|
|
796
|
+
|
|
797
|
+
for charger in visible_chargers:
|
|
690
798
|
tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
|
|
691
799
|
if not tx_obj:
|
|
692
800
|
tx_obj = (
|
|
@@ -694,14 +802,55 @@ def dashboard(request):
|
|
|
694
802
|
.order_by("-start_time")
|
|
695
803
|
.first()
|
|
696
804
|
)
|
|
805
|
+
has_session = _has_active_session(tx_obj)
|
|
697
806
|
state, color = _charger_state(charger, tx_obj)
|
|
698
|
-
|
|
807
|
+
if (
|
|
808
|
+
charger.connector_id is not None
|
|
809
|
+
and not has_session
|
|
810
|
+
and (charger.last_status or "").strip().casefold() == "charging"
|
|
811
|
+
):
|
|
812
|
+
state, color = STATUS_BADGE_MAP["charging"]
|
|
813
|
+
entry = {
|
|
814
|
+
"charger": charger,
|
|
815
|
+
"state": state,
|
|
816
|
+
"color": color,
|
|
817
|
+
"display_name": _charger_display_name(charger),
|
|
818
|
+
"stats": _charger_stats(charger),
|
|
819
|
+
"status_url": _status_url(charger),
|
|
820
|
+
}
|
|
821
|
+
chargers.append(entry)
|
|
822
|
+
if charger.connector_id is None:
|
|
823
|
+
group = {"parent": entry, "children": []}
|
|
824
|
+
charger_groups.append(group)
|
|
825
|
+
group_lookup[charger.charger_id] = group
|
|
826
|
+
else:
|
|
827
|
+
group = group_lookup.get(charger.charger_id)
|
|
828
|
+
if group is None:
|
|
829
|
+
group = {"parent": None, "children": []}
|
|
830
|
+
charger_groups.append(group)
|
|
831
|
+
group_lookup[charger.charger_id] = group
|
|
832
|
+
group["children"].append(entry)
|
|
833
|
+
|
|
834
|
+
for group in charger_groups:
|
|
835
|
+
parent_entry = group.get("parent")
|
|
836
|
+
if not parent_entry or not group["children"]:
|
|
837
|
+
continue
|
|
838
|
+
connector_statuses = [
|
|
839
|
+
(child["charger"].last_status or "").strip().casefold()
|
|
840
|
+
for child in group["children"]
|
|
841
|
+
if child["charger"].connector_id is not None
|
|
842
|
+
]
|
|
843
|
+
if connector_statuses and all(status == "charging" for status in connector_statuses):
|
|
844
|
+
label, badge_color = STATUS_BADGE_MAP["charging"]
|
|
845
|
+
parent_entry["state"] = label
|
|
846
|
+
parent_entry["color"] = badge_color
|
|
699
847
|
scheme = "wss" if request.is_secure() else "ws"
|
|
700
848
|
host = request.get_host()
|
|
701
849
|
ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
|
|
702
850
|
context = {
|
|
703
851
|
"chargers": chargers,
|
|
704
|
-
"
|
|
852
|
+
"charger_groups": charger_groups,
|
|
853
|
+
"show_demo_notice": is_watchtower,
|
|
705
854
|
"demo_ws_url": ws_url,
|
|
706
855
|
"ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
|
|
707
856
|
}
|
|
@@ -1049,7 +1198,10 @@ def charger_status(request, cid, connector=None):
|
|
|
1049
1198
|
"connector_id": connector_id,
|
|
1050
1199
|
}
|
|
1051
1200
|
)
|
|
1052
|
-
|
|
1201
|
+
rfid_cache: dict[str, dict[str, str | None]] = {}
|
|
1202
|
+
overview = _connector_overview(
|
|
1203
|
+
charger, request.user, rfid_cache=rfid_cache
|
|
1204
|
+
)
|
|
1053
1205
|
connector_links = [
|
|
1054
1206
|
{
|
|
1055
1207
|
"slug": item["slug"],
|
|
@@ -1090,12 +1242,15 @@ def charger_status(request, cid, connector=None):
|
|
|
1090
1242
|
action_url = _reverse_connector_url("charger-action", cid, connector_slug)
|
|
1091
1243
|
chart_should_animate = bool(has_active_session and not past_session)
|
|
1092
1244
|
|
|
1245
|
+
tx_rfid_details = _transaction_rfid_details(tx_obj, cache=rfid_cache)
|
|
1246
|
+
|
|
1093
1247
|
return render(
|
|
1094
1248
|
request,
|
|
1095
1249
|
"ocpp/charger_status.html",
|
|
1096
1250
|
{
|
|
1097
1251
|
"charger": charger,
|
|
1098
1252
|
"tx": tx_obj,
|
|
1253
|
+
"tx_rfid_details": tx_rfid_details,
|
|
1099
1254
|
"state": state,
|
|
1100
1255
|
"color": color,
|
|
1101
1256
|
"transactions": transactions,
|