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
@@ -0,0 +1,201 @@
1
+ """Request authorization boundary for the future server-rendered form adapter.
2
+
3
+ Do not bypass this boundary with direct calls to trusted domain services.
4
+ Views must still enforce POST and CSRF through Django middleware/decorators.
5
+ """
6
+
7
+ import secrets
8
+ from datetime import timedelta
9
+
10
+ from django.conf import settings
11
+ from django.core.mail import get_connection
12
+ from django.core.mail.backends.smtp import EmailBackend
13
+ from django.db import transaction
14
+ from django.db.models import F
15
+ from django.http import HttpRequest
16
+ from django.utils import timezone
17
+ from wagtail.models import Site
18
+
19
+ from .config import technical_settings
20
+ from .limits import client_ip, consume
21
+ from .models import Access, Comment, Discussion, FormTicket, Mode, SubmissionRecord
22
+ from .policies import editorial_settings, email_enabled
23
+ from .security import digest, remembered_guest, verify_captcha
24
+ from .services import DomainError, _text, confirm_email, submit_once
25
+
26
+
27
+ def session_digest(request: HttpRequest) -> str:
28
+ if not request.session.session_key:
29
+ request.session.create()
30
+ return digest(request.session.session_key)
31
+
32
+
33
+ def can_view(request: HttpRequest, discussion: Discussion) -> bool:
34
+ if not discussion.page_id:
35
+ from .objects import ObjectAccessError, checked_site, target_for
36
+
37
+ try:
38
+ return (
39
+ checked_site(target_for(discussion), request).pk == discussion.site_id
40
+ )
41
+ except ObjectAccessError:
42
+ return False
43
+ page = discussion.page
44
+ return page.live and all(
45
+ restriction.accept_request(request)
46
+ for restriction in page.get_view_restrictions()
47
+ )
48
+
49
+
50
+ def smtp_configured() -> bool:
51
+ return (
52
+ isinstance(get_connection(), EmailBackend)
53
+ and settings.is_overridden("EMAIL_HOST")
54
+ and bool(settings.EMAIL_HOST)
55
+ )
56
+
57
+
58
+ def issue_form(request: HttpRequest, discussion: Discussion) -> str:
59
+ if not can_view(request, discussion):
60
+ raise DomainError("Page access is required")
61
+ secret = secrets.token_urlsafe(32)
62
+ FormTicket.objects.create(
63
+ digest=digest(secret),
64
+ discussion=discussion,
65
+ session_digest=session_digest(request),
66
+ expires_at=timezone.now()
67
+ + timedelta(seconds=technical_settings(discussion.site)["FORM_TICKET_SECONDS"]),
68
+ )
69
+ return secret
70
+
71
+
72
+ def accept_request(
73
+ request: HttpRequest,
74
+ discussion_id: int,
75
+ key: str,
76
+ *,
77
+ text: str,
78
+ guest_name: str = "",
79
+ guest_email: str = "",
80
+ reply_to: int | None = None,
81
+ challenge_id: str = "",
82
+ answer: str = "",
83
+ subscribe: bool = False,
84
+ ) -> Comment:
85
+ """Validate access on every attempt, including already-accepted form replays.
86
+
87
+ Incorrect CAPTCHA attempts commit before DomainError is raised, so the
88
+ attempts counter cannot be reset by rolling back a rejected submission.
89
+ A successful CAPTCHA and accepted comment share a transaction.
90
+ """
91
+ site = Site.find_for_request(request)
92
+ if site is None:
93
+ raise DomainError("Site cannot be determined")
94
+ failure = None
95
+ result = None
96
+ with transaction.atomic():
97
+ # Lock site-wide quotas before any reads in this admission transaction.
98
+ if not Site.objects.filter(pk=site.pk).update(port=F("port")):
99
+ raise DomainError("Site does not exist")
100
+ discussion = (
101
+ Discussion.objects.select_related("site", "page")
102
+ .filter(pk=discussion_id, site=site)
103
+ .first()
104
+ )
105
+ if discussion is None or not can_view(request, discussion):
106
+ raise DomainError("Page access is required")
107
+ options = editorial_settings(discussion)
108
+ if options.mode != Mode.OPEN:
109
+ raise DomainError("Discussion is closed")
110
+ ticket = FormTicket.objects.filter(
111
+ digest=digest(key),
112
+ discussion=discussion,
113
+ session_digest=session_digest(request),
114
+ expires_at__gt=timezone.now(),
115
+ ).first()
116
+ if ticket is None:
117
+ raise DomainError("Invalid or expired form")
118
+ user = request.user if request.user.is_authenticated else None
119
+ if user is not None and not user.is_active:
120
+ raise DomainError("Account cannot comment")
121
+ if (
122
+ options.access == Access.NONE
123
+ or (user is None and options.access == Access.REGISTERED)
124
+ or (user is not None and options.access == Access.GUESTS)
125
+ ):
126
+ raise DomainError("Author cannot comment")
127
+ mail_available = email_enabled(site.pk) and smtp_configured()
128
+ subscribe = mail_available and bool(
129
+ guest_email.strip() if user is None else getattr(user, "email", "")
130
+ )
131
+ if user is None and not mail_available:
132
+ raise DomainError("Guest comments require configured SMTP")
133
+ actor = (
134
+ "user:" + str(user.pk)
135
+ if user is not None
136
+ else "guest:" + guest_email.strip().casefold()
137
+ )
138
+ if SubmissionRecord.objects.filter(
139
+ discussion=discussion, actor_digest=digest(actor), key_digest=digest(key)
140
+ ).exists():
141
+ return submit_once(
142
+ discussion.pk,
143
+ key,
144
+ text=text,
145
+ user=user,
146
+ guest_name=guest_name,
147
+ guest_email=guest_email,
148
+ reply_to=reply_to,
149
+ subscribe=subscribe,
150
+ language=getattr(request, "LANGUAGE_CODE", settings.LANGUAGE_CODE),
151
+ )
152
+ _text(text, options)
153
+ grant = (
154
+ remembered_guest(
155
+ request.COOKIES.get(f"commentail_guest_{site.pk}", ""), site
156
+ )
157
+ if user is None
158
+ else None
159
+ )
160
+ remembered = (
161
+ grant is not None
162
+ and grant.email.strip().casefold() == guest_email.strip().casefold()
163
+ )
164
+ if (
165
+ user is None
166
+ and not remembered
167
+ and not verify_captcha(request, site, challenge_id, answer)
168
+ ):
169
+ failure = "Incorrect or expired CAPTCHA"
170
+ else:
171
+ consume(site, "comment", actor, client_ip(request, site))
172
+ checks = technical_settings(site)["GUEST_REQUEST_CHECKS"]
173
+ suspicious = user is None and (
174
+ (
175
+ "missing_user_agent" in checks
176
+ and not request.META.get("HTTP_USER_AGENT", "").strip()
177
+ )
178
+ or (
179
+ "cross_site" in checks
180
+ and request.META.get("HTTP_SEC_FETCH_SITE") == "cross-site"
181
+ )
182
+ )
183
+ result = submit_once(
184
+ discussion.pk,
185
+ key,
186
+ text=text,
187
+ user=user,
188
+ guest_name=guest_name,
189
+ guest_email=guest_email,
190
+ reply_to=reply_to,
191
+ subscribe=subscribe,
192
+ suspicious=suspicious,
193
+ language=getattr(request, "LANGUAGE_CODE", settings.LANGUAGE_CODE),
194
+ )
195
+ if user is None and remembered:
196
+ result = confirm_email(result.pk)
197
+ if failure:
198
+ raise DomainError(failure)
199
+ if result is None:
200
+ raise DomainError("Submission was not accepted")
201
+ return result
commentail/apps.py ADDED
@@ -0,0 +1,19 @@
1
+ from importlib import import_module
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class CommentailConfig(AppConfig):
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+ name = "commentail"
9
+
10
+ def ready(self) -> None:
11
+ import_module("commentail.checks")
12
+ import_module("commentail.signals")
13
+ import logging
14
+
15
+ from .logging import CapabilityFilter
16
+
17
+ logger = logging.getLogger("django.server")
18
+ if not any(isinstance(item, CapabilityFilter) for item in logger.filters):
19
+ logger.addFilter(CapabilityFilter())
commentail/assets.py ADDED
@@ -0,0 +1,21 @@
1
+ """Content-versioned package resources shared by public tags and admin forms."""
2
+
3
+ from functools import lru_cache
4
+ from hashlib import sha256
5
+ from importlib.resources import files
6
+ from typing import Literal
7
+
8
+ from django.templatetags.static import static
9
+
10
+ Asset = Literal["admin.css", "admin.js", "commentail.css", "commentail.js"]
11
+
12
+
13
+ @lru_cache(maxsize=4)
14
+ def asset_url(name: Asset) -> str:
15
+ if name not in ("admin.css", "admin.js", "commentail.css", "commentail.js"):
16
+ raise ValueError("Unknown Commentail asset")
17
+ version = sha256(
18
+ files("commentail").joinpath("static", "commentail", name).read_bytes()
19
+ ).hexdigest()[:12]
20
+ url = static("commentail/" + name)
21
+ return url + ("&" if "?" in url else "?") + "v=" + version
commentail/avatars.py ADDED
@@ -0,0 +1,80 @@
1
+ """Deterministic initials/gradient and a safe read-only avatar callback."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+
6
+ from django.contrib.auth.models import AbstractBaseUser
7
+ from django.db.models import Model
8
+ from django.http import HttpRequest
9
+ from django.utils.module_loading import import_string
10
+ from wagtail.models import Site
11
+
12
+ from .config import technical_settings
13
+ from .models import Comment
14
+ from .objects import target_for
15
+
16
+ logger = logging.getLogger("commentail")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class CommentContext:
21
+ id: int
22
+ display_name: str
23
+ user: AbstractBaseUser | None
24
+ site: Site
25
+ target: Model
26
+
27
+
28
+ def default_avatar(name: str) -> tuple[str, str]:
29
+ words = name.strip().split()
30
+ initials = (
31
+ (words[0][:2] if len(words) == 1 else words[0][0] + words[-1][0]).upper()
32
+ if words
33
+ else ""
34
+ )
35
+ if not initials:
36
+ return "?", "linear-gradient(135deg, hsl(220 85% 56%), hsl(265 85% 60%))"
37
+ hashed = 2166136261
38
+ for letter in initials:
39
+ hashed = ((hashed ^ ord(letter)) * 16777619) & 0xFFFFFFFF
40
+ hue = hashed % 360
41
+ second = (hue + 45 + ((hashed >> 8) % 90)) % 360
42
+ gradient = f"linear-gradient(135deg, hsl({hue} {78 + hashed % 12}% {48 + ((hashed >> 12) % 8)}%), hsl({second} {80 + ((hashed >> 4) % 12)}% {50 + ((hashed >> 20) % 8)}%))"
43
+ return initials, gradient
44
+
45
+
46
+ def author_name(comment: Comment, request: HttpRequest) -> str:
47
+ path = technical_settings(comment.discussion.site)["GET_AUTHOR_NAME"]
48
+ if path and comment.user_id:
49
+ try:
50
+ value = import_string(path)(comment.user, request)
51
+ if isinstance(value, str) and value.strip():
52
+ return value.strip()
53
+ except Exception as exc:
54
+ logger.exception(
55
+ "Author name handler failed (%s)", type(exc).__name__, exc_info=False
56
+ )
57
+ return comment.display_name
58
+
59
+
60
+ def avatar_url(comment: Comment, request: HttpRequest, name: str) -> str | None:
61
+ path = technical_settings(comment.discussion.site)["GET_AVATAR"]
62
+ if not path:
63
+ return None
64
+ try:
65
+ value = import_string(path)(
66
+ CommentContext(
67
+ comment.pk,
68
+ name,
69
+ comment.user,
70
+ comment.discussion.site,
71
+ target_for(comment.discussion),
72
+ ),
73
+ request,
74
+ )
75
+ return value if isinstance(value, str) and value else None
76
+ except Exception as exc:
77
+ logger.exception(
78
+ "Avatar handler failed (%s)", type(exc).__name__, exc_info=False
79
+ )
80
+ return None
commentail/checks.py ADDED
@@ -0,0 +1,8 @@
1
+ from django.core.checks import Error, register
2
+
3
+ from .config import configuration_errors
4
+
5
+
6
+ @register()
7
+ def check_settings(app_configs: object, **kwargs: object) -> list[Error]:
8
+ return [Error(message, id="commentail.E001") for message in configuration_errors()]
commentail/config.py ADDED
@@ -0,0 +1,193 @@
1
+ """Technical settings inherit library defaults, default, then Site.hostname."""
2
+
3
+ import ipaddress
4
+ from typing import Literal, TypedDict
5
+
6
+ from django.conf import settings
7
+ from django.core.exceptions import ImproperlyConfigured
8
+ from wagtail.models import Site
9
+
10
+ StructuredDataMode = Literal["both", "json_ld", "microdata", "off"]
11
+
12
+
13
+ class TechnicalSettings(TypedDict):
14
+ STRUCTURED_DATA: StructuredDataMode
15
+ OBJECT_ADAPTERS: dict[str, str]
16
+ RULES_CSV_MAX_BYTES: int
17
+ RULES_CSV_MAX_ROWS: int
18
+ FORM_TICKET_SECONDS: int
19
+ EDIT_WINDOW_SECONDS: int
20
+ EMAIL_CONFIRM_SECONDS: int
21
+ GUEST_GRANT_SECONDS: int
22
+ CAPTCHA_SECONDS: int
23
+ CAPTCHA_ATTEMPTS: int
24
+ AUTHOR_MIN_INTERVAL: int
25
+ AUTHOR_LIMIT_PER_HOUR: int
26
+ IP_LIMIT_PER_HOUR: int
27
+ RESEND_MIN_INTERVAL: int
28
+ RESEND_LIMIT_PER_HOUR: int
29
+ RESEND_IP_LIMIT_PER_HOUR: int
30
+ CAPTCHA_IP_LIMIT_PER_MINUTE: int
31
+ CAPTCHA_IP_LIMIT_PER_HOUR: int
32
+ TRUSTED_PROXIES: list[str]
33
+ GUEST_REQUEST_CHECKS: list[str]
34
+ MAIL_RETENTION_SECONDS: int
35
+ MAIL_LEASE_SECONDS: int
36
+ MAIL_RETRY_SECONDS: list[int]
37
+ ACTION_LINK_SECONDS: int
38
+ CAPTCHA_BACKEND: str | None
39
+ GET_AVATAR: str | None
40
+ GET_AUTHOR_NAME: str | None
41
+ GET_COMMENT_TEMPLATES: str | None
42
+ CAN_SEND_GUEST_EMAIL: str | None
43
+
44
+
45
+ DEFAULTS: TechnicalSettings = {
46
+ "STRUCTURED_DATA": "both",
47
+ "OBJECT_ADAPTERS": {},
48
+ "RULES_CSV_MAX_BYTES": 5242880,
49
+ "RULES_CSV_MAX_ROWS": 20000,
50
+ "FORM_TICKET_SECONDS": 3600,
51
+ "EDIT_WINDOW_SECONDS": 900,
52
+ "EMAIL_CONFIRM_SECONDS": 86400,
53
+ "GUEST_GRANT_SECONDS": 2592000,
54
+ "CAPTCHA_SECONDS": 300,
55
+ "CAPTCHA_ATTEMPTS": 5,
56
+ "AUTHOR_MIN_INTERVAL": 30,
57
+ "AUTHOR_LIMIT_PER_HOUR": 20,
58
+ "IP_LIMIT_PER_HOUR": 100,
59
+ "RESEND_MIN_INTERVAL": 60,
60
+ "RESEND_LIMIT_PER_HOUR": 5,
61
+ "RESEND_IP_LIMIT_PER_HOUR": 20,
62
+ "CAPTCHA_IP_LIMIT_PER_MINUTE": 10,
63
+ "CAPTCHA_IP_LIMIT_PER_HOUR": 100,
64
+ "TRUSTED_PROXIES": [],
65
+ "GUEST_REQUEST_CHECKS": ["missing_user_agent", "cross_site"],
66
+ "MAIL_RETENTION_SECONDS": 604800,
67
+ "MAIL_LEASE_SECONDS": 300,
68
+ "MAIL_RETRY_SECONDS": [60, 300, 900],
69
+ "ACTION_LINK_SECONDS": 2592000,
70
+ "CAN_SEND_GUEST_EMAIL": None,
71
+ "GET_COMMENT_TEMPLATES": None,
72
+ "GET_AVATAR": None,
73
+ "CAPTCHA_BACKEND": None,
74
+ "GET_AUTHOR_NAME": None,
75
+ }
76
+
77
+
78
+ def configuration_errors() -> list[str]:
79
+ config = getattr(settings, "COMMENTAIL", {})
80
+ if not isinstance(config, dict):
81
+ return ["COMMENTAIL must be a dictionary"]
82
+ errors: list[str] = []
83
+ for hostname, values in config.items():
84
+ if (
85
+ not isinstance(hostname, str)
86
+ or not hostname
87
+ or not isinstance(values, dict)
88
+ ):
89
+ errors.append(
90
+ "COMMENTAIL sections must have string names and dictionary values"
91
+ )
92
+ continue
93
+ for key, value in values.items():
94
+ if key not in DEFAULTS:
95
+ errors.append(f"Unknown COMMENTAIL key: {key}")
96
+ elif key == "STRUCTURED_DATA":
97
+ if not isinstance(value, str) or value not in (
98
+ "both", "json_ld", "microdata", "off"
99
+ ):
100
+ errors.append("STRUCTURED_DATA must be both, json_ld, microdata or off")
101
+ elif key == "OBJECT_ADAPTERS":
102
+ from .objects import registry_errors
103
+
104
+ if hostname != "default":
105
+ errors.append(
106
+ "OBJECT_ADAPTERS is project-wide; configure it in default"
107
+ )
108
+ errors.extend(registry_errors(value))
109
+ elif key.startswith("RULES_CSV_") and hostname != "default":
110
+ errors.append(f"{key} is project-wide; configure it in default")
111
+ elif key == "CAPTCHA_BACKEND":
112
+ if value is not None:
113
+ from django.utils.module_loading import import_string
114
+
115
+ try:
116
+ backend = (
117
+ import_string(value) if isinstance(value, str) else None
118
+ )
119
+ if (
120
+ not isinstance(backend, type)
121
+ or len(value) > 255
122
+ or not all(
123
+ callable(getattr(backend, method, None))
124
+ for method in ("create", "verify")
125
+ )
126
+ ):
127
+ errors.append(
128
+ "CAPTCHA_BACKEND must be an import path to a class with create and verify methods"
129
+ )
130
+ except (ImportError, AttributeError):
131
+ errors.append("CAPTCHA_BACKEND cannot be imported")
132
+ elif key in {
133
+ "CAN_SEND_GUEST_EMAIL",
134
+ "GET_COMMENT_TEMPLATES",
135
+ "GET_AVATAR",
136
+ "GET_AUTHOR_NAME",
137
+ }:
138
+ if value is not None:
139
+ from django.utils.module_loading import import_string
140
+
141
+ try:
142
+ if not isinstance(value, str) or not callable(
143
+ import_string(value)
144
+ ):
145
+ errors.append(f"{key} must be an import path to a callable")
146
+ except (ImportError, AttributeError):
147
+ errors.append(f"{key} cannot be imported")
148
+ elif key == "GUEST_REQUEST_CHECKS":
149
+ if (
150
+ not isinstance(value, list)
151
+ or not all(
152
+ isinstance(item, str)
153
+ and item in {"missing_user_agent", "cross_site"}
154
+ for item in value
155
+ )
156
+ or len(value) != len(set(value))
157
+ ):
158
+ errors.append(
159
+ "GUEST_REQUEST_CHECKS must contain unique known signal names: missing_user_agent, cross_site"
160
+ )
161
+ elif key == "MAIL_RETRY_SECONDS":
162
+ if not isinstance(value, list) or not all(
163
+ type(item) is int and item > 0 for item in value
164
+ ):
165
+ errors.append(
166
+ "MAIL_RETRY_SECONDS must contain positive integer delays"
167
+ )
168
+ elif key == "TRUSTED_PROXIES":
169
+ if not isinstance(value, list) or not all(
170
+ isinstance(item, str) for item in value
171
+ ):
172
+ errors.append("TRUSTED_PROXIES must be a list of network strings")
173
+ else:
174
+ for item in value:
175
+ try:
176
+ ipaddress.ip_network(item)
177
+ except ValueError:
178
+ errors.append("Invalid TRUSTED_PROXIES network")
179
+ elif type(value) is not int or value <= 0:
180
+ errors.append(f"{key} must be a positive integer")
181
+ return errors
182
+
183
+
184
+ def technical_settings(site: Site | None) -> TechnicalSettings:
185
+ errors = configuration_errors()
186
+ if errors:
187
+ raise ImproperlyConfigured("; ".join(errors))
188
+ config = getattr(settings, "COMMENTAIL", {})
189
+ return {
190
+ **DEFAULTS,
191
+ **config.get("default", {}),
192
+ **(config.get(site.hostname, {}) if site else {}),
193
+ }
@@ -0,0 +1,89 @@
1
+ from django.core.exceptions import ImproperlyConfigured
2
+ from django.http import HttpRequest
3
+ from django.template import Context, NodeList, TemplateDoesNotExist, TemplateSyntaxError
4
+ from django.template.loader import get_template
5
+ from django.template.loader_tags import ExtendsNode, IncludeNode
6
+ from django.utils.module_loading import import_string
7
+ from wagtail.models import Page, Site
8
+
9
+ from .config import configuration_errors, technical_settings
10
+ from .templatetags.commentail_tags import CommentailNode
11
+
12
+
13
+ class DetectionError(ValueError):
14
+ """The library cannot establish the effective template chain."""
15
+
16
+
17
+ def effective_settings(site: Site | None) -> dict[str, object]:
18
+ if errors := configuration_errors():
19
+ raise ImproperlyConfigured("; ".join(errors))
20
+ if site:
21
+ return dict(technical_settings(site))
22
+ from django.conf import settings
23
+
24
+ return dict(getattr(settings, "COMMENTAIL", {}).get("default", {}))
25
+
26
+
27
+ def has_comments(page: Page, request: HttpRequest) -> bool:
28
+ """Inspect compiled nodes without rendering user templates or evaluating conditions.
29
+
30
+ A configured resolver must enumerate unresolved dynamic dependencies. Template
31
+ nodes are version-sensitive internals covered by integration tests.
32
+ """
33
+ config = effective_settings(Site.find_for_request(request))
34
+ resolver_path = config.get("GET_COMMENT_TEMPLATES")
35
+ resolver = import_string(str(resolver_path)) if resolver_path else None
36
+ try:
37
+ names = resolver(page, request) if resolver else [page.get_template(request)]
38
+ except Exception as exc:
39
+ # Do not expose handler exception payloads in editorial messages.
40
+ raise DetectionError("Comment template handler failed") from exc
41
+ if not isinstance(names, (list, tuple)) or not all(
42
+ isinstance(n, str) for n in names
43
+ ):
44
+ raise DetectionError("GET_COMMENT_TEMPLATES must return template names")
45
+ seen: set[str] = set()
46
+
47
+ def load(name: str) -> bool:
48
+ if name in seen:
49
+ return False
50
+ seen.add(name)
51
+ compiled = get_template(name).template
52
+ return scan(compiled.nodelist)
53
+
54
+ def dependency(expression: object) -> bool:
55
+ if getattr(expression, "is_var", True):
56
+ if resolver:
57
+ return False # Resolver enumerates this dependency in names.
58
+ raise DetectionError(
59
+ "Dynamic template dependency requires GET_COMMENT_TEMPLATES"
60
+ )
61
+ value = expression.resolve(Context())
62
+ candidates = [value] if isinstance(value, str) else list(value)
63
+ for name in candidates:
64
+ try:
65
+ return load(name)
66
+ except TemplateDoesNotExist:
67
+ continue
68
+ raise DetectionError("Included template not found")
69
+
70
+ def scan(nodes: NodeList) -> bool:
71
+ for node in nodes:
72
+ if isinstance(node, CommentailNode):
73
+ return True
74
+ if isinstance(node, ExtendsNode) and dependency(node.parent_name):
75
+ return True
76
+ if isinstance(node, IncludeNode) and dependency(node.template):
77
+ return True
78
+ # Presence controls configuration, even in overridden blocks or
79
+ # false conditions. Rendering is deliberately not simulated.
80
+ for attr in getattr(node, "child_nodelists", ()):
81
+ children = getattr(node, attr, None)
82
+ if isinstance(children, NodeList) and scan(children):
83
+ return True
84
+ return False
85
+
86
+ try:
87
+ return any(load(name) for name in names)
88
+ except (TemplateDoesNotExist, TemplateSyntaxError) as exc:
89
+ raise DetectionError(str(exc)) from exc