Eliminate blog settings page helpers — pure .sx defpages with service data
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m32s

Convert 6 blog settings pages (settings-home, cache, snippets, menu-items,
tag-groups, tag-group-edit) from Python page helpers to .sx defpages with
(service "blog-page" ...) IO primitives. Create data-driven defcomps that
handle iteration via (map ...) instead of Python loops.

Post-related page helpers (editor, post-admin/data/preview/entries/settings/edit)
remain as Python helpers — they depend on _ensure_post_data and sx_components
rendering functions that need separate conversion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 07:50:24 +00:00
parent 418ac9424f
commit e53e8cc1f7
5 changed files with 368 additions and 113 deletions

View File

@@ -76,3 +76,6 @@ def register_domain_services() -> None:
if not services.has("federation"): if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService() services.federation = SqlFederationService()
from .blog_page import BlogPageService
services.register("blog_page", BlogPageService())

234
blog/services/blog_page.py Normal file
View File

@@ -0,0 +1,234 @@
"""Blog page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
class BlogPageService:
"""Service for blog page data, callable via (service "blog-page" ...)."""
async def cache_data(self, session, **kw):
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
return {
"clear_url": qurl("settings.cache_clear"),
"csrf": generate_csrf_token(),
}
async def snippets_data(self, session, **kw):
from quart import g, url_for as qurl
from sqlalchemy import select, or_
from models import Snippet
from shared.browser.app.csrf import generate_csrf_token
uid = g.user.id
is_admin = g.rights.get("admin")
csrf = generate_csrf_token()
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await session.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
snippets = []
for s in rows:
s_id = s.id
s_vis = s.visibility or "private"
s_uid = s.user_id
owner = "You" if s_uid == uid else f"User #{s_uid}"
can_delete = s_uid == uid or is_admin
d = {
"id": s_id,
"name": s.name or "",
"visibility": s_vis,
"owner": owner,
"can_delete": can_delete,
}
if is_admin:
d["patch_url"] = qurl("snippets.patch_visibility", snippet_id=s_id)
if can_delete:
d["delete_url"] = qurl("snippets.delete_snippet", snippet_id=s_id)
snippets.append(d)
return {
"snippets": snippets,
"is_admin": bool(is_admin),
"csrf": csrf,
}
async def menu_items_data(self, session, **kw):
from quart import url_for as qurl
from bp.menu_items.services.menu_items import get_all_menu_items
from shared.browser.app.csrf import generate_csrf_token
menu_items = await get_all_menu_items(session)
csrf = generate_csrf_token()
items = []
for mi in menu_items:
i_id = mi.id
label = mi.label or ""
fi = getattr(mi, "feature_image", None)
sort = mi.position or 0
items.append({
"id": i_id,
"label": label,
"url": mi.url or "",
"sort_order": sort,
"feature_image": fi,
"edit_url": qurl("menu_items.edit_menu_item", item_id=i_id),
"delete_url": qurl("menu_items.delete_menu_item_route", item_id=i_id),
})
return {
"menu_items": items,
"new_url": qurl("menu_items.new_menu_item"),
"csrf": csrf,
}
async def tag_groups_data(self, session, **kw):
from quart import url_for as qurl
from sqlalchemy import select
from models.tag_group import TagGroup
from bp.blog.admin.routes import _unassigned_tags
from shared.browser.app.csrf import generate_csrf_token
groups_rows = list(
(await session.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(session)
groups = []
for g in groups_rows:
groups.append({
"id": g.id,
"name": g.name or "",
"slug": getattr(g, "slug", "") or "",
"feature_image": getattr(g, "feature_image", None),
"colour": getattr(g, "colour", None),
"sort_order": getattr(g, "sort_order", 0) or 0,
"edit_href": qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g.id),
})
unassigned_tags = []
for t in unassigned:
unassigned_tags.append({
"name": getattr(t, "name", "") if hasattr(t, "name") else t.get("name", ""),
})
return {
"groups": groups,
"unassigned_tags": unassigned_tags,
"create_url": qurl("blog.tag_groups_admin.create"),
"csrf": generate_csrf_token(),
}
async def tag_group_edit_data(self, session, *, id=None, **kw):
from quart import abort, url_for as qurl
from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
from shared.browser.app.csrf import generate_csrf_token
tg = await session.get(TagGroup, id)
if not tg:
abort(404)
assigned_rows = list(
(await session.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
)).scalars()
)
assigned_set = set(assigned_rows)
all_tags_rows = list(
(await session.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
all_tags = []
for t in all_tags_rows:
all_tags.append({
"id": t.id,
"name": getattr(t, "name", "") or "",
"feature_image": getattr(t, "feature_image", None),
"checked": t.id in assigned_set,
})
return {
"group": {
"id": tg.id,
"name": tg.name or "",
"colour": getattr(tg, "colour", "") or "",
"sort_order": getattr(tg, "sort_order", 0) or 0,
"feature_image": getattr(tg, "feature_image", "") or "",
},
"all_tags": all_tags,
"save_url": qurl("blog.tag_groups_admin.save", id=tg.id),
"delete_url": qurl("blog.tag_groups_admin.delete_group", id=tg.id),
"csrf": generate_csrf_token(),
}
async def post_admin_data(self, session, *, slug=None, **kw):
"""Post admin panel — just needs post loaded into context."""
from quart import g
from sqlalchemy import select
from shared.models.page_config import PageConfig
# _ensure_post_data is called by before_request in defpage context
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
if post.get("is_page"):
pc = (await session.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
return {
"features": features,
"sumup_configured": sumup_configured,
}
async def preview_data(self, session, *, slug=None, **kw):
"""Build preview data with prettified/rendered content."""
from quart import g
from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await session.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
result = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
result["sx_pretty"] = sx_to_pretty_sx(sx_content)
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
result["json_pretty"] = json_to_pretty_sx(lexical_raw)
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
result["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
result["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
result["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
result["lex_rendered"] = "<em>Error rendering lexical</em>"
return result

View File

@@ -169,3 +169,117 @@
(details :class "border rounded bg-white" (details :class "border rounded bg-white"
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title) (summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
(div :class "p-4 overflow-x-auto text-xs" content))) (div :class "p-4 overflow-x-auto text-xs" content)))
;; ---------------------------------------------------------------------------
;; Data-driven content defcomps (called from defpages with service data)
;; ---------------------------------------------------------------------------
;; Snippets — receives serialized snippet dicts from service
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
(~blog-snippets-panel
:list (if (empty? (or snippets (list)))
(~empty-state :icon "fa fa-puzzle-piece"
:message "No snippets yet. Create one from the blog editor.")
(~blog-snippets-list
:rows (map (lambda (s)
(let* ((badge-colours (dict
"private" "bg-stone-200 text-stone-700"
"shared" "bg-blue-100 text-blue-700"
"admin" "bg-amber-100 text-amber-700"))
(vis (or (get s "visibility") "private"))
(badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
(name (get s "name"))
(owner (get s "owner"))
(can-delete (get s "can_delete")))
(~blog-snippet-row
:name name :owner owner :badge-cls badge-cls :visibility vis
:extra (<>
(when is-admin
(~blog-snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:options (<>
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
(when can-delete
(~delete-btn
:url (get s "delete_url")
:trigger-target "#snippets-list"
:title "Delete snippet?"
:text (str "Delete \u201c" name "\u201d?")
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
(or snippets (list)))))))
;; Menu Items — receives serialized menu item dicts from service
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
(~blog-menu-items-panel
:new-url new-url
:list (if (empty? (or menu-items (list)))
(~empty-state :icon "fa fa-inbox"
:message "No menu items yet. Add one to get started!")
(~blog-menu-items-list
:rows (map (lambda (mi)
(~blog-menu-item-row
:img (~img-or-placeholder
:src (get mi "feature_image") :alt (get mi "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
:label (get mi "label")
:slug (get mi "url")
:sort-order (str (or (get mi "sort_order") 0))
:edit-url (get mi "edit_url")
:delete-url (get mi "delete_url")
:confirm-text (str "Remove " (get mi "label") " from the menu?")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")))
(or menu-items (list)))))))
;; Tag Groups — receives serialized tag group data from service
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
(~blog-tag-groups-list
:items (map (lambda (g)
(let* ((fi (get g "feature_image"))
(colour (get g "colour"))
(name (get g "name"))
(initial (slice (or name "?") 0 1))
(icon (if fi
(~blog-tag-group-icon-image :src fi :name name)
(~blog-tag-group-icon-color
:style (if colour (str "background:" colour) "background:#e7e5e4")
:initial initial))))
(~blog-tag-group-li
:icon icon
:edit-href (get g "edit_href")
:name name
:slug (or (get g "slug") "")
:sort-order (or (get g "sort_order") 0))))
(or groups (list)))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
:spans (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
(or unassigned-tags (list)))))))
;; Tag Group Edit — receives serialized tag group + tags from service
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
(~blog-tag-group-edit-main
:edit-form (~blog-tag-group-edit-form
:save-url save-url :csrf csrf
:name (get group "name")
:colour (get group "colour")
:sort-order (get group "sort_order")
:feature-image (get group "feature_image")
:tags (map (lambda (t)
(~blog-tag-checkbox
:tag-id (get t "id")
:checked (get t "checked")
:img (when (get t "feature_image")
(~blog-tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or all-tags (list))))
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))

View File

@@ -289,12 +289,6 @@ def _register_blog_helpers() -> None:
"post-entries-content": _h_post_entries_content, "post-entries-content": _h_post_entries_content,
"post-settings-content": _h_post_settings_content, "post-settings-content": _h_post_settings_content,
"post-edit-content": _h_post_edit_content, "post-edit-content": _h_post_edit_content,
"settings-content": _h_settings_content,
"cache-content": _h_cache_content,
"snippets-content": _h_snippets_content,
"menu-items-content": _h_menu_items_content,
"tag-groups-content": _h_tag_groups_content,
"tag-group-edit-content": _h_tag_group_edit_content,
}) })
@@ -471,104 +465,3 @@ async def _h_post_edit_content(slug=None, **kw):
return await _post_edit_content_sx(tctx) return await _post_edit_content_sx(tctx)
# --- Settings helpers ---
async def _h_settings_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
return _settings_main_panel_sx(tctx)
async def _h_cache_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
return await _cache_main_panel_sx(tctx)
# --- Snippets helper ---
async def _h_snippets_content(**kw):
from quart import g
from sqlalchemy import select, or_
from models import Snippet
uid = g.user.id
is_admin = g.rights.get("admin")
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await g.s.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
from shared.sx.page import get_template_context
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = rows
tctx["is_admin"] = is_admin
return await _snippets_main_panel_sx(tctx)
# --- Menu Items helper ---
async def _h_menu_items_content(**kw):
from quart import g
from bp.menu_items.services.menu_items import get_all_menu_items
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
return await _menu_items_main_panel_sx(tctx)
# --- Tag Groups helpers ---
async def _h_tag_groups_content(**kw):
from quart import g
from sqlalchemy import select
from models.tag_group import TagGroup
from bp.blog.admin.routes import _unassigned_tags
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_main_panel_sx
tctx = await get_template_context()
tctx.update({"groups": groups, "unassigned_tags": unassigned})
return await _tag_groups_main_panel_sx(tctx)
async def _h_tag_group_edit_content(id=None, **kw):
from quart import g, abort
from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
tg = await g.s.get(TagGroup, id)
if not tg:
abort(404)
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
)).scalars()
)
all_tags = list(
(await g.s.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_edit_main_panel_sx
tctx = await get_template_context()
tctx.update({
"group": tg,
"all_tags": all_tags,
"assigned_tag_ids": set(assigned_rows),
})
return await _tag_groups_edit_main_panel_sx(tctx)

View File

@@ -59,13 +59,14 @@
:path "/settings/" :path "/settings/"
:auth :admin :auth :admin
:layout :blog-settings :layout :blog-settings
:content (settings-content)) :content (div :class "max-w-2xl mx-auto px-4 py-6"))
(defpage cache-page (defpage cache-page
:path "/settings/cache/" :path "/settings/cache/"
:auth :admin :auth :admin
:layout :blog-cache :layout :blog-cache
:content (cache-content)) :data (service "blog-page" "cache-data")
:content (~blog-cache-panel :clear-url clear-url :csrf csrf))
; --- Snippets --- ; --- Snippets ---
@@ -73,7 +74,9 @@
:path "/settings/snippets/" :path "/settings/snippets/"
:auth :login :auth :login
:layout :blog-snippets :layout :blog-snippets
:content (snippets-content)) :data (service "blog-page" "snippets-data")
:content (~blog-snippets-content
:snippets snippets :is-admin is-admin :csrf csrf))
; --- Menu Items --- ; --- Menu Items ---
@@ -81,7 +84,9 @@
:path "/settings/menu_items/" :path "/settings/menu_items/"
:auth :admin :auth :admin
:layout :blog-menu-items :layout :blog-menu-items
:content (menu-items-content)) :data (service "blog-page" "menu-items-data")
:content (~blog-menu-items-content
:menu-items menu-items :new-url new-url :csrf csrf))
; --- Tag Groups --- ; --- Tag Groups ---
@@ -89,10 +94,16 @@
:path "/settings/tag-groups/" :path "/settings/tag-groups/"
:auth :admin :auth :admin
:layout :blog-tag-groups :layout :blog-tag-groups
:content (tag-groups-content)) :data (service "blog-page" "tag-groups-data")
:content (~blog-tag-groups-content
:groups groups :unassigned-tags unassigned-tags
:create-url create-url :csrf csrf))
(defpage tag-group-edit (defpage tag-group-edit
:path "/settings/tag-groups/<int:id>/" :path "/settings/tag-groups/<int:id>/"
:auth :admin :auth :admin
:layout :blog-tag-group-edit :layout :blog-tag-group-edit
:content (tag-group-edit-content id)) :data (service "blog-page" "tag-group-edit-data" :id id)
:content (~blog-tag-group-edit-content
:group group :all-tags all-tags
:save-url save-url :delete-url delete-url :csrf csrf))