diff --git a/alembic/versions/b2c3d4e5f6a7_add_market_places_table.py b/alembic/versions/b2c3d4e5f6a7_add_market_places_table.py new file mode 100644 index 0000000..4dbb124 --- /dev/null +++ b/alembic/versions/b2c3d4e5f6a7_add_market_places_table.py @@ -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') diff --git a/models/__init__.py b/models/__init__.py index 71d926d..9d24247 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -14,6 +14,7 @@ from .ghost_membership_entities import ( from .calendars import Calendar, CalendarEntry, Ticket from .page_config import PageConfig +from .market_place import MarketPlace diff --git a/models/ghost_content.py b/models/ghost_content.py index c278b4f..977c549 100644 --- a/models/ghost_content.py +++ b/models/ghost_content.py @@ -172,6 +172,14 @@ class Post(Base): 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): __tablename__ = "authors" diff --git a/models/market.py b/models/market.py index 004a3b0..58462a1 100644 --- a/models/market.py +++ b/models/market.py @@ -186,11 +186,18 @@ class NavTop(Base): 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"),) diff --git a/models/market_place.py b/models/market_place.py new file mode 100644 index 0000000..c8d40f7 --- /dev/null +++ b/models/market_place.py @@ -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"), + ), + ) diff --git a/shared/urls.py b/shared/urls.py index 26b055c..25d58e6 100644 --- a/shared/urls.py +++ b/shared/urls.py @@ -37,6 +37,12 @@ def events_url(path: str = "/") -> str: 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: if next_url: return coop_url(f"/auth/login/?next={quote(next_url, safe='')}")