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.
- django_stack-0.2.0/PKG-INFO +65 -0
- django_stack-0.2.0/README.md +28 -0
- {django_stack-0.0.1 → django_stack-0.2.0}/pyproject.toml +25 -3
- {django_stack-0.0.1 → django_stack-0.2.0}/src/django_stack/__init__.py +1 -1
- django_stack-0.2.0/src/django_stack/apps.py +19 -0
- django_stack-0.2.0/src/django_stack/backup.py +268 -0
- django_stack-0.2.0/src/django_stack/checks.py +135 -0
- django_stack-0.2.0/src/django_stack/conf.py +33 -0
- django_stack-0.2.0/src/django_stack/contrib/health/__init__.py +0 -0
- django_stack-0.2.0/src/django_stack/contrib/health/urls.py +8 -0
- django_stack-0.2.0/src/django_stack/contrib/health/views.py +36 -0
- django_stack-0.2.0/src/django_stack/contrib/revalidate/__init__.py +0 -0
- django_stack-0.2.0/src/django_stack/contrib/revalidate/receivers.py +20 -0
- django_stack-0.2.0/src/django_stack/contrib/revalidate/tasks.py +71 -0
- django_stack-0.2.0/src/django_stack/env.py +177 -0
- django_stack-0.2.0/src/django_stack/management/__init__.py +0 -0
- django_stack-0.2.0/src/django_stack/management/commands/__init__.py +0 -0
- django_stack-0.2.0/src/django_stack/management/commands/backup_database.py +40 -0
- django_stack-0.2.0/src/django_stack/management/commands/create_admin.py +30 -0
- django_stack-0.2.0/src/django_stack/management/commands/create_bucket.py +21 -0
- django_stack-0.2.0/src/django_stack/management/commands/export_content.py +29 -0
- django_stack-0.2.0/src/django_stack/management/commands/import_content.py +12 -0
- django_stack-0.2.0/src/django_stack/management/commands/restore_database.py +43 -0
- django_stack-0.2.0/src/django_stack/management/commands/restore_media.py +28 -0
- django_stack-0.2.0/src/django_stack/management/commands/run_backups.py +51 -0
- django_stack-0.2.0/src/django_stack/management/commands/seed_content.py +26 -0
- django_stack-0.2.0/src/django_stack/management/commands/wait_for_db.py +25 -0
- django_stack-0.2.0/src/django_stack/models.py +51 -0
- django_stack-0.2.0/src/django_stack/preview.py +33 -0
- django_stack-0.2.0/src/django_stack/rest/__init__.py +1 -0
- django_stack-0.2.0/src/django_stack/rest/filters.py +28 -0
- django_stack-0.2.0/src/django_stack/rest/permissions.py +39 -0
- django_stack-0.2.0/src/django_stack/rest/serializers.py +135 -0
- django_stack-0.2.0/src/django_stack/rest/throttling.py +19 -0
- django_stack-0.2.0/src/django_stack/settings.py +325 -0
- django_stack-0.2.0/src/django_stack/signing.py +61 -0
- django_stack-0.2.0/src/django_stack/staticfiles.py +19 -0
- django_stack-0.2.0/src/django_stack/studio.py +26 -0
- django_stack-0.2.0/src/django_stack/tasks.py +83 -0
- django_stack-0.2.0/src/django_stack/urls.py +41 -0
- django_stack-0.2.0/tests/__init__.py +0 -0
- django_stack-0.2.0/tests/conftest.py +17 -0
- django_stack-0.2.0/tests/settings.py +63 -0
- django_stack-0.2.0/tests/test_api.py +177 -0
- django_stack-0.2.0/tests/test_backup.py +145 -0
- django_stack-0.2.0/tests/test_env.py +53 -0
- django_stack-0.2.0/tests/test_settings.py +107 -0
- django_stack-0.2.0/tests/test_signing.py +29 -0
- django_stack-0.2.0/tests/test_staticfiles.py +9 -0
- django_stack-0.2.0/tests/test_tasks.py +49 -0
- django_stack-0.2.0/tests/test_urls.py +20 -0
- django_stack-0.2.0/tests/testapp/__init__.py +0 -0
- django_stack-0.2.0/tests/testapp/apps.py +6 -0
- django_stack-0.2.0/tests/testapp/models.py +44 -0
- django_stack-0.2.0/tests/urls.py +6 -0
- django_stack-0.2.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.2.0}/.gitignore +0 -0
- {django_stack-0.0.1 → django_stack-0.2.0}/LICENSE +0 -0
- {django_stack-0.0.1/tests → django_stack-0.2.0/src/django_stack/contrib}/__init__.py +0 -0
- {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
|
|
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 = [
|
|
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,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]
|
|
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)
|