arthexis 0.1.16__py3-none-any.whl → 0.1.26__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.

Files changed (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/network.py ADDED
@@ -0,0 +1,398 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from datetime import datetime
5
+ from decimal import Decimal, InvalidOperation
6
+ from typing import Iterable
7
+
8
+ from django.utils import timezone
9
+ from django.utils.dateparse import parse_datetime
10
+
11
+ from .models import Charger, Location, MeterValue, Transaction
12
+
13
+
14
+ def _parse_remote_datetime(value) -> datetime | None:
15
+ if not value:
16
+ return None
17
+ if isinstance(value, datetime):
18
+ timestamp = value
19
+ else:
20
+ timestamp = parse_datetime(str(value))
21
+ if not timestamp:
22
+ return None
23
+ if timezone.is_naive(timestamp):
24
+ timestamp = timezone.make_aware(timestamp, timezone.get_current_timezone())
25
+ return timestamp
26
+
27
+
28
+ def _to_decimal(value) -> Decimal | None:
29
+ if value in (None, ""):
30
+ return None
31
+ try:
32
+ return Decimal(str(value))
33
+ except (InvalidOperation, TypeError, ValueError):
34
+ return None
35
+
36
+
37
+ def serialize_charger_for_network(charger: Charger) -> dict[str, object]:
38
+ simple_fields = [
39
+ "display_name",
40
+ "language",
41
+ "public_display",
42
+ "require_rfid",
43
+ "firmware_status",
44
+ "firmware_status_info",
45
+ "last_status",
46
+ "last_error_code",
47
+ "last_status_vendor_info",
48
+ "availability_state",
49
+ "availability_requested_state",
50
+ "availability_request_status",
51
+ "availability_request_details",
52
+ "temperature",
53
+ "temperature_unit",
54
+ "diagnostics_status",
55
+ "diagnostics_location",
56
+ ]
57
+
58
+ datetime_fields = [
59
+ "firmware_timestamp",
60
+ "last_heartbeat",
61
+ "availability_state_updated_at",
62
+ "availability_requested_at",
63
+ "availability_request_status_at",
64
+ "diagnostics_timestamp",
65
+ "last_status_timestamp",
66
+ "last_online_at",
67
+ ]
68
+
69
+ data: dict[str, object] = {
70
+ "charger_id": charger.charger_id,
71
+ "connector_id": charger.connector_id,
72
+ "allow_remote": charger.allow_remote,
73
+ "export_transactions": charger.export_transactions,
74
+ "last_meter_values": charger.last_meter_values or {},
75
+ }
76
+
77
+ for field in simple_fields:
78
+ data[field] = getattr(charger, field)
79
+
80
+ for field in datetime_fields:
81
+ value = getattr(charger, field)
82
+ data[field] = value.isoformat() if value else None
83
+
84
+ if charger.location:
85
+ location = charger.location
86
+ data["location"] = {
87
+ "name": location.name,
88
+ "latitude": location.latitude,
89
+ "longitude": location.longitude,
90
+ "zone": location.zone,
91
+ "contract_type": location.contract_type,
92
+ }
93
+
94
+ return data
95
+
96
+
97
+ def apply_remote_charger_payload(
98
+ node,
99
+ payload: Mapping,
100
+ *,
101
+ create_missing: bool = True,
102
+ ) -> Charger | None:
103
+ serial = Charger.normalize_serial(payload.get("charger_id"))
104
+ if not serial or Charger.is_placeholder_serial(serial):
105
+ return None
106
+
107
+ connector = payload.get("connector_id")
108
+ if connector in (None, ""):
109
+ connector_value = None
110
+ elif isinstance(connector, int):
111
+ connector_value = connector
112
+ else:
113
+ try:
114
+ connector_value = int(str(connector))
115
+ except (TypeError, ValueError):
116
+ connector_value = None
117
+
118
+ charger = Charger.objects.filter(
119
+ charger_id=serial, connector_id=connector_value
120
+ ).select_related("location").first()
121
+
122
+ if charger is None and not create_missing:
123
+ return None
124
+
125
+ if charger is None:
126
+ charger = Charger.objects.create(
127
+ charger_id=serial,
128
+ connector_id=connector_value,
129
+ node_origin=node,
130
+ forwarded_to=None,
131
+ )
132
+
133
+ location_obj = None
134
+ location_payload = payload.get("location")
135
+ if isinstance(location_payload, Mapping):
136
+ name = location_payload.get("name")
137
+ if name:
138
+ location_obj, _ = Location.objects.get_or_create(name=name)
139
+ for field in ("latitude", "longitude", "zone", "contract_type"):
140
+ setattr(location_obj, field, location_payload.get(field))
141
+ location_obj.save()
142
+
143
+ datetime_fields = [
144
+ "firmware_timestamp",
145
+ "last_heartbeat",
146
+ "availability_state_updated_at",
147
+ "availability_requested_at",
148
+ "availability_request_status_at",
149
+ "diagnostics_timestamp",
150
+ "last_status_timestamp",
151
+ ]
152
+
153
+ updates: dict[str, object] = {
154
+ "node_origin": node,
155
+ "allow_remote": bool(payload.get("allow_remote", False)),
156
+ "export_transactions": bool(payload.get("export_transactions", False)),
157
+ "last_online_at": timezone.now(),
158
+ "forwarded_to": None,
159
+ }
160
+
161
+ simple_fields = [
162
+ "display_name",
163
+ "language",
164
+ "public_display",
165
+ "require_rfid",
166
+ "firmware_status",
167
+ "firmware_status_info",
168
+ "last_status",
169
+ "last_error_code",
170
+ "last_status_vendor_info",
171
+ "availability_state",
172
+ "availability_requested_state",
173
+ "availability_request_status",
174
+ "availability_request_details",
175
+ "temperature",
176
+ "temperature_unit",
177
+ "diagnostics_status",
178
+ "diagnostics_location",
179
+ ]
180
+
181
+ for field in simple_fields:
182
+ if field in payload:
183
+ value = payload.get(field)
184
+ if field in {"require_rfid", "public_display"} and value is None:
185
+ value = False
186
+ updates[field] = value
187
+ else:
188
+ updates[field] = getattr(charger, field)
189
+
190
+ for field in datetime_fields:
191
+ updates[field] = _parse_remote_datetime(payload.get(field))
192
+
193
+ updates["last_meter_values"] = payload.get("last_meter_values") or {}
194
+
195
+ if location_obj is not None:
196
+ updates["location"] = location_obj
197
+
198
+ Charger.objects.filter(pk=charger.pk).update(**updates)
199
+ charger.refresh_from_db()
200
+ return charger
201
+
202
+
203
+ def _normalize_connector(value) -> int | None:
204
+ if value in (None, ""):
205
+ return None
206
+ if isinstance(value, int):
207
+ return value
208
+ try:
209
+ return int(str(value))
210
+ except (TypeError, ValueError):
211
+ return None
212
+
213
+
214
+ def sync_transactions_payload(payload: Mapping) -> int:
215
+ if not isinstance(payload, Mapping):
216
+ return 0
217
+
218
+ chargers_map: dict[tuple[str, int | None], Charger] = {}
219
+ charger_entries = payload.get("chargers", [])
220
+ if isinstance(charger_entries, Iterable):
221
+ for entry in charger_entries:
222
+ if not isinstance(entry, Mapping):
223
+ continue
224
+ serial = Charger.normalize_serial(entry.get("charger_id"))
225
+ if not serial or Charger.is_placeholder_serial(serial):
226
+ continue
227
+ connector_value = _normalize_connector(entry.get("connector_id"))
228
+ charger = Charger.objects.filter(
229
+ charger_id=serial, connector_id=connector_value
230
+ ).first()
231
+ if charger:
232
+ chargers_map[(serial, connector_value)] = charger
233
+
234
+ imported = 0
235
+ transaction_entries = payload.get("transactions", [])
236
+ if not isinstance(transaction_entries, Iterable):
237
+ return 0
238
+
239
+ for tx in transaction_entries:
240
+ if not isinstance(tx, Mapping):
241
+ continue
242
+ serial = Charger.normalize_serial(tx.get("charger"))
243
+ if not serial:
244
+ continue
245
+ connector_value = _normalize_connector(tx.get("connector_id"))
246
+
247
+ charger = chargers_map.get((serial, connector_value))
248
+ if charger is None:
249
+ charger = chargers_map.get((serial, None))
250
+ if charger is None:
251
+ continue
252
+
253
+ start_time = _parse_remote_datetime(tx.get("start_time"))
254
+ if start_time is None:
255
+ continue
256
+
257
+ defaults = {
258
+ "connector_id": connector_value,
259
+ "account_id": tx.get("account"),
260
+ "rfid": tx.get("rfid", ""),
261
+ "vid": tx.get("vid", ""),
262
+ "vin": tx.get("vin", ""),
263
+ "meter_start": tx.get("meter_start"),
264
+ "meter_stop": tx.get("meter_stop"),
265
+ "voltage_start": _to_decimal(tx.get("voltage_start")),
266
+ "voltage_stop": _to_decimal(tx.get("voltage_stop")),
267
+ "current_import_start": _to_decimal(tx.get("current_import_start")),
268
+ "current_import_stop": _to_decimal(tx.get("current_import_stop")),
269
+ "current_offered_start": _to_decimal(tx.get("current_offered_start")),
270
+ "current_offered_stop": _to_decimal(tx.get("current_offered_stop")),
271
+ "temperature_start": _to_decimal(tx.get("temperature_start")),
272
+ "temperature_stop": _to_decimal(tx.get("temperature_stop")),
273
+ "soc_start": _to_decimal(tx.get("soc_start")),
274
+ "soc_stop": _to_decimal(tx.get("soc_stop")),
275
+ "stop_time": _parse_remote_datetime(tx.get("stop_time")),
276
+ "received_start_time": _parse_remote_datetime(tx.get("received_start_time")),
277
+ "received_stop_time": _parse_remote_datetime(tx.get("received_stop_time")),
278
+ }
279
+
280
+ transaction, created = Transaction.objects.update_or_create(
281
+ charger=charger,
282
+ start_time=start_time,
283
+ defaults=defaults,
284
+ )
285
+
286
+ if not created:
287
+ transaction.meter_values.all().delete()
288
+
289
+ meter_values = tx.get("meter_values", [])
290
+ if isinstance(meter_values, Iterable):
291
+ for mv in meter_values:
292
+ if not isinstance(mv, Mapping):
293
+ continue
294
+ timestamp = _parse_remote_datetime(mv.get("timestamp"))
295
+ if timestamp is None:
296
+ continue
297
+ connector_mv = _normalize_connector(mv.get("connector_id"))
298
+ MeterValue.objects.create(
299
+ charger=charger,
300
+ transaction=transaction,
301
+ connector_id=connector_mv,
302
+ timestamp=timestamp,
303
+ context=mv.get("context", ""),
304
+ energy=_to_decimal(mv.get("energy")),
305
+ voltage=_to_decimal(mv.get("voltage")),
306
+ current_import=_to_decimal(mv.get("current_import")),
307
+ current_offered=_to_decimal(mv.get("current_offered")),
308
+ temperature=_to_decimal(mv.get("temperature")),
309
+ soc=_to_decimal(mv.get("soc")),
310
+ )
311
+
312
+ imported += 1
313
+
314
+ return imported
315
+
316
+
317
+ def serialize_transactions_for_forwarding(
318
+ transactions: Iterable[Transaction],
319
+ ) -> list[dict[str, object]]:
320
+ serialized: list[dict[str, object]] = []
321
+ for tx in transactions:
322
+ serialized.append(
323
+ {
324
+ "charger": tx.charger.charger_id if tx.charger else None,
325
+ "connector_id": tx.connector_id,
326
+ "account": tx.account_id,
327
+ "rfid": tx.rfid,
328
+ "vid": tx.vehicle_identifier,
329
+ "vin": tx.vin,
330
+ "meter_start": tx.meter_start,
331
+ "meter_stop": tx.meter_stop,
332
+ "voltage_start": str(tx.voltage_start)
333
+ if tx.voltage_start is not None
334
+ else None,
335
+ "voltage_stop": str(tx.voltage_stop)
336
+ if tx.voltage_stop is not None
337
+ else None,
338
+ "current_import_start": str(tx.current_import_start)
339
+ if tx.current_import_start is not None
340
+ else None,
341
+ "current_import_stop": str(tx.current_import_stop)
342
+ if tx.current_import_stop is not None
343
+ else None,
344
+ "current_offered_start": str(tx.current_offered_start)
345
+ if tx.current_offered_start is not None
346
+ else None,
347
+ "current_offered_stop": str(tx.current_offered_stop)
348
+ if tx.current_offered_stop is not None
349
+ else None,
350
+ "temperature_start": str(tx.temperature_start)
351
+ if tx.temperature_start is not None
352
+ else None,
353
+ "temperature_stop": str(tx.temperature_stop)
354
+ if tx.temperature_stop is not None
355
+ else None,
356
+ "soc_start": str(tx.soc_start) if tx.soc_start is not None else None,
357
+ "soc_stop": str(tx.soc_stop) if tx.soc_stop is not None else None,
358
+ "start_time": tx.start_time.isoformat() if tx.start_time else None,
359
+ "stop_time": tx.stop_time.isoformat() if tx.stop_time else None,
360
+ "received_start_time": tx.received_start_time.isoformat()
361
+ if tx.received_start_time
362
+ else None,
363
+ "received_stop_time": tx.received_stop_time.isoformat()
364
+ if tx.received_stop_time
365
+ else None,
366
+ "meter_values": [
367
+ {
368
+ "connector_id": mv.connector_id,
369
+ "timestamp": mv.timestamp.isoformat(),
370
+ "context": mv.context,
371
+ "energy": str(mv.energy) if mv.energy is not None else None,
372
+ "voltage": str(mv.voltage) if mv.voltage is not None else None,
373
+ "current_import": str(mv.current_import)
374
+ if mv.current_import is not None
375
+ else None,
376
+ "current_offered": str(mv.current_offered)
377
+ if mv.current_offered is not None
378
+ else None,
379
+ "temperature": str(mv.temperature)
380
+ if mv.temperature is not None
381
+ else None,
382
+ "soc": str(mv.soc) if mv.soc is not None else None,
383
+ }
384
+ for mv in tx.meter_values.all()
385
+ ],
386
+ }
387
+ )
388
+ return serialized
389
+
390
+
391
+ def newest_transaction_timestamp(transactions: Iterable[Transaction]) -> datetime | None:
392
+ latest: datetime | None = None
393
+ for tx in transactions:
394
+ if tx.start_time and (
395
+ latest is None or tx.start_time > latest
396
+ ):
397
+ latest = tx.start_time
398
+ return latest
ocpp/store.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from datetime import datetime
6
+ from datetime import datetime, timezone
7
7
  import json
8
8
  from pathlib import Path
9
9
  import re
@@ -427,7 +427,7 @@ def _file_path(cid: str, log_type: str = "charger") -> Path:
427
427
  def add_log(cid: str, entry: str, log_type: str = "charger") -> None:
428
428
  """Append a timestamped log entry for the given id and log type."""
429
429
 
430
- timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
430
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
431
431
  entry = f"{timestamp} {entry}"
432
432
 
433
433
  store = logs[log_type]
@@ -454,7 +454,7 @@ def start_session_log(cid: str, tx_id: int) -> None:
454
454
 
455
455
  history[cid] = {
456
456
  "transaction": tx_id,
457
- "start": datetime.utcnow(),
457
+ "start": datetime.now(timezone.utc),
458
458
  "messages": [],
459
459
  }
460
460
 
@@ -467,7 +467,9 @@ def add_session_message(cid: str, message: str) -> None:
467
467
  return
468
468
  sess["messages"].append(
469
469
  {
470
- "timestamp": datetime.utcnow().isoformat() + "Z",
470
+ "timestamp": datetime.now(timezone.utc)
471
+ .isoformat()
472
+ .replace("+00:00", "Z"),
471
473
  "message": message,
472
474
  }
473
475
  )