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:
0
__init__.py
Normal file
0
__init__.py
Normal file
14
app.py
14
app.py
@@ -6,11 +6,11 @@ from quart import g, request
|
|||||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from shared.factory import create_base_app
|
from shared.infrastructure.factory import create_base_app
|
||||||
from config import config
|
from shared.config import config
|
||||||
from models import KV
|
from shared.models import KV
|
||||||
|
|
||||||
from suma_browser.app.bp import (
|
from bp import (
|
||||||
register_auth_bp,
|
register_auth_bp,
|
||||||
register_blog_bp,
|
register_blog_bp,
|
||||||
register_admin,
|
register_admin,
|
||||||
@@ -27,9 +27,9 @@ async def coop_context() -> dict:
|
|||||||
- menu_items: direct DB query (coop owns this data)
|
- menu_items: direct DB query (coop owns this data)
|
||||||
- cart_count/cart_total: fetched from cart internal API
|
- cart_count/cart_total: fetched from cart internal API
|
||||||
"""
|
"""
|
||||||
from shared.context import base_context
|
from shared.infrastructure.context import base_context
|
||||||
from suma_browser.app.bp.menu_items.services.menu_items import get_all_menu_items
|
from bp.menu_items.services.menu_items import get_all_menu_items
|
||||||
from shared.internal_api import get as api_get
|
from shared.infrastructure.internal_api import get as api_get
|
||||||
|
|
||||||
ctx = await base_context()
|
ctx = await base_context()
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ from quart import (
|
|||||||
request,
|
request,
|
||||||
jsonify
|
jsonify
|
||||||
)
|
)
|
||||||
from suma_browser.app.redis_cacher import clear_all_cache
|
from shared.browser.app.redis_cacher import clear_all_cache
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from config import config
|
from shared.config import config
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
def register(url_prefix):
|
def register(url_prefix):
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ from ..blog.ghost.ghost_sync import (
|
|||||||
sync_member_to_ghost,
|
sync_member_to_ghost,
|
||||||
)
|
)
|
||||||
|
|
||||||
from db.session import get_session
|
from shared.db.session import get_session
|
||||||
from models import User, MagicLink, UserNewsletter
|
from shared.models import User, MagicLink, UserNewsletter
|
||||||
from models.ghost_membership_entities import GhostNewsletter
|
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||||
from config import config
|
from shared.config import config
|
||||||
from utils import host_url
|
from shared.utils import host_url
|
||||||
from shared.urls import coop_url
|
from shared.infrastructure.urls import coop_url
|
||||||
|
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
from shared.cart_identity import current_cart_identity
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
from shared.internal_api import post as api_post
|
from shared.infrastructure.internal_api import post as api_post
|
||||||
from .services import pop_login_redirect_target, store_login_redirect_target
|
from .services import pop_login_redirect_target, store_login_redirect_target
|
||||||
from .services.auth_operations import (
|
from .services.auth_operations import (
|
||||||
get_app_host,
|
get_app_host,
|
||||||
@@ -84,7 +84,7 @@ def register(url_prefix="/auth"):
|
|||||||
|
|
||||||
@auth_bp.get("/account/")
|
@auth_bp.get("/account/")
|
||||||
async def 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"):
|
if not g.get("user"):
|
||||||
return redirect(host_url(url_for("auth.login_form")))
|
return redirect(host_url(url_for("auth.login_form")))
|
||||||
@@ -104,7 +104,7 @@ def register(url_prefix="/auth"):
|
|||||||
|
|
||||||
@auth_bp.get("/newsletters/")
|
@auth_bp.get("/newsletters/")
|
||||||
async def 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"):
|
if not g.get("user"):
|
||||||
return redirect(host_url(url_for("auth.login_form")))
|
return redirect(host_url(url_for("auth.login_form")))
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models import User, MagicLink, UserNewsletter
|
from shared.models import User, MagicLink, UserNewsletter
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
|
|
||||||
def get_app_host() -> str:
|
def get_app_host() -> str:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from quart import session
|
from quart import session
|
||||||
|
|
||||||
from shared.urls import coop_url
|
from shared.infrastructure.urls import coop_url
|
||||||
|
|
||||||
|
|
||||||
LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to"
|
LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to"
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ from quart import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from suma_browser.app.redis_cacher import invalidate_tag_cache
|
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||||
|
|
||||||
from models.tag_group import TagGroup, TagGroupTag
|
from models.tag_group import TagGroup, TagGroupTag
|
||||||
from models.ghost_content import Tag
|
from models.ghost_content import Tag
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ from quart import request
|
|||||||
|
|
||||||
from typing import Iterable, Optional, Union
|
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,
|
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:
|
def decode() -> BlogQuery:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import httpx
|
|||||||
from quart import Blueprint, request, jsonify, g
|
from quart import Blueprint, request, jsonify, g
|
||||||
from sqlalchemy import select, or_
|
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 models import Snippet
|
||||||
from .ghost_admin_token import make_ghost_admin_jwt
|
from .ghost_admin_token import make_ghost_admin_jwt
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ from sqlalchemy.orm.attributes import flag_modified # for non-Mutable JSON colu
|
|||||||
from models.ghost_content import (
|
from models.ghost_content import (
|
||||||
Post, Author, Tag, PostAuthor, PostTag
|
Post, Author, Tag, PostAuthor, PostTag
|
||||||
)
|
)
|
||||||
from models.page_config import PageConfig
|
from cart.models.page_config import PageConfig
|
||||||
|
|
||||||
# User-centric membership models
|
# User-centric membership models
|
||||||
from models import User
|
from shared.models import User
|
||||||
from models.ghost_membership_entities import (
|
from shared.models.ghost_membership_entities import (
|
||||||
GhostLabel, UserLabel,
|
GhostLabel, UserLabel,
|
||||||
GhostNewsletter, UserNewsletter,
|
GhostNewsletter, UserNewsletter,
|
||||||
GhostTier, GhostSubscription,
|
GhostTier, GhostSubscription,
|
||||||
@@ -29,7 +29,7 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
|
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
|
||||||
|
|
||||||
from suma_browser.app.utils import (
|
from shared.browser.app.utils import (
|
||||||
utcnow
|
utcnow
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -242,10 +242,10 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[
|
|||||||
# Auto-create PageConfig for pages
|
# Auto-create PageConfig for pages
|
||||||
if obj.is_page:
|
if obj.is_page:
|
||||||
existing_pc = (await sess.execute(
|
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()
|
)).scalar_one_or_none()
|
||||||
if existing_pc is 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()
|
await sess.flush()
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload, joinedload
|
from sqlalchemy.orm import selectinload, joinedload
|
||||||
|
|
||||||
from models.ghost_content import Post, Author, Tag, PostTag
|
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
|
from models.tag_group import TagGroup, TagGroupTag
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ from quart import (
|
|||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from .ghost_db import DBClient # adjust import path
|
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 .filters.qs import makeqs_factory, decode
|
||||||
from .services.posts_data import posts_data
|
from .services.posts_data import posts_data
|
||||||
from .services.pages_data import pages_data
|
from .services.pages_data import pages_data
|
||||||
|
|
||||||
from suma_browser.app.redis_cacher import cache_page, invalidate_tag_cache
|
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
def register(url_prefix, title):
|
def register(url_prefix, title):
|
||||||
blogs_bp = Blueprint("blog", __name__, url_prefix)
|
blogs_bp = Blueprint("blog", __name__, url_prefix)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from ..ghost_db import DBClient # adjust import path
|
from ..ghost_db import DBClient # adjust import path
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from models.ghost_content import PostLike
|
from models.ghost_content import PostLike
|
||||||
from models.calendars import CalendarEntry, CalendarEntryPost
|
from events.models.calendars import CalendarEntry, CalendarEntryPost
|
||||||
from quart import g
|
from quart import g
|
||||||
|
|
||||||
async def posts_data(
|
async def posts_data(
|
||||||
@@ -89,11 +89,12 @@ async def posts_data(
|
|||||||
# Get all confirmed entries associated with these posts
|
# Get all confirmed entries associated with these posts
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
entries_result = await session.execute(
|
entries_result = await session.execute(
|
||||||
select(CalendarEntry, CalendarEntryPost.post_id)
|
select(CalendarEntry, CalendarEntryPost.content_id)
|
||||||
.join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id)
|
.join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id)
|
||||||
.options(selectinload(CalendarEntry.calendar)) # Eagerly load calendar
|
.options(selectinload(CalendarEntry.calendar)) # Eagerly load calendar
|
||||||
.where(
|
.where(
|
||||||
CalendarEntryPost.post_id.in_(post_ids),
|
CalendarEntryPost.content_type == "post",
|
||||||
|
CalendarEntryPost.content_id.in_(post_ids),
|
||||||
CalendarEntryPost.deleted_at.is_(None),
|
CalendarEntryPost.deleted_at.is_(None),
|
||||||
CalendarEntry.deleted_at.is_(None),
|
CalendarEntry.deleted_at.is_(None),
|
||||||
CalendarEntry.state == "confirmed"
|
CalendarEntry.state == "confirmed"
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from ..ghost.ghost_sync import (
|
|||||||
sync_single_author,
|
sync_single_author,
|
||||||
sync_single_tag,
|
sync_single_tag,
|
||||||
)
|
)
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
from suma_browser.app.csrf import csrf_exempt
|
from shared.browser.app.csrf import csrf_exempt
|
||||||
|
|
||||||
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
|
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from quart import Blueprint, g, jsonify
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models.menu_item import MenuItem
|
from shared.models.menu_item import MenuItem
|
||||||
from suma_browser.app.csrf import csrf_exempt
|
from shared.browser.app.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
def register() -> Blueprint:
|
def register() -> Blueprint:
|
||||||
@@ -53,7 +53,7 @@ def register() -> Blueprint:
|
|||||||
Return a Ghost post's key fields by slug.
|
Return a Ghost post's key fields by slug.
|
||||||
Called by market app for the landing page.
|
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)
|
client = DBClient(g.s)
|
||||||
posts = await client.posts_by_slug(slug, include_drafts=False)
|
posts = await client.posts_by_slug(slug, include_drafts=False)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from quart import Blueprint, render_template, make_response, request, jsonify, g
|
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 (
|
from .services.menu_items import (
|
||||||
get_all_menu_items,
|
get_all_menu_items,
|
||||||
get_menu_item_by_id,
|
get_menu_item_by_id,
|
||||||
@@ -12,7 +12,7 @@ from .services.menu_items import (
|
|||||||
search_pages,
|
search_pages,
|
||||||
MenuItemError,
|
MenuItemError,
|
||||||
)
|
)
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from models.menu_item import MenuItem
|
from shared.models.menu_item import MenuItem
|
||||||
from models.ghost_content import Post
|
from models.ghost_content import Post
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ from quart import (
|
|||||||
redirect,
|
redirect,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from suma_browser.app.authz import require_admin, require_post_author
|
from shared.browser.app.authz import require_admin, require_post_author
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||||
@@ -21,8 +21,8 @@ def register():
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def admin(slug: str):
|
async def admin(slug: str):
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from models.page_config import PageConfig
|
from cart.models.page_config import PageConfig
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
# Load features for page admin
|
# Load features for page admin
|
||||||
@@ -33,7 +33,7 @@ def register():
|
|||||||
sumup_checkout_prefix = ""
|
sumup_checkout_prefix = ""
|
||||||
if post.get("is_page"):
|
if post.get("is_page"):
|
||||||
pc = (await g.s.execute(
|
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()
|
)).scalar_one_or_none()
|
||||||
if pc:
|
if pc:
|
||||||
features = pc.features or {}
|
features = pc.features or {}
|
||||||
@@ -62,7 +62,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def update_features(slug: str):
|
async def update_features(slug: str):
|
||||||
"""Update PageConfig.features for a page."""
|
"""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 models.ghost_content import Post
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
@@ -76,10 +76,10 @@ def register():
|
|||||||
|
|
||||||
# Load or create PageConfig
|
# Load or create PageConfig
|
||||||
pc = (await g.s.execute(
|
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()
|
)).scalar_one_or_none()
|
||||||
if pc is 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)
|
g.s.add(pc)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def update_sumup(slug: str):
|
async def update_sumup(slug: str):
|
||||||
"""Update PageConfig SumUp credentials for a page."""
|
"""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 sqlalchemy import select as sa_select
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
@@ -138,10 +138,10 @@ def register():
|
|||||||
post_id = post["id"]
|
post_id = post["id"]
|
||||||
|
|
||||||
pc = (await g.s.execute(
|
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()
|
)).scalar_one_or_none()
|
||||||
if pc is 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)
|
g.s.add(pc)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def calendar_view(slug: str, calendar_id: int):
|
async def calendar_view(slug: str, calendar_id: int):
|
||||||
"""Show calendar month view for browsing entries"""
|
"""Show calendar month view for browsing entries"""
|
||||||
from models.calendars import Calendar
|
from events.models.calendars import Calendar
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from quart import request
|
from quart import request
|
||||||
@@ -269,7 +269,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def entries(slug: str):
|
async def entries(slug: str):
|
||||||
from ..services.entry_associations import get_post_entry_ids
|
from ..services.entry_associations import get_post_entry_ids
|
||||||
from models.calendars import Calendar
|
from events.models.calendars import Calendar
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
@@ -305,7 +305,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def toggle_entry(slug: str, entry_id: int):
|
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 ..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 sqlalchemy import select
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ def register():
|
|||||||
calendars = (
|
calendars = (
|
||||||
await g.s.execute(
|
await g.s.execute(
|
||||||
select(Calendar)
|
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())
|
.order_by(Calendar.name.asc())
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -389,7 +389,7 @@ def register():
|
|||||||
async def settings_save(slug: str):
|
async def settings_save(slug: str):
|
||||||
from ...blog.ghost.ghost_posts import update_post_settings
|
from ...blog.ghost.ghost_posts import update_post_settings
|
||||||
from ...blog.ghost.ghost_sync import sync_single_post
|
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"]
|
ghost_id = g.post_data["post"]["ghost_id"]
|
||||||
form = await request.form
|
form = await request.form
|
||||||
@@ -452,7 +452,7 @@ def register():
|
|||||||
@require_post_author
|
@require_post_author
|
||||||
async def edit(slug: str):
|
async def edit(slug: str):
|
||||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
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
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
ghost_id = g.post_data["post"]["ghost_id"]
|
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.ghost_posts import update_post
|
||||||
from ...blog.ghost.lexical_validator import validate_lexical
|
from ...blog.ghost.lexical_validator import validate_lexical
|
||||||
from ...blog.ghost.ghost_sync import sync_single_post
|
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"]
|
ghost_id = g.post_data["post"]["ghost_id"]
|
||||||
form = await request.form
|
form = await request.form
|
||||||
@@ -599,7 +599,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def markets(slug: str):
|
async def markets(slug: str):
|
||||||
"""List markets for this page."""
|
"""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
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
post = (g.post_data or {}).get("post", {})
|
post = (g.post_data or {}).get("post", {})
|
||||||
@@ -609,7 +609,8 @@ def register():
|
|||||||
|
|
||||||
page_markets = (await g.s.execute(
|
page_markets = (await g.s.execute(
|
||||||
sa_select(MarketPlace).where(
|
sa_select(MarketPlace).where(
|
||||||
MarketPlace.post_id == post_id,
|
MarketPlace.container_type == "page",
|
||||||
|
MarketPlace.container_id == post_id,
|
||||||
MarketPlace.deleted_at.is_(None),
|
MarketPlace.deleted_at.is_(None),
|
||||||
).order_by(MarketPlace.name)
|
).order_by(MarketPlace.name)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
@@ -626,7 +627,7 @@ def register():
|
|||||||
async def create_market(slug: str):
|
async def create_market(slug: str):
|
||||||
"""Create a new market for this page."""
|
"""Create a new market for this page."""
|
||||||
from ..services.markets import create_market as _create_market, MarketError
|
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 sqlalchemy import select as sa_select
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
@@ -646,7 +647,8 @@ def register():
|
|||||||
# Return updated markets list
|
# Return updated markets list
|
||||||
page_markets = (await g.s.execute(
|
page_markets = (await g.s.execute(
|
||||||
sa_select(MarketPlace).where(
|
sa_select(MarketPlace).where(
|
||||||
MarketPlace.post_id == post_id,
|
MarketPlace.container_type == "page",
|
||||||
|
MarketPlace.container_id == post_id,
|
||||||
MarketPlace.deleted_at.is_(None),
|
MarketPlace.deleted_at.is_(None),
|
||||||
).order_by(MarketPlace.name)
|
).order_by(MarketPlace.name)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
@@ -663,7 +665,7 @@ def register():
|
|||||||
async def delete_market(slug: str, market_slug: str):
|
async def delete_market(slug: str, market_slug: str):
|
||||||
"""Soft-delete a market."""
|
"""Soft-delete a market."""
|
||||||
from ..services.markets import soft_delete_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 sqlalchemy import select as sa_select
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
@@ -677,7 +679,8 @@ def register():
|
|||||||
# Return updated markets list
|
# Return updated markets list
|
||||||
page_markets = (await g.s.execute(
|
page_markets = (await g.s.execute(
|
||||||
sa_select(MarketPlace).where(
|
sa_select(MarketPlace).where(
|
||||||
MarketPlace.post_id == post_id,
|
MarketPlace.container_type == "page",
|
||||||
|
MarketPlace.container_id == post_id,
|
||||||
MarketPlace.deleted_at.is_(None),
|
MarketPlace.deleted_at.is_(None),
|
||||||
).order_by(MarketPlace.name)
|
).order_by(MarketPlace.name)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ from quart import (
|
|||||||
)
|
)
|
||||||
from .services.post_data import post_data
|
from .services.post_data import post_data
|
||||||
from .services.post_operations import toggle_post_like
|
from .services.post_operations import toggle_post_like
|
||||||
from models.calendars import Calendar
|
from events.models.calendars import Calendar
|
||||||
from models.market_place import MarketPlace
|
from market.models.market_place import MarketPlace
|
||||||
from sqlalchemy import select
|
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 .admin.routes import register as register_admin
|
||||||
from config import config
|
from shared.config import config
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("post", __name__, url_prefix='/<slug>')
|
bp = Blueprint("post", __name__, url_prefix='/<slug>')
|
||||||
@@ -65,13 +65,13 @@ def register():
|
|||||||
p_data = getattr(g, "post_data", None)
|
p_data = getattr(g, "post_data", None)
|
||||||
if p_data:
|
if p_data:
|
||||||
from .services.entry_associations import get_associated_entries
|
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
|
db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer
|
||||||
calendars = (
|
calendars = (
|
||||||
await g.s.execute(
|
await g.s.execute(
|
||||||
select(Calendar)
|
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())
|
.order_by(Calendar.name.asc())
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -79,7 +79,7 @@ def register():
|
|||||||
markets = (
|
markets = (
|
||||||
await g.s.execute(
|
await g.s.execute(
|
||||||
select(MarketPlace)
|
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())
|
.order_by(MarketPlace.name.asc())
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -130,7 +130,7 @@ def register():
|
|||||||
@bp.post("/like/toggle/")
|
@bp.post("/like/toggle/")
|
||||||
@clear_cache(tag="post.post_detail", tag_scope="user")
|
@clear_cache(tag="post.post_detail", tag_scope="user")
|
||||||
async def like_toggle(slug: str):
|
async def like_toggle(slug: str):
|
||||||
from utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
# Get post_id from g.post_data
|
# Get post_id from g.post_data
|
||||||
if not g.user:
|
if not g.user:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.sql import func
|
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
|
from models.ghost_content import Post
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +35,8 @@ async def toggle_entry_association(
|
|||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(CalendarEntryPost).where(
|
select(CalendarEntryPost).where(
|
||||||
CalendarEntryPost.entry_id == entry_id,
|
CalendarEntryPost.entry_id == entry_id,
|
||||||
CalendarEntryPost.post_id == post_id,
|
CalendarEntryPost.content_type == "post",
|
||||||
|
CalendarEntryPost.content_id == post_id,
|
||||||
CalendarEntryPost.deleted_at.is_(None)
|
CalendarEntryPost.deleted_at.is_(None)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -49,7 +50,8 @@ async def toggle_entry_association(
|
|||||||
# Create association
|
# Create association
|
||||||
association = CalendarEntryPost(
|
association = CalendarEntryPost(
|
||||||
entry_id=entry_id,
|
entry_id=entry_id,
|
||||||
post_id=post_id
|
content_type="post",
|
||||||
|
content_id=post_id
|
||||||
)
|
)
|
||||||
session.add(association)
|
session.add(association)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -67,7 +69,8 @@ async def get_post_entry_ids(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(CalendarEntryPost.entry_id)
|
select(CalendarEntryPost.entry_id)
|
||||||
.where(
|
.where(
|
||||||
CalendarEntryPost.post_id == post_id,
|
CalendarEntryPost.content_type == "post",
|
||||||
|
CalendarEntryPost.content_id == post_id,
|
||||||
CalendarEntryPost.deleted_at.is_(None)
|
CalendarEntryPost.deleted_at.is_(None)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -88,7 +91,8 @@ async def get_associated_entries(
|
|||||||
entry_ids_result = await session.execute(
|
entry_ids_result = await session.execute(
|
||||||
select(CalendarEntryPost.entry_id)
|
select(CalendarEntryPost.entry_id)
|
||||||
.where(
|
.where(
|
||||||
CalendarEntryPost.post_id == post_id,
|
CalendarEntryPost.content_type == "post",
|
||||||
|
CalendarEntryPost.content_id == post_id,
|
||||||
CalendarEntryPost.deleted_at.is_(None)
|
CalendarEntryPost.deleted_at.is_(None)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import unicodedata
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.ghost_content import Post
|
||||||
from models.page_config import PageConfig
|
from cart.models.page_config import PageConfig
|
||||||
from suma_browser.app.utils import utcnow
|
from shared.browser.app.utils import utcnow
|
||||||
|
|
||||||
|
|
||||||
class MarketError(ValueError):
|
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.")
|
raise MarketError("Markets can only be created on pages, not posts.")
|
||||||
|
|
||||||
pc = (await sess.execute(
|
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()
|
)).scalar_one_or_none()
|
||||||
if pc is None or not (pc.features or {}).get("market"):
|
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.")
|
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
|
||||||
|
|
||||||
# Look for existing (including soft-deleted)
|
# Look for existing (including soft-deleted)
|
||||||
existing = (await sess.execute(
|
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()
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
@@ -61,7 +61,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
|||||||
return existing
|
return existing
|
||||||
raise MarketError(f'Market with slug "{slug}" already exists for this page.')
|
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)
|
sess.add(market)
|
||||||
await sess.flush()
|
await sess.flush()
|
||||||
return market
|
return market
|
||||||
@@ -71,7 +71,8 @@ async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: st
|
|||||||
market = (
|
market = (
|
||||||
await sess.execute(
|
await sess.execute(
|
||||||
select(MarketPlace)
|
select(MarketPlace)
|
||||||
.join(Post, MarketPlace.post_id == Post.id)
|
.join(Post, MarketPlace.container_id == Post.id)
|
||||||
|
.where(MarketPlace.container_type == "page")
|
||||||
.where(
|
.where(
|
||||||
Post.slug == post_slug,
|
Post.slug == post_slug,
|
||||||
MarketPlace.slug == market_slug,
|
MarketPlace.slug == market_slug,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from quart import Blueprint, render_template, make_response, request, g, abort
|
|||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from suma_browser.app.authz import require_login
|
from shared.browser.app.authz import require_login
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from models import Snippet
|
from models import Snippet
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
83
config/app-config.yaml
Normal file
83
config/app-config.yaml
Normal 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
14
models/__init__.py
Normal 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
224
models/ghost_content.py
Normal 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")
|
||||||
12
models/ghost_membership_entities.py
Normal file
12
models/ghost_membership_entities.py
Normal 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
4
models/kv.py
Normal 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
4
models/magic_link.py
Normal 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
4
models/menu_item.py
Normal 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
32
models/snippet.py
Normal 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
52
models/tag_group.py
Normal 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
4
models/user.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.user import User
|
||||||
|
|
||||||
|
__all__ = ["User"]
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Add the shared library submodule to the Python path
|
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
|
_project_root = os.path.dirname(_app_dir)
|
||||||
if _shared not in sys.path:
|
|
||||||
sys.path.insert(0, _shared)
|
for _p in (_project_root, _app_dir):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
|
|||||||
Reference in New Issue
Block a user