feat: initialize market app with browsing, product, and scraping code
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Split from coop monolith. Includes: - Market/browse/product blueprints - Product sync API - Suma scraping pipeline - Templates for market, browse, and product views - Dockerfile and CI workflow for independent deployment
This commit is contained in:
163
bp/browse/services/nav.py
Normal file
163
bp/browse/services/nav.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import re
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
from config import config
|
||||
from . import db_backend as cb
|
||||
from .blacklist.category import is_category_blocked # Reverse map: slug -> label
|
||||
|
||||
# ------------------ Caches ------------------
|
||||
_nav_cache: Dict = {}
|
||||
_nav_cache_ts: float = 0.0
|
||||
_nav_ttl_seconds = 60 * 60 * 6 # 6 hours
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
try:
|
||||
return now() # type: ignore[name-defined]
|
||||
except Exception:
|
||||
return time.time()
|
||||
|
||||
|
||||
def extract_sub_slug(href: str, top_slug: str) -> Optional[str]:
|
||||
p = urlparse(href)
|
||||
parts = [x for x in (p.path or "").split("/") if x]
|
||||
if len(parts) >= 2 and parts[0].lower() == top_slug.lower():
|
||||
sub = parts[1]
|
||||
if sub.lower().endswith((".html", ".htm")):
|
||||
sub = re.sub(r"\.(html?|HTML?)$", "", sub)
|
||||
return sub
|
||||
return None
|
||||
|
||||
|
||||
def group_by_category(slug_to_links: Dict[str, List[Tuple[str, str]]]) -> Dict[str, Dict]:
|
||||
nav = {"cats": {}}
|
||||
for label, slug in config()["categories"]["allow"].items():
|
||||
top_href = urljoin(config()["base_url"], f"/{slug}")
|
||||
subs = []
|
||||
for text, href in slug_to_links.get(slug, []):
|
||||
sub_slug = extract_sub_slug(href, slug)
|
||||
if sub_slug:
|
||||
subs.append({
|
||||
"name": text,
|
||||
"href": href,
|
||||
"slug": sub_slug,
|
||||
# no count here yet in this path
|
||||
})
|
||||
subs.sort(key=lambda x: x["name"].lower())
|
||||
nav["cats"][label] = {"href": top_href, "slug": slug, "subs": subs}
|
||||
nav = _apply_category_blacklist(nav)
|
||||
return nav
|
||||
|
||||
|
||||
async def get_nav(session) -> Dict[str, Dict]:
|
||||
"""
|
||||
Return navigation structure; annotate each sub with product counts.
|
||||
Uses snapshot for offline behaviour.
|
||||
"""
|
||||
global _nav_cache, _nav_cache_ts
|
||||
now_ts = _now()
|
||||
|
||||
# load from snapshot
|
||||
nav = await cb.db_nav(session)
|
||||
|
||||
# inject counts for each subcategory (and for top-level too if you like)
|
||||
for label, cat in (nav.get("cats") or {}).items():
|
||||
top_slug = cat.get("slug")
|
||||
if not top_slug:
|
||||
continue
|
||||
|
||||
|
||||
# Counts for subs
|
||||
new_subs = []
|
||||
for s in cat.get("subs", []):
|
||||
s.get("slug")
|
||||
#if not sub_slug:
|
||||
# s_count = 0
|
||||
#else:
|
||||
# s_count = await cb.db_count_products_in_sub(session,top_slug, sub_slug)
|
||||
#print('sub', s_count)
|
||||
new_subs.append({
|
||||
**s,
|
||||
#"count": s_count,
|
||||
})
|
||||
cat["subs"] = new_subs
|
||||
|
||||
_nav_cache = nav
|
||||
_nav_cache_ts = now_ts
|
||||
|
||||
nav = _apply_category_blacklist(nav)
|
||||
return nav
|
||||
|
||||
|
||||
def category_context(top_slug: Optional[str], sub_slug: Optional[str], nav: Dict[str, Dict]):
|
||||
"""Build template context for a category/subcategory page."""
|
||||
def _order_subs_selected_first(subs, sub_slug: str | None):
|
||||
"""Return subs with the selected subcategory (by slug) first."""
|
||||
if not subs or not sub_slug:
|
||||
return subs
|
||||
head = [s for s in subs if sub_slug and sub_slug.lower() == s['slug']]
|
||||
tail = [s for s in subs if not (sub_slug and sub_slug.lower() == s['slug'])]
|
||||
return head + tail
|
||||
|
||||
REVERSE_CATEGORY = {v: k for k, v in config()["categories"]["allow"].items()}
|
||||
label = REVERSE_CATEGORY.get(top_slug)
|
||||
cat = nav["cats"].get(label) or {}
|
||||
|
||||
top_suma_href = cat.get("href") or urljoin(config()["base_url"], f"/{top_slug}")
|
||||
top_local_href = f"{top_slug}"
|
||||
|
||||
# total products in this top-level category (all subs combined / top-level listing)
|
||||
top_count = cat.get("count", 0)
|
||||
|
||||
subs = []
|
||||
for s in cat.get("subs", []):
|
||||
subs.append({
|
||||
"name": s["name"],
|
||||
"slug": s.get("slug"),
|
||||
"local_href": f"{top_slug}/{s.get('slug')}",
|
||||
"suma_href": s["href"],
|
||||
"count": s.get("count", 0), # per-subcategory product count
|
||||
})
|
||||
|
||||
current_local_href = (
|
||||
f"{top_slug}/{sub_slug}" if sub_slug
|
||||
else f"{top_slug}" if top_slug
|
||||
else ""
|
||||
)
|
||||
|
||||
return {
|
||||
"category_label": label,
|
||||
"top_slug": top_slug,
|
||||
"sub_slug": sub_slug,
|
||||
"top_suma_href": top_suma_href,
|
||||
"top_local_href": top_local_href,
|
||||
|
||||
# 👇 expose total count for the parent category
|
||||
"top_count": top_count,
|
||||
|
||||
# list of subcategories, each with its own count
|
||||
"subs_local": _order_subs_selected_first(subs, sub_slug),
|
||||
|
||||
#"current_local_href": current_local_href,
|
||||
}
|
||||
|
||||
def _apply_category_blacklist(nav: Dict[str, Dict]) -> Dict[str, Dict]:
|
||||
cats = nav.get("cats", {})
|
||||
out = {"cats": {}}
|
||||
for label, data in cats.items():
|
||||
top = (data or {}).get("slug")
|
||||
if not top or is_category_blocked(top):
|
||||
continue
|
||||
# filter subs
|
||||
subs = []
|
||||
for s in (data.get("subs") or []):
|
||||
sub_slug = s.get("slug")
|
||||
if sub_slug and not is_category_blocked(top, sub_slug):
|
||||
subs.append(s)
|
||||
# keep everything else (including counts)
|
||||
out["cats"][label] = {**data, "subs": subs}
|
||||
return out
|
||||
Reference in New Issue
Block a user