@django-stack/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/dist/bin.js +122 -52
  3. package/dist/index.d.ts +13 -4
  4. package/dist/index.js +1 -1
  5. package/dist/{production-Ct_qoeXk.js → production-1O8-1QUl.js} +126 -24
  6. package/package.json +3 -3
  7. package/templates/backend/Dockerfile +7 -2
  8. package/templates/backend/config/settings.py +12 -0
  9. package/templates/backend/config/studio.py +35 -3
  10. package/templates/backend/config/urls.py +11 -0
  11. package/templates/backend/content/admin.py +22 -5
  12. package/templates/backend/content/dashboard.py +112 -0
  13. package/templates/backend/content/seed.py +8 -1
  14. package/templates/backend/content/tests/test_api.py +15 -0
  15. package/templates/backend/content/tests/test_dashboard.py +77 -0
  16. package/templates/backend/pyproject.toml +1 -1
  17. package/templates/base/.github/workflows/deploy.yml +10 -1
  18. package/templates/base/AGENTS.md +78 -0
  19. package/templates/base/Caddyfile +24 -0
  20. package/templates/base/README.md +74 -6
  21. package/templates/base/SECURITY.md +2 -0
  22. package/templates/base/compose.yaml +21 -2
  23. package/templates/base/env.example +25 -0
  24. package/templates/frontend-astro/package.json +1 -1
  25. package/templates/frontend-next/package.json +3 -3
  26. package/templates/frontend-nuxt/package.json +2 -2
  27. package/templates/frontend-sveltekit/package.json +2 -2
  28. package/templates/frontend-templates/template.json +5 -0
  29. package/templates/frontend-templates/website/__init__.py +0 -0
  30. package/templates/frontend-templates/website/apps.py +6 -0
  31. package/templates/frontend-templates/website/context_processors.py +16 -0
  32. package/templates/frontend-templates/website/forms.py +20 -0
  33. package/templates/frontend-templates/website/static/website/favicon.svg +1 -0
  34. package/templates/frontend-templates/website/static/website/global.css +232 -0
  35. package/templates/frontend-templates/website/templates/website/404.html +10 -0
  36. package/templates/frontend-templates/website/templates/website/base.html +44 -0
  37. package/templates/frontend-templates/website/templates/website/blocks/call_to_action.html +5 -0
  38. package/templates/frontend-templates/website/templates/website/blocks/image.html +4 -0
  39. package/templates/frontend-templates/website/templates/website/blocks/quote.html +4 -0
  40. package/templates/frontend-templates/website/templates/website/blocks/text.html +2 -0
  41. package/templates/frontend-templates/website/templates/website/home.html +8 -0
  42. package/templates/frontend-templates/website/templates/website/includes/contact_form.html +21 -0
  43. package/templates/frontend-templates/website/templates/website/includes/post_card.html +9 -0
  44. package/templates/frontend-templates/website/templates/website/includes/post_list.html +11 -0
  45. package/templates/frontend-templates/website/templates/website/page.html +10 -0
  46. package/templates/frontend-templates/website/templates/website/post.html +20 -0
  47. package/templates/frontend-templates/website/templates/website/post_list.html +11 -0
  48. package/templates/frontend-templates/website/templates/website/robots.txt +5 -0
  49. package/templates/frontend-templates/website/templates/website/rss.xml +16 -0
  50. package/templates/frontend-templates/website/templates/website/sitemap.xml +8 -0
  51. package/templates/frontend-templates/website/tests/__init__.py +0 -0
  52. package/templates/frontend-templates/website/tests/test_site.py +102 -0
  53. package/templates/frontend-templates/website/urls.py +17 -0
  54. package/templates/frontend-templates/website/views.py +176 -0
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class WebsiteConfig(AppConfig):
5
+ name = "website"
6
+ verbose_name = "Website"
@@ -0,0 +1,16 @@
1
+ from django.utils.functional import SimpleLazyObject
2
+
3
+ from content.models import Menu, SiteSettings
4
+
5
+
6
+ def menu(slug):
7
+ return Menu.objects.filter(slug=slug).first()
8
+
9
+
10
+ def site(request):
11
+ """The site settings and the menus, for base.html; read only when a template uses them."""
12
+ return {
13
+ "site": SimpleLazyObject(lambda: SiteSettings.objects.select_related("home_page").first()),
14
+ "main_menu": SimpleLazyObject(lambda: menu("main")),
15
+ "footer_menu": SimpleLazyObject(lambda: menu("footer")),
16
+ }
@@ -0,0 +1,20 @@
1
+ from django import forms
2
+ from django_stack.contrib.captcha.forms import CaptchaField
3
+
4
+ from content.models import FormSubmission
5
+
6
+
7
+ class ContactForm(forms.ModelForm):
8
+ """The contact form; submissions appear in the admin under Form submissions."""
9
+
10
+ # The widget of the captcha; CAPTCHA=off leaves it out.
11
+ captcha = CaptchaField(label="")
12
+
13
+ class Meta:
14
+ model = FormSubmission
15
+ fields = ["name", "email", "message"]
16
+ widgets = {
17
+ "name": forms.TextInput(attrs={"autocomplete": "name"}),
18
+ "email": forms.EmailInput(attrs={"autocomplete": "email"}),
19
+ "message": forms.Textarea(attrs={"rows": 6}),
20
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0c4b33"/><path d="M8 9h10a6 6 0 0 1 0 12h-4v3H8z" fill="#44b78b"/><path d="M14 13h4a2 2 0 0 1 0 4h-4z" fill="#0c4b33"/></svg>
@@ -0,0 +1,232 @@
1
+ /* A small, plain stylesheet: replace it with your own design. */
2
+ :root {
3
+ --color-bg: #ffffff;
4
+ --color-text: #1d2420;
5
+ --color-muted: #5b6862;
6
+ --color-line: #e3e8e4;
7
+ --color-accent: #0c4b33;
8
+ --color-accent-soft: #e8f3ee;
9
+ --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
10
+ --width: 72rem;
11
+ --width-text: 44rem;
12
+ color-scheme: light dark;
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --color-bg: #111614;
18
+ --color-text: #e6ebe8;
19
+ --color-muted: #a1aba6;
20
+ --color-line: #2a322e;
21
+ --color-accent: #6fcf9f;
22
+ --color-accent-soft: #1b2a23;
23
+ }
24
+ }
25
+
26
+ *,
27
+ *::before,
28
+ *::after {
29
+ box-sizing: border-box;
30
+ }
31
+
32
+ body {
33
+ margin: 0;
34
+ background: var(--color-bg);
35
+ color: var(--color-text);
36
+ font-family: var(--font-body);
37
+ line-height: 1.6;
38
+ }
39
+
40
+ a {
41
+ color: var(--color-accent);
42
+ }
43
+
44
+ img {
45
+ max-width: 100%;
46
+ height: auto;
47
+ }
48
+
49
+ .container {
50
+ width: min(100% - 2rem, var(--width));
51
+ margin-inline: auto;
52
+ }
53
+
54
+ .narrow {
55
+ width: min(100% - 2rem, var(--width-text));
56
+ margin-inline: auto;
57
+ }
58
+
59
+ .muted {
60
+ color: var(--color-muted);
61
+ }
62
+
63
+ .prose h2,
64
+ .prose h3 {
65
+ line-height: 1.25;
66
+ margin-top: 2em;
67
+ }
68
+
69
+ .prose blockquote {
70
+ margin: 2rem 0;
71
+ padding-left: 1.25rem;
72
+ border-left: 4px solid var(--color-accent);
73
+ }
74
+
75
+ .button {
76
+ display: inline-block;
77
+ padding: 0.6rem 1.2rem;
78
+ border-radius: 0.5rem;
79
+ background: var(--color-accent);
80
+ color: var(--color-bg);
81
+ font-weight: 600;
82
+ text-decoration: none;
83
+ border: 0;
84
+ cursor: pointer;
85
+ font-family: inherit;
86
+ font-size: inherit;
87
+ }
88
+
89
+ .grid {
90
+ display: grid;
91
+ gap: 2rem;
92
+ grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
93
+ }
94
+
95
+ .preview-banner {
96
+ background: #fde68a;
97
+ color: #422006;
98
+ padding: 0.5rem 1rem;
99
+ text-align: center;
100
+ font-size: 0.9rem;
101
+ }
102
+
103
+ /* The layout: website/templates/website/base.html */
104
+ .site-header {
105
+ border-bottom: 1px solid var(--color-line);
106
+ }
107
+ .bar {
108
+ display: flex;
109
+ flex-wrap: wrap;
110
+ gap: 1rem;
111
+ align-items: center;
112
+ justify-content: space-between;
113
+ padding-block: 1rem;
114
+ }
115
+ .brand {
116
+ font-weight: 700;
117
+ font-size: 1.2rem;
118
+ color: inherit;
119
+ text-decoration: none;
120
+ }
121
+ .brand img {
122
+ height: 2.5rem;
123
+ width: auto;
124
+ }
125
+ nav {
126
+ display: flex;
127
+ flex-wrap: wrap;
128
+ gap: 1.25rem;
129
+ }
130
+ nav a {
131
+ text-decoration: none;
132
+ }
133
+ nav a[aria-current="page"] {
134
+ font-weight: 600;
135
+ text-decoration: underline;
136
+ }
137
+ main {
138
+ min-height: 70vh;
139
+ padding-block: 2rem 4rem;
140
+ }
141
+ .site-footer {
142
+ border-top: 1px solid var(--color-line);
143
+ font-size: 0.9rem;
144
+ }
145
+ .footer-text p {
146
+ margin: 0;
147
+ }
148
+
149
+ /* Posts: includes/post_list.html, includes/post_card.html and post.html */
150
+ .card h3 {
151
+ margin: 0.75rem 0 0.25rem;
152
+ line-height: 1.3;
153
+ }
154
+ .card h3 a {
155
+ color: inherit;
156
+ text-decoration: none;
157
+ }
158
+ .card .cover {
159
+ width: 100%;
160
+ aspect-ratio: 16 / 10;
161
+ object-fit: cover;
162
+ border-radius: 0.5rem;
163
+ }
164
+ .meta {
165
+ font-size: 0.9rem;
166
+ margin: 0;
167
+ }
168
+ .pagination {
169
+ display: flex;
170
+ gap: 1.5rem;
171
+ justify-content: center;
172
+ margin: 3rem 0;
173
+ }
174
+ article .cover {
175
+ width: 100%;
176
+ border-radius: 0.75rem;
177
+ }
178
+ .tags {
179
+ display: flex;
180
+ gap: 1rem;
181
+ margin-top: 3rem;
182
+ }
183
+
184
+ /* Blocks: templates/website/blocks/ */
185
+ .block-image {
186
+ margin: 2rem 0;
187
+ }
188
+ .block-image img {
189
+ border-radius: 0.5rem;
190
+ }
191
+ .block-image figcaption {
192
+ font-size: 0.9rem;
193
+ margin-top: 0.5rem;
194
+ }
195
+ .cta {
196
+ margin: 2.5rem 0;
197
+ padding: 2rem;
198
+ border-radius: 0.75rem;
199
+ background: var(--color-accent-soft);
200
+ }
201
+ .cta h2 {
202
+ margin-top: 0;
203
+ }
204
+
205
+ /* The contact form: includes/contact_form.html */
206
+ .contact-form {
207
+ display: grid;
208
+ gap: 1rem;
209
+ max-width: 32rem;
210
+ margin-top: 2rem;
211
+ }
212
+ .contact-form label {
213
+ display: grid;
214
+ gap: 0.25rem;
215
+ font-weight: 600;
216
+ }
217
+ .contact-form input,
218
+ .contact-form textarea {
219
+ font: inherit;
220
+ padding: 0.5rem 0.75rem;
221
+ border: 1px solid var(--color-line);
222
+ border-radius: 0.375rem;
223
+ background: transparent;
224
+ color: inherit;
225
+ }
226
+ .contact-form button {
227
+ justify-self: start;
228
+ }
229
+ .contact-form .error {
230
+ color: #b42318;
231
+ font-weight: 400;
232
+ }
@@ -0,0 +1,10 @@
1
+ {% extends "website/base.html" %}
2
+ {% load stack %}
3
+ {% block seo %}{% seo not_found site %}{% endblock %}
4
+ {% block content %}
5
+ <section class="narrow">
6
+ <h1>Page not found</h1>
7
+ <p>The page you are looking for does not exist or has moved.</p>
8
+ <p><a href="/">Go to the home page</a></p>
9
+ </section>
10
+ {% endblock %}
@@ -0,0 +1,44 @@
1
+ {% load static stack %}<!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <link rel="icon" href="{% static 'website/favicon.svg' %}" type="image/svg+xml">
7
+ <link rel="alternate" type="application/rss+xml" title="{{ site.site_name|default:'RSS' }}" href="/rss.xml">
8
+ <link rel="stylesheet" href="{% static 'website/global.css' %}">
9
+ {% block seo %}{% seo page site %}{% endblock %}
10
+ {% if analytics_website_id %}<script defer src="/_a/script.js" data-website-id="{{ analytics_website_id }}"></script>{% endif %}
11
+ </head>
12
+ <body>
13
+ {% if preview %}
14
+ <div class="preview-banner">Preview: drafts are shown. <a href="/preview/exit">Leave preview</a></div>
15
+ {% endif %}
16
+ <header class="site-header">
17
+ <div class="container bar">
18
+ <a class="brand" href="/">
19
+ {% if site.logo %}{% image site.logo size="thumbnail" alt=site.site_name eager=True %}{% else %}{{ site.site_name }}{% endif %}
20
+ </a>
21
+ <nav aria-label="Main">
22
+ {% for item in main_menu.items.blocks %}
23
+ {% with href=item|link_href %}
24
+ <a href="{{ href }}"{% if href|is_current:request.path %} aria-current="page"{% endif %}>{{ item.value.label }}</a>
25
+ {% endwith %}
26
+ {% endfor %}
27
+ </nav>
28
+ </div>
29
+ </header>
30
+ <main>
31
+ {% block content %}{% endblock %}
32
+ </main>
33
+ <footer class="site-footer">
34
+ <div class="container bar muted">
35
+ {% if site.footer_text %}<div class="footer-text">{{ site.footer_text|safe }}</div>{% endif %}
36
+ <nav aria-label="Footer">
37
+ {% for item in footer_menu.items.blocks %}<a href="{{ item|link_href }}">{{ item.value.label }}</a>{% endfor %}
38
+ {% for name, url in site.social_links.items %}<a href="{{ url }}" rel="me">{{ name }}</a>{% endfor %}
39
+ </nav>
40
+ </div>
41
+ </footer>
42
+ {% block scripts %}{% endblock %}
43
+ </body>
44
+ </html>
@@ -0,0 +1,5 @@
1
+ <aside class="cta">
2
+ <h2>{{ value.title }}</h2>
3
+ {% if value.text %}<p>{{ value.text }}</p>{% endif %}
4
+ <a class="button" href="{{ value.button_url }}">{{ value.button_label }}</a>
5
+ </aside>
@@ -0,0 +1,4 @@
1
+ {% load stack %}<figure class="block-image">
2
+ {% image value.image size="content" sizes="(min-width: 48rem) 44rem, 100vw" %}
3
+ {% if value.caption %}<figcaption class="muted">{{ value.caption }}</figcaption>{% endif %}
4
+ </figure>
@@ -0,0 +1,4 @@
1
+ <blockquote class="prose">
2
+ <p>{{ value.text }}</p>
3
+ {% if value.attribution %}<footer class="muted">{{ value.attribution }}</footer>{% endif %}
4
+ </blockquote>
@@ -0,0 +1,2 @@
1
+ {# HTML from a Content Studio HTMLField: sanitized with an allowlist when it is saved. #}
2
+ <div class="prose">{{ value.body|safe }}</div>
@@ -0,0 +1,8 @@
1
+ {% extends "website/base.html" %}
2
+ {% block content %}
3
+ <section class="container">
4
+ <h1>{{ site.site_name|default:"Latest posts" }}</h1>
5
+ {% if site.tagline %}<p class="muted">{{ site.tagline }}</p>{% endif %}
6
+ {% include "website/includes/post_list.html" with base="/" %}
7
+ </section>
8
+ {% endblock %}
@@ -0,0 +1,21 @@
1
+ {% comment %}
2
+ The contact form posts to its own page. The view saves a FormSubmission,
3
+ which the admin lists, after the captcha (unless CAPTCHA=off) and the
4
+ rate limit per IP address (PUBLIC_WRITE_THROTTLE_RATE).
5
+ {% endcomment %}
6
+ <form class="contact-form" method="post" action="{{ request.path }}">
7
+ {% csrf_token %}
8
+ {% if sent %}<p class="status" role="status">Thank you, your message was sent.</p>{% endif %}
9
+ {% for error in form.non_field_errors %}<p class="status error" role="alert">{{ error }}</p>{% endfor %}
10
+ {% for field in form %}
11
+ {% if field.name == "captcha" %}
12
+ {{ field }}
13
+ {% for error in field.errors %}<p class="error">{{ error }}</p>{% endfor %}
14
+ {% else %}
15
+ <label>{{ field.label }} {{ field }}
16
+ {% for error in field.errors %}<span class="error">{{ error }}</span>{% endfor %}
17
+ </label>
18
+ {% endif %}
19
+ {% endfor %}
20
+ <button class="button" type="submit">Send</button>
21
+ </form>
@@ -0,0 +1,9 @@
1
+ {% load stack %}<article class="card">
2
+ <a href="{{ post.get_absolute_url }}">{% image post.cover size="card" sizes="(min-width: 40rem) 22rem, 100vw" class_name="cover" %}</a>
3
+ <h3><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h3>
4
+ <p class="muted meta">
5
+ {% if post.published_at %}<time datetime="{{ post.published_at|date:'c' }}">{{ post.published_at|date:"M j, Y" }}</time>{% endif %}
6
+ {% if post.author %}<span> · {{ post.author.name }}</span>{% endif %}
7
+ </p>
8
+ {% if post.excerpt %}<p>{{ post.excerpt }}</p>{% endif %}
9
+ </article>
@@ -0,0 +1,11 @@
1
+ {% if not posts %}<p class="muted">No posts yet.</p>{% endif %}
2
+ <div class="grid">
3
+ {% for post in posts %}{% include "website/includes/post_card.html" %}{% endfor %}
4
+ </div>
5
+ {% if posts.paginator.num_pages > 1 %}
6
+ <nav class="pagination" aria-label="Pages">
7
+ {% if posts.has_previous %}<a href="{% if posts.previous_page_number == 1 %}{{ base }}{% else %}{{ base }}?page={{ posts.previous_page_number }}{% endif %}">← Newer</a>{% endif %}
8
+ <span class="muted">Page {{ posts.number }} of {{ posts.paginator.num_pages }}</span>
9
+ {% if posts.has_next %}<a href="{{ base }}?page={{ posts.next_page_number }}">Older →</a>{% endif %}
10
+ </nav>
11
+ {% endif %}
@@ -0,0 +1,10 @@
1
+ {% extends "website/base.html" %}
2
+ {% load stack %}
3
+ {% block content %}
4
+ <article class="narrow">
5
+ <h1>{{ page.title }}</h1>
6
+ {% blocks page.body %}
7
+ {% if form %}{% include "website/includes/contact_form.html" %}{% endif %}
8
+ </article>
9
+ {% endblock %}
10
+ {% block scripts %}{% if form %}{{ form.media }}{% endif %}{% endblock %}
@@ -0,0 +1,20 @@
1
+ {% extends "website/base.html" %}
2
+ {% load stack %}
3
+ {% block seo %}{% seo post site type="article" %}{% endblock %}
4
+ {% block content %}
5
+ <article class="narrow">
6
+ <p class="muted">
7
+ {% if post.published_at %}<time datetime="{{ post.published_at|date:'c' }}">{{ post.published_at|date:"F j, Y" }}</time>{% endif %}
8
+ {% if post.author %}<span> · {{ post.author.name }}</span>{% endif %}
9
+ {% for category in post.categories.all %}<span> · <a href="{{ category.get_absolute_url }}">{{ category.name }}</a></span>{% endfor %}
10
+ </p>
11
+ <h1>{{ post.title }}</h1>
12
+ {% image post.cover size="content" sizes="(min-width: 48rem) 44rem, 100vw" class_name="cover" eager=True %}
13
+ {% blocks post.body %}
14
+ {% with tags=post.tags.all %}
15
+ {% if tags %}
16
+ <p class="tags">{% for tag in tags %}<a href="{{ tag.get_absolute_url }}">#{{ tag.name }}</a>{% endfor %}</p>
17
+ {% endif %}
18
+ {% endwith %}
19
+ </article>
20
+ {% endblock %}
@@ -0,0 +1,11 @@
1
+ {% extends "website/base.html" %}
2
+ {% load stack %}
3
+ {% block seo %}{% seo seo_page site %}{% endblock %}
4
+ {% block content %}
5
+ <section class="container">
6
+ {% if label %}<p class="muted">{{ label }}</p>{% endif %}
7
+ <h1>{{ title }}</h1>
8
+ {% if description %}<p>{{ description }}</p>{% endif %}
9
+ {% include "website/includes/post_list.html" with base=request.path %}
10
+ </section>
11
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ User-agent: *
2
+ Disallow: /admin/
3
+ Disallow: /preview
4
+
5
+ Sitemap: {{ site_url }}/sitemap.xml
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <rss version="2.0">
3
+ <channel>
4
+ <title>{{ site.site_name|default:"Blog" }}</title>
5
+ <description>{% firstof site.seo_description site.tagline "" %}</description>
6
+ <link>{{ site_url }}/</link>
7
+ {% for post in posts %}<item>
8
+ <title>{{ post.title }}</title>
9
+ <link>{{ site_url }}{{ post.get_absolute_url }}</link>
10
+ <guid isPermaLink="true">{{ site_url }}{{ post.get_absolute_url }}</guid>
11
+ {% if post.excerpt %}<description>{{ post.excerpt }}</description>{% endif %}
12
+ {% if post.published_at %}<pubDate>{{ post.published_at|date:"r" }}</pubDate>{% endif %}
13
+ </item>
14
+ {% endfor %}
15
+ </channel>
16
+ </rss>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url><loc>{{ site_url }}/</loc></url>
4
+ <url><loc>{{ site_url }}/blog</loc></url>
5
+ {% for page in pages %}<url><loc>{{ site_url }}{{ page.get_absolute_url }}</loc>{% if page.published_at %}<lastmod>{{ page.published_at|date:"Y-m-d" }}</lastmod>{% endif %}</url>
6
+ {% endfor %}{% for post in posts %}<url><loc>{{ site_url }}{{ post.get_absolute_url }}</loc>{% if post.published_at %}<lastmod>{{ post.published_at|date:"Y-m-d" }}</lastmod>{% endif %}</url>
7
+ {% endfor %}
8
+ </urlset>
@@ -0,0 +1,102 @@
1
+ """
2
+ Tests of the site's pages: every route renders, drafts show only in
3
+ preview, a save empties the page cache, redirects work and the contact form
4
+ needs a captcha token. Run them with `{{pm_run}} test` at the project root.
5
+ """
6
+
7
+ import pytest
8
+ from django.conf import settings
9
+ from django.core.cache import cache
10
+ from django.test import Client
11
+ from django_stack.contrib.captcha.testing import captcha_token
12
+ from django_stack.contrib.site.preview import PREVIEW_COOKIE
13
+ from django_stack.preview import make_preview_token
14
+
15
+ from content.models import FormSubmission, Page, Post
16
+ from content.seed import seed
17
+
18
+ pytestmark = pytest.mark.django_db
19
+
20
+
21
+ @pytest.fixture
22
+ def seeded(settings, tmp_path):
23
+ settings.MEDIA_ROOT = tmp_path
24
+ cache.clear()
25
+ seed()
26
+
27
+
28
+ @pytest.fixture
29
+ def client():
30
+ return Client()
31
+
32
+
33
+ @pytest.mark.parametrize(
34
+ "path", ["/", "/about", "/about/team", "/blog", "/blog?page=2", "/category/news", "/tag/django", "/contact"]
35
+ )
36
+ def test_pages_render(client, seeded, path):
37
+ response = client.get(path)
38
+ assert response.status_code == 200
39
+ assert "<title>" in response.content.decode()
40
+ assert "script-src 'self'" in response["Content-Security-Policy"]
41
+
42
+
43
+ def test_post(client, seeded):
44
+ post = Post.objects.published().first()
45
+ html = client.get(f"/blog/{post.slug}").content.decode()
46
+ assert f"<h1>{post.title}</h1>" in html
47
+ assert '<meta property="og:type" content="article">' in html
48
+
49
+
50
+ @pytest.mark.parametrize(
51
+ ("path", "content_type"),
52
+ [("/rss.xml", "application/rss+xml"), ("/sitemap.xml", "application/xml"), ("/robots.txt", "text/plain")],
53
+ )
54
+ def test_feeds(client, seeded, path, content_type):
55
+ response = client.get(path)
56
+ assert response.status_code == 200
57
+ assert response["Content-Type"].startswith(content_type)
58
+ assert settings.SITE_URL in response.content.decode()
59
+
60
+
61
+ def test_not_found_and_redirects(client, seeded):
62
+ response = client.get("/does-not-exist")
63
+ assert response.status_code == 404
64
+ assert "Page not found" in response.content.decode()
65
+ redirect = client.get("/old-about")
66
+ assert redirect.status_code == 301
67
+ assert redirect["Location"] == "/about"
68
+
69
+
70
+ def test_drafts_only_in_preview(client, seeded):
71
+ assert Page.objects.filter(path="draft").exists()
72
+ assert client.get("/draft").status_code == 404
73
+ client.cookies[PREVIEW_COOKIE] = make_preview_token()
74
+ response = client.get("/draft")
75
+ assert response.status_code == 200
76
+ assert "preview-banner" in response.content.decode()
77
+
78
+
79
+ def test_a_save_empties_the_page_cache(client, seeded):
80
+ assert client.get("/about")["X-Cache"] == "MISS"
81
+ assert client.get("/about")["X-Cache"] == "HIT"
82
+ about = Page.objects.get(path="about")
83
+ about.title = "Changed"
84
+ about.save()
85
+ response = client.get("/about")
86
+ assert response["X-Cache"] == "MISS"
87
+ assert "<h1>Changed</h1>" in response.content.decode()
88
+
89
+
90
+ def test_contact_form(client, seeded):
91
+ data = {"name": "Ada", "email": "ada@example.com", "message": "Hello", "captcha": captcha_token()}
92
+ response = client.post("/contact", data)
93
+ assert response.status_code == 302
94
+ assert FormSubmission.objects.filter(email="ada@example.com").exists()
95
+ assert "Thank you" in client.get(response["Location"]).content.decode()
96
+
97
+
98
+ @pytest.mark.skipif(not settings.DJANGO_STACK["CAPTCHA"], reason="the captcha is off")
99
+ def test_contact_form_needs_a_captcha(client, seeded):
100
+ response = client.post("/contact", {"name": "Ada", "email": "ada@example.com", "message": "Hello"})
101
+ assert response.status_code == 400
102
+ assert not FormSubmission.objects.filter(email="ada@example.com").exists()
@@ -0,0 +1,17 @@
1
+ from django.urls import include, path, re_path
2
+
3
+ from . import views
4
+
5
+ urlpatterns = [
6
+ path("", include("django_stack.contrib.site.urls")), # /preview and /preview/exit
7
+ path("", views.home, name="home"),
8
+ path("blog", views.blog, name="blog"),
9
+ path("blog/<slug:slug>", views.post, name="post"),
10
+ path("category/<slug:slug>", views.category, name="category"),
11
+ path("tag/<slug:slug>", views.tag, name="tag"),
12
+ path("rss.xml", views.rss, name="rss"),
13
+ path("sitemap.xml", views.sitemap, name="sitemap"),
14
+ path("robots.txt", views.robots, name="robots"),
15
+ # Pages at their path, such as /about or /about/team.
16
+ re_path(r"^(?P<path>[\w-]+(?:/[\w-]+)*)/?$", views.page, name="page"),
17
+ ]