arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
ocpp/simulator.py CHANGED
@@ -3,6 +3,7 @@ import base64
3
3
  import json
4
4
  import random
5
5
  import time
6
+ import uuid
6
7
  from dataclasses import dataclass
7
8
  from typing import Optional
8
9
  import threading
@@ -18,7 +19,7 @@ class SimulatorConfig:
18
19
  """Configuration for a simulated charge point."""
19
20
 
20
21
  host: str = "127.0.0.1"
21
- ws_port: int = 8000
22
+ ws_port: Optional[int] = 8000
22
23
  rfid: str = "FFFFFFFF"
23
24
  vin: str = ""
24
25
  # WebSocket path for the charge point. Defaults to just the charger ID at the root.
@@ -31,6 +32,8 @@ class SimulatorConfig:
31
32
  repeat: bool = False
32
33
  username: Optional[str] = None
33
34
  password: Optional[str] = None
35
+ serial_number: str = ""
36
+ connector_id: int = 1
34
37
 
35
38
 
36
39
  class ChargePointSimulator:
@@ -40,14 +43,65 @@ class ChargePointSimulator:
40
43
  self.config = config
41
44
  self._thread: Optional[threading.Thread] = None
42
45
  self._stop_event = threading.Event()
46
+ self._door_open_event = threading.Event()
43
47
  self.status = "stopped"
44
48
  self._connected = threading.Event()
45
49
  self._connect_error = ""
46
50
 
51
+ def trigger_door_open(self) -> None:
52
+ """Queue a DoorOpen status notification for the simulator."""
53
+
54
+ self._door_open_event.set()
55
+
56
+ async def _maybe_send_door_event(self, send, recv) -> None:
57
+ if not self._door_open_event.is_set():
58
+ return
59
+ self._door_open_event.clear()
60
+ cfg = self.config
61
+ store.add_log(
62
+ cfg.cp_path,
63
+ "Sending DoorOpen StatusNotification",
64
+ log_type="simulator",
65
+ )
66
+ event_id = uuid.uuid4().hex
67
+ await send(
68
+ json.dumps(
69
+ [
70
+ 2,
71
+ f"door-open-{event_id}",
72
+ "StatusNotification",
73
+ {
74
+ "connectorId": cfg.connector_id,
75
+ "errorCode": "DoorOpen",
76
+ "status": "Faulted",
77
+ },
78
+ ]
79
+ )
80
+ )
81
+ await recv()
82
+ await send(
83
+ json.dumps(
84
+ [
85
+ 2,
86
+ f"door-closed-{event_id}",
87
+ "StatusNotification",
88
+ {
89
+ "connectorId": cfg.connector_id,
90
+ "errorCode": "NoError",
91
+ "status": "Available",
92
+ },
93
+ ]
94
+ )
95
+ )
96
+ await recv()
97
+
47
98
  @requires_network
48
99
  async def _run_session(self) -> None:
49
100
  cfg = self.config
50
- uri = f"ws://{cfg.host}:{cfg.ws_port}/{cfg.cp_path}"
101
+ if cfg.ws_port:
102
+ uri = f"ws://{cfg.host}:{cfg.ws_port}/{cfg.cp_path}"
103
+ else:
104
+ uri = f"ws://{cfg.host}/{cfg.cp_path}"
51
105
  headers = {}
52
106
  if cfg.username and cfg.password:
53
107
  userpass = f"{cfg.username}:{cfg.password}"
@@ -113,6 +167,7 @@ class ChargePointSimulator:
113
167
  {
114
168
  "chargePointModel": "Simulator",
115
169
  "chargePointVendor": "SimVendor",
170
+ "serialNumber": cfg.serial_number,
116
171
  },
117
172
  ]
118
173
  )
@@ -131,6 +186,7 @@ class ChargePointSimulator:
131
186
 
132
187
  await send(json.dumps([2, "auth", "Authorize", {"idTag": cfg.rfid}]))
133
188
  await recv()
189
+ await self._maybe_send_door_event(send, recv)
134
190
  if not self._connected.is_set():
135
191
  self.status = "running"
136
192
  self._connect_error = "accepted"
@@ -145,7 +201,7 @@ class ChargePointSimulator:
145
201
  "status",
146
202
  "StatusNotification",
147
203
  {
148
- "connectorId": 1,
204
+ "connectorId": cfg.connector_id,
149
205
  "errorCode": "NoError",
150
206
  "status": "Available",
151
207
  },
@@ -162,10 +218,12 @@ class ChargePointSimulator:
162
218
  "meter",
163
219
  "MeterValues",
164
220
  {
165
- "connectorId": 1,
221
+ "connectorId": cfg.connector_id,
166
222
  "meterValue": [
167
223
  {
168
- "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
224
+ "timestamp": time.strftime(
225
+ "%Y-%m-%dT%H:%M:%SZ"
226
+ ),
169
227
  "sampledValue": [
170
228
  {
171
229
  "value": "0",
@@ -177,10 +235,11 @@ class ChargePointSimulator:
177
235
  ],
178
236
  },
179
237
  ]
180
- )
181
238
  )
182
- await recv()
183
- await asyncio.sleep(cfg.interval)
239
+ )
240
+ await recv()
241
+ await self._maybe_send_door_event(send, recv)
242
+ await asyncio.sleep(cfg.interval)
184
243
 
185
244
  meter_start = random.randint(1000, 2000)
186
245
  await send(
@@ -190,7 +249,7 @@ class ChargePointSimulator:
190
249
  "start",
191
250
  "StartTransaction",
192
251
  {
193
- "connectorId": 1,
252
+ "connectorId": cfg.connector_id,
194
253
  "idTag": cfg.rfid,
195
254
  "meterStart": meter_start,
196
255
  "vin": cfg.vin,
@@ -224,11 +283,13 @@ class ChargePointSimulator:
224
283
  "meter",
225
284
  "MeterValues",
226
285
  {
227
- "connectorId": 1,
286
+ "connectorId": cfg.connector_id,
228
287
  "transactionId": tx_id,
229
288
  "meterValue": [
230
289
  {
231
- "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
290
+ "timestamp": time.strftime(
291
+ "%Y-%m-%dT%H:%M:%SZ"
292
+ ),
232
293
  "sampledValue": [
233
294
  {
234
295
  "value": f"{meter_kw:.3f}",
@@ -243,6 +304,7 @@ class ChargePointSimulator:
243
304
  )
244
305
  )
245
306
  await recv()
307
+ await self._maybe_send_door_event(send, recv)
246
308
  await asyncio.sleep(cfg.interval)
247
309
 
248
310
  await send(
@@ -260,6 +322,7 @@ class ChargePointSimulator:
260
322
  )
261
323
  )
262
324
  await recv()
325
+ await self._maybe_send_door_event(send, recv)
263
326
  except asyncio.TimeoutError:
264
327
  if not self._connected.is_set():
265
328
  self._connect_error = "Timeout waiting for response"
@@ -329,6 +392,7 @@ class ChargePointSimulator:
329
392
  self.status = "starting"
330
393
  self._connected.clear()
331
394
  self._connect_error = ""
395
+ self._door_open_event.clear()
332
396
 
333
397
  def _runner() -> None:
334
398
  asyncio.run(self._run())
ocpp/store.py CHANGED
@@ -6,21 +6,232 @@ from pathlib import Path
6
6
  from datetime import datetime
7
7
  import json
8
8
  import re
9
+ import asyncio
9
10
 
10
- connections = {}
11
- transactions = {}
11
+ from core.log_paths import select_log_dir
12
+
13
+ IDENTITY_SEPARATOR = "#"
14
+ AGGREGATE_SLUG = "all"
15
+ PENDING_SLUG = "pending"
16
+
17
+ MAX_CONNECTIONS_PER_IP = 2
18
+
19
+ connections: dict[str, object] = {}
20
+ transactions: dict[str, object] = {}
12
21
  logs: dict[str, dict[str, list[str]]] = {"charger": {}, "simulator": {}}
13
22
  # store per charger session logs before they are flushed to disk
14
23
  history: dict[str, dict[str, object]] = {}
15
24
  simulators = {}
25
+ ip_connections: dict[str, set[object]] = {}
16
26
 
17
27
  # mapping of charger id / cp_path to friendly names used for log files
18
28
  log_names: dict[str, dict[str, str]] = {"charger": {}, "simulator": {}}
19
29
 
20
- LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
21
- LOG_DIR.mkdir(exist_ok=True)
30
+ BASE_DIR = Path(__file__).resolve().parent.parent
31
+ LOG_DIR = select_log_dir(BASE_DIR)
22
32
  SESSION_DIR = LOG_DIR / "sessions"
23
33
  SESSION_DIR.mkdir(exist_ok=True)
34
+ LOCK_DIR = BASE_DIR / "locks"
35
+ LOCK_DIR.mkdir(exist_ok=True)
36
+ SESSION_LOCK = LOCK_DIR / "charging.lck"
37
+ _lock_task: asyncio.Task | None = None
38
+
39
+
40
+ def connector_slug(value: int | str | None) -> str:
41
+ """Return the canonical slug for a connector value."""
42
+
43
+ if value in (None, "", AGGREGATE_SLUG):
44
+ return AGGREGATE_SLUG
45
+ try:
46
+ return str(int(value))
47
+ except (TypeError, ValueError):
48
+ return str(value)
49
+
50
+
51
+ def identity_key(serial: str, connector: int | str | None) -> str:
52
+ """Return the identity key used for in-memory store lookups."""
53
+
54
+ return f"{serial}{IDENTITY_SEPARATOR}{connector_slug(connector)}"
55
+
56
+
57
+ def register_ip_connection(ip: str | None, consumer: object) -> bool:
58
+ """Track a websocket connection for the provided client IP."""
59
+
60
+ if not ip:
61
+ return True
62
+ conns = ip_connections.setdefault(ip, set())
63
+ if consumer in conns:
64
+ return True
65
+ if len(conns) >= MAX_CONNECTIONS_PER_IP:
66
+ return False
67
+ conns.add(consumer)
68
+ return True
69
+
70
+
71
+ def release_ip_connection(ip: str | None, consumer: object) -> None:
72
+ """Remove a websocket connection from the active client registry."""
73
+
74
+ if not ip:
75
+ return
76
+ conns = ip_connections.get(ip)
77
+ if not conns:
78
+ return
79
+ conns.discard(consumer)
80
+ if not conns:
81
+ ip_connections.pop(ip, None)
82
+
83
+
84
+ def pending_key(serial: str) -> str:
85
+ """Return the key used before a connector id has been negotiated."""
86
+
87
+ return f"{serial}{IDENTITY_SEPARATOR}{PENDING_SLUG}"
88
+
89
+
90
+ def _candidate_keys(serial: str, connector: int | str | None) -> list[str]:
91
+ """Return possible keys for lookups with fallbacks."""
92
+
93
+ keys: list[str] = []
94
+ if connector not in (None, "", AGGREGATE_SLUG):
95
+ keys.append(identity_key(serial, connector))
96
+ else:
97
+ keys.append(identity_key(serial, None))
98
+ prefix = f"{serial}{IDENTITY_SEPARATOR}"
99
+ for key in connections.keys():
100
+ if key.startswith(prefix) and key not in keys:
101
+ keys.append(key)
102
+ keys.append(pending_key(serial))
103
+ keys.append(serial)
104
+ seen: set[str] = set()
105
+ result: list[str] = []
106
+ for key in keys:
107
+ if key and key not in seen:
108
+ seen.add(key)
109
+ result.append(key)
110
+ return result
111
+
112
+
113
+ def iter_identity_keys(serial: str) -> list[str]:
114
+ """Return all known keys for the provided serial."""
115
+
116
+ prefix = f"{serial}{IDENTITY_SEPARATOR}"
117
+ keys = [key for key in connections.keys() if key.startswith(prefix)]
118
+ if serial in connections:
119
+ keys.append(serial)
120
+ return keys
121
+
122
+
123
+ def is_connected(serial: str, connector: int | str | None = None) -> bool:
124
+ """Return whether a connection exists for the provided charger identity."""
125
+
126
+ if connector in (None, "", AGGREGATE_SLUG):
127
+ prefix = f"{serial}{IDENTITY_SEPARATOR}"
128
+ return (
129
+ any(key.startswith(prefix) for key in connections) or serial in connections
130
+ )
131
+ return any(key in connections for key in _candidate_keys(serial, connector))
132
+
133
+
134
+ def get_connection(serial: str, connector: int | str | None = None):
135
+ """Return the websocket consumer for the requested identity, if any."""
136
+
137
+ for key in _candidate_keys(serial, connector):
138
+ conn = connections.get(key)
139
+ if conn is not None:
140
+ return conn
141
+ return None
142
+
143
+
144
+ def set_connection(serial: str, connector: int | str | None, consumer) -> str:
145
+ """Store a websocket consumer under the negotiated identity."""
146
+
147
+ key = identity_key(serial, connector)
148
+ connections[key] = consumer
149
+ return key
150
+
151
+
152
+ def pop_connection(serial: str, connector: int | str | None = None):
153
+ """Remove a stored connection for the given identity."""
154
+
155
+ for key in _candidate_keys(serial, connector):
156
+ conn = connections.pop(key, None)
157
+ if conn is not None:
158
+ return conn
159
+ return None
160
+
161
+
162
+ def get_transaction(serial: str, connector: int | str | None = None):
163
+ """Return the active transaction for the provided identity."""
164
+
165
+ for key in _candidate_keys(serial, connector):
166
+ tx = transactions.get(key)
167
+ if tx is not None:
168
+ return tx
169
+ return None
170
+
171
+
172
+ def set_transaction(serial: str, connector: int | str | None, tx) -> str:
173
+ """Store an active transaction under the provided identity."""
174
+
175
+ key = identity_key(serial, connector)
176
+ transactions[key] = tx
177
+ return key
178
+
179
+
180
+ def pop_transaction(serial: str, connector: int | str | None = None):
181
+ """Remove and return an active transaction for the identity."""
182
+
183
+ for key in _candidate_keys(serial, connector):
184
+ tx = transactions.pop(key, None)
185
+ if tx is not None:
186
+ return tx
187
+ return None
188
+
189
+
190
+ def reassign_identity(old_key: str, new_key: str) -> str:
191
+ """Move any stored data from ``old_key`` to ``new_key``."""
192
+
193
+ if old_key == new_key:
194
+ return new_key
195
+ if not old_key:
196
+ return new_key
197
+ for mapping in (connections, transactions, history):
198
+ if old_key in mapping:
199
+ mapping[new_key] = mapping.pop(old_key)
200
+ for log_type in logs:
201
+ store = logs[log_type]
202
+ if old_key in store:
203
+ store[new_key] = store.pop(old_key)
204
+ for log_type in log_names:
205
+ names = log_names[log_type]
206
+ if old_key in names:
207
+ names[new_key] = names.pop(old_key)
208
+ return new_key
209
+
210
+
211
+ async def _touch_lock() -> None:
212
+ try:
213
+ while True:
214
+ SESSION_LOCK.touch()
215
+ await asyncio.sleep(60)
216
+ except asyncio.CancelledError:
217
+ pass
218
+
219
+
220
+ def start_session_lock() -> None:
221
+ global _lock_task
222
+ SESSION_LOCK.touch()
223
+ loop = asyncio.get_event_loop()
224
+ if _lock_task is None or _lock_task.done():
225
+ _lock_task = loop.create_task(_touch_lock())
226
+
227
+
228
+ def stop_session_lock() -> None:
229
+ global _lock_task
230
+ if _lock_task:
231
+ _lock_task.cancel()
232
+ _lock_task = None
233
+ if SESSION_LOCK.exists():
234
+ SESSION_LOCK.unlink()
24
235
 
25
236
 
26
237
  def register_log_name(cid: str, name: str, log_type: str = "charger") -> None:
@@ -86,10 +297,12 @@ def add_session_message(cid: str, message: str) -> None:
86
297
  sess = history.get(cid)
87
298
  if not sess:
88
299
  return
89
- sess["messages"].append({
90
- "timestamp": datetime.utcnow().isoformat() + "Z",
91
- "message": message,
92
- })
300
+ sess["messages"].append(
301
+ {
302
+ "timestamp": datetime.utcnow().isoformat() + "Z",
303
+ "message": message,
304
+ }
305
+ )
93
306
 
94
307
 
95
308
  def end_session_log(cid: str) -> None:
@@ -107,15 +320,33 @@ def end_session_log(cid: str) -> None:
107
320
  json.dump(sess["messages"], handle, ensure_ascii=False, indent=2)
108
321
 
109
322
 
110
- def get_logs(cid: str, log_type: str = "charger") -> list[str]:
111
- """Return all log entries for the given id and type."""
323
+ def _log_key_candidates(cid: str, log_type: str) -> list[str]:
324
+ """Return log identifiers to inspect for the requested cid."""
325
+
326
+ if IDENTITY_SEPARATOR not in cid:
327
+ return [cid]
328
+ serial, slug = cid.split(IDENTITY_SEPARATOR, 1)
329
+ slug = slug or AGGREGATE_SLUG
330
+ if slug != AGGREGATE_SLUG:
331
+ return [cid]
332
+ keys: list[str] = [identity_key(serial, None)]
333
+ prefix = f"{serial}{IDENTITY_SEPARATOR}"
334
+ for source in (log_names[log_type], logs[log_type]):
335
+ for key in source.keys():
336
+ if key.startswith(prefix) and key not in keys:
337
+ keys.append(key)
338
+ return keys
339
+
340
+
341
+ def _resolve_log_identifier(cid: str, log_type: str) -> tuple[str, str | None]:
342
+ """Return the canonical key and friendly name for ``cid``."""
112
343
 
113
344
  names = log_names[log_type]
114
- # Try to find a matching log name case-insensitively
115
345
  name = names.get(cid)
116
346
  if name is None:
347
+ lower = cid.lower()
117
348
  for key, value in names.items():
118
- if key.lower() == cid.lower():
349
+ if key.lower() == lower:
119
350
  cid = key
120
351
  name = value
121
352
  break
@@ -132,14 +363,17 @@ def get_logs(cid: str, log_type: str = "charger") -> list[str]:
132
363
  else:
133
364
  from .models import Charger
134
365
 
135
- ch = Charger.objects.filter(charger_id__iexact=cid).first()
366
+ serial = cid.split(IDENTITY_SEPARATOR, 1)[0]
367
+ ch = Charger.objects.filter(charger_id__iexact=serial).first()
136
368
  if ch and ch.name:
137
- cid = ch.charger_id
138
369
  name = ch.name
139
370
  names[cid] = name
140
371
  except Exception: # pragma: no cover - best effort lookup
141
372
  pass
373
+ return cid, name
374
+
142
375
 
376
+ def _log_file_for_identifier(cid: str, name: str | None, log_type: str) -> Path:
143
377
  path = _file_path(cid, log_type)
144
378
  if not path.exists():
145
379
  target = f"{log_type}.{_safe_name(name or cid).lower()}"
@@ -147,29 +381,53 @@ def get_logs(cid: str, log_type: str = "charger") -> list[str]:
147
381
  if file.stem.lower() == target:
148
382
  path = file
149
383
  break
384
+ return path
150
385
 
151
- if path.exists():
152
- return path.read_text(encoding="utf-8").splitlines()
153
386
 
387
+ def _memory_logs_for_identifier(cid: str, log_type: str) -> list[str]:
154
388
  store = logs[log_type]
389
+ lower = cid.lower()
155
390
  for key, entries in store.items():
156
- if key.lower() == cid.lower():
391
+ if key.lower() == lower:
157
392
  return entries
158
393
  return []
159
394
 
160
395
 
396
+ def get_logs(cid: str, log_type: str = "charger") -> list[str]:
397
+ """Return all log entries for the given id and type."""
398
+
399
+ entries: list[str] = []
400
+ seen_paths: set[Path] = set()
401
+ seen_keys: set[str] = set()
402
+ for key in _log_key_candidates(cid, log_type):
403
+ resolved, name = _resolve_log_identifier(key, log_type)
404
+ path = _log_file_for_identifier(resolved, name, log_type)
405
+ if path.exists() and path not in seen_paths:
406
+ entries.extend(path.read_text(encoding="utf-8").splitlines())
407
+ seen_paths.add(path)
408
+ memory_entries = _memory_logs_for_identifier(resolved, log_type)
409
+ lower_key = resolved.lower()
410
+ if memory_entries and lower_key not in seen_keys:
411
+ entries.extend(memory_entries)
412
+ seen_keys.add(lower_key)
413
+ return entries
414
+
415
+
161
416
  def clear_log(cid: str, log_type: str = "charger") -> None:
162
417
  """Remove any stored logs for the given id and type."""
163
-
164
- store = logs[log_type]
165
- key = next((k for k in list(store.keys()) if k.lower() == cid.lower()), cid)
166
- store.pop(key, None)
167
- path = _file_path(key, log_type)
168
- if not path.exists():
169
- target = f"{log_type}.{_safe_name(log_names[log_type].get(key, key)).lower()}"
170
- for file in LOG_DIR.glob(f"{log_type}.*.log"):
171
- if file.stem.lower() == target:
172
- path = file
173
- break
174
- if path.exists():
175
- path.unlink()
418
+ for key in _log_key_candidates(cid, log_type):
419
+ store_map = logs[log_type]
420
+ resolved = next(
421
+ (k for k in list(store_map.keys()) if k.lower() == key.lower()),
422
+ key,
423
+ )
424
+ store_map.pop(resolved, None)
425
+ path = _file_path(resolved, log_type)
426
+ if not path.exists():
427
+ target = f"{log_type}.{_safe_name(log_names[log_type].get(resolved, resolved)).lower()}"
428
+ for file in LOG_DIR.glob(f"{log_type}.*.log"):
429
+ if file.stem.lower() == target:
430
+ path = file
431
+ break
432
+ if path.exists():
433
+ path.unlink()
ocpp/tasks.py CHANGED
@@ -5,23 +5,27 @@ from celery import shared_task
5
5
  from django.utils import timezone
6
6
  from django.db.models import Q
7
7
 
8
- from .models import MeterReading
8
+ from .models import MeterValue
9
9
 
10
10
  logger = logging.getLogger(__name__)
11
11
 
12
12
 
13
13
  @shared_task
14
- def purge_meter_readings() -> int:
15
- """Delete meter readings older than 7 days.
14
+ def purge_meter_values() -> int:
15
+ """Delete meter values older than 7 days.
16
16
 
17
- Readings tied to transactions without a recorded meter_stop are preserved so
17
+ Values tied to transactions without a recorded meter_stop are preserved so
18
18
  that ongoing or incomplete sessions retain their energy data.
19
- Returns the number of deleted readings.
19
+ Returns the number of deleted rows.
20
20
  """
21
21
  cutoff = timezone.now() - timedelta(days=7)
22
- qs = MeterReading.objects.filter(timestamp__lt=cutoff).filter(
22
+ qs = MeterValue.objects.filter(timestamp__lt=cutoff).filter(
23
23
  Q(transaction__isnull=True) | Q(transaction__meter_stop__isnull=False)
24
24
  )
25
25
  deleted, _ = qs.delete()
26
- logger.info("Purged %s meter readings", deleted)
26
+ logger.info("Purged %s meter values", deleted)
27
27
  return deleted
28
+
29
+
30
+ # Backwards compatibility alias
31
+ purge_meter_readings = purge_meter_values
@@ -10,7 +10,7 @@ from django.utils import timezone
10
10
  from django.urls import reverse
11
11
  from django.contrib.auth import get_user_model
12
12
 
13
- from ocpp.models import Charger, Transaction, MeterReading
13
+ from ocpp.models import Charger, Transaction, MeterValue
14
14
  from core.models import EnergyAccount
15
15
 
16
16
 
@@ -29,12 +29,11 @@ class TransactionExportImportTests(TestCase):
29
29
  charger=self.ch2,
30
30
  start_time=now,
31
31
  )
32
- MeterReading.objects.create(
32
+ MeterValue.objects.create(
33
33
  charger=self.ch1,
34
34
  transaction=self.tx_old,
35
35
  timestamp=now - timedelta(days=5),
36
- value=1,
37
- unit="kW",
36
+ energy=1,
38
37
  )
39
38
 
40
39
  def test_export_filters_and_import_creates_chargers(self):
@@ -55,7 +54,7 @@ class TransactionExportImportTests(TestCase):
55
54
  self.assertEqual(len(data["transactions"]), 1)
56
55
  self.assertEqual(data["transactions"][0]["charger"], "C2")
57
56
 
58
- MeterReading.objects.all().delete()
57
+ MeterValue.objects.all().delete()
59
58
  Transaction.objects.all().delete()
60
59
  Charger.objects.all().delete()
61
60
 
@@ -117,7 +116,7 @@ class TransactionAdminExportImportTests(TestCase):
117
116
  "meter_stop": 0,
118
117
  "start_time": timezone.now().isoformat(),
119
118
  "stop_time": None,
120
- "meter_readings": [],
119
+ "meter_values": [],
121
120
  }
122
121
  ],
123
122
  }
@@ -126,4 +125,6 @@ class TransactionAdminExportImportTests(TestCase):
126
125
  response = self.client.post(url, {"file": json_file})
127
126
  self.assertEqual(response.status_code, 302)
128
127
  self.assertTrue(Charger.objects.filter(charger_id="C9").exists())
129
- self.assertEqual(Transaction.objects.filter(charger__charger_id="C9").count(), 1)
128
+ self.assertEqual(
129
+ Transaction.objects.filter(charger__charger_id="C9").count(), 1
130
+ )