@django-stack/cli 0.5.0 → 0.6.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 +1799 -559
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -1
- package/dist/{scaffold-DbgGYEsc.js → production-DIj9yRcg.js} +93 -66
- 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 +9 -0
- package/templates/base/compose.yaml +39 -1
- package/templates/base/env.example +15 -1
- 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/dist/index.d.ts
CHANGED
|
@@ -93,7 +93,7 @@ export declare const STACK_OPTIONS: readonly [{
|
|
|
93
93
|
readonly value: "umami";
|
|
94
94
|
readonly label: "Umami";
|
|
95
95
|
readonly hint: "built in, private, no cookies";
|
|
96
|
-
readonly generated:
|
|
96
|
+
readonly generated: true;
|
|
97
97
|
}, {
|
|
98
98
|
readonly value: "off";
|
|
99
99
|
readonly label: "Off";
|
|
@@ -235,6 +235,8 @@ interface ScaffoldOptions {
|
|
|
235
235
|
databaseUrl?: string;
|
|
236
236
|
/** Store uploads in the local S3 service in development, like S3 in production. */
|
|
237
237
|
localS3?: boolean;
|
|
238
|
+
/** Page analytics with the bundled Umami (the `analytics` profile). */
|
|
239
|
+
analytics?: boolean;
|
|
238
240
|
/** A DjangoStack monorepo to take the packages from instead of the registries (for development). */
|
|
239
241
|
link?: string;
|
|
240
242
|
/** The time zone of the backend, such as Europe/Amsterdam. UTC by default. */
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { h as DEFAULT_STACK, r as FRONTENDS, g as NAMED_PRESETS, _ as STACK_OPTIONS, m as VERSION, y as decodePreset, b as encodePreset, w as generateProjectSecrets, T as generateSecret, o as normalizeDatabaseUrl, O as parseEnv, x as pendingChoices, t as productionEnv, S as resolvePreset, E as runDoctor, c as scaffold, k as setEnvValues, u as toProjectName, n as validateDomain, f as validateProjectName } from "./production-DIj9yRcg.js";
|
|
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, relative } from "node:path";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
-
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
// src/env.ts
|
|
8
8
|
/** Reads a .env file: KEY=value lines, comments and blank lines ignored. */
|
|
@@ -283,69 +283,9 @@ function generateProjectSecrets() {
|
|
|
283
283
|
api_read: generateSecret(32),
|
|
284
284
|
preview: generateSecret(32),
|
|
285
285
|
revalidate: generateSecret(32),
|
|
286
|
-
admin: generateSecret(12)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// src/production.ts
|
|
290
|
-
function validateDomain(domain) {
|
|
291
|
-
if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain)) return "Enter a domain name such as example.com, without https://.";
|
|
292
|
-
}
|
|
293
|
-
/**
|
|
294
|
-
* The .env for a server, from a project's development .env: the prod
|
|
295
|
-
* profile, HTTPS on the domain, and fresh secrets, so nothing from the
|
|
296
|
-
* laptop works on the server.
|
|
297
|
-
*/
|
|
298
|
-
function productionEnv(devEnv, options) {
|
|
299
|
-
const domainError = validateDomain(options.domain);
|
|
300
|
-
if (domainError) throw new Error(domainError);
|
|
301
|
-
const current = parseEnv(devEnv);
|
|
302
|
-
const secrets = generateProjectSecrets();
|
|
303
|
-
const url = `https://${options.domain.toLowerCase()}`;
|
|
304
|
-
const todo = [];
|
|
305
|
-
const profiles = (current.COMPOSE_PROFILES ?? "dev,db").split(",").map((profile) => profile.trim()).filter((profile) => profile && profile !== "dev" && profile !== "s3");
|
|
306
|
-
profiles.unshift("prod");
|
|
307
|
-
const values = {
|
|
308
|
-
COMPOSE_PROFILES: [...new Set(profiles)].join(","),
|
|
309
|
-
SITE_URL: url,
|
|
310
|
-
BACKEND_URL: url,
|
|
311
|
-
DJANGO_DEBUG: "0",
|
|
312
|
-
DJANGO_SECRET_KEY: secrets.django ?? "",
|
|
313
|
-
API_SECRET_KEY: secrets.api ?? "",
|
|
314
|
-
API_READ_KEY: secrets.api_read ?? "",
|
|
315
|
-
PREVIEW_SECRET: secrets.preview ?? "",
|
|
316
|
-
REVALIDATE_SECRET: secrets.revalidate ?? "",
|
|
317
|
-
ADMIN_PASSWORD: secrets.admin ?? "",
|
|
318
|
-
DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
|
|
319
|
-
};
|
|
320
|
-
if (profiles.includes("db")) {
|
|
321
|
-
const user = current.POSTGRES_USER || "app";
|
|
322
|
-
const name = current.POSTGRES_DB || "app";
|
|
323
|
-
values.POSTGRES_PASSWORD = secrets.postgres ?? "";
|
|
324
|
-
values.DATABASE_URL = `postgres://${user}:${secrets.postgres}@db:5432/${name}`;
|
|
325
|
-
}
|
|
326
|
-
if (options.emailUrl) values.EMAIL_URL = options.emailUrl;
|
|
327
|
-
else if (!current.EMAIL_URL || current.EMAIL_URL.includes("mailpit")) {
|
|
328
|
-
values.EMAIL_URL = "smtp+tls://user:password@smtp.example.com:587";
|
|
329
|
-
todo.push("Set EMAIL_URL to your SMTP server, so password resets and notifications are sent.");
|
|
330
|
-
}
|
|
331
|
-
if ((current.S3_ENDPOINT_URL ?? "").includes("s3:8333")) {
|
|
332
|
-
Object.assign(values, {
|
|
333
|
-
S3_BUCKET: "",
|
|
334
|
-
S3_ENDPOINT_URL: "",
|
|
335
|
-
S3_REGION: "",
|
|
336
|
-
S3_ACCESS_KEY_ID: "",
|
|
337
|
-
S3_SECRET_ACCESS_KEY: "",
|
|
338
|
-
S3_PUBLIC_URL: "",
|
|
339
|
-
S3_CREATE_BUCKET: "0"
|
|
340
|
-
});
|
|
341
|
-
todo.push("Set the S3_* variables to your S3 service, or leave them empty to keep uploads on the server.");
|
|
342
|
-
}
|
|
343
|
-
if (!current.ADMIN_EMAIL || current.ADMIN_EMAIL.endsWith("@example.com")) todo.push("Set ADMIN_EMAIL to your address.");
|
|
344
|
-
if (current.SEED_DEMO_CONTENT === "1") todo.push("Set SEED_DEMO_CONTENT=0 unless the site should start with the demo pages and posts.");
|
|
345
|
-
return {
|
|
346
|
-
text: setEnvValues(devEnv, values),
|
|
347
|
-
adminPassword: values.ADMIN_PASSWORD ?? "",
|
|
348
|
-
todo
|
|
286
|
+
admin: generateSecret(12),
|
|
287
|
+
umami_app: generateSecret(32),
|
|
288
|
+
umami_admin: generateSecret(24)
|
|
349
289
|
};
|
|
350
290
|
}
|
|
351
291
|
// src/stack.ts
|
|
@@ -420,7 +360,7 @@ const STACK_OPTIONS = [
|
|
|
420
360
|
value: "umami",
|
|
421
361
|
label: "Umami",
|
|
422
362
|
hint: "built in, private, no cookies",
|
|
423
|
-
generated:
|
|
363
|
+
generated: true
|
|
424
364
|
}, {
|
|
425
365
|
value: "off",
|
|
426
366
|
label: "Off",
|
|
@@ -642,6 +582,22 @@ const CLI_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
|
642
582
|
// src/scaffold.ts
|
|
643
583
|
/** The frontends this version of the CLI generates. */
|
|
644
584
|
const FRONTENDS = stackOption("frontend").values.filter((value) => value.generated).map((value) => value.value);
|
|
585
|
+
/** Umami's database URL: the project's database, with Umami's tables in the `umami` schema. */
|
|
586
|
+
function umamiDatabaseUrl(databaseUrl) {
|
|
587
|
+
const url = new URL(databaseUrl);
|
|
588
|
+
url.searchParams.set("schema", "umami");
|
|
589
|
+
return url.toString();
|
|
590
|
+
}
|
|
591
|
+
/** The `.env` values of the bundled Umami, from the project's database URL and secrets. */
|
|
592
|
+
function umamiEnv(databaseUrl, secrets) {
|
|
593
|
+
return {
|
|
594
|
+
UMAMI_URL: "http://umami:3000",
|
|
595
|
+
UMAMI_WEBSITE_ID: randomUUID(),
|
|
596
|
+
UMAMI_ADMIN_PASSWORD: secrets.umami_admin ?? "",
|
|
597
|
+
UMAMI_APP_SECRET: secrets.umami_app ?? "",
|
|
598
|
+
UMAMI_DATABASE_URL: umamiDatabaseUrl(databaseUrl)
|
|
599
|
+
};
|
|
600
|
+
}
|
|
645
601
|
/** The local S3 service of the `s3` profile. It accepts any credentials. */
|
|
646
602
|
const LOCAL_S3 = {
|
|
647
603
|
S3_BUCKET: "media",
|
|
@@ -749,6 +705,10 @@ function scaffold(options) {
|
|
|
749
705
|
profiles.push("s3");
|
|
750
706
|
env = setEnvValues(env, LOCAL_S3);
|
|
751
707
|
}
|
|
708
|
+
if (options.analytics) {
|
|
709
|
+
profiles.push("analytics");
|
|
710
|
+
env = setEnvValues(env, umamiEnv(parseEnv(env).DATABASE_URL ?? "", secrets));
|
|
711
|
+
}
|
|
752
712
|
env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
|
|
753
713
|
if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
|
|
754
714
|
writeFileSync(join(target, ".env"), env, { mode: 384 });
|
|
@@ -830,4 +790,71 @@ function relativeDirectory(directory) {
|
|
|
830
790
|
const path = relative(process.cwd(), directory) || ".";
|
|
831
791
|
return path.startsWith("..") ? directory : path;
|
|
832
792
|
}
|
|
833
|
-
|
|
793
|
+
// src/production.ts
|
|
794
|
+
function validateDomain(domain) {
|
|
795
|
+
if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain)) return "Enter a domain name such as example.com, without https://.";
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* The .env for a server, from a project's development .env: the prod
|
|
799
|
+
* profile, HTTPS on the domain, and fresh secrets, so nothing from the
|
|
800
|
+
* laptop works on the server.
|
|
801
|
+
*/
|
|
802
|
+
function productionEnv(devEnv, options) {
|
|
803
|
+
const domainError = validateDomain(options.domain);
|
|
804
|
+
if (domainError) throw new Error(domainError);
|
|
805
|
+
const current = parseEnv(devEnv);
|
|
806
|
+
const secrets = generateProjectSecrets();
|
|
807
|
+
const url = `https://${options.domain.toLowerCase()}`;
|
|
808
|
+
const todo = [];
|
|
809
|
+
const profiles = (current.COMPOSE_PROFILES ?? "dev,db").split(",").map((profile) => profile.trim()).filter((profile) => profile && profile !== "dev" && profile !== "s3");
|
|
810
|
+
profiles.unshift("prod");
|
|
811
|
+
const values = {
|
|
812
|
+
COMPOSE_PROFILES: [...new Set(profiles)].join(","),
|
|
813
|
+
SITE_URL: url,
|
|
814
|
+
BACKEND_URL: url,
|
|
815
|
+
DJANGO_DEBUG: "0",
|
|
816
|
+
DJANGO_SECRET_KEY: secrets.django ?? "",
|
|
817
|
+
API_SECRET_KEY: secrets.api ?? "",
|
|
818
|
+
API_READ_KEY: secrets.api_read ?? "",
|
|
819
|
+
PREVIEW_SECRET: secrets.preview ?? "",
|
|
820
|
+
REVALIDATE_SECRET: secrets.revalidate ?? "",
|
|
821
|
+
ADMIN_PASSWORD: secrets.admin ?? "",
|
|
822
|
+
DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
|
|
823
|
+
};
|
|
824
|
+
if (profiles.includes("db")) {
|
|
825
|
+
const user = current.POSTGRES_USER || "app";
|
|
826
|
+
const name = current.POSTGRES_DB || "app";
|
|
827
|
+
values.POSTGRES_PASSWORD = secrets.postgres ?? "";
|
|
828
|
+
values.DATABASE_URL = `postgres://${user}:${secrets.postgres}@db:5432/${name}`;
|
|
829
|
+
}
|
|
830
|
+
if (current.UMAMI_URL) {
|
|
831
|
+
values.UMAMI_ADMIN_PASSWORD = secrets.umami_admin ?? "";
|
|
832
|
+
values.UMAMI_APP_SECRET = secrets.umami_app ?? "";
|
|
833
|
+
values.UMAMI_DATABASE_URL = umamiDatabaseUrl(values.DATABASE_URL ?? current.DATABASE_URL ?? "");
|
|
834
|
+
}
|
|
835
|
+
if (options.emailUrl) values.EMAIL_URL = options.emailUrl;
|
|
836
|
+
else if (!current.EMAIL_URL || current.EMAIL_URL.includes("mailpit")) {
|
|
837
|
+
values.EMAIL_URL = "smtp+tls://user:password@smtp.example.com:587";
|
|
838
|
+
todo.push("Set EMAIL_URL to your SMTP server, so password resets and notifications are sent.");
|
|
839
|
+
}
|
|
840
|
+
if ((current.S3_ENDPOINT_URL ?? "").includes("s3:8333")) {
|
|
841
|
+
Object.assign(values, {
|
|
842
|
+
S3_BUCKET: "",
|
|
843
|
+
S3_ENDPOINT_URL: "",
|
|
844
|
+
S3_REGION: "",
|
|
845
|
+
S3_ACCESS_KEY_ID: "",
|
|
846
|
+
S3_SECRET_ACCESS_KEY: "",
|
|
847
|
+
S3_PUBLIC_URL: "",
|
|
848
|
+
S3_CREATE_BUCKET: "0"
|
|
849
|
+
});
|
|
850
|
+
todo.push("Set the S3_* variables to your S3 service, or leave them empty to keep uploads on the server.");
|
|
851
|
+
}
|
|
852
|
+
if (!current.ADMIN_EMAIL || current.ADMIN_EMAIL.endsWith("@example.com")) todo.push("Set ADMIN_EMAIL to your address.");
|
|
853
|
+
if (current.SEED_DEMO_CONTENT === "1") todo.push("Set SEED_DEMO_CONTENT=0 unless the site should start with the demo pages and posts.");
|
|
854
|
+
return {
|
|
855
|
+
text: setEnvValues(devEnv, values),
|
|
856
|
+
adminPassword: values.ADMIN_PASSWORD ?? "",
|
|
857
|
+
todo
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
export { stackOption as C, checkTools as D, runDoctor as E, parseEnv as O, resolvePreset as S, generateSecret as T, STACK_OPTIONS as _, lockBackend as a, encodePreset as b, scaffold as c, umamiEnv as d, validateProjectName as f, NAMED_PRESETS as g, DEFAULT_STACK as h, LOCAL_S3 as i, setEnvValues as k, timeZones as l, CLI_VERSION as m, validateDomain as n, normalizeDatabaseUrl as o, validateTimeZone as p, FRONTENDS as r, relativeDirectory as s, productionEnv as t, toProjectName as u, applies as v, generateProjectSecrets as w, pendingChoices as x, decodePreset as y };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@django-stack/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.6.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
|
@@ -51,6 +51,15 @@ site with drafts shown, for an hour.
|
|
|
51
51
|
When an editor saves, Django tells the frontend (`REVALIDATE_URL`), which
|
|
52
52
|
empties its page cache, so the change shows on the next visit.
|
|
53
53
|
|
|
54
|
+
## Analytics
|
|
55
|
+
|
|
56
|
+
When `COMPOSE_PROFILES` in `.env` has `analytics`, the `umami` service
|
|
57
|
+
counts page views without cookies, and the admin shows them under
|
|
58
|
+
**Analytics**. Umami's own interface is not served: only its tracker and the
|
|
59
|
+
page views pass through the site at `/_a/`. To turn it off, remove
|
|
60
|
+
`analytics` from `COMPOSE_PROFILES` and empty `UMAMI_URL`. See
|
|
61
|
+
[Page analytics](https://dwarsbit.github.io/django-stack/docs/analytics).
|
|
62
|
+
|
|
54
63
|
## Deploy
|
|
55
64
|
|
|
56
65
|
On a server with Docker and a domain pointing at it:
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
# prod,db `docker compose up -d` on a server: production images behind
|
|
7
7
|
# Caddy on ports 80 and 443, with automatic HTTPS and daily backups.
|
|
8
8
|
# Leave out `db` to use an external Postgres through DATABASE_URL, and add
|
|
9
|
-
# `s3` in development for a local S3 service (see .env).
|
|
9
|
+
# `s3` in development for a local S3 service (see .env). `analytics` runs
|
|
10
|
+
# Umami for page analytics.
|
|
10
11
|
#
|
|
11
12
|
# The frontend only receives the variables it needs: never the full-access
|
|
12
13
|
# API key or the database password.
|
|
@@ -31,6 +32,9 @@ x-frontend-env: &frontend-env
|
|
|
31
32
|
PREVIEW_SECRET: ${PREVIEW_SECRET}
|
|
32
33
|
REVALIDATE_SECRET: ${REVALIDATE_SECRET}
|
|
33
34
|
MEDIA_ORIGINS: ${S3_PUBLIC_URL:-${S3_ENDPOINT_URL:-}}
|
|
35
|
+
# Page analytics: the tracker loads from /_a/ on the site when set.
|
|
36
|
+
UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
|
|
37
|
+
UMAMI_URL: ${UMAMI_URL:-}
|
|
34
38
|
|
|
35
39
|
x-backend-depends: &backend-depends
|
|
36
40
|
db:
|
|
@@ -41,6 +45,10 @@ x-backend-depends: &backend-depends
|
|
|
41
45
|
s3:
|
|
42
46
|
condition: service_started
|
|
43
47
|
required: false
|
|
48
|
+
# The backend sets Umami up on start; it waits for it by itself.
|
|
49
|
+
umami:
|
|
50
|
+
condition: service_started
|
|
51
|
+
required: false
|
|
44
52
|
|
|
45
53
|
x-backend-health: &backend-health
|
|
46
54
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/readyz', timeout=4)"]
|
|
@@ -232,6 +240,36 @@ services:
|
|
|
232
240
|
|
|
233
241
|
# Shared -----------------------------------------------------------------
|
|
234
242
|
|
|
243
|
+
# Page analytics (the analytics profile). Umami's interface and API stay on
|
|
244
|
+
# the internal network: Caddy, and the frontend's dev server, pass on only
|
|
245
|
+
# the tracker and the page views at /_a/, and the admin reads the numbers
|
|
246
|
+
# with UMAMI_ADMIN_PASSWORD. Its tables are in the umami schema of the
|
|
247
|
+
# database.
|
|
248
|
+
umami:
|
|
249
|
+
profiles: [analytics]
|
|
250
|
+
image: umamisoftware/umami:3.4
|
|
251
|
+
<<: *hardened
|
|
252
|
+
environment:
|
|
253
|
+
DATABASE_URL: ${UMAMI_DATABASE_URL:-}
|
|
254
|
+
APP_SECRET: ${UMAMI_APP_SECRET:-}
|
|
255
|
+
DISABLE_TELEMETRY: "1"
|
|
256
|
+
DISABLE_UPDATES: "1"
|
|
257
|
+
depends_on:
|
|
258
|
+
db:
|
|
259
|
+
condition: service_healthy
|
|
260
|
+
required: false
|
|
261
|
+
healthcheck:
|
|
262
|
+
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/heartbeat').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
|
263
|
+
interval: 5s
|
|
264
|
+
timeout: 5s
|
|
265
|
+
start_period: 60s
|
|
266
|
+
retries: 30
|
|
267
|
+
deploy:
|
|
268
|
+
resources:
|
|
269
|
+
limits:
|
|
270
|
+
memory: 512M
|
|
271
|
+
restart: unless-stopped
|
|
272
|
+
|
|
235
273
|
db:
|
|
236
274
|
profiles: [db]
|
|
237
275
|
image: postgres:17-alpine
|
|
@@ -8,7 +8,7 @@ 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.
|
|
12
12
|
COMPOSE_PROFILES=dev,db
|
|
13
13
|
|
|
14
14
|
# The public URL of the site. In development the frontend's dev server also
|
|
@@ -71,6 +71,20 @@ S3_ACCESS_KEY_ID=
|
|
|
71
71
|
S3_SECRET_ACCESS_KEY=
|
|
72
72
|
S3_PUBLIC_URL=
|
|
73
73
|
|
|
74
|
+
# Page analytics: Umami, in the `analytics` profile. It runs on the internal
|
|
75
|
+
# network only: its tracker and the page views pass through /_a/ on the
|
|
76
|
+
# site, and the admin shows the numbers under Analytics. Umami keeps its
|
|
77
|
+
# tables in the `umami` schema of the database (UMAMI_DATABASE_URL is
|
|
78
|
+
# DATABASE_URL with ?schema=umami), so backups include them. The backend
|
|
79
|
+
# replaces Umami's default admin password with UMAMI_ADMIN_PASSWORD and
|
|
80
|
+
# creates the website UMAMI_WEBSITE_ID on start. To turn analytics off,
|
|
81
|
+
# remove `analytics` from COMPOSE_PROFILES and empty UMAMI_URL.
|
|
82
|
+
UMAMI_URL=
|
|
83
|
+
UMAMI_WEBSITE_ID=
|
|
84
|
+
UMAMI_ADMIN_PASSWORD=
|
|
85
|
+
UMAMI_APP_SECRET=
|
|
86
|
+
UMAMI_DATABASE_URL=
|
|
87
|
+
|
|
74
88
|
# Backups (prod profile): a daily pg_dump at BACKUP_TIME (UTC) and, when
|
|
75
89
|
# uploads are on disk (no S3_BUCKET) and BACKUP_MEDIA=1, an archive of the
|
|
76
90
|
# 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.6.0",
|
|
17
|
+
"@django-stack/react": "^0.6.0",
|
|
18
|
+
"@django-stack/utils": "^0.6.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.6.0",
|
|
19
|
+
"@django-stack/vue": "^0.6.0",
|
|
20
20
|
"nuxt": "^4.6.3",
|
|
21
21
|
"nuxt-security": "^2.7.0",
|
|
22
22
|
"vue": "^3.5.44",
|