@django-stack/cli 0.0.1 → 0.2.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 +2282 -2
- package/dist/index.d.ts +252 -1
- package/dist/index.js +1 -4
- package/dist/stack-CTyqYWCV.js +800 -0
- package/package.json +15 -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 +79 -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 +282 -0
- package/templates/base/env.example +83 -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,418 @@
|
|
|
1
|
+
# Generated by Django 6.1.3 on 2026-11-04 10:30
|
|
2
|
+
|
|
3
|
+
import content_studio.blocks
|
|
4
|
+
import content_studio.fields
|
|
5
|
+
import content_studio.media_library.fields
|
|
6
|
+
import django.db.models.deletion
|
|
7
|
+
from django.db import migrations, models
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Migration(migrations.Migration):
|
|
11
|
+
|
|
12
|
+
initial = True
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
("content_studio_media_library", "0002_media_metadata_properties"),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
operations = [
|
|
19
|
+
migrations.CreateModel(
|
|
20
|
+
name="Category",
|
|
21
|
+
fields=[
|
|
22
|
+
(
|
|
23
|
+
"id",
|
|
24
|
+
models.BigAutoField(
|
|
25
|
+
auto_created=True,
|
|
26
|
+
primary_key=True,
|
|
27
|
+
serialize=False,
|
|
28
|
+
verbose_name="ID",
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
("name", models.CharField(max_length=100)),
|
|
32
|
+
("slug", models.SlugField(unique=True)),
|
|
33
|
+
("description", models.TextField(blank=True)),
|
|
34
|
+
],
|
|
35
|
+
options={
|
|
36
|
+
"verbose_name_plural": "categories",
|
|
37
|
+
"ordering": ["name"],
|
|
38
|
+
},
|
|
39
|
+
),
|
|
40
|
+
migrations.CreateModel(
|
|
41
|
+
name="FormSubmission",
|
|
42
|
+
fields=[
|
|
43
|
+
(
|
|
44
|
+
"id",
|
|
45
|
+
models.BigAutoField(
|
|
46
|
+
auto_created=True,
|
|
47
|
+
primary_key=True,
|
|
48
|
+
serialize=False,
|
|
49
|
+
verbose_name="ID",
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
("name", models.CharField(max_length=200)),
|
|
53
|
+
("email", models.EmailField(max_length=254)),
|
|
54
|
+
("message", models.TextField(max_length=5000)),
|
|
55
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
56
|
+
],
|
|
57
|
+
options={
|
|
58
|
+
"ordering": ["-created_at"],
|
|
59
|
+
},
|
|
60
|
+
),
|
|
61
|
+
migrations.CreateModel(
|
|
62
|
+
name="Menu",
|
|
63
|
+
fields=[
|
|
64
|
+
(
|
|
65
|
+
"id",
|
|
66
|
+
models.BigAutoField(
|
|
67
|
+
auto_created=True,
|
|
68
|
+
primary_key=True,
|
|
69
|
+
serialize=False,
|
|
70
|
+
verbose_name="ID",
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
("name", models.CharField(max_length=100)),
|
|
74
|
+
(
|
|
75
|
+
"slug",
|
|
76
|
+
models.SlugField(
|
|
77
|
+
help_text="The frontend asks for a menu by this name, such as main.",
|
|
78
|
+
unique=True,
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
("items", content_studio.blocks.BlockField(blank=True, default=list)),
|
|
82
|
+
],
|
|
83
|
+
),
|
|
84
|
+
migrations.CreateModel(
|
|
85
|
+
name="Redirect",
|
|
86
|
+
fields=[
|
|
87
|
+
(
|
|
88
|
+
"id",
|
|
89
|
+
models.BigAutoField(
|
|
90
|
+
auto_created=True,
|
|
91
|
+
primary_key=True,
|
|
92
|
+
serialize=False,
|
|
93
|
+
verbose_name="ID",
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
(
|
|
97
|
+
"old_path",
|
|
98
|
+
models.CharField(
|
|
99
|
+
help_text="Such as /old-page, without the domain.",
|
|
100
|
+
max_length=500,
|
|
101
|
+
unique=True,
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
"new_path",
|
|
106
|
+
models.CharField(
|
|
107
|
+
help_text="A path on the site or a full URL.", max_length=500
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
(
|
|
111
|
+
"permanent",
|
|
112
|
+
models.BooleanField(
|
|
113
|
+
default=True, help_text="301 when on, 302 when off."
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
],
|
|
117
|
+
options={
|
|
118
|
+
"ordering": ["old_path"],
|
|
119
|
+
},
|
|
120
|
+
),
|
|
121
|
+
migrations.CreateModel(
|
|
122
|
+
name="Tag",
|
|
123
|
+
fields=[
|
|
124
|
+
(
|
|
125
|
+
"id",
|
|
126
|
+
models.BigAutoField(
|
|
127
|
+
auto_created=True,
|
|
128
|
+
primary_key=True,
|
|
129
|
+
serialize=False,
|
|
130
|
+
verbose_name="ID",
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
("name", models.CharField(max_length=100)),
|
|
134
|
+
("slug", models.SlugField(unique=True)),
|
|
135
|
+
],
|
|
136
|
+
options={
|
|
137
|
+
"ordering": ["name"],
|
|
138
|
+
},
|
|
139
|
+
),
|
|
140
|
+
migrations.CreateModel(
|
|
141
|
+
name="Author",
|
|
142
|
+
fields=[
|
|
143
|
+
(
|
|
144
|
+
"id",
|
|
145
|
+
models.BigAutoField(
|
|
146
|
+
auto_created=True,
|
|
147
|
+
primary_key=True,
|
|
148
|
+
serialize=False,
|
|
149
|
+
verbose_name="ID",
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
("name", models.CharField(max_length=100)),
|
|
153
|
+
("slug", models.SlugField(unique=True)),
|
|
154
|
+
("bio", models.TextField(blank=True)),
|
|
155
|
+
(
|
|
156
|
+
"avatar",
|
|
157
|
+
content_studio.media_library.fields.MediaField(
|
|
158
|
+
blank=True,
|
|
159
|
+
null=True,
|
|
160
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
161
|
+
related_name="+",
|
|
162
|
+
to="content_studio_media_library.media",
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
],
|
|
166
|
+
options={
|
|
167
|
+
"ordering": ["name"],
|
|
168
|
+
},
|
|
169
|
+
),
|
|
170
|
+
migrations.CreateModel(
|
|
171
|
+
name="Page",
|
|
172
|
+
fields=[
|
|
173
|
+
(
|
|
174
|
+
"id",
|
|
175
|
+
models.BigAutoField(
|
|
176
|
+
auto_created=True,
|
|
177
|
+
primary_key=True,
|
|
178
|
+
serialize=False,
|
|
179
|
+
verbose_name="ID",
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
(
|
|
183
|
+
"status",
|
|
184
|
+
models.CharField(
|
|
185
|
+
choices=[("draft", "Draft"), ("published", "Published")],
|
|
186
|
+
default="draft",
|
|
187
|
+
max_length=20,
|
|
188
|
+
verbose_name="status",
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
(
|
|
192
|
+
"published_at",
|
|
193
|
+
models.DateTimeField(
|
|
194
|
+
blank=True,
|
|
195
|
+
help_text="Leave empty to publish right away. A future date schedules the publication.",
|
|
196
|
+
null=True,
|
|
197
|
+
verbose_name="publish date",
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
(
|
|
201
|
+
"seo_title",
|
|
202
|
+
models.CharField(
|
|
203
|
+
blank=True, max_length=70, verbose_name="SEO title"
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
(
|
|
207
|
+
"seo_description",
|
|
208
|
+
models.CharField(
|
|
209
|
+
blank=True, max_length=160, verbose_name="SEO description"
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
("title", models.CharField(max_length=200)),
|
|
213
|
+
("slug", models.SlugField()),
|
|
214
|
+
("path", models.CharField(editable=False, max_length=500, unique=True)),
|
|
215
|
+
("body", content_studio.blocks.BlockField(blank=True, default=list)),
|
|
216
|
+
("order", models.PositiveIntegerField(default=0)),
|
|
217
|
+
(
|
|
218
|
+
"parent",
|
|
219
|
+
models.ForeignKey(
|
|
220
|
+
blank=True,
|
|
221
|
+
null=True,
|
|
222
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
223
|
+
related_name="children",
|
|
224
|
+
to="content.page",
|
|
225
|
+
),
|
|
226
|
+
),
|
|
227
|
+
(
|
|
228
|
+
"share_image",
|
|
229
|
+
content_studio.media_library.fields.MediaField(
|
|
230
|
+
blank=True,
|
|
231
|
+
help_text="Shown when the page is shared; 1200 × 630 works best.",
|
|
232
|
+
null=True,
|
|
233
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
234
|
+
related_name="+",
|
|
235
|
+
to="content_studio_media_library.media",
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
],
|
|
239
|
+
options={
|
|
240
|
+
"ordering": ["order", "title"],
|
|
241
|
+
},
|
|
242
|
+
),
|
|
243
|
+
migrations.CreateModel(
|
|
244
|
+
name="SiteSettings",
|
|
245
|
+
fields=[
|
|
246
|
+
(
|
|
247
|
+
"id",
|
|
248
|
+
models.BigAutoField(
|
|
249
|
+
auto_created=True,
|
|
250
|
+
primary_key=True,
|
|
251
|
+
serialize=False,
|
|
252
|
+
verbose_name="ID",
|
|
253
|
+
),
|
|
254
|
+
),
|
|
255
|
+
("site_name", models.CharField(default="{{project_name}}", max_length=100)),
|
|
256
|
+
("tagline", models.CharField(blank=True, max_length=200)),
|
|
257
|
+
(
|
|
258
|
+
"seo_description",
|
|
259
|
+
models.CharField(
|
|
260
|
+
blank=True,
|
|
261
|
+
max_length=160,
|
|
262
|
+
verbose_name="default SEO description",
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
(
|
|
266
|
+
"social_links",
|
|
267
|
+
models.JSONField(
|
|
268
|
+
blank=True,
|
|
269
|
+
default=dict,
|
|
270
|
+
help_text='Such as {"Mastodon": "https://..."}.',
|
|
271
|
+
),
|
|
272
|
+
),
|
|
273
|
+
("footer_text", content_studio.fields.HTMLField(blank=True)),
|
|
274
|
+
(
|
|
275
|
+
"home_page",
|
|
276
|
+
models.ForeignKey(
|
|
277
|
+
blank=True,
|
|
278
|
+
help_text="The page shown at /. Without one, the home page lists the latest posts.",
|
|
279
|
+
null=True,
|
|
280
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
281
|
+
related_name="+",
|
|
282
|
+
to="content.page",
|
|
283
|
+
),
|
|
284
|
+
),
|
|
285
|
+
(
|
|
286
|
+
"logo",
|
|
287
|
+
content_studio.media_library.fields.MediaField(
|
|
288
|
+
blank=True,
|
|
289
|
+
null=True,
|
|
290
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
291
|
+
related_name="+",
|
|
292
|
+
to="content_studio_media_library.media",
|
|
293
|
+
),
|
|
294
|
+
),
|
|
295
|
+
(
|
|
296
|
+
"share_image",
|
|
297
|
+
content_studio.media_library.fields.MediaField(
|
|
298
|
+
blank=True,
|
|
299
|
+
help_text="The default image for social sharing.",
|
|
300
|
+
null=True,
|
|
301
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
302
|
+
related_name="+",
|
|
303
|
+
to="content_studio_media_library.media",
|
|
304
|
+
),
|
|
305
|
+
),
|
|
306
|
+
],
|
|
307
|
+
options={
|
|
308
|
+
"verbose_name": "site settings",
|
|
309
|
+
"verbose_name_plural": "site settings",
|
|
310
|
+
},
|
|
311
|
+
),
|
|
312
|
+
migrations.CreateModel(
|
|
313
|
+
name="Post",
|
|
314
|
+
fields=[
|
|
315
|
+
(
|
|
316
|
+
"id",
|
|
317
|
+
models.BigAutoField(
|
|
318
|
+
auto_created=True,
|
|
319
|
+
primary_key=True,
|
|
320
|
+
serialize=False,
|
|
321
|
+
verbose_name="ID",
|
|
322
|
+
),
|
|
323
|
+
),
|
|
324
|
+
(
|
|
325
|
+
"status",
|
|
326
|
+
models.CharField(
|
|
327
|
+
choices=[("draft", "Draft"), ("published", "Published")],
|
|
328
|
+
default="draft",
|
|
329
|
+
max_length=20,
|
|
330
|
+
verbose_name="status",
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
(
|
|
334
|
+
"published_at",
|
|
335
|
+
models.DateTimeField(
|
|
336
|
+
blank=True,
|
|
337
|
+
help_text="Leave empty to publish right away. A future date schedules the publication.",
|
|
338
|
+
null=True,
|
|
339
|
+
verbose_name="publish date",
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
(
|
|
343
|
+
"seo_title",
|
|
344
|
+
models.CharField(
|
|
345
|
+
blank=True, max_length=70, verbose_name="SEO title"
|
|
346
|
+
),
|
|
347
|
+
),
|
|
348
|
+
(
|
|
349
|
+
"seo_description",
|
|
350
|
+
models.CharField(
|
|
351
|
+
blank=True, max_length=160, verbose_name="SEO description"
|
|
352
|
+
),
|
|
353
|
+
),
|
|
354
|
+
("title", models.CharField(max_length=200)),
|
|
355
|
+
("slug", models.SlugField(unique=True)),
|
|
356
|
+
(
|
|
357
|
+
"excerpt",
|
|
358
|
+
models.TextField(
|
|
359
|
+
blank=True,
|
|
360
|
+
help_text="A short summary for lists and search results.",
|
|
361
|
+
),
|
|
362
|
+
),
|
|
363
|
+
("body", content_studio.blocks.BlockField(blank=True, default=list)),
|
|
364
|
+
(
|
|
365
|
+
"author",
|
|
366
|
+
models.ForeignKey(
|
|
367
|
+
blank=True,
|
|
368
|
+
null=True,
|
|
369
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
370
|
+
related_name="posts",
|
|
371
|
+
to="content.author",
|
|
372
|
+
),
|
|
373
|
+
),
|
|
374
|
+
(
|
|
375
|
+
"categories",
|
|
376
|
+
models.ManyToManyField(
|
|
377
|
+
blank=True, related_name="posts", to="content.category"
|
|
378
|
+
),
|
|
379
|
+
),
|
|
380
|
+
(
|
|
381
|
+
"cover",
|
|
382
|
+
content_studio.media_library.fields.MediaField(
|
|
383
|
+
blank=True,
|
|
384
|
+
null=True,
|
|
385
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
386
|
+
related_name="+",
|
|
387
|
+
to="content_studio_media_library.media",
|
|
388
|
+
),
|
|
389
|
+
),
|
|
390
|
+
(
|
|
391
|
+
"share_image",
|
|
392
|
+
content_studio.media_library.fields.MediaField(
|
|
393
|
+
blank=True,
|
|
394
|
+
help_text="Shown when the page is shared; 1200 × 630 works best.",
|
|
395
|
+
null=True,
|
|
396
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
397
|
+
related_name="+",
|
|
398
|
+
to="content_studio_media_library.media",
|
|
399
|
+
),
|
|
400
|
+
),
|
|
401
|
+
(
|
|
402
|
+
"tags",
|
|
403
|
+
models.ManyToManyField(
|
|
404
|
+
blank=True, related_name="posts", to="content.tag"
|
|
405
|
+
),
|
|
406
|
+
),
|
|
407
|
+
],
|
|
408
|
+
options={
|
|
409
|
+
"ordering": ["-published_at", "-pk"],
|
|
410
|
+
},
|
|
411
|
+
),
|
|
412
|
+
migrations.AddConstraint(
|
|
413
|
+
model_name="page",
|
|
414
|
+
constraint=models.UniqueConstraint(
|
|
415
|
+
fields=("parent", "slug"), name="unique_page_slug_per_parent"
|
|
416
|
+
),
|
|
417
|
+
),
|
|
418
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The content model of {{project_name}}: pages, posts and their taxonomies,
|
|
3
|
+
site settings, menus, redirects and form submissions.
|
|
4
|
+
|
|
5
|
+
These models are yours: add fields, remove models, create new ones. Every
|
|
6
|
+
model exposed with ``@expose()`` gets routes under /api/ (see
|
|
7
|
+
https://djangoheadless.org/docs/models); register it in ``admin.py`` to edit
|
|
8
|
+
it in Content Studio. After a change, run ``pnpm backend makemigrations``
|
|
9
|
+
and ``pnpm generate:types``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from content_studio.blocks import Block, BlockField
|
|
13
|
+
from content_studio.fields import HTMLField
|
|
14
|
+
from content_studio.media_library.fields import MediaField
|
|
15
|
+
from content_studio.media_library.models import Media
|
|
16
|
+
from django.core.exceptions import ValidationError
|
|
17
|
+
from django.db import models
|
|
18
|
+
from django_stack.models import PublishableModel
|
|
19
|
+
from headless import expose, expose_model
|
|
20
|
+
|
|
21
|
+
from .blocks import BODY_BLOCKS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SeoFields(models.Model):
|
|
25
|
+
"""Search engine and social sharing fields; empty fields fall back to the site settings."""
|
|
26
|
+
|
|
27
|
+
seo_title = models.CharField("SEO title", max_length=70, blank=True)
|
|
28
|
+
seo_description = models.CharField("SEO description", max_length=160, blank=True)
|
|
29
|
+
share_image = MediaField(file_type="image", help_text="Shown when the page is shared; 1200 × 630 works best.")
|
|
30
|
+
|
|
31
|
+
class Meta:
|
|
32
|
+
abstract = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@expose(read_only=True, lookup_fields=["slug"])
|
|
36
|
+
class Category(models.Model):
|
|
37
|
+
name = models.CharField(max_length=100)
|
|
38
|
+
slug = models.SlugField(unique=True)
|
|
39
|
+
description = models.TextField(blank=True)
|
|
40
|
+
|
|
41
|
+
class Meta:
|
|
42
|
+
verbose_name_plural = "categories"
|
|
43
|
+
ordering = ["name"]
|
|
44
|
+
|
|
45
|
+
def __str__(self):
|
|
46
|
+
return self.name
|
|
47
|
+
|
|
48
|
+
def get_absolute_url(self):
|
|
49
|
+
return f"/category/{self.slug}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@expose(read_only=True, lookup_fields=["slug"])
|
|
53
|
+
class Tag(models.Model):
|
|
54
|
+
name = models.CharField(max_length=100)
|
|
55
|
+
slug = models.SlugField(unique=True)
|
|
56
|
+
|
|
57
|
+
class Meta:
|
|
58
|
+
ordering = ["name"]
|
|
59
|
+
|
|
60
|
+
def __str__(self):
|
|
61
|
+
return self.name
|
|
62
|
+
|
|
63
|
+
def get_absolute_url(self):
|
|
64
|
+
return f"/tag/{self.slug}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@expose(read_only=True, lookup_fields=["slug"])
|
|
68
|
+
class Author(models.Model):
|
|
69
|
+
name = models.CharField(max_length=100)
|
|
70
|
+
slug = models.SlugField(unique=True)
|
|
71
|
+
bio = models.TextField(blank=True)
|
|
72
|
+
avatar = MediaField(file_type="image")
|
|
73
|
+
|
|
74
|
+
class Meta:
|
|
75
|
+
ordering = ["name"]
|
|
76
|
+
|
|
77
|
+
def __str__(self):
|
|
78
|
+
return self.name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@expose(read_only=True)
|
|
82
|
+
class Page(PublishableModel, SeoFields):
|
|
83
|
+
"""A page of the site at /<path>, such as /about or /about/team."""
|
|
84
|
+
|
|
85
|
+
title = models.CharField(max_length=200)
|
|
86
|
+
slug = models.SlugField()
|
|
87
|
+
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.PROTECT, related_name="children")
|
|
88
|
+
path = models.CharField(max_length=500, unique=True, editable=False)
|
|
89
|
+
body = BlockField(blocks=BODY_BLOCKS, blank=True, default=list, max_num=50)
|
|
90
|
+
order = models.PositiveIntegerField(default=0)
|
|
91
|
+
|
|
92
|
+
class Meta:
|
|
93
|
+
ordering = ["order", "title"]
|
|
94
|
+
constraints = [models.UniqueConstraint(fields=["parent", "slug"], name="unique_page_slug_per_parent")]
|
|
95
|
+
|
|
96
|
+
def __str__(self):
|
|
97
|
+
return self.title
|
|
98
|
+
|
|
99
|
+
def save(self, *args, **kwargs):
|
|
100
|
+
old_path = self.path
|
|
101
|
+
self.path = self.build_path()
|
|
102
|
+
super().save(*args, **kwargs)
|
|
103
|
+
if old_path and old_path != self.path:
|
|
104
|
+
for child in self.children.all():
|
|
105
|
+
child.save()
|
|
106
|
+
|
|
107
|
+
def get_absolute_url(self):
|
|
108
|
+
return f"/{self.path}"
|
|
109
|
+
|
|
110
|
+
def build_path(self) -> str:
|
|
111
|
+
return f"{self.parent.path}/{self.slug}" if self.parent else self.slug
|
|
112
|
+
|
|
113
|
+
def clean(self):
|
|
114
|
+
ancestor = self.parent
|
|
115
|
+
while ancestor is not None:
|
|
116
|
+
if ancestor.pk == self.pk:
|
|
117
|
+
raise ValidationError({"parent": "A page cannot be its own parent or ancestor."})
|
|
118
|
+
ancestor = ancestor.parent
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@expose(read_only=True, lookup_fields=["slug"])
|
|
122
|
+
class Post(PublishableModel, SeoFields):
|
|
123
|
+
"""A blog post at /blog/<slug>."""
|
|
124
|
+
|
|
125
|
+
title = models.CharField(max_length=200)
|
|
126
|
+
slug = models.SlugField(unique=True)
|
|
127
|
+
excerpt = models.TextField(blank=True, help_text="A short summary for lists and search results.")
|
|
128
|
+
cover = MediaField(file_type="image")
|
|
129
|
+
body = BlockField(blocks=BODY_BLOCKS, blank=True, default=list, max_num=50)
|
|
130
|
+
author = models.ForeignKey(Author, null=True, blank=True, on_delete=models.SET_NULL, related_name="posts")
|
|
131
|
+
categories = models.ManyToManyField(Category, blank=True, related_name="posts")
|
|
132
|
+
tags = models.ManyToManyField(Tag, blank=True, related_name="posts")
|
|
133
|
+
|
|
134
|
+
class Meta:
|
|
135
|
+
ordering = ["-published_at", "-pk"]
|
|
136
|
+
|
|
137
|
+
def __str__(self):
|
|
138
|
+
return self.title
|
|
139
|
+
|
|
140
|
+
def get_absolute_url(self):
|
|
141
|
+
return f"/blog/{self.slug}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class LinkBlock(Block):
|
|
145
|
+
label = models.CharField(max_length=100)
|
|
146
|
+
page = models.ForeignKey(Page, null=True, blank=True, on_delete=models.SET_NULL)
|
|
147
|
+
url = models.CharField(max_length=500, blank=True, help_text="Used when no page is chosen.")
|
|
148
|
+
|
|
149
|
+
class Meta:
|
|
150
|
+
name = "link"
|
|
151
|
+
description = "A link to a page or a URL"
|
|
152
|
+
icon = "ph-bold ph-link"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@expose(read_only=True, lookup_fields=["slug"])
|
|
156
|
+
class Menu(models.Model):
|
|
157
|
+
"""A navigation menu, such as "main" in the header or "footer"."""
|
|
158
|
+
|
|
159
|
+
name = models.CharField(max_length=100)
|
|
160
|
+
slug = models.SlugField(unique=True, help_text="The frontend asks for a menu by this name, such as main.")
|
|
161
|
+
items = BlockField(blocks=[LinkBlock], blank=True, default=list, max_num=20)
|
|
162
|
+
|
|
163
|
+
def __str__(self):
|
|
164
|
+
return self.name
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@expose(singleton=True, read_only=True)
|
|
168
|
+
class SiteSettings(models.Model):
|
|
169
|
+
site_name = models.CharField(max_length=100, default="{{project_name}}")
|
|
170
|
+
tagline = models.CharField(max_length=200, blank=True)
|
|
171
|
+
logo = MediaField(file_type="image")
|
|
172
|
+
home_page = models.ForeignKey(
|
|
173
|
+
Page,
|
|
174
|
+
null=True,
|
|
175
|
+
blank=True,
|
|
176
|
+
on_delete=models.SET_NULL,
|
|
177
|
+
related_name="+",
|
|
178
|
+
help_text="The page shown at /. Without one, the home page lists the latest posts.",
|
|
179
|
+
)
|
|
180
|
+
seo_description = models.CharField("default SEO description", max_length=160, blank=True)
|
|
181
|
+
share_image = MediaField(file_type="image", help_text="The default image for social sharing.")
|
|
182
|
+
social_links = models.JSONField(default=dict, blank=True, help_text='Such as {"Mastodon": "https://..."}.')
|
|
183
|
+
footer_text = HTMLField(blank=True)
|
|
184
|
+
|
|
185
|
+
class Meta:
|
|
186
|
+
verbose_name = "site settings"
|
|
187
|
+
verbose_name_plural = "site settings"
|
|
188
|
+
|
|
189
|
+
def __str__(self):
|
|
190
|
+
return self.site_name
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@expose(read_only=True)
|
|
194
|
+
class Redirect(models.Model):
|
|
195
|
+
"""A redirect the frontend applies before showing its not found page."""
|
|
196
|
+
|
|
197
|
+
old_path = models.CharField(max_length=500, unique=True, help_text="Such as /old-page, without the domain.")
|
|
198
|
+
new_path = models.CharField(max_length=500, help_text="A path on the site or a full URL.")
|
|
199
|
+
permanent = models.BooleanField(default=True, help_text="301 when on, 302 when off.")
|
|
200
|
+
|
|
201
|
+
class Meta:
|
|
202
|
+
ordering = ["old_path"]
|
|
203
|
+
|
|
204
|
+
def __str__(self):
|
|
205
|
+
return f"{self.old_path} → {self.new_path}"
|
|
206
|
+
|
|
207
|
+
def save(self, *args, **kwargs):
|
|
208
|
+
self.old_path = "/" + self.old_path.strip().strip("/")
|
|
209
|
+
super().save(*args, **kwargs)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@expose(fields=["id", "name", "email", "message", "created_at"])
|
|
213
|
+
class FormSubmission(models.Model):
|
|
214
|
+
"""A contact form submission. Anyone may create one (PUBLIC_CREATE); only editors read them."""
|
|
215
|
+
|
|
216
|
+
name = models.CharField(max_length=200)
|
|
217
|
+
email = models.EmailField()
|
|
218
|
+
message = models.TextField(max_length=5000)
|
|
219
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
220
|
+
|
|
221
|
+
class Meta:
|
|
222
|
+
ordering = ["-created_at"]
|
|
223
|
+
|
|
224
|
+
def __str__(self):
|
|
225
|
+
return f"{self.name} <{self.email}>"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# The media library, read-only: `?expand=cover` and blocks return media items
|
|
229
|
+
# with their URL and image sizes.
|
|
230
|
+
expose_model(
|
|
231
|
+
Media,
|
|
232
|
+
read_only=True,
|
|
233
|
+
fields=["id", "name", "alt_text", "type", "file", "metadata", "properties"],
|
|
234
|
+
)
|