|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## Package vs. app name mismatch (read this first) |
| 6 | + |
| 7 | +The PyPI distribution is **`django-site-blog`** but the actual Django app — the Python package, the app label, the URL namespace, the migration `app_label`, the template directory, the admin reverse names — is all **`siteblog`**. Examples: |
| 8 | + |
| 9 | +- Source lives at `src/siteblog/`, not `src/django_site_blog/`. |
| 10 | +- `INSTALLED_APPS = ["siteblog"]` (see `tests/settings.py:22`, `example/example_project/settings.py:19`). |
| 11 | +- URL namespace: `app_name = "siteblog"` (`src/siteblog/urls.py:5`); reverse via `siteblog:article-detail`. |
| 12 | +- Admin reverse: `admin:siteblog_article_change` / `admin:siteblog_article_changelist`. |
| 13 | +- Migrations live under `src/siteblog/migrations/` with `app_label="siteblog"`. |
| 14 | +- `pyproject.toml` ships package data under `siteblog = [...]`. |
| 15 | + |
| 16 | +Do **not** rename `siteblog` to `django_site_blog` casually — it would require renaming migrations, every reverse string, the URL namespace, and a migration that rewrites `app_label`. If a rename is on the table, treat it as a dedicated piece of work. |
| 17 | + |
| 18 | +The package was previously named `miniblog` (PyPI: `django-sites-blog`). The rename happened in 2026-05; the migration files and table names switched from the `miniblog` to the `siteblog` app label as part of that change. Downstream installs that were on the old name need to `UPDATE django_migrations SET app='siteblog' WHERE app='miniblog';`, the same for `django_content_type.app_label`, and rename the `miniblog_article` table to `siteblog_article` before re-running migrations. |
| 19 | + |
| 20 | +## Architecture (the big picture) |
| 21 | + |
| 22 | +Single-model reusable Django app. The whole package is one model (`Article`), one view (`ArticleDetailView`), one admin, two templates, four migrations. |
| 23 | + |
| 24 | +### Sites-scoping model — the headline feature |
| 25 | + |
| 26 | +`Article.sites` is an M2M to `django.contrib.sites.models.Site` with **inverted-default semantics**: |
| 27 | + |
| 28 | +- **Empty M2M ⇒ article is visible on every site.** |
| 29 | +- **Non-empty M2M ⇒ article is restricted to the listed sites.** |
| 30 | + |
| 31 | +This is enforced in `ArticleDetailView.get_queryset` (`src/siteblog/views.py:15-20`) with a single OR query: |
| 32 | + |
| 33 | +```python |
| 34 | +qs.filter(Q(sites__isnull=True) | Q(sites__id=site_id)).distinct() |
| 35 | +``` |
| 36 | + |
| 37 | +**`Article.on_site` exists but does NOT implement these semantics.** It is a standard `CurrentSiteManager("sites")` and treats empty-M2M articles as invisible everywhere. Two managers coexist on the model on purpose: |
| 38 | + |
| 39 | +- `Article.objects` — the OR-default manager-less query used by the view. |
| 40 | +- `Article.on_site` — pure current-site-only (no empty-equals-all fallback). |
| 41 | + |
| 42 | +If a future change adds a list view, decide which semantics it should follow and document the choice. Don't silently switch the detail view to `on_site` — it would break the empty-M2M default. |
| 43 | + |
| 44 | +### Status, URLs, content |
| 45 | + |
| 46 | +- Status comes from `model_utils.StatusModel` with `Choices(("draft", _("draft")), ("published", _("published")))`. The view filters to `status="published"`; drafts 404 on the public URL. |
| 47 | +- `get_absolute_url()` is mode-aware: drafts return the admin change URL, published articles return the public detail URL (`src/siteblog/models.py:52-55`). Keep this dual behaviour if you touch it — tests at `tests/test_models.py:22-37` lock it in. |
| 48 | +- `article_body` is a `model_utils.fields.SplitField`. The split marker comes from `settings.SPLIT_MARKER` (default `"<!-- split -->"`), exposed for the admin help text only — actual splitting is `model_utils`' responsibility. |
| 49 | +- `article_body.content` is rendered with `|safe` in `templates/siteblog/article_detail.html:15`. Authors enter HTML directly; there is no sanitisation layer. |
| 50 | + |
| 51 | +### Quirks worth knowing |
| 52 | + |
| 53 | +- `Article.__str__` returns the Polish literal `f'Artykuł "{title}" - {status_label}"'` (`src/siteblog/models.py:58`). The status label is translated; the surrounding word is not. Inherited from the upstream `bpp` extraction — `tests/test_models.py:8-18` accepts both Polish and English labels. |
| 54 | +- Polish translations ship at `src/siteblog/locale/pl/LC_MESSAGES/`. |
| 55 | +- The project descends from `iplweb/bpp @ 85bca5785` (see `CHANGELOG.md`). The hard dependency on `bpp.models.struktura.Uczelnia` and the `post_save` cache-invalidation signal were removed during extraction. |
| 56 | + |
| 57 | +## Common commands |
| 58 | + |
| 59 | +The project is `uv`-managed; CI uses uv exclusively. |
| 60 | + |
| 61 | +```bash |
| 62 | +uv sync --all-extras # install dev + test extras |
| 63 | +DJANGO_SETTINGS_MODULE=tests.settings uv run pytest # full test suite |
| 64 | +uv run pytest tests/test_views.py::test_detail_view_hidden_on_other_site # one test |
| 65 | +uv run ruff check . # lint |
| 66 | +uv run ruff format --check . # format check (CI uses this) |
| 67 | +uv run ruff format . # apply formatting |
| 68 | +pre-commit install # one-time, then runs on commit |
| 69 | +pre-commit run --all-files # manual full pass |
| 70 | +``` |
| 71 | + |
| 72 | +Running the example project (a real Django project that depends on the in-tree app): |
| 73 | + |
| 74 | +```bash |
| 75 | +cd example |
| 76 | +DJANGO_SETTINGS_MODULE=example_project.settings uv run python manage.py migrate |
| 77 | +DJANGO_SETTINGS_MODULE=example_project.settings uv run python manage.py createsuperuser |
| 78 | +DJANGO_SETTINGS_MODULE=example_project.settings uv run python manage.py runserver |
| 79 | +``` |
| 80 | + |
| 81 | +There are **two distinct Django settings modules** — don't conflate them: |
| 82 | + |
| 83 | +- `tests.settings` — in-memory sqlite, no CSRF middleware, used by pytest. |
| 84 | +- `example_project.settings` — on-disk sqlite at `example/db.sqlite3`, `DEBUG=True`, used by the runnable demo. |
| 85 | + |
| 86 | +## Test layout |
| 87 | + |
| 88 | +- `tests/conftest.py` sets `DJANGO_SETTINGS_MODULE=tests.settings`. |
| 89 | +- `tests/urls.py` is the ROOT_URLCONF for tests: admin at `/admin/` + siteblog mounted at root (so detail URLs are `/{slug}/`, see `tests/test_views.py`). |
| 90 | +- `tests/settings.py:5` hardcodes `SITE_ID=1`. The sites-scoping tests in `test_views.py` override it per-test via the pytest-django `settings` fixture; do the same when adding scoping tests. |
| 91 | +- The default `Site(id=1)` row is created implicitly by the `sites` app's `post_migrate` signal — tests rely on this rather than creating it explicitly. |
| 92 | + |
| 93 | +## Supported Django × Python matrix |
| 94 | + |
| 95 | +CI (`.github/workflows/tests.yml`) tests: |
| 96 | + |
| 97 | +- Django 5.2 LTS × Python 3.10 / 3.11 / 3.12 / 3.13 / 3.14 |
| 98 | +- Django 6.0 × Python 3.12 / 3.13 / 3.14 |
| 99 | + |
| 100 | +`pyproject.toml` requires `django>=5.2`. Pre-commit pins `django-upgrade --target-version 5.2`, so anything that auto-rewrites to Django 5.2-only idioms is expected and fine. |
| 101 | + |
| 102 | +## Migrations |
| 103 | + |
| 104 | +There is exactly one migration: `0001_initial.py`. It was regenerated from scratch at rename time (the four-migration history inherited from `iplweb/bpp` was discarded because this is the package's first standalone release, so there is no downstream migration graph to preserve). `0001_initial` captures every field on `Article`, including the `_article_body_excerpt` column that `model_utils.fields.SplitField` requires, the `sites` M2M, and the explicit `objects` / `on_site` managers in one `CreateModel`. |
| 105 | + |
| 106 | +`SiteblogConfig.default_auto_field = "django.db.models.BigAutoField"` is the **package's** pin, so the schema stays consistent regardless of what the consuming project sets for `DEFAULT_AUTO_FIELD`. |
| 107 | + |
| 108 | +## django-model-utils pin (this matters) |
| 109 | + |
| 110 | +`pyproject.toml` requires `django-model-utils>=4.5,<5`. The 4.x `SplitField.deconstruct()` injects `no_excerpt_field=True` into the migration so `contribute_to_class` skips the dynamic excerpt-column add at apply time — and the migration declares `_article_body_excerpt` explicitly instead. In 5.x that opt-out was removed, so `contribute_to_class` always adds the excerpt column **on top of** the one declared in the migration, producing a `duplicate column name: _article_body_excerpt` SQL error at `migrate`. Until upstream `django-model-utils` provides a working SplitField under 5.x, do not lift the `<5` ceiling without also regenerating `0001_initial` and verifying `manage.py migrate` round-trips on a fresh DB. |
0 commit comments