feat: initial shared library extraction

Contains shared infrastructure for all coop services:
- shared/ (factory, urls, user_loader, context, internal_api, jinja_setup)
- models/ (User, Order, Calendar, Ticket, Product, Ghost CMS)
- db/ (SQLAlchemy async session, base)
- suma_browser/app/ (csrf, middleware, errors, authz, redis_cacher, payments, filters, utils)
- suma_browser/templates/ (shared base layouts, macros, error pages)
- static/ (CSS, JS, fonts, images)
- alembic/ (database migrations)
- config/ (app-config.yaml)
- editor/ (Lexical editor Node.js build)
- requirements.txt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-09 23:11:36 +00:00
commit 668d9c7df8
446 changed files with 22741 additions and 0 deletions

21
models/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
from .kv import KV
from .user import User
from .magic_link import MagicLink
from .market import ProductLike
from .ghost_content import Author, Tag, Post, PostAuthor, PostTag, PostLike
from .menu_item import MenuItem
from .ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
from .calendars import Calendar, CalendarEntry, Ticket
from .order import Order, OrderItem
from .snippet import Snippet
from .tag_group import TagGroup, TagGroupTag

304
models/calendars.py Normal file
View File

@@ -0,0 +1,304 @@
from __future__ import annotations
from sqlalchemy import (
Column, Integer, String, DateTime, ForeignKey, CheckConstraint,
Index, text, Text, Boolean, Time, Numeric
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
# Adjust this import to match where your Base lives
from db.base import Base
from datetime import datetime, timezone
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Calendar(Base):
__tablename__ = "calendars"
id = Column(Integer, primary_key=True)
post_id = Column(Integer, ForeignKey("posts.id", ondelete="CASCADE"), nullable=False)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
slug = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
# relationships
post = relationship("Post", back_populates="calendars")
entries = relationship(
"CalendarEntry",
back_populates="calendar",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="CalendarEntry.start_at",
)
slots = relationship(
"CalendarSlot",
back_populates="calendar",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="CalendarSlot.time_start",
)
# Indexes / constraints (match Alembic migration)
__table_args__ = (
# Helpful lookups
Index("ix_calendars_post_id", "post_id"),
Index("ix_calendars_name", "name"),
Index("ix_calendars_slug", "slug"),
# Soft-delete-aware uniqueness (PostgreSQL):
# one active calendar per post/slug (case-insensitive)
Index(
"ux_calendars_post_slug_active",
"post_id",
func.lower(slug),
unique=True,
postgresql_where=text("deleted_at IS NULL"),
),
)
class CalendarEntry(Base):
__tablename__ = "calendar_entries"
id = Column(Integer, primary_key=True)
calendar_id = Column(
Integer,
ForeignKey("calendars.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# NEW: ownership + order link
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
session_id = Column(String(64), nullable=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id", ondelete="SET NULL"), nullable=True, index=True)
# NEW: slot link
slot_id = Column(Integer, ForeignKey("calendar_slots.id", ondelete="SET NULL"), nullable=True, index=True)
# details
name = Column(String(255), nullable=False)
start_at = Column(DateTime(timezone=True), nullable=False, index=True)
end_at = Column(DateTime(timezone=True), nullable=True)
# NEW: booking state + cost
state = Column(
String(20),
nullable=False,
server_default=text("'pending'"),
)
cost = Column(Numeric(10, 2), nullable=False, server_default=text("10"))
# Ticket configuration
ticket_price = Column(Numeric(10, 2), nullable=True) # Price per ticket (NULL = no tickets)
ticket_count = Column(Integer, nullable=True) # Total available tickets (NULL = unlimited)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"(end_at IS NULL) OR (end_at >= start_at)",
name="ck_calendar_entries_end_after_start",
),
Index("ix_calendar_entries_name", "name"),
Index("ix_calendar_entries_start_at", "start_at"),
Index("ix_calendar_entries_user_id", "user_id"),
Index("ix_calendar_entries_session_id", "session_id"),
Index("ix_calendar_entries_state", "state"),
Index("ix_calendar_entries_order_id", "order_id"),
Index("ix_calendar_entries_slot_id", "slot_id"),
)
calendar = relationship("Calendar", back_populates="entries")
slot = relationship("CalendarSlot", back_populates="entries", lazy="selectin")
# Optional, but handy:
order = relationship("Order", back_populates="calendar_entries", lazy="selectin")
posts = relationship("CalendarEntryPost", back_populates="entry", cascade="all, delete-orphan")
ticket_types = relationship(
"TicketType",
back_populates="entry",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="TicketType.name",
)
DAY_LABELS = [
("mon", "Mon"),
("tue", "Tue"),
("wed", "Wed"),
("thu", "Thu"),
("fri", "Fri"),
("sat", "Sat"),
("sun", "Sun"),
]
class CalendarSlot(Base):
__tablename__ = "calendar_slots"
id = Column(Integer, primary_key=True)
calendar_id = Column(
Integer,
ForeignKey("calendars.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
mon = Column(Boolean, nullable=False, default=False)
tue = Column(Boolean, nullable=False, default=False)
wed = Column(Boolean, nullable=False, default=False)
thu = Column(Boolean, nullable=False, default=False)
fri = Column(Boolean, nullable=False, default=False)
sat = Column(Boolean, nullable=False, default=False)
sun = Column(Boolean, nullable=False, default=False)
# NEW: whether bookings can be made at flexible times within this band
flexible = Column(
Boolean,
nullable=False,
server_default=text("false"),
default=False,
)
@property
def days_display(self) -> str:
days = [label for attr, label in DAY_LABELS if getattr(self, attr)]
if len(days) == len(DAY_LABELS):
# all days selected
return "All" # or "All days" if you prefer
return ", ".join(days) if days else ""
time_start = Column(Time(timezone=False), nullable=False)
time_end = Column(Time(timezone=False), nullable=False)
cost = Column(Numeric(10, 2), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"(time_end > time_start)",
name="ck_calendar_slots_time_end_after_start",
),
Index("ix_calendar_slots_calendar_id", "calendar_id"),
Index("ix_calendar_slots_time_start", "time_start"),
)
calendar = relationship("Calendar", back_populates="slots")
entries = relationship("CalendarEntry", back_populates="slot")
class TicketType(Base):
__tablename__ = "ticket_types"
id = Column(Integer, primary_key=True)
entry_id = Column(
Integer,
ForeignKey("calendar_entries.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name = Column(String(255), nullable=False)
cost = Column(Numeric(10, 2), nullable=False)
count = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_ticket_types_entry_id", "entry_id"),
Index("ix_ticket_types_name", "name"),
)
entry = relationship("CalendarEntry", back_populates="ticket_types")
class Ticket(Base):
__tablename__ = "tickets"
id = Column(Integer, primary_key=True)
entry_id = Column(
Integer,
ForeignKey("calendar_entries.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
ticket_type_id = Column(
Integer,
ForeignKey("ticket_types.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
session_id = Column(String(64), nullable=True, index=True)
order_id = Column(
Integer,
ForeignKey("orders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
code = Column(String(64), unique=True, nullable=False) # QR/barcode value
state = Column(
String(20),
nullable=False,
server_default=text("'reserved'"),
) # reserved, confirmed, checked_in, cancelled
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
checked_in_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_tickets_entry_id", "entry_id"),
Index("ix_tickets_ticket_type_id", "ticket_type_id"),
Index("ix_tickets_user_id", "user_id"),
Index("ix_tickets_session_id", "session_id"),
Index("ix_tickets_order_id", "order_id"),
Index("ix_tickets_code", "code", unique=True),
Index("ix_tickets_state", "state"),
)
entry = relationship("CalendarEntry", backref="tickets")
ticket_type = relationship("TicketType", backref="tickets")
order = relationship("Order", backref="tickets")
class CalendarEntryPost(Base):
__tablename__ = "calendar_entry_posts"
id = Column(Integer, primary_key=True, autoincrement=True)
entry_id = Column(Integer, ForeignKey("calendar_entries.id", ondelete="CASCADE"), nullable=False)
post_id = Column(Integer, ForeignKey("posts.id", ondelete="CASCADE"), nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_entry_posts_entry_id", "entry_id"),
Index("ix_entry_posts_post_id", "post_id"),
)
entry = relationship("CalendarEntry", back_populates="posts")
post = relationship("Post", back_populates="calendar_entries")
__all__ = ["Calendar", "CalendarEntry", "CalendarSlot", "TicketType", "Ticket", "CalendarEntryPost"]

70
models/cart_item.py Normal file
View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, DateTime, ForeignKey, func, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base # you already import Base in app.py
# from .user import User # only if you normally import it here
# from .coop import Product # if not already in this module
from .market import Product
from .user import User
class CartItem(Base):
__tablename__ = "cart_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Either a logged-in user OR an anonymous session
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
)
session_id: Mapped[str | None] = mapped_column(
String(128),
nullable=True,
)
# IMPORTANT: link to product *id*, not slug
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"),
nullable=False,
)
quantity: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
server_default="1",
)
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[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# Relationships
product: Mapped["Product"] = relationship(
"Product",
back_populates="cart_items",
)
user: Mapped["User | None"] = relationship("User", back_populates="cart_items")
__table_args__ = (
Index("ix_cart_items_user_product", "user_id", "product_id"),
Index("ix_cart_items_session_product", "session_id", "product_id"),
)

239
models/ghost_content.py Normal file
View File

@@ -0,0 +1,239 @@
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 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",
)
calendars:Mapped[List["Calendar"]] = relationship(
"Calendar",
back_populates="post",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="Calendar.name",
)
likes: Mapped[List["PostLike"]] = relationship(
"PostLike",
back_populates="post",
cascade="all, delete-orphan",
passive_deletes=True,
)
calendar_entries: Mapped[List["CalendarEntryPost"]] = relationship(
"CalendarEntryPost",
back_populates="post",
cascade="all, delete-orphan",
passive_deletes=True,
)
menu_items: Mapped[List["MenuItem"]] = relationship(
"MenuItem",
back_populates="post",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="MenuItem.sort_order",
)
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(255))
profile_image: Mapped[Optional[str]] = mapped_column(Text())
cover_image: Mapped[Optional[str]] = mapped_column(Text())
bio: Mapped[Optional[str]] = mapped_column(Text())
website: Mapped[Optional[str]] = mapped_column(Text())
location: Mapped[Optional[str]] = mapped_column(Text())
facebook: Mapped[Optional[str]] = mapped_column(Text())
twitter: Mapped[Optional[str]] = mapped_column(Text())
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# backref to posts via post_authors
posts: Mapped[List[Post]] = relationship(
"Post",
secondary="post_authors",
primaryjoin="Author.id==post_authors.c.author_id",
secondaryjoin="Post.id==post_authors.c.post_id",
back_populates="authors",
order_by="PostAuthor.sort_order",
)
class PostAuthor(Base):
__tablename__ = "post_authors"
post_id: Mapped[int] = mapped_column(
ForeignKey("posts.id", ondelete="CASCADE"),
primary_key=True,
)
author_id: Mapped[int] = mapped_column(
ForeignKey("authors.id", ondelete="CASCADE"),
primary_key=True,
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
class PostTag(Base):
__tablename__ = "post_tags"
post_id: Mapped[int] = mapped_column(
ForeignKey("posts.id", ondelete="CASCADE"),
primary_key=True,
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"),
primary_key=True,
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
class PostLike(Base):
__tablename__ = "post_likes"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
post: Mapped["Post"] = relationship("Post", back_populates="likes", foreign_keys=[post_id])
user = relationship("User", back_populates="liked_posts")

View File

@@ -0,0 +1,122 @@
# suma_browser/models/ghost_membership_entities.py
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Integer, String, Text, Boolean, DateTime, ForeignKey, UniqueConstraint
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.ext.associationproxy import association_proxy
from db.base import Base
# -----------------------
# Labels (simple M2M)
# -----------------------
class GhostLabel(Base):
__tablename__ = "ghost_labels"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
ghost_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[Optional[str]] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
# Back-populated by User.labels
users = relationship("User", secondary="user_labels", back_populates="labels", lazy="selectin")
class UserLabel(Base):
__tablename__ = "user_labels"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
label_id: Mapped[int] = mapped_column(ForeignKey("ghost_labels.id", ondelete="CASCADE"), index=True)
__table_args__ = (
UniqueConstraint("user_id", "label_id", name="uq_user_label"),
)
# -----------------------
# Newsletters (association object + proxy)
# -----------------------
class GhostNewsletter(Base):
__tablename__ = "ghost_newsletters"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
ghost_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[Optional[str]] = mapped_column(String(255))
description: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
# Association-object side (one-to-many)
user_newsletters = relationship(
"UserNewsletter",
back_populates="newsletter",
cascade="all, delete-orphan",
lazy="selectin",
)
# Convenience: list-like proxy of Users via association rows (read-only container)
users = association_proxy("user_newsletters", "user")
class UserNewsletter(Base):
__tablename__ = "user_newsletters"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
newsletter_id: Mapped[int] = mapped_column(ForeignKey("ghost_newsletters.id", ondelete="CASCADE"), index=True)
subscribed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
__table_args__ = (
UniqueConstraint("user_id", "newsletter_id", name="uq_user_newsletter"),
)
# Bidirectional links for the association object
user = relationship("User", back_populates="user_newsletters", lazy="selectin")
newsletter = relationship("GhostNewsletter", back_populates="user_newsletters", lazy="selectin")
# -----------------------
# Tiers & Subscriptions
# -----------------------
class GhostTier(Base):
__tablename__ = "ghost_tiers"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
ghost_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[Optional[str]] = mapped_column(String(255))
type: Mapped[Optional[str]] = mapped_column(String(50)) # e.g. free, paid
visibility: Mapped[Optional[str]] = mapped_column(String(50))
class GhostSubscription(Base):
__tablename__ = "ghost_subscriptions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
ghost_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
status: Mapped[Optional[str]] = mapped_column(String(50))
tier_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ghost_tiers.id", ondelete="SET NULL"), index=True)
cadence: Mapped[Optional[str]] = mapped_column(String(50)) # month, year
price_amount: Mapped[Optional[int]] = mapped_column(Integer)
price_currency: Mapped[Optional[str]] = mapped_column(String(10))
stripe_customer_id: Mapped[Optional[str]] = mapped_column(String(255), index=True)
stripe_subscription_id: Mapped[Optional[str]] = mapped_column(String(255), index=True)
raw: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
# Relationships
user = relationship("User", back_populates="subscriptions", lazy="selectin")
tier = relationship("GhostTier", lazy="selectin")

13
models/kv.py Normal file
View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Text, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
class KV(Base):
__tablename__ = "kv"
"""Simple key-value table for settings/cache/demo."""
key: Mapped[str] = mapped_column(String(120), primary_key=True)
value: Mapped[str | None] = mapped_column(Text(), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)

25
models/magic_link.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, ForeignKey, func, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
class MagicLink(Base):
__tablename__ = "magic_links"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
purpose: Mapped[str] = mapped_column(String(32), nullable=False, default="signin")
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(256), nullable=True)
user = relationship("User", backref="magic_links")
__table_args__ = (
Index("ix_magic_link_token", "token", unique=True),
Index("ix_magic_link_user", "user_id"),
)

425
models/market.py Normal file
View File

@@ -0,0 +1,425 @@
# at top of persist_snapshot.py:
from datetime import datetime
from typing import Optional, List
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import List, Optional
from sqlalchemy import (
String, Text, Integer, ForeignKey, DateTime, Boolean, Numeric,
UniqueConstraint, Index, func
)
from db.base import Base # you already import Base in app.py
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
title: Mapped[Optional[str]] = mapped_column(String(512))
image: Mapped[Optional[str]] = mapped_column(Text)
description_short: Mapped[Optional[str]] = mapped_column(Text)
description_html: Mapped[Optional[str]] = mapped_column(Text)
suma_href: Mapped[Optional[str]] = mapped_column(Text)
brand: Mapped[Optional[str]] = mapped_column(String(255))
rrp: Mapped[Optional[float]] = mapped_column(Numeric(12, 2))
rrp_currency: Mapped[Optional[str]] = mapped_column(String(16))
rrp_raw: Mapped[Optional[str]] = mapped_column(String(128))
price_per_unit: Mapped[Optional[float]] = mapped_column(Numeric(12, 4))
price_per_unit_currency: Mapped[Optional[str]] = mapped_column(String(16))
price_per_unit_raw: Mapped[Optional[str]] = mapped_column(String(128))
special_price: Mapped[Optional[float]] = mapped_column(Numeric(12, 2))
special_price_currency: Mapped[Optional[str]] = mapped_column(String(16))
special_price_raw: Mapped[Optional[str]] = mapped_column(String(128))
regular_price: Mapped[Optional[float]] = mapped_column(Numeric(12, 2))
regular_price_currency: Mapped[Optional[str]] = mapped_column(String(16))
regular_price_raw: Mapped[Optional[str]] = mapped_column(String(128))
oe_list_price: Mapped[Optional[float]] = mapped_column(Numeric(12, 2))
case_size_count: Mapped[Optional[int]] = mapped_column(Integer)
case_size_item_qty: Mapped[Optional[float]] = mapped_column(Numeric(12, 3))
case_size_item_unit: Mapped[Optional[str]] = mapped_column(String(32))
case_size_raw: Mapped[Optional[str]] = mapped_column(String(128))
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))
images: Mapped[List["ProductImage"]] = relationship(
back_populates="product",
cascade="all, delete-orphan",
passive_deletes=True,
)
sections: Mapped[List["ProductSection"]] = relationship(
back_populates="product",
cascade="all, delete-orphan",
passive_deletes=True,
)
labels: Mapped[List["ProductLabel"]] = relationship(
cascade="all, delete-orphan",
passive_deletes=True,
)
stickers: Mapped[List["ProductSticker"]] = relationship(
cascade="all, delete-orphan",
passive_deletes=True,
)
ean: Mapped[Optional[str]] = mapped_column(String(64))
sku: Mapped[Optional[str]] = mapped_column(String(128))
unit_size: Mapped[Optional[str]] = mapped_column(String(128))
pack_size: Mapped[Optional[str]] = mapped_column(String(128))
attributes = relationship(
"ProductAttribute",
back_populates="product",
lazy="selectin",
cascade="all, delete-orphan",
)
nutrition = relationship(
"ProductNutrition",
back_populates="product",
lazy="selectin",
cascade="all, delete-orphan",
)
allergens = relationship(
"ProductAllergen",
back_populates="product",
lazy="selectin",
cascade="all, delete-orphan",
)
likes = relationship(
"ProductLike",
back_populates="product",
cascade="all, delete-orphan",
)
cart_items: Mapped[List["CartItem"]] = relationship(
"CartItem",
back_populates="product",
cascade="all, delete-orphan",
)
# NEW: all order items that reference this product
order_items: Mapped[List["OrderItem"]] = relationship(
"OrderItem",
back_populates="product",
cascade="all, delete-orphan",
)
from sqlalchemy import Column
class ProductLike(Base):
__tablename__ = "product_likes"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
product_slug: Mapped[str] = mapped_column(ForeignKey("products.slug", ondelete="CASCADE"))
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))
product: Mapped["Product"] = relationship("Product", back_populates="likes", foreign_keys=[product_slug])
user = relationship("User", back_populates="liked_products") # optional, if you want reverse access
class ProductImage(Base):
__tablename__ = "product_images"
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="gallery")
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))
product: Mapped["Product"] = relationship(back_populates="images")
__table_args__ = (
UniqueConstraint("product_id", "url", "kind", name="uq_product_images_product_url_kind"),
Index("ix_product_images_position", "position"),
)
class ProductSection(Base):
__tablename__ = "product_sections"
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
html: Mapped[str] = mapped_column(Text, 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))
# ⬇️ ADD THIS LINE:
product: Mapped["Product"] = relationship(back_populates="sections")
__table_args__ = (
UniqueConstraint("product_id", "title", name="uq_product_sections_product_title"),
)
# --- Nav & listings ---
class NavTop(Base):
__tablename__ = "nav_tops"
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
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))
listings: Mapped[List["Listing"]] = relationship(back_populates="top", cascade="all, delete-orphan")
__table_args__ = (UniqueConstraint("label", "slug", name="uq_nav_tops_label_slug"),)
class NavSub(Base):
__tablename__ = "nav_subs"
id: Mapped[int] = mapped_column(primary_key=True)
top_id: Mapped[int] = mapped_column(ForeignKey("nav_tops.id", ondelete="CASCADE"), index=True, nullable=False)
label: Mapped[Optional[str]] = mapped_column(String(255))
slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
href: Mapped[Optional[str]] = mapped_column(Text)
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))
listings: Mapped[List["Listing"]] = relationship(back_populates="sub", cascade="all, delete-orphan")
__table_args__ = (UniqueConstraint("top_id", "slug", name="uq_nav_subs_top_slug"),)
class Listing(Base):
__tablename__ = "listings"
id: Mapped[int] = mapped_column(primary_key=True)
# Old slug-based fields (optional: remove)
# top_slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
# sub_slug: Mapped[Optional[str]] = mapped_column(String(255), index=True)
top_id: Mapped[int] = mapped_column(ForeignKey("nav_tops.id", ondelete="CASCADE"), index=True, nullable=False)
sub_id: Mapped[Optional[int]] = mapped_column(ForeignKey("nav_subs.id", ondelete="CASCADE"), index=True)
total_pages: Mapped[Optional[int]] = mapped_column(Integer)
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))
top: Mapped["NavTop"] = relationship(back_populates="listings")
sub: Mapped[Optional["NavSub"]] = relationship(back_populates="listings")
__table_args__ = (
UniqueConstraint("top_id", "sub_id", name="uq_listings_top_sub"),
)
class ListingItem(Base):
__tablename__ = "listing_items"
id: Mapped[int] = mapped_column(primary_key=True)
listing_id: Mapped[int] = mapped_column(ForeignKey("listings.id", ondelete="CASCADE"), index=True, 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))
slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
__table_args__ = (UniqueConstraint("listing_id", "slug", name="uq_listing_items_listing_slug"),)
# --- Reports / redirects / logs ---
class LinkError(Base):
__tablename__ = "link_errors"
id: Mapped[int] = mapped_column(primary_key=True)
product_slug: Mapped[Optional[str]] = mapped_column(String(255), index=True)
href: Mapped[Optional[str]] = mapped_column(Text)
text: Mapped[Optional[str]] = mapped_column(Text)
top: Mapped[Optional[str]] = mapped_column(String(255))
sub: Mapped[Optional[str]] = mapped_column(String(255))
target_slug: Mapped[Optional[str]] = mapped_column(String(255))
type: Mapped[Optional[str]] = mapped_column(String(255))
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))
class LinkExternal(Base):
__tablename__ = "link_externals"
id: Mapped[int] = mapped_column(primary_key=True)
product_slug: Mapped[Optional[str]] = mapped_column(String(255), index=True)
href: Mapped[Optional[str]] = mapped_column(Text)
text: Mapped[Optional[str]] = mapped_column(Text)
host: Mapped[Optional[str]] = mapped_column(String(255))
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))
class SubcategoryRedirect(Base):
__tablename__ = "subcategory_redirects"
id: Mapped[int] = mapped_column(primary_key=True)
old_path: Mapped[str] = mapped_column(String(512), nullable=False, index=True)
new_path: Mapped[str] = mapped_column(String(512), 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))
class ProductLog(Base):
__tablename__ = "product_logs"
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[Optional[str]] = mapped_column(String(255), index=True)
href_tried: Mapped[Optional[str]] = mapped_column(Text)
ok: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
error_type: Mapped[Optional[str]] = mapped_column(String(255))
error_message: Mapped[Optional[str]] = mapped_column(Text)
http_status: Mapped[Optional[int]] = mapped_column(Integer)
final_url: Mapped[Optional[str]] = mapped_column(Text)
transport_error: Mapped[Optional[bool]] = mapped_column(Boolean)
title: Mapped[Optional[str]] = mapped_column(String(512))
has_description_html: Mapped[Optional[bool]] = mapped_column(Boolean)
has_description_short: Mapped[Optional[bool]] = mapped_column(Boolean)
sections_count: Mapped[Optional[int]] = mapped_column(Integer)
images_count: Mapped[Optional[int]] = mapped_column(Integer)
embedded_images_count: Mapped[Optional[int]] = mapped_column(Integer)
all_images_count: Mapped[Optional[int]] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
# ...existing models...
class ProductLabel(Base):
__tablename__ = "product_labels"
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), 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))
product: Mapped["Product"] = relationship(back_populates="labels")
__table_args__ = (UniqueConstraint("product_id", "name", name="uq_product_labels_product_name"),)
class ProductSticker(Base):
__tablename__ = "product_stickers"
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), 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))
product: Mapped["Product"] = relationship(back_populates="stickers")
__table_args__ = (UniqueConstraint("product_id", "name", name="uq_product_stickers_product_name"),)
class ProductAttribute(Base):
__tablename__ = "product_attributes"
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False)
key: Mapped[str] = mapped_column(String(255), 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))
value: Mapped[Optional[str]] = mapped_column(Text)
product = relationship("Product", back_populates="attributes")
__table_args__ = (UniqueConstraint("product_id", "key", name="uq_product_attributes_product_key"),)
class ProductNutrition(Base):
__tablename__ = "product_nutrition"
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False)
key: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[Optional[str]] = mapped_column(String(255))
unit: Mapped[Optional[str]] = mapped_column(String(64))
product = relationship("Product", back_populates="nutrition")
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))
__table_args__ = (UniqueConstraint("product_id", "key", name="uq_product_nutrition_product_key"),)
class ProductAllergen(Base):
__tablename__ = "product_allergens"
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
contains: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="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))
product: Mapped["Product"] = relationship(back_populates="allergens")
__table_args__ = (UniqueConstraint("product_id", "name", name="uq_product_allergens_product_name"),)
class CartItem(Base):
__tablename__ = "cart_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Either a logged-in user OR an anonymous session
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
)
session_id: Mapped[str | None] = mapped_column(
String(128),
nullable=True,
)
# IMPORTANT: link to product *id*, not slug
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"),
nullable=False,
)
quantity: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
server_default="1",
)
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[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# Relationships
product: Mapped["Product"] = relationship(
"Product",
back_populates="cart_items",
)
user: Mapped["User | None"] = relationship("User", back_populates="cart_items")
__table_args__ = (
Index("ix_cart_items_user_product", "user_id", "product_id"),
Index("ix_cart_items_session_product", "session_id", "product_id"),
)

42
models/menu_item.py Normal file
View File

@@ -0,0 +1,42 @@
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Integer, String, DateTime, ForeignKey, func
from db.base import Base
class MenuItem(Base):
__tablename__ = "menu_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Foreign key to posts table
post_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("posts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
# Order for sorting menu items
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False
)
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
# Relationship to Post
post: Mapped["Post"] = relationship("Post", back_populates="menu_items")

108
models/order.py Normal file
View File

@@ -0,0 +1,108 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional, List
from sqlalchemy import Integer, String, DateTime, ForeignKey, Numeric, func, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
session_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="pending",
server_default="pending",
)
currency: Mapped[str] = mapped_column(String(16), nullable=False, default="GBP")
total_amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
# free-form description for the order
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, index=True)
# SumUp reference string (what we send as checkout_reference)
sumup_reference: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
index=True,
)
# SumUp integration fields
sumup_checkout_id: Mapped[Optional[str]] = mapped_column(
String(128),
nullable=True,
index=True,
)
sumup_status: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
sumup_hosted_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
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(),
)
items: Mapped[List["OrderItem"]] = relationship(
"OrderItem",
back_populates="order",
cascade="all, delete-orphan",
lazy="selectin",
)
calendar_entries: Mapped[List["CalendarEntry"]] = relationship(
"CalendarEntry",
back_populates="order",
lazy="selectin",
)
class OrderItem(Base):
__tablename__ = "order_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_id: Mapped[int] = mapped_column(
ForeignKey("orders.id", ondelete="CASCADE"),
nullable=False,
)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id"),
nullable=False,
)
product_title: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
unit_price: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(16), nullable=False, default="GBP")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
order: Mapped["Order"] = relationship(
"Order",
back_populates="items",
)
# NEW: link each order item to its product
product: Mapped["Product"] = relationship(
"Product",
back_populates="order_items",
lazy="selectin",
)

32
models/snippet.py Normal file
View File

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

52
models/tag_group.py Normal file
View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import (
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
func,
)
from 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")

46
models/user.py Normal file
View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, func, Index, Text, Boolean
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.ext.associationproxy import association_proxy
from db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Ghost membership linkage
ghost_id: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
ghost_status: Mapped[str | None] = mapped_column(String(50), nullable=True) # free, paid, comped
ghost_subscribed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=func.true())
ghost_note: Mapped[str | None] = mapped_column(Text, nullable=True)
avatar_image: Mapped[str | None] = mapped_column(Text, nullable=True)
stripe_customer_id: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True)
ghost_raw: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Relationships to Ghost-related entities
user_newsletters = relationship("UserNewsletter", back_populates="user", cascade="all, delete-orphan", lazy="selectin")
newsletters = association_proxy("user_newsletters", "newsletter")
labels = relationship("GhostLabel", secondary="user_labels", back_populates="users", lazy="selectin")
subscriptions = relationship("GhostSubscription", back_populates="user", cascade="all, delete-orphan", lazy="selectin")
liked_products = relationship("ProductLike", back_populates="user", cascade="all, delete-orphan")
liked_posts = relationship("PostLike", back_populates="user", cascade="all, delete-orphan")
cart_items = relationship(
"CartItem",
back_populates="user",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("ix_user_email", "email", unique=True),
)
def __repr__(self) -> str:
return f"<User {self.id} {self.email}>"