arthexis 0.1.17__py3-none-any.whl → 0.1.19__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.17.dist-info → arthexis-0.1.19.dist-info}/METADATA +37 -10
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/RECORD +35 -34
- config/middleware.py +47 -1
- config/settings.py +2 -5
- config/urls.py +5 -0
- core/admin.py +1 -1
- core/models.py +31 -1
- core/system.py +125 -0
- core/tasks.py +0 -22
- core/tests.py +9 -0
- core/views.py +87 -19
- nodes/admin.py +1 -2
- nodes/models.py +18 -23
- nodes/tests.py +42 -34
- nodes/urls.py +0 -1
- nodes/views.py +2 -15
- ocpp/admin.py +23 -2
- ocpp/consumers.py +63 -19
- ocpp/models.py +7 -0
- ocpp/store.py +6 -4
- ocpp/test_rfid.py +70 -0
- ocpp/tests.py +107 -1
- ocpp/views.py +84 -10
- pages/admin.py +150 -15
- pages/apps.py +3 -0
- pages/context_processors.py +11 -0
- pages/middleware.py +3 -0
- pages/models.py +35 -0
- pages/site_config.py +137 -0
- pages/tests.py +352 -30
- pages/urls.py +2 -1
- pages/views.py +70 -23
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/WHEEL +0 -0
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/top_level.txt +0 -0
pages/tests.py
CHANGED
|
@@ -8,18 +8,19 @@ django.setup()
|
|
|
8
8
|
|
|
9
9
|
from django.test import Client, RequestFactory, TestCase, SimpleTestCase, override_settings
|
|
10
10
|
from django.test.utils import CaptureQueriesContext
|
|
11
|
-
from django.urls import
|
|
11
|
+
from django.urls import reverse
|
|
12
12
|
from django.templatetags.static import static
|
|
13
13
|
from urllib.parse import quote
|
|
14
14
|
from django.contrib.auth import get_user_model
|
|
15
15
|
from django.contrib.sites.models import Site
|
|
16
|
-
from django.contrib import admin
|
|
16
|
+
from django.contrib import admin, messages
|
|
17
17
|
from django.contrib.messages.storage.fallback import FallbackStorage
|
|
18
18
|
from django.core.exceptions import DisallowedHost
|
|
19
19
|
from django.core.cache import cache
|
|
20
20
|
from django.db import connection
|
|
21
21
|
import socket
|
|
22
22
|
from django.db import connection
|
|
23
|
+
from pages import site_config
|
|
23
24
|
from pages.models import (
|
|
24
25
|
Application,
|
|
25
26
|
Landing,
|
|
@@ -32,6 +33,8 @@ from pages.models import (
|
|
|
32
33
|
UserManual,
|
|
33
34
|
UserStory,
|
|
34
35
|
)
|
|
36
|
+
from django.http import FileResponse
|
|
37
|
+
|
|
35
38
|
from pages.admin import (
|
|
36
39
|
ApplicationAdmin,
|
|
37
40
|
UserManualAdmin,
|
|
@@ -47,6 +50,7 @@ from pages.screenshot_specs import (
|
|
|
47
50
|
)
|
|
48
51
|
from pages.context_processors import nav_links
|
|
49
52
|
from django.apps import apps as django_apps
|
|
53
|
+
from config.middleware import SiteHttpsRedirectMiddleware
|
|
50
54
|
from core import mailer
|
|
51
55
|
from core.admin import ProfileAdminMixin
|
|
52
56
|
from core.models import (
|
|
@@ -60,8 +64,10 @@ from core.models import (
|
|
|
60
64
|
Todo,
|
|
61
65
|
TOTPDeviceSettings,
|
|
62
66
|
)
|
|
67
|
+
from ocpp.models import Charger
|
|
63
68
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
64
69
|
import base64
|
|
70
|
+
import json
|
|
65
71
|
import tempfile
|
|
66
72
|
import shutil
|
|
67
73
|
from io import StringIO
|
|
@@ -72,6 +78,7 @@ from types import SimpleNamespace
|
|
|
72
78
|
from django.core.management import call_command
|
|
73
79
|
import re
|
|
74
80
|
from django.contrib.contenttypes.models import ContentType
|
|
81
|
+
from django.http import HttpResponse
|
|
75
82
|
from datetime import (
|
|
76
83
|
date,
|
|
77
84
|
datetime,
|
|
@@ -685,23 +692,6 @@ class AdminDashboardAppListTests(TestCase):
|
|
|
685
692
|
resp = self.client.get(reverse("admin:index"))
|
|
686
693
|
self.assertContains(resp, "5. Horologia MODELS")
|
|
687
694
|
|
|
688
|
-
def test_dashboard_handles_missing_last_net_message_url(self):
|
|
689
|
-
from pages.templatetags import admin_extras
|
|
690
|
-
|
|
691
|
-
real_reverse = admin_extras.reverse
|
|
692
|
-
|
|
693
|
-
def fake_reverse(name, *args, **kwargs):
|
|
694
|
-
if name == "last-net-message":
|
|
695
|
-
raise NoReverseMatch("missing")
|
|
696
|
-
return real_reverse(name, *args, **kwargs)
|
|
697
|
-
|
|
698
|
-
with patch("pages.templatetags.admin_extras.reverse", side_effect=fake_reverse):
|
|
699
|
-
resp = self.client.get(reverse("admin:index"))
|
|
700
|
-
|
|
701
|
-
self.assertEqual(resp.status_code, 200)
|
|
702
|
-
self.assertNotIn(b"last-net-message", resp.content)
|
|
703
|
-
|
|
704
|
-
|
|
705
695
|
class AdminSidebarTests(TestCase):
|
|
706
696
|
def setUp(self):
|
|
707
697
|
self.client = Client()
|
|
@@ -811,7 +801,8 @@ class ViewHistoryLoggingTests(TestCase):
|
|
|
811
801
|
)
|
|
812
802
|
landing = module.landings.get(path="/")
|
|
813
803
|
landing.label = "Home Landing"
|
|
814
|
-
landing.
|
|
804
|
+
landing.track_leads = True
|
|
805
|
+
landing.save(update_fields=["label", "track_leads"])
|
|
815
806
|
|
|
816
807
|
resp = self.client.get(
|
|
817
808
|
reverse("pages:index"), HTTP_REFERER="https://example.com/ref"
|
|
@@ -836,7 +827,8 @@ class ViewHistoryLoggingTests(TestCase):
|
|
|
836
827
|
)
|
|
837
828
|
landing = module.landings.get(path="/")
|
|
838
829
|
landing.label = "No Celery"
|
|
839
|
-
landing.
|
|
830
|
+
landing.track_leads = True
|
|
831
|
+
landing.save(update_fields=["label", "track_leads"])
|
|
840
832
|
|
|
841
833
|
resp = self.client.get(reverse("pages:index"))
|
|
842
834
|
|
|
@@ -856,7 +848,8 @@ class ViewHistoryLoggingTests(TestCase):
|
|
|
856
848
|
)
|
|
857
849
|
landing = module.landings.get(path="/")
|
|
858
850
|
landing.enabled = False
|
|
859
|
-
landing.
|
|
851
|
+
landing.track_leads = True
|
|
852
|
+
landing.save(update_fields=["enabled", "track_leads"])
|
|
860
853
|
|
|
861
854
|
resp = self.client.get(reverse("pages:index"))
|
|
862
855
|
|
|
@@ -1125,6 +1118,50 @@ class LogViewerAdminTests(SimpleTestCase):
|
|
|
1125
1118
|
self.assertEqual(context["selected_log"], "selected.log")
|
|
1126
1119
|
self.assertIn("hello world", context["log_content"])
|
|
1127
1120
|
|
|
1121
|
+
def test_log_viewer_applies_line_limit(self):
|
|
1122
|
+
content = "\n".join(f"line {i}" for i in range(50))
|
|
1123
|
+
self._create_log("limited.log", content)
|
|
1124
|
+
response = self._render({"log": "limited.log", "limit": "20"})
|
|
1125
|
+
context = response.context_data
|
|
1126
|
+
self.assertEqual(context["log_limit_choice"], "20")
|
|
1127
|
+
self.assertIn("line 49", context["log_content"])
|
|
1128
|
+
self.assertIn("line 30", context["log_content"])
|
|
1129
|
+
self.assertNotIn("line 29", context["log_content"])
|
|
1130
|
+
|
|
1131
|
+
def test_log_viewer_all_limit_returns_full_log(self):
|
|
1132
|
+
content = "first\nsecond\nthird"
|
|
1133
|
+
self._create_log("all.log", content)
|
|
1134
|
+
response = self._render({"log": "all.log", "limit": "all"})
|
|
1135
|
+
context = response.context_data
|
|
1136
|
+
self.assertEqual(context["log_limit_choice"], "all")
|
|
1137
|
+
self.assertIn("first", context["log_content"])
|
|
1138
|
+
self.assertIn("second", context["log_content"])
|
|
1139
|
+
|
|
1140
|
+
def test_log_viewer_invalid_limit_defaults_to_20(self):
|
|
1141
|
+
content = "\n".join(f"item {i}" for i in range(5))
|
|
1142
|
+
self._create_log("invalid-limit.log", content)
|
|
1143
|
+
response = self._render({"log": "invalid-limit.log", "limit": "oops"})
|
|
1144
|
+
context = response.context_data
|
|
1145
|
+
self.assertEqual(context["log_limit_choice"], "20")
|
|
1146
|
+
|
|
1147
|
+
def test_log_viewer_downloads_selected_log(self):
|
|
1148
|
+
self._create_log("download.log", "downloadable content")
|
|
1149
|
+
request = self._build_request({"log": "download.log", "download": "1"})
|
|
1150
|
+
context = {
|
|
1151
|
+
"site_title": "Constellation",
|
|
1152
|
+
"site_header": "Constellation",
|
|
1153
|
+
"site_url": "/",
|
|
1154
|
+
"available_apps": [],
|
|
1155
|
+
}
|
|
1156
|
+
with patch("pages.admin.admin.site.each_context", return_value=context), patch(
|
|
1157
|
+
"pages.context_processors.get_site", return_value=None
|
|
1158
|
+
):
|
|
1159
|
+
response = log_viewer(request)
|
|
1160
|
+
self.assertIsInstance(response, FileResponse)
|
|
1161
|
+
self.assertIn("attachment", response["Content-Disposition"])
|
|
1162
|
+
content = b"".join(response.streaming_content).decode()
|
|
1163
|
+
self.assertIn("downloadable content", content)
|
|
1164
|
+
|
|
1128
1165
|
def test_log_viewer_reports_missing_log(self):
|
|
1129
1166
|
response = self._render({"log": "missing.log"})
|
|
1130
1167
|
self.assertIn("requested log could not be found", response.context_data["log_error"])
|
|
@@ -1172,6 +1209,125 @@ class AdminModelStatusTests(TestCase):
|
|
|
1172
1209
|
self.assertContains(resp, 'class="model-status missing"', count=1)
|
|
1173
1210
|
|
|
1174
1211
|
|
|
1212
|
+
class _FakeQuerySet(list):
|
|
1213
|
+
def only(self, *args, **kwargs):
|
|
1214
|
+
return self
|
|
1215
|
+
|
|
1216
|
+
def order_by(self, *args, **kwargs):
|
|
1217
|
+
return self
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
class SiteConfigurationStagingTests(SimpleTestCase):
|
|
1221
|
+
def setUp(self):
|
|
1222
|
+
self.tmpdir = tempfile.mkdtemp()
|
|
1223
|
+
self.addCleanup(shutil.rmtree, self.tmpdir)
|
|
1224
|
+
self.config_path = Path(self.tmpdir) / "nginx-sites.json"
|
|
1225
|
+
self._path_patcher = patch(
|
|
1226
|
+
"pages.site_config._sites_config_path", side_effect=lambda: self.config_path
|
|
1227
|
+
)
|
|
1228
|
+
self._path_patcher.start()
|
|
1229
|
+
self.addCleanup(self._path_patcher.stop)
|
|
1230
|
+
self._model_patcher = patch("pages.site_config.apps.get_model")
|
|
1231
|
+
self.mock_get_model = self._model_patcher.start()
|
|
1232
|
+
self.addCleanup(self._model_patcher.stop)
|
|
1233
|
+
|
|
1234
|
+
def _read_config(self):
|
|
1235
|
+
if not self.config_path.exists():
|
|
1236
|
+
return None
|
|
1237
|
+
return json.loads(self.config_path.read_text(encoding="utf-8"))
|
|
1238
|
+
|
|
1239
|
+
def _set_sites(self, sites):
|
|
1240
|
+
queryset = _FakeQuerySet(sites)
|
|
1241
|
+
|
|
1242
|
+
class _Manager:
|
|
1243
|
+
@staticmethod
|
|
1244
|
+
def filter(**kwargs):
|
|
1245
|
+
return queryset
|
|
1246
|
+
|
|
1247
|
+
self.mock_get_model.return_value = SimpleNamespace(objects=_Manager())
|
|
1248
|
+
|
|
1249
|
+
def test_managed_site_persists_configuration(self):
|
|
1250
|
+
self._set_sites([SimpleNamespace(domain="example.com", require_https=True)])
|
|
1251
|
+
site_config.update_local_nginx_scripts()
|
|
1252
|
+
config = self._read_config()
|
|
1253
|
+
self.assertEqual(
|
|
1254
|
+
config,
|
|
1255
|
+
[
|
|
1256
|
+
{
|
|
1257
|
+
"domain": "example.com",
|
|
1258
|
+
"require_https": True,
|
|
1259
|
+
}
|
|
1260
|
+
],
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
def test_disabling_managed_site_removes_entry(self):
|
|
1264
|
+
primary = SimpleNamespace(domain="primary.test", require_https=False)
|
|
1265
|
+
secondary = SimpleNamespace(domain="secondary.test", require_https=False)
|
|
1266
|
+
self._set_sites([primary, secondary])
|
|
1267
|
+
site_config.update_local_nginx_scripts()
|
|
1268
|
+
config = self._read_config()
|
|
1269
|
+
self.assertEqual(
|
|
1270
|
+
[entry["domain"] for entry in config],
|
|
1271
|
+
["primary.test", "secondary.test"],
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
self._set_sites([secondary])
|
|
1275
|
+
site_config.update_local_nginx_scripts()
|
|
1276
|
+
config = self._read_config()
|
|
1277
|
+
self.assertEqual(config, [{"domain": "secondary.test", "require_https": False}])
|
|
1278
|
+
|
|
1279
|
+
self._set_sites([])
|
|
1280
|
+
site_config.update_local_nginx_scripts()
|
|
1281
|
+
self.assertIsNone(self._read_config())
|
|
1282
|
+
|
|
1283
|
+
def test_require_https_toggle_updates_configuration(self):
|
|
1284
|
+
site = SimpleNamespace(domain="secure.example", require_https=False)
|
|
1285
|
+
self._set_sites([site])
|
|
1286
|
+
site_config.update_local_nginx_scripts()
|
|
1287
|
+
config = self._read_config()
|
|
1288
|
+
self.assertEqual(config, [{"domain": "secure.example", "require_https": False}])
|
|
1289
|
+
|
|
1290
|
+
site.require_https = True
|
|
1291
|
+
self._set_sites([site])
|
|
1292
|
+
site_config.update_local_nginx_scripts()
|
|
1293
|
+
config = self._read_config()
|
|
1294
|
+
self.assertEqual(config, [{"domain": "secure.example", "require_https": True}])
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
class SiteRequireHttpsMiddlewareTests(SimpleTestCase):
|
|
1298
|
+
def setUp(self):
|
|
1299
|
+
self.factory = RequestFactory()
|
|
1300
|
+
self.middleware = SiteHttpsRedirectMiddleware(lambda request: HttpResponse("ok"))
|
|
1301
|
+
self.secure_site = SimpleNamespace(domain="secure.test", require_https=True)
|
|
1302
|
+
|
|
1303
|
+
def test_http_request_redirects_to_https(self):
|
|
1304
|
+
request = self.factory.get("/", HTTP_HOST="secure.test")
|
|
1305
|
+
request.site = self.secure_site
|
|
1306
|
+
response = self.middleware(request)
|
|
1307
|
+
self.assertEqual(response.status_code, 301)
|
|
1308
|
+
self.assertTrue(response["Location"].startswith("https://secure.test"))
|
|
1309
|
+
|
|
1310
|
+
def test_secure_request_not_redirected(self):
|
|
1311
|
+
request = self.factory.get("/", HTTP_HOST="secure.test", secure=True)
|
|
1312
|
+
request.site = self.secure_site
|
|
1313
|
+
response = self.middleware(request)
|
|
1314
|
+
self.assertEqual(response.status_code, 200)
|
|
1315
|
+
|
|
1316
|
+
def test_forwarded_proto_respected(self):
|
|
1317
|
+
request = self.factory.get(
|
|
1318
|
+
"/", HTTP_HOST="secure.test", HTTP_X_FORWARDED_PROTO="https"
|
|
1319
|
+
)
|
|
1320
|
+
request.site = self.secure_site
|
|
1321
|
+
response = self.middleware(request)
|
|
1322
|
+
self.assertEqual(response.status_code, 200)
|
|
1323
|
+
|
|
1324
|
+
self.secure_site.require_https = False
|
|
1325
|
+
request = self.factory.get("/", HTTP_HOST="secure.test")
|
|
1326
|
+
request.site = self.secure_site
|
|
1327
|
+
response = self.middleware(request)
|
|
1328
|
+
self.assertEqual(response.status_code, 200)
|
|
1329
|
+
|
|
1330
|
+
|
|
1175
1331
|
class SiteAdminRegisterCurrentTests(TestCase):
|
|
1176
1332
|
def setUp(self):
|
|
1177
1333
|
self.client = Client()
|
|
@@ -1323,17 +1479,17 @@ class NavAppsTests(TestCase):
|
|
|
1323
1479
|
)
|
|
1324
1480
|
app = Application.objects.create(name="Readme")
|
|
1325
1481
|
Module.objects.create(
|
|
1326
|
-
node_role=role, application=app, path="/", is_default=True
|
|
1482
|
+
node_role=role, application=app, path="/", is_default=True, menu="Cookbook"
|
|
1327
1483
|
)
|
|
1328
1484
|
|
|
1329
1485
|
def test_nav_pill_renders(self):
|
|
1330
1486
|
resp = self.client.get(reverse("pages:index"))
|
|
1331
|
-
self.assertContains(resp, "
|
|
1487
|
+
self.assertContains(resp, "COOKBOOK")
|
|
1332
1488
|
self.assertContains(resp, "badge rounded-pill")
|
|
1333
1489
|
|
|
1334
1490
|
def test_nav_pill_renders_with_port(self):
|
|
1335
1491
|
resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8000")
|
|
1336
|
-
self.assertContains(resp, "
|
|
1492
|
+
self.assertContains(resp, "COOKBOOK")
|
|
1337
1493
|
|
|
1338
1494
|
def test_nav_pill_uses_menu_field(self):
|
|
1339
1495
|
site_app = Module.objects.get()
|
|
@@ -1341,7 +1497,7 @@ class NavAppsTests(TestCase):
|
|
|
1341
1497
|
site_app.save()
|
|
1342
1498
|
resp = self.client.get(reverse("pages:index"))
|
|
1343
1499
|
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">DOCS')
|
|
1344
|
-
self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">
|
|
1500
|
+
self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
|
|
1345
1501
|
|
|
1346
1502
|
def test_app_without_root_url_excluded(self):
|
|
1347
1503
|
role = NodeRole.objects.get(name="Terminal")
|
|
@@ -1711,8 +1867,57 @@ class ControlNavTests(TestCase):
|
|
|
1711
1867
|
|
|
1712
1868
|
def test_readme_pill_visible(self):
|
|
1713
1869
|
resp = self.client.get(reverse("pages:readme"))
|
|
1714
|
-
self.assertContains(resp, 'href="/
|
|
1715
|
-
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">
|
|
1870
|
+
self.assertContains(resp, 'href="/read/"')
|
|
1871
|
+
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
|
|
1872
|
+
|
|
1873
|
+
def test_cookbook_pill_has_no_dropdown(self):
|
|
1874
|
+
module = Module.objects.get(node_role__name="Control", path="/read/")
|
|
1875
|
+
Landing.objects.create(
|
|
1876
|
+
module=module,
|
|
1877
|
+
path="/man/",
|
|
1878
|
+
label="Manuals",
|
|
1879
|
+
enabled=True,
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
resp = self.client.get(reverse("pages:readme"))
|
|
1883
|
+
|
|
1884
|
+
self.assertContains(
|
|
1885
|
+
resp,
|
|
1886
|
+
'<a class="nav-link" href="/read/"><span class="badge rounded-pill text-bg-secondary">COOKBOOK</span></a>',
|
|
1887
|
+
html=True,
|
|
1888
|
+
)
|
|
1889
|
+
self.assertNotContains(resp, 'dropdown-item" href="/man/"')
|
|
1890
|
+
|
|
1891
|
+
def test_readme_page_includes_qr_share(self):
|
|
1892
|
+
resp = self.client.get(reverse("pages:readme"), {"section": "intro"})
|
|
1893
|
+
self.assertContains(resp, 'id="reader-qr"')
|
|
1894
|
+
self.assertContains(
|
|
1895
|
+
resp,
|
|
1896
|
+
'data-url="http://testserver/read/?section=intro"',
|
|
1897
|
+
)
|
|
1898
|
+
self.assertNotContains(resp, "Scan this page")
|
|
1899
|
+
self.assertNotContains(
|
|
1900
|
+
resp, 'class="small text-break text-muted mt-3 mb-0"'
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
def test_readme_document_by_name(self):
|
|
1904
|
+
resp = self.client.get(reverse("pages:readme-document", args=["AGENTS.md"]))
|
|
1905
|
+
self.assertEqual(resp.status_code, 200)
|
|
1906
|
+
self.assertContains(resp, "Agent Guidelines")
|
|
1907
|
+
|
|
1908
|
+
def test_readme_document_by_relative_path(self):
|
|
1909
|
+
resp = self.client.get(
|
|
1910
|
+
reverse(
|
|
1911
|
+
"pages:readme-document",
|
|
1912
|
+
args=["docs/development/maintenance-roadmap.md"],
|
|
1913
|
+
)
|
|
1914
|
+
)
|
|
1915
|
+
self.assertEqual(resp.status_code, 200)
|
|
1916
|
+
self.assertContains(resp, "Maintenance Improvement Proposals")
|
|
1917
|
+
|
|
1918
|
+
def test_readme_document_rejects_traversal(self):
|
|
1919
|
+
resp = self.client.get("/read/../../SECRET.md")
|
|
1920
|
+
self.assertEqual(resp.status_code, 404)
|
|
1716
1921
|
|
|
1717
1922
|
|
|
1718
1923
|
class SatelliteNavTests(TestCase):
|
|
@@ -1784,8 +1989,8 @@ class SatelliteNavTests(TestCase):
|
|
|
1784
1989
|
|
|
1785
1990
|
def test_readme_pill_visible(self):
|
|
1786
1991
|
resp = self.client.get(reverse("pages:readme"))
|
|
1787
|
-
self.assertContains(resp, 'href="/
|
|
1788
|
-
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">
|
|
1992
|
+
self.assertContains(resp, 'href="/read/"')
|
|
1993
|
+
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
|
|
1789
1994
|
|
|
1790
1995
|
|
|
1791
1996
|
class PowerNavTests(TestCase):
|
|
@@ -2064,6 +2269,47 @@ class UserManualAdminFormTests(TestCase):
|
|
|
2064
2269
|
self.assertEqual(form.cleaned_data["content_pdf"], self.manual.content_pdf)
|
|
2065
2270
|
|
|
2066
2271
|
|
|
2272
|
+
class UserManualModelTests(TestCase):
|
|
2273
|
+
def _build_manual(self, **overrides):
|
|
2274
|
+
defaults = {
|
|
2275
|
+
"slug": "manual-model-test",
|
|
2276
|
+
"title": "Manual Model",
|
|
2277
|
+
"description": "Manual description",
|
|
2278
|
+
"languages": "en",
|
|
2279
|
+
"content_html": "<p>Manual</p>",
|
|
2280
|
+
"content_pdf": base64.b64encode(b"initial").decode("ascii"),
|
|
2281
|
+
}
|
|
2282
|
+
defaults.update(overrides)
|
|
2283
|
+
return UserManual(**defaults)
|
|
2284
|
+
|
|
2285
|
+
def test_save_encodes_uploaded_file(self):
|
|
2286
|
+
upload = SimpleUploadedFile("manual.pdf", b"PDF data")
|
|
2287
|
+
manual = self._build_manual(slug="manual-upload", content_pdf=upload)
|
|
2288
|
+
manual.save()
|
|
2289
|
+
manual.refresh_from_db()
|
|
2290
|
+
self.assertEqual(
|
|
2291
|
+
manual.content_pdf,
|
|
2292
|
+
base64.b64encode(b"PDF data").decode("ascii"),
|
|
2293
|
+
)
|
|
2294
|
+
|
|
2295
|
+
def test_save_encodes_raw_bytes(self):
|
|
2296
|
+
manual = self._build_manual(slug="manual-bytes", content_pdf=b"PDF raw")
|
|
2297
|
+
manual.save()
|
|
2298
|
+
manual.refresh_from_db()
|
|
2299
|
+
self.assertEqual(
|
|
2300
|
+
manual.content_pdf,
|
|
2301
|
+
base64.b64encode(b"PDF raw").decode("ascii"),
|
|
2302
|
+
)
|
|
2303
|
+
|
|
2304
|
+
def test_save_strips_data_uri_prefix(self):
|
|
2305
|
+
encoded = base64.b64encode(b"PDF data").decode("ascii")
|
|
2306
|
+
data_uri = f"data:application/pdf;base64,{encoded}"
|
|
2307
|
+
manual = self._build_manual(slug="manual-data-uri", content_pdf=data_uri)
|
|
2308
|
+
manual.save()
|
|
2309
|
+
manual.refresh_from_db()
|
|
2310
|
+
self.assertEqual(manual.content_pdf, encoded)
|
|
2311
|
+
|
|
2312
|
+
|
|
2067
2313
|
class LandingCreationTests(TestCase):
|
|
2068
2314
|
def setUp(self):
|
|
2069
2315
|
role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
@@ -2422,6 +2668,48 @@ class FavoriteTests(TestCase):
|
|
|
2422
2668
|
self.assertContains(resp, f'title="{badge_label}"')
|
|
2423
2669
|
self.assertContains(resp, f'aria-label="{badge_label}"')
|
|
2424
2670
|
|
|
2671
|
+
def test_dashboard_shows_charge_point_availability_badge(self):
|
|
2672
|
+
Charger.objects.create(
|
|
2673
|
+
charger_id="CP-001", connector_id=1, last_status="Available"
|
|
2674
|
+
)
|
|
2675
|
+
Charger.objects.create(charger_id="CP-002", last_status="Available")
|
|
2676
|
+
Charger.objects.create(
|
|
2677
|
+
charger_id="CP-003", connector_id=1, last_status="Unavailable"
|
|
2678
|
+
)
|
|
2679
|
+
|
|
2680
|
+
resp = self.client.get(reverse("admin:index"))
|
|
2681
|
+
|
|
2682
|
+
expected = "1 / 2"
|
|
2683
|
+
badge_label = gettext(
|
|
2684
|
+
"%(available)s chargers reporting Available status with a CP number, out of %(total)s total Available chargers. %(missing)s Available chargers are missing a connector number."
|
|
2685
|
+
) % {"available": 1, "total": 2, "missing": 1}
|
|
2686
|
+
|
|
2687
|
+
self.assertContains(resp, expected)
|
|
2688
|
+
self.assertContains(resp, 'class="charger-availability-badge"')
|
|
2689
|
+
self.assertContains(resp, f'title="{badge_label}"')
|
|
2690
|
+
self.assertContains(resp, f'aria-label="{badge_label}"')
|
|
2691
|
+
|
|
2692
|
+
def test_dashboard_charge_point_badge_ignores_aggregator(self):
|
|
2693
|
+
Charger.objects.create(charger_id="CP-AGG", last_status="Available")
|
|
2694
|
+
Charger.objects.create(
|
|
2695
|
+
charger_id="CP-AGG", connector_id=1, last_status="Available"
|
|
2696
|
+
)
|
|
2697
|
+
Charger.objects.create(
|
|
2698
|
+
charger_id="CP-AGG", connector_id=2, last_status="Available"
|
|
2699
|
+
)
|
|
2700
|
+
|
|
2701
|
+
resp = self.client.get(reverse("admin:index"))
|
|
2702
|
+
|
|
2703
|
+
expected = "2 / 2"
|
|
2704
|
+
badge_label = gettext(
|
|
2705
|
+
"%(available)s chargers reporting Available status with a CP number."
|
|
2706
|
+
) % {"available": 2}
|
|
2707
|
+
|
|
2708
|
+
self.assertContains(resp, expected)
|
|
2709
|
+
self.assertContains(resp, 'class="charger-availability-badge"')
|
|
2710
|
+
self.assertContains(resp, f'title="{badge_label}"')
|
|
2711
|
+
self.assertContains(resp, f'aria-label="{badge_label}"')
|
|
2712
|
+
|
|
2425
2713
|
def test_nav_sidebar_hides_dashboard_badges(self):
|
|
2426
2714
|
InviteLead.objects.create(email="open@example.com")
|
|
2427
2715
|
RFID.objects.create(rfid="RFID0003", released=True, allowed=True)
|
|
@@ -3159,6 +3447,40 @@ class UserStoryAdminActionTests(TestCase):
|
|
|
3159
3447
|
|
|
3160
3448
|
mock_create_issue.assert_not_called()
|
|
3161
3449
|
|
|
3450
|
+
def test_create_github_issues_action_links_to_credentials_when_missing(self):
|
|
3451
|
+
request = self._build_request()
|
|
3452
|
+
queryset = UserStory.objects.filter(pk=self.story.pk)
|
|
3453
|
+
|
|
3454
|
+
mock_url = "/admin/core/releasemanager/"
|
|
3455
|
+
with (
|
|
3456
|
+
patch(
|
|
3457
|
+
"pages.admin.reverse", return_value=mock_url
|
|
3458
|
+
) as mock_reverse,
|
|
3459
|
+
patch.object(
|
|
3460
|
+
UserStory,
|
|
3461
|
+
"create_github_issue",
|
|
3462
|
+
side_effect=RuntimeError("GitHub token is not configured"),
|
|
3463
|
+
),
|
|
3464
|
+
):
|
|
3465
|
+
self.admin.create_github_issues(request, queryset)
|
|
3466
|
+
|
|
3467
|
+
messages_list = list(request._messages)
|
|
3468
|
+
self.assertTrue(messages_list)
|
|
3469
|
+
|
|
3470
|
+
opts = ReleaseManager._meta
|
|
3471
|
+
mock_reverse.assert_called_once_with(
|
|
3472
|
+
f"{self.admin.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
|
|
3473
|
+
)
|
|
3474
|
+
self.assertTrue(
|
|
3475
|
+
any(mock_url in message.message for message in messages_list),
|
|
3476
|
+
)
|
|
3477
|
+
self.assertTrue(
|
|
3478
|
+
any("Configure GitHub credentials" in message.message for message in messages_list),
|
|
3479
|
+
)
|
|
3480
|
+
self.assertTrue(
|
|
3481
|
+
any(message.level == messages.ERROR for message in messages_list),
|
|
3482
|
+
)
|
|
3483
|
+
|
|
3162
3484
|
|
|
3163
3485
|
class ClientReportLiveUpdateTests(TestCase):
|
|
3164
3486
|
def setUp(self):
|
pages/urls.py
CHANGED
|
@@ -6,7 +6,8 @@ app_name = "pages"
|
|
|
6
6
|
|
|
7
7
|
urlpatterns = [
|
|
8
8
|
path("", views.index, name="index"),
|
|
9
|
-
path("
|
|
9
|
+
path("read/", views.readme, name="readme"),
|
|
10
|
+
path("read/<path:doc>", views.readme, name="readme-document"),
|
|
10
11
|
path("sitemap.xml", views.sitemap, name="pages-sitemap"),
|
|
11
12
|
path("release/", views.release_admin_redirect, name="release-admin"),
|
|
12
13
|
path("client-report/", views.client_report, name="client-report"),
|
pages/views.py
CHANGED
|
@@ -439,38 +439,80 @@ def admin_model_graph(request, app_label: str):
|
|
|
439
439
|
return response
|
|
440
440
|
|
|
441
441
|
|
|
442
|
-
def _render_readme(request, role):
|
|
442
|
+
def _render_readme(request, role, doc: str | None = None):
|
|
443
443
|
app = (
|
|
444
444
|
Module.objects.filter(node_role=role, is_default=True)
|
|
445
445
|
.select_related("application")
|
|
446
446
|
.first()
|
|
447
447
|
)
|
|
448
448
|
app_slug = app.path.strip("/") if app else ""
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
)
|
|
449
|
+
root_base = Path(settings.BASE_DIR).resolve()
|
|
450
|
+
readme_base = (root_base / app_slug).resolve() if app_slug else root_base
|
|
452
451
|
lang = getattr(request, "LANGUAGE_CODE", "")
|
|
453
452
|
lang = lang.replace("_", "-").lower()
|
|
454
|
-
root_base = Path(settings.BASE_DIR)
|
|
455
453
|
candidates = []
|
|
456
|
-
if
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
454
|
+
if doc:
|
|
455
|
+
normalized = doc.strip().replace("\\", "/")
|
|
456
|
+
while normalized.startswith("./"):
|
|
457
|
+
normalized = normalized[2:]
|
|
458
|
+
normalized = normalized.lstrip("/")
|
|
459
|
+
if not normalized:
|
|
460
|
+
raise Http404("Document not found")
|
|
461
|
+
doc_path = Path(normalized)
|
|
462
|
+
if doc_path.is_absolute() or any(part == ".." for part in doc_path.parts):
|
|
463
|
+
raise Http404("Document not found")
|
|
464
|
+
|
|
465
|
+
relative_candidates: list[Path] = []
|
|
466
|
+
|
|
467
|
+
def add_candidate(path: Path) -> None:
|
|
468
|
+
if path not in relative_candidates:
|
|
469
|
+
relative_candidates.append(path)
|
|
470
|
+
|
|
471
|
+
add_candidate(doc_path)
|
|
472
|
+
if doc_path.suffix.lower() != ".md" or doc_path.suffix != ".md":
|
|
473
|
+
add_candidate(doc_path.with_suffix(".md"))
|
|
474
|
+
if doc_path.suffix.lower() != ".md":
|
|
475
|
+
add_candidate(doc_path / "README.md")
|
|
476
|
+
|
|
477
|
+
search_roots = [readme_base]
|
|
478
|
+
if readme_base != root_base:
|
|
479
|
+
search_roots.append(root_base)
|
|
480
|
+
|
|
481
|
+
for relative in relative_candidates:
|
|
482
|
+
for base in search_roots:
|
|
483
|
+
base_resolved = base.resolve()
|
|
484
|
+
candidate = (base_resolved / relative).resolve(strict=False)
|
|
485
|
+
try:
|
|
486
|
+
candidate.relative_to(base_resolved)
|
|
487
|
+
except ValueError:
|
|
488
|
+
continue
|
|
489
|
+
candidates.append(candidate)
|
|
490
|
+
else:
|
|
463
491
|
if lang:
|
|
464
|
-
candidates.append(
|
|
492
|
+
candidates.append(readme_base / f"README.{lang}.md")
|
|
465
493
|
short = lang.split("-")[0]
|
|
466
494
|
if short != lang:
|
|
467
|
-
candidates.append(
|
|
468
|
-
candidates.append(
|
|
469
|
-
|
|
495
|
+
candidates.append(readme_base / f"README.{short}.md")
|
|
496
|
+
candidates.append(readme_base / "README.md")
|
|
497
|
+
if readme_base != root_base:
|
|
498
|
+
if lang:
|
|
499
|
+
candidates.append(root_base / f"README.{lang}.md")
|
|
500
|
+
short = lang.split("-")[0]
|
|
501
|
+
if short != lang:
|
|
502
|
+
candidates.append(root_base / f"README.{short}.md")
|
|
503
|
+
candidates.append(root_base / "README.md")
|
|
504
|
+
readme_file = next((p for p in candidates if p.exists()), None)
|
|
505
|
+
if readme_file is None:
|
|
506
|
+
raise Http404("Document not found")
|
|
470
507
|
text = readme_file.read_text(encoding="utf-8")
|
|
471
508
|
html, toc_html = _render_markdown_with_toc(text)
|
|
472
509
|
title = "README" if readme_file.name.startswith("README") else readme_file.stem
|
|
473
|
-
context = {
|
|
510
|
+
context = {
|
|
511
|
+
"content": html,
|
|
512
|
+
"title": title,
|
|
513
|
+
"toc": toc_html,
|
|
514
|
+
"page_url": request.build_absolute_uri(),
|
|
515
|
+
}
|
|
474
516
|
response = render(request, "pages/readme.html", context)
|
|
475
517
|
patch_vary_headers(response, ["Accept-Language", "Cookie"])
|
|
476
518
|
return response
|
|
@@ -525,10 +567,10 @@ def index(request):
|
|
|
525
567
|
|
|
526
568
|
|
|
527
569
|
@never_cache
|
|
528
|
-
def readme(request):
|
|
570
|
+
def readme(request, doc=None):
|
|
529
571
|
node = Node.get_local()
|
|
530
572
|
role = node.role if node else None
|
|
531
|
-
return _render_readme(request, role)
|
|
573
|
+
return _render_readme(request, role, doc)
|
|
532
574
|
|
|
533
575
|
|
|
534
576
|
def sitemap(request):
|
|
@@ -976,10 +1018,10 @@ class ClientReportForm(forms.Form):
|
|
|
976
1018
|
label=_("Email destinations"),
|
|
977
1019
|
required=False,
|
|
978
1020
|
widget=forms.Textarea(attrs={"rows": 2}),
|
|
979
|
-
help_text=_("Separate addresses with commas or new lines."),
|
|
1021
|
+
help_text=_("Separate addresses with commas, whitespace, or new lines."),
|
|
980
1022
|
)
|
|
981
1023
|
recurrence = forms.ChoiceField(
|
|
982
|
-
label=_("
|
|
1024
|
+
label=_("Recurrence"),
|
|
983
1025
|
choices=RECURRENCE_CHOICES,
|
|
984
1026
|
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
985
1027
|
help_text=_("Defines how often the report should be generated automatically."),
|
|
@@ -1006,8 +1048,13 @@ class ClientReportForm(forms.Form):
|
|
|
1006
1048
|
week_str = cleaned.get("week")
|
|
1007
1049
|
if not week_str:
|
|
1008
1050
|
raise forms.ValidationError(_("Please select a week."))
|
|
1009
|
-
|
|
1010
|
-
|
|
1051
|
+
try:
|
|
1052
|
+
year_str, week_num_str = week_str.split("-W", 1)
|
|
1053
|
+
start = datetime.date.fromisocalendar(
|
|
1054
|
+
int(year_str), int(week_num_str), 1
|
|
1055
|
+
)
|
|
1056
|
+
except (TypeError, ValueError):
|
|
1057
|
+
raise forms.ValidationError(_("Please select a week."))
|
|
1011
1058
|
cleaned["start"] = start
|
|
1012
1059
|
cleaned["end"] = start + datetime.timedelta(days=6)
|
|
1013
1060
|
elif period == "month":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|