commentail 0.1.0__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.
Files changed (91) hide show
  1. commentail/__init__.py +1 -0
  2. commentail/admin_urls.py +28 -0
  3. commentail/admin_views.py +660 -0
  4. commentail/admission.py +201 -0
  5. commentail/apps.py +19 -0
  6. commentail/assets.py +21 -0
  7. commentail/avatars.py +80 -0
  8. commentail/checks.py +8 -0
  9. commentail/config.py +193 -0
  10. commentail/detection.py +89 -0
  11. commentail/drafts.py +95 -0
  12. commentail/forms.py +138 -0
  13. commentail/group_permissions.py +110 -0
  14. commentail/limits.py +96 -0
  15. commentail/locale/ru/LC_MESSAGES/django.mo +0 -0
  16. commentail/locale/ru/LC_MESSAGES/django.po +825 -0
  17. commentail/logging.py +24 -0
  18. commentail/management/__init__.py +0 -0
  19. commentail/management/commands/__init__.py +0 -0
  20. commentail/management/commands/process_commentail.py +24 -0
  21. commentail/middleware.py +27 -0
  22. commentail/migrations/0001_initial.py +408 -0
  23. commentail/migrations/0002_sitegrouppermission_hide_sitegrouppermission_view.py +24 -0
  24. commentail/migrations/0003_actiontoken_captchachallenge_guestgrant.py +104 -0
  25. commentail/migrations/0004_submissionrecord.py +55 -0
  26. commentail/migrations/0005_rateevent.py +48 -0
  27. commentail/migrations/0006_alter_comment_guest_name_and_more.py +24 -0
  28. commentail/migrations/0007_actiontoken_nonce_replysubscription_mailjob.py +108 -0
  29. commentail/migrations/0008_mailjob_revision.py +25 -0
  30. commentail/migrations/0009_formticket.py +39 -0
  31. commentail/migrations/0010_captchachallenge_backend_and_more.py +25 -0
  32. commentail/migrations/0011_discussion_content_type_discussion_object_id_and_more.py +68 -0
  33. commentail/migrations/0012_formticket_draft.py +21 -0
  34. commentail/migrations/0013_sitecommentsettings_email_enabled.py +21 -0
  35. commentail/migrations/__init__.py +0 -0
  36. commentail/models.py +344 -0
  37. commentail/objects.py +266 -0
  38. commentail/outbox.py +526 -0
  39. commentail/page_integration.py +137 -0
  40. commentail/panels.py +15 -0
  41. commentail/permissions.py +31 -0
  42. commentail/policies.py +80 -0
  43. commentail/public.py +719 -0
  44. commentail/rules_csv.py +164 -0
  45. commentail/security.py +290 -0
  46. commentail/services.py +487 -0
  47. commentail/signals.py +27 -0
  48. commentail/static/commentail/admin.css +62 -0
  49. commentail/static/commentail/admin.js +27 -0
  50. commentail/static/commentail/commentail.css +198 -0
  51. commentail/static/commentail/commentail.js +322 -0
  52. commentail/structured.py +95 -0
  53. commentail/templates/commentail/action.html +23 -0
  54. commentail/templates/commentail/admin/detail.html +5 -0
  55. commentail/templates/commentail/admin/form.html +13 -0
  56. commentail/templates/commentail/admin/group_permission_row.html +4 -0
  57. commentail/templates/commentail/admin/group_permissions.html +13 -0
  58. commentail/templates/commentail/admin/list.html +9 -0
  59. commentail/templates/commentail/admin/mail.html +9 -0
  60. commentail/templates/commentail/admin/objects.html +8 -0
  61. commentail/templates/commentail/admin/page_panel.html +1 -0
  62. commentail/templates/commentail/admin/rules.html +25 -0
  63. commentail/templates/commentail/admin/rules_import.html +24 -0
  64. commentail/templates/commentail/base.html +23 -0
  65. commentail/templates/commentail/block.html +1 -0
  66. commentail/templates/commentail/captcha.html +1 -0
  67. commentail/templates/commentail/comment.html +1 -0
  68. commentail/templates/commentail/edit-form.html +13 -0
  69. commentail/templates/commentail/emails/confirm.txt +7 -0
  70. commentail/templates/commentail/emails/confirm_subject.txt +1 -0
  71. commentail/templates/commentail/emails/edit.txt +5 -0
  72. commentail/templates/commentail/emails/edit_subject.txt +1 -0
  73. commentail/templates/commentail/emails/published.txt +3 -0
  74. commentail/templates/commentail/emails/published_subject.txt +1 -0
  75. commentail/templates/commentail/emails/rejected.txt +5 -0
  76. commentail/templates/commentail/emails/rejected_subject.txt +1 -0
  77. commentail/templates/commentail/emails/reply.txt +6 -0
  78. commentail/templates/commentail/emails/reply_subject.txt +1 -0
  79. commentail/templates/commentail/form.html +1 -0
  80. commentail/templates/commentail/replies.html +1 -0
  81. commentail/templatetags/__init__.py +0 -0
  82. commentail/templatetags/commentail_tags.py +80 -0
  83. commentail/text.py +7 -0
  84. commentail/urls.py +15 -0
  85. commentail/views.py +159 -0
  86. commentail/wagtail_hooks.py +44 -0
  87. commentail-0.1.0.dist-info/METADATA +201 -0
  88. commentail-0.1.0.dist-info/RECORD +91 -0
  89. commentail-0.1.0.dist-info/WHEEL +5 -0
  90. commentail-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. commentail-0.1.0.dist-info/top_level.txt +1 -0
commentail/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Commentail: a Wagtail comments application under active development."""
@@ -0,0 +1,28 @@
1
+ from django.urls import path
2
+
3
+ from . import admin_views as views
4
+
5
+ app_name = "commentail_admin"
6
+ urlpatterns = [
7
+ path("objects/", views.objects_list, name="objects"),
8
+ path("objects/<int:identifier>/", views.object_settings, name="object_settings"),
9
+ path("", views.index, name="list"),
10
+ path("bulk/", views.bulk, name="bulk"),
11
+ path("rules/", views.rules, name="rules"),
12
+ path("rules/import/", views.rules_import, name="rules_import"),
13
+ path("rules/export/", views.rules_export, name="rules_export"),
14
+ path("rules/add/", views.rule_edit, name="rule_add"),
15
+ path("rules/<int:identifier>/", views.rule_edit, name="rule_edit"),
16
+ path("access/", views.access, name="access"),
17
+ path("settings/<int:site_id>/", views.settings_view, name="settings"),
18
+ path(
19
+ "settings/<int:site_id>/<int:page_id>/",
20
+ views.settings_view,
21
+ name="page_settings",
22
+ ),
23
+ path("mail/", views.mail_queue, name="mail"),
24
+ path("mail/<int:identifier>/retry/", views.retry_mail, name="retry"),
25
+ path("<int:identifier>/", views.detail, name="detail"),
26
+ path("<int:identifier>/action/", views.action, name="action"),
27
+ path("<int:identifier>/revision/", views.revision_action, name="revision"),
28
+ ]
@@ -0,0 +1,660 @@
1
+ """Wagtail moderation views; all object queries are bounded by Site rights."""
2
+
3
+ from difflib import SequenceMatcher
4
+
5
+ from django.contrib.auth import get_user_model
6
+ from django.core.exceptions import PermissionDenied, ValidationError
7
+ from django.core.paginator import Paginator
8
+ from django.db import transaction
9
+ from django.db.models import Exists, F, OuterRef, Q, QuerySet
10
+ from django.http import HttpRequest, HttpResponse, StreamingHttpResponse
11
+ from django.shortcuts import get_object_or_404, redirect
12
+ from django.template.response import TemplateResponse
13
+ from django.utils import timezone
14
+ from django.utils.dateparse import parse_date
15
+ from django.utils.html import format_html, format_html_join
16
+ from django.utils.safestring import SafeString
17
+ from django.utils.translation import gettext as _
18
+ from django.views.decorators.http import require_POST
19
+ from wagtail.admin import messages
20
+ from wagtail.models import Page, Site
21
+
22
+ from .forms import (
23
+ DiscussionSettingsForm,
24
+ RuleCsvImportForm,
25
+ RuleForm,
26
+ SitePermissionForm,
27
+ SiteSettingsForm,
28
+ )
29
+ from .models import (
30
+ Comment,
31
+ CommentRevision,
32
+ Discussion,
33
+ MailJob,
34
+ ModerationRule,
35
+ SiteCommentSettings,
36
+ )
37
+ from .outbox import requeue
38
+ from .permissions import Capability, can, sites_for
39
+ from .rules_csv import export_rules, import_rules
40
+ from .services import DomainError, hide, moderate, moderate_revision, remove, restore
41
+
42
+
43
+ def _comments(request: HttpRequest) -> QuerySet[Comment]:
44
+ return Comment.objects.filter(
45
+ discussion__site__in=sites_for(request.user, "view")
46
+ ).select_related("discussion__site", "discussion__page", "user")
47
+
48
+
49
+ def index(request: HttpRequest) -> HttpResponse:
50
+ sites = sites_for(request.user, "view")
51
+ settings_sites = sites_for(request.user, "settings")
52
+ if (
53
+ not sites.exists()
54
+ and not settings_sites.exists()
55
+ and not sites_for(request.user, "rules").exists()
56
+ ):
57
+ raise PermissionDenied
58
+ query = _comments(request).annotate(
59
+ pending_revision=Exists(
60
+ CommentRevision.objects.filter(comment_id=OuterRef("pk"), status="pending")
61
+ )
62
+ )
63
+ if value := request.GET.get("site"):
64
+ query = (
65
+ query.filter(discussion__site_id=value)
66
+ if value.isdecimal()
67
+ else query.none()
68
+ )
69
+ if value := request.GET.get("page"):
70
+ query = (
71
+ query.filter(discussion__page_id=value)
72
+ if value.isdecimal()
73
+ else query.none()
74
+ )
75
+ active_status = request.GET.get("status", "pending")
76
+ if value := request.GET.get("q"):
77
+ names = Q()
78
+ for field in (get_user_model().USERNAME_FIELD, "first_name", "last_name"):
79
+ if field in {item.name for item in get_user_model()._meta.fields}:
80
+ names |= Q(**{f"user__{field}__icontains": value})
81
+ query = query.filter(
82
+ names
83
+ | Q(text__icontains=value)
84
+ | Q(guest_name__icontains=value)
85
+ | Q(email__icontains=value)
86
+ | Q(user__email__icontains=value)
87
+ )
88
+ for key, lookup in [
89
+ ("from", "created_at__date__gte"),
90
+ ("to", "created_at__date__lte"),
91
+ ]:
92
+ if value := request.GET.get(key):
93
+ try:
94
+ day = parse_date(value)
95
+ except ValueError:
96
+ day = None
97
+ query = query.filter(**{lookup: day}) if day else query.none()
98
+ status_tabs: list[dict[str, object]] = []
99
+ for value, label in [
100
+ ("pending", _("Pending")),
101
+ ("published", _("Published")),
102
+ ("rejected", _("Rejected")),
103
+ ("", _("All statuses")),
104
+ ]:
105
+ subset = (
106
+ query.filter(Q(status="pending") | Q(pending_revision=True))
107
+ if value == "pending"
108
+ else query.filter(status=value)
109
+ if value
110
+ else query
111
+ )
112
+ parameters = request.GET.copy()
113
+ parameters["status"] = value
114
+ parameters.pop("p", None)
115
+ status_tabs.append(
116
+ {
117
+ "label": label,
118
+ "count": subset.count(),
119
+ "query": parameters.urlencode(),
120
+ "active": active_status == value,
121
+ }
122
+ )
123
+ if active_status == "pending":
124
+ query = query.filter(Q(status="pending") | Q(pending_revision=True))
125
+ elif active_status:
126
+ query = query.filter(status=active_status)
127
+ page = Paginator(query.order_by("-created_at", "-pk"), 30).get_page(
128
+ request.GET.get("p")
129
+ )
130
+ moderate_sites = set(
131
+ sites_for(request.user, "moderate").values_list("pk", flat=True)
132
+ )
133
+ for comment in page:
134
+ comment.can_select = (
135
+ comment.status == "pending" and comment.discussion.site_id in moderate_sites
136
+ )
137
+ previous = request.GET.copy()
138
+ previous["p"] = str(max(1, page.number - 1))
139
+ following = request.GET.copy()
140
+ following["p"] = str(page.number + 1)
141
+ return TemplateResponse(
142
+ request,
143
+ "commentail/admin/list.html",
144
+ {
145
+ "comments": page,
146
+ "sites": sites,
147
+ "settings_sites": settings_sites,
148
+ "statuses": Comment.Status.choices,
149
+ "active_status": active_status,
150
+ "status_tabs": status_tabs,
151
+ "pages": Page.objects.filter(discussion__site__in=sites)
152
+ .distinct()
153
+ .order_by("title"),
154
+ "mail_allowed": sites.exists(),
155
+ "rules_allowed": sites_for(request.user, "rules").exists(),
156
+ "access_allowed": request.user.is_superuser,
157
+ "can_bulk": sites_for(request.user, "moderate").exists(),
158
+ "previous_query": previous.urlencode(),
159
+ "next_query": following.urlencode(),
160
+ },
161
+ )
162
+
163
+
164
+ def _diff(original: str, proposed: str) -> tuple[SafeString, SafeString]:
165
+ old: list[SafeString] = []
166
+ new: list[SafeString] = []
167
+ # Limit pathological diff work; full texts are still safely visible.
168
+ matcher = SequenceMatcher(
169
+ None, original.splitlines(keepends=True), proposed.splitlines(keepends=True)
170
+ )
171
+ for operation, a, b, c, d in matcher.get_opcodes():
172
+ old.append(
173
+ format_html(
174
+ "<del>{}</del>" if operation != "equal" else "{}",
175
+ "".join(matcher.a[a:b]),
176
+ )
177
+ )
178
+ new.append(
179
+ format_html(
180
+ "<ins>{}</ins>" if operation != "equal" else "{}",
181
+ "".join(matcher.b[c:d]),
182
+ )
183
+ )
184
+ return format_html_join("", "{}", ((part,) for part in old)), format_html_join(
185
+ "", "{}", ((part,) for part in new)
186
+ )
187
+
188
+
189
+ def _mail_retry_allowed(job: MailJob, moderate_sites: set[int]) -> bool:
190
+ return (
191
+ job.status == "failed"
192
+ and job.expires_at > timezone.now()
193
+ and job.comment.discussion.site_id in moderate_sites
194
+ and job.recipient.discussion.site_id in moderate_sites
195
+ )
196
+
197
+
198
+ def detail(request: HttpRequest, identifier: int) -> HttpResponse:
199
+ comment = get_object_or_404(_comments(request), pk=identifier)
200
+ revision = comment.revisions.filter(status="pending").first()
201
+ old, proposed = _diff(comment.text, revision.text) if revision else ("", "")
202
+ site_id = comment.discussion.site_id
203
+ jobs = MailJob.objects.filter(
204
+ recipient=comment,
205
+ status="failed",
206
+ comment__discussion__site__in=sites_for(request.user, "view"),
207
+ ).select_related("comment__discussion", "recipient__discussion")
208
+ moderate_sites = set(
209
+ sites_for(request.user, "moderate").values_list("pk", flat=True)
210
+ )
211
+ for job in jobs:
212
+ job.can_retry = _mail_retry_allowed(job, moderate_sites)
213
+ return TemplateResponse(
214
+ request,
215
+ "commentail/admin/detail.html",
216
+ {
217
+ "comment": comment,
218
+ "revision": revision,
219
+ "old": old,
220
+ "proposed": proposed,
221
+ "can_moderate": can(request.user, site_id, "moderate"),
222
+ "can_configure": can(request.user, site_id, "settings"),
223
+ "can_hide": can(request.user, site_id, "hide"),
224
+ "can_delete": can(request.user, site_id, "delete"),
225
+ "events": comment.events.select_related("actor").order_by("-created_at")[
226
+ :100
227
+ ],
228
+ "jobs": jobs,
229
+ },
230
+ )
231
+
232
+
233
+ def _mutate(request: HttpRequest, comment: Comment) -> None:
234
+ action = request.POST.get("action", "")
235
+ capabilities: dict[str, Capability] = {
236
+ "approve": "moderate",
237
+ "reject": "moderate",
238
+ "hide": "hide",
239
+ "restore": "hide",
240
+ "delete": "delete",
241
+ }
242
+ capability = capabilities.get(action)
243
+ if capability is None:
244
+ raise DomainError("Unknown action")
245
+ if not can(request.user, comment.discussion.site_id, capability):
246
+ raise PermissionDenied
247
+ if action in {"approve", "reject"}:
248
+ moderate(
249
+ comment.pk,
250
+ action,
251
+ actor=request.user,
252
+ reason=request.POST.get("reason", ""),
253
+ )
254
+ else:
255
+ {"hide": hide, "restore": restore, "delete": remove}[action](
256
+ comment.pk, actor=request.user
257
+ )
258
+
259
+
260
+ @require_POST
261
+ def action(request: HttpRequest, identifier: int) -> HttpResponse:
262
+ comment = get_object_or_404(_comments(request), pk=identifier)
263
+ try:
264
+ _mutate(request, comment)
265
+ except PermissionDenied:
266
+ return HttpResponse("Permission denied", status=403)
267
+ except DomainError as exc:
268
+ return HttpResponse(str(exc), status=400)
269
+ comment.refresh_from_db(fields=["status"])
270
+ messages.success(
271
+ request,
272
+ f"{_('Comment')} #{comment.pk}: {comment.get_status_display()}",
273
+ extra_tags="commentail-action-result",
274
+ )
275
+ return redirect("commentail_admin:detail", identifier=identifier)
276
+
277
+
278
+ @require_POST
279
+ @transaction.atomic
280
+ def bulk(request: HttpRequest) -> HttpResponse:
281
+ identifiers = request.POST.getlist("ids")
282
+ if (
283
+ not identifiers
284
+ or len(identifiers) > 100
285
+ or not all(value.isdecimal() for value in identifiers)
286
+ or request.POST.get("action") not in {"approve", "reject"}
287
+ ):
288
+ return HttpResponse("Invalid selection", status=400)
289
+ try:
290
+ # Acquire the writer lock before dependent reads on SQLite as well.
291
+ Comment.objects.filter(pk__in=identifiers).update(edit_locked=F("edit_locked"))
292
+ for identifier in sorted(set(identifiers), key=int):
293
+ comment = get_object_or_404(_comments(request), pk=identifier)
294
+ _mutate(request, comment)
295
+ except (DomainError, PermissionDenied) as exc:
296
+ transaction.set_rollback(True)
297
+ return HttpResponse(
298
+ str(exc), status=403 if isinstance(exc, PermissionDenied) else 400
299
+ )
300
+ outcome = _("Approved") if request.POST["action"] == "approve" else _("Rejected")
301
+ messages.success(
302
+ request,
303
+ f"{outcome}: {len(set(identifiers))}",
304
+ extra_tags="commentail-action-result",
305
+ )
306
+ return redirect("commentail_admin:list")
307
+
308
+
309
+ @require_POST
310
+ def revision_action(request: HttpRequest, identifier: int) -> HttpResponse:
311
+ if not request.POST.get("revision", "").isdecimal() or request.POST.get(
312
+ "action"
313
+ ) not in {"approve", "reject"}:
314
+ return HttpResponse("Invalid revision action", status=400)
315
+ comment = get_object_or_404(_comments(request), pk=identifier)
316
+ if not can(request.user, comment.discussion.site_id, "moderate"):
317
+ raise PermissionDenied
318
+ revision = get_object_or_404(
319
+ comment.revisions, pk=request.POST.get("revision"), status="pending"
320
+ )
321
+ try:
322
+ moderate_revision(
323
+ revision.pk,
324
+ request.POST.get("action"),
325
+ actor=request.user,
326
+ reason=request.POST.get("reason", ""),
327
+ )
328
+ except DomainError as exc:
329
+ return HttpResponse(str(exc), status=400)
330
+ revision.refresh_from_db(fields=["status"])
331
+ messages.success(
332
+ request,
333
+ f"{_('Author revision')}: {revision.get_status_display()}",
334
+ extra_tags="commentail-action-result",
335
+ )
336
+ return redirect("commentail_admin:detail", identifier=identifier)
337
+
338
+
339
+ def settings_view(
340
+ request: HttpRequest, site_id: int, page_id: int | None = None
341
+ ) -> HttpResponse:
342
+ if not can(request.user, site_id, "settings"):
343
+ return HttpResponse("Permission denied", status=403)
344
+ site = get_object_or_404(Site, pk=site_id)
345
+ if page_id is None:
346
+ instance = SiteCommentSettings.objects.filter(
347
+ site=site
348
+ ).first() or SiteCommentSettings(site=site)
349
+ form_class = SiteSettingsForm
350
+ else:
351
+ from wagtail.models import Page
352
+
353
+ page = get_object_or_404(Page, pk=page_id).specific
354
+ if not page.permissions_for_user(request.user).can_edit():
355
+ raise PermissionDenied
356
+ from .detection import has_comments
357
+
358
+ if not has_comments(page, request):
359
+ raise PermissionDenied
360
+ instance = Discussion.objects.filter(
361
+ site=site, page=page
362
+ ).first() or Discussion(site=site, page=page)
363
+ form_class = DiscussionSettingsForm
364
+ form = form_class(
365
+ request.POST if request.method == "POST" else None, instance=instance
366
+ )
367
+ if request.method == "POST" and form.is_valid():
368
+ form.save()
369
+ return redirect(request.path)
370
+ return TemplateResponse(
371
+ request,
372
+ "commentail/admin/form.html",
373
+ {
374
+ "form": form,
375
+ "title": _("Discussion settings") if page_id else _("Site settings"),
376
+ "site": site,
377
+ },
378
+ )
379
+
380
+
381
+ def rules(request: HttpRequest) -> HttpResponse:
382
+ """List project-wide rules with bounded results and persistent filters."""
383
+ if not sites_for(request.user, "rules").exists():
384
+ raise PermissionDenied
385
+ if identifier := request.GET.get("id"):
386
+ if not identifier.isdecimal():
387
+ return HttpResponse("Invalid rule identifier", status=400)
388
+ rule = get_object_or_404(ModerationRule, pk=identifier)
389
+ return redirect("commentail_admin:rule_edit", identifier=rule.pk)
390
+ query = ModerationRule.objects.all()
391
+ search = request.GET.get("q", "").strip()
392
+ action = request.GET.get("action", "")
393
+ enabled = request.GET.get("enabled", "")
394
+ if search:
395
+ query = query.filter(phrase__icontains=search)
396
+ if action in ModerationRule.Action.values:
397
+ query = query.filter(action=action)
398
+ if enabled in ("0", "1"):
399
+ query = query.filter(enabled=enabled == "1")
400
+ page = Paginator(query.order_by("phrase", "pk"), 25).get_page(request.GET.get("p"))
401
+ previous = request.GET.copy()
402
+ previous["p"] = str(max(1, page.number - 1))
403
+ following = request.GET.copy()
404
+ following["p"] = str(page.number + 1)
405
+ return TemplateResponse(
406
+ request,
407
+ "commentail/admin/rules.html",
408
+ {
409
+ "rules": page,
410
+ "actions": ModerationRule.Action.choices,
411
+ "search": search,
412
+ "active_action": action,
413
+ "active_enabled": enabled,
414
+ "previous_query": previous.urlencode(),
415
+ "next_query": following.urlencode(),
416
+ },
417
+ )
418
+
419
+
420
+ def rule_edit(request: HttpRequest, identifier: int | None = None) -> HttpResponse:
421
+ if not sites_for(request.user, "rules").exists():
422
+ raise PermissionDenied
423
+ instance = get_object_or_404(ModerationRule, pk=identifier) if identifier else None
424
+ form = RuleForm(
425
+ request.POST if request.method == "POST" else None, instance=instance
426
+ )
427
+ if request.method == "POST" and form.is_valid():
428
+ form.save()
429
+ return redirect("commentail_admin:rules")
430
+ return TemplateResponse(
431
+ request,
432
+ "commentail/admin/form.html",
433
+ {
434
+ "form": form,
435
+ "title": _("Edit rule") if instance else _("Add rule"),
436
+ "back_url": "commentail_admin:rules",
437
+ "back_label": _("Moderation rules"),
438
+ },
439
+ )
440
+
441
+
442
+ def access(request: HttpRequest) -> HttpResponse:
443
+ if not request.user.is_superuser:
444
+ raise PermissionDenied
445
+ from .models import SiteGroupPermission
446
+
447
+ if request.GET.get("id") and not request.GET["id"].isdecimal():
448
+ return HttpResponse("Invalid permission identifier", status=400)
449
+
450
+ instance = (
451
+ get_object_or_404(SiteGroupPermission, pk=request.GET["id"])
452
+ if request.GET.get("id")
453
+ else None
454
+ )
455
+ form = SitePermissionForm(
456
+ request.POST if request.method == "POST" else None, instance=instance
457
+ )
458
+ if request.method == "POST" and form.is_valid():
459
+ form.save()
460
+ return redirect("commentail_admin:access")
461
+ return TemplateResponse(
462
+ request,
463
+ "commentail/admin/form.html",
464
+ {
465
+ "form": form,
466
+ "title": _("Group access by site"),
467
+ "permissions": SiteGroupPermission.objects.select_related("group", "site"),
468
+ },
469
+ )
470
+
471
+
472
+ @require_POST
473
+ def retry_mail(request: HttpRequest, identifier: int) -> HttpResponse:
474
+ job = get_object_or_404(
475
+ MailJob.objects.filter(
476
+ recipient__discussion__site__in=sites_for(request.user, "view"),
477
+ comment__discussion__site__in=sites_for(request.user, "view"),
478
+ ),
479
+ pk=identifier,
480
+ )
481
+ try:
482
+ requeue(job.pk, actor=request.user)
483
+ except DomainError as exc:
484
+ return HttpResponse(str(exc), status=400)
485
+ if request.POST.get("return_to") == "mail":
486
+ return redirect("commentail_admin:mail")
487
+ return redirect("commentail_admin:detail", identifier=job.recipient_id)
488
+
489
+
490
+ def rules_export(request: HttpRequest) -> StreamingHttpResponse:
491
+ if not sites_for(request.user, "rules").exists():
492
+ raise PermissionDenied
493
+ return StreamingHttpResponse(
494
+ export_rules(),
495
+ content_type="text/csv; charset=utf-8",
496
+ headers={"Content-Disposition": 'attachment; filename="commentail-rules.csv"'},
497
+ )
498
+
499
+
500
+ def rules_import(request: HttpRequest) -> HttpResponse:
501
+ if not sites_for(request.user, "rules").exists():
502
+ raise PermissionDenied
503
+ form = RuleCsvImportForm(
504
+ request.POST if request.method == "POST" else None,
505
+ request.FILES if request.method == "POST" else None,
506
+ )
507
+ if request.method == "POST" and form.is_valid():
508
+ try:
509
+ result = import_rules(form.cleaned_data["file"])
510
+ except ValidationError as error:
511
+ form.add_error("file", error)
512
+ else:
513
+ messages.success(
514
+ request,
515
+ _(
516
+ "CSV imported: %(added)s added, %(updated)s updated, %(unchanged)s unchanged."
517
+ )
518
+ % {
519
+ "added": result.added,
520
+ "updated": result.updated,
521
+ "unchanged": result.unchanged,
522
+ },
523
+ )
524
+ return redirect("commentail_admin:rules")
525
+ return TemplateResponse(
526
+ request, "commentail/admin/rules_import.html", {"form": form}
527
+ )
528
+
529
+
530
+ def mail_queue(request: HttpRequest) -> HttpResponse:
531
+ """Inspect delivery metadata only within both source and recipient Site grants."""
532
+ sites = sites_for(request.user, "view")
533
+ if not sites.exists():
534
+ raise PermissionDenied
535
+ statuses = (
536
+ ("failed", _("Failed")),
537
+ ("pending", _("Pending delivery")),
538
+ ("sending", _("Sending")),
539
+ ("sent", _("Sent")),
540
+ ("cancelled", _("Cancelled")),
541
+ )
542
+ active_status = request.GET.get("status", "failed")
543
+ query = MailJob.objects.filter(
544
+ comment__discussion__site__in=sites, recipient__discussion__site__in=sites
545
+ ).select_related(
546
+ "recipient__discussion__site",
547
+ "recipient__discussion__page",
548
+ "recipient__user",
549
+ "comment__discussion__site",
550
+ )
551
+ if active_status in dict(statuses):
552
+ query = query.filter(status=active_status)
553
+ selected_site = request.GET.get("site", "")
554
+ if selected_site:
555
+ query = (
556
+ query.filter(recipient__discussion__site_id=selected_site)
557
+ if selected_site.isdecimal()
558
+ else query.none()
559
+ )
560
+ page = Paginator(query.order_by("-created_at", "-pk"), 25).get_page(
561
+ request.GET.get("p")
562
+ )
563
+ moderate_sites = set(
564
+ sites_for(request.user, "moderate").values_list("pk", flat=True)
565
+ )
566
+ kinds = {
567
+ "confirm": _("Email confirmation"),
568
+ "edit": _("Guest edit link"),
569
+ "reply": _("Reply notification"),
570
+ "published": _("Publication notification"),
571
+ "rejected": _("Rejection notification"),
572
+ }
573
+ for job in page:
574
+ job.can_retry = _mail_retry_allowed(job, moderate_sites)
575
+ job.status_label = dict(statuses).get(job.status, job.status)
576
+ job.kind_label = kinds.get(job.kind, job.kind)
577
+ previous = request.GET.copy()
578
+ previous["p"] = str(max(1, page.number - 1))
579
+ following = request.GET.copy()
580
+ following["p"] = str(page.number + 1)
581
+ return TemplateResponse(
582
+ request,
583
+ "commentail/admin/mail.html",
584
+ {
585
+ "jobs": page,
586
+ "sites": sites,
587
+ "statuses": statuses,
588
+ "active_status": active_status,
589
+ "previous_query": previous.urlencode(),
590
+ "next_query": following.urlencode(),
591
+ },
592
+ )
593
+
594
+
595
+ def objects_list(request: HttpRequest) -> HttpResponse:
596
+ """Existing non-Page discussions; Site settings grants bound every row."""
597
+ sites = sites_for(request.user, "settings")
598
+ if not sites.exists():
599
+ raise PermissionDenied
600
+ query = Discussion.objects.filter(site__in=sites, page__isnull=True).select_related(
601
+ "site", "content_type"
602
+ )
603
+ if value := request.GET.get("q", "").strip():
604
+ query = query.filter(
605
+ Q(object_id__icontains=value) | Q(content_type__model__icontains=value)
606
+ )
607
+ if value := request.GET.get("site", ""):
608
+ query = query.filter(site_id=value) if value.isdecimal() else query.none()
609
+ page = Paginator(
610
+ query.order_by("site_id", "content_type_id", "object_id", "pk"), 25
611
+ ).get_page(request.GET.get("p"))
612
+ previous = request.GET.copy()
613
+ previous["p"] = str(max(1, page.number - 1))
614
+ following = request.GET.copy()
615
+ following["p"] = str(page.number + 1)
616
+ return TemplateResponse(
617
+ request,
618
+ "commentail/admin/objects.html",
619
+ {
620
+ "discussions": page,
621
+ "sites": sites,
622
+ "previous_query": previous.urlencode(),
623
+ "next_query": following.urlencode(),
624
+ },
625
+ )
626
+
627
+
628
+ def object_settings(request: HttpRequest, identifier: int) -> HttpResponse:
629
+ discussion = get_object_or_404(
630
+ Discussion.objects.select_related("site", "content_type"),
631
+ pk=identifier,
632
+ page__isnull=True,
633
+ )
634
+ if not can(request.user, discussion.site_id, "settings"):
635
+ return HttpResponse(_("Permission denied"), status=403)
636
+ # Administrative Site grant controls settings, independently of visitor access.
637
+ # Unavailable adapters must preserve stored settings and show a neutral error.
638
+ from .objects import ObjectAccessError, target_for
639
+
640
+ try:
641
+ target = target_for(discussion)
642
+ except ObjectAccessError:
643
+ return HttpResponse(_("Comments are unavailable."), status=503)
644
+ form = DiscussionSettingsForm(
645
+ request.POST if request.method == "POST" else None, instance=discussion
646
+ )
647
+ if request.method == "POST" and form.is_valid():
648
+ form.save()
649
+ return redirect(request.path)
650
+ return TemplateResponse(
651
+ request,
652
+ "commentail/admin/form.html",
653
+ {
654
+ "form": form,
655
+ "title": _("Discussion settings"),
656
+ "site": discussion.site,
657
+ "target_label": str(target),
658
+ "objects_back": True,
659
+ },
660
+ )