arthexis 0.1.10__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.
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/METADATA +36 -26
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/RECORD +42 -38
- config/context_processors.py +1 -0
- config/settings.py +24 -3
- config/urls.py +5 -4
- core/admin.py +184 -22
- core/apps.py +27 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +270 -31
- core/reference_utils.py +19 -8
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +247 -1
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +105 -3
- core/user_data.py +51 -8
- core/views.py +245 -8
- nodes/admin.py +137 -2
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/models.py +293 -7
- nodes/tests.py +312 -2
- nodes/views.py +14 -0
- ocpp/consumers.py +11 -8
- ocpp/models.py +3 -0
- ocpp/reference_utils.py +42 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +30 -0
- ocpp/views.py +8 -0
- pages/admin.py +9 -1
- pages/context_processors.py +6 -6
- pages/defaults.py +14 -0
- pages/models.py +53 -14
- pages/tests.py +19 -4
- pages/urls.py +3 -0
- pages/views.py +86 -19
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
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(
|
|
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
|
|
518
|
+
def test_redirect_for_anonymous(self):
|
|
519
|
+
self.client.logout()
|
|
364
520
|
resp = self.client.get(self.url)
|
|
365
|
-
self.
|
|
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
|
|
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
|
|
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
|
|
ocpp/tests.py
CHANGED
|
@@ -141,6 +141,17 @@ class ChargerUrlFallbackTests(TestCase):
|
|
|
141
141
|
self.assertTrue(charger.reference.value.startswith("http://fallback.example"))
|
|
142
142
|
self.assertTrue(charger.reference.value.endswith("/c/NO_SITE/"))
|
|
143
143
|
|
|
144
|
+
def test_reference_not_created_for_loopback_domain(self):
|
|
145
|
+
site = Site.objects.get_current()
|
|
146
|
+
site.domain = "127.0.0.1"
|
|
147
|
+
site.save()
|
|
148
|
+
Site.objects.clear_cache()
|
|
149
|
+
|
|
150
|
+
charger = Charger.objects.create(charger_id="LOCAL_LOOP")
|
|
151
|
+
charger.refresh_from_db()
|
|
152
|
+
|
|
153
|
+
self.assertIsNone(charger.reference)
|
|
154
|
+
|
|
144
155
|
|
|
145
156
|
class SinkConsumerTests(TransactionTestCase):
|
|
146
157
|
async def test_sink_replies(self):
|
|
@@ -667,6 +678,25 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
667
678
|
|
|
668
679
|
await communicator.disconnect()
|
|
669
680
|
|
|
681
|
+
async def test_console_reference_skips_loopback_ip(self):
|
|
682
|
+
communicator = ClientWebsocketCommunicator(
|
|
683
|
+
application,
|
|
684
|
+
"/LOCAL/",
|
|
685
|
+
client=("127.0.0.1", 34567),
|
|
686
|
+
)
|
|
687
|
+
connected, _ = await communicator.connect()
|
|
688
|
+
self.assertTrue(connected)
|
|
689
|
+
|
|
690
|
+
await communicator.send_json_to([2, "1", "BootNotification", {}])
|
|
691
|
+
await communicator.receive_json_from()
|
|
692
|
+
|
|
693
|
+
exists = await database_sync_to_async(
|
|
694
|
+
lambda: Reference.objects.filter(alt_text="LOCAL Console").exists()
|
|
695
|
+
)()
|
|
696
|
+
self.assertFalse(exists)
|
|
697
|
+
|
|
698
|
+
await communicator.disconnect()
|
|
699
|
+
|
|
670
700
|
async def test_transaction_created_from_meter_values(self):
|
|
671
701
|
communicator = WebsocketCommunicator(application, "/NOSTART/")
|
|
672
702
|
connected, _ = await communicator.connect()
|
ocpp/views.py
CHANGED
|
@@ -464,6 +464,7 @@ def cp_simulator(request):
|
|
|
464
464
|
default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
|
|
465
465
|
|
|
466
466
|
message = ""
|
|
467
|
+
dashboard_link: str | None = None
|
|
467
468
|
if request.method == "POST":
|
|
468
469
|
cp_idx = int(request.POST.get("cp") or 1)
|
|
469
470
|
action = request.POST.get("action")
|
|
@@ -500,6 +501,12 @@ def cp_simulator(request):
|
|
|
500
501
|
started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
|
|
501
502
|
if started:
|
|
502
503
|
message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
|
|
504
|
+
try:
|
|
505
|
+
dashboard_link = reverse(
|
|
506
|
+
"charger-status", args=[sim_params["cp_path"]]
|
|
507
|
+
)
|
|
508
|
+
except NoReverseMatch: # pragma: no cover - defensive
|
|
509
|
+
dashboard_link = None
|
|
503
510
|
else:
|
|
504
511
|
message = f"CP{cp_idx} {status}. Logs: {log_file}"
|
|
505
512
|
except Exception as exc: # pragma: no cover - unexpected
|
|
@@ -526,6 +533,7 @@ def cp_simulator(request):
|
|
|
526
533
|
|
|
527
534
|
context = {
|
|
528
535
|
"message": message,
|
|
536
|
+
"dashboard_link": dashboard_link,
|
|
529
537
|
"states": state_list,
|
|
530
538
|
"default_host": default_host,
|
|
531
539
|
"default_ws_port": default_ws_port,
|
pages/admin.py
CHANGED
|
@@ -28,6 +28,7 @@ from .models import (
|
|
|
28
28
|
Landing,
|
|
29
29
|
Favorite,
|
|
30
30
|
ViewHistory,
|
|
31
|
+
UserManual,
|
|
31
32
|
)
|
|
32
33
|
from django.contrib.contenttypes.models import ContentType
|
|
33
34
|
from core.user_data import EntityModelAdmin
|
|
@@ -153,7 +154,7 @@ class ApplicationModuleInline(admin.TabularInline):
|
|
|
153
154
|
@admin.register(Application)
|
|
154
155
|
class ApplicationAdmin(EntityModelAdmin):
|
|
155
156
|
form = ApplicationForm
|
|
156
|
-
list_display = ("name", "app_verbose_name", "installed")
|
|
157
|
+
list_display = ("name", "app_verbose_name", "description", "installed")
|
|
157
158
|
readonly_fields = ("installed",)
|
|
158
159
|
inlines = [ApplicationModuleInline]
|
|
159
160
|
|
|
@@ -180,6 +181,13 @@ class ModuleAdmin(EntityModelAdmin):
|
|
|
180
181
|
inlines = [LandingInline]
|
|
181
182
|
|
|
182
183
|
|
|
184
|
+
@admin.register(UserManual)
|
|
185
|
+
class UserManualAdmin(EntityModelAdmin):
|
|
186
|
+
list_display = ("title", "slug", "languages", "is_seed_data", "is_user_data")
|
|
187
|
+
search_fields = ("title", "slug", "description")
|
|
188
|
+
list_filter = ("is_seed_data", "is_user_data")
|
|
189
|
+
|
|
190
|
+
|
|
183
191
|
@admin.register(ViewHistory)
|
|
184
192
|
class ViewHistoryAdmin(EntityModelAdmin):
|
|
185
193
|
date_hierarchy = "visited_at"
|
pages/context_processors.py
CHANGED
|
@@ -39,12 +39,12 @@ def nav_links(request):
|
|
|
39
39
|
except Resolver404:
|
|
40
40
|
continue
|
|
41
41
|
view_func = match.func
|
|
42
|
-
requires_login = getattr(view_func, "login_required", False)
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
requires_login = bool(getattr(view_func, "login_required", False))
|
|
43
|
+
if not requires_login and hasattr(view_func, "login_url"):
|
|
44
|
+
requires_login = True
|
|
45
45
|
staff_only = getattr(view_func, "staff_required", False)
|
|
46
46
|
if requires_login and not request.user.is_authenticated:
|
|
47
|
-
|
|
47
|
+
setattr(landing, "requires_login", True)
|
|
48
48
|
if staff_only and not request.user.is_staff:
|
|
49
49
|
continue
|
|
50
50
|
landings.append(landing)
|
|
@@ -52,8 +52,8 @@ def nav_links(request):
|
|
|
52
52
|
app_name = getattr(module.application, "name", "").lower()
|
|
53
53
|
if app_name == "awg":
|
|
54
54
|
module.menu = "Calculate"
|
|
55
|
-
elif
|
|
56
|
-
module.menu = "
|
|
55
|
+
elif module.path.rstrip("/").lower() == "/man":
|
|
56
|
+
module.menu = "Manual"
|
|
57
57
|
module.enabled_landings = landings
|
|
58
58
|
valid_modules.append(module)
|
|
59
59
|
if request.path.startswith(module.path):
|
pages/defaults.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Default configuration for the pages application."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
DEFAULT_APPLICATION_DESCRIPTIONS: Dict[str, str] = {
|
|
7
|
+
"awg": "Power, Energy and Cost calculations.",
|
|
8
|
+
"core": "Support for Business Processes and monetization.",
|
|
9
|
+
"ocpp": "Compatibility with Standards and Good Practices.",
|
|
10
|
+
"nodes": "System and Node-level operations,",
|
|
11
|
+
"pages": "Scheduling, Periodicity and Event Signaling,",
|
|
12
|
+
"teams": "Identity, Entitlements and Access Controls.",
|
|
13
|
+
"man": "User QA, Continuity Design and Chaos Testing.",
|
|
14
|
+
}
|
pages/models.py
CHANGED
|
@@ -111,6 +111,7 @@ class Module(Entity):
|
|
|
111
111
|
return
|
|
112
112
|
patterns = getattr(urlconf, "urlpatterns", [])
|
|
113
113
|
created = False
|
|
114
|
+
normalized_module = self.path.strip("/")
|
|
114
115
|
|
|
115
116
|
def _walk(patterns, prefix=""):
|
|
116
117
|
nonlocal created
|
|
@@ -118,17 +119,34 @@ class Module(Entity):
|
|
|
118
119
|
if isinstance(pattern, URLPattern):
|
|
119
120
|
callback = pattern.callback
|
|
120
121
|
if getattr(callback, "landing", False):
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
pattern_path = str(pattern.pattern)
|
|
123
|
+
relative = f"{prefix}{pattern_path}"
|
|
124
|
+
if normalized_module and relative.startswith(normalized_module):
|
|
125
|
+
full_path = f"/{relative}"
|
|
126
|
+
Landing.objects.update_or_create(
|
|
127
|
+
module=self,
|
|
128
|
+
path=full_path,
|
|
129
|
+
defaults={
|
|
130
|
+
"label": getattr(
|
|
131
|
+
callback,
|
|
132
|
+
"landing_label",
|
|
133
|
+
callback.__name__.replace("_", " ").title(),
|
|
134
|
+
)
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
full_path = f"{self.path}{relative}"
|
|
139
|
+
Landing.objects.get_or_create(
|
|
140
|
+
module=self,
|
|
141
|
+
path=full_path,
|
|
142
|
+
defaults={
|
|
143
|
+
"label": getattr(
|
|
144
|
+
callback,
|
|
145
|
+
"landing_label",
|
|
146
|
+
callback.__name__.replace("_", " ").title(),
|
|
147
|
+
)
|
|
148
|
+
},
|
|
149
|
+
)
|
|
132
150
|
created = True
|
|
133
151
|
else:
|
|
134
152
|
_walk(
|
|
@@ -192,6 +210,7 @@ class Landing(Entity):
|
|
|
192
210
|
return f"{self.label} ({self.path})"
|
|
193
211
|
|
|
194
212
|
def save(self, *args, **kwargs):
|
|
213
|
+
existing = None
|
|
195
214
|
if not self.pk:
|
|
196
215
|
existing = (
|
|
197
216
|
type(self).objects.filter(module=self.module, path=self.path).first()
|
|
@@ -200,10 +219,30 @@ class Landing(Entity):
|
|
|
200
219
|
self.pk = existing.pk
|
|
201
220
|
super().save(*args, **kwargs)
|
|
202
221
|
|
|
203
|
-
def natural_key(self): # pragma: no cover - simple representation
|
|
204
|
-
return (self.module.node_role.name, self.module.path, self.path)
|
|
205
222
|
|
|
206
|
-
|
|
223
|
+
class UserManual(Entity):
|
|
224
|
+
slug = models.SlugField(unique=True)
|
|
225
|
+
title = models.CharField(max_length=200)
|
|
226
|
+
description = models.CharField(max_length=200)
|
|
227
|
+
languages = models.CharField(
|
|
228
|
+
max_length=100,
|
|
229
|
+
blank=True,
|
|
230
|
+
default="",
|
|
231
|
+
help_text="Comma-separated 2-letter language codes",
|
|
232
|
+
)
|
|
233
|
+
content_html = models.TextField()
|
|
234
|
+
content_pdf = models.TextField(help_text="Base64 encoded PDF")
|
|
235
|
+
|
|
236
|
+
class Meta:
|
|
237
|
+
db_table = "man_usermanual"
|
|
238
|
+
verbose_name = "User Manual"
|
|
239
|
+
verbose_name_plural = "User Manuals"
|
|
240
|
+
|
|
241
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
242
|
+
return self.title
|
|
243
|
+
|
|
244
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
245
|
+
return (self.slug,)
|
|
207
246
|
|
|
208
247
|
|
|
209
248
|
class ViewHistory(Entity):
|
pages/tests.py
CHANGED
|
@@ -1042,9 +1042,9 @@ class PowerNavTests(TestCase):
|
|
|
1042
1042
|
node_role=role, application=awg_app, path="/awg/"
|
|
1043
1043
|
)
|
|
1044
1044
|
awg_module.create_landings()
|
|
1045
|
-
|
|
1045
|
+
manuals_app, _ = Application.objects.get_or_create(name="pages")
|
|
1046
1046
|
man_module, _ = Module.objects.get_or_create(
|
|
1047
|
-
node_role=role, application=
|
|
1047
|
+
node_role=role, application=manuals_app, path="/man/"
|
|
1048
1048
|
)
|
|
1049
1049
|
man_module.create_landings()
|
|
1050
1050
|
User = get_user_model()
|
|
@@ -1070,7 +1070,7 @@ class PowerNavTests(TestCase):
|
|
|
1070
1070
|
manuals_module = module
|
|
1071
1071
|
break
|
|
1072
1072
|
self.assertIsNotNone(manuals_module)
|
|
1073
|
-
self.assertEqual(manuals_module.menu_label.upper(), "
|
|
1073
|
+
self.assertEqual(manuals_module.menu_label.upper(), "MANUAL")
|
|
1074
1074
|
landing_labels = {landing.label for landing in manuals_module.enabled_landings}
|
|
1075
1075
|
self.assertIn("Manuals", landing_labels)
|
|
1076
1076
|
|
|
@@ -1163,6 +1163,13 @@ class ApplicationAdminDisplayTests(TestCase):
|
|
|
1163
1163
|
config = django_apps.get_app_config("ocpp")
|
|
1164
1164
|
self.assertContains(resp, config.verbose_name)
|
|
1165
1165
|
|
|
1166
|
+
def test_changelist_shows_description(self):
|
|
1167
|
+
Application.objects.create(
|
|
1168
|
+
name="awg", description="Power, Energy and Cost calculations."
|
|
1169
|
+
)
|
|
1170
|
+
resp = self.client.get(reverse("admin:pages_application_changelist"))
|
|
1171
|
+
self.assertContains(resp, "Power, Energy and Cost calculations.")
|
|
1172
|
+
|
|
1166
1173
|
|
|
1167
1174
|
class LandingCreationTests(TestCase):
|
|
1168
1175
|
def setUp(self):
|
|
@@ -1231,8 +1238,16 @@ class RFIDPageTests(TestCase):
|
|
|
1231
1238
|
Site.objects.update_or_create(
|
|
1232
1239
|
id=1, defaults={"domain": "testserver", "name": "pages"}
|
|
1233
1240
|
)
|
|
1241
|
+
User = get_user_model()
|
|
1242
|
+
self.user = User.objects.create_user("rfid-user", password="pwd")
|
|
1234
1243
|
|
|
1235
|
-
def
|
|
1244
|
+
def test_page_redirects_when_anonymous(self):
|
|
1245
|
+
resp = self.client.get(reverse("rfid-reader"))
|
|
1246
|
+
self.assertEqual(resp.status_code, 302)
|
|
1247
|
+
self.assertIn(reverse("pages:login"), resp.url)
|
|
1248
|
+
|
|
1249
|
+
def test_page_renders_for_authenticated_user(self):
|
|
1250
|
+
self.client.force_login(self.user)
|
|
1236
1251
|
resp = self.client.get(reverse("rfid-reader"))
|
|
1237
1252
|
self.assertContains(resp, "Scanner ready")
|
|
1238
1253
|
|
pages/urls.py
CHANGED
|
@@ -18,4 +18,7 @@ urlpatterns = [
|
|
|
18
18
|
name="invitation-login",
|
|
19
19
|
),
|
|
20
20
|
path("datasette-auth/", views.datasette_auth, name="datasette-auth"),
|
|
21
|
+
path("man/", views.manual_list, name="manual-list"),
|
|
22
|
+
path("man/<slug:slug>/", views.manual_detail, name="manual-detail"),
|
|
23
|
+
path("man/<slug:slug>/pdf/", views.manual_pdf, name="manual-pdf"),
|
|
21
24
|
]
|