arthexis 0.1.23__py3-none-any.whl → 0.1.24__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.24.dist-info}/METADATA +5 -5
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/RECORD +17 -17
- config/settings.py +4 -0
- core/admin.py +139 -27
- core/models.py +543 -204
- core/tasks.py +25 -0
- nodes/admin.py +152 -172
- nodes/tests.py +80 -129
- nodes/urls.py +6 -0
- nodes/views.py +520 -0
- ocpp/admin.py +541 -175
- ocpp/models.py +28 -0
- ocpp/tasks.py +336 -1
- pages/views.py +60 -30
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/top_level.txt +0 -0
ocpp/models.py
CHANGED
|
@@ -232,6 +232,13 @@ class Charger(Entity):
|
|
|
232
232
|
"Latest GetConfiguration response received from this charge point."
|
|
233
233
|
),
|
|
234
234
|
)
|
|
235
|
+
node_origin = models.ForeignKey(
|
|
236
|
+
"nodes.Node",
|
|
237
|
+
on_delete=models.SET_NULL,
|
|
238
|
+
null=True,
|
|
239
|
+
blank=True,
|
|
240
|
+
related_name="origin_chargers",
|
|
241
|
+
)
|
|
235
242
|
manager_node = models.ForeignKey(
|
|
236
243
|
"nodes.Node",
|
|
237
244
|
on_delete=models.SET_NULL,
|
|
@@ -239,6 +246,9 @@ class Charger(Entity):
|
|
|
239
246
|
blank=True,
|
|
240
247
|
related_name="managed_chargers",
|
|
241
248
|
)
|
|
249
|
+
allow_remote = models.BooleanField(default=False)
|
|
250
|
+
export_transactions = models.BooleanField(default=False)
|
|
251
|
+
last_online_at = models.DateTimeField(null=True, blank=True)
|
|
242
252
|
owner_users = models.ManyToManyField(
|
|
243
253
|
settings.AUTH_USER_MODEL,
|
|
244
254
|
blank=True,
|
|
@@ -293,6 +303,24 @@ class Charger(Entity):
|
|
|
293
303
|
user_group_ids = user.groups.values_list("pk", flat=True)
|
|
294
304
|
return self.owner_groups.filter(pk__in=user_group_ids).exists()
|
|
295
305
|
|
|
306
|
+
@property
|
|
307
|
+
def is_local(self) -> bool:
|
|
308
|
+
"""Return ``True`` when this charger originates from the local node."""
|
|
309
|
+
|
|
310
|
+
local = Node.get_local()
|
|
311
|
+
if not local:
|
|
312
|
+
return False
|
|
313
|
+
if self.node_origin_id is None:
|
|
314
|
+
return True
|
|
315
|
+
return self.node_origin_id == local.pk
|
|
316
|
+
|
|
317
|
+
def save(self, *args, **kwargs):
|
|
318
|
+
if self.node_origin_id is None:
|
|
319
|
+
local = Node.get_local()
|
|
320
|
+
if local:
|
|
321
|
+
self.node_origin = local
|
|
322
|
+
super().save(*args, **kwargs)
|
|
323
|
+
|
|
296
324
|
class Meta:
|
|
297
325
|
verbose_name = _("Charge Point")
|
|
298
326
|
verbose_name_plural = _("Charge Points")
|
ocpp/tasks.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import json
|
|
2
3
|
import logging
|
|
3
4
|
import uuid
|
|
4
5
|
from datetime import date, datetime, time, timedelta
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
9
|
from asgiref.sync import async_to_sync
|
|
@@ -9,17 +11,259 @@ from celery import shared_task
|
|
|
9
11
|
from django.conf import settings
|
|
10
12
|
from django.contrib.auth import get_user_model
|
|
11
13
|
from django.utils import timezone
|
|
14
|
+
from django.utils.dateparse import parse_datetime
|
|
12
15
|
from django.db.models import Q
|
|
16
|
+
import requests
|
|
17
|
+
from requests import RequestException
|
|
18
|
+
from cryptography.hazmat.primitives import hashes
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
13
20
|
|
|
14
21
|
from core import mailer
|
|
15
22
|
from nodes.models import Node
|
|
16
23
|
|
|
17
24
|
from . import store
|
|
18
|
-
from .models import Charger, MeterValue, Transaction
|
|
25
|
+
from .models import Charger, Location, MeterValue, Transaction
|
|
19
26
|
|
|
20
27
|
logger = logging.getLogger(__name__)
|
|
21
28
|
|
|
22
29
|
|
|
30
|
+
def _sign_payload(payload_json: str, private_key) -> str | None:
|
|
31
|
+
if not private_key:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
signature = private_key.sign(
|
|
35
|
+
payload_json.encode(),
|
|
36
|
+
padding.PKCS1v15(),
|
|
37
|
+
hashes.SHA256(),
|
|
38
|
+
)
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
return base64.b64encode(signature).decode()
|
|
42
|
+
|
|
43
|
+
|
|
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
|
+
|
|
23
267
|
@shared_task
|
|
24
268
|
def check_charge_point_configuration(charger_pk: int) -> bool:
|
|
25
269
|
"""Request the latest configuration from a connected charge point."""
|
|
@@ -135,6 +379,97 @@ def purge_meter_values() -> int:
|
|
|
135
379
|
purge_meter_readings = purge_meter_values
|
|
136
380
|
|
|
137
381
|
|
|
382
|
+
@shared_task
|
|
383
|
+
def sync_remote_chargers() -> int:
|
|
384
|
+
"""Synchronize remote charger metadata and transactions."""
|
|
385
|
+
|
|
386
|
+
local = Node.get_local()
|
|
387
|
+
if not local:
|
|
388
|
+
logger.debug("Remote sync skipped: local node not registered")
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
private_key = local.get_private_key()
|
|
392
|
+
if private_key is None:
|
|
393
|
+
logger.warning("Remote sync skipped: missing local node private key")
|
|
394
|
+
return 0
|
|
395
|
+
|
|
396
|
+
chargers = (
|
|
397
|
+
Charger.objects.filter(export_transactions=True)
|
|
398
|
+
.exclude(node_origin__isnull=True)
|
|
399
|
+
.select_related("node_origin")
|
|
400
|
+
)
|
|
401
|
+
if local.pk:
|
|
402
|
+
chargers = chargers.exclude(node_origin=local)
|
|
403
|
+
|
|
404
|
+
grouped: dict[Node, list[Charger]] = {}
|
|
405
|
+
for charger in chargers:
|
|
406
|
+
origin = charger.node_origin
|
|
407
|
+
if not origin:
|
|
408
|
+
continue
|
|
409
|
+
grouped.setdefault(origin, []).append(charger)
|
|
410
|
+
|
|
411
|
+
if not grouped:
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
synced = 0
|
|
415
|
+
for node, node_chargers in grouped.items():
|
|
416
|
+
payload = {
|
|
417
|
+
"requester": str(local.uuid),
|
|
418
|
+
"include_transactions": True,
|
|
419
|
+
"chargers": [],
|
|
420
|
+
}
|
|
421
|
+
for charger in node_chargers:
|
|
422
|
+
payload["chargers"].append(
|
|
423
|
+
{
|
|
424
|
+
"charger_id": charger.charger_id,
|
|
425
|
+
"connector_id": charger.connector_id,
|
|
426
|
+
"since": charger.last_online_at.isoformat()
|
|
427
|
+
if charger.last_online_at
|
|
428
|
+
else None,
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
432
|
+
signature = _sign_payload(payload_json, private_key)
|
|
433
|
+
headers = {"Content-Type": "application/json"}
|
|
434
|
+
if signature:
|
|
435
|
+
headers["X-Signature"] = signature
|
|
436
|
+
|
|
437
|
+
url = f"http://{node.address}:{node.port}/nodes/network/chargers/"
|
|
438
|
+
try:
|
|
439
|
+
response = requests.post(url, data=payload_json, headers=headers, timeout=5)
|
|
440
|
+
except RequestException as exc:
|
|
441
|
+
logger.warning("Failed to sync chargers from %s: %s", node, exc)
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
if not response.ok:
|
|
445
|
+
logger.warning(
|
|
446
|
+
"Sync request to %s returned %s", node, response.status_code
|
|
447
|
+
)
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
data = response.json()
|
|
452
|
+
except ValueError:
|
|
453
|
+
logger.warning("Invalid JSON payload received from %s", node)
|
|
454
|
+
continue
|
|
455
|
+
|
|
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
|
|
471
|
+
|
|
472
|
+
|
|
138
473
|
def _resolve_report_window() -> tuple[datetime, datetime, date]:
|
|
139
474
|
"""Return the start/end datetimes for today's reporting window."""
|
|
140
475
|
|
pages/views.py
CHANGED
|
@@ -66,6 +66,7 @@ from core.models import (
|
|
|
66
66
|
SecurityGroup,
|
|
67
67
|
Todo,
|
|
68
68
|
)
|
|
69
|
+
from ocpp.models import Charger
|
|
69
70
|
|
|
70
71
|
try: # pragma: no cover - optional dependency guard
|
|
71
72
|
from graphviz import Digraph
|
|
@@ -1269,6 +1270,25 @@ class ClientReportForm(forms.Form):
|
|
|
1269
1270
|
input_formats=["%Y-%m"],
|
|
1270
1271
|
help_text=_("Generates the report for the calendar month that you select."),
|
|
1271
1272
|
)
|
|
1273
|
+
language = forms.ChoiceField(
|
|
1274
|
+
label=_("Report language"),
|
|
1275
|
+
choices=settings.LANGUAGES,
|
|
1276
|
+
help_text=_("Choose the language used for the generated report."),
|
|
1277
|
+
)
|
|
1278
|
+
title = forms.CharField(
|
|
1279
|
+
label=_("Report title"),
|
|
1280
|
+
required=False,
|
|
1281
|
+
max_length=200,
|
|
1282
|
+
help_text=_("Optional heading that replaces the default report title."),
|
|
1283
|
+
)
|
|
1284
|
+
chargers = forms.ModelMultipleChoiceField(
|
|
1285
|
+
label=_("Charge points"),
|
|
1286
|
+
queryset=Charger.objects.filter(connector_id__isnull=True)
|
|
1287
|
+
.order_by("display_name", "charger_id"),
|
|
1288
|
+
required=False,
|
|
1289
|
+
widget=forms.CheckboxSelectMultiple,
|
|
1290
|
+
help_text=_("Choose which charge points are included in the report."),
|
|
1291
|
+
)
|
|
1272
1292
|
owner = forms.ModelChoiceField(
|
|
1273
1293
|
queryset=get_user_model().objects.all(),
|
|
1274
1294
|
required=False,
|
|
@@ -1299,6 +1319,13 @@ class ClientReportForm(forms.Form):
|
|
|
1299
1319
|
super().__init__(*args, **kwargs)
|
|
1300
1320
|
if request and getattr(request, "user", None) and request.user.is_authenticated:
|
|
1301
1321
|
self.fields["owner"].initial = request.user.pk
|
|
1322
|
+
self.fields["chargers"].widget.attrs["class"] = "charger-options"
|
|
1323
|
+
language_initial = ClientReport.default_language()
|
|
1324
|
+
if request:
|
|
1325
|
+
language_initial = ClientReport.normalize_language(
|
|
1326
|
+
getattr(request, "LANGUAGE_CODE", language_initial)
|
|
1327
|
+
)
|
|
1328
|
+
self.fields["language"].initial = language_initial
|
|
1302
1329
|
|
|
1303
1330
|
def clean(self):
|
|
1304
1331
|
cleaned = super().clean()
|
|
@@ -1348,6 +1375,10 @@ class ClientReportForm(forms.Form):
|
|
|
1348
1375
|
emails.append(candidate)
|
|
1349
1376
|
return emails
|
|
1350
1377
|
|
|
1378
|
+
def clean_title(self):
|
|
1379
|
+
title = self.cleaned_data.get("title")
|
|
1380
|
+
return ClientReport.normalize_title(title)
|
|
1381
|
+
|
|
1351
1382
|
|
|
1352
1383
|
@live_update()
|
|
1353
1384
|
def client_report(request):
|
|
@@ -1358,7 +1389,7 @@ def client_report(request):
|
|
|
1358
1389
|
if not request.user.is_authenticated:
|
|
1359
1390
|
form.is_valid() # Run validation to surface field errors alongside auth error.
|
|
1360
1391
|
form.add_error(
|
|
1361
|
-
None, _("You must log in to generate
|
|
1392
|
+
None, _("You must log in to generate consumer reports."),
|
|
1362
1393
|
)
|
|
1363
1394
|
elif form.is_valid():
|
|
1364
1395
|
throttle_seconds = getattr(settings, "CLIENT_REPORT_THROTTLE_SECONDS", 60)
|
|
@@ -1387,7 +1418,7 @@ def client_report(request):
|
|
|
1387
1418
|
form.add_error(
|
|
1388
1419
|
None,
|
|
1389
1420
|
_(
|
|
1390
|
-
"
|
|
1421
|
+
"Consumer reports can only be generated periodically. Please wait before trying again."
|
|
1391
1422
|
),
|
|
1392
1423
|
)
|
|
1393
1424
|
else:
|
|
@@ -1399,14 +1430,36 @@ def client_report(request):
|
|
|
1399
1430
|
recipients = (
|
|
1400
1431
|
form.cleaned_data.get("destinations") if enable_emails else []
|
|
1401
1432
|
)
|
|
1433
|
+
chargers = list(form.cleaned_data.get("chargers") or [])
|
|
1434
|
+
language = form.cleaned_data.get("language")
|
|
1435
|
+
title = form.cleaned_data.get("title")
|
|
1402
1436
|
report = ClientReport.generate(
|
|
1403
1437
|
form.cleaned_data["start"],
|
|
1404
1438
|
form.cleaned_data["end"],
|
|
1405
1439
|
owner=owner,
|
|
1406
1440
|
recipients=recipients,
|
|
1407
1441
|
disable_emails=disable_emails,
|
|
1442
|
+
chargers=chargers,
|
|
1443
|
+
language=language,
|
|
1444
|
+
title=title,
|
|
1408
1445
|
)
|
|
1409
1446
|
report.store_local_copy()
|
|
1447
|
+
if chargers:
|
|
1448
|
+
report.chargers.set(chargers)
|
|
1449
|
+
if enable_emails and recipients:
|
|
1450
|
+
delivered = report.send_delivery(
|
|
1451
|
+
to=recipients,
|
|
1452
|
+
cc=[],
|
|
1453
|
+
outbox=ClientReport.resolve_outbox_for_owner(owner),
|
|
1454
|
+
reply_to=ClientReport.resolve_reply_to_for_owner(owner),
|
|
1455
|
+
)
|
|
1456
|
+
if delivered:
|
|
1457
|
+
report.recipients = delivered
|
|
1458
|
+
report.save(update_fields=["recipients"])
|
|
1459
|
+
messages.success(
|
|
1460
|
+
request,
|
|
1461
|
+
_("Consumer report emailed to the selected recipients."),
|
|
1462
|
+
)
|
|
1410
1463
|
recurrence = form.cleaned_data.get("recurrence")
|
|
1411
1464
|
if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
|
|
1412
1465
|
schedule = ClientReportSchedule.objects.create(
|
|
@@ -1415,13 +1468,17 @@ def client_report(request):
|
|
|
1415
1468
|
periodicity=recurrence,
|
|
1416
1469
|
email_recipients=recipients,
|
|
1417
1470
|
disable_emails=disable_emails,
|
|
1471
|
+
language=language,
|
|
1472
|
+
title=title,
|
|
1418
1473
|
)
|
|
1474
|
+
if chargers:
|
|
1475
|
+
schedule.chargers.set(chargers)
|
|
1419
1476
|
report.schedule = schedule
|
|
1420
1477
|
report.save(update_fields=["schedule"])
|
|
1421
1478
|
messages.success(
|
|
1422
1479
|
request,
|
|
1423
1480
|
_(
|
|
1424
|
-
"
|
|
1481
|
+
"Consumer report schedule created; future reports will be generated automatically."
|
|
1425
1482
|
),
|
|
1426
1483
|
)
|
|
1427
1484
|
if disable_emails:
|
|
@@ -1459,7 +1516,6 @@ def client_report(request):
|
|
|
1459
1516
|
"schedule": schedule,
|
|
1460
1517
|
"login_url": login_url,
|
|
1461
1518
|
"download_url": download_url,
|
|
1462
|
-
"previous_reports": _client_report_history(request),
|
|
1463
1519
|
}
|
|
1464
1520
|
return render(request, "pages/client_report.html", context)
|
|
1465
1521
|
|
|
@@ -1478,32 +1534,6 @@ def client_report_download(request, report_id: int):
|
|
|
1478
1534
|
response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
|
|
1479
1535
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
1480
1536
|
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
1537
|
def _get_request_language_code(request) -> str:
|
|
1508
1538
|
language_code = ""
|
|
1509
1539
|
if hasattr(request, "session"):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|