@django-stack/cli 0.11.0 → 0.12.1
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 +131 -60
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/{production-WThC1aS2.js → production-lgZRSpyw.js} +46 -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 removeLeftovers, d as lockFrontend, dt as setEnvValues, 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 parseEnv, 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 leftoverVolumes, t as missingS3, tt as portValues, u as lockBackend, ut as quoteEnvValue, v as umamiEnv, w as PACKAGE_NAME, x as validateProjectName, y as updateFrontendLock, z as runningServices } from "./production-lgZRSpyw.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 {
|
|
@@ -3988,6 +3978,24 @@ function checkComputer(checks = checkTools()) {
|
|
|
3988
3978
|
});
|
|
3989
3979
|
return !checks.some((check) => !check.ok && !check.warning);
|
|
3990
3980
|
}
|
|
3981
|
+
/**
|
|
3982
|
+
* Docker keeps the volumes of a deleted project. A new project with the same
|
|
3983
|
+
* name would start on the old database, whose password is not the new one in
|
|
3984
|
+
* .env: "password authentication failed for user app". Offers to remove them.
|
|
3985
|
+
*/
|
|
3986
|
+
async function handleLeftovers(name, interactive) {
|
|
3987
|
+
const volumes = leftoverVolumes(name);
|
|
3988
|
+
if (!volumes.length) return;
|
|
3989
|
+
const removeCommand = command("docker compose down --volumes");
|
|
3990
|
+
p.log.warn(`Docker still has the data of an earlier project named ${pc.bold(name)} (${volumes.join(", ")}). The new project gets a new database password, so it cannot start on that database.`);
|
|
3991
|
+
if (interactive && unwrap(await p.confirm({
|
|
3992
|
+
message: `Remove the data of the earlier ${name}? Its database and uploads are deleted for good.`,
|
|
3993
|
+
initialValue: false
|
|
3994
|
+
}))) {
|
|
3995
|
+
if (removeLeftovers(name, volumes)) p.log.success(`Removed the data of the earlier ${name}.`);
|
|
3996
|
+
else fail("Could not remove it. Stop what uses it, or choose another --name.");
|
|
3997
|
+
} else p.log.warn(`Run ${removeCommand} in the new project before you start it, or choose another --name.`);
|
|
3998
|
+
}
|
|
3991
3999
|
async function init(directoryArgument, options) {
|
|
3992
4000
|
const interactive = isInteractive(options.yes);
|
|
3993
4001
|
intro("Create a project");
|
|
@@ -4009,36 +4017,8 @@ async function init(directoryArgument, options) {
|
|
|
4009
4017
|
validate: (value) => validateProjectName(value ?? "")
|
|
4010
4018
|
}));
|
|
4011
4019
|
}
|
|
4012
|
-
|
|
4013
|
-
|
|
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;
|
|
4020
|
+
if (dockerReady) await handleLeftovers(name, interactive);
|
|
4021
|
+
const stack = await chooseStack(options, interactive);
|
|
4042
4022
|
if (!applies("analytics", stack) && stack.analytics !== "off") {
|
|
4043
4023
|
if (options.fromCli("analytics")) fail("--analytics umami needs a frontend: a headless project has no pages to count.");
|
|
4044
4024
|
stack.analytics = "off";
|
|
@@ -4077,7 +4057,7 @@ async function init(directoryArgument, options) {
|
|
|
4077
4057
|
name,
|
|
4078
4058
|
frontend: stack.frontend,
|
|
4079
4059
|
demoContent,
|
|
4080
|
-
databaseUrl,
|
|
4060
|
+
databaseUrl: options.databaseUrl,
|
|
4081
4061
|
postgres: stack.postgres,
|
|
4082
4062
|
localS3: stack.storage === "s3",
|
|
4083
4063
|
analytics: stack.analytics === "umami",
|
|
@@ -4179,7 +4159,7 @@ async function preset(code, options) {
|
|
|
4179
4159
|
p.outro(`Use it with ${command(`${selfCommand()} init --preset ${code in NAMED_PRESETS ? code : encodePreset(stack)}`)}`);
|
|
4180
4160
|
return;
|
|
4181
4161
|
}
|
|
4182
|
-
const
|
|
4162
|
+
const stack = await chooseStack(options, isInteractive(options.yes));
|
|
4183
4163
|
const encoded = encodePreset(stack);
|
|
4184
4164
|
p.outro(`Create this stack with ${command(`${selfCommand()} init --preset ${encoded}`)}`);
|
|
4185
4165
|
}
|
|
@@ -4198,7 +4178,7 @@ function validateUrl(value) {
|
|
|
4198
4178
|
if (!value) return void 0;
|
|
4199
4179
|
return /^https?:\/\/[^\s/]+/.test(value) ? void 0 : "Enter a URL that starts with https://.";
|
|
4200
4180
|
}
|
|
4201
|
-
const required = (label) => (value) => value.trim() ? void 0 : `Enter the ${label}.`;
|
|
4181
|
+
const required$1 = (label) => (value) => value.trim() ? void 0 : `Enter the ${label}.`;
|
|
4202
4182
|
/**
|
|
4203
4183
|
* An SMTP URL for EMAIL_URL: smtps:// on port 465, smtp:// on 25, otherwise
|
|
4204
4184
|
* smtp+tls:// (STARTTLS), with the user name and password URL-encoded.
|
|
@@ -4294,7 +4274,7 @@ const SETTING_GROUPS = [
|
|
|
4294
4274
|
name: "S3_BUCKET",
|
|
4295
4275
|
message: "S3 bucket",
|
|
4296
4276
|
placeholder: "example-media",
|
|
4297
|
-
validate: required("bucket")
|
|
4277
|
+
validate: required$1("bucket")
|
|
4298
4278
|
},
|
|
4299
4279
|
{
|
|
4300
4280
|
name: "S3_ENDPOINT_URL",
|
|
@@ -4307,18 +4287,18 @@ const SETTING_GROUPS = [
|
|
|
4307
4287
|
name: "S3_REGION",
|
|
4308
4288
|
message: "S3 region",
|
|
4309
4289
|
placeholder: "eu-central-1",
|
|
4310
|
-
validate: required("region")
|
|
4290
|
+
validate: required$1("region")
|
|
4311
4291
|
},
|
|
4312
4292
|
{
|
|
4313
4293
|
name: "S3_ACCESS_KEY_ID",
|
|
4314
4294
|
message: "S3 access key ID",
|
|
4315
|
-
validate: required("access key ID")
|
|
4295
|
+
validate: required$1("access key ID")
|
|
4316
4296
|
},
|
|
4317
4297
|
{
|
|
4318
4298
|
name: "S3_SECRET_ACCESS_KEY",
|
|
4319
4299
|
message: "S3 secret access key",
|
|
4320
4300
|
secret: true,
|
|
4321
|
-
validate: required("secret access key")
|
|
4301
|
+
validate: required$1("secret access key")
|
|
4322
4302
|
},
|
|
4323
4303
|
{
|
|
4324
4304
|
name: "S3_PUBLIC_URL",
|
|
@@ -4473,6 +4453,86 @@ async function settings(options) {
|
|
|
4473
4453
|
else p.outro(pc.green("Done"));
|
|
4474
4454
|
}
|
|
4475
4455
|
// src/commands/production.ts
|
|
4456
|
+
const required = (label) => (value) => value?.trim() ? void 0 : `Enter the ${label}.`;
|
|
4457
|
+
/**
|
|
4458
|
+
* Asks for the server's database: the bundled Postgres, or your own as a URL
|
|
4459
|
+
* or as separate values. Returns the URL of your own, or undefined.
|
|
4460
|
+
*/
|
|
4461
|
+
async function askDatabase() {
|
|
4462
|
+
if (unwrap(await p.select({
|
|
4463
|
+
message: "Database on the server",
|
|
4464
|
+
initialValue: "bundled",
|
|
4465
|
+
options: [{
|
|
4466
|
+
value: "bundled",
|
|
4467
|
+
label: "Bundled Postgres",
|
|
4468
|
+
hint: "a container next to the backend"
|
|
4469
|
+
}, {
|
|
4470
|
+
value: "external",
|
|
4471
|
+
label: "Your own Postgres",
|
|
4472
|
+
hint: "a managed database or another server"
|
|
4473
|
+
}]
|
|
4474
|
+
})) === "bundled") return void 0;
|
|
4475
|
+
if (unwrap(await p.select({
|
|
4476
|
+
message: "How do you want to enter it?",
|
|
4477
|
+
initialValue: "parts",
|
|
4478
|
+
options: [{
|
|
4479
|
+
value: "parts",
|
|
4480
|
+
label: "Host, port, name, user and password",
|
|
4481
|
+
hint: "any password works"
|
|
4482
|
+
}, {
|
|
4483
|
+
value: "url",
|
|
4484
|
+
label: "A postgres:// URL",
|
|
4485
|
+
hint: "special characters in the password URL-encoded"
|
|
4486
|
+
}]
|
|
4487
|
+
})) === "url") {
|
|
4488
|
+
const value = unwrap(await p.text({
|
|
4489
|
+
message: "Postgres URL",
|
|
4490
|
+
placeholder: "postgres://user:password@db.example.com:5432/app",
|
|
4491
|
+
validate: (value) => {
|
|
4492
|
+
try {
|
|
4493
|
+
normalizeDatabaseUrl((value ?? "").trim());
|
|
4494
|
+
return;
|
|
4495
|
+
} catch (error) {
|
|
4496
|
+
return error.message;
|
|
4497
|
+
}
|
|
4498
|
+
}
|
|
4499
|
+
}));
|
|
4500
|
+
return normalizeDatabaseUrl(value.trim());
|
|
4501
|
+
}
|
|
4502
|
+
const host = unwrap(await p.text({
|
|
4503
|
+
message: "Host",
|
|
4504
|
+
placeholder: "db.example.com",
|
|
4505
|
+
validate: required("host")
|
|
4506
|
+
}));
|
|
4507
|
+
const port = unwrap(await p.text({
|
|
4508
|
+
message: "Port",
|
|
4509
|
+
placeholder: "5432",
|
|
4510
|
+
defaultValue: "5432",
|
|
4511
|
+
validate: (value) => {
|
|
4512
|
+
if (!value) return void 0;
|
|
4513
|
+
const port = Number(value);
|
|
4514
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? void 0 : "Enter a port, such as 5432.";
|
|
4515
|
+
}
|
|
4516
|
+
}));
|
|
4517
|
+
const name = unwrap(await p.text({
|
|
4518
|
+
message: "Database name",
|
|
4519
|
+
placeholder: "app",
|
|
4520
|
+
validate: required("name")
|
|
4521
|
+
}));
|
|
4522
|
+
const user = unwrap(await p.text({
|
|
4523
|
+
message: "User",
|
|
4524
|
+
placeholder: "app",
|
|
4525
|
+
validate: required("user")
|
|
4526
|
+
}));
|
|
4527
|
+
const password = unwrap(await p.password({ message: "Password" }));
|
|
4528
|
+
return postgresUrl({
|
|
4529
|
+
host: host.trim(),
|
|
4530
|
+
port: port.trim(),
|
|
4531
|
+
name: name.trim(),
|
|
4532
|
+
user: user.trim(),
|
|
4533
|
+
password
|
|
4534
|
+
});
|
|
4535
|
+
}
|
|
4476
4536
|
async function production(directoryArgument, options) {
|
|
4477
4537
|
const interactive = isInteractive(options.yes);
|
|
4478
4538
|
const directory = resolve(directoryArgument ?? ".");
|
|
@@ -4490,12 +4550,21 @@ async function production(directoryArgument, options) {
|
|
|
4490
4550
|
validate: (v) => validateDomain(v ?? "")
|
|
4491
4551
|
}));
|
|
4492
4552
|
}
|
|
4553
|
+
const devEnv = readFileSync(envPath, "utf8");
|
|
4554
|
+
let databaseUrl = options.databaseUrl;
|
|
4555
|
+
if (databaseUrl) try {
|
|
4556
|
+
normalizeDatabaseUrl(databaseUrl);
|
|
4557
|
+
} catch (error) {
|
|
4558
|
+
fail(error.message);
|
|
4559
|
+
}
|
|
4560
|
+
else if (interactive && !parseEnv(devEnv).PRODUCTION_DATABASE_URL) databaseUrl = await askDatabase();
|
|
4493
4561
|
const context = projectContext(directory);
|
|
4494
4562
|
let result;
|
|
4495
4563
|
try {
|
|
4496
|
-
result = productionEnv$1(
|
|
4564
|
+
result = productionEnv$1(devEnv, {
|
|
4497
4565
|
domain,
|
|
4498
4566
|
emailUrl: options.emailUrl,
|
|
4567
|
+
databaseUrl,
|
|
4499
4568
|
media: context.media
|
|
4500
4569
|
});
|
|
4501
4570
|
} catch (error) {
|
|
@@ -5016,6 +5085,12 @@ function nodeModulesVolume(project) {
|
|
|
5016
5085
|
return;
|
|
5017
5086
|
}
|
|
5018
5087
|
}
|
|
5088
|
+
/** The backend packages `dstack update` upgrades without `--all`. */
|
|
5089
|
+
const BACKEND_PACKAGES = [
|
|
5090
|
+
"django-stack",
|
|
5091
|
+
"django-headless",
|
|
5092
|
+
"django-content-studio"
|
|
5093
|
+
];
|
|
5019
5094
|
/** `dstack update`: newer images, newer DjangoStack packages, and a restart of what was running. */
|
|
5020
5095
|
async function update(options) {
|
|
5021
5096
|
const project = open(options);
|
|
@@ -5024,16 +5099,12 @@ async function update(options) {
|
|
|
5024
5099
|
const running = targets.filter((target) => runningServices(target).length > 0);
|
|
5025
5100
|
const lock = join(project.directory, "backend", "uv.lock");
|
|
5026
5101
|
if (existsSync(lock) && has("uv")) {
|
|
5027
|
-
const args = options.all ? ["lock", "--upgrade"] : [
|
|
5028
|
-
"lock",
|
|
5029
|
-
"--upgrade-package",
|
|
5030
|
-
"django-stack"
|
|
5031
|
-
];
|
|
5102
|
+
const args = options.all ? ["lock", "--upgrade"] : ["lock", ...BACKEND_PACKAGES.flatMap((name) => ["--upgrade-package", name])];
|
|
5032
5103
|
if (spawnSync("uv", args, {
|
|
5033
5104
|
cwd: join(project.directory, "backend"),
|
|
5034
5105
|
stdio: "inherit"
|
|
5035
5106
|
}).status !== 0) fail("uv could not update backend/uv.lock.");
|
|
5036
|
-
p.log.success(options.all ? "Updated backend/uv.lock" :
|
|
5107
|
+
p.log.success(options.all ? "Updated backend/uv.lock" : `Updated ${BACKEND_PACKAGES.join(", ")} in backend/uv.lock`);
|
|
5037
5108
|
} else if (existsSync(lock)) p.log.warn("uv is not installed, so backend/uv.lock stays as it is: https://docs.astral.sh/uv/");
|
|
5038
5109
|
if (frontendPackageManager(project.directory)) {
|
|
5039
5110
|
const lockfile = updateFrontendLock(project.directory, options.all);
|
|
@@ -5379,7 +5450,7 @@ function createProgram() {
|
|
|
5379
5450
|
fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
|
|
5380
5451
|
}));
|
|
5381
5452
|
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);
|
|
5453
|
+
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
5454
|
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
5455
|
program.command("doctor").description("Check a project and its services").argument("[directory]", "The project directory", ".").action(doctor);
|
|
5385
5456
|
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, lt as parseEnv, J as pendingChoices, r as productionEnv, Y as resolvePreset, nt as runDoctor, m as scaffold, dt as setEnvValues, h as setNotificationsApp, _ as toProjectName, b as usesValkey, i as validateDomain, x as validateProjectName } from "./production-lgZRSpyw.js";
|
|
@@ -117,6 +117,40 @@ function dockerRunning(runCommand = run) {
|
|
|
117
117
|
"{{.Server.Version}}"
|
|
118
118
|
]));
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* The Docker volumes of an earlier Compose project with this name, such as
|
|
122
|
+
* `<name>_pgdata`. A new project with the same name would reuse them, and
|
|
123
|
+
* Postgres keeps the password of the data it was created with.
|
|
124
|
+
*/
|
|
125
|
+
function leftoverVolumes(name, runCommand = run) {
|
|
126
|
+
return (runCommand("docker", [
|
|
127
|
+
"volume",
|
|
128
|
+
"ls",
|
|
129
|
+
"--quiet",
|
|
130
|
+
"--filter",
|
|
131
|
+
`label=com.docker.compose.project=${name}`
|
|
132
|
+
]) ?? "").split("\n").filter(Boolean);
|
|
133
|
+
}
|
|
134
|
+
/** Removes the containers and volumes of the Compose project with this name. Returns whether it worked. */
|
|
135
|
+
function removeLeftovers(name, volumes, runCommand = run) {
|
|
136
|
+
const containers = (runCommand("docker", [
|
|
137
|
+
"ps",
|
|
138
|
+
"--all",
|
|
139
|
+
"--quiet",
|
|
140
|
+
"--filter",
|
|
141
|
+
`label=com.docker.compose.project=${name}`
|
|
142
|
+
]) ?? "").split("\n").filter(Boolean);
|
|
143
|
+
if (containers.length && runCommand("docker", [
|
|
144
|
+
"rm",
|
|
145
|
+
"--force",
|
|
146
|
+
...containers
|
|
147
|
+
]) === void 0) return false;
|
|
148
|
+
return runCommand("docker", [
|
|
149
|
+
"volume",
|
|
150
|
+
"rm",
|
|
151
|
+
...volumes
|
|
152
|
+
]) !== void 0;
|
|
153
|
+
}
|
|
120
154
|
/** Docker and Docker Compose, which run the project. */
|
|
121
155
|
function checkDocker({ run: runCommand = run, platform = process.platform } = {}) {
|
|
122
156
|
const client = parseVersion(runCommand("docker", ["--version"]));
|
|
@@ -1655,6 +1689,16 @@ function relativeDirectory(directory) {
|
|
|
1655
1689
|
return path.startsWith("..") ? directory : path;
|
|
1656
1690
|
}
|
|
1657
1691
|
// src/production.ts
|
|
1692
|
+
/**
|
|
1693
|
+
* A Postgres URL from its parts, with the user name, password and database
|
|
1694
|
+
* name URL-encoded, so a password with `@`, `:`, `/` or `#` still works.
|
|
1695
|
+
* Hosts other than this machine get `sslmode=require`.
|
|
1696
|
+
*/
|
|
1697
|
+
function postgresUrl(parts) {
|
|
1698
|
+
const host = parts.host.includes(":") && !parts.host.startsWith("[") ? `[${parts.host}]` : parts.host;
|
|
1699
|
+
const password = parts.password ? `:${encodeURIComponent(parts.password)}` : "";
|
|
1700
|
+
return normalizeDatabaseUrl(`postgres://${encodeURIComponent(parts.user)}${password}@${host}:${parts.port || "5432"}/${encodeURIComponent(parts.name)}`);
|
|
1701
|
+
}
|
|
1658
1702
|
/** The S3 settings that uploads to S3 need and that are empty. */
|
|
1659
1703
|
function missingS3(env) {
|
|
1660
1704
|
return [
|
|
@@ -1694,7 +1738,7 @@ function productionEnv(devEnv, options) {
|
|
|
1694
1738
|
ADMIN_PASSWORD: secrets.admin ?? "",
|
|
1695
1739
|
DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
|
|
1696
1740
|
};
|
|
1697
|
-
const external = options.bundledDatabase ? "" : current.PRODUCTION_DATABASE_URL ?? "";
|
|
1741
|
+
const external = options.bundledDatabase ? "" : options.databaseUrl ? normalizeDatabaseUrl(options.databaseUrl) : current.PRODUCTION_DATABASE_URL ?? "";
|
|
1698
1742
|
if (external) {
|
|
1699
1743
|
values.DATABASE_URL = external;
|
|
1700
1744
|
values.POSTGRES_PASSWORD = "";
|
|
@@ -1736,4 +1780,4 @@ function productionEnv(devEnv, options) {
|
|
|
1736
1780
|
todo
|
|
1737
1781
|
};
|
|
1738
1782
|
}
|
|
1739
|
-
export {
|
|
1783
|
+
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, removeLeftovers as ct, lockFrontend as d, setEnvValues as dt, 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, parseEnv 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, leftoverVolumes as st, missingS3 as t, portValues as tt, lockBackend as u, quoteEnvValue as ut, 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.1",
|
|
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.1",
|
|
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.1",
|
|
17
|
+
"@django-stack/react": "^0.12.1",
|
|
18
|
+
"@django-stack/utils": "^0.12.1",
|
|
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.1",
|
|
19
|
+
"@django-stack/vue": "^0.12.1",
|
|
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.1",
|
|
23
|
+
"@django-stack/utils": "^0.12.1"
|
|
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()
|