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

View File

@@ -338,7 +338,9 @@ async def rediects():
@clear_cache(tag='browse')
async def save_nav():
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}

View File

@@ -37,14 +37,9 @@ def register():
async def home():
"""
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
# Fetch the market post from coop internal API
p_data = await api_get("coop", "/internal/post/market")
if not p_data:
abort(404)
p_data = getattr(g, "post_data", None) or {}
# Determine which template to use based on request type
if not is_htmx_request():
@@ -63,7 +58,9 @@ def register():
Browse all products across all categories.
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 = {
"category_label": "All Products",
"top_slug": "all",
@@ -102,7 +99,9 @@ def register():
if is_category_blocked(top_slug):
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)
product_info = await _productInfo(top_slug)
@@ -136,7 +135,9 @@ def register():
if is_category_blocked(top_slug, sub_slug):
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)
product_info = await _productInfo(top_slug, sub_slug)

View File

@@ -34,9 +34,19 @@ def _regular_price_of(p: Product) -> Optional[float]:
return None
# ---------- NAV ----------
async def db_nav(session) -> Dict:
tops = (await session.execute(select(NavTop))).scalars().all()
subs = (await session.execute(select(NavSub))).scalars().all()
async def db_nav(session, market_id=None) -> Dict:
top_q = select(NavTop).where(NavTop.deleted_at.is_(None))
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]] = {}
for s in subs:
@@ -274,7 +284,8 @@ async def db_products_nocounts(
sort: Optional[str] = None,
page_size: int = 20,
liked: bool = None,
user_id: int=0
user_id: int=0,
market_id: int | None = None,
) -> Dict:
BLOCKED_SLUGS = set((config().get("blacklist", {}).get("product", []) or []))
base_conditions = []
@@ -284,18 +295,21 @@ async def db_products_nocounts(
)
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 = (
select(Listing.id)
.join(NavTop, Listing.top)
.outerjoin(NavSub, Listing.sub)
.where(
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),
)
.where(*q_list_conditions)
)
listing_id = (await session.execute(q_list)).scalars().first()
@@ -305,6 +319,20 @@ async def db_products_nocounts(
base_conditions.append(Product.slug.in_(
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 = (await session.execute(base_ids_subq)).scalars().all()
@@ -461,17 +489,21 @@ async def db_products_counts(
top_slug: str | None,
sub_slug: str | None,
search: Optional[str] = None,
user_id: int=0
user_id: int=0,
market_id: int | None = None,
) -> Dict:
BLOCKED_SLUGS = set((config().get("blacklist", {}).get("product", []) or []))
base_conditions = []
if top_slug:
q_list = select(Listing.id).where(
q_list_conditions = [
Listing.deleted_at.is_(None),
Listing.top.has(slug=top_slug),
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()
if not listing_id:
return {
@@ -494,7 +526,29 @@ async def db_products_counts(
else:
base_conditions.append(Product.slug.in_(listing_slug_subquery))
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_ids = (await session.execute(select(Product.id).where(*base_conditions, Product.deleted_at.is_(None)))).scalars().all()
if base_ids:
@@ -628,7 +682,8 @@ async def db_products(
sort: Optional[str] = None,
page_size: int = 20,
liked: bool = None,
user_id: int=0
user_id: int=0,
market_id: int | None = None,
) -> Dict:
return {
**(await db_products_nocounts(
@@ -643,14 +698,16 @@ async def db_products(
sort=sort,
page_size=page_size,
liked=liked,
user_id=user_id
user_id=user_id,
market_id=market_id,
)),
**(await db_products_counts(
session,
top_slug=top_slug,
sub_slug=sub_slug,
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
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.
Uses snapshot for offline behaviour.
@@ -62,7 +62,7 @@ async def get_nav(session) -> Dict[str, Dict]:
now_ts = _now()
# 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)
for label, cat in (nav.get("cats") or {}).items():

View File

@@ -19,9 +19,8 @@ async def products(
search: Optional[str] = None,
sort: Optional[str] = None,
liked: Optional[bool] = None,
# NEW:
user_id: Optional[int] = None,
market_id: int | None = None,
):
p = urlparse(list_url)
parts = [x for x in (p.path or "").split("/") if x]
@@ -41,7 +40,8 @@ async def products(
search,
sort,
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 []
brands = data.get("brands", []) or []
@@ -76,9 +76,8 @@ async def products_nocounts(
search: Optional[str] = None,
sort: Optional[str] = None,
liked: Optional[bool] = None,
# NEW:
user_id: Optional[int] = None,
market_id: int | None = None,
):
p = urlparse(list_url)
parts = [x for x in (p.path or "").split("/") if x]
@@ -99,6 +98,7 @@ async def products_nocounts(
sort,
liked=liked,
user_id = g.user.id if g.user else 0,
market_id=market_id,
)
items = data.get("items", []) or []
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.
Pulls filters from qs.decode(), queries products(), and orders brands/stickers/etc.
"""
q = decode()
page, search, sort = q.page, q.search, q.sort
selected_brands, selected_stickers, selected_labels = q.selected_brands, q.selected_stickers, q.selected_labels
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:
list_url = urljoin(config()["base_url"], f"/{top_slug}/{sub_slug}")
else:
@@ -50,6 +54,7 @@ async def _productInfo(top_slug=None, sub_slug=None):
sort=sort,
user_id=g.user.id if g.user else None,
liked = liked,
market_id=market_id,
)
brands_ordered = _order_brands_selected_first(brands, selected_brands)
@@ -82,6 +87,7 @@ async def _productInfo(top_slug=None, sub_slug=None):
sort=sort,
user_id=g.user.id if g.user else None,
liked = liked,
market_id=market_id,
)
return {
"products": items,

View File

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