@django-stack/cli 0.5.0 → 0.7.0
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.
- package/dist/bin.js +2161 -515
- package/dist/index.d.ts +54 -7
- package/dist/index.js +1 -1
- package/dist/{scaffold-DbgGYEsc.js → production-DBpWWx6i.js} +471 -103
- package/package.json +1 -1
- package/templates/backend/config/studio.py +9 -0
- package/templates/backend/content/admin.py +16 -2
- package/templates/backend/entrypoint.sh +5 -1
- package/templates/backend/pyproject.toml +2 -2
- package/templates/base/AGENTS.md +2 -1
- package/templates/base/Caddyfile +14 -1
- package/templates/base/README.md +18 -4
- package/templates/base/compose.yaml +57 -9
- package/templates/base/env.example +37 -4
- package/templates/frontend-astro/astro.config.mjs +13 -6
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-astro/src/layouts/Base.astro +3 -0
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-next/src/app/layout.tsx +9 -0
- package/templates/frontend-next/src/proxy.ts +6 -0
- package/templates/frontend-nuxt/nuxt.config.ts +12 -6
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +6 -1
- package/templates/frontend-nuxt/src/layouts/default.vue +3 -0
- package/templates/frontend-sveltekit/package.json +2 -2
- package/templates/frontend-sveltekit/src/routes/+layout.server.ts +2 -0
- package/templates/frontend-sveltekit/src/routes/+layout.svelte +3 -0
- package/templates/frontend-sveltekit/vite.config.ts +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@django-stack/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The production-ready Django, batteries included stack. Create and manage DjangoStack projects: a Django backend, a frontend, Postgres and Docker Compose in one command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Leon van der Grient",
|
|
@@ -5,9 +5,11 @@ https://djangocontentstudio.org/docs/extensions for the hooks and extensions.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from content_studio.admin import ContentStudioSite
|
|
8
|
+
from content_studio.analytics import AnalyticsExtension, AnalyticsLink, AnalyticsPage
|
|
8
9
|
from content_studio.contrib.huey import HueyAdapter
|
|
9
10
|
from content_studio.dashboard.tasks import TaskDashboard
|
|
10
11
|
from content_studio.extensions import UserMenuLink
|
|
12
|
+
from django_stack.analytics import UmamiProvider
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class SiteStudio(ContentStudioSite):
|
|
@@ -16,5 +18,12 @@ class SiteStudio(ContentStudioSite):
|
|
|
16
18
|
UserMenuLink(url="/tasks/", label="Tasks", icon="ph-bold ph-queue"),
|
|
17
19
|
]
|
|
18
20
|
|
|
21
|
+
# Page analytics from the bundled Umami when UMAMI_URL is set (see
|
|
22
|
+
# .env): an Analytics page in the main menu. content/admin.py shows the
|
|
23
|
+
# numbers of each page and post. See
|
|
24
|
+
# https://djangocontentstudio.org/docs/analytics.
|
|
25
|
+
if UmamiProvider.is_configured():
|
|
26
|
+
extensions += [AnalyticsExtension(provider=UmamiProvider()), AnalyticsPage(), AnalyticsLink()]
|
|
27
|
+
|
|
19
28
|
|
|
20
29
|
admin_site = SiteStudio()
|
|
@@ -5,6 +5,7 @@ The studios of the content models: how they look in Content Studio at
|
|
|
5
5
|
|
|
6
6
|
from content_studio import register
|
|
7
7
|
from content_studio.admin import ModelStudio
|
|
8
|
+
from content_studio.analytics import Analytics
|
|
8
9
|
from content_studio.form import ActivityLog, Field, FormSet
|
|
9
10
|
from django_stack.studio import PreviewButton
|
|
10
11
|
|
|
@@ -15,6 +16,10 @@ PAGE_PREVIEW = PreviewButton(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a01"
|
|
|
15
16
|
POST_PREVIEW = PreviewButton(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a02")
|
|
16
17
|
PAGE_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a03")
|
|
17
18
|
POST_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a04")
|
|
19
|
+
# The visitors of the page or post, from the analytics (see config/studio.py).
|
|
20
|
+
# Hidden while analytics are off.
|
|
21
|
+
PAGE_ANALYTICS = Analytics(path_field="path", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a05")
|
|
22
|
+
POST_ANALYTICS = Analytics(path_field="slug", path_prefix="/blog/", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a06")
|
|
18
23
|
SEO = FormSet(
|
|
19
24
|
title="Search and sharing",
|
|
20
25
|
description="Leave empty to use the title and the site settings.",
|
|
@@ -37,7 +42,7 @@ class PageStudio(ModelStudio):
|
|
|
37
42
|
FormSet(title="Body", fields=["body"]),
|
|
38
43
|
SEO,
|
|
39
44
|
]
|
|
40
|
-
edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", PAGE_ACTIVITY]
|
|
45
|
+
edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", PAGE_ANALYTICS, PAGE_ACTIVITY]
|
|
41
46
|
|
|
42
47
|
|
|
43
48
|
@register(Post)
|
|
@@ -55,7 +60,16 @@ class PostStudio(ModelStudio):
|
|
|
55
60
|
FormSet(title="Body", fields=["body"]),
|
|
56
61
|
SEO,
|
|
57
62
|
]
|
|
58
|
-
edit_sidebar = [
|
|
63
|
+
edit_sidebar = [
|
|
64
|
+
"status",
|
|
65
|
+
"published_at",
|
|
66
|
+
POST_PREVIEW,
|
|
67
|
+
"author",
|
|
68
|
+
"categories",
|
|
69
|
+
"tags",
|
|
70
|
+
POST_ANALYTICS,
|
|
71
|
+
POST_ACTIVITY,
|
|
72
|
+
]
|
|
59
73
|
|
|
60
74
|
|
|
61
75
|
@register(Category, Tag)
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
# first admin user and, with SEED_DEMO_CONTENT=1, add demo content to an
|
|
6
6
|
# empty site. DJANGO_CHECK_DEPLOY=1 (production): stop when Django's deploy
|
|
7
7
|
# checks find an error. S3_CREATE_BUCKET=1 (the development S3 service):
|
|
8
|
-
# create the media bucket.
|
|
8
|
+
# create the media bucket. UMAMI_URL set (the analytics profile): set up
|
|
9
|
+
# Umami; when that fails the site still starts, without analytics.
|
|
9
10
|
set -eu
|
|
10
11
|
|
|
11
12
|
python manage.py wait_for_db
|
|
@@ -22,6 +23,9 @@ if [ "${DJANGO_MIGRATE:-0}" = "1" ]; then
|
|
|
22
23
|
if [ "${SEED_DEMO_CONTENT:-0}" = "1" ]; then
|
|
23
24
|
python manage.py seed_content --if-empty
|
|
24
25
|
fi
|
|
26
|
+
if [ -n "${UMAMI_URL:-}" ]; then
|
|
27
|
+
python manage.py setup_umami || echo "Umami is not set up, so the admin shows no analytics. See the error above." >&2
|
|
28
|
+
fi
|
|
25
29
|
fi
|
|
26
30
|
|
|
27
31
|
exec "$@"
|
|
@@ -5,9 +5,9 @@ description = "The Django backend of {{project_name}}."
|
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"Django>=6.1,<6.2",
|
|
8
|
-
"django-stack[server,s3]>=0.
|
|
8
|
+
"django-stack[server,s3]>=0.7.0",
|
|
9
9
|
"django-headless[schema]>=1.1.1",
|
|
10
|
-
"django-content-studio[images,huey]>=1.
|
|
10
|
+
"django-content-studio[images,huey]>=1.11.0",
|
|
11
11
|
]
|
|
12
12
|
|
|
13
13
|
[dependency-groups]
|
package/templates/base/AGENTS.md
CHANGED
|
@@ -41,7 +41,8 @@ Run them at the project root. They need Docker; Node is only needed for the `npm
|
|
|
41
41
|
|
|
42
42
|
In production (`COMPOSE_PROFILES=prod,db`) the services are `backend-prod`,
|
|
43
43
|
`worker-prod` (which also runs the daily backup), `frontend-prod` and
|
|
44
|
-
`proxy
|
|
44
|
+
`proxy`, with `umami` for page analytics when the `analytics` profile is on;
|
|
45
|
+
their containers are
|
|
45
46
|
read-only, so a change goes through the code and a new image, never an edit
|
|
46
47
|
in a running container.
|
|
47
48
|
|
package/templates/base/Caddyfile
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# {{project_name}}: the reverse proxy of the prod profile.
|
|
2
2
|
#
|
|
3
3
|
# One domain: Django serves /admin, /api and /static; uploads on the server's
|
|
4
|
-
# disk are served from /media;
|
|
4
|
+
# disk are served from /media; Umami's tracker from /_a/; the frontend
|
|
5
|
+
# serves everything else. Caddy
|
|
5
6
|
# gets and renews the HTTPS certificate for SITE_URL by itself.
|
|
6
7
|
#
|
|
7
8
|
# Behind a platform's proxy (Coolify, Dokploy), CADDY_ADDRESS=:80 serves
|
|
@@ -35,6 +36,18 @@
|
|
|
35
36
|
file_server
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
# Page analytics: only Umami's tracker and its page views, never its
|
|
40
|
+
# interface or API. Without the analytics profile these answer 502.
|
|
41
|
+
handle_path /_a/* {
|
|
42
|
+
@tracker path /script.js /api/send
|
|
43
|
+
handle @tracker {
|
|
44
|
+
reverse_proxy umami:3000
|
|
45
|
+
}
|
|
46
|
+
handle {
|
|
47
|
+
respond 404
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
@django path /admin /admin/* /api /api/* /static/*
|
|
39
52
|
handle @django {
|
|
40
53
|
reverse_proxy backend:8000
|
package/templates/base/README.md
CHANGED
|
@@ -6,7 +6,9 @@ A website with a Django backend and a frontend built with {{frontend_label}}, cr
|
|
|
6
6
|
- [Django Headless](https://djangoheadless.org) serves the content API.
|
|
7
7
|
- [Django Content Studio](https://djangocontentstudio.org) is the admin.
|
|
8
8
|
- [{{frontend_label}}]({{frontend_url}}) renders the site.
|
|
9
|
-
- Postgres stores the content
|
|
9
|
+
- Postgres stores the content, and by default also the cache and the task
|
|
10
|
+
queue (`CACHE_URL` and `TASKS_URL` in `.env`); Valkey can take those over
|
|
11
|
+
with the `valkey` profile.
|
|
10
12
|
|
|
11
13
|
## Start
|
|
12
14
|
|
|
@@ -16,9 +18,12 @@ You need Docker.
|
|
|
16
18
|
docker compose up --build
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
- Site: http://localhost:
|
|
20
|
-
- Admin: http://localhost:
|
|
21
|
-
- Outgoing mail: http://localhost:
|
|
21
|
+
- Site: http://localhost:{{site_port}}
|
|
22
|
+
- Admin: http://localhost:{{site_port}}/admin/ (user `admin`, password `ADMIN_PASSWORD` in `.env`)
|
|
23
|
+
- Outgoing mail: http://localhost:{{mail_port}}
|
|
24
|
+
|
|
25
|
+
The ports are `SITE_PORT` and `MAIL_PORT` in `.env`. When you change
|
|
26
|
+
`SITE_PORT`, change `SITE_URL` and `BACKEND_URL` with it.
|
|
22
27
|
|
|
23
28
|
The first start migrates the database, creates the admin user and adds demo
|
|
24
29
|
content. Edit a page in the admin, publish, and reload the site.
|
|
@@ -51,6 +56,15 @@ site with drafts shown, for an hour.
|
|
|
51
56
|
When an editor saves, Django tells the frontend (`REVALIDATE_URL`), which
|
|
52
57
|
empties its page cache, so the change shows on the next visit.
|
|
53
58
|
|
|
59
|
+
## Analytics
|
|
60
|
+
|
|
61
|
+
When `COMPOSE_PROFILES` in `.env` has `analytics`, the `umami` service
|
|
62
|
+
counts page views without cookies, and the admin shows them under
|
|
63
|
+
**Analytics**. Umami's own interface is not served: only its tracker and the
|
|
64
|
+
page views pass through the site at `/_a/`. To turn it off, remove
|
|
65
|
+
`analytics` from `COMPOSE_PROFILES` and empty `UMAMI_URL`. See
|
|
66
|
+
[Page analytics](https://dwarsbit.github.io/django-stack/docs/analytics).
|
|
67
|
+
|
|
54
68
|
## Deploy
|
|
55
69
|
|
|
56
70
|
On a server with Docker and a domain pointing at it:
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
#
|
|
3
3
|
# COMPOSE_PROFILES in .env picks the services:
|
|
4
4
|
# dev,db `docker compose up` on a laptop: dev servers with hot reload,
|
|
5
|
-
# http://localhost:3000, Mailpit at http://localhost:8025
|
|
5
|
+
# http://localhost:3000, Mailpit at http://localhost:8025 (the
|
|
6
|
+
# *_PORT variables in .env change the ports).
|
|
6
7
|
# prod,db `docker compose up -d` on a server: production images behind
|
|
7
8
|
# Caddy on ports 80 and 443, with automatic HTTPS and daily backups.
|
|
8
9
|
# Leave out `db` to use an external Postgres through DATABASE_URL, and add
|
|
9
|
-
# `s3` in development for a local S3 service (see .env).
|
|
10
|
+
# `s3` in development for a local S3 service (see .env). `analytics` runs
|
|
11
|
+
# Umami for page analytics, `valkey` the Valkey service for CACHE_URL or
|
|
12
|
+
# TASKS_URL.
|
|
10
13
|
#
|
|
11
14
|
# The frontend only receives the variables it needs: never the full-access
|
|
12
15
|
# API key or the database password.
|
|
@@ -31,6 +34,9 @@ x-frontend-env: &frontend-env
|
|
|
31
34
|
PREVIEW_SECRET: ${PREVIEW_SECRET}
|
|
32
35
|
REVALIDATE_SECRET: ${REVALIDATE_SECRET}
|
|
33
36
|
MEDIA_ORIGINS: ${S3_PUBLIC_URL:-${S3_ENDPOINT_URL:-}}
|
|
37
|
+
# Page analytics: the tracker loads from /_a/ on the site when set.
|
|
38
|
+
UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
|
|
39
|
+
UMAMI_URL: ${UMAMI_URL:-}
|
|
34
40
|
|
|
35
41
|
x-backend-depends: &backend-depends
|
|
36
42
|
db:
|
|
@@ -38,9 +44,14 @@ x-backend-depends: &backend-depends
|
|
|
38
44
|
required: false
|
|
39
45
|
cache:
|
|
40
46
|
condition: service_healthy
|
|
47
|
+
required: false
|
|
41
48
|
s3:
|
|
42
49
|
condition: service_started
|
|
43
50
|
required: false
|
|
51
|
+
# The backend sets Umami up on start; it waits for it by itself.
|
|
52
|
+
umami:
|
|
53
|
+
condition: service_started
|
|
54
|
+
required: false
|
|
44
55
|
|
|
45
56
|
x-backend-health: &backend-health
|
|
46
57
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/readyz', timeout=4)"]
|
|
@@ -64,7 +75,7 @@ services:
|
|
|
64
75
|
- ./backend:/app
|
|
65
76
|
- media:/app/media
|
|
66
77
|
ports:
|
|
67
|
-
- "127.0.0.1
|
|
78
|
+
- "127.0.0.1:${BACKEND_PORT:-8000}:8000"
|
|
68
79
|
depends_on: *backend-depends
|
|
69
80
|
healthcheck: *backend-health
|
|
70
81
|
restart: unless-stopped
|
|
@@ -79,6 +90,9 @@ services:
|
|
|
79
90
|
volumes:
|
|
80
91
|
- ./backend:/app
|
|
81
92
|
- media:/app/media
|
|
93
|
+
# 0 with TASKS_URL=immediate://: tasks run in the request.
|
|
94
|
+
deploy:
|
|
95
|
+
replicas: ${TASK_WORKERS:-1}
|
|
82
96
|
depends_on:
|
|
83
97
|
backend:
|
|
84
98
|
condition: service_healthy
|
|
@@ -94,7 +108,7 @@ services:
|
|
|
94
108
|
- ./frontend:/app
|
|
95
109
|
- frontend_node_modules:/app/node_modules
|
|
96
110
|
ports:
|
|
97
|
-
- "127.0.0.1
|
|
111
|
+
- "127.0.0.1:${SITE_PORT:-3000}:3000"
|
|
98
112
|
depends_on:
|
|
99
113
|
backend:
|
|
100
114
|
condition: service_healthy
|
|
@@ -104,11 +118,12 @@ services:
|
|
|
104
118
|
profiles: [dev]
|
|
105
119
|
image: axllent/mailpit:v1.27
|
|
106
120
|
ports:
|
|
107
|
-
- "127.0.0.1
|
|
121
|
+
- "127.0.0.1:${MAIL_PORT:-8025}:8025"
|
|
108
122
|
restart: unless-stopped
|
|
109
123
|
|
|
110
124
|
# A local S3 service, so development runs the same storage code as
|
|
111
|
-
# production with S3. Files are public at http://localhost:8333/<bucket
|
|
125
|
+
# production with S3. Files are public at http://localhost:8333/<bucket>/
|
|
126
|
+
# (S3_PORT).
|
|
112
127
|
s3:
|
|
113
128
|
profiles: [s3]
|
|
114
129
|
image: chrislusf/seaweedfs:4.52
|
|
@@ -116,7 +131,7 @@ services:
|
|
|
116
131
|
volumes:
|
|
117
132
|
- s3data:/data
|
|
118
133
|
ports:
|
|
119
|
-
- "127.0.0.1
|
|
134
|
+
- "127.0.0.1:${S3_PORT:-8333}:8333"
|
|
120
135
|
restart: unless-stopped
|
|
121
136
|
|
|
122
137
|
# Production -------------------------------------------------------------
|
|
@@ -173,6 +188,7 @@ services:
|
|
|
173
188
|
healthcheck:
|
|
174
189
|
disable: true
|
|
175
190
|
deploy:
|
|
191
|
+
replicas: ${TASK_WORKERS:-1}
|
|
176
192
|
resources:
|
|
177
193
|
limits:
|
|
178
194
|
memory: 512M
|
|
@@ -232,6 +248,36 @@ services:
|
|
|
232
248
|
|
|
233
249
|
# Shared -----------------------------------------------------------------
|
|
234
250
|
|
|
251
|
+
# Page analytics (the analytics profile). Umami's interface and API stay on
|
|
252
|
+
# the internal network: Caddy, and the frontend's dev server, pass on only
|
|
253
|
+
# the tracker and the page views at /_a/, and the admin reads the numbers
|
|
254
|
+
# with UMAMI_ADMIN_PASSWORD. Its tables are in the umami schema of the
|
|
255
|
+
# database.
|
|
256
|
+
umami:
|
|
257
|
+
profiles: [analytics]
|
|
258
|
+
image: umamisoftware/umami:3.4
|
|
259
|
+
<<: *hardened
|
|
260
|
+
environment:
|
|
261
|
+
DATABASE_URL: ${UMAMI_DATABASE_URL:-}
|
|
262
|
+
APP_SECRET: ${UMAMI_APP_SECRET:-}
|
|
263
|
+
DISABLE_TELEMETRY: "1"
|
|
264
|
+
DISABLE_UPDATES: "1"
|
|
265
|
+
depends_on:
|
|
266
|
+
db:
|
|
267
|
+
condition: service_healthy
|
|
268
|
+
required: false
|
|
269
|
+
healthcheck:
|
|
270
|
+
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/heartbeat').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
|
271
|
+
interval: 5s
|
|
272
|
+
timeout: 5s
|
|
273
|
+
start_period: 60s
|
|
274
|
+
retries: 30
|
|
275
|
+
deploy:
|
|
276
|
+
resources:
|
|
277
|
+
limits:
|
|
278
|
+
memory: 512M
|
|
279
|
+
restart: unless-stopped
|
|
280
|
+
|
|
235
281
|
db:
|
|
236
282
|
profiles: [db]
|
|
237
283
|
image: postgres:17-alpine
|
|
@@ -254,9 +300,11 @@ services:
|
|
|
254
300
|
memory: 1G
|
|
255
301
|
restart: unless-stopped
|
|
256
302
|
|
|
257
|
-
#
|
|
258
|
-
# evicted when memory runs out, so queued
|
|
303
|
+
# Valkey (the valkey profile), for CACHE_URL or TASKS_URL redis://cache.
|
|
304
|
+
# Only keys with an expiry are evicted when memory runs out, so queued
|
|
305
|
+
# tasks are never dropped.
|
|
259
306
|
cache:
|
|
307
|
+
profiles: [valkey]
|
|
260
308
|
image: valkey/valkey:8-alpine
|
|
261
309
|
user: valkey
|
|
262
310
|
command: ["valkey-server", "--save", "", "--appendonly", "no", "--maxmemory", "200mb", "--maxmemory-policy", "volatile-lru"]
|
|
@@ -8,7 +8,8 @@ PROJECT_NAME={{project_name}}
|
|
|
8
8
|
# dev,db development on this machine
|
|
9
9
|
# prod,db production on a server
|
|
10
10
|
# Leave out `db` when DATABASE_URL points to an external Postgres; add `s3`
|
|
11
|
-
# in development for the local S3 service.
|
|
11
|
+
# in development for the local S3 service. `analytics` runs Umami, `valkey`
|
|
12
|
+
# the Valkey service.
|
|
12
13
|
COMPOSE_PROFILES=dev,db
|
|
13
14
|
|
|
14
15
|
# The public URL of the site. In development the frontend's dev server also
|
|
@@ -17,6 +18,15 @@ COMPOSE_PROFILES=dev,db
|
|
|
17
18
|
SITE_URL=http://localhost:3000
|
|
18
19
|
BACKEND_URL=http://localhost:3000
|
|
19
20
|
|
|
21
|
+
# The ports of the development services on this machine: the site with the
|
|
22
|
+
# admin, the backend, Mailpit and the local S3 service. `init` and
|
|
23
|
+
# `dstack dev` move to free ones when these are in use, and change the
|
|
24
|
+
# URLs above with SITE_PORT.
|
|
25
|
+
SITE_PORT=3000
|
|
26
|
+
BACKEND_PORT=8000
|
|
27
|
+
MAIL_PORT=8025
|
|
28
|
+
S3_PORT=8333
|
|
29
|
+
|
|
20
30
|
# Behind the proxy of a platform such as Coolify or Dokploy, which gets the
|
|
21
31
|
# certificate, Caddy serves plain HTTP on port 80 to it instead:
|
|
22
32
|
# CADDY_ADDRESS=:80 TRUSTED_PROXIES=private_ranges NUM_PROXIES=2
|
|
@@ -33,9 +43,18 @@ POSTGRES_USER=app
|
|
|
33
43
|
POSTGRES_PASSWORD={{secret:postgres}}
|
|
34
44
|
DATABASE_URL=postgres://app:{{secret:postgres}}@db:5432/app
|
|
35
45
|
|
|
36
|
-
# Cache
|
|
37
|
-
#
|
|
38
|
-
|
|
46
|
+
# Cache and task queue: db:// keeps them in tables in the database, so
|
|
47
|
+
# nothing else runs. For the Valkey service add `valkey` to COMPOSE_PROFILES
|
|
48
|
+
# and use redis://cache:6379/0 (the cache) or redis://cache:6379/1 (the
|
|
49
|
+
# tasks). CACHE_URL=locmem:// is a cache per process. TASKS_URL=immediate://
|
|
50
|
+
# runs tasks in the request, with TASK_WORKERS=0 to stop the worker; there
|
|
51
|
+
# are no daily backups then.
|
|
52
|
+
CACHE_URL=db://
|
|
53
|
+
TASKS_URL=db://
|
|
54
|
+
TASK_WORKERS=1
|
|
55
|
+
|
|
56
|
+
# Mail. In production, EMAIL_URL is your SMTP server, such as
|
|
57
|
+
# smtp+tls://user:password@smtp.example.com:587.
|
|
39
58
|
EMAIL_URL=smtp://mailpit:1025
|
|
40
59
|
DEFAULT_FROM_EMAIL={{project_name}} <noreply@localhost>
|
|
41
60
|
|
|
@@ -71,6 +90,20 @@ S3_ACCESS_KEY_ID=
|
|
|
71
90
|
S3_SECRET_ACCESS_KEY=
|
|
72
91
|
S3_PUBLIC_URL=
|
|
73
92
|
|
|
93
|
+
# Page analytics: Umami, in the `analytics` profile. It runs on the internal
|
|
94
|
+
# network only: its tracker and the page views pass through /_a/ on the
|
|
95
|
+
# site, and the admin shows the numbers under Analytics. Umami keeps its
|
|
96
|
+
# tables in the `umami` schema of the database (UMAMI_DATABASE_URL is
|
|
97
|
+
# DATABASE_URL with ?schema=umami), so backups include them. The backend
|
|
98
|
+
# replaces Umami's default admin password with UMAMI_ADMIN_PASSWORD and
|
|
99
|
+
# creates the website UMAMI_WEBSITE_ID on start. To turn analytics off,
|
|
100
|
+
# remove `analytics` from COMPOSE_PROFILES and empty UMAMI_URL.
|
|
101
|
+
UMAMI_URL=
|
|
102
|
+
UMAMI_WEBSITE_ID=
|
|
103
|
+
UMAMI_ADMIN_PASSWORD=
|
|
104
|
+
UMAMI_APP_SECRET=
|
|
105
|
+
UMAMI_DATABASE_URL=
|
|
106
|
+
|
|
74
107
|
# Backups (prod profile): a daily pg_dump at BACKUP_TIME (UTC) and, when
|
|
75
108
|
# uploads are on disk (no S3_BUCKET) and BACKUP_MEDIA=1, an archive of the
|
|
76
109
|
# media volume; the newest BACKUP_KEEP of each kept in the backups volume.
|
|
@@ -4,6 +4,8 @@ import { defineConfig } from "astro/config";
|
|
|
4
4
|
|
|
5
5
|
// The Django backend: the frontend container reaches it on the Compose network.
|
|
6
6
|
const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
|
|
7
|
+
// Umami, for page analytics: only its tracker and page views, under /_a/.
|
|
8
|
+
const umami = process.env.UMAMI_URL || "http://umami:3000";
|
|
7
9
|
|
|
8
10
|
export default defineConfig({
|
|
9
11
|
site: process.env.SITE_URL ?? "http://localhost:3000",
|
|
@@ -17,6 +19,8 @@ export default defineConfig({
|
|
|
17
19
|
security: {
|
|
18
20
|
csp: {
|
|
19
21
|
directives: ["default-src 'self'", "base-uri 'self'", "form-action 'self'", "object-src 'none'"],
|
|
22
|
+
// Scripts from this site, such as the analytics tracker at /_a/script.js.
|
|
23
|
+
scriptDirective: { resources: ["'self'"] },
|
|
20
24
|
// Inline style attributes, such as the focal point of an image.
|
|
21
25
|
styleDirective: { resources: ["'self'", { resource: "'unsafe-inline'", kind: "attribute" }] },
|
|
22
26
|
},
|
|
@@ -27,12 +31,15 @@ export default defineConfig({
|
|
|
27
31
|
allowedHosts: ["frontend"],
|
|
28
32
|
// In development, serve Django's paths from the same origin as the site,
|
|
29
33
|
// like the reverse proxy does in production.
|
|
30
|
-
proxy:
|
|
31
|
-
|
|
32
|
-
path
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
proxy: {
|
|
35
|
+
...Object.fromEntries(
|
|
36
|
+
["/admin", "/api", "/media", "/static"].map((path) => [
|
|
37
|
+
path,
|
|
38
|
+
{ target: backend, changeOrigin: false },
|
|
39
|
+
]),
|
|
40
|
+
),
|
|
41
|
+
"^/_a/(script\\.js|api/send)$": { target: umami, rewrite: (path) => path.slice("/_a".length) },
|
|
42
|
+
},
|
|
36
43
|
},
|
|
37
44
|
},
|
|
38
45
|
});
|
|
@@ -20,6 +20,8 @@ const [settings, mainMenu, footerMenu] = await Promise.all([
|
|
|
20
20
|
]);
|
|
21
21
|
const preview = Astro.cookies.has(PREVIEW_COOKIE);
|
|
22
22
|
const path = Astro.url.pathname;
|
|
23
|
+
// Page analytics: Umami's tracker, served from this site at /_a/.
|
|
24
|
+
const analytics = process.env.UMAMI_WEBSITE_ID;
|
|
23
25
|
---
|
|
24
26
|
|
|
25
27
|
<!doctype html>
|
|
@@ -30,6 +32,7 @@ const path = Astro.url.pathname;
|
|
|
30
32
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
31
33
|
<link rel="alternate" type="application/rss+xml" title={settings?.site_name ?? "RSS"} href="/rss.xml" />
|
|
32
34
|
<Seo page={page} settings={settings} type={type} />
|
|
35
|
+
{analytics && <script is:inline defer src="/_a/script.js" data-website-id={analytics} />}
|
|
33
36
|
</head>
|
|
34
37
|
<body>
|
|
35
38
|
{
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@django-headless/client": "^1.1.1",
|
|
16
|
-
"@django-stack/next": "^0.
|
|
17
|
-
"@django-stack/react": "^0.
|
|
18
|
-
"@django-stack/utils": "^0.
|
|
16
|
+
"@django-stack/next": "^0.7.0",
|
|
17
|
+
"@django-stack/react": "^0.7.0",
|
|
18
|
+
"@django-stack/utils": "^0.7.0",
|
|
19
19
|
"next": "^16.4.4",
|
|
20
20
|
"react": "^19.3.1",
|
|
21
21
|
"react-dom": "^19.3.1",
|
|
@@ -2,6 +2,7 @@ import "./globals.css";
|
|
|
2
2
|
import { Image, RichText } from "@django-stack/react";
|
|
3
3
|
import { linkHref } from "@django-stack/utils";
|
|
4
4
|
import type { Metadata } from "next";
|
|
5
|
+
import Script from "next/script";
|
|
5
6
|
import { NavLink } from "@/components/NavLink";
|
|
6
7
|
import { getContent, previewToken, SITE_URL } from "@/lib/api";
|
|
7
8
|
|
|
@@ -64,6 +65,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|
|
64
65
|
</nav>
|
|
65
66
|
</div>
|
|
66
67
|
</footer>
|
|
68
|
+
{/* Page analytics: Umami's tracker, served from this site at /_a/. */}
|
|
69
|
+
{process.env.UMAMI_WEBSITE_ID ? (
|
|
70
|
+
<Script
|
|
71
|
+
src="/_a/script.js"
|
|
72
|
+
data-website-id={process.env.UMAMI_WEBSITE_ID}
|
|
73
|
+
strategy="afterInteractive"
|
|
74
|
+
/>
|
|
75
|
+
) : null}
|
|
67
76
|
</body>
|
|
68
77
|
</html>
|
|
69
78
|
);
|
|
@@ -7,6 +7,9 @@ const dev = process.env.NODE_ENV === "development";
|
|
|
7
7
|
// development, this proxy does, so the site and the admin share an origin.
|
|
8
8
|
const BACKEND_PATHS = /^\/(admin|api|media|static)(\/|$)/;
|
|
9
9
|
const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
|
|
10
|
+
// Umami's tracker and page views, for page analytics; Caddy passes them on in production.
|
|
11
|
+
const TRACKER_PATHS = /^\/_a\/(script\.js|api\/send)$/;
|
|
12
|
+
const umami = process.env.UMAMI_URL || "http://umami:3000";
|
|
10
13
|
const media = mediaOrigins();
|
|
11
14
|
|
|
12
15
|
export function proxy(request: NextRequest) {
|
|
@@ -14,6 +17,9 @@ export function proxy(request: NextRequest) {
|
|
|
14
17
|
if (dev && BACKEND_PATHS.test(pathname)) {
|
|
15
18
|
return NextResponse.rewrite(new URL(pathname + search, backend));
|
|
16
19
|
}
|
|
20
|
+
if (dev && TRACKER_PATHS.test(pathname)) {
|
|
21
|
+
return NextResponse.rewrite(new URL(pathname.slice("/_a".length) + search, umami));
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
// A fresh nonce for every page: Next adds it to the scripts it renders.
|
|
19
25
|
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// The Django backend: the frontend container reaches it on the Compose network.
|
|
2
2
|
const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
|
|
3
|
+
// Umami, for page analytics: only its tracker and page views, under /_a/.
|
|
4
|
+
const umami = process.env.UMAMI_URL || "http://umami:3000";
|
|
3
5
|
|
|
4
6
|
export default defineNuxtConfig({
|
|
5
7
|
compatibilityDate: "2026-11-01",
|
|
@@ -26,12 +28,16 @@ export default defineNuxtConfig({
|
|
|
26
28
|
// In development, serve Django's paths from the same origin as the site,
|
|
27
29
|
// like the reverse proxy does in production.
|
|
28
30
|
$development: {
|
|
29
|
-
routeRules:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
routeRules: {
|
|
32
|
+
...Object.fromEntries(
|
|
33
|
+
["/admin", "/api", "/media", "/static"].map((path) => [
|
|
34
|
+
`${path}/**`,
|
|
35
|
+
{ proxy: `${backend}${path}/**` },
|
|
36
|
+
]),
|
|
37
|
+
),
|
|
38
|
+
"/_a/script.js": { proxy: `${umami}/script.js` },
|
|
39
|
+
"/_a/api/send": { proxy: `${umami}/api/send` },
|
|
40
|
+
},
|
|
35
41
|
},
|
|
36
42
|
// Security headers, with a Content-Security-Policy on every page in
|
|
37
43
|
// production: scripts and styles only from this site, with a nonce for
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@django-headless/client": "^1.1.1",
|
|
18
|
-
"@django-stack/utils": "^0.
|
|
19
|
-
"@django-stack/vue": "^0.
|
|
18
|
+
"@django-stack/utils": "^0.7.0",
|
|
19
|
+
"@django-stack/vue": "^0.7.0",
|
|
20
20
|
"nuxt": "^4.6.3",
|
|
21
21
|
"nuxt-security": "^2.7.0",
|
|
22
22
|
"vue": "^3.5.44",
|
|
@@ -9,5 +9,10 @@ export default defineEventHandler(async (event) => {
|
|
|
9
9
|
]);
|
|
10
10
|
return { settings, mainMenu, footerMenu, siteUrl: SITE_URL };
|
|
11
11
|
});
|
|
12
|
-
return {
|
|
12
|
+
return {
|
|
13
|
+
...data,
|
|
14
|
+
preview: previewToken(event) !== null,
|
|
15
|
+
// Page analytics: Umami's tracker, served from this site at /_a/.
|
|
16
|
+
analytics: process.env.UMAMI_WEBSITE_ID || null,
|
|
17
|
+
};
|
|
13
18
|
});
|
|
@@ -10,6 +10,9 @@ useHead(() => ({
|
|
|
10
10
|
link: [
|
|
11
11
|
{ rel: "alternate", type: "application/rss+xml", title: data.value?.settings?.site_name ?? "RSS", href: "/rss.xml" },
|
|
12
12
|
],
|
|
13
|
+
script: data.value?.analytics
|
|
14
|
+
? [{ src: "/_a/script.js", defer: true, "data-website-id": data.value.analytics }]
|
|
15
|
+
: [],
|
|
13
16
|
}));
|
|
14
17
|
</script>
|
|
15
18
|
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@django-headless/client": "^1.1.1",
|
|
22
|
-
"@django-stack/svelte": "^0.
|
|
23
|
-
"@django-stack/utils": "^0.
|
|
22
|
+
"@django-stack/svelte": "^0.7.0",
|
|
23
|
+
"@django-stack/utils": "^0.7.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@sveltejs/adapter-node": "^6.0.1",
|
|
@@ -14,5 +14,7 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
|
|
|
14
14
|
footerMenu,
|
|
15
15
|
preview: cookies.get(PREVIEW_COOKIE) !== undefined,
|
|
16
16
|
siteUrl: SITE_URL,
|
|
17
|
+
// Page analytics: Umami's tracker, served from this site at /_a/.
|
|
18
|
+
analytics: process.env.UMAMI_WEBSITE_ID || null,
|
|
17
19
|
};
|
|
18
20
|
};
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
<svelte:head>
|
|
15
15
|
<link rel="alternate" type="application/rss+xml" title={data.settings?.site_name ?? "RSS"} href="/rss.xml" />
|
|
16
|
+
{#if data.analytics}
|
|
17
|
+
<script defer src="/_a/script.js" data-website-id={data.analytics}></script>
|
|
18
|
+
{/if}
|
|
16
19
|
</svelte:head>
|
|
17
20
|
|
|
18
21
|
<Seo
|