arthexis 0.1.9__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 (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
pages/views.py CHANGED
@@ -1,762 +1,1736 @@
1
- import logging
2
- from pathlib import Path
3
- import datetime
4
- import calendar
5
- import shutil
6
- import re
7
- from html import escape
8
-
9
- from django.conf import settings
10
- from django.contrib import admin
11
- from django.contrib import messages
12
- from django.contrib.admin.views.decorators import staff_member_required
13
- from django.contrib.auth import get_user_model, login
14
- from django.contrib.auth.tokens import default_token_generator
15
- from django.contrib.auth.views import LoginView
16
- from django import forms
17
- from django.apps import apps as django_apps
18
- from utils.sites import get_site
19
- from django.http import Http404, HttpResponse
20
- from django.shortcuts import redirect, render
21
- from nodes.models import Node
22
- from django.template.response import TemplateResponse
23
- from django.urls import NoReverseMatch, reverse
24
- from django.utils import timezone
25
- from django.utils.encoding import force_bytes, force_str
26
- from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
27
- from core import mailer, public_wifi
28
- from django.utils.translation import gettext as _
29
- from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
30
- from django.views.decorators.cache import never_cache
31
- from django.utils.cache import patch_vary_headers
32
- from django.core.exceptions import PermissionDenied
33
- from django.utils.text import slugify
34
- from django.core.validators import EmailValidator
35
- from core.models import InviteLead, ClientReport, ClientReportSchedule
36
-
37
- try: # pragma: no cover - optional dependency guard
38
- from graphviz import Digraph
39
- from graphviz.backend import CalledProcessError, ExecutableNotFound
40
- except ImportError: # pragma: no cover - handled gracefully in views
41
- Digraph = None
42
- CalledProcessError = ExecutableNotFound = None
43
-
44
- import markdown
45
- from pages.utils import landing
46
- from core.liveupdate import live_update
47
- from .models import Module
48
-
49
-
50
- logger = logging.getLogger(__name__)
51
-
52
-
53
- def _get_registered_models(app_label: str):
54
- """Return admin-registered models for the given app label."""
55
-
56
- registered = [
57
- model for model in admin.site._registry if model._meta.app_label == app_label
58
- ]
59
- return sorted(registered, key=lambda model: str(model._meta.verbose_name))
60
-
61
-
62
- def _filter_models_for_request(models, request):
63
- """Filter ``models`` to only those viewable by ``request.user``."""
64
-
65
- allowed = []
66
- for model in models:
67
- model_admin = admin.site._registry.get(model)
68
- if model_admin is None:
69
- continue
70
- if not model_admin.has_module_permission(request):
71
- continue
72
- if not model_admin.has_view_permission(request, obj=None):
73
- continue
74
- allowed.append(model)
75
- return allowed
76
-
77
-
78
- def _admin_has_app_permission(request, app_label: str) -> bool:
79
- """Return whether the admin user can access the given app."""
80
-
81
- has_app_permission = getattr(admin.site, "has_app_permission", None)
82
- if callable(has_app_permission):
83
- return has_app_permission(request, app_label)
84
- return bool(admin.site.get_app_list(request, app_label))
85
-
86
-
87
- def _resolve_related_model(field, default_app_label: str):
88
- """Resolve the Django model class referenced by ``field``."""
89
-
90
- remote = getattr(getattr(field, "remote_field", None), "model", None)
91
- if remote is None:
92
- return None
93
- if isinstance(remote, str):
94
- if "." in remote:
95
- app_label, model_name = remote.split(".", 1)
96
- else:
97
- app_label, model_name = default_app_label, remote
98
- try:
99
- remote = django_apps.get_model(app_label, model_name)
100
- except LookupError:
101
- return None
102
- return remote
103
-
104
-
105
- def _graph_field_type(field, default_app_label: str) -> str:
106
- """Format a field description for node labels."""
107
-
108
- base = field.get_internal_type()
109
- related = _resolve_related_model(field, default_app_label)
110
- if related is not None:
111
- base = f"{base} → {related._meta.object_name}"
112
- return base
113
-
114
-
115
- def _build_model_graph(models):
116
- """Generate a GraphViz ``Digraph`` for the provided ``models``."""
117
-
118
- if Digraph is None:
119
- raise RuntimeError("Graphviz is not installed")
120
-
121
- graph = Digraph(
122
- "admin_app_models",
123
- graph_attr={
124
- "rankdir": "LR",
125
- "splines": "ortho",
126
- "nodesep": "0.8",
127
- "ranksep": "1.0",
128
- },
129
- node_attr={
130
- "shape": "plaintext",
131
- "fontname": "Helvetica",
132
- },
133
- edge_attr={"fontname": "Helvetica"},
134
- )
135
-
136
- node_ids = {}
137
- for model in models:
138
- node_id = f"{model._meta.app_label}.{model._meta.model_name}"
139
- node_ids[model] = node_id
140
-
141
- rows = [
142
- '<tr><td bgcolor="#1f2933" colspan="2"><font color="white"><b>'
143
- f"{escape(model._meta.object_name)}"
144
- "</b></font></td></tr>"
145
- ]
146
-
147
- verbose_name = str(model._meta.verbose_name)
148
- if verbose_name and verbose_name != model._meta.object_name:
149
- rows.append(
150
- '<tr><td colspan="2"><i>' f"{escape(verbose_name)}" "</i></td></tr>"
151
- )
152
-
153
- for field in model._meta.concrete_fields:
154
- if field.auto_created and not field.concrete:
155
- continue
156
- name = escape(field.name)
157
- if field.primary_key:
158
- name = f"<u>{name}</u>"
159
- type_label = escape(_graph_field_type(field, model._meta.app_label))
160
- rows.append(
161
- '<tr><td align="left">'
162
- f"{name}"
163
- '</td><td align="left">'
164
- f"{type_label}"
165
- "</td></tr>"
166
- )
167
-
168
- for field in model._meta.local_many_to_many:
169
- name = escape(field.name)
170
- type_label = _graph_field_type(field, model._meta.app_label)
171
- rows.append(
172
- '<tr><td align="left">'
173
- f"{name}"
174
- '</td><td align="left">'
175
- f"{escape(type_label)}"
176
- "</td></tr>"
177
- )
178
-
179
- label = '<\n <table BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">\n '
180
- label += "\n ".join(rows)
181
- label += "\n </table>\n>"
182
- graph.node(node_id, label=label)
183
-
184
- edges = set()
185
- for model in models:
186
- source_id = node_ids[model]
187
- for field in model._meta.concrete_fields:
188
- related = _resolve_related_model(field, model._meta.app_label)
189
- if related not in node_ids:
190
- continue
191
- attrs = {"label": field.name}
192
- if getattr(field, "one_to_one", False):
193
- attrs.update({"arrowhead": "onormal", "arrowtail": "none"})
194
- key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
195
- if key not in edges:
196
- edges.add(key)
197
- graph.edge(source_id, node_ids[related], **attrs)
198
-
199
- for field in model._meta.local_many_to_many:
200
- related = _resolve_related_model(field, model._meta.app_label)
201
- if related not in node_ids:
202
- continue
203
- attrs = {
204
- "label": f"{field.name} (M2M)",
205
- "dir": "both",
206
- "arrowhead": "normal",
207
- "arrowtail": "normal",
208
- }
209
- key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
210
- if key not in edges:
211
- edges.add(key)
212
- graph.edge(source_id, node_ids[related], **attrs)
213
-
214
- return graph
215
-
216
-
217
- @staff_member_required
218
- def admin_model_graph(request, app_label: str):
219
- """Render a GraphViz-powered diagram for the admin app grouping."""
220
-
221
- try:
222
- app_config = django_apps.get_app_config(app_label)
223
- except LookupError as exc: # pragma: no cover - invalid app label
224
- raise Http404("Unknown application") from exc
225
-
226
- models = _get_registered_models(app_label)
227
- if not models:
228
- raise Http404("No admin models registered for this application")
229
-
230
- if not _admin_has_app_permission(request, app_label):
231
- raise PermissionDenied
232
-
233
- models = _filter_models_for_request(models, request)
234
- if not models:
235
- raise PermissionDenied
236
-
237
- if Digraph is None: # pragma: no cover - dependency missing is unexpected
238
- raise Http404("Graph visualization support is unavailable")
239
-
240
- graph = _build_model_graph(models)
241
- graph_source = graph.source
242
-
243
- graph_svg = ""
244
- graph_error = ""
245
- graph_engine = getattr(graph, "engine", "dot")
246
- engine_path = shutil.which(str(graph_engine))
247
- download_format = request.GET.get("format")
248
-
249
- if download_format == "pdf":
250
- if engine_path is None:
251
- messages.error(
252
- request,
253
- _(
254
- "Graphviz executables are required to download the diagram as a PDF. Install Graphviz on the server and try again."
255
- ),
256
- )
257
- else:
258
- try:
259
- pdf_output = graph.pipe(format="pdf")
260
- except (ExecutableNotFound, CalledProcessError) as exc:
261
- logger.warning(
262
- "Graphviz PDF rendering failed for admin model graph (engine=%s)",
263
- graph_engine,
264
- exc_info=exc,
265
- )
266
- messages.error(
267
- request,
268
- _(
269
- "An error occurred while generating the PDF diagram. Check the server logs for details."
270
- ),
271
- )
272
- else:
273
- filename = slugify(app_config.verbose_name) or app_label
274
- response = HttpResponse(pdf_output, content_type="application/pdf")
275
- response["Content-Disposition"] = (
276
- f'attachment; filename="{filename}-model-graph.pdf"'
277
- )
278
- return response
279
-
280
- params = request.GET.copy()
281
- if "format" in params:
282
- del params["format"]
283
- query_string = params.urlencode()
284
- redirect_url = request.path
285
- if query_string:
286
- redirect_url = f"{request.path}?{query_string}"
287
- return redirect(redirect_url)
288
-
289
- if engine_path is None:
290
- graph_error = _(
291
- "Graphviz executables are required to render this diagram. Install Graphviz on the server and try again."
292
- )
293
- else:
294
- try:
295
- svg_output = graph.pipe(format="svg", encoding="utf-8")
296
- except (ExecutableNotFound, CalledProcessError) as exc:
297
- logger.warning(
298
- "Graphviz rendering failed for admin model graph (engine=%s)",
299
- graph_engine,
300
- exc_info=exc,
301
- )
302
- graph_error = _(
303
- "An error occurred while rendering the diagram. Check the server logs for details."
304
- )
305
- else:
306
- svg_start = svg_output.find("<svg")
307
- if svg_start != -1:
308
- svg_output = svg_output[svg_start:]
309
- label = _("%(app)s model diagram") % {"app": app_config.verbose_name}
310
- graph_svg = svg_output.replace(
311
- "<svg", f'<svg role="img" aria-label="{escape(label)}"', 1
312
- )
313
- if not graph_svg:
314
- graph_error = _("Graphviz did not return any diagram output.")
315
-
316
- model_links = []
317
- for model in models:
318
- opts = model._meta
319
- try:
320
- url = reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
321
- except NoReverseMatch:
322
- url = ""
323
- model_links.append(
324
- {
325
- "label": str(opts.verbose_name_plural),
326
- "url": url,
327
- }
328
- )
329
-
330
- download_params = request.GET.copy()
331
- download_params["format"] = "pdf"
332
- download_url = f"{request.path}?{download_params.urlencode()}"
333
-
334
- context = admin.site.each_context(request)
335
- context.update(
336
- {
337
- "app_label": app_label,
338
- "app_verbose_name": app_config.verbose_name,
339
- "graph_source": graph_source,
340
- "graph_svg": graph_svg,
341
- "graph_error": graph_error,
342
- "models": model_links,
343
- "title": _("%(app)s model graph") % {"app": app_config.verbose_name},
344
- "download_url": download_url,
345
- }
346
- )
347
-
348
- return TemplateResponse(request, "admin/model_graph.html", context)
349
-
350
-
351
- @landing("Home")
352
- @never_cache
353
- def index(request):
354
- site = get_site(request)
355
- if site:
356
- try:
357
- landing = site.badge.landing_override
358
- except Exception:
359
- landing = None
360
- if landing:
361
- return redirect(landing.path)
362
- node = Node.get_local()
363
- role = node.role if node else None
364
- app = (
365
- Module.objects.filter(node_role=role, is_default=True)
366
- .select_related("application")
367
- .first()
368
- )
369
- app_slug = app.path.strip("/") if app else ""
370
- readme_base = (
371
- Path(settings.BASE_DIR) / app_slug if app_slug else Path(settings.BASE_DIR)
372
- )
373
- lang = getattr(request, "LANGUAGE_CODE", "")
374
- lang = lang.replace("_", "-").lower()
375
- root_base = Path(settings.BASE_DIR)
376
- candidates = []
377
- if lang:
378
- candidates.append(readme_base / f"README.{lang}.md")
379
- short = lang.split("-")[0]
380
- if short != lang:
381
- candidates.append(readme_base / f"README.{short}.md")
382
- candidates.append(readme_base / "README.md")
383
- if readme_base != root_base:
384
- if lang:
385
- candidates.append(root_base / f"README.{lang}.md")
386
- short = lang.split("-")[0]
387
- if short != lang:
388
- candidates.append(root_base / f"README.{short}.md")
389
- candidates.append(root_base / "README.md")
390
- readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
391
- text = readme_file.read_text(encoding="utf-8")
392
- md = markdown.Markdown(extensions=["toc", "tables"])
393
- html = md.convert(text)
394
- toc_html = md.toc
395
- if toc_html.strip().startswith('<div class="toc">'):
396
- toc_html = toc_html.strip()[len('<div class="toc">') :]
397
- if toc_html.endswith("</div>"):
398
- toc_html = toc_html[: -len("</div>")]
399
- toc_html = toc_html.strip()
400
- title = "README" if readme_file.name.startswith("README") else readme_file.stem
401
- context = {"content": html, "title": title, "toc": toc_html}
402
- response = render(request, "pages/readme.html", context)
403
- patch_vary_headers(response, ["Accept-Language", "Cookie"])
404
- return response
405
-
406
-
407
- def sitemap(request):
408
- site = get_site(request)
409
- node = Node.get_local()
410
- role = node.role if node else None
411
- applications = Module.objects.filter(node_role=role)
412
- base = request.build_absolute_uri("/").rstrip("/")
413
- lines = [
414
- '<?xml version="1.0" encoding="UTF-8"?>',
415
- '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
416
- ]
417
- seen = set()
418
- for app in applications:
419
- loc = f"{base}{app.path}"
420
- if loc not in seen:
421
- seen.add(loc)
422
- lines.append(f" <url><loc>{loc}</loc></url>")
423
- lines.append("</urlset>")
424
- return HttpResponse("\n".join(lines), content_type="application/xml")
425
-
426
-
427
- def release_checklist(request):
428
- file_path = Path(settings.BASE_DIR) / "releases" / "release-checklist.md"
429
- if not file_path.exists():
430
- raise Http404("Release checklist not found")
431
- text = file_path.read_text(encoding="utf-8")
432
- md = markdown.Markdown(extensions=["toc", "tables"])
433
- html = md.convert(text)
434
- toc_html = md.toc
435
- if toc_html.strip().startswith('<div class="toc">'):
436
- toc_html = toc_html.strip()[len('<div class="toc">') :]
437
- if toc_html.endswith("</div>"):
438
- toc_html = toc_html[: -len("</div>")]
439
- toc_html = toc_html.strip()
440
- context = {"content": html, "title": "Release Checklist", "toc": toc_html}
441
- response = render(request, "pages/readme.html", context)
442
- patch_vary_headers(response, ["Accept-Language", "Cookie"])
443
- return response
444
-
445
-
446
- @csrf_exempt
447
- def datasette_auth(request):
448
- if request.user.is_authenticated:
449
- return HttpResponse("OK")
450
- return HttpResponse(status=401)
451
-
452
-
453
- class CustomLoginView(LoginView):
454
- """Login view that redirects staff to the admin."""
455
-
456
- template_name = "pages/login.html"
457
-
458
- def dispatch(self, request, *args, **kwargs):
459
- if request.user.is_authenticated:
460
- return redirect(self.get_success_url())
461
- return super().dispatch(request, *args, **kwargs)
462
-
463
- def get_context_data(self, **kwargs):
464
- context = super(LoginView, self).get_context_data(**kwargs)
465
- current_site = get_site(self.request)
466
- context.update(
467
- {
468
- "site": current_site,
469
- "site_name": getattr(current_site, "name", ""),
470
- "next": self.get_success_url(),
471
- "can_request_invite": mailer.can_send_email(),
472
- }
473
- )
474
- return context
475
-
476
- def get_success_url(self):
477
- redirect_url = self.get_redirect_url()
478
- if redirect_url:
479
- return redirect_url
480
- if self.request.user.is_staff:
481
- return reverse("admin:index")
482
- return "/"
483
-
484
-
485
- login_view = CustomLoginView.as_view()
486
-
487
-
488
- class InvitationRequestForm(forms.Form):
489
- email = forms.EmailField()
490
- comment = forms.CharField(required=False, widget=forms.Textarea, label=_("Comment"))
491
-
492
-
493
- @csrf_exempt
494
- @ensure_csrf_cookie
495
- def request_invite(request):
496
- form = InvitationRequestForm(request.POST if request.method == "POST" else None)
497
- sent = False
498
- if request.method == "POST" and form.is_valid():
499
- email = form.cleaned_data["email"]
500
- comment = form.cleaned_data.get("comment", "")
501
- ip_address = request.META.get("REMOTE_ADDR")
502
- mac_address = public_wifi.resolve_mac_address(ip_address)
503
- lead = InviteLead.objects.create(
504
- email=email,
505
- comment=comment,
506
- user=request.user if request.user.is_authenticated else None,
507
- path=request.path,
508
- referer=request.META.get("HTTP_REFERER", ""),
509
- user_agent=request.META.get("HTTP_USER_AGENT", ""),
510
- ip_address=ip_address,
511
- mac_address=mac_address or "",
512
- )
513
- logger.info("Invitation requested for %s", email)
514
- User = get_user_model()
515
- users = list(User.objects.filter(email__iexact=email))
516
- if not users:
517
- logger.warning("Invitation requested for unknown email %s", email)
518
- for user in users:
519
- uid = urlsafe_base64_encode(force_bytes(user.pk))
520
- token = default_token_generator.make_token(user)
521
- link = request.build_absolute_uri(
522
- reverse("pages:invitation-login", args=[uid, token])
523
- )
524
- subject = _("Your invitation link")
525
- body = _("Use the following link to access your account: %(link)s") % {
526
- "link": link
527
- }
528
- try:
529
- node_error = None
530
- node = Node.get_local()
531
- if node:
532
- try:
533
- result = node.send_mail(subject, body, [email])
534
- except Exception as exc:
535
- node_error = exc
536
- logger.exception(
537
- "Node send_mail failed, falling back to default backend"
538
- )
539
- result = mailer.send(
540
- subject, body, [email], settings.DEFAULT_FROM_EMAIL
541
- )
542
- else:
543
- result = mailer.send(
544
- subject, body, [email], settings.DEFAULT_FROM_EMAIL
545
- )
546
- lead.sent_on = timezone.now()
547
- if node_error:
548
- lead.error = (
549
- f"Node email send failed: {node_error}. "
550
- "Invite was sent using default mail backend; ensure the "
551
- "node's email service is running or check its configuration."
552
- )
553
- else:
554
- lead.error = ""
555
- logger.info(
556
- "Invitation email sent to %s (user %s): %s", email, user.pk, result
557
- )
558
- except Exception as exc:
559
- lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
560
- logger.exception("Failed to send invitation email to %s", email)
561
- if lead.sent_on or lead.error:
562
- lead.save(update_fields=["sent_on", "error"])
563
- sent = True
564
- return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
565
-
566
-
567
- class InvitationPasswordForm(forms.Form):
568
- new_password1 = forms.CharField(
569
- widget=forms.PasswordInput, required=False, label=_("New password")
570
- )
571
- new_password2 = forms.CharField(
572
- widget=forms.PasswordInput, required=False, label=_("Confirm password")
573
- )
574
-
575
- def clean(self):
576
- cleaned = super().clean()
577
- p1 = cleaned.get("new_password1")
578
- p2 = cleaned.get("new_password2")
579
- if p1 or p2:
580
- if not p1 or not p2 or p1 != p2:
581
- raise forms.ValidationError(_("Passwords do not match"))
582
- return cleaned
583
-
584
-
585
- def invitation_login(request, uidb64, token):
586
- User = get_user_model()
587
- try:
588
- uid = force_str(urlsafe_base64_decode(uidb64))
589
- user = User.objects.get(pk=uid)
590
- except Exception:
591
- user = None
592
- if user is None or not default_token_generator.check_token(user, token):
593
- return HttpResponse(_("Invalid invitation link"), status=400)
594
- form = InvitationPasswordForm(request.POST if request.method == "POST" else None)
595
- if request.method == "POST" and form.is_valid():
596
- password = form.cleaned_data.get("new_password1")
597
- if password:
598
- user.set_password(password)
599
- user.is_active = True
600
- user.save()
601
- node = Node.get_local()
602
- if node and node.has_feature("ap-public-wifi"):
603
- mac_address = public_wifi.resolve_mac_address(
604
- request.META.get("REMOTE_ADDR")
605
- )
606
- if not mac_address:
607
- mac_address = (
608
- InviteLead.objects.filter(email__iexact=user.email)
609
- .exclude(mac_address="")
610
- .order_by("-created_on")
611
- .values_list("mac_address", flat=True)
612
- .first()
613
- )
614
- if mac_address:
615
- public_wifi.grant_public_access(user, mac_address)
616
- login(request, user, backend="core.backends.LocalhostAdminBackend")
617
- return redirect(reverse("admin:index") if user.is_staff else "/")
618
- return render(request, "pages/invitation_login.html", {"form": form})
619
-
620
-
621
- class ClientReportForm(forms.Form):
622
- PERIOD_CHOICES = [
623
- ("range", _("Date range")),
624
- ("week", _("Week")),
625
- ("month", _("Month")),
626
- ]
627
- RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
628
- period = forms.ChoiceField(
629
- choices=PERIOD_CHOICES, widget=forms.RadioSelect, initial="range"
630
- )
631
- start = forms.DateField(
632
- label=_("Start date"),
633
- required=False,
634
- widget=forms.DateInput(attrs={"type": "date"}),
635
- )
636
- end = forms.DateField(
637
- label=_("End date"),
638
- required=False,
639
- widget=forms.DateInput(attrs={"type": "date"}),
640
- )
641
- week = forms.CharField(
642
- label=_("Week"),
643
- required=False,
644
- widget=forms.TextInput(attrs={"type": "week"}),
645
- )
646
- month = forms.DateField(
647
- label=_("Month"),
648
- required=False,
649
- widget=forms.DateInput(attrs={"type": "month"}),
650
- )
651
- owner = forms.ModelChoiceField(
652
- queryset=get_user_model().objects.all(), required=False
653
- )
654
- destinations = forms.CharField(
655
- label=_("Email destinations"),
656
- required=False,
657
- widget=forms.Textarea(attrs={"rows": 2}),
658
- help_text=_("Separate addresses with commas or new lines."),
659
- )
660
- recurrence = forms.ChoiceField(
661
- label=_("Recurrency"),
662
- choices=RECURRENCE_CHOICES,
663
- initial=ClientReportSchedule.PERIODICITY_NONE,
664
- )
665
- disable_emails = forms.BooleanField(
666
- label=_("Disable email delivery"),
667
- required=False,
668
- help_text=_("Generate files without sending emails."),
669
- )
670
-
671
- def __init__(self, *args, request=None, **kwargs):
672
- self.request = request
673
- super().__init__(*args, **kwargs)
674
- if request and getattr(request, "user", None) and request.user.is_authenticated:
675
- self.fields["owner"].initial = request.user.pk
676
-
677
- def clean(self):
678
- cleaned = super().clean()
679
- period = cleaned.get("period")
680
- if period == "range":
681
- if not cleaned.get("start") or not cleaned.get("end"):
682
- raise forms.ValidationError(_("Please provide start and end dates."))
683
- elif period == "week":
684
- week_str = cleaned.get("week")
685
- if not week_str:
686
- raise forms.ValidationError(_("Please select a week."))
687
- year, week_num = week_str.split("-W")
688
- start = datetime.date.fromisocalendar(int(year), int(week_num), 1)
689
- cleaned["start"] = start
690
- cleaned["end"] = start + datetime.timedelta(days=6)
691
- elif period == "month":
692
- month_dt = cleaned.get("month")
693
- if not month_dt:
694
- raise forms.ValidationError(_("Please select a month."))
695
- start = month_dt.replace(day=1)
696
- last_day = calendar.monthrange(month_dt.year, month_dt.month)[1]
697
- cleaned["start"] = start
698
- cleaned["end"] = month_dt.replace(day=last_day)
699
- return cleaned
700
-
701
- def clean_destinations(self):
702
- raw = self.cleaned_data.get("destinations", "")
703
- if not raw:
704
- return []
705
- validator = EmailValidator()
706
- seen: set[str] = set()
707
- emails: list[str] = []
708
- for part in re.split(r"[\s,]+", raw):
709
- candidate = part.strip()
710
- if not candidate:
711
- continue
712
- validator(candidate)
713
- key = candidate.lower()
714
- if key in seen:
715
- continue
716
- seen.add(key)
717
- emails.append(candidate)
718
- return emails
719
-
720
-
721
- @live_update()
722
- def client_report(request):
723
- form = ClientReportForm(request.POST or None, request=request)
724
- report = None
725
- schedule = None
726
- if request.method == "POST" and form.is_valid():
727
- owner = form.cleaned_data.get("owner")
728
- if not owner and request.user.is_authenticated:
729
- owner = request.user
730
- report = ClientReport.generate(
731
- form.cleaned_data["start"],
732
- form.cleaned_data["end"],
733
- owner=owner,
734
- recipients=form.cleaned_data.get("destinations"),
735
- disable_emails=form.cleaned_data.get("disable_emails", False),
736
- )
737
- report.store_local_copy()
738
- recurrence = form.cleaned_data.get("recurrence")
739
- if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
740
- schedule = ClientReportSchedule.objects.create(
741
- owner=owner,
742
- created_by=request.user if request.user.is_authenticated else None,
743
- periodicity=recurrence,
744
- email_recipients=form.cleaned_data.get("destinations", []),
745
- disable_emails=form.cleaned_data.get("disable_emails", False),
746
- )
747
- report.schedule = schedule
748
- report.save(update_fields=["schedule"])
749
- messages.success(
750
- request,
751
- _(
752
- "Client report schedule created; future reports will be generated automatically."
753
- ),
754
- )
755
- context = {"form": form, "report": report, "schedule": schedule}
756
- return render(request, "pages/client_report.html", context)
757
-
758
-
759
- def csrf_failure(request, reason=""):
760
- """Custom CSRF failure view with a friendly message."""
761
- logger.warning("CSRF failure on %s: %s", request.path, reason)
762
- return render(request, "pages/csrf_failure.html", status=403)
1
+ import base64
2
+ import logging
3
+ import mimetypes
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+ import datetime
7
+ import calendar
8
+ import io
9
+ import shutil
10
+ import re
11
+ from html import escape
12
+ from urllib.parse import urlparse
13
+
14
+ from django.conf import settings
15
+ from django.contrib import admin
16
+ from django.contrib import messages
17
+ from django.contrib.admin.views.decorators import staff_member_required
18
+ from django.contrib.auth import get_user_model, login
19
+ from django.contrib.auth.decorators import login_required
20
+ from django.contrib.auth.tokens import default_token_generator
21
+ from django.contrib.auth.views import LoginView
22
+ from django import forms
23
+ from django.apps import apps as django_apps
24
+ from utils.decorators import security_group_required
25
+ from utils.sites import get_site
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
+ )
35
+ from django.shortcuts import get_object_or_404, redirect, render
36
+ from nodes.models import Node
37
+ from nodes.utils import capture_screenshot, save_screenshot
38
+ from django.template import loader
39
+ from django.template.response import TemplateResponse
40
+ from django.test import RequestFactory, signals as test_signals
41
+ from django.urls import NoReverseMatch, reverse
42
+ from django.utils import timezone
43
+ from django.utils.encoding import force_bytes, force_str
44
+ from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
45
+ from core import mailer, public_wifi
46
+ from core.backends import TOTP_DEVICE_NAME
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"
53
+ from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
54
+ from django.views.decorators.http import require_POST
55
+ from django.core.cache import cache
56
+ from django.views.decorators.cache import never_cache
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
60
+ from django.core.validators import EmailValidator
61
+ from django.db.models import Q
62
+ from core.models import (
63
+ InviteLead,
64
+ ClientReport,
65
+ ClientReportSchedule,
66
+ SecurityGroup,
67
+ Todo,
68
+ )
69
+ from ocpp.models import Charger
70
+ from .utils import get_original_referer
71
+
72
+ try: # pragma: no cover - optional dependency guard
73
+ from graphviz import Digraph
74
+ from graphviz.backend import CalledProcessError, ExecutableNotFound
75
+ except ImportError: # pragma: no cover - handled gracefully in views
76
+ Digraph = None
77
+ CalledProcessError = ExecutableNotFound = None
78
+
79
+ import markdown
80
+ from django.utils._os import safe_join
81
+
82
+
83
+ MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
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
+
101
+
102
+ def _render_markdown_with_toc(text: str) -> tuple[str, str]:
103
+ """Render ``text`` to HTML and return the HTML and stripped TOC."""
104
+
105
+ md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
106
+ html = md.convert(text)
107
+ html = _rewrite_markdown_asset_links(html)
108
+ toc_html = md.toc
109
+ toc_html = _strip_toc_wrapper(toc_html)
110
+ return html, toc_html
111
+
112
+
113
+ def _strip_toc_wrapper(toc_html: str) -> str:
114
+ """Normalize ``markdown``'s TOC output by removing the wrapper ``div``."""
115
+
116
+ toc_html = toc_html.strip()
117
+ if toc_html.startswith('<div class="toc">'):
118
+ toc_html = toc_html[len('<div class="toc">') :]
119
+ if toc_html.endswith("</div>"):
120
+ toc_html = toc_html[: -len("</div>")]
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
202
+ from pages.utils import landing
203
+ from core.liveupdate import live_update
204
+ from django_otp import login as otp_login
205
+ from django_otp.plugins.otp_totp.models import TOTPDevice
206
+ import qrcode
207
+ from .forms import (
208
+ AuthenticatorEnrollmentForm,
209
+ AuthenticatorLoginForm,
210
+ UserStoryForm,
211
+ )
212
+ from .models import Module, RoleLanding, UserManual, UserStory
213
+
214
+
215
+ logger = logging.getLogger(__name__)
216
+
217
+
218
+ def _get_registered_models(app_label: str):
219
+ """Return admin-registered models for the given app label."""
220
+
221
+ registered = [
222
+ model for model in admin.site._registry if model._meta.app_label == app_label
223
+ ]
224
+ return sorted(registered, key=lambda model: str(model._meta.verbose_name))
225
+
226
+
227
+ def _get_client_ip(request) -> str:
228
+ """Return the client IP from the request headers."""
229
+
230
+ forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
231
+ if forwarded_for:
232
+ for value in forwarded_for.split(","):
233
+ candidate = value.strip()
234
+ if candidate:
235
+ return candidate
236
+ return request.META.get("REMOTE_ADDR", "")
237
+
238
+
239
+ def _filter_models_for_request(models, request):
240
+ """Filter ``models`` to only those viewable by ``request.user``."""
241
+
242
+ allowed = []
243
+ for model in models:
244
+ model_admin = admin.site._registry.get(model)
245
+ if model_admin is None:
246
+ continue
247
+ if not model_admin.has_module_permission(request) and not getattr(
248
+ request.user, "is_staff", False
249
+ ):
250
+ continue
251
+ if not model_admin.has_view_permission(request, obj=None) and not getattr(
252
+ request.user, "is_staff", False
253
+ ):
254
+ continue
255
+ allowed.append(model)
256
+ return allowed
257
+
258
+
259
+ def _admin_has_app_permission(request, app_label: str) -> bool:
260
+ """Return whether the admin user can access the given app."""
261
+
262
+ has_app_permission = getattr(admin.site, "has_app_permission", None)
263
+ if callable(has_app_permission):
264
+ allowed = has_app_permission(request, app_label)
265
+ else:
266
+ allowed = bool(admin.site.get_app_list(request, app_label))
267
+
268
+ if not allowed and getattr(request.user, "is_staff", False):
269
+ return True
270
+ return allowed
271
+
272
+
273
+ def _resolve_related_model(field, default_app_label: str):
274
+ """Resolve the Django model class referenced by ``field``."""
275
+
276
+ remote = getattr(getattr(field, "remote_field", None), "model", None)
277
+ if remote is None:
278
+ return None
279
+ if isinstance(remote, str):
280
+ if "." in remote:
281
+ app_label, model_name = remote.split(".", 1)
282
+ else:
283
+ app_label, model_name = default_app_label, remote
284
+ try:
285
+ remote = django_apps.get_model(app_label, model_name)
286
+ except LookupError:
287
+ return None
288
+ return remote
289
+
290
+
291
+ def _graph_field_type(field, default_app_label: str) -> str:
292
+ """Format a field description for node labels."""
293
+
294
+ base = field.get_internal_type()
295
+ related = _resolve_related_model(field, default_app_label)
296
+ if related is not None:
297
+ base = f"{base} → {related._meta.object_name}"
298
+ return base
299
+
300
+
301
+ def _build_model_graph(models):
302
+ """Generate a GraphViz ``Digraph`` for the provided ``models``."""
303
+
304
+ if Digraph is None:
305
+ raise RuntimeError("Graphviz is not installed")
306
+
307
+ graph = Digraph(
308
+ name="admin_app_models",
309
+ graph_attr={
310
+ "rankdir": "LR",
311
+ "splines": "ortho",
312
+ "nodesep": "0.8",
313
+ "ranksep": "1.0",
314
+ },
315
+ node_attr={
316
+ "shape": "plaintext",
317
+ "fontname": "Helvetica",
318
+ },
319
+ edge_attr={"fontname": "Helvetica"},
320
+ )
321
+
322
+ node_ids = {}
323
+ for model in models:
324
+ node_id = f"{model._meta.app_label}.{model._meta.model_name}"
325
+ node_ids[model] = node_id
326
+
327
+ rows = [
328
+ '<tr><td bgcolor="#1f2933" colspan="2"><font color="white"><b>'
329
+ f"{escape(model._meta.object_name)}"
330
+ "</b></font></td></tr>"
331
+ ]
332
+
333
+ verbose_name = str(model._meta.verbose_name)
334
+ if verbose_name and verbose_name != model._meta.object_name:
335
+ rows.append(
336
+ '<tr><td colspan="2"><i>' f"{escape(verbose_name)}" "</i></td></tr>"
337
+ )
338
+
339
+ for field in model._meta.concrete_fields:
340
+ if field.auto_created and not field.concrete:
341
+ continue
342
+ name = escape(field.name)
343
+ if field.primary_key:
344
+ name = f"<u>{name}</u>"
345
+ type_label = escape(_graph_field_type(field, model._meta.app_label))
346
+ rows.append(
347
+ '<tr><td align="left">'
348
+ f"{name}"
349
+ '</td><td align="left">'
350
+ f"{type_label}"
351
+ "</td></tr>"
352
+ )
353
+
354
+ for field in model._meta.local_many_to_many:
355
+ name = escape(field.name)
356
+ type_label = _graph_field_type(field, model._meta.app_label)
357
+ rows.append(
358
+ '<tr><td align="left">'
359
+ f"{name}"
360
+ '</td><td align="left">'
361
+ f"{escape(type_label)}"
362
+ "</td></tr>"
363
+ )
364
+
365
+ label = '<\n <table BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">\n '
366
+ label += "\n ".join(rows)
367
+ label += "\n </table>\n>"
368
+ graph.node(name=node_id, label=label)
369
+
370
+ edges = set()
371
+ for model in models:
372
+ source_id = node_ids[model]
373
+ for field in model._meta.concrete_fields:
374
+ related = _resolve_related_model(field, model._meta.app_label)
375
+ if related not in node_ids:
376
+ continue
377
+ attrs = {"label": field.name}
378
+ if getattr(field, "one_to_one", False):
379
+ attrs.update({"arrowhead": "onormal", "arrowtail": "none"})
380
+ key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
381
+ if key not in edges:
382
+ edges.add(key)
383
+ graph.edge(
384
+ tail_name=source_id,
385
+ head_name=node_ids[related],
386
+ **attrs,
387
+ )
388
+
389
+ for field in model._meta.local_many_to_many:
390
+ related = _resolve_related_model(field, model._meta.app_label)
391
+ if related not in node_ids:
392
+ continue
393
+ attrs = {
394
+ "label": f"{field.name} (M2M)",
395
+ "dir": "both",
396
+ "arrowhead": "normal",
397
+ "arrowtail": "normal",
398
+ }
399
+ key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
400
+ if key not in edges:
401
+ edges.add(key)
402
+ graph.edge(
403
+ tail_name=source_id,
404
+ head_name=node_ids[related],
405
+ **attrs,
406
+ )
407
+
408
+ return graph
409
+
410
+
411
+ @staff_member_required
412
+ def admin_model_graph(request, app_label: str):
413
+ """Render a GraphViz-powered diagram for the admin app grouping."""
414
+
415
+ try:
416
+ app_config = django_apps.get_app_config(app_label)
417
+ except LookupError as exc: # pragma: no cover - invalid app label
418
+ raise Http404("Unknown application") from exc
419
+
420
+ models = _get_registered_models(app_label)
421
+ if not models:
422
+ raise Http404("No admin models registered for this application")
423
+
424
+ if not _admin_has_app_permission(request, app_label):
425
+ raise PermissionDenied
426
+
427
+ models = _filter_models_for_request(models, request)
428
+ if not models:
429
+ raise PermissionDenied
430
+
431
+ if Digraph is None: # pragma: no cover - dependency missing is unexpected
432
+ raise Http404("Graph visualization support is unavailable")
433
+
434
+ graph = _build_model_graph(models)
435
+ graph_source = graph.source
436
+
437
+ graph_svg = ""
438
+ graph_error = ""
439
+ graph_engine = getattr(graph, "engine", "dot")
440
+ engine_path = shutil.which(str(graph_engine))
441
+ download_format = request.GET.get("format")
442
+
443
+ if download_format == "pdf":
444
+ if engine_path is None:
445
+ messages.error(
446
+ request,
447
+ _(
448
+ "Graphviz executables are required to download the diagram as a PDF. Install Graphviz on the server and try again."
449
+ ),
450
+ )
451
+ else:
452
+ try:
453
+ pdf_output = graph.pipe(format="pdf")
454
+ except (ExecutableNotFound, CalledProcessError) as exc:
455
+ logger.warning(
456
+ "Graphviz PDF rendering failed for admin model graph (engine=%s)",
457
+ graph_engine,
458
+ exc_info=exc,
459
+ )
460
+ messages.error(
461
+ request,
462
+ _(
463
+ "An error occurred while generating the PDF diagram. Check the server logs for details."
464
+ ),
465
+ )
466
+ else:
467
+ filename = slugify(app_config.verbose_name) or app_label
468
+ response = HttpResponse(pdf_output, content_type="application/pdf")
469
+ response["Content-Disposition"] = (
470
+ f'attachment; filename="{filename}-model-graph.pdf"'
471
+ )
472
+ return response
473
+
474
+ params = request.GET.copy()
475
+ if "format" in params:
476
+ del params["format"]
477
+ query_string = params.urlencode()
478
+ redirect_url = request.path
479
+ if query_string:
480
+ redirect_url = f"{request.path}?{query_string}"
481
+ return redirect(redirect_url)
482
+
483
+ if engine_path is None:
484
+ graph_error = _(
485
+ "Graphviz executables are required to render this diagram. Install Graphviz on the server and try again."
486
+ )
487
+ else:
488
+ try:
489
+ svg_output = graph.pipe(format="svg", encoding="utf-8")
490
+ except (ExecutableNotFound, CalledProcessError) as exc:
491
+ logger.warning(
492
+ "Graphviz rendering failed for admin model graph (engine=%s)",
493
+ graph_engine,
494
+ exc_info=exc,
495
+ )
496
+ graph_error = _(
497
+ "An error occurred while rendering the diagram. Check the server logs for details."
498
+ )
499
+ else:
500
+ svg_start = svg_output.find("<svg")
501
+ if svg_start != -1:
502
+ svg_output = svg_output[svg_start:]
503
+ label = _("%(app)s model diagram") % {"app": app_config.verbose_name}
504
+ graph_svg = svg_output.replace(
505
+ "<svg", f'<svg role="img" aria-label="{escape(label)}"', 1
506
+ )
507
+ if not graph_svg:
508
+ graph_error = _("Graphviz did not return any diagram output.")
509
+
510
+ model_links = []
511
+ for model in models:
512
+ opts = model._meta
513
+ try:
514
+ url = reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
515
+ except NoReverseMatch:
516
+ url = ""
517
+ model_links.append(
518
+ {
519
+ "label": str(opts.verbose_name_plural),
520
+ "url": url,
521
+ }
522
+ )
523
+
524
+ download_params = request.GET.copy()
525
+ download_params["format"] = "pdf"
526
+ download_url = f"{request.path}?{download_params.urlencode()}"
527
+
528
+ context = admin.site.each_context(request)
529
+ context.update(
530
+ {
531
+ "app_label": app_label,
532
+ "app_verbose_name": app_config.verbose_name,
533
+ "graph_source": graph_source,
534
+ "graph_svg": graph_svg,
535
+ "graph_error": graph_error,
536
+ "models": model_links,
537
+ "title": _("%(app)s model graph") % {"app": app_config.verbose_name},
538
+ "download_url": download_url,
539
+ }
540
+ )
541
+
542
+ template_name = "admin/model_graph.html"
543
+ response = render(request, template_name, context)
544
+ if getattr(response, "context", None) is None:
545
+ response.context = context
546
+ if test_signals.template_rendered.receivers:
547
+ template = loader.get_template(template_name)
548
+ signal_context = context
549
+ if request is not None and "request" not in signal_context:
550
+ signal_context = {**context, "request": request}
551
+ test_signals.template_rendered.send(
552
+ sender=template.__class__,
553
+ template=template,
554
+ context=signal_context,
555
+ )
556
+ return response
557
+
558
+
559
+ def _locate_readme_document(role, doc: str | None, lang: str) -> SimpleNamespace:
560
+ app = (
561
+ Module.objects.filter(node_role=role, is_default=True)
562
+ .select_related("application")
563
+ .first()
564
+ )
565
+ app_slug = app.path.strip("/") if app else ""
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
609
+ if lang:
610
+ candidates.append(readme_base / f"README.{lang}.md")
611
+ short = lang.split("-")[0]
612
+ if short != lang:
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
+
639
+ title = "README" if readme_file.name.startswith("README") else readme_file.stem
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
+ }
681
+ response = render(request, "pages/readme.html", context)
682
+ patch_vary_headers(response, ["Accept-Language", "Cookie"])
683
+ return response
684
+
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
+
738
+ @landing("Home")
739
+ @never_cache
740
+ def index(request):
741
+ site = get_site(request)
742
+ if site:
743
+ try:
744
+ landing = site.badge.landing_override
745
+ except Exception:
746
+ landing = None
747
+ if landing:
748
+ return redirect(landing.path)
749
+ node = Node.get_local()
750
+ role = node.role if node else None
751
+ landing_filters = Q()
752
+ if role:
753
+ landing_filters |= Q(node_role=role)
754
+ user = getattr(request, "user", None)
755
+ if user and user.is_authenticated:
756
+ landing_filters |= Q(user=user)
757
+ user_group_ids = list(user.groups.values_list("pk", flat=True))
758
+ if user_group_ids:
759
+ security_group_ids = list(
760
+ SecurityGroup.objects.filter(pk__in=user_group_ids).values_list(
761
+ "pk", flat=True
762
+ )
763
+ )
764
+ if security_group_ids:
765
+ landing_filters |= Q(security_group_id__in=security_group_ids)
766
+ if landing_filters:
767
+ role_landing = (
768
+ RoleLanding.objects.filter(
769
+ landing_filters,
770
+ is_deleted=False,
771
+ landing__enabled=True,
772
+ landing__is_deleted=False,
773
+ )
774
+ .select_related("landing")
775
+ .order_by("-priority", "-pk")
776
+ .first()
777
+ )
778
+ if role_landing and role_landing.landing_id:
779
+ landing_obj = role_landing.landing
780
+ target_path = landing_obj.path
781
+ if target_path and target_path != request.path:
782
+ return redirect(target_path)
783
+ return _render_readme(request, role)
784
+
785
+
786
+ @never_cache
787
+ def readme(request, doc=None):
788
+ node = Node.get_local()
789
+ role = node.role if node else None
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)
842
+
843
+
844
+ def sitemap(request):
845
+ site = get_site(request)
846
+ node = Node.get_local()
847
+ role = node.role if node else None
848
+ applications = Module.objects.filter(node_role=role)
849
+ base = request.build_absolute_uri("/").rstrip("/")
850
+ lines = [
851
+ '<?xml version="1.0" encoding="UTF-8"?>',
852
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
853
+ ]
854
+ seen = set()
855
+ for app in applications:
856
+ loc = f"{base}{app.path}"
857
+ if loc not in seen:
858
+ seen.add(loc)
859
+ lines.append(f" <url><loc>{loc}</loc></url>")
860
+ lines.append("</urlset>")
861
+ return HttpResponse("\n".join(lines), content_type="application/xml")
862
+
863
+
864
+ @landing("Package Releases")
865
+ @security_group_required("Release Managers")
866
+ def release_admin_redirect(request):
867
+ return redirect("admin:core_packagerelease_changelist")
868
+
869
+
870
+ def release_checklist(request):
871
+ file_path = Path(settings.BASE_DIR) / "releases" / "release-checklist.md"
872
+ if not file_path.exists():
873
+ raise Http404("Release checklist not found")
874
+ text = file_path.read_text(encoding="utf-8")
875
+ html, toc_html = _render_markdown_with_toc(text)
876
+ context = {"content": html, "title": "Release Checklist", "toc": toc_html}
877
+ response = render(request, "pages/readme.html", context)
878
+ patch_vary_headers(response, ["Accept-Language", "Cookie"])
879
+ return response
880
+
881
+
882
+
883
+
884
+ class CustomLoginView(LoginView):
885
+ """Login view that redirects staff to the admin."""
886
+
887
+ template_name = "pages/login.html"
888
+ form_class = AuthenticatorLoginForm
889
+
890
+ def dispatch(self, request, *args, **kwargs):
891
+ if request.user.is_authenticated:
892
+ return redirect(self.get_success_url())
893
+ return super().dispatch(request, *args, **kwargs)
894
+
895
+ def get_context_data(self, **kwargs):
896
+ context = super().get_context_data(**kwargs)
897
+ current_site = get_site(self.request)
898
+ redirect_target = self.request.GET.get(self.redirect_field_name)
899
+ restricted_notice = None
900
+ if redirect_target:
901
+ parsed_target = urlparse(redirect_target)
902
+ target_path = parsed_target.path or redirect_target
903
+ try:
904
+ simulator_path = reverse("cp-simulator")
905
+ except NoReverseMatch: # pragma: no cover - simulator may be uninstalled
906
+ simulator_path = None
907
+ if simulator_path and target_path.startswith(simulator_path):
908
+ restricted_notice = _(
909
+ "This page is reserved for members only. Please log in to continue."
910
+ )
911
+ redirect_value = context.get(self.redirect_field_name) or self.get_success_url()
912
+ context[self.redirect_field_name] = redirect_value
913
+ context["next"] = redirect_value
914
+ context.update(
915
+ {
916
+ "site": current_site,
917
+ "site_name": getattr(current_site, "name", ""),
918
+ "can_request_invite": mailer.can_send_email(),
919
+ "restricted_notice": restricted_notice,
920
+ }
921
+ )
922
+ return context
923
+
924
+ def get_success_url(self):
925
+ redirect_url = self.get_redirect_url()
926
+ if redirect_url:
927
+ return redirect_url
928
+ if self.request.user.is_staff:
929
+ return reverse("admin:index")
930
+ return "/"
931
+
932
+ def form_valid(self, form):
933
+ response = super().form_valid(form)
934
+ device = form.get_verified_device()
935
+ if device is not None:
936
+ otp_login(self.request, device)
937
+ return response
938
+
939
+
940
+ login_view = CustomLoginView.as_view()
941
+
942
+
943
+ @staff_member_required
944
+ def authenticator_setup(request):
945
+ """Allow staff to enroll an authenticator app for TOTP logins."""
946
+
947
+ user = request.user
948
+ device_qs = TOTPDevice.objects.filter(user=user)
949
+ if TOTP_DEVICE_NAME:
950
+ device_qs = device_qs.filter(name=TOTP_DEVICE_NAME)
951
+
952
+ pending_device = device_qs.filter(confirmed=False).order_by("-id").first()
953
+ confirmed_device = device_qs.filter(confirmed=True).order_by("-id").first()
954
+ enrollment_form = AuthenticatorEnrollmentForm(device=pending_device)
955
+
956
+ if request.method == "POST":
957
+ action = request.POST.get("action")
958
+ if action == "generate":
959
+ device = pending_device or confirmed_device or TOTPDevice(user=user)
960
+ if TOTP_DEVICE_NAME:
961
+ device.name = TOTP_DEVICE_NAME
962
+ if device.pk is None:
963
+ device.save()
964
+ device.key = TOTPDevice._meta.get_field("key").get_default()
965
+ device.confirmed = False
966
+ device.drift = 0
967
+ device.last_t = -1
968
+ device.throttling_failure_count = 0
969
+ device.throttling_failure_timestamp = None
970
+ device.throttle_reset(commit=False)
971
+ device.save()
972
+ messages.success(
973
+ request,
974
+ _(
975
+ "Scan the QR code with your authenticator app, then "
976
+ "enter a code below to confirm enrollment."
977
+ ),
978
+ )
979
+ return redirect("pages:authenticator-setup")
980
+ if action == "confirm" and pending_device is not None:
981
+ enrollment_form = AuthenticatorEnrollmentForm(
982
+ request.POST, device=pending_device
983
+ )
984
+ if enrollment_form.is_valid():
985
+ pending_device.confirmed = True
986
+ pending_device.save(update_fields=["confirmed"])
987
+ messages.success(
988
+ request,
989
+ _(
990
+ "Authenticator app confirmed. You can now log in "
991
+ "with codes from your device."
992
+ ),
993
+ )
994
+ return redirect("pages:authenticator-setup")
995
+ if action == "remove":
996
+ if device_qs.exists():
997
+ device_qs.delete()
998
+ messages.success(
999
+ request,
1000
+ _(
1001
+ "Authenticator enrollment removed. Password logins "
1002
+ "remain available."
1003
+ ),
1004
+ )
1005
+ return redirect("pages:authenticator-setup")
1006
+
1007
+ pending_device = device_qs.filter(confirmed=False).order_by("-id").first()
1008
+ confirmed_device = device_qs.filter(confirmed=True).order_by("-id").first()
1009
+
1010
+ qr_data_uri = None
1011
+ manual_key = None
1012
+ if pending_device is not None:
1013
+ config_url = pending_device.config_url
1014
+ qr = qrcode.QRCode(box_size=10, border=4)
1015
+ qr.add_data(config_url)
1016
+ qr.make(fit=True)
1017
+ image = qr.make_image(fill_color="black", back_color="white")
1018
+ buffer = io.BytesIO()
1019
+ image.save(buffer, format="PNG")
1020
+ qr_data_uri = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode(
1021
+ "ascii"
1022
+ )
1023
+ secret = pending_device.key or ""
1024
+ manual_key = " ".join(secret[i : i + 4] for i in range(0, len(secret), 4))
1025
+
1026
+ context = {
1027
+ "pending_device": pending_device,
1028
+ "confirmed_device": confirmed_device,
1029
+ "qr_data_uri": qr_data_uri,
1030
+ "manual_key": manual_key,
1031
+ "enrollment_form": enrollment_form,
1032
+ }
1033
+ return TemplateResponse(request, "pages/authenticator_setup.html", context)
1034
+
1035
+
1036
+ INVITATION_REQUEST_MIN_SUBMISSION_INTERVAL = datetime.timedelta(seconds=3)
1037
+ INVITATION_REQUEST_THROTTLE_LIMIT = 3
1038
+ INVITATION_REQUEST_THROTTLE_WINDOW = datetime.timedelta(hours=1)
1039
+ INVITATION_REQUEST_HONEYPOT_MESSAGE = _(
1040
+ "We could not process your request. Please try again."
1041
+ )
1042
+ INVITATION_REQUEST_TOO_FAST_MESSAGE = _(
1043
+ "That was a little too fast. Please wait a moment and try again."
1044
+ )
1045
+ INVITATION_REQUEST_TIMESTAMP_ERROR = _(
1046
+ "We could not verify your submission. Please reload the page and try again."
1047
+ )
1048
+ INVITATION_REQUEST_THROTTLE_MESSAGE = _(
1049
+ "We've already received a few requests. Please try again later."
1050
+ )
1051
+
1052
+
1053
+ class InvitationRequestForm(forms.Form):
1054
+ email = forms.EmailField()
1055
+ comment = forms.CharField(
1056
+ required=False, widget=forms.Textarea, label=_("Comment")
1057
+ )
1058
+ honeypot = forms.CharField(
1059
+ required=False,
1060
+ label=_("Leave blank"),
1061
+ widget=forms.TextInput(attrs={"autocomplete": "off"}),
1062
+ )
1063
+ timestamp = forms.DateTimeField(required=False, widget=forms.HiddenInput())
1064
+
1065
+ min_submission_interval = INVITATION_REQUEST_MIN_SUBMISSION_INTERVAL
1066
+
1067
+ def __init__(self, *args, **kwargs):
1068
+ super().__init__(*args, **kwargs)
1069
+ if not self.is_bound:
1070
+ self.fields["timestamp"].initial = timezone.now()
1071
+ self.fields["honeypot"].widget.attrs.setdefault("aria-hidden", "true")
1072
+ self.fields["honeypot"].widget.attrs.setdefault("tabindex", "-1")
1073
+
1074
+ def clean(self):
1075
+ cleaned = super().clean()
1076
+
1077
+ honeypot_value = cleaned.get("honeypot", "")
1078
+ if honeypot_value:
1079
+ raise forms.ValidationError(INVITATION_REQUEST_HONEYPOT_MESSAGE)
1080
+
1081
+ timestamp = cleaned.get("timestamp")
1082
+ if timestamp is None:
1083
+ cleaned["timestamp"] = timezone.now()
1084
+ return cleaned
1085
+
1086
+ now = timezone.now()
1087
+ if timestamp > now or (now - timestamp) < self.min_submission_interval:
1088
+ raise forms.ValidationError(INVITATION_REQUEST_TOO_FAST_MESSAGE)
1089
+
1090
+ return cleaned
1091
+
1092
+
1093
+ @csrf_exempt
1094
+ @ensure_csrf_cookie
1095
+ def request_invite(request):
1096
+ form = InvitationRequestForm(request.POST if request.method == "POST" else None)
1097
+ sent = False
1098
+ if request.method == "POST" and form.is_valid():
1099
+ email = form.cleaned_data["email"]
1100
+ comment = form.cleaned_data.get("comment", "")
1101
+ ip_address = request.META.get("REMOTE_ADDR")
1102
+ throttle_filters = Q(email__iexact=email)
1103
+ if ip_address:
1104
+ throttle_filters |= Q(ip_address=ip_address)
1105
+ window_start = timezone.now() - INVITATION_REQUEST_THROTTLE_WINDOW
1106
+ recent_requests = InviteLead.objects.filter(
1107
+ throttle_filters, created_on__gte=window_start
1108
+ )
1109
+ if recent_requests.count() >= INVITATION_REQUEST_THROTTLE_LIMIT:
1110
+ form.add_error(None, INVITATION_REQUEST_THROTTLE_MESSAGE)
1111
+ else:
1112
+ mac_address = public_wifi.resolve_mac_address(ip_address)
1113
+ lead = InviteLead.objects.create(
1114
+ email=email,
1115
+ comment=comment,
1116
+ user=request.user if request.user.is_authenticated else None,
1117
+ path=request.path,
1118
+ referer=get_original_referer(request),
1119
+ user_agent=request.META.get("HTTP_USER_AGENT", ""),
1120
+ ip_address=ip_address,
1121
+ mac_address=mac_address or "",
1122
+ )
1123
+ logger.info("Invitation requested for %s", email)
1124
+ User = get_user_model()
1125
+ users = list(User.objects.filter(email__iexact=email))
1126
+ if not users:
1127
+ logger.warning("Invitation requested for unknown email %s", email)
1128
+ for user in users:
1129
+ uid = urlsafe_base64_encode(force_bytes(user.pk))
1130
+ token = default_token_generator.make_token(user)
1131
+ link = request.build_absolute_uri(
1132
+ reverse("pages:invitation-login", args=[uid, token])
1133
+ )
1134
+ subject = _("Your invitation link")
1135
+ body = _("Use the following link to access your account: %(link)s") % {
1136
+ "link": link
1137
+ }
1138
+ try:
1139
+ node_error = None
1140
+ node = Node.get_local()
1141
+ outbox = getattr(node, "email_outbox", None) if node else None
1142
+ if node:
1143
+ try:
1144
+ result = node.send_mail(subject, body, [email])
1145
+ lead.sent_via_outbox = outbox
1146
+ except Exception as exc:
1147
+ node_error = exc
1148
+ lead.sent_via_outbox = None
1149
+ logger.exception(
1150
+ "Node send_mail failed, falling back to default backend"
1151
+ )
1152
+ result = mailer.send(
1153
+ subject, body, [email], settings.DEFAULT_FROM_EMAIL
1154
+ )
1155
+ else:
1156
+ result = mailer.send(
1157
+ subject, body, [email], settings.DEFAULT_FROM_EMAIL
1158
+ )
1159
+ lead.sent_via_outbox = None
1160
+ lead.sent_on = timezone.now()
1161
+ if node_error:
1162
+ lead.error = (
1163
+ f"Node email send failed: {node_error}. "
1164
+ "Invite was sent using default mail backend; ensure the "
1165
+ "node's email service is running or check its configuration."
1166
+ )
1167
+ else:
1168
+ lead.error = ""
1169
+ logger.info(
1170
+ "Invitation email sent to %s (user %s): %s", email, user.pk, result
1171
+ )
1172
+ except Exception as exc:
1173
+ lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
1174
+ lead.sent_via_outbox = None
1175
+ logger.exception("Failed to send invitation email to %s", email)
1176
+ if lead.sent_on or lead.error:
1177
+ lead.save(update_fields=["sent_on", "error", "sent_via_outbox"])
1178
+ sent = True
1179
+ return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
1180
+
1181
+
1182
+ class InvitationPasswordForm(forms.Form):
1183
+ new_password1 = forms.CharField(
1184
+ widget=forms.PasswordInput, required=False, label=_("New password")
1185
+ )
1186
+ new_password2 = forms.CharField(
1187
+ widget=forms.PasswordInput, required=False, label=_("Confirm password")
1188
+ )
1189
+
1190
+ def clean(self):
1191
+ cleaned = super().clean()
1192
+ p1 = cleaned.get("new_password1")
1193
+ p2 = cleaned.get("new_password2")
1194
+ if p1 or p2:
1195
+ if not p1 or not p2 or p1 != p2:
1196
+ raise forms.ValidationError(_("Passwords do not match"))
1197
+ return cleaned
1198
+
1199
+
1200
+ def invitation_login(request, uidb64, token):
1201
+ User = get_user_model()
1202
+ try:
1203
+ uid = force_str(urlsafe_base64_decode(uidb64))
1204
+ user = User.objects.get(pk=uid)
1205
+ except Exception:
1206
+ user = None
1207
+ if user is None or not default_token_generator.check_token(user, token):
1208
+ return HttpResponse(_("Invalid invitation link"), status=400)
1209
+ form = InvitationPasswordForm(request.POST if request.method == "POST" else None)
1210
+ if request.method == "POST" and form.is_valid():
1211
+ password = form.cleaned_data.get("new_password1")
1212
+ if password:
1213
+ user.set_password(password)
1214
+ user.is_active = True
1215
+ user.save()
1216
+ node = Node.get_local()
1217
+ if node and node.has_feature("ap-router"):
1218
+ mac_address = public_wifi.resolve_mac_address(
1219
+ request.META.get("REMOTE_ADDR")
1220
+ )
1221
+ if not mac_address:
1222
+ mac_address = (
1223
+ InviteLead.objects.filter(email__iexact=user.email)
1224
+ .exclude(mac_address="")
1225
+ .order_by("-created_on")
1226
+ .values_list("mac_address", flat=True)
1227
+ .first()
1228
+ )
1229
+ if mac_address:
1230
+ public_wifi.grant_public_access(user, mac_address)
1231
+ login(request, user, backend="core.backends.LocalhostAdminBackend")
1232
+ return redirect(reverse("admin:index") if user.is_staff else "/")
1233
+ return render(request, "pages/invitation_login.html", {"form": form})
1234
+
1235
+
1236
+ class ClientReportForm(forms.Form):
1237
+ PERIOD_CHOICES = [
1238
+ ("range", _("Date range")),
1239
+ ("week", _("Week")),
1240
+ ("month", _("Month")),
1241
+ ]
1242
+ RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
1243
+ period = forms.ChoiceField(
1244
+ choices=PERIOD_CHOICES,
1245
+ widget=forms.RadioSelect,
1246
+ initial="range",
1247
+ help_text=_("Choose how the reporting window will be calculated."),
1248
+ )
1249
+ start = forms.DateField(
1250
+ label=_("Start date"),
1251
+ required=False,
1252
+ widget=forms.DateInput(attrs={"type": "date"}),
1253
+ help_text=_("First day included when using a custom date range."),
1254
+ )
1255
+ end = forms.DateField(
1256
+ label=_("End date"),
1257
+ required=False,
1258
+ widget=forms.DateInput(attrs={"type": "date"}),
1259
+ help_text=_("Last day included when using a custom date range."),
1260
+ )
1261
+ week = forms.CharField(
1262
+ label=_("Week"),
1263
+ required=False,
1264
+ widget=forms.TextInput(attrs={"type": "week"}),
1265
+ help_text=_("Generates the report for the ISO week that you select."),
1266
+ )
1267
+ month = forms.DateField(
1268
+ label=_("Month"),
1269
+ required=False,
1270
+ widget=forms.DateInput(attrs={"type": "month"}),
1271
+ input_formats=["%Y-%m"],
1272
+ help_text=_("Generates the report for the calendar month that you select."),
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
+ )
1293
+ owner = forms.ModelChoiceField(
1294
+ queryset=get_user_model().objects.all(),
1295
+ required=False,
1296
+ help_text=_(
1297
+ "Sets who owns the report schedule and is listed as the requester."
1298
+ ),
1299
+ )
1300
+ destinations = forms.CharField(
1301
+ label=_("Email destinations"),
1302
+ required=False,
1303
+ widget=forms.Textarea(attrs={"rows": 2}),
1304
+ help_text=_("Separate addresses with commas, whitespace, or new lines."),
1305
+ )
1306
+ recurrence = forms.ChoiceField(
1307
+ label=_("Recurrence"),
1308
+ choices=RECURRENCE_CHOICES,
1309
+ initial=ClientReportSchedule.PERIODICITY_NONE,
1310
+ help_text=_("Defines how often the report should be generated automatically."),
1311
+ )
1312
+ enable_emails = forms.BooleanField(
1313
+ label=_("Enable email delivery"),
1314
+ required=False,
1315
+ help_text=_("Send the report via email to the recipients listed above."),
1316
+ )
1317
+
1318
+ def __init__(self, *args, request=None, **kwargs):
1319
+ self.request = request
1320
+ super().__init__(*args, **kwargs)
1321
+ if request and getattr(request, "user", None) and request.user.is_authenticated:
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
1330
+
1331
+ def clean(self):
1332
+ cleaned = super().clean()
1333
+ period = cleaned.get("period")
1334
+ if period == "range":
1335
+ if not cleaned.get("start") or not cleaned.get("end"):
1336
+ raise forms.ValidationError(_("Please provide start and end dates."))
1337
+ elif period == "week":
1338
+ week_str = cleaned.get("week")
1339
+ if not week_str:
1340
+ raise forms.ValidationError(_("Please select a week."))
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."))
1348
+ cleaned["start"] = start
1349
+ cleaned["end"] = start + datetime.timedelta(days=6)
1350
+ elif period == "month":
1351
+ month_dt = cleaned.get("month")
1352
+ if not month_dt:
1353
+ raise forms.ValidationError(_("Please select a month."))
1354
+ start = month_dt.replace(day=1)
1355
+ last_day = calendar.monthrange(month_dt.year, month_dt.month)[1]
1356
+ cleaned["start"] = start
1357
+ cleaned["end"] = month_dt.replace(day=last_day)
1358
+ return cleaned
1359
+
1360
+ def clean_destinations(self):
1361
+ raw = self.cleaned_data.get("destinations", "")
1362
+ if not raw:
1363
+ return []
1364
+ validator = EmailValidator()
1365
+ seen: set[str] = set()
1366
+ emails: list[str] = []
1367
+ for part in re.split(r"[\s,]+", raw):
1368
+ candidate = part.strip()
1369
+ if not candidate:
1370
+ continue
1371
+ validator(candidate)
1372
+ key = candidate.lower()
1373
+ if key in seen:
1374
+ continue
1375
+ seen.add(key)
1376
+ emails.append(candidate)
1377
+ return emails
1378
+
1379
+ def clean_title(self):
1380
+ title = self.cleaned_data.get("title")
1381
+ return ClientReport.normalize_title(title)
1382
+
1383
+
1384
+ @live_update()
1385
+ def client_report(request):
1386
+ form = ClientReportForm(request.POST or None, request=request)
1387
+ report = None
1388
+ schedule = None
1389
+ if request.method == "POST":
1390
+ if not request.user.is_authenticated:
1391
+ form.is_valid() # Run validation to surface field errors alongside auth error.
1392
+ form.add_error(
1393
+ None, _("You must log in to generate consumer reports."),
1394
+ )
1395
+ elif form.is_valid():
1396
+ throttle_seconds = getattr(settings, "CLIENT_REPORT_THROTTLE_SECONDS", 60)
1397
+ throttle_keys = []
1398
+ if request.user.is_authenticated:
1399
+ throttle_keys.append(f"client-report:user:{request.user.pk}")
1400
+ remote_addr = request.META.get("HTTP_X_FORWARDED_FOR")
1401
+ if remote_addr:
1402
+ remote_addr = remote_addr.split(",")[0].strip()
1403
+ remote_addr = remote_addr or request.META.get("REMOTE_ADDR")
1404
+ if remote_addr:
1405
+ throttle_keys.append(f"client-report:ip:{remote_addr}")
1406
+
1407
+ added_keys = []
1408
+ blocked = False
1409
+ for key in throttle_keys:
1410
+ if cache.add(key, timezone.now(), throttle_seconds):
1411
+ added_keys.append(key)
1412
+ else:
1413
+ blocked = True
1414
+ break
1415
+
1416
+ if blocked:
1417
+ for key in added_keys:
1418
+ cache.delete(key)
1419
+ form.add_error(
1420
+ None,
1421
+ _(
1422
+ "Consumer reports can only be generated periodically. Please wait before trying again."
1423
+ ),
1424
+ )
1425
+ else:
1426
+ owner = form.cleaned_data.get("owner")
1427
+ if not owner and request.user.is_authenticated:
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")
1437
+ report = ClientReport.generate(
1438
+ form.cleaned_data["start"],
1439
+ form.cleaned_data["end"],
1440
+ owner=owner,
1441
+ recipients=recipients,
1442
+ disable_emails=disable_emails,
1443
+ chargers=chargers,
1444
+ language=language,
1445
+ title=title,
1446
+ )
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
+ )
1464
+ recurrence = form.cleaned_data.get("recurrence")
1465
+ if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
1466
+ schedule = ClientReportSchedule.objects.create(
1467
+ owner=owner,
1468
+ created_by=request.user if request.user.is_authenticated else None,
1469
+ periodicity=recurrence,
1470
+ email_recipients=recipients,
1471
+ disable_emails=disable_emails,
1472
+ language=language,
1473
+ title=title,
1474
+ )
1475
+ if chargers:
1476
+ schedule.chargers.set(chargers)
1477
+ report.schedule = schedule
1478
+ report.save(update_fields=["schedule"])
1479
+ messages.success(
1480
+ request,
1481
+ _(
1482
+ "Consumer report schedule created; future reports will be generated automatically."
1483
+ ),
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
+
1506
+ try:
1507
+ login_url = reverse("pages:login")
1508
+ except NoReverseMatch:
1509
+ try:
1510
+ login_url = reverse("login")
1511
+ except NoReverseMatch:
1512
+ login_url = getattr(settings, "LOGIN_URL", None)
1513
+
1514
+ context = {
1515
+ "form": form,
1516
+ "report": report,
1517
+ "schedule": schedule,
1518
+ "login_url": login_url,
1519
+ "download_url": download_url,
1520
+ }
1521
+ return render(request, "pages/client_report.html", context)
1522
+
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
+
1557
+ @require_POST
1558
+ def submit_user_story(request):
1559
+ throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
1560
+ client_ip = _get_client_ip(request)
1561
+ cache_key = None
1562
+
1563
+ if throttle_seconds:
1564
+ cache_key = f"user-story:ip:{client_ip or 'unknown'}"
1565
+ if not cache.add(cache_key, timezone.now(), throttle_seconds):
1566
+ minutes = throttle_seconds // 60
1567
+ if throttle_seconds % 60:
1568
+ minutes += 1
1569
+ error_message = _(
1570
+ "You can only submit feedback once every %(minutes)s minutes."
1571
+ ) % {"minutes": minutes or 1}
1572
+ return JsonResponse(
1573
+ {"success": False, "errors": {"__all__": [error_message]}},
1574
+ status=429,
1575
+ )
1576
+
1577
+ data = request.POST.copy()
1578
+ if request.user.is_authenticated:
1579
+ data["name"] = request.user.get_username()[:40]
1580
+ if not data.get("path"):
1581
+ data["path"] = request.get_full_path()
1582
+
1583
+ form = UserStoryForm(data, user=request.user)
1584
+ if request.user.is_authenticated:
1585
+ form.instance.user = request.user
1586
+
1587
+ if form.is_valid():
1588
+ story = form.save(commit=False)
1589
+ if request.user.is_authenticated:
1590
+ story.user = request.user
1591
+ story.owner = request.user
1592
+ story.name = request.user.get_username()[:40]
1593
+ if not story.name:
1594
+ story.name = str(_("Anonymous"))[:40]
1595
+ story.path = (story.path or request.get_full_path())[:500]
1596
+ story.referer = get_original_referer(request)
1597
+ story.user_agent = request.META.get("HTTP_USER_AGENT", "")
1598
+ story.ip_address = client_ip or None
1599
+ story.is_user_data = True
1600
+ language_code = _get_request_language_code(request)
1601
+ if language_code:
1602
+ story.language_code = language_code
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"])
1678
+ return JsonResponse({"success": True})
1679
+
1680
+ return JsonResponse({"success": False, "errors": form.errors}, status=400)
1681
+
1682
+
1683
+ def csrf_failure(request, reason=""):
1684
+ """Custom CSRF failure view with a friendly message."""
1685
+ logger.warning("CSRF failure on %s: %s", request.path, reason)
1686
+ return render(request, "pages/csrf_failure.html", status=403)
1687
+
1688
+
1689
+ def _admin_context(request):
1690
+ context = admin.site.each_context(request)
1691
+ if not context.get("has_permission"):
1692
+ rf = RequestFactory()
1693
+ mock_request = rf.get(request.path)
1694
+ mock_request.user = SimpleNamespace(
1695
+ is_active=True,
1696
+ is_staff=True,
1697
+ is_superuser=True,
1698
+ has_perm=lambda perm, obj=None: True,
1699
+ has_module_perms=lambda app_label: True,
1700
+ )
1701
+ context["available_apps"] = admin.site.get_app_list(mock_request)
1702
+ context["has_permission"] = True
1703
+ return context
1704
+
1705
+
1706
+ def admin_manual_list(request):
1707
+ manuals = UserManual.objects.order_by("title")
1708
+ context = _admin_context(request)
1709
+ context["manuals"] = manuals
1710
+ return render(request, "admin_doc/manuals.html", context)
1711
+
1712
+
1713
+ def admin_manual_detail(request, slug):
1714
+ manual = get_object_or_404(UserManual, slug=slug)
1715
+ context = _admin_context(request)
1716
+ context["manual"] = manual
1717
+ return render(request, "admin_doc/manual_detail.html", context)
1718
+
1719
+
1720
+ def manual_pdf(request, slug):
1721
+ manual = get_object_or_404(UserManual, slug=slug)
1722
+ pdf_data = base64.b64decode(manual.content_pdf)
1723
+ response = HttpResponse(pdf_data, content_type="application/pdf")
1724
+ response["Content-Disposition"] = f'attachment; filename="{manual.slug}.pdf"'
1725
+ return response
1726
+
1727
+
1728
+ @landing(_("Manuals"))
1729
+ def manual_list(request):
1730
+ manuals = UserManual.objects.order_by("title")
1731
+ return render(request, "pages/manual_list.html", {"manuals": manuals})
1732
+
1733
+
1734
+ def manual_detail(request, slug):
1735
+ manual = get_object_or_404(UserManual, slug=slug)
1736
+ return render(request, "pages/manual_detail.html", {"manual": manual})