Decouple cross-app models: move canonical definitions to shared/models/
- Move 6 model files to shared/models/ (ghost_content, order, page_config,
market, market_place, calendars) so apps import from shared, not each other
- Fix auth templates: replace url_for('auth.*') with coop_url() for
cross-app compatibility
- Fix TYPE_CHECKING import in sumup.py to use shared.models.order
- Delete dead infrastructure/cart_loader.py (inverted dependency)
- Update models/__init__.py to export all new models
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ from quart import current_app
|
|||||||
from shared.config import config
|
from shared.config import config
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cart.models.order import Order
|
from shared.models.order import Order
|
||||||
|
|
||||||
SUMUP_BASE_URL = "https://api.sumup.com/v0.1"
|
SUMUP_BASE_URL = "https://api.sumup.com/v0.1"
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<form action="{{ url_for('auth.logout')|host }}" method="post">
|
<form action="{{ coop_url('/auth/logout/') }}" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
|
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
|
||||||
<button
|
<button
|
||||||
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=un.newsletter_id) }}"
|
hx-post="{{ coop_url('/auth/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||||
hx-target="#nl-{{ un.newsletter_id }}"
|
hx-target="#nl-{{ un.newsletter_id }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{# No subscription row yet — show an off toggle that will create one #}
|
{# No subscription row yet — show an off toggle that will create one #}
|
||||||
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
|
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
|
||||||
<button
|
<button
|
||||||
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=item.newsletter.id) }}"
|
hx-post="{{ coop_url('/auth/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||||
hx-target="#nl-{{ item.newsletter.id }}"
|
hx-target="#nl-{{ item.newsletter.id }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<p class="mt-6 text-sm">
|
<p class="mt-6 text-sm">
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('auth.login_form')|host }}"
|
href="{{ coop_url('/auth/login/') }}"
|
||||||
class="text-stone-600 dark:text-stone-300 hover:underline"
|
class="text-stone-600 dark:text-stone-300 hover:underline"
|
||||||
>
|
>
|
||||||
← Back
|
← Back
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
method="post" action="{{ url_for('auth.start_login')|host }}"
|
method="post" action="{{ coop_url('/auth/start/') }}"
|
||||||
class="mt-6 space-y-5"
|
class="mt-6 space-y-5"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from quart import g
|
|
||||||
|
|
||||||
|
|
||||||
async def load_cart():
|
|
||||||
# Lazy import: cart.bp.cart.services only exists in the cart app process.
|
|
||||||
# This avoids cross-app model conflicts at import time.
|
|
||||||
from cart.bp.cart.services import get_cart
|
|
||||||
|
|
||||||
g.cart = await get_cart(g.s)
|
|
||||||
@@ -9,3 +9,19 @@ from .ghost_membership_entities import (
|
|||||||
GhostTier, GhostSubscription,
|
GhostTier, GhostSubscription,
|
||||||
)
|
)
|
||||||
from .domain_event import DomainEvent
|
from .domain_event import DomainEvent
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|||||||
296
models/calendars.py
Normal file
296
models/calendars.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
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"]
|
||||||
216
models/ghost_content.py
Normal file
216
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")
|
||||||
441
models/market.py
Normal file
441
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
models/market_place.py
Normal file
52
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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
114
models/order.py
Normal file
114
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
models/page_config.py
Normal file
39
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
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user