arthexis 0.1.21__py3-none-any.whl → 0.1.23__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.21.dist-info → arthexis-0.1.23.dist-info}/METADATA +9 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/RECORD +33 -33
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +224 -32
- core/environment.py +2 -239
- core/models.py +903 -65
- core/release.py +0 -5
- core/system.py +76 -0
- core/tests.py +181 -9
- core/user_data.py +42 -2
- core/views.py +68 -27
- nodes/admin.py +211 -60
- nodes/apps.py +11 -0
- nodes/models.py +35 -7
- nodes/tests.py +288 -1
- nodes/views.py +101 -48
- ocpp/admin.py +32 -2
- ocpp/consumers.py +1 -0
- ocpp/models.py +52 -3
- ocpp/tasks.py +99 -1
- ocpp/tests.py +350 -2
- ocpp/views.py +300 -6
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +386 -28
- pages/urls.py +10 -0
- pages/views.py +347 -18
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/top_level.txt +0 -0
pages/views.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import logging
|
|
3
|
+
import mimetypes
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from types import SimpleNamespace
|
|
5
6
|
import datetime
|
|
@@ -15,15 +16,25 @@ from django.contrib import admin
|
|
|
15
16
|
from django.contrib import messages
|
|
16
17
|
from django.contrib.admin.views.decorators import staff_member_required
|
|
17
18
|
from django.contrib.auth import get_user_model, login
|
|
19
|
+
from django.contrib.auth.decorators import login_required
|
|
18
20
|
from django.contrib.auth.tokens import default_token_generator
|
|
19
21
|
from django.contrib.auth.views import LoginView
|
|
20
22
|
from django import forms
|
|
21
23
|
from django.apps import apps as django_apps
|
|
22
24
|
from utils.decorators import security_group_required
|
|
23
25
|
from utils.sites import get_site
|
|
24
|
-
from django.
|
|
26
|
+
from django.contrib.staticfiles import finders
|
|
27
|
+
from django.http import (
|
|
28
|
+
FileResponse,
|
|
29
|
+
Http404,
|
|
30
|
+
HttpResponse,
|
|
31
|
+
HttpResponseForbidden,
|
|
32
|
+
HttpResponseRedirect,
|
|
33
|
+
JsonResponse,
|
|
34
|
+
)
|
|
25
35
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
26
36
|
from nodes.models import Node
|
|
37
|
+
from nodes.utils import capture_screenshot, save_screenshot
|
|
27
38
|
from django.template import loader
|
|
28
39
|
from django.template.response import TemplateResponse
|
|
29
40
|
from django.test import RequestFactory, signals as test_signals
|
|
@@ -33,14 +44,19 @@ from django.utils.encoding import force_bytes, force_str
|
|
|
33
44
|
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
|
34
45
|
from core import mailer, public_wifi
|
|
35
46
|
from core.backends import TOTP_DEVICE_NAME
|
|
36
|
-
from django.utils.translation import gettext as _
|
|
47
|
+
from django.utils.translation import get_language, gettext as _
|
|
48
|
+
|
|
49
|
+
try: # pragma: no cover - compatibility shim for Django versions without constant
|
|
50
|
+
from django.utils.translation import LANGUAGE_SESSION_KEY
|
|
51
|
+
except ImportError: # pragma: no cover - fallback when constant is unavailable
|
|
52
|
+
LANGUAGE_SESSION_KEY = "_language"
|
|
37
53
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
38
54
|
from django.views.decorators.http import require_POST
|
|
39
55
|
from django.core.cache import cache
|
|
40
56
|
from django.views.decorators.cache import never_cache
|
|
41
|
-
from django.utils.cache import patch_vary_headers
|
|
42
|
-
from django.core.exceptions import PermissionDenied
|
|
43
|
-
from django.utils.text import slugify
|
|
57
|
+
from django.utils.cache import patch_cache_control, patch_vary_headers
|
|
58
|
+
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
|
|
59
|
+
from django.utils.text import slugify, Truncator
|
|
44
60
|
from django.core.validators import EmailValidator
|
|
45
61
|
from django.db.models import Q
|
|
46
62
|
from core.models import (
|
|
@@ -48,6 +64,7 @@ from core.models import (
|
|
|
48
64
|
ClientReport,
|
|
49
65
|
ClientReportSchedule,
|
|
50
66
|
SecurityGroup,
|
|
67
|
+
Todo,
|
|
51
68
|
)
|
|
52
69
|
|
|
53
70
|
try: # pragma: no cover - optional dependency guard
|
|
@@ -58,16 +75,34 @@ except ImportError: # pragma: no cover - handled gracefully in views
|
|
|
58
75
|
CalledProcessError = ExecutableNotFound = None
|
|
59
76
|
|
|
60
77
|
import markdown
|
|
78
|
+
from django.utils._os import safe_join
|
|
61
79
|
|
|
62
80
|
|
|
63
81
|
MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
|
|
64
82
|
|
|
83
|
+
MARKDOWN_IMAGE_PATTERN = re.compile(
|
|
84
|
+
r"(?P<prefix><img\b[^>]*\bsrc=[\"\'])(?P<scheme>(?:static|work))://(?P<path>[^\"\']+)(?P<suffix>[\"\'])",
|
|
85
|
+
re.IGNORECASE,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
ALLOWED_IMAGE_EXTENSIONS = {
|
|
89
|
+
".apng",
|
|
90
|
+
".avif",
|
|
91
|
+
".gif",
|
|
92
|
+
".jpg",
|
|
93
|
+
".jpeg",
|
|
94
|
+
".png",
|
|
95
|
+
".svg",
|
|
96
|
+
".webp",
|
|
97
|
+
}
|
|
98
|
+
|
|
65
99
|
|
|
66
100
|
def _render_markdown_with_toc(text: str) -> tuple[str, str]:
|
|
67
101
|
"""Render ``text`` to HTML and return the HTML and stripped TOC."""
|
|
68
102
|
|
|
69
103
|
md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
|
|
70
104
|
html = md.convert(text)
|
|
105
|
+
html = _rewrite_markdown_asset_links(html)
|
|
71
106
|
toc_html = md.toc
|
|
72
107
|
toc_html = _strip_toc_wrapper(toc_html)
|
|
73
108
|
return html, toc_html
|
|
@@ -82,6 +117,86 @@ def _strip_toc_wrapper(toc_html: str) -> str:
|
|
|
82
117
|
if toc_html.endswith("</div>"):
|
|
83
118
|
toc_html = toc_html[: -len("</div>")]
|
|
84
119
|
return toc_html.strip()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _rewrite_markdown_asset_links(html: str) -> str:
|
|
123
|
+
"""Rewrite asset links that reference local asset schemes."""
|
|
124
|
+
|
|
125
|
+
def _replace(match: re.Match[str]) -> str:
|
|
126
|
+
scheme = match.group("scheme").lower()
|
|
127
|
+
asset_path = match.group("path").lstrip("/")
|
|
128
|
+
if not asset_path:
|
|
129
|
+
return match.group(0)
|
|
130
|
+
extension = Path(asset_path).suffix.lower()
|
|
131
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
132
|
+
return match.group(0)
|
|
133
|
+
try:
|
|
134
|
+
asset_url = reverse(
|
|
135
|
+
"pages:readme-asset",
|
|
136
|
+
kwargs={"source": scheme, "asset": asset_path},
|
|
137
|
+
)
|
|
138
|
+
except NoReverseMatch:
|
|
139
|
+
return match.group(0)
|
|
140
|
+
return f"{match.group('prefix')}{escape(asset_url)}{match.group('suffix')}"
|
|
141
|
+
|
|
142
|
+
return MARKDOWN_IMAGE_PATTERN.sub(_replace, html)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _resolve_static_asset(path: str) -> Path:
|
|
146
|
+
normalized = path.lstrip("/")
|
|
147
|
+
if not normalized:
|
|
148
|
+
raise Http404("Asset not found")
|
|
149
|
+
resolved = finders.find(normalized)
|
|
150
|
+
if not resolved:
|
|
151
|
+
raise Http404("Asset not found")
|
|
152
|
+
if isinstance(resolved, (list, tuple)):
|
|
153
|
+
resolved = resolved[0]
|
|
154
|
+
file_path = Path(resolved)
|
|
155
|
+
if file_path.is_dir():
|
|
156
|
+
raise Http404("Asset not found")
|
|
157
|
+
return file_path
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve_work_asset(user, path: str) -> Path:
|
|
161
|
+
if not (user and getattr(user, "is_authenticated", False)):
|
|
162
|
+
raise PermissionDenied
|
|
163
|
+
normalized = path.lstrip("/")
|
|
164
|
+
if not normalized:
|
|
165
|
+
raise Http404("Asset not found")
|
|
166
|
+
username = getattr(user, "get_username", None)
|
|
167
|
+
if callable(username):
|
|
168
|
+
username = username()
|
|
169
|
+
else:
|
|
170
|
+
username = getattr(user, "username", "")
|
|
171
|
+
username_component = Path(str(username or user.pk)).name
|
|
172
|
+
base_work = Path(settings.BASE_DIR) / "work"
|
|
173
|
+
try:
|
|
174
|
+
user_dir = Path(safe_join(str(base_work), username_component))
|
|
175
|
+
asset_path = Path(safe_join(str(user_dir), normalized))
|
|
176
|
+
except SuspiciousFileOperation as exc:
|
|
177
|
+
logger.warning("Rejected suspicious work asset path: %s", normalized, exc_info=exc)
|
|
178
|
+
raise Http404("Asset not found") from exc
|
|
179
|
+
try:
|
|
180
|
+
user_dir_resolved = user_dir.resolve(strict=True)
|
|
181
|
+
except FileNotFoundError as exc:
|
|
182
|
+
logger.warning(
|
|
183
|
+
"Work directory missing for asset request: %s", user_dir, exc_info=exc
|
|
184
|
+
)
|
|
185
|
+
raise Http404("Asset not found") from exc
|
|
186
|
+
try:
|
|
187
|
+
asset_resolved = asset_path.resolve(strict=True)
|
|
188
|
+
except FileNotFoundError as exc:
|
|
189
|
+
raise Http404("Asset not found") from exc
|
|
190
|
+
try:
|
|
191
|
+
asset_resolved.relative_to(user_dir_resolved)
|
|
192
|
+
except ValueError as exc:
|
|
193
|
+
logger.warning(
|
|
194
|
+
"Rejected work asset outside directory: %s", asset_resolved, exc_info=exc
|
|
195
|
+
)
|
|
196
|
+
raise Http404("Asset not found") from exc
|
|
197
|
+
if asset_resolved.is_dir():
|
|
198
|
+
raise Http404("Asset not found")
|
|
199
|
+
return asset_resolved
|
|
85
200
|
from pages.utils import landing
|
|
86
201
|
from core.liveupdate import live_update
|
|
87
202
|
from django_otp import login as otp_login
|
|
@@ -487,19 +602,33 @@ def _locate_readme_document(role, doc: str | None, lang: str) -> SimpleNamespace
|
|
|
487
602
|
continue
|
|
488
603
|
candidates.append(candidate)
|
|
489
604
|
else:
|
|
605
|
+
default_readme = readme_base / "README.md"
|
|
606
|
+
root_default: Path | None = None
|
|
490
607
|
if lang:
|
|
491
608
|
candidates.append(readme_base / f"README.{lang}.md")
|
|
492
609
|
short = lang.split("-")[0]
|
|
493
610
|
if short != lang:
|
|
494
611
|
candidates.append(readme_base / f"README.{short}.md")
|
|
495
|
-
candidates.append(readme_base / "README.md")
|
|
496
612
|
if readme_base != root_base:
|
|
613
|
+
candidates.append(default_readme)
|
|
497
614
|
if lang:
|
|
498
615
|
candidates.append(root_base / f"README.{lang}.md")
|
|
499
616
|
short = lang.split("-")[0]
|
|
500
617
|
if short != lang:
|
|
501
618
|
candidates.append(root_base / f"README.{short}.md")
|
|
502
|
-
|
|
619
|
+
root_default = root_base / "README.md"
|
|
620
|
+
else:
|
|
621
|
+
root_default = default_readme
|
|
622
|
+
locale_base = root_base / "locale"
|
|
623
|
+
if locale_base.exists():
|
|
624
|
+
if lang:
|
|
625
|
+
candidates.append(locale_base / f"README.{lang}.md")
|
|
626
|
+
short = lang.split("-")[0]
|
|
627
|
+
if short != lang:
|
|
628
|
+
candidates.append(locale_base / f"README.{short}.md")
|
|
629
|
+
candidates.append(locale_base / "README.md")
|
|
630
|
+
if root_default is not None:
|
|
631
|
+
candidates.append(root_default)
|
|
503
632
|
|
|
504
633
|
readme_file = next((p for p in candidates if p.exists()), None)
|
|
505
634
|
if readme_file is None:
|
|
@@ -552,6 +681,44 @@ def _render_readme(request, role, doc: str | None = None):
|
|
|
552
681
|
return response
|
|
553
682
|
|
|
554
683
|
|
|
684
|
+
def readme_asset(request, source: str, asset: str):
|
|
685
|
+
source_normalized = (source or "").lower()
|
|
686
|
+
if source_normalized == "static":
|
|
687
|
+
file_path = _resolve_static_asset(asset)
|
|
688
|
+
elif source_normalized == "work":
|
|
689
|
+
file_path = _resolve_work_asset(getattr(request, "user", None), asset)
|
|
690
|
+
else:
|
|
691
|
+
raise Http404("Asset not found")
|
|
692
|
+
|
|
693
|
+
if not file_path.exists() or not file_path.is_file():
|
|
694
|
+
raise Http404("Asset not found")
|
|
695
|
+
|
|
696
|
+
extension = file_path.suffix.lower()
|
|
697
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
698
|
+
raise Http404("Asset not found")
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
file_handle = file_path.open("rb")
|
|
702
|
+
except OSError as exc: # pragma: no cover - unexpected filesystem error
|
|
703
|
+
logger.warning("Unable to open asset %s", file_path, exc_info=exc)
|
|
704
|
+
raise Http404("Asset not found") from exc
|
|
705
|
+
|
|
706
|
+
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
707
|
+
response = FileResponse(file_handle, content_type=content_type)
|
|
708
|
+
try:
|
|
709
|
+
response["Content-Length"] = str(file_path.stat().st_size)
|
|
710
|
+
except OSError: # pragma: no cover - filesystem race
|
|
711
|
+
pass
|
|
712
|
+
|
|
713
|
+
if source_normalized == "work":
|
|
714
|
+
patch_cache_control(response, private=True, no_store=True)
|
|
715
|
+
patch_vary_headers(response, ["Cookie"])
|
|
716
|
+
else:
|
|
717
|
+
patch_cache_control(response, public=True, max_age=3600)
|
|
718
|
+
|
|
719
|
+
return response
|
|
720
|
+
|
|
721
|
+
|
|
555
722
|
class MarkdownDocumentForm(forms.Form):
|
|
556
723
|
content = forms.CharField(
|
|
557
724
|
widget=forms.Textarea(
|
|
@@ -1121,10 +1288,10 @@ class ClientReportForm(forms.Form):
|
|
|
1121
1288
|
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
1122
1289
|
help_text=_("Defines how often the report should be generated automatically."),
|
|
1123
1290
|
)
|
|
1124
|
-
|
|
1125
|
-
label=_("
|
|
1291
|
+
enable_emails = forms.BooleanField(
|
|
1292
|
+
label=_("Enable email delivery"),
|
|
1126
1293
|
required=False,
|
|
1127
|
-
help_text=_("
|
|
1294
|
+
help_text=_("Send the report via email to the recipients listed above."),
|
|
1128
1295
|
)
|
|
1129
1296
|
|
|
1130
1297
|
def __init__(self, *args, request=None, **kwargs):
|
|
@@ -1227,12 +1394,17 @@ def client_report(request):
|
|
|
1227
1394
|
owner = form.cleaned_data.get("owner")
|
|
1228
1395
|
if not owner and request.user.is_authenticated:
|
|
1229
1396
|
owner = request.user
|
|
1397
|
+
enable_emails = form.cleaned_data.get("enable_emails", False)
|
|
1398
|
+
disable_emails = not enable_emails
|
|
1399
|
+
recipients = (
|
|
1400
|
+
form.cleaned_data.get("destinations") if enable_emails else []
|
|
1401
|
+
)
|
|
1230
1402
|
report = ClientReport.generate(
|
|
1231
1403
|
form.cleaned_data["start"],
|
|
1232
1404
|
form.cleaned_data["end"],
|
|
1233
1405
|
owner=owner,
|
|
1234
|
-
recipients=
|
|
1235
|
-
disable_emails=
|
|
1406
|
+
recipients=recipients,
|
|
1407
|
+
disable_emails=disable_emails,
|
|
1236
1408
|
)
|
|
1237
1409
|
report.store_local_copy()
|
|
1238
1410
|
recurrence = form.cleaned_data.get("recurrence")
|
|
@@ -1241,8 +1413,8 @@ def client_report(request):
|
|
|
1241
1413
|
owner=owner,
|
|
1242
1414
|
created_by=request.user if request.user.is_authenticated else None,
|
|
1243
1415
|
periodicity=recurrence,
|
|
1244
|
-
email_recipients=
|
|
1245
|
-
disable_emails=
|
|
1416
|
+
email_recipients=recipients,
|
|
1417
|
+
disable_emails=disable_emails,
|
|
1246
1418
|
)
|
|
1247
1419
|
report.schedule = schedule
|
|
1248
1420
|
report.save(update_fields=["schedule"])
|
|
@@ -1252,6 +1424,27 @@ def client_report(request):
|
|
|
1252
1424
|
"Client report schedule created; future reports will be generated automatically."
|
|
1253
1425
|
),
|
|
1254
1426
|
)
|
|
1427
|
+
if disable_emails:
|
|
1428
|
+
messages.success(
|
|
1429
|
+
request,
|
|
1430
|
+
_(
|
|
1431
|
+
"Consumer report generated. The download will begin automatically."
|
|
1432
|
+
),
|
|
1433
|
+
)
|
|
1434
|
+
redirect_url = f"{reverse('pages:client-report')}?download={report.pk}"
|
|
1435
|
+
return HttpResponseRedirect(redirect_url)
|
|
1436
|
+
download_url = None
|
|
1437
|
+
download_param = request.GET.get("download")
|
|
1438
|
+
if download_param and request.user.is_authenticated:
|
|
1439
|
+
try:
|
|
1440
|
+
download_id = int(download_param)
|
|
1441
|
+
except (TypeError, ValueError):
|
|
1442
|
+
download_id = None
|
|
1443
|
+
if download_id:
|
|
1444
|
+
download_url = reverse(
|
|
1445
|
+
"pages:client-report-download", args=[download_id]
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1255
1448
|
try:
|
|
1256
1449
|
login_url = reverse("pages:login")
|
|
1257
1450
|
except NoReverseMatch:
|
|
@@ -1265,10 +1458,71 @@ def client_report(request):
|
|
|
1265
1458
|
"report": report,
|
|
1266
1459
|
"schedule": schedule,
|
|
1267
1460
|
"login_url": login_url,
|
|
1461
|
+
"download_url": download_url,
|
|
1462
|
+
"previous_reports": _client_report_history(request),
|
|
1268
1463
|
}
|
|
1269
1464
|
return render(request, "pages/client_report.html", context)
|
|
1270
1465
|
|
|
1271
1466
|
|
|
1467
|
+
@login_required
|
|
1468
|
+
def client_report_download(request, report_id: int):
|
|
1469
|
+
report = get_object_or_404(ClientReport, pk=report_id)
|
|
1470
|
+
if not request.user.is_staff and report.owner_id != request.user.pk:
|
|
1471
|
+
return HttpResponseForbidden(
|
|
1472
|
+
_("You do not have permission to download this report.")
|
|
1473
|
+
)
|
|
1474
|
+
pdf_path = report.ensure_pdf()
|
|
1475
|
+
if not pdf_path.exists():
|
|
1476
|
+
raise Http404(_("Report file unavailable."))
|
|
1477
|
+
filename = f"consumer-report-{report.start_date}-{report.end_date}.pdf"
|
|
1478
|
+
response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
|
|
1479
|
+
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
1480
|
+
return response
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _client_report_history(request, limit: int = 20):
|
|
1484
|
+
if not request.user.is_authenticated:
|
|
1485
|
+
return []
|
|
1486
|
+
qs = ClientReport.objects.order_by("-created_on")
|
|
1487
|
+
if not request.user.is_staff:
|
|
1488
|
+
qs = qs.filter(owner=request.user)
|
|
1489
|
+
history = []
|
|
1490
|
+
for report in qs[:limit]:
|
|
1491
|
+
totals = report.rows_for_display.get("totals", {})
|
|
1492
|
+
history.append(
|
|
1493
|
+
{
|
|
1494
|
+
"instance": report,
|
|
1495
|
+
"download_url": reverse("pages:client-report-download", args=[report.pk]),
|
|
1496
|
+
"email_enabled": not report.disable_emails,
|
|
1497
|
+
"recipients": report.recipients or [],
|
|
1498
|
+
"totals": {
|
|
1499
|
+
"total_kw": totals.get("total_kw", 0.0),
|
|
1500
|
+
"total_kw_period": totals.get("total_kw_period", 0.0),
|
|
1501
|
+
},
|
|
1502
|
+
}
|
|
1503
|
+
)
|
|
1504
|
+
return history
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def _get_request_language_code(request) -> str:
|
|
1508
|
+
language_code = ""
|
|
1509
|
+
if hasattr(request, "session"):
|
|
1510
|
+
language_code = request.session.get(LANGUAGE_SESSION_KEY, "")
|
|
1511
|
+
if not language_code:
|
|
1512
|
+
cookie_name = getattr(settings, "LANGUAGE_COOKIE_NAME", "django_language")
|
|
1513
|
+
language_code = request.COOKIES.get(cookie_name, "")
|
|
1514
|
+
if not language_code:
|
|
1515
|
+
language_code = getattr(request, "LANGUAGE_CODE", "") or ""
|
|
1516
|
+
if not language_code:
|
|
1517
|
+
language_code = get_language() or ""
|
|
1518
|
+
|
|
1519
|
+
language_code = language_code.strip()
|
|
1520
|
+
if not language_code:
|
|
1521
|
+
return ""
|
|
1522
|
+
|
|
1523
|
+
return language_code.replace("_", "-").lower()[:15]
|
|
1524
|
+
|
|
1525
|
+
|
|
1272
1526
|
@require_POST
|
|
1273
1527
|
def submit_user_story(request):
|
|
1274
1528
|
throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
|
|
@@ -1290,12 +1544,12 @@ def submit_user_story(request):
|
|
|
1290
1544
|
)
|
|
1291
1545
|
|
|
1292
1546
|
data = request.POST.copy()
|
|
1293
|
-
if request.user.is_authenticated
|
|
1547
|
+
if request.user.is_authenticated:
|
|
1294
1548
|
data["name"] = request.user.get_username()[:40]
|
|
1295
1549
|
if not data.get("path"):
|
|
1296
1550
|
data["path"] = request.get_full_path()
|
|
1297
1551
|
|
|
1298
|
-
form = UserStoryForm(data)
|
|
1552
|
+
form = UserStoryForm(data, user=request.user)
|
|
1299
1553
|
if request.user.is_authenticated:
|
|
1300
1554
|
form.instance.user = request.user
|
|
1301
1555
|
|
|
@@ -1304,8 +1558,7 @@ def submit_user_story(request):
|
|
|
1304
1558
|
if request.user.is_authenticated:
|
|
1305
1559
|
story.user = request.user
|
|
1306
1560
|
story.owner = request.user
|
|
1307
|
-
|
|
1308
|
-
story.name = request.user.get_username()[:40]
|
|
1561
|
+
story.name = request.user.get_username()[:40]
|
|
1309
1562
|
if not story.name:
|
|
1310
1563
|
story.name = str(_("Anonymous"))[:40]
|
|
1311
1564
|
story.path = (story.path or request.get_full_path())[:500]
|
|
@@ -1313,7 +1566,83 @@ def submit_user_story(request):
|
|
|
1313
1566
|
story.user_agent = request.META.get("HTTP_USER_AGENT", "")
|
|
1314
1567
|
story.ip_address = client_ip or None
|
|
1315
1568
|
story.is_user_data = True
|
|
1569
|
+
language_code = _get_request_language_code(request)
|
|
1570
|
+
if language_code:
|
|
1571
|
+
story.language_code = language_code
|
|
1316
1572
|
story.save()
|
|
1573
|
+
if request.user.is_authenticated and request.user.is_superuser:
|
|
1574
|
+
comment_text = (story.comments or "").strip()
|
|
1575
|
+
prefix = "Triage "
|
|
1576
|
+
request_field = Todo._meta.get_field("request")
|
|
1577
|
+
available_length = max(request_field.max_length - len(prefix), 0)
|
|
1578
|
+
if available_length > 0 and comment_text:
|
|
1579
|
+
summary = Truncator(comment_text).chars(
|
|
1580
|
+
available_length, truncate="…"
|
|
1581
|
+
)
|
|
1582
|
+
else:
|
|
1583
|
+
summary = comment_text[:available_length]
|
|
1584
|
+
todo_request = f"{prefix}{summary}".strip()
|
|
1585
|
+
user_is_authenticated = request.user.is_authenticated
|
|
1586
|
+
node = Node.get_local()
|
|
1587
|
+
existing_todo = (
|
|
1588
|
+
Todo.objects.filter(request__iexact=todo_request, is_deleted=False)
|
|
1589
|
+
.order_by("pk")
|
|
1590
|
+
.first()
|
|
1591
|
+
)
|
|
1592
|
+
if existing_todo:
|
|
1593
|
+
update_fields: set[str] = set()
|
|
1594
|
+
if node and existing_todo.origin_node_id != node.pk:
|
|
1595
|
+
existing_todo.origin_node = node
|
|
1596
|
+
update_fields.add("origin_node")
|
|
1597
|
+
if existing_todo.original_user_id != request.user.pk:
|
|
1598
|
+
existing_todo.original_user = request.user
|
|
1599
|
+
update_fields.add("original_user")
|
|
1600
|
+
if (
|
|
1601
|
+
existing_todo.original_user_is_authenticated
|
|
1602
|
+
!= user_is_authenticated
|
|
1603
|
+
):
|
|
1604
|
+
existing_todo.original_user_is_authenticated = (
|
|
1605
|
+
user_is_authenticated
|
|
1606
|
+
)
|
|
1607
|
+
update_fields.add("original_user_is_authenticated")
|
|
1608
|
+
if not existing_todo.is_user_data:
|
|
1609
|
+
existing_todo.is_user_data = True
|
|
1610
|
+
update_fields.add("is_user_data")
|
|
1611
|
+
if update_fields:
|
|
1612
|
+
existing_todo.save(update_fields=tuple(update_fields))
|
|
1613
|
+
else:
|
|
1614
|
+
Todo.objects.create(
|
|
1615
|
+
request=todo_request,
|
|
1616
|
+
origin_node=node,
|
|
1617
|
+
original_user=request.user,
|
|
1618
|
+
original_user_is_authenticated=user_is_authenticated,
|
|
1619
|
+
is_user_data=True,
|
|
1620
|
+
)
|
|
1621
|
+
if story.take_screenshot:
|
|
1622
|
+
screenshot_url = request.META.get("HTTP_REFERER", "")
|
|
1623
|
+
parsed = urlparse(screenshot_url)
|
|
1624
|
+
if not (parsed.scheme and parsed.netloc):
|
|
1625
|
+
target_path = story.path or request.get_full_path() or "/"
|
|
1626
|
+
screenshot_url = request.build_absolute_uri(target_path)
|
|
1627
|
+
try:
|
|
1628
|
+
screenshot_path = capture_screenshot(screenshot_url)
|
|
1629
|
+
except Exception: # pragma: no cover - best effort capture
|
|
1630
|
+
logger.exception("Failed to capture screenshot for user story %s", story.pk)
|
|
1631
|
+
else:
|
|
1632
|
+
try:
|
|
1633
|
+
sample = save_screenshot(
|
|
1634
|
+
screenshot_path,
|
|
1635
|
+
method="USER_STORY",
|
|
1636
|
+
user=story.user if story.user_id else None,
|
|
1637
|
+
)
|
|
1638
|
+
except Exception: # pragma: no cover - best effort persistence
|
|
1639
|
+
logger.exception(
|
|
1640
|
+
"Failed to persist screenshot for user story %s", story.pk
|
|
1641
|
+
)
|
|
1642
|
+
else:
|
|
1643
|
+
if sample is not None:
|
|
1644
|
+
story.screenshot = sample
|
|
1645
|
+
story.save(update_fields=["screenshot"])
|
|
1317
1646
|
return JsonResponse({"success": True})
|
|
1318
1647
|
|
|
1319
1648
|
return JsonResponse({"success": False, "errors": form.errors}, status=400)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|