diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py index f921376..ada3fcc 100644 --- a/app.py +++ b/app.py @@ -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() diff --git a/bp/admin/routes.py b/bp/admin/routes.py index ee90805..e387c17 100644 --- a/bp/admin/routes.py +++ b/bp/admin/routes.py @@ -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): diff --git a/bp/auth/routes.py b/bp/auth/routes.py index 63db016..a8574d9 100644 --- a/bp/auth/routes.py +++ b/bp/auth/routes.py @@ -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"))) diff --git a/bp/auth/services/auth_operations.py b/bp/auth/services/auth_operations.py index 8dfdc3e..a0d0d9c 100644 --- a/bp/auth/services/auth_operations.py +++ b/bp/auth/services/auth_operations.py @@ -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: diff --git a/bp/auth/services/login_redirect.py b/bp/auth/services/login_redirect.py index e7267e3..9dba0a3 100644 --- a/bp/auth/services/login_redirect.py +++ b/bp/auth/services/login_redirect.py @@ -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" diff --git a/bp/blog/admin/routes.py b/bp/blog/admin/routes.py index d13441b..4bf8139 100644 --- a/bp/blog/admin/routes.py +++ b/bp/blog/admin/routes.py @@ -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 diff --git a/bp/blog/filters/qs.py b/bp/blog/filters/qs.py index 0064cc7..073dd13 100644 --- a/bp/blog/filters/qs.py +++ b/bp/blog/filters/qs.py @@ -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: diff --git a/bp/blog/ghost/editor_api.py b/bp/blog/ghost/editor_api.py index a7c1855..c37fa96 100644 --- a/bp/blog/ghost/editor_api.py +++ b/bp/blog/ghost/editor_api.py @@ -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 diff --git a/bp/blog/ghost/ghost_sync.py b/bp/blog/ghost/ghost_sync.py index 8ad456d..3c1e3b4 100644 --- a/bp/blog/ghost/ghost_sync.py +++ b/bp/blog/ghost/ghost_sync.py @@ -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 diff --git a/bp/blog/ghost_db.py b/bp/blog/ghost_db.py index 3c0642b..a058997 100644 --- a/bp/blog/ghost_db.py +++ b/bp/blog/ghost_db.py @@ -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 diff --git a/bp/blog/routes.py b/bp/blog/routes.py index 805f474..e5b3083 100644 --- a/bp/blog/routes.py +++ b/bp/blog/routes.py @@ -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) diff --git a/bp/blog/services/posts_data.py b/bp/blog/services/posts_data.py index de17767..6034307 100644 --- a/bp/blog/services/posts_data.py +++ b/bp/blog/services/posts_data.py @@ -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" diff --git a/bp/blog/web_hooks/routes.py b/bp/blog/web_hooks/routes.py index 6722632..b02138b 100644 --- a/bp/blog/web_hooks/routes.py +++ b/bp/blog/web_hooks/routes.py @@ -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") diff --git a/bp/coop_api.py b/bp/coop_api.py index e68e4e6..250acf5 100644 --- a/bp/coop_api.py +++ b/bp/coop_api.py @@ -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) diff --git a/bp/menu_items/routes.py b/bp/menu_items/routes.py index a6a0296..26ac745 100644 --- a/bp/menu_items/routes.py +++ b/bp/menu_items/routes.py @@ -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') diff --git a/bp/menu_items/services/menu_items.py b/bp/menu_items/services/menu_items.py index cd979f8..bc5aca4 100644 --- a/bp/menu_items/services/menu_items.py +++ b/bp/menu_items/services/menu_items.py @@ -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 diff --git a/bp/post/admin/routes.py b/bp/post/admin/routes.py index afe6ab6..0832253 100644 --- a/bp/post/admin/routes.py +++ b/bp/post/admin/routes.py @@ -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() diff --git a/bp/post/routes.py b/bp/post/routes.py index 42aa130..ea8679e 100644 --- a/bp/post/routes.py +++ b/bp/post/routes.py @@ -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='/') @@ -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: diff --git a/bp/post/services/entry_associations.py b/bp/post/services/entry_associations.py index fa34077..af387a4 100644 --- a/bp/post/services/entry_associations.py +++ b/bp/post/services/entry_associations.py @@ -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) ) ) diff --git a/bp/post/services/markets.py b/bp/post/services/markets.py index 6b6ca6f..b432f86 100644 --- a/bp/post/services/markets.py +++ b/bp/post/services/markets.py @@ -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, diff --git a/bp/snippets/routes.py b/bp/snippets/routes.py index ca27ab7..8f4778a 100644 --- a/bp/snippets/routes.py +++ b/bp/snippets/routes.py @@ -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 diff --git a/config/app-config.yaml b/config/app-config.yaml new file mode 100644 index 0000000..227cc2e --- /dev/null +++ b/config/app-config.yaml @@ -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-' + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e434f4a --- /dev/null +++ b/models/__init__.py @@ -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 diff --git a/models/ghost_content.py b/models/ghost_content.py new file mode 100644 index 0000000..a24c03b --- /dev/null +++ b/models/ghost_content.py @@ -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") diff --git a/models/ghost_membership_entities.py b/models/ghost_membership_entities.py new file mode 100644 index 0000000..d07520f --- /dev/null +++ b/models/ghost_membership_entities.py @@ -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", +] diff --git a/models/kv.py b/models/kv.py new file mode 100644 index 0000000..d54f0a3 --- /dev/null +++ b/models/kv.py @@ -0,0 +1,4 @@ +# Re-export from canonical shared location +from shared.models.kv import KV + +__all__ = ["KV"] diff --git a/models/magic_link.py b/models/magic_link.py new file mode 100644 index 0000000..9031ca4 --- /dev/null +++ b/models/magic_link.py @@ -0,0 +1,4 @@ +# Re-export from canonical shared location +from shared.models.magic_link import MagicLink + +__all__ = ["MagicLink"] diff --git a/models/menu_item.py b/models/menu_item.py new file mode 100644 index 0000000..f36a146 --- /dev/null +++ b/models/menu_item.py @@ -0,0 +1,4 @@ +# Re-export from canonical shared location +from shared.models.menu_item import MenuItem + +__all__ = ["MenuItem"] diff --git a/models/snippet.py b/models/snippet.py new file mode 100644 index 0000000..47cad35 --- /dev/null +++ b/models/snippet.py @@ -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(), + ) diff --git a/models/tag_group.py b/models/tag_group.py new file mode 100644 index 0000000..77ddc41 --- /dev/null +++ b/models/tag_group.py @@ -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") diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..3feae81 --- /dev/null +++ b/models/user.py @@ -0,0 +1,4 @@ +# Re-export from canonical shared location +from shared.models.user import User + +__all__ = ["User"] diff --git a/path_setup.py b/path_setup.py index 1d4c9ab..c7166f7 100644 --- a/path_setup.py +++ b/path_setup.py @@ -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)