arthexis 0.1.9__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.
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/METADATA +63 -20
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/RECORD +39 -36
- config/settings.py +221 -23
- config/urls.py +6 -0
- core/admin.py +401 -35
- core/apps.py +3 -0
- core/auto_upgrade.py +57 -0
- core/backends.py +77 -3
- core/fields.py +93 -0
- core/models.py +212 -7
- core/reference_utils.py +97 -0
- core/sigil_builder.py +16 -3
- core/system.py +157 -143
- core/tasks.py +151 -8
- core/test_system_info.py +37 -1
- core/tests.py +288 -12
- core/user_data.py +103 -8
- core/views.py +257 -15
- nodes/admin.py +12 -4
- nodes/backends.py +109 -17
- nodes/models.py +205 -2
- nodes/tests.py +370 -1
- nodes/views.py +140 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +252 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +49 -7
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/tests.py +384 -8
- ocpp/views.py +101 -76
- pages/context_processors.py +20 -0
- pages/forms.py +131 -0
- pages/tests.py +434 -13
- pages/urls.py +1 -0
- pages/views.py +334 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
pages/views.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import logging
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
import datetime
|
|
4
5
|
import calendar
|
|
6
|
+
import io
|
|
5
7
|
import shutil
|
|
6
8
|
import re
|
|
7
9
|
from html import escape
|
|
@@ -25,13 +27,16 @@ from django.utils import timezone
|
|
|
25
27
|
from django.utils.encoding import force_bytes, force_str
|
|
26
28
|
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
|
27
29
|
from core import mailer, public_wifi
|
|
30
|
+
from core.backends import TOTP_DEVICE_NAME
|
|
28
31
|
from django.utils.translation import gettext as _
|
|
29
32
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
33
|
+
from django.core.cache import cache
|
|
30
34
|
from django.views.decorators.cache import never_cache
|
|
31
35
|
from django.utils.cache import patch_vary_headers
|
|
32
36
|
from django.core.exceptions import PermissionDenied
|
|
33
37
|
from django.utils.text import slugify
|
|
34
38
|
from django.core.validators import EmailValidator
|
|
39
|
+
from django.db.models import Q
|
|
35
40
|
from core.models import InviteLead, ClientReport, ClientReportSchedule
|
|
36
41
|
|
|
37
42
|
try: # pragma: no cover - optional dependency guard
|
|
@@ -44,6 +49,10 @@ except ImportError: # pragma: no cover - handled gracefully in views
|
|
|
44
49
|
import markdown
|
|
45
50
|
from pages.utils import landing
|
|
46
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
|
|
47
56
|
from .models import Module
|
|
48
57
|
|
|
49
58
|
|
|
@@ -67,9 +76,13 @@ def _filter_models_for_request(models, request):
|
|
|
67
76
|
model_admin = admin.site._registry.get(model)
|
|
68
77
|
if model_admin is None:
|
|
69
78
|
continue
|
|
70
|
-
if not model_admin.has_module_permission(request)
|
|
79
|
+
if not model_admin.has_module_permission(request) and not getattr(
|
|
80
|
+
request.user, "is_staff", False
|
|
81
|
+
):
|
|
71
82
|
continue
|
|
72
|
-
if not model_admin.has_view_permission(request, obj=None)
|
|
83
|
+
if not model_admin.has_view_permission(request, obj=None) and not getattr(
|
|
84
|
+
request.user, "is_staff", False
|
|
85
|
+
):
|
|
73
86
|
continue
|
|
74
87
|
allowed.append(model)
|
|
75
88
|
return allowed
|
|
@@ -80,8 +93,13 @@ def _admin_has_app_permission(request, app_label: str) -> bool:
|
|
|
80
93
|
|
|
81
94
|
has_app_permission = getattr(admin.site, "has_app_permission", None)
|
|
82
95
|
if callable(has_app_permission):
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
85
103
|
|
|
86
104
|
|
|
87
105
|
def _resolve_related_model(field, default_app_label: str):
|
|
@@ -454,6 +472,7 @@ class CustomLoginView(LoginView):
|
|
|
454
472
|
"""Login view that redirects staff to the admin."""
|
|
455
473
|
|
|
456
474
|
template_name = "pages/login.html"
|
|
475
|
+
form_class = AuthenticatorLoginForm
|
|
457
476
|
|
|
458
477
|
def dispatch(self, request, *args, **kwargs):
|
|
459
478
|
if request.user.is_authenticated:
|
|
@@ -481,13 +500,165 @@ class CustomLoginView(LoginView):
|
|
|
481
500
|
return reverse("admin:index")
|
|
482
501
|
return "/"
|
|
483
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
|
+
|
|
484
510
|
|
|
485
511
|
login_view = CustomLoginView.as_view()
|
|
486
512
|
|
|
487
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
|
+
|
|
488
624
|
class InvitationRequestForm(forms.Form):
|
|
489
625
|
email = forms.EmailField()
|
|
490
|
-
comment = forms.CharField(
|
|
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
|
|
491
662
|
|
|
492
663
|
|
|
493
664
|
@csrf_exempt
|
|
@@ -499,68 +670,78 @@ def request_invite(request):
|
|
|
499
670
|
email = form.cleaned_data["email"]
|
|
500
671
|
comment = form.cleaned_data.get("comment", "")
|
|
501
672
|
ip_address = request.META.get("REMOTE_ADDR")
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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 "",
|
|
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
|
|
512
679
|
)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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 "",
|
|
523
693
|
)
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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])
|
|
704
|
+
)
|
|
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:
|
|
539
724
|
result = mailer.send(
|
|
540
725
|
subject, body, [email], settings.DEFAULT_FROM_EMAIL
|
|
541
726
|
)
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
552
738
|
)
|
|
553
|
-
|
|
554
|
-
lead.error = ""
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
)
|
|
558
|
-
|
|
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
|
|
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
|
|
564
745
|
return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
|
|
565
746
|
|
|
566
747
|
|
|
@@ -626,30 +807,41 @@ class ClientReportForm(forms.Form):
|
|
|
626
807
|
]
|
|
627
808
|
RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
|
|
628
809
|
period = forms.ChoiceField(
|
|
629
|
-
choices=PERIOD_CHOICES,
|
|
810
|
+
choices=PERIOD_CHOICES,
|
|
811
|
+
widget=forms.RadioSelect,
|
|
812
|
+
initial="range",
|
|
813
|
+
help_text=_("Choose how the reporting window will be calculated."),
|
|
630
814
|
)
|
|
631
815
|
start = forms.DateField(
|
|
632
816
|
label=_("Start date"),
|
|
633
817
|
required=False,
|
|
634
818
|
widget=forms.DateInput(attrs={"type": "date"}),
|
|
819
|
+
help_text=_("First day included when using a custom date range."),
|
|
635
820
|
)
|
|
636
821
|
end = forms.DateField(
|
|
637
822
|
label=_("End date"),
|
|
638
823
|
required=False,
|
|
639
824
|
widget=forms.DateInput(attrs={"type": "date"}),
|
|
825
|
+
help_text=_("Last day included when using a custom date range."),
|
|
640
826
|
)
|
|
641
827
|
week = forms.CharField(
|
|
642
828
|
label=_("Week"),
|
|
643
829
|
required=False,
|
|
644
830
|
widget=forms.TextInput(attrs={"type": "week"}),
|
|
831
|
+
help_text=_("Generates the report for the ISO week that you select."),
|
|
645
832
|
)
|
|
646
833
|
month = forms.DateField(
|
|
647
834
|
label=_("Month"),
|
|
648
835
|
required=False,
|
|
649
836
|
widget=forms.DateInput(attrs={"type": "month"}),
|
|
837
|
+
help_text=_("Generates the report for the calendar month that you select."),
|
|
650
838
|
)
|
|
651
839
|
owner = forms.ModelChoiceField(
|
|
652
|
-
queryset=get_user_model().objects.all(),
|
|
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
|
+
),
|
|
653
845
|
)
|
|
654
846
|
destinations = forms.CharField(
|
|
655
847
|
label=_("Email destinations"),
|
|
@@ -661,6 +853,7 @@ class ClientReportForm(forms.Form):
|
|
|
661
853
|
label=_("Recurrency"),
|
|
662
854
|
choices=RECURRENCE_CHOICES,
|
|
663
855
|
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
856
|
+
help_text=_("Defines how often the report should be generated automatically."),
|
|
664
857
|
)
|
|
665
858
|
disable_emails = forms.BooleanField(
|
|
666
859
|
label=_("Disable email delivery"),
|
|
@@ -723,36 +916,85 @@ def client_report(request):
|
|
|
723
916
|
form = ClientReportForm(request.POST or None, request=request)
|
|
724
917
|
report = None
|
|
725
918
|
schedule = None
|
|
726
|
-
if request.method == "POST"
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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),
|
|
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."),
|
|
746
924
|
)
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
+
}
|
|
756
998
|
return render(request, "pages/client_report.html", context)
|
|
757
999
|
|
|
758
1000
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|