diff --git a/app.py b/app.py index 5e53590..35391d5 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,11 @@ from __future__ import annotations 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 config import config @@ -38,17 +42,95 @@ async def market_context() -> dict: 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) - # 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 // app.register_blueprint( register_market_bp( - url_prefix="/", + url_prefix="/", title=config()["coop_title"], ), - url_prefix="/", + url_prefix="/", ) + # --- 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 diff --git a/bp/api/routes.py b/bp/api/routes.py index 5ab7b10..01bb799 100644 --- a/bp/api/routes.py +++ b/bp/api/routes.py @@ -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} diff --git a/bp/browse/routes.py b/bp/browse/routes.py index bdc820b..3fb422c 100644 --- a/bp/browse/routes.py +++ b/bp/browse/routes.py @@ -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) diff --git a/bp/browse/services/db_backend.py b/bp/browse/services/db_backend.py index 86dd41b..72cd79d 100644 --- a/bp/browse/services/db_backend.py +++ b/bp/browse/services/db_backend.py @@ -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, )), } diff --git a/bp/browse/services/nav.py b/bp/browse/services/nav.py index 6a3a901..68ddc4b 100644 --- a/bp/browse/services/nav.py +++ b/bp/browse/services/nav.py @@ -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(): diff --git a/bp/browse/services/products.py b/bp/browse/services/products.py index 7fd931c..f9a7be3 100644 --- a/bp/browse/services/products.py +++ b/bp/browse/services/products.py @@ -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) diff --git a/bp/browse/services/services.py b/bp/browse/services/services.py index a3b00c2..150f4ef 100644 --- a/bp/browse/services/services.py +++ b/bp/browse/services/services.py @@ -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, diff --git a/bp/market/routes.py b/bp/market/routes.py index 48de972..8f928a8 100644 --- a/bp/market/routes.py +++ b/bp/market/routes.py @@ -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( diff --git a/scrape/persist_api/capture_listing.py b/scrape/persist_api/capture_listing.py index 280f1d0..6774602 100644 --- a/scrape/persist_api/capture_listing.py +++ b/scrape/persist_api/capture_listing.py @@ -11,7 +11,7 @@ async def capture_listing( 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: _d = { diff --git a/scrape/persist_api/save_nav.py b/scrape/persist_api/save_nav.py index 238fac7..8ffd915 100644 --- a/scrape/persist_api/save_nav.py +++ b/scrape/persist_api/save_nav.py @@ -8,7 +8,7 @@ from typing import Dict async def save_nav( 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: resp = await client.post(sync_url, json=nav) diff --git a/scrape/persist_api/upsert_product.py b/scrape/persist_api/upsert_product.py index 7eb46d3..3e66b6b 100644 --- a/scrape/persist_api/upsert_product.py +++ b/scrape/persist_api/upsert_product.py @@ -21,7 +21,7 @@ async def upsert_product( d["slug"] = slug # 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/") diff --git a/scrape/persist_snapshot/save_nav.py b/scrape/persist_snapshot/save_nav.py index 1d3cf00..3c0fb2b 100644 --- a/scrape/persist_snapshot/save_nav.py +++ b/scrape/persist_snapshot/save_nav.py @@ -19,7 +19,7 @@ async def save_nav(nav: Dict) -> None: await _save_nav(session, nav) 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(nav) now = datetime.utcnow() @@ -79,8 +79,10 @@ async def _save_nav(session, nav: Dict) -> None: if top: top.label = label top.deleted_at = None + if market_id is not None and top.market_id is None: + top.market_id = market_id else: - top = NavTop(label=label, slug=top_slug) + top = NavTop(label=label, slug=top_slug, market_id=market_id) session.add(top) await session.flush() diff --git a/templates/_types/market/markets_listing.html b/templates/_types/market/markets_listing.html new file mode 100644 index 0000000..690d17b --- /dev/null +++ b/templates/_types/market/markets_listing.html @@ -0,0 +1,23 @@ +{% extends '_types/root/_index.html' %} + +{% block content %} +
+

Markets

+ + {% if markets %} + + {% else %} +

No markets available.

+ {% endif %} +
+{% endblock %}