@django-stack/cli 0.11.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @django-stack/cli
2
2
 
3
- 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
+ The production-ready batteries included Django stack. Create and manage DjangoStack projects: a Django backend, a frontend, Postgres and Docker Compose in one command.
4
4
 
5
5
  ```bash
6
6
  npx @django-stack/cli init my-site
@@ -27,7 +27,7 @@ Run `dstack help <command>` for the options of a command. Each command
27
27
  checks for a newer version once a day; turn that off with
28
28
  `--no-update-check` or `DJANGO_STACK_NO_UPDATE_CHECK=1`.
29
29
 
30
- Read the [documentation](https://github.com/dwarsbit/django-stack/tree/main/website/docs) of
30
+ Read the [documentation](https://djangostack.org/) of
31
31
  [DjangoStack](https://github.com/dwarsbit/django-stack).
32
32
 
33
33
  ## License
package/dist/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as envPorts, A as compose, B as NAMED_PRESETS, C as PACKAGE_NAME, D as RUN_DOMAIN, E as CAPTCHA_APP, F as projectUrls, H as PROMPT_ORDER, I as readLocalRun, J as resolvePreset, K as encodePreset, L as readProject, M as findProjectDirectory, N as parseProfiles, O as RUN_ENV, P as projectStack, Q as describeMove, R as runningServices, S as CLI_VERSION, T as generateSecret, U as STACK_OPTIONS, V as NODE_FRONTENDS, W as applies, X as DEV_PORTS, Y as stackOption, Z as chooseFreePorts, _ as umamiEnv, a as LOCAL_S3, at as isDockerDown, b as validateProjectName, c as localS3, ct as setEnvValues, et as portValues, f as relativeDirectory, g as toProjectName, h as timeZones, it as dockerRunning, j as composeOutput, k as activeProject, l as lockBackend, m as setNotificationsApp, n as productionEnv$1, nt as checkTools, o as cacheEnv, ot as parseEnv, p as scaffold, q as pendingChoices, r as validateDomain, rt as dockerNotRunning, s as frontendPackageManager, st as quoteEnvValue, t as missingS3, tt as runDoctor, u as lockFrontend, v as updateFrontendLock, w as generateProjectSecrets, x as validateTimeZone, y as usesValkey, z as DEFAULT_STACK } from "./production-WThC1aS2.js";
2
+ import { $ as describeMove, A as activeProject, B as DEFAULT_STACK, C as CLI_VERSION, D as CAPTCHA_APP, E as generateSecret, F as projectStack, G as applies, H as NODE_FRONTENDS, I as projectUrls, J as pendingChoices, L as readLocalRun, M as composeOutput, N as findProjectDirectory, O as RUN_DOMAIN, P as parseProfiles, Q as chooseFreePorts, R as readProject, S as validateTimeZone, T as generateProjectSecrets, U as PROMPT_ORDER, V as NAMED_PRESETS, W as STACK_OPTIONS, X as stackOption, Y as resolvePreset, Z as DEV_PORTS, _ as toProjectName, at as dockerRunning, b as usesValkey, c as frontendPackageManager, ct as quoteEnvValue, d as lockFrontend, et as envPorts, f as normalizeDatabaseUrl, g as timeZones, h as setNotificationsApp, i as validateDomain, it as dockerNotRunning, j as compose, k as RUN_ENV, l as localS3, lt as setEnvValues, m as scaffold, n as postgresUrl, nt as runDoctor, o as LOCAL_S3, ot as isDockerDown, p as relativeDirectory, q as encodePreset, r as productionEnv$1, rt as checkTools, s as cacheEnv, st as parseEnv, t as missingS3, tt as portValues, u as lockBackend, v as umamiEnv, w as PACKAGE_NAME, x as validateProjectName, y as updateFrontendLock, z as runningServices } from "./production-1O8-1QUl.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { basename, delimiter, dirname, join, posix, relative, resolve, sep } from "node:path";
@@ -3929,11 +3929,9 @@ function choices(key, stack = {}) {
3929
3929
  }
3930
3930
  /**
3931
3931
  * The stack from, in order of precedence, the flags, the preset, the prompts
3932
- * and the defaults. `askDatabase` asks for the Postgres version together with
3933
- * the external database choice; it returns the version, and the URL for
3934
- * production when there is one.
3932
+ * and the defaults.
3935
3933
  */
3936
- async function chooseStack(options, interactive, askDatabase) {
3934
+ async function chooseStack(options, interactive) {
3937
3935
  let preset;
3938
3936
  if (options.preset) try {
3939
3937
  preset = resolvePreset(options.preset);
@@ -3941,18 +3939,13 @@ async function chooseStack(options, interactive, askDatabase) {
3941
3939
  fail(error.message);
3942
3940
  }
3943
3941
  const stack = { ...DEFAULT_STACK };
3944
- let databaseUrl;
3945
3942
  for (const key of PROMPT_ORDER) {
3946
3943
  const option = stackOption(key);
3947
3944
  if (options.fromCli(key)) stack[key] = options[key];
3948
3945
  else if (key === "storage" && options.fromCli("localS3") && options.localS3) stack[key] = "s3";
3949
3946
  else if (preset) stack[key] = preset[key];
3950
3947
  else if (!interactive || !applies(key, stack)) continue;
3951
- else if (key === "postgres" && askDatabase && !options.databaseUrl) {
3952
- const answer = await askDatabase(stack[key]);
3953
- if (answer.databaseUrl) databaseUrl = answer.databaseUrl;
3954
- stack[key] = answer.postgres;
3955
- } else {
3948
+ else {
3956
3949
  const initialValue = key === "storage" && stack.media === "s3" ? "s3" : stack[key];
3957
3950
  stack[key] = unwrap(await p.select({
3958
3951
  message: option.message,
@@ -3961,10 +3954,7 @@ async function chooseStack(options, interactive, askDatabase) {
3961
3954
  }));
3962
3955
  }
3963
3956
  }
3964
- return {
3965
- stack,
3966
- databaseUrl
3967
- };
3957
+ return stack;
3968
3958
  }
3969
3959
  function systemTimeZone() {
3970
3960
  try {
@@ -4009,36 +3999,7 @@ async function init(directoryArgument, options) {
4009
3999
  validate: (value) => validateProjectName(value ?? "")
4010
4000
  }));
4011
4001
  }
4012
- const chosen = await chooseStack(options, interactive, async (initial) => {
4013
- const choice = unwrap(await p.select({
4014
- message: "Database",
4015
- initialValue: initial,
4016
- options: [...choices("postgres").map((value) => ({
4017
- ...value,
4018
- label: `Bundled Postgres, ${value.label.toLowerCase()}`
4019
- })), {
4020
- value: "external",
4021
- label: "External Postgres in production",
4022
- hint: "a URL to your server; development runs the bundled one"
4023
- }]
4024
- }));
4025
- if (choice !== "external") return { postgres: choice };
4026
- const databaseUrl = unwrap(await p.text({
4027
- message: "Postgres URL for production",
4028
- placeholder: "postgres://user:password@host:5432/name",
4029
- validate: (value) => value?.startsWith("postgres") ? void 0 : "Enter a postgres:// URL."
4030
- }));
4031
- return {
4032
- postgres: unwrap(await p.select({
4033
- message: "Its Postgres version",
4034
- initialValue: initial,
4035
- options: choices("postgres")
4036
- })),
4037
- databaseUrl
4038
- };
4039
- });
4040
- const stack = chosen.stack;
4041
- const databaseUrl = options.databaseUrl ?? chosen.databaseUrl;
4002
+ const stack = await chooseStack(options, interactive);
4042
4003
  if (!applies("analytics", stack) && stack.analytics !== "off") {
4043
4004
  if (options.fromCli("analytics")) fail("--analytics umami needs a frontend: a headless project has no pages to count.");
4044
4005
  stack.analytics = "off";
@@ -4077,7 +4038,7 @@ async function init(directoryArgument, options) {
4077
4038
  name,
4078
4039
  frontend: stack.frontend,
4079
4040
  demoContent,
4080
- databaseUrl,
4041
+ databaseUrl: options.databaseUrl,
4081
4042
  postgres: stack.postgres,
4082
4043
  localS3: stack.storage === "s3",
4083
4044
  analytics: stack.analytics === "umami",
@@ -4179,7 +4140,7 @@ async function preset(code, options) {
4179
4140
  p.outro(`Use it with ${command(`${selfCommand()} init --preset ${code in NAMED_PRESETS ? code : encodePreset(stack)}`)}`);
4180
4141
  return;
4181
4142
  }
4182
- const { stack } = await chooseStack(options, isInteractive(options.yes));
4143
+ const stack = await chooseStack(options, isInteractive(options.yes));
4183
4144
  const encoded = encodePreset(stack);
4184
4145
  p.outro(`Create this stack with ${command(`${selfCommand()} init --preset ${encoded}`)}`);
4185
4146
  }
@@ -4198,7 +4159,7 @@ function validateUrl(value) {
4198
4159
  if (!value) return void 0;
4199
4160
  return /^https?:\/\/[^\s/]+/.test(value) ? void 0 : "Enter a URL that starts with https://.";
4200
4161
  }
4201
- const required = (label) => (value) => value.trim() ? void 0 : `Enter the ${label}.`;
4162
+ const required$1 = (label) => (value) => value.trim() ? void 0 : `Enter the ${label}.`;
4202
4163
  /**
4203
4164
  * An SMTP URL for EMAIL_URL: smtps:// on port 465, smtp:// on 25, otherwise
4204
4165
  * smtp+tls:// (STARTTLS), with the user name and password URL-encoded.
@@ -4294,7 +4255,7 @@ const SETTING_GROUPS = [
4294
4255
  name: "S3_BUCKET",
4295
4256
  message: "S3 bucket",
4296
4257
  placeholder: "example-media",
4297
- validate: required("bucket")
4258
+ validate: required$1("bucket")
4298
4259
  },
4299
4260
  {
4300
4261
  name: "S3_ENDPOINT_URL",
@@ -4307,18 +4268,18 @@ const SETTING_GROUPS = [
4307
4268
  name: "S3_REGION",
4308
4269
  message: "S3 region",
4309
4270
  placeholder: "eu-central-1",
4310
- validate: required("region")
4271
+ validate: required$1("region")
4311
4272
  },
4312
4273
  {
4313
4274
  name: "S3_ACCESS_KEY_ID",
4314
4275
  message: "S3 access key ID",
4315
- validate: required("access key ID")
4276
+ validate: required$1("access key ID")
4316
4277
  },
4317
4278
  {
4318
4279
  name: "S3_SECRET_ACCESS_KEY",
4319
4280
  message: "S3 secret access key",
4320
4281
  secret: true,
4321
- validate: required("secret access key")
4282
+ validate: required$1("secret access key")
4322
4283
  },
4323
4284
  {
4324
4285
  name: "S3_PUBLIC_URL",
@@ -4473,6 +4434,86 @@ async function settings(options) {
4473
4434
  else p.outro(pc.green("Done"));
4474
4435
  }
4475
4436
  // src/commands/production.ts
4437
+ const required = (label) => (value) => value?.trim() ? void 0 : `Enter the ${label}.`;
4438
+ /**
4439
+ * Asks for the server's database: the bundled Postgres, or your own as a URL
4440
+ * or as separate values. Returns the URL of your own, or undefined.
4441
+ */
4442
+ async function askDatabase() {
4443
+ if (unwrap(await p.select({
4444
+ message: "Database on the server",
4445
+ initialValue: "bundled",
4446
+ options: [{
4447
+ value: "bundled",
4448
+ label: "Bundled Postgres",
4449
+ hint: "a container next to the backend"
4450
+ }, {
4451
+ value: "external",
4452
+ label: "Your own Postgres",
4453
+ hint: "a managed database or another server"
4454
+ }]
4455
+ })) === "bundled") return void 0;
4456
+ if (unwrap(await p.select({
4457
+ message: "How do you want to enter it?",
4458
+ initialValue: "parts",
4459
+ options: [{
4460
+ value: "parts",
4461
+ label: "Host, port, name, user and password",
4462
+ hint: "any password works"
4463
+ }, {
4464
+ value: "url",
4465
+ label: "A postgres:// URL",
4466
+ hint: "special characters in the password URL-encoded"
4467
+ }]
4468
+ })) === "url") {
4469
+ const value = unwrap(await p.text({
4470
+ message: "Postgres URL",
4471
+ placeholder: "postgres://user:password@db.example.com:5432/app",
4472
+ validate: (value) => {
4473
+ try {
4474
+ normalizeDatabaseUrl((value ?? "").trim());
4475
+ return;
4476
+ } catch (error) {
4477
+ return error.message;
4478
+ }
4479
+ }
4480
+ }));
4481
+ return normalizeDatabaseUrl(value.trim());
4482
+ }
4483
+ const host = unwrap(await p.text({
4484
+ message: "Host",
4485
+ placeholder: "db.example.com",
4486
+ validate: required("host")
4487
+ }));
4488
+ const port = unwrap(await p.text({
4489
+ message: "Port",
4490
+ placeholder: "5432",
4491
+ defaultValue: "5432",
4492
+ validate: (value) => {
4493
+ if (!value) return void 0;
4494
+ const port = Number(value);
4495
+ return Number.isInteger(port) && port > 0 && port < 65536 ? void 0 : "Enter a port, such as 5432.";
4496
+ }
4497
+ }));
4498
+ const name = unwrap(await p.text({
4499
+ message: "Database name",
4500
+ placeholder: "app",
4501
+ validate: required("name")
4502
+ }));
4503
+ const user = unwrap(await p.text({
4504
+ message: "User",
4505
+ placeholder: "app",
4506
+ validate: required("user")
4507
+ }));
4508
+ const password = unwrap(await p.password({ message: "Password" }));
4509
+ return postgresUrl({
4510
+ host: host.trim(),
4511
+ port: port.trim(),
4512
+ name: name.trim(),
4513
+ user: user.trim(),
4514
+ password
4515
+ });
4516
+ }
4476
4517
  async function production(directoryArgument, options) {
4477
4518
  const interactive = isInteractive(options.yes);
4478
4519
  const directory = resolve(directoryArgument ?? ".");
@@ -4490,12 +4531,21 @@ async function production(directoryArgument, options) {
4490
4531
  validate: (v) => validateDomain(v ?? "")
4491
4532
  }));
4492
4533
  }
4534
+ const devEnv = readFileSync(envPath, "utf8");
4535
+ let databaseUrl = options.databaseUrl;
4536
+ if (databaseUrl) try {
4537
+ normalizeDatabaseUrl(databaseUrl);
4538
+ } catch (error) {
4539
+ fail(error.message);
4540
+ }
4541
+ else if (interactive && !parseEnv(devEnv).PRODUCTION_DATABASE_URL) databaseUrl = await askDatabase();
4493
4542
  const context = projectContext(directory);
4494
4543
  let result;
4495
4544
  try {
4496
- result = productionEnv$1(readFileSync(envPath, "utf8"), {
4545
+ result = productionEnv$1(devEnv, {
4497
4546
  domain,
4498
4547
  emailUrl: options.emailUrl,
4548
+ databaseUrl,
4499
4549
  media: context.media
4500
4550
  });
4501
4551
  } catch (error) {
@@ -5379,7 +5429,7 @@ function createProgram() {
5379
5429
  fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
5380
5430
  }));
5381
5431
  for (const flag of stackFlags()) presetCommand.addOption(flag);
5382
- program.command("production").description("Write .env.production for a server").argument("[directory]", "The project directory", ".").option("--domain <domain>", "The domain of the site, such as example.com").option("--email-url <url>", "The SMTP server, such as smtp+tls://user:pass@host:587").option("--force", "Overwrite an existing .env.production").option("-y, --yes", "Don't ask; fail when --domain is missing").action(production);
5432
+ program.command("production").description("Write .env.production for a server").argument("[directory]", "The project directory", ".").option("--domain <domain>", "The domain of the site, such as example.com").option("--email-url <url>", "The SMTP server, such as smtp+tls://user:pass@host:587").option("--database-url <url>", "Your own Postgres for the server instead of the bundled one").option("--force", "Overwrite an existing .env.production").option("-y, --yes", "Don't ask; fail when --domain is missing").action(production);
5383
5433
  program.command("settings").description("Ask for the missing production settings and credentials, and save them").addOption(dirOption()).option("--file <file>", "The file to change (default: .env.production, or .env on a server)").option("--set <NAME=value>", "Set a value without asking, such as --set ADMIN_EMAIL=you@example.com", (value, previous = []) => [...previous, value]).option("--all", "Ask for every setting, also the ones that are set").option("-y, --yes", "Don't ask; fail when a setting is still missing").action(settings);
5384
5434
  program.command("doctor").description("Check a project and its services").argument("[directory]", "The project directory", ".").action(doctor);
5385
5435
  program.command("self-update").description(`Update ${BIN} to the latest version on npm`).action(selfUpdate);
package/dist/index.d.ts CHANGED
@@ -22,6 +22,8 @@ interface ProductionOptions {
22
22
  media?: string;
23
23
  /** Keep the bundled database even with PRODUCTION_DATABASE_URL: the local run on this machine. */
24
24
  bundledDatabase?: boolean;
25
+ /** An external Postgres for the server; it takes the place of PRODUCTION_DATABASE_URL. */
26
+ databaseUrl?: string;
25
27
  }
26
28
  interface ProductionEnv {
27
29
  text: string;
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { z as DEFAULT_STACK, i as FRONTENDS, B as NAMED_PRESETS, U as STACK_OPTIONS, S as VERSION, o as cacheEnv, G as decodePreset, K as encodePreset, w as generateProjectSecrets, T as generateSecret, d as normalizeDatabaseUrl, ot as parseEnv, q as pendingChoices, n as productionEnv, J as resolvePreset, tt as runDoctor, p as scaffold, ct as setEnvValues, m as setNotificationsApp, g as toProjectName, y as usesValkey, r as validateDomain, b as validateProjectName } from "./production-WThC1aS2.js";
1
+ export { B as DEFAULT_STACK, a as FRONTENDS, V as NAMED_PRESETS, W as STACK_OPTIONS, C as VERSION, s as cacheEnv, K as decodePreset, q as encodePreset, T as generateProjectSecrets, E as generateSecret, f as normalizeDatabaseUrl, st as parseEnv, J as pendingChoices, r as productionEnv, Y as resolvePreset, nt as runDoctor, m as scaffold, lt as setEnvValues, h as setNotificationsApp, _ as toProjectName, b as usesValkey, i as validateDomain, x as validateProjectName } from "./production-1O8-1QUl.js";
@@ -1655,6 +1655,16 @@ function relativeDirectory(directory) {
1655
1655
  return path.startsWith("..") ? directory : path;
1656
1656
  }
1657
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
+ }
1658
1668
  /** The S3 settings that uploads to S3 need and that are empty. */
1659
1669
  function missingS3(env) {
1660
1670
  return [
@@ -1694,7 +1704,7 @@ function productionEnv(devEnv, options) {
1694
1704
  ADMIN_PASSWORD: secrets.admin ?? "",
1695
1705
  DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
1696
1706
  };
1697
- const external = options.bundledDatabase ? "" : current.PRODUCTION_DATABASE_URL ?? "";
1707
+ const external = options.bundledDatabase ? "" : options.databaseUrl ? normalizeDatabaseUrl(options.databaseUrl) : current.PRODUCTION_DATABASE_URL ?? "";
1698
1708
  if (external) {
1699
1709
  values.DATABASE_URL = external;
1700
1710
  values.POSTGRES_PASSWORD = "";
@@ -1736,4 +1746,4 @@ function productionEnv(devEnv, options) {
1736
1746
  todo
1737
1747
  };
1738
1748
  }
1739
- 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.11.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",
@@ -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()
@@ -124,7 +124,15 @@ class RedirectStudio(ModelStudio):
124
124
 
125
125
  @register(FormSubmission)
126
126
  class FormSubmissionStudio(ModelStudio):
127
+ """Submissions come from the site's form: editors read and delete them, never create or edit."""
128
+
127
129
  icon = "ph-bold ph-envelope"
128
130
  table_display = ["name", "email", "created_at"]
129
131
  search_fields = ["name", "email", "message"]
130
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)
@@ -0,0 +1,77 @@
1
+ """
2
+ The widgets of the admin dashboard (config/studio.py) answer with the
3
+ seeded content, and only to editors who may view it.
4
+ """
5
+
6
+ import pytest
7
+ from content_studio.settings import cs_settings
8
+ from django.contrib.auth import get_user_model
9
+ from django.contrib.auth.models import Permission
10
+ from django.test import RequestFactory
11
+ from rest_framework.exceptions import PermissionDenied
12
+
13
+ from content.dashboard import PublishedCountWidget, RecentSubmissionsWidget, SubmissionCountWidget
14
+ from content.models import Page
15
+
16
+ pytestmark = pytest.mark.django_db
17
+
18
+
19
+ @pytest.fixture
20
+ def seeded(settings, tmp_path):
21
+ from content.seed import seed
22
+
23
+ settings.MEDIA_ROOT = tmp_path
24
+ seed()
25
+
26
+
27
+ def request_as(user):
28
+ request = RequestFactory().get("/")
29
+ request.user = user
30
+ return request
31
+
32
+
33
+ @pytest.fixture
34
+ def admin():
35
+ return get_user_model().objects.create_superuser("admin", "admin@example.com", "password")
36
+
37
+
38
+ def widget(cls):
39
+ return next(w for w in cs_settings.ADMIN_SITE.dashboard.widgets if isinstance(w, cls))
40
+
41
+
42
+ def test_every_widget_answers_on_an_empty_site(admin):
43
+ for item in cs_settings.ADMIN_SITE.dashboard.widgets:
44
+ assert item.get_data(request_as(admin)) is not None
45
+
46
+
47
+ def test_no_submissions_yet(admin):
48
+ data = widget(RecentSubmissionsWidget).get_data(request_as(admin))
49
+ assert data["content"] == []
50
+ assert data["description"].startswith("No messages yet.")
51
+ assert data["view_all"] == "/content/content.formsubmission"
52
+
53
+
54
+ def test_every_widget_answers(seeded, admin):
55
+ for item in cs_settings.ADMIN_SITE.dashboard.widgets:
56
+ assert item.get_data(request_as(admin)) is not None
57
+
58
+
59
+ def test_counts_published_pages(seeded, admin):
60
+ data = widget(PublishedCountWidget).get_data(request_as(admin))
61
+ assert data["value"] == Page.objects.published().count()
62
+ assert data["footnote"] == "1 draft"
63
+
64
+
65
+ def test_submissions(seeded, admin):
66
+ assert widget(SubmissionCountWidget).get_data(request_as(admin))["value"] == 2
67
+ rows = widget(RecentSubmissionsWidget).get_data(request_as(admin))["content"]
68
+ assert [row["title"] for row in rows] == ["Alan Visitor", "Grace Reader"]
69
+
70
+
71
+ def test_submissions_need_the_view_permission(seeded):
72
+ editor = get_user_model().objects.create_user("editor", "editor@example.com", "password", is_staff=True)
73
+ with pytest.raises(PermissionDenied):
74
+ widget(RecentSubmissionsWidget).get_data(request_as(editor))
75
+ editor.user_permissions.add(Permission.objects.get(codename="view_formsubmission"))
76
+ editor = get_user_model().objects.get(pk=editor.pk)
77
+ assert len(widget(RecentSubmissionsWidget).get_data(request_as(editor))["content"]) == 2
@@ -5,7 +5,7 @@ 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.11.0",
8
+ "django-stack[server,s3]>=0.12.0",
9
9
  "django-headless[schema]>=1.1.1",
10
10
  "django-content-studio[images,huey]>=1.11.0",
11
11
  ]
@@ -90,7 +90,7 @@ commands.
90
90
  With the `dstack` command installed (`npm i -g @django-stack/cli`),
91
91
  `dstack dev`, `dstack check`, `dstack manage <command>` and `dstack types` do
92
92
  the same from any directory in the project. See the
93
- [CLI reference](https://dwarsbit.github.io/django-stack/docs/cli).
93
+ [CLI reference](https://djangostack.org/docs/cli).
94
94
 
95
95
  ## Preview and publishing
96
96
 
@@ -106,7 +106,7 @@ empties its page cache, so the change shows on the next visit.
106
106
  The button opens `/preview` on `SITE_URL`, which your frontend serves; set
107
107
  `REVALIDATE_URL` to the endpoint where your frontend empties its page cache,
108
108
  and Django calls it when an editor saves. See
109
- [Preview and revalidation](https://dwarsbit.github.io/django-stack/docs/preview-revalidation).
109
+ [Preview and revalidation](https://djangostack.org/docs/preview-revalidation).
110
110
  <!-- @end -->
111
111
  <!-- @if templates -->
112
112
  The site keeps each page in Django's cache, and empties it when an editor
@@ -122,7 +122,7 @@ counts page views without cookies, and the admin shows them under
122
122
  **Analytics**. Umami's own interface is not served: only its tracker and the
123
123
  page views pass through the site at `/_a/`. To turn it off, remove
124
124
  `analytics` from `COMPOSE_PROFILES` and empty `UMAMI_URL`. See
125
- [Page analytics](https://dwarsbit.github.io/django-stack/docs/analytics).
125
+ [Page analytics](https://djangostack.org/docs/analytics).
126
126
  <!-- @end -->
127
127
 
128
128
  ## Deploy
@@ -138,7 +138,7 @@ docker compose up -d --build
138
138
  The `prod` profile runs production images behind Caddy, which gets the HTTPS
139
139
  certificate, and backs up the database every day. Media can go to S3 and the
140
140
  database to a managed Postgres. `.github/workflows/deploy.yml` deploys from
141
- GitHub Actions. The [deployment guide](https://dwarsbit.github.io/django-stack/docs/production/server)
141
+ GitHub Actions. The [deployment guide](https://djangostack.org/docs/production/server)
142
142
  has every step.
143
143
 
144
144
  | Task | Command (on the server) |
@@ -16,7 +16,7 @@
16
16
  "@astrojs/node": "^11.1.9",
17
17
  "@astrojs/rss": "^4.0.19",
18
18
  "@django-headless/client": "^1.1.1",
19
- "@django-stack/utils": "^0.11.0",
19
+ "@django-stack/utils": "^0.12.0",
20
20
  "astro": "^7.4.5"
21
21
  },
22
22
  "devDependencies": {
@@ -13,9 +13,9 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@django-headless/client": "^1.1.1",
16
- "@django-stack/next": "^0.11.0",
17
- "@django-stack/react": "^0.11.0",
18
- "@django-stack/utils": "^0.11.0",
16
+ "@django-stack/next": "^0.12.0",
17
+ "@django-stack/react": "^0.12.0",
18
+ "@django-stack/utils": "^0.12.0",
19
19
  "next": "^16.4.4",
20
20
  "react": "^19.3.1",
21
21
  "react-dom": "^19.3.1",
@@ -15,8 +15,8 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@django-headless/client": "^1.1.1",
18
- "@django-stack/utils": "^0.11.0",
19
- "@django-stack/vue": "^0.11.0",
18
+ "@django-stack/utils": "^0.12.0",
19
+ "@django-stack/vue": "^0.12.0",
20
20
  "nuxt": "^4.6.3",
21
21
  "nuxt-security": "^2.7.0",
22
22
  "vue": "^3.5.44",
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@django-headless/client": "^1.1.1",
22
- "@django-stack/svelte": "^0.11.0",
23
- "@django-stack/utils": "^0.11.0"
22
+ "@django-stack/svelte": "^0.12.0",
23
+ "@django-stack/utils": "^0.12.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@sveltejs/adapter-node": "^6.0.1",
@@ -99,4 +99,4 @@ def test_contact_form(client, seeded):
99
99
  def test_contact_form_needs_a_captcha(client, seeded):
100
100
  response = client.post("/contact", {"name": "Ada", "email": "ada@example.com", "message": "Hello"})
101
101
  assert response.status_code == 400
102
- assert not FormSubmission.objects.exists()
102
+ assert not FormSubmission.objects.filter(email="ada@example.com").exists()