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.

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 client reports."),
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
- "Client reports can only be generated periodically. Please wait before trying again."
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
- "Client report schedule created; future reports will be generated automatically."
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"):