arthexis 0.1.11__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.11.dist-info → arthexis-0.1.12.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/RECORD +38 -35
- config/settings.py +7 -2
- core/admin.py +246 -68
- core/apps.py +21 -0
- core/models.py +41 -8
- core/reference_utils.py +1 -1
- core/release.py +4 -0
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +64 -0
- core/views.py +131 -17
- nodes/admin.py +316 -6
- nodes/feature_checks.py +133 -0
- nodes/models.py +83 -26
- nodes/reports.py +411 -0
- nodes/tests.py +365 -36
- nodes/utils.py +32 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +506 -8
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +234 -4
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/tests.py +789 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +225 -19
- pages/admin.py +135 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +38 -0
- pages/models.py +136 -1
- pages/tests.py +262 -4
- pages/urls.py +1 -0
- pages/views.py +52 -3
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
pages/forms.py
CHANGED
|
@@ -9,6 +9,8 @@ from django.core.exceptions import ValidationError
|
|
|
9
9
|
from django.utils.translation import gettext_lazy as _
|
|
10
10
|
from django.views.decorators.debug import sensitive_variables
|
|
11
11
|
|
|
12
|
+
from .models import UserStory
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
class AuthenticatorLoginForm(AuthenticationForm):
|
|
14
16
|
"""Authentication form that supports password or authenticator codes."""
|
|
@@ -129,3 +131,39 @@ class AuthenticatorEnrollmentForm(forms.Form):
|
|
|
129
131
|
|
|
130
132
|
def get_verified_device(self):
|
|
131
133
|
return self.device
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class UserStoryForm(forms.ModelForm):
|
|
137
|
+
class Meta:
|
|
138
|
+
model = UserStory
|
|
139
|
+
fields = ("name", "rating", "comments", "take_screenshot", "path")
|
|
140
|
+
widgets = {
|
|
141
|
+
"path": forms.HiddenInput(),
|
|
142
|
+
"comments": forms.Textarea(attrs={"rows": 4, "maxlength": 400}),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def __init__(self, *args, **kwargs):
|
|
146
|
+
super().__init__(*args, **kwargs)
|
|
147
|
+
self.fields["name"].required = False
|
|
148
|
+
self.fields["name"].widget.attrs.update(
|
|
149
|
+
{
|
|
150
|
+
"maxlength": 40,
|
|
151
|
+
"placeholder": _("Name, email or pseudonym"),
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
self.fields["take_screenshot"].initial = True
|
|
155
|
+
self.fields["rating"].widget = forms.RadioSelect(
|
|
156
|
+
choices=[(i, str(i)) for i in range(1, 6)]
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def clean_comments(self):
|
|
160
|
+
comments = (self.cleaned_data.get("comments") or "").strip()
|
|
161
|
+
if len(comments) > 400:
|
|
162
|
+
raise forms.ValidationError(
|
|
163
|
+
_("Feedback must be 400 characters or fewer."), code="max_length"
|
|
164
|
+
)
|
|
165
|
+
return comments
|
|
166
|
+
|
|
167
|
+
def clean_name(self):
|
|
168
|
+
name = (self.cleaned_data.get("name") or "").strip()
|
|
169
|
+
return name[:40]
|
pages/models.py
CHANGED
|
@@ -4,11 +4,14 @@ from django.contrib.sites.models import Site
|
|
|
4
4
|
from nodes.models import NodeRole
|
|
5
5
|
from django.apps import apps as django_apps
|
|
6
6
|
from django.utils.text import slugify
|
|
7
|
-
from django.utils.translation import gettext_lazy as _
|
|
7
|
+
from django.utils.translation import gettext, gettext_lazy as _
|
|
8
8
|
from importlib import import_module
|
|
9
9
|
from django.urls import URLPattern
|
|
10
10
|
from django.conf import settings
|
|
11
11
|
from django.contrib.contenttypes.models import ContentType
|
|
12
|
+
from django.core.validators import MaxLengthValidator, MaxValueValidator, MinValueValidator
|
|
13
|
+
|
|
14
|
+
from core import github_issues
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
class ApplicationManager(models.Manager):
|
|
@@ -279,6 +282,138 @@ class Favorite(Entity):
|
|
|
279
282
|
unique_together = ("user", "content_type")
|
|
280
283
|
|
|
281
284
|
|
|
285
|
+
class UserStory(Entity):
|
|
286
|
+
path = models.CharField(max_length=500)
|
|
287
|
+
name = models.CharField(max_length=40, blank=True)
|
|
288
|
+
rating = models.PositiveSmallIntegerField(
|
|
289
|
+
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
|
290
|
+
help_text=_("Rate your experience from 1 (lowest) to 5 (highest)."),
|
|
291
|
+
)
|
|
292
|
+
comments = models.TextField(
|
|
293
|
+
validators=[MaxLengthValidator(400)],
|
|
294
|
+
help_text=_("Share more about your experience."),
|
|
295
|
+
)
|
|
296
|
+
take_screenshot = models.BooleanField(
|
|
297
|
+
default=True,
|
|
298
|
+
help_text=_("Request a screenshot capture for this feedback."),
|
|
299
|
+
)
|
|
300
|
+
user = models.ForeignKey(
|
|
301
|
+
settings.AUTH_USER_MODEL,
|
|
302
|
+
on_delete=models.SET_NULL,
|
|
303
|
+
blank=True,
|
|
304
|
+
null=True,
|
|
305
|
+
related_name="user_stories",
|
|
306
|
+
)
|
|
307
|
+
owner = models.ForeignKey(
|
|
308
|
+
settings.AUTH_USER_MODEL,
|
|
309
|
+
on_delete=models.SET_NULL,
|
|
310
|
+
blank=True,
|
|
311
|
+
null=True,
|
|
312
|
+
related_name="owned_user_stories",
|
|
313
|
+
help_text=_("Internal owner for this feedback."),
|
|
314
|
+
)
|
|
315
|
+
submitted_at = models.DateTimeField(auto_now_add=True)
|
|
316
|
+
github_issue_number = models.PositiveIntegerField(
|
|
317
|
+
blank=True,
|
|
318
|
+
null=True,
|
|
319
|
+
help_text=_("Number of the GitHub issue created for this feedback."),
|
|
320
|
+
)
|
|
321
|
+
github_issue_url = models.URLField(
|
|
322
|
+
blank=True,
|
|
323
|
+
help_text=_("Link to the GitHub issue created for this feedback."),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
class Meta:
|
|
327
|
+
ordering = ["-submitted_at"]
|
|
328
|
+
verbose_name = _("User Story")
|
|
329
|
+
verbose_name_plural = _("User Stories")
|
|
330
|
+
|
|
331
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
332
|
+
display = self.name or _("Anonymous")
|
|
333
|
+
return f"{display} ({self.rating}/5)"
|
|
334
|
+
|
|
335
|
+
def get_github_issue_labels(self) -> list[str]:
|
|
336
|
+
"""Return default labels used when creating GitHub issues."""
|
|
337
|
+
|
|
338
|
+
return ["feedback"]
|
|
339
|
+
|
|
340
|
+
def get_github_issue_fingerprint(self) -> str | None:
|
|
341
|
+
"""Return a fingerprint used to avoid duplicate issue submissions."""
|
|
342
|
+
|
|
343
|
+
if self.pk:
|
|
344
|
+
return f"user-story:{self.pk}"
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
def build_github_issue_title(self) -> str:
|
|
348
|
+
"""Return the title used for GitHub issues."""
|
|
349
|
+
|
|
350
|
+
path = self.path or "/"
|
|
351
|
+
return gettext("Feedback for %(path)s (%(rating)s/5)") % {
|
|
352
|
+
"path": path,
|
|
353
|
+
"rating": self.rating,
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
def build_github_issue_body(self) -> str:
|
|
357
|
+
"""Return the issue body summarising the feedback details."""
|
|
358
|
+
|
|
359
|
+
name = self.name or gettext("Anonymous")
|
|
360
|
+
path = self.path or "/"
|
|
361
|
+
screenshot_requested = gettext("Yes") if self.take_screenshot else gettext("No")
|
|
362
|
+
|
|
363
|
+
lines = [
|
|
364
|
+
f"**Path:** {path}",
|
|
365
|
+
f"**Rating:** {self.rating}/5",
|
|
366
|
+
f"**Name:** {name}",
|
|
367
|
+
f"**Screenshot requested:** {screenshot_requested}",
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
if self.submitted_at:
|
|
371
|
+
lines.append(f"**Submitted at:** {self.submitted_at.isoformat()}")
|
|
372
|
+
|
|
373
|
+
comment = (self.comments or "").strip()
|
|
374
|
+
if comment:
|
|
375
|
+
lines.extend(["", comment])
|
|
376
|
+
|
|
377
|
+
return "\n".join(lines).strip()
|
|
378
|
+
|
|
379
|
+
def create_github_issue(self) -> str | None:
|
|
380
|
+
"""Create a GitHub issue for this feedback and store the identifiers."""
|
|
381
|
+
|
|
382
|
+
if self.github_issue_url:
|
|
383
|
+
return self.github_issue_url
|
|
384
|
+
|
|
385
|
+
response = github_issues.create_issue(
|
|
386
|
+
self.build_github_issue_title(),
|
|
387
|
+
self.build_github_issue_body(),
|
|
388
|
+
labels=self.get_github_issue_labels(),
|
|
389
|
+
fingerprint=self.get_github_issue_fingerprint(),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if response is None:
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
payload = response.json()
|
|
397
|
+
except ValueError: # pragma: no cover - defensive guard
|
|
398
|
+
payload = {}
|
|
399
|
+
|
|
400
|
+
issue_url = payload.get("html_url")
|
|
401
|
+
issue_number = payload.get("number")
|
|
402
|
+
|
|
403
|
+
update_fields = []
|
|
404
|
+
if issue_url and issue_url != self.github_issue_url:
|
|
405
|
+
self.github_issue_url = issue_url
|
|
406
|
+
update_fields.append("github_issue_url")
|
|
407
|
+
if issue_number is not None and issue_number != self.github_issue_number:
|
|
408
|
+
self.github_issue_number = issue_number
|
|
409
|
+
update_fields.append("github_issue_number")
|
|
410
|
+
|
|
411
|
+
if update_fields:
|
|
412
|
+
self.save(update_fields=update_fields)
|
|
413
|
+
|
|
414
|
+
return issue_url
|
|
415
|
+
|
|
416
|
+
|
|
282
417
|
from django.db.models.signals import post_save
|
|
283
418
|
from django.dispatch import receiver
|
|
284
419
|
|
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")
|
|
@@ -1339,6 +1410,29 @@ class FaviconTests(TestCase):
|
|
|
1339
1410
|
)
|
|
1340
1411
|
self.assertContains(resp, b64)
|
|
1341
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
|
+
|
|
1342
1436
|
|
|
1343
1437
|
class FavoriteTests(TestCase):
|
|
1344
1438
|
def setUp(self):
|
|
@@ -1727,6 +1821,170 @@ class DatasetteTests(TestCase):
|
|
|
1727
1821
|
lock_file.unlink(missing_ok=True)
|
|
1728
1822
|
|
|
1729
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
|
+
|
|
1730
1988
|
class ClientReportLiveUpdateTests(TestCase):
|
|
1731
1989
|
def setUp(self):
|
|
1732
1990
|
self.client = Client()
|
pages/urls.py
CHANGED
|
@@ -21,4 +21,5 @@ urlpatterns = [
|
|
|
21
21
|
path("man/", views.manual_list, name="manual-list"),
|
|
22
22
|
path("man/<slug:slug>/", views.manual_detail, name="manual-detail"),
|
|
23
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"),
|
|
24
25
|
]
|
pages/views.py
CHANGED
|
@@ -8,6 +8,7 @@ import io
|
|
|
8
8
|
import shutil
|
|
9
9
|
import re
|
|
10
10
|
from html import escape
|
|
11
|
+
from urllib.parse import urlparse
|
|
11
12
|
|
|
12
13
|
from django.conf import settings
|
|
13
14
|
from django.contrib import admin
|
|
@@ -19,7 +20,7 @@ from django.contrib.auth.views import LoginView
|
|
|
19
20
|
from django import forms
|
|
20
21
|
from django.apps import apps as django_apps
|
|
21
22
|
from utils.sites import get_site
|
|
22
|
-
from django.http import Http404, HttpResponse
|
|
23
|
+
from django.http import Http404, HttpResponse, JsonResponse
|
|
23
24
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
24
25
|
from nodes.models import Node
|
|
25
26
|
from django.template.response import TemplateResponse
|
|
@@ -32,6 +33,7 @@ from core import mailer, public_wifi
|
|
|
32
33
|
from core.backends import TOTP_DEVICE_NAME
|
|
33
34
|
from django.utils.translation import gettext as _
|
|
34
35
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
36
|
+
from django.views.decorators.http import require_POST
|
|
35
37
|
from django.core.cache import cache
|
|
36
38
|
from django.views.decorators.cache import never_cache
|
|
37
39
|
from django.utils.cache import patch_vary_headers
|
|
@@ -78,8 +80,12 @@ from core.liveupdate import live_update
|
|
|
78
80
|
from django_otp import login as otp_login
|
|
79
81
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
|
80
82
|
import qrcode
|
|
81
|
-
from .forms import
|
|
82
|
-
|
|
83
|
+
from .forms import (
|
|
84
|
+
AuthenticatorEnrollmentForm,
|
|
85
|
+
AuthenticatorLoginForm,
|
|
86
|
+
UserStoryForm,
|
|
87
|
+
)
|
|
88
|
+
from .models import Module, UserManual, UserStory
|
|
83
89
|
|
|
84
90
|
|
|
85
91
|
logger = logging.getLogger(__name__)
|
|
@@ -494,12 +500,26 @@ class CustomLoginView(LoginView):
|
|
|
494
500
|
def get_context_data(self, **kwargs):
|
|
495
501
|
context = super(LoginView, self).get_context_data(**kwargs)
|
|
496
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
|
+
)
|
|
497
516
|
context.update(
|
|
498
517
|
{
|
|
499
518
|
"site": current_site,
|
|
500
519
|
"site_name": getattr(current_site, "name", ""),
|
|
501
520
|
"next": self.get_success_url(),
|
|
502
521
|
"can_request_invite": mailer.can_send_email(),
|
|
522
|
+
"restricted_notice": restricted_notice,
|
|
503
523
|
}
|
|
504
524
|
)
|
|
505
525
|
return context
|
|
@@ -1015,6 +1035,35 @@ def client_report(request):
|
|
|
1015
1035
|
return render(request, "pages/client_report.html", context)
|
|
1016
1036
|
|
|
1017
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
|
+
|
|
1018
1067
|
def csrf_failure(request, reason=""):
|
|
1019
1068
|
"""Custom CSRF failure view with a friendly message."""
|
|
1020
1069
|
logger.warning("CSRF failure on %s: %s", request.path, reason)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|