Skip to content

Build a Module

A small, complete Django module built to the Django Module Standard: package layout, model and migration, Pydantic schemas, a service, an admin and a public v2 API with @extend_schema, tests, and a green make check test. The rules live in the standard pages; the steps below link them instead of repeating them.

entirius-django-notices: store notices per channel, the short announcements a storefront shows (“Free shipping over 100 EUR until Friday”). One model, no external service, no Celery.

NameValue
Distributionentirius-django-notices
Import name and app_labeldjango_notices
Admin APIapi/notices/v2/admin/<channel_idx>/notices/
Public APIapi/notices/v2/<channel_idx>/notices/

The finished tree:

entirius-django-notices/
├── .gitattributes
├── .gitignore
├── .license-header.txt
├── LICENSE
├── Makefile
├── README.md
├── pyproject.toml
├── src/django_notices/
│ ├── __init__.py
│ ├── apps.py
│ ├── urls.py
│ ├── models/ __init__.py, notice.py
│ ├── migrations/ __init__.py, 0001_initial.py
│ ├── schemas/ __init__.py, requests/notice.py, responses/notice.py
│ ├── services/ __init__.py, notice_service.py
│ └── api/
│ ├── admin/ pagination.py, permissions.py, urls.py, views/notice_views.py
│ └── public/ urls.py, views/notice_views.py
└── tests/ settings.py, conftest.py, test_*.py
  • Python 3.11 or newer and uv. No database server: the tests run on in-memory sqlite.
  • The reference is entirius-django-faq, the smallest released module whose layout is exactly the standard. Several files below are copied from it.
  • Toolchain rules (uv, ruff, hatchling, pytest): Python Toolchain.

The packaging files come first, because uv needs them before any code runs.

pyproject.toml
[project]
name = "entirius-django-notices"
version = "0.1.0"
description = "Store notices per channel for Entirius"
readme = "README.md"
requires-python = ">=3.11"
license = "MPL-2.0"
dependencies = [
"django>=5.0",
"djangorestframework>=3.14",
"djangorestframework-simplejwt>=5.3",
"drf-spectacular>=0.27",
"pydantic>=2.0",
"entirius-django-utils>=2.2.0",
]
[project.optional-dependencies]
dev = ["ruff", "pre-commit"]
test = ["pytest", "pytest-django", "dj-database-url"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/django_notices"]
[tool.ruff]
line-length = 120
target-version = "py311"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP", "DJ", "S"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
"tests/**" = ["S101", "S105", "S106"]
[tool.ruff.lint.isort]
known-first-party = ["django_notices"]
[tool.ruff.format]
quote-style = "double"
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings"
testpaths = ["tests"]
pythonpath = ["."]

readme points at a file, so the package needs one from the start. A short stub is enough for now; the full layout is in the README Convention.

README.md
# entirius-django-notices
Store notices per channel.

The Makefile follows the Makefile Convention: check is lint and format check, test is pytest.

# Makefile
.PHONY: help install check fix test
.DEFAULT_GOAL := help
help: ## List targets
@grep -E '^[a-z-]+:.*##' $(firstword $(MAKEFILE_LIST)) | awk -F':.*##' '{printf " %-16s %s\n", $$1, $$2}'
install: ## Sync dependencies (uv, incl. extras)
uv sync --all-extras
check: ## Lint + format-check (ruff)
uv run ruff check .
uv run ruff format --check .
fix: ## Auto-fix lint + format
uv run ruff check --fix .
uv run ruff format .
test: ## Test suite (pytest)
uv run pytest -x -q

A released module’s check has one more line: it fails when the maintainers’ secret-scanning config is missing (Secret Scanning). A new module does not add it.

The license header goes on top of every .py file, tests included. __init__.py is exempt, and the ones created below carry it anyway (Licensing):

.license-header.txt
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.

LICENSE is the full MPL-2.0 text, copied from the reference module or from mozilla.org. The ignore and attribute files:

.gitignore
# Python
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
# Virtualenv / uv
.venv/
.python-version
# Django (test project artifacts)
db.sqlite3
db.sqlite3-journal
*.log
# Test / coverage
.pytest_cache/
.coverage
htmlcov/
coverage.xml
# Tooling caches
.ruff_cache/
.mypy_cache/
# IDE / OS
.idea/
.vscode/
.DS_Store
.gitattributes
# uv.lock is generated — collapse it in diffs and exclude from language stats.
uv.lock linguist-generated=true

Create the package directories and their __init__.py files. Each one holds the license header and nothing else:

Terminal window
mkdir -p src/django_notices/{models,migrations,schemas/requests,schemas/responses,services} \
src/django_notices/api/{admin/views,public/views} tests
for d in src/django_notices src/django_notices/{migrations,schemas,schemas/requests,schemas/responses,services} \
src/django_notices/api src/django_notices/api/{admin,admin/views,public,public/views} tests; do
sed 's/^/# /' .license-header.txt > "$d/__init__.py"
done

Then install everything, extras included:

Terminal window
uv sync --all-extras

uv creates .venv/ and uv.lock. Commit the lock file.

The AppConfig names the app; name is the import name and the app_label, which is also the table prefix and never changes (The Schema Contract).

src/django_notices/apps.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from django.apps import AppConfig
class DjangoNoticesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "django_notices"
verbose_name = "Notices"
is_volkanos = True

The model is a plain Django model. channel_idx scopes a notice to a storefront channel, and idx is unique per channel.

src/django_notices/models/notice.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from django.db import models
class Notice(models.Model):
channel_idx = models.CharField(max_length=64, db_index=True)
idx = models.SlugField(max_length=64)
title = models.CharField(max_length=200)
body = models.TextField(blank=True, default="")
is_active = models.BooleanField(default=True)
position = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["position", "idx"]
constraints = [
models.UniqueConstraint(fields=["channel_idx", "idx"], name="django_notices_notice_channel_idx_uniq"),
]
def __str__(self) -> str:
return f"{self.channel_idx}:{self.idx}"
src/django_notices/models/__init__.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from django_notices.models.notice import Notice
__all__ = ["Notice"]

Generate the migration against the test settings (written in step 7; create them first if you follow the steps in order), then format it:

Terminal window
PYTHONPATH=. uv run django-admin makemigrations django_notices --settings=tests.settings
sed 's/^/# /' .license-header.txt | cat - src/django_notices/migrations/0001_initial.py > /tmp/0001 \
&& mv /tmp/0001 src/django_notices/migrations/0001_initial.py
make fix

src/django_notices/migrations/0001_initial.py is generated, never written by hand, and once released it is part of the module’s public contract (Migrations = Public Contract).

Pydantic models are the API contract: one request model per write, one response model per shape. No DRF serializers (API v2 Standard). Every field carries a description and an example, because drf-spectacular reads them into the OpenAPI schema.

src/django_notices/schemas/requests/notice.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from pydantic import BaseModel, Field
class NoticeCreateRequest(BaseModel):
idx: str = Field(
min_length=1,
max_length=64,
pattern=r"^[-a-zA-Z0-9_]+$",
description="Slug, unique per channel",
examples=["free-shipping"],
)
title: str = Field(min_length=1, max_length=200, description="Headline", examples=["Free shipping over 100 EUR"])
body: str = Field(default="", max_length=2000, description="Optional details", examples=["Until Friday."])
is_active: bool = Field(default=True, description="Shown on the storefront", examples=[True])
position: int = Field(default=0, ge=0, description="Sort order", examples=[0])
class NoticeUpdateRequest(BaseModel):
title: str | None = Field(None, min_length=1, max_length=200, description="Headline", examples=["Free shipping"])
body: str | None = Field(None, max_length=2000, description="Optional details", examples=["Until Friday."])
is_active: bool | None = Field(None, description="Shown on the storefront", examples=[False])
position: int | None = Field(None, ge=0, description="Sort order", examples=[1])

The update schema has no idx: the slug is the URL, and renaming it is not an edit.

src/django_notices/schemas/responses/notice.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class NoticeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int = Field(description="Notice PK", examples=[1])
channel_idx: str = Field(description="Channel identifier", examples=["default-europe"])
idx: str = Field(description="Slug, unique per channel", examples=["free-shipping"])
title: str = Field(description="Headline", examples=["Free shipping over 100 EUR"])
body: str = Field(description="Optional details", examples=["Until Friday."])
is_active: bool = Field(description="Shown on the storefront", examples=[True])
position: int = Field(description="Sort order", examples=[0])
updated_at: datetime = Field(description="Last change")
class NoticeListResponse(BaseModel):
count: int = Field(description="Total item count", examples=[3])
next: str | None = Field(None, description="Next page URL", examples=["?page=2"])
previous: str | None = Field(None, description="Previous page URL", examples=[None])
results: list[NoticeResponse] = Field(description="Page results")
class PublicNoticeListResponse(BaseModel):
results: list[NoticeResponse] = Field(description="Active notices of the channel")

NoticeListResponse is the standard paginated shape, PublicNoticeListResponse an unpaginated results list (Response Contract).

All ORM access lives in the service; views call it and never touch Notice.objects. _EDITABLE_FIELDS is the mass-assignment defense: update_notice refuses any field outside it, whoever the caller is.

src/django_notices/services/notice_service.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from typing import Any
from django.db.models import Q, QuerySet
from django_notices.models import Notice
from django_notices.schemas.requests.notice import NoticeCreateRequest
_EDITABLE_FIELDS = frozenset({"title", "body", "is_active", "position"})
def list_notices(*, channel_idx: str, is_active: bool | None = None, search: str | None = None) -> QuerySet[Notice]:
notices = Notice.objects.filter(channel_idx=channel_idx)
if is_active is not None:
notices = notices.filter(is_active=is_active)
if search:
notices = notices.filter(Q(idx__icontains=search) | Q(title__icontains=search))
return notices
def get_notice(*, channel_idx: str, idx: str) -> Notice:
return Notice.objects.get(channel_idx=channel_idx, idx=idx)
def create_notice(*, channel_idx: str, data: NoticeCreateRequest) -> Notice:
return Notice.objects.create(channel_idx=channel_idx, **data.model_dump())
def update_notice(*, notice: Notice, updates: dict[str, Any]) -> Notice:
invalid = set(updates) - _EDITABLE_FIELDS
if invalid:
raise ValueError(f"Fields not editable via update_notice: {sorted(invalid)}")
for field, value in updates.items():
setattr(notice, field, value)
notice.save()
return notice
def delete_notice(*, notice: Notice) -> None:
notice.delete()

channel_idx and idx are not editable on purpose. Moving a notice to another channel would be its own service function with its own checks.

Admin views are thin: parse the request with Pydantic, call the service, serialize the response. Authentication and permission classes are declared on the viewset, never inherited from project defaults. The pagination and permission classes are copied from the reference module.

src/django_notices/api/admin/pagination.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from rest_framework.pagination import PageNumberPagination
class AdminPageNumberPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
page_query_param = "page"
src/django_notices/api/admin/permissions.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.views import APIView
class IsAdminUser(IsAuthenticated):
def has_permission(self, request: Request, view: APIView) -> bool:
if not super().has_permission(request, view):
return False
return request.user.is_staff or request.user.is_superuser

Every action is described in extend_schema_view with its path parameters, request schema and responses, so spectacular --validate --fail-on-warn passes without guessing (OpenAPI Generation). A Pydantic ValidationError goes through raise_pydantic_as_drf, so it reaches the client in the v2 error format (Error Handling).

src/django_notices/api/admin/views/notice_views.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
"""Admin notice management: list, create, retrieve, update, delete."""
from django.db import IntegrityError
from django_utils.api.v2_errors import raise_pydantic_as_drf
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view
from pydantic import ValidationError
from rest_framework import status, viewsets
from rest_framework.exceptions import NotFound
from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework_simplejwt.authentication import JWTAuthentication
from django_notices.api.admin.pagination import AdminPageNumberPagination
from django_notices.api.admin.permissions import IsAdminUser
from django_notices.models import Notice
from django_notices.schemas.requests.notice import NoticeCreateRequest, NoticeUpdateRequest
from django_notices.schemas.responses.notice import NoticeListResponse, NoticeResponse
from django_notices.services import notice_service
TAGS = ["Notices"]
CHANNEL = OpenApiParameter(name="channel_idx", location="path", type=str, description="Channel identifier")
IDX = OpenApiParameter(name="idx", location="path", type=str, description="Notice slug")
ERRORS = {
400: OpenApiResponse(description="Validation error"),
401: OpenApiResponse(description="Not authenticated"),
403: OpenApiResponse(description="Not a staff user"),
404: OpenApiResponse(description="Notice not found"),
}
def _serialize(notice: Notice) -> dict:
return NoticeResponse.model_validate(notice).model_dump(mode="json")
def _get_or_404(channel_idx: str, idx: str) -> Notice:
try:
return notice_service.get_notice(channel_idx=channel_idx, idx=idx)
except Notice.DoesNotExist:
raise NotFound("Notice not found.") from None
@extend_schema_view(
list=extend_schema(
summary="List notices",
description="Notices of one channel, ordered by position.",
tags=TAGS,
parameters=[
CHANNEL,
OpenApiParameter(name="search", type=str, description="Search in idx and title"),
OpenApiParameter(name="is_active", type=bool, description="Filter by active status"),
],
responses={200: NoticeListResponse, **ERRORS},
),
create=extend_schema(
summary="Create notice",
description="Create a notice in the channel. The idx is unique per channel.",
tags=TAGS,
parameters=[CHANNEL],
request=NoticeCreateRequest,
responses={201: NoticeResponse, **ERRORS},
),
retrieve=extend_schema(
summary="Retrieve notice",
description="One notice by its idx.",
tags=TAGS,
parameters=[CHANNEL, IDX],
responses={200: NoticeResponse, **ERRORS},
),
partial_update=extend_schema(
summary="Update notice",
description="Change title, body, is_active or position.",
tags=TAGS,
parameters=[CHANNEL, IDX],
request=NoticeUpdateRequest,
responses={200: NoticeResponse, **ERRORS},
),
destroy=extend_schema(
summary="Delete notice",
description="Delete a notice for good.",
tags=TAGS,
parameters=[CHANNEL, IDX],
responses={204: OpenApiResponse(description="Deleted"), **ERRORS},
),
)
class NoticeViewSet(viewsets.ViewSet):
authentication_classes = [JWTAuthentication]
permission_classes = [IsAdminUser]
def list(self, request: Request, channel_idx: str) -> Response:
is_active = request.query_params.get("is_active")
notices = notice_service.list_notices(
channel_idx=channel_idx,
is_active=None if is_active is None else is_active.lower() == "true",
search=request.query_params.get("search"),
)
paginator = AdminPageNumberPagination()
page = paginator.paginate_queryset(notices, request, view=self)
return paginator.get_paginated_response([_serialize(n) for n in page])
def create(self, request: Request, channel_idx: str) -> Response:
try:
data = NoticeCreateRequest(**request.data)
except ValidationError as exc:
raise_pydantic_as_drf(exc)
try:
notice = notice_service.create_notice(channel_idx=channel_idx, data=data)
except IntegrityError:
raise DRFValidationError({"idx": ["A notice with this idx already exists."]}) from None
return Response(_serialize(notice), status=status.HTTP_201_CREATED)
def retrieve(self, request: Request, channel_idx: str, idx: str) -> Response:
return Response(_serialize(_get_or_404(channel_idx, idx)))
def partial_update(self, request: Request, channel_idx: str, idx: str) -> Response:
try:
data = NoticeUpdateRequest(**request.data)
except ValidationError as exc:
raise_pydantic_as_drf(exc)
notice = _get_or_404(channel_idx, idx)
notice = notice_service.update_notice(notice=notice, updates=data.model_dump(exclude_unset=True))
return Response(_serialize(notice))
def destroy(self, request: Request, channel_idx: str, idx: str) -> Response:
notice_service.delete_notice(notice=_get_or_404(channel_idx, idx))
return Response(status=status.HTTP_204_NO_CONTENT)
src/django_notices/api/admin/urls.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from django.urls import path
from django_notices.api.admin.views.notice_views import NoticeViewSet
urlpatterns = [
path(
"<str:channel_idx>/notices/",
NoticeViewSet.as_view({"get": "list", "post": "create"}),
name="notices-admin-list",
),
path(
"<str:channel_idx>/notices/<slug:idx>/",
NoticeViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="notices-admin-detail",
),
]

The storefront reads active notices of one channel without a token. It is read only, so it needs no throttle; a public endpoint that creates state does (Throttling).

src/django_notices/api/public/views/notice_views.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
"""Public notices: the active notices of one channel, no authentication."""
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from rest_framework import viewsets
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from django_notices.schemas.responses.notice import NoticeResponse, PublicNoticeListResponse
from django_notices.services import notice_service
@extend_schema_view(
list=extend_schema(
summary="List active notices",
description="Active notices of one channel, ordered by position.",
tags=["Public Notices"],
parameters=[OpenApiParameter(name="channel_idx", location="path", type=str, description="Channel identifier")],
responses={200: PublicNoticeListResponse},
),
)
class PublicNoticeViewSet(viewsets.ViewSet):
authentication_classes = []
permission_classes = [AllowAny]
def list(self, request: Request, channel_idx: str) -> Response:
notices = notice_service.list_notices(channel_idx=channel_idx, is_active=True)
results = [NoticeResponse.model_validate(n) for n in notices]
return Response(PublicNoticeListResponse(results=results).model_dump(mode="json"))
src/django_notices/api/public/urls.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from django.urls import path
from django_notices.api.public.views.notice_views import PublicNoticeViewSet
urlpatterns = [
path("<str:channel_idx>/notices/", PublicNoticeViewSet.as_view({"get": "list"}), name="notices-public-list"),
]

The module’s root URLconf mounts both under the module’s own prefix:

src/django_notices/urls.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from django.urls import include, path
app_name = "django_notices"
urlpatterns = [
path("api/notices/v2/admin/", include("django_notices.api.admin.urls")),
path("api/notices/v2/", include("django_notices.api.public.urls")),
]

A service includes it with path("", include("django_notices.urls")) and adds django_notices to INSTALLED_APPS.

Tests live in tests/, outside the package. The settings file is a minimal Django project around the module. The v2 error format needs v2_exception_handler; OAS_VERSION makes drf-spectacular emit OpenAPI 3.1.

tests/settings.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import dj_database_url
SECRET_KEY = "not so secret test secret"
STATIC_URL = "/static/"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework_simplejwt",
"drf_spectacular",
"django_notices",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework_simplejwt.authentication.JWTAuthentication"],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"EXCEPTION_HANDLER": "django_utils.api.v2_errors.v2_exception_handler",
}
SPECTACULAR_SETTINGS = {"TITLE": "django-notices API v2", "VERSION": "0.1.0", "OAS_VERSION": "3.1.0"}
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
DATABASES = {"default": dj_database_url.config(default="sqlite://:memory:")}
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
AUTH_PASSWORD_VALIDATORS = []
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
ROOT_URLCONF = "django_notices.urls"

Sqlite is allowed here because the module uses no Postgres-only feature (Tests). DATABASE_URL=postgresql://… switches the suite to Postgres.

tests/conftest.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from django_notices.models import Notice
User = get_user_model()
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def admin_user(db):
return User.objects.create_superuser(username="admin", password="admin123", email="[email protected]")
@pytest.fixture
def admin_client(api_client, admin_user):
api_client.force_authenticate(user=admin_user)
return api_client
@pytest.fixture
def regular_user(db):
return User.objects.create_user(username="user", password="user123", email="[email protected]")
@pytest.fixture
def regular_client(api_client, regular_user):
api_client.force_authenticate(user=regular_user)
return api_client
@pytest.fixture
def notice(db):
return Notice.objects.create(channel_idx="default-europe", idx="free-shipping", title="Free shipping over 100 EUR")

The admin tests cover authentication, permissions, every action and the v2 error body:

tests/test_admin_api.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from django_notices.models import Notice
URL = "/api/notices/v2/admin/default-europe/notices/"
DETAIL = f"{URL}free-shipping/"
def test_no_auth_returns_401(api_client, db):
assert api_client.get(URL).status_code == 401
def test_regular_user_returns_403(regular_client):
assert regular_client.get(URL).status_code == 403
def test_admin_lists_notices(admin_client, notice):
resp = admin_client.get(URL)
assert resp.status_code == 200
assert resp.data["count"] == 1
assert resp.data["results"][0]["idx"] == "free-shipping"
def test_create_notice(admin_client, db):
resp = admin_client.post(URL, {"idx": "returns", "title": "Free returns"}, format="json")
assert resp.status_code == 201
assert Notice.objects.get(idx="returns").channel_idx == "default-europe"
def test_create_invalid_payload_returns_v2_error(admin_client, db):
resp = admin_client.post(URL, {}, format="json")
assert resp.status_code == 400
assert resp.data["error"] == "VALIDATION_ERROR"
assert {"idx", "title"} <= {d["field"] for d in resp.data["details"]}
def test_create_duplicate_idx_returns_400(admin_client, notice):
resp = admin_client.post(URL, {"idx": "free-shipping", "title": "Again"}, format="json")
assert resp.status_code == 400
def test_retrieve_notice(admin_client, notice):
resp = admin_client.get(DETAIL)
assert resp.status_code == 200
assert resp.data["title"] == "Free shipping over 100 EUR"
def test_update_notice(admin_client, notice):
resp = admin_client.patch(DETAIL, {"is_active": False}, format="json")
assert resp.status_code == 200
notice.refresh_from_db()
assert notice.is_active is False
def test_update_not_found(admin_client, db):
resp = admin_client.patch(f"{URL}missing/", {"title": "x"}, format="json")
assert resp.status_code == 404
def test_delete_notice(admin_client, notice):
assert admin_client.delete(DETAIL).status_code == 204
assert not Notice.objects.exists()
tests/test_public_api.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from django_notices.models import Notice
URL = "/api/notices/v2/default-europe/notices/"
def test_public_list_is_open(api_client, notice):
resp = api_client.get(URL)
assert resp.status_code == 200
assert [n["idx"] for n in resp.data["results"]] == ["free-shipping"]
def test_public_list_hides_inactive(api_client, notice):
Notice.objects.create(channel_idx="default-europe", idx="old-sale", title="Old sale", is_active=False)
resp = api_client.get(URL)
assert [n["idx"] for n in resp.data["results"]] == ["free-shipping"]
tests/test_services.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import pytest
from django_notices.services import notice_service
def test_update_rejects_unknown_field(notice):
with pytest.raises(ValueError, match="channel_idx"):
notice_service.update_notice(notice=notice, updates={"channel_idx": "other"})

Two package tests guard the contract: the OpenAPI schema validates with warnings as errors, and the models have no migration drift.

tests/test_package.py
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from io import StringIO
from django.core.management import call_command
def test_openapi_schema_validates(tmp_path):
call_command("spectacular", "--validate", "--fail-on-warn", "--file", str(tmp_path / "schema.yaml"))
def test_migrations_are_complete(db):
call_command("makemigrations", "django_notices", "--check", "--dry-run", stdout=StringIO())
Terminal window
make check test

make check runs ruff check . and ruff format --check .; make test runs pytest -x -q, which includes test_openapi_schema_validates and test_migrations_are_complete. Both green means the module is ready for its first commit. A warning from drf-spectacular fails the suite: fix the view’s extend_schema, never drop --fail-on-warn.

CMS Admin API: the endpoints the installed modules already serve