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