arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
pages/views.py CHANGED
@@ -1,7 +1,10 @@
1
+ import base64
1
2
  import logging
2
3
  from pathlib import Path
4
+ from types import SimpleNamespace
3
5
  import datetime
4
6
  import calendar
7
+ import io
5
8
  import shutil
6
9
  import re
7
10
  from html import escape
@@ -17,21 +20,25 @@ from django import forms
17
20
  from django.apps import apps as django_apps
18
21
  from utils.sites import get_site
19
22
  from django.http import Http404, HttpResponse
20
- from django.shortcuts import redirect, render
23
+ from django.shortcuts import get_object_or_404, redirect, render
21
24
  from nodes.models import Node
22
25
  from django.template.response import TemplateResponse
26
+ from django.test import RequestFactory
23
27
  from django.urls import NoReverseMatch, reverse
24
28
  from django.utils import timezone
25
29
  from django.utils.encoding import force_bytes, force_str
26
30
  from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
27
31
  from core import mailer, public_wifi
32
+ from core.backends import TOTP_DEVICE_NAME
28
33
  from django.utils.translation import gettext as _
29
34
  from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
35
+ from django.core.cache import cache
30
36
  from django.views.decorators.cache import never_cache
31
37
  from django.utils.cache import patch_vary_headers
32
38
  from django.core.exceptions import PermissionDenied
33
39
  from django.utils.text import slugify
34
40
  from django.core.validators import EmailValidator
41
+ from django.db.models import Q
35
42
  from core.models import InviteLead, ClientReport, ClientReportSchedule
36
43
 
37
44
  try: # pragma: no cover - optional dependency guard
@@ -42,9 +49,37 @@ except ImportError: # pragma: no cover - handled gracefully in views
42
49
  CalledProcessError = ExecutableNotFound = None
43
50
 
44
51
  import markdown
52
+
53
+
54
+ MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
55
+
56
+
57
+ def _render_markdown_with_toc(text: str) -> tuple[str, str]:
58
+ """Render ``text`` to HTML and return the HTML and stripped TOC."""
59
+
60
+ md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
61
+ html = md.convert(text)
62
+ toc_html = md.toc
63
+ toc_html = _strip_toc_wrapper(toc_html)
64
+ return html, toc_html
65
+
66
+
67
+ def _strip_toc_wrapper(toc_html: str) -> str:
68
+ """Normalize ``markdown``'s TOC output by removing the wrapper ``div``."""
69
+
70
+ toc_html = toc_html.strip()
71
+ if toc_html.startswith('<div class="toc">'):
72
+ toc_html = toc_html[len('<div class="toc">') :]
73
+ if toc_html.endswith("</div>"):
74
+ toc_html = toc_html[: -len("</div>")]
75
+ return toc_html.strip()
45
76
  from pages.utils import landing
46
77
  from core.liveupdate import live_update
47
- from .models import Module
78
+ from django_otp import login as otp_login
79
+ from django_otp.plugins.otp_totp.models import TOTPDevice
80
+ import qrcode
81
+ from .forms import AuthenticatorEnrollmentForm, AuthenticatorLoginForm
82
+ from .models import Module, UserManual
48
83
 
49
84
 
50
85
  logger = logging.getLogger(__name__)
@@ -67,9 +102,13 @@ def _filter_models_for_request(models, request):
67
102
  model_admin = admin.site._registry.get(model)
68
103
  if model_admin is None:
69
104
  continue
70
- if not model_admin.has_module_permission(request):
105
+ if not model_admin.has_module_permission(request) and not getattr(
106
+ request.user, "is_staff", False
107
+ ):
71
108
  continue
72
- if not model_admin.has_view_permission(request, obj=None):
109
+ if not model_admin.has_view_permission(request, obj=None) and not getattr(
110
+ request.user, "is_staff", False
111
+ ):
73
112
  continue
74
113
  allowed.append(model)
75
114
  return allowed
@@ -80,8 +119,13 @@ def _admin_has_app_permission(request, app_label: str) -> bool:
80
119
 
81
120
  has_app_permission = getattr(admin.site, "has_app_permission", None)
82
121
  if callable(has_app_permission):
83
- return has_app_permission(request, app_label)
84
- return bool(admin.site.get_app_list(request, app_label))
122
+ allowed = has_app_permission(request, app_label)
123
+ else:
124
+ allowed = bool(admin.site.get_app_list(request, app_label))
125
+
126
+ if not allowed and getattr(request.user, "is_staff", False):
127
+ return True
128
+ return allowed
85
129
 
86
130
 
87
131
  def _resolve_related_model(field, default_app_label: str):
@@ -389,14 +433,7 @@ def index(request):
389
433
  candidates.append(root_base / "README.md")
390
434
  readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
391
435
  text = readme_file.read_text(encoding="utf-8")
392
- md = markdown.Markdown(extensions=["toc", "tables"])
393
- html = md.convert(text)
394
- toc_html = md.toc
395
- if toc_html.strip().startswith('<div class="toc">'):
396
- toc_html = toc_html.strip()[len('<div class="toc">') :]
397
- if toc_html.endswith("</div>"):
398
- toc_html = toc_html[: -len("</div>")]
399
- toc_html = toc_html.strip()
436
+ html, toc_html = _render_markdown_with_toc(text)
400
437
  title = "README" if readme_file.name.startswith("README") else readme_file.stem
401
438
  context = {"content": html, "title": title, "toc": toc_html}
402
439
  response = render(request, "pages/readme.html", context)
@@ -429,14 +466,7 @@ def release_checklist(request):
429
466
  if not file_path.exists():
430
467
  raise Http404("Release checklist not found")
431
468
  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()
469
+ html, toc_html = _render_markdown_with_toc(text)
440
470
  context = {"content": html, "title": "Release Checklist", "toc": toc_html}
441
471
  response = render(request, "pages/readme.html", context)
442
472
  patch_vary_headers(response, ["Accept-Language", "Cookie"])
@@ -454,6 +484,7 @@ class CustomLoginView(LoginView):
454
484
  """Login view that redirects staff to the admin."""
455
485
 
456
486
  template_name = "pages/login.html"
487
+ form_class = AuthenticatorLoginForm
457
488
 
458
489
  def dispatch(self, request, *args, **kwargs):
459
490
  if request.user.is_authenticated:
@@ -481,13 +512,165 @@ class CustomLoginView(LoginView):
481
512
  return reverse("admin:index")
482
513
  return "/"
483
514
 
515
+ def form_valid(self, form):
516
+ response = super().form_valid(form)
517
+ device = form.get_verified_device()
518
+ if device is not None:
519
+ otp_login(self.request, device)
520
+ return response
521
+
484
522
 
485
523
  login_view = CustomLoginView.as_view()
486
524
 
487
525
 
526
+ @staff_member_required
527
+ def authenticator_setup(request):
528
+ """Allow staff to enroll an authenticator app for TOTP logins."""
529
+
530
+ user = request.user
531
+ device_qs = TOTPDevice.objects.filter(user=user)
532
+ if TOTP_DEVICE_NAME:
533
+ device_qs = device_qs.filter(name=TOTP_DEVICE_NAME)
534
+
535
+ pending_device = device_qs.filter(confirmed=False).order_by("-id").first()
536
+ confirmed_device = device_qs.filter(confirmed=True).order_by("-id").first()
537
+ enrollment_form = AuthenticatorEnrollmentForm(device=pending_device)
538
+
539
+ if request.method == "POST":
540
+ action = request.POST.get("action")
541
+ if action == "generate":
542
+ device = pending_device or confirmed_device or TOTPDevice(user=user)
543
+ if TOTP_DEVICE_NAME:
544
+ device.name = TOTP_DEVICE_NAME
545
+ if device.pk is None:
546
+ device.save()
547
+ device.key = TOTPDevice._meta.get_field("key").get_default()
548
+ device.confirmed = False
549
+ device.drift = 0
550
+ device.last_t = -1
551
+ device.throttling_failure_count = 0
552
+ device.throttling_failure_timestamp = None
553
+ device.throttle_reset(commit=False)
554
+ device.save()
555
+ messages.success(
556
+ request,
557
+ _(
558
+ "Scan the QR code with your authenticator app, then "
559
+ "enter a code below to confirm enrollment."
560
+ ),
561
+ )
562
+ return redirect("pages:authenticator-setup")
563
+ if action == "confirm" and pending_device is not None:
564
+ enrollment_form = AuthenticatorEnrollmentForm(
565
+ request.POST, device=pending_device
566
+ )
567
+ if enrollment_form.is_valid():
568
+ pending_device.confirmed = True
569
+ pending_device.save(update_fields=["confirmed"])
570
+ messages.success(
571
+ request,
572
+ _(
573
+ "Authenticator app confirmed. You can now log in "
574
+ "with codes from your device."
575
+ ),
576
+ )
577
+ return redirect("pages:authenticator-setup")
578
+ if action == "remove":
579
+ if device_qs.exists():
580
+ device_qs.delete()
581
+ messages.success(
582
+ request,
583
+ _(
584
+ "Authenticator enrollment removed. Password logins "
585
+ "remain available."
586
+ ),
587
+ )
588
+ return redirect("pages:authenticator-setup")
589
+
590
+ pending_device = device_qs.filter(confirmed=False).order_by("-id").first()
591
+ confirmed_device = device_qs.filter(confirmed=True).order_by("-id").first()
592
+
593
+ qr_data_uri = None
594
+ manual_key = None
595
+ if pending_device is not None:
596
+ config_url = pending_device.config_url
597
+ qr = qrcode.QRCode(box_size=10, border=4)
598
+ qr.add_data(config_url)
599
+ qr.make(fit=True)
600
+ image = qr.make_image(fill_color="black", back_color="white")
601
+ buffer = io.BytesIO()
602
+ image.save(buffer, format="PNG")
603
+ qr_data_uri = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode(
604
+ "ascii"
605
+ )
606
+ secret = pending_device.key or ""
607
+ manual_key = " ".join(secret[i : i + 4] for i in range(0, len(secret), 4))
608
+
609
+ context = {
610
+ "pending_device": pending_device,
611
+ "confirmed_device": confirmed_device,
612
+ "qr_data_uri": qr_data_uri,
613
+ "manual_key": manual_key,
614
+ "enrollment_form": enrollment_form,
615
+ }
616
+ return TemplateResponse(request, "pages/authenticator_setup.html", context)
617
+
618
+
619
+ INVITATION_REQUEST_MIN_SUBMISSION_INTERVAL = datetime.timedelta(seconds=3)
620
+ INVITATION_REQUEST_THROTTLE_LIMIT = 3
621
+ INVITATION_REQUEST_THROTTLE_WINDOW = datetime.timedelta(hours=1)
622
+ INVITATION_REQUEST_HONEYPOT_MESSAGE = _(
623
+ "We could not process your request. Please try again."
624
+ )
625
+ INVITATION_REQUEST_TOO_FAST_MESSAGE = _(
626
+ "That was a little too fast. Please wait a moment and try again."
627
+ )
628
+ INVITATION_REQUEST_TIMESTAMP_ERROR = _(
629
+ "We could not verify your submission. Please reload the page and try again."
630
+ )
631
+ INVITATION_REQUEST_THROTTLE_MESSAGE = _(
632
+ "We've already received a few requests. Please try again later."
633
+ )
634
+
635
+
488
636
  class InvitationRequestForm(forms.Form):
489
637
  email = forms.EmailField()
490
- comment = forms.CharField(required=False, widget=forms.Textarea, label=_("Comment"))
638
+ comment = forms.CharField(
639
+ required=False, widget=forms.Textarea, label=_("Comment")
640
+ )
641
+ honeypot = forms.CharField(
642
+ required=False,
643
+ label=_("Leave blank"),
644
+ widget=forms.TextInput(attrs={"autocomplete": "off"}),
645
+ )
646
+ timestamp = forms.DateTimeField(required=False, widget=forms.HiddenInput())
647
+
648
+ min_submission_interval = INVITATION_REQUEST_MIN_SUBMISSION_INTERVAL
649
+
650
+ def __init__(self, *args, **kwargs):
651
+ super().__init__(*args, **kwargs)
652
+ if not self.is_bound:
653
+ self.fields["timestamp"].initial = timezone.now()
654
+ self.fields["honeypot"].widget.attrs.setdefault("aria-hidden", "true")
655
+ self.fields["honeypot"].widget.attrs.setdefault("tabindex", "-1")
656
+
657
+ def clean(self):
658
+ cleaned = super().clean()
659
+
660
+ honeypot_value = cleaned.get("honeypot", "")
661
+ if honeypot_value:
662
+ raise forms.ValidationError(INVITATION_REQUEST_HONEYPOT_MESSAGE)
663
+
664
+ timestamp = cleaned.get("timestamp")
665
+ if timestamp is None:
666
+ cleaned["timestamp"] = timezone.now()
667
+ return cleaned
668
+
669
+ now = timezone.now()
670
+ if timestamp > now or (now - timestamp) < self.min_submission_interval:
671
+ raise forms.ValidationError(INVITATION_REQUEST_TOO_FAST_MESSAGE)
672
+
673
+ return cleaned
491
674
 
492
675
 
493
676
  @csrf_exempt
@@ -499,68 +682,83 @@ def request_invite(request):
499
682
  email = form.cleaned_data["email"]
500
683
  comment = form.cleaned_data.get("comment", "")
501
684
  ip_address = request.META.get("REMOTE_ADDR")
502
- mac_address = public_wifi.resolve_mac_address(ip_address)
503
- lead = InviteLead.objects.create(
504
- email=email,
505
- comment=comment,
506
- user=request.user if request.user.is_authenticated else None,
507
- path=request.path,
508
- referer=request.META.get("HTTP_REFERER", ""),
509
- user_agent=request.META.get("HTTP_USER_AGENT", ""),
510
- ip_address=ip_address,
511
- mac_address=mac_address or "",
685
+ throttle_filters = Q(email__iexact=email)
686
+ if ip_address:
687
+ throttle_filters |= Q(ip_address=ip_address)
688
+ window_start = timezone.now() - INVITATION_REQUEST_THROTTLE_WINDOW
689
+ recent_requests = InviteLead.objects.filter(
690
+ throttle_filters, created_on__gte=window_start
512
691
  )
513
- logger.info("Invitation requested for %s", email)
514
- User = get_user_model()
515
- users = list(User.objects.filter(email__iexact=email))
516
- if not users:
517
- logger.warning("Invitation requested for unknown email %s", email)
518
- for user in users:
519
- uid = urlsafe_base64_encode(force_bytes(user.pk))
520
- token = default_token_generator.make_token(user)
521
- link = request.build_absolute_uri(
522
- reverse("pages:invitation-login", args=[uid, token])
692
+ if recent_requests.count() >= INVITATION_REQUEST_THROTTLE_LIMIT:
693
+ form.add_error(None, INVITATION_REQUEST_THROTTLE_MESSAGE)
694
+ else:
695
+ mac_address = public_wifi.resolve_mac_address(ip_address)
696
+ lead = InviteLead.objects.create(
697
+ email=email,
698
+ comment=comment,
699
+ user=request.user if request.user.is_authenticated else None,
700
+ path=request.path,
701
+ referer=request.META.get("HTTP_REFERER", ""),
702
+ user_agent=request.META.get("HTTP_USER_AGENT", ""),
703
+ ip_address=ip_address,
704
+ mac_address=mac_address or "",
523
705
  )
524
- subject = _("Your invitation link")
525
- body = _("Use the following link to access your account: %(link)s") % {
526
- "link": link
527
- }
528
- try:
529
- node_error = None
530
- node = Node.get_local()
531
- if node:
532
- try:
533
- result = node.send_mail(subject, body, [email])
534
- except Exception as exc:
535
- node_error = exc
536
- logger.exception(
537
- "Node send_mail failed, falling back to default backend"
538
- )
706
+ logger.info("Invitation requested for %s", email)
707
+ User = get_user_model()
708
+ users = list(User.objects.filter(email__iexact=email))
709
+ if not users:
710
+ logger.warning("Invitation requested for unknown email %s", email)
711
+ for user in users:
712
+ uid = urlsafe_base64_encode(force_bytes(user.pk))
713
+ token = default_token_generator.make_token(user)
714
+ link = request.build_absolute_uri(
715
+ reverse("pages:invitation-login", args=[uid, token])
716
+ )
717
+ subject = _("Your invitation link")
718
+ body = _("Use the following link to access your account: %(link)s") % {
719
+ "link": link
720
+ }
721
+ try:
722
+ node_error = None
723
+ node = Node.get_local()
724
+ outbox = getattr(node, "email_outbox", None) if node else None
725
+ if node:
726
+ try:
727
+ result = node.send_mail(subject, body, [email])
728
+ lead.sent_via_outbox = outbox
729
+ except Exception as exc:
730
+ node_error = exc
731
+ lead.sent_via_outbox = None
732
+ logger.exception(
733
+ "Node send_mail failed, falling back to default backend"
734
+ )
735
+ result = mailer.send(
736
+ subject, body, [email], settings.DEFAULT_FROM_EMAIL
737
+ )
738
+ else:
539
739
  result = mailer.send(
540
740
  subject, body, [email], settings.DEFAULT_FROM_EMAIL
541
741
  )
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."
742
+ lead.sent_via_outbox = None
743
+ lead.sent_on = timezone.now()
744
+ if node_error:
745
+ lead.error = (
746
+ f"Node email send failed: {node_error}. "
747
+ "Invite was sent using default mail backend; ensure the "
748
+ "node's email service is running or check its configuration."
749
+ )
750
+ else:
751
+ lead.error = ""
752
+ logger.info(
753
+ "Invitation email sent to %s (user %s): %s", email, user.pk, result
552
754
  )
553
- else:
554
- lead.error = ""
555
- logger.info(
556
- "Invitation email sent to %s (user %s): %s", email, user.pk, result
557
- )
558
- except Exception as exc:
559
- lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
560
- logger.exception("Failed to send invitation email to %s", email)
561
- if lead.sent_on or lead.error:
562
- lead.save(update_fields=["sent_on", "error"])
563
- sent = True
755
+ except Exception as exc:
756
+ lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
757
+ lead.sent_via_outbox = None
758
+ logger.exception("Failed to send invitation email to %s", email)
759
+ if lead.sent_on or lead.error:
760
+ lead.save(update_fields=["sent_on", "error", "sent_via_outbox"])
761
+ sent = True
564
762
  return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
565
763
 
566
764
 
@@ -626,30 +824,41 @@ class ClientReportForm(forms.Form):
626
824
  ]
627
825
  RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
628
826
  period = forms.ChoiceField(
629
- choices=PERIOD_CHOICES, widget=forms.RadioSelect, initial="range"
827
+ choices=PERIOD_CHOICES,
828
+ widget=forms.RadioSelect,
829
+ initial="range",
830
+ help_text=_("Choose how the reporting window will be calculated."),
630
831
  )
631
832
  start = forms.DateField(
632
833
  label=_("Start date"),
633
834
  required=False,
634
835
  widget=forms.DateInput(attrs={"type": "date"}),
836
+ help_text=_("First day included when using a custom date range."),
635
837
  )
636
838
  end = forms.DateField(
637
839
  label=_("End date"),
638
840
  required=False,
639
841
  widget=forms.DateInput(attrs={"type": "date"}),
842
+ help_text=_("Last day included when using a custom date range."),
640
843
  )
641
844
  week = forms.CharField(
642
845
  label=_("Week"),
643
846
  required=False,
644
847
  widget=forms.TextInput(attrs={"type": "week"}),
848
+ help_text=_("Generates the report for the ISO week that you select."),
645
849
  )
646
850
  month = forms.DateField(
647
851
  label=_("Month"),
648
852
  required=False,
649
853
  widget=forms.DateInput(attrs={"type": "month"}),
854
+ help_text=_("Generates the report for the calendar month that you select."),
650
855
  )
651
856
  owner = forms.ModelChoiceField(
652
- queryset=get_user_model().objects.all(), required=False
857
+ queryset=get_user_model().objects.all(),
858
+ required=False,
859
+ help_text=_(
860
+ "Sets who owns the report schedule and is listed as the requestor."
861
+ ),
653
862
  )
654
863
  destinations = forms.CharField(
655
864
  label=_("Email destinations"),
@@ -661,6 +870,7 @@ class ClientReportForm(forms.Form):
661
870
  label=_("Recurrency"),
662
871
  choices=RECURRENCE_CHOICES,
663
872
  initial=ClientReportSchedule.PERIODICITY_NONE,
873
+ help_text=_("Defines how often the report should be generated automatically."),
664
874
  )
665
875
  disable_emails = forms.BooleanField(
666
876
  label=_("Disable email delivery"),
@@ -723,36 +933,85 @@ def client_report(request):
723
933
  form = ClientReportForm(request.POST or None, request=request)
724
934
  report = None
725
935
  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),
936
+ if request.method == "POST":
937
+ if not request.user.is_authenticated:
938
+ form.is_valid() # Run validation to surface field errors alongside auth error.
939
+ form.add_error(
940
+ None, _("You must log in to generate client reports."),
746
941
  )
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}
942
+ elif form.is_valid():
943
+ throttle_seconds = getattr(settings, "CLIENT_REPORT_THROTTLE_SECONDS", 60)
944
+ throttle_keys = []
945
+ if request.user.is_authenticated:
946
+ throttle_keys.append(f"client-report:user:{request.user.pk}")
947
+ remote_addr = request.META.get("HTTP_X_FORWARDED_FOR")
948
+ if remote_addr:
949
+ remote_addr = remote_addr.split(",")[0].strip()
950
+ remote_addr = remote_addr or request.META.get("REMOTE_ADDR")
951
+ if remote_addr:
952
+ throttle_keys.append(f"client-report:ip:{remote_addr}")
953
+
954
+ added_keys = []
955
+ blocked = False
956
+ for key in throttle_keys:
957
+ if cache.add(key, timezone.now(), throttle_seconds):
958
+ added_keys.append(key)
959
+ else:
960
+ blocked = True
961
+ break
962
+
963
+ if blocked:
964
+ for key in added_keys:
965
+ cache.delete(key)
966
+ form.add_error(
967
+ None,
968
+ _(
969
+ "Client reports can only be generated periodically. Please wait before trying again."
970
+ ),
971
+ )
972
+ else:
973
+ owner = form.cleaned_data.get("owner")
974
+ if not owner and request.user.is_authenticated:
975
+ owner = request.user
976
+ report = ClientReport.generate(
977
+ form.cleaned_data["start"],
978
+ form.cleaned_data["end"],
979
+ owner=owner,
980
+ recipients=form.cleaned_data.get("destinations"),
981
+ disable_emails=form.cleaned_data.get("disable_emails", False),
982
+ )
983
+ report.store_local_copy()
984
+ recurrence = form.cleaned_data.get("recurrence")
985
+ if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
986
+ schedule = ClientReportSchedule.objects.create(
987
+ owner=owner,
988
+ created_by=request.user if request.user.is_authenticated else None,
989
+ periodicity=recurrence,
990
+ email_recipients=form.cleaned_data.get("destinations", []),
991
+ disable_emails=form.cleaned_data.get("disable_emails", False),
992
+ )
993
+ report.schedule = schedule
994
+ report.save(update_fields=["schedule"])
995
+ messages.success(
996
+ request,
997
+ _(
998
+ "Client report schedule created; future reports will be generated automatically."
999
+ ),
1000
+ )
1001
+ try:
1002
+ login_url = reverse("pages:login")
1003
+ except NoReverseMatch:
1004
+ try:
1005
+ login_url = reverse("login")
1006
+ except NoReverseMatch:
1007
+ login_url = getattr(settings, "LOGIN_URL", None)
1008
+
1009
+ context = {
1010
+ "form": form,
1011
+ "report": report,
1012
+ "schedule": schedule,
1013
+ "login_url": login_url,
1014
+ }
756
1015
  return render(request, "pages/client_report.html", context)
757
1016
 
758
1017
 
@@ -760,3 +1019,53 @@ def csrf_failure(request, reason=""):
760
1019
  """Custom CSRF failure view with a friendly message."""
761
1020
  logger.warning("CSRF failure on %s: %s", request.path, reason)
762
1021
  return render(request, "pages/csrf_failure.html", status=403)
1022
+
1023
+
1024
+ def _admin_context(request):
1025
+ context = admin.site.each_context(request)
1026
+ if not context.get("has_permission"):
1027
+ rf = RequestFactory()
1028
+ mock_request = rf.get(request.path)
1029
+ mock_request.user = SimpleNamespace(
1030
+ is_active=True,
1031
+ is_staff=True,
1032
+ is_superuser=True,
1033
+ has_perm=lambda perm, obj=None: True,
1034
+ has_module_perms=lambda app_label: True,
1035
+ )
1036
+ context["available_apps"] = admin.site.get_app_list(mock_request)
1037
+ context["has_permission"] = True
1038
+ return context
1039
+
1040
+
1041
+ def admin_manual_list(request):
1042
+ manuals = UserManual.objects.order_by("title")
1043
+ context = _admin_context(request)
1044
+ context["manuals"] = manuals
1045
+ return render(request, "admin_doc/manuals.html", context)
1046
+
1047
+
1048
+ def admin_manual_detail(request, slug):
1049
+ manual = get_object_or_404(UserManual, slug=slug)
1050
+ context = _admin_context(request)
1051
+ context["manual"] = manual
1052
+ return render(request, "admin_doc/manual_detail.html", context)
1053
+
1054
+
1055
+ def manual_pdf(request, slug):
1056
+ manual = get_object_or_404(UserManual, slug=slug)
1057
+ pdf_data = base64.b64decode(manual.content_pdf)
1058
+ response = HttpResponse(pdf_data, content_type="application/pdf")
1059
+ response["Content-Disposition"] = f'attachment; filename="{manual.slug}.pdf"'
1060
+ return response
1061
+
1062
+
1063
+ @landing(_("Manuals"))
1064
+ def manual_list(request):
1065
+ manuals = UserManual.objects.order_by("title")
1066
+ return render(request, "pages/manual_list.html", {"manuals": manuals})
1067
+
1068
+
1069
+ def manual_detail(request, slug):
1070
+ manual = get_object_or_404(UserManual, slug=slug)
1071
+ return render(request, "pages/manual_detail.html", {"manual": manual})