arthexis 0.1.16__py3-none-any.whl → 0.1.18__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.16.dist-info → arthexis-0.1.18.dist-info}/METADATA +1 -1
- {arthexis-0.1.16.dist-info → arthexis-0.1.18.dist-info}/RECORD +26 -25
- config/middleware.py +47 -1
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +69 -9
- core/backends.py +2 -0
- core/changelog.py +66 -5
- core/models.py +88 -7
- core/release.py +55 -2
- core/system.py +1 -1
- core/tasks.py +0 -6
- core/tests.py +131 -0
- core/views.py +112 -24
- ocpp/admin.py +92 -10
- ocpp/consumers.py +63 -19
- ocpp/test_rfid.py +118 -3
- ocpp/tests.py +225 -0
- ocpp/views.py +46 -7
- pages/admin.py +87 -5
- pages/apps.py +3 -0
- pages/site_config.py +137 -0
- pages/tests.py +206 -2
- {arthexis-0.1.16.dist-info → arthexis-0.1.18.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.18.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.18.dist-info}/top_level.txt +0 -0
ocpp/consumers.py
CHANGED
|
@@ -5,6 +5,7 @@ from datetime import datetime
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import inspect
|
|
7
7
|
import json
|
|
8
|
+
import logging
|
|
8
9
|
from urllib.parse import parse_qs
|
|
9
10
|
from django.utils import timezone
|
|
10
11
|
from core.models import EnergyAccount, Reference, RFID as CoreRFID
|
|
@@ -32,6 +33,9 @@ from .evcs_discovery import (
|
|
|
32
33
|
FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
|
|
33
34
|
|
|
34
35
|
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
35
39
|
# Query parameter keys that may contain the charge point serial. Keys are
|
|
36
40
|
# matched case-insensitively and trimmed before use.
|
|
37
41
|
SERIAL_QUERY_PARAM_NAMES = (
|
|
@@ -309,6 +313,19 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
309
313
|
|
|
310
314
|
return await database_sync_to_async(_ensure)()
|
|
311
315
|
|
|
316
|
+
def _log_unlinked_rfid(self, rfid: str) -> None:
|
|
317
|
+
"""Record a warning when an RFID is authorized without an account."""
|
|
318
|
+
|
|
319
|
+
message = (
|
|
320
|
+
f"Authorized RFID {rfid} on charger {self.charger_id} without linked energy account"
|
|
321
|
+
)
|
|
322
|
+
logger.warning(message)
|
|
323
|
+
store.add_log(
|
|
324
|
+
store.pending_key(self.charger_id),
|
|
325
|
+
message,
|
|
326
|
+
log_type="charger",
|
|
327
|
+
)
|
|
328
|
+
|
|
312
329
|
async def _assign_connector(self, connector: int | str | None) -> None:
|
|
313
330
|
"""Ensure ``self.charger`` matches the provided connector id."""
|
|
314
331
|
if connector in (None, "", "-"):
|
|
@@ -1395,13 +1412,25 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1395
1412
|
elif action == "Authorize":
|
|
1396
1413
|
id_tag = payload.get("idTag")
|
|
1397
1414
|
account = await self._get_account(id_tag)
|
|
1415
|
+
status = "Invalid"
|
|
1398
1416
|
if self.charger.require_rfid:
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1417
|
+
tag = None
|
|
1418
|
+
tag_created = False
|
|
1419
|
+
if id_tag:
|
|
1420
|
+
tag, tag_created = await database_sync_to_async(
|
|
1421
|
+
CoreRFID.register_scan
|
|
1422
|
+
)(id_tag)
|
|
1423
|
+
if account:
|
|
1424
|
+
if await database_sync_to_async(account.can_authorize)():
|
|
1425
|
+
status = "Accepted"
|
|
1426
|
+
elif (
|
|
1427
|
+
id_tag
|
|
1428
|
+
and tag
|
|
1429
|
+
and not tag_created
|
|
1430
|
+
and tag.allowed
|
|
1431
|
+
):
|
|
1432
|
+
status = "Accepted"
|
|
1433
|
+
self._log_unlinked_rfid(tag.rfid)
|
|
1405
1434
|
else:
|
|
1406
1435
|
await self._ensure_rfid_seen(id_tag)
|
|
1407
1436
|
status = "Accepted"
|
|
@@ -1475,23 +1504,38 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1475
1504
|
reply_payload = {}
|
|
1476
1505
|
elif action == "StartTransaction":
|
|
1477
1506
|
id_tag = payload.get("idTag")
|
|
1478
|
-
|
|
1507
|
+
tag = None
|
|
1508
|
+
tag_created = False
|
|
1479
1509
|
if id_tag:
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1510
|
+
tag, tag_created = await database_sync_to_async(
|
|
1511
|
+
CoreRFID.register_scan
|
|
1512
|
+
)(id_tag)
|
|
1513
|
+
account = await self._get_account(id_tag)
|
|
1514
|
+
if id_tag and not self.charger.require_rfid:
|
|
1515
|
+
seen_tag = await self._ensure_rfid_seen(id_tag)
|
|
1516
|
+
if seen_tag:
|
|
1517
|
+
tag = seen_tag
|
|
1486
1518
|
await self._assign_connector(payload.get("connectorId"))
|
|
1519
|
+
authorized = True
|
|
1520
|
+
authorized_via_tag = False
|
|
1487
1521
|
if self.charger.require_rfid:
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1522
|
+
if account is not None:
|
|
1523
|
+
authorized = await database_sync_to_async(
|
|
1524
|
+
account.can_authorize
|
|
1525
|
+
)()
|
|
1526
|
+
elif (
|
|
1527
|
+
id_tag
|
|
1528
|
+
and tag
|
|
1529
|
+
and not tag_created
|
|
1530
|
+
and getattr(tag, "allowed", False)
|
|
1531
|
+
):
|
|
1532
|
+
authorized = True
|
|
1533
|
+
authorized_via_tag = True
|
|
1534
|
+
else:
|
|
1535
|
+
authorized = False
|
|
1494
1536
|
if authorized:
|
|
1537
|
+
if authorized_via_tag and tag:
|
|
1538
|
+
self._log_unlinked_rfid(tag.rfid)
|
|
1495
1539
|
start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
|
|
1496
1540
|
received_start = timezone.now()
|
|
1497
1541
|
tx_obj = await database_sync_to_async(Transaction.objects.create)(
|
ocpp/test_rfid.py
CHANGED
|
@@ -131,7 +131,7 @@ class ScanNextViewTests(TestCase):
|
|
|
131
131
|
self.assertEqual(
|
|
132
132
|
resp.json(), {"rfid": "ABCD1234", "label_id": 1, "created": False}
|
|
133
133
|
)
|
|
134
|
-
mock_validate.assert_called_once_with("ABCD1234", kind=None)
|
|
134
|
+
mock_validate.assert_called_once_with("ABCD1234", kind=None, endianness=None)
|
|
135
135
|
|
|
136
136
|
@patch("config.middleware.Node.get_local", return_value=None)
|
|
137
137
|
@patch("config.middleware.get_site")
|
|
@@ -342,16 +342,20 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
342
342
|
tag.released = False
|
|
343
343
|
tag.reference = None
|
|
344
344
|
tag.kind = RFID.CLASSIC
|
|
345
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
345
346
|
mock_register.return_value = (tag, True)
|
|
346
347
|
|
|
347
348
|
result = validate_rfid_value("abcd1234")
|
|
348
349
|
|
|
349
|
-
mock_register.assert_called_once_with(
|
|
350
|
+
mock_register.assert_called_once_with(
|
|
351
|
+
"ABCD1234", kind=None, endianness=RFID.BIG_ENDIAN
|
|
352
|
+
)
|
|
350
353
|
tag.save.assert_called_once_with(update_fields=["last_seen_on"])
|
|
351
354
|
self.assertIs(tag.last_seen_on, fake_now)
|
|
352
355
|
mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
|
|
353
356
|
self.assertTrue(result["created"])
|
|
354
357
|
self.assertEqual(result["rfid"], "ABCD1234")
|
|
358
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
355
359
|
|
|
356
360
|
@patch("ocpp.rfid.reader.timezone.now")
|
|
357
361
|
@patch("ocpp.rfid.reader.notify_async")
|
|
@@ -367,11 +371,14 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
367
371
|
tag.released = True
|
|
368
372
|
tag.reference = None
|
|
369
373
|
tag.kind = RFID.CLASSIC
|
|
374
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
370
375
|
mock_register.return_value = (tag, False)
|
|
371
376
|
|
|
372
377
|
result = validate_rfid_value("abcd", kind=RFID.NTAG215)
|
|
373
378
|
|
|
374
|
-
mock_register.assert_called_once_with(
|
|
379
|
+
mock_register.assert_called_once_with(
|
|
380
|
+
"ABCD", kind=RFID.NTAG215, endianness=RFID.BIG_ENDIAN
|
|
381
|
+
)
|
|
375
382
|
tag.save.assert_called_once_with(update_fields=["kind", "last_seen_on"])
|
|
376
383
|
self.assertIs(tag.last_seen_on, fake_now)
|
|
377
384
|
self.assertEqual(tag.kind, RFID.NTAG215)
|
|
@@ -379,6 +386,36 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
379
386
|
self.assertFalse(result["allowed"])
|
|
380
387
|
self.assertFalse(result["created"])
|
|
381
388
|
self.assertEqual(result["kind"], RFID.NTAG215)
|
|
389
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
390
|
+
|
|
391
|
+
@patch("ocpp.rfid.reader.timezone.now")
|
|
392
|
+
@patch("ocpp.rfid.reader.notify_async")
|
|
393
|
+
@patch("ocpp.rfid.reader.RFID.register_scan")
|
|
394
|
+
def test_registers_little_endian_value(
|
|
395
|
+
self, mock_register, mock_notify, mock_now
|
|
396
|
+
):
|
|
397
|
+
fake_now = object()
|
|
398
|
+
mock_now.return_value = fake_now
|
|
399
|
+
tag = MagicMock()
|
|
400
|
+
tag.pk = 7
|
|
401
|
+
tag.label_id = 7
|
|
402
|
+
tag.allowed = True
|
|
403
|
+
tag.color = "B"
|
|
404
|
+
tag.released = False
|
|
405
|
+
tag.reference = None
|
|
406
|
+
tag.kind = RFID.CLASSIC
|
|
407
|
+
tag.endianness = RFID.LITTLE_ENDIAN
|
|
408
|
+
mock_register.return_value = (tag, True)
|
|
409
|
+
|
|
410
|
+
result = validate_rfid_value("A1B2C3D4", endianness=RFID.LITTLE_ENDIAN)
|
|
411
|
+
|
|
412
|
+
mock_register.assert_called_once_with(
|
|
413
|
+
"D4C3B2A1", kind=None, endianness=RFID.LITTLE_ENDIAN
|
|
414
|
+
)
|
|
415
|
+
tag.save.assert_called_once_with(update_fields=["last_seen_on"])
|
|
416
|
+
self.assertEqual(result["rfid"], "D4C3B2A1")
|
|
417
|
+
self.assertEqual(result["endianness"], RFID.LITTLE_ENDIAN)
|
|
418
|
+
mock_notify.assert_called_once()
|
|
382
419
|
|
|
383
420
|
def test_rejects_invalid_value(self):
|
|
384
421
|
result = validate_rfid_value("invalid!")
|
|
@@ -412,6 +449,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
412
449
|
tag.released = False
|
|
413
450
|
tag.reference = None
|
|
414
451
|
tag.kind = RFID.CLASSIC
|
|
452
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
415
453
|
mock_register.return_value = (tag, False)
|
|
416
454
|
mock_run.return_value = types.SimpleNamespace(
|
|
417
455
|
returncode=0, stdout="ok\n", stderr=""
|
|
@@ -427,6 +465,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
427
465
|
env = run_kwargs.get("env", {})
|
|
428
466
|
self.assertEqual(env.get("RFID_VALUE"), "ABCD1234")
|
|
429
467
|
self.assertEqual(env.get("RFID_LABEL_ID"), "1")
|
|
468
|
+
self.assertEqual(env.get("RFID_ENDIANNESS"), RFID.BIG_ENDIAN)
|
|
430
469
|
mock_popen.assert_not_called()
|
|
431
470
|
mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
|
|
432
471
|
tag.save.assert_called_once_with(update_fields=["last_seen_on"])
|
|
@@ -437,6 +476,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
437
476
|
self.assertEqual(output.get("stderr"), "")
|
|
438
477
|
self.assertEqual(output.get("returncode"), 0)
|
|
439
478
|
self.assertEqual(output.get("error"), "")
|
|
479
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
440
480
|
|
|
441
481
|
@patch("ocpp.rfid.reader.timezone.now")
|
|
442
482
|
@patch("ocpp.rfid.reader.notify_async")
|
|
@@ -457,6 +497,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
457
497
|
tag.released = False
|
|
458
498
|
tag.reference = None
|
|
459
499
|
tag.kind = RFID.CLASSIC
|
|
500
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
460
501
|
mock_register.return_value = (tag, False)
|
|
461
502
|
mock_run.return_value = types.SimpleNamespace(
|
|
462
503
|
returncode=1, stdout="", stderr="failure"
|
|
@@ -476,6 +517,77 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
476
517
|
self.assertEqual(output.get("stderr"), "failure")
|
|
477
518
|
self.assertEqual(output.get("error"), "")
|
|
478
519
|
mock_popen.assert_not_called()
|
|
520
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
521
|
+
|
|
522
|
+
@patch("ocpp.rfid.reader.timezone.now")
|
|
523
|
+
@patch("ocpp.rfid.reader.notify_async")
|
|
524
|
+
@patch("ocpp.rfid.reader.subprocess.Popen")
|
|
525
|
+
@patch("ocpp.rfid.reader.subprocess.run")
|
|
526
|
+
@patch("ocpp.rfid.reader.RFID.register_scan")
|
|
527
|
+
def test_external_command_strips_trailing_percent_tokens(
|
|
528
|
+
self, mock_register, mock_run, mock_popen, mock_notify, mock_now
|
|
529
|
+
):
|
|
530
|
+
mock_now.return_value = timezone.now()
|
|
531
|
+
tag = MagicMock()
|
|
532
|
+
tag.pk = 3
|
|
533
|
+
tag.label_id = 3
|
|
534
|
+
tag.allowed = True
|
|
535
|
+
tag.external_command = "echo weird"
|
|
536
|
+
tag.color = "Y"
|
|
537
|
+
tag.released = False
|
|
538
|
+
tag.reference = None
|
|
539
|
+
tag.kind = RFID.CLASSIC
|
|
540
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
541
|
+
mock_register.return_value = (tag, False)
|
|
542
|
+
mock_run.return_value = types.SimpleNamespace(
|
|
543
|
+
returncode=0,
|
|
544
|
+
stdout="first %\nsecond 50%\r\nthird % %\n",
|
|
545
|
+
stderr="oops %\n",
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
result = validate_rfid_value("abc3")
|
|
549
|
+
|
|
550
|
+
output = result.get("command_output")
|
|
551
|
+
self.assertIsNotNone(output)
|
|
552
|
+
self.assertEqual(
|
|
553
|
+
output.get("stdout"), "first\nsecond 50%\r\nthird\n"
|
|
554
|
+
)
|
|
555
|
+
self.assertEqual(output.get("stderr"), "oops\n")
|
|
556
|
+
self.assertEqual(output.get("returncode"), 0)
|
|
557
|
+
self.assertEqual(output.get("error"), "")
|
|
558
|
+
mock_popen.assert_not_called()
|
|
559
|
+
|
|
560
|
+
@patch("ocpp.rfid.reader.timezone.now")
|
|
561
|
+
@patch("ocpp.rfid.reader.notify_async")
|
|
562
|
+
@patch("ocpp.rfid.reader.subprocess.Popen")
|
|
563
|
+
@patch("ocpp.rfid.reader.subprocess.run")
|
|
564
|
+
@patch("ocpp.rfid.reader.RFID.register_scan")
|
|
565
|
+
def test_external_command_error_strips_trailing_percent_tokens(
|
|
566
|
+
self, mock_register, mock_run, mock_popen, mock_notify, mock_now
|
|
567
|
+
):
|
|
568
|
+
mock_now.return_value = timezone.now()
|
|
569
|
+
tag = MagicMock()
|
|
570
|
+
tag.pk = 4
|
|
571
|
+
tag.label_id = 4
|
|
572
|
+
tag.allowed = True
|
|
573
|
+
tag.external_command = "echo boom"
|
|
574
|
+
tag.color = "R"
|
|
575
|
+
tag.released = False
|
|
576
|
+
tag.reference = None
|
|
577
|
+
tag.kind = RFID.CLASSIC
|
|
578
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
579
|
+
mock_register.return_value = (tag, False)
|
|
580
|
+
mock_run.side_effect = RuntimeError("bad % %")
|
|
581
|
+
|
|
582
|
+
result = validate_rfid_value("abcd")
|
|
583
|
+
|
|
584
|
+
output = result.get("command_output")
|
|
585
|
+
self.assertIsInstance(output, dict)
|
|
586
|
+
self.assertEqual(output.get("stdout"), "")
|
|
587
|
+
self.assertEqual(output.get("stderr"), "")
|
|
588
|
+
self.assertEqual(output.get("error"), "bad")
|
|
589
|
+
self.assertFalse(result["allowed"])
|
|
590
|
+
mock_popen.assert_not_called()
|
|
479
591
|
|
|
480
592
|
@patch("ocpp.rfid.reader.timezone.now")
|
|
481
593
|
@patch("ocpp.rfid.reader.notify_async")
|
|
@@ -497,6 +609,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
497
609
|
tag.released = False
|
|
498
610
|
tag.reference = None
|
|
499
611
|
tag.kind = RFID.CLASSIC
|
|
612
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
500
613
|
mock_register.return_value = (tag, False)
|
|
501
614
|
result = validate_rfid_value("abcdef")
|
|
502
615
|
|
|
@@ -507,10 +620,12 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
507
620
|
env = kwargs.get("env", {})
|
|
508
621
|
self.assertEqual(env.get("RFID_VALUE"), "ABCDEF")
|
|
509
622
|
self.assertEqual(env.get("RFID_LABEL_ID"), "3")
|
|
623
|
+
self.assertEqual(env.get("RFID_ENDIANNESS"), RFID.BIG_ENDIAN)
|
|
510
624
|
self.assertIs(kwargs.get("stdout"), subprocess.DEVNULL)
|
|
511
625
|
self.assertIs(kwargs.get("stderr"), subprocess.DEVNULL)
|
|
512
626
|
self.assertTrue(result["allowed"])
|
|
513
627
|
mock_notify.assert_called_once_with("RFID 3 OK", "ABCDEF B")
|
|
628
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
514
629
|
|
|
515
630
|
|
|
516
631
|
class CardTypeDetectionTests(TestCase):
|
ocpp/tests.py
CHANGED
|
@@ -1931,6 +1931,27 @@ class ChargerLandingTests(TestCase):
|
|
|
1931
1931
|
finally:
|
|
1932
1932
|
store.transactions.pop(key, None)
|
|
1933
1933
|
|
|
1934
|
+
def test_public_page_shows_available_when_status_stale(self):
|
|
1935
|
+
charger = Charger.objects.create(
|
|
1936
|
+
charger_id="STALEPUB",
|
|
1937
|
+
last_status="Charging",
|
|
1938
|
+
)
|
|
1939
|
+
response = self.client.get(reverse("charger-page", args=["STALEPUB"]))
|
|
1940
|
+
self.assertEqual(response.status_code, 200)
|
|
1941
|
+
self.assertContains(
|
|
1942
|
+
response,
|
|
1943
|
+
'style="background-color: #0d6efd; color: #fff;">Available</span>',
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
def test_admin_status_shows_available_when_status_stale(self):
|
|
1947
|
+
charger = Charger.objects.create(
|
|
1948
|
+
charger_id="STALEADM",
|
|
1949
|
+
last_status="Charging",
|
|
1950
|
+
)
|
|
1951
|
+
response = self.client.get(reverse("charger-status", args=["STALEADM"]))
|
|
1952
|
+
self.assertEqual(response.status_code, 200)
|
|
1953
|
+
self.assertContains(response, 'id="charger-state">Available</strong>')
|
|
1954
|
+
|
|
1934
1955
|
def test_public_status_shows_rfid_link_for_known_tag(self):
|
|
1935
1956
|
aggregate = Charger.objects.create(charger_id="PUBRFID")
|
|
1936
1957
|
connector = Charger.objects.create(
|
|
@@ -2129,6 +2150,32 @@ class ChargerAdminTests(TestCase):
|
|
|
2129
2150
|
resp = self.client.get(url)
|
|
2130
2151
|
self.assertNotContains(resp, charger.reference.image.url)
|
|
2131
2152
|
|
|
2153
|
+
def test_toggle_rfid_authentication_action_toggles_value(self):
|
|
2154
|
+
charger_requires = Charger.objects.create(
|
|
2155
|
+
charger_id="RFIDON", require_rfid=True
|
|
2156
|
+
)
|
|
2157
|
+
charger_optional = Charger.objects.create(
|
|
2158
|
+
charger_id="RFIDOFF", require_rfid=False
|
|
2159
|
+
)
|
|
2160
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2161
|
+
response = self.client.post(
|
|
2162
|
+
url,
|
|
2163
|
+
{
|
|
2164
|
+
"action": "toggle_rfid_authentication",
|
|
2165
|
+
"_selected_action": [
|
|
2166
|
+
charger_requires.pk,
|
|
2167
|
+
charger_optional.pk,
|
|
2168
|
+
],
|
|
2169
|
+
},
|
|
2170
|
+
follow=True,
|
|
2171
|
+
)
|
|
2172
|
+
self.assertEqual(response.status_code, 200)
|
|
2173
|
+
charger_requires.refresh_from_db()
|
|
2174
|
+
charger_optional.refresh_from_db()
|
|
2175
|
+
self.assertFalse(charger_requires.require_rfid)
|
|
2176
|
+
self.assertTrue(charger_optional.require_rfid)
|
|
2177
|
+
self.assertContains(response, "Updated RFID authentication")
|
|
2178
|
+
|
|
2132
2179
|
def test_admin_lists_log_link(self):
|
|
2133
2180
|
charger = Charger.objects.create(charger_id="LOG1")
|
|
2134
2181
|
url = reverse("admin:ocpp_charger_changelist")
|
|
@@ -2155,6 +2202,48 @@ class ChargerAdminTests(TestCase):
|
|
|
2155
2202
|
finally:
|
|
2156
2203
|
store.transactions.pop(key, None)
|
|
2157
2204
|
|
|
2205
|
+
def test_admin_status_shows_available_when_status_stale(self):
|
|
2206
|
+
charger = Charger.objects.create(
|
|
2207
|
+
charger_id="ADMINSTALE",
|
|
2208
|
+
last_status="Charging",
|
|
2209
|
+
)
|
|
2210
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2211
|
+
resp = self.client.get(url)
|
|
2212
|
+
available_label = force_str(STATUS_BADGE_MAP["available"][0])
|
|
2213
|
+
self.assertContains(resp, f">{available_label}<")
|
|
2214
|
+
|
|
2215
|
+
def test_recheck_charger_status_action_sends_trigger(self):
|
|
2216
|
+
charger = Charger.objects.create(charger_id="RECHECK1")
|
|
2217
|
+
|
|
2218
|
+
class DummyConnection:
|
|
2219
|
+
def __init__(self):
|
|
2220
|
+
self.sent: list[str] = []
|
|
2221
|
+
|
|
2222
|
+
async def send(self, message):
|
|
2223
|
+
self.sent.append(message)
|
|
2224
|
+
|
|
2225
|
+
ws = DummyConnection()
|
|
2226
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2227
|
+
try:
|
|
2228
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2229
|
+
response = self.client.post(
|
|
2230
|
+
url,
|
|
2231
|
+
{
|
|
2232
|
+
"action": "recheck_charger_status",
|
|
2233
|
+
"index": 0,
|
|
2234
|
+
"select_across": 0,
|
|
2235
|
+
"_selected_action": [charger.pk],
|
|
2236
|
+
},
|
|
2237
|
+
follow=True,
|
|
2238
|
+
)
|
|
2239
|
+
self.assertEqual(response.status_code, 200)
|
|
2240
|
+
self.assertTrue(ws.sent)
|
|
2241
|
+
self.assertIn("TriggerMessage", ws.sent[0])
|
|
2242
|
+
self.assertContains(response, "Requested status update")
|
|
2243
|
+
finally:
|
|
2244
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2245
|
+
store.clear_pending_calls(charger.charger_id)
|
|
2246
|
+
|
|
2158
2247
|
def test_admin_log_view_displays_entries(self):
|
|
2159
2248
|
charger = Charger.objects.create(charger_id="LOG2")
|
|
2160
2249
|
log_id = store.identity_key(charger.charger_id, charger.connector_id)
|
|
@@ -2860,6 +2949,44 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2860
2949
|
|
|
2861
2950
|
await communicator.disconnect()
|
|
2862
2951
|
|
|
2952
|
+
async def test_authorize_requires_rfid_accepts_allowed_tag_without_account(self):
|
|
2953
|
+
charger_id = "AUTHWARN"
|
|
2954
|
+
tag_value = "WARN01"
|
|
2955
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
2956
|
+
charger_id=charger_id, require_rfid=True
|
|
2957
|
+
)
|
|
2958
|
+
await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
|
|
2959
|
+
|
|
2960
|
+
pending_key = store.pending_key(charger_id)
|
|
2961
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2962
|
+
|
|
2963
|
+
communicator = WebsocketCommunicator(application, f"/{charger_id}/")
|
|
2964
|
+
connected, _ = await communicator.connect()
|
|
2965
|
+
self.assertTrue(connected)
|
|
2966
|
+
|
|
2967
|
+
message_id = "auth-unlinked"
|
|
2968
|
+
await communicator.send_json_to(
|
|
2969
|
+
[2, message_id, "Authorize", {"idTag": tag_value}]
|
|
2970
|
+
)
|
|
2971
|
+
response = await communicator.receive_json_from()
|
|
2972
|
+
self.assertEqual(response[0], 3)
|
|
2973
|
+
self.assertEqual(response[1], message_id)
|
|
2974
|
+
self.assertEqual(response[2], {"idTagInfo": {"status": "Accepted"}})
|
|
2975
|
+
|
|
2976
|
+
log_entries = store.get_logs(pending_key, log_type="charger")
|
|
2977
|
+
self.assertTrue(
|
|
2978
|
+
any(
|
|
2979
|
+
"Authorized RFID" in entry
|
|
2980
|
+
and tag_value in entry
|
|
2981
|
+
and charger_id in entry
|
|
2982
|
+
for entry in log_entries
|
|
2983
|
+
),
|
|
2984
|
+
log_entries,
|
|
2985
|
+
)
|
|
2986
|
+
|
|
2987
|
+
await communicator.disconnect()
|
|
2988
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2989
|
+
|
|
2863
2990
|
async def test_authorize_without_requirement_records_rfid(self):
|
|
2864
2991
|
await database_sync_to_async(Charger.objects.create)(
|
|
2865
2992
|
charger_id="AUTHOPT", require_rfid=False
|
|
@@ -2952,6 +3079,61 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2952
3079
|
)
|
|
2953
3080
|
self.assertEqual(tx.account_id, user.energy_account.id)
|
|
2954
3081
|
|
|
3082
|
+
async def test_start_transaction_allows_allowed_tag_without_account(self):
|
|
3083
|
+
charger_id = "STARTWARN"
|
|
3084
|
+
tag_value = "WARN02"
|
|
3085
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
3086
|
+
charger_id=charger_id, require_rfid=True
|
|
3087
|
+
)
|
|
3088
|
+
await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
|
|
3089
|
+
|
|
3090
|
+
pending_key = store.pending_key(charger_id)
|
|
3091
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3092
|
+
|
|
3093
|
+
communicator = WebsocketCommunicator(application, f"/{charger_id}/")
|
|
3094
|
+
connected, _ = await communicator.connect()
|
|
3095
|
+
self.assertTrue(connected)
|
|
3096
|
+
|
|
3097
|
+
start_payload = {
|
|
3098
|
+
"meterStart": 5,
|
|
3099
|
+
"idTag": tag_value,
|
|
3100
|
+
"connectorId": 1,
|
|
3101
|
+
}
|
|
3102
|
+
await communicator.send_json_to([2, "start-1", "StartTransaction", start_payload])
|
|
3103
|
+
response = await communicator.receive_json_from()
|
|
3104
|
+
self.assertEqual(response[0], 3)
|
|
3105
|
+
self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
|
|
3106
|
+
tx_id = response[2]["transactionId"]
|
|
3107
|
+
|
|
3108
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
3109
|
+
pk=tx_id, charger__charger_id=charger_id
|
|
3110
|
+
)
|
|
3111
|
+
self.assertIsNone(tx.account_id)
|
|
3112
|
+
|
|
3113
|
+
log_entries = store.get_logs(pending_key, log_type="charger")
|
|
3114
|
+
self.assertTrue(
|
|
3115
|
+
any(
|
|
3116
|
+
"Authorized RFID" in entry
|
|
3117
|
+
and tag_value in entry
|
|
3118
|
+
and charger_id in entry
|
|
3119
|
+
for entry in log_entries
|
|
3120
|
+
),
|
|
3121
|
+
log_entries,
|
|
3122
|
+
)
|
|
3123
|
+
|
|
3124
|
+
await communicator.send_json_to(
|
|
3125
|
+
[
|
|
3126
|
+
2,
|
|
3127
|
+
"stop-1",
|
|
3128
|
+
"StopTransaction",
|
|
3129
|
+
{"transactionId": tx_id, "meterStop": 6},
|
|
3130
|
+
]
|
|
3131
|
+
)
|
|
3132
|
+
await communicator.receive_json_from()
|
|
3133
|
+
|
|
3134
|
+
await communicator.disconnect()
|
|
3135
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3136
|
+
|
|
2955
3137
|
async def test_status_fields_updated(self):
|
|
2956
3138
|
communicator = WebsocketCommunicator(application, "/STAT/")
|
|
2957
3139
|
connected, _ = await communicator.connect()
|
|
@@ -4442,6 +4624,49 @@ class LiveUpdateViewTests(TestCase):
|
|
|
4442
4624
|
)
|
|
4443
4625
|
self.assertEqual(aggregate_entry["state"], available_label)
|
|
4444
4626
|
|
|
4627
|
+
def test_dashboard_connector_treats_finishing_as_available_without_session(self):
|
|
4628
|
+
charger = Charger.objects.create(
|
|
4629
|
+
charger_id="FINISH-STATE",
|
|
4630
|
+
connector_id=1,
|
|
4631
|
+
last_status="Finishing",
|
|
4632
|
+
)
|
|
4633
|
+
|
|
4634
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
4635
|
+
self.assertEqual(resp.status_code, 200)
|
|
4636
|
+
self.assertIsNotNone(resp.context)
|
|
4637
|
+
context = resp.context
|
|
4638
|
+
available_label = force_str(STATUS_BADGE_MAP["available"][0])
|
|
4639
|
+
entry = next(
|
|
4640
|
+
item
|
|
4641
|
+
for item in context["chargers"]
|
|
4642
|
+
if item["charger"].pk == charger.pk
|
|
4643
|
+
)
|
|
4644
|
+
self.assertEqual(entry["state"], available_label)
|
|
4645
|
+
|
|
4646
|
+
def test_dashboard_aggregate_treats_finishing_as_available_without_session(self):
|
|
4647
|
+
aggregate = Charger.objects.create(
|
|
4648
|
+
charger_id="FINISH-AGG",
|
|
4649
|
+
connector_id=None,
|
|
4650
|
+
last_status="Finishing",
|
|
4651
|
+
)
|
|
4652
|
+
Charger.objects.create(
|
|
4653
|
+
charger_id=aggregate.charger_id,
|
|
4654
|
+
connector_id=1,
|
|
4655
|
+
last_status="Finishing",
|
|
4656
|
+
)
|
|
4657
|
+
|
|
4658
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
4659
|
+
self.assertEqual(resp.status_code, 200)
|
|
4660
|
+
self.assertIsNotNone(resp.context)
|
|
4661
|
+
context = resp.context
|
|
4662
|
+
available_label = force_str(STATUS_BADGE_MAP["available"][0])
|
|
4663
|
+
aggregate_entry = next(
|
|
4664
|
+
item
|
|
4665
|
+
for item in context["chargers"]
|
|
4666
|
+
if item["charger"].pk == aggregate.pk
|
|
4667
|
+
)
|
|
4668
|
+
self.assertEqual(aggregate_entry["state"], available_label)
|
|
4669
|
+
|
|
4445
4670
|
def test_dashboard_aggregate_uses_connection_when_status_missing(self):
|
|
4446
4671
|
aggregate = Charger.objects.create(
|
|
4447
4672
|
charger_id="DASHAGG-CONN", last_status="Charging"
|
ocpp/views.py
CHANGED
|
@@ -370,10 +370,6 @@ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
|
|
|
370
370
|
)
|
|
371
371
|
statuses: list[str] = []
|
|
372
372
|
for sibling in siblings:
|
|
373
|
-
status_value = (sibling.last_status or "").strip()
|
|
374
|
-
if status_value:
|
|
375
|
-
statuses.append(status_value.casefold())
|
|
376
|
-
continue
|
|
377
373
|
tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
|
|
378
374
|
if not tx_obj:
|
|
379
375
|
tx_obj = (
|
|
@@ -381,9 +377,22 @@ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
|
|
|
381
377
|
.order_by("-start_time")
|
|
382
378
|
.first()
|
|
383
379
|
)
|
|
384
|
-
|
|
380
|
+
has_session = _has_active_session(tx_obj)
|
|
381
|
+
status_value = (sibling.last_status or "").strip()
|
|
382
|
+
normalized_status = status_value.casefold() if status_value else ""
|
|
383
|
+
error_code_lower = (sibling.last_error_code or "").strip().lower()
|
|
384
|
+
if has_session:
|
|
385
385
|
statuses.append("charging")
|
|
386
386
|
continue
|
|
387
|
+
if (
|
|
388
|
+
normalized_status in {"charging", "finishing"}
|
|
389
|
+
and error_code_lower in ERROR_OK_VALUES
|
|
390
|
+
):
|
|
391
|
+
statuses.append("available")
|
|
392
|
+
continue
|
|
393
|
+
if normalized_status:
|
|
394
|
+
statuses.append(normalized_status)
|
|
395
|
+
continue
|
|
387
396
|
if store.is_connected(sibling.charger_id, sibling.connector_id):
|
|
388
397
|
statuses.append("available")
|
|
389
398
|
|
|
@@ -424,6 +433,15 @@ def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
|
|
|
424
433
|
# while a session is active. Override the badge so the user can see
|
|
425
434
|
# the charger is actually busy.
|
|
426
435
|
label, color = STATUS_BADGE_MAP.get("charging", (_("Charging"), "#198754"))
|
|
436
|
+
elif (
|
|
437
|
+
not has_session
|
|
438
|
+
and key in {"charging", "finishing"}
|
|
439
|
+
and error_code_lower in ERROR_OK_VALUES
|
|
440
|
+
):
|
|
441
|
+
# Some chargers continue reporting "Charging" after a session ends.
|
|
442
|
+
# When no active transaction exists, surface the state as available
|
|
443
|
+
# so the UI reflects the actual behaviour at the site.
|
|
444
|
+
label, color = STATUS_BADGE_MAP.get("available", (_("Available"), "#0d6efd"))
|
|
427
445
|
elif error_code and error_code_lower not in ERROR_OK_VALUES:
|
|
428
446
|
label = _("%(status)s (%(error)s)") % {
|
|
429
447
|
"status": label,
|
|
@@ -1191,17 +1209,38 @@ def charger_log_page(request, cid, connector=None):
|
|
|
1191
1209
|
charger_id=cid
|
|
1192
1210
|
)
|
|
1193
1211
|
target_id = cid
|
|
1194
|
-
|
|
1212
|
+
limit_options = [
|
|
1213
|
+
{"value": "10", "label": "10"},
|
|
1214
|
+
{"value": "20", "label": "20"},
|
|
1215
|
+
{"value": "40", "label": "40"},
|
|
1216
|
+
{"value": "100", "label": "100"},
|
|
1217
|
+
{"value": "all", "label": gettext("All")},
|
|
1218
|
+
]
|
|
1219
|
+
allowed_values = [item["value"] for item in limit_options]
|
|
1220
|
+
limit_choice = request.GET.get("limit", "20")
|
|
1221
|
+
if limit_choice not in allowed_values:
|
|
1222
|
+
limit_choice = "20"
|
|
1223
|
+
|
|
1224
|
+
log_entries = list(store.get_logs(target_id, log_type=log_type) or [])
|
|
1225
|
+
if limit_choice != "all":
|
|
1226
|
+
try:
|
|
1227
|
+
limit_value = int(limit_choice)
|
|
1228
|
+
except (TypeError, ValueError):
|
|
1229
|
+
limit_value = 20
|
|
1230
|
+
limit_choice = "20"
|
|
1231
|
+
log_entries = log_entries[-limit_value:]
|
|
1195
1232
|
return render(
|
|
1196
1233
|
request,
|
|
1197
1234
|
"ocpp/charger_logs.html",
|
|
1198
1235
|
{
|
|
1199
1236
|
"charger": charger,
|
|
1200
|
-
"log":
|
|
1237
|
+
"log": log_entries,
|
|
1201
1238
|
"log_type": log_type,
|
|
1202
1239
|
"connector_slug": connector_slug,
|
|
1203
1240
|
"connector_links": connector_links,
|
|
1204
1241
|
"status_url": status_url,
|
|
1242
|
+
"log_limit_options": limit_options,
|
|
1243
|
+
"log_limit_index": allowed_values.index(limit_choice),
|
|
1205
1244
|
},
|
|
1206
1245
|
)
|
|
1207
1246
|
|