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.
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
- arthexis-0.1.11.dist-info/RECORD +99 -0
- config/context_processors.py +1 -0
- config/settings.py +245 -26
- config/urls.py +11 -4
- core/admin.py +585 -57
- core/apps.py +29 -1
- core/auto_upgrade.py +57 -0
- core/backends.py +115 -3
- core/environment.py +23 -5
- core/fields.py +93 -0
- core/mailer.py +3 -1
- core/models.py +482 -38
- core/reference_utils.py +108 -0
- core/sigil_builder.py +23 -5
- core/sigil_resolver.py +35 -4
- core/system.py +400 -140
- core/tasks.py +151 -8
- core/temp_passwords.py +181 -0
- core/test_system_info.py +97 -1
- core/tests.py +393 -15
- core/user_data.py +154 -16
- core/views.py +499 -20
- nodes/admin.py +149 -6
- nodes/backends.py +125 -18
- nodes/dns.py +203 -0
- nodes/models.py +498 -9
- nodes/tests.py +682 -3
- nodes/views.py +154 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +255 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +52 -7
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +414 -8
- ocpp/views.py +109 -76
- pages/admin.py +9 -1
- pages/context_processors.py +24 -4
- pages/defaults.py +14 -0
- pages/forms.py +131 -0
- pages/models.py +53 -14
- pages/tests.py +450 -14
- pages/urls.py +4 -0
- pages/views.py +419 -110
- arthexis-0.1.9.dist-info/RECORD +0 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 "",
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
554
|
-
lead.error = ""
|
|
555
|
-
|
|
556
|
-
"
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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,
|
|
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(),
|
|
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"
|
|
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),
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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})
|