arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- 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 +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- 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.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.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,32 +16,51 @@ 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
|
|
30
41
|
from django.urls import NoReverseMatch, reverse
|
|
31
42
|
from django.utils import timezone
|
|
32
43
|
from django.utils.encoding import force_bytes, force_str
|
|
33
|
-
from django.utils.http import
|
|
44
|
+
from django.utils.http import (
|
|
45
|
+
url_has_allowed_host_and_scheme,
|
|
46
|
+
urlsafe_base64_decode,
|
|
47
|
+
urlsafe_base64_encode,
|
|
48
|
+
)
|
|
34
49
|
from core import mailer, public_wifi
|
|
35
50
|
from core.backends import TOTP_DEVICE_NAME
|
|
36
|
-
from django.utils.translation import gettext as _
|
|
51
|
+
from django.utils.translation import get_language, gettext as _
|
|
52
|
+
|
|
53
|
+
try: # pragma: no cover - compatibility shim for Django versions without constant
|
|
54
|
+
from django.utils.translation import LANGUAGE_SESSION_KEY
|
|
55
|
+
except ImportError: # pragma: no cover - fallback when constant is unavailable
|
|
56
|
+
LANGUAGE_SESSION_KEY = "_language"
|
|
37
57
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
38
58
|
from django.views.decorators.http import require_POST
|
|
39
59
|
from django.core.cache import cache
|
|
40
60
|
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
|
|
61
|
+
from django.utils.cache import patch_cache_control, patch_vary_headers
|
|
62
|
+
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
|
|
63
|
+
from django.utils.text import slugify, Truncator
|
|
44
64
|
from django.core.validators import EmailValidator
|
|
45
65
|
from django.db.models import Q
|
|
46
66
|
from core.models import (
|
|
@@ -48,7 +68,24 @@ from core.models import (
|
|
|
48
68
|
ClientReport,
|
|
49
69
|
ClientReportSchedule,
|
|
50
70
|
SecurityGroup,
|
|
71
|
+
Todo,
|
|
51
72
|
)
|
|
73
|
+
from ocpp.models import Charger
|
|
74
|
+
from .utils import get_original_referer
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _GraphvizDeprecationFilter(logging.Filter):
|
|
78
|
+
"""Filter out Graphviz debug logs about positional arg deprecations."""
|
|
79
|
+
|
|
80
|
+
_MESSAGE_PREFIX = "deprecate positional args:"
|
|
81
|
+
|
|
82
|
+
def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover - logging hook
|
|
83
|
+
try:
|
|
84
|
+
message = record.getMessage()
|
|
85
|
+
except Exception: # pragma: no cover - defensive fallback
|
|
86
|
+
return True
|
|
87
|
+
return not message.startswith(self._MESSAGE_PREFIX)
|
|
88
|
+
|
|
52
89
|
|
|
53
90
|
try: # pragma: no cover - optional dependency guard
|
|
54
91
|
from graphviz import Digraph
|
|
@@ -56,18 +93,43 @@ try: # pragma: no cover - optional dependency guard
|
|
|
56
93
|
except ImportError: # pragma: no cover - handled gracefully in views
|
|
57
94
|
Digraph = None
|
|
58
95
|
CalledProcessError = ExecutableNotFound = None
|
|
96
|
+
else:
|
|
97
|
+
graphviz_logger = logging.getLogger("graphviz._tools")
|
|
98
|
+
if not any(
|
|
99
|
+
isinstance(existing_filter, _GraphvizDeprecationFilter)
|
|
100
|
+
for existing_filter in graphviz_logger.filters
|
|
101
|
+
):
|
|
102
|
+
graphviz_logger.addFilter(_GraphvizDeprecationFilter())
|
|
59
103
|
|
|
60
104
|
import markdown
|
|
105
|
+
from django.utils._os import safe_join
|
|
61
106
|
|
|
62
107
|
|
|
63
108
|
MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
|
|
64
109
|
|
|
110
|
+
MARKDOWN_IMAGE_PATTERN = re.compile(
|
|
111
|
+
r"(?P<prefix><img\b[^>]*\bsrc=[\"\'])(?P<scheme>(?:static|work))://(?P<path>[^\"\']+)(?P<suffix>[\"\'])",
|
|
112
|
+
re.IGNORECASE,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
ALLOWED_IMAGE_EXTENSIONS = {
|
|
116
|
+
".apng",
|
|
117
|
+
".avif",
|
|
118
|
+
".gif",
|
|
119
|
+
".jpg",
|
|
120
|
+
".jpeg",
|
|
121
|
+
".png",
|
|
122
|
+
".svg",
|
|
123
|
+
".webp",
|
|
124
|
+
}
|
|
125
|
+
|
|
65
126
|
|
|
66
127
|
def _render_markdown_with_toc(text: str) -> tuple[str, str]:
|
|
67
128
|
"""Render ``text`` to HTML and return the HTML and stripped TOC."""
|
|
68
129
|
|
|
69
130
|
md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
|
|
70
131
|
html = md.convert(text)
|
|
132
|
+
html = _rewrite_markdown_asset_links(html)
|
|
71
133
|
toc_html = md.toc
|
|
72
134
|
toc_html = _strip_toc_wrapper(toc_html)
|
|
73
135
|
return html, toc_html
|
|
@@ -82,6 +144,86 @@ def _strip_toc_wrapper(toc_html: str) -> str:
|
|
|
82
144
|
if toc_html.endswith("</div>"):
|
|
83
145
|
toc_html = toc_html[: -len("</div>")]
|
|
84
146
|
return toc_html.strip()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _rewrite_markdown_asset_links(html: str) -> str:
|
|
150
|
+
"""Rewrite asset links that reference local asset schemes."""
|
|
151
|
+
|
|
152
|
+
def _replace(match: re.Match[str]) -> str:
|
|
153
|
+
scheme = match.group("scheme").lower()
|
|
154
|
+
asset_path = match.group("path").lstrip("/")
|
|
155
|
+
if not asset_path:
|
|
156
|
+
return match.group(0)
|
|
157
|
+
extension = Path(asset_path).suffix.lower()
|
|
158
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
159
|
+
return match.group(0)
|
|
160
|
+
try:
|
|
161
|
+
asset_url = reverse(
|
|
162
|
+
"pages:readme-asset",
|
|
163
|
+
kwargs={"source": scheme, "asset": asset_path},
|
|
164
|
+
)
|
|
165
|
+
except NoReverseMatch:
|
|
166
|
+
return match.group(0)
|
|
167
|
+
return f"{match.group('prefix')}{escape(asset_url)}{match.group('suffix')}"
|
|
168
|
+
|
|
169
|
+
return MARKDOWN_IMAGE_PATTERN.sub(_replace, html)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resolve_static_asset(path: str) -> Path:
|
|
173
|
+
normalized = path.lstrip("/")
|
|
174
|
+
if not normalized:
|
|
175
|
+
raise Http404("Asset not found")
|
|
176
|
+
resolved = finders.find(normalized)
|
|
177
|
+
if not resolved:
|
|
178
|
+
raise Http404("Asset not found")
|
|
179
|
+
if isinstance(resolved, (list, tuple)):
|
|
180
|
+
resolved = resolved[0]
|
|
181
|
+
file_path = Path(resolved)
|
|
182
|
+
if file_path.is_dir():
|
|
183
|
+
raise Http404("Asset not found")
|
|
184
|
+
return file_path
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _resolve_work_asset(user, path: str) -> Path:
|
|
188
|
+
if not (user and getattr(user, "is_authenticated", False)):
|
|
189
|
+
raise PermissionDenied
|
|
190
|
+
normalized = path.lstrip("/")
|
|
191
|
+
if not normalized:
|
|
192
|
+
raise Http404("Asset not found")
|
|
193
|
+
username = getattr(user, "get_username", None)
|
|
194
|
+
if callable(username):
|
|
195
|
+
username = username()
|
|
196
|
+
else:
|
|
197
|
+
username = getattr(user, "username", "")
|
|
198
|
+
username_component = Path(str(username or user.pk)).name
|
|
199
|
+
base_work = Path(settings.BASE_DIR) / "work"
|
|
200
|
+
try:
|
|
201
|
+
user_dir = Path(safe_join(str(base_work), username_component))
|
|
202
|
+
asset_path = Path(safe_join(str(user_dir), normalized))
|
|
203
|
+
except SuspiciousFileOperation as exc:
|
|
204
|
+
logger.warning("Rejected suspicious work asset path: %s", normalized, exc_info=exc)
|
|
205
|
+
raise Http404("Asset not found") from exc
|
|
206
|
+
try:
|
|
207
|
+
user_dir_resolved = user_dir.resolve(strict=True)
|
|
208
|
+
except FileNotFoundError as exc:
|
|
209
|
+
logger.warning(
|
|
210
|
+
"Work directory missing for asset request: %s", user_dir, exc_info=exc
|
|
211
|
+
)
|
|
212
|
+
raise Http404("Asset not found") from exc
|
|
213
|
+
try:
|
|
214
|
+
asset_resolved = asset_path.resolve(strict=True)
|
|
215
|
+
except FileNotFoundError as exc:
|
|
216
|
+
raise Http404("Asset not found") from exc
|
|
217
|
+
try:
|
|
218
|
+
asset_resolved.relative_to(user_dir_resolved)
|
|
219
|
+
except ValueError as exc:
|
|
220
|
+
logger.warning(
|
|
221
|
+
"Rejected work asset outside directory: %s", asset_resolved, exc_info=exc
|
|
222
|
+
)
|
|
223
|
+
raise Http404("Asset not found") from exc
|
|
224
|
+
if asset_resolved.is_dir():
|
|
225
|
+
raise Http404("Asset not found")
|
|
226
|
+
return asset_resolved
|
|
85
227
|
from pages.utils import landing
|
|
86
228
|
from core.liveupdate import live_update
|
|
87
229
|
from django_otp import login as otp_login
|
|
@@ -439,43 +581,185 @@ def admin_model_graph(request, app_label: str):
|
|
|
439
581
|
return response
|
|
440
582
|
|
|
441
583
|
|
|
442
|
-
def
|
|
584
|
+
def _locate_readme_document(role, doc: str | None, lang: str) -> SimpleNamespace:
|
|
443
585
|
app = (
|
|
444
586
|
Module.objects.filter(node_role=role, is_default=True)
|
|
445
587
|
.select_related("application")
|
|
446
588
|
.first()
|
|
447
589
|
)
|
|
448
590
|
app_slug = app.path.strip("/") if app else ""
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
591
|
+
root_base = Path(settings.BASE_DIR).resolve()
|
|
592
|
+
readme_base = (root_base / app_slug).resolve() if app_slug else root_base
|
|
593
|
+
candidates: list[Path] = []
|
|
594
|
+
|
|
595
|
+
if doc:
|
|
596
|
+
normalized = doc.strip().replace("\\", "/")
|
|
597
|
+
while normalized.startswith("./"):
|
|
598
|
+
normalized = normalized[2:]
|
|
599
|
+
normalized = normalized.lstrip("/")
|
|
600
|
+
if not normalized:
|
|
601
|
+
raise Http404("Document not found")
|
|
602
|
+
doc_path = Path(normalized)
|
|
603
|
+
if doc_path.is_absolute() or any(part == ".." for part in doc_path.parts):
|
|
604
|
+
raise Http404("Document not found")
|
|
605
|
+
|
|
606
|
+
relative_candidates: list[Path] = []
|
|
607
|
+
|
|
608
|
+
def add_candidate(path: Path) -> None:
|
|
609
|
+
if path not in relative_candidates:
|
|
610
|
+
relative_candidates.append(path)
|
|
611
|
+
|
|
612
|
+
add_candidate(doc_path)
|
|
613
|
+
if doc_path.suffix.lower() != ".md" or doc_path.suffix != ".md":
|
|
614
|
+
add_candidate(doc_path.with_suffix(".md"))
|
|
615
|
+
if doc_path.suffix.lower() != ".md":
|
|
616
|
+
add_candidate(doc_path / "README.md")
|
|
617
|
+
|
|
618
|
+
search_roots = [readme_base]
|
|
619
|
+
if readme_base != root_base:
|
|
620
|
+
search_roots.append(root_base)
|
|
621
|
+
|
|
622
|
+
for relative in relative_candidates:
|
|
623
|
+
for base in search_roots:
|
|
624
|
+
base_resolved = base.resolve()
|
|
625
|
+
candidate = (base_resolved / relative).resolve(strict=False)
|
|
626
|
+
try:
|
|
627
|
+
candidate.relative_to(base_resolved)
|
|
628
|
+
except ValueError:
|
|
629
|
+
continue
|
|
630
|
+
candidates.append(candidate)
|
|
631
|
+
else:
|
|
632
|
+
default_readme = readme_base / "README.md"
|
|
633
|
+
root_default: Path | None = None
|
|
463
634
|
if lang:
|
|
464
|
-
candidates.append(
|
|
635
|
+
candidates.append(readme_base / f"README.{lang}.md")
|
|
465
636
|
short = lang.split("-")[0]
|
|
466
637
|
if short != lang:
|
|
467
|
-
candidates.append(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
638
|
+
candidates.append(readme_base / f"README.{short}.md")
|
|
639
|
+
if readme_base != root_base:
|
|
640
|
+
candidates.append(default_readme)
|
|
641
|
+
if lang:
|
|
642
|
+
candidates.append(root_base / f"README.{lang}.md")
|
|
643
|
+
short = lang.split("-")[0]
|
|
644
|
+
if short != lang:
|
|
645
|
+
candidates.append(root_base / f"README.{short}.md")
|
|
646
|
+
root_default = root_base / "README.md"
|
|
647
|
+
else:
|
|
648
|
+
root_default = default_readme
|
|
649
|
+
locale_base = root_base / "locale"
|
|
650
|
+
if locale_base.exists():
|
|
651
|
+
if lang:
|
|
652
|
+
candidates.append(locale_base / f"README.{lang}.md")
|
|
653
|
+
short = lang.split("-")[0]
|
|
654
|
+
if short != lang:
|
|
655
|
+
candidates.append(locale_base / f"README.{short}.md")
|
|
656
|
+
candidates.append(locale_base / "README.md")
|
|
657
|
+
if root_default is not None:
|
|
658
|
+
candidates.append(root_default)
|
|
659
|
+
|
|
660
|
+
readme_file = next((p for p in candidates if p.exists()), None)
|
|
661
|
+
if readme_file is None:
|
|
662
|
+
raise Http404("Document not found")
|
|
663
|
+
|
|
472
664
|
title = "README" if readme_file.name.startswith("README") else readme_file.stem
|
|
473
|
-
|
|
665
|
+
return SimpleNamespace(
|
|
666
|
+
file=readme_file,
|
|
667
|
+
title=title,
|
|
668
|
+
root_base=root_base,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _relative_readme_path(readme_file: Path, root_base: Path) -> str | None:
|
|
673
|
+
try:
|
|
674
|
+
return readme_file.relative_to(root_base).as_posix()
|
|
675
|
+
except ValueError:
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _render_readme(request, role, doc: str | None = None):
|
|
680
|
+
lang = getattr(request, "LANGUAGE_CODE", "")
|
|
681
|
+
lang = lang.replace("_", "-").lower()
|
|
682
|
+
document = _locate_readme_document(role, doc, lang)
|
|
683
|
+
text = document.file.read_text(encoding="utf-8")
|
|
684
|
+
html, toc_html = _render_markdown_with_toc(text)
|
|
685
|
+
relative_path = _relative_readme_path(document.file, document.root_base)
|
|
686
|
+
user = getattr(request, "user", None)
|
|
687
|
+
can_edit = bool(
|
|
688
|
+
relative_path
|
|
689
|
+
and user
|
|
690
|
+
and user.is_authenticated
|
|
691
|
+
and user.is_superuser
|
|
692
|
+
)
|
|
693
|
+
edit_url = None
|
|
694
|
+
if can_edit:
|
|
695
|
+
try:
|
|
696
|
+
edit_url = reverse("pages:readme-edit", kwargs={"doc": relative_path})
|
|
697
|
+
except NoReverseMatch:
|
|
698
|
+
edit_url = None
|
|
699
|
+
context = {
|
|
700
|
+
"content": html,
|
|
701
|
+
"title": document.title,
|
|
702
|
+
"toc": toc_html,
|
|
703
|
+
"page_url": request.build_absolute_uri(),
|
|
704
|
+
"edit_url": edit_url,
|
|
705
|
+
}
|
|
474
706
|
response = render(request, "pages/readme.html", context)
|
|
475
707
|
patch_vary_headers(response, ["Accept-Language", "Cookie"])
|
|
476
708
|
return response
|
|
477
709
|
|
|
478
710
|
|
|
711
|
+
def readme_asset(request, source: str, asset: str):
|
|
712
|
+
source_normalized = (source or "").lower()
|
|
713
|
+
if source_normalized == "static":
|
|
714
|
+
file_path = _resolve_static_asset(asset)
|
|
715
|
+
elif source_normalized == "work":
|
|
716
|
+
file_path = _resolve_work_asset(getattr(request, "user", None), asset)
|
|
717
|
+
else:
|
|
718
|
+
raise Http404("Asset not found")
|
|
719
|
+
|
|
720
|
+
if not file_path.exists() or not file_path.is_file():
|
|
721
|
+
raise Http404("Asset not found")
|
|
722
|
+
|
|
723
|
+
extension = file_path.suffix.lower()
|
|
724
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
725
|
+
raise Http404("Asset not found")
|
|
726
|
+
|
|
727
|
+
try:
|
|
728
|
+
file_handle = file_path.open("rb")
|
|
729
|
+
except OSError as exc: # pragma: no cover - unexpected filesystem error
|
|
730
|
+
logger.warning("Unable to open asset %s", file_path, exc_info=exc)
|
|
731
|
+
raise Http404("Asset not found") from exc
|
|
732
|
+
|
|
733
|
+
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
734
|
+
response = FileResponse(file_handle, content_type=content_type)
|
|
735
|
+
try:
|
|
736
|
+
response["Content-Length"] = str(file_path.stat().st_size)
|
|
737
|
+
except OSError: # pragma: no cover - filesystem race
|
|
738
|
+
pass
|
|
739
|
+
|
|
740
|
+
if source_normalized == "work":
|
|
741
|
+
patch_cache_control(response, private=True, no_store=True)
|
|
742
|
+
patch_vary_headers(response, ["Cookie"])
|
|
743
|
+
else:
|
|
744
|
+
patch_cache_control(response, public=True, max_age=3600)
|
|
745
|
+
|
|
746
|
+
return response
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class MarkdownDocumentForm(forms.Form):
|
|
750
|
+
content = forms.CharField(
|
|
751
|
+
widget=forms.Textarea(
|
|
752
|
+
attrs={
|
|
753
|
+
"class": "form-control",
|
|
754
|
+
"rows": 24,
|
|
755
|
+
"spellcheck": "false",
|
|
756
|
+
}
|
|
757
|
+
),
|
|
758
|
+
required=False,
|
|
759
|
+
strip=False,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
479
763
|
@landing("Home")
|
|
480
764
|
@never_cache
|
|
481
765
|
def index(request):
|
|
@@ -525,10 +809,61 @@ def index(request):
|
|
|
525
809
|
|
|
526
810
|
|
|
527
811
|
@never_cache
|
|
528
|
-
def readme(request):
|
|
812
|
+
def readme(request, doc=None):
|
|
529
813
|
node = Node.get_local()
|
|
530
814
|
role = node.role if node else None
|
|
531
|
-
return _render_readme(request, role)
|
|
815
|
+
return _render_readme(request, role, doc)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def readme_edit(request, doc):
|
|
819
|
+
user = getattr(request, "user", None)
|
|
820
|
+
if not (user and user.is_authenticated and user.is_superuser):
|
|
821
|
+
raise PermissionDenied
|
|
822
|
+
|
|
823
|
+
node = Node.get_local()
|
|
824
|
+
role = node.role if node else None
|
|
825
|
+
lang = getattr(request, "LANGUAGE_CODE", "")
|
|
826
|
+
lang = lang.replace("_", "-").lower()
|
|
827
|
+
document = _locate_readme_document(role, doc, lang)
|
|
828
|
+
relative_path = _relative_readme_path(document.file, document.root_base)
|
|
829
|
+
if relative_path:
|
|
830
|
+
read_url = reverse("pages:readme-document", kwargs={"doc": relative_path})
|
|
831
|
+
else:
|
|
832
|
+
read_url = reverse("pages:readme")
|
|
833
|
+
|
|
834
|
+
if request.method == "POST":
|
|
835
|
+
form = MarkdownDocumentForm(request.POST)
|
|
836
|
+
if form.is_valid():
|
|
837
|
+
content = form.cleaned_data["content"]
|
|
838
|
+
try:
|
|
839
|
+
document.file.write_text(content, encoding="utf-8")
|
|
840
|
+
except OSError:
|
|
841
|
+
logger.exception("Failed to update markdown document %s", document.file)
|
|
842
|
+
messages.error(
|
|
843
|
+
request,
|
|
844
|
+
_("Unable to save changes. Please try again."),
|
|
845
|
+
)
|
|
846
|
+
else:
|
|
847
|
+
messages.success(request, _("Document saved successfully."))
|
|
848
|
+
if relative_path:
|
|
849
|
+
return redirect("pages:readme-edit", doc=relative_path)
|
|
850
|
+
return redirect("pages:readme")
|
|
851
|
+
else:
|
|
852
|
+
try:
|
|
853
|
+
initial_text = document.file.read_text(encoding="utf-8")
|
|
854
|
+
except OSError:
|
|
855
|
+
logger.exception("Failed to read markdown document %s", document.file)
|
|
856
|
+
messages.error(request, _("Unable to load the document for editing."))
|
|
857
|
+
return redirect("pages:readme")
|
|
858
|
+
form = MarkdownDocumentForm(initial={"content": initial_text})
|
|
859
|
+
|
|
860
|
+
context = {
|
|
861
|
+
"form": form,
|
|
862
|
+
"title": document.title,
|
|
863
|
+
"relative_path": relative_path,
|
|
864
|
+
"read_url": read_url,
|
|
865
|
+
}
|
|
866
|
+
return render(request, "pages/readme_edit.html", context)
|
|
532
867
|
|
|
533
868
|
|
|
534
869
|
def sitemap(request):
|
|
@@ -569,11 +904,6 @@ def release_checklist(request):
|
|
|
569
904
|
return response
|
|
570
905
|
|
|
571
906
|
|
|
572
|
-
@csrf_exempt
|
|
573
|
-
def datasette_auth(request):
|
|
574
|
-
if request.user.is_authenticated:
|
|
575
|
-
return HttpResponse("OK")
|
|
576
|
-
return HttpResponse(status=401)
|
|
577
907
|
|
|
578
908
|
|
|
579
909
|
class CustomLoginView(LoginView):
|
|
@@ -614,6 +944,17 @@ class CustomLoginView(LoginView):
|
|
|
614
944
|
"restricted_notice": restricted_notice,
|
|
615
945
|
}
|
|
616
946
|
)
|
|
947
|
+
node = Node.get_local()
|
|
948
|
+
has_rfid_scanner = False
|
|
949
|
+
if node:
|
|
950
|
+
try:
|
|
951
|
+
node.refresh_features()
|
|
952
|
+
except Exception:
|
|
953
|
+
logger.exception("Unable to refresh node features for login page")
|
|
954
|
+
has_rfid_scanner = node.has_feature("rfid-scanner")
|
|
955
|
+
context["show_rfid_login"] = has_rfid_scanner
|
|
956
|
+
if has_rfid_scanner:
|
|
957
|
+
context["rfid_login_url"] = reverse("pages:rfid-login")
|
|
617
958
|
return context
|
|
618
959
|
|
|
619
960
|
def get_success_url(self):
|
|
@@ -635,6 +976,31 @@ class CustomLoginView(LoginView):
|
|
|
635
976
|
login_view = CustomLoginView.as_view()
|
|
636
977
|
|
|
637
978
|
|
|
979
|
+
@ensure_csrf_cookie
|
|
980
|
+
def rfid_login_page(request):
|
|
981
|
+
node = Node.get_local()
|
|
982
|
+
if not node or not node.has_feature("rfid-scanner"):
|
|
983
|
+
raise Http404
|
|
984
|
+
if request.user.is_authenticated:
|
|
985
|
+
return redirect(reverse("admin:index") if request.user.is_staff else "/")
|
|
986
|
+
redirect_field_name = CustomLoginView.redirect_field_name
|
|
987
|
+
redirect_target = request.GET.get(redirect_field_name, "")
|
|
988
|
+
if redirect_target and not url_has_allowed_host_and_scheme(
|
|
989
|
+
redirect_target,
|
|
990
|
+
allowed_hosts={request.get_host()},
|
|
991
|
+
require_https=request.is_secure(),
|
|
992
|
+
):
|
|
993
|
+
redirect_target = ""
|
|
994
|
+
context = {
|
|
995
|
+
"login_api_url": reverse("rfid-login"),
|
|
996
|
+
"scan_api_url": reverse("rfid-scan-next"),
|
|
997
|
+
"redirect_field_name": redirect_field_name,
|
|
998
|
+
"redirect_target": redirect_target,
|
|
999
|
+
"back_url": reverse("pages:login"),
|
|
1000
|
+
}
|
|
1001
|
+
return render(request, "pages/rfid_login.html", context)
|
|
1002
|
+
|
|
1003
|
+
|
|
638
1004
|
@staff_member_required
|
|
639
1005
|
def authenticator_setup(request):
|
|
640
1006
|
"""Allow staff to enroll an authenticator app for TOTP logins."""
|
|
@@ -810,7 +1176,7 @@ def request_invite(request):
|
|
|
810
1176
|
comment=comment,
|
|
811
1177
|
user=request.user if request.user.is_authenticated else None,
|
|
812
1178
|
path=request.path,
|
|
813
|
-
referer=request
|
|
1179
|
+
referer=get_original_referer(request),
|
|
814
1180
|
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
|
815
1181
|
ip_address=ip_address,
|
|
816
1182
|
mac_address=mac_address or "",
|
|
@@ -963,31 +1329,51 @@ class ClientReportForm(forms.Form):
|
|
|
963
1329
|
label=_("Month"),
|
|
964
1330
|
required=False,
|
|
965
1331
|
widget=forms.DateInput(attrs={"type": "month"}),
|
|
1332
|
+
input_formats=["%Y-%m"],
|
|
966
1333
|
help_text=_("Generates the report for the calendar month that you select."),
|
|
967
1334
|
)
|
|
1335
|
+
language = forms.ChoiceField(
|
|
1336
|
+
label=_("Report language"),
|
|
1337
|
+
choices=settings.LANGUAGES,
|
|
1338
|
+
help_text=_("Choose the language used for the generated report."),
|
|
1339
|
+
)
|
|
1340
|
+
title = forms.CharField(
|
|
1341
|
+
label=_("Report title"),
|
|
1342
|
+
required=False,
|
|
1343
|
+
max_length=200,
|
|
1344
|
+
help_text=_("Optional heading that replaces the default report title."),
|
|
1345
|
+
)
|
|
1346
|
+
chargers = forms.ModelMultipleChoiceField(
|
|
1347
|
+
label=_("Charge points"),
|
|
1348
|
+
queryset=Charger.objects.filter(connector_id__isnull=True)
|
|
1349
|
+
.order_by("display_name", "charger_id"),
|
|
1350
|
+
required=False,
|
|
1351
|
+
widget=forms.CheckboxSelectMultiple,
|
|
1352
|
+
help_text=_("Choose which charge points are included in the report."),
|
|
1353
|
+
)
|
|
968
1354
|
owner = forms.ModelChoiceField(
|
|
969
1355
|
queryset=get_user_model().objects.all(),
|
|
970
1356
|
required=False,
|
|
971
1357
|
help_text=_(
|
|
972
|
-
"Sets who owns the report schedule and is listed as the
|
|
1358
|
+
"Sets who owns the report schedule and is listed as the requester."
|
|
973
1359
|
),
|
|
974
1360
|
)
|
|
975
1361
|
destinations = forms.CharField(
|
|
976
1362
|
label=_("Email destinations"),
|
|
977
1363
|
required=False,
|
|
978
1364
|
widget=forms.Textarea(attrs={"rows": 2}),
|
|
979
|
-
help_text=_("Separate addresses with commas or new lines."),
|
|
1365
|
+
help_text=_("Separate addresses with commas, whitespace, or new lines."),
|
|
980
1366
|
)
|
|
981
1367
|
recurrence = forms.ChoiceField(
|
|
982
|
-
label=_("
|
|
1368
|
+
label=_("Recurrence"),
|
|
983
1369
|
choices=RECURRENCE_CHOICES,
|
|
984
1370
|
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
985
1371
|
help_text=_("Defines how often the report should be generated automatically."),
|
|
986
1372
|
)
|
|
987
|
-
|
|
988
|
-
label=_("
|
|
1373
|
+
enable_emails = forms.BooleanField(
|
|
1374
|
+
label=_("Enable email delivery"),
|
|
989
1375
|
required=False,
|
|
990
|
-
help_text=_("
|
|
1376
|
+
help_text=_("Send the report via email to the recipients listed above."),
|
|
991
1377
|
)
|
|
992
1378
|
|
|
993
1379
|
def __init__(self, *args, request=None, **kwargs):
|
|
@@ -995,6 +1381,13 @@ class ClientReportForm(forms.Form):
|
|
|
995
1381
|
super().__init__(*args, **kwargs)
|
|
996
1382
|
if request and getattr(request, "user", None) and request.user.is_authenticated:
|
|
997
1383
|
self.fields["owner"].initial = request.user.pk
|
|
1384
|
+
self.fields["chargers"].widget.attrs["class"] = "charger-options"
|
|
1385
|
+
language_initial = ClientReport.default_language()
|
|
1386
|
+
if request:
|
|
1387
|
+
language_initial = ClientReport.normalize_language(
|
|
1388
|
+
getattr(request, "LANGUAGE_CODE", language_initial)
|
|
1389
|
+
)
|
|
1390
|
+
self.fields["language"].initial = language_initial
|
|
998
1391
|
|
|
999
1392
|
def clean(self):
|
|
1000
1393
|
cleaned = super().clean()
|
|
@@ -1006,8 +1399,13 @@ class ClientReportForm(forms.Form):
|
|
|
1006
1399
|
week_str = cleaned.get("week")
|
|
1007
1400
|
if not week_str:
|
|
1008
1401
|
raise forms.ValidationError(_("Please select a week."))
|
|
1009
|
-
|
|
1010
|
-
|
|
1402
|
+
try:
|
|
1403
|
+
year_str, week_num_str = week_str.split("-W", 1)
|
|
1404
|
+
start = datetime.date.fromisocalendar(
|
|
1405
|
+
int(year_str), int(week_num_str), 1
|
|
1406
|
+
)
|
|
1407
|
+
except (TypeError, ValueError):
|
|
1408
|
+
raise forms.ValidationError(_("Please select a week."))
|
|
1011
1409
|
cleaned["start"] = start
|
|
1012
1410
|
cleaned["end"] = start + datetime.timedelta(days=6)
|
|
1013
1411
|
elif period == "month":
|
|
@@ -1039,6 +1437,10 @@ class ClientReportForm(forms.Form):
|
|
|
1039
1437
|
emails.append(candidate)
|
|
1040
1438
|
return emails
|
|
1041
1439
|
|
|
1440
|
+
def clean_title(self):
|
|
1441
|
+
title = self.cleaned_data.get("title")
|
|
1442
|
+
return ClientReport.normalize_title(title)
|
|
1443
|
+
|
|
1042
1444
|
|
|
1043
1445
|
@live_update()
|
|
1044
1446
|
def client_report(request):
|
|
@@ -1049,7 +1451,7 @@ def client_report(request):
|
|
|
1049
1451
|
if not request.user.is_authenticated:
|
|
1050
1452
|
form.is_valid() # Run validation to surface field errors alongside auth error.
|
|
1051
1453
|
form.add_error(
|
|
1052
|
-
None, _("You must log in to generate
|
|
1454
|
+
None, _("You must log in to generate consumer reports."),
|
|
1053
1455
|
)
|
|
1054
1456
|
elif form.is_valid():
|
|
1055
1457
|
throttle_seconds = getattr(settings, "CLIENT_REPORT_THROTTLE_SECONDS", 60)
|
|
@@ -1078,38 +1480,92 @@ def client_report(request):
|
|
|
1078
1480
|
form.add_error(
|
|
1079
1481
|
None,
|
|
1080
1482
|
_(
|
|
1081
|
-
"
|
|
1483
|
+
"Consumer reports can only be generated periodically. Please wait before trying again."
|
|
1082
1484
|
),
|
|
1083
1485
|
)
|
|
1084
1486
|
else:
|
|
1085
1487
|
owner = form.cleaned_data.get("owner")
|
|
1086
1488
|
if not owner and request.user.is_authenticated:
|
|
1087
1489
|
owner = request.user
|
|
1490
|
+
enable_emails = form.cleaned_data.get("enable_emails", False)
|
|
1491
|
+
disable_emails = not enable_emails
|
|
1492
|
+
recipients = (
|
|
1493
|
+
form.cleaned_data.get("destinations") if enable_emails else []
|
|
1494
|
+
)
|
|
1495
|
+
chargers = list(form.cleaned_data.get("chargers") or [])
|
|
1496
|
+
language = form.cleaned_data.get("language")
|
|
1497
|
+
title = form.cleaned_data.get("title")
|
|
1088
1498
|
report = ClientReport.generate(
|
|
1089
1499
|
form.cleaned_data["start"],
|
|
1090
1500
|
form.cleaned_data["end"],
|
|
1091
1501
|
owner=owner,
|
|
1092
|
-
recipients=
|
|
1093
|
-
disable_emails=
|
|
1502
|
+
recipients=recipients,
|
|
1503
|
+
disable_emails=disable_emails,
|
|
1504
|
+
chargers=chargers,
|
|
1505
|
+
language=language,
|
|
1506
|
+
title=title,
|
|
1094
1507
|
)
|
|
1095
1508
|
report.store_local_copy()
|
|
1509
|
+
if chargers:
|
|
1510
|
+
report.chargers.set(chargers)
|
|
1511
|
+
if enable_emails and recipients:
|
|
1512
|
+
delivered = report.send_delivery(
|
|
1513
|
+
to=recipients,
|
|
1514
|
+
cc=[],
|
|
1515
|
+
outbox=ClientReport.resolve_outbox_for_owner(owner),
|
|
1516
|
+
reply_to=ClientReport.resolve_reply_to_for_owner(owner),
|
|
1517
|
+
)
|
|
1518
|
+
if delivered:
|
|
1519
|
+
report.recipients = delivered
|
|
1520
|
+
report.save(update_fields=["recipients"])
|
|
1521
|
+
messages.success(
|
|
1522
|
+
request,
|
|
1523
|
+
_("Consumer report emailed to the selected recipients."),
|
|
1524
|
+
)
|
|
1096
1525
|
recurrence = form.cleaned_data.get("recurrence")
|
|
1097
1526
|
if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
|
|
1098
1527
|
schedule = ClientReportSchedule.objects.create(
|
|
1099
1528
|
owner=owner,
|
|
1100
1529
|
created_by=request.user if request.user.is_authenticated else None,
|
|
1101
1530
|
periodicity=recurrence,
|
|
1102
|
-
email_recipients=
|
|
1103
|
-
disable_emails=
|
|
1531
|
+
email_recipients=recipients,
|
|
1532
|
+
disable_emails=disable_emails,
|
|
1533
|
+
language=language,
|
|
1534
|
+
title=title,
|
|
1104
1535
|
)
|
|
1536
|
+
if chargers:
|
|
1537
|
+
schedule.chargers.set(chargers)
|
|
1105
1538
|
report.schedule = schedule
|
|
1106
1539
|
report.save(update_fields=["schedule"])
|
|
1107
1540
|
messages.success(
|
|
1108
1541
|
request,
|
|
1109
1542
|
_(
|
|
1110
|
-
"
|
|
1543
|
+
"Consumer report schedule created; future reports will be generated automatically."
|
|
1111
1544
|
),
|
|
1112
1545
|
)
|
|
1546
|
+
if disable_emails:
|
|
1547
|
+
messages.success(
|
|
1548
|
+
request,
|
|
1549
|
+
_(
|
|
1550
|
+
"Consumer report generated. The download will begin automatically."
|
|
1551
|
+
),
|
|
1552
|
+
)
|
|
1553
|
+
redirect_url = f"{reverse('pages:client-report')}?download={report.pk}"
|
|
1554
|
+
return HttpResponseRedirect(redirect_url)
|
|
1555
|
+
download_url = None
|
|
1556
|
+
download_param = request.GET.get("download")
|
|
1557
|
+
if download_param:
|
|
1558
|
+
try:
|
|
1559
|
+
download_id = int(download_param)
|
|
1560
|
+
except (TypeError, ValueError):
|
|
1561
|
+
download_id = None
|
|
1562
|
+
if download_id and request.user.is_authenticated:
|
|
1563
|
+
download_url = reverse(
|
|
1564
|
+
"pages:client-report-download", args=[download_id]
|
|
1565
|
+
)
|
|
1566
|
+
if download_url:
|
|
1567
|
+
setattr(request, "live_update_interval", None)
|
|
1568
|
+
|
|
1113
1569
|
try:
|
|
1114
1570
|
login_url = reverse("pages:login")
|
|
1115
1571
|
except NoReverseMatch:
|
|
@@ -1123,10 +1579,44 @@ def client_report(request):
|
|
|
1123
1579
|
"report": report,
|
|
1124
1580
|
"schedule": schedule,
|
|
1125
1581
|
"login_url": login_url,
|
|
1582
|
+
"download_url": download_url,
|
|
1126
1583
|
}
|
|
1127
1584
|
return render(request, "pages/client_report.html", context)
|
|
1128
1585
|
|
|
1129
1586
|
|
|
1587
|
+
@login_required
|
|
1588
|
+
def client_report_download(request, report_id: int):
|
|
1589
|
+
report = get_object_or_404(ClientReport, pk=report_id)
|
|
1590
|
+
if not request.user.is_staff and report.owner_id != request.user.pk:
|
|
1591
|
+
return HttpResponseForbidden(
|
|
1592
|
+
_("You do not have permission to download this report.")
|
|
1593
|
+
)
|
|
1594
|
+
pdf_path = report.ensure_pdf()
|
|
1595
|
+
if not pdf_path.exists():
|
|
1596
|
+
raise Http404(_("Report file unavailable."))
|
|
1597
|
+
filename = f"consumer-report-{report.start_date}-{report.end_date}.pdf"
|
|
1598
|
+
response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
|
|
1599
|
+
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
1600
|
+
return response
|
|
1601
|
+
def _get_request_language_code(request) -> str:
|
|
1602
|
+
language_code = ""
|
|
1603
|
+
if hasattr(request, "session"):
|
|
1604
|
+
language_code = request.session.get(LANGUAGE_SESSION_KEY, "")
|
|
1605
|
+
if not language_code:
|
|
1606
|
+
cookie_name = getattr(settings, "LANGUAGE_COOKIE_NAME", "django_language")
|
|
1607
|
+
language_code = request.COOKIES.get(cookie_name, "")
|
|
1608
|
+
if not language_code:
|
|
1609
|
+
language_code = getattr(request, "LANGUAGE_CODE", "") or ""
|
|
1610
|
+
if not language_code:
|
|
1611
|
+
language_code = get_language() or ""
|
|
1612
|
+
|
|
1613
|
+
language_code = language_code.strip()
|
|
1614
|
+
if not language_code:
|
|
1615
|
+
return ""
|
|
1616
|
+
|
|
1617
|
+
return language_code.replace("_", "-").lower()[:15]
|
|
1618
|
+
|
|
1619
|
+
|
|
1130
1620
|
@require_POST
|
|
1131
1621
|
def submit_user_story(request):
|
|
1132
1622
|
throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
|
|
@@ -1148,12 +1638,12 @@ def submit_user_story(request):
|
|
|
1148
1638
|
)
|
|
1149
1639
|
|
|
1150
1640
|
data = request.POST.copy()
|
|
1151
|
-
if request.user.is_authenticated
|
|
1641
|
+
if request.user.is_authenticated:
|
|
1152
1642
|
data["name"] = request.user.get_username()[:40]
|
|
1153
1643
|
if not data.get("path"):
|
|
1154
1644
|
data["path"] = request.get_full_path()
|
|
1155
1645
|
|
|
1156
|
-
form = UserStoryForm(data)
|
|
1646
|
+
form = UserStoryForm(data, user=request.user)
|
|
1157
1647
|
if request.user.is_authenticated:
|
|
1158
1648
|
form.instance.user = request.user
|
|
1159
1649
|
|
|
@@ -1162,16 +1652,92 @@ def submit_user_story(request):
|
|
|
1162
1652
|
if request.user.is_authenticated:
|
|
1163
1653
|
story.user = request.user
|
|
1164
1654
|
story.owner = request.user
|
|
1165
|
-
|
|
1166
|
-
story.name = request.user.get_username()[:40]
|
|
1655
|
+
story.name = request.user.get_username()[:40]
|
|
1167
1656
|
if not story.name:
|
|
1168
1657
|
story.name = str(_("Anonymous"))[:40]
|
|
1169
1658
|
story.path = (story.path or request.get_full_path())[:500]
|
|
1170
|
-
story.referer = request
|
|
1659
|
+
story.referer = get_original_referer(request)
|
|
1171
1660
|
story.user_agent = request.META.get("HTTP_USER_AGENT", "")
|
|
1172
1661
|
story.ip_address = client_ip or None
|
|
1173
1662
|
story.is_user_data = True
|
|
1663
|
+
language_code = _get_request_language_code(request)
|
|
1664
|
+
if language_code:
|
|
1665
|
+
story.language_code = language_code
|
|
1174
1666
|
story.save()
|
|
1667
|
+
if request.user.is_authenticated and request.user.is_superuser:
|
|
1668
|
+
comment_text = (story.comments or "").strip()
|
|
1669
|
+
prefix = "Triage "
|
|
1670
|
+
request_field = Todo._meta.get_field("request")
|
|
1671
|
+
available_length = max(request_field.max_length - len(prefix), 0)
|
|
1672
|
+
if available_length > 0 and comment_text:
|
|
1673
|
+
summary = Truncator(comment_text).chars(
|
|
1674
|
+
available_length, truncate="…"
|
|
1675
|
+
)
|
|
1676
|
+
else:
|
|
1677
|
+
summary = comment_text[:available_length]
|
|
1678
|
+
todo_request = f"{prefix}{summary}".strip()
|
|
1679
|
+
user_is_authenticated = request.user.is_authenticated
|
|
1680
|
+
node = Node.get_local()
|
|
1681
|
+
existing_todo = (
|
|
1682
|
+
Todo.objects.filter(request__iexact=todo_request, is_deleted=False)
|
|
1683
|
+
.order_by("pk")
|
|
1684
|
+
.first()
|
|
1685
|
+
)
|
|
1686
|
+
if existing_todo:
|
|
1687
|
+
update_fields: set[str] = set()
|
|
1688
|
+
if node and existing_todo.origin_node_id != node.pk:
|
|
1689
|
+
existing_todo.origin_node = node
|
|
1690
|
+
update_fields.add("origin_node")
|
|
1691
|
+
if existing_todo.original_user_id != request.user.pk:
|
|
1692
|
+
existing_todo.original_user = request.user
|
|
1693
|
+
update_fields.add("original_user")
|
|
1694
|
+
if (
|
|
1695
|
+
existing_todo.original_user_is_authenticated
|
|
1696
|
+
!= user_is_authenticated
|
|
1697
|
+
):
|
|
1698
|
+
existing_todo.original_user_is_authenticated = (
|
|
1699
|
+
user_is_authenticated
|
|
1700
|
+
)
|
|
1701
|
+
update_fields.add("original_user_is_authenticated")
|
|
1702
|
+
if not existing_todo.is_user_data:
|
|
1703
|
+
existing_todo.is_user_data = True
|
|
1704
|
+
update_fields.add("is_user_data")
|
|
1705
|
+
if update_fields:
|
|
1706
|
+
existing_todo.save(update_fields=tuple(update_fields))
|
|
1707
|
+
else:
|
|
1708
|
+
Todo.objects.create(
|
|
1709
|
+
request=todo_request,
|
|
1710
|
+
origin_node=node,
|
|
1711
|
+
original_user=request.user,
|
|
1712
|
+
original_user_is_authenticated=user_is_authenticated,
|
|
1713
|
+
is_user_data=True,
|
|
1714
|
+
)
|
|
1715
|
+
if story.take_screenshot:
|
|
1716
|
+
screenshot_url = request.META.get("HTTP_REFERER", "")
|
|
1717
|
+
parsed = urlparse(screenshot_url)
|
|
1718
|
+
if not (parsed.scheme and parsed.netloc):
|
|
1719
|
+
target_path = story.path or request.get_full_path() or "/"
|
|
1720
|
+
screenshot_url = request.build_absolute_uri(target_path)
|
|
1721
|
+
try:
|
|
1722
|
+
screenshot_path = capture_screenshot(screenshot_url)
|
|
1723
|
+
except Exception: # pragma: no cover - best effort capture
|
|
1724
|
+
logger.exception("Failed to capture screenshot for user story %s", story.pk)
|
|
1725
|
+
else:
|
|
1726
|
+
try:
|
|
1727
|
+
sample = save_screenshot(
|
|
1728
|
+
screenshot_path,
|
|
1729
|
+
method="USER_STORY",
|
|
1730
|
+
user=story.user if story.user_id else None,
|
|
1731
|
+
link_duplicates=True,
|
|
1732
|
+
)
|
|
1733
|
+
except Exception: # pragma: no cover - best effort persistence
|
|
1734
|
+
logger.exception(
|
|
1735
|
+
"Failed to persist screenshot for user story %s", story.pk
|
|
1736
|
+
)
|
|
1737
|
+
else:
|
|
1738
|
+
if sample is not None:
|
|
1739
|
+
story.screenshot = sample
|
|
1740
|
+
story.save(update_fields=["screenshot"])
|
|
1175
1741
|
return JsonResponse({"success": True})
|
|
1176
1742
|
|
|
1177
1743
|
return JsonResponse({"success": False, "errors": form.errors}, status=400)
|