arthexis 0.1.10__py3-none-any.whl → 0.1.12__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.12.dist-info}/METADATA +36 -26
- arthexis-0.1.12.dist-info/RECORD +102 -0
- config/context_processors.py +1 -0
- config/settings.py +31 -5
- config/urls.py +5 -4
- core/admin.py +430 -90
- core/apps.py +48 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +303 -31
- core/reference_utils.py +20 -9
- core/release.py +4 -0
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +250 -1
- core/tasks.py +92 -40
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +169 -3
- core/user_data.py +51 -8
- core/views.py +371 -20
- nodes/admin.py +453 -8
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +374 -31
- nodes/reports.py +411 -0
- nodes/tests.py +677 -38
- nodes/utils.py +32 -0
- nodes/views.py +14 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +517 -16
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +237 -4
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +819 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +233 -19
- pages/admin.py +144 -4
- pages/context_processors.py +21 -7
- pages/defaults.py +13 -0
- pages/forms.py +38 -0
- pages/models.py +189 -15
- pages/tests.py +281 -8
- pages/urls.py +4 -0
- pages/views.py +137 -21
- arthexis-0.1.10.dist-info/RECORD +0 -95
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
pages/tests.py
CHANGED
|
@@ -13,10 +13,18 @@ from urllib.parse import quote
|
|
|
13
13
|
from django.contrib.auth import get_user_model
|
|
14
14
|
from django.contrib.sites.models import Site
|
|
15
15
|
from django.contrib import admin
|
|
16
|
+
from django.contrib.messages.storage.fallback import FallbackStorage
|
|
16
17
|
from django.core.exceptions import DisallowedHost
|
|
17
18
|
import socket
|
|
18
|
-
from pages.models import
|
|
19
|
-
|
|
19
|
+
from pages.models import (
|
|
20
|
+
Application,
|
|
21
|
+
Module,
|
|
22
|
+
SiteBadge,
|
|
23
|
+
Favorite,
|
|
24
|
+
ViewHistory,
|
|
25
|
+
UserStory,
|
|
26
|
+
)
|
|
27
|
+
from pages.admin import ApplicationAdmin, UserStoryAdmin, ViewHistoryAdmin
|
|
20
28
|
from pages.screenshot_specs import (
|
|
21
29
|
ScreenshotSpec,
|
|
22
30
|
ScreenshotSpecRunner,
|
|
@@ -33,6 +41,7 @@ from core.models import (
|
|
|
33
41
|
Reference,
|
|
34
42
|
ReleaseManager,
|
|
35
43
|
Todo,
|
|
44
|
+
TOTPDeviceSettings,
|
|
36
45
|
)
|
|
37
46
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
38
47
|
import base64
|
|
@@ -41,12 +50,18 @@ import shutil
|
|
|
41
50
|
from io import StringIO
|
|
42
51
|
from django.conf import settings
|
|
43
52
|
from pathlib import Path
|
|
44
|
-
from unittest.mock import
|
|
53
|
+
from unittest.mock import MagicMock, Mock, patch
|
|
45
54
|
from types import SimpleNamespace
|
|
46
55
|
from django.core.management import call_command
|
|
47
56
|
import re
|
|
48
57
|
from django.contrib.contenttypes.models import ContentType
|
|
49
|
-
from datetime import
|
|
58
|
+
from datetime import (
|
|
59
|
+
date,
|
|
60
|
+
datetime,
|
|
61
|
+
time as datetime_time,
|
|
62
|
+
timedelta,
|
|
63
|
+
timezone as datetime_timezone,
|
|
64
|
+
)
|
|
50
65
|
from django.core import mail
|
|
51
66
|
from django.utils import timezone
|
|
52
67
|
from django.utils.text import slugify
|
|
@@ -84,6 +99,14 @@ class LoginViewTests(TestCase):
|
|
|
84
99
|
resp = self.client.get(reverse("pages:login"))
|
|
85
100
|
self.assertContains(resp, "Use Authenticator app")
|
|
86
101
|
|
|
102
|
+
def test_cp_simulator_redirect_shows_restricted_message(self):
|
|
103
|
+
simulator_path = reverse("cp-simulator")
|
|
104
|
+
resp = self.client.get(f"{reverse('pages:login')}?next={simulator_path}")
|
|
105
|
+
self.assertContains(
|
|
106
|
+
resp,
|
|
107
|
+
"This page is reserved for members only. Please log in to continue.",
|
|
108
|
+
)
|
|
109
|
+
|
|
87
110
|
def test_staff_login_redirects_admin(self):
|
|
88
111
|
resp = self.client.post(
|
|
89
112
|
reverse("pages:login"),
|
|
@@ -303,6 +326,15 @@ class AuthenticatorSetupTests(TestCase):
|
|
|
303
326
|
self.assertIn(label, config_url)
|
|
304
327
|
self.assertIn(f"issuer={quote(settings.OTP_TOTP_ISSUER)}", config_url)
|
|
305
328
|
|
|
329
|
+
def test_device_config_url_uses_custom_issuer_when_available(self):
|
|
330
|
+
self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
|
|
331
|
+
device = TOTPDevice.objects.get(user=self.staff)
|
|
332
|
+
TOTPDeviceSettings.objects.create(device=device, issuer="Custom Co")
|
|
333
|
+
config_url = device.config_url
|
|
334
|
+
quoted_issuer = quote("Custom Co")
|
|
335
|
+
self.assertIn(quoted_issuer, config_url)
|
|
336
|
+
self.assertIn(f"issuer={quoted_issuer}", config_url)
|
|
337
|
+
|
|
306
338
|
def test_pending_device_context_includes_qr(self):
|
|
307
339
|
self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
|
|
308
340
|
resp = self.client.get(reverse("pages:authenticator-setup"))
|
|
@@ -731,6 +763,7 @@ class ViewHistoryAdminTests(TestCase):
|
|
|
731
763
|
self.assertContains(resp, static("core/vendor/chart.umd.min.js"))
|
|
732
764
|
|
|
733
765
|
def test_graph_data_endpoint(self):
|
|
766
|
+
ViewHistory.all_objects.all().delete()
|
|
734
767
|
self._create_history("/", count=2)
|
|
735
768
|
self._create_history("/about/", days_offset=1)
|
|
736
769
|
url = reverse("admin:pages_viewhistory_traffic_data")
|
|
@@ -746,6 +779,44 @@ class ViewHistoryAdminTests(TestCase):
|
|
|
746
779
|
self.assertEqual(totals.get("/"), 2)
|
|
747
780
|
self.assertEqual(totals.get("/about/"), 1)
|
|
748
781
|
|
|
782
|
+
def test_graph_data_includes_late_evening_visits(self):
|
|
783
|
+
target_date = date(2025, 9, 27)
|
|
784
|
+
entry = ViewHistory.objects.create(
|
|
785
|
+
path="/late/",
|
|
786
|
+
method="GET",
|
|
787
|
+
status_code=200,
|
|
788
|
+
status_text="OK",
|
|
789
|
+
error_message="",
|
|
790
|
+
view_name="pages:index",
|
|
791
|
+
)
|
|
792
|
+
local_evening = datetime.combine(target_date, datetime_time(21, 30))
|
|
793
|
+
aware_evening = timezone.make_aware(
|
|
794
|
+
local_evening, timezone.get_current_timezone()
|
|
795
|
+
)
|
|
796
|
+
entry.visited_at = aware_evening.astimezone(datetime_timezone.utc)
|
|
797
|
+
entry.save(update_fields=["visited_at"])
|
|
798
|
+
|
|
799
|
+
url = reverse("admin:pages_viewhistory_traffic_data")
|
|
800
|
+
with patch("pages.admin.timezone.localdate", return_value=target_date):
|
|
801
|
+
resp = self.client.get(url)
|
|
802
|
+
self.assertEqual(resp.status_code, 200)
|
|
803
|
+
data = resp.json()
|
|
804
|
+
totals = {
|
|
805
|
+
dataset["label"]: sum(dataset["data"]) for dataset in data["datasets"]
|
|
806
|
+
}
|
|
807
|
+
self.assertEqual(totals.get("/late/"), 1)
|
|
808
|
+
|
|
809
|
+
def test_graph_data_filters_using_datetime_range(self):
|
|
810
|
+
admin_view = ViewHistoryAdmin(ViewHistory, admin.site)
|
|
811
|
+
with patch.object(ViewHistory.objects, "filter") as mock_filter:
|
|
812
|
+
mock_queryset = mock_filter.return_value
|
|
813
|
+
mock_queryset.exists.return_value = False
|
|
814
|
+
admin_view._build_chart_data()
|
|
815
|
+
|
|
816
|
+
kwargs = mock_filter.call_args.kwargs
|
|
817
|
+
self.assertIn("visited_at__gte", kwargs)
|
|
818
|
+
self.assertIn("visited_at__lt", kwargs)
|
|
819
|
+
|
|
749
820
|
def test_admin_index_displays_widget(self):
|
|
750
821
|
resp = self.client.get(reverse("admin:index"))
|
|
751
822
|
self.assertContains(resp, "viewhistory-mini-module")
|
|
@@ -1042,9 +1113,9 @@ class PowerNavTests(TestCase):
|
|
|
1042
1113
|
node_role=role, application=awg_app, path="/awg/"
|
|
1043
1114
|
)
|
|
1044
1115
|
awg_module.create_landings()
|
|
1045
|
-
|
|
1116
|
+
manuals_app, _ = Application.objects.get_or_create(name="pages")
|
|
1046
1117
|
man_module, _ = Module.objects.get_or_create(
|
|
1047
|
-
node_role=role, application=
|
|
1118
|
+
node_role=role, application=manuals_app, path="/man/"
|
|
1048
1119
|
)
|
|
1049
1120
|
man_module.create_landings()
|
|
1050
1121
|
User = get_user_model()
|
|
@@ -1070,7 +1141,7 @@ class PowerNavTests(TestCase):
|
|
|
1070
1141
|
manuals_module = module
|
|
1071
1142
|
break
|
|
1072
1143
|
self.assertIsNotNone(manuals_module)
|
|
1073
|
-
self.assertEqual(manuals_module.menu_label.upper(), "
|
|
1144
|
+
self.assertEqual(manuals_module.menu_label.upper(), "MANUAL")
|
|
1074
1145
|
landing_labels = {landing.label for landing in manuals_module.enabled_landings}
|
|
1075
1146
|
self.assertIn("Manuals", landing_labels)
|
|
1076
1147
|
|
|
@@ -1163,6 +1234,13 @@ class ApplicationAdminDisplayTests(TestCase):
|
|
|
1163
1234
|
config = django_apps.get_app_config("ocpp")
|
|
1164
1235
|
self.assertContains(resp, config.verbose_name)
|
|
1165
1236
|
|
|
1237
|
+
def test_changelist_shows_description(self):
|
|
1238
|
+
Application.objects.create(
|
|
1239
|
+
name="awg", description="Power, Energy and Cost calculations."
|
|
1240
|
+
)
|
|
1241
|
+
resp = self.client.get(reverse("admin:pages_application_changelist"))
|
|
1242
|
+
self.assertContains(resp, "Power, Energy and Cost calculations.")
|
|
1243
|
+
|
|
1166
1244
|
|
|
1167
1245
|
class LandingCreationTests(TestCase):
|
|
1168
1246
|
def setUp(self):
|
|
@@ -1231,8 +1309,16 @@ class RFIDPageTests(TestCase):
|
|
|
1231
1309
|
Site.objects.update_or_create(
|
|
1232
1310
|
id=1, defaults={"domain": "testserver", "name": "pages"}
|
|
1233
1311
|
)
|
|
1312
|
+
User = get_user_model()
|
|
1313
|
+
self.user = User.objects.create_user("rfid-user", password="pwd")
|
|
1234
1314
|
|
|
1235
|
-
def
|
|
1315
|
+
def test_page_redirects_when_anonymous(self):
|
|
1316
|
+
resp = self.client.get(reverse("rfid-reader"))
|
|
1317
|
+
self.assertEqual(resp.status_code, 302)
|
|
1318
|
+
self.assertIn(reverse("pages:login"), resp.url)
|
|
1319
|
+
|
|
1320
|
+
def test_page_renders_for_authenticated_user(self):
|
|
1321
|
+
self.client.force_login(self.user)
|
|
1236
1322
|
resp = self.client.get(reverse("rfid-reader"))
|
|
1237
1323
|
self.assertContains(resp, "Scanner ready")
|
|
1238
1324
|
|
|
@@ -1324,6 +1410,29 @@ class FaviconTests(TestCase):
|
|
|
1324
1410
|
)
|
|
1325
1411
|
self.assertContains(resp, b64)
|
|
1326
1412
|
|
|
1413
|
+
def test_control_nodes_use_purple_favicon(self):
|
|
1414
|
+
with override_settings(MEDIA_ROOT=self.tmpdir):
|
|
1415
|
+
role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1416
|
+
Node.objects.update_or_create(
|
|
1417
|
+
mac_address=Node.get_current_mac(),
|
|
1418
|
+
defaults={
|
|
1419
|
+
"hostname": "localhost",
|
|
1420
|
+
"address": "127.0.0.1",
|
|
1421
|
+
"role": role,
|
|
1422
|
+
},
|
|
1423
|
+
)
|
|
1424
|
+
Site.objects.update_or_create(
|
|
1425
|
+
id=1, defaults={"domain": "testserver", "name": ""}
|
|
1426
|
+
)
|
|
1427
|
+
resp = self.client.get(reverse("pages:index"))
|
|
1428
|
+
b64 = (
|
|
1429
|
+
Path(settings.BASE_DIR)
|
|
1430
|
+
.joinpath("pages", "fixtures", "data", "favicon_control.txt")
|
|
1431
|
+
.read_text()
|
|
1432
|
+
.strip()
|
|
1433
|
+
)
|
|
1434
|
+
self.assertContains(resp, b64)
|
|
1435
|
+
|
|
1327
1436
|
|
|
1328
1437
|
class FavoriteTests(TestCase):
|
|
1329
1438
|
def setUp(self):
|
|
@@ -1712,6 +1821,170 @@ class DatasetteTests(TestCase):
|
|
|
1712
1821
|
lock_file.unlink(missing_ok=True)
|
|
1713
1822
|
|
|
1714
1823
|
|
|
1824
|
+
class UserStorySubmissionTests(TestCase):
|
|
1825
|
+
def setUp(self):
|
|
1826
|
+
self.client = Client()
|
|
1827
|
+
self.url = reverse("pages:user-story-submit")
|
|
1828
|
+
User = get_user_model()
|
|
1829
|
+
self.user = User.objects.create_user(username="feedbacker", password="pwd")
|
|
1830
|
+
|
|
1831
|
+
def test_authenticated_submission_defaults_to_username(self):
|
|
1832
|
+
self.client.force_login(self.user)
|
|
1833
|
+
response = self.client.post(
|
|
1834
|
+
self.url,
|
|
1835
|
+
{
|
|
1836
|
+
"rating": 5,
|
|
1837
|
+
"comments": "Loved the experience!",
|
|
1838
|
+
"path": "/wizard/step-1/",
|
|
1839
|
+
"take_screenshot": "1",
|
|
1840
|
+
},
|
|
1841
|
+
)
|
|
1842
|
+
self.assertEqual(response.status_code, 200)
|
|
1843
|
+
self.assertEqual(response.json(), {"success": True})
|
|
1844
|
+
story = UserStory.objects.get()
|
|
1845
|
+
self.assertEqual(story.name, "feedbacker")
|
|
1846
|
+
self.assertEqual(story.rating, 5)
|
|
1847
|
+
self.assertEqual(story.path, "/wizard/step-1/")
|
|
1848
|
+
self.assertEqual(story.user, self.user)
|
|
1849
|
+
self.assertEqual(story.owner, self.user)
|
|
1850
|
+
self.assertTrue(story.is_user_data)
|
|
1851
|
+
self.assertTrue(story.take_screenshot)
|
|
1852
|
+
|
|
1853
|
+
def test_anonymous_submission_uses_provided_name(self):
|
|
1854
|
+
response = self.client.post(
|
|
1855
|
+
self.url,
|
|
1856
|
+
{
|
|
1857
|
+
"name": "Guest Reviewer",
|
|
1858
|
+
"rating": 3,
|
|
1859
|
+
"comments": "It was fine.",
|
|
1860
|
+
"path": "/status/",
|
|
1861
|
+
"take_screenshot": "on",
|
|
1862
|
+
},
|
|
1863
|
+
)
|
|
1864
|
+
self.assertEqual(response.status_code, 200)
|
|
1865
|
+
self.assertEqual(UserStory.objects.count(), 1)
|
|
1866
|
+
story = UserStory.objects.get()
|
|
1867
|
+
self.assertEqual(story.name, "Guest Reviewer")
|
|
1868
|
+
self.assertIsNone(story.user)
|
|
1869
|
+
self.assertIsNone(story.owner)
|
|
1870
|
+
self.assertEqual(story.comments, "It was fine.")
|
|
1871
|
+
self.assertTrue(story.take_screenshot)
|
|
1872
|
+
|
|
1873
|
+
def test_invalid_rating_returns_errors(self):
|
|
1874
|
+
response = self.client.post(
|
|
1875
|
+
self.url,
|
|
1876
|
+
{
|
|
1877
|
+
"rating": 7,
|
|
1878
|
+
"comments": "Way off the scale",
|
|
1879
|
+
"path": "/feedback/",
|
|
1880
|
+
"take_screenshot": "1",
|
|
1881
|
+
},
|
|
1882
|
+
)
|
|
1883
|
+
self.assertEqual(response.status_code, 400)
|
|
1884
|
+
data = response.json()
|
|
1885
|
+
self.assertFalse(UserStory.objects.exists())
|
|
1886
|
+
self.assertIn("rating", data.get("errors", {}))
|
|
1887
|
+
|
|
1888
|
+
def test_anonymous_submission_without_name_uses_fallback(self):
|
|
1889
|
+
response = self.client.post(
|
|
1890
|
+
self.url,
|
|
1891
|
+
{
|
|
1892
|
+
"rating": 2,
|
|
1893
|
+
"comments": "Could be better.",
|
|
1894
|
+
"path": "/feedback/",
|
|
1895
|
+
"take_screenshot": "1",
|
|
1896
|
+
},
|
|
1897
|
+
)
|
|
1898
|
+
self.assertEqual(response.status_code, 200)
|
|
1899
|
+
story = UserStory.objects.get()
|
|
1900
|
+
self.assertEqual(story.name, "Anonymous")
|
|
1901
|
+
self.assertIsNone(story.user)
|
|
1902
|
+
self.assertIsNone(story.owner)
|
|
1903
|
+
self.assertTrue(story.take_screenshot)
|
|
1904
|
+
|
|
1905
|
+
def test_submission_without_screenshot_request(self):
|
|
1906
|
+
response = self.client.post(
|
|
1907
|
+
self.url,
|
|
1908
|
+
{
|
|
1909
|
+
"rating": 4,
|
|
1910
|
+
"comments": "Skip the screenshot, please.",
|
|
1911
|
+
"path": "/feedback/",
|
|
1912
|
+
},
|
|
1913
|
+
)
|
|
1914
|
+
self.assertEqual(response.status_code, 200)
|
|
1915
|
+
story = UserStory.objects.get()
|
|
1916
|
+
self.assertFalse(story.take_screenshot)
|
|
1917
|
+
self.assertIsNone(story.owner)
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
class UserStoryAdminActionTests(TestCase):
|
|
1921
|
+
def setUp(self):
|
|
1922
|
+
self.client = Client()
|
|
1923
|
+
self.factory = RequestFactory()
|
|
1924
|
+
User = get_user_model()
|
|
1925
|
+
self.admin_user = User.objects.create_superuser(
|
|
1926
|
+
username="admin",
|
|
1927
|
+
email="admin@example.com",
|
|
1928
|
+
password="pwd",
|
|
1929
|
+
)
|
|
1930
|
+
self.story = UserStory.objects.create(
|
|
1931
|
+
path="/",
|
|
1932
|
+
name="Feedback",
|
|
1933
|
+
rating=4,
|
|
1934
|
+
comments="Helpful notes",
|
|
1935
|
+
take_screenshot=True,
|
|
1936
|
+
)
|
|
1937
|
+
self.admin = UserStoryAdmin(UserStory, admin.site)
|
|
1938
|
+
|
|
1939
|
+
def _build_request(self):
|
|
1940
|
+
request = self.factory.post("/admin/pages/userstory/")
|
|
1941
|
+
request.user = self.admin_user
|
|
1942
|
+
request.session = self.client.session
|
|
1943
|
+
setattr(request, "_messages", FallbackStorage(request))
|
|
1944
|
+
return request
|
|
1945
|
+
|
|
1946
|
+
@patch("pages.models.github_issues.create_issue")
|
|
1947
|
+
def test_create_github_issues_action_updates_issue_fields(self, mock_create_issue):
|
|
1948
|
+
response = MagicMock()
|
|
1949
|
+
response.json.return_value = {
|
|
1950
|
+
"html_url": "https://github.com/example/repo/issues/123",
|
|
1951
|
+
"number": 123,
|
|
1952
|
+
}
|
|
1953
|
+
mock_create_issue.return_value = response
|
|
1954
|
+
|
|
1955
|
+
request = self._build_request()
|
|
1956
|
+
queryset = UserStory.objects.filter(pk=self.story.pk)
|
|
1957
|
+
self.admin.create_github_issues(request, queryset)
|
|
1958
|
+
|
|
1959
|
+
self.story.refresh_from_db()
|
|
1960
|
+
self.assertEqual(self.story.github_issue_number, 123)
|
|
1961
|
+
self.assertEqual(
|
|
1962
|
+
self.story.github_issue_url,
|
|
1963
|
+
"https://github.com/example/repo/issues/123",
|
|
1964
|
+
)
|
|
1965
|
+
|
|
1966
|
+
mock_create_issue.assert_called_once()
|
|
1967
|
+
args, kwargs = mock_create_issue.call_args
|
|
1968
|
+
self.assertIn("Feedback for", args[0])
|
|
1969
|
+
self.assertIn("**Rating:**", args[1])
|
|
1970
|
+
self.assertEqual(kwargs.get("labels"), ["feedback"])
|
|
1971
|
+
self.assertEqual(
|
|
1972
|
+
kwargs.get("fingerprint"), f"user-story:{self.story.pk}"
|
|
1973
|
+
)
|
|
1974
|
+
|
|
1975
|
+
@patch("pages.models.github_issues.create_issue")
|
|
1976
|
+
def test_create_github_issues_action_skips_existing_issue(self, mock_create_issue):
|
|
1977
|
+
self.story.github_issue_url = "https://github.com/example/repo/issues/5"
|
|
1978
|
+
self.story.github_issue_number = 5
|
|
1979
|
+
self.story.save(update_fields=["github_issue_url", "github_issue_number"])
|
|
1980
|
+
|
|
1981
|
+
request = self._build_request()
|
|
1982
|
+
queryset = UserStory.objects.filter(pk=self.story.pk)
|
|
1983
|
+
self.admin.create_github_issues(request, queryset)
|
|
1984
|
+
|
|
1985
|
+
mock_create_issue.assert_not_called()
|
|
1986
|
+
|
|
1987
|
+
|
|
1715
1988
|
class ClientReportLiveUpdateTests(TestCase):
|
|
1716
1989
|
def setUp(self):
|
|
1717
1990
|
self.client = Client()
|
pages/urls.py
CHANGED
|
@@ -18,4 +18,8 @@ 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"),
|
|
24
|
+
path("feedback/user-story/", views.submit_user_story, name="user-story-submit"),
|
|
21
25
|
]
|
pages/views.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import logging
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
from types import SimpleNamespace
|
|
4
5
|
import datetime
|
|
5
6
|
import calendar
|
|
6
7
|
import io
|
|
7
8
|
import shutil
|
|
8
9
|
import re
|
|
9
10
|
from html import escape
|
|
11
|
+
from urllib.parse import urlparse
|
|
10
12
|
|
|
11
13
|
from django.conf import settings
|
|
12
14
|
from django.contrib import admin
|
|
@@ -18,10 +20,11 @@ from django.contrib.auth.views import LoginView
|
|
|
18
20
|
from django import forms
|
|
19
21
|
from django.apps import apps as django_apps
|
|
20
22
|
from utils.sites import get_site
|
|
21
|
-
from django.http import Http404, HttpResponse
|
|
22
|
-
from django.shortcuts import redirect, render
|
|
23
|
+
from django.http import Http404, HttpResponse, JsonResponse
|
|
24
|
+
from django.shortcuts import get_object_or_404, redirect, render
|
|
23
25
|
from nodes.models import Node
|
|
24
26
|
from django.template.response import TemplateResponse
|
|
27
|
+
from django.test import RequestFactory
|
|
25
28
|
from django.urls import NoReverseMatch, reverse
|
|
26
29
|
from django.utils import timezone
|
|
27
30
|
from django.utils.encoding import force_bytes, force_str
|
|
@@ -30,6 +33,7 @@ from core import mailer, public_wifi
|
|
|
30
33
|
from core.backends import TOTP_DEVICE_NAME
|
|
31
34
|
from django.utils.translation import gettext as _
|
|
32
35
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
36
|
+
from django.views.decorators.http import require_POST
|
|
33
37
|
from django.core.cache import cache
|
|
34
38
|
from django.views.decorators.cache import never_cache
|
|
35
39
|
from django.utils.cache import patch_vary_headers
|
|
@@ -47,13 +51,41 @@ except ImportError: # pragma: no cover - handled gracefully in views
|
|
|
47
51
|
CalledProcessError = ExecutableNotFound = None
|
|
48
52
|
|
|
49
53
|
import markdown
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _render_markdown_with_toc(text: str) -> tuple[str, str]:
|
|
60
|
+
"""Render ``text`` to HTML and return the HTML and stripped TOC."""
|
|
61
|
+
|
|
62
|
+
md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
|
|
63
|
+
html = md.convert(text)
|
|
64
|
+
toc_html = md.toc
|
|
65
|
+
toc_html = _strip_toc_wrapper(toc_html)
|
|
66
|
+
return html, toc_html
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _strip_toc_wrapper(toc_html: str) -> str:
|
|
70
|
+
"""Normalize ``markdown``'s TOC output by removing the wrapper ``div``."""
|
|
71
|
+
|
|
72
|
+
toc_html = toc_html.strip()
|
|
73
|
+
if toc_html.startswith('<div class="toc">'):
|
|
74
|
+
toc_html = toc_html[len('<div class="toc">') :]
|
|
75
|
+
if toc_html.endswith("</div>"):
|
|
76
|
+
toc_html = toc_html[: -len("</div>")]
|
|
77
|
+
return toc_html.strip()
|
|
50
78
|
from pages.utils import landing
|
|
51
79
|
from core.liveupdate import live_update
|
|
52
80
|
from django_otp import login as otp_login
|
|
53
81
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
|
54
82
|
import qrcode
|
|
55
|
-
from .forms import
|
|
56
|
-
|
|
83
|
+
from .forms import (
|
|
84
|
+
AuthenticatorEnrollmentForm,
|
|
85
|
+
AuthenticatorLoginForm,
|
|
86
|
+
UserStoryForm,
|
|
87
|
+
)
|
|
88
|
+
from .models import Module, UserManual, UserStory
|
|
57
89
|
|
|
58
90
|
|
|
59
91
|
logger = logging.getLogger(__name__)
|
|
@@ -407,14 +439,7 @@ def index(request):
|
|
|
407
439
|
candidates.append(root_base / "README.md")
|
|
408
440
|
readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
|
|
409
441
|
text = readme_file.read_text(encoding="utf-8")
|
|
410
|
-
|
|
411
|
-
html = md.convert(text)
|
|
412
|
-
toc_html = md.toc
|
|
413
|
-
if toc_html.strip().startswith('<div class="toc">'):
|
|
414
|
-
toc_html = toc_html.strip()[len('<div class="toc">') :]
|
|
415
|
-
if toc_html.endswith("</div>"):
|
|
416
|
-
toc_html = toc_html[: -len("</div>")]
|
|
417
|
-
toc_html = toc_html.strip()
|
|
442
|
+
html, toc_html = _render_markdown_with_toc(text)
|
|
418
443
|
title = "README" if readme_file.name.startswith("README") else readme_file.stem
|
|
419
444
|
context = {"content": html, "title": title, "toc": toc_html}
|
|
420
445
|
response = render(request, "pages/readme.html", context)
|
|
@@ -447,14 +472,7 @@ def release_checklist(request):
|
|
|
447
472
|
if not file_path.exists():
|
|
448
473
|
raise Http404("Release checklist not found")
|
|
449
474
|
text = file_path.read_text(encoding="utf-8")
|
|
450
|
-
|
|
451
|
-
html = md.convert(text)
|
|
452
|
-
toc_html = md.toc
|
|
453
|
-
if toc_html.strip().startswith('<div class="toc">'):
|
|
454
|
-
toc_html = toc_html.strip()[len('<div class="toc">') :]
|
|
455
|
-
if toc_html.endswith("</div>"):
|
|
456
|
-
toc_html = toc_html[: -len("</div>")]
|
|
457
|
-
toc_html = toc_html.strip()
|
|
475
|
+
html, toc_html = _render_markdown_with_toc(text)
|
|
458
476
|
context = {"content": html, "title": "Release Checklist", "toc": toc_html}
|
|
459
477
|
response = render(request, "pages/readme.html", context)
|
|
460
478
|
patch_vary_headers(response, ["Accept-Language", "Cookie"])
|
|
@@ -482,12 +500,26 @@ class CustomLoginView(LoginView):
|
|
|
482
500
|
def get_context_data(self, **kwargs):
|
|
483
501
|
context = super(LoginView, self).get_context_data(**kwargs)
|
|
484
502
|
current_site = get_site(self.request)
|
|
503
|
+
redirect_target = self.request.GET.get(self.redirect_field_name)
|
|
504
|
+
restricted_notice = None
|
|
505
|
+
if redirect_target:
|
|
506
|
+
parsed_target = urlparse(redirect_target)
|
|
507
|
+
target_path = parsed_target.path or redirect_target
|
|
508
|
+
try:
|
|
509
|
+
simulator_path = reverse("cp-simulator")
|
|
510
|
+
except NoReverseMatch: # pragma: no cover - simulator may be uninstalled
|
|
511
|
+
simulator_path = None
|
|
512
|
+
if simulator_path and target_path.startswith(simulator_path):
|
|
513
|
+
restricted_notice = _(
|
|
514
|
+
"This page is reserved for members only. Please log in to continue."
|
|
515
|
+
)
|
|
485
516
|
context.update(
|
|
486
517
|
{
|
|
487
518
|
"site": current_site,
|
|
488
519
|
"site_name": getattr(current_site, "name", ""),
|
|
489
520
|
"next": self.get_success_url(),
|
|
490
521
|
"can_request_invite": mailer.can_send_email(),
|
|
522
|
+
"restricted_notice": restricted_notice,
|
|
491
523
|
}
|
|
492
524
|
)
|
|
493
525
|
return context
|
|
@@ -709,11 +741,14 @@ def request_invite(request):
|
|
|
709
741
|
try:
|
|
710
742
|
node_error = None
|
|
711
743
|
node = Node.get_local()
|
|
744
|
+
outbox = getattr(node, "email_outbox", None) if node else None
|
|
712
745
|
if node:
|
|
713
746
|
try:
|
|
714
747
|
result = node.send_mail(subject, body, [email])
|
|
748
|
+
lead.sent_via_outbox = outbox
|
|
715
749
|
except Exception as exc:
|
|
716
750
|
node_error = exc
|
|
751
|
+
lead.sent_via_outbox = None
|
|
717
752
|
logger.exception(
|
|
718
753
|
"Node send_mail failed, falling back to default backend"
|
|
719
754
|
)
|
|
@@ -724,6 +759,7 @@ def request_invite(request):
|
|
|
724
759
|
result = mailer.send(
|
|
725
760
|
subject, body, [email], settings.DEFAULT_FROM_EMAIL
|
|
726
761
|
)
|
|
762
|
+
lead.sent_via_outbox = None
|
|
727
763
|
lead.sent_on = timezone.now()
|
|
728
764
|
if node_error:
|
|
729
765
|
lead.error = (
|
|
@@ -738,9 +774,10 @@ def request_invite(request):
|
|
|
738
774
|
)
|
|
739
775
|
except Exception as exc:
|
|
740
776
|
lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
|
|
777
|
+
lead.sent_via_outbox = None
|
|
741
778
|
logger.exception("Failed to send invitation email to %s", email)
|
|
742
779
|
if lead.sent_on or lead.error:
|
|
743
|
-
lead.save(update_fields=["sent_on", "error"])
|
|
780
|
+
lead.save(update_fields=["sent_on", "error", "sent_via_outbox"])
|
|
744
781
|
sent = True
|
|
745
782
|
return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
|
|
746
783
|
|
|
@@ -998,7 +1035,86 @@ def client_report(request):
|
|
|
998
1035
|
return render(request, "pages/client_report.html", context)
|
|
999
1036
|
|
|
1000
1037
|
|
|
1038
|
+
@require_POST
|
|
1039
|
+
def submit_user_story(request):
|
|
1040
|
+
data = request.POST.copy()
|
|
1041
|
+
if request.user.is_authenticated and not data.get("name"):
|
|
1042
|
+
data["name"] = request.user.get_username()[:40]
|
|
1043
|
+
if not data.get("path"):
|
|
1044
|
+
data["path"] = request.get_full_path()
|
|
1045
|
+
|
|
1046
|
+
form = UserStoryForm(data)
|
|
1047
|
+
if request.user.is_authenticated:
|
|
1048
|
+
form.instance.user = request.user
|
|
1049
|
+
|
|
1050
|
+
if form.is_valid():
|
|
1051
|
+
story = form.save(commit=False)
|
|
1052
|
+
if request.user.is_authenticated:
|
|
1053
|
+
story.user = request.user
|
|
1054
|
+
story.owner = request.user
|
|
1055
|
+
if not story.name:
|
|
1056
|
+
story.name = request.user.get_username()[:40]
|
|
1057
|
+
if not story.name:
|
|
1058
|
+
story.name = str(_("Anonymous"))[:40]
|
|
1059
|
+
story.path = (story.path or request.get_full_path())[:500]
|
|
1060
|
+
story.is_user_data = True
|
|
1061
|
+
story.save()
|
|
1062
|
+
return JsonResponse({"success": True})
|
|
1063
|
+
|
|
1064
|
+
return JsonResponse({"success": False, "errors": form.errors}, status=400)
|
|
1065
|
+
|
|
1066
|
+
|
|
1001
1067
|
def csrf_failure(request, reason=""):
|
|
1002
1068
|
"""Custom CSRF failure view with a friendly message."""
|
|
1003
1069
|
logger.warning("CSRF failure on %s: %s", request.path, reason)
|
|
1004
1070
|
return render(request, "pages/csrf_failure.html", status=403)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _admin_context(request):
|
|
1074
|
+
context = admin.site.each_context(request)
|
|
1075
|
+
if not context.get("has_permission"):
|
|
1076
|
+
rf = RequestFactory()
|
|
1077
|
+
mock_request = rf.get(request.path)
|
|
1078
|
+
mock_request.user = SimpleNamespace(
|
|
1079
|
+
is_active=True,
|
|
1080
|
+
is_staff=True,
|
|
1081
|
+
is_superuser=True,
|
|
1082
|
+
has_perm=lambda perm, obj=None: True,
|
|
1083
|
+
has_module_perms=lambda app_label: True,
|
|
1084
|
+
)
|
|
1085
|
+
context["available_apps"] = admin.site.get_app_list(mock_request)
|
|
1086
|
+
context["has_permission"] = True
|
|
1087
|
+
return context
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def admin_manual_list(request):
|
|
1091
|
+
manuals = UserManual.objects.order_by("title")
|
|
1092
|
+
context = _admin_context(request)
|
|
1093
|
+
context["manuals"] = manuals
|
|
1094
|
+
return render(request, "admin_doc/manuals.html", context)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def admin_manual_detail(request, slug):
|
|
1098
|
+
manual = get_object_or_404(UserManual, slug=slug)
|
|
1099
|
+
context = _admin_context(request)
|
|
1100
|
+
context["manual"] = manual
|
|
1101
|
+
return render(request, "admin_doc/manual_detail.html", context)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def manual_pdf(request, slug):
|
|
1105
|
+
manual = get_object_or_404(UserManual, slug=slug)
|
|
1106
|
+
pdf_data = base64.b64decode(manual.content_pdf)
|
|
1107
|
+
response = HttpResponse(pdf_data, content_type="application/pdf")
|
|
1108
|
+
response["Content-Disposition"] = f'attachment; filename="{manual.slug}.pdf"'
|
|
1109
|
+
return response
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
@landing(_("Manuals"))
|
|
1113
|
+
def manual_list(request):
|
|
1114
|
+
manuals = UserManual.objects.order_by("title")
|
|
1115
|
+
return render(request, "pages/manual_list.html", {"manuals": manuals})
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def manual_detail(request, slug):
|
|
1119
|
+
manual = get_object_or_404(UserManual, slug=slug)
|
|
1120
|
+
return render(request, "pages/manual_detail.html", {"manual": manual})
|