arthexis 0.1.16__py3-none-any.whl → 0.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
pages/tests.py
CHANGED
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
4
|
|
|
5
5
|
import django
|
|
6
|
+
import pytest
|
|
6
7
|
|
|
7
8
|
django.setup()
|
|
8
9
|
|
|
@@ -13,13 +14,14 @@ from django.templatetags.static import static
|
|
|
13
14
|
from urllib.parse import quote
|
|
14
15
|
from django.contrib.auth import get_user_model
|
|
15
16
|
from django.contrib.sites.models import Site
|
|
16
|
-
from django.contrib import admin
|
|
17
|
+
from django.contrib import admin, messages
|
|
17
18
|
from django.contrib.messages.storage.fallback import FallbackStorage
|
|
18
19
|
from django.core.exceptions import DisallowedHost
|
|
19
20
|
from django.core.cache import cache
|
|
20
21
|
from django.db import connection
|
|
21
22
|
import socket
|
|
22
23
|
from django.db import connection
|
|
24
|
+
from pages import site_config
|
|
23
25
|
from pages.models import (
|
|
24
26
|
Application,
|
|
25
27
|
Landing,
|
|
@@ -32,6 +34,8 @@ from pages.models import (
|
|
|
32
34
|
UserManual,
|
|
33
35
|
UserStory,
|
|
34
36
|
)
|
|
37
|
+
from django.http import FileResponse
|
|
38
|
+
|
|
35
39
|
from pages.admin import (
|
|
36
40
|
ApplicationAdmin,
|
|
37
41
|
UserManualAdmin,
|
|
@@ -47,6 +51,7 @@ from pages.screenshot_specs import (
|
|
|
47
51
|
)
|
|
48
52
|
from pages.context_processors import nav_links
|
|
49
53
|
from django.apps import apps as django_apps
|
|
54
|
+
from config.middleware import SiteHttpsRedirectMiddleware
|
|
50
55
|
from core import mailer
|
|
51
56
|
from core.admin import ProfileAdminMixin
|
|
52
57
|
from core.models import (
|
|
@@ -57,21 +62,28 @@ from core.models import (
|
|
|
57
62
|
RFID,
|
|
58
63
|
ReleaseManager,
|
|
59
64
|
SecurityGroup,
|
|
65
|
+
GoogleCalendarProfile,
|
|
60
66
|
Todo,
|
|
61
67
|
TOTPDeviceSettings,
|
|
62
68
|
)
|
|
69
|
+
from ocpp.models import Charger
|
|
63
70
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
64
71
|
import base64
|
|
72
|
+
import json
|
|
65
73
|
import tempfile
|
|
66
74
|
import shutil
|
|
75
|
+
from datetime import timedelta
|
|
67
76
|
from io import StringIO
|
|
68
77
|
from django.conf import settings
|
|
78
|
+
from django.utils import timezone
|
|
79
|
+
from django.utils.html import escape
|
|
69
80
|
from pathlib import Path
|
|
70
81
|
from unittest.mock import MagicMock, Mock, call, patch
|
|
71
82
|
from types import SimpleNamespace
|
|
72
83
|
from django.core.management import call_command
|
|
73
84
|
import re
|
|
74
85
|
from django.contrib.contenttypes.models import ContentType
|
|
86
|
+
from django.http import HttpResponse
|
|
75
87
|
from datetime import (
|
|
76
88
|
date,
|
|
77
89
|
datetime,
|
|
@@ -82,6 +94,7 @@ from datetime import (
|
|
|
82
94
|
from django.core import mail
|
|
83
95
|
from django.utils import timezone
|
|
84
96
|
from django.utils.text import slugify
|
|
97
|
+
from django.utils import translation
|
|
85
98
|
from django.utils.translation import gettext
|
|
86
99
|
from django_otp import DEVICE_ID_SESSION_KEY
|
|
87
100
|
from django_otp.oath import TOTP
|
|
@@ -96,6 +109,7 @@ from nodes.models import (
|
|
|
96
109
|
NodeRole,
|
|
97
110
|
NodeFeature,
|
|
98
111
|
NodeFeatureAssignment,
|
|
112
|
+
NetMessage,
|
|
99
113
|
)
|
|
100
114
|
from django.contrib.auth.models import AnonymousUser
|
|
101
115
|
|
|
@@ -187,6 +201,41 @@ class LoginViewTests(TestCase):
|
|
|
187
201
|
)
|
|
188
202
|
self.assertRedirects(resp, "/nodes/list/")
|
|
189
203
|
|
|
204
|
+
def test_homepage_excludes_version_banner_for_anonymous(self):
|
|
205
|
+
response = self.client.get(reverse("pages:index"))
|
|
206
|
+
|
|
207
|
+
self.assertEqual(response.status_code, 200)
|
|
208
|
+
self.assertNotContains(response, "__versionCheckInitialized")
|
|
209
|
+
|
|
210
|
+
def test_homepage_includes_version_banner_for_staff(self):
|
|
211
|
+
self.client.force_login(self.staff)
|
|
212
|
+
response = self.client.get(reverse("pages:index"))
|
|
213
|
+
|
|
214
|
+
self.assertEqual(response.status_code, 200)
|
|
215
|
+
self.assertContains(response, "__versionCheckInitialized")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class AdminTemplateVersionBannerTests(TestCase):
|
|
219
|
+
def setUp(self):
|
|
220
|
+
self.client = Client()
|
|
221
|
+
User = get_user_model()
|
|
222
|
+
self.staff = User.objects.create_user(
|
|
223
|
+
username="admin-staff", password="pwd", is_staff=True
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def test_admin_login_excludes_version_banner_for_anonymous(self):
|
|
227
|
+
response = self.client.get(reverse("admin:login"))
|
|
228
|
+
|
|
229
|
+
self.assertEqual(response.status_code, 200)
|
|
230
|
+
self.assertNotContains(response, "__versionCheckInitialized")
|
|
231
|
+
|
|
232
|
+
def test_admin_dashboard_includes_version_banner_for_staff(self):
|
|
233
|
+
self.client.force_login(self.staff)
|
|
234
|
+
response = self.client.get(reverse("admin:index"))
|
|
235
|
+
|
|
236
|
+
self.assertEqual(response.status_code, 200)
|
|
237
|
+
self.assertContains(response, "__versionCheckInitialized")
|
|
238
|
+
|
|
190
239
|
def test_staff_redirects_next_when_specified(self):
|
|
191
240
|
resp = self.client.post(
|
|
192
241
|
reverse("pages:login") + "?next=/nodes/list/",
|
|
@@ -466,6 +515,23 @@ class InvitationTests(TestCase):
|
|
|
466
515
|
self.assertEqual(lead.mac_address, "")
|
|
467
516
|
self.assertEqual(len(mail.outbox), 0)
|
|
468
517
|
|
|
518
|
+
def test_request_invite_uses_original_referer(self):
|
|
519
|
+
InviteLead.objects.all().delete()
|
|
520
|
+
self.client.get(
|
|
521
|
+
reverse("pages:index"),
|
|
522
|
+
HTTP_REFERER="https://campaign.example/landing",
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
resp = self.client.post(
|
|
526
|
+
reverse("pages:request-invite"),
|
|
527
|
+
{"email": "origin@example.com"},
|
|
528
|
+
HTTP_REFERER="http://testserver/pages/request-invite/",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
self.assertEqual(resp.status_code, 200)
|
|
532
|
+
lead = InviteLead.objects.get()
|
|
533
|
+
self.assertEqual(lead.referer, "https://campaign.example/landing")
|
|
534
|
+
|
|
469
535
|
def test_request_invite_falls_back_to_send_mail(self):
|
|
470
536
|
node = Node.objects.create(
|
|
471
537
|
hostname="local", address="127.0.0.1", mac_address="00:11:22:33:44:55"
|
|
@@ -501,6 +567,7 @@ class InvitationTests(TestCase):
|
|
|
501
567
|
lead = InviteLead.objects.get()
|
|
502
568
|
self.assertEqual(lead.mac_address, "aa:bb:cc:dd:ee:ff")
|
|
503
569
|
|
|
570
|
+
@pytest.mark.feature("ap-router")
|
|
504
571
|
@patch("pages.views.public_wifi.grant_public_access")
|
|
505
572
|
@patch(
|
|
506
573
|
"pages.views.public_wifi.resolve_mac_address",
|
|
@@ -672,19 +739,36 @@ class AdminDashboardAppListTests(TestCase):
|
|
|
672
739
|
|
|
673
740
|
def test_horologia_hidden_without_celery_feature(self):
|
|
674
741
|
resp = self.client.get(reverse("admin:index"))
|
|
675
|
-
self.assertNotContains(resp, "5. Horologia
|
|
742
|
+
self.assertNotContains(resp, "5. Horologia</a>")
|
|
676
743
|
|
|
677
744
|
def test_horologia_visible_with_celery_feature(self):
|
|
678
745
|
feature = NodeFeature.objects.create(slug="celery-queue", display="Celery Queue")
|
|
679
746
|
NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
|
|
680
747
|
resp = self.client.get(reverse("admin:index"))
|
|
681
|
-
self.assertContains(resp, "5. Horologia
|
|
748
|
+
self.assertContains(resp, "5. Horologia</a>")
|
|
682
749
|
|
|
683
750
|
def test_horologia_visible_with_celery_lock(self):
|
|
684
751
|
self.celery_lock.write_text("")
|
|
685
752
|
resp = self.client.get(reverse("admin:index"))
|
|
686
|
-
self.assertContains(resp, "5. Horologia
|
|
753
|
+
self.assertContains(resp, "5. Horologia</a>")
|
|
687
754
|
|
|
755
|
+
def test_dashboard_shows_last_net_message(self):
|
|
756
|
+
NetMessage.objects.all().delete()
|
|
757
|
+
NetMessage.objects.create(subject="Older", body="First body")
|
|
758
|
+
NetMessage.objects.create(subject="Latest", body="Signal ready")
|
|
759
|
+
|
|
760
|
+
resp = self.client.get(reverse("admin:index"))
|
|
761
|
+
|
|
762
|
+
self.assertContains(resp, gettext("Net message"))
|
|
763
|
+
self.assertContains(resp, "Latest — Signal ready")
|
|
764
|
+
self.assertNotContains(resp, gettext("No net messages available"))
|
|
765
|
+
|
|
766
|
+
def test_dashboard_shows_placeholder_without_net_message(self):
|
|
767
|
+
NetMessage.objects.all().delete()
|
|
768
|
+
|
|
769
|
+
resp = self.client.get(reverse("admin:index"))
|
|
770
|
+
|
|
771
|
+
self.assertContains(resp, gettext("No net messages available"))
|
|
688
772
|
|
|
689
773
|
class AdminSidebarTests(TestCase):
|
|
690
774
|
def setUp(self):
|
|
@@ -707,6 +791,66 @@ class AdminSidebarTests(TestCase):
|
|
|
707
791
|
self.assertContains(resp, 'id="admin-collapsible-apps"')
|
|
708
792
|
|
|
709
793
|
|
|
794
|
+
class AdminGoogleCalendarSidebarTests(TestCase):
|
|
795
|
+
def setUp(self):
|
|
796
|
+
self.client = Client()
|
|
797
|
+
User = get_user_model()
|
|
798
|
+
self.admin = User.objects.create_superuser(
|
|
799
|
+
username="calendar_admin", password="pwd", email="admin@example.com"
|
|
800
|
+
)
|
|
801
|
+
self.client.force_login(self.admin)
|
|
802
|
+
Site.objects.update_or_create(
|
|
803
|
+
id=1, defaults={"name": "test", "domain": "testserver"}
|
|
804
|
+
)
|
|
805
|
+
Node.objects.create(hostname="testserver", address="127.0.0.1")
|
|
806
|
+
|
|
807
|
+
def test_calendar_module_hidden_without_profile(self):
|
|
808
|
+
resp = self.client.get(reverse("admin:index"))
|
|
809
|
+
self.assertNotContains(resp, 'id="google-calendar-module"', html=False)
|
|
810
|
+
|
|
811
|
+
@patch("core.models.GoogleCalendarProfile.fetch_events")
|
|
812
|
+
def test_calendar_module_shows_events_for_user(self, fetch_events):
|
|
813
|
+
fetch_events.return_value = [
|
|
814
|
+
{
|
|
815
|
+
"summary": "Standup",
|
|
816
|
+
"start": timezone.now(),
|
|
817
|
+
"end": None,
|
|
818
|
+
"all_day": False,
|
|
819
|
+
"html_link": "https://calendar.google.com/event",
|
|
820
|
+
"location": "HQ",
|
|
821
|
+
}
|
|
822
|
+
]
|
|
823
|
+
GoogleCalendarProfile.objects.create(
|
|
824
|
+
user=self.admin,
|
|
825
|
+
calendar_id="example@group.calendar.google.com",
|
|
826
|
+
api_key="secret",
|
|
827
|
+
display_name="Team Calendar",
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
resp = self.client.get(reverse("admin:index"))
|
|
831
|
+
|
|
832
|
+
self.assertContains(resp, 'id="google-calendar-module"', html=False)
|
|
833
|
+
self.assertContains(resp, "Standup")
|
|
834
|
+
self.assertContains(resp, "Open full calendar")
|
|
835
|
+
fetch_events.assert_called_once()
|
|
836
|
+
|
|
837
|
+
@patch("core.models.GoogleCalendarProfile.fetch_events")
|
|
838
|
+
def test_calendar_module_uses_group_profile(self, fetch_events):
|
|
839
|
+
fetch_events.return_value = []
|
|
840
|
+
group = SecurityGroup.objects.create(name="Calendar Group")
|
|
841
|
+
self.admin.groups.add(group)
|
|
842
|
+
GoogleCalendarProfile.objects.create(
|
|
843
|
+
group=group,
|
|
844
|
+
calendar_id="group@calendar.google.com",
|
|
845
|
+
api_key="secret",
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
resp = self.client.get(reverse("admin:index"))
|
|
849
|
+
|
|
850
|
+
self.assertContains(resp, 'id="google-calendar-module"', html=False)
|
|
851
|
+
fetch_events.assert_called_once()
|
|
852
|
+
|
|
853
|
+
|
|
710
854
|
class ViewHistoryLoggingTests(TestCase):
|
|
711
855
|
def setUp(self):
|
|
712
856
|
self.client = Client()
|
|
@@ -795,7 +939,8 @@ class ViewHistoryLoggingTests(TestCase):
|
|
|
795
939
|
)
|
|
796
940
|
landing = module.landings.get(path="/")
|
|
797
941
|
landing.label = "Home Landing"
|
|
798
|
-
landing.
|
|
942
|
+
landing.track_leads = True
|
|
943
|
+
landing.save(update_fields=["label", "track_leads"])
|
|
799
944
|
|
|
800
945
|
resp = self.client.get(
|
|
801
946
|
reverse("pages:index"), HTTP_REFERER="https://example.com/ref"
|
|
@@ -807,6 +952,35 @@ class ViewHistoryLoggingTests(TestCase):
|
|
|
807
952
|
self.assertEqual(lead.path, "/")
|
|
808
953
|
self.assertEqual(lead.referer, "https://example.com/ref")
|
|
809
954
|
|
|
955
|
+
def test_pages_config_purges_old_view_history(self):
|
|
956
|
+
ViewHistory.objects.all().delete()
|
|
957
|
+
|
|
958
|
+
old_entry = ViewHistory.objects.create(
|
|
959
|
+
path="/old/",
|
|
960
|
+
method="GET",
|
|
961
|
+
status_code=200,
|
|
962
|
+
status_text="OK",
|
|
963
|
+
)
|
|
964
|
+
new_entry = ViewHistory.objects.create(
|
|
965
|
+
path="/recent/",
|
|
966
|
+
method="GET",
|
|
967
|
+
status_code=200,
|
|
968
|
+
status_text="OK",
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
ViewHistory.objects.filter(pk=old_entry.pk).update(
|
|
972
|
+
visited_at=timezone.now() - timedelta(days=20)
|
|
973
|
+
)
|
|
974
|
+
ViewHistory.objects.filter(pk=new_entry.pk).update(
|
|
975
|
+
visited_at=timezone.now() - timedelta(days=10)
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
config = django_apps.get_app_config("pages")
|
|
979
|
+
config._purge_view_history()
|
|
980
|
+
|
|
981
|
+
self.assertFalse(ViewHistory.objects.filter(pk=old_entry.pk).exists())
|
|
982
|
+
self.assertTrue(ViewHistory.objects.filter(pk=new_entry.pk).exists())
|
|
983
|
+
|
|
810
984
|
def test_landing_visit_does_not_record_lead_without_celery(self):
|
|
811
985
|
role = NodeRole.objects.create(name="no-celery-role")
|
|
812
986
|
application = Application.objects.create(
|
|
@@ -820,7 +994,8 @@ class ViewHistoryLoggingTests(TestCase):
|
|
|
820
994
|
)
|
|
821
995
|
landing = module.landings.get(path="/")
|
|
822
996
|
landing.label = "No Celery"
|
|
823
|
-
landing.
|
|
997
|
+
landing.track_leads = True
|
|
998
|
+
landing.save(update_fields=["label", "track_leads"])
|
|
824
999
|
|
|
825
1000
|
resp = self.client.get(reverse("pages:index"))
|
|
826
1001
|
|
|
@@ -840,7 +1015,8 @@ class ViewHistoryLoggingTests(TestCase):
|
|
|
840
1015
|
)
|
|
841
1016
|
landing = module.landings.get(path="/")
|
|
842
1017
|
landing.enabled = False
|
|
843
|
-
landing.
|
|
1018
|
+
landing.track_leads = True
|
|
1019
|
+
landing.save(update_fields=["enabled", "track_leads"])
|
|
844
1020
|
|
|
845
1021
|
resp = self.client.get(reverse("pages:index"))
|
|
846
1022
|
|
|
@@ -902,6 +1078,44 @@ class ViewHistoryAdminTests(TestCase):
|
|
|
902
1078
|
self.assertEqual(totals.get("/"), 2)
|
|
903
1079
|
self.assertEqual(totals.get("/about/"), 1)
|
|
904
1080
|
|
|
1081
|
+
def test_graph_data_endpoint_respects_days_parameter(self):
|
|
1082
|
+
ViewHistory.all_objects.all().delete()
|
|
1083
|
+
reference_date = date(2025, 5, 1)
|
|
1084
|
+
tz = timezone.get_current_timezone()
|
|
1085
|
+
path = "/range/"
|
|
1086
|
+
|
|
1087
|
+
for offset in range(10):
|
|
1088
|
+
entry = ViewHistory.objects.create(
|
|
1089
|
+
path=path,
|
|
1090
|
+
method="GET",
|
|
1091
|
+
status_code=200,
|
|
1092
|
+
status_text="OK",
|
|
1093
|
+
error_message="",
|
|
1094
|
+
view_name="pages:index",
|
|
1095
|
+
)
|
|
1096
|
+
visited_date = reference_date - timedelta(days=offset)
|
|
1097
|
+
visited_at = timezone.make_aware(
|
|
1098
|
+
datetime.combine(visited_date, datetime_time(12, 0)), tz
|
|
1099
|
+
)
|
|
1100
|
+
entry.visited_at = visited_at
|
|
1101
|
+
entry.save(update_fields=["visited_at"])
|
|
1102
|
+
|
|
1103
|
+
url = reverse("admin:pages_viewhistory_traffic_data")
|
|
1104
|
+
with patch("pages.admin.timezone.localdate", return_value=reference_date):
|
|
1105
|
+
resp = self.client.get(url, {"days": 7})
|
|
1106
|
+
|
|
1107
|
+
self.assertEqual(resp.status_code, 200)
|
|
1108
|
+
data = resp.json()
|
|
1109
|
+
|
|
1110
|
+
self.assertEqual(len(data.get("labels", [])), 7)
|
|
1111
|
+
self.assertEqual(data.get("meta", {}).get("start"), (reference_date - timedelta(days=6)).isoformat())
|
|
1112
|
+
self.assertEqual(data.get("meta", {}).get("end"), reference_date.isoformat())
|
|
1113
|
+
|
|
1114
|
+
totals = {
|
|
1115
|
+
dataset["label"]: sum(dataset["data"]) for dataset in data.get("datasets", [])
|
|
1116
|
+
}
|
|
1117
|
+
self.assertEqual(totals.get(path), 7)
|
|
1118
|
+
|
|
905
1119
|
def test_graph_data_includes_late_evening_visits(self):
|
|
906
1120
|
target_date = date(2025, 9, 27)
|
|
907
1121
|
entry = ViewHistory.objects.create(
|
|
@@ -1109,6 +1323,50 @@ class LogViewerAdminTests(SimpleTestCase):
|
|
|
1109
1323
|
self.assertEqual(context["selected_log"], "selected.log")
|
|
1110
1324
|
self.assertIn("hello world", context["log_content"])
|
|
1111
1325
|
|
|
1326
|
+
def test_log_viewer_applies_line_limit(self):
|
|
1327
|
+
content = "\n".join(f"line {i}" for i in range(50))
|
|
1328
|
+
self._create_log("limited.log", content)
|
|
1329
|
+
response = self._render({"log": "limited.log", "limit": "20"})
|
|
1330
|
+
context = response.context_data
|
|
1331
|
+
self.assertEqual(context["log_limit_choice"], "20")
|
|
1332
|
+
self.assertIn("line 49", context["log_content"])
|
|
1333
|
+
self.assertIn("line 30", context["log_content"])
|
|
1334
|
+
self.assertNotIn("line 29", context["log_content"])
|
|
1335
|
+
|
|
1336
|
+
def test_log_viewer_all_limit_returns_full_log(self):
|
|
1337
|
+
content = "first\nsecond\nthird"
|
|
1338
|
+
self._create_log("all.log", content)
|
|
1339
|
+
response = self._render({"log": "all.log", "limit": "all"})
|
|
1340
|
+
context = response.context_data
|
|
1341
|
+
self.assertEqual(context["log_limit_choice"], "all")
|
|
1342
|
+
self.assertIn("first", context["log_content"])
|
|
1343
|
+
self.assertIn("second", context["log_content"])
|
|
1344
|
+
|
|
1345
|
+
def test_log_viewer_invalid_limit_defaults_to_20(self):
|
|
1346
|
+
content = "\n".join(f"item {i}" for i in range(5))
|
|
1347
|
+
self._create_log("invalid-limit.log", content)
|
|
1348
|
+
response = self._render({"log": "invalid-limit.log", "limit": "oops"})
|
|
1349
|
+
context = response.context_data
|
|
1350
|
+
self.assertEqual(context["log_limit_choice"], "20")
|
|
1351
|
+
|
|
1352
|
+
def test_log_viewer_downloads_selected_log(self):
|
|
1353
|
+
self._create_log("download.log", "downloadable content")
|
|
1354
|
+
request = self._build_request({"log": "download.log", "download": "1"})
|
|
1355
|
+
context = {
|
|
1356
|
+
"site_title": "Constellation",
|
|
1357
|
+
"site_header": "Constellation",
|
|
1358
|
+
"site_url": "/",
|
|
1359
|
+
"available_apps": [],
|
|
1360
|
+
}
|
|
1361
|
+
with patch("pages.admin.admin.site.each_context", return_value=context), patch(
|
|
1362
|
+
"pages.context_processors.get_site", return_value=None
|
|
1363
|
+
):
|
|
1364
|
+
response = log_viewer(request)
|
|
1365
|
+
self.assertIsInstance(response, FileResponse)
|
|
1366
|
+
self.assertIn("attachment", response["Content-Disposition"])
|
|
1367
|
+
content = b"".join(response.streaming_content).decode()
|
|
1368
|
+
self.assertIn("downloadable content", content)
|
|
1369
|
+
|
|
1112
1370
|
def test_log_viewer_reports_missing_log(self):
|
|
1113
1371
|
response = self._render({"log": "missing.log"})
|
|
1114
1372
|
self.assertIn("requested log could not be found", response.context_data["log_error"])
|
|
@@ -1156,6 +1414,125 @@ class AdminModelStatusTests(TestCase):
|
|
|
1156
1414
|
self.assertContains(resp, 'class="model-status missing"', count=1)
|
|
1157
1415
|
|
|
1158
1416
|
|
|
1417
|
+
class _FakeQuerySet(list):
|
|
1418
|
+
def only(self, *args, **kwargs):
|
|
1419
|
+
return self
|
|
1420
|
+
|
|
1421
|
+
def order_by(self, *args, **kwargs):
|
|
1422
|
+
return self
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
class SiteConfigurationStagingTests(SimpleTestCase):
|
|
1426
|
+
def setUp(self):
|
|
1427
|
+
self.tmpdir = tempfile.mkdtemp()
|
|
1428
|
+
self.addCleanup(shutil.rmtree, self.tmpdir)
|
|
1429
|
+
self.config_path = Path(self.tmpdir) / "nginx-sites.json"
|
|
1430
|
+
self._path_patcher = patch(
|
|
1431
|
+
"pages.site_config._sites_config_path", side_effect=lambda: self.config_path
|
|
1432
|
+
)
|
|
1433
|
+
self._path_patcher.start()
|
|
1434
|
+
self.addCleanup(self._path_patcher.stop)
|
|
1435
|
+
self._model_patcher = patch("pages.site_config.apps.get_model")
|
|
1436
|
+
self.mock_get_model = self._model_patcher.start()
|
|
1437
|
+
self.addCleanup(self._model_patcher.stop)
|
|
1438
|
+
|
|
1439
|
+
def _read_config(self):
|
|
1440
|
+
if not self.config_path.exists():
|
|
1441
|
+
return None
|
|
1442
|
+
return json.loads(self.config_path.read_text(encoding="utf-8"))
|
|
1443
|
+
|
|
1444
|
+
def _set_sites(self, sites):
|
|
1445
|
+
queryset = _FakeQuerySet(sites)
|
|
1446
|
+
|
|
1447
|
+
class _Manager:
|
|
1448
|
+
@staticmethod
|
|
1449
|
+
def filter(**kwargs):
|
|
1450
|
+
return queryset
|
|
1451
|
+
|
|
1452
|
+
self.mock_get_model.return_value = SimpleNamespace(objects=_Manager())
|
|
1453
|
+
|
|
1454
|
+
def test_managed_site_persists_configuration(self):
|
|
1455
|
+
self._set_sites([SimpleNamespace(domain="example.com", require_https=True)])
|
|
1456
|
+
site_config.update_local_nginx_scripts()
|
|
1457
|
+
config = self._read_config()
|
|
1458
|
+
self.assertEqual(
|
|
1459
|
+
config,
|
|
1460
|
+
[
|
|
1461
|
+
{
|
|
1462
|
+
"domain": "example.com",
|
|
1463
|
+
"require_https": True,
|
|
1464
|
+
}
|
|
1465
|
+
],
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
def test_disabling_managed_site_removes_entry(self):
|
|
1469
|
+
primary = SimpleNamespace(domain="primary.test", require_https=False)
|
|
1470
|
+
secondary = SimpleNamespace(domain="secondary.test", require_https=False)
|
|
1471
|
+
self._set_sites([primary, secondary])
|
|
1472
|
+
site_config.update_local_nginx_scripts()
|
|
1473
|
+
config = self._read_config()
|
|
1474
|
+
self.assertEqual(
|
|
1475
|
+
[entry["domain"] for entry in config],
|
|
1476
|
+
["primary.test", "secondary.test"],
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
self._set_sites([secondary])
|
|
1480
|
+
site_config.update_local_nginx_scripts()
|
|
1481
|
+
config = self._read_config()
|
|
1482
|
+
self.assertEqual(config, [{"domain": "secondary.test", "require_https": False}])
|
|
1483
|
+
|
|
1484
|
+
self._set_sites([])
|
|
1485
|
+
site_config.update_local_nginx_scripts()
|
|
1486
|
+
self.assertIsNone(self._read_config())
|
|
1487
|
+
|
|
1488
|
+
def test_require_https_toggle_updates_configuration(self):
|
|
1489
|
+
site = SimpleNamespace(domain="secure.example", require_https=False)
|
|
1490
|
+
self._set_sites([site])
|
|
1491
|
+
site_config.update_local_nginx_scripts()
|
|
1492
|
+
config = self._read_config()
|
|
1493
|
+
self.assertEqual(config, [{"domain": "secure.example", "require_https": False}])
|
|
1494
|
+
|
|
1495
|
+
site.require_https = True
|
|
1496
|
+
self._set_sites([site])
|
|
1497
|
+
site_config.update_local_nginx_scripts()
|
|
1498
|
+
config = self._read_config()
|
|
1499
|
+
self.assertEqual(config, [{"domain": "secure.example", "require_https": True}])
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
class SiteRequireHttpsMiddlewareTests(SimpleTestCase):
|
|
1503
|
+
def setUp(self):
|
|
1504
|
+
self.factory = RequestFactory()
|
|
1505
|
+
self.middleware = SiteHttpsRedirectMiddleware(lambda request: HttpResponse("ok"))
|
|
1506
|
+
self.secure_site = SimpleNamespace(domain="secure.test", require_https=True)
|
|
1507
|
+
|
|
1508
|
+
def test_http_request_redirects_to_https(self):
|
|
1509
|
+
request = self.factory.get("/", HTTP_HOST="secure.test")
|
|
1510
|
+
request.site = self.secure_site
|
|
1511
|
+
response = self.middleware(request)
|
|
1512
|
+
self.assertEqual(response.status_code, 301)
|
|
1513
|
+
self.assertTrue(response["Location"].startswith("https://secure.test"))
|
|
1514
|
+
|
|
1515
|
+
def test_secure_request_not_redirected(self):
|
|
1516
|
+
request = self.factory.get("/", HTTP_HOST="secure.test", secure=True)
|
|
1517
|
+
request.site = self.secure_site
|
|
1518
|
+
response = self.middleware(request)
|
|
1519
|
+
self.assertEqual(response.status_code, 200)
|
|
1520
|
+
|
|
1521
|
+
def test_forwarded_proto_respected(self):
|
|
1522
|
+
request = self.factory.get(
|
|
1523
|
+
"/", HTTP_HOST="secure.test", HTTP_X_FORWARDED_PROTO="https"
|
|
1524
|
+
)
|
|
1525
|
+
request.site = self.secure_site
|
|
1526
|
+
response = self.middleware(request)
|
|
1527
|
+
self.assertEqual(response.status_code, 200)
|
|
1528
|
+
|
|
1529
|
+
self.secure_site.require_https = False
|
|
1530
|
+
request = self.factory.get("/", HTTP_HOST="secure.test")
|
|
1531
|
+
request.site = self.secure_site
|
|
1532
|
+
response = self.middleware(request)
|
|
1533
|
+
self.assertEqual(response.status_code, 200)
|
|
1534
|
+
|
|
1535
|
+
|
|
1159
1536
|
class SiteAdminRegisterCurrentTests(TestCase):
|
|
1160
1537
|
def setUp(self):
|
|
1161
1538
|
self.client = Client()
|
|
@@ -1188,6 +1565,7 @@ class SiteAdminRegisterCurrentTests(TestCase):
|
|
|
1188
1565
|
self.assertEqual(site.name, "")
|
|
1189
1566
|
|
|
1190
1567
|
|
|
1568
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1191
1569
|
class SiteAdminScreenshotTests(TestCase):
|
|
1192
1570
|
def setUp(self):
|
|
1193
1571
|
self.client = Client()
|
|
@@ -1307,17 +1685,17 @@ class NavAppsTests(TestCase):
|
|
|
1307
1685
|
)
|
|
1308
1686
|
app = Application.objects.create(name="Readme")
|
|
1309
1687
|
Module.objects.create(
|
|
1310
|
-
node_role=role, application=app, path="/", is_default=True
|
|
1688
|
+
node_role=role, application=app, path="/", is_default=True, menu="Cookbooks"
|
|
1311
1689
|
)
|
|
1312
1690
|
|
|
1313
1691
|
def test_nav_pill_renders(self):
|
|
1314
1692
|
resp = self.client.get(reverse("pages:index"))
|
|
1315
|
-
self.assertContains(resp, "
|
|
1693
|
+
self.assertContains(resp, "COOKBOOKS")
|
|
1316
1694
|
self.assertContains(resp, "badge rounded-pill")
|
|
1317
1695
|
|
|
1318
1696
|
def test_nav_pill_renders_with_port(self):
|
|
1319
1697
|
resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8000")
|
|
1320
|
-
self.assertContains(resp, "
|
|
1698
|
+
self.assertContains(resp, "COOKBOOKS")
|
|
1321
1699
|
|
|
1322
1700
|
def test_nav_pill_uses_menu_field(self):
|
|
1323
1701
|
site_app = Module.objects.get()
|
|
@@ -1325,7 +1703,7 @@ class NavAppsTests(TestCase):
|
|
|
1325
1703
|
site_app.save()
|
|
1326
1704
|
resp = self.client.get(reverse("pages:index"))
|
|
1327
1705
|
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">DOCS')
|
|
1328
|
-
self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">
|
|
1706
|
+
self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
|
|
1329
1707
|
|
|
1330
1708
|
def test_app_without_root_url_excluded(self):
|
|
1331
1709
|
role = NodeRole.objects.get(name="Terminal")
|
|
@@ -1390,20 +1768,22 @@ class RoleLandingRedirectTests(TestCase):
|
|
|
1390
1768
|
|
|
1391
1769
|
def test_satellite_redirects_to_dashboard(self):
|
|
1392
1770
|
target = self._configure_role_landing(
|
|
1393
|
-
"Satellite", "/ocpp/", "CPMS Online Dashboard"
|
|
1771
|
+
"Satellite", "/ocpp/cpms/dashboard/", "CPMS Online Dashboard"
|
|
1394
1772
|
)
|
|
1395
1773
|
resp = self.client.get(reverse("pages:index"))
|
|
1396
1774
|
self.assertRedirects(resp, target, fetch_redirect_response=False)
|
|
1397
1775
|
|
|
1398
1776
|
def test_control_redirects_to_rfid(self):
|
|
1399
1777
|
target = self._configure_role_landing(
|
|
1400
|
-
"Control", "/ocpp/rfid/", "RFID Tag Validator"
|
|
1778
|
+
"Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
|
|
1401
1779
|
)
|
|
1402
1780
|
resp = self.client.get(reverse("pages:index"))
|
|
1403
1781
|
self.assertRedirects(resp, target, fetch_redirect_response=False)
|
|
1404
1782
|
|
|
1405
1783
|
def test_security_group_redirect_takes_priority(self):
|
|
1406
|
-
self._configure_role_landing(
|
|
1784
|
+
self._configure_role_landing(
|
|
1785
|
+
"Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
|
|
1786
|
+
)
|
|
1407
1787
|
role = self.node.role
|
|
1408
1788
|
group = SecurityGroup.objects.create(name="Operators")
|
|
1409
1789
|
group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
|
|
@@ -1420,7 +1800,9 @@ class RoleLandingRedirectTests(TestCase):
|
|
|
1420
1800
|
)
|
|
1421
1801
|
|
|
1422
1802
|
def test_user_redirect_overrides_group_with_higher_priority(self):
|
|
1423
|
-
self._configure_role_landing(
|
|
1803
|
+
self._configure_role_landing(
|
|
1804
|
+
"Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
|
|
1805
|
+
)
|
|
1424
1806
|
role = self.node.role
|
|
1425
1807
|
group = SecurityGroup.objects.create(name="Operators")
|
|
1426
1808
|
group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
|
|
@@ -1442,10 +1824,10 @@ class RoleLandingRedirectTests(TestCase):
|
|
|
1442
1824
|
)
|
|
1443
1825
|
|
|
1444
1826
|
|
|
1445
|
-
class
|
|
1827
|
+
class WatchtowerNavTests(TestCase):
|
|
1446
1828
|
def setUp(self):
|
|
1447
1829
|
self.client = Client()
|
|
1448
|
-
role, _ = NodeRole.objects.get_or_create(name="
|
|
1830
|
+
role, _ = NodeRole.objects.get_or_create(name="Watchtower")
|
|
1449
1831
|
Node.objects.update_or_create(
|
|
1450
1832
|
mac_address=Node.get_current_mac(),
|
|
1451
1833
|
defaults={
|
|
@@ -1455,38 +1837,56 @@ class ConstellationNavTests(TestCase):
|
|
|
1455
1837
|
},
|
|
1456
1838
|
)
|
|
1457
1839
|
Site.objects.update_or_create(
|
|
1458
|
-
id=1, defaults={"domain": "
|
|
1840
|
+
id=1, defaults={"domain": "arthexis.com", "name": "Arthexis"}
|
|
1459
1841
|
)
|
|
1460
1842
|
fixtures = [
|
|
1461
1843
|
Path(
|
|
1462
1844
|
settings.BASE_DIR,
|
|
1463
1845
|
"pages",
|
|
1464
1846
|
"fixtures",
|
|
1465
|
-
"
|
|
1847
|
+
"default__application_pages.json",
|
|
1848
|
+
),
|
|
1849
|
+
Path(
|
|
1850
|
+
settings.BASE_DIR,
|
|
1851
|
+
"pages",
|
|
1852
|
+
"fixtures",
|
|
1853
|
+
"watchtower__application_ocpp.json",
|
|
1854
|
+
),
|
|
1855
|
+
Path(
|
|
1856
|
+
settings.BASE_DIR,
|
|
1857
|
+
"pages",
|
|
1858
|
+
"fixtures",
|
|
1859
|
+
"watchtower__module_ocpp.json",
|
|
1860
|
+
),
|
|
1861
|
+
Path(
|
|
1862
|
+
settings.BASE_DIR,
|
|
1863
|
+
"pages",
|
|
1864
|
+
"fixtures",
|
|
1865
|
+
"watchtower__landing_ocpp_dashboard.json",
|
|
1466
1866
|
),
|
|
1467
1867
|
Path(
|
|
1468
1868
|
settings.BASE_DIR,
|
|
1469
1869
|
"pages",
|
|
1470
1870
|
"fixtures",
|
|
1471
|
-
"
|
|
1871
|
+
"watchtower__landing_ocpp_cp_simulator.json",
|
|
1472
1872
|
),
|
|
1473
1873
|
Path(
|
|
1474
1874
|
settings.BASE_DIR,
|
|
1475
1875
|
"pages",
|
|
1476
1876
|
"fixtures",
|
|
1477
|
-
"
|
|
1877
|
+
"watchtower__landing_ocpp_rfid.json",
|
|
1478
1878
|
),
|
|
1479
1879
|
Path(
|
|
1480
1880
|
settings.BASE_DIR,
|
|
1481
1881
|
"pages",
|
|
1482
1882
|
"fixtures",
|
|
1483
|
-
"
|
|
1883
|
+
"watchtower__module_readme.json",
|
|
1484
1884
|
),
|
|
1485
1885
|
Path(
|
|
1486
1886
|
settings.BASE_DIR,
|
|
1487
1887
|
"pages",
|
|
1488
1888
|
"fixtures",
|
|
1489
|
-
"
|
|
1889
|
+
"watchtower__landing_readme.json",
|
|
1490
1890
|
),
|
|
1491
1891
|
]
|
|
1492
1892
|
call_command("loaddata", *map(str, fixtures))
|
|
@@ -1499,13 +1899,13 @@ class ConstellationNavTests(TestCase):
|
|
|
1499
1899
|
self.assertNotIn("RFID", nav_labels)
|
|
1500
1900
|
self.assertTrue(
|
|
1501
1901
|
Module.objects.filter(
|
|
1502
|
-
path="/ocpp/", node_role__name="
|
|
1902
|
+
path="/ocpp/", node_role__name="Watchtower"
|
|
1503
1903
|
).exists()
|
|
1504
1904
|
)
|
|
1505
1905
|
self.assertFalse(
|
|
1506
1906
|
Module.objects.filter(
|
|
1507
1907
|
path="/ocpp/rfid/",
|
|
1508
|
-
node_role__name="
|
|
1908
|
+
node_role__name="Watchtower",
|
|
1509
1909
|
is_deleted=False,
|
|
1510
1910
|
).exists()
|
|
1511
1911
|
)
|
|
@@ -1517,9 +1917,16 @@ class ConstellationNavTests(TestCase):
|
|
|
1517
1917
|
landing_labels = [landing.label for landing in ocpp_module.enabled_landings]
|
|
1518
1918
|
self.assertIn("RFID Tag Validator", landing_labels)
|
|
1519
1919
|
|
|
1920
|
+
@override_settings(ALLOWED_HOSTS=["testserver", "arthexis.com"])
|
|
1921
|
+
def test_cookbooks_pill_visible_for_arthexis(self):
|
|
1922
|
+
resp = self.client.get(
|
|
1923
|
+
reverse("pages:index"), HTTP_HOST="arthexis.com"
|
|
1924
|
+
)
|
|
1925
|
+
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
|
|
1926
|
+
|
|
1520
1927
|
def test_ocpp_dashboard_visible(self):
|
|
1521
1928
|
resp = self.client.get(reverse("pages:index"))
|
|
1522
|
-
self.assertContains(resp, 'href="/ocpp/"')
|
|
1929
|
+
self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
|
|
1523
1930
|
|
|
1524
1931
|
|
|
1525
1932
|
class ReleaseModuleNavTests(TestCase):
|
|
@@ -1661,7 +2068,7 @@ class ControlNavTests(TestCase):
|
|
|
1661
2068
|
self.client.force_login(user)
|
|
1662
2069
|
resp = self.client.get(reverse("pages:index"))
|
|
1663
2070
|
self.assertEqual(resp.status_code, 200)
|
|
1664
|
-
self.assertContains(resp, 'href="/ocpp/"')
|
|
2071
|
+
self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
|
|
1665
2072
|
self.assertContains(
|
|
1666
2073
|
resp, 'badge rounded-pill text-bg-secondary">CHARGERS'
|
|
1667
2074
|
)
|
|
@@ -1693,10 +2100,76 @@ class ControlNavTests(TestCase):
|
|
|
1693
2100
|
self.assertFalse(resp.context["header_references"])
|
|
1694
2101
|
self.assertNotContains(resp, "https://example.com/hidden")
|
|
1695
2102
|
|
|
2103
|
+
def test_header_link_hidden_when_only_site_matches(self):
|
|
2104
|
+
terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
2105
|
+
site = Site.objects.get(domain="testserver")
|
|
2106
|
+
reference = Reference.objects.create(
|
|
2107
|
+
alt_text="Restricted",
|
|
2108
|
+
value="https://example.com/restricted",
|
|
2109
|
+
show_in_header=True,
|
|
2110
|
+
)
|
|
2111
|
+
reference.roles.add(terminal_role)
|
|
2112
|
+
reference.sites.add(site)
|
|
2113
|
+
|
|
2114
|
+
resp = self.client.get(reverse("pages:index"))
|
|
2115
|
+
|
|
2116
|
+
self.assertIn("header_references", resp.context)
|
|
2117
|
+
self.assertFalse(resp.context["header_references"])
|
|
2118
|
+
self.assertNotContains(resp, "https://example.com/restricted")
|
|
2119
|
+
|
|
1696
2120
|
def test_readme_pill_visible(self):
|
|
1697
2121
|
resp = self.client.get(reverse("pages:readme"))
|
|
1698
|
-
self.assertContains(resp, 'href="/
|
|
1699
|
-
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">
|
|
2122
|
+
self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
|
|
2123
|
+
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
|
|
2124
|
+
|
|
2125
|
+
def test_cookbook_pill_has_no_dropdown(self):
|
|
2126
|
+
module = Module.objects.get(node_role__name="Control", path="/read/")
|
|
2127
|
+
Landing.objects.create(
|
|
2128
|
+
module=module,
|
|
2129
|
+
path="/man/",
|
|
2130
|
+
label="Manuals",
|
|
2131
|
+
enabled=True,
|
|
2132
|
+
)
|
|
2133
|
+
|
|
2134
|
+
resp = self.client.get(reverse("pages:readme"))
|
|
2135
|
+
|
|
2136
|
+
self.assertContains(
|
|
2137
|
+
resp,
|
|
2138
|
+
'<a class="nav-link" href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
|
|
2139
|
+
html=True,
|
|
2140
|
+
)
|
|
2141
|
+
self.assertNotContains(resp, 'dropdown-item" href="/man/"')
|
|
2142
|
+
|
|
2143
|
+
def test_readme_page_includes_qr_share(self):
|
|
2144
|
+
resp = self.client.get(reverse("pages:readme"), {"section": "intro"})
|
|
2145
|
+
self.assertContains(resp, 'id="reader-qr"')
|
|
2146
|
+
self.assertContains(
|
|
2147
|
+
resp,
|
|
2148
|
+
'data-url="http://testserver/read/?section=intro"',
|
|
2149
|
+
)
|
|
2150
|
+
self.assertNotContains(resp, "Scan this page")
|
|
2151
|
+
self.assertNotContains(
|
|
2152
|
+
resp, 'class="small text-break text-muted mt-3 mb-0"'
|
|
2153
|
+
)
|
|
2154
|
+
|
|
2155
|
+
def test_readme_document_by_name(self):
|
|
2156
|
+
resp = self.client.get(reverse("pages:readme-document", args=["AGENTS.md"]))
|
|
2157
|
+
self.assertEqual(resp.status_code, 200)
|
|
2158
|
+
self.assertContains(resp, "Agent Guidelines")
|
|
2159
|
+
|
|
2160
|
+
def test_readme_document_by_relative_path(self):
|
|
2161
|
+
resp = self.client.get(
|
|
2162
|
+
reverse(
|
|
2163
|
+
"pages:readme-document",
|
|
2164
|
+
args=["docs/development/maintenance-roadmap.md"],
|
|
2165
|
+
)
|
|
2166
|
+
)
|
|
2167
|
+
self.assertEqual(resp.status_code, 200)
|
|
2168
|
+
self.assertContains(resp, "Maintenance Improvement Proposals")
|
|
2169
|
+
|
|
2170
|
+
def test_readme_document_rejects_traversal(self):
|
|
2171
|
+
resp = self.client.get("/read/../../SECRET.md")
|
|
2172
|
+
self.assertEqual(resp.status_code, 404)
|
|
1700
2173
|
|
|
1701
2174
|
|
|
1702
2175
|
class SatelliteNavTests(TestCase):
|
|
@@ -1768,8 +2241,8 @@ class SatelliteNavTests(TestCase):
|
|
|
1768
2241
|
|
|
1769
2242
|
def test_readme_pill_visible(self):
|
|
1770
2243
|
resp = self.client.get(reverse("pages:readme"))
|
|
1771
|
-
self.assertContains(resp, 'href="/
|
|
1772
|
-
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">
|
|
2244
|
+
self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
|
|
2245
|
+
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
|
|
1773
2246
|
|
|
1774
2247
|
|
|
1775
2248
|
class PowerNavTests(TestCase):
|
|
@@ -1804,9 +2277,9 @@ class PowerNavTests(TestCase):
|
|
|
1804
2277
|
power_module = module
|
|
1805
2278
|
break
|
|
1806
2279
|
self.assertIsNotNone(power_module)
|
|
1807
|
-
self.assertEqual(power_module.menu_label.upper(), "
|
|
2280
|
+
self.assertEqual(power_module.menu_label.upper(), "CALCULATORS")
|
|
1808
2281
|
landing_labels = {landing.label for landing in power_module.enabled_landings}
|
|
1809
|
-
self.assertIn("AWG Calculator", landing_labels)
|
|
2282
|
+
self.assertIn("AWG Cable Calculator", landing_labels)
|
|
1810
2283
|
|
|
1811
2284
|
def test_manual_pill_label(self):
|
|
1812
2285
|
resp = self.client.get(reverse("pages:index"))
|
|
@@ -1830,9 +2303,26 @@ class PowerNavTests(TestCase):
|
|
|
1830
2303
|
break
|
|
1831
2304
|
self.assertIsNotNone(power_module)
|
|
1832
2305
|
landing_labels = {landing.label for landing in power_module.enabled_landings}
|
|
1833
|
-
self.assertIn("AWG Calculator", landing_labels)
|
|
2306
|
+
self.assertIn("AWG Cable Calculator", landing_labels)
|
|
1834
2307
|
self.assertIn("Energy Tariff Calculator", landing_labels)
|
|
1835
2308
|
|
|
2309
|
+
def test_locked_landing_shows_lock_icon(self):
|
|
2310
|
+
resp = self.client.get(reverse("pages:index"))
|
|
2311
|
+
html = resp.content.decode()
|
|
2312
|
+
energy_index = html.find("Energy Tariff Calculator")
|
|
2313
|
+
self.assertGreaterEqual(energy_index, 0)
|
|
2314
|
+
icon_index = html.find("dropdown-lock-icon", energy_index, energy_index + 300)
|
|
2315
|
+
self.assertGreaterEqual(icon_index, 0)
|
|
2316
|
+
|
|
2317
|
+
def test_lock_icon_disappears_after_login(self):
|
|
2318
|
+
self.client.force_login(self.user)
|
|
2319
|
+
resp = self.client.get(reverse("pages:index"))
|
|
2320
|
+
html = resp.content.decode()
|
|
2321
|
+
energy_index = html.find("Energy Tariff Calculator")
|
|
2322
|
+
self.assertGreaterEqual(energy_index, 0)
|
|
2323
|
+
icon_index = html.find("dropdown-lock-icon", energy_index, energy_index + 300)
|
|
2324
|
+
self.assertEqual(icon_index, -1)
|
|
2325
|
+
|
|
1836
2326
|
|
|
1837
2327
|
class StaffNavVisibilityTests(TestCase):
|
|
1838
2328
|
def setUp(self):
|
|
@@ -1854,12 +2344,12 @@ class StaffNavVisibilityTests(TestCase):
|
|
|
1854
2344
|
def test_nonstaff_pill_hidden(self):
|
|
1855
2345
|
self.client.login(username="user", password="pw")
|
|
1856
2346
|
resp = self.client.get(reverse("pages:index"))
|
|
1857
|
-
self.assertContains(resp, 'href="/ocpp/"')
|
|
2347
|
+
self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
|
|
1858
2348
|
|
|
1859
2349
|
def test_staff_sees_pill(self):
|
|
1860
2350
|
self.client.login(username="staff", password="pw")
|
|
1861
2351
|
resp = self.client.get(reverse("pages:index"))
|
|
1862
|
-
self.assertContains(resp, 'href="/ocpp/"')
|
|
2352
|
+
self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
|
|
1863
2353
|
|
|
1864
2354
|
|
|
1865
2355
|
class ModuleAdminReloadActionTests(TestCase):
|
|
@@ -1872,7 +2362,7 @@ class ModuleAdminReloadActionTests(TestCase):
|
|
|
1872
2362
|
password="pw",
|
|
1873
2363
|
)
|
|
1874
2364
|
self.client.force_login(self.superuser)
|
|
1875
|
-
self.role, _ = NodeRole.objects.get_or_create(name="
|
|
2365
|
+
self.role, _ = NodeRole.objects.get_or_create(name="Watchtower")
|
|
1876
2366
|
Application.objects.get_or_create(name="ocpp")
|
|
1877
2367
|
Application.objects.get_or_create(name="awg")
|
|
1878
2368
|
Site.objects.update_or_create(
|
|
@@ -1910,7 +2400,11 @@ class ModuleAdminReloadActionTests(TestCase):
|
|
|
1910
2400
|
)
|
|
1911
2401
|
self.assertSetEqual(
|
|
1912
2402
|
charger_landings,
|
|
1913
|
-
{
|
|
2403
|
+
{
|
|
2404
|
+
"/ocpp/cpms/dashboard/",
|
|
2405
|
+
"/ocpp/evcs/simulator/",
|
|
2406
|
+
"/ocpp/rfid/validator/",
|
|
2407
|
+
},
|
|
1914
2408
|
)
|
|
1915
2409
|
|
|
1916
2410
|
calculator_landings = set(
|
|
@@ -2048,6 +2542,47 @@ class UserManualAdminFormTests(TestCase):
|
|
|
2048
2542
|
self.assertEqual(form.cleaned_data["content_pdf"], self.manual.content_pdf)
|
|
2049
2543
|
|
|
2050
2544
|
|
|
2545
|
+
class UserManualModelTests(TestCase):
|
|
2546
|
+
def _build_manual(self, **overrides):
|
|
2547
|
+
defaults = {
|
|
2548
|
+
"slug": "manual-model-test",
|
|
2549
|
+
"title": "Manual Model",
|
|
2550
|
+
"description": "Manual description",
|
|
2551
|
+
"languages": "en",
|
|
2552
|
+
"content_html": "<p>Manual</p>",
|
|
2553
|
+
"content_pdf": base64.b64encode(b"initial").decode("ascii"),
|
|
2554
|
+
}
|
|
2555
|
+
defaults.update(overrides)
|
|
2556
|
+
return UserManual(**defaults)
|
|
2557
|
+
|
|
2558
|
+
def test_save_encodes_uploaded_file(self):
|
|
2559
|
+
upload = SimpleUploadedFile("manual.pdf", b"PDF data")
|
|
2560
|
+
manual = self._build_manual(slug="manual-upload", content_pdf=upload)
|
|
2561
|
+
manual.save()
|
|
2562
|
+
manual.refresh_from_db()
|
|
2563
|
+
self.assertEqual(
|
|
2564
|
+
manual.content_pdf,
|
|
2565
|
+
base64.b64encode(b"PDF data").decode("ascii"),
|
|
2566
|
+
)
|
|
2567
|
+
|
|
2568
|
+
def test_save_encodes_raw_bytes(self):
|
|
2569
|
+
manual = self._build_manual(slug="manual-bytes", content_pdf=b"PDF raw")
|
|
2570
|
+
manual.save()
|
|
2571
|
+
manual.refresh_from_db()
|
|
2572
|
+
self.assertEqual(
|
|
2573
|
+
manual.content_pdf,
|
|
2574
|
+
base64.b64encode(b"PDF raw").decode("ascii"),
|
|
2575
|
+
)
|
|
2576
|
+
|
|
2577
|
+
def test_save_strips_data_uri_prefix(self):
|
|
2578
|
+
encoded = base64.b64encode(b"PDF data").decode("ascii")
|
|
2579
|
+
data_uri = f"data:application/pdf;base64,{encoded}"
|
|
2580
|
+
manual = self._build_manual(slug="manual-data-uri", content_pdf=data_uri)
|
|
2581
|
+
manual.save()
|
|
2582
|
+
manual.refresh_from_db()
|
|
2583
|
+
self.assertEqual(manual.content_pdf, encoded)
|
|
2584
|
+
|
|
2585
|
+
|
|
2051
2586
|
class LandingCreationTests(TestCase):
|
|
2052
2587
|
def setUp(self):
|
|
2053
2588
|
role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
@@ -2069,12 +2604,12 @@ class LandingCreationTests(TestCase):
|
|
|
2069
2604
|
|
|
2070
2605
|
|
|
2071
2606
|
class LandingFixtureTests(TestCase):
|
|
2072
|
-
def
|
|
2607
|
+
def test_watchtower_fixture_loads_without_duplicates(self):
|
|
2073
2608
|
from glob import glob
|
|
2074
2609
|
|
|
2075
|
-
NodeRole.objects.get_or_create(name="
|
|
2610
|
+
NodeRole.objects.get_or_create(name="Watchtower")
|
|
2076
2611
|
fixtures = glob(
|
|
2077
|
-
str(Path(settings.BASE_DIR, "pages", "fixtures", "
|
|
2612
|
+
str(Path(settings.BASE_DIR, "pages", "fixtures", "watchtower__*.json"))
|
|
2078
2613
|
)
|
|
2079
2614
|
fixtures = sorted(
|
|
2080
2615
|
fixtures,
|
|
@@ -2084,9 +2619,11 @@ class LandingFixtureTests(TestCase):
|
|
|
2084
2619
|
)
|
|
2085
2620
|
call_command("loaddata", *fixtures)
|
|
2086
2621
|
call_command("loaddata", *fixtures)
|
|
2087
|
-
module = Module.objects.get(path="/ocpp/", node_role__name="
|
|
2622
|
+
module = Module.objects.get(path="/ocpp/", node_role__name="Watchtower")
|
|
2088
2623
|
module.create_landings()
|
|
2089
|
-
self.assertEqual(
|
|
2624
|
+
self.assertEqual(
|
|
2625
|
+
module.landings.filter(path="/ocpp/rfid/validator/").count(), 1
|
|
2626
|
+
)
|
|
2090
2627
|
|
|
2091
2628
|
|
|
2092
2629
|
class AllowedHostSubnetTests(TestCase):
|
|
@@ -2239,9 +2776,9 @@ class FaviconTests(TestCase):
|
|
|
2239
2776
|
)
|
|
2240
2777
|
self.assertContains(resp, b64)
|
|
2241
2778
|
|
|
2242
|
-
def
|
|
2779
|
+
def test_watchtower_nodes_use_goldenrod_favicon(self):
|
|
2243
2780
|
with override_settings(MEDIA_ROOT=self.tmpdir):
|
|
2244
|
-
role, _ = NodeRole.objects.get_or_create(name="
|
|
2781
|
+
role, _ = NodeRole.objects.get_or_create(name="Watchtower")
|
|
2245
2782
|
Node.objects.update_or_create(
|
|
2246
2783
|
mac_address=Node.get_current_mac(),
|
|
2247
2784
|
defaults={
|
|
@@ -2256,7 +2793,7 @@ class FaviconTests(TestCase):
|
|
|
2256
2793
|
resp = self.client.get(reverse("pages:index"))
|
|
2257
2794
|
b64 = (
|
|
2258
2795
|
Path(settings.BASE_DIR)
|
|
2259
|
-
.joinpath("pages", "fixtures", "data", "
|
|
2796
|
+
.joinpath("pages", "fixtures", "data", "favicon_watchtower.txt")
|
|
2260
2797
|
.read_text()
|
|
2261
2798
|
.strip()
|
|
2262
2799
|
)
|
|
@@ -2323,6 +2860,20 @@ class FavoriteTests(TestCase):
|
|
|
2323
2860
|
self.assertEqual(fav.custom_label, "Apps")
|
|
2324
2861
|
self.assertTrue(fav.user_data)
|
|
2325
2862
|
|
|
2863
|
+
def test_add_favorite_defaults_user_data_checked(self):
|
|
2864
|
+
ct = ContentType.objects.get_by_natural_key("pages", "application")
|
|
2865
|
+
url = reverse("admin:favorite_toggle", args=[ct.id])
|
|
2866
|
+
resp = self.client.get(url)
|
|
2867
|
+
self.assertContains(resp, 'name="user_data" checked')
|
|
2868
|
+
|
|
2869
|
+
def test_add_favorite_with_priority(self):
|
|
2870
|
+
ct = ContentType.objects.get_by_natural_key("pages", "application")
|
|
2871
|
+
url = reverse("admin:favorite_toggle", args=[ct.id])
|
|
2872
|
+
resp = self.client.post(url, {"priority": "7"})
|
|
2873
|
+
self.assertRedirects(resp, reverse("admin:index"))
|
|
2874
|
+
fav = Favorite.objects.get(user=self.user, content_type=ct)
|
|
2875
|
+
self.assertEqual(fav.priority, 7)
|
|
2876
|
+
|
|
2326
2877
|
def test_cancel_link_uses_next(self):
|
|
2327
2878
|
ct = ContentType.objects.get_by_natural_key("pages", "application")
|
|
2328
2879
|
next_url = reverse("admin:pages_application_changelist")
|
|
@@ -2332,14 +2883,31 @@ class FavoriteTests(TestCase):
|
|
|
2332
2883
|
resp = self.client.get(url)
|
|
2333
2884
|
self.assertContains(resp, f'href="{next_url}"')
|
|
2334
2885
|
|
|
2335
|
-
def
|
|
2886
|
+
def test_existing_favorite_shows_update_form(self):
|
|
2336
2887
|
ct = ContentType.objects.get_by_natural_key("pages", "application")
|
|
2337
|
-
Favorite.objects.create(
|
|
2888
|
+
favorite = Favorite.objects.create(
|
|
2889
|
+
user=self.user, content_type=ct, custom_label="Apps", user_data=True
|
|
2890
|
+
)
|
|
2338
2891
|
url = reverse("admin:favorite_toggle", args=[ct.id])
|
|
2339
2892
|
resp = self.client.get(url)
|
|
2340
|
-
self.
|
|
2341
|
-
|
|
2342
|
-
self.assertContains(resp,
|
|
2893
|
+
self.assertContains(resp, "Update Favorite")
|
|
2894
|
+
self.assertContains(resp, "value=\"Apps\"")
|
|
2895
|
+
self.assertContains(resp, "checked")
|
|
2896
|
+
self.assertContains(resp, "name=\"remove\"")
|
|
2897
|
+
|
|
2898
|
+
resp = self.client.post(url, {"custom_label": "Apps Updated"})
|
|
2899
|
+
self.assertRedirects(resp, reverse("admin:index"))
|
|
2900
|
+
favorite.refresh_from_db()
|
|
2901
|
+
self.assertEqual(favorite.custom_label, "Apps Updated")
|
|
2902
|
+
self.assertFalse(favorite.user_data)
|
|
2903
|
+
|
|
2904
|
+
def test_remove_existing_favorite_from_toggle(self):
|
|
2905
|
+
ct = ContentType.objects.get_by_natural_key("pages", "application")
|
|
2906
|
+
Favorite.objects.create(user=self.user, content_type=ct)
|
|
2907
|
+
url = reverse("admin:favorite_toggle", args=[ct.id])
|
|
2908
|
+
resp = self.client.post(url, {"remove": "1"})
|
|
2909
|
+
self.assertRedirects(resp, reverse("admin:index"))
|
|
2910
|
+
self.assertFalse(Favorite.objects.filter(user=self.user, content_type=ct).exists())
|
|
2343
2911
|
|
|
2344
2912
|
def test_update_user_data_from_list(self):
|
|
2345
2913
|
ct = ContentType.objects.get_by_natural_key("pages", "application")
|
|
@@ -2350,6 +2918,15 @@ class FavoriteTests(TestCase):
|
|
|
2350
2918
|
fav.refresh_from_db()
|
|
2351
2919
|
self.assertTrue(fav.user_data)
|
|
2352
2920
|
|
|
2921
|
+
def test_update_priority_from_list(self):
|
|
2922
|
+
ct = ContentType.objects.get_by_natural_key("pages", "application")
|
|
2923
|
+
fav = Favorite.objects.create(user=self.user, content_type=ct, priority=3)
|
|
2924
|
+
url = reverse("admin:favorite_list")
|
|
2925
|
+
resp = self.client.post(url, {f"priority_{fav.pk}": "12"})
|
|
2926
|
+
self.assertRedirects(resp, url)
|
|
2927
|
+
fav.refresh_from_db()
|
|
2928
|
+
self.assertEqual(fav.priority, 12)
|
|
2929
|
+
|
|
2353
2930
|
def test_dashboard_includes_favorites_and_user_data(self):
|
|
2354
2931
|
fav_ct = ContentType.objects.get_by_natural_key("pages", "application")
|
|
2355
2932
|
Favorite.objects.create(
|
|
@@ -2360,6 +2937,12 @@ class FavoriteTests(TestCase):
|
|
|
2360
2937
|
self.assertContains(resp, reverse("admin:pages_application_changelist"))
|
|
2361
2938
|
self.assertContains(resp, reverse("admin:nodes_noderole_changelist"))
|
|
2362
2939
|
|
|
2940
|
+
def test_dashboard_shows_empty_todo_state(self):
|
|
2941
|
+
Todo.objects.all().delete()
|
|
2942
|
+
resp = self.client.get(reverse("admin:index"))
|
|
2943
|
+
self.assertContains(resp, "Release manager tasks")
|
|
2944
|
+
self.assertContains(resp, "No pending TODOs")
|
|
2945
|
+
|
|
2363
2946
|
def test_dashboard_merges_duplicate_future_actions(self):
|
|
2364
2947
|
ct = ContentType.objects.get_for_model(NodeRole)
|
|
2365
2948
|
Favorite.objects.create(user=self.user, content_type=ct)
|
|
@@ -2406,6 +2989,48 @@ class FavoriteTests(TestCase):
|
|
|
2406
2989
|
self.assertContains(resp, f'title="{badge_label}"')
|
|
2407
2990
|
self.assertContains(resp, f'aria-label="{badge_label}"')
|
|
2408
2991
|
|
|
2992
|
+
def test_dashboard_shows_charge_point_availability_badge(self):
|
|
2993
|
+
Charger.objects.create(
|
|
2994
|
+
charger_id="CP-001", connector_id=1, last_status="Available"
|
|
2995
|
+
)
|
|
2996
|
+
Charger.objects.create(charger_id="CP-002", last_status="Available")
|
|
2997
|
+
Charger.objects.create(
|
|
2998
|
+
charger_id="CP-003", connector_id=1, last_status="Unavailable"
|
|
2999
|
+
)
|
|
3000
|
+
|
|
3001
|
+
resp = self.client.get(reverse("admin:index"))
|
|
3002
|
+
|
|
3003
|
+
expected = "1 / 2"
|
|
3004
|
+
badge_label = gettext(
|
|
3005
|
+
"%(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."
|
|
3006
|
+
) % {"available": 1, "total": 2, "missing": 1}
|
|
3007
|
+
|
|
3008
|
+
self.assertContains(resp, expected)
|
|
3009
|
+
self.assertContains(resp, 'class="charger-availability-badge"')
|
|
3010
|
+
self.assertContains(resp, f'title="{badge_label}"')
|
|
3011
|
+
self.assertContains(resp, f'aria-label="{badge_label}"')
|
|
3012
|
+
|
|
3013
|
+
def test_dashboard_charge_point_badge_ignores_aggregator(self):
|
|
3014
|
+
Charger.objects.create(charger_id="CP-AGG", last_status="Available")
|
|
3015
|
+
Charger.objects.create(
|
|
3016
|
+
charger_id="CP-AGG", connector_id=1, last_status="Available"
|
|
3017
|
+
)
|
|
3018
|
+
Charger.objects.create(
|
|
3019
|
+
charger_id="CP-AGG", connector_id=2, last_status="Available"
|
|
3020
|
+
)
|
|
3021
|
+
|
|
3022
|
+
resp = self.client.get(reverse("admin:index"))
|
|
3023
|
+
|
|
3024
|
+
expected = "2 / 2"
|
|
3025
|
+
badge_label = gettext(
|
|
3026
|
+
"%(available)s chargers reporting Available status with a CP number."
|
|
3027
|
+
) % {"available": 2}
|
|
3028
|
+
|
|
3029
|
+
self.assertContains(resp, expected)
|
|
3030
|
+
self.assertContains(resp, 'class="charger-availability-badge"')
|
|
3031
|
+
self.assertContains(resp, f'title="{badge_label}"')
|
|
3032
|
+
self.assertContains(resp, f'aria-label="{badge_label}"')
|
|
3033
|
+
|
|
2409
3034
|
def test_nav_sidebar_hides_dashboard_badges(self):
|
|
2410
3035
|
InviteLead.objects.create(email="open@example.com")
|
|
2411
3036
|
RFID.objects.create(rfid="RFID0003", released=True, allowed=True)
|
|
@@ -2557,7 +3182,11 @@ class FavoriteTests(TestCase):
|
|
|
2557
3182
|
todo = Todo.objects.create(request="Do thing")
|
|
2558
3183
|
resp = self.client.get(reverse("admin:index"))
|
|
2559
3184
|
done_url = reverse("todo-done", args=[todo.pk])
|
|
2560
|
-
|
|
3185
|
+
tooltip = escape(todo.request)
|
|
3186
|
+
self.assertContains(resp, f'title="{tooltip}"')
|
|
3187
|
+
self.assertContains(resp, f'aria-label="{tooltip}"')
|
|
3188
|
+
task_label = gettext("Task %(counter)s") % {"counter": 1}
|
|
3189
|
+
self.assertContains(resp, task_label)
|
|
2561
3190
|
self.assertContains(resp, f'action="{done_url}"')
|
|
2562
3191
|
self.assertContains(resp, "DONE")
|
|
2563
3192
|
|
|
@@ -2568,6 +3197,15 @@ class FavoriteTests(TestCase):
|
|
|
2568
3197
|
resp, '<div class="todo-details">More info</div>', html=True
|
|
2569
3198
|
)
|
|
2570
3199
|
|
|
3200
|
+
def test_dashboard_hides_completed_todos(self):
|
|
3201
|
+
todo = Todo.objects.create(request="Completed task")
|
|
3202
|
+
Todo.objects.filter(pk=todo.pk).update(done_on=timezone.now())
|
|
3203
|
+
|
|
3204
|
+
resp = self.client.get(reverse("admin:index"))
|
|
3205
|
+
|
|
3206
|
+
self.assertNotContains(resp, todo.request)
|
|
3207
|
+
self.assertNotContains(resp, "Completed")
|
|
3208
|
+
|
|
2571
3209
|
def test_dashboard_shows_todos_when_node_unknown(self):
|
|
2572
3210
|
Todo.objects.create(request="Check fallback")
|
|
2573
3211
|
from nodes.models import Node
|
|
@@ -2576,7 +3214,8 @@ class FavoriteTests(TestCase):
|
|
|
2576
3214
|
|
|
2577
3215
|
resp = self.client.get(reverse("admin:index"))
|
|
2578
3216
|
self.assertContains(resp, "Release manager tasks")
|
|
2579
|
-
|
|
3217
|
+
tooltip = escape("Check fallback")
|
|
3218
|
+
self.assertContains(resp, f'title="{tooltip}"')
|
|
2580
3219
|
|
|
2581
3220
|
def test_dashboard_shows_todos_without_release_manager_profile(self):
|
|
2582
3221
|
Todo.objects.create(request="Unrestricted task")
|
|
@@ -2584,7 +3223,8 @@ class FavoriteTests(TestCase):
|
|
|
2584
3223
|
|
|
2585
3224
|
resp = self.client.get(reverse("admin:index"))
|
|
2586
3225
|
self.assertContains(resp, "Release manager tasks")
|
|
2587
|
-
|
|
3226
|
+
tooltip = escape("Unrestricted task")
|
|
3227
|
+
self.assertContains(resp, f'title="{tooltip}"')
|
|
2588
3228
|
|
|
2589
3229
|
def test_dashboard_excludes_todo_changelist_link(self):
|
|
2590
3230
|
ct = ContentType.objects.get_for_model(Todo)
|
|
@@ -2608,7 +3248,8 @@ class FavoriteTests(TestCase):
|
|
|
2608
3248
|
self.client.force_login(other_user)
|
|
2609
3249
|
resp = self.client.get(reverse("admin:index"))
|
|
2610
3250
|
self.assertContains(resp, "Release manager tasks")
|
|
2611
|
-
|
|
3251
|
+
tooltip = escape(todo.request)
|
|
3252
|
+
self.assertContains(resp, f'title="{tooltip}"')
|
|
2612
3253
|
|
|
2613
3254
|
def test_dashboard_shows_todos_for_non_terminal_node(self):
|
|
2614
3255
|
todo = Todo.objects.create(request="Terminal Tasks")
|
|
@@ -2619,7 +3260,8 @@ class FavoriteTests(TestCase):
|
|
|
2619
3260
|
self.node.save(update_fields=["role"])
|
|
2620
3261
|
resp = self.client.get(reverse("admin:index"))
|
|
2621
3262
|
self.assertContains(resp, "Release manager tasks")
|
|
2622
|
-
|
|
3263
|
+
tooltip = escape(todo.request)
|
|
3264
|
+
self.assertContains(resp, f'title="{tooltip}"')
|
|
2623
3265
|
|
|
2624
3266
|
def test_dashboard_shows_todos_for_delegate_release_manager(self):
|
|
2625
3267
|
todo = Todo.objects.create(request="Delegate Task")
|
|
@@ -2641,7 +3283,8 @@ class FavoriteTests(TestCase):
|
|
|
2641
3283
|
self.client.force_login(operator)
|
|
2642
3284
|
resp = self.client.get(reverse("admin:index"))
|
|
2643
3285
|
self.assertContains(resp, "Release manager tasks")
|
|
2644
|
-
|
|
3286
|
+
tooltip = escape(todo.request)
|
|
3287
|
+
self.assertContains(resp, f'title="{tooltip}"')
|
|
2645
3288
|
|
|
2646
3289
|
|
|
2647
3290
|
class AdminIndexQueryRegressionTests(TestCase):
|
|
@@ -2836,47 +3479,6 @@ class AdminModelGraphViewTests(TestCase):
|
|
|
2836
3479
|
self.assertEqual(kwargs.get("format"), "pdf")
|
|
2837
3480
|
|
|
2838
3481
|
|
|
2839
|
-
class DatasetteTests(TestCase):
|
|
2840
|
-
def setUp(self):
|
|
2841
|
-
self.client = Client()
|
|
2842
|
-
User = get_user_model()
|
|
2843
|
-
self.user = User.objects.create_user(username="ds", password="pwd")
|
|
2844
|
-
Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
|
|
2845
|
-
|
|
2846
|
-
def test_datasette_auth_endpoint(self):
|
|
2847
|
-
resp = self.client.get(reverse("pages:datasette-auth"))
|
|
2848
|
-
self.assertEqual(resp.status_code, 401)
|
|
2849
|
-
self.client.force_login(self.user)
|
|
2850
|
-
resp = self.client.get(reverse("pages:datasette-auth"))
|
|
2851
|
-
self.assertEqual(resp.status_code, 200)
|
|
2852
|
-
|
|
2853
|
-
def test_navbar_includes_datasette_when_enabled(self):
|
|
2854
|
-
lock_dir = Path(settings.BASE_DIR) / "locks"
|
|
2855
|
-
lock_dir.mkdir(exist_ok=True)
|
|
2856
|
-
lock_file = lock_dir / "datasette.lck"
|
|
2857
|
-
try:
|
|
2858
|
-
lock_file.touch()
|
|
2859
|
-
resp = self.client.get(reverse("pages:index"))
|
|
2860
|
-
self.assertContains(resp, 'href="/data/"')
|
|
2861
|
-
finally:
|
|
2862
|
-
lock_file.unlink(missing_ok=True)
|
|
2863
|
-
|
|
2864
|
-
def test_admin_home_includes_datasette_button_when_enabled(self):
|
|
2865
|
-
lock_dir = Path(settings.BASE_DIR) / "locks"
|
|
2866
|
-
lock_dir.mkdir(exist_ok=True)
|
|
2867
|
-
lock_file = lock_dir / "datasette.lck"
|
|
2868
|
-
try:
|
|
2869
|
-
lock_file.touch()
|
|
2870
|
-
self.user.is_staff = True
|
|
2871
|
-
self.user.is_superuser = True
|
|
2872
|
-
self.user.save()
|
|
2873
|
-
self.client.force_login(self.user)
|
|
2874
|
-
resp = self.client.get(reverse("admin:index"))
|
|
2875
|
-
self.assertContains(resp, 'href="/data/"')
|
|
2876
|
-
self.assertContains(resp, ">Datasette<")
|
|
2877
|
-
finally:
|
|
2878
|
-
lock_file.unlink(missing_ok=True)
|
|
2879
|
-
|
|
2880
3482
|
|
|
2881
3483
|
class UserStorySubmissionTests(TestCase):
|
|
2882
3484
|
def setUp(self):
|
|
@@ -2885,6 +3487,14 @@ class UserStorySubmissionTests(TestCase):
|
|
|
2885
3487
|
self.url = reverse("pages:user-story-submit")
|
|
2886
3488
|
User = get_user_model()
|
|
2887
3489
|
self.user = User.objects.create_user(username="feedbacker", password="pwd")
|
|
3490
|
+
self.capture_patcher = patch("pages.views.capture_screenshot", autospec=True)
|
|
3491
|
+
self.save_patcher = patch("pages.views.save_screenshot", autospec=True)
|
|
3492
|
+
self.mock_capture = self.capture_patcher.start()
|
|
3493
|
+
self.mock_save = self.save_patcher.start()
|
|
3494
|
+
self.mock_capture.return_value = Path("/tmp/fake.png")
|
|
3495
|
+
self.mock_save.return_value = None
|
|
3496
|
+
self.addCleanup(self.capture_patcher.stop)
|
|
3497
|
+
self.addCleanup(self.save_patcher.stop)
|
|
2888
3498
|
|
|
2889
3499
|
def test_authenticated_submission_defaults_to_username(self):
|
|
2890
3500
|
self.client.force_login(self.user)
|
|
@@ -2913,12 +3523,121 @@ class UserStorySubmissionTests(TestCase):
|
|
|
2913
3523
|
self.assertEqual(story.referer, "https://example.test/wizard/step-1/")
|
|
2914
3524
|
self.assertEqual(story.user_agent, "FeedbackBot/1.0")
|
|
2915
3525
|
self.assertEqual(story.ip_address, "127.0.0.1")
|
|
3526
|
+
expected_language = (translation.get_language() or "").split("-")[0]
|
|
3527
|
+
self.assertTrue(story.language_code)
|
|
3528
|
+
self.assertTrue(
|
|
3529
|
+
story.language_code.startswith(expected_language),
|
|
3530
|
+
story.language_code,
|
|
3531
|
+
)
|
|
3532
|
+
|
|
3533
|
+
def test_submission_records_request_language(self):
|
|
3534
|
+
self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = "es"
|
|
3535
|
+
with translation.override("es"):
|
|
3536
|
+
response = self.client.post(
|
|
3537
|
+
self.url,
|
|
3538
|
+
{
|
|
3539
|
+
"rating": 4,
|
|
3540
|
+
"comments": "Buena experiencia",
|
|
3541
|
+
"path": "/es/soporte/",
|
|
3542
|
+
"take_screenshot": "1",
|
|
3543
|
+
},
|
|
3544
|
+
HTTP_ACCEPT_LANGUAGE="es",
|
|
3545
|
+
)
|
|
3546
|
+
|
|
3547
|
+
self.assertEqual(response.status_code, 200)
|
|
3548
|
+
story = UserStory.objects.get()
|
|
3549
|
+
self.assertEqual(story.language_code, "es")
|
|
2916
3550
|
|
|
2917
|
-
def
|
|
3551
|
+
def test_submission_prefers_original_referer(self):
|
|
3552
|
+
self.client.get(
|
|
3553
|
+
reverse("pages:index"),
|
|
3554
|
+
HTTP_REFERER="https://ads.example/original",
|
|
3555
|
+
)
|
|
2918
3556
|
response = self.client.post(
|
|
2919
3557
|
self.url,
|
|
2920
3558
|
{
|
|
2921
|
-
"
|
|
3559
|
+
"rating": 3,
|
|
3560
|
+
"comments": "Works well",
|
|
3561
|
+
"path": "/wizard/step-2/",
|
|
3562
|
+
"name": "visitor@example.com",
|
|
3563
|
+
"take_screenshot": "0",
|
|
3564
|
+
},
|
|
3565
|
+
HTTP_REFERER="http://testserver/wizard/step-2/",
|
|
3566
|
+
HTTP_USER_AGENT="FeedbackBot/2.0",
|
|
3567
|
+
)
|
|
3568
|
+
|
|
3569
|
+
self.assertEqual(response.status_code, 200)
|
|
3570
|
+
story = UserStory.objects.get()
|
|
3571
|
+
self.assertEqual(story.referer, "https://ads.example/original")
|
|
3572
|
+
|
|
3573
|
+
def test_superuser_submission_creates_triage_todo(self):
|
|
3574
|
+
Todo.objects.all().delete()
|
|
3575
|
+
superuser = get_user_model().objects.create_superuser(
|
|
3576
|
+
username="overseer", email="overseer@example.com", password="pwd"
|
|
3577
|
+
)
|
|
3578
|
+
Node.objects.update_or_create(
|
|
3579
|
+
mac_address=Node.get_current_mac(),
|
|
3580
|
+
defaults={
|
|
3581
|
+
"hostname": "local-node",
|
|
3582
|
+
"address": "127.0.0.1",
|
|
3583
|
+
"port": 8000,
|
|
3584
|
+
"public_endpoint": "local-node",
|
|
3585
|
+
},
|
|
3586
|
+
)
|
|
3587
|
+
self.client.force_login(superuser)
|
|
3588
|
+
comments = "Review analytics dashboard flow"
|
|
3589
|
+
response = self.client.post(
|
|
3590
|
+
self.url,
|
|
3591
|
+
{
|
|
3592
|
+
"rating": 5,
|
|
3593
|
+
"comments": comments,
|
|
3594
|
+
"path": "/reports/analytics/",
|
|
3595
|
+
"take_screenshot": "0",
|
|
3596
|
+
},
|
|
3597
|
+
)
|
|
3598
|
+
self.assertEqual(response.status_code, 200)
|
|
3599
|
+
self.assertEqual(Todo.objects.count(), 1)
|
|
3600
|
+
todo = Todo.objects.get()
|
|
3601
|
+
self.assertEqual(todo.request, f"Triage {comments}")
|
|
3602
|
+
self.assertTrue(todo.is_user_data)
|
|
3603
|
+
self.assertEqual(todo.original_user, superuser)
|
|
3604
|
+
self.assertTrue(todo.original_user_is_authenticated)
|
|
3605
|
+
self.assertEqual(todo.origin_node, Node.get_local())
|
|
3606
|
+
|
|
3607
|
+
def test_screenshot_request_links_saved_sample(self):
|
|
3608
|
+
self.client.force_login(self.user)
|
|
3609
|
+
screenshot_file = Path("/tmp/fake.png")
|
|
3610
|
+
self.mock_capture.return_value = screenshot_file
|
|
3611
|
+
sample = ContentSample.objects.create(kind=ContentSample.IMAGE)
|
|
3612
|
+
self.mock_save.return_value = sample
|
|
3613
|
+
|
|
3614
|
+
response = self.client.post(
|
|
3615
|
+
self.url,
|
|
3616
|
+
{
|
|
3617
|
+
"rating": 5,
|
|
3618
|
+
"comments": "Loved the experience!",
|
|
3619
|
+
"path": "/wizard/step-1/",
|
|
3620
|
+
"take_screenshot": "1",
|
|
3621
|
+
},
|
|
3622
|
+
HTTP_REFERER="https://example.test/wizard/step-1/",
|
|
3623
|
+
)
|
|
3624
|
+
|
|
3625
|
+
self.assertEqual(response.status_code, 200)
|
|
3626
|
+
story = UserStory.objects.get()
|
|
3627
|
+
self.assertEqual(story.screenshot, sample)
|
|
3628
|
+
self.mock_capture.assert_called_once_with("https://example.test/wizard/step-1/")
|
|
3629
|
+
self.mock_save.assert_called_once_with(
|
|
3630
|
+
screenshot_file,
|
|
3631
|
+
method="USER_STORY",
|
|
3632
|
+
user=self.user,
|
|
3633
|
+
link_duplicates=True,
|
|
3634
|
+
)
|
|
3635
|
+
|
|
3636
|
+
def test_anonymous_submission_uses_provided_email(self):
|
|
3637
|
+
response = self.client.post(
|
|
3638
|
+
self.url,
|
|
3639
|
+
{
|
|
3640
|
+
"name": "guest@example.com",
|
|
2922
3641
|
"rating": 3,
|
|
2923
3642
|
"comments": "It was fine.",
|
|
2924
3643
|
"path": "/status/",
|
|
@@ -2928,7 +3647,7 @@ class UserStorySubmissionTests(TestCase):
|
|
|
2928
3647
|
self.assertEqual(response.status_code, 200)
|
|
2929
3648
|
self.assertEqual(UserStory.objects.count(), 1)
|
|
2930
3649
|
story = UserStory.objects.get()
|
|
2931
|
-
self.assertEqual(story.name, "
|
|
3650
|
+
self.assertEqual(story.name, "guest@example.com")
|
|
2932
3651
|
self.assertIsNone(story.user)
|
|
2933
3652
|
self.assertIsNone(story.owner)
|
|
2934
3653
|
self.assertEqual(story.comments, "It was fine.")
|
|
@@ -2950,7 +3669,7 @@ class UserStorySubmissionTests(TestCase):
|
|
|
2950
3669
|
self.assertFalse(UserStory.objects.exists())
|
|
2951
3670
|
self.assertIn("rating", data.get("errors", {}))
|
|
2952
3671
|
|
|
2953
|
-
def
|
|
3672
|
+
def test_anonymous_submission_without_email_returns_errors(self):
|
|
2954
3673
|
response = self.client.post(
|
|
2955
3674
|
self.url,
|
|
2956
3675
|
{
|
|
@@ -2960,18 +3679,32 @@ class UserStorySubmissionTests(TestCase):
|
|
|
2960
3679
|
"take_screenshot": "1",
|
|
2961
3680
|
},
|
|
2962
3681
|
)
|
|
2963
|
-
self.assertEqual(response.status_code,
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
self.
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
self.
|
|
3682
|
+
self.assertEqual(response.status_code, 400)
|
|
3683
|
+
self.assertFalse(UserStory.objects.exists())
|
|
3684
|
+
data = response.json()
|
|
3685
|
+
self.assertIn("name", data.get("errors", {}))
|
|
3686
|
+
|
|
3687
|
+
def test_anonymous_submission_with_invalid_email_returns_errors(self):
|
|
3688
|
+
response = self.client.post(
|
|
3689
|
+
self.url,
|
|
3690
|
+
{
|
|
3691
|
+
"name": "Guest Reviewer",
|
|
3692
|
+
"rating": 3,
|
|
3693
|
+
"comments": "Needs improvement.",
|
|
3694
|
+
"path": "/feedback/",
|
|
3695
|
+
"take_screenshot": "1",
|
|
3696
|
+
},
|
|
3697
|
+
)
|
|
3698
|
+
self.assertEqual(response.status_code, 400)
|
|
3699
|
+
self.assertFalse(UserStory.objects.exists())
|
|
3700
|
+
data = response.json()
|
|
3701
|
+
self.assertIn("name", data.get("errors", {}))
|
|
2970
3702
|
|
|
2971
3703
|
def test_submission_without_screenshot_request(self):
|
|
2972
3704
|
response = self.client.post(
|
|
2973
3705
|
self.url,
|
|
2974
3706
|
{
|
|
3707
|
+
"name": "guest@example.com",
|
|
2975
3708
|
"rating": 4,
|
|
2976
3709
|
"comments": "Skip the screenshot, please.",
|
|
2977
3710
|
"path": "/feedback/",
|
|
@@ -2981,9 +3714,14 @@ class UserStorySubmissionTests(TestCase):
|
|
|
2981
3714
|
story = UserStory.objects.get()
|
|
2982
3715
|
self.assertFalse(story.take_screenshot)
|
|
2983
3716
|
self.assertIsNone(story.owner)
|
|
3717
|
+
self.assertIsNone(story.screenshot)
|
|
3718
|
+
self.assertEqual(story.status, UserStory.Status.OPEN)
|
|
3719
|
+
self.mock_capture.assert_not_called()
|
|
3720
|
+
self.mock_save.assert_not_called()
|
|
2984
3721
|
|
|
2985
3722
|
def test_rate_limit_blocks_repeated_submissions(self):
|
|
2986
3723
|
payload = {
|
|
3724
|
+
"name": "guest@example.com",
|
|
2987
3725
|
"rating": 4,
|
|
2988
3726
|
"comments": "Pretty good",
|
|
2989
3727
|
"path": "/feedback/",
|
|
@@ -3084,6 +3822,8 @@ class UserStoryAdminActionTests(TestCase):
|
|
|
3084
3822
|
comments="Helpful notes",
|
|
3085
3823
|
take_screenshot=True,
|
|
3086
3824
|
)
|
|
3825
|
+
self.story.language_code = "es"
|
|
3826
|
+
self.story.save(update_fields=["language_code"])
|
|
3087
3827
|
self.admin = UserStoryAdmin(UserStory, admin.site)
|
|
3088
3828
|
|
|
3089
3829
|
def _build_request(self):
|
|
@@ -3117,6 +3857,8 @@ class UserStoryAdminActionTests(TestCase):
|
|
|
3117
3857
|
args, kwargs = mock_create_issue.call_args
|
|
3118
3858
|
self.assertIn("Feedback for", args[0])
|
|
3119
3859
|
self.assertIn("**Rating:**", args[1])
|
|
3860
|
+
self.assertIn("**Language:**", args[1])
|
|
3861
|
+
self.assertIn("(es)", args[1])
|
|
3120
3862
|
self.assertEqual(kwargs.get("labels"), ["feedback"])
|
|
3121
3863
|
self.assertEqual(
|
|
3122
3864
|
kwargs.get("fingerprint"), f"user-story:{self.story.pk}"
|
|
@@ -3134,6 +3876,40 @@ class UserStoryAdminActionTests(TestCase):
|
|
|
3134
3876
|
|
|
3135
3877
|
mock_create_issue.assert_not_called()
|
|
3136
3878
|
|
|
3879
|
+
def test_create_github_issues_action_links_to_credentials_when_missing(self):
|
|
3880
|
+
request = self._build_request()
|
|
3881
|
+
queryset = UserStory.objects.filter(pk=self.story.pk)
|
|
3882
|
+
|
|
3883
|
+
mock_url = "/admin/core/releasemanager/"
|
|
3884
|
+
with (
|
|
3885
|
+
patch(
|
|
3886
|
+
"pages.admin.reverse", return_value=mock_url
|
|
3887
|
+
) as mock_reverse,
|
|
3888
|
+
patch.object(
|
|
3889
|
+
UserStory,
|
|
3890
|
+
"create_github_issue",
|
|
3891
|
+
side_effect=RuntimeError("GitHub token is not configured"),
|
|
3892
|
+
),
|
|
3893
|
+
):
|
|
3894
|
+
self.admin.create_github_issues(request, queryset)
|
|
3895
|
+
|
|
3896
|
+
messages_list = list(request._messages)
|
|
3897
|
+
self.assertTrue(messages_list)
|
|
3898
|
+
|
|
3899
|
+
opts = ReleaseManager._meta
|
|
3900
|
+
mock_reverse.assert_called_once_with(
|
|
3901
|
+
f"{self.admin.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
|
|
3902
|
+
)
|
|
3903
|
+
self.assertTrue(
|
|
3904
|
+
any(mock_url in message.message for message in messages_list),
|
|
3905
|
+
)
|
|
3906
|
+
self.assertTrue(
|
|
3907
|
+
any("Configure GitHub credentials" in message.message for message in messages_list),
|
|
3908
|
+
)
|
|
3909
|
+
self.assertTrue(
|
|
3910
|
+
any(message.level == messages.ERROR for message in messages_list),
|
|
3911
|
+
)
|
|
3912
|
+
|
|
3137
3913
|
|
|
3138
3914
|
class ClientReportLiveUpdateTests(TestCase):
|
|
3139
3915
|
def setUp(self):
|