stapel-tools 0.3.1__tar.gz

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.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-tools
3
+ Version: 0.3.1
4
+ Summary: CLI scaffold and linting tools for Stapel/Django microservices
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+
8
+ # stapel-tools
9
+
10
+ CLI scaffold and linting tools for Stapel/Django projects.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install git+https://github.com/usestapel/stapel-tools.git
16
+ ```
17
+
18
+ Or as a dev dependency in your project:
19
+
20
+ ```bash
21
+ pip install -e path/to/stapel-tools
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ ### `stapel-create-project` — interactive project wizard
27
+
28
+ ```bash
29
+ stapel-create-project # full wizard
30
+ stapel-create-project my-app --type monolith # skip some wizard steps
31
+ stapel-create-project my-app \
32
+ --type monolith \
33
+ --title "My App" \
34
+ --url https://myapp.com \
35
+ --company-name "ACME" \
36
+ --company-email hello@myapp.com \
37
+ --modules auth billing # fully non-interactive
38
+ ```
39
+
40
+ Project types: `monolith` (recommended), `microservices`, `minimal` (no Docker, SQLite).
41
+
42
+ ### `stapel-new-service` — add a service to an existing project
43
+
44
+ ```bash
45
+ stapel-new-service auth
46
+ stapel-new-service auth --title "Auth Service" --prefix iron-
47
+ stapel-new-service blog --celery
48
+ stapel-new-service blog --dry-run
49
+ ```
50
+
51
+ ### `stapel-new-library` — scaffold a standalone stapel-* package repo
52
+
53
+ For contributing a new reusable package to the framework (or building your
54
+ own to the same standard). Materializes the Stapel library standard: flat
55
+ layout, `STAPEL_<NAME>` settings namespace, comm surface with JSON schemas,
56
+ serializer seams, MODULE.md, community files, CI with the codecov
57
+ ratchet/floor policy, ruff git hooks. The generated repo's own test suite
58
+ is green out of the box.
59
+
60
+ ```bash
61
+ stapel-new-library search # L2 service module
62
+ stapel-new-library attributes --kind library # L1 importable lib
63
+ stapel-new-library support-chat --title "Support chat" --dir ~/Projects
64
+ ```
65
+
66
+ Kinds: `module` (default — Django app with models/views/comm surface;
67
+ modules never import each other) and `library` (importable package without
68
+ service identity, like stapel-attributes).
69
+
70
+ ### `stapel-new-module` — add a Django app to a service
71
+
72
+ ```bash
73
+ cd svc-auth/
74
+ stapel-new-module users
75
+ stapel-new-module billing --title "Billing Plans"
76
+ ```
77
+
78
+ ### `stapel-remove-service` — remove a service
79
+
80
+ ```bash
81
+ stapel-remove-service auth
82
+ stapel-remove-service auth --prefix iron- --yes
83
+ stapel-remove-service auth --dry-run
84
+ ```
85
+
86
+ ### `stapel-lint` — project-specific static linter
87
+
88
+ ```bash
89
+ stapel-lint # scan current directory
90
+ stapel-lint svc-auth/ # scan specific service
91
+ stapel-lint --stats # show per-rule counts
92
+ stapel-lint --ignore R002 # skip a rule
93
+ ```
94
+
95
+ Rules: R001 bare `Response()`, R002 `serializers.ValidationError`, R003 undocumented `@action`,
96
+ R004 `@dataclass` without docstring, R005 hardcoded error string, R006 `StapelResponse(dict)`.
97
+
98
+ Suppress per-line: `# noqa: R001`
99
+
100
+ ## Available modules
101
+
102
+ | Module | Description |
103
+ |--------|-------------|
104
+ | `core` | Core framework (always included) |
105
+ | `auth` | Authentication — JWT, OAuth, OTP |
106
+ | `billing` | Billing & subscriptions |
107
+ | `cdn` | File uploads & CDN |
108
+ | `notifications` | Email / push notifications |
109
+ | `profiles` | User profiles |
110
+ | `translate` | Translations & i18n |
111
+ | `workspaces` | Workspaces & multi-tenancy |
112
+ | `gdpr` | GDPR — data export & deletion |
@@ -0,0 +1,105 @@
1
+ # stapel-tools
2
+
3
+ CLI scaffold and linting tools for Stapel/Django projects.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install git+https://github.com/usestapel/stapel-tools.git
9
+ ```
10
+
11
+ Or as a dev dependency in your project:
12
+
13
+ ```bash
14
+ pip install -e path/to/stapel-tools
15
+ ```
16
+
17
+ ## Commands
18
+
19
+ ### `stapel-create-project` — interactive project wizard
20
+
21
+ ```bash
22
+ stapel-create-project # full wizard
23
+ stapel-create-project my-app --type monolith # skip some wizard steps
24
+ stapel-create-project my-app \
25
+ --type monolith \
26
+ --title "My App" \
27
+ --url https://myapp.com \
28
+ --company-name "ACME" \
29
+ --company-email hello@myapp.com \
30
+ --modules auth billing # fully non-interactive
31
+ ```
32
+
33
+ Project types: `monolith` (recommended), `microservices`, `minimal` (no Docker, SQLite).
34
+
35
+ ### `stapel-new-service` — add a service to an existing project
36
+
37
+ ```bash
38
+ stapel-new-service auth
39
+ stapel-new-service auth --title "Auth Service" --prefix iron-
40
+ stapel-new-service blog --celery
41
+ stapel-new-service blog --dry-run
42
+ ```
43
+
44
+ ### `stapel-new-library` — scaffold a standalone stapel-* package repo
45
+
46
+ For contributing a new reusable package to the framework (or building your
47
+ own to the same standard). Materializes the Stapel library standard: flat
48
+ layout, `STAPEL_<NAME>` settings namespace, comm surface with JSON schemas,
49
+ serializer seams, MODULE.md, community files, CI with the codecov
50
+ ratchet/floor policy, ruff git hooks. The generated repo's own test suite
51
+ is green out of the box.
52
+
53
+ ```bash
54
+ stapel-new-library search # L2 service module
55
+ stapel-new-library attributes --kind library # L1 importable lib
56
+ stapel-new-library support-chat --title "Support chat" --dir ~/Projects
57
+ ```
58
+
59
+ Kinds: `module` (default — Django app with models/views/comm surface;
60
+ modules never import each other) and `library` (importable package without
61
+ service identity, like stapel-attributes).
62
+
63
+ ### `stapel-new-module` — add a Django app to a service
64
+
65
+ ```bash
66
+ cd svc-auth/
67
+ stapel-new-module users
68
+ stapel-new-module billing --title "Billing Plans"
69
+ ```
70
+
71
+ ### `stapel-remove-service` — remove a service
72
+
73
+ ```bash
74
+ stapel-remove-service auth
75
+ stapel-remove-service auth --prefix iron- --yes
76
+ stapel-remove-service auth --dry-run
77
+ ```
78
+
79
+ ### `stapel-lint` — project-specific static linter
80
+
81
+ ```bash
82
+ stapel-lint # scan current directory
83
+ stapel-lint svc-auth/ # scan specific service
84
+ stapel-lint --stats # show per-rule counts
85
+ stapel-lint --ignore R002 # skip a rule
86
+ ```
87
+
88
+ Rules: R001 bare `Response()`, R002 `serializers.ValidationError`, R003 undocumented `@action`,
89
+ R004 `@dataclass` without docstring, R005 hardcoded error string, R006 `StapelResponse(dict)`.
90
+
91
+ Suppress per-line: `# noqa: R001`
92
+
93
+ ## Available modules
94
+
95
+ | Module | Description |
96
+ |--------|-------------|
97
+ | `core` | Core framework (always included) |
98
+ | `auth` | Authentication — JWT, OAuth, OTP |
99
+ | `billing` | Billing & subscriptions |
100
+ | `cdn` | File uploads & CDN |
101
+ | `notifications` | Email / push notifications |
102
+ | `profiles` | User profiles |
103
+ | `translate` | Translations & i18n |
104
+ | `workspaces` | Workspaces & multi-tenancy |
105
+ | `gdpr` | GDPR — data export & deletion |
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stapel-tools"
7
+ version = "0.3.1"
8
+ description = "CLI scaffold and linting tools for Stapel/Django microservices"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = []
12
+
13
+ [project.scripts]
14
+ stapel-lint = "stapel_tools.lint:main"
15
+ stapel-new-service = "stapel_tools.new_service:main"
16
+ stapel-new-module = "stapel_tools.new_module:main"
17
+ stapel-new-library = "stapel_tools.new_library:main"
18
+ stapel-remove-service = "stapel_tools.remove_service:main"
19
+ stapel-create-project = "stapel_tools.create_project:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["."]
23
+ include = ["stapel_tools*"]
24
+
25
+ [tool.ruff]
26
+ line-length = 100
27
+ target-version = "py311"
28
+
29
+ [tool.ruff.lint]
30
+ select = ["E", "F", "W", "I"]
31
+ ignore = ["E501"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,373 @@
1
+ """Docker Compose templates for project scaffolding."""
2
+
3
+ # Runs on EVERY postgres startup (via the db service's command wrapper),
4
+ # creating any database listed in POSTGRES_MULTIPLE_DATABASES that does not
5
+ # exist yet. Running at every startup — not just first initdb — means adding
6
+ # a service later creates its database without wiping the data volume.
7
+ POSTGRES_ENSURE_DATABASES = """\
8
+ #!/bin/sh
9
+ set -eu
10
+
11
+ ensure_database_exists() {
12
+ database=$1
13
+ if psql -v ON_ERROR_STOP=1 -d postgres --username "$POSTGRES_USER" -lqt \\
14
+ | cut -d '|' -f 1 | grep -qw "$database"; then
15
+ echo " database '$database' exists"
16
+ else
17
+ echo " creating database '$database'"
18
+ psql -v ON_ERROR_STOP=1 -d postgres --username "$POSTGRES_USER" <<-EOSQL
19
+ CREATE DATABASE $database;
20
+ GRANT ALL PRIVILEGES ON DATABASE $database TO $POSTGRES_USER;
21
+ EOSQL
22
+ fi
23
+ }
24
+
25
+ if [ -n "${POSTGRES_MULTIPLE_DATABASES:-}" ]; then
26
+ echo "Ensuring databases exist: $POSTGRES_MULTIPLE_DATABASES"
27
+ for db in $(echo "$POSTGRES_MULTIPLE_DATABASES" | tr ',' ' '); do
28
+ db=$(echo "$db" | xargs)
29
+ [ -n "$db" ] && ensure_database_exists "$db"
30
+ done
31
+ fi
32
+ """
33
+
34
+ # Mounted at /etc/nginx/conf.d/nginx.conf. stapel-new-service appends a
35
+ # location block per service before the closing brace.
36
+ NGINX_CONF = """\
37
+ server {
38
+ listen 80;
39
+ server_name _;
40
+ client_max_body_size 50m;
41
+ resolver 127.0.0.11 valid=10s;
42
+
43
+ location /staticfiles/ {
44
+ alias /staticfiles/;
45
+ }
46
+
47
+ location /media/ {
48
+ alias /media/;
49
+ }
50
+ }
51
+ """
52
+
53
+
54
+ # ─── Broker building blocks ─────────────────────────────────────────────────
55
+ # Compose bases carry {{BROKER_SERVICES}} / {{BROKER_VOLUMES}} markers; the
56
+ # generator splices the chosen broker(s) in. Env templates carry
57
+ # {{BROKER_ENV}}. A dedicated Task broker (--task-broker) adds its blocks
58
+ # next to the event broker's.
59
+
60
+ NATS_SERVICE_BLOCK = """\
61
+ # Events (JetStream) + RPC (request-reply) for stapel_core.comm
62
+ nats:
63
+ image: nats:2.10-alpine
64
+ restart: unless-stopped
65
+ command: ["--jetstream", "--store_dir", "/data"]
66
+ volumes:
67
+ - nats-data:/data
68
+
69
+ """
70
+
71
+ KAFKA_SERVICE_BLOCK = """\
72
+ kafka:
73
+ image: apache/kafka:3.9.0
74
+ restart: unless-stopped
75
+ environment:
76
+ KAFKA_NODE_ID: 1
77
+ KAFKA_PROCESS_ROLES: broker,controller
78
+ KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
79
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
80
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
81
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
82
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
83
+ KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
84
+ volumes:
85
+ - kafka-data:/var/lib/kafka/data
86
+
87
+ """
88
+
89
+ NATS_ENV_BLOCK = """\
90
+ # ─── NATS: events (JetStream) + RPC ─────────────────────────────────────────
91
+ STAPEL_BUS_BACKEND=nats
92
+ NATS_URL=nats://nats:4222
93
+ """
94
+
95
+ KAFKA_ENV_BLOCK = """\
96
+ # ─── Kafka: events ──────────────────────────────────────────────────────────
97
+ STAPEL_BUS_BACKEND=kafka
98
+ KAFKA_BOOTSTRAP_SERVERS=kafka:9092
99
+ """
100
+
101
+ # Task broker only (monolith --task-broker nats): Actions stay in-process;
102
+ # STAPEL_TASK_DISPATCH=bus makes stapel_core publish task.* events through
103
+ # the broker to a dedicated worker (STAPEL_COMM["TASK_DISPATCH"]).
104
+ TASK_ONLY_NATS_ENV_BLOCK = """\
105
+ # ─── NATS: broker for long-running Tasks only (Actions stay in-process) ────
106
+ STAPEL_BUS_BACKEND=nats
107
+ NATS_URL=nats://nats:4222
108
+ STAPEL_TASK_DISPATCH=bus
109
+ """
110
+
111
+ _BROKER_SERVICES = {"nats": NATS_SERVICE_BLOCK, "kafka": KAFKA_SERVICE_BLOCK, "none": ""}
112
+ _BROKER_VOLUMES = {"nats": " nats-data:\n", "kafka": " kafka-data:\n", "none": ""}
113
+ _BROKER_ENV = {"nats": NATS_ENV_BLOCK, "kafka": KAFKA_ENV_BLOCK, "none": ""}
114
+ _BROKER_URL_LINES = {
115
+ "nats": "NATS_URL=nats://nats:4222\n",
116
+ "kafka": "KAFKA_BOOTSTRAP_SERVERS=kafka:9092\n",
117
+ }
118
+
119
+
120
+ def render_compose_base(template: str, broker: str, task_broker: str = "none") -> str:
121
+ """Splice the chosen broker(s) (nats | kafka | none) into a compose base.
122
+
123
+ *task_broker* adds a second broker dedicated to Tasks when it differs
124
+ from the event broker.
125
+ """
126
+ brokers = [b for b in ("nats", "kafka") if b in (broker, task_broker)]
127
+ services = "".join(_BROKER_SERVICES[b] for b in brokers)
128
+ volumes = "".join(_BROKER_VOLUMES[b] for b in brokers)
129
+ return template.replace("{{BROKER_SERVICES}}", services).replace(
130
+ "{{BROKER_VOLUMES}}", volumes
131
+ )
132
+
133
+
134
+ def _broker_env(broker: str, task_broker: str) -> str:
135
+ if task_broker in ("none", broker):
136
+ return _BROKER_ENV[broker]
137
+ if broker == "none":
138
+ # Broker exists for Tasks only — Actions stay in-process.
139
+ return TASK_ONLY_NATS_ENV_BLOCK
140
+ # Two brokers: RoutingBus splits by topic prefix — task.* to the task
141
+ # broker, everything else to the event broker.
142
+ urls = "".join(
143
+ _BROKER_URL_LINES[b] for b in ("nats", "kafka") if b in (broker, task_broker)
144
+ )
145
+ return (
146
+ f"# ─── Brokers: {broker} for events/RPC, {task_broker} for Tasks ─────────────────\n"
147
+ "STAPEL_BUS_BACKEND=routing\n"
148
+ f'STAPEL_BUS_ROUTES={{"task.": "{task_broker}", "": "{broker}"}}\n'
149
+ f"{urls}"
150
+ )
151
+
152
+
153
+ def render_env(template: str, broker: str, ctx: dict, task_broker: str = "none") -> str:
154
+ # Format first ({{BROKER_ENV}} collapses to {BROKER_ENV}), then splice the
155
+ # broker block in — it may contain literal braces (STAPEL_BUS_ROUTES JSON)
156
+ # that str.format must never see.
157
+ rendered = template.format(**ctx)
158
+ return rendered.replace("{BROKER_ENV}", _broker_env(broker, task_broker))
159
+
160
+
161
+ MONOLITH_COMPOSE_BASE = """\
162
+ # Shared infrastructure — included by all environments.
163
+ # Do not put service-specific overrides here.
164
+ services:
165
+ db:
166
+ image: postgres:16
167
+ restart: unless-stopped
168
+ environment:
169
+ POSTGRES_USER: "${POSTGRES_USER:-stapel}"
170
+ POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
171
+ POSTGRES_MULTIPLE_DATABASES: "stapel_main"
172
+ volumes:
173
+ - db-data:/var/lib/postgresql/data
174
+ - ./service-configs/postgres/ensure-databases.sh:/usr/local/bin/ensure-databases.sh:ro
175
+ command: >
176
+ bash -c "
177
+ docker-entrypoint.sh postgres &
178
+ until pg_isready -U $${POSTGRES_USER:-stapel}; do sleep 1; done
179
+ /usr/local/bin/ensure-databases.sh
180
+ wait
181
+ "
182
+ healthcheck:
183
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-stapel}"]
184
+ interval: 5s
185
+ timeout: 5s
186
+ retries: 10
187
+
188
+ redis:
189
+ image: redis:7-alpine
190
+ restart: unless-stopped
191
+ volumes:
192
+ - redis-data:/data
193
+
194
+ {{BROKER_SERVICES}} nginx:
195
+ image: nginx:alpine
196
+ restart: unless-stopped
197
+ ports:
198
+ - "${HTTP_PORT:-80}:80"
199
+ volumes:
200
+ - ./service-configs/nginx:/etc/nginx/conf.d:ro
201
+ - static-content:/staticfiles:ro
202
+ - media-content:/media:ro
203
+ depends_on: []
204
+
205
+ volumes:
206
+ db-data:
207
+ redis-data:
208
+ {{BROKER_VOLUMES}} static-content:
209
+ media-content:
210
+ """
211
+
212
+ MONOLITH_COMPOSE_LOCAL = """\
213
+ include:
214
+ - docker-compose.base.yml
215
+
216
+ services:
217
+ # Add services from their individual .yml files:
218
+ # svc-app:
219
+ # extends:
220
+ # file: svc-app.yml
221
+ # service: svc-app
222
+ # volumes:
223
+ # - ./svc-app:/app
224
+ # - ./stapel_core:/app/stapel_core:ro
225
+ """
226
+
227
+ MONOLITH_COMPOSE_DEV = """\
228
+ include:
229
+ - docker-compose.base.yml
230
+
231
+ services:
232
+ # Add services from their individual .yml files:
233
+ # svc-app:
234
+ # extends:
235
+ # file: svc-app.yml
236
+ # service: svc-app
237
+ """
238
+
239
+ MONOLITH_ENV_TEMPLATE = """\
240
+ # ─── Database ──────────────────────────────────────────────────────────────
241
+ POSTGRES_USER=stapel
242
+ POSTGRES_PASSWORD=change_me
243
+ POSTGRES_HOST=db
244
+ POSTGRES_PORT=5432
245
+
246
+ # ─── Redis ─────────────────────────────────────────────────────────────────
247
+ REDIS_URL=redis://redis:6379/0
248
+
249
+ {{BROKER_ENV}}
250
+ # ─── App ───────────────────────────────────────────────────────────────────
251
+ SECRET_KEY=change_me_to_a_long_random_string
252
+ JWT_SECRET_KEY=change_me_to_another_long_random_string
253
+ ALLOWED_HOSTS={domain}
254
+ SITE_URL={url}
255
+
256
+ # ─── Email ─────────────────────────────────────────────────────────────────
257
+ DEFAULT_FROM_EMAIL={company_name} <{company_email}>
258
+ EMAIL_HOST=smtp.example.com
259
+ EMAIL_PORT=587
260
+ EMAIL_HOST_USER=
261
+ EMAIL_HOST_PASSWORD=
262
+
263
+ # ─── OAuth (optional) ───────────────────────────────────────────────────────
264
+ GOOGLE_OAUTH2_KEY=
265
+ GOOGLE_OAUTH2_SECRET=
266
+
267
+ # ─── Run command ────────────────────────────────────────────────────────────
268
+ RUN_CMD=gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 2
269
+ """
270
+
271
+ MONOLITH_GITIGNORE = """\
272
+ .env
273
+ *.pyc
274
+ __pycache__/
275
+ .venv/
276
+ venv/
277
+ *.egg-info/
278
+ dist/
279
+ build/
280
+ htmlcov/
281
+ .coverage
282
+ *.sqlite3
283
+ media/
284
+ staticfiles/
285
+ .DS_Store
286
+ """
287
+
288
+ MICRO_COMPOSE_BASE = """\
289
+ # Shared infrastructure for microservices stack.
290
+ services:
291
+ db:
292
+ image: postgres:16
293
+ restart: unless-stopped
294
+ environment:
295
+ POSTGRES_USER: "${POSTGRES_USER:-stapel}"
296
+ POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
297
+ POSTGRES_MULTIPLE_DATABASES: ""
298
+ volumes:
299
+ - db-data:/var/lib/postgresql/data
300
+ - ./service-configs/postgres/ensure-databases.sh:/usr/local/bin/ensure-databases.sh:ro
301
+ command: >
302
+ bash -c "
303
+ docker-entrypoint.sh postgres &
304
+ until pg_isready -U $${POSTGRES_USER:-stapel}; do sleep 1; done
305
+ /usr/local/bin/ensure-databases.sh
306
+ wait
307
+ "
308
+ healthcheck:
309
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-stapel}"]
310
+ interval: 5s
311
+ timeout: 5s
312
+ retries: 10
313
+
314
+ redis:
315
+ image: redis:7-alpine
316
+ restart: unless-stopped
317
+ volumes:
318
+ - redis-data:/data
319
+
320
+ {{BROKER_SERVICES}} nginx:
321
+ image: nginx:alpine
322
+ restart: unless-stopped
323
+ ports:
324
+ - "${HTTP_PORT:-80}:80"
325
+ volumes:
326
+ - ./service-configs/nginx:/etc/nginx/conf.d:ro
327
+ - static-content:/staticfiles:ro
328
+ - media-content:/media:ro
329
+ depends_on: []
330
+
331
+ volumes:
332
+ db-data:
333
+ redis-data:
334
+ {{BROKER_VOLUMES}} static-content:
335
+ media-content:
336
+ """
337
+
338
+ MICRO_COMPOSE_LOCAL = """\
339
+ include:
340
+ - docker-compose.base.yml
341
+
342
+ services:
343
+ # Add your services here.
344
+ # Run: stapel-new-service <name> --prefix svc-
345
+ """
346
+
347
+ MICRO_ENV_TEMPLATE = """\
348
+ # ─── Database ──────────────────────────────────────────────────────────────
349
+ POSTGRES_USER=stapel
350
+ POSTGRES_PASSWORD=change_me
351
+ POSTGRES_HOST=db
352
+ POSTGRES_PORT=5432
353
+
354
+ # ─── Redis ─────────────────────────────────────────────────────────────────
355
+ REDIS_URL=redis://redis:6379/0
356
+
357
+ {{BROKER_ENV}}
358
+ # ─── App ───────────────────────────────────────────────────────────────────
359
+ SECRET_KEY=change_me_to_a_long_random_string
360
+ JWT_SECRET_KEY=change_me_to_another_long_random_string
361
+ ALLOWED_HOSTS={domain}
362
+ SITE_URL={url}
363
+
364
+ # ─── Email ─────────────────────────────────────────────────────────────────
365
+ DEFAULT_FROM_EMAIL={company_name} <{company_email}>
366
+ EMAIL_HOST=smtp.example.com
367
+ EMAIL_PORT=587
368
+ EMAIL_HOST_USER=
369
+ EMAIL_HOST_PASSWORD=
370
+
371
+ # ─── Run command ────────────────────────────────────────────────────────────
372
+ RUN_CMD=gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 2
373
+ """