feat: initialize blog app with blueprints and templates
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Extract blog-specific code from the coop monolith into a standalone
repository. Includes auth, blog, post, admin, menu_items, snippets
blueprints, associated templates, Dockerfile (APP_MODULE=app:app),
entrypoint, and Gitea CI workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-09 23:15:56 +00:00
commit 8f7a15186c
128 changed files with 9246 additions and 0 deletions

462
bp/post/admin/routes.py Normal file
View File

@@ -0,0 +1,462 @@
from __future__ import annotations
from quart import (
render_template,
make_response,
Blueprint,
g,
request,
redirect,
url_for,
)
from suma_browser.app.authz import require_admin, require_post_author
from suma_browser.app.utils.htmx import is_htmx_request
from utils import host_url
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.get("/")
@require_admin
async def admin(slug: str):
from suma_browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/post/admin/index.html")
else:
# HTMX request: main panel + OOB elements
html = await render_template("_types/post/admin/_oob_elements.html")
return await make_response(html)
@bp.get("/data/")
@require_admin
async def data(slug: str):
if not is_htmx_request():
html = await render_template(
"_types/post_data/index.html",
)
else:
html = await render_template(
"_types/post_data/_oob_elements.html",
)
return await make_response(html)
@bp.get("/entries/calendar/<int:calendar_id>/")
@require_admin
async def calendar_view(slug: str, calendar_id: int):
"""Show calendar month view for browsing entries"""
from models.calendars import Calendar
from sqlalchemy import select
from datetime import datetime, timezone
from quart import request
import calendar as pycalendar
from ...calendar.services.calendar_view import parse_int_arg, add_months, build_calendar_weeks
from ...calendar.services import get_visible_entries_for_period
from quart import session as qsession
from ..services.entry_associations import get_post_entry_ids
# Get month/year from query params
today = datetime.now(timezone.utc).date()
month = parse_int_arg("month")
year = parse_int_arg("year")
if year is None:
year = today.year
if month is None or not (1 <= month <= 12):
month = today.month
# Load calendar
result = await g.s.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.deleted_at.is_(None))
)
calendar_obj = result.scalar_one_or_none()
if not calendar_obj:
return await make_response("Calendar not found", 404)
# Build calendar data
prev_month_year, prev_month = add_months(year, month, -1)
next_month_year, next_month = add_months(year, month, +1)
prev_year = year - 1
next_year = year + 1
weeks = build_calendar_weeks(year, month)
month_name = pycalendar.month_name[month]
weekday_names = [pycalendar.day_abbr[i] for i in range(7)]
# Get entries for this month
period_start = datetime(year, month, 1, tzinfo=timezone.utc)
next_y, next_m = add_months(year, month, +1)
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
user = getattr(g, "user", None)
session_id = qsession.get("calendar_sid")
visible = await get_visible_entries_for_period(
sess=g.s,
calendar_id=calendar_obj.id,
period_start=period_start,
period_end=period_end,
user=user,
session_id=session_id,
)
# Get associated entry IDs for this post
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
html = await render_template(
"_types/post/admin/_calendar_view.html",
calendar=calendar_obj,
year=year,
month=month,
month_name=month_name,
weekday_names=weekday_names,
weeks=weeks,
prev_month=prev_month,
prev_month_year=prev_month_year,
next_month=next_month,
next_month_year=next_month_year,
prev_year=prev_year,
next_year=next_year,
month_entries=visible.merged_entries,
associated_entry_ids=associated_entry_ids,
)
return await make_response(html)
@bp.get("/entries/")
@require_admin
async def entries(slug: str):
from ..services.entry_associations import get_post_entry_ids
from models.calendars import Calendar
from sqlalchemy import select
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
# Load ALL calendars (not just this post's calendars)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
# Load entries and post for each calendar
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
if not is_htmx_request():
html = await render_template(
"_types/post_entries/index.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
else:
html = await render_template(
"_types/post_entries/_oob_elements.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
return await make_response(html)
@bp.post("/entries/<int:entry_id>/toggle/")
@require_admin
async def toggle_entry(slug: str, entry_id: int):
from ..services.entry_associations import toggle_entry_association, get_post_entry_ids, get_associated_entries
from models.calendars import Calendar
from sqlalchemy import select
from quart import jsonify
post_id = g.post_data["post"]["id"]
is_associated, error = await toggle_entry_association(g.s, post_id, entry_id)
if error:
return jsonify({"message": error, "errors": {}}), 400
await g.s.flush()
# Return updated association status
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
# Load ALL calendars
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
# Load entries and post for each calendar
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
# Fetch associated entries for nav display
associated_entries = await get_associated_entries(g.s, post_id)
# Load calendars for this post (for nav display)
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
# Return the associated entries admin list + OOB update for nav entries
admin_list = await render_template(
"_types/post/admin/_associated_entries.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
nav_entries_oob = await render_template(
"_types/post/admin/_nav_entries_oob.html",
associated_entries=associated_entries,
calendars=calendars,
post=g.post_data["post"],
)
return await make_response(admin_list + nav_entries_oob)
@bp.get("/settings/")
@require_post_author
async def settings(slug: str):
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_id = g.post_data["post"]["ghost_id"]
ghost_post = await get_post_for_edit(ghost_id)
save_success = request.args.get("saved") == "1"
if not is_htmx_request():
html = await render_template(
"_types/post_settings/index.html",
ghost_post=ghost_post,
save_success=save_success,
)
else:
html = await render_template(
"_types/post_settings/_oob_elements.html",
ghost_post=ghost_post,
save_success=save_success,
)
return await make_response(html)
@bp.post("/settings/")
@require_post_author
async def settings_save(slug: str):
from ...blog.ghost.ghost_posts import update_post_settings
from ...blog.ghost.ghost_sync import sync_single_post
from suma_browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
form = await request.form
updated_at = form.get("updated_at", "")
# Build kwargs — only include fields that were submitted
kwargs: dict = {}
# Text fields
for field in (
"slug", "custom_template", "meta_title", "meta_description",
"canonical_url", "og_image", "og_title", "og_description",
"twitter_image", "twitter_title", "twitter_description",
"feature_image_alt",
):
val = form.get(field)
if val is not None:
kwargs[field] = val.strip()
# Select fields
visibility = form.get("visibility")
if visibility is not None:
kwargs["visibility"] = visibility
# Datetime
published_at = form.get("published_at", "").strip()
if published_at:
kwargs["published_at"] = published_at
# Checkbox fields: present = True, absent = False
kwargs["featured"] = form.get("featured") == "on"
kwargs["email_only"] = form.get("email_only") == "on"
# Tags — comma-separated string → list of {"name": "..."} dicts
tags_str = form.get("tags", "").strip()
if tags_str:
kwargs["tags"] = [{"name": t.strip()} for t in tags_str.split(",") if t.strip()]
else:
kwargs["tags"] = []
# Update in Ghost
await update_post_settings(
ghost_id=ghost_id,
updated_at=updated_at,
**kwargs,
)
# Sync to local DB
await sync_single_post(g.s, ghost_id)
await g.s.flush()
# Clear caches
await invalidate_tag_cache("blog")
await invalidate_tag_cache("post.post_detail")
return redirect(host_url(url_for("blog.post.admin.settings", slug=slug)) + "?saved=1")
@bp.get("/edit/")
@require_post_author
async def edit(slug: str):
from ...blog.ghost.ghost_posts import get_post_for_edit
from models.ghost_membership_entities import GhostNewsletter
from sqlalchemy import select as sa_select
ghost_id = g.post_data["post"]["ghost_id"]
ghost_post = await get_post_for_edit(ghost_id)
save_success = request.args.get("saved") == "1"
newsletters = (await g.s.execute(
sa_select(GhostNewsletter).order_by(GhostNewsletter.name)
)).scalars().all()
if not is_htmx_request():
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_success=save_success,
newsletters=newsletters,
)
else:
html = await render_template(
"_types/post_edit/_oob_elements.html",
ghost_post=ghost_post,
save_success=save_success,
newsletters=newsletters,
)
return await make_response(html)
@bp.post("/edit/")
@require_post_author
async def edit_save(slug: str):
import json
from ...blog.ghost.ghost_posts import update_post
from ...blog.ghost.lexical_validator import validate_lexical
from ...blog.ghost.ghost_sync import sync_single_post
from suma_browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
form = await request.form
title = form.get("title", "").strip()
lexical_raw = form.get("lexical", "")
updated_at = form.get("updated_at", "")
status = form.get("status", "draft")
publish_mode = form.get("publish_mode", "web")
newsletter_slug = form.get("newsletter_slug", "").strip() or None
feature_image = form.get("feature_image", "").strip()
custom_excerpt = form.get("custom_excerpt", "").strip()
feature_image_caption = form.get("feature_image_caption", "").strip()
# Validate the lexical JSON
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_error="Invalid JSON in editor content.",
)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_error=reason,
)
return await make_response(html, 400)
# Update in Ghost (content save — no status change yet)
ghost_post = await update_post(
ghost_id=ghost_id,
lexical_json=lexical_raw,
title=title or None,
updated_at=updated_at,
feature_image=feature_image,
custom_excerpt=custom_excerpt,
feature_image_caption=feature_image_caption,
)
# Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin"))
publish_requested_msg = None
# Guard: if already emailed, force publish_mode to "web" to prevent re-send
already_emailed = bool(ghost_post.get("email") and ghost_post["email"].get("status"))
if already_emailed and publish_mode in ("email", "both"):
publish_mode = "web"
if status == "published" and ghost_post.get("status") != "published" and not is_admin:
# Non-admin requesting publish: don't send status to Ghost, set local flag
publish_requested_msg = "Publish requested — an admin will review."
elif status and status != ghost_post.get("status"):
# Status is changing — determine email params based on publish_mode
email_kwargs: dict = {}
if status == "published" and publish_mode in ("email", "both") and newsletter_slug:
email_kwargs["newsletter_slug"] = newsletter_slug
email_kwargs["email_segment"] = "all"
if publish_mode == "email":
email_kwargs["email_only"] = True
from ...blog.ghost.ghost_posts import update_post as _up
ghost_post = await _up(
ghost_id=ghost_id,
lexical_json=lexical_raw,
title=None,
updated_at=ghost_post["updated_at"],
status=status,
**email_kwargs,
)
# Sync to local DB
await sync_single_post(g.s, ghost_id)
await g.s.flush()
# Handle publish_requested flag on the local post
from models.ghost_content import Post
from sqlalchemy import select as sa_select
local_post = (await g.s.execute(
sa_select(Post).where(Post.ghost_id == ghost_id)
)).scalar_one_or_none()
if local_post:
if publish_requested_msg:
local_post.publish_requested = True
elif status == "published" and is_admin:
local_post.publish_requested = False
await g.s.flush()
# Clear caches
await invalidate_tag_cache("blog")
await invalidate_tag_cache("post.post_detail")
# Redirect to GET to avoid resubmit warning on refresh (PRG pattern)
redirect_url = host_url(url_for("blog.post.admin.edit", slug=slug)) + "?saved=1"
if publish_requested_msg:
redirect_url += "&publish_requested=1"
return redirect(redirect_url)
return bp

158
bp/post/routes.py Normal file
View File

@@ -0,0 +1,158 @@
from __future__ import annotations
from quart import (
render_template,
make_response,
g,
Blueprint,
abort,
url_for,
)
from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from models.calendars import Calendar
from sqlalchemy import select
from suma_browser.app.redis_cacher import cache_page, clear_cache
from .admin.routes import register as register_admin
from config import config
from suma_browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("post", __name__, url_prefix='/<slug>')
bp.register_blueprint(
register_admin()
)
# Calendar blueprints now live in the events service.
# Post pages link to events_url() instead of embedding calendars.
@bp.url_value_preprocessor
def pull_blog(endpoint, values):
g.post_slug = values.get("slug")
@bp.before_request
async def hydrate_post_data():
slug = getattr(g, "post_slug", None)
if not slug:
return # not a blog route or no slug in this URL
is_admin = bool((g.get("rights") or {}).get("admin"))
# Always include drafts so we can check ownership below
p_data = await post_data(slug, g.s, include_drafts=True)
if not p_data:
abort(404)
return
# Access control for draft posts
if p_data["post"].get("status") != "published":
if is_admin:
pass # admin can see all drafts
elif g.user and p_data["post"].get("user_id") == g.user.id:
pass # author can see their own drafts
else:
abort(404)
return
g.post_data = p_data
@bp.context_processor
async def context():
p_data = getattr(g, "post_data", None)
if p_data:
from .services.entry_associations import get_associated_entries
db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.post_id == db_post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
# Fetch associated entries for nav display
associated_entries = await get_associated_entries(g.s, db_post_id)
return {
**p_data,
"base_title": f"{config()['title']} {p_data['post']['title']}",
"calendars": calendars,
"associated_entries": associated_entries,
}
else:
return {}
@bp.get("/")
@cache_page(tag="post.post_detail")
async def post_detail(slug: str):
# Determine which template to use based on request type
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/post/index.html")
else:
# HTMX request: main panel + OOB elements
html = await render_template("_types/post/_oob_elements.html")
return await make_response(html)
@bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str):
from utils import host_url
# Get post_id from g.post_data
if not g.user:
html = await render_template(
"_types/browse/like/button.html",
slug=slug,
liked=False,
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
item_type='post',
)
resp = make_response(html, 403)
return resp
post_id = g.post_data["post"]["id"]
user_id = g.user.id
liked, error = await toggle_post_like(g.s, user_id, post_id)
if error:
resp = make_response(error, 404)
return resp
html = await render_template(
"_types/browse/like/button.html",
slug=slug,
liked=liked,
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
item_type='post',
)
return html
@bp.get("/entries/")
async def get_entries(slug: str):
"""Get paginated associated entries for infinite scroll in nav"""
from .services.entry_associations import get_associated_entries
from quart import request
page = int(request.args.get("page", 1))
post_id = g.post_data["post"]["id"]
result = await get_associated_entries(g.s, post_id, page=page, per_page=10)
html = await render_template(
"_types/post/_entry_items.html",
entries=result["entries"],
page=result["page"],
has_more=result["has_more"],
)
return await make_response(html)
return bp

View File

@@ -0,0 +1,143 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.sql import func
from models.calendars import CalendarEntry, CalendarEntryPost, Calendar
from models.ghost_content import Post
async def toggle_entry_association(
session: AsyncSession,
post_id: int,
entry_id: int
) -> tuple[bool, str | None]:
"""
Toggle association between a post and calendar entry.
Returns (is_now_associated, error_message).
"""
# Check if entry exists (don't filter by deleted_at - allow associating with any entry)
entry = await session.scalar(
select(CalendarEntry).where(CalendarEntry.id == entry_id)
)
if not entry:
return False, f"Calendar entry {entry_id} not found in database"
# Check if post exists
post = await session.scalar(
select(Post).where(Post.id == post_id)
)
if not post:
return False, "Post not found"
# Check if association already exists
existing = await session.scalar(
select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
if existing:
# Remove association (soft delete)
existing.deleted_at = func.now()
await session.flush()
return False, None
else:
# Create association
association = CalendarEntryPost(
entry_id=entry_id,
post_id=post_id
)
session.add(association)
await session.flush()
return True, None
async def get_post_entry_ids(
session: AsyncSession,
post_id: int
) -> set[int]:
"""
Get all entry IDs associated with this post.
Returns a set of entry IDs.
"""
result = await session.execute(
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
return set(result.scalars().all())
async def get_associated_entries(
session: AsyncSession,
post_id: int,
page: int = 1,
per_page: int = 10
) -> dict:
"""
Get paginated associated entries for this post.
Returns dict with entries, total_count, and has_more.
"""
# Get all associated entry IDs
entry_ids_result = await session.execute(
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
entry_ids = set(entry_ids_result.scalars().all())
if not entry_ids:
return {
"entries": [],
"total_count": 0,
"has_more": False,
"page": page,
}
# Get total count
from sqlalchemy import func
total_count = len(entry_ids)
# Get paginated entries ordered by start_at desc
# Only include confirmed entries
offset = (page - 1) * per_page
result = await session.execute(
select(CalendarEntry)
.where(
CalendarEntry.id.in_(entry_ids),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "confirmed" # Only confirmed entries in nav
)
.order_by(CalendarEntry.start_at.desc())
.limit(per_page)
.offset(offset)
)
entries = result.scalars().all()
# Recalculate total_count based on confirmed entries only
total_count = len(entries) + offset # Rough estimate
if len(entries) < per_page:
total_count = offset + len(entries)
# Load calendar relationship for each entry
for entry in entries:
await session.refresh(entry, ["calendar"])
if entry.calendar:
await session.refresh(entry.calendar, ["post"])
has_more = len(entries) == per_page # More accurate check
return {
"entries": entries,
"total_count": total_count,
"has_more": has_more,
"page": page,
}

View File

@@ -0,0 +1,42 @@
from ...blog.ghost_db import DBClient # adjust import path
from sqlalchemy import select
from models.ghost_content import PostLike
from quart import g
async def post_data(slug, session, include_drafts=False):
client = DBClient(session)
posts = (await client.posts_by_slug(slug, include_drafts=include_drafts))
if not posts:
# 404 page (you can make a template for this if you want)
return None
post, original_post = posts[0]
# Check if current user has liked this post
is_liked = False
if g.user:
liked_record = await session.scalar(
select(PostLike).where(
PostLike.user_id == g.user.id,
PostLike.post_id == post["id"],
PostLike.deleted_at.is_(None),
)
)
is_liked = liked_record is not None
# Add is_liked to post dict
post["is_liked"] = is_liked
tags=await client.list_tags(
limit=50000
) # <-- new
authors=await client.list_authors(
limit=50000
)
return {
"post": post,
"original_post": original_post,
"tags": tags,
"authors": authors,
}

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.ghost_content import Post, PostLike
async def toggle_post_like(
session: AsyncSession,
user_id: int,
post_id: int,
) -> tuple[bool, Optional[str]]:
"""
Toggle a post like for a given user using soft deletes.
Returns (liked_state, error_message).
- If error_message is not None, an error occurred.
- liked_state indicates whether post is now liked (True) or unliked (False).
"""
# Verify post exists
post_exists = await session.scalar(
select(Post.id).where(Post.id == post_id, Post.deleted_at.is_(None))
)
if not post_exists:
return False, "Post not found"
# Check if like exists (not deleted)
existing = await session.scalar(
select(PostLike).where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
)
if existing:
# Unlike: soft delete the like
await session.execute(
update(PostLike)
.where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
.values(deleted_at=func.now())
)
return False, None
else:
# Like: add a new like
new_like = PostLike(
user_id=user_id,
post_id=post_id,
)
session.add(new_like)
return True, None