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

0
__init__.py Normal file
View File

14
app.py
View File

@@ -6,11 +6,11 @@ from quart import g, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.factory import create_base_app
from config import config
from models import KV
from shared.infrastructure.factory import create_base_app
from shared.config import config
from shared.models import KV
from suma_browser.app.bp import (
from bp import (
register_auth_bp,
register_blog_bp,
register_admin,
@@ -27,9 +27,9 @@ async def coop_context() -> dict:
- menu_items: direct DB query (coop owns this data)
- cart_count/cart_total: fetched from cart internal API
"""
from shared.context import base_context
from suma_browser.app.bp.menu_items.services.menu_items import get_all_menu_items
from shared.internal_api import get as api_get
from shared.infrastructure.context import base_context
from bp.menu_items.services.menu_items import get_all_menu_items
from shared.infrastructure.internal_api import get as api_get
ctx = await base_context()

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

83
config/app-config.yaml Normal file
View File

@@ -0,0 +1,83 @@
# App-wide settings
base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: Rose Ash
coop_root: /market
coop_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
coop: "http://localhost:8000"
market: "http://localhost:8001"
cart: "http://localhost:8002"
events: "http://localhost:8003"
cache:
fs_root: _snapshot # <- absolute path to your snapshot dir
categories:
allow:
Basics: basics
Branded Goods: branded-goods
Chilled: chilled
Frozen: frozen
Non-foods: non-foods
Supplements: supplements
Christmas: christmas
slugs:
skip:
- ""
- customer
- account
- checkout
- wishlist
- sales
- contact
- privacy-policy
- terms-and-conditions
- delivery
- catalogsearch
- quickorder
- apply
- search
- static
- media
section-titles:
- ingredients
- allergy information
- allergens
- nutritional information
- nutrition
- storage
- directions
- preparation
- serving suggestions
- origin
- country of origin
- recycling
- general information
- additional information
- a note about prices
blacklist:
category:
- branded-goods/alcoholic-drinks
- branded-goods/beers
- branded-goods/wines
- branded-goods/ciders
product:
- list-price-suma-current-suma-price-list-each-bk012-2-html
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
product-details:
- General Information
- A Note About Prices
# SumUp payment settings (fill these in for live usage)
sumup:
merchant_code: "ME4J6100"
currency: "GBP"
# Name of the environment variable that holds your SumUp API key
api_key_env: "SUMUP_API_KEY"
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
checkout_reference_prefix: 'dev-'

14
models/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
from .snippet import Snippet
from .tag_group import TagGroup, TagGroupTag
# Shared models — canonical definitions live in shared/models/
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
from shared.models.menu_item import MenuItem
from shared.models.kv import KV
from shared.models.magic_link import MagicLink
from shared.models.user import User

224
models/ghost_content.py Normal file
View File

@@ -0,0 +1,224 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import (
Integer,
String,
Text,
Boolean,
DateTime,
ForeignKey,
Column,
func,
)
from shared.db.base import Base # whatever your Base is
# from .author import Author # make sure imports resolve
# from ..app.blog.calendars.model import Calendar
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text())
visibility: Mapped[str] = mapped_column(String(32), default="public", nullable=False)
feature_image: Mapped[Optional[str]] = mapped_column(Text())
meta_title: Mapped[Optional[str]] = mapped_column(String(300))
meta_description: Mapped[Optional[str]] = mapped_column(Text())
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# NEW: posts relationship is now direct Post objects via PostTag
posts: Mapped[List["Post"]] = relationship(
"Post",
secondary="post_tags",
primaryjoin="Tag.id==post_tags.c.tag_id",
secondaryjoin="Post.id==post_tags.c.post_id",
back_populates="tags",
order_by="PostTag.sort_order",
)
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
html: Mapped[Optional[str]] = mapped_column(Text())
plaintext: Mapped[Optional[str]] = mapped_column(Text())
mobiledoc: Mapped[Optional[str]] = mapped_column(Text())
lexical: Mapped[Optional[str]] = mapped_column(Text())
feature_image: Mapped[Optional[str]] = mapped_column(Text())
feature_image_alt: Mapped[Optional[str]] = mapped_column(Text())
feature_image_caption: Mapped[Optional[str]] = mapped_column(Text())
excerpt: Mapped[Optional[str]] = mapped_column(Text())
custom_excerpt: Mapped[Optional[str]] = mapped_column(Text())
visibility: Mapped[str] = mapped_column(String(32), default="public", nullable=False)
status: Mapped[str] = mapped_column(String(32), default="draft", nullable=False)
featured: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False)
is_page: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False)
email_only: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False)
canonical_url: Mapped[Optional[str]] = mapped_column(Text())
meta_title: Mapped[Optional[str]] = mapped_column(String(500))
meta_description: Mapped[Optional[str]] = mapped_column(Text())
og_image: Mapped[Optional[str]] = mapped_column(Text())
og_title: Mapped[Optional[str]] = mapped_column(String(500))
og_description: Mapped[Optional[str]] = mapped_column(Text())
twitter_image: Mapped[Optional[str]] = mapped_column(Text())
twitter_title: Mapped[Optional[str]] = mapped_column(String(500))
twitter_description: Mapped[Optional[str]] = mapped_column(Text())
custom_template: Mapped[Optional[str]] = mapped_column(String(191))
reading_time: Mapped[Optional[int]] = mapped_column(Integer())
comment_id: Mapped[Optional[str]] = mapped_column(String(191))
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), index=True
)
publish_requested: Mapped[bool] = mapped_column(Boolean(), default=False, server_default="false", nullable=False)
primary_author_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("authors.id", ondelete="SET NULL")
)
primary_tag_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("tags.id", ondelete="SET NULL")
)
primary_author: Mapped[Optional["Author"]] = relationship(
"Author", foreign_keys=[primary_author_id]
)
primary_tag: Mapped[Optional[Tag]] = relationship(
"Tag", foreign_keys=[primary_tag_id]
)
user: Mapped[Optional["User"]] = relationship(
"User", foreign_keys=[user_id]
)
# AUTHORS RELATIONSHIP (many-to-many via post_authors)
authors: Mapped[List["Author"]] = relationship(
"Author",
secondary="post_authors",
primaryjoin="Post.id==post_authors.c.post_id",
secondaryjoin="Author.id==post_authors.c.author_id",
back_populates="posts",
order_by="PostAuthor.sort_order",
)
# TAGS RELATIONSHIP (many-to-many via post_tags)
tags: Mapped[List[Tag]] = relationship(
"Tag",
secondary="post_tags",
primaryjoin="Post.id==post_tags.c.post_id",
secondaryjoin="Tag.id==post_tags.c.tag_id",
back_populates="posts",
order_by="PostTag.sort_order",
)
likes: Mapped[List["PostLike"]] = relationship(
"PostLike",
back_populates="post",
cascade="all, delete-orphan",
passive_deletes=True,
)
menu_items: Mapped[List["MenuItem"]] = relationship(
"MenuItem",
back_populates="post",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="MenuItem.sort_order",
)
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(255))
profile_image: Mapped[Optional[str]] = mapped_column(Text())
cover_image: Mapped[Optional[str]] = mapped_column(Text())
bio: Mapped[Optional[str]] = mapped_column(Text())
website: Mapped[Optional[str]] = mapped_column(Text())
location: Mapped[Optional[str]] = mapped_column(Text())
facebook: Mapped[Optional[str]] = mapped_column(Text())
twitter: Mapped[Optional[str]] = mapped_column(Text())
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# backref to posts via post_authors
posts: Mapped[List[Post]] = relationship(
"Post",
secondary="post_authors",
primaryjoin="Author.id==post_authors.c.author_id",
secondaryjoin="Post.id==post_authors.c.post_id",
back_populates="authors",
order_by="PostAuthor.sort_order",
)
class PostAuthor(Base):
__tablename__ = "post_authors"
post_id: Mapped[int] = mapped_column(
ForeignKey("posts.id", ondelete="CASCADE"),
primary_key=True,
)
author_id: Mapped[int] = mapped_column(
ForeignKey("authors.id", ondelete="CASCADE"),
primary_key=True,
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
class PostTag(Base):
__tablename__ = "post_tags"
post_id: Mapped[int] = mapped_column(
ForeignKey("posts.id", ondelete="CASCADE"),
primary_key=True,
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"),
primary_key=True,
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
class PostLike(Base):
__tablename__ = "post_likes"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
post: Mapped["Post"] = relationship("Post", back_populates="likes", foreign_keys=[post_id])
user = relationship("User", back_populates="liked_posts")

View File

@@ -0,0 +1,12 @@
# Re-export from canonical shared location
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
__all__ = [
"GhostLabel", "UserLabel",
"GhostNewsletter", "UserNewsletter",
"GhostTier", "GhostSubscription",
]

4
models/kv.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.kv import KV
__all__ = ["KV"]

4
models/magic_link.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.magic_link import MagicLink
__all__ = ["MagicLink"]

4
models/menu_item.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.menu_item import MenuItem
__all__ = ["MenuItem"]

32
models/snippet.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from shared.db.base import Base
class Snippet(Base):
__tablename__ = "snippets"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
Index("ix_snippets_visibility", "visibility"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(
String(20), nullable=False, default="private", server_default="private",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
)

52
models/tag_group.py Normal file
View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import (
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
func,
)
from shared.db.base import Base
class TagGroup(Base):
__tablename__ = "tag_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)
feature_image: Mapped[Optional[str]] = mapped_column(Text())
colour: Mapped[Optional[str]] = mapped_column(String(32))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
tag_links: Mapped[List["TagGroupTag"]] = relationship(
"TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True
)
class TagGroupTag(Base):
__tablename__ = "tag_group_tags"
__table_args__ = (
UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tag_group_id: Mapped[int] = mapped_column(
ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
)
group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links")

4
models/user.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.user import User
__all__ = ["User"]

View File

@@ -1,7 +1,9 @@
import sys
import os
# Add the shared library submodule to the Python path
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
if _shared not in sys.path:
sys.path.insert(0, _shared)
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)