feat: restructure market app with per-market URL scoping
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s

- URL structure changes from /<route> to /<market_slug>/<route>
- Root / shows markets listing page
- app.py: url_value_preprocessor, url_defaults, hydrate_market (events app pattern)
- Browse queries (db_nav, db_products_nocounts, db_products_counts) accept market_id
- _productInfo reads g.market.id to scope all queries
- save_nav accepts market_id, sets on new NavTop rows
- API save_nav passes g.market.id
- Scraper default URLs point to /suma-market/ on port 8001

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-10 18:08:48 +00:00
parent 6a266bd94d
commit 9b2687b039
13 changed files with 224 additions and 48 deletions

90
app.py
View File

@@ -1,7 +1,11 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared_lib to sys.path import path_setup # noqa: F401 # adds shared_lib to sys.path
from quart import g from pathlib import Path
from quart import g, abort, render_template, make_response
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.factory import create_base_app from shared.factory import create_base_app
from config import config from config import config
@@ -38,17 +42,95 @@ async def market_context() -> dict:
def create_app() -> "Quart": def create_app() -> "Quart":
from models.market_place import MarketPlace
from models.ghost_content import Post
app = create_base_app("market", context_fn=market_context) app = create_base_app("market", context_fn=market_context)
# Market blueprint at root (was /market in monolith) # App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
app.jinja_loader = ChoiceLoader([
FileSystemLoader(app_templates),
app.jinja_loader,
])
# Market blueprint scoped under /<market_slug>/
app.register_blueprint( app.register_blueprint(
register_market_bp( register_market_bp(
url_prefix="/", url_prefix="/<market_slug>",
title=config()["coop_title"], title=config()["coop_title"],
), ),
url_prefix="/", url_prefix="/<market_slug>",
) )
# --- Auto-inject market_slug into url_for() calls ---
@app.url_value_preprocessor
def pull_market_slug(endpoint, values):
if values and "market_slug" in values:
g.market_slug = values.pop("market_slug")
@app.url_defaults
def inject_market_slug(endpoint, values):
slug = g.get("market_slug")
if slug and "market_slug" not in values:
if app.url_map.is_endpoint_expecting(endpoint, "market_slug"):
values["market_slug"] = slug
# --- Load market data for market_slug ---
@app.before_request
async def hydrate_market():
slug = getattr(g, "market_slug", None)
if not slug:
return
market = (
await g.s.execute(
select(MarketPlace).where(
MarketPlace.slug == slug,
MarketPlace.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if not market:
abort(404)
g.market = market
# Load associated Post for context
post = (
await g.s.execute(
select(Post).where(Post.id == market.post_id)
)
).scalar_one_or_none()
if post:
g.post_data = {
"post": {
"id": post.id,
"title": post.title,
"slug": post.slug,
"feature_image": post.feature_image,
"html": post.html,
"status": post.status,
"visibility": post.visibility,
"is_page": post.is_page,
},
}
# --- Root route: market listing ---
@app.get("/")
async def markets_listing():
markets = (
await g.s.execute(
select(MarketPlace)
.where(MarketPlace.deleted_at.is_(None))
.order_by(MarketPlace.name)
)
).scalars().all()
html = await render_template(
"_types/market/markets_listing.html",
markets=markets,
)
return await make_response(html)
return app return app

View File

@@ -338,7 +338,9 @@ async def rediects():
@clear_cache(tag='browse') @clear_cache(tag='browse')
async def save_nav(): async def save_nav():
data: Dict[str, Any] = await request.get_json(force=True, silent=False) data: Dict[str, Any] = await request.get_json(force=True, silent=False)
await _save_nav(g.s, data) market = getattr(g, "market", None)
market_id = market.id if market else None
await _save_nav(g.s, data, market_id=market_id)
return {"ok": True} return {"ok": True}

View File

@@ -37,14 +37,9 @@ def register():
async def home(): async def home():
""" """
Market landing page. Market landing page.
Shows the Ghost CMS post with slug='market'. Uses the post data hydrated by the app-level before_request (g.post_data).
""" """
from shared.internal_api import get as api_get p_data = getattr(g, "post_data", None) or {}
# Fetch the market post from coop internal API
p_data = await api_get("coop", "/internal/post/market")
if not p_data:
abort(404)
# Determine which template to use based on request type # Determine which template to use based on request type
if not is_htmx_request(): if not is_htmx_request():
@@ -63,7 +58,9 @@ def register():
Browse all products across all categories. Browse all products across all categories.
Renders full page or just product cards (HTMX pagination fragment). Renders full page or just product cards (HTMX pagination fragment).
""" """
nav = await get_nav(g.s) market = getattr(g, "market", None)
market_id = market.id if market else None
nav = await get_nav(g.s, market_id=market_id)
ctx = { ctx = {
"category_label": "All Products", "category_label": "All Products",
"top_slug": "all", "top_slug": "all",
@@ -102,7 +99,9 @@ def register():
if is_category_blocked(top_slug): if is_category_blocked(top_slug):
abort(404) abort(404)
nav = await get_nav(g.s) market = getattr(g, "market", None)
market_id = market.id if market else None
nav = await get_nav(g.s, market_id=market_id)
ctx = category_context(top_slug, None, nav) ctx = category_context(top_slug, None, nav)
product_info = await _productInfo(top_slug) product_info = await _productInfo(top_slug)
@@ -136,7 +135,9 @@ def register():
if is_category_blocked(top_slug, sub_slug): if is_category_blocked(top_slug, sub_slug):
abort(404) abort(404)
nav = await get_nav(g.s) market = getattr(g, "market", None)
market_id = market.id if market else None
nav = await get_nav(g.s, market_id=market_id)
ctx = category_context(top_slug, sub_slug, nav) ctx = category_context(top_slug, sub_slug, nav)
product_info = await _productInfo(top_slug, sub_slug) product_info = await _productInfo(top_slug, sub_slug)

View File

@@ -34,9 +34,19 @@ def _regular_price_of(p: Product) -> Optional[float]:
return None return None
# ---------- NAV ---------- # ---------- NAV ----------
async def db_nav(session) -> Dict: async def db_nav(session, market_id=None) -> Dict:
tops = (await session.execute(select(NavTop))).scalars().all() top_q = select(NavTop).where(NavTop.deleted_at.is_(None))
subs = (await session.execute(select(NavSub))).scalars().all() if market_id is not None:
top_q = top_q.where(NavTop.market_id == market_id)
tops = (await session.execute(top_q)).scalars().all()
top_ids = [t.id for t in tops]
if top_ids:
subs = (await session.execute(
select(NavSub).where(NavSub.top_id.in_(top_ids), NavSub.deleted_at.is_(None))
)).scalars().all()
else:
subs = []
subs_by_top: Dict[int, List[Dict]] = {} subs_by_top: Dict[int, List[Dict]] = {}
for s in subs: for s in subs:
@@ -274,7 +284,8 @@ async def db_products_nocounts(
sort: Optional[str] = None, sort: Optional[str] = None,
page_size: int = 20, page_size: int = 20,
liked: bool = None, liked: bool = None,
user_id: int=0 user_id: int=0,
market_id: int | None = None,
) -> Dict: ) -> Dict:
BLOCKED_SLUGS = set((config().get("blacklist", {}).get("product", []) or [])) BLOCKED_SLUGS = set((config().get("blacklist", {}).get("product", []) or []))
base_conditions = [] base_conditions = []
@@ -284,18 +295,21 @@ async def db_products_nocounts(
) )
if top_slug: if top_slug:
q_list_conditions = [
Listing.deleted_at.is_(None),
NavTop.deleted_at.is_(None),
NavTop.slug == top_slug,
NavSub.deleted_at.is_(None),
NavSub.slug == sub_slug if sub_slug else Listing.sub_id.is_(None),
]
if market_id is not None:
q_list_conditions.append(NavTop.market_id == market_id)
q_list = ( q_list = (
select(Listing.id) select(Listing.id)
.join(NavTop, Listing.top) .join(NavTop, Listing.top)
.outerjoin(NavSub, Listing.sub) .outerjoin(NavSub, Listing.sub)
.where( .where(*q_list_conditions)
Listing.deleted_at.is_(None),
NavTop.deleted_at.is_(None),
NavTop.slug == top_slug,
NavSub.deleted_at.is_(None),
NavSub.slug == sub_slug if sub_slug else Listing.sub_id.is_(None),
)
) )
listing_id = (await session.execute(q_list)).scalars().first() listing_id = (await session.execute(q_list)).scalars().first()
@@ -305,6 +319,20 @@ async def db_products_nocounts(
base_conditions.append(Product.slug.in_( base_conditions.append(Product.slug.in_(
select(ListingItem.slug).where(ListingItem.listing_id == listing_id, ListingItem.deleted_at.is_(None)) select(ListingItem.slug).where(ListingItem.listing_id == listing_id, ListingItem.deleted_at.is_(None))
)) ))
elif market_id is not None:
# Browse all within a specific market: filter products through market's nav hierarchy
market_product_slugs = (
select(ListingItem.slug)
.join(Listing, ListingItem.listing_id == Listing.id)
.join(NavTop, Listing.top_id == NavTop.id)
.where(
ListingItem.deleted_at.is_(None),
Listing.deleted_at.is_(None),
NavTop.deleted_at.is_(None),
NavTop.market_id == market_id,
)
)
base_conditions.append(Product.slug.in_(market_product_slugs))
base_ids_subq = select(Product.id).where(*base_conditions, Product.deleted_at.is_(None)) base_ids_subq = select(Product.id).where(*base_conditions, Product.deleted_at.is_(None))
base_ids = (await session.execute(base_ids_subq)).scalars().all() base_ids = (await session.execute(base_ids_subq)).scalars().all()
@@ -461,17 +489,21 @@ async def db_products_counts(
top_slug: str | None, top_slug: str | None,
sub_slug: str | None, sub_slug: str | None,
search: Optional[str] = None, search: Optional[str] = None,
user_id: int=0 user_id: int=0,
market_id: int | None = None,
) -> Dict: ) -> Dict:
BLOCKED_SLUGS = set((config().get("blacklist", {}).get("product", []) or [])) BLOCKED_SLUGS = set((config().get("blacklist", {}).get("product", []) or []))
base_conditions = [] base_conditions = []
if top_slug: if top_slug:
q_list = select(Listing.id).where( q_list_conditions = [
Listing.deleted_at.is_(None), Listing.deleted_at.is_(None),
Listing.top.has(slug=top_slug), Listing.top.has(slug=top_slug),
Listing.sub.has(slug=sub_slug) if sub_slug else Listing.sub_id.is_(None), Listing.sub.has(slug=sub_slug) if sub_slug else Listing.sub_id.is_(None),
) ]
if market_id is not None:
q_list_conditions.append(Listing.top.has(market_id=market_id))
q_list = select(Listing.id).where(*q_list_conditions)
listing_id = (await session.execute(q_list)).scalars().first() listing_id = (await session.execute(q_list)).scalars().first()
if not listing_id: if not listing_id:
return { return {
@@ -494,7 +526,29 @@ async def db_products_counts(
else: else:
base_conditions.append(Product.slug.in_(listing_slug_subquery)) base_conditions.append(Product.slug.in_(listing_slug_subquery))
else: else:
if BLOCKED_SLUGS: if market_id is not None:
# Browse all within a specific market
market_product_slugs = (
select(ListingItem.slug)
.join(Listing, ListingItem.listing_id == Listing.id)
.join(NavTop, Listing.top_id == NavTop.id)
.where(
ListingItem.deleted_at.is_(None),
Listing.deleted_at.is_(None),
NavTop.deleted_at.is_(None),
NavTop.market_id == market_id,
)
)
if BLOCKED_SLUGS:
base_conditions.append(
and_(
Product.slug.in_(market_product_slugs),
~Product.slug.in_(BLOCKED_SLUGS),
)
)
else:
base_conditions.append(Product.slug.in_(market_product_slugs))
elif BLOCKED_SLUGS:
base_conditions.append(~Product.slug.in_(BLOCKED_SLUGS)) base_conditions.append(~Product.slug.in_(BLOCKED_SLUGS))
base_ids = (await session.execute(select(Product.id).where(*base_conditions, Product.deleted_at.is_(None)))).scalars().all() base_ids = (await session.execute(select(Product.id).where(*base_conditions, Product.deleted_at.is_(None)))).scalars().all()
if base_ids: if base_ids:
@@ -628,7 +682,8 @@ async def db_products(
sort: Optional[str] = None, sort: Optional[str] = None,
page_size: int = 20, page_size: int = 20,
liked: bool = None, liked: bool = None,
user_id: int=0 user_id: int=0,
market_id: int | None = None,
) -> Dict: ) -> Dict:
return { return {
**(await db_products_nocounts( **(await db_products_nocounts(
@@ -643,14 +698,16 @@ async def db_products(
sort=sort, sort=sort,
page_size=page_size, page_size=page_size,
liked=liked, liked=liked,
user_id=user_id user_id=user_id,
market_id=market_id,
)), )),
**(await db_products_counts( **(await db_products_counts(
session, session,
top_slug=top_slug, top_slug=top_slug,
sub_slug=sub_slug, sub_slug=sub_slug,
search=search, search=search,
user_id=user_id user_id=user_id,
market_id=market_id,
)), )),
} }

View File

@@ -53,7 +53,7 @@ def group_by_category(slug_to_links: Dict[str, List[Tuple[str, str]]]) -> Dict[s
return nav return nav
async def get_nav(session) -> Dict[str, Dict]: async def get_nav(session, market_id=None) -> Dict[str, Dict]:
""" """
Return navigation structure; annotate each sub with product counts. Return navigation structure; annotate each sub with product counts.
Uses snapshot for offline behaviour. Uses snapshot for offline behaviour.
@@ -62,7 +62,7 @@ async def get_nav(session) -> Dict[str, Dict]:
now_ts = _now() now_ts = _now()
# load from snapshot # load from snapshot
nav = await cb.db_nav(session) nav = await cb.db_nav(session, market_id=market_id)
# inject counts for each subcategory (and for top-level too if you like) # inject counts for each subcategory (and for top-level too if you like)
for label, cat in (nav.get("cats") or {}).items(): for label, cat in (nav.get("cats") or {}).items():

View File

@@ -19,9 +19,8 @@ async def products(
search: Optional[str] = None, search: Optional[str] = None,
sort: Optional[str] = None, sort: Optional[str] = None,
liked: Optional[bool] = None, liked: Optional[bool] = None,
# NEW:
user_id: Optional[int] = None, user_id: Optional[int] = None,
market_id: int | None = None,
): ):
p = urlparse(list_url) p = urlparse(list_url)
parts = [x for x in (p.path or "").split("/") if x] parts = [x for x in (p.path or "").split("/") if x]
@@ -41,7 +40,8 @@ async def products(
search, search,
sort, sort,
liked=liked, liked=liked,
user_id = g.user.id if g.user else 0 user_id = g.user.id if g.user else 0,
market_id=market_id,
) )
items = data.get("items", []) or [] items = data.get("items", []) or []
brands = data.get("brands", []) or [] brands = data.get("brands", []) or []
@@ -76,9 +76,8 @@ async def products_nocounts(
search: Optional[str] = None, search: Optional[str] = None,
sort: Optional[str] = None, sort: Optional[str] = None,
liked: Optional[bool] = None, liked: Optional[bool] = None,
# NEW:
user_id: Optional[int] = None, user_id: Optional[int] = None,
market_id: int | None = None,
): ):
p = urlparse(list_url) p = urlparse(list_url)
parts = [x for x in (p.path or "").split("/") if x] parts = [x for x in (p.path or "").split("/") if x]
@@ -99,6 +98,7 @@ async def products_nocounts(
sort, sort,
liked=liked, liked=liked,
user_id = g.user.id if g.user else 0, user_id = g.user.id if g.user else 0,
market_id=market_id,
) )
items = data.get("items", []) or [] items = data.get("items", []) or []
total_pages = int(data.get("total_pages", 1) or 1) total_pages = int(data.get("total_pages", 1) or 1)

View File

@@ -26,12 +26,16 @@ async def _productInfo(top_slug=None, sub_slug=None):
Shared query logic for home / category / subcategory pages. Shared query logic for home / category / subcategory pages.
Pulls filters from qs.decode(), queries products(), and orders brands/stickers/etc. Pulls filters from qs.decode(), queries products(), and orders brands/stickers/etc.
""" """
q = decode() q = decode()
page, search, sort = q.page, q.search, q.sort page, search, sort = q.page, q.search, q.sort
selected_brands, selected_stickers, selected_labels = q.selected_brands, q.selected_stickers, q.selected_labels selected_brands, selected_stickers, selected_labels = q.selected_brands, q.selected_stickers, q.selected_labels
liked = q.liked liked = q.liked
# Get market_id from hydrated market context
market = getattr(g, "market", None)
market_id = market.id if market else None
if top_slug is not None and sub_slug is not None: if top_slug is not None and sub_slug is not None:
list_url = urljoin(config()["base_url"], f"/{top_slug}/{sub_slug}") list_url = urljoin(config()["base_url"], f"/{top_slug}/{sub_slug}")
else: else:
@@ -50,6 +54,7 @@ async def _productInfo(top_slug=None, sub_slug=None):
sort=sort, sort=sort,
user_id=g.user.id if g.user else None, user_id=g.user.id if g.user else None,
liked = liked, liked = liked,
market_id=market_id,
) )
brands_ordered = _order_brands_selected_first(brands, selected_brands) brands_ordered = _order_brands_selected_first(brands, selected_brands)
@@ -82,6 +87,7 @@ async def _productInfo(top_slug=None, sub_slug=None):
sort=sort, sort=sort,
user_id=g.user.id if g.user else None, user_id=g.user.id if g.user else None,
liked = liked, liked = liked,
market_id=market_id,
) )
return { return {
"products": items, "products": items,

View File

@@ -22,10 +22,13 @@ def register(url_prefix, title):
@bp.context_processor @bp.context_processor
async def inject_root(): async def inject_root():
market = getattr(g, "market", None)
market_id = market.id if market else None
return { return {
"coop_title": title, "coop_title": title,
"categories": (await get_nav(g.s))["cats"], "categories": (await get_nav(g.s, market_id=market_id))["cats"],
"qs": makeqs_factory()(), "qs": makeqs_factory()(),
"market": market,
} }
bp.register_blueprint( bp.register_blueprint(

View File

@@ -11,7 +11,7 @@ async def capture_listing(
total_pages: int total_pages: int
): ):
sync_url = os.getenv("CAPTURE_LISTING_URL", "http://localhost:8000/market/api/products/listing/") sync_url = os.getenv("CAPTURE_LISTING_URL", "http://localhost:8001/suma-market/api/products/listing/")
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client:
_d = { _d = {

View File

@@ -8,7 +8,7 @@ from typing import Dict
async def save_nav( async def save_nav(
nav: Dict, nav: Dict,
): ):
sync_url = os.getenv("SAVE_NAV_URL", "http://localhost:8000/market/api/products/nav/") sync_url = os.getenv("SAVE_NAV_URL", "http://localhost:8001/suma-market/api/products/nav/")
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client:
resp = await client.post(sync_url, json=nav) resp = await client.post(sync_url, json=nav)

View File

@@ -21,7 +21,7 @@ async def upsert_product(
d["slug"] = slug d["slug"] = slug
# Where to post; override via env if needed # Where to post; override via env if needed
sync_url = os.getenv("PRODUCT_SYNC_URL", "http://localhost:8000/market/api/products/sync/") sync_url = os.getenv("PRODUCT_SYNC_URL", "http://localhost:8001/suma-market/api/products/sync/")

View File

@@ -19,7 +19,7 @@ async def save_nav(nav: Dict) -> None:
await _save_nav(session, nav) await _save_nav(session, nav)
await session.commit() await session.commit()
async def _save_nav(session, nav: Dict) -> None: async def _save_nav(session, nav: Dict, market_id=None) -> None:
print('===================SAVE NAV========================') print('===================SAVE NAV========================')
print(nav) print(nav)
now = datetime.utcnow() now = datetime.utcnow()
@@ -79,8 +79,10 @@ async def _save_nav(session, nav: Dict) -> None:
if top: if top:
top.label = label top.label = label
top.deleted_at = None top.deleted_at = None
if market_id is not None and top.market_id is None:
top.market_id = market_id
else: else:
top = NavTop(label=label, slug=top_slug) top = NavTop(label=label, slug=top_slug, market_id=market_id)
session.add(top) session.add(top)
await session.flush() await session.flush()

View File

@@ -0,0 +1,23 @@
{% extends '_types/root/_index.html' %}
{% block content %}
<div class="max-w-3xl mx-auto py-8 px-4">
<h1 class="text-2xl font-bold mb-6">Markets</h1>
{% if markets %}
<div class="grid gap-4">
{% for m in markets %}
<a href="/{{ m.slug }}/"
class="block p-6 bg-white border border-stone-200 rounded-lg hover:border-stone-400 transition-colors">
<h2 class="text-lg font-semibold">{{ m.name }}</h2>
{% if m.description %}
<p class="text-stone-600 mt-1">{{ m.description }}</p>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<p class="text-stone-500">No markets available.</p>
{% endif %}
</div>
{% endblock %}