@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 +2 -2
- package/dist/bin.js +104 -54
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/{production-WThC1aS2.js → production-1O8-1QUl.js} +12 -2
- package/package.json +3 -3
- package/templates/backend/config/studio.py +35 -3
- package/templates/backend/content/admin.py +8 -0
- 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/README.md +4 -4
- 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/website/tests/test_site.py +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @django-stack/cli
|
|
2
2
|
|
|
3
|
-
The production-ready
|
|
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://
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 {
|
|
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 {
|
|
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",
|
|
@@ -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()
|
|
@@ -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.
|
|
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
|
]
|
package/templates/base/README.md
CHANGED
|
@@ -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://
|
|
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://
|
|
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://
|
|
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://
|
|
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) |
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@django-headless/client": "^1.1.1",
|
|
16
|
-
"@django-stack/next": "^0.
|
|
17
|
-
"@django-stack/react": "^0.
|
|
18
|
-
"@django-stack/utils": "^0.
|
|
16
|
+
"@django-stack/next": "^0.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.
|
|
19
|
-
"@django-stack/vue": "^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.
|
|
23
|
-
"@django-stack/utils": "^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()
|