arthexis 0.1.3__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 (73) hide show
  1. arthexis-0.1.3.dist-info/METADATA +126 -0
  2. arthexis-0.1.3.dist-info/RECORD +73 -0
  3. arthexis-0.1.3.dist-info/WHEEL +5 -0
  4. arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
  5. arthexis-0.1.3.dist-info/top_level.txt +5 -0
  6. config/__init__.py +6 -0
  7. config/active_app.py +15 -0
  8. config/asgi.py +29 -0
  9. config/auth_app.py +8 -0
  10. config/celery.py +19 -0
  11. config/context_processors.py +68 -0
  12. config/loadenv.py +11 -0
  13. config/logging.py +43 -0
  14. config/middleware.py +25 -0
  15. config/offline.py +47 -0
  16. config/settings.py +374 -0
  17. config/urls.py +91 -0
  18. config/wsgi.py +17 -0
  19. core/__init__.py +0 -0
  20. core/admin.py +830 -0
  21. core/apps.py +67 -0
  22. core/backends.py +82 -0
  23. core/entity.py +97 -0
  24. core/environment.py +43 -0
  25. core/fields.py +70 -0
  26. core/lcd_screen.py +77 -0
  27. core/middleware.py +34 -0
  28. core/models.py +1277 -0
  29. core/notifications.py +95 -0
  30. core/release.py +451 -0
  31. core/system.py +111 -0
  32. core/tasks.py +100 -0
  33. core/tests.py +483 -0
  34. core/urls.py +11 -0
  35. core/user_data.py +333 -0
  36. core/views.py +431 -0
  37. nodes/__init__.py +0 -0
  38. nodes/actions.py +72 -0
  39. nodes/admin.py +347 -0
  40. nodes/apps.py +76 -0
  41. nodes/lcd.py +151 -0
  42. nodes/models.py +577 -0
  43. nodes/tasks.py +50 -0
  44. nodes/tests.py +1072 -0
  45. nodes/urls.py +13 -0
  46. nodes/utils.py +62 -0
  47. nodes/views.py +262 -0
  48. ocpp/__init__.py +0 -0
  49. ocpp/admin.py +392 -0
  50. ocpp/apps.py +24 -0
  51. ocpp/consumers.py +267 -0
  52. ocpp/evcs.py +911 -0
  53. ocpp/models.py +300 -0
  54. ocpp/routing.py +9 -0
  55. ocpp/simulator.py +357 -0
  56. ocpp/store.py +175 -0
  57. ocpp/tasks.py +27 -0
  58. ocpp/test_export_import.py +129 -0
  59. ocpp/test_rfid.py +345 -0
  60. ocpp/tests.py +1229 -0
  61. ocpp/transactions_io.py +119 -0
  62. ocpp/urls.py +17 -0
  63. ocpp/views.py +359 -0
  64. pages/__init__.py +0 -0
  65. pages/admin.py +231 -0
  66. pages/apps.py +10 -0
  67. pages/checks.py +41 -0
  68. pages/context_processors.py +72 -0
  69. pages/models.py +224 -0
  70. pages/tests.py +628 -0
  71. pages/urls.py +17 -0
  72. pages/utils.py +13 -0
  73. pages/views.py +191 -0
ocpp/store.py ADDED
@@ -0,0 +1,175 @@
1
+ """In-memory store for OCPP data with file backed logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ import json
8
+ import re
9
+
10
+ connections = {}
11
+ transactions = {}
12
+ logs: dict[str, dict[str, list[str]]] = {"charger": {}, "simulator": {}}
13
+ # store per charger session logs before they are flushed to disk
14
+ history: dict[str, dict[str, object]] = {}
15
+ simulators = {}
16
+
17
+ # mapping of charger id / cp_path to friendly names used for log files
18
+ log_names: dict[str, dict[str, str]] = {"charger": {}, "simulator": {}}
19
+
20
+ LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
21
+ LOG_DIR.mkdir(exist_ok=True)
22
+ SESSION_DIR = LOG_DIR / "sessions"
23
+ SESSION_DIR.mkdir(exist_ok=True)
24
+
25
+
26
+ def register_log_name(cid: str, name: str, log_type: str = "charger") -> None:
27
+ """Register a friendly name for the id used in log files."""
28
+
29
+ names = log_names[log_type]
30
+ # Ensure lookups are case-insensitive by overwriting any existing entry
31
+ # that matches the provided cid regardless of case.
32
+ for key in list(names.keys()):
33
+ if key.lower() == cid.lower():
34
+ cid = key
35
+ break
36
+ names[cid] = name
37
+
38
+
39
+ def _safe_name(name: str) -> str:
40
+ return re.sub(r"[^\w.-]", "_", name)
41
+
42
+
43
+ def _file_path(cid: str, log_type: str = "charger") -> Path:
44
+ name = log_names[log_type].get(cid, cid)
45
+ return LOG_DIR / f"{log_type}.{_safe_name(name)}.log"
46
+
47
+
48
+ def add_log(cid: str, entry: str, log_type: str = "charger") -> None:
49
+ """Append a timestamped log entry for the given id and log type."""
50
+
51
+ timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
52
+ entry = f"{timestamp} {entry}"
53
+
54
+ store = logs[log_type]
55
+ # Store log entries under the cid as provided but allow retrieval using
56
+ # any casing by recording entries in a case-insensitive manner.
57
+ key = next((k for k in store.keys() if k.lower() == cid.lower()), cid)
58
+ store.setdefault(key, []).append(entry)
59
+ path = _file_path(key, log_type)
60
+ with path.open("a", encoding="utf-8") as handle:
61
+ handle.write(entry + "\n")
62
+
63
+
64
+ def _session_folder(cid: str) -> Path:
65
+ """Return the folder path for session logs for the given charger."""
66
+
67
+ name = log_names["charger"].get(cid, cid)
68
+ folder = SESSION_DIR / _safe_name(name)
69
+ folder.mkdir(parents=True, exist_ok=True)
70
+ return folder
71
+
72
+
73
+ def start_session_log(cid: str, tx_id: int) -> None:
74
+ """Begin logging a session for the given charger and transaction id."""
75
+
76
+ history[cid] = {
77
+ "transaction": tx_id,
78
+ "start": datetime.utcnow(),
79
+ "messages": [],
80
+ }
81
+
82
+
83
+ def add_session_message(cid: str, message: str) -> None:
84
+ """Record a raw message for the current session if one is active."""
85
+
86
+ sess = history.get(cid)
87
+ if not sess:
88
+ return
89
+ sess["messages"].append({
90
+ "timestamp": datetime.utcnow().isoformat() + "Z",
91
+ "message": message,
92
+ })
93
+
94
+
95
+ def end_session_log(cid: str) -> None:
96
+ """Write any recorded session log to disk for the given charger."""
97
+
98
+ sess = history.pop(cid, None)
99
+ if not sess:
100
+ return
101
+ folder = _session_folder(cid)
102
+ date = sess["start"].strftime("%Y%m%d")
103
+ tx_id = sess.get("transaction")
104
+ filename = f"{date}_{tx_id}.json"
105
+ path = folder / filename
106
+ with path.open("w", encoding="utf-8") as handle:
107
+ json.dump(sess["messages"], handle, ensure_ascii=False, indent=2)
108
+
109
+
110
+ def get_logs(cid: str, log_type: str = "charger") -> list[str]:
111
+ """Return all log entries for the given id and type."""
112
+
113
+ names = log_names[log_type]
114
+ # Try to find a matching log name case-insensitively
115
+ name = names.get(cid)
116
+ if name is None:
117
+ for key, value in names.items():
118
+ if key.lower() == cid.lower():
119
+ cid = key
120
+ name = value
121
+ break
122
+ else:
123
+ try:
124
+ if log_type == "simulator":
125
+ from .models import Simulator
126
+
127
+ sim = Simulator.objects.filter(cp_path__iexact=cid).first()
128
+ if sim:
129
+ cid = sim.cp_path
130
+ name = sim.name
131
+ names[cid] = name
132
+ else:
133
+ from .models import Charger
134
+
135
+ ch = Charger.objects.filter(charger_id__iexact=cid).first()
136
+ if ch and ch.name:
137
+ cid = ch.charger_id
138
+ name = ch.name
139
+ names[cid] = name
140
+ except Exception: # pragma: no cover - best effort lookup
141
+ pass
142
+
143
+ path = _file_path(cid, log_type)
144
+ if not path.exists():
145
+ target = f"{log_type}.{_safe_name(name or cid).lower()}"
146
+ for file in LOG_DIR.glob(f"{log_type}.*.log"):
147
+ if file.stem.lower() == target:
148
+ path = file
149
+ break
150
+
151
+ if path.exists():
152
+ return path.read_text(encoding="utf-8").splitlines()
153
+
154
+ store = logs[log_type]
155
+ for key, entries in store.items():
156
+ if key.lower() == cid.lower():
157
+ return entries
158
+ return []
159
+
160
+
161
+ def clear_log(cid: str, log_type: str = "charger") -> None:
162
+ """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()
ocpp/tasks.py ADDED
@@ -0,0 +1,27 @@
1
+ import logging
2
+ from datetime import timedelta
3
+
4
+ from celery import shared_task
5
+ from django.utils import timezone
6
+ from django.db.models import Q
7
+
8
+ from .models import MeterReading
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @shared_task
14
+ def purge_meter_readings() -> int:
15
+ """Delete meter readings older than 7 days.
16
+
17
+ Readings tied to transactions without a recorded meter_stop are preserved so
18
+ that ongoing or incomplete sessions retain their energy data.
19
+ Returns the number of deleted readings.
20
+ """
21
+ cutoff = timezone.now() - timedelta(days=7)
22
+ qs = MeterReading.objects.filter(timestamp__lt=cutoff).filter(
23
+ Q(transaction__isnull=True) | Q(transaction__meter_stop__isnull=False)
24
+ )
25
+ deleted, _ = qs.delete()
26
+ logger.info("Purged %s meter readings", deleted)
27
+ return deleted
@@ -0,0 +1,129 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from datetime import timedelta
5
+ import io
6
+
7
+ from django.core.management import call_command
8
+ from django.test import TestCase
9
+ from django.utils import timezone
10
+ from django.urls import reverse
11
+ from django.contrib.auth import get_user_model
12
+
13
+ from ocpp.models import Charger, Transaction, MeterReading
14
+ from core.models import EnergyAccount
15
+
16
+
17
+ class TransactionExportImportTests(TestCase):
18
+ def setUp(self):
19
+ self.account = EnergyAccount.objects.create(name="ACC")
20
+ self.ch1 = Charger.objects.create(charger_id="C1")
21
+ self.ch2 = Charger.objects.create(charger_id="C2")
22
+ now = timezone.now()
23
+ self.tx_old = Transaction.objects.create(
24
+ charger=self.ch1,
25
+ account=self.account,
26
+ start_time=now - timedelta(days=5),
27
+ )
28
+ self.tx_new = Transaction.objects.create(
29
+ charger=self.ch2,
30
+ start_time=now,
31
+ )
32
+ MeterReading.objects.create(
33
+ charger=self.ch1,
34
+ transaction=self.tx_old,
35
+ timestamp=now - timedelta(days=5),
36
+ value=1,
37
+ unit="kW",
38
+ )
39
+
40
+ def test_export_filters_and_import_creates_chargers(self):
41
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
42
+ tmp_path = tmp.name
43
+
44
+ start = (timezone.now() - timedelta(days=1)).date().isoformat()
45
+ call_command(
46
+ "export_transactions",
47
+ tmp_path,
48
+ "--start",
49
+ start,
50
+ "--chargers",
51
+ self.ch2.charger_id,
52
+ )
53
+ with open(tmp_path, "r", encoding="utf-8") as fh:
54
+ data = json.load(fh)
55
+ self.assertEqual(len(data["transactions"]), 1)
56
+ self.assertEqual(data["transactions"][0]["charger"], "C2")
57
+
58
+ MeterReading.objects.all().delete()
59
+ Transaction.objects.all().delete()
60
+ Charger.objects.all().delete()
61
+
62
+ call_command("import_transactions", tmp_path)
63
+ os.remove(tmp_path)
64
+
65
+ self.assertTrue(Charger.objects.filter(charger_id="C2").exists())
66
+ self.assertEqual(Transaction.objects.count(), 1)
67
+ tx = Transaction.objects.first()
68
+ self.assertEqual(tx.charger.charger_id, "C2")
69
+
70
+
71
+ class TransactionAdminExportImportTests(TestCase):
72
+ def setUp(self):
73
+ self.account = EnergyAccount.objects.create(name="ACC")
74
+ self.ch1 = Charger.objects.create(charger_id="C1")
75
+ self.ch2 = Charger.objects.create(charger_id="C2")
76
+ now = timezone.now()
77
+ Transaction.objects.create(
78
+ charger=self.ch1,
79
+ account=self.account,
80
+ start_time=now - timedelta(days=2),
81
+ )
82
+ Transaction.objects.create(
83
+ charger=self.ch2,
84
+ start_time=now,
85
+ )
86
+ User = get_user_model()
87
+ self.admin = User.objects.create_superuser(
88
+ username="txadmin", email="txadmin@example.com", password="pwd"
89
+ )
90
+ self.client.force_login(self.admin)
91
+
92
+ def test_admin_export_filters(self):
93
+ url = reverse("admin:ocpp_transaction_export")
94
+ start = (timezone.now() - timedelta(days=1)).isoformat()
95
+ response = self.client.post(
96
+ url,
97
+ {"start": start, "chargers": [self.ch2.pk]},
98
+ )
99
+ self.assertEqual(response.status_code, 200)
100
+ data = json.loads(response.content)
101
+ self.assertEqual(len(data["transactions"]), 1)
102
+ self.assertEqual(data["transactions"][0]["charger"], "C2")
103
+
104
+ def test_admin_import_creates_charger(self):
105
+ url = reverse("admin:ocpp_transaction_import")
106
+ payload = {
107
+ "chargers": [
108
+ {"charger_id": "C9", "connector_id": 1, "require_rfid": False}
109
+ ],
110
+ "transactions": [
111
+ {
112
+ "charger": "C9",
113
+ "account": None,
114
+ "rfid": "",
115
+ "vin": "",
116
+ "meter_start": 0,
117
+ "meter_stop": 0,
118
+ "start_time": timezone.now().isoformat(),
119
+ "stop_time": None,
120
+ "meter_readings": [],
121
+ }
122
+ ],
123
+ }
124
+ json_file = io.StringIO(json.dumps(payload))
125
+ json_file.name = "tx.json"
126
+ response = self.client.post(url, {"file": json_file})
127
+ self.assertEqual(response.status_code, 302)
128
+ self.assertTrue(Charger.objects.filter(charger_id="C9").exists())
129
+ self.assertEqual(Transaction.objects.filter(charger__charger_id="C9").count(), 1)
ocpp/test_rfid.py ADDED
@@ -0,0 +1,345 @@
1
+ import os
2
+ from unittest.mock import patch, MagicMock, call
3
+
4
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
5
+
6
+ import django
7
+ django.setup()
8
+
9
+ from django.test import SimpleTestCase, TestCase
10
+ from django.urls import reverse
11
+ from django.contrib.auth import get_user_model
12
+ from django.contrib.sites.models import Site
13
+
14
+ from pages.models import Application, Module
15
+ from nodes.models import Node, NodeRole
16
+
17
+ from core.models import RFID
18
+ from ocpp.rfid.reader import read_rfid, enable_deep_read
19
+
20
+
21
+ class ScanNextViewTests(SimpleTestCase):
22
+ @patch("config.middleware.Node.get_local", return_value=None)
23
+ @patch("config.middleware.get_site")
24
+ @patch(
25
+ "ocpp.rfid.views.scan_sources",
26
+ return_value={
27
+ "rfid": "ABCD1234",
28
+ "label_id": 1,
29
+ "created": False,
30
+ "kind": RFID.CLASSIC,
31
+ },
32
+ )
33
+ def test_scan_next_success(self, mock_scan, mock_site, mock_node):
34
+ resp = self.client.get(reverse("rfid-scan-next"))
35
+ self.assertEqual(resp.status_code, 200)
36
+ self.assertEqual(
37
+ resp.json(),
38
+ {
39
+ "rfid": "ABCD1234",
40
+ "label_id": 1,
41
+ "created": False,
42
+ "kind": RFID.CLASSIC,
43
+ },
44
+ )
45
+
46
+ @patch("config.middleware.Node.get_local", return_value=None)
47
+ @patch("config.middleware.get_site")
48
+ @patch("ocpp.rfid.views.scan_sources", return_value={"error": "boom"})
49
+ def test_scan_next_error(self, mock_scan, mock_site, mock_node):
50
+ resp = self.client.get(reverse("rfid-scan-next"))
51
+ self.assertEqual(resp.status_code, 500)
52
+ self.assertEqual(resp.json(), {"error": "boom"})
53
+
54
+
55
+ class ReaderNotificationTests(TestCase):
56
+ def _mock_reader(self):
57
+ class MockReader:
58
+ MI_OK = 1
59
+ PICC_REQIDL = 0
60
+
61
+ def MFRC522_Request(self, _):
62
+ return (self.MI_OK, None)
63
+
64
+ def MFRC522_Anticoll(self):
65
+ return (self.MI_OK, [0xAB, 0xCD, 0x12, 0x34, 0x56])
66
+
67
+ return MockReader()
68
+
69
+ @patch("ocpp.rfid.reader.notify_async")
70
+ @patch("core.models.RFID.objects.get_or_create")
71
+ def test_notify_on_allowed_tag(self, mock_get, mock_notify):
72
+ reference = MagicMock(value="https://example.com")
73
+ tag = MagicMock(
74
+ label_id=1,
75
+ pk=1,
76
+ allowed=True,
77
+ color="B",
78
+ released=False,
79
+ reference=reference,
80
+ )
81
+ mock_get.return_value = (tag, False)
82
+
83
+ result = read_rfid(mfrc=self._mock_reader(), cleanup=False)
84
+ self.assertEqual(result["label_id"], 1)
85
+ self.assertEqual(result["kind"], RFID.CLASSIC)
86
+ self.assertEqual(result["reference"], "https://example.com")
87
+ self.assertEqual(mock_notify.call_count, 1)
88
+ mock_notify.assert_has_calls(
89
+ [call("RFID 1 OK", f"{result['rfid']} B")]
90
+ )
91
+
92
+ @patch("ocpp.rfid.reader.notify_async")
93
+ @patch("core.models.RFID.objects.get_or_create")
94
+ def test_notify_on_disallowed_tag(self, mock_get, mock_notify):
95
+ tag = MagicMock(
96
+ label_id=2,
97
+ pk=2,
98
+ allowed=False,
99
+ color="B",
100
+ released=False,
101
+ reference=None,
102
+ )
103
+ mock_get.return_value = (tag, False)
104
+
105
+ result = read_rfid(mfrc=self._mock_reader(), cleanup=False)
106
+ self.assertEqual(result["kind"], RFID.CLASSIC)
107
+ self.assertEqual(mock_notify.call_count, 1)
108
+ mock_notify.assert_has_calls(
109
+ [call("RFID 2 BAD", f"{result['rfid']} B")]
110
+ )
111
+
112
+
113
+ class CardTypeDetectionTests(TestCase):
114
+ def _mock_ntag_reader(self):
115
+ class MockReader:
116
+ MI_OK = 1
117
+ PICC_REQIDL = 0
118
+
119
+ def MFRC522_Request(self, _):
120
+ return (self.MI_OK, None)
121
+
122
+ def MFRC522_Anticoll(self):
123
+ return (
124
+ self.MI_OK,
125
+ [0x04, 0xD3, 0x2A, 0x1B, 0x5F, 0x23, 0x19],
126
+ )
127
+
128
+ return MockReader()
129
+
130
+ @patch("ocpp.rfid.reader.notify_async")
131
+ def test_detects_ntag215(self, _mock_notify):
132
+ result = read_rfid(mfrc=self._mock_ntag_reader(), cleanup=False)
133
+ self.assertEqual(result["kind"], RFID.NTAG215)
134
+
135
+
136
+ class RFIDLastSeenTests(TestCase):
137
+ def _mock_reader(self):
138
+ class MockReader:
139
+ MI_OK = 1
140
+ PICC_REQIDL = 0
141
+
142
+ def MFRC522_Request(self, _):
143
+ return (self.MI_OK, None)
144
+
145
+ def MFRC522_Anticoll(self):
146
+ return (self.MI_OK, [0xAB, 0xCD, 0x12, 0x34])
147
+
148
+ return MockReader()
149
+
150
+ @patch("ocpp.rfid.reader.notify_async")
151
+ def test_last_seen_updated_on_read(self, _mock_notify):
152
+ tag = RFID.objects.create(rfid="ABCD1234")
153
+ result = read_rfid(mfrc=self._mock_reader(), cleanup=False)
154
+ tag.refresh_from_db()
155
+ self.assertIsNotNone(tag.last_seen_on)
156
+ self.assertEqual(result["kind"], RFID.CLASSIC)
157
+
158
+
159
+ class RestartViewTests(SimpleTestCase):
160
+ @patch("config.middleware.Node.get_local", return_value=None)
161
+ @patch("config.middleware.get_site")
162
+ @patch("ocpp.rfid.views.restart_sources", return_value={"status": "restarted"})
163
+ def test_restart_endpoint(self, mock_restart, mock_site, mock_node):
164
+ resp = self.client.post(reverse("rfid-scan-restart"))
165
+ self.assertEqual(resp.status_code, 200)
166
+ self.assertEqual(resp.json(), {"status": "restarted"})
167
+ mock_restart.assert_called_once()
168
+
169
+
170
+ class ScanTestViewTests(SimpleTestCase):
171
+ @patch("config.middleware.Node.get_local", return_value=None)
172
+ @patch("config.middleware.get_site")
173
+ @patch("ocpp.rfid.views.test_sources", return_value={"irq_pin": 7})
174
+ def test_scan_test_success(self, mock_test, mock_site, mock_node):
175
+ resp = self.client.get(reverse("rfid-scan-test"))
176
+ self.assertEqual(resp.status_code, 200)
177
+ self.assertEqual(resp.json(), {"irq_pin": 7})
178
+
179
+ @patch("config.middleware.Node.get_local", return_value=None)
180
+ @patch("config.middleware.get_site")
181
+ @patch(
182
+ "ocpp.rfid.views.test_sources",
183
+ return_value={"error": "no scanner detected"},
184
+ )
185
+ def test_scan_test_error(self, mock_test, mock_site, mock_node):
186
+ resp = self.client.get(reverse("rfid-scan-test"))
187
+ self.assertEqual(resp.status_code, 500)
188
+ self.assertEqual(resp.json(), {"error": "no scanner detected"})
189
+
190
+
191
+ class RFIDLandingTests(TestCase):
192
+ def test_scanner_view_registered_as_landing(self):
193
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
194
+ Node.objects.update_or_create(
195
+ mac_address=Node.get_current_mac(),
196
+ defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
197
+ )
198
+ Site.objects.update_or_create(
199
+ id=1, defaults={"domain": "testserver", "name": ""}
200
+ )
201
+ app = Application.objects.create(name="Ocpp")
202
+ module = Module.objects.create(node_role=role, application=app, path="/ocpp/")
203
+ module.create_landings()
204
+ self.assertTrue(
205
+ module.landings.filter(path="/ocpp/rfid/").exists()
206
+ )
207
+
208
+ class ScannerTemplateTests(TestCase):
209
+ def setUp(self):
210
+ self.url = reverse("rfid-reader")
211
+
212
+ def test_configure_link_for_staff(self):
213
+ User = get_user_model()
214
+ staff = User.objects.create_user("staff", password="pwd", is_staff=True)
215
+ self.client.force_login(staff)
216
+ resp = self.client.get(self.url)
217
+ self.assertContains(resp, 'id="rfid-configure"')
218
+
219
+ def test_no_link_for_anonymous(self):
220
+ resp = self.client.get(self.url)
221
+ self.assertNotContains(resp, 'id="rfid-configure"')
222
+
223
+ def test_advanced_fields_for_staff(self):
224
+ User = get_user_model()
225
+ staff = User.objects.create_user("staff2", password="pwd", is_staff=True)
226
+ self.client.force_login(staff)
227
+ resp = self.client.get(self.url)
228
+ self.assertContains(resp, 'id="rfid-kind"')
229
+ self.assertContains(resp, 'id="rfid-rfid"')
230
+ self.assertContains(resp, 'id="rfid-released"')
231
+ self.assertContains(resp, 'id="rfid-reference"')
232
+
233
+ def test_basic_fields_for_public(self):
234
+ resp = self.client.get(self.url)
235
+ self.assertContains(resp, 'id="rfid-kind"')
236
+ self.assertNotContains(resp, 'id="rfid-rfid"')
237
+ self.assertNotContains(resp, 'id="rfid-released"')
238
+ self.assertNotContains(resp, 'id="rfid-reference"')
239
+
240
+ def test_deep_read_button_for_staff(self):
241
+ User = get_user_model()
242
+ staff = User.objects.create_user("staff3", password="pwd", is_staff=True)
243
+ self.client.force_login(staff)
244
+ resp = self.client.get(self.url)
245
+ self.assertContains(resp, 'id="rfid-deep-read"')
246
+
247
+ def test_no_deep_read_button_for_public(self):
248
+ resp = self.client.get(self.url)
249
+ self.assertNotContains(resp, 'id="rfid-deep-read"')
250
+
251
+
252
+ class ReaderPollingTests(SimpleTestCase):
253
+ def _mock_reader_no_tag(self):
254
+ class MockReader:
255
+ MI_OK = 1
256
+ PICC_REQIDL = 0
257
+
258
+ def MFRC522_Request(self, _):
259
+ return (0, None)
260
+
261
+ return MockReader()
262
+
263
+ @patch("ocpp.rfid.reader.time.sleep")
264
+ def test_poll_interval_used(self, mock_sleep):
265
+ read_rfid(
266
+ mfrc=self._mock_reader_no_tag(),
267
+ cleanup=False,
268
+ timeout=0.002,
269
+ poll_interval=0.001,
270
+ )
271
+ mock_sleep.assert_called_with(0.001)
272
+
273
+ @patch("ocpp.rfid.reader.time.sleep")
274
+ def test_use_irq_skips_sleep(self, mock_sleep):
275
+ read_rfid(
276
+ mfrc=self._mock_reader_no_tag(),
277
+ cleanup=False,
278
+ timeout=0.002,
279
+ use_irq=True,
280
+ )
281
+ mock_sleep.assert_not_called()
282
+
283
+
284
+ class DeepReadViewTests(TestCase):
285
+ @patch("config.middleware.Node.get_local", return_value=None)
286
+ @patch("config.middleware.get_site")
287
+ @patch("ocpp.rfid.views.enable_deep_read_mode", return_value={"status": "deep", "timeout": 60})
288
+ def test_enable_deep_read(self, mock_enable, mock_site, mock_node):
289
+ User = get_user_model()
290
+ staff = User.objects.create_user("staff4", password="pwd", is_staff=True)
291
+ self.client.force_login(staff)
292
+ resp = self.client.post(reverse("rfid-scan-deep"))
293
+ self.assertEqual(resp.status_code, 200)
294
+ self.assertEqual(resp.json(), {"status": "deep", "timeout": 60})
295
+ mock_enable.assert_called_once()
296
+
297
+ def test_forbidden_for_anonymous(self):
298
+ resp = self.client.post(reverse("rfid-scan-deep"))
299
+ self.assertNotEqual(resp.status_code, 200)
300
+
301
+
302
+ class DeepReadAuthTests(TestCase):
303
+ class MockReader:
304
+ MI_OK = 1
305
+ MI_ERR = 2
306
+ PICC_REQIDL = 0
307
+ PICC_AUTHENT1A = 0x60
308
+ PICC_AUTHENT1B = 0x61
309
+
310
+ def __init__(self):
311
+ self.auth_calls = []
312
+
313
+ def MFRC522_Request(self, _):
314
+ return (self.MI_OK, None)
315
+
316
+ def MFRC522_Anticoll(self):
317
+ return (self.MI_OK, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE])
318
+
319
+ def MFRC522_Auth(self, mode, block, key, uid):
320
+ self.auth_calls.append(mode)
321
+ return self.MI_ERR if mode == self.PICC_AUTHENT1A else self.MI_OK
322
+
323
+ def MFRC522_Read(self, block):
324
+ return (self.MI_OK, [0] * 16)
325
+
326
+ @patch("core.notifications.notify_async")
327
+ @patch("core.models.RFID.objects.get_or_create")
328
+ def test_auth_tries_key_a_then_b(self, mock_get, mock_notify):
329
+ tag = MagicMock(
330
+ label_id=1,
331
+ pk=1,
332
+ allowed=True,
333
+ color="B",
334
+ released=False,
335
+ reference=None,
336
+ )
337
+ mock_get.return_value = (tag, False)
338
+ reader = self.MockReader()
339
+ enable_deep_read(60)
340
+ read_rfid(mfrc=reader, cleanup=False)
341
+ self.assertGreaterEqual(len(reader.auth_calls), 2)
342
+ self.assertEqual(reader.auth_calls[0], reader.PICC_AUTHENT1A)
343
+ self.assertEqual(reader.auth_calls[1], reader.PICC_AUTHENT1B)
344
+
345
+