Populate the client website template with catalog feature flags.

Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed.

Refs #1
Refs #2

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 07:55:26 -05:00
co-authored by Cursor
parent 45d0888d33
commit 787f0e48fb
297 changed files with 32534 additions and 3 deletions
View File
+11
View File
@@ -0,0 +1,11 @@
from django.contrib import admin
from blog.models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ("title", "is_published", "published_at", "author", "updated_at")
list_filter = ("is_published",)
prepopulated_fields = {"slug": ("title",)}
search_fields = ("title", "excerpt", "body")
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "blog"
verbose_name = "Blog"
def ready(self):
from blog import hooks
hooks.register()
+18
View File
@@ -0,0 +1,18 @@
from core.registry import register_feature, register_portal_nav, register_public_nav
def register() -> None:
register_feature("blog")
register_public_nav(
section="blog",
label="Blog",
url_name="blog:list",
order=40,
)
register_portal_nav(
section="blog",
label="Blog posts",
url_name="blog_portal:portal_list",
group="Content",
order=10,
)
+36
View File
@@ -0,0 +1,36 @@
# Generated by Django 6.1 on 2026-08-26 11:38
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Post',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=220, unique=True)),
('excerpt', models.TextField(blank=True)),
('body', models.TextField()),
('is_published', models.BooleanField(default=False)),
('published_at', models.DateTimeField(blank=True, null=True)),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blog_posts', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-published_at', '-created_at'],
},
),
]
View File
+45
View File
@@ -0,0 +1,45 @@
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Post(UUIDPrimaryKeyModel, TimeStampedModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
excerpt = models.TextField(blank=True)
body = models.TextField()
is_published = models.BooleanField(default=False)
published_at = models.DateTimeField(null=True, blank=True)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="blog_posts",
)
class Meta:
ordering = ["-published_at", "-created_at"]
def __str__(self) -> str:
return self.title
def get_absolute_url(self) -> str:
return reverse("blog:detail", kwargs={"slug": self.slug})
def save(self, *args, **kwargs):
if not self.slug:
base = slugify(self.title)[:200] or "post"
slug = base
n = 2
while Post.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base}-{n}"
n += 1
self.slug = slug
if self.is_published and self.published_at is None:
self.published_at = timezone.now()
super().save(*args, **kwargs)
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from blog import views
app_name = "blog_portal"
urlpatterns = [
path("", views.portal_list, name="portal_list"),
path("new/", views.portal_edit, name="portal_new"),
path("<uuid:pk>/", views.portal_edit, name="portal_edit"),
path("<uuid:pk>/delete/", views.portal_delete, name="portal_delete"),
]
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from blog import views
app_name = "blog"
urlpatterns = [
path("", views.post_list, name="list"),
path("<slug:slug>/", views.post_detail, name="detail"),
]
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<p><a href="{% url 'blog:list' %}">← Blog</a></p>
<h1>{{ post.title }}</h1>
<p class="muted">{% if post.published_at %}{{ post.published_at|date:"F j, Y" }}{% endif %}</p>
<div>{{ post.body|linebreaks }}</div>
</div>
</section>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Blog · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1 class="text-uppercase">Blog</h1>
{% for post in posts %}
<article style="margin:0 0 32px">
<h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
<p class="muted">{% if post.published_at %}{{ post.published_at|date:"F j, Y" }}{% endif %}</p>
<p>{{ post.excerpt|default:post.body|truncatewords:40 }}</p>
</article>
{% empty %}
<p>No posts yet.</p>
{% endfor %}
</div>
</section>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "portal_base.html" %}
{% block title %}{% if post %}Edit{% else %}New{% endif %} post · Portal{% endblock %}
{% block topbar_title %}{% if post %}Edit post{% else %}New post{% endif %}{% endblock %}
{% block portal_content %}
<form method="post" class="form-grid">
{% csrf_token %}
<div class="field"><label>Title</label><input name="title" required value="{{ post.title|default:'' }}"></div>
<div class="field"><label>Slug</label><input name="slug" value="{{ post.slug|default:'' }}" placeholder="auto from title"></div>
<div class="field"><label>Excerpt</label><textarea name="excerpt" style="min-height:64px">{{ post.excerpt|default:'' }}</textarea></div>
<div class="field"><label>Body</label><textarea name="body" required style="min-height:220px">{{ post.body|default:'' }}</textarea></div>
<label><input type="checkbox" name="is_published" {% if post.is_published %}checked{% endif %}> Published</label>
<button class="btn btn-primary" type="submit">Save</button>
</form>
{% if post %}
<form method="post" action="{% url 'blog_portal:portal_delete' post.pk %}" style="margin-top:24px" onsubmit="return confirm('Delete this post?');">
{% csrf_token %}
<button class="btn btn-ghost" type="submit">Delete</button>
</form>
{% endif %}
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "portal_base.html" %}
{% block title %}Blog posts · Portal{% endblock %}
{% block topbar_title %}Blog posts{% endblock %}
{% block portal_content %}
<p><a class="btn btn-primary" href="{% url 'blog_portal:portal_new' %}">New post</a></p>
<table class="table">
<thead><tr><th>Title</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for post in posts %}
<tr>
<td><a href="{% url 'blog_portal:portal_edit' post.pk %}">{{ post.title }}</a></td>
<td>{% if post.is_published %}Published{% else %}Draft{% endif %}</td>
<td><a href="{{ post.get_absolute_url }}">View</a></td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No posts yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from blog.models import Post
class BlogPublicTests(TestCase):
def test_list_hides_drafts(self):
Post.objects.create(title="Draft", body="x", is_published=False)
Post.objects.create(
title="Live",
body="hello",
is_published=True,
published_at=timezone.now(),
)
response = Client().get(reverse("blog:list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Live")
self.assertNotContains(response, "Draft")
def test_portal_requires_login(self):
response = Client().get(reverse("blog_portal:portal_list"))
self.assertEqual(response.status_code, 302)
class BlogPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("editor", password="test-pass-123")
self.client = Client()
self.client.login(username="editor", password="test-pass-123")
def test_create_published_post(self):
response = self.client.post(
reverse("blog_portal:portal_new"),
{"title": "Hello", "body": "World", "is_published": "on"},
)
self.assertEqual(response.status_code, 302)
post = Post.objects.get()
self.assertTrue(post.is_published)
self.assertEqual(post.slug, "hello")
+64
View File
@@ -0,0 +1,64 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.text import slugify
from django.views.decorators.http import require_http_methods, require_POST
from blog.models import Post
def post_list(request):
posts = Post.objects.filter(is_published=True)
return render(request, "blog/list.html", {"posts": posts})
def post_detail(request, slug):
post = get_object_or_404(Post, slug=slug, is_published=True)
return render(request, "blog/detail.html", {"post": post})
@login_required
def portal_list(request):
posts = Post.objects.all()
return render(request, "blog/portal/list.html", {"posts": posts})
@login_required
@require_http_methods(["GET", "POST"])
def portal_edit(request, pk=None):
post = get_object_or_404(Post, pk=pk) if pk else None
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
body = (request.POST.get("body") or "").strip()
excerpt = (request.POST.get("excerpt") or "").strip()
slug = (request.POST.get("slug") or "").strip()
is_published = request.POST.get("is_published") == "on"
if not title or not body:
messages.error(request, "Title and body are required.")
else:
if post is None:
post = Post(author=request.user)
post.title = title
post.body = body
post.excerpt = excerpt
post.slug = slugify(slug)[:220] if slug else ""
post.is_published = is_published
if is_published and post.published_at is None:
post.published_at = timezone.now()
if not is_published:
post.published_at = None
post.save()
messages.success(request, f'Saved “{post.title}”.')
return redirect("blog_portal:portal_list")
return render(request, "blog/portal/edit.html", {"post": post})
@login_required
@require_POST
def portal_delete(request, pk):
post = get_object_or_404(Post, pk=pk)
title = post.title
post.delete()
messages.success(request, f'Deleted “{title}”.')
return redirect("blog_portal:portal_list")