arthexis 0.1.26__py3-none-any.whl → 0.1.28__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.
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/METADATA +16 -11
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/RECORD +39 -38
- config/settings.py +7 -1
- config/settings_helpers.py +176 -1
- config/urls.py +18 -2
- core/admin.py +265 -23
- core/apps.py +6 -2
- core/celery_utils.py +73 -0
- core/models.py +307 -63
- core/system.py +17 -2
- core/tasks.py +304 -129
- core/test_system_info.py +43 -5
- core/tests.py +202 -2
- core/user_data.py +52 -19
- core/views.py +70 -3
- nodes/admin.py +348 -3
- nodes/apps.py +1 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +146 -18
- nodes/tasks.py +1 -1
- nodes/tests.py +181 -48
- nodes/views.py +148 -3
- ocpp/admin.py +1001 -10
- ocpp/consumers.py +572 -7
- ocpp/models.py +499 -33
- ocpp/store.py +406 -40
- ocpp/tasks.py +109 -145
- ocpp/test_rfid.py +73 -2
- ocpp/tests.py +982 -90
- ocpp/urls.py +5 -0
- ocpp/views.py +172 -70
- pages/context_processors.py +2 -0
- pages/models.py +9 -0
- pages/tests.py +166 -18
- pages/urls.py +1 -0
- pages/views.py +66 -3
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/tasks.py
CHANGED
|
@@ -1,47 +1,78 @@
|
|
|
1
|
-
import base64
|
|
2
1
|
import json
|
|
3
2
|
import logging
|
|
4
3
|
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
5
|
from datetime import date, datetime, time, timedelta
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Iterable
|
|
7
8
|
|
|
8
9
|
from asgiref.sync import async_to_sync
|
|
9
10
|
from celery import shared_task
|
|
10
11
|
from django.conf import settings
|
|
11
12
|
from django.contrib.auth import get_user_model
|
|
12
|
-
from django.db.models import Q
|
|
13
|
+
from django.db.models import Q, Prefetch
|
|
13
14
|
from django.utils import timezone
|
|
14
|
-
import
|
|
15
|
-
from
|
|
16
|
-
from cryptography.hazmat.primitives import hashes
|
|
17
|
-
from cryptography.hazmat.primitives.asymmetric import padding
|
|
15
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
16
|
+
from websocket import WebSocketException, create_connection
|
|
18
17
|
|
|
19
18
|
from core import mailer
|
|
20
19
|
from nodes.models import Node
|
|
21
20
|
|
|
22
21
|
from . import store
|
|
23
22
|
from .models import Charger, MeterValue, Transaction
|
|
24
|
-
from .network import (
|
|
25
|
-
newest_transaction_timestamp,
|
|
26
|
-
serialize_charger_for_network,
|
|
27
|
-
serialize_transactions_for_forwarding,
|
|
28
|
-
)
|
|
29
|
-
|
|
30
23
|
logger = logging.getLogger(__name__)
|
|
31
24
|
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
@dataclass
|
|
27
|
+
class ForwardingSession:
|
|
28
|
+
"""Active websocket forwarding session for a charge point."""
|
|
29
|
+
|
|
30
|
+
charger_pk: int
|
|
31
|
+
node_id: int | None
|
|
32
|
+
url: str
|
|
33
|
+
connection: object
|
|
34
|
+
connected_at: datetime
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_connected(self) -> bool:
|
|
38
|
+
return bool(getattr(self.connection, "connected", False))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_FORWARDING_SESSIONS: dict[int, ForwardingSession] = {}
|
|
42
|
+
|
|
43
|
+
def _candidate_forwarding_urls(node: Node, charger: Charger) -> Iterable[str]:
|
|
44
|
+
"""Yield websocket URLs suitable for forwarding ``charger`` via ``node``."""
|
|
45
|
+
|
|
46
|
+
charger_id = (charger.charger_id or "").strip()
|
|
47
|
+
if not charger_id:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
encoded_id = quote(charger_id, safe="")
|
|
51
|
+
for base in node.iter_remote_urls("/"):
|
|
52
|
+
if not base:
|
|
53
|
+
continue
|
|
54
|
+
parsed = urlsplit(base)
|
|
55
|
+
if parsed.scheme not in {"http", "https"}:
|
|
56
|
+
continue
|
|
57
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
58
|
+
base_path = parsed.path.rstrip("/")
|
|
59
|
+
for prefix in ("", "/ws"):
|
|
60
|
+
path = f"{base_path}{prefix}/{encoded_id}".replace("//", "/")
|
|
61
|
+
if not path.startswith("/"):
|
|
62
|
+
path = f"/{path}"
|
|
63
|
+
yield urlunsplit((scheme, parsed.netloc, path, "", ""))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _close_forwarding_session(session: ForwardingSession) -> None:
|
|
67
|
+
"""Close the websocket connection associated with ``session`` if open."""
|
|
68
|
+
|
|
69
|
+
connection = session.connection
|
|
70
|
+
if connection is None:
|
|
71
|
+
return
|
|
36
72
|
try:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
hashes.SHA256(),
|
|
41
|
-
)
|
|
42
|
-
except Exception:
|
|
43
|
-
return None
|
|
44
|
-
return base64.b64encode(signature).decode()
|
|
73
|
+
connection.close()
|
|
74
|
+
except Exception: # pragma: no cover - best effort close
|
|
75
|
+
pass
|
|
45
76
|
|
|
46
77
|
|
|
47
78
|
@shared_task
|
|
@@ -159,20 +190,15 @@ def purge_meter_values() -> int:
|
|
|
159
190
|
purge_meter_readings = purge_meter_values
|
|
160
191
|
|
|
161
192
|
|
|
162
|
-
@shared_task
|
|
193
|
+
@shared_task(rate_limit="1/10m")
|
|
163
194
|
def push_forwarded_charge_points() -> int:
|
|
164
|
-
"""
|
|
195
|
+
"""Ensure websocket connections exist for forwarded charge points."""
|
|
165
196
|
|
|
166
197
|
local = Node.get_local()
|
|
167
198
|
if not local:
|
|
168
199
|
logger.debug("Forwarding skipped: local node not registered")
|
|
169
200
|
return 0
|
|
170
201
|
|
|
171
|
-
private_key = local.get_private_key()
|
|
172
|
-
if private_key is None:
|
|
173
|
-
logger.warning("Forwarding skipped: missing local node private key")
|
|
174
|
-
return 0
|
|
175
|
-
|
|
176
202
|
chargers_qs = (
|
|
177
203
|
Charger.objects.filter(export_transactions=True, forwarded_to__isnull=False)
|
|
178
204
|
.select_related("forwarded_to", "node_origin")
|
|
@@ -184,143 +210,75 @@ def push_forwarded_charge_points() -> int:
|
|
|
184
210
|
node_filter |= Q(node_origin=local)
|
|
185
211
|
|
|
186
212
|
chargers = list(chargers_qs.filter(node_filter))
|
|
213
|
+
active_ids = {charger.pk for charger in chargers}
|
|
214
|
+
|
|
215
|
+
# Close sessions that no longer map to active forwarded chargers.
|
|
216
|
+
for pk in list(_FORWARDING_SESSIONS.keys()):
|
|
217
|
+
if pk not in active_ids:
|
|
218
|
+
session = _FORWARDING_SESSIONS.pop(pk)
|
|
219
|
+
_close_forwarding_session(session)
|
|
220
|
+
|
|
187
221
|
if not chargers:
|
|
188
222
|
return 0
|
|
189
223
|
|
|
190
|
-
|
|
224
|
+
connected = 0
|
|
225
|
+
|
|
191
226
|
for charger in chargers:
|
|
192
227
|
target = charger.forwarded_to
|
|
193
228
|
if not target:
|
|
194
229
|
continue
|
|
195
230
|
if local.pk and target.pk == local.pk:
|
|
196
231
|
continue
|
|
197
|
-
grouped.setdefault(target, []).append(charger)
|
|
198
|
-
|
|
199
|
-
if not grouped:
|
|
200
|
-
return 0
|
|
201
|
-
|
|
202
|
-
forwarded_total = 0
|
|
203
|
-
|
|
204
|
-
for node, node_chargers in grouped.items():
|
|
205
|
-
if not node_chargers:
|
|
206
|
-
continue
|
|
207
|
-
|
|
208
|
-
initializing = [ch for ch in node_chargers if ch.forwarding_watermark is None]
|
|
209
|
-
charger_by_pk = {ch.pk: ch for ch in node_chargers}
|
|
210
|
-
transactions_map: dict[int, list[Transaction]] = {}
|
|
211
|
-
|
|
212
|
-
for charger in node_chargers:
|
|
213
|
-
watermark = charger.forwarding_watermark
|
|
214
|
-
if watermark is None:
|
|
215
|
-
continue
|
|
216
|
-
tx_queryset = (
|
|
217
|
-
Transaction.objects.filter(charger=charger, start_time__gt=watermark)
|
|
218
|
-
.select_related("charger")
|
|
219
|
-
.prefetch_related("meter_values")
|
|
220
|
-
.order_by("start_time")
|
|
221
|
-
)
|
|
222
|
-
txs = list(tx_queryset)
|
|
223
|
-
if txs:
|
|
224
|
-
transactions_map[charger.pk] = txs
|
|
225
|
-
|
|
226
|
-
transaction_payload = {"chargers": [], "transactions": []}
|
|
227
|
-
for charger_pk, txs in transactions_map.items():
|
|
228
|
-
charger = charger_by_pk[charger_pk]
|
|
229
|
-
transaction_payload["chargers"].append(
|
|
230
|
-
{
|
|
231
|
-
"charger_id": charger.charger_id,
|
|
232
|
-
"connector_id": charger.connector_id,
|
|
233
|
-
"require_rfid": charger.require_rfid,
|
|
234
|
-
}
|
|
235
|
-
)
|
|
236
|
-
transaction_payload["transactions"].extend(
|
|
237
|
-
serialize_transactions_for_forwarding(txs)
|
|
238
|
-
)
|
|
239
|
-
|
|
240
|
-
payload = {
|
|
241
|
-
"requester": str(local.uuid),
|
|
242
|
-
"requester_mac": local.mac_address,
|
|
243
|
-
"requester_public_key": local.public_key,
|
|
244
|
-
"chargers": [serialize_charger_for_network(ch) for ch in initializing],
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
has_transactions = bool(transaction_payload["transactions"])
|
|
248
|
-
if has_transactions or payload["chargers"]:
|
|
249
|
-
payload["transactions"] = transaction_payload
|
|
250
|
-
else:
|
|
251
|
-
continue
|
|
252
232
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if signature:
|
|
257
|
-
headers["X-Signature"] = signature
|
|
258
|
-
|
|
259
|
-
success = False
|
|
260
|
-
attempted = False
|
|
261
|
-
for url in node.iter_remote_urls("/nodes/network/chargers/forward/"):
|
|
262
|
-
if not url:
|
|
233
|
+
existing = _FORWARDING_SESSIONS.get(charger.pk)
|
|
234
|
+
if existing and existing.node_id == getattr(target, "pk", None):
|
|
235
|
+
if existing.is_connected:
|
|
263
236
|
continue
|
|
237
|
+
_close_forwarding_session(existing)
|
|
238
|
+
_FORWARDING_SESSIONS.pop(charger.pk, None)
|
|
264
239
|
|
|
265
|
-
|
|
240
|
+
for url in _candidate_forwarding_urls(target, charger):
|
|
266
241
|
try:
|
|
267
|
-
|
|
268
|
-
url, data=payload_json, headers=headers, timeout=5
|
|
269
|
-
)
|
|
270
|
-
except RequestException as exc:
|
|
271
|
-
logger.warning("Failed to forward chargers to %s: %s", node, exc)
|
|
272
|
-
continue
|
|
273
|
-
|
|
274
|
-
if not response.ok:
|
|
275
|
-
logger.warning(
|
|
276
|
-
"Forwarding request to %s via %s returned %s",
|
|
277
|
-
node,
|
|
242
|
+
connection = create_connection(
|
|
278
243
|
url,
|
|
279
|
-
|
|
244
|
+
timeout=5,
|
|
245
|
+
subprotocols=["ocpp1.6"],
|
|
280
246
|
)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
try:
|
|
284
|
-
data = response.json()
|
|
285
|
-
except ValueError:
|
|
286
|
-
logger.warning("Invalid JSON payload received from %s", node)
|
|
287
|
-
continue
|
|
288
|
-
|
|
289
|
-
if data.get("status") != "ok":
|
|
290
|
-
detail = data.get("detail") if isinstance(data, dict) else None
|
|
247
|
+
except (WebSocketException, OSError) as exc:
|
|
291
248
|
logger.warning(
|
|
292
|
-
"
|
|
293
|
-
|
|
249
|
+
"Websocket forwarding connection to %s via %s failed: %s",
|
|
250
|
+
target,
|
|
294
251
|
url,
|
|
295
|
-
|
|
252
|
+
exc,
|
|
296
253
|
)
|
|
297
254
|
continue
|
|
298
255
|
|
|
299
|
-
|
|
256
|
+
session = ForwardingSession(
|
|
257
|
+
charger_pk=charger.pk,
|
|
258
|
+
node_id=getattr(target, "pk", None),
|
|
259
|
+
url=url,
|
|
260
|
+
connection=connection,
|
|
261
|
+
connected_at=timezone.now(),
|
|
262
|
+
)
|
|
263
|
+
_FORWARDING_SESSIONS[charger.pk] = session
|
|
264
|
+
Charger.objects.filter(pk=charger.pk).update(
|
|
265
|
+
forwarding_watermark=session.connected_at
|
|
266
|
+
)
|
|
267
|
+
connected += 1
|
|
268
|
+
logger.info(
|
|
269
|
+
"Established forwarding websocket for charger %s to %s via %s",
|
|
270
|
+
charger.charger_id,
|
|
271
|
+
target,
|
|
272
|
+
url,
|
|
273
|
+
)
|
|
300
274
|
break
|
|
275
|
+
else:
|
|
276
|
+
logger.warning(
|
|
277
|
+
"Unable to establish forwarding websocket for charger %s",
|
|
278
|
+
charger.charger_id or charger.pk,
|
|
279
|
+
)
|
|
301
280
|
|
|
302
|
-
|
|
303
|
-
if not attempted:
|
|
304
|
-
logger.warning(
|
|
305
|
-
"No reachable host found for %s when forwarding chargers", node
|
|
306
|
-
)
|
|
307
|
-
continue
|
|
308
|
-
|
|
309
|
-
updates: dict[int, datetime] = {}
|
|
310
|
-
now = timezone.now()
|
|
311
|
-
for charger in initializing:
|
|
312
|
-
updates[charger.pk] = now
|
|
313
|
-
for charger_pk, txs in transactions_map.items():
|
|
314
|
-
latest = newest_transaction_timestamp(txs)
|
|
315
|
-
if latest:
|
|
316
|
-
updates[charger_pk] = latest
|
|
317
|
-
|
|
318
|
-
for pk, timestamp in updates.items():
|
|
319
|
-
Charger.objects.filter(pk=pk).update(forwarding_watermark=timestamp)
|
|
320
|
-
|
|
321
|
-
forwarded_total += len(transaction_payload["transactions"])
|
|
322
|
-
|
|
323
|
-
return forwarded_total
|
|
281
|
+
return connected
|
|
324
282
|
|
|
325
283
|
|
|
326
284
|
# Backwards compatibility alias for legacy schedules
|
|
@@ -403,9 +361,15 @@ def send_daily_session_report() -> int:
|
|
|
403
361
|
return 0
|
|
404
362
|
|
|
405
363
|
start, end, today = _resolve_report_window()
|
|
364
|
+
meter_value_prefetch = Prefetch(
|
|
365
|
+
"meter_values",
|
|
366
|
+
queryset=MeterValue.objects.filter(energy__isnull=False).order_by("timestamp"),
|
|
367
|
+
to_attr="prefetched_meter_values",
|
|
368
|
+
)
|
|
406
369
|
transactions = list(
|
|
407
370
|
Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
|
|
408
371
|
.select_related("charger", "account")
|
|
372
|
+
.prefetch_related(meter_value_prefetch)
|
|
409
373
|
.order_by("start_time")
|
|
410
374
|
)
|
|
411
375
|
if not transactions:
|
ocpp/test_rfid.py
CHANGED
|
@@ -707,11 +707,12 @@ class RFIDLastSeenTests(TestCase):
|
|
|
707
707
|
|
|
708
708
|
class RFIDDetectionScriptTests(SimpleTestCase):
|
|
709
709
|
@patch("ocpp.rfid.detect._ensure_django")
|
|
710
|
+
@patch("ocpp.rfid.detect._lockfile_status", return_value=(False, None))
|
|
710
711
|
@patch(
|
|
711
712
|
"ocpp.rfid.irq_wiring_check.check_irq_pin",
|
|
712
713
|
return_value={"irq_pin": DEFAULT_IRQ_PIN},
|
|
713
714
|
)
|
|
714
|
-
def test_detect_scanner_success(self, mock_check, _mock_setup):
|
|
715
|
+
def test_detect_scanner_success(self, mock_check, _mock_lock, _mock_setup):
|
|
715
716
|
result = detect_scanner()
|
|
716
717
|
self.assertEqual(
|
|
717
718
|
result,
|
|
@@ -723,16 +724,34 @@ class RFIDDetectionScriptTests(SimpleTestCase):
|
|
|
723
724
|
mock_check.assert_called_once()
|
|
724
725
|
|
|
725
726
|
@patch("ocpp.rfid.detect._ensure_django")
|
|
727
|
+
@patch("ocpp.rfid.detect._lockfile_status", return_value=(False, None))
|
|
726
728
|
@patch(
|
|
727
729
|
"ocpp.rfid.irq_wiring_check.check_irq_pin",
|
|
728
730
|
return_value={"error": "no scanner detected"},
|
|
729
731
|
)
|
|
730
|
-
def test_detect_scanner_failure(self, mock_check, _mock_setup):
|
|
732
|
+
def test_detect_scanner_failure(self, mock_check, _mock_lock, _mock_setup):
|
|
731
733
|
result = detect_scanner()
|
|
732
734
|
self.assertFalse(result["detected"])
|
|
733
735
|
self.assertEqual(result["reason"], "no scanner detected")
|
|
734
736
|
mock_check.assert_called_once()
|
|
735
737
|
|
|
738
|
+
@patch("ocpp.rfid.detect._ensure_django")
|
|
739
|
+
@patch(
|
|
740
|
+
"ocpp.rfid.detect._lockfile_status",
|
|
741
|
+
return_value=(True, Path("/locks/rfid.lck")),
|
|
742
|
+
)
|
|
743
|
+
@patch(
|
|
744
|
+
"ocpp.rfid.irq_wiring_check.check_irq_pin",
|
|
745
|
+
return_value={"error": "no scanner detected"},
|
|
746
|
+
)
|
|
747
|
+
def test_detect_scanner_assumed_with_lock(self, mock_check, _mock_lock, _mock_setup):
|
|
748
|
+
result = detect_scanner()
|
|
749
|
+
self.assertTrue(result["detected"])
|
|
750
|
+
self.assertTrue(result["assumed"])
|
|
751
|
+
self.assertEqual(result["reason"], "no scanner detected")
|
|
752
|
+
self.assertEqual(result["lockfile"], "/locks/rfid.lck")
|
|
753
|
+
mock_check.assert_called_once()
|
|
754
|
+
|
|
736
755
|
@patch(
|
|
737
756
|
"ocpp.rfid.detect.detect_scanner",
|
|
738
757
|
return_value={"detected": True, "irq_pin": DEFAULT_IRQ_PIN},
|
|
@@ -757,6 +776,58 @@ class RFIDDetectionScriptTests(SimpleTestCase):
|
|
|
757
776
|
self.assertIn("missing hardware", buffer.getvalue())
|
|
758
777
|
mock_detect.assert_called_once()
|
|
759
778
|
|
|
779
|
+
@patch(
|
|
780
|
+
"ocpp.rfid.detect.detect_scanner",
|
|
781
|
+
return_value={
|
|
782
|
+
"detected": True,
|
|
783
|
+
"assumed": True,
|
|
784
|
+
"reason": "no scanner detected",
|
|
785
|
+
"lockfile": "/locks/rfid.lck",
|
|
786
|
+
},
|
|
787
|
+
)
|
|
788
|
+
def test_detect_main_assumed_output(self, mock_detect):
|
|
789
|
+
buffer = io.StringIO()
|
|
790
|
+
with patch("sys.stdout", new=buffer):
|
|
791
|
+
exit_code = detect_main([])
|
|
792
|
+
self.assertEqual(exit_code, 0)
|
|
793
|
+
self.assertIn("assumed active", buffer.getvalue())
|
|
794
|
+
self.assertIn("/locks/rfid.lck", buffer.getvalue())
|
|
795
|
+
mock_detect.assert_called_once()
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
class RFIDLockFileUsageTests(SimpleTestCase):
|
|
799
|
+
@patch("ocpp.rfid.background_reader.is_configured", return_value=True)
|
|
800
|
+
def test_queue_result_marks_lock(self, _mock_config):
|
|
801
|
+
with patch(
|
|
802
|
+
"ocpp.rfid.background_reader._tag_queue.get",
|
|
803
|
+
return_value={"rfid": "ABC"},
|
|
804
|
+
) as mock_get, patch(
|
|
805
|
+
"ocpp.rfid.background_reader._mark_scanner_used"
|
|
806
|
+
) as mock_mark:
|
|
807
|
+
result = background_reader.get_next_tag()
|
|
808
|
+
self.assertEqual(result["rfid"], "ABC")
|
|
809
|
+
mock_get.assert_called_once()
|
|
810
|
+
mock_mark.assert_called_once()
|
|
811
|
+
|
|
812
|
+
@patch("ocpp.rfid.background_reader.is_configured", return_value=True)
|
|
813
|
+
def test_direct_read_marks_lock(self, _mock_config):
|
|
814
|
+
with (
|
|
815
|
+
patch(
|
|
816
|
+
"ocpp.rfid.background_reader._tag_queue.get",
|
|
817
|
+
side_effect=background_reader.queue.Empty,
|
|
818
|
+
) as mock_get,
|
|
819
|
+
patch(
|
|
820
|
+
"ocpp.rfid.reader.read_rfid",
|
|
821
|
+
return_value={"rfid": "XYZ"},
|
|
822
|
+
) as mock_read,
|
|
823
|
+
patch("ocpp.rfid.background_reader._mark_scanner_used") as mock_mark,
|
|
824
|
+
):
|
|
825
|
+
result = background_reader.get_next_tag()
|
|
826
|
+
self.assertEqual(result["rfid"], "XYZ")
|
|
827
|
+
mock_get.assert_called_once()
|
|
828
|
+
mock_read.assert_called_once()
|
|
829
|
+
mock_mark.assert_called_once()
|
|
830
|
+
|
|
760
831
|
|
|
761
832
|
class RFIDLandingTests(TestCase):
|
|
762
833
|
def test_scanner_view_registered_as_landing(self):
|