django-flex-blog 1.0.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 (77) hide show
  1. django_flex_blog-1.0.0/CHANGELOG.md +26 -0
  2. django_flex_blog-1.0.0/LICENSE +21 -0
  3. django_flex_blog-1.0.0/MANIFEST.in +5 -0
  4. django_flex_blog-1.0.0/PKG-INFO +459 -0
  5. django_flex_blog-1.0.0/README.md +411 -0
  6. django_flex_blog-1.0.0/SECURITY.md +7 -0
  7. django_flex_blog-1.0.0/django_flex_blog.egg-info/PKG-INFO +459 -0
  8. django_flex_blog-1.0.0/django_flex_blog.egg-info/SOURCES.txt +75 -0
  9. django_flex_blog-1.0.0/django_flex_blog.egg-info/dependency_links.txt +1 -0
  10. django_flex_blog-1.0.0/django_flex_blog.egg-info/requires.txt +17 -0
  11. django_flex_blog-1.0.0/django_flex_blog.egg-info/top_level.txt +1 -0
  12. django_flex_blog-1.0.0/flex_blog/__init__.py +3 -0
  13. django_flex_blog-1.0.0/flex_blog/admin.py +263 -0
  14. django_flex_blog-1.0.0/flex_blog/api.py +230 -0
  15. django_flex_blog-1.0.0/flex_blog/apps.py +13 -0
  16. django_flex_blog-1.0.0/flex_blog/cache.py +92 -0
  17. django_flex_blog-1.0.0/flex_blog/checks.py +77 -0
  18. django_flex_blog-1.0.0/flex_blog/conf.py +284 -0
  19. django_flex_blog-1.0.0/flex_blog/feeds.py +86 -0
  20. django_flex_blog-1.0.0/flex_blog/filters.py +71 -0
  21. django_flex_blog-1.0.0/flex_blog/management/__init__.py +0 -0
  22. django_flex_blog-1.0.0/flex_blog/management/commands/__init__.py +0 -0
  23. django_flex_blog-1.0.0/flex_blog/management/commands/blog_cleanup.py +31 -0
  24. django_flex_blog-1.0.0/flex_blog/management/commands/blog_export.py +84 -0
  25. django_flex_blog-1.0.0/flex_blog/management/commands/blog_import.py +122 -0
  26. django_flex_blog-1.0.0/flex_blog/management/commands/blog_maintenance.py +15 -0
  27. django_flex_blog-1.0.0/flex_blog/management/commands/blog_recount.py +26 -0
  28. django_flex_blog-1.0.0/flex_blog/management/commands/blog_seed.py +64 -0
  29. django_flex_blog-1.0.0/flex_blog/management/commands/blog_setup_roles.py +30 -0
  30. django_flex_blog-1.0.0/flex_blog/migrations/0001_initial.py +457 -0
  31. django_flex_blog-1.0.0/flex_blog/migrations/__init__.py +0 -0
  32. django_flex_blog-1.0.0/flex_blog/models/__init__.py +27 -0
  33. django_flex_blog-1.0.0/flex_blog/models/article.py +265 -0
  34. django_flex_blog-1.0.0/flex_blog/models/base.py +77 -0
  35. django_flex_blog-1.0.0/flex_blog/models/comment.py +99 -0
  36. django_flex_blog-1.0.0/flex_blog/models/engagement.py +73 -0
  37. django_flex_blog-1.0.0/flex_blog/models/media.py +87 -0
  38. django_flex_blog-1.0.0/flex_blog/models/system.py +131 -0
  39. django_flex_blog-1.0.0/flex_blog/models/taxonomy.py +140 -0
  40. django_flex_blog-1.0.0/flex_blog/notifications/__init__.py +88 -0
  41. django_flex_blog-1.0.0/flex_blog/notifications/backends.py +68 -0
  42. django_flex_blog-1.0.0/flex_blog/permissions.py +126 -0
  43. django_flex_blog-1.0.0/flex_blog/receipts.py +29 -0
  44. django_flex_blog-1.0.0/flex_blog/receivers.py +120 -0
  45. django_flex_blog-1.0.0/flex_blog/rendering.py +122 -0
  46. django_flex_blog-1.0.0/flex_blog/search.py +72 -0
  47. django_flex_blog-1.0.0/flex_blog/serializers/__init__.py +51 -0
  48. django_flex_blog-1.0.0/flex_blog/serializers/articles.py +260 -0
  49. django_flex_blog-1.0.0/flex_blog/serializers/comments.py +99 -0
  50. django_flex_blog-1.0.0/flex_blog/serializers/misc.py +55 -0
  51. django_flex_blog-1.0.0/flex_blog/serializers/taxonomy.py +108 -0
  52. django_flex_blog-1.0.0/flex_blog/services/__init__.py +8 -0
  53. django_flex_blog-1.0.0/flex_blog/services/articles.py +188 -0
  54. django_flex_blog-1.0.0/flex_blog/services/comments.py +190 -0
  55. django_flex_blog-1.0.0/flex_blog/services/engagement.py +54 -0
  56. django_flex_blog-1.0.0/flex_blog/services/maintenance.py +37 -0
  57. django_flex_blog-1.0.0/flex_blog/services/newsletter.py +188 -0
  58. django_flex_blog-1.0.0/flex_blog/signals.py +98 -0
  59. django_flex_blog-1.0.0/flex_blog/sitemaps.py +114 -0
  60. django_flex_blog-1.0.0/flex_blog/tasks.py +126 -0
  61. django_flex_blog-1.0.0/flex_blog/templates/flex_blog/email/new_article.txt +12 -0
  62. django_flex_blog-1.0.0/flex_blog/templates/flex_blog/email/new_article_subject.txt +1 -0
  63. django_flex_blog-1.0.0/flex_blog/templates/flex_blog/email/newsletter_confirm.txt +8 -0
  64. django_flex_blog-1.0.0/flex_blog/templates/flex_blog/email/newsletter_confirm_subject.txt +1 -0
  65. django_flex_blog-1.0.0/flex_blog/templates/flex_blog/email/notification.txt +9 -0
  66. django_flex_blog-1.0.0/flex_blog/urls.py +60 -0
  67. django_flex_blog-1.0.0/flex_blog/urls_utils.py +25 -0
  68. django_flex_blog-1.0.0/flex_blog/utils.py +11 -0
  69. django_flex_blog-1.0.0/flex_blog/validators.py +106 -0
  70. django_flex_blog-1.0.0/flex_blog/views/__init__.py +19 -0
  71. django_flex_blog-1.0.0/flex_blog/views/articles.py +308 -0
  72. django_flex_blog-1.0.0/flex_blog/views/comments.py +101 -0
  73. django_flex_blog-1.0.0/flex_blog/views/misc.py +166 -0
  74. django_flex_blog-1.0.0/flex_blog/views/taxonomy.py +169 -0
  75. django_flex_blog-1.0.0/flex_blog/webhooks.py +216 -0
  76. django_flex_blog-1.0.0/pyproject.toml +82 -0
  77. django_flex_blog-1.0.0/setup.cfg +4 -0
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 (2026-10-01)
4
+
5
+ First public release: a complete rewrite of the 0.1 prototype.
6
+
7
+ ### Added
8
+ - Articles with Markdown/HTML/plain content, sanitised rendering, table of contents, reading time,
9
+ scheduling, visibility (public / unlisted / members), SEO fields, revisions, slug redirects,
10
+ preview links and optimistic locking.
11
+ - Authors, hierarchical categories, tags (created by name), series with navigation.
12
+ - Threaded comments with moderation modes, spam hooks, honeypot, flagging, edit window and soft delete.
13
+ - Reactions, bookmarks, view counting with de-duplication.
14
+ - Double opt-in newsletter with batched, exactly-once delivery.
15
+ - Domain event signals; in-app and email notifications with pluggable channel backends; signed webhooks with retries.
16
+ - Idempotency-Key support, per-action throttling, anonymous response caching.
17
+ - Optional Celery backend; `blog_maintenance`, `blog_recount`, `blog_export`, `blog_import`,
18
+ `blog_seed`, `blog_setup_roles`, `blog_cleanup` commands.
19
+ - RSS/Atom feeds, sitemap, search (database or PostgreSQL full-text), system checks.
20
+
21
+ ### Changed (from 0.1)
22
+ - UUID primary keys always (`USE_UUID` and swappable `BLOG_MODELS` removed: they cannot work with
23
+ shipped migrations). Extend models through `extra_data`, services, signals and serializer overrides.
24
+ - Dropped `django-mptt`, `django-taggit` and `python-slugify` dependencies.
25
+ - `cleanup_blog`/`export_blog`/`import_blog` replaced by `blog_*` commands; cleanup never deletes
26
+ drafts unless asked.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NzeStan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,5 @@
1
+ include LICENSE README.md CHANGELOG.md SECURITY.md
2
+ recursive-include flex_blog/templates *
3
+ recursive-include flex_blog/locale *
4
+ prune tests
5
+ global-exclude *.py[cod] __pycache__
@@ -0,0 +1,459 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-flex-blog
3
+ Version: 1.0.0
4
+ Summary: A complete, secure, headless blog backend for Django REST Framework: articles, comments, reactions, newsletter, notifications, webhooks, feeds and more. Every feature optional.
5
+ Author: NzeStan
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/NzeStan/Django-flex-blog
8
+ Project-URL: Documentation, https://github.com/NzeStan/Django-flex-blog#readme
9
+ Project-URL: Issues, https://github.com/NzeStan/Django-flex-blog/issues
10
+ Project-URL: Changelog, https://github.com/NzeStan/Django-flex-blog/blob/main/CHANGELOG.md
11
+ Keywords: django,blog,cms,headless,rest,api,django-rest-framework,newsletter,comments
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 4.2
16
+ Classifier: Framework :: Django :: 5.0
17
+ Classifier: Framework :: Django :: 5.1
18
+ Classifier: Framework :: Django :: 5.2
19
+ Classifier: Framework :: Django :: 6.0
20
+ Classifier: Intended Audience :: Developers
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Programming Language :: Python :: 3
23
+ Classifier: Programming Language :: Python :: 3.10
24
+ Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
27
+ Classifier: Programming Language :: Python :: 3.14
28
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
29
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary
30
+ Requires-Python: >=3.10
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ Requires-Dist: Django>=4.2
34
+ Requires-Dist: djangorestframework>=3.14
35
+ Requires-Dist: django-filter>=23.1
36
+ Requires-Dist: Pillow>=10.0
37
+ Requires-Dist: markdown-it-py>=3.0
38
+ Requires-Dist: nh3>=0.2.14
39
+ Provides-Extra: celery
40
+ Requires-Dist: celery>=5.3; extra == "celery"
41
+ Provides-Extra: postgres
42
+ Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
43
+ Provides-Extra: test
44
+ Requires-Dist: pytest>=8; extra == "test"
45
+ Requires-Dist: pytest-django>=4.8; extra == "test"
46
+ Requires-Dist: pytest-cov>=5; extra == "test"
47
+ Dynamic: license-file
48
+
49
+ # django-flex-blog
50
+
51
+ **A complete, secure, headless blog backend for Django.** Install it, add four lines of configuration, and your frontend (React, Next.js, Vue, Flutter, a mobile app...) has a production-grade blog API: articles, authors, categories, tags, series, threaded comments with moderation, reactions, bookmarks, a double opt-in newsletter, in-app and email notifications, signed webhooks, search, RSS/Atom feeds, sitemaps and an editorial workflow.
52
+
53
+ Every feature can be turned off. Built-in behaviour hangs off documented signals, so you can extend or replace any of it without forking.
54
+
55
+ - Backend only: a JSON REST API built on Django REST Framework, plus the Django admin. No templates or CSS to fight with.
56
+ - Secure by default. All HTML is sanitized, uploads are verified, every write is throttled, permissions are role based, and private data is never exposed.
57
+ - Built for traffic. Counters are atomic, anonymous responses are cached and invalidated by generation, indexed queries avoid N+1, and Celery is optional.
58
+ - Idempotent. `Idempotency-Key` headers are supported, publishing and newsletters happen exactly once, and repeated PUT/DELETE calls are harmless.
59
+ - Works on Django 4.2 to 6.x and Python 3.10 to 3.14, with SQLite or PostgreSQL.
60
+
61
+ ---
62
+
63
+ ## Contents
64
+
65
+ 1. [Quick start (10 minutes)](#quick-start)
66
+ 2. [What you get: API reference](#api-reference)
67
+ 3. [Roles and permissions](#roles-and-permissions)
68
+ 4. [Configuration](#configuration)
69
+ 5. [Events, plugins and notifications](#events-plugins-and-notifications)
70
+ 6. [Background tasks and Celery](#background-tasks-and-celery)
71
+ 7. [Idempotency and concurrency](#idempotency-and-concurrency)
72
+ 8. [Security](#security)
73
+ 9. [Going to production](#going-to-production)
74
+ 10. [Customising](#customising)
75
+ 11. [Management commands](#management-commands)
76
+
77
+ ---
78
+
79
+ ## Quick start
80
+
81
+ ```bash
82
+ pip install django-flex-blog
83
+ # optional extras: django-flex-blog[celery] django-flex-blog[postgres]
84
+ ```
85
+
86
+ **1. Settings**
87
+
88
+ ```python
89
+ INSTALLED_APPS = [
90
+ # ...django.contrib apps...
91
+ "rest_framework",
92
+ "django_filters",
93
+ "flex_blog",
94
+ ]
95
+
96
+ FLEX_BLOG = {
97
+ "SITE_URL": "https://example.com", # your public site, used in emails and feeds
98
+ "SITE_NAME": "My Blog",
99
+ }
100
+
101
+ MEDIA_ROOT = BASE_DIR / "media" # where uploads go (or configure STORAGES for S3 etc.)
102
+ MEDIA_URL = "/media/"
103
+ ```
104
+
105
+ **2. URLs**
106
+
107
+ ```python
108
+ from django.urls import include, path
109
+
110
+ urlpatterns = [
111
+ # ...
112
+ path("api/blog/", include("flex_blog.urls")),
113
+ ]
114
+ ```
115
+
116
+ **3. Database and roles**
117
+
118
+ ```bash
119
+ python manage.py migrate
120
+ python manage.py blog_setup_roles # creates "Blog authors", "Blog editors", "Blog moderators" groups
121
+ python manage.py blog_seed # optional demo content
122
+ ```
123
+
124
+ **4. Done.** Open `/api/blog/` for the browsable API, and use `/admin/` to write.
125
+
126
+ ```bash
127
+ curl https://example.com/api/blog/articles/
128
+ ```
129
+
130
+ Superusers can do everything. To give other people access, add them to one of the groups in the admin.
131
+
132
+ ---
133
+
134
+ ## API reference
135
+
136
+ All paths below are relative to where you mounted `flex_blog.urls`. Lists are paginated (`?page=`, `?page_size=` up to 100) and return `{count, next, previous, results}`.
137
+
138
+ ### Articles
139
+
140
+ | Method & path | Who | What |
141
+ |---|---|---|
142
+ | `GET /articles/` | anyone | Public feed. Pinned articles come first. |
143
+ | `GET /articles/?q=jollof rice` | anyone | Search, ranked by relevance |
144
+ | `GET /articles/?category=tech&tag=django,python&author=ada&series=course&language=yo&is_featured=true&year=2026&month=3&published_after=…&ordering=-views_count` | anyone | Filters (combine freely). `category` includes sub-categories. |
145
+ | `GET /articles/?scope=mine` | authors | Your own articles in any status |
146
+ | `GET /articles/?scope=all&status=review` | editors | All articles, e.g. the review queue |
147
+ | `GET /articles/{slug}/` | anyone | Detail: `content_html` (sanitized), `table_of_contents`, `reactions`, `viewer` state, `series_navigation`. Old slugs return **301** to the new one. |
148
+ | `GET /articles/{slug}/?preview={token}` | link holders | View an unpublished article |
149
+ | `POST /articles/` | authors | Create (JSON or multipart with `cover_image`). Tags are names and are created on the fly; categories and series are slugs. |
150
+ | `PATCH /articles/{slug}/` | owner, editors | Update. Send `version` to get a **409** instead of overwriting a concurrent edit. |
151
+ | `DELETE /articles/{slug}/` | owner, editors | Delete |
152
+ | `POST /articles/{slug}/publish/` | publishers | Publish now, or `{"published_at": "<future>"}` to schedule. Idempotent. |
153
+ | `POST /articles/{slug}/unpublish/` | publishers | Back to draft, or `{"status": "archived"}` |
154
+ | `POST /articles/{slug}/submit/` | authors | Send to editorial review |
155
+ | `POST /articles/{slug}/preview-link/` | owner, editors | Returns `{token, url, expires_in}` |
156
+ | `GET /articles/{slug}/revisions/` | owner, editors | Edit history |
157
+ | `POST /articles/{slug}/revisions/{id}/restore/` | owner, editors | Roll back |
158
+ | `GET /articles/{slug}/related/` | anyone | Articles sharing tags/categories |
159
+ | `GET /articles/popular/?days=30` | anyone | Most viewed recently |
160
+ | `GET /articles/archive/` | anyone | `[{year, month, count}]` |
161
+ | `PUT` / `DELETE /articles/{slug}/reactions/{kind}/` | signed in | React / un-react (idempotent) |
162
+ | `PUT` / `DELETE /articles/{slug}/bookmark/` | signed in | Save for later (idempotent) |
163
+ | `GET` / `POST /articles/{slug}/comments/` | anyone / commenters | Comments of an article (flat list with `parent` and `depth`; `?parent=none` for top level) |
164
+
165
+ Article writes accept: `title, subtitle, slug, summary, content, content_format (markdown|html|plain), categories, tags, series, series_order, cover_image, cover_image_alt, status, visibility (public|unlisted|members), published_at, allow_comments, language, meta_title, meta_description, canonical_url, noindex, extra_data, version`. Editors can also set `is_featured`, `is_pinned` and `author`.
166
+
167
+ ### Comments
168
+
169
+ | Method & path | Who | What |
170
+ |---|---|---|
171
+ | `GET /comments/?article={slug}&parent={id\|none}&status=pending` | anyone (moderators see all statuses) | Site-wide list |
172
+ | `POST /comments/` | commenters | `{article, content, parent?}` (guests also send `author_name`, `author_email`) |
173
+ | `PATCH /comments/{id}/` | author (within the edit window), moderators | Edit `content` |
174
+ | `DELETE /comments/{id}/` | author, moderators | Soft delete; replies keep their thread |
175
+ | `POST /comments/{id}/approve/` `…/reject/` `…/spam/` | moderators | Moderate (idempotent) |
176
+ | `POST /comments/{id}/flag/` | signed in | Report; enough flags send it back to moderation |
177
+
178
+ ### Everything else
179
+
180
+ | Path | What |
181
+ |---|---|
182
+ | `GET /authors/`, `GET /authors/{slug}/` | Public profiles with article counts |
183
+ | `GET` / `PATCH /authors/me/` | The signed-in author's own profile |
184
+ | `/categories/`, `/categories/tree/`, `/tags/`, `/series/` | Taxonomy (writes need the matching model permission) |
185
+ | `GET /search/?q=` | One-box search: top articles, categories, tags, authors |
186
+ | `/media/` | Media library for authors (multipart `file` upload) |
187
+ | `GET /bookmarks/` | The reader's saved articles |
188
+ | `GET /notifications/`, `GET …/unread-count/`, `POST …/{id}/read/`, `POST …/read-all/` | In-app inbox |
189
+ | `POST /newsletter/subscribe/`, `…/confirm/`, `…/unsubscribe/` | Double opt-in newsletter (`{email}` / `{token}`) |
190
+ | `GET /stats/` | Dashboard numbers (editors) |
191
+ | `GET /feeds/rss/`, `/feeds/atom/`, `/feeds/category/{slug}/`, `/feeds/tag/{slug}/`, `/feeds/author/{slug}/` | Feeds |
192
+ | `GET /sitemap.xml` | Sitemap of the frontend URLs |
193
+
194
+ ### Frontend URLs
195
+
196
+ The API is headless, so tell it where readers see things. These values are used in feeds, sitemaps, emails and webhooks:
197
+
198
+ ```python
199
+ FLEX_BLOG = {
200
+ "SITE_URL": "https://example.com",
201
+ "FRONTEND_URLS": {
202
+ "article": "/blog/{slug}/",
203
+ "newsletter_confirm": "/newsletter/confirm/?token={token}",
204
+ "newsletter_unsubscribe": "/newsletter/unsubscribe/?token={token}",
205
+ "article_preview": "/blog/preview/{slug}/?token={token}",
206
+ # also: category, tag, author, series
207
+ },
208
+ }
209
+ ```
210
+
211
+ Your newsletter confirmation page reads `token` from its own URL and `POST`s it to `/newsletter/confirm/`.
212
+
213
+ ---
214
+
215
+ ## Roles and permissions
216
+
217
+ Roles are plain Django permissions, so you manage them with groups in the admin (`blog_setup_roles` creates sensible groups):
218
+
219
+ | Role | Permission | Can |
220
+ |---|---|---|
221
+ | Reader | none | Read, comment (if allowed), react, bookmark |
222
+ | Author | `flex_blog.add_article` | Write and edit **own** articles, upload media, manage own profile |
223
+ | Publisher | `flex_blog.publish_article` | Publish / schedule / unpublish |
224
+ | Editor | `flex_blog.change_article` | Edit any article, feature/pin, set the author |
225
+ | Moderator | `flex_blog.moderate_comment` | Approve/reject comments, see commenter emails |
226
+
227
+ - **One-person blog?** Set `"ARTICLES": {"require_publish_permission": False}` so every author can publish.
228
+ - **Your own rules?** Use `"CAN_AUTHOR": "myapp.rules.can_author"` (a function `(user) -> bool`).
229
+ - **Paywall or members area?** Articles with `visibility="members"` show their teaser to everyone but full content only to signed-in users. To plug in your subscription check, use `"CONTENT_ACCESS_CHECK": "myapp.billing.can_read"` (a function `(user, article) -> bool`).
230
+
231
+ ---
232
+
233
+ ## Configuration
234
+
235
+ Everything lives in one dict. Anything you don't set falls back to the defaults, and nested dicts are merged, so you only override the keys you need. Below are the defaults:
236
+
237
+ ```python
238
+ FLEX_BLOG = {
239
+ "FEATURES": { # set any to False to remove it completely
240
+ "comments": True, "reactions": True, "bookmarks": True, "series": True, "media": True,
241
+ "newsletter": True, "notifications": True, "webhooks": True, "search": True,
242
+ "feeds": True, "sitemaps": True, "revisions": True, "view_counting": True,
243
+ "idempotency": True, "response_cache": True, "preview_links": True,
244
+ "slug_redirects": True, "admin": True,
245
+ },
246
+ "ARTICLES": {
247
+ "content_formats": ["markdown", "html", "plain"], "default_content_format": "markdown",
248
+ "words_per_minute": 200, "excerpt_length": 300, "max_tags": 10, "max_categories": 5,
249
+ "require_publish_permission": True, "auto_create_author_profile": True,
250
+ "slug_allow_unicode": False, "related_count": 5, "preview_link_max_age": 259200,
251
+ },
252
+ "COMMENTS": {
253
+ "allow_anonymous": False,
254
+ "moderation": "first_time", # none | anonymous | first_time | all
255
+ "max_depth": 4, "min_length": 2, "max_length": 5000, "max_links": 3,
256
+ "blocked_words": [], "edit_window_minutes": 15, "close_after_days": None,
257
+ "store_ip_address": False, "honeypot_field": "website_hp", "flag_threshold": 5,
258
+ "spam_checker": None, # "myapp.spam.check" -> (comment, request) -> bool
259
+ "markdown": True,
260
+ },
261
+ "REACTIONS": {"kinds": ["like"]}, # e.g. ["like", "love", "clap", "fire"]
262
+ "VIEW_COUNTING": {"dedupe_seconds": 1800, "async": False},
263
+ "NEWSLETTER": {"double_opt_in": True, "confirm_max_age": 259200, "send_on_publish": True,
264
+ "batch_size": 200, "from_email": None},
265
+ "NOTIFICATIONS": {
266
+ "backends": ["flex_blog.notifications.backends.InAppBackend",
267
+ "flex_blog.notifications.backends.EmailBackend"],
268
+ "events": {"comment_on_article": True, "comment_reply": True,
269
+ "comment_approved": True, "comment_pending": True},
270
+ "moderator_emails": [], "recipient_filter": None, "from_email": None,
271
+ },
272
+ "WEBHOOKS": {"timeout": 5, "max_retries": 5, "allow_http": False, "allow_private_hosts": False},
273
+ "TASKS": {"backend": "sync", "queue": None}, # or "celery"
274
+ "CACHE": {"alias": "default", "timeout": 300, "key_prefix": "flexblog"},
275
+ "THROTTLE_RATES": {"comments": "10/min", "reactions": "60/min", "newsletter": "5/hour",
276
+ "search": "60/min", "uploads": "30/hour", "write": "120/min"},
277
+ "PAGINATION": {"page_size": 20, "max_page_size": 100},
278
+ "MEDIA": {"storage": None, "upload_to": "flex_blog/%Y/%m/", "max_upload_size": 10485760,
279
+ "allowed_extensions": ["jpg", "jpeg", "png", "gif", "webp", "avif", "pdf", "mp4", "mp3"],
280
+ "max_image_pixels": 50000000},
281
+ "IDEMPOTENCY": {"header": "Idempotency-Key", "ttl_seconds": 86400},
282
+ "SEARCH": {"backend": "flex_blog.search.DatabaseSearchBackend", "config": "english"},
283
+ "FEEDS": {"items": 20, "description": ""},
284
+ "SANITIZER": {...}, # allow-listed tags/attributes/URL schemes
285
+ "SERIALIZERS": {}, # swap any serializer, see "Customising"
286
+ "CONTENT_ACCESS_CHECK": None,
287
+ "CAN_AUTHOR": None,
288
+ }
289
+ ```
290
+
291
+ `python manage.py check` validates your settings (typos, missing apps, imports that can't be resolved), and `check --deploy` warns about production pitfalls.
292
+
293
+ ---
294
+
295
+ ## Events, plugins and notifications
296
+
297
+ **Domain events are the plugin system.** Everything the package does after a state change (notifications, newsletters, webhooks, cache invalidation) is an ordinary receiver of these signals. Your code is a first-class citizen right next to it.
298
+
299
+ ```python
300
+ from django.dispatch import receiver
301
+ from flex_blog.signals import article_published, comment_posted
302
+
303
+ @receiver(article_published)
304
+ def share_on_socials(sender, article, event_id, **kwargs):
305
+ ... # event_id is unique per event: use it as your own idempotency key
306
+
307
+ @receiver(comment_posted)
308
+ def index_for_moderation_ai(sender, comment, **kwargs):
309
+ ...
310
+ ```
311
+
312
+ | Event | Arguments |
313
+ |---|---|
314
+ | `article_published` | `article` (fires once per article, ever, even when scheduled) |
315
+ | `article_unpublished` | `article` |
316
+ | `article_viewed` | `article`, `user` (after view de-duplication) |
317
+ | `comment_posted` | `comment` (any status) |
318
+ | `comment_approved` | `comment`, `moderator` (None when auto-approved) |
319
+ | `comment_rejected` | `comment`, `moderator`, `status` |
320
+ | `reaction_added` / `reaction_removed` | `reaction` / `article, user, kind` |
321
+ | `bookmark_added` | `bookmark` |
322
+ | `subscriber_confirmed` / `subscriber_unsubscribed` | `subscriber` |
323
+
324
+ Events are sent **after the database transaction commits** (so receivers never see rolled-back data), and a failing receiver is logged but never breaks the request or the other receivers.
325
+
326
+ ### How notifications are wired (and why)
327
+
328
+ ```
329
+ event ──► receiver decides who should hear about it ──► NotificationMessage
330
+ ──► queued after commit (sync or Celery) ──► every channel backend .send()
331
+ ```
332
+
333
+ - **Who gets notified** is decided by receivers: the article author on a new comment, the parent commenter on a reply, the commenter when their comment is approved, and `moderator_emails` when something waits for moderation. Toggle each under `NOTIFICATIONS["events"]`, or filter per user with `recipient_filter` (a hook for "user preferences").
334
+ - **How they're delivered** is decided by channel backends. In-app (`/notifications/` inbox) and email ship built in, and adding SMS, WhatsApp, push or Slack takes about ten lines:
335
+
336
+ ```python
337
+ from flex_blog.notifications.backends import BaseBackend
338
+
339
+ class SMSBackend(BaseBackend):
340
+ name = "sms"
341
+ def send(self, message): # message.title, .body, .url, .event, .get_recipient()
342
+ user = message.get_recipient()
343
+ termii.send(user.profile.phone, message.title)
344
+
345
+ FLEX_BLOG = {"NOTIFICATIONS": {"backends": [
346
+ "flex_blog.notifications.backends.InAppBackend",
347
+ "myapp.notify.SMSBackend",
348
+ ]}}
349
+ ```
350
+
351
+ - **Exactly once per channel.** Each message carries a `dedupe_key`. Delivery claims it in the database first, so a retried task never sends twice. If sending fails, the claim is released so a retry can succeed.
352
+ - **Your own notifications** can use the same pipeline: `from flex_blog.notifications import notify, NotificationMessage`.
353
+ - **Email text** comes from overridable templates in `flex_blog/email/*.txt`.
354
+
355
+ ### Webhooks (no code)
356
+
357
+ In the admin, add a **Webhook endpoint** with a URL and the events you want (or `["*"]`). Each event is POSTed as JSON, and every request is signed:
358
+
359
+ ```
360
+ X-FlexBlog-Event: article_published
361
+ X-FlexBlog-Delivery: <event id, the same on every retry>
362
+ X-FlexBlog-Signature: t=<timestamp>,v1=<HMAC-SHA256(secret, "<t>.<body>")>
363
+ ```
364
+
365
+ Verify the signature with `flex_blog.webhooks.verify_signature(secret, body, header)`.
366
+
367
+ - Failed deliveries retry with exponential backoff, and every attempt is logged in the admin.
368
+ - URLs must be `https` and may not point to private or internal addresses (SSRF protection), and redirects are not followed.
369
+
370
+ ---
371
+
372
+ ## Background tasks and Celery
373
+
374
+ Background work (newsletters, notifications, webhooks, view counters) goes through `flex_blog.tasks.enqueue` and always runs **after the transaction commits**:
375
+
376
+ - `"sync"` (default): runs in the same process. Nothing extra to install, which is perfect to start with.
377
+ - `"celery"`: sends the work to your Celery workers. Recommended once your newsletter has thousands of subscribers.
378
+
379
+ ```python
380
+ # pip install django-flex-blog[celery]
381
+ FLEX_BLOG = {"TASKS": {"backend": "celery", "queue": "blog"}}
382
+
383
+ CELERY_BEAT_SCHEDULE = {
384
+ "flex-blog-maintenance": {"task": "flex_blog.run_maintenance", "schedule": 60.0},
385
+ }
386
+ ```
387
+
388
+ No Celery? Run `python manage.py blog_maintenance` every minute from cron. It publishes scheduled articles, retries failed webhooks and cleans up expired data. Even without it, scheduled articles become visible at the right time, because visibility is computed from `published_at`. Maintenance only fires the "published" event (emails, webhooks) for them.
389
+
390
+ ---
391
+
392
+ ## Idempotency and concurrency
393
+
394
+ - **`Idempotency-Key` header** on any POST/PUT/PATCH/DELETE: the first request runs, retries with the same key get the stored response back (`Idempotent-Replayed: true`) without running anything again, a retry while the original is still running gets 409, and reusing a key with a different body gets 422. Keys are scoped per user (per IP hash for guests) and expire after `ttl_seconds`. This is safe across many servers because it is enforced by a unique database constraint.
395
+ - **Idempotent by design**: publish, unpublish, approve, reactions, bookmarks, subscribe, confirm and unsubscribe can all be repeated safely.
396
+ - **Exactly-once side effects**: `article_published` is guarded by a conditional UPDATE, and newsletter emails, notifications and webhooks are guarded by unique delivery receipts.
397
+ - **No lost updates**: article edits accept `version`. A stale edit gets **409 Conflict**, and the row is locked while it is checked.
398
+ - **Atomic counters**: views, comments and reactions use `UPDATE … SET n = n + 1`, never read-modify-write. Run `blog_recount` to rebuild them from scratch at any time.
399
+ - **Race-safe slugs and tags**: losing a unique-constraint race retries instead of failing.
400
+
401
+ ---
402
+
403
+ ## Security
404
+
405
+ - **XSS:** all HTML (Markdown, HTML and comments) is sanitized once, on save, with [nh3](https://github.com/messense/nh3) using a strict allow-list. Links get `rel="noopener noreferrer nofollow"`, `javascript:` URLs are removed, and comments get an even tighter allow-list.
406
+ - **Uploads:** size limit, extension allow-list, and a check with Pillow that the file really is the image it claims to be. This blocks polyglots, HTML/SVG renamed to `.png`, and decompression bombs. Files get random names.
407
+ - **Abuse:** per-action throttles (comments, reactions, newsletter, search, uploads, writes), a comment honeypot, link and length limits, blocked words, a pluggable spam checker (Akismet etc.), and flagging.
408
+ - **Privacy:** commenter emails and IPs are only shown to moderators, storing IPs is off by default, newsletter answers never reveal whether an address is subscribed, and drafts are never counted in public counters.
409
+ - **Access:** UUID primary keys, explicit serializer allow-lists (no mass assignment), drafts return 404 to non-owners, and the revision history is visible to editors only.
410
+ - **Webhooks:** HMAC-signed, https only, SSRF guarded, no redirects.
411
+
412
+ ---
413
+
414
+ ## Going to production
415
+
416
+ 1. Use **Redis or Memcached** as the cache. Throttling, view de-duplication and response caching need a cache that all workers share (`check --deploy` warns otherwise).
417
+ 2. Behind a load balancer, set `REST_FRAMEWORK["NUM_PROXIES"]` so client IPs (throttles) can't be spoofed.
418
+ 3. Set `SITE_URL`, a real `EMAIL_BACKEND`/`MAILERS`, and `DEFAULT_FROM_EMAIL`.
419
+ 4. Use `TASKS = {"backend": "celery"}` for large newsletters, and schedule `flex_blog.run_maintenance`.
420
+ 5. PostgreSQL? Switch to ranked full-text search: `"SEARCH": {"backend": "flex_blog.search.PostgresSearchBackend"}`.
421
+ 6. Store media on S3/GCS: define a storage in Django's `STORAGES` and set `"MEDIA": {"storage": "<alias>"}`.
422
+
423
+ ---
424
+
425
+ ## Customising
426
+
427
+ - **Serializers:** swap any of them by name: `"SERIALIZERS": {"article_detail": "myapp.api.ArticleDetail"}`. The names are `article_list`, `article_detail`, `article_write`, `comment`, `comment_create`, `author`, `author_profile`, `category`, `tag`, `series` and `media`. Subclass ours to add fields.
428
+ - **Extra data without migrations:** articles, authors, categories, series, media and comments have an `extra_data` JSON field. Article `extra_data` is public, so don't put secrets in it.
429
+ - **Views:** every viewset is importable from `flex_blog.views`. Subclass one and register your own router if you need to.
430
+ - **Search:** subclass `flex_blog.search.BaseSearchBackend` (e.g. for Meilisearch or Elasticsearch) and keep your index fresh with the signals.
431
+ - **Admin:** set `FEATURES["admin"] = False` and register your own, or reuse `flex_blog.admin.ArticleAdmin`.
432
+ - **Business logic:** use the same functions the API uses: `flex_blog.services.articles.publish(article)`, `services.comments.moderate(...)`, `services.engagement.add_reaction(...)` and so on.
433
+ - **Translations:** every string is translatable, and articles have a `language` field validated against `settings.LANGUAGES`.
434
+
435
+ ---
436
+
437
+ ## Management commands
438
+
439
+ | Command | What |
440
+ |---|---|
441
+ | `blog_setup_roles` | Create the author/editor/moderator groups (idempotent) |
442
+ | `blog_seed` | Demo content to play with (idempotent) |
443
+ | `blog_maintenance` | Announce scheduled articles, retry webhooks, purge expired data. Run every minute. |
444
+ | `blog_recount` | Rebuild comment/reaction counters |
445
+ | `blog_export [-o file.json] [--no-comments]` | Export everything as portable JSON |
446
+ | `blog_import file.json` | Import (idempotent: re-running updates rather than duplicates, and never emails subscribers) |
447
+ | `blog_cleanup [--spam-days 30] [--drafts-days N] [--dry-run]` | Delete old spam; drafts only if you ask |
448
+
449
+ ---
450
+
451
+ ## Development
452
+
453
+ ```bash
454
+ pip install -e ".[test,celery]"
455
+ pytest # SQLite
456
+ FLEX_BLOG_TEST_DB=postgres pytest # adds real parallel-write tests on PostgreSQL
457
+ ```
458
+
459
+ MIT licensed. Contributions welcome!