django-heckle 0.1.0__tar.gz

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 (47) hide show
  1. django_heckle-0.1.0/.github/dependabot.yml +13 -0
  2. django_heckle-0.1.0/.github/workflows/ci.yml +38 -0
  3. django_heckle-0.1.0/.github/workflows/release.yml +23 -0
  4. django_heckle-0.1.0/.gitignore +8 -0
  5. django_heckle-0.1.0/CHANGELOG.md +11 -0
  6. django_heckle-0.1.0/LICENSE +28 -0
  7. django_heckle-0.1.0/PKG-INFO +153 -0
  8. django_heckle-0.1.0/README.md +122 -0
  9. django_heckle-0.1.0/pyproject.toml +64 -0
  10. django_heckle-0.1.0/src/heckle/__init__.py +1 -0
  11. django_heckle-0.1.0/src/heckle/admin.py +39 -0
  12. django_heckle-0.1.0/src/heckle/antispam.py +68 -0
  13. django_heckle-0.1.0/src/heckle/apps.py +8 -0
  14. django_heckle-0.1.0/src/heckle/avatars.py +169 -0
  15. django_heckle-0.1.0/src/heckle/conf.py +40 -0
  16. django_heckle-0.1.0/src/heckle/forms.py +104 -0
  17. django_heckle-0.1.0/src/heckle/migrations/0001_initial.py +42 -0
  18. django_heckle-0.1.0/src/heckle/migrations/__init__.py +0 -0
  19. django_heckle-0.1.0/src/heckle/models.py +73 -0
  20. django_heckle-0.1.0/src/heckle/moderation.py +81 -0
  21. django_heckle-0.1.0/src/heckle/rendering.py +24 -0
  22. django_heckle-0.1.0/src/heckle/signals.py +6 -0
  23. django_heckle-0.1.0/src/heckle/static/heckle/heckle.css +46 -0
  24. django_heckle-0.1.0/src/heckle/templates/heckle/_comment.html +6 -0
  25. django_heckle-0.1.0/src/heckle/templates/heckle/_fields.html +14 -0
  26. django_heckle-0.1.0/src/heckle/templates/heckle/_form.html +5 -0
  27. django_heckle-0.1.0/src/heckle/templates/heckle/_privacy.html +1 -0
  28. django_heckle-0.1.0/src/heckle/templates/heckle/comments.html +8 -0
  29. django_heckle-0.1.0/src/heckle/templates/heckle/email/moderate.txt +9 -0
  30. django_heckle-0.1.0/src/heckle/templates/heckle/moderate.html +21 -0
  31. django_heckle-0.1.0/src/heckle/templates/heckle/page.html +18 -0
  32. django_heckle-0.1.0/src/heckle/templates/heckle/posted.html +13 -0
  33. django_heckle-0.1.0/src/heckle/templatetags/__init__.py +0 -0
  34. django_heckle-0.1.0/src/heckle/templatetags/heckle_tags.py +63 -0
  35. django_heckle-0.1.0/src/heckle/urls.py +10 -0
  36. django_heckle-0.1.0/src/heckle/views.py +86 -0
  37. django_heckle-0.1.0/src/heckle/wagtail_hooks.py +29 -0
  38. django_heckle-0.1.0/tests/__init__.py +0 -0
  39. django_heckle-0.1.0/tests/conftest.py +43 -0
  40. django_heckle-0.1.0/tests/models.py +15 -0
  41. django_heckle-0.1.0/tests/settings.py +52 -0
  42. django_heckle-0.1.0/tests/templates/article.html +3 -0
  43. django_heckle-0.1.0/tests/test_avatars_and_admin.py +43 -0
  44. django_heckle-0.1.0/tests/test_comments.py +173 -0
  45. django_heckle-0.1.0/tests/urls.py +10 -0
  46. django_heckle-0.1.0/tests/views.py +9 -0
  47. django_heckle-0.1.0/uv.lock +969 -0
@@ -0,0 +1,13 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: uv
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ - package-ecosystem: github-actions
8
+ directory: /
9
+ schedule:
10
+ interval: weekly
11
+ groups:
12
+ actions:
13
+ patterns: ["*"]
@@ -0,0 +1,38 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python: ["3.12", "3.13", "3.14"]
18
+ django: ["5.2", "6.0", "6.1"]
19
+ steps:
20
+ - uses: actions/checkout@v7
21
+ - uses: astral-sh/setup-uv@v7
22
+ with:
23
+ python-version: ${{ matrix.python }}
24
+ - run: uv sync --group dev
25
+ - run: uv pip install "django~=${{ matrix.django }}.0"
26
+ - run: uv run --no-sync ruff check .
27
+ - run: uv run --no-sync ruff format --check .
28
+ - run: uv run --no-sync pytest
29
+ - name: No model changes without a migration
30
+ run: uv run --no-sync django-admin makemigrations heckle --check --dry-run --settings=tests.settings --pythonpath=.
31
+
32
+ build:
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@v7
36
+ - uses: astral-sh/setup-uv@v7
37
+ - run: uv build
38
+ - run: uvx twine check --strict dist/*
@@ -0,0 +1,23 @@
1
+ # Publishes a release to PyPI when a tag such as v0.2.0 is pushed, by trusted
2
+ # publishing (no token is stored): on PyPI, the project's publisher is this
3
+ # repository, this workflow (release.yml) and the environment "pypi".
4
+ name: Release
5
+
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ permissions:
15
+ contents: read
16
+ id-token: write
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+ - uses: astral-sh/setup-uv@v7
20
+ - name: The tag matches the version in pyproject.toml
21
+ run: test "v$(uv version --short)" = "$GITHUB_REF_NAME"
22
+ - run: uv build
23
+ - run: uv publish --trusted-publishing always
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[co]
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (11 November 2026)
4
+
5
+ The first release.
6
+
7
+ - Comments on any object whose `comments_open` attribute is true, without an account, in Markdown cleaned of anything harmful.
8
+ - Pre-moderation from a signed link in the moderators' email (which gives the commenter's email address), and in Django's and Wagtail's admins; comments by staff approved at once and marked *Author*.
9
+ - Replies, one level deep by default (`HECKLE_MAX_DEPTH`).
10
+ - A honeypot field, a signed timestamp, a rate limit, a signed target, optional Cloudflare Turnstile, and a limit on links.
11
+ - Pictures for commenters drawn from a keyed hash of their email address, in four styles: Lissajous figure, atom, snowflake and emission spectrum.
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Christian Hill
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-heckle
3
+ Version: 0.1.0
4
+ Summary: Moderated comments for Django sites: anyone may comment, without an account; every comment waits for approval; bots are kept out.
5
+ Project-URL: Homepage, https://github.com/xnx/django-heckle
6
+ Project-URL: Changelog, https://github.com/xnx/django-heckle/blob/main/CHANGELOG.md
7
+ Author: Christian Hill
8
+ License-Expression: BSD-3-Clause
9
+ License-File: LICENSE
10
+ Keywords: blog,comments,django,moderation,wagtail
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 5.2
15
+ Classifier: Framework :: Django :: 6.0
16
+ Classifier: Framework :: Django :: 6.1
17
+ Classifier: Framework :: Wagtail
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards
24
+ Requires-Python: >=3.12
25
+ Requires-Dist: django>=5.2
26
+ Requires-Dist: markdown>=3.5
27
+ Requires-Dist: nh3>=0.2.14
28
+ Provides-Extra: wagtail
29
+ Requires-Dist: wagtail>=7.0; extra == 'wagtail'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # django-heckle
33
+
34
+ The django-heckle package adds comments to the pages of a Django site. Anyone can comment, without an account; each comment waits for a moderator's approval, which is given from a link in an email. Bots and spam are kept out by a honeypot field, a signed timestamp, a rate limit and, optionally, Cloudflare Turnstile, with no tracking by a third party. Each commenter gets a picture drawn from a hash of their email address, in one of four scientific styles (a Lissajous figure, an atom, a snowflake or an emission spectrum). The name comes from *heckling*: interrupting a speaker with comments from the audience.
35
+
36
+ The package needs Python 3.12 or later and Django 5.2 or later, and works with or without Wagtail. It runs on the blog of https://scipython.com.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install django-heckle # or "django-heckle[wagtail]"
42
+ ```
43
+
44
+ Add the app to `INSTALLED_APPS`, include its URLs and migrate:
45
+
46
+ ```python
47
+ INSTALLED_APPS = [
48
+ ...,
49
+ "heckle",
50
+ ]
51
+ ```
52
+
53
+ ```python
54
+ urlpatterns = [
55
+ ...,
56
+ path("comments/", include("heckle.urls")),
57
+ ]
58
+ ```
59
+
60
+ The site must be able to send email (Django's `MAILERS`, or `EMAIL_BACKEND` before Django 6.1, and `DEFAULT_FROM_EMAIL`), and should have a shared cache (such as Redis or the database cache) if it runs several processes, since the rate limit is kept in the cache.
61
+
62
+ ## Usage
63
+
64
+ Comments can be attached to an instance of any model, but only one whose `comments_open` attribute is true takes them, so a model opts in with a field such as
65
+
66
+ ```python
67
+ comments_open = models.BooleanField(default=True)
68
+ ```
69
+
70
+ A page's template shows its approved comments and the form for a new one with
71
+
72
+ ```django
73
+ {% load heckle_tags %}
74
+ {% heckle_comments post %}
75
+ ```
76
+
77
+ and `{% heckle_count post %}` gives the number of approved comments (on an index of posts, for example). Each comment links to itself (`#comment-<id>`), and the section to `#comments`. If `comments_open` is false, the approved comments are still shown, with the form replaced by "Comments are closed."
78
+
79
+ ## Moderation
80
+
81
+ A new comment is *pending*, and the moderators (`HECKLE_MODERATORS`, or the site's `MANAGERS`) are emailed its text, the commenter's name and email address (if given, also as the email's Reply-To), the page, and a link at which to approve it, reject it or mark it as spam. The link is signed, so it works without logging in, and it expires after `HECKLE_MODERATION_LINK_DAYS`. Opening it changes nothing until a button is pressed, which makes it safe from mail scanners that follow links.
82
+
83
+ Comments are also moderated in Django's admin and, on a Wagtail site, in Wagtail's admin under *Comments*. A comment posted by a logged-in member of staff (`is_staff`) is approved at once and marked *Author*.
84
+
85
+ A reply is a comment with a parent. Replies go one level deep by default (replies to comments, but not to replies); `HECKLE_MAX_DEPTH` allows more. Only an approved comment can be replied to.
86
+
87
+ ## Defences against spam
88
+
89
+ - A *honeypot*: a field hidden from people, which bots fill in.
90
+ - A signed timestamp: a form sent back sooner than `HECKLE_MIN_SECONDS` after it was shown, or later than `HECKLE_MAX_FORM_AGE`, is refused.
91
+ - A rate limit of `HECKLE_RATE_LIMIT_PER_HOUR` comments per hour from each IP address (stored only as a hash).
92
+ - A signed target: the form names the object it belongs to in a signed token, so it cannot be pointed at another.
93
+ - [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/), if both of its keys are set.
94
+ - A comment with more than `HECKLE_MAX_LINKS` links is kept as spam, and nobody is emailed.
95
+
96
+ Comments are written in Markdown, of which only paragraphs, emphasis, links, lists, quotations and code are kept: the HTML is cleaned with [nh3](https://github.com/messense/nh3), and links are marked `rel="nofollow ugc noopener"`, which tells search engines not to credit them.
97
+
98
+ Staff are exempt from the rate limit and from Turnstile. The client's IP address is taken from `REMOTE_ADDR`, so a site behind a proxy must set it from the proxy's header (with middleware or the server's configuration).
99
+
100
+ ## Avatars
101
+
102
+ The picture beside each comment is drawn from an HMAC (a hash keyed with the site's `SECRET_KEY`) of the commenter's email address, or of their name if they gave none. The same commenter always gets the same picture but, unlike a [Gravatar](https://gravatar.com)'s hash, the key reveals nothing about the address. `HECKLE_AVATAR` chooses the style:
103
+
104
+ - `"lissajous"`: a *Lissajous figure*, the curve traced by two oscillations at right angles, as on an oscilloscope;
105
+ - `"atom"`: a nucleus with one, two or three electron orbits;
106
+ - `"snowflake"`: a six-fold dendrite;
107
+ - `"spectrum"`: the bright lines of an emission spectrum, a commenter's "fingerprint".
108
+
109
+ It can also be the dotted path of a function that takes the key (32 bytes) and returns an SVG image, or `None` for no pictures.
110
+
111
+ ## Settings
112
+
113
+ | Setting | Default | Meaning |
114
+ |---|---|---|
115
+ | `HECKLE_MODERATORS` | `[]` | The addresses emailed about each comment awaiting approval; the site's `MANAGERS` if empty |
116
+ | `HECKLE_FROM_EMAIL` | `""` | The address those emails are sent from; `DEFAULT_FROM_EMAIL` if empty |
117
+ | `HECKLE_MODERATION_LINK_DAYS` | `14` | How long the link in a moderator's email works, in days |
118
+ | `HECKLE_MAX_DEPTH` | `1` | How deep replies may go (1: replies to comments, but not to replies) |
119
+ | `HECKLE_MAX_LENGTH` | `5000` | The longest comment, in characters |
120
+ | `HECKLE_MAX_LINKS` | `3` | The most links a comment may have before it is kept as spam |
121
+ | `HECKLE_RATE_LIMIT_PER_HOUR` | `5` | Comments that each IP address may post per hour |
122
+ | `HECKLE_MIN_SECONDS` | `3` | The shortest time, in seconds, between showing a form and its being sent back |
123
+ | `HECKLE_MAX_FORM_AGE` | `86400` | The longest time, in seconds, for which a form can be sent back |
124
+ | `HECKLE_TURNSTILE_SITE_KEY`, `HECKLE_TURNSTILE_SECRET_KEY` | `""` | Cloudflare Turnstile's keys; it is used if both are set |
125
+ | `HECKLE_AVATAR` | `"lissajous"` | The style of picture (see "Avatars") |
126
+
127
+ ## Templates and styles
128
+
129
+ The comments are drawn by `heckle/comments.html`, which includes `heckle/_comment.html` for each comment and `heckle/_form.html` for the form; the note under the form about what happens to an email address is `heckle/_privacy.html`. Any of these can be overridden. The pages of the app's own (a comment that could not be posted, and moderation) extend `heckle/page.html`, which by default is a page of its own. A site overrides it to put them inside its own pages:
130
+
131
+ ```django
132
+ {% extends "base.html" %}
133
+ {% block title %}{% block heckle_title %}{% endblock %}{% endblock %}
134
+ {% block content %}{% block heckle_content %}{% endblock %}{% endblock %}
135
+ ```
136
+
137
+ The app has its own stylesheet, with no dependence on a CSS framework. Its colours follow the page's text colour and `color-scheme` (light or dark), unless the site sets these custom properties on `.heckle`: `--heckle-ink`, `--heckle-muted`, `--heckle-line`, `--heckle-surface`, `--heckle-input`, `--heckle-accent` and `--heckle-accent-on`.
138
+
139
+ ## Signals
140
+
141
+ `heckle.signals.comment_posted` is sent with `comment` and `request` when a comment has been posted (whatever its status), and `comment_moderated` with `comment` when a moderator has changed its status from the emailed link.
142
+
143
+ ## Development
144
+
145
+ ```bash
146
+ uv sync --group dev
147
+ uv run pytest
148
+ uv run ruff check . && uv run ruff format --check .
149
+ ```
150
+
151
+ ## Licence
152
+
153
+ BSD 3-Clause; see `LICENSE`.
@@ -0,0 +1,122 @@
1
+ # django-heckle
2
+
3
+ The django-heckle package adds comments to the pages of a Django site. Anyone can comment, without an account; each comment waits for a moderator's approval, which is given from a link in an email. Bots and spam are kept out by a honeypot field, a signed timestamp, a rate limit and, optionally, Cloudflare Turnstile, with no tracking by a third party. Each commenter gets a picture drawn from a hash of their email address, in one of four scientific styles (a Lissajous figure, an atom, a snowflake or an emission spectrum). The name comes from *heckling*: interrupting a speaker with comments from the audience.
4
+
5
+ The package needs Python 3.12 or later and Django 5.2 or later, and works with or without Wagtail. It runs on the blog of https://scipython.com.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install django-heckle # or "django-heckle[wagtail]"
11
+ ```
12
+
13
+ Add the app to `INSTALLED_APPS`, include its URLs and migrate:
14
+
15
+ ```python
16
+ INSTALLED_APPS = [
17
+ ...,
18
+ "heckle",
19
+ ]
20
+ ```
21
+
22
+ ```python
23
+ urlpatterns = [
24
+ ...,
25
+ path("comments/", include("heckle.urls")),
26
+ ]
27
+ ```
28
+
29
+ The site must be able to send email (Django's `MAILERS`, or `EMAIL_BACKEND` before Django 6.1, and `DEFAULT_FROM_EMAIL`), and should have a shared cache (such as Redis or the database cache) if it runs several processes, since the rate limit is kept in the cache.
30
+
31
+ ## Usage
32
+
33
+ Comments can be attached to an instance of any model, but only one whose `comments_open` attribute is true takes them, so a model opts in with a field such as
34
+
35
+ ```python
36
+ comments_open = models.BooleanField(default=True)
37
+ ```
38
+
39
+ A page's template shows its approved comments and the form for a new one with
40
+
41
+ ```django
42
+ {% load heckle_tags %}
43
+ {% heckle_comments post %}
44
+ ```
45
+
46
+ and `{% heckle_count post %}` gives the number of approved comments (on an index of posts, for example). Each comment links to itself (`#comment-<id>`), and the section to `#comments`. If `comments_open` is false, the approved comments are still shown, with the form replaced by "Comments are closed."
47
+
48
+ ## Moderation
49
+
50
+ A new comment is *pending*, and the moderators (`HECKLE_MODERATORS`, or the site's `MANAGERS`) are emailed its text, the commenter's name and email address (if given, also as the email's Reply-To), the page, and a link at which to approve it, reject it or mark it as spam. The link is signed, so it works without logging in, and it expires after `HECKLE_MODERATION_LINK_DAYS`. Opening it changes nothing until a button is pressed, which makes it safe from mail scanners that follow links.
51
+
52
+ Comments are also moderated in Django's admin and, on a Wagtail site, in Wagtail's admin under *Comments*. A comment posted by a logged-in member of staff (`is_staff`) is approved at once and marked *Author*.
53
+
54
+ A reply is a comment with a parent. Replies go one level deep by default (replies to comments, but not to replies); `HECKLE_MAX_DEPTH` allows more. Only an approved comment can be replied to.
55
+
56
+ ## Defences against spam
57
+
58
+ - A *honeypot*: a field hidden from people, which bots fill in.
59
+ - A signed timestamp: a form sent back sooner than `HECKLE_MIN_SECONDS` after it was shown, or later than `HECKLE_MAX_FORM_AGE`, is refused.
60
+ - A rate limit of `HECKLE_RATE_LIMIT_PER_HOUR` comments per hour from each IP address (stored only as a hash).
61
+ - A signed target: the form names the object it belongs to in a signed token, so it cannot be pointed at another.
62
+ - [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/), if both of its keys are set.
63
+ - A comment with more than `HECKLE_MAX_LINKS` links is kept as spam, and nobody is emailed.
64
+
65
+ Comments are written in Markdown, of which only paragraphs, emphasis, links, lists, quotations and code are kept: the HTML is cleaned with [nh3](https://github.com/messense/nh3), and links are marked `rel="nofollow ugc noopener"`, which tells search engines not to credit them.
66
+
67
+ Staff are exempt from the rate limit and from Turnstile. The client's IP address is taken from `REMOTE_ADDR`, so a site behind a proxy must set it from the proxy's header (with middleware or the server's configuration).
68
+
69
+ ## Avatars
70
+
71
+ The picture beside each comment is drawn from an HMAC (a hash keyed with the site's `SECRET_KEY`) of the commenter's email address, or of their name if they gave none. The same commenter always gets the same picture but, unlike a [Gravatar](https://gravatar.com)'s hash, the key reveals nothing about the address. `HECKLE_AVATAR` chooses the style:
72
+
73
+ - `"lissajous"`: a *Lissajous figure*, the curve traced by two oscillations at right angles, as on an oscilloscope;
74
+ - `"atom"`: a nucleus with one, two or three electron orbits;
75
+ - `"snowflake"`: a six-fold dendrite;
76
+ - `"spectrum"`: the bright lines of an emission spectrum, a commenter's "fingerprint".
77
+
78
+ It can also be the dotted path of a function that takes the key (32 bytes) and returns an SVG image, or `None` for no pictures.
79
+
80
+ ## Settings
81
+
82
+ | Setting | Default | Meaning |
83
+ |---|---|---|
84
+ | `HECKLE_MODERATORS` | `[]` | The addresses emailed about each comment awaiting approval; the site's `MANAGERS` if empty |
85
+ | `HECKLE_FROM_EMAIL` | `""` | The address those emails are sent from; `DEFAULT_FROM_EMAIL` if empty |
86
+ | `HECKLE_MODERATION_LINK_DAYS` | `14` | How long the link in a moderator's email works, in days |
87
+ | `HECKLE_MAX_DEPTH` | `1` | How deep replies may go (1: replies to comments, but not to replies) |
88
+ | `HECKLE_MAX_LENGTH` | `5000` | The longest comment, in characters |
89
+ | `HECKLE_MAX_LINKS` | `3` | The most links a comment may have before it is kept as spam |
90
+ | `HECKLE_RATE_LIMIT_PER_HOUR` | `5` | Comments that each IP address may post per hour |
91
+ | `HECKLE_MIN_SECONDS` | `3` | The shortest time, in seconds, between showing a form and its being sent back |
92
+ | `HECKLE_MAX_FORM_AGE` | `86400` | The longest time, in seconds, for which a form can be sent back |
93
+ | `HECKLE_TURNSTILE_SITE_KEY`, `HECKLE_TURNSTILE_SECRET_KEY` | `""` | Cloudflare Turnstile's keys; it is used if both are set |
94
+ | `HECKLE_AVATAR` | `"lissajous"` | The style of picture (see "Avatars") |
95
+
96
+ ## Templates and styles
97
+
98
+ The comments are drawn by `heckle/comments.html`, which includes `heckle/_comment.html` for each comment and `heckle/_form.html` for the form; the note under the form about what happens to an email address is `heckle/_privacy.html`. Any of these can be overridden. The pages of the app's own (a comment that could not be posted, and moderation) extend `heckle/page.html`, which by default is a page of its own. A site overrides it to put them inside its own pages:
99
+
100
+ ```django
101
+ {% extends "base.html" %}
102
+ {% block title %}{% block heckle_title %}{% endblock %}{% endblock %}
103
+ {% block content %}{% block heckle_content %}{% endblock %}{% endblock %}
104
+ ```
105
+
106
+ The app has its own stylesheet, with no dependence on a CSS framework. Its colours follow the page's text colour and `color-scheme` (light or dark), unless the site sets these custom properties on `.heckle`: `--heckle-ink`, `--heckle-muted`, `--heckle-line`, `--heckle-surface`, `--heckle-input`, `--heckle-accent` and `--heckle-accent-on`.
107
+
108
+ ## Signals
109
+
110
+ `heckle.signals.comment_posted` is sent with `comment` and `request` when a comment has been posted (whatever its status), and `comment_moderated` with `comment` when a moderator has changed its status from the emailed link.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ uv sync --group dev
116
+ uv run pytest
117
+ uv run ruff check . && uv run ruff format --check .
118
+ ```
119
+
120
+ ## Licence
121
+
122
+ BSD 3-Clause; see `LICENSE`.
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "django-heckle"
3
+ version = "0.1.0"
4
+ description = "Moderated comments for Django sites: anyone may comment, without an account; every comment waits for approval; bots are kept out."
5
+ readme = "README.md"
6
+ license = "BSD-3-Clause"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Christian Hill" }]
9
+ requires-python = ">=3.12"
10
+ keywords = ["django", "wagtail", "comments", "moderation", "blog"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Web Environment",
14
+ "Framework :: Django",
15
+ "Framework :: Django :: 5.2",
16
+ "Framework :: Django :: 6.0",
17
+ "Framework :: Django :: 6.1",
18
+ "Framework :: Wagtail",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards",
25
+ ]
26
+ dependencies = [
27
+ "django>=5.2",
28
+ "markdown>=3.5",
29
+ "nh3>=0.2.14",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ wagtail = ["wagtail>=7.0"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/xnx/django-heckle"
37
+ Changelog = "https://github.com/xnx/django-heckle/blob/main/CHANGELOG.md"
38
+
39
+ [dependency-groups]
40
+ dev = ["pytest", "pytest-django", "ruff"]
41
+
42
+ [build-system]
43
+ requires = ["hatchling>=1.27"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/heckle"]
48
+
49
+ [tool.pytest.ini_options]
50
+ DJANGO_SETTINGS_MODULE = "tests.settings"
51
+ pythonpath = ["."]
52
+ testpaths = ["tests"]
53
+ # A warning raised in the package's own code, such as of a deprecation, fails.
54
+ filterwarnings = ["error:::heckle"]
55
+
56
+ [tool.ruff]
57
+ line-length = 88
58
+ target-version = "py312"
59
+ src = ["src", "."]
60
+ extend-exclude = ["*/migrations/*"]
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E", "F", "I", "B", "UP", "DJ"]
64
+ ignore = ["E501"] # line length is left to `ruff format`
@@ -0,0 +1 @@
1
+ """Moderated comments for Django sites."""
@@ -0,0 +1,39 @@
1
+ """Moderation in Django's admin. (On a Wagtail site, wagtail_hooks.py adds comments
2
+ to Wagtail's admin too.)"""
3
+
4
+ from django.contrib import admin
5
+
6
+ from .models import Comment
7
+
8
+
9
+ @admin.register(Comment)
10
+ class CommentAdmin(admin.ModelAdmin):
11
+ list_display = ["__str__", "name", "email", "target", "status", "created"]
12
+ list_filter = ["status"]
13
+ search_fields = ["name", "email", "body"]
14
+ readonly_fields = [
15
+ "content_type",
16
+ "object_pk",
17
+ "parent",
18
+ "user",
19
+ "by_staff",
20
+ "created",
21
+ ]
22
+ actions = ["approve", "reject", "mark_as_spam"]
23
+
24
+ def _set(self, queryset, status):
25
+ for comment in queryset:
26
+ comment.status = status
27
+ comment.save(update_fields=["status"])
28
+
29
+ @admin.action(description="Approve")
30
+ def approve(self, request, queryset):
31
+ self._set(queryset, Comment.Status.APPROVED)
32
+
33
+ @admin.action(description="Reject")
34
+ def reject(self, request, queryset):
35
+ self._set(queryset, Comment.Status.REJECTED)
36
+
37
+ @admin.action(description="Mark as spam")
38
+ def mark_as_spam(self, request, queryset):
39
+ self._set(queryset, Comment.Status.SPAM)
@@ -0,0 +1,68 @@
1
+ """Keeping bots out: a signed timestamp, a limit per visitor, and Cloudflare
2
+ Turnstile. (The honeypot is a form field: see forms.py.)"""
3
+
4
+ import hashlib
5
+ import json
6
+ import time
7
+ import urllib.parse
8
+ import urllib.request
9
+
10
+ from django.core import signing
11
+ from django.core.cache import cache
12
+
13
+ from .conf import setting
14
+
15
+ TURNSTILE_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
16
+
17
+
18
+ def client_ip(request):
19
+ """The visitor's address: the first in X-Forwarded-For (set by the proxy in
20
+ front of the site), else the connection's."""
21
+ forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
22
+ return forwarded.split(",")[0].strip() or request.META.get("REMOTE_ADDR", "")
23
+
24
+
25
+ def issued():
26
+ """A signed timestamp for a new form."""
27
+ return signing.dumps(time.time(), salt="heckle.issued")
28
+
29
+
30
+ def form_age(token):
31
+ """The seconds since the form was issued, or None if the token is forged or
32
+ too old."""
33
+ try:
34
+ when = signing.loads(
35
+ token, salt="heckle.issued", max_age=setting("MAX_FORM_AGE")
36
+ )
37
+ except signing.BadSignature:
38
+ return None
39
+ return time.time() - float(when)
40
+
41
+
42
+ def rate_limited(ip):
43
+ """Counts this comment from the address; True once over the limit for the
44
+ hour. The address itself is not kept, only a hash of it, in the cache."""
45
+ key = "heckle:" + hashlib.sha256(ip.encode()).hexdigest()
46
+ cache.add(key, 0, timeout=60 * 60)
47
+ try:
48
+ count = cache.incr(key)
49
+ except ValueError:
50
+ count = 1
51
+ return count > setting("RATE_LIMIT_PER_HOUR")
52
+
53
+
54
+ def turnstile_enabled():
55
+ return bool(setting("TURNSTILE_SITE_KEY") and setting("TURNSTILE_SECRET_KEY"))
56
+
57
+
58
+ def turnstile_passed(token, ip):
59
+ """Whether Cloudflare accepts the widget's token. If Cloudflare cannot be
60
+ reached, the comment is refused (the visitor can try again)."""
61
+ data = urllib.parse.urlencode(
62
+ {"secret": setting("TURNSTILE_SECRET_KEY"), "response": token, "remoteip": ip}
63
+ ).encode()
64
+ try:
65
+ with urllib.request.urlopen(TURNSTILE_URL, data=data, timeout=5) as response:
66
+ return bool(json.load(response).get("success"))
67
+ except (OSError, ValueError):
68
+ return False
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class HeckleConfig(AppConfig):
5
+ name = "heckle"
6
+ verbose_name = "Comments"
7
+ # Set here, so that the app's migrations do not depend on the site's setting.
8
+ default_auto_field = "django.db.models.BigAutoField"