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.

Files changed (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {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.http import Http404, HttpResponse, JsonResponse
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 urlsafe_base64_decode, urlsafe_base64_encode
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 _render_readme(request, role):
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
- readme_base = (
450
- Path(settings.BASE_DIR) / app_slug if app_slug else Path(settings.BASE_DIR)
451
- )
452
- lang = getattr(request, "LANGUAGE_CODE", "")
453
- lang = lang.replace("_", "-").lower()
454
- root_base = Path(settings.BASE_DIR)
455
- candidates = []
456
- if lang:
457
- candidates.append(readme_base / f"README.{lang}.md")
458
- short = lang.split("-")[0]
459
- if short != lang:
460
- candidates.append(readme_base / f"README.{short}.md")
461
- candidates.append(readme_base / "README.md")
462
- if readme_base != root_base:
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(root_base / f"README.{lang}.md")
635
+ candidates.append(readme_base / f"README.{lang}.md")
465
636
  short = lang.split("-")[0]
466
637
  if short != lang:
467
- candidates.append(root_base / f"README.{short}.md")
468
- candidates.append(root_base / "README.md")
469
- readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
470
- text = readme_file.read_text(encoding="utf-8")
471
- html, toc_html = _render_markdown_with_toc(text)
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
- context = {"content": html, "title": title, "toc": toc_html}
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.META.get("HTTP_REFERER", ""),
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 requestor."
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=_("Recurrency"),
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
- disable_emails = forms.BooleanField(
988
- label=_("Disable email delivery"),
1373
+ enable_emails = forms.BooleanField(
1374
+ label=_("Enable email delivery"),
989
1375
  required=False,
990
- help_text=_("Generate files without sending emails."),
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
- year, week_num = week_str.split("-W")
1010
- start = datetime.date.fromisocalendar(int(year), int(week_num), 1)
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 client reports."),
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
- "Client reports can only be generated periodically. Please wait before trying again."
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=form.cleaned_data.get("destinations"),
1093
- disable_emails=form.cleaned_data.get("disable_emails", False),
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=form.cleaned_data.get("destinations", []),
1103
- disable_emails=form.cleaned_data.get("disable_emails", False),
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
- "Client report schedule created; future reports will be generated automatically."
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 and not data.get("name"):
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
- if not story.name:
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.META.get("HTTP_REFERER", "")
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)