Compare commits

...

8 Commits

Author SHA1 Message Date
giles
123f752946 feat: per-page SumUp models and migration (Phase 3)
- Migration: add market_place_id to cart_items, page_config_id to orders
- Order model: add page_config_id FK and page_config relationship
- CartItem model: add market_place_id FK and market_place relationship

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:49:55 +00:00
giles
a420bfa7f0 fix: market URLs include post slug prefix
Nav entries template now links to /<post_slug>/<market_slug>/
matching the events app calendar URL pattern. market_url_for()
updated to accept post_slug parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:15:29 +00:00
giles
42f4a8b68f fix: top menu Market link goes to coop blog page, not market app
Removed 'market' from _app_slugs so the Market menu item links
to coop.rose-ash.com/market/ (the blog page) instead of directly
to the market app. Individual markets are linked from the post nav.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:07:52 +00:00
giles
e653acb921 feat: add market links to post nav entries template
Markets now appear alongside calendars in the post nav bar,
linking to the market app via market_url().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:36:57 +00:00
giles
7eb66fbf24 feat: add MarketPlace model, migration, and market_url_for helper
Introduces per-page markets (Phase 2):
- MarketPlace model with soft-delete and unique slug index
- NavTop gains market_id FK to scope nav hierarchy per market
- Migration with backfill creates default 'suma-market' for existing market page
- market_url_for() cross-app URL helper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:08:18 +00:00
giles
3cc5730377 fix: use jsonb cast and EXISTS in page_configs migration
PostgreSQL json type doesn't support equality operators needed by
DISTINCT. Use EXISTS subquery instead and cast to jsonb.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:35:31 +00:00
giles
f63a9ec0a7 feat: add page_configs migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:33:23 +00:00
giles
56e32585b7 feat: add PageConfig model and remove duplicate CartItem
Introduce page_configs table for per-page feature flags (calendar, market)
and future SumUp credentials. Add page_config relationship to Post model.
Remove duplicate CartItem definition from cart_item.py (canonical stays in market.py).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:19:25 +00:00
13 changed files with 390 additions and 73 deletions

View 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')

View 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')

View File

@@ -0,0 +1,55 @@
"""add page_config_id to orders, market_place_id to cart_items
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-02-10
"""
from alembic import op
import sqlalchemy as sa
revision = 'c3d4e5f6a7b8'
down_revision = 'b2c3d4e5f6a7'
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Add market_place_id to cart_items
op.add_column(
'cart_items',
sa.Column('market_place_id', sa.Integer(), nullable=True),
)
op.create_foreign_key(
'fk_cart_items_market_place_id',
'cart_items',
'market_places',
['market_place_id'],
['id'],
ondelete='SET NULL',
)
op.create_index('ix_cart_items_market_place_id', 'cart_items', ['market_place_id'])
# 2. Add page_config_id to orders
op.add_column(
'orders',
sa.Column('page_config_id', sa.Integer(), nullable=True),
)
op.create_foreign_key(
'fk_orders_page_config_id',
'orders',
'page_configs',
['page_config_id'],
['id'],
ondelete='SET NULL',
)
op.create_index('ix_orders_page_config_id', 'orders', ['page_config_id'])
def downgrade() -> None:
op.drop_index('ix_orders_page_config_id', table_name='orders')
op.drop_constraint('fk_orders_page_config_id', 'orders', type_='foreignkey')
op.drop_column('orders', 'page_config_id')
op.drop_index('ix_cart_items_market_place_id', table_name='cart_items')
op.drop_constraint('fk_cart_items_market_place_id', 'cart_items', type_='foreignkey')
op.drop_column('cart_items', 'market_place_id')

View File

@@ -13,6 +13,8 @@ from .ghost_membership_entities import (
)
from .calendars import Calendar, CalendarEntry, Ticket
from .page_config import PageConfig
from .market_place import MarketPlace

View File

@@ -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"),
)

View File

@@ -164,6 +164,22 @@ class Post(Base):
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):
__tablename__ = "authors"

View File

@@ -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"),)
@@ -406,13 +413,23 @@ class CartItem(Base):
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",

54
models/market_place.py Normal file
View 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"),
),
)

View File

@@ -17,6 +17,12 @@ class Order(Base):
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,
@@ -68,6 +74,11 @@ class Order(Base):
back_populates="order",
lazy="selectin",
)
page_config: Mapped[Optional["PageConfig"]] = relationship(
"PageConfig",
foreign_keys=[page_config_id],
lazy="selectin",
)
class OrderItem(Base):

45
models/page_config.py Normal file
View 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]
)

View File

@@ -37,6 +37,12 @@ def events_url(path: str = "/") -> str:
return app_url("events", path)
def market_url_for(post_slug: str, market_slug: str, path: str = "/") -> str:
if not path.startswith("/"):
path = "/" + path
return market_url(f"/{post_slug}/{market_slug}{path}")
def login_url(next_url: str = "") -> str:
if next_url:
return coop_url(f"/auth/login/?next={quote(next_url, safe='')}")

View File

@@ -37,6 +37,16 @@
<div>{{calendar.name}}</div>
</a>
{% endfor %}
{# Markets #}
{% for m in markets %}
<a
href="{{ market_url('/' + post.slug + '/' + m.slug + '/') }}"
class="{{styles.nav_button_less_pad}}">
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
<div>{{m.name}}</div>
</a>
{% endfor %}
</div>
</div>

View File

@@ -1,4 +1,4 @@
{% set _app_slugs = {'market': market_url('/'), 'cart': cart_url('/')} %}
{% set _app_slugs = {'cart': cart_url('/')} %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="menu-items-nav-wrapper">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}