@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.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/dist/bin.js +122 -52
  3. package/dist/index.d.ts +13 -4
  4. package/dist/index.js +1 -1
  5. package/dist/{production-Ct_qoeXk.js → production-1O8-1QUl.js} +126 -24
  6. package/package.json +3 -3
  7. package/templates/backend/Dockerfile +7 -2
  8. package/templates/backend/config/settings.py +12 -0
  9. package/templates/backend/config/studio.py +35 -3
  10. package/templates/backend/config/urls.py +11 -0
  11. package/templates/backend/content/admin.py +22 -5
  12. package/templates/backend/content/dashboard.py +112 -0
  13. package/templates/backend/content/seed.py +8 -1
  14. package/templates/backend/content/tests/test_api.py +15 -0
  15. package/templates/backend/content/tests/test_dashboard.py +77 -0
  16. package/templates/backend/pyproject.toml +1 -1
  17. package/templates/base/.github/workflows/deploy.yml +10 -1
  18. package/templates/base/AGENTS.md +78 -0
  19. package/templates/base/Caddyfile +24 -0
  20. package/templates/base/README.md +74 -6
  21. package/templates/base/SECURITY.md +2 -0
  22. package/templates/base/compose.yaml +21 -2
  23. package/templates/base/env.example +25 -0
  24. package/templates/frontend-astro/package.json +1 -1
  25. package/templates/frontend-next/package.json +3 -3
  26. package/templates/frontend-nuxt/package.json +2 -2
  27. package/templates/frontend-sveltekit/package.json +2 -2
  28. package/templates/frontend-templates/template.json +5 -0
  29. package/templates/frontend-templates/website/__init__.py +0 -0
  30. package/templates/frontend-templates/website/apps.py +6 -0
  31. package/templates/frontend-templates/website/context_processors.py +16 -0
  32. package/templates/frontend-templates/website/forms.py +20 -0
  33. package/templates/frontend-templates/website/static/website/favicon.svg +1 -0
  34. package/templates/frontend-templates/website/static/website/global.css +232 -0
  35. package/templates/frontend-templates/website/templates/website/404.html +10 -0
  36. package/templates/frontend-templates/website/templates/website/base.html +44 -0
  37. package/templates/frontend-templates/website/templates/website/blocks/call_to_action.html +5 -0
  38. package/templates/frontend-templates/website/templates/website/blocks/image.html +4 -0
  39. package/templates/frontend-templates/website/templates/website/blocks/quote.html +4 -0
  40. package/templates/frontend-templates/website/templates/website/blocks/text.html +2 -0
  41. package/templates/frontend-templates/website/templates/website/home.html +8 -0
  42. package/templates/frontend-templates/website/templates/website/includes/contact_form.html +21 -0
  43. package/templates/frontend-templates/website/templates/website/includes/post_card.html +9 -0
  44. package/templates/frontend-templates/website/templates/website/includes/post_list.html +11 -0
  45. package/templates/frontend-templates/website/templates/website/page.html +10 -0
  46. package/templates/frontend-templates/website/templates/website/post.html +20 -0
  47. package/templates/frontend-templates/website/templates/website/post_list.html +11 -0
  48. package/templates/frontend-templates/website/templates/website/robots.txt +5 -0
  49. package/templates/frontend-templates/website/templates/website/rss.xml +16 -0
  50. package/templates/frontend-templates/website/templates/website/sitemap.xml +8 -0
  51. package/templates/frontend-templates/website/tests/__init__.py +0 -0
  52. package/templates/frontend-templates/website/tests/test_site.py +102 -0
  53. package/templates/frontend-templates/website/urls.py +17 -0
  54. 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 frontend = await fetchStatus(site);
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: frontend !== void 0 && frontend < 500,
285
- detail: frontend ? `${site} answered ${frontend}` : `No answer on ${site}. See \`docker compose logs ${prod ? "proxy" : frontendService}\`.`
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: false
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: false
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: false
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
- /** Whether an option applies to a stack: the package manager only with a Node frontend. */
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
- cpSync(frontendTemplate, join(target, "frontend"), { recursive: true });
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
- const about = join(target, "frontend", "template.json");
1356
- const frontend = JSON.parse(readFileSync(about, "utf8"));
1357
- rmSync(about);
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, values);
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, { DATABASE_URL: normalizeDatabaseUrl(options.databaseUrl) });
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
- if (profiles.includes("db")) {
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 { envPorts as $, compose as A, NAMED_PRESETS as B, PACKAGE_NAME as C, RUN_DOMAIN as D, CAPTCHA_APP as E, projectUrls as F, decodePreset as G, PROMPT_ORDER as H, readLocalRun as I, resolvePreset as J, encodePreset as K, readProject as L, findProjectDirectory as M, parseProfiles as N, RUN_ENV as O, projectStack as P, describeMove as Q, runningServices as R, CLI_VERSION as S, generateSecret as T, STACK_OPTIONS as U, NODE_FRONTENDS as V, applies as W, DEV_PORTS as X, stackOption as Y, chooseFreePorts as Z, umamiEnv as _, LOCAL_S3 as a, isDockerDown as at, validateProjectName as b, localS3 as c, setEnvValues as ct, normalizeDatabaseUrl as d, portValues as et, relativeDirectory as f, toProjectName as g, timeZones as h, FRONTENDS as i, dockerRunning as it, composeOutput as j, activeProject as k, lockBackend as l, setNotificationsApp as m, productionEnv as n, checkTools as nt, cacheEnv as o, parseEnv as ot, scaffold as p, pendingChoices as q, validateDomain as r, dockerNotRunning as rt, frontendPackageManager as s, quoteEnvValue as st, missingS3 as t, runDoctor as tt, lockFrontend as u, updateFrontendLock as v, generateProjectSecrets as w, validateTimeZone as x, usesValkey as y, DEFAULT_STACK as z };
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.10.0",
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.",
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://github.com/dwarsbit/django-stack/tree/main/packages/cli#readme",
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-client \
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 AnalyticsExtension, AnalyticsLink, AnalyticsPage
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.tasks import TaskDashboard
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=HueyAdapter()),
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
- # 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")
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)