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.

Files changed (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {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.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
@@ -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 _render_readme(request, role):
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
- 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:
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(root_base / f"README.{lang}.md")
610
+ candidates.append(readme_base / f"README.{lang}.md")
465
611
  short = lang.split("-")[0]
466
612
  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)
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
- context = {"content": html, "title": title, "toc": toc_html}
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.META.get("HTTP_REFERER", ""),
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 requestor."
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=_("Recurrency"),
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
- disable_emails = forms.BooleanField(
988
- label=_("Disable email delivery"),
1312
+ enable_emails = forms.BooleanField(
1313
+ label=_("Enable email delivery"),
989
1314
  required=False,
990
- help_text=_("Generate files without sending emails."),
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
- year, week_num = week_str.split("-W")
1010
- start = datetime.date.fromisocalendar(int(year), int(week_num), 1)
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 client reports."),
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
- "Client reports can only be generated periodically. Please wait before trying again."
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=form.cleaned_data.get("destinations"),
1093
- disable_emails=form.cleaned_data.get("disable_emails", False),
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=form.cleaned_data.get("destinations", []),
1103
- disable_emails=form.cleaned_data.get("disable_emails", False),
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
- "Client report schedule created; future reports will be generated automatically."
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 and not data.get("name"):
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
- if not story.name:
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.META.get("HTTP_REFERER", "")
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)