@django-stack/cli 0.10.0 → 0.12.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/README.md +2 -2
- package/dist/bin.js +122 -52
- package/dist/index.d.ts +13 -4
- package/dist/index.js +1 -1
- package/dist/{production-Ct_qoeXk.js → production-1O8-1QUl.js} +126 -24
- package/package.json +3 -3
- package/templates/backend/Dockerfile +7 -2
- package/templates/backend/config/settings.py +12 -0
- package/templates/backend/config/studio.py +35 -3
- package/templates/backend/config/urls.py +11 -0
- package/templates/backend/content/admin.py +22 -5
- package/templates/backend/content/dashboard.py +112 -0
- package/templates/backend/content/seed.py +8 -1
- package/templates/backend/content/tests/test_api.py +15 -0
- package/templates/backend/content/tests/test_dashboard.py +77 -0
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/.github/workflows/deploy.yml +10 -1
- package/templates/base/AGENTS.md +78 -0
- package/templates/base/Caddyfile +24 -0
- package/templates/base/README.md +74 -6
- package/templates/base/SECURITY.md +2 -0
- package/templates/base/compose.yaml +21 -2
- package/templates/base/env.example +25 -0
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-sveltekit/package.json +2 -2
- package/templates/frontend-templates/template.json +5 -0
- package/templates/frontend-templates/website/__init__.py +0 -0
- package/templates/frontend-templates/website/apps.py +6 -0
- package/templates/frontend-templates/website/context_processors.py +16 -0
- package/templates/frontend-templates/website/forms.py +20 -0
- package/templates/frontend-templates/website/static/website/favicon.svg +1 -0
- package/templates/frontend-templates/website/static/website/global.css +232 -0
- package/templates/frontend-templates/website/templates/website/404.html +10 -0
- package/templates/frontend-templates/website/templates/website/base.html +44 -0
- package/templates/frontend-templates/website/templates/website/blocks/call_to_action.html +5 -0
- package/templates/frontend-templates/website/templates/website/blocks/image.html +4 -0
- package/templates/frontend-templates/website/templates/website/blocks/quote.html +4 -0
- package/templates/frontend-templates/website/templates/website/blocks/text.html +2 -0
- package/templates/frontend-templates/website/templates/website/home.html +8 -0
- package/templates/frontend-templates/website/templates/website/includes/contact_form.html +21 -0
- package/templates/frontend-templates/website/templates/website/includes/post_card.html +9 -0
- package/templates/frontend-templates/website/templates/website/includes/post_list.html +11 -0
- package/templates/frontend-templates/website/templates/website/page.html +10 -0
- package/templates/frontend-templates/website/templates/website/post.html +20 -0
- package/templates/frontend-templates/website/templates/website/post_list.html +11 -0
- package/templates/frontend-templates/website/templates/website/robots.txt +5 -0
- package/templates/frontend-templates/website/templates/website/rss.xml +16 -0
- package/templates/frontend-templates/website/templates/website/sitemap.xml +8 -0
- package/templates/frontend-templates/website/tests/__init__.py +0 -0
- package/templates/frontend-templates/website/tests/test_site.py +102 -0
- package/templates/frontend-templates/website/urls.py +17 -0
- package/templates/frontend-templates/website/views.py +176 -0
|
@@ -258,9 +258,10 @@ async function runDoctor(directory) {
|
|
|
258
258
|
const services = running ? running.split("\n").filter(Boolean) : [];
|
|
259
259
|
const backendService = prod ? "backend-prod" : "backend";
|
|
260
260
|
const frontendService = prod ? "frontend-prod" : "frontend";
|
|
261
|
+
const frontend = existsSync(join(directory, "frontend", "package.json"));
|
|
261
262
|
checks.push({
|
|
262
263
|
name: "Services are running",
|
|
263
|
-
ok: services.includes(backendService) && services.includes(frontendService),
|
|
264
|
+
ok: services.includes(backendService) && (!frontend || services.includes(frontendService)),
|
|
264
265
|
warning: services.length === 0,
|
|
265
266
|
detail: services.length ? services.join(", ") : `Nothing is running. Start the project with \`docker compose up${prod ? " -d" : ""}\`.`
|
|
266
267
|
});
|
|
@@ -278,11 +279,12 @@ async function runDoctor(directory) {
|
|
|
278
279
|
detail: health ? `The ${backendService} container is ${health}` : `See \`docker compose logs ${backendService}\`.`
|
|
279
280
|
});
|
|
280
281
|
const site = (env.SITE_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
|
281
|
-
const
|
|
282
|
+
const status = await fetchStatus(site);
|
|
283
|
+
const logs = prod ? "proxy" : frontend ? frontendService : backendService;
|
|
282
284
|
checks.push({
|
|
283
285
|
name: "The site answers",
|
|
284
|
-
ok:
|
|
285
|
-
detail:
|
|
286
|
+
ok: status !== void 0 && status < 500,
|
|
287
|
+
detail: status ? `${site} answered ${status}` : `No answer on ${site}. See \`docker compose logs ${logs}\`.`
|
|
286
288
|
});
|
|
287
289
|
const api = await fetchStatus(`${site}/api/content.sitesettings`, { "X-Secret-Key": env.API_READ_KEY ?? "" });
|
|
288
290
|
checks.push({
|
|
@@ -461,13 +463,13 @@ const STACK_OPTIONS = [
|
|
|
461
463
|
value: "headless",
|
|
462
464
|
label: "Headless",
|
|
463
465
|
hint: "just the API and the admin",
|
|
464
|
-
generated:
|
|
466
|
+
generated: true
|
|
465
467
|
},
|
|
466
468
|
{
|
|
467
469
|
value: "templates",
|
|
468
470
|
label: "Django templates",
|
|
469
471
|
hint: "no Node, Django renders the site",
|
|
470
|
-
generated:
|
|
472
|
+
generated: true
|
|
471
473
|
},
|
|
472
474
|
{
|
|
473
475
|
value: "nuxt",
|
|
@@ -498,7 +500,7 @@ const STACK_OPTIONS = [
|
|
|
498
500
|
value: "latest",
|
|
499
501
|
label: "Latest",
|
|
500
502
|
hint: "Postgres 18",
|
|
501
|
-
generated:
|
|
503
|
+
generated: true
|
|
502
504
|
}]
|
|
503
505
|
},
|
|
504
506
|
{
|
|
@@ -726,9 +728,13 @@ const NODE_FRONTENDS = [
|
|
|
726
728
|
"nuxt",
|
|
727
729
|
"sveltekit"
|
|
728
730
|
];
|
|
729
|
-
/**
|
|
731
|
+
/**
|
|
732
|
+
* Whether an option applies to a stack: the package manager only with a Node
|
|
733
|
+
* frontend, page analytics only with a frontend for its tracker.
|
|
734
|
+
*/
|
|
730
735
|
function applies(key, stack) {
|
|
731
736
|
if (key === "packageManager") return stack.frontend === void 0 || NODE_FRONTENDS.includes(stack.frontend);
|
|
737
|
+
if (key === "analytics") return stack.frontend !== "headless";
|
|
732
738
|
return true;
|
|
733
739
|
}
|
|
734
740
|
const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
@@ -916,6 +922,10 @@ function projectName(directory) {
|
|
|
916
922
|
} catch {}
|
|
917
923
|
return basename(directory).toLowerCase();
|
|
918
924
|
}
|
|
925
|
+
/** Whether the project has a frontend: a headless one has no `frontend/`. */
|
|
926
|
+
function hasFrontend(directory) {
|
|
927
|
+
return existsSync(join(directory, "frontend", "package.json"));
|
|
928
|
+
}
|
|
919
929
|
function fromEnv(directory, name, envFile, localRun) {
|
|
920
930
|
const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
|
|
921
931
|
const profiles = parseProfiles(env.COMPOSE_PROFILES);
|
|
@@ -930,7 +940,7 @@ function fromEnv(directory, name, envFile, localRun) {
|
|
|
930
940
|
localRun,
|
|
931
941
|
services: {
|
|
932
942
|
backend: `backend${suffix}`,
|
|
933
|
-
frontend: `frontend${suffix}
|
|
943
|
+
...hasFrontend(directory) ? { frontend: `frontend${suffix}` } : {},
|
|
934
944
|
worker: `worker${suffix}`
|
|
935
945
|
},
|
|
936
946
|
composeArgs: localRun ? [
|
|
@@ -1161,6 +1171,23 @@ const PACKAGE_NAME = "@django-stack/cli";
|
|
|
1161
1171
|
// src/scaffold.ts
|
|
1162
1172
|
/** The frontends this version of the CLI generates. */
|
|
1163
1173
|
const FRONTENDS = stackOption("frontend").values.filter((value) => value.generated).map((value) => value.value);
|
|
1174
|
+
/** The Postgres major version of each `--postgres` value. */
|
|
1175
|
+
const POSTGRES_VERSIONS = {
|
|
1176
|
+
stable: "17",
|
|
1177
|
+
latest: "18"
|
|
1178
|
+
};
|
|
1179
|
+
/**
|
|
1180
|
+
* The template values of a Postgres version. From 18 the image keeps its data
|
|
1181
|
+
* in a versioned directory under /var/lib/postgresql, so the volume is
|
|
1182
|
+
* mounted there; before 18 at /var/lib/postgresql/data.
|
|
1183
|
+
*/
|
|
1184
|
+
function postgresValues(postgres) {
|
|
1185
|
+
const version = POSTGRES_VERSIONS[postgres];
|
|
1186
|
+
return {
|
|
1187
|
+
postgres_version: version,
|
|
1188
|
+
postgres_data: Number(version) >= 18 ? "/var/lib/postgresql" : "/var/lib/postgresql/data"
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1164
1191
|
/** The pnpm and Bun versions the frontend image installs with. */
|
|
1165
1192
|
const PNPM_VERSION = "12.18.0";
|
|
1166
1193
|
const BUN_VERSION = "1.4.4";
|
|
@@ -1289,7 +1316,7 @@ function localS3(port = 8333) {
|
|
|
1289
1316
|
};
|
|
1290
1317
|
}
|
|
1291
1318
|
const LOCAL_S3 = localS3();
|
|
1292
|
-
const TEXT_EXTENSIONS = /\.(py|ts|tsx|mjs|js|jsx|json|astro|vue|svelte|css|md|yaml|yml|toml|txt|html|sh|svg)$|Dockerfile$|gitignore$|\.dockerignore$|env\.example$/;
|
|
1319
|
+
const TEXT_EXTENSIONS = /\.(py|ts|tsx|mjs|js|jsx|json|astro|vue|svelte|css|md|yaml|yml|toml|txt|html|sh|svg)$|Dockerfile$|Caddyfile$|gitignore$|\.dockerignore$|env\.example$/;
|
|
1293
1320
|
function defaultTemplatesDir() {
|
|
1294
1321
|
return fileURLToPath(new URL("../templates", import.meta.url));
|
|
1295
1322
|
}
|
|
@@ -1323,6 +1350,35 @@ function walk(directory) {
|
|
|
1323
1350
|
return statSync(path).isDirectory() ? walk(path) : [path];
|
|
1324
1351
|
});
|
|
1325
1352
|
}
|
|
1353
|
+
const BLOCK_START = /^\s*(?:#|<!--)\s*@if\s+(!?)([a-z]+)\s*(?:-->)?\s*$/;
|
|
1354
|
+
const BLOCK_END = /^\s*(?:#|<!--)\s*@end\s*(?:-->)?\s*$/;
|
|
1355
|
+
/**
|
|
1356
|
+
* Keeps or drops the template's conditional blocks: the lines between a
|
|
1357
|
+
* comment line `# @if frontend` (or `<!-- @if !frontend -->`) and `# @end`
|
|
1358
|
+
* stay when the flag is on (off with `!`). The marker lines always go.
|
|
1359
|
+
*/
|
|
1360
|
+
function selectBlocks(text, flags) {
|
|
1361
|
+
if (!/@(if|end)\b/.test(text)) return text;
|
|
1362
|
+
const kept = [];
|
|
1363
|
+
const open = [];
|
|
1364
|
+
for (const line of text.split("\n")) {
|
|
1365
|
+
const start = line.match(BLOCK_START);
|
|
1366
|
+
if (start) {
|
|
1367
|
+
const flag = flags[start[2]];
|
|
1368
|
+
if (flag === void 0) throw new Error(`Unknown template flag ${start[2]}.`);
|
|
1369
|
+
open.push(start[1] ? !flag : flag);
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
if (BLOCK_END.test(line)) {
|
|
1373
|
+
if (!open.length) throw new Error("@end without @if.");
|
|
1374
|
+
open.pop();
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
if (open.every(Boolean)) kept.push(line);
|
|
1378
|
+
}
|
|
1379
|
+
if (open.length) throw new Error("@if without @end.");
|
|
1380
|
+
return kept.join("\n");
|
|
1381
|
+
}
|
|
1326
1382
|
function render(text, values) {
|
|
1327
1383
|
return text.replace(/\{\{([a-z_]+)\}\}/g, (match, key) => values[key] ?? match);
|
|
1328
1384
|
}
|
|
@@ -1334,6 +1390,16 @@ function validateTimeZone(value) {
|
|
|
1334
1390
|
if (timeZones().includes(value)) return void 0;
|
|
1335
1391
|
return `${JSON.stringify(value)} is not a time zone. Use a name such as Europe/Amsterdam or UTC.`;
|
|
1336
1392
|
}
|
|
1393
|
+
/** The root package.json of a headless project: the scripts without the frontend's steps. */
|
|
1394
|
+
function headlessScripts(text) {
|
|
1395
|
+
const manifest = JSON.parse(text);
|
|
1396
|
+
const scripts = manifest.scripts;
|
|
1397
|
+
scripts.test = "docker compose exec backend pytest";
|
|
1398
|
+
scripts.lint = "docker compose exec backend ruff check .";
|
|
1399
|
+
scripts["generate:types"] = (scripts["generate:types"] ?? "").split(" && ")[0] ?? "";
|
|
1400
|
+
manifest.description = manifest.description.replace(", Content Studio and Headless,", " and Content Studio,");
|
|
1401
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
1402
|
+
}
|
|
1337
1403
|
function scaffold(options) {
|
|
1338
1404
|
const templates = options.templatesDir ?? defaultTemplatesDir();
|
|
1339
1405
|
const target = options.directory;
|
|
@@ -1344,17 +1410,28 @@ function scaffold(options) {
|
|
|
1344
1410
|
const zoneError = validateTimeZone(options.timeZone);
|
|
1345
1411
|
if (zoneError) throw new Error(zoneError);
|
|
1346
1412
|
}
|
|
1413
|
+
const headless = options.frontend === "headless";
|
|
1414
|
+
const djangoTemplates = options.frontend === "templates";
|
|
1415
|
+
const jsFrontend = !headless && !djangoTemplates;
|
|
1347
1416
|
const frontendTemplate = join(templates, `frontend-${options.frontend}`);
|
|
1348
|
-
if (!existsSync(frontendTemplate)) throw new Error(`This version of the CLI has no ${options.frontend} frontend.`);
|
|
1417
|
+
if (!headless && !existsSync(frontendTemplate)) throw new Error(`This version of the CLI has no ${options.frontend} frontend.`);
|
|
1349
1418
|
mkdirSync(target, { recursive: true });
|
|
1350
1419
|
cpSync(join(templates, "base"), target, { recursive: true });
|
|
1351
1420
|
cpSync(join(templates, "backend"), join(target, "backend"), { recursive: true });
|
|
1352
|
-
|
|
1421
|
+
const frontendDir = join(target, djangoTemplates ? "backend" : "frontend");
|
|
1422
|
+
if (!headless) cpSync(frontendTemplate, frontendDir, { recursive: true });
|
|
1353
1423
|
renameSync(join(target, "gitignore"), join(target, ".gitignore"));
|
|
1354
1424
|
makeExecutable(target);
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1425
|
+
let frontend = {
|
|
1426
|
+
label: "Headless",
|
|
1427
|
+
url: "",
|
|
1428
|
+
security: ""
|
|
1429
|
+
};
|
|
1430
|
+
if (!headless) {
|
|
1431
|
+
const about = join(frontendDir, "template.json");
|
|
1432
|
+
frontend = JSON.parse(readFileSync(about, "utf8"));
|
|
1433
|
+
rmSync(about);
|
|
1434
|
+
}
|
|
1358
1435
|
const packageManager = options.packageManager ?? "pnpm";
|
|
1359
1436
|
const values = {
|
|
1360
1437
|
project_name: options.name,
|
|
@@ -1366,17 +1443,24 @@ function scaffold(options) {
|
|
|
1366
1443
|
preset: options.preset ?? encodePreset(DEFAULT_STACK),
|
|
1367
1444
|
site_port: String(options.ports?.SITE_PORT ?? 3e3),
|
|
1368
1445
|
mail_port: String(options.ports?.MAIL_PORT ?? 8025),
|
|
1369
|
-
...packageManagerValues(packageManager)
|
|
1446
|
+
...packageManagerValues(packageManager),
|
|
1447
|
+
...postgresValues(options.postgres ?? DEFAULT_STACK.postgres)
|
|
1370
1448
|
};
|
|
1371
1449
|
const files = walk(target);
|
|
1372
1450
|
for (const file of files) {
|
|
1373
1451
|
if (!TEXT_EXTENSIONS.test(file)) continue;
|
|
1374
1452
|
const text = readFileSync(file, "utf8");
|
|
1375
|
-
const rendered = render(text,
|
|
1453
|
+
const rendered = render(selectBlocks(text, {
|
|
1454
|
+
frontend: jsFrontend,
|
|
1455
|
+
headless,
|
|
1456
|
+
templates: djangoTemplates,
|
|
1457
|
+
site: !headless
|
|
1458
|
+
}), values);
|
|
1376
1459
|
if (rendered !== text) writeFileSync(file, rendered);
|
|
1377
1460
|
}
|
|
1461
|
+
if (!jsFrontend) writeFileSync(join(target, "package.json"), headlessScripts(readFileSync(join(target, "package.json"), "utf8")));
|
|
1378
1462
|
if (packageManager !== "pnpm") rmSync(join(target, "frontend", "pnpm-workspace.yaml"), { force: true });
|
|
1379
|
-
if (packageManager !== "npm") {
|
|
1463
|
+
if (jsFrontend && packageManager !== "npm") {
|
|
1380
1464
|
const packageJson = join(target, "frontend", "package.json");
|
|
1381
1465
|
const manifest = JSON.parse(readFileSync(packageJson, "utf8"));
|
|
1382
1466
|
const version = packageManager === "pnpm" ? PNPM_VERSION : BUN_VERSION;
|
|
@@ -1398,14 +1482,13 @@ function scaffold(options) {
|
|
|
1398
1482
|
const secrets = generateProjectSecrets();
|
|
1399
1483
|
let env = template.replace(/\{\{secret:([a-z_]+)\}\}/g, (match, key) => secrets[key] ?? match);
|
|
1400
1484
|
const envExample = template.replace(/\{\{secret:[a-z_]+\}\}/g, "");
|
|
1401
|
-
const profiles = ["dev"];
|
|
1402
|
-
if (options.databaseUrl) env = setEnvValues(env, {
|
|
1403
|
-
else profiles.push("db");
|
|
1485
|
+
const profiles = ["dev", "db"];
|
|
1486
|
+
if (options.databaseUrl) env = setEnvValues(env, { PRODUCTION_DATABASE_URL: normalizeDatabaseUrl(options.databaseUrl) });
|
|
1404
1487
|
if (options.localS3) {
|
|
1405
1488
|
profiles.push("s3");
|
|
1406
1489
|
env = setEnvValues(env, LOCAL_S3);
|
|
1407
1490
|
}
|
|
1408
|
-
if (options.analytics) {
|
|
1491
|
+
if (options.analytics && !headless) {
|
|
1409
1492
|
profiles.push("analytics");
|
|
1410
1493
|
env = setEnvValues(env, umamiEnv(parseEnv(env).DATABASE_URL ?? "", secrets));
|
|
1411
1494
|
}
|
|
@@ -1456,6 +1539,10 @@ function linkPackages(target, monorepo) {
|
|
|
1456
1539
|
const pyproject = join(target, "backend", "pyproject.toml");
|
|
1457
1540
|
writeFileSync(pyproject, `${readFileSync(pyproject, "utf8").trimEnd()}\n\n[tool.uv.sources]\ndjango-stack = { path = "vendor/${wheel}" }\n`);
|
|
1458
1541
|
const packageJson = join(target, "frontend", "package.json");
|
|
1542
|
+
if (!existsSync(packageJson)) {
|
|
1543
|
+
rmSync(frontendVendor, { recursive: true });
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1459
1546
|
const manifest = JSON.parse(readFileSync(packageJson, "utf8"));
|
|
1460
1547
|
const packages = Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@django-stack/"));
|
|
1461
1548
|
for (const name of packages) {
|
|
@@ -1568,6 +1655,16 @@ function relativeDirectory(directory) {
|
|
|
1568
1655
|
return path.startsWith("..") ? directory : path;
|
|
1569
1656
|
}
|
|
1570
1657
|
// src/production.ts
|
|
1658
|
+
/**
|
|
1659
|
+
* A Postgres URL from its parts, with the user name, password and database
|
|
1660
|
+
* name URL-encoded, so a password with `@`, `:`, `/` or `#` still works.
|
|
1661
|
+
* Hosts other than this machine get `sslmode=require`.
|
|
1662
|
+
*/
|
|
1663
|
+
function postgresUrl(parts) {
|
|
1664
|
+
const host = parts.host.includes(":") && !parts.host.startsWith("[") ? `[${parts.host}]` : parts.host;
|
|
1665
|
+
const password = parts.password ? `:${encodeURIComponent(parts.password)}` : "";
|
|
1666
|
+
return normalizeDatabaseUrl(`postgres://${encodeURIComponent(parts.user)}${password}@${host}:${parts.port || "5432"}/${encodeURIComponent(parts.name)}`);
|
|
1667
|
+
}
|
|
1571
1668
|
/** The S3 settings that uploads to S3 need and that are empty. */
|
|
1572
1669
|
function missingS3(env) {
|
|
1573
1670
|
return [
|
|
@@ -1607,7 +1704,12 @@ function productionEnv(devEnv, options) {
|
|
|
1607
1704
|
ADMIN_PASSWORD: secrets.admin ?? "",
|
|
1608
1705
|
DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
|
|
1609
1706
|
};
|
|
1610
|
-
|
|
1707
|
+
const external = options.bundledDatabase ? "" : options.databaseUrl ? normalizeDatabaseUrl(options.databaseUrl) : current.PRODUCTION_DATABASE_URL ?? "";
|
|
1708
|
+
if (external) {
|
|
1709
|
+
values.DATABASE_URL = external;
|
|
1710
|
+
values.POSTGRES_PASSWORD = "";
|
|
1711
|
+
values.COMPOSE_PROFILES = [...new Set(profiles.filter((profile) => profile !== "db"))].join(",");
|
|
1712
|
+
} else if (profiles.includes("db")) {
|
|
1611
1713
|
const user = current.POSTGRES_USER || "app";
|
|
1612
1714
|
const name = current.POSTGRES_DB || "app";
|
|
1613
1715
|
values.POSTGRES_PASSWORD = secrets.postgres ?? "";
|
|
@@ -1644,4 +1746,4 @@ function productionEnv(devEnv, options) {
|
|
|
1644
1746
|
todo
|
|
1645
1747
|
};
|
|
1646
1748
|
}
|
|
1647
|
-
export {
|
|
1749
|
+
export { describeMove as $, activeProject as A, DEFAULT_STACK as B, CLI_VERSION as C, CAPTCHA_APP as D, generateSecret as E, projectStack as F, applies as G, NODE_FRONTENDS as H, projectUrls as I, pendingChoices as J, decodePreset as K, readLocalRun as L, composeOutput as M, findProjectDirectory as N, RUN_DOMAIN as O, parseProfiles as P, chooseFreePorts as Q, readProject as R, validateTimeZone as S, generateProjectSecrets as T, PROMPT_ORDER as U, NAMED_PRESETS as V, STACK_OPTIONS as W, stackOption as X, resolvePreset as Y, DEV_PORTS as Z, toProjectName as _, FRONTENDS as a, dockerRunning as at, usesValkey as b, frontendPackageManager as c, quoteEnvValue as ct, lockFrontend as d, envPorts as et, normalizeDatabaseUrl as f, timeZones as g, setNotificationsApp as h, validateDomain as i, dockerNotRunning as it, compose as j, RUN_ENV as k, localS3 as l, setEnvValues as lt, scaffold as m, postgresUrl as n, runDoctor as nt, LOCAL_S3 as o, isDockerDown as ot, relativeDirectory as p, encodePreset as q, productionEnv as r, checkTools as rt, cacheEnv as s, parseEnv as st, missingS3 as t, portValues as tt, lockBackend as u, umamiEnv as v, PACKAGE_NAME as w, validateProjectName as x, updateFrontendLock as y, runningServices as z };
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@django-stack/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The production-ready
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "The production-ready batteries included Django 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",
|
|
7
|
-
"homepage": "https://
|
|
7
|
+
"homepage": "https://djangostack.org/",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/dwarsbit/django-stack.git",
|
|
@@ -10,9 +10,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
|
10
10
|
UV_PROJECT_ENVIRONMENT=/venv \
|
|
11
11
|
PATH="/venv/bin:$PATH"
|
|
12
12
|
COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /bin/uv
|
|
13
|
-
# The Postgres client, for the backups (pg_dump and pg_restore)
|
|
13
|
+
# The Postgres client, for the backups (pg_dump and pg_restore), from the
|
|
14
|
+
# PostgreSQL apt repository: its major version must match the server's, as
|
|
15
|
+
# pg_dump refuses a newer server. Change it with the database.
|
|
16
|
+
ARG POSTGRES_VERSION={{postgres_version}}
|
|
14
17
|
RUN apt-get update \
|
|
15
|
-
&& apt-get install --yes --no-install-recommends postgresql-
|
|
18
|
+
&& apt-get install --yes --no-install-recommends postgresql-common \
|
|
19
|
+
&& /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y \
|
|
20
|
+
&& apt-get install --yes --no-install-recommends "postgresql-client-${POSTGRES_VERSION}" \
|
|
16
21
|
&& rm -rf /var/lib/apt/lists/*
|
|
17
22
|
RUN groupadd --system app && useradd --system --gid app --home-dir /app app
|
|
18
23
|
WORKDIR /app
|
|
@@ -42,6 +42,10 @@ INSTALLED_APPS = [
|
|
|
42
42
|
"django_stack",
|
|
43
43
|
"django_stack.contrib.captcha", # the captcha of the contact form (CAPTCHA=off turns it off)
|
|
44
44
|
"content",
|
|
45
|
+
# @if templates
|
|
46
|
+
"django_stack.contrib.site", # preview, the page cache, redirects and the template tags of the site
|
|
47
|
+
"website", # the site's pages
|
|
48
|
+
# @end
|
|
45
49
|
# Last: builds the API routes once every model is exposed.
|
|
46
50
|
"headless.rest",
|
|
47
51
|
]
|
|
@@ -56,6 +60,10 @@ MIDDLEWARE = [
|
|
|
56
60
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
57
61
|
"django.contrib.messages.middleware.MessageMiddleware",
|
|
58
62
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
63
|
+
# @if templates
|
|
64
|
+
# The redirects editors manage in the admin, for paths that do not exist.
|
|
65
|
+
"django_stack.contrib.site.middleware.RedirectMiddleware",
|
|
66
|
+
# @end
|
|
59
67
|
]
|
|
60
68
|
|
|
61
69
|
ROOT_URLCONF = "config.urls"
|
|
@@ -72,6 +80,10 @@ TEMPLATES = [
|
|
|
72
80
|
"django.template.context_processors.request",
|
|
73
81
|
"django.contrib.auth.context_processors.auth",
|
|
74
82
|
"django.contrib.messages.context_processors.messages",
|
|
83
|
+
# @if templates
|
|
84
|
+
"django_stack.contrib.site.context_processors.site", # preview, analytics_website_id
|
|
85
|
+
"website.context_processors.site", # site, main_menu, footer_menu
|
|
86
|
+
# @end
|
|
75
87
|
],
|
|
76
88
|
},
|
|
77
89
|
},
|
|
@@ -5,17 +5,30 @@ 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
|
|
8
|
+
from content_studio.analytics import (
|
|
9
|
+
AnalyticsExtension,
|
|
10
|
+
AnalyticsLink,
|
|
11
|
+
AnalyticsPage,
|
|
12
|
+
AnalyticsStatisticWidget,
|
|
13
|
+
AnalyticsTopPagesWidget,
|
|
14
|
+
)
|
|
9
15
|
from content_studio.contrib.huey import HueyAdapter
|
|
10
|
-
from content_studio.dashboard
|
|
16
|
+
from content_studio.dashboard import Dashboard
|
|
17
|
+
from content_studio.dashboard.activity_log import ActivityLogWidget
|
|
18
|
+
from content_studio.dashboard.tasks import TaskDashboard, TaskStatisticWidget
|
|
11
19
|
from content_studio.extensions import UserMenuLink
|
|
12
20
|
from django_stack.analytics import UmamiProvider
|
|
13
21
|
from django_stack.studio import SystemLink, SystemPage
|
|
14
22
|
|
|
23
|
+
from content.dashboard import PublishedCountWidget, RecentSubmissionsWidget, SubmissionCountWidget
|
|
24
|
+
from content.models import Page, Post
|
|
25
|
+
|
|
26
|
+
TASKS = HueyAdapter()
|
|
27
|
+
|
|
15
28
|
|
|
16
29
|
class SiteStudio(ContentStudioSite):
|
|
17
30
|
extensions = [
|
|
18
|
-
TaskDashboard(adapter=
|
|
31
|
+
TaskDashboard(adapter=TASKS),
|
|
19
32
|
UserMenuLink(url="/tasks/", label="Tasks", icon="ph-bold ph-queue"),
|
|
20
33
|
# Superusers only: the health checks and the latest error reports.
|
|
21
34
|
SystemPage(),
|
|
@@ -29,5 +42,24 @@ class SiteStudio(ContentStudioSite):
|
|
|
29
42
|
if UmamiProvider.is_configured():
|
|
30
43
|
extensions += [AnalyticsExtension(provider=UmamiProvider()), AnalyticsPage(), AnalyticsLink()]
|
|
31
44
|
|
|
45
|
+
# The dashboard at /admin/: what the site has, what came in, what
|
|
46
|
+
# changed and how the worker is doing. The counters and the form
|
|
47
|
+
# submissions are in content/dashboard.py; change or remove them there.
|
|
48
|
+
dashboard = Dashboard(
|
|
49
|
+
widgets=[
|
|
50
|
+
PublishedCountWidget(Page, "Published pages", icon="ph-bold ph-file-text"),
|
|
51
|
+
PublishedCountWidget(Post, "Published posts", icon="ph-bold ph-article"),
|
|
52
|
+
SubmissionCountWidget(),
|
|
53
|
+
# Visitors and the most visited pages, with analytics on.
|
|
54
|
+
*([AnalyticsStatisticWidget("visitors", "30d")] if UmamiProvider.is_configured() else []),
|
|
55
|
+
RecentSubmissionsWidget(),
|
|
56
|
+
ActivityLogWidget(),
|
|
57
|
+
*([AnalyticsTopPagesWidget("30d")] if UmamiProvider.is_configured() else []),
|
|
58
|
+
# The task counts; the tasks themselves are on the Tasks page.
|
|
59
|
+
TaskStatisticWidget(TASKS, "queued"),
|
|
60
|
+
TaskStatisticWidget(TASKS, "failed"),
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
|
|
32
64
|
|
|
33
65
|
admin_site = SiteStudio()
|
|
@@ -6,4 +6,15 @@ urlpatterns = [
|
|
|
6
6
|
path("api/", include("headless.rest.urls")),
|
|
7
7
|
path("", include("django_stack.urls")), # /healthz and /readyz
|
|
8
8
|
*media_urlpatterns(), # uploaded files, in development only
|
|
9
|
+
# @if headless
|
|
10
|
+
path("", include("django_stack.start")), # a start page with links to the admin and the API schema
|
|
11
|
+
# @end
|
|
12
|
+
# @if templates
|
|
13
|
+
path("", include("website.urls")), # the site's pages, last: they include a catch-all for page paths
|
|
14
|
+
# @end
|
|
9
15
|
]
|
|
16
|
+
# @if templates
|
|
17
|
+
|
|
18
|
+
# The site's 404 page, with its layout (when DJANGO_DEBUG is off).
|
|
19
|
+
handler404 = "website.views.not_found"
|
|
20
|
+
# @end
|
|
@@ -7,6 +7,7 @@ from content_studio import register
|
|
|
7
7
|
from content_studio.admin import ModelStudio
|
|
8
8
|
from content_studio.analytics import Analytics
|
|
9
9
|
from content_studio.form import ActivityLog, Field, FormSet
|
|
10
|
+
from django_stack.analytics import UmamiProvider
|
|
10
11
|
from django_stack.studio import PreviewButton
|
|
11
12
|
|
|
12
13
|
from .models import Author, Category, FormSubmission, Menu, Page, Post, Redirect, SiteSettings, Tag
|
|
@@ -17,9 +18,17 @@ POST_PREVIEW = PreviewButton(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a02"
|
|
|
17
18
|
PAGE_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a03")
|
|
18
19
|
POST_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a04")
|
|
19
20
|
# The visitors of the page or post, from the analytics (see config/studio.py).
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
# Only with analytics on: Content Studio refuses them without its
|
|
22
|
+
# AnalyticsExtension.
|
|
23
|
+
ANALYTICS = UmamiProvider.is_configured()
|
|
24
|
+
PAGE_ANALYTICS = (
|
|
25
|
+
[Analytics(path_field="path", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a05")] if ANALYTICS else []
|
|
26
|
+
)
|
|
27
|
+
POST_ANALYTICS = (
|
|
28
|
+
[Analytics(path_field="slug", path_prefix="/blog/", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a06")]
|
|
29
|
+
if ANALYTICS
|
|
30
|
+
else []
|
|
31
|
+
)
|
|
23
32
|
SEO = FormSet(
|
|
24
33
|
title="Search and sharing",
|
|
25
34
|
description="Leave empty to use the title and the site settings.",
|
|
@@ -42,7 +51,7 @@ class PageStudio(ModelStudio):
|
|
|
42
51
|
FormSet(title="Body", fields=["body"]),
|
|
43
52
|
SEO,
|
|
44
53
|
]
|
|
45
|
-
edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", PAGE_ANALYTICS, PAGE_ACTIVITY]
|
|
54
|
+
edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", *PAGE_ANALYTICS, PAGE_ACTIVITY]
|
|
46
55
|
|
|
47
56
|
|
|
48
57
|
@register(Post)
|
|
@@ -67,7 +76,7 @@ class PostStudio(ModelStudio):
|
|
|
67
76
|
"author",
|
|
68
77
|
"categories",
|
|
69
78
|
"tags",
|
|
70
|
-
POST_ANALYTICS,
|
|
79
|
+
*POST_ANALYTICS,
|
|
71
80
|
POST_ACTIVITY,
|
|
72
81
|
]
|
|
73
82
|
|
|
@@ -115,7 +124,15 @@ class RedirectStudio(ModelStudio):
|
|
|
115
124
|
|
|
116
125
|
@register(FormSubmission)
|
|
117
126
|
class FormSubmissionStudio(ModelStudio):
|
|
127
|
+
"""Submissions come from the site's form: editors read and delete them, never create or edit."""
|
|
128
|
+
|
|
118
129
|
icon = "ph-bold ph-envelope"
|
|
119
130
|
table_display = ["name", "email", "created_at"]
|
|
120
131
|
search_fields = ["name", "email", "message"]
|
|
121
132
|
ordering = ["-created_at"]
|
|
133
|
+
|
|
134
|
+
def has_add_permission(self, request, obj=None):
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def has_change_permission(self, request, obj=None):
|
|
138
|
+
return False
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The widgets of the dashboard at /admin/: config/studio.py lists them. See
|
|
3
|
+
https://djangocontentstudio.org/docs/dashboard for the widget types.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from content_studio.dashboard.content_list import ContentListWidget
|
|
9
|
+
from content_studio.dashboard.statistic import StatisticWidget
|
|
10
|
+
from content_studio.settings import cs_settings
|
|
11
|
+
from django.db.models import Count
|
|
12
|
+
from django.db.models.functions import TruncDate
|
|
13
|
+
from django.utils import timezone
|
|
14
|
+
from django.utils.text import Truncator
|
|
15
|
+
from rest_framework.exceptions import PermissionDenied
|
|
16
|
+
|
|
17
|
+
from .models import FormSubmission
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check_view_permission(request, model):
|
|
21
|
+
"""Widgets show only what the editor may view in the model's own list."""
|
|
22
|
+
studio = cs_settings.ADMIN_SITE._registry.get(model)
|
|
23
|
+
if studio is None or not studio.has_view_permission(request):
|
|
24
|
+
raise PermissionDenied()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PublishedCountWidget(StatisticWidget):
|
|
28
|
+
"""The number of published objects of a model, with its drafts in the footnote."""
|
|
29
|
+
|
|
30
|
+
name = "PublishedCount"
|
|
31
|
+
refresh_interval = 300
|
|
32
|
+
|
|
33
|
+
def __init__(self, model, title: str, icon: str = "", widget_id=None):
|
|
34
|
+
self.model = model
|
|
35
|
+
self.title = title
|
|
36
|
+
self.icon = icon
|
|
37
|
+
super().__init__(widget_id)
|
|
38
|
+
|
|
39
|
+
def get_id_parts(self):
|
|
40
|
+
return (self.name, self.model._meta.label)
|
|
41
|
+
|
|
42
|
+
def get_data(self, request):
|
|
43
|
+
check_view_permission(request, self.model)
|
|
44
|
+
published = self.model.objects.published().count()
|
|
45
|
+
drafts = self.model.objects.filter(status=self.model.Status.DRAFT).count()
|
|
46
|
+
return {
|
|
47
|
+
"title": self.title,
|
|
48
|
+
"value": published,
|
|
49
|
+
"number_format": "full",
|
|
50
|
+
"icon": self.icon,
|
|
51
|
+
"footnote": f"{drafts} draft{'' if drafts == 1 else 's'}",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SubmissionCountWidget(StatisticWidget):
|
|
56
|
+
"""The contact form submissions of the last 30 days, with one bar a day."""
|
|
57
|
+
|
|
58
|
+
name = "SubmissionCount"
|
|
59
|
+
refresh_interval = 300
|
|
60
|
+
days = 30
|
|
61
|
+
|
|
62
|
+
def get_data(self, request):
|
|
63
|
+
check_view_permission(request, FormSubmission)
|
|
64
|
+
today = timezone.localdate()
|
|
65
|
+
first = today - timedelta(days=self.days - 1)
|
|
66
|
+
per_day = dict(
|
|
67
|
+
FormSubmission.objects.filter(created_at__date__gte=first)
|
|
68
|
+
.annotate(day=TruncDate("created_at"))
|
|
69
|
+
.values("day")
|
|
70
|
+
.annotate(count=Count("pk"))
|
|
71
|
+
.values_list("day", "count")
|
|
72
|
+
)
|
|
73
|
+
series = [per_day.get(first + timedelta(days=offset), 0) for offset in range(self.days)]
|
|
74
|
+
return {
|
|
75
|
+
"title": "Form submissions",
|
|
76
|
+
"value": sum(series),
|
|
77
|
+
"number_format": "full",
|
|
78
|
+
"icon": "ph-bold ph-envelope-simple",
|
|
79
|
+
"footnote": f"Last {self.days} days",
|
|
80
|
+
"series": series,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RecentSubmissionsWidget(ContentListWidget):
|
|
85
|
+
"""The latest contact form submissions, each linking to its page in the admin."""
|
|
86
|
+
|
|
87
|
+
name = "RecentSubmissions"
|
|
88
|
+
refresh_interval = 60
|
|
89
|
+
limit = 5
|
|
90
|
+
|
|
91
|
+
def get_data(self, request):
|
|
92
|
+
check_view_permission(request, FormSubmission)
|
|
93
|
+
latest = list(FormSubmission.objects.order_by("-created_at")[: self.limit])
|
|
94
|
+
return {
|
|
95
|
+
"title": "Recent form submissions",
|
|
96
|
+
"description": (
|
|
97
|
+
"The latest messages from the contact form."
|
|
98
|
+
if latest
|
|
99
|
+
else "No messages yet. Submissions from the contact form appear here."
|
|
100
|
+
),
|
|
101
|
+
"view_all": f"/content/{FormSubmission._meta.label_lower}",
|
|
102
|
+
"content": [
|
|
103
|
+
{
|
|
104
|
+
"model": FormSubmission._meta.label_lower,
|
|
105
|
+
"id": str(submission.pk),
|
|
106
|
+
"title": submission.name,
|
|
107
|
+
"description": Truncator(submission.message).chars(120),
|
|
108
|
+
"timestamp": submission.created_at,
|
|
109
|
+
}
|
|
110
|
+
for submission in latest
|
|
111
|
+
],
|
|
112
|
+
}
|
|
@@ -10,7 +10,7 @@ from content_studio.media_library.models import Media
|
|
|
10
10
|
from django.core.files.base import ContentFile
|
|
11
11
|
from django.db import transaction
|
|
12
12
|
|
|
13
|
-
from .models import Author, Category, Menu, Page, Post, Redirect, SiteSettings, Tag
|
|
13
|
+
from .models import Author, Category, FormSubmission, Menu, Page, Post, Redirect, SiteSettings, Tag
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def block(type: str, **data) -> dict:
|
|
@@ -141,6 +141,13 @@ def seed(if_empty: bool = False, stdout=None) -> bool:
|
|
|
141
141
|
items=[block("link", label="RSS", url="/rss.xml")],
|
|
142
142
|
)
|
|
143
143
|
Redirect.objects.create(old_path="/old-about", new_path="/about")
|
|
144
|
+
# Messages of the contact form, so the dashboard has something to show.
|
|
145
|
+
FormSubmission.objects.bulk_create(
|
|
146
|
+
[
|
|
147
|
+
FormSubmission(name="Grace Reader", email="grace@example.com", message="Is there an RSS feed of the blog?"),
|
|
148
|
+
FormSubmission(name="Alan Visitor", email="alan@example.com", message="Hello from the contact page."),
|
|
149
|
+
]
|
|
150
|
+
)
|
|
144
151
|
|
|
145
152
|
settings = SiteSettings.objects.first() or SiteSettings()
|
|
146
153
|
settings.site_name = "{{project_name}}"
|
|
@@ -101,3 +101,18 @@ def test_contact_form_notifies_the_editors(seeded, django_capture_on_commit_call
|
|
|
101
101
|
|
|
102
102
|
def test_read_key_cannot_read_submissions(reader):
|
|
103
103
|
assert reader.get("/api/content.formsubmission").status_code == 403
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_editors_only_read_and_delete_submissions(rf):
|
|
107
|
+
from content_studio.admin import admin_site
|
|
108
|
+
from django.contrib.auth import get_user_model
|
|
109
|
+
|
|
110
|
+
from content.admin import FormSubmissionStudio
|
|
111
|
+
|
|
112
|
+
request = rf.get("/")
|
|
113
|
+
request.user = get_user_model().objects.create_superuser("editor", "editor@example.com", "x")
|
|
114
|
+
studio = FormSubmissionStudio(FormSubmission, admin_site)
|
|
115
|
+
assert studio.has_view_permission(request)
|
|
116
|
+
assert studio.has_delete_permission(request)
|
|
117
|
+
assert not studio.has_add_permission(request)
|
|
118
|
+
assert not studio.has_change_permission(request)
|