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