@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.
- package/README.md +27 -3
- package/dist/bin.js +1302 -2
- package/dist/index.d.ts +250 -1
- package/dist/index.js +1 -4
- package/dist/stack-OTrWHXrO.js +712 -0
- package/package.json +14 -7
- package/templates/backend/.dockerignore +11 -0
- package/templates/backend/Dockerfile +45 -0
- package/templates/backend/config/__init__.py +0 -0
- package/templates/backend/config/asgi.py +7 -0
- package/templates/backend/config/settings.py +87 -0
- package/templates/backend/config/studio.py +20 -0
- package/templates/backend/config/urls.py +9 -0
- package/templates/backend/config/wsgi.py +7 -0
- package/templates/backend/conftest.py +7 -0
- package/templates/backend/content/__init__.py +0 -0
- package/templates/backend/content/admin.py +107 -0
- package/templates/backend/content/apps.py +6 -0
- package/templates/backend/content/blocks.py +51 -0
- package/templates/backend/content/migrations/0001_initial.py +418 -0
- package/templates/backend/content/migrations/__init__.py +0 -0
- package/templates/backend/content/models.py +234 -0
- package/templates/backend/content/seed.py +153 -0
- package/templates/backend/content/tests/__init__.py +0 -0
- package/templates/backend/content/tests/test_api.py +73 -0
- package/templates/backend/entrypoint.sh +27 -0
- package/templates/backend/manage.py +16 -0
- package/templates/backend/pyproject.toml +33 -0
- package/templates/backend/vendor/.gitkeep +0 -0
- package/templates/base/.github/workflows/deploy.yml +70 -0
- package/templates/base/AGENTS.md +78 -0
- package/templates/base/CLAUDE.md +1 -0
- package/templates/base/Caddyfile +51 -0
- package/templates/base/README.md +87 -0
- package/templates/base/SECURITY.md +12 -0
- package/templates/base/compose.yaml +303 -0
- package/templates/base/env.example +80 -0
- package/templates/base/gitignore +15 -0
- package/templates/base/package.json +21 -0
- package/templates/frontend-astro/.dockerignore +4 -0
- package/templates/frontend-astro/Dockerfile +32 -0
- package/templates/frontend-astro/astro.config.mjs +38 -0
- package/templates/frontend-astro/package.json +31 -0
- package/templates/frontend-astro/public/favicon.svg +1 -0
- package/templates/frontend-astro/src/components/Blocks.astro +32 -0
- package/templates/frontend-astro/src/components/ContactForm.astro +68 -0
- package/templates/frontend-astro/src/components/Image.astro +34 -0
- package/templates/frontend-astro/src/components/PostCard.astro +41 -0
- package/templates/frontend-astro/src/components/PostList.astro +39 -0
- package/templates/frontend-astro/src/components/RichText.astro +14 -0
- package/templates/frontend-astro/src/components/Seo.astro +20 -0
- package/templates/frontend-astro/src/components/blocks/CallToAction.astro +23 -0
- package/templates/frontend-astro/src/components/blocks/Image.astro +24 -0
- package/templates/frontend-astro/src/components/blocks/Quote.astro +10 -0
- package/templates/frontend-astro/src/components/blocks/Text.astro +8 -0
- package/templates/frontend-astro/src/layouts/Base.astro +122 -0
- package/templates/frontend-astro/src/lib/api.ts +24 -0
- package/templates/frontend-astro/src/lib/cache.ts +58 -0
- package/templates/frontend-astro/src/lib/headless.ts +344 -0
- package/templates/frontend-astro/src/middleware.ts +69 -0
- package/templates/frontend-astro/src/pages/404.astro +13 -0
- package/templates/frontend-astro/src/pages/[...slug].astro +21 -0
- package/templates/frontend-astro/src/pages/blog/[slug].astro +50 -0
- package/templates/frontend-astro/src/pages/blog/index.astro +15 -0
- package/templates/frontend-astro/src/pages/category/[slug].astro +25 -0
- package/templates/frontend-astro/src/pages/index.astro +28 -0
- package/templates/frontend-astro/src/pages/preview/exit.ts +7 -0
- package/templates/frontend-astro/src/pages/preview/index.ts +26 -0
- package/templates/frontend-astro/src/pages/revalidate.ts +19 -0
- package/templates/frontend-astro/src/pages/robots.txt.ts +7 -0
- package/templates/frontend-astro/src/pages/rss.xml.ts +19 -0
- package/templates/frontend-astro/src/pages/sitemap.xml.ts +29 -0
- package/templates/frontend-astro/src/pages/tag/[slug].astro +24 -0
- package/templates/frontend-astro/src/styles/global.css +101 -0
- package/templates/frontend-astro/test/blocks.test.ts +42 -0
- package/templates/frontend-astro/test/cache.test.ts +22 -0
- package/templates/frontend-astro/tsconfig.json +5 -0
- package/templates/frontend-astro/vendor/.gitkeep +0 -0
- 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
|