arthexis 0.1.23__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/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)
pages/tests.py CHANGED
@@ -515,6 +515,23 @@ class InvitationTests(TestCase):
515
515
  self.assertEqual(lead.mac_address, "")
516
516
  self.assertEqual(len(mail.outbox), 0)
517
517
 
518
+ def test_request_invite_uses_original_referer(self):
519
+ InviteLead.objects.all().delete()
520
+ self.client.get(
521
+ reverse("pages:index"),
522
+ HTTP_REFERER="https://campaign.example/landing",
523
+ )
524
+
525
+ resp = self.client.post(
526
+ reverse("pages:request-invite"),
527
+ {"email": "origin@example.com"},
528
+ HTTP_REFERER="http://testserver/pages/request-invite/",
529
+ )
530
+
531
+ self.assertEqual(resp.status_code, 200)
532
+ lead = InviteLead.objects.get()
533
+ self.assertEqual(lead.referer, "https://campaign.example/landing")
534
+
518
535
  def test_request_invite_falls_back_to_send_mail(self):
519
536
  node = Node.objects.create(
520
537
  hostname="local", address="127.0.0.1", mac_address="00:11:22:33:44:55"
@@ -3531,6 +3548,28 @@ class UserStorySubmissionTests(TestCase):
3531
3548
  story = UserStory.objects.get()
3532
3549
  self.assertEqual(story.language_code, "es")
3533
3550
 
3551
+ def test_submission_prefers_original_referer(self):
3552
+ self.client.get(
3553
+ reverse("pages:index"),
3554
+ HTTP_REFERER="https://ads.example/original",
3555
+ )
3556
+ response = self.client.post(
3557
+ self.url,
3558
+ {
3559
+ "rating": 3,
3560
+ "comments": "Works well",
3561
+ "path": "/wizard/step-2/",
3562
+ "name": "visitor@example.com",
3563
+ "take_screenshot": "0",
3564
+ },
3565
+ HTTP_REFERER="http://testserver/wizard/step-2/",
3566
+ HTTP_USER_AGENT="FeedbackBot/2.0",
3567
+ )
3568
+
3569
+ self.assertEqual(response.status_code, 200)
3570
+ story = UserStory.objects.get()
3571
+ self.assertEqual(story.referer, "https://ads.example/original")
3572
+
3534
3573
  def test_superuser_submission_creates_triage_todo(self):
3535
3574
  Todo.objects.all().delete()
3536
3575
  superuser = get_user_model().objects.create_superuser(
@@ -3591,6 +3630,7 @@ class UserStorySubmissionTests(TestCase):
3591
3630
  screenshot_file,
3592
3631
  method="USER_STORY",
3593
3632
  user=self.user,
3633
+ link_duplicates=True,
3594
3634
  )
3595
3635
 
3596
3636
  def test_anonymous_submission_uses_provided_email(self):
pages/utils.py CHANGED
@@ -1,6 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import urlsplit
4
+
5
+ from django.core.exceptions import DisallowedHost
6
+ from django.http.request import split_domain_port
1
7
  from django.urls import path as django_path
2
8
 
3
9
 
10
+ ORIGINAL_REFERER_SESSION_KEY = "pages:original_referer"
11
+
12
+
4
13
  def landing(label=None):
5
14
  """Decorator to mark a view as a landing page."""
6
15
 
@@ -12,6 +21,67 @@ def landing(label=None):
12
21
  return decorator
13
22
 
14
23
 
24
+ def cache_original_referer(request) -> None:
25
+ """Persist the first external referer observed for the session."""
26
+
27
+ session = getattr(request, "session", None)
28
+ if not hasattr(session, "get"):
29
+ return
30
+
31
+ original = session.get(ORIGINAL_REFERER_SESSION_KEY)
32
+ if original:
33
+ request.original_referer = original
34
+ return
35
+
36
+ referer = (request.META.get("HTTP_REFERER") or "").strip()
37
+ if not referer:
38
+ return
39
+
40
+ try:
41
+ parsed = urlsplit(referer)
42
+ except ValueError:
43
+ return
44
+
45
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
46
+ return
47
+
48
+ try:
49
+ host = request.get_host()
50
+ except DisallowedHost:
51
+ host = ""
52
+
53
+ referer_host, _ = split_domain_port(parsed.netloc)
54
+ request_host, _ = split_domain_port(host)
55
+
56
+ if referer_host and request_host:
57
+ if referer_host.lower() == request_host.lower():
58
+ return
59
+
60
+ referer_value = referer[:1000]
61
+ session[ORIGINAL_REFERER_SESSION_KEY] = referer_value
62
+ request.original_referer = referer_value
63
+
64
+
65
+ def get_original_referer(request) -> str:
66
+ """Return the original external referer recorded for the session."""
67
+
68
+ if hasattr(request, "original_referer"):
69
+ return request.original_referer or ""
70
+
71
+ session = getattr(request, "session", None)
72
+ if hasattr(session, "get"):
73
+ referer = session.get(ORIGINAL_REFERER_SESSION_KEY)
74
+ if referer:
75
+ request.original_referer = referer
76
+ return referer
77
+
78
+ referer = (request.META.get("HTTP_REFERER") or "").strip()
79
+ if referer:
80
+ referer = referer[:1000]
81
+ request.original_referer = referer
82
+ return referer
83
+
84
+
15
85
  def landing_leads_supported() -> bool:
16
86
  """Return ``True`` when the local node supports landing lead tracking."""
17
87
 
pages/views.py CHANGED
@@ -66,6 +66,8 @@ from core.models import (
66
66
  SecurityGroup,
67
67
  Todo,
68
68
  )
69
+ from ocpp.models import Charger
70
+ from .utils import get_original_referer
69
71
 
70
72
  try: # pragma: no cover - optional dependency guard
71
73
  from graphviz import Digraph
@@ -1113,7 +1115,7 @@ def request_invite(request):
1113
1115
  comment=comment,
1114
1116
  user=request.user if request.user.is_authenticated else None,
1115
1117
  path=request.path,
1116
- referer=request.META.get("HTTP_REFERER", ""),
1118
+ referer=get_original_referer(request),
1117
1119
  user_agent=request.META.get("HTTP_USER_AGENT", ""),
1118
1120
  ip_address=ip_address,
1119
1121
  mac_address=mac_address or "",
@@ -1269,6 +1271,25 @@ class ClientReportForm(forms.Form):
1269
1271
  input_formats=["%Y-%m"],
1270
1272
  help_text=_("Generates the report for the calendar month that you select."),
1271
1273
  )
1274
+ language = forms.ChoiceField(
1275
+ label=_("Report language"),
1276
+ choices=settings.LANGUAGES,
1277
+ help_text=_("Choose the language used for the generated report."),
1278
+ )
1279
+ title = forms.CharField(
1280
+ label=_("Report title"),
1281
+ required=False,
1282
+ max_length=200,
1283
+ help_text=_("Optional heading that replaces the default report title."),
1284
+ )
1285
+ chargers = forms.ModelMultipleChoiceField(
1286
+ label=_("Charge points"),
1287
+ queryset=Charger.objects.filter(connector_id__isnull=True)
1288
+ .order_by("display_name", "charger_id"),
1289
+ required=False,
1290
+ widget=forms.CheckboxSelectMultiple,
1291
+ help_text=_("Choose which charge points are included in the report."),
1292
+ )
1272
1293
  owner = forms.ModelChoiceField(
1273
1294
  queryset=get_user_model().objects.all(),
1274
1295
  required=False,
@@ -1299,6 +1320,13 @@ class ClientReportForm(forms.Form):
1299
1320
  super().__init__(*args, **kwargs)
1300
1321
  if request and getattr(request, "user", None) and request.user.is_authenticated:
1301
1322
  self.fields["owner"].initial = request.user.pk
1323
+ self.fields["chargers"].widget.attrs["class"] = "charger-options"
1324
+ language_initial = ClientReport.default_language()
1325
+ if request:
1326
+ language_initial = ClientReport.normalize_language(
1327
+ getattr(request, "LANGUAGE_CODE", language_initial)
1328
+ )
1329
+ self.fields["language"].initial = language_initial
1302
1330
 
1303
1331
  def clean(self):
1304
1332
  cleaned = super().clean()
@@ -1348,6 +1376,10 @@ class ClientReportForm(forms.Form):
1348
1376
  emails.append(candidate)
1349
1377
  return emails
1350
1378
 
1379
+ def clean_title(self):
1380
+ title = self.cleaned_data.get("title")
1381
+ return ClientReport.normalize_title(title)
1382
+
1351
1383
 
1352
1384
  @live_update()
1353
1385
  def client_report(request):
@@ -1358,7 +1390,7 @@ def client_report(request):
1358
1390
  if not request.user.is_authenticated:
1359
1391
  form.is_valid() # Run validation to surface field errors alongside auth error.
1360
1392
  form.add_error(
1361
- None, _("You must log in to generate client reports."),
1393
+ None, _("You must log in to generate consumer reports."),
1362
1394
  )
1363
1395
  elif form.is_valid():
1364
1396
  throttle_seconds = getattr(settings, "CLIENT_REPORT_THROTTLE_SECONDS", 60)
@@ -1387,7 +1419,7 @@ def client_report(request):
1387
1419
  form.add_error(
1388
1420
  None,
1389
1421
  _(
1390
- "Client reports can only be generated periodically. Please wait before trying again."
1422
+ "Consumer reports can only be generated periodically. Please wait before trying again."
1391
1423
  ),
1392
1424
  )
1393
1425
  else:
@@ -1399,14 +1431,36 @@ def client_report(request):
1399
1431
  recipients = (
1400
1432
  form.cleaned_data.get("destinations") if enable_emails else []
1401
1433
  )
1434
+ chargers = list(form.cleaned_data.get("chargers") or [])
1435
+ language = form.cleaned_data.get("language")
1436
+ title = form.cleaned_data.get("title")
1402
1437
  report = ClientReport.generate(
1403
1438
  form.cleaned_data["start"],
1404
1439
  form.cleaned_data["end"],
1405
1440
  owner=owner,
1406
1441
  recipients=recipients,
1407
1442
  disable_emails=disable_emails,
1443
+ chargers=chargers,
1444
+ language=language,
1445
+ title=title,
1408
1446
  )
1409
1447
  report.store_local_copy()
1448
+ if chargers:
1449
+ report.chargers.set(chargers)
1450
+ if enable_emails and recipients:
1451
+ delivered = report.send_delivery(
1452
+ to=recipients,
1453
+ cc=[],
1454
+ outbox=ClientReport.resolve_outbox_for_owner(owner),
1455
+ reply_to=ClientReport.resolve_reply_to_for_owner(owner),
1456
+ )
1457
+ if delivered:
1458
+ report.recipients = delivered
1459
+ report.save(update_fields=["recipients"])
1460
+ messages.success(
1461
+ request,
1462
+ _("Consumer report emailed to the selected recipients."),
1463
+ )
1410
1464
  recurrence = form.cleaned_data.get("recurrence")
1411
1465
  if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
1412
1466
  schedule = ClientReportSchedule.objects.create(
@@ -1415,13 +1469,17 @@ def client_report(request):
1415
1469
  periodicity=recurrence,
1416
1470
  email_recipients=recipients,
1417
1471
  disable_emails=disable_emails,
1472
+ language=language,
1473
+ title=title,
1418
1474
  )
1475
+ if chargers:
1476
+ schedule.chargers.set(chargers)
1419
1477
  report.schedule = schedule
1420
1478
  report.save(update_fields=["schedule"])
1421
1479
  messages.success(
1422
1480
  request,
1423
1481
  _(
1424
- "Client report schedule created; future reports will be generated automatically."
1482
+ "Consumer report schedule created; future reports will be generated automatically."
1425
1483
  ),
1426
1484
  )
1427
1485
  if disable_emails:
@@ -1459,7 +1517,6 @@ def client_report(request):
1459
1517
  "schedule": schedule,
1460
1518
  "login_url": login_url,
1461
1519
  "download_url": download_url,
1462
- "previous_reports": _client_report_history(request),
1463
1520
  }
1464
1521
  return render(request, "pages/client_report.html", context)
1465
1522
 
@@ -1478,32 +1535,6 @@ def client_report_download(request, report_id: int):
1478
1535
  response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
1479
1536
  response["Content-Disposition"] = f'attachment; filename="{filename}"'
1480
1537
  return response
1481
-
1482
-
1483
- def _client_report_history(request, limit: int = 20):
1484
- if not request.user.is_authenticated:
1485
- return []
1486
- qs = ClientReport.objects.order_by("-created_on")
1487
- if not request.user.is_staff:
1488
- qs = qs.filter(owner=request.user)
1489
- history = []
1490
- for report in qs[:limit]:
1491
- totals = report.rows_for_display.get("totals", {})
1492
- history.append(
1493
- {
1494
- "instance": report,
1495
- "download_url": reverse("pages:client-report-download", args=[report.pk]),
1496
- "email_enabled": not report.disable_emails,
1497
- "recipients": report.recipients or [],
1498
- "totals": {
1499
- "total_kw": totals.get("total_kw", 0.0),
1500
- "total_kw_period": totals.get("total_kw_period", 0.0),
1501
- },
1502
- }
1503
- )
1504
- return history
1505
-
1506
-
1507
1538
  def _get_request_language_code(request) -> str:
1508
1539
  language_code = ""
1509
1540
  if hasattr(request, "session"):
@@ -1562,7 +1593,7 @@ def submit_user_story(request):
1562
1593
  if not story.name:
1563
1594
  story.name = str(_("Anonymous"))[:40]
1564
1595
  story.path = (story.path or request.get_full_path())[:500]
1565
- story.referer = request.META.get("HTTP_REFERER", "")
1596
+ story.referer = get_original_referer(request)
1566
1597
  story.user_agent = request.META.get("HTTP_USER_AGENT", "")
1567
1598
  story.ip_address = client_ip or None
1568
1599
  story.is_user_data = True
@@ -1634,6 +1665,7 @@ def submit_user_story(request):
1634
1665
  screenshot_path,
1635
1666
  method="USER_STORY",
1636
1667
  user=story.user if story.user_id else None,
1668
+ link_duplicates=True,
1637
1669
  )
1638
1670
  except Exception: # pragma: no cover - best effort persistence
1639
1671
  logger.exception(