django-stack 0.0.1__tar.gz → 0.1.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 (61) hide show
  1. django_stack-0.1.0/PKG-INFO +65 -0
  2. django_stack-0.1.0/README.md +28 -0
  3. {django_stack-0.0.1 → django_stack-0.1.0}/pyproject.toml +25 -3
  4. {django_stack-0.0.1 → django_stack-0.1.0}/src/django_stack/__init__.py +1 -1
  5. django_stack-0.1.0/src/django_stack/apps.py +19 -0
  6. django_stack-0.1.0/src/django_stack/backup.py +199 -0
  7. django_stack-0.1.0/src/django_stack/checks.py +135 -0
  8. django_stack-0.1.0/src/django_stack/conf.py +31 -0
  9. django_stack-0.1.0/src/django_stack/contrib/health/__init__.py +0 -0
  10. django_stack-0.1.0/src/django_stack/contrib/health/urls.py +8 -0
  11. django_stack-0.1.0/src/django_stack/contrib/health/views.py +36 -0
  12. django_stack-0.1.0/src/django_stack/contrib/revalidate/__init__.py +0 -0
  13. django_stack-0.1.0/src/django_stack/contrib/revalidate/receivers.py +20 -0
  14. django_stack-0.1.0/src/django_stack/contrib/revalidate/tasks.py +71 -0
  15. django_stack-0.1.0/src/django_stack/env.py +177 -0
  16. django_stack-0.1.0/src/django_stack/management/__init__.py +0 -0
  17. django_stack-0.1.0/src/django_stack/management/commands/__init__.py +0 -0
  18. django_stack-0.1.0/src/django_stack/management/commands/backup_database.py +30 -0
  19. django_stack-0.1.0/src/django_stack/management/commands/create_admin.py +30 -0
  20. django_stack-0.1.0/src/django_stack/management/commands/create_bucket.py +21 -0
  21. django_stack-0.1.0/src/django_stack/management/commands/export_content.py +29 -0
  22. django_stack-0.1.0/src/django_stack/management/commands/import_content.py +12 -0
  23. django_stack-0.1.0/src/django_stack/management/commands/restore_database.py +33 -0
  24. django_stack-0.1.0/src/django_stack/management/commands/run_backups.py +43 -0
  25. django_stack-0.1.0/src/django_stack/management/commands/seed_content.py +26 -0
  26. django_stack-0.1.0/src/django_stack/management/commands/wait_for_db.py +25 -0
  27. django_stack-0.1.0/src/django_stack/models.py +51 -0
  28. django_stack-0.1.0/src/django_stack/preview.py +33 -0
  29. django_stack-0.1.0/src/django_stack/rest/__init__.py +1 -0
  30. django_stack-0.1.0/src/django_stack/rest/filters.py +28 -0
  31. django_stack-0.1.0/src/django_stack/rest/permissions.py +39 -0
  32. django_stack-0.1.0/src/django_stack/rest/serializers.py +135 -0
  33. django_stack-0.1.0/src/django_stack/rest/throttling.py +19 -0
  34. django_stack-0.1.0/src/django_stack/settings.py +322 -0
  35. django_stack-0.1.0/src/django_stack/signing.py +61 -0
  36. django_stack-0.1.0/src/django_stack/staticfiles.py +19 -0
  37. django_stack-0.1.0/src/django_stack/studio.py +26 -0
  38. django_stack-0.1.0/src/django_stack/urls.py +41 -0
  39. django_stack-0.1.0/tests/__init__.py +0 -0
  40. django_stack-0.1.0/tests/conftest.py +17 -0
  41. django_stack-0.1.0/tests/settings.py +63 -0
  42. django_stack-0.1.0/tests/test_api.py +177 -0
  43. django_stack-0.1.0/tests/test_backup.py +85 -0
  44. django_stack-0.1.0/tests/test_env.py +53 -0
  45. django_stack-0.1.0/tests/test_settings.py +107 -0
  46. django_stack-0.1.0/tests/test_signing.py +29 -0
  47. django_stack-0.1.0/tests/test_staticfiles.py +9 -0
  48. django_stack-0.1.0/tests/test_urls.py +20 -0
  49. django_stack-0.1.0/tests/testapp/__init__.py +0 -0
  50. django_stack-0.1.0/tests/testapp/apps.py +6 -0
  51. django_stack-0.1.0/tests/testapp/models.py +44 -0
  52. django_stack-0.1.0/tests/urls.py +6 -0
  53. django_stack-0.1.0/uv.lock +1280 -0
  54. django_stack-0.0.1/PKG-INFO +0 -37
  55. django_stack-0.0.1/README.md +0 -12
  56. django_stack-0.0.1/tests/settings.py +0 -4
  57. django_stack-0.0.1/uv.lock +0 -167
  58. {django_stack-0.0.1 → django_stack-0.1.0}/.gitignore +0 -0
  59. {django_stack-0.0.1 → django_stack-0.1.0}/LICENSE +0 -0
  60. {django_stack-0.0.1/tests → django_stack-0.1.0/src/django_stack/contrib}/__init__.py +0 -0
  61. {django_stack-0.0.1 → django_stack-0.1.0}/tests/test_version.py +0 -0
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-stack
3
+ Version: 0.1.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.1.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.1.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,199 @@
1
+ """
2
+ Database backups: ``pg_dump`` to a directory and, optionally, to a private
3
+ S3 bucket, with retention.
4
+
5
+ The ``backup`` service of a project runs ``run_backups``, which calls
6
+ :func:`create_backup` once a day. ``backup_database`` makes one now and
7
+ ``restore_database`` restores one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ from django.conf import settings
21
+ from django.core.exceptions import ImproperlyConfigured
22
+
23
+ from . import conf
24
+
25
+ NAME_PATTERN = re.compile(r"^(?P<prefix>.+)-(?P<stamp>\d{8}T\d{6}Z)\.dump$")
26
+
27
+
28
+ @dataclass
29
+ class Backup:
30
+ name: str
31
+ path: Path
32
+ uploaded: bool = False
33
+
34
+
35
+ def _database() -> dict:
36
+ database = settings.DATABASES["default"]
37
+ if "postgresql" not in database["ENGINE"]:
38
+ raise ImproperlyConfigured("Backups need a Postgres database; DATABASE_URL is not postgres://.")
39
+ return database
40
+
41
+
42
+ def pg_environment(database: dict | None = None) -> dict[str, str]:
43
+ """The environment for pg_dump and pg_restore: connection values as PG* variables."""
44
+ database = database or _database()
45
+ env = dict(os.environ)
46
+ values = {
47
+ "PGHOST": database.get("HOST") or "localhost",
48
+ "PGPORT": str(database.get("PORT") or 5432),
49
+ "PGUSER": database.get("USER") or "",
50
+ "PGPASSWORD": database.get("PASSWORD") or "",
51
+ "PGDATABASE": database.get("NAME") or "",
52
+ }
53
+ sslmode = (database.get("OPTIONS") or {}).get("sslmode")
54
+ if sslmode:
55
+ values["PGSSLMODE"] = sslmode
56
+ env.update({key: value for key, value in values.items() if value})
57
+ return env
58
+
59
+
60
+ def backup_dir() -> Path:
61
+ path = Path(conf.get("BACKUP_DIR") or Path(settings.BASE_DIR) / "backups")
62
+ path.mkdir(parents=True, exist_ok=True)
63
+ return path
64
+
65
+
66
+ def backup_name(now: datetime | None = None) -> str:
67
+ now = now or datetime.now(UTC)
68
+ prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", getattr(settings, "PROJECT_NAME", "") or "backup")
69
+ return f"{prefix}-{now.strftime('%Y%m%dT%H%M%SZ')}.dump"
70
+
71
+
72
+ def s3_client():
73
+ """A boto3 client for the backup bucket, with the S3_* credentials."""
74
+ import boto3
75
+
76
+ options = conf.get("BACKUP_S3") or {}
77
+ return boto3.client(
78
+ "s3",
79
+ endpoint_url=options.get("endpoint_url"),
80
+ region_name=options.get("region_name"),
81
+ aws_access_key_id=options.get("access_key"),
82
+ aws_secret_access_key=options.get("secret_key"),
83
+ )
84
+
85
+
86
+ def _s3_target() -> tuple[str, str] | None:
87
+ bucket = conf.get("BACKUP_S3_BUCKET")
88
+ if not bucket:
89
+ return None
90
+ prefix = (conf.get("BACKUP_S3_PREFIX") or "").strip("/")
91
+ return bucket, f"{prefix}/" if prefix else ""
92
+
93
+
94
+ def create_backup(*, keep: int | None = None) -> Backup:
95
+ """Dump the database, upload it when a backup bucket is set, and apply the retention."""
96
+ pg_dump = shutil.which("pg_dump")
97
+ if pg_dump is None:
98
+ raise ImproperlyConfigured("pg_dump is not installed; install the Postgres client.")
99
+ directory = backup_dir()
100
+ name = backup_name()
101
+ path = directory / name
102
+ partial = path.with_suffix(".partial")
103
+ subprocess.run( # noqa: S603 (fixed arguments, no shell)
104
+ [pg_dump, "--format=custom", "--no-owner", "--no-acl", "--file", str(partial)],
105
+ env=pg_environment(),
106
+ check=True,
107
+ capture_output=True,
108
+ )
109
+ partial.rename(path)
110
+ path.chmod(0o600)
111
+ backup = Backup(name=name, path=path)
112
+
113
+ target = _s3_target()
114
+ if target:
115
+ bucket, prefix = target
116
+ s3_client().upload_file(str(path), bucket, f"{prefix}{name}")
117
+ backup.uploaded = True
118
+
119
+ prune(keep if keep is not None else conf.get("BACKUP_KEEP"))
120
+ return backup
121
+
122
+
123
+ def _sorted_names(names) -> list[str]:
124
+ return sorted(
125
+ (name for name in names if NAME_PATTERN.match(name)),
126
+ key=lambda name: NAME_PATTERN.match(name)["stamp"],
127
+ )
128
+
129
+
130
+ def local_backups() -> list[str]:
131
+ return _sorted_names(path.name for path in backup_dir().iterdir())
132
+
133
+
134
+ def remote_backups() -> list[str]:
135
+ target = _s3_target()
136
+ if not target:
137
+ return []
138
+ bucket, prefix = target
139
+ names = []
140
+ for page in s3_client().get_paginator("list_objects_v2").paginate(Bucket=bucket, Prefix=prefix):
141
+ names += [item["Key"][len(prefix) :] for item in page.get("Contents", [])]
142
+ return _sorted_names(names)
143
+
144
+
145
+ def prune(keep: int) -> list[str]:
146
+ """Delete all but the ``keep`` newest backups, locally and in the bucket."""
147
+ removed = []
148
+ if keep is None or keep <= 0:
149
+ return removed
150
+ for name in local_backups()[:-keep]:
151
+ (backup_dir() / name).unlink(missing_ok=True)
152
+ removed.append(name)
153
+ target = _s3_target()
154
+ if target:
155
+ bucket, prefix = target
156
+ client = s3_client()
157
+ for name in remote_backups()[:-keep]:
158
+ client.delete_object(Bucket=bucket, Key=f"{prefix}{name}")
159
+ removed.append(f"s3://{bucket}/{prefix}{name}")
160
+ return removed
161
+
162
+
163
+ def fetch_backup(name: str) -> Path:
164
+ """The path of a local backup, downloading it from the bucket when it is only there."""
165
+ path = Path(name)
166
+ if path.is_file():
167
+ return path
168
+ local = backup_dir() / path.name
169
+ if local.is_file():
170
+ return local
171
+ target = _s3_target()
172
+ if target and NAME_PATTERN.match(path.name):
173
+ bucket, prefix = target
174
+ s3_client().download_file(bucket, f"{prefix}{path.name}", str(local))
175
+ return local
176
+ raise FileNotFoundError(f"No backup named {name} in {backup_dir()} or the backup bucket.")
177
+
178
+
179
+ def restore_backup(path: Path) -> None:
180
+ """Restore a dump over the current database: objects in the dump are dropped and recreated."""
181
+ pg_restore = shutil.which("pg_restore")
182
+ if pg_restore is None:
183
+ raise ImproperlyConfigured("pg_restore is not installed; install the Postgres client.")
184
+ subprocess.run( # noqa: S603 (fixed arguments, no shell)
185
+ [
186
+ pg_restore,
187
+ "--clean",
188
+ "--if-exists",
189
+ "--no-owner",
190
+ "--no-acl",
191
+ "--single-transaction",
192
+ "--dbname",
193
+ _database()["NAME"],
194
+ str(path),
195
+ ],
196
+ env=pg_environment(),
197
+ check=True,
198
+ capture_output=True,
199
+ )
@@ -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,31 @@
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
+ }
25
+
26
+
27
+ def get(name: str):
28
+ configured = getattr(settings, "DJANGO_STACK", None) or {}
29
+ if name in configured:
30
+ return configured[name]
31
+ 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)
@@ -0,0 +1,71 @@
1
+ """Tell the frontend which content changed, so it can purge its cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+ from ... import conf
11
+ from ...signing import sign_webhook
12
+
13
+ logger = logging.getLogger("django_stack.revalidate")
14
+
15
+ try: # Django 6.0+
16
+ from django.tasks import task
17
+ except ImportError: # pragma: no cover - Django 5.2 with or without django-tasks
18
+ try:
19
+ from django_tasks import task
20
+ except ImportError:
21
+ task = None
22
+
23
+
24
+ def send_revalidate(changes: list[dict]) -> bool:
25
+ """POST the changes to REVALIDATE_URL, signed with REVALIDATE_SECRET."""
26
+ url = conf.get("REVALIDATE_URL")
27
+ secret = conf.get("REVALIDATE_SECRET")
28
+ if not url or not secret:
29
+ return False
30
+
31
+ body = json.dumps({"version": 1, "event": "content.changed", "changes": changes}).encode()
32
+ request = urllib.request.Request( # noqa: S310 - the URL is the project's own setting
33
+ url,
34
+ data=body,
35
+ method="POST",
36
+ headers={
37
+ "Content-Type": "application/json",
38
+ "User-Agent": "django-stack",
39
+ "X-Django-Stack-Signature": sign_webhook(secret, body),
40
+ },
41
+ )
42
+ try:
43
+ with urllib.request.urlopen(request, timeout=conf.get("REVALIDATE_TIMEOUT")) as response: # noqa: S310
44
+ ok = 200 <= response.status < 300
45
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
46
+ logger.warning("Revalidation request to %s failed: %s", url, error)
47
+ return False
48
+ if not ok:
49
+ logger.warning("Revalidation request to %s answered %s", url, response.status)
50
+ return ok
51
+
52
+
53
+ if task is not None:
54
+
55
+ @task()
56
+ def revalidate(changes: list[dict]) -> bool:
57
+ """The task the worker runs: one revalidation request."""
58
+ return send_revalidate(changes)
59
+
60
+ else: # pragma: no cover
61
+ revalidate = None
62
+
63
+
64
+ def enqueue_revalidate(changes: list[dict]) -> None:
65
+ if revalidate is not None:
66
+ try:
67
+ revalidate.enqueue(changes)
68
+ return
69
+ except Exception as error: # noqa: BLE001 - fall back to sending in the request
70
+ logger.warning("Could not enqueue the revalidation task: %s", error)
71
+ send_revalidate(changes)