@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.
Files changed (79) hide show
  1. package/README.md +27 -3
  2. package/dist/bin.js +2282 -2
  3. package/dist/index.d.ts +252 -1
  4. package/dist/index.js +1 -4
  5. package/dist/stack-CTyqYWCV.js +800 -0
  6. package/package.json +15 -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 +79 -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 +282 -0
  37. package/templates/base/env.example +83 -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,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
+ ]
@@ -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
+ )