feat: decouple blog from shared_lib, add app-owned models

Phase 1-3 of decoupling:
- path_setup.py adds project root to sys.path
- Blog-owned models in blog/models/ (ghost_content, snippet, tag_group)
- Re-export shims for shared models (user, kv, magic_link, menu_item)
- All imports updated: shared.infrastructure, shared.db, shared.browser, etc.
- No more cross-app post_id FKs in calendar/market/page_config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-11 12:46:31 +00:00
parent 5053448ee2
commit a01016d8d5
33 changed files with 550 additions and 106 deletions

View File

@@ -11,10 +11,10 @@ from quart import (
request,
jsonify
)
from suma_browser.app.redis_cacher import clear_all_cache
from suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
from config import config
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.config import config
from datetime import datetime
def register(url_prefix):

View File

@@ -21,17 +21,17 @@ from ..blog.ghost.ghost_sync import (
sync_member_to_ghost,
)
from db.session import get_session
from models import User, MagicLink, UserNewsletter
from models.ghost_membership_entities import GhostNewsletter
from config import config
from utils import host_url
from shared.urls import coop_url
from shared.db.session import get_session
from shared.models import User, MagicLink, UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.config import config
from shared.utils import host_url
from shared.infrastructure.urls import coop_url
from sqlalchemy.orm import selectinload
from suma_browser.app.redis_cacher import clear_cache
from shared.cart_identity import current_cart_identity
from shared.internal_api import post as api_post
from shared.browser.app.redis_cacher import clear_cache
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.internal_api import post as api_post
from .services import pop_login_redirect_target, store_login_redirect_target
from .services.auth_operations import (
get_app_host,
@@ -84,7 +84,7 @@ def register(url_prefix="/auth"):
@auth_bp.get("/account/")
async def account():
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.utils.htmx import is_htmx_request
if not g.get("user"):
return redirect(host_url(url_for("auth.login_form")))
@@ -104,7 +104,7 @@ def register(url_prefix="/auth"):
@auth_bp.get("/newsletters/")
async def newsletters():
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.utils.htmx import is_htmx_request
if not g.get("user"):
return redirect(host_url(url_for("auth.login_form")))

View File

@@ -10,8 +10,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models import User, MagicLink, UserNewsletter
from config import config
from shared.models import User, MagicLink, UserNewsletter
from shared.config import config
def get_app_host() -> str:

View File

@@ -1,7 +1,7 @@
from urllib.parse import urlparse
from quart import session
from shared.urls import coop_url
from shared.infrastructure.urls import coop_url
LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to"

View File

@@ -12,9 +12,9 @@ from quart import (
)
from sqlalchemy import select, delete
from suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.redis_cacher import invalidate_tag_cache
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag

View File

@@ -2,10 +2,10 @@ from quart import request
from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import (
from shared.browser.app.filters.qs_base import (
KEEP, _norm, make_filter_set, build_qs,
)
from suma_browser.app.filters.query_types import BlogQuery
from shared.browser.app.filters.query_types import BlogQuery
def decode() -> BlogQuery:

View File

@@ -13,7 +13,7 @@ import httpx
from quart import Blueprint, request, jsonify, g
from sqlalchemy import select, or_
from suma_browser.app.authz import require_admin, require_login
from shared.browser.app.authz import require_admin, require_login
from models import Snippet
from .ghost_admin_token import make_ghost_admin_jwt

View File

@@ -13,11 +13,11 @@ from sqlalchemy.orm.attributes import flag_modified # for non-Mutable JSON colu
from models.ghost_content import (
Post, Author, Tag, PostAuthor, PostTag
)
from models.page_config import PageConfig
from cart.models.page_config import PageConfig
# User-centric membership models
from models import User
from models.ghost_membership_entities import (
from shared.models import User
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
@@ -29,7 +29,7 @@ from urllib.parse import quote
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
from suma_browser.app.utils import (
from shared.browser.app.utils import (
utcnow
)
@@ -242,10 +242,10 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[
# Auto-create PageConfig for pages
if obj.is_page:
existing_pc = (await sess.execute(
select(PageConfig).where(PageConfig.post_id == obj.id)
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == obj.id)
)).scalar_one_or_none()
if existing_pc is None:
sess.add(PageConfig(post_id=obj.id, features={}))
sess.add(PageConfig(container_type="page", container_id=obj.id, features={}))
await sess.flush()
return obj

View File

@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload
from models.ghost_content import Post, Author, Tag, PostTag
from models.page_config import PageConfig
from cart.models.page_config import PageConfig
from models.tag_group import TagGroup, TagGroupTag

View File

@@ -15,15 +15,15 @@ from quart import (
url_for,
)
from .ghost_db import DBClient # adjust import path
from db.session import get_session
from shared.db.session import get_session
from .filters.qs import makeqs_factory, decode
from .services.posts_data import posts_data
from .services.pages_data import pages_data
from suma_browser.app.redis_cacher import cache_page, invalidate_tag_cache
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.authz import require_admin
from utils import host_url
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_admin
from shared.utils import host_url
def register(url_prefix, title):
blogs_bp = Blueprint("blog", __name__, url_prefix)

View File

@@ -1,7 +1,7 @@
from ..ghost_db import DBClient # adjust import path
from sqlalchemy import select
from models.ghost_content import PostLike
from models.calendars import CalendarEntry, CalendarEntryPost
from events.models.calendars import CalendarEntry, CalendarEntryPost
from quart import g
async def posts_data(
@@ -89,11 +89,12 @@ async def posts_data(
# Get all confirmed entries associated with these posts
from sqlalchemy.orm import selectinload
entries_result = await session.execute(
select(CalendarEntry, CalendarEntryPost.post_id)
select(CalendarEntry, CalendarEntryPost.content_id)
.join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id)
.options(selectinload(CalendarEntry.calendar)) # Eagerly load calendar
.where(
CalendarEntryPost.post_id.in_(post_ids),
CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id.in_(post_ids),
CalendarEntryPost.deleted_at.is_(None),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "confirmed"

View File

@@ -10,8 +10,8 @@ from ..ghost.ghost_sync import (
sync_single_author,
sync_single_tag,
)
from suma_browser.app.redis_cacher import clear_cache
from suma_browser.app.csrf import csrf_exempt
from shared.browser.app.redis_cacher import clear_cache
from shared.browser.app.csrf import csrf_exempt
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")

View File

@@ -10,8 +10,8 @@ from quart import Blueprint, g, jsonify
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.menu_item import MenuItem
from suma_browser.app.csrf import csrf_exempt
from shared.models.menu_item import MenuItem
from shared.browser.app.csrf import csrf_exempt
def register() -> Blueprint:
@@ -53,7 +53,7 @@ def register() -> Blueprint:
Return a Ghost post's key fields by slug.
Called by market app for the landing page.
"""
from suma_browser.app.bp.blog.ghost_db import DBClient
from bp.blog.ghost_db import DBClient
client = DBClient(g.s)
posts = await client.posts_by_slug(slug, include_drafts=False)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from quart import Blueprint, render_template, make_response, request, jsonify, g
from suma_browser.app.authz import require_admin
from shared.browser.app.authz import require_admin
from .services.menu_items import (
get_all_menu_items,
get_menu_item_by_id,
@@ -12,7 +12,7 @@ from .services.menu_items import (
search_pages,
MenuItemError,
)
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from models.menu_item import MenuItem
from shared.models.menu_item import MenuItem
from models.ghost_content import Post

View File

@@ -10,9 +10,9 @@ from quart import (
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
from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.utils import host_url
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@@ -21,8 +21,8 @@ def register():
@bp.get("/")
@require_admin
async def admin(slug: str):
from suma_browser.app.utils.htmx import is_htmx_request
from models.page_config import PageConfig
from shared.browser.app.utils.htmx import is_htmx_request
from cart.models.page_config import PageConfig
from sqlalchemy import select as sa_select
# Load features for page admin
@@ -33,7 +33,7 @@ def register():
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.post_id == post["id"])
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post["id"])
)).scalar_one_or_none()
if pc:
features = pc.features or {}
@@ -62,7 +62,7 @@ def register():
@require_admin
async def update_features(slug: str):
"""Update PageConfig.features for a page."""
from models.page_config import PageConfig
from cart.models.page_config import PageConfig
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from quart import jsonify
@@ -76,10 +76,10 @@ def register():
# Load or create PageConfig
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.post_id == post_id)
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(post_id=post_id, features={})
pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
@@ -127,7 +127,7 @@ def register():
@require_admin
async def update_sumup(slug: str):
"""Update PageConfig SumUp credentials for a page."""
from models.page_config import PageConfig
from cart.models.page_config import PageConfig
from sqlalchemy import select as sa_select
from quart import jsonify
@@ -138,10 +138,10 @@ def register():
post_id = post["id"]
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.post_id == post_id)
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(post_id=post_id, features={})
pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
@@ -187,7 +187,7 @@ def register():
@require_admin
async def calendar_view(slug: str, calendar_id: int):
"""Show calendar month view for browsing entries"""
from models.calendars import Calendar
from events.models.calendars import Calendar
from sqlalchemy import select
from datetime import datetime, timezone
from quart import request
@@ -269,7 +269,7 @@ def register():
@require_admin
async def entries(slug: str):
from ..services.entry_associations import get_post_entry_ids
from models.calendars import Calendar
from events.models.calendars import Calendar
from sqlalchemy import select
post_id = g.post_data["post"]["id"]
@@ -305,7 +305,7 @@ def register():
@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 events.models.calendars import Calendar
from sqlalchemy import select
from quart import jsonify
@@ -339,7 +339,7 @@ def register():
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
.where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
@@ -389,7 +389,7 @@ def register():
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
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
form = await request.form
@@ -452,7 +452,7 @@ def register():
@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 shared.models.ghost_membership_entities import GhostNewsletter
from sqlalchemy import select as sa_select
ghost_id = g.post_data["post"]["ghost_id"]
@@ -487,7 +487,7 @@ def register():
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
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
form = await request.form
@@ -599,7 +599,7 @@ def register():
@require_admin
async def markets(slug: str):
"""List markets for this page."""
from models.market_place import MarketPlace
from market.models.market_place import MarketPlace
from sqlalchemy import select as sa_select
post = (g.post_data or {}).get("post", {})
@@ -609,7 +609,8 @@ def register():
page_markets = (await g.s.execute(
sa_select(MarketPlace).where(
MarketPlace.post_id == post_id,
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name)
)).scalars().all()
@@ -626,7 +627,7 @@ def register():
async def create_market(slug: str):
"""Create a new market for this page."""
from ..services.markets import create_market as _create_market, MarketError
from models.market_place import MarketPlace
from market.models.market_place import MarketPlace
from sqlalchemy import select as sa_select
from quart import jsonify
@@ -646,7 +647,8 @@ def register():
# Return updated markets list
page_markets = (await g.s.execute(
sa_select(MarketPlace).where(
MarketPlace.post_id == post_id,
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name)
)).scalars().all()
@@ -663,7 +665,7 @@ def register():
async def delete_market(slug: str, market_slug: str):
"""Soft-delete a market."""
from ..services.markets import soft_delete_market
from models.market_place import MarketPlace
from market.models.market_place import MarketPlace
from sqlalchemy import select as sa_select
from quart import jsonify
@@ -677,7 +679,8 @@ def register():
# Return updated markets list
page_markets = (await g.s.execute(
sa_select(MarketPlace).where(
MarketPlace.post_id == post_id,
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name)
)).scalars().all()

View File

@@ -11,16 +11,16 @@ from quart import (
)
from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from models.calendars import Calendar
from models.market_place import MarketPlace
from events.models.calendars import Calendar
from market.models.market_place import MarketPlace
from sqlalchemy import select
from suma_browser.app.redis_cacher import cache_page, clear_cache
from shared.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
from shared.config import config
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("post", __name__, url_prefix='/<slug>')
@@ -65,13 +65,13 @@ def register():
p_data = getattr(g, "post_data", None)
if p_data:
from .services.entry_associations import get_associated_entries
from shared.internal_api import get as api_get
from shared.infrastructure.internal_api import get as api_get
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))
.where(Calendar.container_type == "page", Calendar.container_id == db_post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
@@ -79,7 +79,7 @@ def register():
markets = (
await g.s.execute(
select(MarketPlace)
.where(MarketPlace.post_id == db_post_id, MarketPlace.deleted_at.is_(None))
.where(MarketPlace.container_type == "page", MarketPlace.container_id == db_post_id, MarketPlace.deleted_at.is_(None))
.order_by(MarketPlace.name.asc())
)
).scalars().all()
@@ -130,7 +130,7 @@ def register():
@bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str):
from utils import host_url
from shared.utils import host_url
# Get post_id from g.post_data
if not g.user:

View File

@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.sql import func
from models.calendars import CalendarEntry, CalendarEntryPost, Calendar
from events.models.calendars import CalendarEntry, CalendarEntryPost, Calendar
from models.ghost_content import Post
@@ -35,7 +35,8 @@ async def toggle_entry_association(
existing = await session.scalar(
select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
@@ -49,7 +50,8 @@ async def toggle_entry_association(
# Create association
association = CalendarEntryPost(
entry_id=entry_id,
post_id=post_id
content_type="post",
content_id=post_id
)
session.add(association)
await session.flush()
@@ -67,7 +69,8 @@ async def get_post_entry_ids(
result = await session.execute(
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
@@ -88,7 +91,8 @@ async def get_associated_entries(
entry_ids_result = await session.execute(
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)

View File

@@ -6,10 +6,10 @@ import unicodedata
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.market_place import MarketPlace
from market.models.market_place import MarketPlace
from models.ghost_content import Post
from models.page_config import PageConfig
from suma_browser.app.utils import utcnow
from cart.models.page_config import PageConfig
from shared.browser.app.utils import utcnow
class MarketError(ValueError):
@@ -43,14 +43,14 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
raise MarketError("Markets can only be created on pages, not posts.")
pc = (await sess.execute(
select(PageConfig).where(PageConfig.post_id == post_id)
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None or not (pc.features or {}).get("market"):
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
# Look for existing (including soft-deleted)
existing = (await sess.execute(
select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug)
select(MarketPlace).where(MarketPlace.container_type == "page", MarketPlace.container_id == post_id, MarketPlace.slug == slug)
)).scalar_one_or_none()
if existing:
@@ -61,7 +61,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
return existing
raise MarketError(f'Market with slug "{slug}" already exists for this page.')
market = MarketPlace(post_id=post_id, name=name, slug=slug)
market = MarketPlace(container_type="page", container_id=post_id, name=name, slug=slug)
sess.add(market)
await sess.flush()
return market
@@ -71,7 +71,8 @@ async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: st
market = (
await sess.execute(
select(MarketPlace)
.join(Post, MarketPlace.post_id == Post.id)
.join(Post, MarketPlace.container_id == Post.id)
.where(MarketPlace.container_type == "page")
.where(
Post.slug == post_slug,
MarketPlace.slug == market_slug,

View File

@@ -4,8 +4,8 @@ from quart import Blueprint, render_template, make_response, request, g, abort
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from suma_browser.app.authz import require_login
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from models import Snippet