arthexis 0.1.13__py3-none-any.whl → 0.1.14__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 (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -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 +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
pages/admin.py CHANGED
@@ -1,539 +1,708 @@
1
- import logging
2
-
3
- from django.contrib import admin, messages
4
- from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
5
- from django.contrib.sites.models import Site
6
- from django import forms
7
- from django.db import models
8
- from core.widgets import CopyColorWidget
9
- from django.shortcuts import redirect, render, get_object_or_404
10
- from django.urls import path, reverse
11
- from django.utils.html import format_html
12
- from django.template.response import TemplateResponse
13
- from django.http import JsonResponse
14
- from django.utils import timezone
15
- from django.db.models import Count
16
- from django.db.models.functions import TruncDate
17
- from datetime import datetime, time, timedelta
18
- import ipaddress
19
- from django.apps import apps as django_apps
20
- from django.conf import settings
21
- from django.utils.translation import gettext_lazy as _, ngettext
22
-
23
- from nodes.models import Node
24
- from nodes.utils import capture_screenshot, save_screenshot
25
-
26
- from .forms import UserManualAdminForm
27
-
28
- from .models import (
29
- SiteBadge,
30
- Application,
31
- SiteProxy,
32
- Module,
33
- Landing,
34
- Favorite,
35
- ViewHistory,
36
- UserManual,
37
- UserStory,
38
- )
39
- from django.contrib.contenttypes.models import ContentType
40
- from core.user_data import EntityModelAdmin
41
-
42
-
43
- logger = logging.getLogger(__name__)
44
-
45
-
46
- def get_local_app_choices():
47
- choices = []
48
- for app_label in getattr(settings, "LOCAL_APPS", []):
49
- try:
50
- config = django_apps.get_app_config(app_label)
51
- except LookupError:
52
- continue
53
- choices.append((config.label, config.verbose_name))
54
- return choices
55
-
56
-
57
- class SiteBadgeInline(admin.StackedInline):
58
- model = SiteBadge
59
- can_delete = False
60
- extra = 0
61
- formfield_overrides = {models.CharField: {"widget": CopyColorWidget}}
62
- fields = ("badge_color", "favicon", "landing_override")
63
-
64
-
65
- class SiteForm(forms.ModelForm):
66
- name = forms.CharField(required=False)
67
-
68
- class Meta:
69
- model = Site
70
- fields = "__all__"
71
-
72
-
73
- class SiteAdmin(DjangoSiteAdmin):
74
- form = SiteForm
75
- inlines = [SiteBadgeInline]
76
- change_list_template = "admin/sites/site/change_list.html"
77
- fields = ("domain", "name")
78
- list_display = ("domain", "name")
79
- actions = ["capture_screenshot"]
80
-
81
- @admin.action(description="Capture screenshot")
82
- def capture_screenshot(self, request, queryset):
83
- node = Node.get_local()
84
- for site in queryset:
85
- url = f"http://{site.domain}/"
86
- try:
87
- path = capture_screenshot(url)
88
- screenshot = save_screenshot(path, node=node, method="ADMIN")
89
- except Exception as exc: # pragma: no cover - browser issues
90
- self.message_user(request, f"{site.domain}: {exc}", messages.ERROR)
91
- continue
92
- if screenshot:
93
- link = reverse("admin:nodes_contentsample_change", args=[screenshot.pk])
94
- self.message_user(
95
- request,
96
- format_html(
97
- 'Screenshot for {} saved. <a href="{}">View</a>',
98
- site.domain,
99
- link,
100
- ),
101
- messages.SUCCESS,
102
- )
103
- else:
104
- self.message_user(
105
- request,
106
- f"{site.domain}: duplicate screenshot; not saved",
107
- messages.INFO,
108
- )
109
-
110
- def get_urls(self):
111
- urls = super().get_urls()
112
- custom = [
113
- path(
114
- "register-current/",
115
- self.admin_site.admin_view(self.register_current),
116
- name="pages_siteproxy_register_current",
117
- )
118
- ]
119
- return custom + urls
120
-
121
- def register_current(self, request):
122
- domain = request.get_host().split(":")[0]
123
- try:
124
- ipaddress.ip_address(domain)
125
- except ValueError:
126
- name = domain
127
- else:
128
- name = ""
129
- site, created = Site.objects.get_or_create(
130
- domain=domain, defaults={"name": name}
131
- )
132
- if created:
133
- self.message_user(request, "Current domain registered", messages.SUCCESS)
134
- else:
135
- self.message_user(
136
- request, "Current domain already registered", messages.INFO
137
- )
138
- return redirect("..")
139
-
140
-
141
- admin.site.unregister(Site)
142
- admin.site.register(SiteProxy, SiteAdmin)
143
-
144
-
145
- class ApplicationForm(forms.ModelForm):
146
- name = forms.ChoiceField(choices=[])
147
-
148
- class Meta:
149
- model = Application
150
- fields = "__all__"
151
-
152
- def __init__(self, *args, **kwargs):
153
- super().__init__(*args, **kwargs)
154
- self.fields["name"].choices = get_local_app_choices()
155
-
156
-
157
- class ApplicationModuleInline(admin.TabularInline):
158
- model = Module
159
- fk_name = "application"
160
- extra = 0
161
-
162
-
163
- @admin.register(Application)
164
- class ApplicationAdmin(EntityModelAdmin):
165
- form = ApplicationForm
166
- list_display = ("name", "app_verbose_name", "description", "installed")
167
- readonly_fields = ("installed",)
168
- inlines = [ApplicationModuleInline]
169
-
170
- @admin.display(description="Verbose name")
171
- def app_verbose_name(self, obj):
172
- return obj.verbose_name
173
-
174
- @admin.display(boolean=True)
175
- def installed(self, obj):
176
- return obj.installed
177
-
178
-
179
- class LandingInline(admin.TabularInline):
180
- model = Landing
181
- extra = 0
182
- fields = ("path", "label", "enabled", "description")
183
-
184
-
185
- @admin.register(Module)
186
- class ModuleAdmin(EntityModelAdmin):
187
- list_display = ("application", "node_role", "path", "menu", "is_default")
188
- list_filter = ("node_role", "application")
189
- fields = ("node_role", "application", "path", "menu", "is_default", "favicon")
190
- inlines = [LandingInline]
191
-
192
-
193
- @admin.register(UserManual)
194
- class UserManualAdmin(EntityModelAdmin):
195
- form = UserManualAdminForm
196
- list_display = ("title", "slug", "languages", "is_seed_data", "is_user_data")
197
- search_fields = ("title", "slug", "description")
198
- list_filter = ("is_seed_data", "is_user_data")
199
-
200
-
201
- @admin.register(ViewHistory)
202
- class ViewHistoryAdmin(EntityModelAdmin):
203
- date_hierarchy = "visited_at"
204
- list_display = (
205
- "path",
206
- "status_code",
207
- "status_text",
208
- "method",
209
- "visited_at",
210
- )
211
- list_filter = ("method", "status_code")
212
- search_fields = ("path", "error_message", "view_name", "status_text")
213
- readonly_fields = (
214
- "path",
215
- "method",
216
- "status_code",
217
- "status_text",
218
- "error_message",
219
- "view_name",
220
- "visited_at",
221
- )
222
- ordering = ("-visited_at",)
223
- change_list_template = "admin/pages/viewhistory/change_list.html"
224
- actions = ["view_traffic_graph"]
225
-
226
- def has_add_permission(self, request):
227
- return False
228
-
229
- @admin.action(description="View traffic graph")
230
- def view_traffic_graph(self, request, queryset):
231
- return redirect("admin:pages_viewhistory_traffic_graph")
232
-
233
- def get_urls(self):
234
- urls = super().get_urls()
235
- custom = [
236
- path(
237
- "traffic-graph/",
238
- self.admin_site.admin_view(self.traffic_graph_view),
239
- name="pages_viewhistory_traffic_graph",
240
- ),
241
- path(
242
- "traffic-data/",
243
- self.admin_site.admin_view(self.traffic_data_view),
244
- name="pages_viewhistory_traffic_data",
245
- ),
246
- ]
247
- return custom + urls
248
-
249
- def traffic_graph_view(self, request):
250
- context = {
251
- **self.admin_site.each_context(request),
252
- "opts": self.model._meta,
253
- "title": "Public site traffic",
254
- "chart_endpoint": reverse("admin:pages_viewhistory_traffic_data"),
255
- }
256
- return TemplateResponse(
257
- request,
258
- "admin/pages/viewhistory/traffic_graph.html",
259
- context,
260
- )
261
-
262
- def traffic_data_view(self, request):
263
- return JsonResponse(self._build_chart_data())
264
-
265
- def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
266
- end_date = timezone.localdate()
267
- start_date = end_date - timedelta(days=days - 1)
268
-
269
- start_at = datetime.combine(start_date, time.min)
270
- end_at = datetime.combine(end_date + timedelta(days=1), time.min)
271
-
272
- if settings.USE_TZ:
273
- current_tz = timezone.get_current_timezone()
274
- start_at = timezone.make_aware(start_at, current_tz)
275
- end_at = timezone.make_aware(end_at, current_tz)
276
- trunc_expression = TruncDate("visited_at", tzinfo=current_tz)
277
- else:
278
- trunc_expression = TruncDate("visited_at")
279
-
280
- queryset = ViewHistory.objects.filter(
281
- visited_at__gte=start_at, visited_at__lt=end_at
282
- )
283
-
284
- meta = {
285
- "start": start_date.isoformat(),
286
- "end": end_date.isoformat(),
287
- }
288
-
289
- if not queryset.exists():
290
- meta["pages"] = []
291
- return {"labels": [], "datasets": [], "meta": meta}
292
-
293
- top_paths = list(
294
- queryset.values("path")
295
- .annotate(total=Count("id"))
296
- .order_by("-total")[:max_pages]
297
- )
298
- paths = [entry["path"] for entry in top_paths]
299
- meta["pages"] = paths
300
-
301
- labels = [
302
- (start_date + timedelta(days=offset)).isoformat() for offset in range(days)
303
- ]
304
-
305
- aggregates = (
306
- queryset.filter(path__in=paths)
307
- .annotate(day=trunc_expression)
308
- .values("day", "path")
309
- .order_by("day")
310
- .annotate(total=Count("id"))
311
- )
312
-
313
- counts: dict[str, dict[str, int]] = {
314
- path: {label: 0 for label in labels} for path in paths
315
- }
316
- for row in aggregates:
317
- day = row["day"].isoformat()
318
- path = row["path"]
319
- if day in counts.get(path, {}):
320
- counts[path][day] = row["total"]
321
-
322
- palette = [
323
- "#1f77b4",
324
- "#ff7f0e",
325
- "#2ca02c",
326
- "#d62728",
327
- "#9467bd",
328
- "#8c564b",
329
- "#e377c2",
330
- "#7f7f7f",
331
- "#bcbd22",
332
- "#17becf",
333
- ]
334
- datasets = []
335
- for index, path in enumerate(paths):
336
- color = palette[index % len(palette)]
337
- datasets.append(
338
- {
339
- "label": path,
340
- "data": [counts[path][label] for label in labels],
341
- "borderColor": color,
342
- "backgroundColor": color,
343
- "fill": False,
344
- "tension": 0.3,
345
- }
346
- )
347
-
348
- return {"labels": labels, "datasets": datasets, "meta": meta}
349
-
350
-
351
- @admin.register(UserStory)
352
- class UserStoryAdmin(EntityModelAdmin):
353
- date_hierarchy = "submitted_at"
354
- actions = ["create_github_issues"]
355
- list_display = (
356
- "name",
357
- "rating",
358
- "path",
359
- "submitted_at",
360
- "github_issue_display",
361
- "take_screenshot",
362
- "owner",
363
- )
364
- list_filter = ("rating", "submitted_at", "take_screenshot")
365
- search_fields = ("name", "comments", "path", "github_issue_url")
366
- readonly_fields = (
367
- "name",
368
- "rating",
369
- "comments",
370
- "take_screenshot",
371
- "path",
372
- "user",
373
- "owner",
374
- "submitted_at",
375
- "github_issue_number",
376
- "github_issue_url",
377
- )
378
- ordering = ("-submitted_at",)
379
- fields = (
380
- "name",
381
- "rating",
382
- "comments",
383
- "take_screenshot",
384
- "path",
385
- "user",
386
- "owner",
387
- "submitted_at",
388
- "github_issue_number",
389
- "github_issue_url",
390
- )
391
-
392
- @admin.display(description=_("GitHub issue"), ordering="github_issue_number")
393
- def github_issue_display(self, obj):
394
- if obj.github_issue_url:
395
- label = (
396
- f"#{obj.github_issue_number}"
397
- if obj.github_issue_number is not None
398
- else obj.github_issue_url
399
- )
400
- return format_html(
401
- '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
402
- obj.github_issue_url,
403
- label,
404
- )
405
- if obj.github_issue_number is not None:
406
- return f"#{obj.github_issue_number}"
407
- return _("Not created")
408
-
409
- @admin.action(description=_("Create GitHub issues"))
410
- def create_github_issues(self, request, queryset):
411
- created = 0
412
- skipped = 0
413
-
414
- for story in queryset:
415
- if story.github_issue_url:
416
- skipped += 1
417
- continue
418
-
419
- try:
420
- issue_url = story.create_github_issue()
421
- except Exception as exc: # pragma: no cover - network/runtime errors
422
- logger.exception("Failed to create GitHub issue for UserStory %s", story.pk)
423
- self.message_user(
424
- request,
425
- _("Unable to create a GitHub issue for %(story)s: %(error)s")
426
- % {"story": story, "error": exc},
427
- messages.ERROR,
428
- )
429
- continue
430
-
431
- if issue_url:
432
- created += 1
433
- else:
434
- skipped += 1
435
-
436
- if created:
437
- self.message_user(
438
- request,
439
- ngettext(
440
- "Created %(count)d GitHub issue.",
441
- "Created %(count)d GitHub issues.",
442
- created,
443
- )
444
- % {"count": created},
445
- messages.SUCCESS,
446
- )
447
-
448
- if skipped:
449
- self.message_user(
450
- request,
451
- ngettext(
452
- "Skipped %(count)d feedback item (issue already exists or was throttled).",
453
- "Skipped %(count)d feedback items (issues already exist or were throttled).",
454
- skipped,
455
- )
456
- % {"count": skipped},
457
- messages.INFO,
458
- )
459
-
460
- def has_add_permission(self, request):
461
- return False
462
-
463
-
464
- def favorite_toggle(request, ct_id):
465
- ct = get_object_or_404(ContentType, pk=ct_id)
466
- fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
467
- next_url = request.GET.get("next")
468
- if fav:
469
- return redirect(next_url or "admin:favorite_list")
470
- if request.method == "POST":
471
- label = request.POST.get("custom_label", "").strip()
472
- user_data = request.POST.get("user_data") == "on"
473
- Favorite.objects.create(
474
- user=request.user,
475
- content_type=ct,
476
- custom_label=label,
477
- user_data=user_data,
478
- )
479
- return redirect(next_url or "admin:index")
480
- return render(
481
- request,
482
- "admin/favorite_confirm.html",
483
- {"content_type": ct, "next": next_url},
484
- )
485
-
486
-
487
- def favorite_list(request):
488
- favorites = Favorite.objects.filter(user=request.user).select_related(
489
- "content_type"
490
- )
491
- if request.method == "POST":
492
- selected = request.POST.getlist("user_data")
493
- for fav in favorites:
494
- fav.user_data = str(fav.pk) in selected
495
- fav.save(update_fields=["user_data"])
496
- return redirect("admin:favorite_list")
497
- return render(request, "admin/favorite_list.html", {"favorites": favorites})
498
-
499
-
500
- def favorite_delete(request, pk):
501
- fav = get_object_or_404(Favorite, pk=pk, user=request.user)
502
- fav.delete()
503
- return redirect("admin:favorite_list")
504
-
505
-
506
- def favorite_clear(request):
507
- Favorite.objects.filter(user=request.user).delete()
508
- return redirect("admin:favorite_list")
509
-
510
-
511
- def get_admin_urls(original_get_urls):
512
- def get_urls():
513
- urls = original_get_urls()
514
- my_urls = [
515
- path(
516
- "favorites/<int:ct_id>/",
517
- admin.site.admin_view(favorite_toggle),
518
- name="favorite_toggle",
519
- ),
520
- path(
521
- "favorites/", admin.site.admin_view(favorite_list), name="favorite_list"
522
- ),
523
- path(
524
- "favorites/delete/<int:pk>/",
525
- admin.site.admin_view(favorite_delete),
526
- name="favorite_delete",
527
- ),
528
- path(
529
- "favorites/clear/",
530
- admin.site.admin_view(favorite_clear),
531
- name="favorite_clear",
532
- ),
533
- ]
534
- return my_urls + original_get_urls()
535
-
536
- return get_urls
537
-
538
-
539
- admin.site.get_urls = get_admin_urls(admin.site.get_urls)
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ from django.contrib import admin, messages
5
+ from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
6
+ from django.contrib.sites.models import Site
7
+ from django import forms
8
+ from django.shortcuts import redirect, render, get_object_or_404
9
+ from django.urls import path, reverse
10
+ from django.utils.html import format_html
11
+ from django.template.response import TemplateResponse
12
+ from django.http import JsonResponse
13
+ from django.utils import timezone
14
+ from django.db.models import Count
15
+ from django.db.models.functions import TruncDate
16
+ from datetime import datetime, time, timedelta
17
+ import ipaddress
18
+ from django.apps import apps as django_apps
19
+ from django.conf import settings
20
+ from django.utils.translation import gettext_lazy as _, ngettext
21
+
22
+ from nodes.models import Node
23
+ from nodes.utils import capture_screenshot, save_screenshot
24
+
25
+ from .forms import UserManualAdminForm
26
+
27
+ from .models import (
28
+ SiteBadge,
29
+ Application,
30
+ SiteProxy,
31
+ Module,
32
+ Landing,
33
+ LandingLead,
34
+ RoleLanding,
35
+ Favorite,
36
+ ViewHistory,
37
+ UserManual,
38
+ UserStory,
39
+ )
40
+ from django.contrib.contenttypes.models import ContentType
41
+ from core.user_data import EntityModelAdmin
42
+
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ def get_local_app_choices():
48
+ choices = []
49
+ for app_label in getattr(settings, "LOCAL_APPS", []):
50
+ try:
51
+ config = django_apps.get_app_config(app_label)
52
+ except LookupError:
53
+ continue
54
+ choices.append((config.label, config.verbose_name))
55
+ return choices
56
+
57
+
58
+ class SiteBadgeInline(admin.StackedInline):
59
+ model = SiteBadge
60
+ can_delete = False
61
+ extra = 0
62
+ fields = ("favicon", "landing_override")
63
+
64
+
65
+ class SiteForm(forms.ModelForm):
66
+ name = forms.CharField(required=False)
67
+
68
+ class Meta:
69
+ model = Site
70
+ fields = "__all__"
71
+
72
+
73
+ class SiteAdmin(DjangoSiteAdmin):
74
+ form = SiteForm
75
+ inlines = [SiteBadgeInline]
76
+ change_list_template = "admin/sites/site/change_list.html"
77
+ fields = ("domain", "name")
78
+ list_display = ("domain", "name")
79
+ actions = ["capture_screenshot"]
80
+
81
+ @admin.action(description="Capture screenshot")
82
+ def capture_screenshot(self, request, queryset):
83
+ node = Node.get_local()
84
+ for site in queryset:
85
+ url = f"http://{site.domain}/"
86
+ try:
87
+ path = capture_screenshot(url)
88
+ screenshot = save_screenshot(path, node=node, method="ADMIN")
89
+ except Exception as exc: # pragma: no cover - browser issues
90
+ self.message_user(request, f"{site.domain}: {exc}", messages.ERROR)
91
+ continue
92
+ if screenshot:
93
+ link = reverse("admin:nodes_contentsample_change", args=[screenshot.pk])
94
+ self.message_user(
95
+ request,
96
+ format_html(
97
+ 'Screenshot for {} saved. <a href="{}">View</a>',
98
+ site.domain,
99
+ link,
100
+ ),
101
+ messages.SUCCESS,
102
+ )
103
+ else:
104
+ self.message_user(
105
+ request,
106
+ f"{site.domain}: duplicate screenshot; not saved",
107
+ messages.INFO,
108
+ )
109
+
110
+ def get_urls(self):
111
+ urls = super().get_urls()
112
+ custom = [
113
+ path(
114
+ "register-current/",
115
+ self.admin_site.admin_view(self.register_current),
116
+ name="pages_siteproxy_register_current",
117
+ )
118
+ ]
119
+ return custom + urls
120
+
121
+ def register_current(self, request):
122
+ domain = request.get_host().split(":")[0]
123
+ try:
124
+ ipaddress.ip_address(domain)
125
+ except ValueError:
126
+ name = domain
127
+ else:
128
+ name = ""
129
+ site, created = Site.objects.get_or_create(
130
+ domain=domain, defaults={"name": name}
131
+ )
132
+ if created:
133
+ self.message_user(request, "Current domain registered", messages.SUCCESS)
134
+ else:
135
+ self.message_user(
136
+ request, "Current domain already registered", messages.INFO
137
+ )
138
+ return redirect("..")
139
+
140
+
141
+ admin.site.unregister(Site)
142
+ admin.site.register(SiteProxy, SiteAdmin)
143
+
144
+
145
+ class ApplicationForm(forms.ModelForm):
146
+ name = forms.ChoiceField(choices=[])
147
+
148
+ class Meta:
149
+ model = Application
150
+ fields = "__all__"
151
+
152
+ def __init__(self, *args, **kwargs):
153
+ super().__init__(*args, **kwargs)
154
+ self.fields["name"].choices = get_local_app_choices()
155
+
156
+
157
+ class ApplicationModuleInline(admin.TabularInline):
158
+ model = Module
159
+ fk_name = "application"
160
+ extra = 0
161
+
162
+
163
+ @admin.register(Application)
164
+ class ApplicationAdmin(EntityModelAdmin):
165
+ form = ApplicationForm
166
+ list_display = ("name", "app_verbose_name", "description", "installed")
167
+ readonly_fields = ("installed",)
168
+ inlines = [ApplicationModuleInline]
169
+
170
+ @admin.display(description="Verbose name")
171
+ def app_verbose_name(self, obj):
172
+ return obj.verbose_name
173
+
174
+ @admin.display(boolean=True)
175
+ def installed(self, obj):
176
+ return obj.installed
177
+
178
+
179
+ class LandingInline(admin.TabularInline):
180
+ model = Landing
181
+ extra = 0
182
+ fields = ("path", "label", "enabled", "description")
183
+
184
+
185
+ @admin.register(Module)
186
+ class ModuleAdmin(EntityModelAdmin):
187
+ list_display = ("application", "node_role", "path", "menu", "is_default")
188
+ list_filter = ("node_role", "application")
189
+ fields = ("node_role", "application", "path", "menu", "is_default", "favicon")
190
+ inlines = [LandingInline]
191
+
192
+
193
+ @admin.register(LandingLead)
194
+ class LandingLeadAdmin(EntityModelAdmin):
195
+ list_display = (
196
+ "landing_label",
197
+ "landing_path",
198
+ "status",
199
+ "user",
200
+ "referer_display",
201
+ "created_on",
202
+ )
203
+ list_filter = (
204
+ "status",
205
+ "landing__module__node_role",
206
+ "landing__module__application",
207
+ )
208
+ search_fields = (
209
+ "landing__label",
210
+ "landing__path",
211
+ "referer",
212
+ "path",
213
+ "user__username",
214
+ "user__email",
215
+ )
216
+ readonly_fields = (
217
+ "landing",
218
+ "user",
219
+ "path",
220
+ "referer",
221
+ "user_agent",
222
+ "ip_address",
223
+ "created_on",
224
+ )
225
+ fields = (
226
+ "landing",
227
+ "user",
228
+ "path",
229
+ "referer",
230
+ "user_agent",
231
+ "ip_address",
232
+ "status",
233
+ "assign_to",
234
+ "created_on",
235
+ )
236
+ list_select_related = ("landing", "landing__module", "landing__module__application")
237
+ ordering = ("-created_on",)
238
+ date_hierarchy = "created_on"
239
+
240
+ @admin.display(description=_("Landing"), ordering="landing__label")
241
+ def landing_label(self, obj):
242
+ return obj.landing.label
243
+
244
+ @admin.display(description=_("Path"), ordering="landing__path")
245
+ def landing_path(self, obj):
246
+ return obj.landing.path
247
+
248
+ @admin.display(description=_("Referrer"))
249
+ def referer_display(self, obj):
250
+ return obj.referer or ""
251
+
252
+
253
+ @admin.register(RoleLanding)
254
+ class RoleLandingAdmin(EntityModelAdmin):
255
+ list_display = (
256
+ "target_display",
257
+ "landing_path",
258
+ "landing_label",
259
+ "priority",
260
+ "is_seed_data",
261
+ )
262
+ list_filter = ("node_role", "security_group")
263
+ search_fields = (
264
+ "node_role__name",
265
+ "security_group__name",
266
+ "user__username",
267
+ "landing__path",
268
+ "landing__label",
269
+ )
270
+ fields = ("node_role", "security_group", "user", "priority", "landing")
271
+ list_select_related = (
272
+ "node_role",
273
+ "security_group",
274
+ "user",
275
+ "landing",
276
+ "landing__module",
277
+ )
278
+
279
+ @admin.display(description="Landing Path")
280
+ def landing_path(self, obj):
281
+ return obj.landing.path if obj.landing_id else ""
282
+
283
+ @admin.display(description="Landing Label")
284
+ def landing_label(self, obj):
285
+ return obj.landing.label if obj.landing_id else ""
286
+
287
+ @admin.display(description="Target", ordering="priority")
288
+ def target_display(self, obj):
289
+ if obj.node_role_id:
290
+ return obj.node_role.name
291
+ if obj.security_group_id:
292
+ return obj.security_group.name
293
+ if obj.user_id:
294
+ return obj.user.get_username()
295
+ return ""
296
+
297
+
298
+ @admin.register(UserManual)
299
+ class UserManualAdmin(EntityModelAdmin):
300
+ form = UserManualAdminForm
301
+ list_display = ("title", "slug", "languages", "is_seed_data", "is_user_data")
302
+ search_fields = ("title", "slug", "description")
303
+ list_filter = ("is_seed_data", "is_user_data")
304
+
305
+
306
+ @admin.register(ViewHistory)
307
+ class ViewHistoryAdmin(EntityModelAdmin):
308
+ date_hierarchy = "visited_at"
309
+ list_display = (
310
+ "path",
311
+ "status_code",
312
+ "status_text",
313
+ "method",
314
+ "visited_at",
315
+ )
316
+ list_filter = ("method", "status_code")
317
+ search_fields = ("path", "error_message", "view_name", "status_text")
318
+ readonly_fields = (
319
+ "path",
320
+ "method",
321
+ "status_code",
322
+ "status_text",
323
+ "error_message",
324
+ "view_name",
325
+ "visited_at",
326
+ )
327
+ ordering = ("-visited_at",)
328
+ change_list_template = "admin/pages/viewhistory/change_list.html"
329
+ actions = ["view_traffic_graph"]
330
+
331
+ def has_add_permission(self, request):
332
+ return False
333
+
334
+ @admin.action(description="View traffic graph")
335
+ def view_traffic_graph(self, request, queryset):
336
+ return redirect("admin:pages_viewhistory_traffic_graph")
337
+
338
+ def get_urls(self):
339
+ urls = super().get_urls()
340
+ custom = [
341
+ path(
342
+ "traffic-graph/",
343
+ self.admin_site.admin_view(self.traffic_graph_view),
344
+ name="pages_viewhistory_traffic_graph",
345
+ ),
346
+ path(
347
+ "traffic-data/",
348
+ self.admin_site.admin_view(self.traffic_data_view),
349
+ name="pages_viewhistory_traffic_data",
350
+ ),
351
+ ]
352
+ return custom + urls
353
+
354
+ def traffic_graph_view(self, request):
355
+ context = {
356
+ **self.admin_site.each_context(request),
357
+ "opts": self.model._meta,
358
+ "title": "Public site traffic",
359
+ "chart_endpoint": reverse("admin:pages_viewhistory_traffic_data"),
360
+ }
361
+ return TemplateResponse(
362
+ request,
363
+ "admin/pages/viewhistory/traffic_graph.html",
364
+ context,
365
+ )
366
+
367
+ def traffic_data_view(self, request):
368
+ return JsonResponse(self._build_chart_data())
369
+
370
+ def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
371
+ end_date = timezone.localdate()
372
+ start_date = end_date - timedelta(days=days - 1)
373
+
374
+ start_at = datetime.combine(start_date, time.min)
375
+ end_at = datetime.combine(end_date + timedelta(days=1), time.min)
376
+
377
+ if settings.USE_TZ:
378
+ current_tz = timezone.get_current_timezone()
379
+ start_at = timezone.make_aware(start_at, current_tz)
380
+ end_at = timezone.make_aware(end_at, current_tz)
381
+ trunc_expression = TruncDate("visited_at", tzinfo=current_tz)
382
+ else:
383
+ trunc_expression = TruncDate("visited_at")
384
+
385
+ queryset = ViewHistory.objects.filter(
386
+ visited_at__gte=start_at, visited_at__lt=end_at
387
+ )
388
+
389
+ meta = {
390
+ "start": start_date.isoformat(),
391
+ "end": end_date.isoformat(),
392
+ }
393
+
394
+ if not queryset.exists():
395
+ meta["pages"] = []
396
+ return {"labels": [], "datasets": [], "meta": meta}
397
+
398
+ top_paths = list(
399
+ queryset.values("path")
400
+ .annotate(total=Count("id"))
401
+ .order_by("-total")[:max_pages]
402
+ )
403
+ paths = [entry["path"] for entry in top_paths]
404
+ meta["pages"] = paths
405
+
406
+ labels = [
407
+ (start_date + timedelta(days=offset)).isoformat() for offset in range(days)
408
+ ]
409
+
410
+ aggregates = (
411
+ queryset.filter(path__in=paths)
412
+ .annotate(day=trunc_expression)
413
+ .values("day", "path")
414
+ .order_by("day")
415
+ .annotate(total=Count("id"))
416
+ )
417
+
418
+ counts: dict[str, dict[str, int]] = {
419
+ path: {label: 0 for label in labels} for path in paths
420
+ }
421
+ for row in aggregates:
422
+ day = row["day"].isoformat()
423
+ path = row["path"]
424
+ if day in counts.get(path, {}):
425
+ counts[path][day] = row["total"]
426
+
427
+ palette = [
428
+ "#1f77b4",
429
+ "#ff7f0e",
430
+ "#2ca02c",
431
+ "#d62728",
432
+ "#9467bd",
433
+ "#8c564b",
434
+ "#e377c2",
435
+ "#7f7f7f",
436
+ "#bcbd22",
437
+ "#17becf",
438
+ ]
439
+ datasets = []
440
+ for index, path in enumerate(paths):
441
+ color = palette[index % len(palette)]
442
+ datasets.append(
443
+ {
444
+ "label": path,
445
+ "data": [counts[path][label] for label in labels],
446
+ "borderColor": color,
447
+ "backgroundColor": color,
448
+ "fill": False,
449
+ "tension": 0.3,
450
+ }
451
+ )
452
+
453
+ return {"labels": labels, "datasets": datasets, "meta": meta}
454
+
455
+
456
+ @admin.register(UserStory)
457
+ class UserStoryAdmin(EntityModelAdmin):
458
+ date_hierarchy = "submitted_at"
459
+ actions = ["create_github_issues"]
460
+ list_display = (
461
+ "name",
462
+ "rating",
463
+ "path",
464
+ "submitted_at",
465
+ "github_issue_display",
466
+ "take_screenshot",
467
+ "owner",
468
+ )
469
+ list_filter = ("rating", "submitted_at", "take_screenshot")
470
+ search_fields = ("name", "comments", "path", "github_issue_url")
471
+ readonly_fields = (
472
+ "name",
473
+ "rating",
474
+ "comments",
475
+ "take_screenshot",
476
+ "path",
477
+ "user",
478
+ "owner",
479
+ "submitted_at",
480
+ "github_issue_number",
481
+ "github_issue_url",
482
+ )
483
+ ordering = ("-submitted_at",)
484
+ fields = (
485
+ "name",
486
+ "rating",
487
+ "comments",
488
+ "take_screenshot",
489
+ "path",
490
+ "user",
491
+ "owner",
492
+ "submitted_at",
493
+ "github_issue_number",
494
+ "github_issue_url",
495
+ )
496
+
497
+ @admin.display(description=_("GitHub issue"), ordering="github_issue_number")
498
+ def github_issue_display(self, obj):
499
+ if obj.github_issue_url:
500
+ label = (
501
+ f"#{obj.github_issue_number}"
502
+ if obj.github_issue_number is not None
503
+ else obj.github_issue_url
504
+ )
505
+ return format_html(
506
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
507
+ obj.github_issue_url,
508
+ label,
509
+ )
510
+ if obj.github_issue_number is not None:
511
+ return f"#{obj.github_issue_number}"
512
+ return _("Not created")
513
+
514
+ @admin.action(description=_("Create GitHub issues"))
515
+ def create_github_issues(self, request, queryset):
516
+ created = 0
517
+ skipped = 0
518
+
519
+ for story in queryset:
520
+ if story.github_issue_url:
521
+ skipped += 1
522
+ continue
523
+
524
+ try:
525
+ issue_url = story.create_github_issue()
526
+ except Exception as exc: # pragma: no cover - network/runtime errors
527
+ logger.exception("Failed to create GitHub issue for UserStory %s", story.pk)
528
+ self.message_user(
529
+ request,
530
+ _("Unable to create a GitHub issue for %(story)s: %(error)s")
531
+ % {"story": story, "error": exc},
532
+ messages.ERROR,
533
+ )
534
+ continue
535
+
536
+ if issue_url:
537
+ created += 1
538
+ else:
539
+ skipped += 1
540
+
541
+ if created:
542
+ self.message_user(
543
+ request,
544
+ ngettext(
545
+ "Created %(count)d GitHub issue.",
546
+ "Created %(count)d GitHub issues.",
547
+ created,
548
+ )
549
+ % {"count": created},
550
+ messages.SUCCESS,
551
+ )
552
+
553
+ if skipped:
554
+ self.message_user(
555
+ request,
556
+ ngettext(
557
+ "Skipped %(count)d feedback item (issue already exists or was throttled).",
558
+ "Skipped %(count)d feedback items (issues already exist or were throttled).",
559
+ skipped,
560
+ )
561
+ % {"count": skipped},
562
+ messages.INFO,
563
+ )
564
+
565
+ def has_add_permission(self, request):
566
+ return False
567
+
568
+
569
+ def favorite_toggle(request, ct_id):
570
+ ct = get_object_or_404(ContentType, pk=ct_id)
571
+ fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
572
+ next_url = request.GET.get("next")
573
+ if fav:
574
+ return redirect(next_url or "admin:favorite_list")
575
+ if request.method == "POST":
576
+ label = request.POST.get("custom_label", "").strip()
577
+ user_data = request.POST.get("user_data") == "on"
578
+ Favorite.objects.create(
579
+ user=request.user,
580
+ content_type=ct,
581
+ custom_label=label,
582
+ user_data=user_data,
583
+ )
584
+ return redirect(next_url or "admin:index")
585
+ return render(
586
+ request,
587
+ "admin/favorite_confirm.html",
588
+ {"content_type": ct, "next": next_url},
589
+ )
590
+
591
+
592
+ def favorite_list(request):
593
+ favorites = Favorite.objects.filter(user=request.user).select_related(
594
+ "content_type"
595
+ )
596
+ if request.method == "POST":
597
+ selected = request.POST.getlist("user_data")
598
+ for fav in favorites:
599
+ fav.user_data = str(fav.pk) in selected
600
+ fav.save(update_fields=["user_data"])
601
+ return redirect("admin:favorite_list")
602
+ return render(request, "admin/favorite_list.html", {"favorites": favorites})
603
+
604
+
605
+ def favorite_delete(request, pk):
606
+ fav = get_object_or_404(Favorite, pk=pk, user=request.user)
607
+ fav.delete()
608
+ return redirect("admin:favorite_list")
609
+
610
+
611
+ def favorite_clear(request):
612
+ Favorite.objects.filter(user=request.user).delete()
613
+ return redirect("admin:favorite_list")
614
+
615
+
616
+ def log_viewer(request):
617
+ logs_dir = Path(settings.BASE_DIR) / "logs"
618
+ logs_exist = logs_dir.exists() and logs_dir.is_dir()
619
+ available_logs = []
620
+ if logs_exist:
621
+ available_logs = sorted(
622
+ [
623
+ entry.name
624
+ for entry in logs_dir.iterdir()
625
+ if entry.is_file() and not entry.name.startswith(".")
626
+ ],
627
+ key=str.lower,
628
+ )
629
+
630
+ selected_log = request.GET.get("log", "")
631
+ log_content = ""
632
+ log_error = ""
633
+
634
+ if selected_log:
635
+ if selected_log in available_logs:
636
+ selected_path = logs_dir / selected_log
637
+ try:
638
+ log_content = selected_path.read_text(encoding="utf-8")
639
+ except UnicodeDecodeError:
640
+ log_content = selected_path.read_text(
641
+ encoding="utf-8", errors="replace"
642
+ )
643
+ except OSError as exc: # pragma: no cover - filesystem edge cases
644
+ logger.warning("Unable to read log file %s", selected_path, exc_info=exc)
645
+ log_error = _(
646
+ "The log file could not be read. Check server permissions and try again."
647
+ )
648
+ else:
649
+ log_error = _("The requested log could not be found.")
650
+
651
+ if not logs_exist:
652
+ log_notice = _("The logs directory could not be found at %(path)s.") % {
653
+ "path": logs_dir,
654
+ }
655
+ elif not available_logs:
656
+ log_notice = _("No log files were found in %(path)s.") % {"path": logs_dir}
657
+ else:
658
+ log_notice = ""
659
+
660
+ context = {**admin.site.each_context(request)}
661
+ context.update(
662
+ {
663
+ "title": _("Log viewer"),
664
+ "available_logs": available_logs,
665
+ "selected_log": selected_log,
666
+ "log_content": log_content,
667
+ "log_error": log_error,
668
+ "log_notice": log_notice,
669
+ "logs_directory": logs_dir,
670
+ }
671
+ )
672
+ return TemplateResponse(request, "admin/log_viewer.html", context)
673
+
674
+
675
+ def get_admin_urls(original_get_urls):
676
+ def get_urls():
677
+ urls = original_get_urls()
678
+ my_urls = [
679
+ path(
680
+ "logs/viewer/",
681
+ admin.site.admin_view(log_viewer),
682
+ name="log_viewer",
683
+ ),
684
+ path(
685
+ "favorites/<int:ct_id>/",
686
+ admin.site.admin_view(favorite_toggle),
687
+ name="favorite_toggle",
688
+ ),
689
+ path(
690
+ "favorites/", admin.site.admin_view(favorite_list), name="favorite_list"
691
+ ),
692
+ path(
693
+ "favorites/delete/<int:pk>/",
694
+ admin.site.admin_view(favorite_delete),
695
+ name="favorite_delete",
696
+ ),
697
+ path(
698
+ "favorites/clear/",
699
+ admin.site.admin_view(favorite_clear),
700
+ name="favorite_clear",
701
+ ),
702
+ ]
703
+ return my_urls + original_get_urls()
704
+
705
+ return get_urls
706
+
707
+
708
+ admin.site.get_urls = get_admin_urls(admin.site.get_urls)