arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.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.
@@ -42,14 +43,65 @@ class ChargePointSimulator:
42
43
  self.config = config
43
44
  self._thread: Optional[threading.Thread] = None
44
45
  self._stop_event = threading.Event()
46
+ self._door_open_event = threading.Event()
45
47
  self.status = "stopped"
46
48
  self._connected = threading.Event()
47
49
  self._connect_error = ""
48
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
+
49
98
  @requires_network
50
99
  async def _run_session(self) -> None:
51
100
  cfg = self.config
52
- 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}"
53
105
  headers = {}
54
106
  if cfg.username and cfg.password:
55
107
  userpass = f"{cfg.username}:{cfg.password}"
@@ -134,6 +186,7 @@ class ChargePointSimulator:
134
186
 
135
187
  await send(json.dumps([2, "auth", "Authorize", {"idTag": cfg.rfid}]))
136
188
  await recv()
189
+ await self._maybe_send_door_event(send, recv)
137
190
  if not self._connected.is_set():
138
191
  self.status = "running"
139
192
  self._connect_error = "accepted"
@@ -182,10 +235,11 @@ class ChargePointSimulator:
182
235
  ],
183
236
  },
184
237
  ]
185
- )
186
238
  )
187
- await recv()
188
- await asyncio.sleep(cfg.interval)
239
+ )
240
+ await recv()
241
+ await self._maybe_send_door_event(send, recv)
242
+ await asyncio.sleep(cfg.interval)
189
243
 
190
244
  meter_start = random.randint(1000, 2000)
191
245
  await send(
@@ -250,6 +304,7 @@ class ChargePointSimulator:
250
304
  )
251
305
  )
252
306
  await recv()
307
+ await self._maybe_send_door_event(send, recv)
253
308
  await asyncio.sleep(cfg.interval)
254
309
 
255
310
  await send(
@@ -267,6 +322,7 @@ class ChargePointSimulator:
267
322
  )
268
323
  )
269
324
  await recv()
325
+ await self._maybe_send_door_event(send, recv)
270
326
  except asyncio.TimeoutError:
271
327
  if not self._connected.is_set():
272
328
  self._connect_error = "Timeout waiting for response"
@@ -336,6 +392,7 @@ class ChargePointSimulator:
336
392
  self.status = "starting"
337
393
  self._connected.clear()
338
394
  self._connect_error = ""
395
+ self._door_open_event.clear()
339
396
 
340
397
  def _runner() -> None:
341
398
  asyncio.run(self._run())
ocpp/store.py CHANGED
@@ -14,12 +14,15 @@ IDENTITY_SEPARATOR = "#"
14
14
  AGGREGATE_SLUG = "all"
15
15
  PENDING_SLUG = "pending"
16
16
 
17
+ MAX_CONNECTIONS_PER_IP = 2
18
+
17
19
  connections: dict[str, object] = {}
18
20
  transactions: dict[str, object] = {}
19
21
  logs: dict[str, dict[str, list[str]]] = {"charger": {}, "simulator": {}}
20
22
  # store per charger session logs before they are flushed to disk
21
23
  history: dict[str, dict[str, object]] = {}
22
24
  simulators = {}
25
+ ip_connections: dict[str, set[object]] = {}
23
26
 
24
27
  # mapping of charger id / cp_path to friendly names used for log files
25
28
  log_names: dict[str, dict[str, str]] = {"charger": {}, "simulator": {}}
@@ -51,6 +54,33 @@ def identity_key(serial: str, connector: int | str | None) -> str:
51
54
  return f"{serial}{IDENTITY_SEPARATOR}{connector_slug(connector)}"
52
55
 
53
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
+
54
84
  def pending_key(serial: str) -> str:
55
85
  """Return the key used before a connector id has been negotiated."""
56
86
 
ocpp/test_rfid.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import io
2
+ import json
2
3
  import os
3
4
  import sys
4
5
  import types
@@ -17,12 +18,13 @@ from django.test import SimpleTestCase, TestCase
17
18
  from django.urls import reverse
18
19
  from django.contrib.auth import get_user_model
19
20
  from django.contrib.sites.models import Site
21
+ from django.utils import timezone
20
22
 
21
23
  from pages.models import Application, Module
22
24
  from nodes.models import Node, NodeRole
23
25
 
24
26
  from core.models import RFID
25
- from ocpp.rfid.reader import read_rfid, enable_deep_read
27
+ from ocpp.rfid.reader import read_rfid, enable_deep_read, validate_rfid_value
26
28
  from ocpp.rfid.detect import detect_scanner, main as detect_main
27
29
  from ocpp.rfid import background_reader
28
30
  from ocpp.rfid.constants import (
@@ -71,6 +73,11 @@ class BackgroundReaderConfigurationTests(SimpleTestCase):
71
73
 
72
74
 
73
75
  class ScanNextViewTests(TestCase):
76
+ def setUp(self):
77
+ User = get_user_model()
78
+ self.user = User.objects.create_user("rfid-user", password="pwd")
79
+ self.client.force_login(self.user)
80
+
74
81
  @patch("config.middleware.Node.get_local", return_value=None)
75
82
  @patch("config.middleware.get_site")
76
83
  @patch(
@@ -103,6 +110,62 @@ class ScanNextViewTests(TestCase):
103
110
  self.assertEqual(resp.status_code, 500)
104
111
  self.assertEqual(resp.json(), {"error": "boom"})
105
112
 
113
+ @patch("config.middleware.Node.get_local", return_value=None)
114
+ @patch("config.middleware.get_site")
115
+ @patch(
116
+ "ocpp.rfid.views.validate_rfid_value",
117
+ return_value={"rfid": "ABCD1234", "label_id": 1, "created": False},
118
+ )
119
+ def test_scan_next_post_validates(self, mock_validate, mock_site, mock_node):
120
+ User = get_user_model()
121
+ user = User.objects.create_user("scanner", password="pwd")
122
+ self.client.force_login(user)
123
+ resp = self.client.post(
124
+ reverse("rfid-scan-next"),
125
+ data=json.dumps({"rfid": "ABCD1234"}),
126
+ content_type="application/json",
127
+ )
128
+ self.assertEqual(resp.status_code, 200)
129
+ self.assertEqual(
130
+ resp.json(), {"rfid": "ABCD1234", "label_id": 1, "created": False}
131
+ )
132
+ mock_validate.assert_called_once_with("ABCD1234", kind=None)
133
+
134
+ @patch("config.middleware.Node.get_local", return_value=None)
135
+ @patch("config.middleware.get_site")
136
+ @patch("ocpp.rfid.views.validate_rfid_value")
137
+ def test_scan_next_post_requires_authentication(
138
+ self, mock_validate, mock_site, mock_node
139
+ ):
140
+ resp = self.client.post(
141
+ reverse("rfid-scan-next"),
142
+ data=json.dumps({"rfid": "ABCD1234"}),
143
+ content_type="application/json",
144
+ )
145
+ self.assertEqual(resp.status_code, 401)
146
+ self.assertEqual(resp.json(), {"error": "Authentication required"})
147
+ mock_validate.assert_not_called()
148
+
149
+ @patch("config.middleware.Node.get_local", return_value=None)
150
+ @patch("config.middleware.get_site")
151
+ def test_scan_next_post_invalid_json(self, mock_site, mock_node):
152
+ User = get_user_model()
153
+ user = User.objects.create_user("invalid-json", password="pwd")
154
+ self.client.force_login(user)
155
+ resp = self.client.post(
156
+ reverse("rfid-scan-next"),
157
+ data="{",
158
+ content_type="application/json",
159
+ )
160
+ self.assertEqual(resp.status_code, 400)
161
+ self.assertEqual(resp.json(), {"error": "Invalid JSON payload"})
162
+
163
+ def test_scan_next_requires_authentication(self):
164
+ self.client.logout()
165
+ resp = self.client.get(reverse("rfid-scan-next"))
166
+ self.assertEqual(resp.status_code, 302)
167
+ self.assertIn(reverse("pages:login"), resp.url)
168
+
106
169
 
107
170
  class ReaderNotificationTests(TestCase):
108
171
  def _mock_reader(self):
@@ -171,6 +234,74 @@ class ReaderNotificationTests(TestCase):
171
234
  self.assertTrue(getattr(reader, "stop_called", False))
172
235
 
173
236
 
237
+ class ValidateRfidValueTests(SimpleTestCase):
238
+ @patch("ocpp.rfid.reader.timezone.now")
239
+ @patch("ocpp.rfid.reader.notify_async")
240
+ @patch("ocpp.rfid.reader.RFID.objects.get_or_create")
241
+ def test_creates_new_tag(self, mock_get, mock_notify, mock_now):
242
+ fake_now = object()
243
+ mock_now.return_value = fake_now
244
+ tag = MagicMock()
245
+ tag.pk = 1
246
+ tag.label_id = 1
247
+ tag.allowed = True
248
+ tag.color = "B"
249
+ tag.released = False
250
+ tag.reference = None
251
+ tag.kind = RFID.CLASSIC
252
+ mock_get.return_value = (tag, True)
253
+
254
+ result = validate_rfid_value("abcd1234")
255
+
256
+ mock_get.assert_called_once_with(rfid="ABCD1234", defaults={})
257
+ tag.save.assert_called_once_with(update_fields=["last_seen_on"])
258
+ self.assertIs(tag.last_seen_on, fake_now)
259
+ mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
260
+ self.assertTrue(result["created"])
261
+ self.assertEqual(result["rfid"], "ABCD1234")
262
+
263
+ @patch("ocpp.rfid.reader.timezone.now")
264
+ @patch("ocpp.rfid.reader.notify_async")
265
+ @patch("ocpp.rfid.reader.RFID.objects.get_or_create")
266
+ def test_updates_existing_tag_kind(self, mock_get, mock_notify, mock_now):
267
+ fake_now = object()
268
+ mock_now.return_value = fake_now
269
+ tag = MagicMock()
270
+ tag.pk = 5
271
+ tag.label_id = 5
272
+ tag.allowed = False
273
+ tag.color = "G"
274
+ tag.released = True
275
+ tag.reference = None
276
+ tag.kind = RFID.CLASSIC
277
+ mock_get.return_value = (tag, False)
278
+
279
+ result = validate_rfid_value("abcd", kind=RFID.NTAG215)
280
+
281
+ mock_get.assert_called_once_with(
282
+ rfid="ABCD", defaults={"kind": RFID.NTAG215}
283
+ )
284
+ tag.save.assert_called_once_with(update_fields=["kind", "last_seen_on"])
285
+ self.assertIs(tag.last_seen_on, fake_now)
286
+ self.assertEqual(tag.kind, RFID.NTAG215)
287
+ mock_notify.assert_called_once_with("RFID 5 BAD", "ABCD G")
288
+ self.assertFalse(result["allowed"])
289
+ self.assertFalse(result["created"])
290
+ self.assertEqual(result["kind"], RFID.NTAG215)
291
+
292
+ def test_rejects_invalid_value(self):
293
+ result = validate_rfid_value("invalid!")
294
+ self.assertEqual(result, {"error": "RFID must be hexadecimal digits"})
295
+
296
+ def test_rejects_non_string_values(self):
297
+ result = validate_rfid_value(12345)
298
+ self.assertEqual(result, {"error": "RFID must be a string"})
299
+
300
+ def test_rejects_missing_value(self):
301
+ result = validate_rfid_value(None)
302
+ self.assertEqual(result, {"error": "RFID value is required"})
303
+
304
+
174
305
  class CardTypeDetectionTests(TestCase):
175
306
  def _mock_ntag_reader(self):
176
307
  class MockReader:
@@ -301,7 +432,12 @@ class RFIDDetectionScriptTests(SimpleTestCase):
301
432
  mock_detect.assert_called_once()
302
433
 
303
434
 
304
- class RestartViewTests(SimpleTestCase):
435
+ class RestartViewTests(TestCase):
436
+ def setUp(self):
437
+ User = get_user_model()
438
+ self.user = User.objects.create_user("restart-user", password="pwd")
439
+ self.client.force_login(self.user)
440
+
305
441
  @patch("config.middleware.Node.get_local", return_value=None)
306
442
  @patch("config.middleware.get_site")
307
443
  @patch("ocpp.rfid.views.restart_sources", return_value={"status": "restarted"})
@@ -311,8 +447,19 @@ class RestartViewTests(SimpleTestCase):
311
447
  self.assertEqual(resp.json(), {"status": "restarted"})
312
448
  mock_restart.assert_called_once()
313
449
 
450
+ def test_restart_requires_authentication(self):
451
+ self.client.logout()
452
+ resp = self.client.post(reverse("rfid-scan-restart"))
453
+ self.assertEqual(resp.status_code, 302)
454
+ self.assertIn(reverse("pages:login"), resp.url)
455
+
456
+
457
+ class ScanTestViewTests(TestCase):
458
+ def setUp(self):
459
+ User = get_user_model()
460
+ self.user = User.objects.create_user("scan-test-user", password="pwd")
461
+ self.client.force_login(self.user)
314
462
 
315
- class ScanTestViewTests(SimpleTestCase):
316
463
  @patch("config.middleware.Node.get_local", return_value=None)
317
464
  @patch("config.middleware.get_site")
318
465
  @patch("ocpp.rfid.views.test_sources", return_value={"irq_pin": 7})
@@ -332,6 +479,12 @@ class ScanTestViewTests(SimpleTestCase):
332
479
  self.assertEqual(resp.status_code, 500)
333
480
  self.assertEqual(resp.json(), {"error": "no scanner detected"})
334
481
 
482
+ def test_scan_test_requires_authentication(self):
483
+ self.client.logout()
484
+ resp = self.client.get(reverse("rfid-scan-test"))
485
+ self.assertEqual(resp.status_code, 302)
486
+ self.assertIn(reverse("pages:login"), resp.url)
487
+
335
488
 
336
489
  class RFIDLandingTests(TestCase):
337
490
  def test_scanner_view_registered_as_landing(self):
@@ -352,6 +505,8 @@ class RFIDLandingTests(TestCase):
352
505
  class ScannerTemplateTests(TestCase):
353
506
  def setUp(self):
354
507
  self.url = reverse("rfid-reader")
508
+ User = get_user_model()
509
+ self.user = User.objects.create_user("scanner-user", password="pwd")
355
510
 
356
511
  def test_configure_link_for_staff(self):
357
512
  User = get_user_model()
@@ -360,9 +515,11 @@ class ScannerTemplateTests(TestCase):
360
515
  resp = self.client.get(self.url)
361
516
  self.assertContains(resp, 'id="rfid-configure"')
362
517
 
363
- def test_no_link_for_anonymous(self):
518
+ def test_redirect_for_anonymous(self):
519
+ self.client.logout()
364
520
  resp = self.client.get(self.url)
365
- self.assertNotContains(resp, 'id="rfid-configure"')
521
+ self.assertEqual(resp.status_code, 302)
522
+ self.assertIn(reverse("pages:login"), resp.url)
366
523
 
367
524
  def test_advanced_fields_for_staff(self):
368
525
  User = get_user_model()
@@ -374,9 +531,12 @@ class ScannerTemplateTests(TestCase):
374
531
  self.assertContains(resp, 'id="rfid-released"')
375
532
  self.assertContains(resp, 'id="rfid-reference"')
376
533
 
377
- def test_basic_fields_for_public(self):
534
+ def test_basic_fields_for_authenticated_user(self):
535
+ self.client.logout()
536
+ self.client.force_login(self.user)
378
537
  resp = self.client.get(self.url)
379
538
  self.assertContains(resp, 'id="rfid-kind"')
539
+ self.assertNotContains(resp, 'id="rfid-connect-local"')
380
540
  self.assertNotContains(resp, 'id="rfid-rfid"')
381
541
  self.assertNotContains(resp, 'id="rfid-released"')
382
542
  self.assertNotContains(resp, 'id="rfid-reference"')
@@ -388,7 +548,9 @@ class ScannerTemplateTests(TestCase):
388
548
  resp = self.client.get(self.url)
389
549
  self.assertContains(resp, 'id="rfid-deep-read"')
390
550
 
391
- def test_no_deep_read_button_for_public(self):
551
+ def test_no_deep_read_button_for_authenticated_user(self):
552
+ self.client.logout()
553
+ self.client.force_login(self.user)
392
554
  resp = self.client.get(self.url)
393
555
  self.assertNotContains(resp, 'id="rfid-deep-read"')
394
556