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 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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -11,16 +11,16 @@ from quart import (
|
||||
)
|
||||
from .services.post_data import post_data
|
||||
from .services.post_operations import toggle_post_like
|
||||
from models.calendars import Calendar
|
||||
from models.market_place import MarketPlace
|
||||
from events.models.calendars import Calendar
|
||||
from market.models.market_place import MarketPlace
|
||||
from sqlalchemy import select
|
||||
|
||||
from suma_browser.app.redis_cacher import cache_page, clear_cache
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
|
||||
|
||||
from .admin.routes import register as register_admin
|
||||
from config import config
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.config import config
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
def register():
|
||||
bp = Blueprint("post", __name__, url_prefix='/<slug>')
|
||||
@@ -65,13 +65,13 @@ def register():
|
||||
p_data = getattr(g, "post_data", None)
|
||||
if p_data:
|
||||
from .services.entry_associations import get_associated_entries
|
||||
from shared.internal_api import get as api_get
|
||||
from shared.infrastructure.internal_api import get as api_get
|
||||
|
||||
db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer
|
||||
calendars = (
|
||||
await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.post_id == db_post_id, Calendar.deleted_at.is_(None))
|
||||
.where(Calendar.container_type == "page", Calendar.container_id == db_post_id, Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
@@ -79,7 +79,7 @@ def register():
|
||||
markets = (
|
||||
await g.s.execute(
|
||||
select(MarketPlace)
|
||||
.where(MarketPlace.post_id == db_post_id, MarketPlace.deleted_at.is_(None))
|
||||
.where(MarketPlace.container_type == "page", MarketPlace.container_id == db_post_id, MarketPlace.deleted_at.is_(None))
|
||||
.order_by(MarketPlace.name.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
@@ -130,7 +130,7 @@ def register():
|
||||
@bp.post("/like/toggle/")
|
||||
@clear_cache(tag="post.post_detail", tag_scope="user")
|
||||
async def like_toggle(slug: str):
|
||||
from utils import host_url
|
||||
from shared.utils import host_url
|
||||
|
||||
# Get post_id from g.post_data
|
||||
if not g.user:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,8 +4,8 @@ from quart import Blueprint, render_template, make_response, request, g, abort
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from suma_browser.app.authz import require_login
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.authz import require_login
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from models import Snippet
|
||||
|
||||
|
||||
|
||||
83
config/app-config.yaml
Normal file
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 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)
|
||||
|
||||
Reference in New Issue
Block a user