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