arthexis 0.1.9__py3-none-any.whl → 0.1.26__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 (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
pages/admin.py CHANGED
@@ -1,396 +1,1123 @@
1
- from django.contrib import admin, messages
2
- from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
3
- from django.contrib.sites.models import Site
4
- from django import forms
5
- from django.db import models
6
- from core.widgets import CopyColorWidget
7
- from django.shortcuts import redirect, render, get_object_or_404
8
- from django.urls import path, reverse
9
- from django.utils.html import format_html
10
- from django.template.response import TemplateResponse
11
- from django.http import JsonResponse
12
- from django.utils import timezone
13
- from django.db.models import Count
14
- from django.db.models.functions import TruncDate
15
- from datetime import timedelta
16
- import ipaddress
17
- from django.apps import apps as django_apps
18
- from django.conf import settings
19
-
20
- from nodes.models import Node
21
- from nodes.utils import capture_screenshot, save_screenshot
22
-
23
- from .models import (
24
- SiteBadge,
25
- Application,
26
- SiteProxy,
27
- Module,
28
- Landing,
29
- Favorite,
30
- ViewHistory,
31
- )
32
- from django.contrib.contenttypes.models import ContentType
33
- from core.user_data import EntityModelAdmin
34
-
35
-
36
- def get_local_app_choices():
37
- choices = []
38
- for app_label in getattr(settings, "LOCAL_APPS", []):
39
- try:
40
- config = django_apps.get_app_config(app_label)
41
- except LookupError:
42
- continue
43
- choices.append((config.label, config.verbose_name))
44
- return choices
45
-
46
-
47
- class SiteBadgeInline(admin.StackedInline):
48
- model = SiteBadge
49
- can_delete = False
50
- extra = 0
51
- formfield_overrides = {models.CharField: {"widget": CopyColorWidget}}
52
- fields = ("badge_color", "favicon", "landing_override")
53
-
54
-
55
- class SiteForm(forms.ModelForm):
56
- name = forms.CharField(required=False)
57
-
58
- class Meta:
59
- model = Site
60
- fields = "__all__"
61
-
62
-
63
- class SiteAdmin(DjangoSiteAdmin):
64
- form = SiteForm
65
- inlines = [SiteBadgeInline]
66
- change_list_template = "admin/sites/site/change_list.html"
67
- fields = ("domain", "name")
68
- list_display = ("domain", "name")
69
- actions = ["capture_screenshot"]
70
-
71
- @admin.action(description="Capture screenshot")
72
- def capture_screenshot(self, request, queryset):
73
- node = Node.get_local()
74
- for site in queryset:
75
- url = f"http://{site.domain}/"
76
- try:
77
- path = capture_screenshot(url)
78
- screenshot = save_screenshot(path, node=node, method="ADMIN")
79
- except Exception as exc: # pragma: no cover - browser issues
80
- self.message_user(request, f"{site.domain}: {exc}", messages.ERROR)
81
- continue
82
- if screenshot:
83
- link = reverse("admin:nodes_contentsample_change", args=[screenshot.pk])
84
- self.message_user(
85
- request,
86
- format_html(
87
- 'Screenshot for {} saved. <a href="{}">View</a>',
88
- site.domain,
89
- link,
90
- ),
91
- messages.SUCCESS,
92
- )
93
- else:
94
- self.message_user(
95
- request,
96
- f"{site.domain}: duplicate screenshot; not saved",
97
- messages.INFO,
98
- )
99
-
100
- def get_urls(self):
101
- urls = super().get_urls()
102
- custom = [
103
- path(
104
- "register-current/",
105
- self.admin_site.admin_view(self.register_current),
106
- name="pages_siteproxy_register_current",
107
- )
108
- ]
109
- return custom + urls
110
-
111
- def register_current(self, request):
112
- domain = request.get_host().split(":")[0]
113
- try:
114
- ipaddress.ip_address(domain)
115
- except ValueError:
116
- name = domain
117
- else:
118
- name = ""
119
- site, created = Site.objects.get_or_create(
120
- domain=domain, defaults={"name": name}
121
- )
122
- if created:
123
- self.message_user(request, "Current domain registered", messages.SUCCESS)
124
- else:
125
- self.message_user(
126
- request, "Current domain already registered", messages.INFO
127
- )
128
- return redirect("..")
129
-
130
-
131
- admin.site.unregister(Site)
132
- admin.site.register(SiteProxy, SiteAdmin)
133
-
134
-
135
- class ApplicationForm(forms.ModelForm):
136
- name = forms.ChoiceField(choices=[])
137
-
138
- class Meta:
139
- model = Application
140
- fields = "__all__"
141
-
142
- def __init__(self, *args, **kwargs):
143
- super().__init__(*args, **kwargs)
144
- self.fields["name"].choices = get_local_app_choices()
145
-
146
-
147
- class ApplicationModuleInline(admin.TabularInline):
148
- model = Module
149
- fk_name = "application"
150
- extra = 0
151
-
152
-
153
- @admin.register(Application)
154
- class ApplicationAdmin(EntityModelAdmin):
155
- form = ApplicationForm
156
- list_display = ("name", "app_verbose_name", "installed")
157
- readonly_fields = ("installed",)
158
- inlines = [ApplicationModuleInline]
159
-
160
- @admin.display(description="Verbose name")
161
- def app_verbose_name(self, obj):
162
- return obj.verbose_name
163
-
164
- @admin.display(boolean=True)
165
- def installed(self, obj):
166
- return obj.installed
167
-
168
-
169
- class LandingInline(admin.TabularInline):
170
- model = Landing
171
- extra = 0
172
- fields = ("path", "label", "enabled", "description")
173
-
174
-
175
- @admin.register(Module)
176
- class ModuleAdmin(EntityModelAdmin):
177
- list_display = ("application", "node_role", "path", "menu", "is_default")
178
- list_filter = ("node_role", "application")
179
- fields = ("node_role", "application", "path", "menu", "is_default", "favicon")
180
- inlines = [LandingInline]
181
-
182
-
183
- @admin.register(ViewHistory)
184
- class ViewHistoryAdmin(EntityModelAdmin):
185
- date_hierarchy = "visited_at"
186
- list_display = (
187
- "path",
188
- "status_code",
189
- "status_text",
190
- "method",
191
- "visited_at",
192
- )
193
- list_filter = ("method", "status_code")
194
- search_fields = ("path", "error_message", "view_name", "status_text")
195
- readonly_fields = (
196
- "path",
197
- "method",
198
- "status_code",
199
- "status_text",
200
- "error_message",
201
- "view_name",
202
- "visited_at",
203
- )
204
- ordering = ("-visited_at",)
205
- change_list_template = "admin/pages/viewhistory/change_list.html"
206
- actions = ["view_traffic_graph"]
207
-
208
- def has_add_permission(self, request):
209
- return False
210
-
211
- @admin.action(description="View traffic graph")
212
- def view_traffic_graph(self, request, queryset):
213
- return redirect("admin:pages_viewhistory_traffic_graph")
214
-
215
- def get_urls(self):
216
- urls = super().get_urls()
217
- custom = [
218
- path(
219
- "traffic-graph/",
220
- self.admin_site.admin_view(self.traffic_graph_view),
221
- name="pages_viewhistory_traffic_graph",
222
- ),
223
- path(
224
- "traffic-data/",
225
- self.admin_site.admin_view(self.traffic_data_view),
226
- name="pages_viewhistory_traffic_data",
227
- ),
228
- ]
229
- return custom + urls
230
-
231
- def traffic_graph_view(self, request):
232
- context = {
233
- **self.admin_site.each_context(request),
234
- "opts": self.model._meta,
235
- "title": "Public site traffic",
236
- "chart_endpoint": reverse("admin:pages_viewhistory_traffic_data"),
237
- }
238
- return TemplateResponse(
239
- request,
240
- "admin/pages/viewhistory/traffic_graph.html",
241
- context,
242
- )
243
-
244
- def traffic_data_view(self, request):
245
- return JsonResponse(self._build_chart_data())
246
-
247
- def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
248
- end_date = timezone.localdate()
249
- start_date = end_date - timedelta(days=days - 1)
250
- queryset = ViewHistory.objects.filter(
251
- visited_at__date__range=(start_date, end_date)
252
- )
253
-
254
- meta = {
255
- "start": start_date.isoformat(),
256
- "end": end_date.isoformat(),
257
- }
258
-
259
- if not queryset.exists():
260
- meta["pages"] = []
261
- return {"labels": [], "datasets": [], "meta": meta}
262
-
263
- top_paths = list(
264
- queryset.values("path")
265
- .annotate(total=Count("id"))
266
- .order_by("-total")[:max_pages]
267
- )
268
- paths = [entry["path"] for entry in top_paths]
269
- meta["pages"] = paths
270
-
271
- labels = [
272
- (start_date + timedelta(days=offset)).isoformat() for offset in range(days)
273
- ]
274
-
275
- aggregates = (
276
- queryset.filter(path__in=paths)
277
- .annotate(day=TruncDate("visited_at"))
278
- .values("day", "path")
279
- .order_by("day")
280
- .annotate(total=Count("id"))
281
- )
282
-
283
- counts: dict[str, dict[str, int]] = {
284
- path: {label: 0 for label in labels} for path in paths
285
- }
286
- for row in aggregates:
287
- day = row["day"].isoformat()
288
- path = row["path"]
289
- if day in counts.get(path, {}):
290
- counts[path][day] = row["total"]
291
-
292
- palette = [
293
- "#1f77b4",
294
- "#ff7f0e",
295
- "#2ca02c",
296
- "#d62728",
297
- "#9467bd",
298
- "#8c564b",
299
- "#e377c2",
300
- "#7f7f7f",
301
- "#bcbd22",
302
- "#17becf",
303
- ]
304
- datasets = []
305
- for index, path in enumerate(paths):
306
- color = palette[index % len(palette)]
307
- datasets.append(
308
- {
309
- "label": path,
310
- "data": [counts[path][label] for label in labels],
311
- "borderColor": color,
312
- "backgroundColor": color,
313
- "fill": False,
314
- "tension": 0.3,
315
- }
316
- )
317
-
318
- return {"labels": labels, "datasets": datasets, "meta": meta}
319
-
320
-
321
- def favorite_toggle(request, ct_id):
322
- ct = get_object_or_404(ContentType, pk=ct_id)
323
- fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
324
- next_url = request.GET.get("next")
325
- if fav:
326
- return redirect(next_url or "admin:favorite_list")
327
- if request.method == "POST":
328
- label = request.POST.get("custom_label", "").strip()
329
- user_data = request.POST.get("user_data") == "on"
330
- Favorite.objects.create(
331
- user=request.user,
332
- content_type=ct,
333
- custom_label=label,
334
- user_data=user_data,
335
- )
336
- return redirect(next_url or "admin:index")
337
- return render(
338
- request,
339
- "admin/favorite_confirm.html",
340
- {"content_type": ct, "next": next_url},
341
- )
342
-
343
-
344
- def favorite_list(request):
345
- favorites = Favorite.objects.filter(user=request.user).select_related(
346
- "content_type"
347
- )
348
- if request.method == "POST":
349
- selected = request.POST.getlist("user_data")
350
- for fav in favorites:
351
- fav.user_data = str(fav.pk) in selected
352
- fav.save(update_fields=["user_data"])
353
- return redirect("admin:favorite_list")
354
- return render(request, "admin/favorite_list.html", {"favorites": favorites})
355
-
356
-
357
- def favorite_delete(request, pk):
358
- fav = get_object_or_404(Favorite, pk=pk, user=request.user)
359
- fav.delete()
360
- return redirect("admin:favorite_list")
361
-
362
-
363
- def favorite_clear(request):
364
- Favorite.objects.filter(user=request.user).delete()
365
- return redirect("admin:favorite_list")
366
-
367
-
368
- def get_admin_urls(original_get_urls):
369
- def get_urls():
370
- urls = original_get_urls()
371
- my_urls = [
372
- path(
373
- "favorites/<int:ct_id>/",
374
- admin.site.admin_view(favorite_toggle),
375
- name="favorite_toggle",
376
- ),
377
- path(
378
- "favorites/", admin.site.admin_view(favorite_list), name="favorite_list"
379
- ),
380
- path(
381
- "favorites/delete/<int:pk>/",
382
- admin.site.admin_view(favorite_delete),
383
- name="favorite_delete",
384
- ),
385
- path(
386
- "favorites/clear/",
387
- admin.site.admin_view(favorite_clear),
388
- name="favorite_clear",
389
- ),
390
- ]
391
- return my_urls + original_get_urls()
392
-
393
- return get_urls
394
-
395
-
396
- admin.site.get_urls = get_admin_urls(admin.site.get_urls)
1
+ import logging
2
+ from collections import deque
3
+ from pathlib import Path
4
+
5
+ from django.contrib import admin, messages
6
+ from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
7
+ from django.contrib.sites.models import Site
8
+ from django import forms
9
+ from django.shortcuts import redirect, render, get_object_or_404
10
+ from django.urls import NoReverseMatch, path, reverse
11
+ from django.utils.html import format_html
12
+
13
+ from django.template.response import TemplateResponse
14
+ from django.http import FileResponse, JsonResponse
15
+ from django.utils import timezone
16
+ from django.db.models import Count
17
+ from django.core.exceptions import FieldError
18
+ from django.db.models.functions import TruncDate
19
+ from datetime import datetime, time, timedelta
20
+ import ipaddress
21
+ from django.apps import apps as django_apps
22
+ from django.conf import settings
23
+ from django.utils.translation import gettext_lazy as _, ngettext
24
+ from django.core.management import CommandError, call_command
25
+
26
+ from nodes.models import Node, NodeRole
27
+ from nodes.utils import capture_screenshot, save_screenshot
28
+
29
+ from .forms import UserManualAdminForm
30
+ from .module_defaults import reload_default_modules as restore_default_modules
31
+ from .site_config import ensure_site_fields
32
+ from .utils import landing_leads_supported
33
+
34
+ from .models import (
35
+ SiteBadge,
36
+ Application,
37
+ SiteProxy,
38
+ Module,
39
+ Landing,
40
+ LandingLead,
41
+ RoleLanding,
42
+ Favorite,
43
+ ViewHistory,
44
+ UserManual,
45
+ UserStory,
46
+ )
47
+ from django.contrib.contenttypes.models import ContentType
48
+ from core.models import ReleaseManager
49
+ from core.user_data import EntityModelAdmin
50
+
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ def get_local_app_choices():
56
+ choices = []
57
+ for app_label in getattr(settings, "LOCAL_APPS", []):
58
+ try:
59
+ config = django_apps.get_app_config(app_label)
60
+ except LookupError:
61
+ continue
62
+ choices.append((config.label, config.verbose_name))
63
+ return choices
64
+
65
+
66
+ class SiteBadgeInline(admin.StackedInline):
67
+ model = SiteBadge
68
+ can_delete = False
69
+ extra = 0
70
+ fields = ("favicon", "landing_override")
71
+
72
+
73
+ class SiteForm(forms.ModelForm):
74
+ name = forms.CharField(required=False)
75
+
76
+ class Meta:
77
+ model = Site
78
+ fields = "__all__"
79
+
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
+
115
+ class SiteAdmin(DjangoSiteAdmin):
116
+ form = SiteForm
117
+ inlines = [SiteBadgeInline]
118
+ change_list_template = "admin/sites/site/change_list.html"
119
+ fields = ("domain", "name", "managed", "require_https")
120
+ list_display = ("domain", "name", "managed", "require_https")
121
+ list_filter = (ManagedSiteListFilter, RequireHttpsListFilter)
122
+ actions = ["capture_screenshot"]
123
+
124
+ @admin.action(description="Capture screenshot")
125
+ def capture_screenshot(self, request, queryset):
126
+ node = Node.get_local()
127
+ for site in queryset:
128
+ url = f"http://{site.domain}/"
129
+ try:
130
+ path = capture_screenshot(url)
131
+ screenshot = save_screenshot(path, node=node, method="ADMIN")
132
+ except Exception as exc: # pragma: no cover - browser issues
133
+ self.message_user(request, f"{site.domain}: {exc}", messages.ERROR)
134
+ continue
135
+ if screenshot:
136
+ link = reverse("admin:nodes_contentsample_change", args=[screenshot.pk])
137
+ self.message_user(
138
+ request,
139
+ format_html(
140
+ 'Screenshot for {} saved. <a href="{}">View</a>',
141
+ site.domain,
142
+ link,
143
+ ),
144
+ messages.SUCCESS,
145
+ )
146
+ else:
147
+ self.message_user(
148
+ request,
149
+ f"{site.domain}: duplicate screenshot; not saved",
150
+ messages.INFO,
151
+ )
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
+
174
+ def _reload_site_fixtures(self, request):
175
+ fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
176
+ fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
177
+ sigil_fixture = fixtures_dir / "sigil_roots__site.json"
178
+ if sigil_fixture.exists():
179
+ fixture_paths.append(sigil_fixture)
180
+
181
+ if not fixture_paths:
182
+ self.message_user(request, _("No site fixtures found."), messages.WARNING)
183
+ return None
184
+
185
+ loaded = 0
186
+ for path in fixture_paths:
187
+ try:
188
+ call_command("loaddata", str(path), verbosity=0)
189
+ except CommandError as exc:
190
+ self.message_user(
191
+ request,
192
+ _("%(fixture)s: %(error)s")
193
+ % {"fixture": path.name, "error": exc},
194
+ messages.ERROR,
195
+ )
196
+ else:
197
+ loaded += 1
198
+
199
+ if loaded:
200
+ message = ngettext(
201
+ "Reloaded %(count)d site fixture.",
202
+ "Reloaded %(count)d site fixtures.",
203
+ loaded,
204
+ ) % {"count": loaded}
205
+ self.message_user(request, message, messages.SUCCESS)
206
+
207
+ return None
208
+
209
+ def reload_site_fixtures(self, request):
210
+ if request.method != "POST":
211
+ return redirect("..")
212
+
213
+ self._reload_site_fixtures(request)
214
+
215
+ return redirect("..")
216
+
217
+ def get_urls(self):
218
+ urls = super().get_urls()
219
+ custom = [
220
+ path(
221
+ "register-current/",
222
+ self.admin_site.admin_view(self.register_current),
223
+ name="pages_siteproxy_register_current",
224
+ ),
225
+ path(
226
+ "reload-site-fixtures/",
227
+ self.admin_site.admin_view(self.reload_site_fixtures),
228
+ name="pages_siteproxy_reload_site_fixtures",
229
+ ),
230
+ ]
231
+ return custom + urls
232
+
233
+ def register_current(self, request):
234
+ domain = request.get_host().split(":")[0]
235
+ try:
236
+ ipaddress.ip_address(domain)
237
+ except ValueError:
238
+ name = domain
239
+ else:
240
+ name = ""
241
+ site, created = Site.objects.get_or_create(
242
+ domain=domain, defaults={"name": name}
243
+ )
244
+ if created:
245
+ self.message_user(request, "Current domain registered", messages.SUCCESS)
246
+ else:
247
+ self.message_user(
248
+ request, "Current domain already registered", messages.INFO
249
+ )
250
+ return redirect("..")
251
+
252
+
253
+ admin.site.unregister(Site)
254
+ admin.site.register(SiteProxy, SiteAdmin)
255
+
256
+
257
+ class ApplicationForm(forms.ModelForm):
258
+ name = forms.ChoiceField(choices=[])
259
+
260
+ class Meta:
261
+ model = Application
262
+ fields = "__all__"
263
+
264
+ def __init__(self, *args, **kwargs):
265
+ super().__init__(*args, **kwargs)
266
+ self.fields["name"].choices = get_local_app_choices()
267
+
268
+
269
+ class ApplicationModuleInline(admin.TabularInline):
270
+ model = Module
271
+ fk_name = "application"
272
+ extra = 0
273
+
274
+
275
+ @admin.register(Application)
276
+ class ApplicationAdmin(EntityModelAdmin):
277
+ form = ApplicationForm
278
+ list_display = ("name", "app_verbose_name", "description", "installed")
279
+ readonly_fields = ("installed",)
280
+ inlines = [ApplicationModuleInline]
281
+
282
+ @admin.display(description="Verbose name")
283
+ def app_verbose_name(self, obj):
284
+ return obj.verbose_name
285
+
286
+ @admin.display(boolean=True)
287
+ def installed(self, obj):
288
+ return obj.installed
289
+
290
+
291
+ class LandingInline(admin.TabularInline):
292
+ model = Landing
293
+ extra = 0
294
+ fields = ("path", "label", "enabled", "track_leads")
295
+ show_change_link = True
296
+
297
+
298
+ @admin.register(Landing)
299
+ class LandingAdmin(EntityModelAdmin):
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
+ )
307
+ search_fields = (
308
+ "label",
309
+ "path",
310
+ "description",
311
+ "module__path",
312
+ "module__application__name",
313
+ "module__node_role__name",
314
+ )
315
+ fields = ("module", "path", "label", "enabled", "track_leads", "description")
316
+ list_select_related = ("module", "module__application", "module__node_role")
317
+
318
+
319
+ @admin.register(Module)
320
+ class ModuleAdmin(EntityModelAdmin):
321
+ change_list_template = "admin/pages/module/change_list.html"
322
+ list_display = ("application", "node_role", "path", "menu", "is_default")
323
+ list_filter = ("node_role", "application")
324
+ fields = ("node_role", "application", "path", "menu", "is_default", "favicon")
325
+ inlines = [LandingInline]
326
+
327
+ def get_urls(self):
328
+ urls = super().get_urls()
329
+ custom = [
330
+ path(
331
+ "reload-default-modules/",
332
+ self.admin_site.admin_view(self.reload_default_modules_view),
333
+ name="pages_module_reload_default_modules",
334
+ ),
335
+ ]
336
+ return custom + urls
337
+
338
+ def reload_default_modules_view(self, request):
339
+ if request.method != "POST":
340
+ return redirect("..")
341
+
342
+ summary = restore_default_modules(Application, Module, Landing, NodeRole)
343
+
344
+ if summary.roles_processed == 0:
345
+ self.message_user(
346
+ request,
347
+ _("No default modules were reloaded because the required node roles are missing."),
348
+ messages.WARNING,
349
+ )
350
+ elif summary.has_changes:
351
+ parts: list[str] = []
352
+ if summary.modules_created:
353
+ parts.append(
354
+ ngettext(
355
+ "%(count)d module created",
356
+ "%(count)d modules created",
357
+ summary.modules_created,
358
+ )
359
+ % {"count": summary.modules_created}
360
+ )
361
+ if summary.modules_updated:
362
+ parts.append(
363
+ ngettext(
364
+ "%(count)d module updated",
365
+ "%(count)d modules updated",
366
+ summary.modules_updated,
367
+ )
368
+ % {"count": summary.modules_updated}
369
+ )
370
+ if summary.landings_created:
371
+ parts.append(
372
+ ngettext(
373
+ "%(count)d landing created",
374
+ "%(count)d landings created",
375
+ summary.landings_created,
376
+ )
377
+ % {"count": summary.landings_created}
378
+ )
379
+ if summary.landings_updated:
380
+ parts.append(
381
+ ngettext(
382
+ "%(count)d landing updated",
383
+ "%(count)d landings updated",
384
+ summary.landings_updated,
385
+ )
386
+ % {"count": summary.landings_updated}
387
+ )
388
+
389
+ details = "; ".join(parts)
390
+ if details:
391
+ message = _(
392
+ "Reloaded default modules for %(roles)d role(s). %(details)s."
393
+ ) % {"roles": summary.roles_processed, "details": details}
394
+ else:
395
+ message = _(
396
+ "Reloaded default modules for %(roles)d role(s)."
397
+ ) % {"roles": summary.roles_processed}
398
+ self.message_user(request, message, messages.SUCCESS)
399
+ else:
400
+ self.message_user(
401
+ request,
402
+ _(
403
+ "Default modules are already up to date for %(roles)d role(s)."
404
+ )
405
+ % {"roles": summary.roles_processed},
406
+ messages.INFO,
407
+ )
408
+
409
+ return redirect("..")
410
+
411
+
412
+ @admin.register(LandingLead)
413
+ class LandingLeadAdmin(EntityModelAdmin):
414
+ list_display = (
415
+ "landing_label",
416
+ "landing_path",
417
+ "status",
418
+ "user",
419
+ "referer_display",
420
+ "created_on",
421
+ )
422
+ list_filter = (
423
+ "status",
424
+ "landing__module__node_role",
425
+ "landing__module__application",
426
+ )
427
+ search_fields = (
428
+ "landing__label",
429
+ "landing__path",
430
+ "referer",
431
+ "path",
432
+ "user__username",
433
+ "user__email",
434
+ )
435
+ readonly_fields = (
436
+ "landing",
437
+ "user",
438
+ "path",
439
+ "referer",
440
+ "user_agent",
441
+ "ip_address",
442
+ "created_on",
443
+ )
444
+ fields = (
445
+ "landing",
446
+ "user",
447
+ "path",
448
+ "referer",
449
+ "user_agent",
450
+ "ip_address",
451
+ "status",
452
+ "assign_to",
453
+ "created_on",
454
+ )
455
+ list_select_related = ("landing", "landing__module", "landing__module__application")
456
+ ordering = ("-created_on",)
457
+ date_hierarchy = "created_on"
458
+
459
+ def changelist_view(self, request, extra_context=None):
460
+ if not landing_leads_supported():
461
+ self.message_user(
462
+ request,
463
+ _(
464
+ "Landing leads are not being recorded because Celery is not running on this node."
465
+ ),
466
+ messages.WARNING,
467
+ )
468
+ return super().changelist_view(request, extra_context=extra_context)
469
+
470
+ @admin.display(description=_("Landing"), ordering="landing__label")
471
+ def landing_label(self, obj):
472
+ return obj.landing.label
473
+
474
+ @admin.display(description=_("Path"), ordering="landing__path")
475
+ def landing_path(self, obj):
476
+ return obj.landing.path
477
+
478
+ @admin.display(description=_("Referrer"))
479
+ def referer_display(self, obj):
480
+ return obj.referer or ""
481
+
482
+
483
+ @admin.register(RoleLanding)
484
+ class RoleLandingAdmin(EntityModelAdmin):
485
+ list_display = (
486
+ "target_display",
487
+ "landing_path",
488
+ "landing_label",
489
+ "priority",
490
+ "is_seed_data",
491
+ )
492
+ list_filter = ("node_role", "security_group")
493
+ search_fields = (
494
+ "node_role__name",
495
+ "security_group__name",
496
+ "user__username",
497
+ "landing__path",
498
+ "landing__label",
499
+ )
500
+ fields = ("node_role", "security_group", "user", "priority", "landing")
501
+ list_select_related = (
502
+ "node_role",
503
+ "security_group",
504
+ "user",
505
+ "landing",
506
+ "landing__module",
507
+ )
508
+
509
+ @admin.display(description="Landing Path")
510
+ def landing_path(self, obj):
511
+ return obj.landing.path if obj.landing_id else ""
512
+
513
+ @admin.display(description="Landing Label")
514
+ def landing_label(self, obj):
515
+ return obj.landing.label if obj.landing_id else ""
516
+
517
+ @admin.display(description="Target", ordering="priority")
518
+ def target_display(self, obj):
519
+ if obj.node_role_id:
520
+ return obj.node_role.name
521
+ if obj.security_group_id:
522
+ return obj.security_group.name
523
+ if obj.user_id:
524
+ return obj.user.get_username()
525
+ return ""
526
+
527
+
528
+ @admin.register(UserManual)
529
+ class UserManualAdmin(EntityModelAdmin):
530
+ form = UserManualAdminForm
531
+ list_display = ("title", "slug", "languages", "is_seed_data", "is_user_data")
532
+ search_fields = ("title", "slug", "description")
533
+ list_filter = ("is_seed_data", "is_user_data")
534
+
535
+
536
+ @admin.register(ViewHistory)
537
+ class ViewHistoryAdmin(EntityModelAdmin):
538
+ date_hierarchy = "visited_at"
539
+ list_display = (
540
+ "path",
541
+ "status_code",
542
+ "status_text",
543
+ "method",
544
+ "visited_at",
545
+ )
546
+ list_filter = ("method", "status_code")
547
+ search_fields = ("path", "error_message", "view_name", "status_text")
548
+ readonly_fields = (
549
+ "path",
550
+ "method",
551
+ "status_code",
552
+ "status_text",
553
+ "error_message",
554
+ "view_name",
555
+ "visited_at",
556
+ )
557
+ ordering = ("-visited_at",)
558
+ change_list_template = "admin/pages/viewhistory/change_list.html"
559
+ actions = ["view_traffic_graph"]
560
+
561
+ def has_add_permission(self, request):
562
+ return False
563
+
564
+ @admin.action(description="View traffic graph")
565
+ def view_traffic_graph(self, request, queryset):
566
+ return redirect("admin:pages_viewhistory_traffic_graph")
567
+
568
+ def get_urls(self):
569
+ urls = super().get_urls()
570
+ custom = [
571
+ path(
572
+ "traffic-graph/",
573
+ self.admin_site.admin_view(self.traffic_graph_view),
574
+ name="pages_viewhistory_traffic_graph",
575
+ ),
576
+ path(
577
+ "traffic-data/",
578
+ self.admin_site.admin_view(self.traffic_data_view),
579
+ name="pages_viewhistory_traffic_data",
580
+ ),
581
+ ]
582
+ return custom + urls
583
+
584
+ def traffic_graph_view(self, request):
585
+ context = {
586
+ **self.admin_site.each_context(request),
587
+ "opts": self.model._meta,
588
+ "title": "Public site traffic",
589
+ "chart_endpoint": reverse("admin:pages_viewhistory_traffic_data"),
590
+ }
591
+ return TemplateResponse(
592
+ request,
593
+ "admin/pages/viewhistory/traffic_graph.html",
594
+ context,
595
+ )
596
+
597
+ def traffic_data_view(self, request):
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))
615
+
616
+ def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
617
+ end_date = timezone.localdate()
618
+ start_date = end_date - timedelta(days=days - 1)
619
+
620
+ start_at = datetime.combine(start_date, time.min)
621
+ end_at = datetime.combine(end_date + timedelta(days=1), time.min)
622
+
623
+ if settings.USE_TZ:
624
+ current_tz = timezone.get_current_timezone()
625
+ start_at = timezone.make_aware(start_at, current_tz)
626
+ end_at = timezone.make_aware(end_at, current_tz)
627
+ trunc_expression = TruncDate("visited_at", tzinfo=current_tz)
628
+ else:
629
+ trunc_expression = TruncDate("visited_at")
630
+
631
+ queryset = ViewHistory.objects.filter(
632
+ visited_at__gte=start_at, visited_at__lt=end_at
633
+ )
634
+
635
+ meta = {
636
+ "start": start_date.isoformat(),
637
+ "end": end_date.isoformat(),
638
+ }
639
+
640
+ if not queryset.exists():
641
+ meta["pages"] = []
642
+ return {"labels": [], "datasets": [], "meta": meta}
643
+
644
+ top_paths = list(
645
+ queryset.values("path")
646
+ .annotate(total=Count("id"))
647
+ .order_by("-total")[:max_pages]
648
+ )
649
+ paths = [entry["path"] for entry in top_paths]
650
+ meta["pages"] = paths
651
+
652
+ labels = [
653
+ (start_date + timedelta(days=offset)).isoformat() for offset in range(days)
654
+ ]
655
+
656
+ aggregates = (
657
+ queryset.filter(path__in=paths)
658
+ .annotate(day=trunc_expression)
659
+ .values("day", "path")
660
+ .order_by("day")
661
+ .annotate(total=Count("id"))
662
+ )
663
+
664
+ counts: dict[str, dict[str, int]] = {
665
+ path: {label: 0 for label in labels} for path in paths
666
+ }
667
+ for row in aggregates:
668
+ day = row["day"].isoformat()
669
+ path = row["path"]
670
+ if day in counts.get(path, {}):
671
+ counts[path][day] = row["total"]
672
+
673
+ palette = [
674
+ "#1f77b4",
675
+ "#ff7f0e",
676
+ "#2ca02c",
677
+ "#d62728",
678
+ "#9467bd",
679
+ "#8c564b",
680
+ "#e377c2",
681
+ "#7f7f7f",
682
+ "#bcbd22",
683
+ "#17becf",
684
+ ]
685
+ datasets = []
686
+ for index, path in enumerate(paths):
687
+ color = palette[index % len(palette)]
688
+ datasets.append(
689
+ {
690
+ "label": path,
691
+ "data": [counts[path][label] for label in labels],
692
+ "borderColor": color,
693
+ "backgroundColor": color,
694
+ "fill": False,
695
+ "tension": 0.3,
696
+ }
697
+ )
698
+
699
+ return {"labels": labels, "datasets": datasets, "meta": meta}
700
+
701
+
702
+ @admin.register(UserStory)
703
+ class UserStoryAdmin(EntityModelAdmin):
704
+ date_hierarchy = "submitted_at"
705
+ actions = ["create_github_issues"]
706
+ list_display = (
707
+ "name",
708
+ "language_code",
709
+ "rating",
710
+ "path",
711
+ "status",
712
+ "submitted_at",
713
+ "github_issue_display",
714
+ "screenshot_display",
715
+ "take_screenshot",
716
+ "owner",
717
+ "assign_to",
718
+ )
719
+ list_filter = ("rating", "status", "submitted_at", "take_screenshot")
720
+ search_fields = (
721
+ "name",
722
+ "comments",
723
+ "path",
724
+ "language_code",
725
+ "referer",
726
+ "github_issue_url",
727
+ "ip_address",
728
+ )
729
+ readonly_fields = (
730
+ "name",
731
+ "rating",
732
+ "comments",
733
+ "take_screenshot",
734
+ "path",
735
+ "user",
736
+ "owner",
737
+ "language_code",
738
+ "referer",
739
+ "user_agent",
740
+ "ip_address",
741
+ "created_on",
742
+ "submitted_at",
743
+ "github_issue_number",
744
+ "github_issue_url",
745
+ "screenshot_display",
746
+ )
747
+ ordering = ("-submitted_at",)
748
+ fields = (
749
+ "name",
750
+ "rating",
751
+ "comments",
752
+ "take_screenshot",
753
+ "screenshot_display",
754
+ "path",
755
+ "language_code",
756
+ "user",
757
+ "owner",
758
+ "status",
759
+ "assign_to",
760
+ "referer",
761
+ "user_agent",
762
+ "ip_address",
763
+ "created_on",
764
+ "submitted_at",
765
+ "github_issue_number",
766
+ "github_issue_url",
767
+ )
768
+
769
+ @admin.display(description=_("GitHub issue"), ordering="github_issue_number")
770
+ def github_issue_display(self, obj):
771
+ if obj.github_issue_url:
772
+ label = (
773
+ f"#{obj.github_issue_number}"
774
+ if obj.github_issue_number is not None
775
+ else obj.github_issue_url
776
+ )
777
+ return format_html(
778
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
779
+ obj.github_issue_url,
780
+ label,
781
+ )
782
+ if obj.github_issue_number is not None:
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
+ )
799
+ return _("Not created")
800
+
801
+ @admin.action(description=_("Create GitHub issues"))
802
+ def create_github_issues(self, request, queryset):
803
+ created = 0
804
+ skipped = 0
805
+
806
+ for story in queryset:
807
+ if story.github_issue_url:
808
+ skipped += 1
809
+ continue
810
+
811
+ try:
812
+ issue_url = story.create_github_issue()
813
+ except Exception as exc: # pragma: no cover - network/runtime errors
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
+
839
+ self.message_user(
840
+ request,
841
+ message,
842
+ messages.ERROR,
843
+ )
844
+ continue
845
+
846
+ if issue_url:
847
+ created += 1
848
+ else:
849
+ skipped += 1
850
+
851
+ if created:
852
+ self.message_user(
853
+ request,
854
+ ngettext(
855
+ "Created %(count)d GitHub issue.",
856
+ "Created %(count)d GitHub issues.",
857
+ created,
858
+ )
859
+ % {"count": created},
860
+ messages.SUCCESS,
861
+ )
862
+
863
+ if skipped:
864
+ self.message_user(
865
+ request,
866
+ ngettext(
867
+ "Skipped %(count)d feedback item (issue already exists or was throttled).",
868
+ "Skipped %(count)d feedback items (issues already exist or were throttled).",
869
+ skipped,
870
+ )
871
+ % {"count": skipped},
872
+ messages.INFO,
873
+ )
874
+
875
+ def has_add_permission(self, request):
876
+ return False
877
+
878
+
879
+ def favorite_toggle(request, ct_id):
880
+ ct = get_object_or_404(ContentType, pk=ct_id)
881
+ fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
882
+ next_url = request.GET.get("next")
883
+ if request.method == "POST":
884
+ if fav and request.POST.get("remove"):
885
+ fav.delete()
886
+ return redirect(next_url or "admin:index")
887
+ label = request.POST.get("custom_label", "").strip()
888
+ user_data = request.POST.get("user_data") == "on"
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
+ )
923
+ return redirect(next_url or "admin:index")
924
+ return render(
925
+ request,
926
+ "admin/favorite_confirm.html",
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
+ },
935
+ )
936
+
937
+
938
+ def favorite_list(request):
939
+ favorites = (
940
+ Favorite.objects.filter(user=request.user)
941
+ .select_related("content_type")
942
+ .order_by("priority", "pk")
943
+ )
944
+ if request.method == "POST":
945
+ selected = set(request.POST.getlist("user_data"))
946
+ for fav in favorites:
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)
970
+ return redirect("admin:favorite_list")
971
+ return render(request, "admin/favorite_list.html", {"favorites": favorites})
972
+
973
+
974
+ def favorite_delete(request, pk):
975
+ fav = get_object_or_404(Favorite, pk=pk, user=request.user)
976
+ fav.delete()
977
+ return redirect("admin:favorite_list")
978
+
979
+
980
+ def favorite_clear(request):
981
+ Favorite.objects.filter(user=request.user).delete()
982
+ return redirect("admin:favorite_list")
983
+
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
+
992
+ def log_viewer(request):
993
+ logs_dir = Path(settings.BASE_DIR) / "logs"
994
+ logs_exist = logs_dir.exists() and logs_dir.is_dir()
995
+ available_logs = []
996
+ if logs_exist:
997
+ available_logs = sorted(
998
+ [
999
+ entry.name
1000
+ for entry in logs_dir.iterdir()
1001
+ if entry.is_file() and not entry.name.startswith(".")
1002
+ ],
1003
+ key=str.lower,
1004
+ )
1005
+
1006
+ selected_log = request.GET.get("log", "")
1007
+ log_content = ""
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"
1021
+
1022
+ if selected_log:
1023
+ if selected_log in available_logs:
1024
+ selected_path = logs_dir / selected_log
1025
+ try:
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))
1053
+ except OSError as exc: # pragma: no cover - filesystem edge cases
1054
+ logger.warning("Unable to read log file %s", selected_path, exc_info=exc)
1055
+ log_error = _(
1056
+ "The log file could not be read. Check server permissions and try again."
1057
+ )
1058
+ else:
1059
+ log_error = _("The requested log could not be found.")
1060
+
1061
+ if not logs_exist:
1062
+ log_notice = _("The logs directory could not be found at %(path)s.") % {
1063
+ "path": logs_dir,
1064
+ }
1065
+ elif not available_logs:
1066
+ log_notice = _("No log files were found in %(path)s.") % {"path": logs_dir}
1067
+ else:
1068
+ log_notice = ""
1069
+
1070
+ limit_label = limit_options[limit_index]["label"]
1071
+ context = {**admin.site.each_context(request)}
1072
+ context.update(
1073
+ {
1074
+ "title": _("Log viewer"),
1075
+ "available_logs": available_logs,
1076
+ "selected_log": selected_log,
1077
+ "log_content": log_content,
1078
+ "log_error": log_error,
1079
+ "log_notice": log_notice,
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,
1085
+ }
1086
+ )
1087
+ return TemplateResponse(request, "admin/log_viewer.html", context)
1088
+
1089
+
1090
+ def get_admin_urls(original_get_urls):
1091
+ def get_urls():
1092
+ urls = original_get_urls()
1093
+ my_urls = [
1094
+ path(
1095
+ "logs/viewer/",
1096
+ admin.site.admin_view(log_viewer),
1097
+ name="log_viewer",
1098
+ ),
1099
+ path(
1100
+ "favorites/<int:ct_id>/",
1101
+ admin.site.admin_view(favorite_toggle),
1102
+ name="favorite_toggle",
1103
+ ),
1104
+ path(
1105
+ "favorites/", admin.site.admin_view(favorite_list), name="favorite_list"
1106
+ ),
1107
+ path(
1108
+ "favorites/delete/<int:pk>/",
1109
+ admin.site.admin_view(favorite_delete),
1110
+ name="favorite_delete",
1111
+ ),
1112
+ path(
1113
+ "favorites/clear/",
1114
+ admin.site.admin_view(favorite_clear),
1115
+ name="favorite_clear",
1116
+ ),
1117
+ ]
1118
+ return my_urls + original_get_urls()
1119
+
1120
+ return get_urls
1121
+
1122
+
1123
+ admin.site.get_urls = get_admin_urls(admin.site.get_urls)