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.
What You Build
Section titled “What You Build”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.
| Name | Value |
|---|---|
| Distribution | entirius-django-notices |
Import name and app_label | django_notices |
| Admin API | api/notices/v2/admin/<channel_idx>/notices/ |
| Public API | api/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_*.pyBefore You Start
Section titled “Before You Start”- 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.
1. Skeleton
Section titled “1. Skeleton”The packaging files come first, because uv needs them before any code runs.
[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 = 120target-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.
# 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-extrascheck: ## 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 -qA 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):
This Source Code Form is subject to the terms of the Mozilla PublicLicense, v. 2.0. If a copy of the MPL was not distributed with thisfile, 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:
# Python__pycache__/*.py[cod]*.egg-info/build/dist/
# Virtualenv / uv.venv/.python-version
# Django (test project artifacts)db.sqlite3db.sqlite3-journal*.log
# Test / coverage.pytest_cache/.coveragehtmlcov/coverage.xml
# Tooling caches.ruff_cache/.mypy_cache/
# IDE / OS.idea/.vscode/.DS_Store# uv.lock is generated — collapse it in diffs and exclude from language stats.uv.lock linguist-generated=trueCreate the package directories and their __init__.py files. Each one holds the license header and nothing else:
mkdir -p src/django_notices/{models,migrations,schemas/requests,schemas/responses,services} \ src/django_notices/api/{admin/views,public/views} testsfor 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"doneThen install everything, extras included:
uv sync --all-extrasuv creates .venv/ and uv.lock. Commit the lock file.
2. App and Model
Section titled “2. App and Model”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).
# 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 = TrueThe model is a plain Django model. channel_idx scopes a notice to a storefront channel, and idx is unique per
channel.
# 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}"# 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:
PYTHONPATH=. uv run django-admin makemigrations django_notices --settings=tests.settingssed 's/^/# /' .license-header.txt | cat - src/django_notices/migrations/0001_initial.py > /tmp/0001 \ && mv /tmp/0001 src/django_notices/migrations/0001_initial.pymake fixsrc/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).
3. Schemas
Section titled “3. Schemas”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.
# 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.
# 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).
4. Service
Section titled “4. Service”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.
# 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 Noticefrom 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.
5. Admin API
Section titled “5. Admin API”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.
# 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"# 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 IsAuthenticatedfrom rest_framework.request import Requestfrom 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_superuserEvery 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).
# 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 IntegrityErrorfrom django_utils.api.v2_errors import raise_pydantic_as_drffrom drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_viewfrom pydantic import ValidationErrorfrom rest_framework import status, viewsetsfrom rest_framework.exceptions import NotFoundfrom rest_framework.exceptions import ValidationError as DRFValidationErrorfrom rest_framework.request import Requestfrom rest_framework.response import Responsefrom rest_framework_simplejwt.authentication import JWTAuthentication
from django_notices.api.admin.pagination import AdminPageNumberPaginationfrom django_notices.api.admin.permissions import IsAdminUserfrom django_notices.models import Noticefrom django_notices.schemas.requests.notice import NoticeCreateRequest, NoticeUpdateRequestfrom django_notices.schemas.responses.notice import NoticeListResponse, NoticeResponsefrom 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)# 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", ),]6. Public API
Section titled “6. Public API”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).
# 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_viewfrom rest_framework import viewsetsfrom rest_framework.permissions import AllowAnyfrom rest_framework.request import Requestfrom rest_framework.response import Response
from django_notices.schemas.responses.notice import NoticeResponse, PublicNoticeListResponsefrom 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"))# 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:
# 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.
7. Tests
Section titled “7. Tests”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.
# 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 = TrueALLOWED_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 = TrueUSE_TZ = TrueDEFAULT_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.
# 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 pytestfrom django.contrib.auth import get_user_modelfrom rest_framework.test import APIClient
from django_notices.models import Notice
User = get_user_model()
@pytest.fixturedef api_client(): return APIClient()
@pytest.fixturedef admin_user(db): return User.objects.create_superuser(username="admin", password="admin123", email="[email protected]")
@pytest.fixturedef admin_client(api_client, admin_user): api_client.force_authenticate(user=admin_user) return api_client
@pytest.fixturedef regular_user(db):
@pytest.fixturedef regular_client(api_client, regular_user): api_client.force_authenticate(user=regular_user) return api_client
@pytest.fixturedef 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:
# 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()# 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"]# 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.
# 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())8. Check and Test
Section titled “8. Check and Test”make check testmake 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.
What a Released Module Adds
Section titled “What a Released Module Adds”- pre-commit hooks, the license header hook among them: Python Toolchain.
- CI workflows for lint, tests on Postgres and publishing: CI/CD.
- A full
README.md: README Convention. AGENTS.mdandCLAUDE.md: AGENTS.md Convention.CHANGELOG.mdand the release flow: Releases.docs/when the settings reference outgrowsAGENTS.md: Django Module Standard.- The secret-scanning guard line in
make check: Secret Scanning. - The full rule set behind the admin and public views: API v2 Standard.
CMS Admin API: the endpoints the installed modules already serve