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.
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/METADATA +39 -18
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/RECORD +31 -30
- config/settings.py +7 -0
- config/urls.py +2 -0
- core/admin.py +140 -213
- core/backends.py +3 -1
- core/models.py +612 -207
- core/system.py +67 -2
- core/tasks.py +25 -0
- core/views.py +0 -3
- nodes/admin.py +465 -292
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +291 -130
- nodes/urls.py +11 -0
- nodes/utils.py +9 -2
- nodes/views.py +588 -20
- ocpp/admin.py +729 -175
- ocpp/consumers.py +98 -0
- ocpp/models.py +299 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +177 -1
- 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 +64 -32
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
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)
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|