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.

Files changed (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {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 = ("enabled", "module__node_role", "module__application")
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(self._build_chart_data())
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
- _("Unable to create a GitHub issue for %(story)s: %(error)s")
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
- Favorite.objects.create(
762
- user=request.user,
763
- content_type=ct,
764
- custom_label=label,
765
- user_data=user_data,
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
- {"content_type": ct, "next": next_url},
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 = Favorite.objects.filter(user=request.user).select_related(
777
- "content_type"
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
- fav.user_data = str(fav.pk) in selected
783
- fav.save(update_fields=["user_data"])
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
- log_content = selected_path.read_text(encoding="utf-8")
822
- except UnicodeDecodeError:
823
- log_content = selected_path.read_text(
824
- encoding="utf-8", errors="replace"
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)
@@ -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
- "Constellation": "favicon_constellation.txt",
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
- continue
80
- if not user_is_superuser and not (
79
+ blocked_reason = "login"
80
+ elif not user_is_superuser and not (
81
81
  user_group_names & set(required_groups)
82
82
  ):
83
- continue
83
+ blocked_reason = "permission"
84
84
  elif requires_login and not user_is_authenticated:
85
- setattr(landing, "requires_login", True)
86
- if staff_only and not request.user.is_staff:
87
- continue
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 = "Calculate"
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
- "datasette_enabled": datasette_enabled,
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
- self.fields["name"].required = False
177
- self.fields["name"].widget.attrs.update(
178
- {
179
- "maxlength": 40,
180
- "placeholder": _("Name, email or pseudonym"),
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]