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.

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, Location, MeterValue, Transaction
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 sync_remote_chargers() -> int:
384
- """Synchronize remote charger metadata and transactions."""
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("Remote sync skipped: local node not registered")
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("Remote sync skipped: missing local node private key")
173
+ logger.warning("Forwarding skipped: missing local node private key")
394
174
  return 0
395
175
 
396
- chargers = (
397
- Charger.objects.filter(export_transactions=True)
398
- .exclude(node_origin__isnull=True)
399
- .select_related("node_origin")
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
- chargers = chargers.exclude(node_origin=local)
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
- origin = charger.node_origin
407
- if not origin:
192
+ target = charger.forwarded_to
193
+ if not target:
408
194
  continue
409
- grouped.setdefault(origin, []).append(charger)
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
- synced = 0
202
+ forwarded_total = 0
203
+
415
204
  for node, node_chargers in grouped.items():
416
- payload = {
417
- "requester": str(local.uuid),
418
- "include_transactions": True,
419
- "chargers": [],
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
- payload["chargers"].append(
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
- "since": charger.last_online_at.isoformat()
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 = f"http://{node.address}:{node.port}/nodes/network/chargers/"
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 sync chargers from %s: %s", node, exc)
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
- "Sync request to %s returned %s", node, response.status_code
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
- chargers_payload = data.get("chargers", [])
457
- for entry in chargers_payload:
458
- charger = _apply_remote_charger_payload(node, entry)
459
- if charger:
460
- synced += 1
461
-
462
- transactions_payload = data.get("transactions")
463
- if transactions_payload:
464
- imported = _sync_transactions_payload(transactions_payload)
465
- if imported:
466
- logger.info(
467
- "Imported %s transaction(s) from node %s", imported, node
468
- )
469
-
470
- return synced
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.META.get("HTTP_REFERER", "") or ""
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)