arthexis 0.1.8__py3-none-any.whl → 0.1.9__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 (81) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.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 +133 -16
  10. config/urls.py +65 -6
  11. core/admin.py +1226 -191
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1071 -264
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +358 -63
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +7 -3
  42. core/workgroup_views.py +43 -6
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +1 -1
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.8.dist-info/RECORD +0 -80
  77. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  78. config/workgroup_app.py +0 -7
  79. core/checks.py +0 -29
  80. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  81. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
pages/views.py CHANGED
@@ -1,33 +1,355 @@
1
1
  import logging
2
2
  from pathlib import Path
3
+ import datetime
4
+ import calendar
5
+ import shutil
6
+ import re
7
+ from html import escape
3
8
 
4
9
  from django.conf import settings
10
+ from django.contrib import admin
11
+ from django.contrib import messages
12
+ from django.contrib.admin.views.decorators import staff_member_required
5
13
  from django.contrib.auth import get_user_model, login
6
14
  from django.contrib.auth.tokens import default_token_generator
7
15
  from django.contrib.auth.views import LoginView
8
16
  from django import forms
17
+ from django.apps import apps as django_apps
9
18
  from utils.sites import get_site
10
- from django.http import HttpResponse
19
+ from django.http import Http404, HttpResponse
11
20
  from django.shortcuts import redirect, render
12
21
  from nodes.models import Node
13
- from django.urls import reverse
14
- from django.utils import translation
22
+ from django.template.response import TemplateResponse
23
+ from django.urls import NoReverseMatch, reverse
24
+ from django.utils import timezone
15
25
  from django.utils.encoding import force_bytes, force_str
16
26
  from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
17
- from django.core.mail import send_mail
27
+ from core import mailer, public_wifi
18
28
  from django.utils.translation import gettext as _
19
29
  from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
20
- from core.models import InviteLead
30
+ from django.views.decorators.cache import never_cache
31
+ from django.utils.cache import patch_vary_headers
32
+ from django.core.exceptions import PermissionDenied
33
+ from django.utils.text import slugify
34
+ from django.core.validators import EmailValidator
35
+ from core.models import InviteLead, ClientReport, ClientReportSchedule
36
+
37
+ try: # pragma: no cover - optional dependency guard
38
+ from graphviz import Digraph
39
+ from graphviz.backend import CalledProcessError, ExecutableNotFound
40
+ except ImportError: # pragma: no cover - handled gracefully in views
41
+ Digraph = None
42
+ CalledProcessError = ExecutableNotFound = None
21
43
 
22
44
  import markdown
23
45
  from pages.utils import landing
46
+ from core.liveupdate import live_update
24
47
  from .models import Module
25
48
 
26
49
 
27
50
  logger = logging.getLogger(__name__)
28
51
 
29
52
 
53
+ def _get_registered_models(app_label: str):
54
+ """Return admin-registered models for the given app label."""
55
+
56
+ registered = [
57
+ model for model in admin.site._registry if model._meta.app_label == app_label
58
+ ]
59
+ return sorted(registered, key=lambda model: str(model._meta.verbose_name))
60
+
61
+
62
+ def _filter_models_for_request(models, request):
63
+ """Filter ``models`` to only those viewable by ``request.user``."""
64
+
65
+ allowed = []
66
+ for model in models:
67
+ model_admin = admin.site._registry.get(model)
68
+ if model_admin is None:
69
+ continue
70
+ if not model_admin.has_module_permission(request):
71
+ continue
72
+ if not model_admin.has_view_permission(request, obj=None):
73
+ continue
74
+ allowed.append(model)
75
+ return allowed
76
+
77
+
78
+ def _admin_has_app_permission(request, app_label: str) -> bool:
79
+ """Return whether the admin user can access the given app."""
80
+
81
+ has_app_permission = getattr(admin.site, "has_app_permission", None)
82
+ if callable(has_app_permission):
83
+ return has_app_permission(request, app_label)
84
+ return bool(admin.site.get_app_list(request, app_label))
85
+
86
+
87
+ def _resolve_related_model(field, default_app_label: str):
88
+ """Resolve the Django model class referenced by ``field``."""
89
+
90
+ remote = getattr(getattr(field, "remote_field", None), "model", None)
91
+ if remote is None:
92
+ return None
93
+ if isinstance(remote, str):
94
+ if "." in remote:
95
+ app_label, model_name = remote.split(".", 1)
96
+ else:
97
+ app_label, model_name = default_app_label, remote
98
+ try:
99
+ remote = django_apps.get_model(app_label, model_name)
100
+ except LookupError:
101
+ return None
102
+ return remote
103
+
104
+
105
+ def _graph_field_type(field, default_app_label: str) -> str:
106
+ """Format a field description for node labels."""
107
+
108
+ base = field.get_internal_type()
109
+ related = _resolve_related_model(field, default_app_label)
110
+ if related is not None:
111
+ base = f"{base} → {related._meta.object_name}"
112
+ return base
113
+
114
+
115
+ def _build_model_graph(models):
116
+ """Generate a GraphViz ``Digraph`` for the provided ``models``."""
117
+
118
+ if Digraph is None:
119
+ raise RuntimeError("Graphviz is not installed")
120
+
121
+ graph = Digraph(
122
+ "admin_app_models",
123
+ graph_attr={
124
+ "rankdir": "LR",
125
+ "splines": "ortho",
126
+ "nodesep": "0.8",
127
+ "ranksep": "1.0",
128
+ },
129
+ node_attr={
130
+ "shape": "plaintext",
131
+ "fontname": "Helvetica",
132
+ },
133
+ edge_attr={"fontname": "Helvetica"},
134
+ )
135
+
136
+ node_ids = {}
137
+ for model in models:
138
+ node_id = f"{model._meta.app_label}.{model._meta.model_name}"
139
+ node_ids[model] = node_id
140
+
141
+ rows = [
142
+ '<tr><td bgcolor="#1f2933" colspan="2"><font color="white"><b>'
143
+ f"{escape(model._meta.object_name)}"
144
+ "</b></font></td></tr>"
145
+ ]
146
+
147
+ verbose_name = str(model._meta.verbose_name)
148
+ if verbose_name and verbose_name != model._meta.object_name:
149
+ rows.append(
150
+ '<tr><td colspan="2"><i>' f"{escape(verbose_name)}" "</i></td></tr>"
151
+ )
152
+
153
+ for field in model._meta.concrete_fields:
154
+ if field.auto_created and not field.concrete:
155
+ continue
156
+ name = escape(field.name)
157
+ if field.primary_key:
158
+ name = f"<u>{name}</u>"
159
+ type_label = escape(_graph_field_type(field, model._meta.app_label))
160
+ rows.append(
161
+ '<tr><td align="left">'
162
+ f"{name}"
163
+ '</td><td align="left">'
164
+ f"{type_label}"
165
+ "</td></tr>"
166
+ )
167
+
168
+ for field in model._meta.local_many_to_many:
169
+ name = escape(field.name)
170
+ type_label = _graph_field_type(field, model._meta.app_label)
171
+ rows.append(
172
+ '<tr><td align="left">'
173
+ f"{name}"
174
+ '</td><td align="left">'
175
+ f"{escape(type_label)}"
176
+ "</td></tr>"
177
+ )
178
+
179
+ label = '<\n <table BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">\n '
180
+ label += "\n ".join(rows)
181
+ label += "\n </table>\n>"
182
+ graph.node(node_id, label=label)
183
+
184
+ edges = set()
185
+ for model in models:
186
+ source_id = node_ids[model]
187
+ for field in model._meta.concrete_fields:
188
+ related = _resolve_related_model(field, model._meta.app_label)
189
+ if related not in node_ids:
190
+ continue
191
+ attrs = {"label": field.name}
192
+ if getattr(field, "one_to_one", False):
193
+ attrs.update({"arrowhead": "onormal", "arrowtail": "none"})
194
+ key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
195
+ if key not in edges:
196
+ edges.add(key)
197
+ graph.edge(source_id, node_ids[related], **attrs)
198
+
199
+ for field in model._meta.local_many_to_many:
200
+ related = _resolve_related_model(field, model._meta.app_label)
201
+ if related not in node_ids:
202
+ continue
203
+ attrs = {
204
+ "label": f"{field.name} (M2M)",
205
+ "dir": "both",
206
+ "arrowhead": "normal",
207
+ "arrowtail": "normal",
208
+ }
209
+ key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
210
+ if key not in edges:
211
+ edges.add(key)
212
+ graph.edge(source_id, node_ids[related], **attrs)
213
+
214
+ return graph
215
+
216
+
217
+ @staff_member_required
218
+ def admin_model_graph(request, app_label: str):
219
+ """Render a GraphViz-powered diagram for the admin app grouping."""
220
+
221
+ try:
222
+ app_config = django_apps.get_app_config(app_label)
223
+ except LookupError as exc: # pragma: no cover - invalid app label
224
+ raise Http404("Unknown application") from exc
225
+
226
+ models = _get_registered_models(app_label)
227
+ if not models:
228
+ raise Http404("No admin models registered for this application")
229
+
230
+ if not _admin_has_app_permission(request, app_label):
231
+ raise PermissionDenied
232
+
233
+ models = _filter_models_for_request(models, request)
234
+ if not models:
235
+ raise PermissionDenied
236
+
237
+ if Digraph is None: # pragma: no cover - dependency missing is unexpected
238
+ raise Http404("Graph visualization support is unavailable")
239
+
240
+ graph = _build_model_graph(models)
241
+ graph_source = graph.source
242
+
243
+ graph_svg = ""
244
+ graph_error = ""
245
+ graph_engine = getattr(graph, "engine", "dot")
246
+ engine_path = shutil.which(str(graph_engine))
247
+ download_format = request.GET.get("format")
248
+
249
+ if download_format == "pdf":
250
+ if engine_path is None:
251
+ messages.error(
252
+ request,
253
+ _(
254
+ "Graphviz executables are required to download the diagram as a PDF. Install Graphviz on the server and try again."
255
+ ),
256
+ )
257
+ else:
258
+ try:
259
+ pdf_output = graph.pipe(format="pdf")
260
+ except (ExecutableNotFound, CalledProcessError) as exc:
261
+ logger.warning(
262
+ "Graphviz PDF rendering failed for admin model graph (engine=%s)",
263
+ graph_engine,
264
+ exc_info=exc,
265
+ )
266
+ messages.error(
267
+ request,
268
+ _(
269
+ "An error occurred while generating the PDF diagram. Check the server logs for details."
270
+ ),
271
+ )
272
+ else:
273
+ filename = slugify(app_config.verbose_name) or app_label
274
+ response = HttpResponse(pdf_output, content_type="application/pdf")
275
+ response["Content-Disposition"] = (
276
+ f'attachment; filename="{filename}-model-graph.pdf"'
277
+ )
278
+ return response
279
+
280
+ params = request.GET.copy()
281
+ if "format" in params:
282
+ del params["format"]
283
+ query_string = params.urlencode()
284
+ redirect_url = request.path
285
+ if query_string:
286
+ redirect_url = f"{request.path}?{query_string}"
287
+ return redirect(redirect_url)
288
+
289
+ if engine_path is None:
290
+ graph_error = _(
291
+ "Graphviz executables are required to render this diagram. Install Graphviz on the server and try again."
292
+ )
293
+ else:
294
+ try:
295
+ svg_output = graph.pipe(format="svg", encoding="utf-8")
296
+ except (ExecutableNotFound, CalledProcessError) as exc:
297
+ logger.warning(
298
+ "Graphviz rendering failed for admin model graph (engine=%s)",
299
+ graph_engine,
300
+ exc_info=exc,
301
+ )
302
+ graph_error = _(
303
+ "An error occurred while rendering the diagram. Check the server logs for details."
304
+ )
305
+ else:
306
+ svg_start = svg_output.find("<svg")
307
+ if svg_start != -1:
308
+ svg_output = svg_output[svg_start:]
309
+ label = _("%(app)s model diagram") % {"app": app_config.verbose_name}
310
+ graph_svg = svg_output.replace(
311
+ "<svg", f'<svg role="img" aria-label="{escape(label)}"', 1
312
+ )
313
+ if not graph_svg:
314
+ graph_error = _("Graphviz did not return any diagram output.")
315
+
316
+ model_links = []
317
+ for model in models:
318
+ opts = model._meta
319
+ try:
320
+ url = reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
321
+ except NoReverseMatch:
322
+ url = ""
323
+ model_links.append(
324
+ {
325
+ "label": str(opts.verbose_name_plural),
326
+ "url": url,
327
+ }
328
+ )
329
+
330
+ download_params = request.GET.copy()
331
+ download_params["format"] = "pdf"
332
+ download_url = f"{request.path}?{download_params.urlencode()}"
333
+
334
+ context = admin.site.each_context(request)
335
+ context.update(
336
+ {
337
+ "app_label": app_label,
338
+ "app_verbose_name": app_config.verbose_name,
339
+ "graph_source": graph_source,
340
+ "graph_svg": graph_svg,
341
+ "graph_error": graph_error,
342
+ "models": model_links,
343
+ "title": _("%(app)s model graph") % {"app": app_config.verbose_name},
344
+ "download_url": download_url,
345
+ }
346
+ )
347
+
348
+ return TemplateResponse(request, "admin/model_graph.html", context)
349
+
350
+
30
351
  @landing("Home")
352
+ @never_cache
31
353
  def index(request):
32
354
  site = get_site(request)
33
355
  if site:
@@ -45,18 +367,27 @@ def index(request):
45
367
  .first()
46
368
  )
47
369
  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"
370
+ readme_base = (
371
+ Path(settings.BASE_DIR) / app_slug if app_slug else Path(settings.BASE_DIR)
372
+ )
373
+ lang = getattr(request, "LANGUAGE_CODE", "")
374
+ lang = lang.replace("_", "-").lower()
375
+ root_base = Path(settings.BASE_DIR)
376
+ candidates = []
51
377
  if lang:
52
- localized = readme_base / f"README.{lang}.md"
53
- if not localized.exists():
378
+ candidates.append(readme_base / f"README.{lang}.md")
379
+ short = lang.split("-")[0]
380
+ if short != lang:
381
+ candidates.append(readme_base / f"README.{short}.md")
382
+ candidates.append(readme_base / "README.md")
383
+ if readme_base != root_base:
384
+ if lang:
385
+ candidates.append(root_base / f"README.{lang}.md")
54
386
  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"
387
+ if short != lang:
388
+ candidates.append(root_base / f"README.{short}.md")
389
+ candidates.append(root_base / "README.md")
390
+ readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
60
391
  text = readme_file.read_text(encoding="utf-8")
61
392
  md = markdown.Markdown(extensions=["toc", "tables"])
62
393
  html = md.convert(text)
@@ -68,7 +399,9 @@ def index(request):
68
399
  toc_html = toc_html.strip()
69
400
  title = "README" if readme_file.name.startswith("README") else readme_file.stem
70
401
  context = {"content": html, "title": title, "toc": toc_html}
71
- return render(request, "pages/readme.html", context)
402
+ response = render(request, "pages/readme.html", context)
403
+ patch_vary_headers(response, ["Accept-Language", "Cookie"])
404
+ return response
72
405
 
73
406
 
74
407
  def sitemap(request):
@@ -91,6 +424,31 @@ def sitemap(request):
91
424
  return HttpResponse("\n".join(lines), content_type="application/xml")
92
425
 
93
426
 
427
+ def release_checklist(request):
428
+ file_path = Path(settings.BASE_DIR) / "releases" / "release-checklist.md"
429
+ if not file_path.exists():
430
+ raise Http404("Release checklist not found")
431
+ text = file_path.read_text(encoding="utf-8")
432
+ md = markdown.Markdown(extensions=["toc", "tables"])
433
+ html = md.convert(text)
434
+ toc_html = md.toc
435
+ if toc_html.strip().startswith('<div class="toc">'):
436
+ toc_html = toc_html.strip()[len('<div class="toc">') :]
437
+ if toc_html.endswith("</div>"):
438
+ toc_html = toc_html[: -len("</div>")]
439
+ toc_html = toc_html.strip()
440
+ context = {"content": html, "title": "Release Checklist", "toc": toc_html}
441
+ response = render(request, "pages/readme.html", context)
442
+ patch_vary_headers(response, ["Accept-Language", "Cookie"])
443
+ return response
444
+
445
+
446
+ @csrf_exempt
447
+ def datasette_auth(request):
448
+ if request.user.is_authenticated:
449
+ return HttpResponse("OK")
450
+ return HttpResponse(status=401)
451
+
94
452
 
95
453
  class CustomLoginView(LoginView):
96
454
  """Login view that redirects staff to the admin."""
@@ -102,6 +460,19 @@ class CustomLoginView(LoginView):
102
460
  return redirect(self.get_success_url())
103
461
  return super().dispatch(request, *args, **kwargs)
104
462
 
463
+ def get_context_data(self, **kwargs):
464
+ context = super(LoginView, self).get_context_data(**kwargs)
465
+ current_site = get_site(self.request)
466
+ context.update(
467
+ {
468
+ "site": current_site,
469
+ "site_name": getattr(current_site, "name", ""),
470
+ "next": self.get_success_url(),
471
+ "can_request_invite": mailer.can_send_email(),
472
+ }
473
+ )
474
+ return context
475
+
105
476
  def get_success_url(self):
106
477
  redirect_url = self.get_redirect_url()
107
478
  if redirect_url:
@@ -118,6 +489,7 @@ class InvitationRequestForm(forms.Form):
118
489
  email = forms.EmailField()
119
490
  comment = forms.CharField(required=False, widget=forms.Textarea, label=_("Comment"))
120
491
 
492
+
121
493
  @csrf_exempt
122
494
  @ensure_csrf_cookie
123
495
  def request_invite(request):
@@ -126,14 +498,17 @@ def request_invite(request):
126
498
  if request.method == "POST" and form.is_valid():
127
499
  email = form.cleaned_data["email"]
128
500
  comment = form.cleaned_data.get("comment", "")
129
- InviteLead.objects.create(
501
+ ip_address = request.META.get("REMOTE_ADDR")
502
+ mac_address = public_wifi.resolve_mac_address(ip_address)
503
+ lead = InviteLead.objects.create(
130
504
  email=email,
131
505
  comment=comment,
132
506
  user=request.user if request.user.is_authenticated else None,
133
507
  path=request.path,
134
508
  referer=request.META.get("HTTP_REFERER", ""),
135
509
  user_agent=request.META.get("HTTP_USER_AGENT", ""),
136
- ip_address=request.META.get("REMOTE_ADDR"),
510
+ ip_address=ip_address,
511
+ mac_address=mac_address or "",
137
512
  )
138
513
  logger.info("Invitation requested for %s", email)
139
514
  User = get_user_model()
@@ -147,20 +522,44 @@ def request_invite(request):
147
522
  reverse("pages:invitation-login", args=[uid, token])
148
523
  )
149
524
  subject = _("Your invitation link")
150
- body = _(
151
- "Use the following link to access your account: %(link)s"
152
- ) % {"link": link}
525
+ body = _("Use the following link to access your account: %(link)s") % {
526
+ "link": link
527
+ }
153
528
  try:
529
+ node_error = None
154
530
  node = Node.get_local()
155
531
  if node:
156
- result = node.send_mail(subject, body, [email])
532
+ try:
533
+ result = node.send_mail(subject, body, [email])
534
+ except Exception as exc:
535
+ node_error = exc
536
+ logger.exception(
537
+ "Node send_mail failed, falling back to default backend"
538
+ )
539
+ result = mailer.send(
540
+ subject, body, [email], settings.DEFAULT_FROM_EMAIL
541
+ )
542
+ else:
543
+ result = mailer.send(
544
+ subject, body, [email], settings.DEFAULT_FROM_EMAIL
545
+ )
546
+ lead.sent_on = timezone.now()
547
+ if node_error:
548
+ lead.error = (
549
+ f"Node email send failed: {node_error}. "
550
+ "Invite was sent using default mail backend; ensure the "
551
+ "node's email service is running or check its configuration."
552
+ )
157
553
  else:
158
- result = send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [email])
554
+ lead.error = ""
159
555
  logger.info(
160
556
  "Invitation email sent to %s (user %s): %s", email, user.pk, result
161
557
  )
162
- except Exception:
558
+ except Exception as exc:
559
+ lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
163
560
  logger.exception("Failed to send invitation email to %s", email)
561
+ if lead.sent_on or lead.error:
562
+ lead.save(update_fields=["sent_on", "error"])
164
563
  sent = True
165
564
  return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
166
565
 
@@ -199,13 +598,165 @@ def invitation_login(request, uidb64, token):
199
598
  user.set_password(password)
200
599
  user.is_active = True
201
600
  user.save()
601
+ node = Node.get_local()
602
+ if node and node.has_feature("ap-public-wifi"):
603
+ mac_address = public_wifi.resolve_mac_address(
604
+ request.META.get("REMOTE_ADDR")
605
+ )
606
+ if not mac_address:
607
+ mac_address = (
608
+ InviteLead.objects.filter(email__iexact=user.email)
609
+ .exclude(mac_address="")
610
+ .order_by("-created_on")
611
+ .values_list("mac_address", flat=True)
612
+ .first()
613
+ )
614
+ if mac_address:
615
+ public_wifi.grant_public_access(user, mac_address)
202
616
  login(request, user, backend="core.backends.LocalhostAdminBackend")
203
617
  return redirect(reverse("admin:index") if user.is_staff else "/")
204
618
  return render(request, "pages/invitation_login.html", {"form": form})
205
619
 
206
620
 
621
+ class ClientReportForm(forms.Form):
622
+ PERIOD_CHOICES = [
623
+ ("range", _("Date range")),
624
+ ("week", _("Week")),
625
+ ("month", _("Month")),
626
+ ]
627
+ RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
628
+ period = forms.ChoiceField(
629
+ choices=PERIOD_CHOICES, widget=forms.RadioSelect, initial="range"
630
+ )
631
+ start = forms.DateField(
632
+ label=_("Start date"),
633
+ required=False,
634
+ widget=forms.DateInput(attrs={"type": "date"}),
635
+ )
636
+ end = forms.DateField(
637
+ label=_("End date"),
638
+ required=False,
639
+ widget=forms.DateInput(attrs={"type": "date"}),
640
+ )
641
+ week = forms.CharField(
642
+ label=_("Week"),
643
+ required=False,
644
+ widget=forms.TextInput(attrs={"type": "week"}),
645
+ )
646
+ month = forms.DateField(
647
+ label=_("Month"),
648
+ required=False,
649
+ widget=forms.DateInput(attrs={"type": "month"}),
650
+ )
651
+ owner = forms.ModelChoiceField(
652
+ queryset=get_user_model().objects.all(), required=False
653
+ )
654
+ destinations = forms.CharField(
655
+ label=_("Email destinations"),
656
+ required=False,
657
+ widget=forms.Textarea(attrs={"rows": 2}),
658
+ help_text=_("Separate addresses with commas or new lines."),
659
+ )
660
+ recurrence = forms.ChoiceField(
661
+ label=_("Recurrency"),
662
+ choices=RECURRENCE_CHOICES,
663
+ initial=ClientReportSchedule.PERIODICITY_NONE,
664
+ )
665
+ disable_emails = forms.BooleanField(
666
+ label=_("Disable email delivery"),
667
+ required=False,
668
+ help_text=_("Generate files without sending emails."),
669
+ )
670
+
671
+ def __init__(self, *args, request=None, **kwargs):
672
+ self.request = request
673
+ super().__init__(*args, **kwargs)
674
+ if request and getattr(request, "user", None) and request.user.is_authenticated:
675
+ self.fields["owner"].initial = request.user.pk
676
+
677
+ def clean(self):
678
+ cleaned = super().clean()
679
+ period = cleaned.get("period")
680
+ if period == "range":
681
+ if not cleaned.get("start") or not cleaned.get("end"):
682
+ raise forms.ValidationError(_("Please provide start and end dates."))
683
+ elif period == "week":
684
+ week_str = cleaned.get("week")
685
+ if not week_str:
686
+ raise forms.ValidationError(_("Please select a week."))
687
+ year, week_num = week_str.split("-W")
688
+ start = datetime.date.fromisocalendar(int(year), int(week_num), 1)
689
+ cleaned["start"] = start
690
+ cleaned["end"] = start + datetime.timedelta(days=6)
691
+ elif period == "month":
692
+ month_dt = cleaned.get("month")
693
+ if not month_dt:
694
+ raise forms.ValidationError(_("Please select a month."))
695
+ start = month_dt.replace(day=1)
696
+ last_day = calendar.monthrange(month_dt.year, month_dt.month)[1]
697
+ cleaned["start"] = start
698
+ cleaned["end"] = month_dt.replace(day=last_day)
699
+ return cleaned
700
+
701
+ def clean_destinations(self):
702
+ raw = self.cleaned_data.get("destinations", "")
703
+ if not raw:
704
+ return []
705
+ validator = EmailValidator()
706
+ seen: set[str] = set()
707
+ emails: list[str] = []
708
+ for part in re.split(r"[\s,]+", raw):
709
+ candidate = part.strip()
710
+ if not candidate:
711
+ continue
712
+ validator(candidate)
713
+ key = candidate.lower()
714
+ if key in seen:
715
+ continue
716
+ seen.add(key)
717
+ emails.append(candidate)
718
+ return emails
719
+
720
+
721
+ @live_update()
722
+ def client_report(request):
723
+ form = ClientReportForm(request.POST or None, request=request)
724
+ report = None
725
+ schedule = None
726
+ if request.method == "POST" and form.is_valid():
727
+ owner = form.cleaned_data.get("owner")
728
+ if not owner and request.user.is_authenticated:
729
+ owner = request.user
730
+ report = ClientReport.generate(
731
+ form.cleaned_data["start"],
732
+ form.cleaned_data["end"],
733
+ owner=owner,
734
+ recipients=form.cleaned_data.get("destinations"),
735
+ disable_emails=form.cleaned_data.get("disable_emails", False),
736
+ )
737
+ report.store_local_copy()
738
+ recurrence = form.cleaned_data.get("recurrence")
739
+ if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
740
+ schedule = ClientReportSchedule.objects.create(
741
+ owner=owner,
742
+ created_by=request.user if request.user.is_authenticated else None,
743
+ periodicity=recurrence,
744
+ email_recipients=form.cleaned_data.get("destinations", []),
745
+ disable_emails=form.cleaned_data.get("disable_emails", False),
746
+ )
747
+ report.schedule = schedule
748
+ report.save(update_fields=["schedule"])
749
+ messages.success(
750
+ request,
751
+ _(
752
+ "Client report schedule created; future reports will be generated automatically."
753
+ ),
754
+ )
755
+ context = {"form": form, "report": report, "schedule": schedule}
756
+ return render(request, "pages/client_report.html", context)
757
+
758
+
207
759
  def csrf_failure(request, reason=""):
208
760
  """Custom CSRF failure view with a friendly message."""
209
761
  logger.warning("CSRF failure on %s: %s", request.path, reason)
210
762
  return render(request, "pages/csrf_failure.html", status=403)
211
-