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.

ocpp/test_rfid.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import io
2
+ import json
2
3
  import os
3
4
  import sys
4
5
  import types
@@ -17,12 +18,13 @@ from django.test import SimpleTestCase, TestCase
17
18
  from django.urls import reverse
18
19
  from django.contrib.auth import get_user_model
19
20
  from django.contrib.sites.models import Site
21
+ from django.utils import timezone
20
22
 
21
23
  from pages.models import Application, Module
22
24
  from nodes.models import Node, NodeRole
23
25
 
24
26
  from core.models import RFID
25
- from ocpp.rfid.reader import read_rfid, enable_deep_read
27
+ from ocpp.rfid.reader import read_rfid, enable_deep_read, validate_rfid_value
26
28
  from ocpp.rfid.detect import detect_scanner, main as detect_main
27
29
  from ocpp.rfid import background_reader
28
30
  from ocpp.rfid.constants import (
@@ -71,6 +73,11 @@ class BackgroundReaderConfigurationTests(SimpleTestCase):
71
73
 
72
74
 
73
75
  class ScanNextViewTests(TestCase):
76
+ def setUp(self):
77
+ User = get_user_model()
78
+ self.user = User.objects.create_user("rfid-user", password="pwd")
79
+ self.client.force_login(self.user)
80
+
74
81
  @patch("config.middleware.Node.get_local", return_value=None)
75
82
  @patch("config.middleware.get_site")
76
83
  @patch(
@@ -103,6 +110,62 @@ class ScanNextViewTests(TestCase):
103
110
  self.assertEqual(resp.status_code, 500)
104
111
  self.assertEqual(resp.json(), {"error": "boom"})
105
112
 
113
+ @patch("config.middleware.Node.get_local", return_value=None)
114
+ @patch("config.middleware.get_site")
115
+ @patch(
116
+ "ocpp.rfid.views.validate_rfid_value",
117
+ return_value={"rfid": "ABCD1234", "label_id": 1, "created": False},
118
+ )
119
+ def test_scan_next_post_validates(self, mock_validate, mock_site, mock_node):
120
+ User = get_user_model()
121
+ user = User.objects.create_user("scanner", password="pwd")
122
+ self.client.force_login(user)
123
+ resp = self.client.post(
124
+ reverse("rfid-scan-next"),
125
+ data=json.dumps({"rfid": "ABCD1234"}),
126
+ content_type="application/json",
127
+ )
128
+ self.assertEqual(resp.status_code, 200)
129
+ self.assertEqual(
130
+ resp.json(), {"rfid": "ABCD1234", "label_id": 1, "created": False}
131
+ )
132
+ mock_validate.assert_called_once_with("ABCD1234", kind=None)
133
+
134
+ @patch("config.middleware.Node.get_local", return_value=None)
135
+ @patch("config.middleware.get_site")
136
+ @patch("ocpp.rfid.views.validate_rfid_value")
137
+ def test_scan_next_post_requires_authentication(
138
+ self, mock_validate, mock_site, mock_node
139
+ ):
140
+ resp = self.client.post(
141
+ reverse("rfid-scan-next"),
142
+ data=json.dumps({"rfid": "ABCD1234"}),
143
+ content_type="application/json",
144
+ )
145
+ self.assertEqual(resp.status_code, 401)
146
+ self.assertEqual(resp.json(), {"error": "Authentication required"})
147
+ mock_validate.assert_not_called()
148
+
149
+ @patch("config.middleware.Node.get_local", return_value=None)
150
+ @patch("config.middleware.get_site")
151
+ def test_scan_next_post_invalid_json(self, mock_site, mock_node):
152
+ User = get_user_model()
153
+ user = User.objects.create_user("invalid-json", password="pwd")
154
+ self.client.force_login(user)
155
+ resp = self.client.post(
156
+ reverse("rfid-scan-next"),
157
+ data="{",
158
+ content_type="application/json",
159
+ )
160
+ self.assertEqual(resp.status_code, 400)
161
+ self.assertEqual(resp.json(), {"error": "Invalid JSON payload"})
162
+
163
+ def test_scan_next_requires_authentication(self):
164
+ self.client.logout()
165
+ resp = self.client.get(reverse("rfid-scan-next"))
166
+ self.assertEqual(resp.status_code, 302)
167
+ self.assertIn(reverse("pages:login"), resp.url)
168
+
106
169
 
107
170
  class ReaderNotificationTests(TestCase):
108
171
  def _mock_reader(self):
@@ -171,6 +234,74 @@ class ReaderNotificationTests(TestCase):
171
234
  self.assertTrue(getattr(reader, "stop_called", False))
172
235
 
173
236
 
237
+ class ValidateRfidValueTests(SimpleTestCase):
238
+ @patch("ocpp.rfid.reader.timezone.now")
239
+ @patch("ocpp.rfid.reader.notify_async")
240
+ @patch("ocpp.rfid.reader.RFID.objects.get_or_create")
241
+ def test_creates_new_tag(self, mock_get, mock_notify, mock_now):
242
+ fake_now = object()
243
+ mock_now.return_value = fake_now
244
+ tag = MagicMock()
245
+ tag.pk = 1
246
+ tag.label_id = 1
247
+ tag.allowed = True
248
+ tag.color = "B"
249
+ tag.released = False
250
+ tag.reference = None
251
+ tag.kind = RFID.CLASSIC
252
+ mock_get.return_value = (tag, True)
253
+
254
+ result = validate_rfid_value("abcd1234")
255
+
256
+ mock_get.assert_called_once_with(rfid="ABCD1234", defaults={})
257
+ tag.save.assert_called_once_with(update_fields=["last_seen_on"])
258
+ self.assertIs(tag.last_seen_on, fake_now)
259
+ mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
260
+ self.assertTrue(result["created"])
261
+ self.assertEqual(result["rfid"], "ABCD1234")
262
+
263
+ @patch("ocpp.rfid.reader.timezone.now")
264
+ @patch("ocpp.rfid.reader.notify_async")
265
+ @patch("ocpp.rfid.reader.RFID.objects.get_or_create")
266
+ def test_updates_existing_tag_kind(self, mock_get, mock_notify, mock_now):
267
+ fake_now = object()
268
+ mock_now.return_value = fake_now
269
+ tag = MagicMock()
270
+ tag.pk = 5
271
+ tag.label_id = 5
272
+ tag.allowed = False
273
+ tag.color = "G"
274
+ tag.released = True
275
+ tag.reference = None
276
+ tag.kind = RFID.CLASSIC
277
+ mock_get.return_value = (tag, False)
278
+
279
+ result = validate_rfid_value("abcd", kind=RFID.NTAG215)
280
+
281
+ mock_get.assert_called_once_with(
282
+ rfid="ABCD", defaults={"kind": RFID.NTAG215}
283
+ )
284
+ tag.save.assert_called_once_with(update_fields=["kind", "last_seen_on"])
285
+ self.assertIs(tag.last_seen_on, fake_now)
286
+ self.assertEqual(tag.kind, RFID.NTAG215)
287
+ mock_notify.assert_called_once_with("RFID 5 BAD", "ABCD G")
288
+ self.assertFalse(result["allowed"])
289
+ self.assertFalse(result["created"])
290
+ self.assertEqual(result["kind"], RFID.NTAG215)
291
+
292
+ def test_rejects_invalid_value(self):
293
+ result = validate_rfid_value("invalid!")
294
+ self.assertEqual(result, {"error": "RFID must be hexadecimal digits"})
295
+
296
+ def test_rejects_non_string_values(self):
297
+ result = validate_rfid_value(12345)
298
+ self.assertEqual(result, {"error": "RFID must be a string"})
299
+
300
+ def test_rejects_missing_value(self):
301
+ result = validate_rfid_value(None)
302
+ self.assertEqual(result, {"error": "RFID value is required"})
303
+
304
+
174
305
  class CardTypeDetectionTests(TestCase):
175
306
  def _mock_ntag_reader(self):
176
307
  class MockReader:
@@ -301,7 +432,12 @@ class RFIDDetectionScriptTests(SimpleTestCase):
301
432
  mock_detect.assert_called_once()
302
433
 
303
434
 
304
- class RestartViewTests(SimpleTestCase):
435
+ class RestartViewTests(TestCase):
436
+ def setUp(self):
437
+ User = get_user_model()
438
+ self.user = User.objects.create_user("restart-user", password="pwd")
439
+ self.client.force_login(self.user)
440
+
305
441
  @patch("config.middleware.Node.get_local", return_value=None)
306
442
  @patch("config.middleware.get_site")
307
443
  @patch("ocpp.rfid.views.restart_sources", return_value={"status": "restarted"})
@@ -311,8 +447,19 @@ class RestartViewTests(SimpleTestCase):
311
447
  self.assertEqual(resp.json(), {"status": "restarted"})
312
448
  mock_restart.assert_called_once()
313
449
 
450
+ def test_restart_requires_authentication(self):
451
+ self.client.logout()
452
+ resp = self.client.post(reverse("rfid-scan-restart"))
453
+ self.assertEqual(resp.status_code, 302)
454
+ self.assertIn(reverse("pages:login"), resp.url)
455
+
456
+
457
+ class ScanTestViewTests(TestCase):
458
+ def setUp(self):
459
+ User = get_user_model()
460
+ self.user = User.objects.create_user("scan-test-user", password="pwd")
461
+ self.client.force_login(self.user)
314
462
 
315
- class ScanTestViewTests(SimpleTestCase):
316
463
  @patch("config.middleware.Node.get_local", return_value=None)
317
464
  @patch("config.middleware.get_site")
318
465
  @patch("ocpp.rfid.views.test_sources", return_value={"irq_pin": 7})
@@ -332,6 +479,12 @@ class ScanTestViewTests(SimpleTestCase):
332
479
  self.assertEqual(resp.status_code, 500)
333
480
  self.assertEqual(resp.json(), {"error": "no scanner detected"})
334
481
 
482
+ def test_scan_test_requires_authentication(self):
483
+ self.client.logout()
484
+ resp = self.client.get(reverse("rfid-scan-test"))
485
+ self.assertEqual(resp.status_code, 302)
486
+ self.assertIn(reverse("pages:login"), resp.url)
487
+
335
488
 
336
489
  class RFIDLandingTests(TestCase):
337
490
  def test_scanner_view_registered_as_landing(self):
@@ -352,6 +505,8 @@ class RFIDLandingTests(TestCase):
352
505
  class ScannerTemplateTests(TestCase):
353
506
  def setUp(self):
354
507
  self.url = reverse("rfid-reader")
508
+ User = get_user_model()
509
+ self.user = User.objects.create_user("scanner-user", password="pwd")
355
510
 
356
511
  def test_configure_link_for_staff(self):
357
512
  User = get_user_model()
@@ -360,9 +515,11 @@ class ScannerTemplateTests(TestCase):
360
515
  resp = self.client.get(self.url)
361
516
  self.assertContains(resp, 'id="rfid-configure"')
362
517
 
363
- def test_no_link_for_anonymous(self):
518
+ def test_redirect_for_anonymous(self):
519
+ self.client.logout()
364
520
  resp = self.client.get(self.url)
365
- self.assertNotContains(resp, 'id="rfid-configure"')
521
+ self.assertEqual(resp.status_code, 302)
522
+ self.assertIn(reverse("pages:login"), resp.url)
366
523
 
367
524
  def test_advanced_fields_for_staff(self):
368
525
  User = get_user_model()
@@ -374,9 +531,12 @@ class ScannerTemplateTests(TestCase):
374
531
  self.assertContains(resp, 'id="rfid-released"')
375
532
  self.assertContains(resp, 'id="rfid-reference"')
376
533
 
377
- def test_basic_fields_for_public(self):
534
+ def test_basic_fields_for_authenticated_user(self):
535
+ self.client.logout()
536
+ self.client.force_login(self.user)
378
537
  resp = self.client.get(self.url)
379
538
  self.assertContains(resp, 'id="rfid-kind"')
539
+ self.assertNotContains(resp, 'id="rfid-connect-local"')
380
540
  self.assertNotContains(resp, 'id="rfid-rfid"')
381
541
  self.assertNotContains(resp, 'id="rfid-released"')
382
542
  self.assertNotContains(resp, 'id="rfid-reference"')
@@ -388,7 +548,9 @@ class ScannerTemplateTests(TestCase):
388
548
  resp = self.client.get(self.url)
389
549
  self.assertContains(resp, 'id="rfid-deep-read"')
390
550
 
391
- def test_no_deep_read_button_for_public(self):
551
+ def test_no_deep_read_button_for_authenticated_user(self):
552
+ self.client.logout()
553
+ self.client.force_login(self.user)
392
554
  resp = self.client.get(self.url)
393
555
  self.assertNotContains(resp, 'id="rfid-deep-read"')
394
556
 
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"
@@ -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) or hasattr(
43
- view_func, "login_url"
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
- continue
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 app_name == "man":
56
- module.menu = "Manuals"
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
- Landing.objects.get_or_create(
122
- module=self,
123
- path=f"{self.path}{prefix}{str(pattern.pattern)}",
124
- defaults={
125
- "label": getattr(
126
- callback,
127
- "landing_label",
128
- callback.__name__.replace("_", " ").title(),
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
- natural_key.dependencies = ["nodes.NodeRole", "pages.Module"]
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
- man_app, _ = Application.objects.get_or_create(name="man")
1045
+ manuals_app, _ = Application.objects.get_or_create(name="pages")
1046
1046
  man_module, _ = Module.objects.get_or_create(
1047
- node_role=role, application=man_app, path="/man/"
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(), "MANUALS")
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 test_page_renders(self):
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
  ]