Compare commits
4 Commits
fa9ffa98e5
...
7eb66fbf24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eb66fbf24 | ||
|
|
3cc5730377 | ||
|
|
f63a9ec0a7 | ||
|
|
56e32585b7 |
74
alembic/versions/a1b2c3d4e5f6_add_page_configs_table.py
Normal file
74
alembic/versions/a1b2c3d4e5f6_add_page_configs_table.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""add page_configs table
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: f6d4a1b2c3e7
|
||||||
|
Create Date: 2026-02-10
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = 'a1b2c3d4e5f6'
|
||||||
|
down_revision = 'f6d4a1b2c3e7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'page_configs',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('post_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('features', sa.JSON(), server_default='{}', nullable=False),
|
||||||
|
sa.Column('sumup_merchant_code', sa.String(64), nullable=True),
|
||||||
|
sa.Column('sumup_api_key', sa.Text(), nullable=True),
|
||||||
|
sa.Column('sumup_checkout_prefix', sa.String(64), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('post_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill: create PageConfig for every existing page
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Pages with calendars -> features={"calendar": true}
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO page_configs (post_id, features, created_at, updated_at)
|
||||||
|
SELECT p.id, '{"calendar": true}'::jsonb, now(), now()
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.is_page = true
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM calendars c
|
||||||
|
WHERE c.post_id = p.id AND c.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 2. Market page (slug='market', is_page=true) -> features={"market": true}
|
||||||
|
# Only if not already inserted above
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO page_configs (post_id, features, created_at, updated_at)
|
||||||
|
SELECT p.id, '{"market": true}'::jsonb, now(), now()
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.slug = 'market'
|
||||||
|
AND p.is_page = true
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND p.id NOT IN (SELECT post_id FROM page_configs)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 3. All other pages -> features={}
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO page_configs (post_id, features, created_at, updated_at)
|
||||||
|
SELECT p.id, '{}'::jsonb, now(), now()
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.is_page = true
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND p.id NOT IN (SELECT post_id FROM page_configs)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('page_configs')
|
||||||
97
alembic/versions/b2c3d4e5f6a7_add_market_places_table.py
Normal file
97
alembic/versions/b2c3d4e5f6a7_add_market_places_table.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""add market_places table and nav_tops.market_id
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-02-10
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = 'b2c3d4e5f6a7'
|
||||||
|
down_revision = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Create market_places table
|
||||||
|
op.create_table(
|
||||||
|
'market_places',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('post_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_market_places_post_id', 'market_places', ['post_id'])
|
||||||
|
op.create_index(
|
||||||
|
'ux_market_places_slug_active',
|
||||||
|
'market_places',
|
||||||
|
[sa.text('lower(slug)')],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text('deleted_at IS NULL'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Add market_id column to nav_tops
|
||||||
|
op.add_column(
|
||||||
|
'nav_tops',
|
||||||
|
sa.Column('market_id', sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_nav_tops_market_id',
|
||||||
|
'nav_tops',
|
||||||
|
'market_places',
|
||||||
|
['market_id'],
|
||||||
|
['id'],
|
||||||
|
ondelete='SET NULL',
|
||||||
|
)
|
||||||
|
op.create_index('ix_nav_tops_market_id', 'nav_tops', ['market_id'])
|
||||||
|
|
||||||
|
# 3. Backfill: create default MarketPlace for the 'market' page
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Find the market page
|
||||||
|
result = conn.execute(text("""
|
||||||
|
SELECT id FROM posts
|
||||||
|
WHERE slug = 'market' AND is_page = true AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
"""))
|
||||||
|
row = result.fetchone()
|
||||||
|
if row:
|
||||||
|
post_id = row[0]
|
||||||
|
|
||||||
|
# Insert the default market
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO market_places (post_id, name, slug, created_at, updated_at)
|
||||||
|
VALUES (:post_id, 'Suma Market', 'suma-market', now(), now())
|
||||||
|
"""), {"post_id": post_id})
|
||||||
|
|
||||||
|
# Get the new market_places id
|
||||||
|
market_row = conn.execute(text("""
|
||||||
|
SELECT id FROM market_places
|
||||||
|
WHERE slug = 'suma-market' AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
if market_row:
|
||||||
|
market_id = market_row[0]
|
||||||
|
# Assign all active nav_tops to this market
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE nav_tops SET market_id = :market_id
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
"""), {"market_id": market_id})
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_nav_tops_market_id', table_name='nav_tops')
|
||||||
|
op.drop_constraint('fk_nav_tops_market_id', 'nav_tops', type_='foreignkey')
|
||||||
|
op.drop_column('nav_tops', 'market_id')
|
||||||
|
op.drop_index('ux_market_places_slug_active', table_name='market_places')
|
||||||
|
op.drop_index('ix_market_places_post_id', table_name='market_places')
|
||||||
|
op.drop_table('market_places')
|
||||||
@@ -13,6 +13,8 @@ from .ghost_membership_entities import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .calendars import Calendar, CalendarEntry, Ticket
|
from .calendars import Calendar, CalendarEntry, Ticket
|
||||||
|
from .page_config import PageConfig
|
||||||
|
from .market_place import MarketPlace
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Integer, String, DateTime, ForeignKey, func, Index
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from db.base import Base # you already import Base in app.py
|
|
||||||
# from .user import User # only if you normally import it here
|
|
||||||
# from .coop import Product # if not already in this module
|
|
||||||
|
|
||||||
from .market import Product
|
|
||||||
|
|
||||||
from .user import User
|
|
||||||
|
|
||||||
class CartItem(Base):
|
|
||||||
__tablename__ = "cart_items"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
|
|
||||||
# Either a logged-in user OR an anonymous session
|
|
||||||
user_id: Mapped[int | None] = mapped_column(
|
|
||||||
ForeignKey("users.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
session_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(128),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# IMPORTANT: link to product *id*, not slug
|
|
||||||
product_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("products.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
quantity: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
default=1,
|
|
||||||
server_default="1",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
)
|
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
|
|
||||||
product: Mapped["Product"] = relationship(
|
|
||||||
"Product",
|
|
||||||
back_populates="cart_items",
|
|
||||||
)
|
|
||||||
user: Mapped["User | None"] = relationship("User", back_populates="cart_items")
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_cart_items_user_product", "user_id", "product_id"),
|
|
||||||
Index("ix_cart_items_session_product", "session_id", "product_id"),
|
|
||||||
)
|
|
||||||
@@ -164,6 +164,22 @@ class Post(Base):
|
|||||||
order_by="MenuItem.sort_order",
|
order_by="MenuItem.sort_order",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
page_config: Mapped[Optional["PageConfig"]] = relationship(
|
||||||
|
"PageConfig",
|
||||||
|
back_populates="post",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
markets: Mapped[List["MarketPlace"]] = relationship(
|
||||||
|
"MarketPlace",
|
||||||
|
back_populates="post",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
order_by="MarketPlace.name",
|
||||||
|
)
|
||||||
|
|
||||||
class Author(Base):
|
class Author(Base):
|
||||||
__tablename__ = "authors"
|
__tablename__ = "authors"
|
||||||
|
|
||||||
|
|||||||
@@ -186,11 +186,18 @@ class NavTop(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
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())
|
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())
|
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))
|
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
listings: Mapped[List["Listing"]] = relationship(back_populates="top", cascade="all, delete-orphan")
|
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"),)
|
__table_args__ = (UniqueConstraint("label", "slug", name="uq_nav_tops_label_slug"),)
|
||||||
|
|
||||||
|
|||||||
54
models/market_place.py
Normal file
54
models/market_place.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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 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)
|
||||||
|
post_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("posts.id", ondelete="CASCADE"),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
post = relationship("Post", back_populates="markets")
|
||||||
|
nav_tops: Mapped[List["NavTop"]] = relationship(
|
||||||
|
"NavTop", back_populates="market",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_market_places_post_id", "post_id"),
|
||||||
|
Index(
|
||||||
|
"ux_market_places_slug_active",
|
||||||
|
func.lower(slug),
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("deleted_at IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
45
models/page_config.py
Normal file
45
models/page_config.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, func, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PageConfig(Base):
|
||||||
|
__tablename__ = "page_configs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
post_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("posts.id", ondelete="CASCADE"),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
features: Mapped[dict] = mapped_column(
|
||||||
|
JSON, nullable=False, server_default="{}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 3: 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
|
||||||
|
)
|
||||||
|
|
||||||
|
post: Mapped["Post"] = relationship(
|
||||||
|
"Post", back_populates="page_config", foreign_keys=[post_id]
|
||||||
|
)
|
||||||
@@ -37,6 +37,12 @@ def events_url(path: str = "/") -> str:
|
|||||||
return app_url("events", path)
|
return app_url("events", path)
|
||||||
|
|
||||||
|
|
||||||
|
def market_url_for(market_slug: str, path: str = "/") -> str:
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
return market_url(f"/{market_slug}{path}")
|
||||||
|
|
||||||
|
|
||||||
def login_url(next_url: str = "") -> str:
|
def login_url(next_url: str = "") -> str:
|
||||||
if next_url:
|
if next_url:
|
||||||
return coop_url(f"/auth/login/?next={quote(next_url, safe='')}")
|
return coop_url(f"/auth/login/?next={quote(next_url, safe='')}")
|
||||||
|
|||||||
Reference in New Issue
Block a user