arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
pages/admin.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from collections import deque
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
from django.contrib import admin, messages
|
|
@@ -6,12 +7,14 @@ from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
|
|
|
6
7
|
from django.contrib.sites.models import Site
|
|
7
8
|
from django import forms
|
|
8
9
|
from django.shortcuts import redirect, render, get_object_or_404
|
|
9
|
-
from django.urls import path, reverse
|
|
10
|
+
from django.urls import NoReverseMatch, path, reverse
|
|
10
11
|
from django.utils.html import format_html
|
|
12
|
+
|
|
11
13
|
from django.template.response import TemplateResponse
|
|
12
|
-
from django.http import JsonResponse
|
|
14
|
+
from django.http import FileResponse, JsonResponse
|
|
13
15
|
from django.utils import timezone
|
|
14
16
|
from django.db.models import Count
|
|
17
|
+
from django.core.exceptions import FieldError
|
|
15
18
|
from django.db.models.functions import TruncDate
|
|
16
19
|
from datetime import datetime, time, timedelta
|
|
17
20
|
import ipaddress
|
|
@@ -25,6 +28,7 @@ from nodes.utils import capture_screenshot, save_screenshot
|
|
|
25
28
|
|
|
26
29
|
from .forms import UserManualAdminForm
|
|
27
30
|
from .module_defaults import reload_default_modules as restore_default_modules
|
|
31
|
+
from .site_config import ensure_site_fields
|
|
28
32
|
from .utils import landing_leads_supported
|
|
29
33
|
|
|
30
34
|
from .models import (
|
|
@@ -41,6 +45,7 @@ from .models import (
|
|
|
41
45
|
UserStory,
|
|
42
46
|
)
|
|
43
47
|
from django.contrib.contenttypes.models import ContentType
|
|
48
|
+
from core.models import ReleaseManager
|
|
44
49
|
from core.user_data import EntityModelAdmin
|
|
45
50
|
|
|
46
51
|
|
|
@@ -73,12 +78,47 @@ class SiteForm(forms.ModelForm):
|
|
|
73
78
|
fields = "__all__"
|
|
74
79
|
|
|
75
80
|
|
|
81
|
+
ensure_site_fields()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _BooleanAttributeListFilter(admin.SimpleListFilter):
|
|
85
|
+
"""Filter helper for boolean attributes on :class:`~django.contrib.sites.models.Site`."""
|
|
86
|
+
|
|
87
|
+
field_name: str
|
|
88
|
+
|
|
89
|
+
def lookups(self, request, model_admin): # pragma: no cover - admin UI
|
|
90
|
+
return (("1", _("Yes")), ("0", _("No")))
|
|
91
|
+
|
|
92
|
+
def queryset(self, request, queryset):
|
|
93
|
+
value = self.value()
|
|
94
|
+
if value not in {"0", "1"}:
|
|
95
|
+
return queryset
|
|
96
|
+
expected = value == "1"
|
|
97
|
+
try:
|
|
98
|
+
return queryset.filter(**{self.field_name: expected})
|
|
99
|
+
except FieldError: # pragma: no cover - defensive when fields missing
|
|
100
|
+
return queryset
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ManagedSiteListFilter(_BooleanAttributeListFilter):
|
|
104
|
+
title = _("Managed by local NGINX")
|
|
105
|
+
parameter_name = "managed"
|
|
106
|
+
field_name = "managed"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RequireHttpsListFilter(_BooleanAttributeListFilter):
|
|
110
|
+
title = _("Require HTTPS")
|
|
111
|
+
parameter_name = "require_https"
|
|
112
|
+
field_name = "require_https"
|
|
113
|
+
|
|
114
|
+
|
|
76
115
|
class SiteAdmin(DjangoSiteAdmin):
|
|
77
116
|
form = SiteForm
|
|
78
117
|
inlines = [SiteBadgeInline]
|
|
79
118
|
change_list_template = "admin/sites/site/change_list.html"
|
|
80
|
-
fields = ("domain", "name")
|
|
81
|
-
list_display = ("domain", "name")
|
|
119
|
+
fields = ("domain", "name", "managed", "require_https")
|
|
120
|
+
list_display = ("domain", "name", "managed", "require_https")
|
|
121
|
+
list_filter = (ManagedSiteListFilter, RequireHttpsListFilter)
|
|
82
122
|
actions = ["capture_screenshot"]
|
|
83
123
|
|
|
84
124
|
@admin.action(description="Capture screenshot")
|
|
@@ -110,6 +150,27 @@ class SiteAdmin(DjangoSiteAdmin):
|
|
|
110
150
|
messages.INFO,
|
|
111
151
|
)
|
|
112
152
|
|
|
153
|
+
def save_model(self, request, obj, form, change):
|
|
154
|
+
super().save_model(request, obj, form, change)
|
|
155
|
+
if {"managed", "require_https"} & set(form.changed_data or []):
|
|
156
|
+
self.message_user(
|
|
157
|
+
request,
|
|
158
|
+
_(
|
|
159
|
+
"Managed NGINX configuration staged. Run network-setup.sh to apply changes."
|
|
160
|
+
),
|
|
161
|
+
messages.INFO,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def delete_model(self, request, obj):
|
|
165
|
+
super().delete_model(request, obj)
|
|
166
|
+
self.message_user(
|
|
167
|
+
request,
|
|
168
|
+
_(
|
|
169
|
+
"Managed NGINX configuration staged. Run network-setup.sh to apply changes."
|
|
170
|
+
),
|
|
171
|
+
messages.INFO,
|
|
172
|
+
)
|
|
173
|
+
|
|
113
174
|
def _reload_site_fixtures(self, request):
|
|
114
175
|
fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
|
|
115
176
|
fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
|
|
@@ -230,14 +291,19 @@ class ApplicationAdmin(EntityModelAdmin):
|
|
|
230
291
|
class LandingInline(admin.TabularInline):
|
|
231
292
|
model = Landing
|
|
232
293
|
extra = 0
|
|
233
|
-
fields = ("path", "label", "enabled")
|
|
294
|
+
fields = ("path", "label", "enabled", "track_leads")
|
|
234
295
|
show_change_link = True
|
|
235
296
|
|
|
236
297
|
|
|
237
298
|
@admin.register(Landing)
|
|
238
299
|
class LandingAdmin(EntityModelAdmin):
|
|
239
|
-
list_display = ("label", "path", "module", "enabled")
|
|
240
|
-
list_filter = (
|
|
300
|
+
list_display = ("label", "path", "module", "enabled", "track_leads")
|
|
301
|
+
list_filter = (
|
|
302
|
+
"enabled",
|
|
303
|
+
"track_leads",
|
|
304
|
+
"module__node_role",
|
|
305
|
+
"module__application",
|
|
306
|
+
)
|
|
241
307
|
search_fields = (
|
|
242
308
|
"label",
|
|
243
309
|
"path",
|
|
@@ -246,7 +312,7 @@ class LandingAdmin(EntityModelAdmin):
|
|
|
246
312
|
"module__application__name",
|
|
247
313
|
"module__node_role__name",
|
|
248
314
|
)
|
|
249
|
-
fields = ("module", "path", "label", "enabled", "description")
|
|
315
|
+
fields = ("module", "path", "label", "enabled", "track_leads", "description")
|
|
250
316
|
list_select_related = ("module", "module__application", "module__node_role")
|
|
251
317
|
|
|
252
318
|
|
|
@@ -529,7 +595,23 @@ class ViewHistoryAdmin(EntityModelAdmin):
|
|
|
529
595
|
)
|
|
530
596
|
|
|
531
597
|
def traffic_data_view(self, request):
|
|
532
|
-
return JsonResponse(
|
|
598
|
+
return JsonResponse(
|
|
599
|
+
self._build_chart_data(days=self._resolve_requested_days(request))
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def _resolve_requested_days(self, request, default: int = 30) -> int:
|
|
603
|
+
raw_value = request.GET.get("days")
|
|
604
|
+
if raw_value in (None, ""):
|
|
605
|
+
return default
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
days = int(raw_value)
|
|
609
|
+
except (TypeError, ValueError):
|
|
610
|
+
return default
|
|
611
|
+
|
|
612
|
+
minimum = 1
|
|
613
|
+
maximum = 90
|
|
614
|
+
return max(minimum, min(days, maximum))
|
|
533
615
|
|
|
534
616
|
def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
|
|
535
617
|
end_date = timezone.localdate()
|
|
@@ -623,11 +705,13 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
623
705
|
actions = ["create_github_issues"]
|
|
624
706
|
list_display = (
|
|
625
707
|
"name",
|
|
708
|
+
"language_code",
|
|
626
709
|
"rating",
|
|
627
710
|
"path",
|
|
628
711
|
"status",
|
|
629
712
|
"submitted_at",
|
|
630
713
|
"github_issue_display",
|
|
714
|
+
"screenshot_display",
|
|
631
715
|
"take_screenshot",
|
|
632
716
|
"owner",
|
|
633
717
|
"assign_to",
|
|
@@ -637,6 +721,7 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
637
721
|
"name",
|
|
638
722
|
"comments",
|
|
639
723
|
"path",
|
|
724
|
+
"language_code",
|
|
640
725
|
"referer",
|
|
641
726
|
"github_issue_url",
|
|
642
727
|
"ip_address",
|
|
@@ -649,6 +734,7 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
649
734
|
"path",
|
|
650
735
|
"user",
|
|
651
736
|
"owner",
|
|
737
|
+
"language_code",
|
|
652
738
|
"referer",
|
|
653
739
|
"user_agent",
|
|
654
740
|
"ip_address",
|
|
@@ -656,6 +742,7 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
656
742
|
"submitted_at",
|
|
657
743
|
"github_issue_number",
|
|
658
744
|
"github_issue_url",
|
|
745
|
+
"screenshot_display",
|
|
659
746
|
)
|
|
660
747
|
ordering = ("-submitted_at",)
|
|
661
748
|
fields = (
|
|
@@ -663,7 +750,9 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
663
750
|
"rating",
|
|
664
751
|
"comments",
|
|
665
752
|
"take_screenshot",
|
|
753
|
+
"screenshot_display",
|
|
666
754
|
"path",
|
|
755
|
+
"language_code",
|
|
667
756
|
"user",
|
|
668
757
|
"owner",
|
|
669
758
|
"status",
|
|
@@ -692,6 +781,21 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
692
781
|
)
|
|
693
782
|
if obj.github_issue_number is not None:
|
|
694
783
|
return f"#{obj.github_issue_number}"
|
|
784
|
+
return ""
|
|
785
|
+
|
|
786
|
+
@admin.display(description=_("Screenshot"), ordering="screenshot")
|
|
787
|
+
def screenshot_display(self, obj):
|
|
788
|
+
if not obj.screenshot_id:
|
|
789
|
+
return ""
|
|
790
|
+
try:
|
|
791
|
+
url = reverse("admin:nodes_contentsample_change", args=[obj.screenshot_id])
|
|
792
|
+
except NoReverseMatch:
|
|
793
|
+
return obj.screenshot.path
|
|
794
|
+
return format_html(
|
|
795
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
796
|
+
url,
|
|
797
|
+
_("View screenshot"),
|
|
798
|
+
)
|
|
695
799
|
return _("Not created")
|
|
696
800
|
|
|
697
801
|
@admin.action(description=_("Create GitHub issues"))
|
|
@@ -708,10 +812,33 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
708
812
|
issue_url = story.create_github_issue()
|
|
709
813
|
except Exception as exc: # pragma: no cover - network/runtime errors
|
|
710
814
|
logger.exception("Failed to create GitHub issue for UserStory %s", story.pk)
|
|
815
|
+
message = _("Unable to create a GitHub issue for %(story)s: %(error)s") % {
|
|
816
|
+
"story": story,
|
|
817
|
+
"error": exc,
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (
|
|
821
|
+
isinstance(exc, RuntimeError)
|
|
822
|
+
and "GitHub token is not configured" in str(exc)
|
|
823
|
+
):
|
|
824
|
+
try:
|
|
825
|
+
opts = ReleaseManager._meta
|
|
826
|
+
config_url = reverse(
|
|
827
|
+
f"{self.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
|
|
828
|
+
)
|
|
829
|
+
except NoReverseMatch: # pragma: no cover - defensive guard
|
|
830
|
+
config_url = None
|
|
831
|
+
if config_url:
|
|
832
|
+
message = format_html(
|
|
833
|
+
"{} <a href=\"{}\">{}</a>",
|
|
834
|
+
message,
|
|
835
|
+
config_url,
|
|
836
|
+
_("Configure GitHub credentials."),
|
|
837
|
+
)
|
|
838
|
+
|
|
711
839
|
self.message_user(
|
|
712
840
|
request,
|
|
713
|
-
|
|
714
|
-
% {"story": story, "error": exc},
|
|
841
|
+
message,
|
|
715
842
|
messages.ERROR,
|
|
716
843
|
)
|
|
717
844
|
continue
|
|
@@ -753,34 +880,93 @@ def favorite_toggle(request, ct_id):
|
|
|
753
880
|
ct = get_object_or_404(ContentType, pk=ct_id)
|
|
754
881
|
fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
|
|
755
882
|
next_url = request.GET.get("next")
|
|
756
|
-
if fav:
|
|
757
|
-
return redirect(next_url or "admin:favorite_list")
|
|
758
883
|
if request.method == "POST":
|
|
884
|
+
if fav and request.POST.get("remove"):
|
|
885
|
+
fav.delete()
|
|
886
|
+
return redirect(next_url or "admin:index")
|
|
759
887
|
label = request.POST.get("custom_label", "").strip()
|
|
760
888
|
user_data = request.POST.get("user_data") == "on"
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
889
|
+
priority_raw = request.POST.get("priority", "").strip()
|
|
890
|
+
if fav:
|
|
891
|
+
default_priority = fav.priority
|
|
892
|
+
else:
|
|
893
|
+
default_priority = 0
|
|
894
|
+
if priority_raw:
|
|
895
|
+
try:
|
|
896
|
+
priority = int(priority_raw)
|
|
897
|
+
except (TypeError, ValueError):
|
|
898
|
+
priority = default_priority
|
|
899
|
+
else:
|
|
900
|
+
priority = default_priority
|
|
901
|
+
|
|
902
|
+
if fav:
|
|
903
|
+
update_fields = []
|
|
904
|
+
if fav.custom_label != label:
|
|
905
|
+
fav.custom_label = label
|
|
906
|
+
update_fields.append("custom_label")
|
|
907
|
+
if fav.user_data != user_data:
|
|
908
|
+
fav.user_data = user_data
|
|
909
|
+
update_fields.append("user_data")
|
|
910
|
+
if fav.priority != priority:
|
|
911
|
+
fav.priority = priority
|
|
912
|
+
update_fields.append("priority")
|
|
913
|
+
if update_fields:
|
|
914
|
+
fav.save(update_fields=update_fields)
|
|
915
|
+
else:
|
|
916
|
+
Favorite.objects.create(
|
|
917
|
+
user=request.user,
|
|
918
|
+
content_type=ct,
|
|
919
|
+
custom_label=label,
|
|
920
|
+
user_data=user_data,
|
|
921
|
+
priority=priority,
|
|
922
|
+
)
|
|
767
923
|
return redirect(next_url or "admin:index")
|
|
768
924
|
return render(
|
|
769
925
|
request,
|
|
770
926
|
"admin/favorite_confirm.html",
|
|
771
|
-
{
|
|
927
|
+
{
|
|
928
|
+
"content_type": ct,
|
|
929
|
+
"favorite": fav,
|
|
930
|
+
"next": next_url,
|
|
931
|
+
"initial_label": fav.custom_label if fav else "",
|
|
932
|
+
"initial_priority": fav.priority if fav else 0,
|
|
933
|
+
"is_checked": fav.user_data if fav else True,
|
|
934
|
+
},
|
|
772
935
|
)
|
|
773
936
|
|
|
774
937
|
|
|
775
938
|
def favorite_list(request):
|
|
776
|
-
favorites =
|
|
777
|
-
|
|
939
|
+
favorites = (
|
|
940
|
+
Favorite.objects.filter(user=request.user)
|
|
941
|
+
.select_related("content_type")
|
|
942
|
+
.order_by("priority", "pk")
|
|
778
943
|
)
|
|
779
944
|
if request.method == "POST":
|
|
780
|
-
selected = request.POST.getlist("user_data")
|
|
945
|
+
selected = set(request.POST.getlist("user_data"))
|
|
781
946
|
for fav in favorites:
|
|
782
|
-
|
|
783
|
-
fav.
|
|
947
|
+
update_fields = []
|
|
948
|
+
user_selected = str(fav.pk) in selected
|
|
949
|
+
if fav.user_data != user_selected:
|
|
950
|
+
fav.user_data = user_selected
|
|
951
|
+
update_fields.append("user_data")
|
|
952
|
+
|
|
953
|
+
priority_raw = request.POST.get(f"priority_{fav.pk}", "").strip()
|
|
954
|
+
if priority_raw:
|
|
955
|
+
try:
|
|
956
|
+
priority = int(priority_raw)
|
|
957
|
+
except (TypeError, ValueError):
|
|
958
|
+
priority = fav.priority
|
|
959
|
+
else:
|
|
960
|
+
if fav.priority != priority:
|
|
961
|
+
fav.priority = priority
|
|
962
|
+
update_fields.append("priority")
|
|
963
|
+
else:
|
|
964
|
+
if fav.priority != 0:
|
|
965
|
+
fav.priority = 0
|
|
966
|
+
update_fields.append("priority")
|
|
967
|
+
|
|
968
|
+
if update_fields:
|
|
969
|
+
fav.save(update_fields=update_fields)
|
|
784
970
|
return redirect("admin:favorite_list")
|
|
785
971
|
return render(request, "admin/favorite_list.html", {"favorites": favorites})
|
|
786
972
|
|
|
@@ -796,6 +982,13 @@ def favorite_clear(request):
|
|
|
796
982
|
return redirect("admin:favorite_list")
|
|
797
983
|
|
|
798
984
|
|
|
985
|
+
def _read_log_tail(path: Path, limit: int) -> str:
|
|
986
|
+
"""Return the last ``limit`` lines from ``path`` preserving newlines."""
|
|
987
|
+
|
|
988
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
989
|
+
return "".join(deque(handle, maxlen=limit))
|
|
990
|
+
|
|
991
|
+
|
|
799
992
|
def log_viewer(request):
|
|
800
993
|
logs_dir = Path(settings.BASE_DIR) / "logs"
|
|
801
994
|
logs_exist = logs_dir.exists() and logs_dir.is_dir()
|
|
@@ -813,16 +1006,50 @@ def log_viewer(request):
|
|
|
813
1006
|
selected_log = request.GET.get("log", "")
|
|
814
1007
|
log_content = ""
|
|
815
1008
|
log_error = ""
|
|
1009
|
+
limit_options = [
|
|
1010
|
+
{"value": "20", "label": "20"},
|
|
1011
|
+
{"value": "40", "label": "40"},
|
|
1012
|
+
{"value": "100", "label": "100"},
|
|
1013
|
+
{"value": "all", "label": _("All")},
|
|
1014
|
+
]
|
|
1015
|
+
allowed_limits = [item["value"] for item in limit_options]
|
|
1016
|
+
limit_choice = request.GET.get("limit", "20")
|
|
1017
|
+
if limit_choice not in allowed_limits:
|
|
1018
|
+
limit_choice = "20"
|
|
1019
|
+
limit_index = allowed_limits.index(limit_choice)
|
|
1020
|
+
download_requested = request.GET.get("download") == "1"
|
|
816
1021
|
|
|
817
1022
|
if selected_log:
|
|
818
1023
|
if selected_log in available_logs:
|
|
819
1024
|
selected_path = logs_dir / selected_log
|
|
820
1025
|
try:
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1026
|
+
if download_requested:
|
|
1027
|
+
return FileResponse(
|
|
1028
|
+
selected_path.open("rb"),
|
|
1029
|
+
as_attachment=True,
|
|
1030
|
+
filename=selected_log,
|
|
1031
|
+
)
|
|
1032
|
+
if limit_choice == "all":
|
|
1033
|
+
try:
|
|
1034
|
+
log_content = selected_path.read_text(encoding="utf-8")
|
|
1035
|
+
except UnicodeDecodeError:
|
|
1036
|
+
log_content = selected_path.read_text(
|
|
1037
|
+
encoding="utf-8", errors="replace"
|
|
1038
|
+
)
|
|
1039
|
+
else:
|
|
1040
|
+
try:
|
|
1041
|
+
limit_value = int(limit_choice)
|
|
1042
|
+
except (TypeError, ValueError):
|
|
1043
|
+
limit_value = 20
|
|
1044
|
+
limit_choice = "20"
|
|
1045
|
+
limit_index = allowed_limits.index(limit_choice)
|
|
1046
|
+
try:
|
|
1047
|
+
log_content = _read_log_tail(selected_path, limit_value)
|
|
1048
|
+
except UnicodeDecodeError:
|
|
1049
|
+
with selected_path.open(
|
|
1050
|
+
"r", encoding="utf-8", errors="replace"
|
|
1051
|
+
) as handle:
|
|
1052
|
+
log_content = "".join(deque(handle, maxlen=limit_value))
|
|
826
1053
|
except OSError as exc: # pragma: no cover - filesystem edge cases
|
|
827
1054
|
logger.warning("Unable to read log file %s", selected_path, exc_info=exc)
|
|
828
1055
|
log_error = _(
|
|
@@ -840,6 +1067,7 @@ def log_viewer(request):
|
|
|
840
1067
|
else:
|
|
841
1068
|
log_notice = ""
|
|
842
1069
|
|
|
1070
|
+
limit_label = limit_options[limit_index]["label"]
|
|
843
1071
|
context = {**admin.site.each_context(request)}
|
|
844
1072
|
context.update(
|
|
845
1073
|
{
|
|
@@ -850,6 +1078,10 @@ def log_viewer(request):
|
|
|
850
1078
|
"log_error": log_error,
|
|
851
1079
|
"log_notice": log_notice,
|
|
852
1080
|
"logs_directory": logs_dir,
|
|
1081
|
+
"log_limit_options": limit_options,
|
|
1082
|
+
"log_limit_index": limit_index,
|
|
1083
|
+
"log_limit_choice": limit_choice,
|
|
1084
|
+
"log_limit_label": limit_label,
|
|
853
1085
|
}
|
|
854
1086
|
)
|
|
855
1087
|
return TemplateResponse(request, "admin/log_viewer.html", context)
|
pages/apps.py
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
from django.apps import AppConfig
|
|
4
|
+
from django.db import DatabaseError
|
|
5
|
+
from django.db.backends.signals import connection_created
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
2
9
|
|
|
3
10
|
|
|
4
11
|
class PagesConfig(AppConfig):
|
|
5
12
|
default_auto_field = "django.db.models.BigAutoField"
|
|
6
13
|
name = "pages"
|
|
7
14
|
verbose_name = "7. Experience"
|
|
15
|
+
_view_history_purged = False
|
|
8
16
|
|
|
9
17
|
def ready(self): # pragma: no cover - import for side effects
|
|
10
18
|
from . import checks # noqa: F401
|
|
19
|
+
from . import site_config
|
|
20
|
+
|
|
21
|
+
site_config.ready()
|
|
22
|
+
connection_created.connect(
|
|
23
|
+
self._handle_connection_created,
|
|
24
|
+
dispatch_uid="pages_view_history_connection_created",
|
|
25
|
+
weak=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def _handle_connection_created(self, sender, connection, **kwargs):
|
|
29
|
+
if self._view_history_purged:
|
|
30
|
+
return
|
|
31
|
+
self._view_history_purged = True
|
|
32
|
+
self._purge_view_history()
|
|
33
|
+
|
|
34
|
+
def _purge_view_history(self, days: int = 15) -> None:
|
|
35
|
+
"""Remove stale :class:`pages.models.ViewHistory` entries."""
|
|
36
|
+
|
|
37
|
+
from .models import ViewHistory
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
deleted = ViewHistory.purge_older_than(days=days)
|
|
41
|
+
except DatabaseError:
|
|
42
|
+
logger.debug("Skipping view history purge; database unavailable", exc_info=True)
|
|
43
|
+
else:
|
|
44
|
+
if deleted:
|
|
45
|
+
logger.info("Purged %s view history entries older than %s days", deleted, days)
|
pages/context_processors.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from utils.sites import get_site
|
|
2
2
|
from django.urls import Resolver404, resolve
|
|
3
|
+
from django.shortcuts import resolve_url
|
|
3
4
|
from django.conf import settings
|
|
4
5
|
from pathlib import Path
|
|
5
|
-
from types import SimpleNamespace
|
|
6
6
|
from nodes.models import Node
|
|
7
7
|
from core.models import Reference
|
|
8
8
|
from core.reference_utils import filter_visible_references
|
|
@@ -11,7 +11,8 @@ from .models import Module
|
|
|
11
11
|
_FAVICON_DIR = Path(settings.BASE_DIR) / "pages" / "fixtures" / "data"
|
|
12
12
|
_FAVICON_FILENAMES = {
|
|
13
13
|
"default": "favicon.txt",
|
|
14
|
-
"
|
|
14
|
+
"Watchtower": "favicon_watchtower.txt",
|
|
15
|
+
"Constellation": "favicon_watchtower.txt",
|
|
15
16
|
"Control": "favicon_control.txt",
|
|
16
17
|
"Satellite": "favicon_satellite.txt",
|
|
17
18
|
}
|
|
@@ -48,7 +49,6 @@ def nav_links(request):
|
|
|
48
49
|
modules = []
|
|
49
50
|
|
|
50
51
|
valid_modules = []
|
|
51
|
-
datasette_enabled = False
|
|
52
52
|
current_module = None
|
|
53
53
|
user = getattr(request, "user", None)
|
|
54
54
|
user_is_authenticated = getattr(user, "is_authenticated", False)
|
|
@@ -72,24 +72,40 @@ def nav_links(request):
|
|
|
72
72
|
required_groups = getattr(
|
|
73
73
|
view_func, "required_security_groups", frozenset()
|
|
74
74
|
)
|
|
75
|
+
blocked_reason = None
|
|
75
76
|
if required_groups:
|
|
76
77
|
requires_login = True
|
|
77
|
-
setattr(landing, "requires_login", True)
|
|
78
78
|
if not user_is_authenticated:
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
blocked_reason = "login"
|
|
80
|
+
elif not user_is_superuser and not (
|
|
81
81
|
user_group_names & set(required_groups)
|
|
82
82
|
):
|
|
83
|
-
|
|
83
|
+
blocked_reason = "permission"
|
|
84
84
|
elif requires_login and not user_is_authenticated:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
blocked_reason = "login"
|
|
86
|
+
|
|
87
|
+
if staff_only and not getattr(request.user, "is_staff", False):
|
|
88
|
+
if blocked_reason != "login":
|
|
89
|
+
blocked_reason = "permission"
|
|
90
|
+
|
|
91
|
+
landing.nav_is_locked = bool(blocked_reason)
|
|
92
|
+
landing.nav_lock_reason = blocked_reason
|
|
88
93
|
landings.append(landing)
|
|
89
94
|
if landings:
|
|
95
|
+
normalized_module_path = module.path.rstrip("/") or "/"
|
|
96
|
+
if normalized_module_path == "/read":
|
|
97
|
+
primary_landings = [
|
|
98
|
+
landing
|
|
99
|
+
for landing in landings
|
|
100
|
+
if landing.path.rstrip("/") == normalized_module_path
|
|
101
|
+
]
|
|
102
|
+
if primary_landings:
|
|
103
|
+
landings = primary_landings
|
|
104
|
+
else:
|
|
105
|
+
landings = [landings[0]]
|
|
90
106
|
app_name = getattr(module.application, "name", "").lower()
|
|
91
107
|
if app_name == "awg":
|
|
92
|
-
module.menu = "
|
|
108
|
+
module.menu = "Calculators"
|
|
93
109
|
elif module.path.rstrip("/").lower() == "/man":
|
|
94
110
|
module.menu = "Manual"
|
|
95
111
|
module.enabled_landings = landings
|
|
@@ -100,15 +116,6 @@ def nav_links(request):
|
|
|
100
116
|
):
|
|
101
117
|
current_module = module
|
|
102
118
|
|
|
103
|
-
datasette_lock = Path(settings.BASE_DIR) / "locks" / "datasette.lck"
|
|
104
|
-
if datasette_lock.exists():
|
|
105
|
-
datasette_enabled = True
|
|
106
|
-
datasette_module = SimpleNamespace(
|
|
107
|
-
menu_label="Data",
|
|
108
|
-
path="/data/",
|
|
109
|
-
enabled_landings=[SimpleNamespace(path="/data/", label="Datasette")],
|
|
110
|
-
)
|
|
111
|
-
valid_modules.append(datasette_module)
|
|
112
119
|
|
|
113
120
|
valid_modules.sort(key=lambda m: m.menu_label.lower())
|
|
114
121
|
|
|
@@ -142,5 +149,5 @@ def nav_links(request):
|
|
|
142
149
|
"nav_modules": valid_modules,
|
|
143
150
|
"favicon_url": favicon_url,
|
|
144
151
|
"header_references": header_references,
|
|
145
|
-
"
|
|
152
|
+
"login_url": resolve_url(settings.LOGIN_URL),
|
|
146
153
|
}
|
pages/defaults.py
CHANGED
|
@@ -7,7 +7,7 @@ DEFAULT_APPLICATION_DESCRIPTIONS: Dict[str, str] = {
|
|
|
7
7
|
"awg": "Power, Energy and Cost calculations.",
|
|
8
8
|
"core": "Support for Business Processes and monetization.",
|
|
9
9
|
"ocpp": "Compatibility with Standards and Good Practices.",
|
|
10
|
-
"nodes": "System and Node-level operations
|
|
10
|
+
"nodes": "System and Node-level operations.",
|
|
11
11
|
"pages": "User QA, Continuity Design and Chaos Testing.",
|
|
12
12
|
"teams": "Identity, Entitlements and Access Controls.",
|
|
13
13
|
}
|
pages/forms.py
CHANGED
|
@@ -171,15 +171,35 @@ class UserStoryForm(forms.ModelForm):
|
|
|
171
171
|
"comments": forms.Textarea(attrs={"rows": 4, "maxlength": 400}),
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
def __init__(self, *args, **kwargs):
|
|
174
|
+
def __init__(self, *args, user=None, **kwargs):
|
|
175
|
+
self.user = user
|
|
175
176
|
super().__init__(*args, **kwargs)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
177
|
+
|
|
178
|
+
if user is not None and user.is_authenticated:
|
|
179
|
+
name_field = self.fields["name"]
|
|
180
|
+
name_field.required = False
|
|
181
|
+
name_field.label = _("Username")
|
|
182
|
+
name_field.initial = (user.get_username() or "")[:40]
|
|
183
|
+
name_field.widget.attrs.update(
|
|
184
|
+
{
|
|
185
|
+
"maxlength": 40,
|
|
186
|
+
"readonly": "readonly",
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
self.fields["name"] = forms.EmailField(
|
|
191
|
+
label=_("Email address"),
|
|
192
|
+
max_length=40,
|
|
193
|
+
required=True,
|
|
194
|
+
widget=forms.EmailInput(
|
|
195
|
+
attrs={
|
|
196
|
+
"maxlength": 40,
|
|
197
|
+
"placeholder": _("name@example.com"),
|
|
198
|
+
"autocomplete": "email",
|
|
199
|
+
"inputmode": "email",
|
|
200
|
+
}
|
|
201
|
+
),
|
|
202
|
+
)
|
|
183
203
|
self.fields["take_screenshot"].initial = True
|
|
184
204
|
self.fields["rating"].widget = forms.RadioSelect(
|
|
185
205
|
choices=[(i, str(i)) for i in range(1, 6)]
|
|
@@ -194,5 +214,8 @@ class UserStoryForm(forms.ModelForm):
|
|
|
194
214
|
return comments
|
|
195
215
|
|
|
196
216
|
def clean_name(self):
|
|
217
|
+
if self.user is not None and self.user.is_authenticated:
|
|
218
|
+
return (self.user.get_username() or "")[:40]
|
|
219
|
+
|
|
197
220
|
name = (self.cleaned_data.get("name") or "").strip()
|
|
198
221
|
return name[:40]
|