Monorepo: consolidate 7 repos into one
Combines shared, blog, market, cart, events, federation, and account into a single repository. Eliminates submodule sync, sibling model copying at build time, and per-app CI orchestration. Changes: - Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs - Remove stale sibling model copies from each app - Update all 6 Dockerfiles for monorepo build context (root = .) - Add build directives to docker-compose.yml - Add single .gitea/workflows/ci.yml with change detection - Add .dockerignore for monorepo build context - Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
33
shared/models/__init__.py
Normal file
33
shared/models/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from .user import User
|
||||
from .kv import KV
|
||||
from .magic_link import MagicLink
|
||||
from .oauth_code import OAuthCode
|
||||
from .oauth_grant import OAuthGrant
|
||||
from .menu_item import MenuItem
|
||||
|
||||
from .ghost_membership_entities import (
|
||||
GhostLabel, UserLabel,
|
||||
GhostNewsletter, UserNewsletter,
|
||||
GhostTier, GhostSubscription,
|
||||
)
|
||||
from .ghost_content import Tag, Post, Author, PostAuthor, PostTag, PostLike
|
||||
from .page_config import PageConfig
|
||||
from .order import Order, OrderItem
|
||||
from .market import (
|
||||
Product, ProductLike, ProductImage, ProductSection,
|
||||
NavTop, NavSub, Listing, ListingItem,
|
||||
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||
CartItem,
|
||||
)
|
||||
from .market_place import MarketPlace
|
||||
from .calendars import (
|
||||
Calendar, CalendarEntry, CalendarSlot,
|
||||
TicketType, Ticket, CalendarEntryPost,
|
||||
)
|
||||
from .container_relation import ContainerRelation
|
||||
from .menu_node import MenuNode
|
||||
from .federation import (
|
||||
ActorProfile, APActivity, APFollower, APInboxItem, APAnchor, IPFSPin,
|
||||
RemoteActor, APFollowing, APRemotePost, APLocalPost, APInteraction, APNotification,
|
||||
)
|
||||
297
shared/models/calendars.py
Normal file
297
shared/models/calendars.py
Normal file
@@ -0,0 +1,297 @@
|
||||
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 shared.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)
|
||||
container_type = Column(String(32), nullable=False, server_default=text("'page'"))
|
||||
container_id = Column(Integer, 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
|
||||
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
|
||||
__table_args__ = (
|
||||
Index("ix_calendars_container", "container_type", "container_id"),
|
||||
Index("ix_calendars_name", "name"),
|
||||
Index("ix_calendars_slug", "slug"),
|
||||
# Soft-delete-aware uniqueness: one active calendar per container/slug
|
||||
Index(
|
||||
"ux_calendars_container_slug_active",
|
||||
"container_type",
|
||||
"container_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, 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")
|
||||
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",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
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, 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")
|
||||
|
||||
|
||||
class CalendarEntryPost(Base):
|
||||
"""Junction between calendar entries and content (posts, etc.)."""
|
||||
__tablename__ = "calendar_entry_posts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
entry_id = Column(Integer, ForeignKey("calendar_entries.id", ondelete="CASCADE"), nullable=False)
|
||||
content_type = Column(String(32), nullable=False, server_default=text("'post'"))
|
||||
content_id = Column(Integer, 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_content", "content_type", "content_id"),
|
||||
)
|
||||
|
||||
entry = relationship("CalendarEntry", back_populates="posts")
|
||||
|
||||
|
||||
__all__ = ["Calendar", "CalendarEntry", "CalendarSlot", "TicketType", "Ticket", "CalendarEntryPost"]
|
||||
38
shared/models/container_relation.py
Normal file
38
shared/models/container_relation.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import Integer, String, DateTime, Index, UniqueConstraint, func
|
||||
from shared.db.base import Base
|
||||
|
||||
|
||||
class ContainerRelation(Base):
|
||||
__tablename__ = "container_relations"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"parent_type", "parent_id", "child_type", "child_id",
|
||||
name="uq_container_relations_parent_child",
|
||||
),
|
||||
Index("ix_container_relations_parent", "parent_type", "parent_id"),
|
||||
Index("ix_container_relations_child", "child_type", "child_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
parent_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
parent_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
child_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
child_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
466
shared/models/federation.py
Normal file
466
shared/models/federation.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""Federation / ActivityPub ORM models.
|
||||
|
||||
These models support AP identity, activities, followers, inbox processing,
|
||||
IPFS content addressing, and OpenTimestamps anchoring.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
String, Integer, DateTime, Text, Boolean, BigInteger,
|
||||
ForeignKey, UniqueConstraint, Index, func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from shared.db.base import Base
|
||||
|
||||
|
||||
class ActorProfile(Base):
|
||||
"""AP identity for a user. Created when user chooses a username."""
|
||||
__tablename__ = "ap_actor_profiles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True, nullable=False,
|
||||
)
|
||||
preferred_username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
public_key_pem: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
private_key_pem: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="actor_profile", uselist=False, lazy="selectin")
|
||||
activities = relationship("APActivity", back_populates="actor_profile", lazy="dynamic")
|
||||
followers = relationship("APFollower", back_populates="actor_profile", lazy="dynamic")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_actor_user_id", "user_id", unique=True),
|
||||
Index("ix_ap_actor_username", "preferred_username", unique=True),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ActorProfile {self.id} @{self.preferred_username}>"
|
||||
|
||||
|
||||
class APActivity(Base):
|
||||
"""An ActivityPub activity (local or remote).
|
||||
|
||||
Also serves as the unified event bus: internal domain events and public
|
||||
federation activities both live here, distinguished by ``visibility``.
|
||||
The ``EventProcessor`` polls rows with ``process_state='pending'``.
|
||||
"""
|
||||
__tablename__ = "ap_activities"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
activity_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
|
||||
activity_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
actor_profile_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=True,
|
||||
)
|
||||
object_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
object_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
published: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
signature: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
is_local: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
|
||||
# Link back to originating domain object (e.g. source_type='post', source_id=42)
|
||||
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# IPFS content-addressed copy of the activity
|
||||
ipfs_cid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
# Anchoring (filled later when batched into a merkle tree)
|
||||
anchor_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ap_anchors.id", ondelete="SET NULL"), nullable=True,
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
# --- Unified event-bus columns ---
|
||||
actor_uri: Mapped[str | None] = mapped_column(
|
||||
String(512), nullable=True,
|
||||
)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="public", server_default="public",
|
||||
)
|
||||
process_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="completed", server_default="completed",
|
||||
)
|
||||
process_attempts: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0",
|
||||
)
|
||||
process_max_attempts: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=5, server_default="5",
|
||||
)
|
||||
process_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
origin_app: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
actor_profile = relationship("ActorProfile", back_populates="activities")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_activity_actor", "actor_profile_id"),
|
||||
Index("ix_ap_activity_source", "source_type", "source_id"),
|
||||
Index("ix_ap_activity_published", "published"),
|
||||
Index("ix_ap_activity_process", "process_state"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<APActivity {self.id} {self.activity_type}>"
|
||||
|
||||
|
||||
class APFollower(Base):
|
||||
"""A remote follower of a local actor.
|
||||
|
||||
``app_domain`` scopes the follow to a specific app (e.g. "blog",
|
||||
"market", "events"). "federation" means the aggregate — the
|
||||
follower subscribes to all activities.
|
||||
"""
|
||||
__tablename__ = "ap_followers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor_profile_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
follower_acct: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
follower_inbox: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
follower_actor_url: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
follower_public_key: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
app_domain: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, default="federation", server_default="federation",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
actor_profile = relationship("ActorProfile", back_populates="followers")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"actor_profile_id", "follower_acct", "app_domain",
|
||||
name="uq_follower_acct_app",
|
||||
),
|
||||
Index("ix_ap_follower_actor", "actor_profile_id"),
|
||||
Index("ix_ap_follower_app_domain", "actor_profile_id", "app_domain"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<APFollower {self.id} {self.follower_acct}>"
|
||||
|
||||
|
||||
class APInboxItem(Base):
|
||||
"""Raw incoming AP activity, stored for async processing."""
|
||||
__tablename__ = "ap_inbox_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor_profile_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
raw_json: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
activity_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
from_actor: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending", server_default="pending",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_inbox_state", "state"),
|
||||
Index("ix_ap_inbox_actor", "actor_profile_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<APInboxItem {self.id} {self.activity_type} [{self.state}]>"
|
||||
|
||||
|
||||
class APAnchor(Base):
|
||||
"""OpenTimestamps anchoring batch — merkle tree of activities."""
|
||||
__tablename__ = "ap_anchors"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
merkle_root: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
tree_ipfs_cid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
ots_proof_cid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
activity_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
bitcoin_txid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<APAnchor {self.id} activities={self.activity_count}>"
|
||||
|
||||
|
||||
class IPFSPin(Base):
|
||||
"""Tracks content stored on IPFS — used by all domains."""
|
||||
__tablename__ = "ipfs_pins"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
content_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
ipfs_cid: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
|
||||
pin_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ipfs_pin_source", "source_type", "source_id"),
|
||||
Index("ix_ipfs_pin_cid", "ipfs_cid", unique=True),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<IPFSPin {self.id} {self.ipfs_cid[:16]}...>"
|
||||
|
||||
|
||||
class RemoteActor(Base):
|
||||
"""Cached profile of a remote actor we interact with."""
|
||||
__tablename__ = "ap_remote_actors"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor_url: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
|
||||
inbox_url: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
shared_inbox_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
preferred_username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
icon_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
public_key_pem: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
domain: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_remote_actor_url", "actor_url", unique=True),
|
||||
Index("ix_ap_remote_actor_domain", "domain"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RemoteActor {self.id} {self.preferred_username}@{self.domain}>"
|
||||
|
||||
|
||||
class APFollowing(Base):
|
||||
"""Outbound follow: local actor → remote actor."""
|
||||
__tablename__ = "ap_following"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor_profile_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
remote_actor_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ap_remote_actors.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending", server_default="pending",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
actor_profile = relationship("ActorProfile")
|
||||
remote_actor = relationship("RemoteActor")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("actor_profile_id", "remote_actor_id", name="uq_following"),
|
||||
Index("ix_ap_following_actor", "actor_profile_id"),
|
||||
Index("ix_ap_following_remote", "remote_actor_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<APFollowing {self.id} [{self.state}]>"
|
||||
|
||||
|
||||
class APRemotePost(Base):
|
||||
"""A federated post ingested from a remote actor."""
|
||||
__tablename__ = "ap_remote_posts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
remote_actor_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ap_remote_actors.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
activity_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
|
||||
object_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
|
||||
object_type: Mapped[str] = mapped_column(String(64), nullable=False, default="Note")
|
||||
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
attachment_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
tag_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
in_reply_to: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
conversation: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
published: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
remote_actor = relationship("RemoteActor")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_remote_post_actor", "remote_actor_id"),
|
||||
Index("ix_ap_remote_post_published", "published"),
|
||||
Index("ix_ap_remote_post_object", "object_id", unique=True),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<APRemotePost {self.id} {self.object_type}>"
|
||||
|
||||
|
||||
class APLocalPost(Base):
|
||||
"""A native post composed in the federation UI."""
|
||||
__tablename__ = "ap_local_posts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor_profile_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="public", server_default="public",
|
||||
)
|
||||
in_reply_to: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
published: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
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(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
actor_profile = relationship("ActorProfile")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_local_post_actor", "actor_profile_id"),
|
||||
Index("ix_ap_local_post_published", "published"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<APLocalPost {self.id}>"
|
||||
|
||||
|
||||
class APInteraction(Base):
|
||||
"""Like or boost (local or remote)."""
|
||||
__tablename__ = "ap_interactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor_profile_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=True,
|
||||
)
|
||||
remote_actor_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ap_remote_actors.id", ondelete="CASCADE"), nullable=True,
|
||||
)
|
||||
post_type: Mapped[str] = mapped_column(String(20), nullable=False) # local/remote
|
||||
post_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
interaction_type: Mapped[str] = mapped_column(String(20), nullable=False) # like/boost
|
||||
activity_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_interaction_post", "post_type", "post_id"),
|
||||
Index("ix_ap_interaction_actor", "actor_profile_id"),
|
||||
Index("ix_ap_interaction_remote", "remote_actor_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<APInteraction {self.id} {self.interaction_type}>"
|
||||
|
||||
|
||||
class APNotification(Base):
|
||||
"""Notification for a local actor."""
|
||||
__tablename__ = "ap_notifications"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor_profile_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
notification_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
from_remote_actor_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ap_remote_actors.id", ondelete="SET NULL"), nullable=True,
|
||||
)
|
||||
from_actor_profile_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="SET NULL"), nullable=True,
|
||||
)
|
||||
target_activity_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ap_activities.id", ondelete="SET NULL"), nullable=True,
|
||||
)
|
||||
target_remote_post_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ap_remote_posts.id", ondelete="SET NULL"), nullable=True,
|
||||
)
|
||||
read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
actor_profile = relationship("ActorProfile", foreign_keys=[actor_profile_id])
|
||||
from_remote_actor = relationship("RemoteActor")
|
||||
from_actor_profile = relationship("ActorProfile", foreign_keys=[from_actor_profile_id])
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_notification_actor", "actor_profile_id"),
|
||||
Index("ix_ap_notification_read", "actor_profile_id", "read"),
|
||||
Index("ix_ap_notification_created", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class APDeliveryLog(Base):
|
||||
"""Tracks successful deliveries of activities to remote inboxes.
|
||||
|
||||
Used for idempotency: the delivery handler skips inboxes that already
|
||||
have a success row, so retries after a crash never send duplicates.
|
||||
"""
|
||||
__tablename__ = "ap_delivery_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
activity_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ap_activities.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
inbox_url: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
app_domain: Mapped[str] = mapped_column(String(128), nullable=False, server_default="federation")
|
||||
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
delivered_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("activity_id", "inbox_url", "app_domain", name="uq_delivery_activity_inbox_domain"),
|
||||
Index("ix_ap_delivery_activity", "activity_id"),
|
||||
)
|
||||
216
shared/models/ghost_content.py
Normal file
216
shared/models/ghost_content.py
Normal file
@@ -0,0 +1,216 @@
|
||||
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,
|
||||
)
|
||||
|
||||
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")
|
||||
122
shared/models/ghost_membership_entities.py
Normal file
122
shared/models/ghost_membership_entities.py
Normal 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 shared.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")
|
||||
12
shared/models/kv.py
Normal file
12
shared/models/kv.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from shared.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
shared/models/magic_link.py
Normal file
25
shared/models/magic_link.py
Normal 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 shared.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"),
|
||||
)
|
||||
441
shared/models/market.py
Normal file
441
shared/models/market.py
Normal file
@@ -0,0 +1,441 @@
|
||||
# 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 shared.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))
|
||||
|
||||
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)
|
||||
market_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("market_places.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
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")
|
||||
market = relationship("MarketPlace", back_populates="nav_tops")
|
||||
|
||||
__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(),
|
||||
)
|
||||
market_place_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("market_places.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
|
||||
market_place: Mapped["MarketPlace | None"] = relationship(
|
||||
"MarketPlace",
|
||||
foreign_keys=[market_place_id],
|
||||
)
|
||||
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"),
|
||||
)
|
||||
52
shared/models/market_place.py
Normal file
52
shared/models/market_place.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import (
|
||||
Integer, String, Text, DateTime, ForeignKey, Index, func, text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from shared.db.base import Base
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class MarketPlace(Base):
|
||||
__tablename__ = "market_places"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
container_type: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, server_default=text("'page'"),
|
||||
)
|
||||
container_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: 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(),
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
nav_tops: Mapped[List["NavTop"]] = relationship(
|
||||
"NavTop", back_populates="market",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_market_places_container", "container_type", "container_id"),
|
||||
Index(
|
||||
"ux_market_places_slug_active",
|
||||
func.lower(slug),
|
||||
unique=True,
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
)
|
||||
37
shared/models/menu_item.py
Normal file
37
shared/models/menu_item.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import Integer, String, DateTime, ForeignKey, func
|
||||
from shared.db.base import Base
|
||||
|
||||
|
||||
class MenuItem(Base):
|
||||
"""Deprecated — kept so the table isn't dropped. Use shared.models.menu_node.MenuNode."""
|
||||
__tablename__ = "menu_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
post_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("posts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
|
||||
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
|
||||
)
|
||||
50
shared/models/menu_node.py
Normal file
50
shared/models/menu_node.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, Index, func
|
||||
from shared.db.base import Base
|
||||
|
||||
|
||||
class MenuNode(Base):
|
||||
__tablename__ = "menu_nodes"
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_menu_nodes_container", "container_type", "container_id"),
|
||||
Index("ix_menu_nodes_parent_id", "parent_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
container_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
container_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("menu_nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
href: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
feature_image: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
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,
|
||||
)
|
||||
26
shared/models/oauth_code.py
Normal file
26
shared/models/oauth_code.py
Normal file
@@ -0,0 +1,26 @@
|
||||
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 shared.db.base import Base
|
||||
|
||||
|
||||
class OAuthCode(Base):
|
||||
__tablename__ = "oauth_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: 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)
|
||||
client_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
redirect_uri: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
grant_token: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
user = relationship("User", backref="oauth_codes")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_oauth_code_code", "code", unique=True),
|
||||
Index("ix_oauth_code_user", "user_id"),
|
||||
)
|
||||
32
shared/models/oauth_grant.py
Normal file
32
shared/models/oauth_grant.py
Normal file
@@ -0,0 +1,32 @@
|
||||
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 shared.db.base import Base
|
||||
|
||||
|
||||
class OAuthGrant(Base):
|
||||
"""Long-lived grant tracking each client-app session authorization.
|
||||
|
||||
Created when the OAuth authorize endpoint issues a code. Tied to the
|
||||
account session that issued it (``issuer_session``) so that logging out
|
||||
on one device revokes only that device's grants.
|
||||
"""
|
||||
__tablename__ = "oauth_grants"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
token: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
issuer_session: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
device_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user = relationship("User", backref="oauth_grants")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_oauth_grant_token", "token", unique=True),
|
||||
Index("ix_oauth_grant_issuer", "issuer_session"),
|
||||
Index("ix_oauth_grant_device", "device_id", "client_id"),
|
||||
)
|
||||
114
shared/models/order.py
Normal file
114
shared/models/order.py
Normal file
@@ -0,0 +1,114 @@
|
||||
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 shared.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)
|
||||
|
||||
page_config_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("page_configs.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=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",
|
||||
)
|
||||
page_config: Mapped[Optional["PageConfig"]] = relationship(
|
||||
"PageConfig",
|
||||
foreign_keys=[page_config_id],
|
||||
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",
|
||||
)
|
||||
39
shared/models/page_config.py
Normal file
39
shared/models/page_config.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Integer, String, Text, DateTime, func, JSON, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from shared.db.base import Base
|
||||
|
||||
|
||||
class PageConfig(Base):
|
||||
__tablename__ = "page_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
container_type: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, server_default=text("'page'"),
|
||||
)
|
||||
container_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
features: Mapped[dict] = mapped_column(
|
||||
JSON, nullable=False, server_default="{}"
|
||||
)
|
||||
|
||||
# Per-page SumUp credentials (NULL until configured)
|
||||
sumup_merchant_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
sumup_api_key: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
|
||||
sumup_checkout_prefix: Mapped[Optional[str]] = mapped_column(String(64), 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()
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
46
shared/models/user.py
Normal file
46
shared/models/user.py
Normal 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 shared.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}>"
|
||||
Reference in New Issue
Block a user