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.
- commentail/__init__.py +1 -0
- commentail/admin_urls.py +28 -0
- commentail/admin_views.py +660 -0
- commentail/admission.py +201 -0
- commentail/apps.py +19 -0
- commentail/assets.py +21 -0
- commentail/avatars.py +80 -0
- commentail/checks.py +8 -0
- commentail/config.py +193 -0
- commentail/detection.py +89 -0
- commentail/drafts.py +95 -0
- commentail/forms.py +138 -0
- commentail/group_permissions.py +110 -0
- commentail/limits.py +96 -0
- commentail/locale/ru/LC_MESSAGES/django.mo +0 -0
- commentail/locale/ru/LC_MESSAGES/django.po +825 -0
- commentail/logging.py +24 -0
- commentail/management/__init__.py +0 -0
- commentail/management/commands/__init__.py +0 -0
- commentail/management/commands/process_commentail.py +24 -0
- commentail/middleware.py +27 -0
- commentail/migrations/0001_initial.py +408 -0
- commentail/migrations/0002_sitegrouppermission_hide_sitegrouppermission_view.py +24 -0
- commentail/migrations/0003_actiontoken_captchachallenge_guestgrant.py +104 -0
- commentail/migrations/0004_submissionrecord.py +55 -0
- commentail/migrations/0005_rateevent.py +48 -0
- commentail/migrations/0006_alter_comment_guest_name_and_more.py +24 -0
- commentail/migrations/0007_actiontoken_nonce_replysubscription_mailjob.py +108 -0
- commentail/migrations/0008_mailjob_revision.py +25 -0
- commentail/migrations/0009_formticket.py +39 -0
- commentail/migrations/0010_captchachallenge_backend_and_more.py +25 -0
- commentail/migrations/0011_discussion_content_type_discussion_object_id_and_more.py +68 -0
- commentail/migrations/0012_formticket_draft.py +21 -0
- commentail/migrations/0013_sitecommentsettings_email_enabled.py +21 -0
- commentail/migrations/__init__.py +0 -0
- commentail/models.py +344 -0
- commentail/objects.py +266 -0
- commentail/outbox.py +526 -0
- commentail/page_integration.py +137 -0
- commentail/panels.py +15 -0
- commentail/permissions.py +31 -0
- commentail/policies.py +80 -0
- commentail/public.py +719 -0
- commentail/rules_csv.py +164 -0
- commentail/security.py +290 -0
- commentail/services.py +487 -0
- commentail/signals.py +27 -0
- commentail/static/commentail/admin.css +62 -0
- commentail/static/commentail/admin.js +27 -0
- commentail/static/commentail/commentail.css +198 -0
- commentail/static/commentail/commentail.js +322 -0
- commentail/structured.py +95 -0
- commentail/templates/commentail/action.html +23 -0
- commentail/templates/commentail/admin/detail.html +5 -0
- commentail/templates/commentail/admin/form.html +13 -0
- commentail/templates/commentail/admin/group_permission_row.html +4 -0
- commentail/templates/commentail/admin/group_permissions.html +13 -0
- commentail/templates/commentail/admin/list.html +9 -0
- commentail/templates/commentail/admin/mail.html +9 -0
- commentail/templates/commentail/admin/objects.html +8 -0
- commentail/templates/commentail/admin/page_panel.html +1 -0
- commentail/templates/commentail/admin/rules.html +25 -0
- commentail/templates/commentail/admin/rules_import.html +24 -0
- commentail/templates/commentail/base.html +23 -0
- commentail/templates/commentail/block.html +1 -0
- commentail/templates/commentail/captcha.html +1 -0
- commentail/templates/commentail/comment.html +1 -0
- commentail/templates/commentail/edit-form.html +13 -0
- commentail/templates/commentail/emails/confirm.txt +7 -0
- commentail/templates/commentail/emails/confirm_subject.txt +1 -0
- commentail/templates/commentail/emails/edit.txt +5 -0
- commentail/templates/commentail/emails/edit_subject.txt +1 -0
- commentail/templates/commentail/emails/published.txt +3 -0
- commentail/templates/commentail/emails/published_subject.txt +1 -0
- commentail/templates/commentail/emails/rejected.txt +5 -0
- commentail/templates/commentail/emails/rejected_subject.txt +1 -0
- commentail/templates/commentail/emails/reply.txt +6 -0
- commentail/templates/commentail/emails/reply_subject.txt +1 -0
- commentail/templates/commentail/form.html +1 -0
- commentail/templates/commentail/replies.html +1 -0
- commentail/templatetags/__init__.py +0 -0
- commentail/templatetags/commentail_tags.py +80 -0
- commentail/text.py +7 -0
- commentail/urls.py +15 -0
- commentail/views.py +159 -0
- commentail/wagtail_hooks.py +44 -0
- commentail-0.1.0.dist-info/METADATA +201 -0
- commentail-0.1.0.dist-info/RECORD +91 -0
- commentail-0.1.0.dist-info/WHEEL +5 -0
- commentail-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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."""
|
commentail/admin_urls.py
ADDED
|
@@ -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
|
+
)
|