arthexis 0.1.24__py3-none-any.whl → 0.1.25__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.24.dist-info → arthexis-0.1.25.dist-info}/METADATA +35 -14
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/RECORD +30 -29
- config/settings.py +6 -3
- config/urls.py +2 -0
- core/admin.py +1 -186
- core/backends.py +3 -1
- core/models.py +74 -8
- core/system.py +67 -2
- core/views.py +0 -3
- nodes/admin.py +444 -251
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +211 -1
- nodes/urls.py +5 -0
- nodes/utils.py +9 -2
- nodes/views.py +128 -80
- ocpp/admin.py +190 -2
- ocpp/consumers.py +98 -0
- ocpp/models.py +271 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +108 -267
- ocpp/tests.py +179 -0
- ocpp/views.py +2 -0
- pages/middleware.py +3 -2
- pages/tests.py +40 -0
- pages/utils.py +70 -0
- pages/views.py +4 -2
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
ocpp/tasks.py
CHANGED
|
@@ -3,16 +3,14 @@ import json
|
|
|
3
3
|
import logging
|
|
4
4
|
import uuid
|
|
5
5
|
from datetime import date, datetime, time, timedelta
|
|
6
|
-
from decimal import Decimal, InvalidOperation
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
|
|
9
8
|
from asgiref.sync import async_to_sync
|
|
10
9
|
from celery import shared_task
|
|
11
10
|
from django.conf import settings
|
|
12
11
|
from django.contrib.auth import get_user_model
|
|
13
|
-
from django.utils import timezone
|
|
14
|
-
from django.utils.dateparse import parse_datetime
|
|
15
12
|
from django.db.models import Q
|
|
13
|
+
from django.utils import timezone
|
|
16
14
|
import requests
|
|
17
15
|
from requests import RequestException
|
|
18
16
|
from cryptography.hazmat.primitives import hashes
|
|
@@ -22,7 +20,12 @@ from core import mailer
|
|
|
22
20
|
from nodes.models import Node
|
|
23
21
|
|
|
24
22
|
from . import store
|
|
25
|
-
from .models import Charger,
|
|
23
|
+
from .models import Charger, MeterValue, Transaction
|
|
24
|
+
from .network import (
|
|
25
|
+
newest_transaction_timestamp,
|
|
26
|
+
serialize_charger_for_network,
|
|
27
|
+
serialize_transactions_for_forwarding,
|
|
28
|
+
)
|
|
26
29
|
|
|
27
30
|
logger = logging.getLogger(__name__)
|
|
28
31
|
|
|
@@ -41,229 +44,6 @@ def _sign_payload(payload_json: str, private_key) -> str | None:
|
|
|
41
44
|
return base64.b64encode(signature).decode()
|
|
42
45
|
|
|
43
46
|
|
|
44
|
-
def _parse_remote_datetime(value):
|
|
45
|
-
if not value:
|
|
46
|
-
return None
|
|
47
|
-
dt = parse_datetime(value)
|
|
48
|
-
if dt is None:
|
|
49
|
-
return None
|
|
50
|
-
if timezone.is_naive(dt):
|
|
51
|
-
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
|
52
|
-
return dt
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _to_decimal(value):
|
|
56
|
-
if value in (None, ""):
|
|
57
|
-
return None
|
|
58
|
-
try:
|
|
59
|
-
return Decimal(str(value))
|
|
60
|
-
except (InvalidOperation, TypeError, ValueError):
|
|
61
|
-
return None
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _apply_remote_charger_payload(node, payload: dict) -> Charger | None:
|
|
65
|
-
serial = Charger.normalize_serial(payload.get("charger_id"))
|
|
66
|
-
if not serial or Charger.is_placeholder_serial(serial):
|
|
67
|
-
return None
|
|
68
|
-
|
|
69
|
-
connector = payload.get("connector_id")
|
|
70
|
-
if connector in (None, ""):
|
|
71
|
-
connector_value = None
|
|
72
|
-
elif isinstance(connector, int):
|
|
73
|
-
connector_value = connector
|
|
74
|
-
else:
|
|
75
|
-
try:
|
|
76
|
-
connector_value = int(str(connector))
|
|
77
|
-
except (TypeError, ValueError):
|
|
78
|
-
connector_value = None
|
|
79
|
-
|
|
80
|
-
charger = Charger.objects.filter(
|
|
81
|
-
charger_id=serial, connector_id=connector_value
|
|
82
|
-
).first()
|
|
83
|
-
if not charger:
|
|
84
|
-
return None
|
|
85
|
-
|
|
86
|
-
location_payload = payload.get("location")
|
|
87
|
-
location_obj = None
|
|
88
|
-
if isinstance(location_payload, dict):
|
|
89
|
-
name = location_payload.get("name")
|
|
90
|
-
if name:
|
|
91
|
-
location_obj, _ = Location.objects.get_or_create(name=name)
|
|
92
|
-
for field in ("latitude", "longitude", "zone", "contract_type"):
|
|
93
|
-
setattr(location_obj, field, location_payload.get(field))
|
|
94
|
-
location_obj.save()
|
|
95
|
-
|
|
96
|
-
datetime_fields = [
|
|
97
|
-
"firmware_timestamp",
|
|
98
|
-
"last_heartbeat",
|
|
99
|
-
"availability_state_updated_at",
|
|
100
|
-
"availability_requested_at",
|
|
101
|
-
"availability_request_status_at",
|
|
102
|
-
"diagnostics_timestamp",
|
|
103
|
-
"last_status_timestamp",
|
|
104
|
-
]
|
|
105
|
-
|
|
106
|
-
updates: dict[str, object] = {
|
|
107
|
-
"node_origin": node,
|
|
108
|
-
"allow_remote": bool(payload.get("allow_remote", False)),
|
|
109
|
-
"export_transactions": bool(payload.get("export_transactions", False)),
|
|
110
|
-
"last_online_at": timezone.now(),
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
simple_fields = [
|
|
114
|
-
"display_name",
|
|
115
|
-
"language",
|
|
116
|
-
"public_display",
|
|
117
|
-
"require_rfid",
|
|
118
|
-
"firmware_status",
|
|
119
|
-
"firmware_status_info",
|
|
120
|
-
"last_status",
|
|
121
|
-
"last_error_code",
|
|
122
|
-
"last_status_vendor_info",
|
|
123
|
-
"availability_state",
|
|
124
|
-
"availability_requested_state",
|
|
125
|
-
"availability_request_status",
|
|
126
|
-
"availability_request_details",
|
|
127
|
-
"temperature",
|
|
128
|
-
"temperature_unit",
|
|
129
|
-
"diagnostics_status",
|
|
130
|
-
"diagnostics_location",
|
|
131
|
-
]
|
|
132
|
-
|
|
133
|
-
for field in simple_fields:
|
|
134
|
-
updates[field] = payload.get(field)
|
|
135
|
-
|
|
136
|
-
if location_obj is not None:
|
|
137
|
-
updates["location"] = location_obj
|
|
138
|
-
|
|
139
|
-
for field in datetime_fields:
|
|
140
|
-
updates[field] = _parse_remote_datetime(payload.get(field))
|
|
141
|
-
|
|
142
|
-
updates["last_meter_values"] = payload.get("last_meter_values") or {}
|
|
143
|
-
|
|
144
|
-
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
145
|
-
charger.refresh_from_db()
|
|
146
|
-
return charger
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def _sync_transactions_payload(payload: dict) -> int:
|
|
150
|
-
if not isinstance(payload, dict):
|
|
151
|
-
return 0
|
|
152
|
-
|
|
153
|
-
chargers_map: dict[tuple[str, int | None], Charger] = {}
|
|
154
|
-
for entry in payload.get("chargers", []):
|
|
155
|
-
serial = Charger.normalize_serial(entry.get("charger_id"))
|
|
156
|
-
if not serial or Charger.is_placeholder_serial(serial):
|
|
157
|
-
continue
|
|
158
|
-
connector = entry.get("connector_id")
|
|
159
|
-
if connector in (None, ""):
|
|
160
|
-
connector_value = None
|
|
161
|
-
elif isinstance(connector, int):
|
|
162
|
-
connector_value = connector
|
|
163
|
-
else:
|
|
164
|
-
try:
|
|
165
|
-
connector_value = int(str(connector))
|
|
166
|
-
except (TypeError, ValueError):
|
|
167
|
-
connector_value = None
|
|
168
|
-
charger = Charger.objects.filter(
|
|
169
|
-
charger_id=serial, connector_id=connector_value
|
|
170
|
-
).first()
|
|
171
|
-
if charger:
|
|
172
|
-
chargers_map[(serial, connector_value)] = charger
|
|
173
|
-
|
|
174
|
-
imported = 0
|
|
175
|
-
for tx in payload.get("transactions", []):
|
|
176
|
-
if not isinstance(tx, dict):
|
|
177
|
-
continue
|
|
178
|
-
serial = Charger.normalize_serial(tx.get("charger"))
|
|
179
|
-
if not serial:
|
|
180
|
-
continue
|
|
181
|
-
connector = tx.get("connector_id")
|
|
182
|
-
if connector in (None, ""):
|
|
183
|
-
connector_value = None
|
|
184
|
-
elif isinstance(connector, int):
|
|
185
|
-
connector_value = connector
|
|
186
|
-
else:
|
|
187
|
-
try:
|
|
188
|
-
connector_value = int(str(connector))
|
|
189
|
-
except (TypeError, ValueError):
|
|
190
|
-
connector_value = None
|
|
191
|
-
|
|
192
|
-
charger = chargers_map.get((serial, connector_value))
|
|
193
|
-
if charger is None:
|
|
194
|
-
charger = chargers_map.get((serial, None))
|
|
195
|
-
if charger is None:
|
|
196
|
-
continue
|
|
197
|
-
|
|
198
|
-
start_time = _parse_remote_datetime(tx.get("start_time"))
|
|
199
|
-
if start_time is None:
|
|
200
|
-
continue
|
|
201
|
-
|
|
202
|
-
defaults = {
|
|
203
|
-
"connector_id": connector_value,
|
|
204
|
-
"account_id": tx.get("account"),
|
|
205
|
-
"rfid": tx.get("rfid", ""),
|
|
206
|
-
"vid": tx.get("vid", ""),
|
|
207
|
-
"vin": tx.get("vin", ""),
|
|
208
|
-
"meter_start": tx.get("meter_start"),
|
|
209
|
-
"meter_stop": tx.get("meter_stop"),
|
|
210
|
-
"voltage_start": _to_decimal(tx.get("voltage_start")),
|
|
211
|
-
"voltage_stop": _to_decimal(tx.get("voltage_stop")),
|
|
212
|
-
"current_import_start": _to_decimal(tx.get("current_import_start")),
|
|
213
|
-
"current_import_stop": _to_decimal(tx.get("current_import_stop")),
|
|
214
|
-
"current_offered_start": _to_decimal(tx.get("current_offered_start")),
|
|
215
|
-
"current_offered_stop": _to_decimal(tx.get("current_offered_stop")),
|
|
216
|
-
"temperature_start": _to_decimal(tx.get("temperature_start")),
|
|
217
|
-
"temperature_stop": _to_decimal(tx.get("temperature_stop")),
|
|
218
|
-
"soc_start": _to_decimal(tx.get("soc_start")),
|
|
219
|
-
"soc_stop": _to_decimal(tx.get("soc_stop")),
|
|
220
|
-
"stop_time": _parse_remote_datetime(tx.get("stop_time")),
|
|
221
|
-
"received_start_time": _parse_remote_datetime(
|
|
222
|
-
tx.get("received_start_time")
|
|
223
|
-
),
|
|
224
|
-
"received_stop_time": _parse_remote_datetime(
|
|
225
|
-
tx.get("received_stop_time")
|
|
226
|
-
),
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
transaction, _ = Transaction.objects.update_or_create(
|
|
230
|
-
charger=charger,
|
|
231
|
-
start_time=start_time,
|
|
232
|
-
defaults=defaults,
|
|
233
|
-
)
|
|
234
|
-
transaction.meter_values.all().delete()
|
|
235
|
-
for mv in tx.get("meter_values", []):
|
|
236
|
-
if not isinstance(mv, dict):
|
|
237
|
-
continue
|
|
238
|
-
timestamp = _parse_remote_datetime(mv.get("timestamp"))
|
|
239
|
-
if timestamp is None:
|
|
240
|
-
continue
|
|
241
|
-
connector_mv = mv.get("connector_id")
|
|
242
|
-
if connector_mv in (None, ""):
|
|
243
|
-
connector_mv = None
|
|
244
|
-
elif isinstance(connector_mv, str):
|
|
245
|
-
try:
|
|
246
|
-
connector_mv = int(connector_mv)
|
|
247
|
-
except (TypeError, ValueError):
|
|
248
|
-
connector_mv = None
|
|
249
|
-
MeterValue.objects.create(
|
|
250
|
-
charger=charger,
|
|
251
|
-
transaction=transaction,
|
|
252
|
-
connector_id=connector_mv,
|
|
253
|
-
timestamp=timestamp,
|
|
254
|
-
context=mv.get("context", ""),
|
|
255
|
-
energy=_to_decimal(mv.get("energy")),
|
|
256
|
-
voltage=_to_decimal(mv.get("voltage")),
|
|
257
|
-
current_import=_to_decimal(mv.get("current_import")),
|
|
258
|
-
current_offered=_to_decimal(mv.get("current_offered")),
|
|
259
|
-
temperature=_to_decimal(mv.get("temperature")),
|
|
260
|
-
soc=_to_decimal(mv.get("soc")),
|
|
261
|
-
)
|
|
262
|
-
imported += 1
|
|
263
|
-
|
|
264
|
-
return imported
|
|
265
|
-
|
|
266
|
-
|
|
267
47
|
@shared_task
|
|
268
48
|
def check_charge_point_configuration(charger_pk: int) -> bool:
|
|
269
49
|
"""Request the latest configuration from a connected charge point."""
|
|
@@ -380,70 +160,118 @@ purge_meter_readings = purge_meter_values
|
|
|
380
160
|
|
|
381
161
|
|
|
382
162
|
@shared_task
|
|
383
|
-
def
|
|
384
|
-
"""
|
|
163
|
+
def push_forwarded_charge_points() -> int:
|
|
164
|
+
"""Push local charge point sessions to configured upstream nodes."""
|
|
385
165
|
|
|
386
166
|
local = Node.get_local()
|
|
387
167
|
if not local:
|
|
388
|
-
logger.debug("
|
|
168
|
+
logger.debug("Forwarding skipped: local node not registered")
|
|
389
169
|
return 0
|
|
390
170
|
|
|
391
171
|
private_key = local.get_private_key()
|
|
392
172
|
if private_key is None:
|
|
393
|
-
logger.warning("
|
|
173
|
+
logger.warning("Forwarding skipped: missing local node private key")
|
|
394
174
|
return 0
|
|
395
175
|
|
|
396
|
-
|
|
397
|
-
Charger.objects.filter(export_transactions=True)
|
|
398
|
-
.
|
|
399
|
-
.
|
|
176
|
+
chargers_qs = (
|
|
177
|
+
Charger.objects.filter(export_transactions=True, forwarded_to__isnull=False)
|
|
178
|
+
.select_related("forwarded_to", "node_origin")
|
|
179
|
+
.order_by("pk")
|
|
400
180
|
)
|
|
181
|
+
|
|
182
|
+
node_filter = Q(node_origin__isnull=True)
|
|
401
183
|
if local.pk:
|
|
402
|
-
|
|
184
|
+
node_filter |= Q(node_origin=local)
|
|
185
|
+
|
|
186
|
+
chargers = list(chargers_qs.filter(node_filter))
|
|
187
|
+
if not chargers:
|
|
188
|
+
return 0
|
|
403
189
|
|
|
404
190
|
grouped: dict[Node, list[Charger]] = {}
|
|
405
191
|
for charger in chargers:
|
|
406
|
-
|
|
407
|
-
if not
|
|
192
|
+
target = charger.forwarded_to
|
|
193
|
+
if not target:
|
|
408
194
|
continue
|
|
409
|
-
|
|
195
|
+
if local.pk and target.pk == local.pk:
|
|
196
|
+
continue
|
|
197
|
+
grouped.setdefault(target, []).append(charger)
|
|
410
198
|
|
|
411
199
|
if not grouped:
|
|
412
200
|
return 0
|
|
413
201
|
|
|
414
|
-
|
|
202
|
+
forwarded_total = 0
|
|
203
|
+
|
|
415
204
|
for node, node_chargers in grouped.items():
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
205
|
+
if not node_chargers:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
initializing = [ch for ch in node_chargers if ch.forwarding_watermark is None]
|
|
209
|
+
charger_by_pk = {ch.pk: ch for ch in node_chargers}
|
|
210
|
+
transactions_map: dict[int, list[Transaction]] = {}
|
|
211
|
+
|
|
421
212
|
for charger in node_chargers:
|
|
422
|
-
|
|
213
|
+
watermark = charger.forwarding_watermark
|
|
214
|
+
if watermark is None:
|
|
215
|
+
continue
|
|
216
|
+
tx_queryset = (
|
|
217
|
+
Transaction.objects.filter(charger=charger, start_time__gt=watermark)
|
|
218
|
+
.select_related("charger")
|
|
219
|
+
.prefetch_related("meter_values")
|
|
220
|
+
.order_by("start_time")
|
|
221
|
+
)
|
|
222
|
+
txs = list(tx_queryset)
|
|
223
|
+
if txs:
|
|
224
|
+
transactions_map[charger.pk] = txs
|
|
225
|
+
|
|
226
|
+
transaction_payload = {"chargers": [], "transactions": []}
|
|
227
|
+
for charger_pk, txs in transactions_map.items():
|
|
228
|
+
charger = charger_by_pk[charger_pk]
|
|
229
|
+
transaction_payload["chargers"].append(
|
|
423
230
|
{
|
|
424
231
|
"charger_id": charger.charger_id,
|
|
425
232
|
"connector_id": charger.connector_id,
|
|
426
|
-
"
|
|
427
|
-
if charger.last_online_at
|
|
428
|
-
else None,
|
|
233
|
+
"require_rfid": charger.require_rfid,
|
|
429
234
|
}
|
|
430
235
|
)
|
|
236
|
+
transaction_payload["transactions"].extend(
|
|
237
|
+
serialize_transactions_for_forwarding(txs)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
payload = {
|
|
241
|
+
"requester": str(local.uuid),
|
|
242
|
+
"requester_mac": local.mac_address,
|
|
243
|
+
"requester_public_key": local.public_key,
|
|
244
|
+
"chargers": [serialize_charger_for_network(ch) for ch in initializing],
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
has_transactions = bool(transaction_payload["transactions"])
|
|
248
|
+
if has_transactions or payload["chargers"]:
|
|
249
|
+
payload["transactions"] = transaction_payload
|
|
250
|
+
else:
|
|
251
|
+
continue
|
|
252
|
+
|
|
431
253
|
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
432
254
|
signature = _sign_payload(payload_json, private_key)
|
|
433
255
|
headers = {"Content-Type": "application/json"}
|
|
434
256
|
if signature:
|
|
435
257
|
headers["X-Signature"] = signature
|
|
436
258
|
|
|
437
|
-
url =
|
|
259
|
+
url = next(node.iter_remote_urls("/nodes/network/chargers/forward/"), "")
|
|
260
|
+
if not url:
|
|
261
|
+
logger.warning(
|
|
262
|
+
"No reachable host found for %s when forwarding chargers", node
|
|
263
|
+
)
|
|
264
|
+
continue
|
|
265
|
+
|
|
438
266
|
try:
|
|
439
267
|
response = requests.post(url, data=payload_json, headers=headers, timeout=5)
|
|
440
268
|
except RequestException as exc:
|
|
441
|
-
logger.warning("Failed to
|
|
269
|
+
logger.warning("Failed to forward chargers to %s: %s", node, exc)
|
|
442
270
|
continue
|
|
443
271
|
|
|
444
272
|
if not response.ok:
|
|
445
273
|
logger.warning(
|
|
446
|
-
"
|
|
274
|
+
"Forwarding request to %s returned %s", node, response.status_code
|
|
447
275
|
)
|
|
448
276
|
continue
|
|
449
277
|
|
|
@@ -453,21 +281,34 @@ def sync_remote_chargers() -> int:
|
|
|
453
281
|
logger.warning("Invalid JSON payload received from %s", node)
|
|
454
282
|
continue
|
|
455
283
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
284
|
+
if data.get("status") != "ok":
|
|
285
|
+
detail = data.get("detail") if isinstance(data, dict) else None
|
|
286
|
+
logger.warning(
|
|
287
|
+
"Forwarding rejected by %s: %s",
|
|
288
|
+
node,
|
|
289
|
+
detail or response.text or "Remote node rejected the request.",
|
|
290
|
+
)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
updates: dict[int, datetime] = {}
|
|
294
|
+
now = timezone.now()
|
|
295
|
+
for charger in initializing:
|
|
296
|
+
updates[charger.pk] = now
|
|
297
|
+
for charger_pk, txs in transactions_map.items():
|
|
298
|
+
latest = newest_transaction_timestamp(txs)
|
|
299
|
+
if latest:
|
|
300
|
+
updates[charger_pk] = latest
|
|
301
|
+
|
|
302
|
+
for pk, timestamp in updates.items():
|
|
303
|
+
Charger.objects.filter(pk=pk).update(forwarding_watermark=timestamp)
|
|
304
|
+
|
|
305
|
+
forwarded_total += len(transaction_payload["transactions"])
|
|
306
|
+
|
|
307
|
+
return forwarded_total
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# Backwards compatibility alias for legacy schedules
|
|
311
|
+
sync_remote_chargers = push_forwarded_charge_points
|
|
471
312
|
|
|
472
313
|
|
|
473
314
|
def _resolve_report_window() -> tuple[datetime, datetime, date]:
|
ocpp/tests.py
CHANGED
|
@@ -67,6 +67,7 @@ from .models import (
|
|
|
67
67
|
MeterReading,
|
|
68
68
|
Location,
|
|
69
69
|
DataTransferMessage,
|
|
70
|
+
CPReservation,
|
|
70
71
|
)
|
|
71
72
|
from .admin import ChargerConfigurationAdmin
|
|
72
73
|
from .consumers import CSMSConsumer
|
|
@@ -304,6 +305,184 @@ class ChargerRefreshManagerNodeTests(TestCase):
|
|
|
304
305
|
self.assertEqual(charger.manager_node, remote)
|
|
305
306
|
|
|
306
307
|
|
|
308
|
+
class CPReservationTests(TransactionTestCase):
|
|
309
|
+
def setUp(self):
|
|
310
|
+
self.location = Location.objects.create(name="Reservation Site")
|
|
311
|
+
self.aggregate = Charger.objects.create(charger_id="RSV100", location=self.location)
|
|
312
|
+
self.connector_one = Charger.objects.create(
|
|
313
|
+
charger_id="RSV100", connector_id=1, location=self.location
|
|
314
|
+
)
|
|
315
|
+
self.connector_two = Charger.objects.create(
|
|
316
|
+
charger_id="RSV100", connector_id=2, location=self.location
|
|
317
|
+
)
|
|
318
|
+
self.addCleanup(store.clear_pending_calls, "RSV100")
|
|
319
|
+
|
|
320
|
+
def test_allocates_preferred_connector(self):
|
|
321
|
+
start = timezone.now() + timedelta(hours=1)
|
|
322
|
+
reservation = CPReservation(
|
|
323
|
+
location=self.location,
|
|
324
|
+
start_time=start,
|
|
325
|
+
duration_minutes=90,
|
|
326
|
+
id_tag="TAG001",
|
|
327
|
+
)
|
|
328
|
+
reservation.save()
|
|
329
|
+
self.assertEqual(reservation.connector, self.connector_two)
|
|
330
|
+
|
|
331
|
+
def test_allocation_falls_back_and_blocks_overlaps(self):
|
|
332
|
+
start = timezone.now() + timedelta(hours=1)
|
|
333
|
+
first = CPReservation.objects.create(
|
|
334
|
+
location=self.location,
|
|
335
|
+
start_time=start,
|
|
336
|
+
duration_minutes=60,
|
|
337
|
+
id_tag="TAG002",
|
|
338
|
+
)
|
|
339
|
+
self.assertEqual(first.connector, self.connector_two)
|
|
340
|
+
second = CPReservation(
|
|
341
|
+
location=self.location,
|
|
342
|
+
start_time=start + timedelta(minutes=15),
|
|
343
|
+
duration_minutes=60,
|
|
344
|
+
id_tag="TAG003",
|
|
345
|
+
)
|
|
346
|
+
second.save()
|
|
347
|
+
self.assertEqual(second.connector, self.connector_one)
|
|
348
|
+
third = CPReservation(
|
|
349
|
+
location=self.location,
|
|
350
|
+
start_time=start + timedelta(minutes=30),
|
|
351
|
+
duration_minutes=45,
|
|
352
|
+
id_tag="TAG004",
|
|
353
|
+
)
|
|
354
|
+
with self.assertRaises(ValidationError):
|
|
355
|
+
third.save()
|
|
356
|
+
|
|
357
|
+
def test_send_reservation_request_dispatches_frame(self):
|
|
358
|
+
start = timezone.now() + timedelta(hours=1)
|
|
359
|
+
reservation = CPReservation.objects.create(
|
|
360
|
+
location=self.location,
|
|
361
|
+
start_time=start,
|
|
362
|
+
duration_minutes=30,
|
|
363
|
+
id_tag="TAG005",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
class DummyConnection:
|
|
367
|
+
def __init__(self):
|
|
368
|
+
self.sent: list[str] = []
|
|
369
|
+
|
|
370
|
+
async def send(self, message):
|
|
371
|
+
self.sent.append(message)
|
|
372
|
+
|
|
373
|
+
ws = DummyConnection()
|
|
374
|
+
store.set_connection(
|
|
375
|
+
reservation.connector.charger_id,
|
|
376
|
+
reservation.connector.connector_id,
|
|
377
|
+
ws,
|
|
378
|
+
)
|
|
379
|
+
self.addCleanup(
|
|
380
|
+
store.pop_connection,
|
|
381
|
+
reservation.connector.charger_id,
|
|
382
|
+
reservation.connector.connector_id,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
message_id = reservation.send_reservation_request()
|
|
386
|
+
self.assertTrue(ws.sent)
|
|
387
|
+
frame = json.loads(ws.sent[0])
|
|
388
|
+
self.assertEqual(frame[0], 2)
|
|
389
|
+
self.assertEqual(frame[2], "ReserveNow")
|
|
390
|
+
self.assertEqual(frame[3]["reservationId"], reservation.pk)
|
|
391
|
+
self.assertEqual(frame[3]["connectorId"], reservation.connector.connector_id)
|
|
392
|
+
self.assertEqual(frame[3]["idTag"], "TAG005")
|
|
393
|
+
metadata = store.pending_calls.get(message_id)
|
|
394
|
+
self.assertIsNotNone(metadata)
|
|
395
|
+
self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
|
|
396
|
+
|
|
397
|
+
def test_call_result_marks_reservation_confirmed(self):
|
|
398
|
+
start = timezone.now() + timedelta(hours=1)
|
|
399
|
+
reservation = CPReservation.objects.create(
|
|
400
|
+
location=self.location,
|
|
401
|
+
start_time=start,
|
|
402
|
+
duration_minutes=45,
|
|
403
|
+
id_tag="TAG006",
|
|
404
|
+
)
|
|
405
|
+
log_key = store.identity_key(
|
|
406
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
407
|
+
)
|
|
408
|
+
message_id = "reserve-success"
|
|
409
|
+
store.register_pending_call(
|
|
410
|
+
message_id,
|
|
411
|
+
{
|
|
412
|
+
"action": "ReserveNow",
|
|
413
|
+
"charger_id": reservation.connector.charger_id,
|
|
414
|
+
"connector_id": reservation.connector.connector_id,
|
|
415
|
+
"log_key": log_key,
|
|
416
|
+
"reservation_pk": reservation.pk,
|
|
417
|
+
},
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
consumer = CSMSConsumer()
|
|
421
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
422
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
423
|
+
consumer.store_key = log_key
|
|
424
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
425
|
+
consumer.charger = reservation.connector
|
|
426
|
+
consumer.aggregate_charger = self.aggregate
|
|
427
|
+
consumer._consumption_task = None
|
|
428
|
+
consumer._consumption_message_uuid = None
|
|
429
|
+
consumer.send = AsyncMock()
|
|
430
|
+
|
|
431
|
+
async_to_sync(consumer._handle_call_result)(
|
|
432
|
+
message_id, {"status": "Accepted"}
|
|
433
|
+
)
|
|
434
|
+
reservation.refresh_from_db()
|
|
435
|
+
self.assertTrue(reservation.evcs_confirmed)
|
|
436
|
+
self.assertEqual(reservation.evcs_status, "Accepted")
|
|
437
|
+
self.assertIsNotNone(reservation.evcs_confirmed_at)
|
|
438
|
+
|
|
439
|
+
def test_call_error_updates_reservation_status(self):
|
|
440
|
+
start = timezone.now() + timedelta(hours=1)
|
|
441
|
+
reservation = CPReservation.objects.create(
|
|
442
|
+
location=self.location,
|
|
443
|
+
start_time=start,
|
|
444
|
+
duration_minutes=45,
|
|
445
|
+
id_tag="TAG007",
|
|
446
|
+
)
|
|
447
|
+
log_key = store.identity_key(
|
|
448
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
449
|
+
)
|
|
450
|
+
message_id = "reserve-error"
|
|
451
|
+
store.register_pending_call(
|
|
452
|
+
message_id,
|
|
453
|
+
{
|
|
454
|
+
"action": "ReserveNow",
|
|
455
|
+
"charger_id": reservation.connector.charger_id,
|
|
456
|
+
"connector_id": reservation.connector.connector_id,
|
|
457
|
+
"log_key": log_key,
|
|
458
|
+
"reservation_pk": reservation.pk,
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
consumer = CSMSConsumer()
|
|
463
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
464
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
465
|
+
consumer.store_key = log_key
|
|
466
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
467
|
+
consumer.charger = reservation.connector
|
|
468
|
+
consumer.aggregate_charger = self.aggregate
|
|
469
|
+
consumer._consumption_task = None
|
|
470
|
+
consumer._consumption_message_uuid = None
|
|
471
|
+
consumer.send = AsyncMock()
|
|
472
|
+
|
|
473
|
+
async_to_sync(consumer._handle_call_error)(
|
|
474
|
+
message_id,
|
|
475
|
+
"Rejected",
|
|
476
|
+
"Charger unavailable",
|
|
477
|
+
{"reason": "maintenance"},
|
|
478
|
+
)
|
|
479
|
+
reservation.refresh_from_db()
|
|
480
|
+
self.assertFalse(reservation.evcs_confirmed)
|
|
481
|
+
self.assertEqual(reservation.evcs_status, "")
|
|
482
|
+
self.assertIsNone(reservation.evcs_confirmed_at)
|
|
483
|
+
self.assertIn("Rejected", reservation.evcs_error or "")
|
|
484
|
+
|
|
485
|
+
|
|
307
486
|
class ChargerUrlFallbackTests(TestCase):
|
|
308
487
|
@override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
|
|
309
488
|
def test_reference_created_when_site_missing(self):
|
ocpp/views.py
CHANGED
|
@@ -47,6 +47,7 @@ CALL_ACTION_LABELS = {
|
|
|
47
47
|
"DataTransfer": _("Data transfer"),
|
|
48
48
|
"Reset": _("Reset"),
|
|
49
49
|
"TriggerMessage": _("Trigger message"),
|
|
50
|
+
"ReserveNow": _("Reserve connector"),
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
CALL_EXPECTED_STATUSES: dict[str, set[str]] = {
|
|
@@ -56,6 +57,7 @@ CALL_EXPECTED_STATUSES: dict[str, set[str]] = {
|
|
|
56
57
|
"DataTransfer": {"Accepted"},
|
|
57
58
|
"Reset": {"Accepted"},
|
|
58
59
|
"TriggerMessage": {"Accepted"},
|
|
60
|
+
"ReserveNow": {"Accepted"},
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
|
pages/middleware.py
CHANGED
|
@@ -10,7 +10,7 @@ from django.conf import settings
|
|
|
10
10
|
from django.urls import Resolver404, resolve
|
|
11
11
|
|
|
12
12
|
from .models import Landing, LandingLead, ViewHistory
|
|
13
|
-
from .utils import landing_leads_supported
|
|
13
|
+
from .utils import cache_original_referer, get_original_referer, landing_leads_supported
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
logger = logging.getLogger(__name__)
|
|
@@ -30,6 +30,7 @@ class ViewHistoryMiddleware:
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
def __call__(self, request):
|
|
33
|
+
cache_original_referer(request)
|
|
33
34
|
should_track = self._should_track(request)
|
|
34
35
|
if not should_track:
|
|
35
36
|
return self.get_response(request)
|
|
@@ -132,7 +133,7 @@ class ViewHistoryMiddleware:
|
|
|
132
133
|
if not landing_leads_supported():
|
|
133
134
|
return
|
|
134
135
|
|
|
135
|
-
referer = request
|
|
136
|
+
referer = get_original_referer(request)
|
|
136
137
|
user_agent = request.META.get("HTTP_USER_AGENT", "") or ""
|
|
137
138
|
ip_address = self._extract_client_ip(request) or None
|
|
138
139
|
user = getattr(request, "user", None)
|