Small Django blog/news app with multi-site support (django.contrib.sites).
Project description
django-site-blog
Small Django blog / news app with multi-site support
(django.contrib.sites).
Lets editors restrict an article to one or more Site objects, or
leave the assignment empty so the article appears on every site that
runs the same Django project.
Why?
Many Django projects host multiple sites — a corporate front page, a
research portal, project subsites — from a single codebase using
django.contrib.sites. Most blog/news apps either ignore that scenario
or expect a separate deployment per site.
django-site-blog keeps one article store and lets editors say, per
article, whether it should appear on every site (the default) or be
restricted to a chosen subset. One schema, one query, no middleware
gymnastics.
Features
- Per-article site assignment via M2M to
django.contrib.sites.models.Site. - "Empty M2M = visible everywhere" default — restrict only when you need to.
draft/publishedstatus viamodel_utils.StatusModel; admin-awareget_absolute_urlso drafts link back into the admin.- Split-marker excerpts via
model_utils.SplitField(marker configurable through theSPLIT_MARKERsetting). - Multi-site-aware
ArticleDetailView(slug-based, resolves the active site viadjango.contrib.sites.shortcuts.get_current_site(request)— honorsCurrentSiteMiddlewarefor per-Host deployments and falls back tosettings.SITE_IDfor single-site projects). Article.on_siteCurrentSiteManagerfor pure "current-site only" semantics when the empty-equals-all default isn't what you want.- Polish translation included; rest of the UI is
gettext_lazy-ready. - Admin with
filter_horizontal,list_filter, slug prepopulation.
Supported versions
Django × Python
| Django | 3.10 | 3.11 | 3.12 | 3.13 | 3.14 | Status |
|---|---|---|---|---|---|---|
| 5.2 LTS | ✓ | ✓ | ✓ | ✓ | ✓ | Active LTS (extended support Apr 2028) |
| 6.0 | — | — | ✓ | ✓ | ✓ | Mainstream Aug 2026, extended Apr 2027 |
Verified against the CI matrix in
.github/workflows/tests.yml. Also
requires django-model-utils
>=5,<6 (for SplitField, TimeStampedModel, StatusModel). The 5.x
release changed how SplitField auto-injects its _article_body_excerpt
column at runtime; the shipped 0001_initial migration uses
SeparateDatabaseAndState to keep the migration state and the
database in sync without emitting the column twice on a fresh
migrate.
Installation
uv add django-site-blog
or with pip:
pip install django-site-blog
Add the app and django.contrib.sites to INSTALLED_APPS:
INSTALLED_APPS = [
# ...
"django.contrib.sites",
"siteblog",
]
SITE_ID = 1 # required by django.contrib.sites
Include the URLs in your project's urls.py:
urlpatterns = [
# ...
path("articles/", include("siteblog.urls")),
]
Run migrations:
python manage.py migrate
How site assignment works
Each Article has a sites M2M to django.contrib.sites.models.Site.
- Empty M2M → article visible on every site (the default).
- One or more sites set → article only visible on the listed sites.
The included ArticleDetailView resolves the active site via
django.contrib.sites.shortcuts.get_current_site(request) and then runs
the inverted-default query through the chainable ArticleQuerySet:
from django.contrib.sites.shortcuts import get_current_site
site = get_current_site(request)
Article.objects.published().visible_on_site(site)
get_current_site() honors request.site set by CurrentSiteMiddleware
(per-Host multi-site deployments) and transparently falls back to
settings.SITE_ID for single-site projects — so the same view works in
both modes without configuration. visible_on_site() accepts either a
Site instance or its primary key.
For list / feed views in your own project, prefer this method over
Article.on_site — the latter is a plain CurrentSiteManager and treats
empty-M2M articles as invisible everywhere, which silently drops your
"visible on every site" default.
Settings
| Setting | Default | Purpose |
|---|---|---|
SITE_ID |
— | Required by django.contrib.sites. Drives the per-site filtering. |
SPLIT_MARKER |
"<!-- split -->" |
Marker shown in the admin help text for Article.article_body to indicate the split-point between excerpt and full article. The actual splitting is performed by model_utils.fields.SplitField per its own settings. |
Templates
The package ships two minimal templates:
siteblog/article_detail.html— uses{% extends "siteblog/base.html" %}.siteblog/base.html— a bare HTML skeleton; override it in your project by placing asiteblog/base.htmlahead of the package's in yourTEMPLATESDIRS.
Security — trust boundary on article_body
siteblog/article_detail.html renders article.article_body.content
through Django's |safe filter, so HTML written by editors is emitted
verbatim. This is deliberate — the field is intended for authoring
formatted content (<strong>, <h2>, <a>, <blockquote>, etc.) —
but it makes the admin a trusted authoring surface.
If everyone with change_article permission is trusted to author HTML
(the default Django-admin assumption), no extra work is required. If
that is not the case in your deployment, either:
- sanitize on save (e.g. a
clean_article_bodythat runsbleachover the value), or - override
siteblog/article_detail.htmlin your project and drop|safe(renders the raw markup as text).
The same caveat applies to any list / excerpt view you build on top of
article_body.excerpt.
Example / demo project
The repository ships with a runnable demo at example/
that wires siteblog together with four django.contrib.sites rows
(example.com, mac-mini, localhost, 127.0.0.1) so you can see
per-host filtering in action from a single dev server. The seeded
articles use the <!-- split --> marker, so the homepage shows just
the excerpt with a "Read more →" link, while the detail page renders
the full body with <strong> / <em> / <h2> / lists / blockquotes /
<code> to illustrate the package's |safe rendering path.
Quick start:
cd example
DJANGO_SETTINGS_MODULE=example_project.settings uv run python manage.py migrate
DJANGO_SETTINGS_MODULE=example_project.settings uv run python manage.py seed_demo
DJANGO_SETTINGS_MODULE=example_project.settings uv run python manage.py createsuperuser
DJANGO_SETTINGS_MODULE=example_project.settings uv run python manage.py runserver
Then visit http://localhost:8000/. The header shows a "Sign in to
admin" link pointing at /admin/login/, which switches to "Admin
(username)" once you sign in.
Optional: TinyMCE rich text editor in the admin
The package ships with a plain <textarea> for article_body. The
example project can swap that for a TinyMCE
editor via the rich-editor extras dependency:
# from the repository root:
uv sync --extra rich-editor # installs django-tinymce
cd example
RICH_EDITOR=1 DJANGO_SETTINGS_MODULE=example_project.settings \
uv run python manage.py runserver
When RICH_EDITOR=1 is set, the example's settings.py adds
tinymce to INSTALLED_APPS, the URLconf mounts tinymce.urls, and
example_project/admin.py (auto-loaded by Django's admin
autodiscover) unregisters the package's ArticleAdmin and
re-registers it with a TinyMCE widget on article_body. The model,
migrations, and public view are unchanged — only the admin form
widget differs, so the rich editor is purely a demo-side overlay you
can opt into in your own project.
If RICH_EDITOR=1 is set but django-tinymce is missing,
settings.py raises a clear ImproperlyConfigured at startup with
the exact install command, instead of a ModuleNotFoundError from
deep in Django's app registry.
See example/README.md for the multi-host
testing recipe (/etc/hosts vs. curl --resolve) and a full
breakdown of which articles are visible on which hostnames.
Development
git clone https://github.com/iplweb/django-site-blog.git
cd django-site-blog
uv sync --all-extras --all-groups
DJANGO_SETTINGS_MODULE=tests.settings uv run pytest
Dependency layout: the user-facing rich-editor opt-in lives under
[project.optional-dependencies] (published in PyPI metadata), while
the local-only tooling (pre-commit, ruff, pytest, pytest-django)
is declared under PEP 735 [dependency-groups] so it never reaches
published metadata. CI installs them targeted (uv sync --group test
for the matrix, uv sync --group dev for lint).
pre-commit install to wire ruff + pyupgrade + django-upgrade.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_site_blog-0.2.1.tar.gz.
File metadata
- Download URL: django_site_blog-0.2.1.tar.gz
- Upload date:
- Size: 13.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b5d47cb50c7c2994100a548aee603e92dcf91d8f19d172c5914fe079b5e583f8
|
|
| MD5 |
59c5dedf181168a121ca6ae7a6417cb8
|
|
| BLAKE2b-256 |
eb3c3eae4a5043fdf5516fe67df690ebf863916d2e97be924d781eec32451fd9
|
File details
Details for the file django_site_blog-0.2.1-py3-none-any.whl.
File metadata
- Download URL: django_site_blog-0.2.1-py3-none-any.whl
- Upload date:
- Size: 13.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
51372c548ff5d70135b0548d87a92e3c2508bde9ebd6a9db19dd45c2391a1d05
|
|
| MD5 |
d39f062a10be8640fa77648ec2264ab6
|
|
| BLAKE2b-256 |
8d3e0e93d33039fd0b022a37d6ef838ddfee64357bc02c2c818002c012d2662e
|