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.
- django_stack-0.1.0/PKG-INFO +65 -0
- django_stack-0.1.0/README.md +28 -0
- {django_stack-0.0.1 → django_stack-0.1.0}/pyproject.toml +25 -3
- {django_stack-0.0.1 → django_stack-0.1.0}/src/django_stack/__init__.py +1 -1
- django_stack-0.1.0/src/django_stack/apps.py +19 -0
- django_stack-0.1.0/src/django_stack/backup.py +199 -0
- django_stack-0.1.0/src/django_stack/checks.py +135 -0
- django_stack-0.1.0/src/django_stack/conf.py +31 -0
- django_stack-0.1.0/src/django_stack/contrib/health/__init__.py +0 -0
- django_stack-0.1.0/src/django_stack/contrib/health/urls.py +8 -0
- django_stack-0.1.0/src/django_stack/contrib/health/views.py +36 -0
- django_stack-0.1.0/src/django_stack/contrib/revalidate/__init__.py +0 -0
- django_stack-0.1.0/src/django_stack/contrib/revalidate/receivers.py +20 -0
- django_stack-0.1.0/src/django_stack/contrib/revalidate/tasks.py +71 -0
- django_stack-0.1.0/src/django_stack/env.py +177 -0
- django_stack-0.1.0/src/django_stack/management/__init__.py +0 -0
- django_stack-0.1.0/src/django_stack/management/commands/__init__.py +0 -0
- django_stack-0.1.0/src/django_stack/management/commands/backup_database.py +30 -0
- django_stack-0.1.0/src/django_stack/management/commands/create_admin.py +30 -0
- django_stack-0.1.0/src/django_stack/management/commands/create_bucket.py +21 -0
- django_stack-0.1.0/src/django_stack/management/commands/export_content.py +29 -0
- django_stack-0.1.0/src/django_stack/management/commands/import_content.py +12 -0
- django_stack-0.1.0/src/django_stack/management/commands/restore_database.py +33 -0
- django_stack-0.1.0/src/django_stack/management/commands/run_backups.py +43 -0
- django_stack-0.1.0/src/django_stack/management/commands/seed_content.py +26 -0
- django_stack-0.1.0/src/django_stack/management/commands/wait_for_db.py +25 -0
- django_stack-0.1.0/src/django_stack/models.py +51 -0
- django_stack-0.1.0/src/django_stack/preview.py +33 -0
- django_stack-0.1.0/src/django_stack/rest/__init__.py +1 -0
- django_stack-0.1.0/src/django_stack/rest/filters.py +28 -0
- django_stack-0.1.0/src/django_stack/rest/permissions.py +39 -0
- django_stack-0.1.0/src/django_stack/rest/serializers.py +135 -0
- django_stack-0.1.0/src/django_stack/rest/throttling.py +19 -0
- django_stack-0.1.0/src/django_stack/settings.py +322 -0
- django_stack-0.1.0/src/django_stack/signing.py +61 -0
- django_stack-0.1.0/src/django_stack/staticfiles.py +19 -0
- django_stack-0.1.0/src/django_stack/studio.py +26 -0
- django_stack-0.1.0/src/django_stack/urls.py +41 -0
- django_stack-0.1.0/tests/__init__.py +0 -0
- django_stack-0.1.0/tests/conftest.py +17 -0
- django_stack-0.1.0/tests/settings.py +63 -0
- django_stack-0.1.0/tests/test_api.py +177 -0
- django_stack-0.1.0/tests/test_backup.py +85 -0
- django_stack-0.1.0/tests/test_env.py +53 -0
- django_stack-0.1.0/tests/test_settings.py +107 -0
- django_stack-0.1.0/tests/test_signing.py +29 -0
- django_stack-0.1.0/tests/test_staticfiles.py +9 -0
- django_stack-0.1.0/tests/test_urls.py +20 -0
- django_stack-0.1.0/tests/testapp/__init__.py +0 -0
- django_stack-0.1.0/tests/testapp/apps.py +6 -0
- django_stack-0.1.0/tests/testapp/models.py +44 -0
- django_stack-0.1.0/tests/urls.py +6 -0
- django_stack-0.1.0/uv.lock +1280 -0
- django_stack-0.0.1/PKG-INFO +0 -37
- django_stack-0.0.1/README.md +0 -12
- django_stack-0.0.1/tests/settings.py +0 -4
- django_stack-0.0.1/uv.lock +0 -167
- {django_stack-0.0.1 → django_stack-0.1.0}/.gitignore +0 -0
- {django_stack-0.0.1 → django_stack-0.1.0}/LICENSE +0 -0
- {django_stack-0.0.1/tests → django_stack-0.1.0/src/django_stack/contrib}/__init__.py +0 -0
- {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
|
|
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 = [
|
|
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 = [
|
|
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"]
|
|
@@ -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]
|
|
File without changes
|
|
@@ -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
|
+
)
|
|
File without changes
|
|
@@ -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)
|