django-stack 0.0.1__tar.gz → 0.2.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 (64) hide show
  1. django_stack-0.2.0/PKG-INFO +65 -0
  2. django_stack-0.2.0/README.md +28 -0
  3. {django_stack-0.0.1 → django_stack-0.2.0}/pyproject.toml +25 -3
  4. {django_stack-0.0.1 → django_stack-0.2.0}/src/django_stack/__init__.py +1 -1
  5. django_stack-0.2.0/src/django_stack/apps.py +19 -0
  6. django_stack-0.2.0/src/django_stack/backup.py +268 -0
  7. django_stack-0.2.0/src/django_stack/checks.py +135 -0
  8. django_stack-0.2.0/src/django_stack/conf.py +33 -0
  9. django_stack-0.2.0/src/django_stack/contrib/health/__init__.py +0 -0
  10. django_stack-0.2.0/src/django_stack/contrib/health/urls.py +8 -0
  11. django_stack-0.2.0/src/django_stack/contrib/health/views.py +36 -0
  12. django_stack-0.2.0/src/django_stack/contrib/revalidate/__init__.py +0 -0
  13. django_stack-0.2.0/src/django_stack/contrib/revalidate/receivers.py +20 -0
  14. django_stack-0.2.0/src/django_stack/contrib/revalidate/tasks.py +71 -0
  15. django_stack-0.2.0/src/django_stack/env.py +177 -0
  16. django_stack-0.2.0/src/django_stack/management/__init__.py +0 -0
  17. django_stack-0.2.0/src/django_stack/management/commands/__init__.py +0 -0
  18. django_stack-0.2.0/src/django_stack/management/commands/backup_database.py +40 -0
  19. django_stack-0.2.0/src/django_stack/management/commands/create_admin.py +30 -0
  20. django_stack-0.2.0/src/django_stack/management/commands/create_bucket.py +21 -0
  21. django_stack-0.2.0/src/django_stack/management/commands/export_content.py +29 -0
  22. django_stack-0.2.0/src/django_stack/management/commands/import_content.py +12 -0
  23. django_stack-0.2.0/src/django_stack/management/commands/restore_database.py +43 -0
  24. django_stack-0.2.0/src/django_stack/management/commands/restore_media.py +28 -0
  25. django_stack-0.2.0/src/django_stack/management/commands/run_backups.py +51 -0
  26. django_stack-0.2.0/src/django_stack/management/commands/seed_content.py +26 -0
  27. django_stack-0.2.0/src/django_stack/management/commands/wait_for_db.py +25 -0
  28. django_stack-0.2.0/src/django_stack/models.py +51 -0
  29. django_stack-0.2.0/src/django_stack/preview.py +33 -0
  30. django_stack-0.2.0/src/django_stack/rest/__init__.py +1 -0
  31. django_stack-0.2.0/src/django_stack/rest/filters.py +28 -0
  32. django_stack-0.2.0/src/django_stack/rest/permissions.py +39 -0
  33. django_stack-0.2.0/src/django_stack/rest/serializers.py +135 -0
  34. django_stack-0.2.0/src/django_stack/rest/throttling.py +19 -0
  35. django_stack-0.2.0/src/django_stack/settings.py +325 -0
  36. django_stack-0.2.0/src/django_stack/signing.py +61 -0
  37. django_stack-0.2.0/src/django_stack/staticfiles.py +19 -0
  38. django_stack-0.2.0/src/django_stack/studio.py +26 -0
  39. django_stack-0.2.0/src/django_stack/tasks.py +83 -0
  40. django_stack-0.2.0/src/django_stack/urls.py +41 -0
  41. django_stack-0.2.0/tests/__init__.py +0 -0
  42. django_stack-0.2.0/tests/conftest.py +17 -0
  43. django_stack-0.2.0/tests/settings.py +63 -0
  44. django_stack-0.2.0/tests/test_api.py +177 -0
  45. django_stack-0.2.0/tests/test_backup.py +145 -0
  46. django_stack-0.2.0/tests/test_env.py +53 -0
  47. django_stack-0.2.0/tests/test_settings.py +107 -0
  48. django_stack-0.2.0/tests/test_signing.py +29 -0
  49. django_stack-0.2.0/tests/test_staticfiles.py +9 -0
  50. django_stack-0.2.0/tests/test_tasks.py +49 -0
  51. django_stack-0.2.0/tests/test_urls.py +20 -0
  52. django_stack-0.2.0/tests/testapp/__init__.py +0 -0
  53. django_stack-0.2.0/tests/testapp/apps.py +6 -0
  54. django_stack-0.2.0/tests/testapp/models.py +44 -0
  55. django_stack-0.2.0/tests/urls.py +6 -0
  56. django_stack-0.2.0/uv.lock +1280 -0
  57. django_stack-0.0.1/PKG-INFO +0 -37
  58. django_stack-0.0.1/README.md +0 -12
  59. django_stack-0.0.1/tests/settings.py +0 -4
  60. django_stack-0.0.1/uv.lock +0 -167
  61. {django_stack-0.0.1 → django_stack-0.2.0}/.gitignore +0 -0
  62. {django_stack-0.0.1 → django_stack-0.2.0}/LICENSE +0 -0
  63. {django_stack-0.0.1/tests → django_stack-0.2.0/src/django_stack/contrib}/__init__.py +0 -0
  64. {django_stack-0.0.1 → django_stack-0.2.0}/tests/test_version.py +0 -0
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-stack
3
+ Version: 0.2.0
4
+ Summary: The backend glue of DjangoStack: settings from environment variables, API defaults, health checks and system checks.
5
+ Project-URL: Homepage, https://github.com/dwarsbit/django-stack
6
+ Project-URL: Source, https://github.com/dwarsbit/django-stack/tree/main/python/django-stack
7
+ Project-URL: Issues, https://github.com/dwarsbit/django-stack/issues
8
+ Author-email: Leon van der Grient <leon@dwarsbit.nl>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cms,django,django-content-studio,django-headless,headless,jamstack
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 5.2
15
+ Classifier: Framework :: Django :: 6.0
16
+ Classifier: Framework :: Django :: 6.1
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Requires-Python: >=3.12
23
+ Requires-Dist: django-content-studio[huey,images]<2,>=1.8
24
+ Requires-Dist: django-headless[schema]<2,>=1.1.1
25
+ Requires-Dist: django<7,>=5.2
26
+ Requires-Dist: djangorestframework<4,>=3.17
27
+ Provides-Extra: s3
28
+ Requires-Dist: django-storages[s3]>=1.14; extra == 's3'
29
+ Provides-Extra: server
30
+ Requires-Dist: gunicorn>=26; extra == 'server'
31
+ Requires-Dist: psycopg[binary]>=3.2; extra == 'server'
32
+ Requires-Dist: redis>=5; extra == 'server'
33
+ Requires-Dist: uvicorn-worker>=0.3; extra == 'server'
34
+ Requires-Dist: uvicorn[standard]>=0.34; extra == 'server'
35
+ Requires-Dist: whitenoise>=6.9; extra == 'server'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # django-stack
39
+
40
+ The backend glue of [DjangoStack](https://github.com/dwarsbit/django-stack).
41
+ Projects created with `npx @django-stack/cli init` depend on it.
42
+
43
+ - `django_stack.settings.configure()` sets Django's settings from environment
44
+ variables: database, cache, task queue, mail, storage, security, and safe
45
+ defaults for Django Headless and Django Content Studio.
46
+ - `PublishableModel`: draft and published content, with preview access.
47
+ - API defaults: a closed API with a full-access and a read-only key, and
48
+ rate-limited public forms.
49
+ - Preview tokens and signed revalidation requests to the frontend.
50
+ - `/healthz` and `/readyz`, system checks, and the management commands
51
+ `create_admin`, `seed_content`, `export_content`, `import_content` and
52
+ `wait_for_db`.
53
+
54
+ ```python
55
+ # config/settings.py
56
+ from pathlib import Path
57
+
58
+ from django_stack.settings import configure
59
+
60
+ configure(globals(), base_dir=Path(__file__).resolve().parent.parent)
61
+ ```
62
+
63
+ ## License
64
+
65
+ MIT; see [LICENSE](LICENSE).
@@ -0,0 +1,28 @@
1
+ # django-stack
2
+
3
+ The backend glue of [DjangoStack](https://github.com/dwarsbit/django-stack).
4
+ Projects created with `npx @django-stack/cli init` depend on it.
5
+
6
+ - `django_stack.settings.configure()` sets Django's settings from environment
7
+ variables: database, cache, task queue, mail, storage, security, and safe
8
+ defaults for Django Headless and Django Content Studio.
9
+ - `PublishableModel`: draft and published content, with preview access.
10
+ - API defaults: a closed API with a full-access and a read-only key, and
11
+ rate-limited public forms.
12
+ - Preview tokens and signed revalidation requests to the frontend.
13
+ - `/healthz` and `/readyz`, system checks, and the management commands
14
+ `create_admin`, `seed_content`, `export_content`, `import_content` and
15
+ `wait_for_db`.
16
+
17
+ ```python
18
+ # config/settings.py
19
+ from pathlib import Path
20
+
21
+ from django_stack.settings import configure
22
+
23
+ configure(globals(), base_dir=Path(__file__).resolve().parent.parent)
24
+ ```
25
+
26
+ ## License
27
+
28
+ MIT; see [LICENSE](LICENSE).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-stack"
3
- version = "0.0.1"
3
+ version = "0.2.0"
4
4
  description = "The backend glue of DjangoStack: settings from environment variables, API defaults, health checks and system checks."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,7 +20,23 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.13",
21
21
  "Programming Language :: Python :: 3.14",
22
22
  ]
23
- dependencies = ["Django>=5.2,<7"]
23
+ dependencies = [
24
+ "Django>=5.2,<7",
25
+ "djangorestframework>=3.17,<4",
26
+ "django-headless[schema]>=1.1.1,<2",
27
+ "django-content-studio[images,huey]>=1.8,<2",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ server = [
32
+ "gunicorn>=26",
33
+ "uvicorn[standard]>=0.34",
34
+ "uvicorn-worker>=0.3",
35
+ "whitenoise>=6.9",
36
+ "psycopg[binary]>=3.2",
37
+ "redis>=5",
38
+ ]
39
+ s3 = ["django-storages[s3]>=1.14"]
24
40
 
25
41
  [project.urls]
26
42
  Homepage = "https://github.com/dwarsbit/django-stack"
@@ -28,7 +44,13 @@ Source = "https://github.com/dwarsbit/django-stack/tree/main/python/django-stack
28
44
  Issues = "https://github.com/dwarsbit/django-stack/issues"
29
45
 
30
46
  [dependency-groups]
31
- dev = ["pytest>=8", "pytest-django>=4.11", "ruff>=0.15"]
47
+ dev = [
48
+ "pytest>=8",
49
+ "pytest-django>=4.11",
50
+ "ruff>=0.15",
51
+ "django-storages[s3]>=1.14",
52
+ "whitenoise>=6.9",
53
+ ]
32
54
 
33
55
  [build-system]
34
56
  requires = ["hatchling>=1.27"]
@@ -1,3 +1,3 @@
1
1
  """The backend glue of DjangoStack."""
2
2
 
3
- __version__ = "0.0.1"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,19 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoStackConfig(AppConfig):
5
+ name = "django_stack"
6
+ label = "django_stack"
7
+ verbose_name = "DjangoStack"
8
+
9
+ def ready(self):
10
+ from . import checks # noqa: F401 - registers the system checks
11
+
12
+ try:
13
+ from content_studio.signals import content_changed
14
+ except ImportError: # pragma: no cover - Content Studio is not installed
15
+ return
16
+
17
+ from .contrib.revalidate.receivers import on_content_changed
18
+
19
+ content_changed.connect(on_content_changed, dispatch_uid="django_stack_revalidate")
@@ -0,0 +1,268 @@
1
+ """
2
+ Backups: the database with ``pg_dump`` and, when uploads are stored on
3
+ disk, the media directory as a ``.tar.gz``, to a directory and, optionally,
4
+ to a private S3 bucket, with retention.
5
+
6
+ The ``backup`` service of a project runs ``run_backups``, which calls
7
+ :func:`create_backup` and :func:`create_media_backup` once a day.
8
+ ``backup_database`` makes them now, ``restore_database`` and
9
+ ``restore_media`` restore them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import tarfile
19
+ from dataclasses import dataclass
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+
23
+ from django.conf import settings
24
+ from django.core.exceptions import ImproperlyConfigured
25
+
26
+ from . import conf
27
+
28
+ NAME_PATTERN = re.compile(r"^(?P<prefix>.+)-(?P<stamp>\d{8}T\d{6}Z)\.dump$")
29
+ MEDIA_PATTERN = re.compile(r"^(?P<prefix>.+)-(?P<stamp>\d{8}T\d{6}Z)\.media\.tar\.gz$")
30
+ MEDIA_SUFFIX = ".media.tar.gz"
31
+
32
+
33
+ @dataclass
34
+ class Backup:
35
+ name: str
36
+ path: Path
37
+ uploaded: bool = False
38
+
39
+
40
+ def _database() -> dict:
41
+ database = settings.DATABASES["default"]
42
+ if "postgresql" not in database["ENGINE"]:
43
+ raise ImproperlyConfigured("Backups need a Postgres database; DATABASE_URL is not postgres://.")
44
+ return database
45
+
46
+
47
+ def pg_environment(database: dict | None = None) -> dict[str, str]:
48
+ """The environment for pg_dump and pg_restore: connection values as PG* variables."""
49
+ database = database or _database()
50
+ env = dict(os.environ)
51
+ values = {
52
+ "PGHOST": database.get("HOST") or "localhost",
53
+ "PGPORT": str(database.get("PORT") or 5432),
54
+ "PGUSER": database.get("USER") or "",
55
+ "PGPASSWORD": database.get("PASSWORD") or "",
56
+ "PGDATABASE": database.get("NAME") or "",
57
+ }
58
+ sslmode = (database.get("OPTIONS") or {}).get("sslmode")
59
+ if sslmode:
60
+ values["PGSSLMODE"] = sslmode
61
+ env.update({key: value for key, value in values.items() if value})
62
+ return env
63
+
64
+
65
+ def backup_dir() -> Path:
66
+ path = Path(conf.get("BACKUP_DIR") or Path(settings.BASE_DIR) / "backups")
67
+ path.mkdir(parents=True, exist_ok=True)
68
+ return path
69
+
70
+
71
+ def backup_name(now: datetime | None = None) -> str:
72
+ now = now or datetime.now(UTC)
73
+ prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", getattr(settings, "PROJECT_NAME", "") or "backup")
74
+ return f"{prefix}-{now.strftime('%Y%m%dT%H%M%SZ')}.dump"
75
+
76
+
77
+ def media_name(database_name: str) -> str:
78
+ """The media archive that belongs to a database dump: same name and time."""
79
+ return database_name.removesuffix(".dump") + MEDIA_SUFFIX
80
+
81
+
82
+ def s3_client():
83
+ """A boto3 client for the backup bucket, with the S3_* credentials."""
84
+ import boto3
85
+
86
+ options = conf.get("BACKUP_S3") or {}
87
+ return boto3.client(
88
+ "s3",
89
+ endpoint_url=options.get("endpoint_url"),
90
+ region_name=options.get("region_name"),
91
+ aws_access_key_id=options.get("access_key"),
92
+ aws_secret_access_key=options.get("secret_key"),
93
+ )
94
+
95
+
96
+ def _s3_target() -> tuple[str, str] | None:
97
+ bucket = conf.get("BACKUP_S3_BUCKET")
98
+ if not bucket:
99
+ return None
100
+ prefix = (conf.get("BACKUP_S3_PREFIX") or "").strip("/")
101
+ return bucket, f"{prefix}/" if prefix else ""
102
+
103
+
104
+ def create_backup(*, keep: int | None = None, name: str | None = None) -> Backup:
105
+ """Dump the database, upload it when a backup bucket is set, and apply the retention."""
106
+ pg_dump = shutil.which("pg_dump")
107
+ if pg_dump is None:
108
+ raise ImproperlyConfigured("pg_dump is not installed; install the Postgres client.")
109
+ directory = backup_dir()
110
+ name = name or backup_name()
111
+ path = directory / name
112
+ partial = path.with_suffix(".partial")
113
+ subprocess.run( # noqa: S603 (fixed arguments, no shell)
114
+ [pg_dump, "--format=custom", "--no-owner", "--no-acl", "--file", str(partial)],
115
+ env=pg_environment(),
116
+ check=True,
117
+ capture_output=True,
118
+ )
119
+ partial.rename(path)
120
+ path.chmod(0o600)
121
+ backup = Backup(name=name, path=path)
122
+
123
+ _upload(backup)
124
+ prune(keep if keep is not None else conf.get("BACKUP_KEEP"))
125
+ return backup
126
+
127
+
128
+ def _upload(backup: Backup) -> None:
129
+ target = _s3_target()
130
+ if target:
131
+ bucket, prefix = target
132
+ s3_client().upload_file(str(backup.path), bucket, f"{prefix}{backup.name}")
133
+ backup.uploaded = True
134
+
135
+
136
+ def media_on_disk() -> bool:
137
+ """Whether uploads are stored in MEDIA_ROOT, not in S3: only then the media needs a backup."""
138
+ backend = settings.STORAGES.get("default", {}).get("BACKEND", "")
139
+ return backend == "django.core.files.storage.FileSystemStorage"
140
+
141
+
142
+ def media_backups_enabled() -> bool:
143
+ return bool(conf.get("BACKUP_MEDIA")) and media_on_disk()
144
+
145
+
146
+ def create_media_backup(database_name: str, *, keep: int | None = None) -> Backup:
147
+ """Archive MEDIA_ROOT next to a database dump, upload it, and apply the retention."""
148
+ root = Path(settings.MEDIA_ROOT)
149
+ name = media_name(database_name)
150
+ path = backup_dir() / name
151
+ partial = path.with_name(f"{name}.partial")
152
+ with tarfile.open(partial, "w:gz") as archive:
153
+ if root.is_dir():
154
+ for entry in sorted(root.iterdir()):
155
+ if not entry.name.startswith(".restoring"):
156
+ archive.add(entry, arcname=entry.name)
157
+ partial.rename(path)
158
+ path.chmod(0o600)
159
+ backup = Backup(name=name, path=path)
160
+ _upload(backup)
161
+ prune(keep if keep is not None else conf.get("BACKUP_KEEP"), pattern=MEDIA_PATTERN)
162
+ return backup
163
+
164
+
165
+ def _sorted_names(names, pattern: re.Pattern = NAME_PATTERN) -> list[str]:
166
+ return sorted(
167
+ (name for name in names if pattern.match(name)),
168
+ key=lambda name: pattern.match(name)["stamp"],
169
+ )
170
+
171
+
172
+ def local_backups(pattern: re.Pattern = NAME_PATTERN) -> list[str]:
173
+ return _sorted_names((path.name for path in backup_dir().iterdir()), pattern)
174
+
175
+
176
+ def remote_backups(pattern: re.Pattern = NAME_PATTERN) -> list[str]:
177
+ target = _s3_target()
178
+ if not target:
179
+ return []
180
+ bucket, prefix = target
181
+ names = []
182
+ for page in s3_client().get_paginator("list_objects_v2").paginate(Bucket=bucket, Prefix=prefix):
183
+ names += [item["Key"][len(prefix) :] for item in page.get("Contents", [])]
184
+ return _sorted_names(names, pattern)
185
+
186
+
187
+ def prune(keep: int, pattern: re.Pattern = NAME_PATTERN) -> list[str]:
188
+ """Delete all but the ``keep`` newest backups of a kind, locally and in the bucket."""
189
+ removed = []
190
+ if keep is None or keep <= 0:
191
+ return removed
192
+ for name in local_backups(pattern)[:-keep]:
193
+ (backup_dir() / name).unlink(missing_ok=True)
194
+ removed.append(name)
195
+ target = _s3_target()
196
+ if target:
197
+ bucket, prefix = target
198
+ client = s3_client()
199
+ for name in remote_backups(pattern)[:-keep]:
200
+ client.delete_object(Bucket=bucket, Key=f"{prefix}{name}")
201
+ removed.append(f"s3://{bucket}/{prefix}{name}")
202
+ return removed
203
+
204
+
205
+ def fetch_backup(name: str) -> Path:
206
+ """The path of a local backup, downloading it from the bucket when it is only there."""
207
+ path = Path(name)
208
+ if path.is_file():
209
+ return path
210
+ local = backup_dir() / path.name
211
+ if local.is_file():
212
+ return local
213
+ target = _s3_target()
214
+ if target and (NAME_PATTERN.match(path.name) or MEDIA_PATTERN.match(path.name)):
215
+ bucket, prefix = target
216
+ s3_client().download_file(bucket, f"{prefix}{path.name}", str(local))
217
+ return local
218
+ raise FileNotFoundError(f"No backup named {name} in {backup_dir()} or the backup bucket.")
219
+
220
+
221
+ def restore_backup(path: Path) -> None:
222
+ """Restore a dump over the current database: objects in the dump are dropped and recreated."""
223
+ pg_restore = shutil.which("pg_restore")
224
+ if pg_restore is None:
225
+ raise ImproperlyConfigured("pg_restore is not installed; install the Postgres client.")
226
+ subprocess.run( # noqa: S603 (fixed arguments, no shell)
227
+ [
228
+ pg_restore,
229
+ "--clean",
230
+ "--if-exists",
231
+ "--no-owner",
232
+ "--no-acl",
233
+ "--single-transaction",
234
+ "--dbname",
235
+ _database()["NAME"],
236
+ str(path),
237
+ ],
238
+ env=pg_environment(),
239
+ check=True,
240
+ capture_output=True,
241
+ )
242
+
243
+
244
+ def restore_media(path: Path) -> None:
245
+ """Replace the contents of MEDIA_ROOT with a media archive."""
246
+ root = Path(settings.MEDIA_ROOT)
247
+ root.mkdir(parents=True, exist_ok=True)
248
+ # Extract next to the current files first, so a broken archive changes nothing.
249
+ staging = root / ".restoring"
250
+ if staging.exists():
251
+ shutil.rmtree(staging)
252
+ staging.mkdir()
253
+ try:
254
+ with tarfile.open(path, "r:gz") as archive:
255
+ archive.extractall(staging, filter="data")
256
+ except Exception:
257
+ shutil.rmtree(staging, ignore_errors=True)
258
+ raise
259
+ for entry in root.iterdir():
260
+ if entry == staging:
261
+ continue
262
+ if entry.is_dir() and not entry.is_symlink():
263
+ shutil.rmtree(entry)
264
+ else:
265
+ entry.unlink()
266
+ for entry in staging.iterdir():
267
+ entry.rename(root / entry.name)
268
+ staging.rmdir()
@@ -0,0 +1,135 @@
1
+ """
2
+ DjangoStack's system checks.
3
+
4
+ - ``django_stack.E001``: the read-only API key is the full-access key.
5
+ - ``django_stack.E002``: no read-only API key, so the frontend cannot read content.
6
+ - ``django_stack.E003``: ``DEBUG`` is on while ``SITE_URL`` is https.
7
+ - ``django_stack.E004``: a model in ``PUBLIC_CREATE`` is not installed.
8
+ - ``django_stack.W001``: ``REVALIDATE_URL`` without ``REVALIDATE_SECRET``, so the
9
+ frontend is never told about changes.
10
+ - ``django_stack.W002``: no ``PREVIEW_SECRET``, so editors cannot preview drafts.
11
+ - ``django_stack.W003`` (deploy): ``SECRET_KEY`` shorter than 50 characters.
12
+ - ``django_stack.W004`` (deploy): the cache is per process, so throttles,
13
+ sessions and presence are not shared between workers.
14
+ - ``django_stack.W005`` (deploy): ``SITE_URL`` is https but ``NUM_PROXIES`` is 0,
15
+ so Django does not see that requests came in over HTTPS.
16
+ """
17
+
18
+ from django.apps import apps
19
+ from django.conf import settings
20
+ from django.core.checks import Error, Tags, Warning, register
21
+
22
+ from . import conf
23
+
24
+
25
+ def _headless():
26
+ return getattr(settings, "HEADLESS", None) or {}
27
+
28
+
29
+ @register(Tags.security)
30
+ def check_api_keys(app_configs, **kwargs):
31
+ errors = []
32
+ write_key = _headless().get("AUTH_SECRET_KEY")
33
+ read_key = _headless().get("AUTH_READ_ONLY_SECRET_KEY")
34
+ if write_key and read_key and write_key == read_key:
35
+ errors.append(
36
+ Error(
37
+ "API_READ_KEY is the same as API_SECRET_KEY.",
38
+ hint="Generate a separate read-only key: the frontend must not hold a key that can write.",
39
+ id="django_stack.E001",
40
+ )
41
+ )
42
+ if not read_key:
43
+ errors.append(
44
+ Error(
45
+ "API_READ_KEY is not set.",
46
+ hint="Set API_READ_KEY: the frontend reads content with it.",
47
+ id="django_stack.E002",
48
+ )
49
+ )
50
+ return errors
51
+
52
+
53
+ @register(Tags.security)
54
+ def check_debug(app_configs, **kwargs):
55
+ site_url = getattr(settings, "SITE_URL", "") or ""
56
+ if settings.DEBUG and site_url.startswith("https://"):
57
+ return [
58
+ Error(
59
+ "DEBUG is on while SITE_URL is https.",
60
+ hint="Set DJANGO_DEBUG=0 in production: debug pages show settings and source code.",
61
+ id="django_stack.E003",
62
+ )
63
+ ]
64
+ return []
65
+
66
+
67
+ @register()
68
+ def check_public_create(app_configs, **kwargs):
69
+ errors = []
70
+ for label in conf.get("PUBLIC_CREATE"):
71
+ try:
72
+ apps.get_model(label)
73
+ except (LookupError, ValueError):
74
+ errors.append(
75
+ Error(
76
+ f"PUBLIC_CREATE names {label!r}, which is not an installed model.",
77
+ hint="Use the model's label, such as 'content.formsubmission'.",
78
+ id="django_stack.E004",
79
+ )
80
+ )
81
+ return errors
82
+
83
+
84
+ @register()
85
+ def check_secrets(app_configs, **kwargs):
86
+ warnings = []
87
+ if conf.get("REVALIDATE_URL") and not conf.get("REVALIDATE_SECRET"):
88
+ warnings.append(
89
+ Warning(
90
+ "REVALIDATE_URL is set without REVALIDATE_SECRET.",
91
+ hint="Set REVALIDATE_SECRET to the same value as in the frontend.",
92
+ id="django_stack.W001",
93
+ )
94
+ )
95
+ if not conf.get("PREVIEW_SECRET"):
96
+ warnings.append(
97
+ Warning(
98
+ "PREVIEW_SECRET is not set, so editors cannot preview drafts.",
99
+ hint="Set PREVIEW_SECRET to the same value as in the frontend.",
100
+ id="django_stack.W002",
101
+ )
102
+ )
103
+ return warnings
104
+
105
+
106
+ @register(Tags.security, deploy=True)
107
+ def check_deploy(app_configs, **kwargs):
108
+ warnings = []
109
+ if len(settings.SECRET_KEY or "") < 50:
110
+ warnings.append(
111
+ Warning(
112
+ "DJANGO_SECRET_KEY is shorter than 50 characters.",
113
+ hint="Generate a new one, for example with `npx @django-stack/cli secret`.",
114
+ id="django_stack.W003",
115
+ )
116
+ )
117
+ backend = settings.CACHES.get("default", {}).get("BACKEND", "")
118
+ if backend.endswith(("LocMemCache", "DummyCache")):
119
+ warnings.append(
120
+ Warning(
121
+ "The default cache is not shared between processes.",
122
+ hint="Set CACHE_URL to the Valkey or Redis service.",
123
+ id="django_stack.W004",
124
+ )
125
+ )
126
+ site_url = getattr(settings, "SITE_URL", "") or ""
127
+ if site_url.startswith("https://") and not getattr(settings, "SECURE_PROXY_SSL_HEADER", None):
128
+ warnings.append(
129
+ Warning(
130
+ "SITE_URL is https, but Django does not trust the proxy's X-Forwarded-Proto header.",
131
+ hint="Set NUM_PROXIES=1 behind Caddy, so secure cookies and CSRF checks see HTTPS.",
132
+ id="django_stack.W005",
133
+ )
134
+ )
135
+ return warnings
@@ -0,0 +1,33 @@
1
+ """Access to the DJANGO_STACK settings with their defaults."""
2
+
3
+ from django.conf import settings
4
+
5
+ DEFAULTS = {
6
+ "PREVIEW_SECRET": None,
7
+ "PREVIEW_MAX_AGE": 3600,
8
+ "PREVIEW_HEADER": "X-Preview-Token",
9
+ "REVALIDATE_URL": None,
10
+ "REVALIDATE_SECRET": None,
11
+ "REVALIDATE_TIMEOUT": 5,
12
+ "PUBLIC_CREATE": [],
13
+ "CONTENT_APPS": ["content"],
14
+ "SEED_CONTENT": None,
15
+ "ADMIN_USERNAME": "admin",
16
+ "ADMIN_EMAIL": None,
17
+ "ADMIN_PASSWORD": None,
18
+ "BACKUP_DIR": None,
19
+ "BACKUP_KEEP": 14,
20
+ "BACKUP_TIME": "03:00",
21
+ "BACKUP_S3_BUCKET": None,
22
+ "BACKUP_S3_PREFIX": "backups/",
23
+ "BACKUP_S3": {},
24
+ "BACKUP_MEDIA": True,
25
+ "BACKUP_DAILY": False,
26
+ }
27
+
28
+
29
+ def get(name: str):
30
+ configured = getattr(settings, "DJANGO_STACK", None) or {}
31
+ if name in configured:
32
+ return configured[name]
33
+ return DEFAULTS[name]
@@ -0,0 +1,8 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ urlpatterns = [
6
+ path("healthz", views.healthz, name="django-stack-healthz"),
7
+ path("readyz", views.readyz, name="django-stack-readyz"),
8
+ ]
@@ -0,0 +1,36 @@
1
+ from django.core.cache import cache
2
+ from django.db import connection
3
+ from django.http import JsonResponse
4
+ from django.views.decorators.cache import never_cache
5
+ from django.views.decorators.http import require_GET
6
+
7
+
8
+ @never_cache
9
+ @require_GET
10
+ def healthz(request):
11
+ """The process is up."""
12
+ return JsonResponse({"status": "ok"})
13
+
14
+
15
+ @never_cache
16
+ @require_GET
17
+ def readyz(request):
18
+ """The database and the cache are reachable."""
19
+ checks = {}
20
+ try:
21
+ connection.ensure_connection()
22
+ with connection.cursor() as cursor:
23
+ cursor.execute("SELECT 1")
24
+ checks["database"] = "ok"
25
+ except Exception: # noqa: BLE001
26
+ checks["database"] = "error"
27
+ try:
28
+ cache.set("django_stack:readyz", "1", 5)
29
+ checks["cache"] = "ok" if cache.get("django_stack:readyz") == "1" else "error"
30
+ except Exception: # noqa: BLE001
31
+ checks["cache"] = "error"
32
+
33
+ ready = all(value == "ok" for value in checks.values())
34
+ return JsonResponse(
35
+ {"status": "ok" if ready else "error", "checks": checks}, status=200 if ready else 503
36
+ )
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from ... import conf
4
+ from .tasks import enqueue_revalidate
5
+
6
+
7
+ def on_content_changed(sender, changes, **kwargs):
8
+ """Content Studio's content_changed signal: forward the changes to the frontend."""
9
+ if not conf.get("REVALIDATE_URL") or not conf.get("REVALIDATE_SECRET"):
10
+ return
11
+ payload = [
12
+ {
13
+ "action": change.action,
14
+ "model": change.model._meta.label_lower if change.model is not None else None,
15
+ "id": str(change.object_id) if change.object_id is not None else None,
16
+ }
17
+ for change in changes
18
+ ]
19
+ if payload:
20
+ enqueue_revalidate(payload)