arthexis 0.1.16__py3-none-any.whl → 0.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
pages/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,7 +64,10 @@ from core.models import (
|
|
|
48
64
|
ClientReport,
|
|
49
65
|
ClientReportSchedule,
|
|
50
66
|
SecurityGroup,
|
|
67
|
+
Todo,
|
|
51
68
|
)
|
|
69
|
+
from ocpp.models import Charger
|
|
70
|
+
from .utils import get_original_referer
|
|
52
71
|
|
|
53
72
|
try: # pragma: no cover - optional dependency guard
|
|
54
73
|
from graphviz import Digraph
|
|
@@ -58,16 +77,34 @@ except ImportError: # pragma: no cover - handled gracefully in views
|
|
|
58
77
|
CalledProcessError = ExecutableNotFound = None
|
|
59
78
|
|
|
60
79
|
import markdown
|
|
80
|
+
from django.utils._os import safe_join
|
|
61
81
|
|
|
62
82
|
|
|
63
83
|
MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
|
|
64
84
|
|
|
85
|
+
MARKDOWN_IMAGE_PATTERN = re.compile(
|
|
86
|
+
r"(?P<prefix><img\b[^>]*\bsrc=[\"\'])(?P<scheme>(?:static|work))://(?P<path>[^\"\']+)(?P<suffix>[\"\'])",
|
|
87
|
+
re.IGNORECASE,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
ALLOWED_IMAGE_EXTENSIONS = {
|
|
91
|
+
".apng",
|
|
92
|
+
".avif",
|
|
93
|
+
".gif",
|
|
94
|
+
".jpg",
|
|
95
|
+
".jpeg",
|
|
96
|
+
".png",
|
|
97
|
+
".svg",
|
|
98
|
+
".webp",
|
|
99
|
+
}
|
|
100
|
+
|
|
65
101
|
|
|
66
102
|
def _render_markdown_with_toc(text: str) -> tuple[str, str]:
|
|
67
103
|
"""Render ``text`` to HTML and return the HTML and stripped TOC."""
|
|
68
104
|
|
|
69
105
|
md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
|
|
70
106
|
html = md.convert(text)
|
|
107
|
+
html = _rewrite_markdown_asset_links(html)
|
|
71
108
|
toc_html = md.toc
|
|
72
109
|
toc_html = _strip_toc_wrapper(toc_html)
|
|
73
110
|
return html, toc_html
|
|
@@ -82,6 +119,86 @@ def _strip_toc_wrapper(toc_html: str) -> str:
|
|
|
82
119
|
if toc_html.endswith("</div>"):
|
|
83
120
|
toc_html = toc_html[: -len("</div>")]
|
|
84
121
|
return toc_html.strip()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _rewrite_markdown_asset_links(html: str) -> str:
|
|
125
|
+
"""Rewrite asset links that reference local asset schemes."""
|
|
126
|
+
|
|
127
|
+
def _replace(match: re.Match[str]) -> str:
|
|
128
|
+
scheme = match.group("scheme").lower()
|
|
129
|
+
asset_path = match.group("path").lstrip("/")
|
|
130
|
+
if not asset_path:
|
|
131
|
+
return match.group(0)
|
|
132
|
+
extension = Path(asset_path).suffix.lower()
|
|
133
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
134
|
+
return match.group(0)
|
|
135
|
+
try:
|
|
136
|
+
asset_url = reverse(
|
|
137
|
+
"pages:readme-asset",
|
|
138
|
+
kwargs={"source": scheme, "asset": asset_path},
|
|
139
|
+
)
|
|
140
|
+
except NoReverseMatch:
|
|
141
|
+
return match.group(0)
|
|
142
|
+
return f"{match.group('prefix')}{escape(asset_url)}{match.group('suffix')}"
|
|
143
|
+
|
|
144
|
+
return MARKDOWN_IMAGE_PATTERN.sub(_replace, html)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _resolve_static_asset(path: str) -> Path:
|
|
148
|
+
normalized = path.lstrip("/")
|
|
149
|
+
if not normalized:
|
|
150
|
+
raise Http404("Asset not found")
|
|
151
|
+
resolved = finders.find(normalized)
|
|
152
|
+
if not resolved:
|
|
153
|
+
raise Http404("Asset not found")
|
|
154
|
+
if isinstance(resolved, (list, tuple)):
|
|
155
|
+
resolved = resolved[0]
|
|
156
|
+
file_path = Path(resolved)
|
|
157
|
+
if file_path.is_dir():
|
|
158
|
+
raise Http404("Asset not found")
|
|
159
|
+
return file_path
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolve_work_asset(user, path: str) -> Path:
|
|
163
|
+
if not (user and getattr(user, "is_authenticated", False)):
|
|
164
|
+
raise PermissionDenied
|
|
165
|
+
normalized = path.lstrip("/")
|
|
166
|
+
if not normalized:
|
|
167
|
+
raise Http404("Asset not found")
|
|
168
|
+
username = getattr(user, "get_username", None)
|
|
169
|
+
if callable(username):
|
|
170
|
+
username = username()
|
|
171
|
+
else:
|
|
172
|
+
username = getattr(user, "username", "")
|
|
173
|
+
username_component = Path(str(username or user.pk)).name
|
|
174
|
+
base_work = Path(settings.BASE_DIR) / "work"
|
|
175
|
+
try:
|
|
176
|
+
user_dir = Path(safe_join(str(base_work), username_component))
|
|
177
|
+
asset_path = Path(safe_join(str(user_dir), normalized))
|
|
178
|
+
except SuspiciousFileOperation as exc:
|
|
179
|
+
logger.warning("Rejected suspicious work asset path: %s", normalized, exc_info=exc)
|
|
180
|
+
raise Http404("Asset not found") from exc
|
|
181
|
+
try:
|
|
182
|
+
user_dir_resolved = user_dir.resolve(strict=True)
|
|
183
|
+
except FileNotFoundError as exc:
|
|
184
|
+
logger.warning(
|
|
185
|
+
"Work directory missing for asset request: %s", user_dir, exc_info=exc
|
|
186
|
+
)
|
|
187
|
+
raise Http404("Asset not found") from exc
|
|
188
|
+
try:
|
|
189
|
+
asset_resolved = asset_path.resolve(strict=True)
|
|
190
|
+
except FileNotFoundError as exc:
|
|
191
|
+
raise Http404("Asset not found") from exc
|
|
192
|
+
try:
|
|
193
|
+
asset_resolved.relative_to(user_dir_resolved)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
logger.warning(
|
|
196
|
+
"Rejected work asset outside directory: %s", asset_resolved, exc_info=exc
|
|
197
|
+
)
|
|
198
|
+
raise Http404("Asset not found") from exc
|
|
199
|
+
if asset_resolved.is_dir():
|
|
200
|
+
raise Http404("Asset not found")
|
|
201
|
+
return asset_resolved
|
|
85
202
|
from pages.utils import landing
|
|
86
203
|
from core.liveupdate import live_update
|
|
87
204
|
from django_otp import login as otp_login
|
|
@@ -439,43 +556,185 @@ def admin_model_graph(request, app_label: str):
|
|
|
439
556
|
return response
|
|
440
557
|
|
|
441
558
|
|
|
442
|
-
def
|
|
559
|
+
def _locate_readme_document(role, doc: str | None, lang: str) -> SimpleNamespace:
|
|
443
560
|
app = (
|
|
444
561
|
Module.objects.filter(node_role=role, is_default=True)
|
|
445
562
|
.select_related("application")
|
|
446
563
|
.first()
|
|
447
564
|
)
|
|
448
565
|
app_slug = app.path.strip("/") if app else ""
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
566
|
+
root_base = Path(settings.BASE_DIR).resolve()
|
|
567
|
+
readme_base = (root_base / app_slug).resolve() if app_slug else root_base
|
|
568
|
+
candidates: list[Path] = []
|
|
569
|
+
|
|
570
|
+
if doc:
|
|
571
|
+
normalized = doc.strip().replace("\\", "/")
|
|
572
|
+
while normalized.startswith("./"):
|
|
573
|
+
normalized = normalized[2:]
|
|
574
|
+
normalized = normalized.lstrip("/")
|
|
575
|
+
if not normalized:
|
|
576
|
+
raise Http404("Document not found")
|
|
577
|
+
doc_path = Path(normalized)
|
|
578
|
+
if doc_path.is_absolute() or any(part == ".." for part in doc_path.parts):
|
|
579
|
+
raise Http404("Document not found")
|
|
580
|
+
|
|
581
|
+
relative_candidates: list[Path] = []
|
|
582
|
+
|
|
583
|
+
def add_candidate(path: Path) -> None:
|
|
584
|
+
if path not in relative_candidates:
|
|
585
|
+
relative_candidates.append(path)
|
|
586
|
+
|
|
587
|
+
add_candidate(doc_path)
|
|
588
|
+
if doc_path.suffix.lower() != ".md" or doc_path.suffix != ".md":
|
|
589
|
+
add_candidate(doc_path.with_suffix(".md"))
|
|
590
|
+
if doc_path.suffix.lower() != ".md":
|
|
591
|
+
add_candidate(doc_path / "README.md")
|
|
592
|
+
|
|
593
|
+
search_roots = [readme_base]
|
|
594
|
+
if readme_base != root_base:
|
|
595
|
+
search_roots.append(root_base)
|
|
596
|
+
|
|
597
|
+
for relative in relative_candidates:
|
|
598
|
+
for base in search_roots:
|
|
599
|
+
base_resolved = base.resolve()
|
|
600
|
+
candidate = (base_resolved / relative).resolve(strict=False)
|
|
601
|
+
try:
|
|
602
|
+
candidate.relative_to(base_resolved)
|
|
603
|
+
except ValueError:
|
|
604
|
+
continue
|
|
605
|
+
candidates.append(candidate)
|
|
606
|
+
else:
|
|
607
|
+
default_readme = readme_base / "README.md"
|
|
608
|
+
root_default: Path | None = None
|
|
463
609
|
if lang:
|
|
464
|
-
candidates.append(
|
|
610
|
+
candidates.append(readme_base / f"README.{lang}.md")
|
|
465
611
|
short = lang.split("-")[0]
|
|
466
612
|
if short != lang:
|
|
467
|
-
candidates.append(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
613
|
+
candidates.append(readme_base / f"README.{short}.md")
|
|
614
|
+
if readme_base != root_base:
|
|
615
|
+
candidates.append(default_readme)
|
|
616
|
+
if lang:
|
|
617
|
+
candidates.append(root_base / f"README.{lang}.md")
|
|
618
|
+
short = lang.split("-")[0]
|
|
619
|
+
if short != lang:
|
|
620
|
+
candidates.append(root_base / f"README.{short}.md")
|
|
621
|
+
root_default = root_base / "README.md"
|
|
622
|
+
else:
|
|
623
|
+
root_default = default_readme
|
|
624
|
+
locale_base = root_base / "locale"
|
|
625
|
+
if locale_base.exists():
|
|
626
|
+
if lang:
|
|
627
|
+
candidates.append(locale_base / f"README.{lang}.md")
|
|
628
|
+
short = lang.split("-")[0]
|
|
629
|
+
if short != lang:
|
|
630
|
+
candidates.append(locale_base / f"README.{short}.md")
|
|
631
|
+
candidates.append(locale_base / "README.md")
|
|
632
|
+
if root_default is not None:
|
|
633
|
+
candidates.append(root_default)
|
|
634
|
+
|
|
635
|
+
readme_file = next((p for p in candidates if p.exists()), None)
|
|
636
|
+
if readme_file is None:
|
|
637
|
+
raise Http404("Document not found")
|
|
638
|
+
|
|
472
639
|
title = "README" if readme_file.name.startswith("README") else readme_file.stem
|
|
473
|
-
|
|
640
|
+
return SimpleNamespace(
|
|
641
|
+
file=readme_file,
|
|
642
|
+
title=title,
|
|
643
|
+
root_base=root_base,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _relative_readme_path(readme_file: Path, root_base: Path) -> str | None:
|
|
648
|
+
try:
|
|
649
|
+
return readme_file.relative_to(root_base).as_posix()
|
|
650
|
+
except ValueError:
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _render_readme(request, role, doc: str | None = None):
|
|
655
|
+
lang = getattr(request, "LANGUAGE_CODE", "")
|
|
656
|
+
lang = lang.replace("_", "-").lower()
|
|
657
|
+
document = _locate_readme_document(role, doc, lang)
|
|
658
|
+
text = document.file.read_text(encoding="utf-8")
|
|
659
|
+
html, toc_html = _render_markdown_with_toc(text)
|
|
660
|
+
relative_path = _relative_readme_path(document.file, document.root_base)
|
|
661
|
+
user = getattr(request, "user", None)
|
|
662
|
+
can_edit = bool(
|
|
663
|
+
relative_path
|
|
664
|
+
and user
|
|
665
|
+
and user.is_authenticated
|
|
666
|
+
and user.is_superuser
|
|
667
|
+
)
|
|
668
|
+
edit_url = None
|
|
669
|
+
if can_edit:
|
|
670
|
+
try:
|
|
671
|
+
edit_url = reverse("pages:readme-edit", kwargs={"doc": relative_path})
|
|
672
|
+
except NoReverseMatch:
|
|
673
|
+
edit_url = None
|
|
674
|
+
context = {
|
|
675
|
+
"content": html,
|
|
676
|
+
"title": document.title,
|
|
677
|
+
"toc": toc_html,
|
|
678
|
+
"page_url": request.build_absolute_uri(),
|
|
679
|
+
"edit_url": edit_url,
|
|
680
|
+
}
|
|
474
681
|
response = render(request, "pages/readme.html", context)
|
|
475
682
|
patch_vary_headers(response, ["Accept-Language", "Cookie"])
|
|
476
683
|
return response
|
|
477
684
|
|
|
478
685
|
|
|
686
|
+
def readme_asset(request, source: str, asset: str):
|
|
687
|
+
source_normalized = (source or "").lower()
|
|
688
|
+
if source_normalized == "static":
|
|
689
|
+
file_path = _resolve_static_asset(asset)
|
|
690
|
+
elif source_normalized == "work":
|
|
691
|
+
file_path = _resolve_work_asset(getattr(request, "user", None), asset)
|
|
692
|
+
else:
|
|
693
|
+
raise Http404("Asset not found")
|
|
694
|
+
|
|
695
|
+
if not file_path.exists() or not file_path.is_file():
|
|
696
|
+
raise Http404("Asset not found")
|
|
697
|
+
|
|
698
|
+
extension = file_path.suffix.lower()
|
|
699
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
700
|
+
raise Http404("Asset not found")
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
file_handle = file_path.open("rb")
|
|
704
|
+
except OSError as exc: # pragma: no cover - unexpected filesystem error
|
|
705
|
+
logger.warning("Unable to open asset %s", file_path, exc_info=exc)
|
|
706
|
+
raise Http404("Asset not found") from exc
|
|
707
|
+
|
|
708
|
+
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
709
|
+
response = FileResponse(file_handle, content_type=content_type)
|
|
710
|
+
try:
|
|
711
|
+
response["Content-Length"] = str(file_path.stat().st_size)
|
|
712
|
+
except OSError: # pragma: no cover - filesystem race
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
if source_normalized == "work":
|
|
716
|
+
patch_cache_control(response, private=True, no_store=True)
|
|
717
|
+
patch_vary_headers(response, ["Cookie"])
|
|
718
|
+
else:
|
|
719
|
+
patch_cache_control(response, public=True, max_age=3600)
|
|
720
|
+
|
|
721
|
+
return response
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
class MarkdownDocumentForm(forms.Form):
|
|
725
|
+
content = forms.CharField(
|
|
726
|
+
widget=forms.Textarea(
|
|
727
|
+
attrs={
|
|
728
|
+
"class": "form-control",
|
|
729
|
+
"rows": 24,
|
|
730
|
+
"spellcheck": "false",
|
|
731
|
+
}
|
|
732
|
+
),
|
|
733
|
+
required=False,
|
|
734
|
+
strip=False,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
479
738
|
@landing("Home")
|
|
480
739
|
@never_cache
|
|
481
740
|
def index(request):
|
|
@@ -525,10 +784,61 @@ def index(request):
|
|
|
525
784
|
|
|
526
785
|
|
|
527
786
|
@never_cache
|
|
528
|
-
def readme(request):
|
|
787
|
+
def readme(request, doc=None):
|
|
529
788
|
node = Node.get_local()
|
|
530
789
|
role = node.role if node else None
|
|
531
|
-
return _render_readme(request, role)
|
|
790
|
+
return _render_readme(request, role, doc)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def readme_edit(request, doc):
|
|
794
|
+
user = getattr(request, "user", None)
|
|
795
|
+
if not (user and user.is_authenticated and user.is_superuser):
|
|
796
|
+
raise PermissionDenied
|
|
797
|
+
|
|
798
|
+
node = Node.get_local()
|
|
799
|
+
role = node.role if node else None
|
|
800
|
+
lang = getattr(request, "LANGUAGE_CODE", "")
|
|
801
|
+
lang = lang.replace("_", "-").lower()
|
|
802
|
+
document = _locate_readme_document(role, doc, lang)
|
|
803
|
+
relative_path = _relative_readme_path(document.file, document.root_base)
|
|
804
|
+
if relative_path:
|
|
805
|
+
read_url = reverse("pages:readme-document", kwargs={"doc": relative_path})
|
|
806
|
+
else:
|
|
807
|
+
read_url = reverse("pages:readme")
|
|
808
|
+
|
|
809
|
+
if request.method == "POST":
|
|
810
|
+
form = MarkdownDocumentForm(request.POST)
|
|
811
|
+
if form.is_valid():
|
|
812
|
+
content = form.cleaned_data["content"]
|
|
813
|
+
try:
|
|
814
|
+
document.file.write_text(content, encoding="utf-8")
|
|
815
|
+
except OSError:
|
|
816
|
+
logger.exception("Failed to update markdown document %s", document.file)
|
|
817
|
+
messages.error(
|
|
818
|
+
request,
|
|
819
|
+
_("Unable to save changes. Please try again."),
|
|
820
|
+
)
|
|
821
|
+
else:
|
|
822
|
+
messages.success(request, _("Document saved successfully."))
|
|
823
|
+
if relative_path:
|
|
824
|
+
return redirect("pages:readme-edit", doc=relative_path)
|
|
825
|
+
return redirect("pages:readme")
|
|
826
|
+
else:
|
|
827
|
+
try:
|
|
828
|
+
initial_text = document.file.read_text(encoding="utf-8")
|
|
829
|
+
except OSError:
|
|
830
|
+
logger.exception("Failed to read markdown document %s", document.file)
|
|
831
|
+
messages.error(request, _("Unable to load the document for editing."))
|
|
832
|
+
return redirect("pages:readme")
|
|
833
|
+
form = MarkdownDocumentForm(initial={"content": initial_text})
|
|
834
|
+
|
|
835
|
+
context = {
|
|
836
|
+
"form": form,
|
|
837
|
+
"title": document.title,
|
|
838
|
+
"relative_path": relative_path,
|
|
839
|
+
"read_url": read_url,
|
|
840
|
+
}
|
|
841
|
+
return render(request, "pages/readme_edit.html", context)
|
|
532
842
|
|
|
533
843
|
|
|
534
844
|
def sitemap(request):
|
|
@@ -569,11 +879,6 @@ def release_checklist(request):
|
|
|
569
879
|
return response
|
|
570
880
|
|
|
571
881
|
|
|
572
|
-
@csrf_exempt
|
|
573
|
-
def datasette_auth(request):
|
|
574
|
-
if request.user.is_authenticated:
|
|
575
|
-
return HttpResponse("OK")
|
|
576
|
-
return HttpResponse(status=401)
|
|
577
882
|
|
|
578
883
|
|
|
579
884
|
class CustomLoginView(LoginView):
|
|
@@ -810,7 +1115,7 @@ def request_invite(request):
|
|
|
810
1115
|
comment=comment,
|
|
811
1116
|
user=request.user if request.user.is_authenticated else None,
|
|
812
1117
|
path=request.path,
|
|
813
|
-
referer=request
|
|
1118
|
+
referer=get_original_referer(request),
|
|
814
1119
|
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
|
815
1120
|
ip_address=ip_address,
|
|
816
1121
|
mac_address=mac_address or "",
|
|
@@ -963,31 +1268,51 @@ class ClientReportForm(forms.Form):
|
|
|
963
1268
|
label=_("Month"),
|
|
964
1269
|
required=False,
|
|
965
1270
|
widget=forms.DateInput(attrs={"type": "month"}),
|
|
1271
|
+
input_formats=["%Y-%m"],
|
|
966
1272
|
help_text=_("Generates the report for the calendar month that you select."),
|
|
967
1273
|
)
|
|
1274
|
+
language = forms.ChoiceField(
|
|
1275
|
+
label=_("Report language"),
|
|
1276
|
+
choices=settings.LANGUAGES,
|
|
1277
|
+
help_text=_("Choose the language used for the generated report."),
|
|
1278
|
+
)
|
|
1279
|
+
title = forms.CharField(
|
|
1280
|
+
label=_("Report title"),
|
|
1281
|
+
required=False,
|
|
1282
|
+
max_length=200,
|
|
1283
|
+
help_text=_("Optional heading that replaces the default report title."),
|
|
1284
|
+
)
|
|
1285
|
+
chargers = forms.ModelMultipleChoiceField(
|
|
1286
|
+
label=_("Charge points"),
|
|
1287
|
+
queryset=Charger.objects.filter(connector_id__isnull=True)
|
|
1288
|
+
.order_by("display_name", "charger_id"),
|
|
1289
|
+
required=False,
|
|
1290
|
+
widget=forms.CheckboxSelectMultiple,
|
|
1291
|
+
help_text=_("Choose which charge points are included in the report."),
|
|
1292
|
+
)
|
|
968
1293
|
owner = forms.ModelChoiceField(
|
|
969
1294
|
queryset=get_user_model().objects.all(),
|
|
970
1295
|
required=False,
|
|
971
1296
|
help_text=_(
|
|
972
|
-
"Sets who owns the report schedule and is listed as the
|
|
1297
|
+
"Sets who owns the report schedule and is listed as the requester."
|
|
973
1298
|
),
|
|
974
1299
|
)
|
|
975
1300
|
destinations = forms.CharField(
|
|
976
1301
|
label=_("Email destinations"),
|
|
977
1302
|
required=False,
|
|
978
1303
|
widget=forms.Textarea(attrs={"rows": 2}),
|
|
979
|
-
help_text=_("Separate addresses with commas or new lines."),
|
|
1304
|
+
help_text=_("Separate addresses with commas, whitespace, or new lines."),
|
|
980
1305
|
)
|
|
981
1306
|
recurrence = forms.ChoiceField(
|
|
982
|
-
label=_("
|
|
1307
|
+
label=_("Recurrence"),
|
|
983
1308
|
choices=RECURRENCE_CHOICES,
|
|
984
1309
|
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
985
1310
|
help_text=_("Defines how often the report should be generated automatically."),
|
|
986
1311
|
)
|
|
987
|
-
|
|
988
|
-
label=_("
|
|
1312
|
+
enable_emails = forms.BooleanField(
|
|
1313
|
+
label=_("Enable email delivery"),
|
|
989
1314
|
required=False,
|
|
990
|
-
help_text=_("
|
|
1315
|
+
help_text=_("Send the report via email to the recipients listed above."),
|
|
991
1316
|
)
|
|
992
1317
|
|
|
993
1318
|
def __init__(self, *args, request=None, **kwargs):
|
|
@@ -995,6 +1320,13 @@ class ClientReportForm(forms.Form):
|
|
|
995
1320
|
super().__init__(*args, **kwargs)
|
|
996
1321
|
if request and getattr(request, "user", None) and request.user.is_authenticated:
|
|
997
1322
|
self.fields["owner"].initial = request.user.pk
|
|
1323
|
+
self.fields["chargers"].widget.attrs["class"] = "charger-options"
|
|
1324
|
+
language_initial = ClientReport.default_language()
|
|
1325
|
+
if request:
|
|
1326
|
+
language_initial = ClientReport.normalize_language(
|
|
1327
|
+
getattr(request, "LANGUAGE_CODE", language_initial)
|
|
1328
|
+
)
|
|
1329
|
+
self.fields["language"].initial = language_initial
|
|
998
1330
|
|
|
999
1331
|
def clean(self):
|
|
1000
1332
|
cleaned = super().clean()
|
|
@@ -1006,8 +1338,13 @@ class ClientReportForm(forms.Form):
|
|
|
1006
1338
|
week_str = cleaned.get("week")
|
|
1007
1339
|
if not week_str:
|
|
1008
1340
|
raise forms.ValidationError(_("Please select a week."))
|
|
1009
|
-
|
|
1010
|
-
|
|
1341
|
+
try:
|
|
1342
|
+
year_str, week_num_str = week_str.split("-W", 1)
|
|
1343
|
+
start = datetime.date.fromisocalendar(
|
|
1344
|
+
int(year_str), int(week_num_str), 1
|
|
1345
|
+
)
|
|
1346
|
+
except (TypeError, ValueError):
|
|
1347
|
+
raise forms.ValidationError(_("Please select a week."))
|
|
1011
1348
|
cleaned["start"] = start
|
|
1012
1349
|
cleaned["end"] = start + datetime.timedelta(days=6)
|
|
1013
1350
|
elif period == "month":
|
|
@@ -1039,6 +1376,10 @@ class ClientReportForm(forms.Form):
|
|
|
1039
1376
|
emails.append(candidate)
|
|
1040
1377
|
return emails
|
|
1041
1378
|
|
|
1379
|
+
def clean_title(self):
|
|
1380
|
+
title = self.cleaned_data.get("title")
|
|
1381
|
+
return ClientReport.normalize_title(title)
|
|
1382
|
+
|
|
1042
1383
|
|
|
1043
1384
|
@live_update()
|
|
1044
1385
|
def client_report(request):
|
|
@@ -1049,7 +1390,7 @@ def client_report(request):
|
|
|
1049
1390
|
if not request.user.is_authenticated:
|
|
1050
1391
|
form.is_valid() # Run validation to surface field errors alongside auth error.
|
|
1051
1392
|
form.add_error(
|
|
1052
|
-
None, _("You must log in to generate
|
|
1393
|
+
None, _("You must log in to generate consumer reports."),
|
|
1053
1394
|
)
|
|
1054
1395
|
elif form.is_valid():
|
|
1055
1396
|
throttle_seconds = getattr(settings, "CLIENT_REPORT_THROTTLE_SECONDS", 60)
|
|
@@ -1078,38 +1419,90 @@ def client_report(request):
|
|
|
1078
1419
|
form.add_error(
|
|
1079
1420
|
None,
|
|
1080
1421
|
_(
|
|
1081
|
-
"
|
|
1422
|
+
"Consumer reports can only be generated periodically. Please wait before trying again."
|
|
1082
1423
|
),
|
|
1083
1424
|
)
|
|
1084
1425
|
else:
|
|
1085
1426
|
owner = form.cleaned_data.get("owner")
|
|
1086
1427
|
if not owner and request.user.is_authenticated:
|
|
1087
1428
|
owner = request.user
|
|
1429
|
+
enable_emails = form.cleaned_data.get("enable_emails", False)
|
|
1430
|
+
disable_emails = not enable_emails
|
|
1431
|
+
recipients = (
|
|
1432
|
+
form.cleaned_data.get("destinations") if enable_emails else []
|
|
1433
|
+
)
|
|
1434
|
+
chargers = list(form.cleaned_data.get("chargers") or [])
|
|
1435
|
+
language = form.cleaned_data.get("language")
|
|
1436
|
+
title = form.cleaned_data.get("title")
|
|
1088
1437
|
report = ClientReport.generate(
|
|
1089
1438
|
form.cleaned_data["start"],
|
|
1090
1439
|
form.cleaned_data["end"],
|
|
1091
1440
|
owner=owner,
|
|
1092
|
-
recipients=
|
|
1093
|
-
disable_emails=
|
|
1441
|
+
recipients=recipients,
|
|
1442
|
+
disable_emails=disable_emails,
|
|
1443
|
+
chargers=chargers,
|
|
1444
|
+
language=language,
|
|
1445
|
+
title=title,
|
|
1094
1446
|
)
|
|
1095
1447
|
report.store_local_copy()
|
|
1448
|
+
if chargers:
|
|
1449
|
+
report.chargers.set(chargers)
|
|
1450
|
+
if enable_emails and recipients:
|
|
1451
|
+
delivered = report.send_delivery(
|
|
1452
|
+
to=recipients,
|
|
1453
|
+
cc=[],
|
|
1454
|
+
outbox=ClientReport.resolve_outbox_for_owner(owner),
|
|
1455
|
+
reply_to=ClientReport.resolve_reply_to_for_owner(owner),
|
|
1456
|
+
)
|
|
1457
|
+
if delivered:
|
|
1458
|
+
report.recipients = delivered
|
|
1459
|
+
report.save(update_fields=["recipients"])
|
|
1460
|
+
messages.success(
|
|
1461
|
+
request,
|
|
1462
|
+
_("Consumer report emailed to the selected recipients."),
|
|
1463
|
+
)
|
|
1096
1464
|
recurrence = form.cleaned_data.get("recurrence")
|
|
1097
1465
|
if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
|
|
1098
1466
|
schedule = ClientReportSchedule.objects.create(
|
|
1099
1467
|
owner=owner,
|
|
1100
1468
|
created_by=request.user if request.user.is_authenticated else None,
|
|
1101
1469
|
periodicity=recurrence,
|
|
1102
|
-
email_recipients=
|
|
1103
|
-
disable_emails=
|
|
1470
|
+
email_recipients=recipients,
|
|
1471
|
+
disable_emails=disable_emails,
|
|
1472
|
+
language=language,
|
|
1473
|
+
title=title,
|
|
1104
1474
|
)
|
|
1475
|
+
if chargers:
|
|
1476
|
+
schedule.chargers.set(chargers)
|
|
1105
1477
|
report.schedule = schedule
|
|
1106
1478
|
report.save(update_fields=["schedule"])
|
|
1107
1479
|
messages.success(
|
|
1108
1480
|
request,
|
|
1109
1481
|
_(
|
|
1110
|
-
"
|
|
1482
|
+
"Consumer report schedule created; future reports will be generated automatically."
|
|
1111
1483
|
),
|
|
1112
1484
|
)
|
|
1485
|
+
if disable_emails:
|
|
1486
|
+
messages.success(
|
|
1487
|
+
request,
|
|
1488
|
+
_(
|
|
1489
|
+
"Consumer report generated. The download will begin automatically."
|
|
1490
|
+
),
|
|
1491
|
+
)
|
|
1492
|
+
redirect_url = f"{reverse('pages:client-report')}?download={report.pk}"
|
|
1493
|
+
return HttpResponseRedirect(redirect_url)
|
|
1494
|
+
download_url = None
|
|
1495
|
+
download_param = request.GET.get("download")
|
|
1496
|
+
if download_param and request.user.is_authenticated:
|
|
1497
|
+
try:
|
|
1498
|
+
download_id = int(download_param)
|
|
1499
|
+
except (TypeError, ValueError):
|
|
1500
|
+
download_id = None
|
|
1501
|
+
if download_id:
|
|
1502
|
+
download_url = reverse(
|
|
1503
|
+
"pages:client-report-download", args=[download_id]
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1113
1506
|
try:
|
|
1114
1507
|
login_url = reverse("pages:login")
|
|
1115
1508
|
except NoReverseMatch:
|
|
@@ -1123,10 +1516,44 @@ def client_report(request):
|
|
|
1123
1516
|
"report": report,
|
|
1124
1517
|
"schedule": schedule,
|
|
1125
1518
|
"login_url": login_url,
|
|
1519
|
+
"download_url": download_url,
|
|
1126
1520
|
}
|
|
1127
1521
|
return render(request, "pages/client_report.html", context)
|
|
1128
1522
|
|
|
1129
1523
|
|
|
1524
|
+
@login_required
|
|
1525
|
+
def client_report_download(request, report_id: int):
|
|
1526
|
+
report = get_object_or_404(ClientReport, pk=report_id)
|
|
1527
|
+
if not request.user.is_staff and report.owner_id != request.user.pk:
|
|
1528
|
+
return HttpResponseForbidden(
|
|
1529
|
+
_("You do not have permission to download this report.")
|
|
1530
|
+
)
|
|
1531
|
+
pdf_path = report.ensure_pdf()
|
|
1532
|
+
if not pdf_path.exists():
|
|
1533
|
+
raise Http404(_("Report file unavailable."))
|
|
1534
|
+
filename = f"consumer-report-{report.start_date}-{report.end_date}.pdf"
|
|
1535
|
+
response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
|
|
1536
|
+
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
1537
|
+
return response
|
|
1538
|
+
def _get_request_language_code(request) -> str:
|
|
1539
|
+
language_code = ""
|
|
1540
|
+
if hasattr(request, "session"):
|
|
1541
|
+
language_code = request.session.get(LANGUAGE_SESSION_KEY, "")
|
|
1542
|
+
if not language_code:
|
|
1543
|
+
cookie_name = getattr(settings, "LANGUAGE_COOKIE_NAME", "django_language")
|
|
1544
|
+
language_code = request.COOKIES.get(cookie_name, "")
|
|
1545
|
+
if not language_code:
|
|
1546
|
+
language_code = getattr(request, "LANGUAGE_CODE", "") or ""
|
|
1547
|
+
if not language_code:
|
|
1548
|
+
language_code = get_language() or ""
|
|
1549
|
+
|
|
1550
|
+
language_code = language_code.strip()
|
|
1551
|
+
if not language_code:
|
|
1552
|
+
return ""
|
|
1553
|
+
|
|
1554
|
+
return language_code.replace("_", "-").lower()[:15]
|
|
1555
|
+
|
|
1556
|
+
|
|
1130
1557
|
@require_POST
|
|
1131
1558
|
def submit_user_story(request):
|
|
1132
1559
|
throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
|
|
@@ -1148,12 +1575,12 @@ def submit_user_story(request):
|
|
|
1148
1575
|
)
|
|
1149
1576
|
|
|
1150
1577
|
data = request.POST.copy()
|
|
1151
|
-
if request.user.is_authenticated
|
|
1578
|
+
if request.user.is_authenticated:
|
|
1152
1579
|
data["name"] = request.user.get_username()[:40]
|
|
1153
1580
|
if not data.get("path"):
|
|
1154
1581
|
data["path"] = request.get_full_path()
|
|
1155
1582
|
|
|
1156
|
-
form = UserStoryForm(data)
|
|
1583
|
+
form = UserStoryForm(data, user=request.user)
|
|
1157
1584
|
if request.user.is_authenticated:
|
|
1158
1585
|
form.instance.user = request.user
|
|
1159
1586
|
|
|
@@ -1162,16 +1589,92 @@ def submit_user_story(request):
|
|
|
1162
1589
|
if request.user.is_authenticated:
|
|
1163
1590
|
story.user = request.user
|
|
1164
1591
|
story.owner = request.user
|
|
1165
|
-
|
|
1166
|
-
story.name = request.user.get_username()[:40]
|
|
1592
|
+
story.name = request.user.get_username()[:40]
|
|
1167
1593
|
if not story.name:
|
|
1168
1594
|
story.name = str(_("Anonymous"))[:40]
|
|
1169
1595
|
story.path = (story.path or request.get_full_path())[:500]
|
|
1170
|
-
story.referer = request
|
|
1596
|
+
story.referer = get_original_referer(request)
|
|
1171
1597
|
story.user_agent = request.META.get("HTTP_USER_AGENT", "")
|
|
1172
1598
|
story.ip_address = client_ip or None
|
|
1173
1599
|
story.is_user_data = True
|
|
1600
|
+
language_code = _get_request_language_code(request)
|
|
1601
|
+
if language_code:
|
|
1602
|
+
story.language_code = language_code
|
|
1174
1603
|
story.save()
|
|
1604
|
+
if request.user.is_authenticated and request.user.is_superuser:
|
|
1605
|
+
comment_text = (story.comments or "").strip()
|
|
1606
|
+
prefix = "Triage "
|
|
1607
|
+
request_field = Todo._meta.get_field("request")
|
|
1608
|
+
available_length = max(request_field.max_length - len(prefix), 0)
|
|
1609
|
+
if available_length > 0 and comment_text:
|
|
1610
|
+
summary = Truncator(comment_text).chars(
|
|
1611
|
+
available_length, truncate="…"
|
|
1612
|
+
)
|
|
1613
|
+
else:
|
|
1614
|
+
summary = comment_text[:available_length]
|
|
1615
|
+
todo_request = f"{prefix}{summary}".strip()
|
|
1616
|
+
user_is_authenticated = request.user.is_authenticated
|
|
1617
|
+
node = Node.get_local()
|
|
1618
|
+
existing_todo = (
|
|
1619
|
+
Todo.objects.filter(request__iexact=todo_request, is_deleted=False)
|
|
1620
|
+
.order_by("pk")
|
|
1621
|
+
.first()
|
|
1622
|
+
)
|
|
1623
|
+
if existing_todo:
|
|
1624
|
+
update_fields: set[str] = set()
|
|
1625
|
+
if node and existing_todo.origin_node_id != node.pk:
|
|
1626
|
+
existing_todo.origin_node = node
|
|
1627
|
+
update_fields.add("origin_node")
|
|
1628
|
+
if existing_todo.original_user_id != request.user.pk:
|
|
1629
|
+
existing_todo.original_user = request.user
|
|
1630
|
+
update_fields.add("original_user")
|
|
1631
|
+
if (
|
|
1632
|
+
existing_todo.original_user_is_authenticated
|
|
1633
|
+
!= user_is_authenticated
|
|
1634
|
+
):
|
|
1635
|
+
existing_todo.original_user_is_authenticated = (
|
|
1636
|
+
user_is_authenticated
|
|
1637
|
+
)
|
|
1638
|
+
update_fields.add("original_user_is_authenticated")
|
|
1639
|
+
if not existing_todo.is_user_data:
|
|
1640
|
+
existing_todo.is_user_data = True
|
|
1641
|
+
update_fields.add("is_user_data")
|
|
1642
|
+
if update_fields:
|
|
1643
|
+
existing_todo.save(update_fields=tuple(update_fields))
|
|
1644
|
+
else:
|
|
1645
|
+
Todo.objects.create(
|
|
1646
|
+
request=todo_request,
|
|
1647
|
+
origin_node=node,
|
|
1648
|
+
original_user=request.user,
|
|
1649
|
+
original_user_is_authenticated=user_is_authenticated,
|
|
1650
|
+
is_user_data=True,
|
|
1651
|
+
)
|
|
1652
|
+
if story.take_screenshot:
|
|
1653
|
+
screenshot_url = request.META.get("HTTP_REFERER", "")
|
|
1654
|
+
parsed = urlparse(screenshot_url)
|
|
1655
|
+
if not (parsed.scheme and parsed.netloc):
|
|
1656
|
+
target_path = story.path or request.get_full_path() or "/"
|
|
1657
|
+
screenshot_url = request.build_absolute_uri(target_path)
|
|
1658
|
+
try:
|
|
1659
|
+
screenshot_path = capture_screenshot(screenshot_url)
|
|
1660
|
+
except Exception: # pragma: no cover - best effort capture
|
|
1661
|
+
logger.exception("Failed to capture screenshot for user story %s", story.pk)
|
|
1662
|
+
else:
|
|
1663
|
+
try:
|
|
1664
|
+
sample = save_screenshot(
|
|
1665
|
+
screenshot_path,
|
|
1666
|
+
method="USER_STORY",
|
|
1667
|
+
user=story.user if story.user_id else None,
|
|
1668
|
+
link_duplicates=True,
|
|
1669
|
+
)
|
|
1670
|
+
except Exception: # pragma: no cover - best effort persistence
|
|
1671
|
+
logger.exception(
|
|
1672
|
+
"Failed to persist screenshot for user story %s", story.pk
|
|
1673
|
+
)
|
|
1674
|
+
else:
|
|
1675
|
+
if sample is not None:
|
|
1676
|
+
story.screenshot = sample
|
|
1677
|
+
story.save(update_fields=["screenshot"])
|
|
1175
1678
|
return JsonResponse({"success": True})
|
|
1176
1679
|
|
|
1177
1680
|
return JsonResponse({"success": False, "errors": form.errors}, status=400)
|