@django-stack/cli 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +27 -3
  2. package/dist/bin.js +1302 -2
  3. package/dist/index.d.ts +250 -1
  4. package/dist/index.js +1 -4
  5. package/dist/stack-OTrWHXrO.js +712 -0
  6. package/package.json +14 -7
  7. package/templates/backend/.dockerignore +11 -0
  8. package/templates/backend/Dockerfile +45 -0
  9. package/templates/backend/config/__init__.py +0 -0
  10. package/templates/backend/config/asgi.py +7 -0
  11. package/templates/backend/config/settings.py +87 -0
  12. package/templates/backend/config/studio.py +20 -0
  13. package/templates/backend/config/urls.py +9 -0
  14. package/templates/backend/config/wsgi.py +7 -0
  15. package/templates/backend/conftest.py +7 -0
  16. package/templates/backend/content/__init__.py +0 -0
  17. package/templates/backend/content/admin.py +107 -0
  18. package/templates/backend/content/apps.py +6 -0
  19. package/templates/backend/content/blocks.py +51 -0
  20. package/templates/backend/content/migrations/0001_initial.py +418 -0
  21. package/templates/backend/content/migrations/__init__.py +0 -0
  22. package/templates/backend/content/models.py +234 -0
  23. package/templates/backend/content/seed.py +153 -0
  24. package/templates/backend/content/tests/__init__.py +0 -0
  25. package/templates/backend/content/tests/test_api.py +73 -0
  26. package/templates/backend/entrypoint.sh +27 -0
  27. package/templates/backend/manage.py +16 -0
  28. package/templates/backend/pyproject.toml +33 -0
  29. package/templates/backend/vendor/.gitkeep +0 -0
  30. package/templates/base/.github/workflows/deploy.yml +70 -0
  31. package/templates/base/AGENTS.md +78 -0
  32. package/templates/base/CLAUDE.md +1 -0
  33. package/templates/base/Caddyfile +51 -0
  34. package/templates/base/README.md +87 -0
  35. package/templates/base/SECURITY.md +12 -0
  36. package/templates/base/compose.yaml +303 -0
  37. package/templates/base/env.example +80 -0
  38. package/templates/base/gitignore +15 -0
  39. package/templates/base/package.json +21 -0
  40. package/templates/frontend-astro/.dockerignore +4 -0
  41. package/templates/frontend-astro/Dockerfile +32 -0
  42. package/templates/frontend-astro/astro.config.mjs +38 -0
  43. package/templates/frontend-astro/package.json +31 -0
  44. package/templates/frontend-astro/public/favicon.svg +1 -0
  45. package/templates/frontend-astro/src/components/Blocks.astro +32 -0
  46. package/templates/frontend-astro/src/components/ContactForm.astro +68 -0
  47. package/templates/frontend-astro/src/components/Image.astro +34 -0
  48. package/templates/frontend-astro/src/components/PostCard.astro +41 -0
  49. package/templates/frontend-astro/src/components/PostList.astro +39 -0
  50. package/templates/frontend-astro/src/components/RichText.astro +14 -0
  51. package/templates/frontend-astro/src/components/Seo.astro +20 -0
  52. package/templates/frontend-astro/src/components/blocks/CallToAction.astro +23 -0
  53. package/templates/frontend-astro/src/components/blocks/Image.astro +24 -0
  54. package/templates/frontend-astro/src/components/blocks/Quote.astro +10 -0
  55. package/templates/frontend-astro/src/components/blocks/Text.astro +8 -0
  56. package/templates/frontend-astro/src/layouts/Base.astro +122 -0
  57. package/templates/frontend-astro/src/lib/api.ts +24 -0
  58. package/templates/frontend-astro/src/lib/cache.ts +58 -0
  59. package/templates/frontend-astro/src/lib/headless.ts +344 -0
  60. package/templates/frontend-astro/src/middleware.ts +69 -0
  61. package/templates/frontend-astro/src/pages/404.astro +13 -0
  62. package/templates/frontend-astro/src/pages/[...slug].astro +21 -0
  63. package/templates/frontend-astro/src/pages/blog/[slug].astro +50 -0
  64. package/templates/frontend-astro/src/pages/blog/index.astro +15 -0
  65. package/templates/frontend-astro/src/pages/category/[slug].astro +25 -0
  66. package/templates/frontend-astro/src/pages/index.astro +28 -0
  67. package/templates/frontend-astro/src/pages/preview/exit.ts +7 -0
  68. package/templates/frontend-astro/src/pages/preview/index.ts +26 -0
  69. package/templates/frontend-astro/src/pages/revalidate.ts +19 -0
  70. package/templates/frontend-astro/src/pages/robots.txt.ts +7 -0
  71. package/templates/frontend-astro/src/pages/rss.xml.ts +19 -0
  72. package/templates/frontend-astro/src/pages/sitemap.xml.ts +29 -0
  73. package/templates/frontend-astro/src/pages/tag/[slug].astro +24 -0
  74. package/templates/frontend-astro/src/styles/global.css +101 -0
  75. package/templates/frontend-astro/test/blocks.test.ts +42 -0
  76. package/templates/frontend-astro/test/cache.test.ts +22 -0
  77. package/templates/frontend-astro/tsconfig.json +5 -0
  78. package/templates/frontend-astro/vendor/.gitkeep +0 -0
  79. package/templates/frontend-astro/vitest.config.ts +8 -0
@@ -0,0 +1,153 @@
1
+ """
2
+ Demo content for a new site: `manage.py seed_content` runs ``seed``.
3
+ Change it to fit your site, or delete the demo content in the admin.
4
+ """
5
+
6
+ import io
7
+ import uuid
8
+
9
+ from content_studio.media_library.models import Media
10
+ from django.core.files.base import ContentFile
11
+ from django.db import transaction
12
+
13
+ from .models import Author, Category, Menu, Page, Post, Redirect, SiteSettings, Tag
14
+
15
+
16
+ def block(type: str, **data) -> dict:
17
+ return {"id": str(uuid.uuid4()), "type": type, "data": data}
18
+
19
+
20
+ def text(html: str) -> dict:
21
+ return block("text", body=html)
22
+
23
+
24
+ def image_block(media: Media, caption: str = "") -> dict:
25
+ return block("image", image=str(media.pk), caption=caption)
26
+
27
+
28
+ def make_image(name: str, hue: int, width: int = 1600, height: int = 900) -> Media:
29
+ """A generated gradient image, so the demo needs no downloads."""
30
+ from PIL import Image, ImageOps
31
+
32
+ gradient = Image.linear_gradient("L").rotate(30 * hue, expand=True).resize((width, height))
33
+ colors = [("#0c4b33", "#44b78b"), ("#1e3a8a", "#60a5fa"), ("#7c2d12", "#fb923c"), ("#4c1d95", "#c084fc")]
34
+ dark, light = colors[hue % len(colors)]
35
+ image = ImageOps.colorize(gradient, black=dark, white=light)
36
+ buffer = io.BytesIO()
37
+ image.save(buffer, format="JPEG", quality=85)
38
+ media = Media(name=name, alt_text=name, type="image", metadata={"width": width, "height": height})
39
+ media.file.save(f"{name.lower().replace(' ', '-')}.jpg", ContentFile(buffer.getvalue()), save=False)
40
+ media.save()
41
+ return media
42
+
43
+
44
+ @transaction.atomic
45
+ def seed(if_empty: bool = False, stdout=None) -> bool:
46
+ if if_empty and (Page.objects.exists() or Post.objects.exists()):
47
+ return False
48
+
49
+ hero = make_image("Hero image", 0)
50
+ covers = [make_image(f"Cover {number}", number) for number in range(1, 4)]
51
+
52
+ home = Page.objects.create(
53
+ title="Welcome",
54
+ slug="home",
55
+ status="published",
56
+ seo_description="A new site built with DjangoStack.",
57
+ body=[
58
+ text(
59
+ "<h2>Your site is running</h2>"
60
+ '<p>This page comes from Django: edit it in the admin at <a href="/admin/">/admin/</a>, '
61
+ "publish, and the site shows the change.</p>"
62
+ ),
63
+ image_block(hero, "Images come from the media library, in every size the site needs."),
64
+ block(
65
+ "call_to_action",
66
+ title="Read the blog",
67
+ text="Three demo posts show categories, tags, authors and cover images.",
68
+ button_label="Go to the blog",
69
+ button_url="/blog",
70
+ ),
71
+ ],
72
+ )
73
+ about = Page.objects.create(
74
+ title="About",
75
+ slug="about",
76
+ status="published",
77
+ body=[
78
+ text("<p>An about page. Pages can have child pages, such as the team page below.</p>"),
79
+ block("quote", text="Content first, code second.", attribution="An editor"),
80
+ ],
81
+ )
82
+ Page.objects.create(
83
+ title="Team",
84
+ slug="team",
85
+ parent=about,
86
+ status="published",
87
+ body=[text("<p>A child page at <code>/about/team</code>.</p>")],
88
+ )
89
+ contact = Page.objects.create(
90
+ title="Contact",
91
+ slug="contact",
92
+ status="published",
93
+ body=[text("<p>Send a message with the form below. Submissions appear in the admin.</p>")],
94
+ )
95
+ Page.objects.create(
96
+ title="Draft page",
97
+ slug="draft",
98
+ body=[text("<p>Only visible with a preview link from the admin.</p>")],
99
+ )
100
+
101
+ author = Author.objects.create(name="Ada Editor", slug="ada", bio="Writes the demo posts.")
102
+ news = Category.objects.create(name="News", slug="news")
103
+ guides = Category.objects.create(name="Guides", slug="guides")
104
+ django_tag = Tag.objects.create(name="Django", slug="django")
105
+ astro_tag = Tag.objects.create(name="Astro", slug="astro")
106
+
107
+ posts = [
108
+ ("Hello, world", "hello-world", news, [django_tag], "The first post of the new site."),
109
+ ("Editing content", "editing-content", guides, [django_tag], "How pages and posts are edited."),
110
+ ("Rendering with Astro", "rendering-with-astro", guides, [astro_tag], "How the frontend shows content."),
111
+ ]
112
+ for (title, slug, category, tags, excerpt), cover in zip(posts, covers, strict=True):
113
+ post = Post.objects.create(
114
+ title=title,
115
+ slug=slug,
116
+ excerpt=excerpt,
117
+ cover=cover,
118
+ author=author,
119
+ status="published",
120
+ body=[
121
+ text(f"<p>{excerpt}</p><h2>A heading</h2><p>Body text with <strong>bold</strong> words.</p>"),
122
+ text("<ul><li>A list item</li><li>Another one</li></ul>"),
123
+ ],
124
+ )
125
+ post.categories.add(category)
126
+ post.tags.set(tags)
127
+
128
+ Menu.objects.create(
129
+ name="Main",
130
+ slug="main",
131
+ items=[
132
+ block("link", label="Home", url="/"),
133
+ block("link", label="Blog", url="/blog"),
134
+ block("link", label="About", page=about.pk),
135
+ block("link", label="Contact", page=contact.pk),
136
+ ],
137
+ )
138
+ Menu.objects.create(
139
+ name="Footer",
140
+ slug="footer",
141
+ items=[block("link", label="RSS", url="/rss.xml")],
142
+ )
143
+ Redirect.objects.create(old_path="/old-about", new_path="/about")
144
+
145
+ settings = SiteSettings.objects.first() or SiteSettings()
146
+ settings.site_name = "{{project_name}}"
147
+ settings.tagline = "Built with DjangoStack"
148
+ settings.home_page = home
149
+ settings.seo_description = "A website built with Django, Django Headless, Content Studio and Astro."
150
+ settings.share_image = hero
151
+ settings.footer_text = "<p>Powered by DjangoStack.</p>"
152
+ settings.save()
153
+ return True
File without changes
@@ -0,0 +1,73 @@
1
+ """
2
+ Smoke tests of the content API: every exposed model answers the read-only
3
+ key, drafts stay hidden, and the contact form takes submissions.
4
+ Run them with `pnpm test` at the project root.
5
+ """
6
+
7
+ import pytest
8
+ from django.conf import settings
9
+ from rest_framework.test import APIClient
10
+
11
+ from content.models import FormSubmission, Page, Post
12
+ from content.seed import seed
13
+
14
+ pytestmark = pytest.mark.django_db
15
+
16
+ ROUTES = [
17
+ "content.page",
18
+ "content.post",
19
+ "content.category",
20
+ "content.tag",
21
+ "content.author",
22
+ "content.menu",
23
+ "content.redirect",
24
+ "content.sitesettings",
25
+ "content_studio_media_library.media",
26
+ ]
27
+
28
+
29
+ @pytest.fixture
30
+ def reader():
31
+ return APIClient(headers={"X-Secret-Key": settings.HEADLESS["AUTH_READ_ONLY_SECRET_KEY"]})
32
+
33
+
34
+ @pytest.fixture
35
+ def seeded(settings, tmp_path):
36
+ settings.MEDIA_ROOT = tmp_path
37
+ seed()
38
+
39
+
40
+ @pytest.mark.parametrize("route", ROUTES)
41
+ def test_read_key_reads_every_model(reader, seeded, route):
42
+ assert reader.get(f"/api/{route}").status_code == 200
43
+
44
+
45
+ def test_api_is_closed_without_a_key(seeded):
46
+ assert APIClient().get("/api/content.page").status_code in (401, 403)
47
+
48
+
49
+ def test_drafts_are_hidden(reader, seeded):
50
+ assert Page.objects.filter(slug="draft").exists()
51
+ assert reader.get("/api/content.page?path=draft").json()["data"] == []
52
+ assert reader.get("/api/content.page?path=about/team").json()["data"][0]["title"] == "Team"
53
+
54
+
55
+ def test_post_by_slug(reader, seeded):
56
+ post = Post.objects.first()
57
+ data = reader.get(f"/api/content.post/by_slug/{post.slug}?expand=cover,author").json()
58
+ assert data["title"] == post.title
59
+ assert data["cover"]["url"]
60
+
61
+
62
+ def test_contact_form(seeded):
63
+ response = APIClient().post(
64
+ "/api/content.formsubmission",
65
+ {"name": "Ada", "email": "ada@example.com", "message": "Hello"},
66
+ format="json",
67
+ )
68
+ assert response.status_code == 201
69
+ assert FormSubmission.objects.filter(email="ada@example.com").exists()
70
+
71
+
72
+ def test_read_key_cannot_read_submissions(reader):
73
+ assert reader.get("/api/content.formsubmission").status_code == 403
@@ -0,0 +1,27 @@
1
+ #!/bin/sh
2
+ # Runs before the backend, worker and backup commands.
3
+ #
4
+ # DJANGO_MIGRATE=1 (the backend service): migrate the database, create the
5
+ # first admin user and, with SEED_DEMO_CONTENT=1, add demo content to an
6
+ # empty site. DJANGO_CHECK_DEPLOY=1 (production): stop when Django's deploy
7
+ # checks find an error. S3_CREATE_BUCKET=1 (the development S3 service):
8
+ # create the media bucket.
9
+ set -eu
10
+
11
+ python manage.py wait_for_db
12
+
13
+ if [ "${DJANGO_MIGRATE:-0}" = "1" ]; then
14
+ if [ "${DJANGO_CHECK_DEPLOY:-0}" = "1" ]; then
15
+ python manage.py check --deploy --fail-level ERROR
16
+ fi
17
+ if [ "${S3_CREATE_BUCKET:-0}" = "1" ]; then
18
+ python manage.py create_bucket
19
+ fi
20
+ python manage.py migrate --noinput
21
+ python manage.py create_admin
22
+ if [ "${SEED_DEMO_CONTENT:-0}" = "1" ]; then
23
+ python manage.py seed_content --if-empty
24
+ fi
25
+ fi
26
+
27
+ exec "$@"
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env python
2
+ """Django's command-line utility for administrative tasks."""
3
+
4
+ import os
5
+ import sys
6
+
7
+
8
+ def main():
9
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
10
+ from django.core.management import execute_from_command_line
11
+
12
+ execute_from_command_line(sys.argv)
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "{{project_name}}-backend"
3
+ version = "0.1.0"
4
+ description = "The Django backend of {{project_name}}."
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "Django>=6.1,<6.2",
8
+ "django-stack[server,s3]>=0.1.0",
9
+ "django-headless[schema]>=1.1.1",
10
+ "django-content-studio[images,huey]>=1.8.0",
11
+ ]
12
+
13
+ [dependency-groups]
14
+ dev = ["pytest>=8", "pytest-django>=4.11", "ruff>=0.15"]
15
+
16
+ [tool.uv]
17
+ package = false
18
+
19
+ [tool.pytest.ini_options]
20
+ DJANGO_SETTINGS_MODULE = "config.settings"
21
+ python_files = ["test_*.py"]
22
+ filterwarnings = [
23
+ # WhiteNoise warns that collectstatic has not run, which tests do not need.
24
+ "ignore:No directory at:UserWarning",
25
+ ]
26
+
27
+ [tool.ruff]
28
+ line-length = 120
29
+ target-version = "py312"
30
+ extend-exclude = ["*/migrations/*"]
31
+
32
+ [tool.ruff.lint]
33
+ select = ["E", "F", "I", "B", "UP", "DJ"]
File without changes
@@ -0,0 +1,70 @@
1
+ # Builds the production images, pushes them to the GitHub Container Registry
2
+ # and deploys them to your server over SSH. Optional: without it, build on
3
+ # the server with `docker compose up -d --build`.
4
+ #
5
+ # Set up once (see the deployment docs):
6
+ # 1. The server has Docker, the project directory (DEPLOY_PATH) and its .env.
7
+ # 2. Repository secrets: DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY (a private
8
+ # key the server accepts), DEPLOY_PATH and DEPLOY_KNOWN_HOSTS (the output
9
+ # of `ssh-keyscan your-server`).
10
+ #
11
+ # It runs when you start it from the Actions tab. To deploy every push to
12
+ # main, add `push: { branches: [main] }` under `on`.
13
+
14
+ name: Deploy
15
+
16
+ on:
17
+ workflow_dispatch:
18
+
19
+ concurrency: deploy
20
+
21
+ permissions:
22
+ contents: read
23
+ packages: write
24
+
25
+ env:
26
+ BACKEND_IMAGE: ghcr.io/${{ github.repository }}-backend:${{ github.sha }}
27
+ FRONTEND_IMAGE: ghcr.io/${{ github.repository }}-frontend:${{ github.sha }}
28
+
29
+ jobs:
30
+ deploy:
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@v6
34
+
35
+ - name: Log in to the container registry
36
+ run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.actor }}" --password-stdin
37
+
38
+ - name: Build and push the images
39
+ run: |
40
+ docker build --target prod --tag "${BACKEND_IMAGE,,}" backend
41
+ docker build --target prod --tag "${FRONTEND_IMAGE,,}" frontend
42
+ docker push "${BACKEND_IMAGE,,}"
43
+ docker push "${FRONTEND_IMAGE,,}"
44
+
45
+ - name: Set up SSH
46
+ run: |
47
+ mkdir -p ~/.ssh
48
+ echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_deploy
49
+ echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
50
+ chmod 600 ~/.ssh/id_deploy ~/.ssh/known_hosts
51
+
52
+ - name: Deploy
53
+ env:
54
+ TARGET: ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}
55
+ DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
56
+ REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
57
+ run: |
58
+ ssh_cmd="ssh -i $HOME/.ssh/id_deploy"
59
+ # The Compose file and the Caddyfile come from the repository; the
60
+ # server keeps its own .env.
61
+ scp -i ~/.ssh/id_deploy compose.yaml Caddyfile "$TARGET:$DEPLOY_PATH/"
62
+ $ssh_cmd "$TARGET" "
63
+ set -e
64
+ cd '$DEPLOY_PATH'
65
+ echo '$REGISTRY_TOKEN' | docker login ghcr.io --username '${{ github.actor }}' --password-stdin
66
+ export BACKEND_IMAGE='${BACKEND_IMAGE,,}' FRONTEND_IMAGE='${FRONTEND_IMAGE,,}'
67
+ docker compose pull --quiet --ignore-buildable
68
+ docker compose up -d --no-build --remove-orphans
69
+ docker logout ghcr.io
70
+ "
@@ -0,0 +1,78 @@
1
+ # {{project_name}}: guide for AI agents
2
+
3
+ A website built with DjangoStack: a Django backend (Django Headless serves
4
+ the content API, Django Content Studio is the admin) and an Astro frontend
5
+ that renders the content, run with Docker Compose.
6
+
7
+ ## Layout
8
+
9
+ ```
10
+ backend/ Django project
11
+ config/settings.py settings; environment-driven ones come from django_stack.settings.configure
12
+ config/studio.py the Content Studio site (admin extensions)
13
+ content/models.py the content models: Page, Post, Category, Tag, Author, Menu, SiteSettings, Redirect, FormSubmission
14
+ content/blocks.py the blocks of page and post bodies
15
+ content/admin.py how each model looks in the admin (studios)
16
+ content/seed.py demo content
17
+ content/tests/ pytest tests of the API
18
+ frontend/ Astro site (server output, Node adapter)
19
+ src/pages/ routes: /, /[...slug], /blog, /blog/[slug], /category/[slug], /tag/[slug], sitemap.xml, rss.xml, robots.txt, /preview, /revalidate
20
+ src/components/ Blocks.astro maps block types to components in components/blocks/
21
+ src/lib/api.ts getContent(): content helpers with the read-only key, drafts in preview
22
+ src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
23
+ src/middleware.ts page cache, editor-managed redirects, security headers
24
+ compose.yaml every service; COMPOSE_PROFILES in .env picks dev or prod
25
+ Caddyfile the prod profile's reverse proxy and HTTPS
26
+ .github/workflows/deploy.yml optional deploy to a server over SSH
27
+ .env secrets and settings; never commit, never print
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ Run them at the project root. They need Docker; Node is only needed for the `npm run` shortcuts.
33
+
34
+ | Task | Command |
35
+ |------|---------|
36
+ | Start everything | `docker compose up --build` (or `npm run dev`) |
37
+ | Run a manage.py command | `docker compose exec backend python manage.py <command>` (or `npm run backend -- <command>`) |
38
+ | Make migrations after a model change | `docker compose exec backend python manage.py makemigrations` |
39
+ | Regenerate the API types after a model change | `npm run generate:types` |
40
+ | Run the tests | `npm test` |
41
+ | Lint and type-check | `npm run lint` |
42
+ | Logs | `docker compose logs --follow backend` |
43
+ | Production settings for a server | `npx @django-stack/cli production --domain example.com` |
44
+
45
+ In production (`COMPOSE_PROFILES=prod,db`) the services are `backend-prod`,
46
+ `worker-prod`, `frontend-prod`, `proxy` and `backup`; their containers are
47
+ read-only, so a change goes through the code and a new image, never an edit
48
+ in a running container.
49
+
50
+ ## How to add a content model
51
+
52
+ 1. Define the model in `backend/content/models.py` and decorate it with
53
+ `@expose()` (add `read_only=True` for content the frontend only reads).
54
+ 2. Register it in `backend/content/admin.py` with `@register(Model)` and a
55
+ `ModelStudio`.
56
+ 3. Run `makemigrations`; the backend migrates on its next start, or run
57
+ `migrate`.
58
+ 4. Run `npm run generate:types`, then read it in the frontend through
59
+ `getClient()` from `src/lib/api.ts`: `getClient(Astro.cookies).model("content.mymodel").list()`.
60
+ 5. Add an API smoke test to `backend/content/tests/test_api.py`.
61
+
62
+ ## How to add a block type
63
+
64
+ 1. Add a `Block` subclass in `backend/content/blocks.py` and add it to
65
+ `BODY_BLOCKS`. Blocks need no migration.
66
+ 2. Add a component in `frontend/src/components/blocks/` and map the block's
67
+ type in `frontend/src/components/Blocks.astro`.
68
+
69
+ ## Rules
70
+
71
+ - Never edit `.env` values you were not asked to change, and never print secrets.
72
+ - Never write migrations by hand; use `makemigrations`.
73
+ - The frontend only ever holds `API_READ_KEY`. Never give it `API_SECRET_KEY`.
74
+ - API routes are closed by default. Opening a model to anonymous writes goes
75
+ through `DJANGO_STACK["PUBLIC_CREATE"]` in `config/settings.py`, and is
76
+ rate limited.
77
+ - Django owns `/admin`, `/api`, `/media` and `/static`; frontend routes must
78
+ not use these prefixes.
@@ -0,0 +1 @@
1
+ Read [AGENTS.md](AGENTS.md): it describes this project's layout, its commands and what not to touch.
@@ -0,0 +1,51 @@
1
+ # {{project_name}}: the reverse proxy of the prod profile.
2
+ #
3
+ # One domain: Django serves /admin, /api and /static; uploads on the server's
4
+ # disk are served from /media; the frontend serves everything else. Caddy
5
+ # gets and renews the HTTPS certificate for SITE_URL by itself.
6
+ #
7
+ # Behind a platform's proxy (Coolify, Dokploy), CADDY_ADDRESS=:80 serves
8
+ # plain HTTP to that proxy, and TRUSTED_PROXIES=private_ranges keeps the
9
+ # client's address and scheme it forwards. See the deployment docs.
10
+
11
+ {
12
+ servers {
13
+ trusted_proxies static {$TRUSTED_PROXIES}
14
+ }
15
+ }
16
+
17
+ {$CADDY_ADDRESS} {
18
+ encode zstd gzip
19
+
20
+ # Defaults for every response; Django and the frontend set their own,
21
+ # stricter ones where they need them. One directive per header: Caddy
22
+ # skips a whole block of `?` headers when any one of them is set.
23
+ header ?Strict-Transport-Security "max-age=31536000"
24
+ header ?X-Content-Type-Options "nosniff"
25
+ header ?Referrer-Policy "strict-origin-when-cross-origin"
26
+ header ?X-Frame-Options "DENY"
27
+ header -Server
28
+
29
+ # Uploads, when media is stored on the server instead of S3. No scripts
30
+ # run from uploaded files, whatever their type.
31
+ handle_path /media/* {
32
+ root * /srv/media
33
+ header Content-Security-Policy "script-src 'none'; object-src 'none'"
34
+ header Cache-Control "public, max-age=604800"
35
+ file_server
36
+ }
37
+
38
+ @django path /admin /admin/* /api /api/* /static/*
39
+ handle @django {
40
+ reverse_proxy backend:8000
41
+ }
42
+
43
+ handle {
44
+ reverse_proxy frontend:3000
45
+ }
46
+
47
+ log {
48
+ output stdout
49
+ format console
50
+ }
51
+ }
@@ -0,0 +1,87 @@
1
+ # {{project_name}}
2
+
3
+ A website with a Django backend and an Astro frontend, created with
4
+ [DjangoStack](https://github.com/dwarsbit/django-stack).
5
+
6
+ - [Django Headless](https://djangoheadless.org) serves the content API.
7
+ - [Django Content Studio](https://djangocontentstudio.org) is the admin.
8
+ - [Astro](https://astro.build) renders the site.
9
+ - Postgres stores the content; Valkey is the cache and task queue.
10
+
11
+ ## Start
12
+
13
+ You need Docker.
14
+
15
+ ```bash
16
+ docker compose up --build
17
+ ```
18
+
19
+ - Site: http://localhost:3000
20
+ - Admin: http://localhost:3000/admin/ (user `admin`, password `ADMIN_PASSWORD` in `.env`)
21
+ - Outgoing mail: http://localhost:8025
22
+
23
+ The first start migrates the database, creates the admin user and adds demo
24
+ content. Edit a page in the admin, publish, and reload the site.
25
+
26
+ ## Develop
27
+
28
+ Both sides reload on save: the backend runs Django's development server and
29
+ the frontend Astro's, with the source mounted from `backend/` and
30
+ `frontend/`. `AGENTS.md` lists the layout and the commands.
31
+
32
+ | Task | Command |
33
+ |------|---------|
34
+ | Run a manage.py command | `docker compose exec backend python manage.py <command>` |
35
+ | Make migrations | `docker compose exec backend python manage.py makemigrations` |
36
+ | Regenerate the API types | `npm run generate:types` |
37
+ | Run the tests | `npm test` |
38
+ | Check the setup | `npm run doctor` |
39
+
40
+ With the `dstack` command installed (`npm install --global @django-stack/cli`),
41
+ `dstack dev`, `dstack check`, `dstack manage <command>` and `dstack types` do
42
+ the same from any directory in the project. See the
43
+ [CLI reference](https://dwarsbit.github.io/django-stack/docs/cli).
44
+
45
+ ## Preview and publishing
46
+
47
+ Pages and posts have a status. Drafts and posts with a future publish date
48
+ are not served by the API. The **Preview** button in the admin opens the
49
+ site with drafts shown, for an hour.
50
+
51
+ When an editor saves, Django tells the frontend (`REVALIDATE_URL`), which
52
+ empties its page cache, so the change shows on the next visit.
53
+
54
+ ## Deploy
55
+
56
+ On a server with Docker and a domain pointing at it:
57
+
58
+ ```bash
59
+ npx @django-stack/cli production --domain example.com --email-url smtp+tls://user:pass@smtp.example.com:587
60
+ # copy the project to the server, with .env.production as .env, then there:
61
+ docker compose up -d --build
62
+ ```
63
+
64
+ The `prod` profile runs production images behind Caddy, which gets the HTTPS
65
+ certificate, and backs up the database every day. Media can go to S3 and the
66
+ database to a managed Postgres. `.github/workflows/deploy.yml` deploys from
67
+ GitHub Actions. The [deployment guide](https://dwarsbit.github.io/django-stack/docs/production/server)
68
+ has every step.
69
+
70
+ | Task | Command (on the server) |
71
+ |------|---------|
72
+ | Update | `git pull && docker compose up -d --build` |
73
+ | Back up now | `docker compose exec backup python manage.py backup_database` |
74
+ | List backups | `docker compose exec backup python manage.py backup_database --list` |
75
+ | Restore | `docker compose exec backup python manage.py restore_database <name>` |
76
+ | Logs | `docker compose logs --follow backend-prod` |
77
+
78
+ ## Security
79
+
80
+ - The API answers only requests with a key. The frontend holds the
81
+ read-only key (`API_READ_KEY`) and uses it on the server only.
82
+ - `API_SECRET_KEY` can write: keep it for scripts and never give it to the
83
+ frontend.
84
+ - `.env` holds every secret. It is generated with random values and is not
85
+ committed.
86
+
87
+ See `SECURITY.md` to report a problem.
@@ -0,0 +1,12 @@
1
+ # Security
2
+
3
+ To report a security problem in this site, contact its maintainers
4
+ privately.
5
+
6
+ Problems in the packages it is built on go to their projects:
7
+
8
+ - DjangoStack: https://github.com/dwarsbit/django-stack/security
9
+ - Django Headless: https://github.com/dwarsbit/django-headless/security
10
+ - Django Content Studio: https://github.com/dwarsbit/django-content-studio/security
11
+ - Django: https://www.djangoproject.com/security/
12
+ - Astro: https://github.com/withastro/astro/security