diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py index c131c74..d61fc3e 100644 --- a/app.py +++ b/app.py @@ -7,11 +7,10 @@ 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.cart_loader import load_cart -from config import config +from shared.infrastructure.factory import create_base_app +from shared.config import config -from suma_browser.app.bp import register_market_bp +from bp import register_market_bp async def market_context() -> dict: @@ -21,8 +20,8 @@ async def market_context() -> dict: - menu_items: fetched from coop internal API - cart_count/cart_total: fetched from cart internal API """ - from shared.context import base_context - from shared.internal_api import get as api_get, dictobj + from shared.infrastructure.context import base_context + from shared.infrastructure.internal_api import get as api_get, dictobj ctx = await base_context() @@ -44,9 +43,9 @@ async def market_context() -> dict: def create_app() -> "Quart": from models.market_place import MarketPlace - from models.ghost_content import Post + from blog.models.ghost_content import Post - app = create_base_app("market", context_fn=market_context, before_request_fns=[load_cart]) + app = create_base_app("market", context_fn=market_context) # App-specific templates override shared templates app_templates = str(Path(__file__).resolve().parent / "templates") @@ -111,12 +110,13 @@ def create_app() -> "Quart": }, } - # Load market scoped to post + # Load market scoped to post (container pattern) market = ( await g.s.execute( select(MarketPlace).where( MarketPlace.slug == market_slug, - MarketPlace.post_id == post.id, + MarketPlace.container_type == "page", + MarketPlace.container_id == post.id, MarketPlace.deleted_at.is_(None), ) ) @@ -135,16 +135,24 @@ def create_app() -> "Quart": # --- Root route: market listing --- @app.get("/") async def markets_listing(): - from sqlalchemy.orm import selectinload - - markets = ( + rows = ( await g.s.execute( - select(MarketPlace) + select(MarketPlace, Post) + .join( + Post, + (MarketPlace.container_type == "page") + & (MarketPlace.container_id == Post.id), + ) .where(MarketPlace.deleted_at.is_(None)) - .options(selectinload(MarketPlace.post)) .order_by(MarketPlace.name) ) - ).scalars().all() + ).all() + + # Attach the joined post to each market for template access + markets = [] + for market, post in rows: + market.page = post + markets.append(market) html = await render_template( "_types/market/markets_listing.html", diff --git a/bp/api/routes.py b/bp/api/routes.py index 01bb799..70740d2 100644 --- a/bp/api/routes.py +++ b/bp/api/routes.py @@ -27,8 +27,8 @@ from models.market import ( ProductAllergen, ) -from suma_browser.app.redis_cacher import clear_cache -from suma_browser.app.csrf import csrf_exempt +from shared.browser.app.redis_cacher import clear_cache +from shared.browser.app.csrf import csrf_exempt products_api = Blueprint("products_api", __name__, url_prefix="/api/products") diff --git a/bp/browse/routes.py b/bp/browse/routes.py index 3fb422c..750b816 100644 --- a/bp/browse/routes.py +++ b/bp/browse/routes.py @@ -10,7 +10,7 @@ from quart import ( make_response, current_app, ) -from config import config +from shared.config import config from .services.nav import category_context, get_nav from .services.blacklist.category import is_category_blocked @@ -21,8 +21,8 @@ from .services import ( _current_url_without_page, ) -from suma_browser.app.redis_cacher import cache_page -from suma_browser.app.utils.htmx import is_htmx_request +from shared.browser.app.redis_cacher import cache_page +from shared.browser.app.utils.htmx import is_htmx_request def register(): browse_bp = Blueprint("browse", __name__) diff --git a/bp/browse/services/blacklist/category.py b/bp/browse/services/blacklist/category.py index ab8ae81..87aceda 100644 --- a/bp/browse/services/blacklist/category.py +++ b/bp/browse/services/blacklist/category.py @@ -1,7 +1,7 @@ # suma_browser/category_blacklist.py from __future__ import annotations from typing import Optional -from config import config +from shared.config import config def _norm(s: str) -> str: return (s or "").strip().lower().strip("/") diff --git a/bp/browse/services/blacklist/product.py b/bp/browse/services/blacklist/product.py index 8f877aa..d7d298b 100644 --- a/bp/browse/services/blacklist/product.py +++ b/bp/browse/services/blacklist/product.py @@ -1,6 +1,6 @@ from typing import Set, Optional from ..slugs import canonical_html_slug -from config import config +from shared.config import config _blocked: Set[str] = set() _mtime: Optional[float] = None diff --git a/bp/browse/services/blacklist/product_details.py b/bp/browse/services/blacklist/product_details.py index 7207e48..7a2244a 100644 --- a/bp/browse/services/blacklist/product_details.py +++ b/bp/browse/services/blacklist/product_details.py @@ -1,5 +1,5 @@ import re -from config import config +from shared.config import config def _norm_title_key(t: str) -> str: t = (t or "").strip().lower() diff --git a/bp/browse/services/cache_backend.py b/bp/browse/services/cache_backend.py index 1b940f6..00a0f77 100644 --- a/bp/browse/services/cache_backend.py +++ b/bp/browse/services/cache_backend.py @@ -1,7 +1,7 @@ from __future__ import annotations import os, json from typing import List, Optional -from config import config +from shared.config import config from .blacklist.product import is_product_blocked diff --git a/bp/browse/services/db_backend.py b/bp/browse/services/db_backend.py index 72cd79d..dab83b2 100644 --- a/bp/browse/services/db_backend.py +++ b/bp/browse/services/db_backend.py @@ -4,7 +4,7 @@ from typing import Dict, List, Optional from sqlalchemy import select, and_ from sqlalchemy.orm import selectinload -from config import config # if unused elsewhere, you can remove this import +from shared.config import config # if unused elsewhere, you can remove this import # ORM models from models.market import ( diff --git a/bp/browse/services/nav.py b/bp/browse/services/nav.py index 68ddc4b..bdef674 100644 --- a/bp/browse/services/nav.py +++ b/bp/browse/services/nav.py @@ -5,7 +5,7 @@ import re from typing import Dict, List, Tuple, Optional from urllib.parse import urlparse, urljoin -from config import config +from shared.config import config from . import db_backend as cb from .blacklist.category import is_category_blocked # Reverse map: slug -> label diff --git a/bp/browse/services/services.py b/bp/browse/services/services.py index 150f4ef..dbdcaad 100644 --- a/bp/browse/services/services.py +++ b/bp/browse/services/services.py @@ -6,11 +6,11 @@ from quart import ( g, request, ) -from config import config +from shared.config import config from .products import products, products_nocounts from .blacklist.product_details import is_blacklisted_heading -from utils import host_url +from shared.utils import host_url from sqlalchemy import select @@ -163,7 +163,7 @@ def _massage_product(d): # Re-export from canonical shared location -from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page +from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page async def _is_liked(user_id: int | None, slug: str) -> bool: """ diff --git a/bp/browse/services/slugs.py b/bp/browse/services/slugs.py index f23c0c7..f45a258 100644 --- a/bp/browse/services/slugs.py +++ b/bp/browse/services/slugs.py @@ -1,6 +1,6 @@ import re from urllib.parse import urljoin, urlparse -from config import config +from shared.config import config def product_slug_from_href(href: str) -> str: p = urlparse(href) diff --git a/bp/cart/services/identity.py b/bp/cart/services/identity.py index a1f8594..50ecb70 100644 --- a/bp/cart/services/identity.py +++ b/bp/cart/services/identity.py @@ -1,4 +1,4 @@ # Re-export from canonical shared location -from shared.cart_identity import CartIdentity, current_cart_identity +from shared.infrastructure.cart_identity import CartIdentity, current_cart_identity __all__ = ["CartIdentity", "current_cart_identity"] diff --git a/bp/market/admin/routes.py b/bp/market/admin/routes.py index ccea191..0b8478a 100644 --- a/bp/market/admin/routes.py +++ b/bp/market/admin/routes.py @@ -5,7 +5,7 @@ from quart import ( ) -from suma_browser.app.authz import require_admin +from shared.browser.app.authz import require_admin def register(): @@ -15,7 +15,7 @@ def register(): @bp.get("/") @require_admin async def admin(): - from suma_browser.app.utils.htmx import is_htmx_request + from shared.browser.app.utils.htmx import is_htmx_request # Determine which template to use based on request type if not is_htmx_request(): diff --git a/bp/market/filters/qs.py b/bp/market/filters/qs.py index c8b0949..d5a9950 100644 --- a/bp/market/filters/qs.py +++ b/bp/market/filters/qs.py @@ -2,10 +2,10 @@ from quart import request from typing import Iterable, Optional, Union -from suma_browser.app.filters.qs_base import ( +from shared.browser.app.filters.qs_base import ( KEEP, _norm, make_filter_set, build_qs, ) -from suma_browser.app.filters.query_types import MarketQuery +from shared.browser.app.filters.query_types import MarketQuery def decode() -> MarketQuery: diff --git a/bp/product/routes.py b/bp/product/routes.py index 0ae0014..a3807e6 100644 --- a/bp/product/routes.py +++ b/bp/product/routes.py @@ -15,8 +15,8 @@ from ..browse.services.slugs import canonical_html_slug from ..browse.services.blacklist.product import is_product_blocked from ..browse.services import db_backend as cb from ..browse.services import _massage_product -from utils import host_url -from suma_browser.app.redis_cacher import cache_page, clear_cache +from shared.utils import host_url +from shared.browser.app.redis_cacher import cache_page, clear_cache from ..cart.services import total from .services.product_operations import toggle_product_like, massage_full_product @@ -94,7 +94,7 @@ def register(): @bp.get("/") @cache_page(tag="browse") async def product_detail(slug: str): - from suma_browser.app.utils.htmx import is_htmx_request + from shared.browser.app.utils.htmx import is_htmx_request # Determine which template to use based on request type if not is_htmx_request(): @@ -136,11 +136,11 @@ def register(): ) return html - - + + @bp.get("/admin/") async def admin(slug: str): - from suma_browser.app.utils.htmx import is_htmx_request + from shared.browser.app.utils.htmx import is_htmx_request if not is_htmx_request(): # Normal browser request: full page with layout @@ -152,8 +152,8 @@ def register(): return await make_response(html) - from suma_browser.app.bp.cart.services.identity import current_cart_identity - #from suma_browser.app.bp.cart.routes import view_cart + from bp.cart.services.identity import current_cart_identity + #from bp.cart.routes import view_cart from models.market import CartItem from quart import request, url_for @@ -242,7 +242,7 @@ def register(): ) # normal POST: go to cart page - from shared.urls import cart_url + from shared.infrastructure.urls import cart_url return redirect(cart_url("/")) diff --git a/bp/product/services/product_operations.py b/bp/product/services/product_operations.py index 44c7212..343be8e 100644 --- a/bp/product/services/product_operations.py +++ b/bp/product/services/product_operations.py @@ -13,7 +13,7 @@ def massage_full_product(product: Product) -> dict: Convert a Product ORM model to a dictionary with all fields. Used for rendering product detail pages. """ - from suma_browser.app.bp.browse.services import _massage_product + from bp.browse.services import _massage_product gallery = [] if product.image: diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..9ca9e79 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,8 @@ +from .market import ( + Product, ProductLike, ProductImage, ProductSection, + NavTop, NavSub, Listing, ListingItem, + LinkError, LinkExternal, SubcategoryRedirect, ProductLog, + ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen, + CartItem, +) +from .market_place import MarketPlace diff --git a/models/market.py b/models/market.py new file mode 100644 index 0000000..87a6b72 --- /dev/null +++ b/models/market.py @@ -0,0 +1,441 @@ +# at top of persist_snapshot.py: +from datetime import datetime +from typing import Optional, List +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from typing import List, Optional + +from sqlalchemy import ( + String, Text, Integer, ForeignKey, DateTime, Boolean, Numeric, + UniqueConstraint, Index, func +) + +from shared.db.base import Base # you already import Base in app.py + + + +class Product(Base): + __tablename__ = "products" + + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + + title: Mapped[Optional[str]] = mapped_column(String(512)) + image: Mapped[Optional[str]] = mapped_column(Text) + + description_short: Mapped[Optional[str]] = mapped_column(Text) + description_html: Mapped[Optional[str]] = mapped_column(Text) + + suma_href: Mapped[Optional[str]] = mapped_column(Text) + brand: Mapped[Optional[str]] = mapped_column(String(255)) + + rrp: Mapped[Optional[float]] = mapped_column(Numeric(12, 2)) + rrp_currency: Mapped[Optional[str]] = mapped_column(String(16)) + rrp_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + price_per_unit: Mapped[Optional[float]] = mapped_column(Numeric(12, 4)) + price_per_unit_currency: Mapped[Optional[str]] = mapped_column(String(16)) + price_per_unit_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + special_price: Mapped[Optional[float]] = mapped_column(Numeric(12, 2)) + special_price_currency: Mapped[Optional[str]] = mapped_column(String(16)) + special_price_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + regular_price: Mapped[Optional[float]] = mapped_column(Numeric(12, 2)) + regular_price_currency: Mapped[Optional[str]] = mapped_column(String(16)) + regular_price_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + oe_list_price: Mapped[Optional[float]] = mapped_column(Numeric(12, 2)) + + case_size_count: Mapped[Optional[int]] = mapped_column(Integer) + case_size_item_qty: Mapped[Optional[float]] = mapped_column(Numeric(12, 3)) + case_size_item_unit: Mapped[Optional[str]] = mapped_column(String(32)) + case_size_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + 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)) + + images: Mapped[List["ProductImage"]] = relationship( + back_populates="product", + cascade="all, delete-orphan", + passive_deletes=True, + ) + sections: Mapped[List["ProductSection"]] = relationship( + back_populates="product", + cascade="all, delete-orphan", + passive_deletes=True, + ) + labels: Mapped[List["ProductLabel"]] = relationship( + cascade="all, delete-orphan", + passive_deletes=True, + ) + stickers: Mapped[List["ProductSticker"]] = relationship( + cascade="all, delete-orphan", + passive_deletes=True, + ) + + ean: Mapped[Optional[str]] = mapped_column(String(64)) + sku: Mapped[Optional[str]] = mapped_column(String(128)) + unit_size: Mapped[Optional[str]] = mapped_column(String(128)) + pack_size: Mapped[Optional[str]] = mapped_column(String(128)) + + attributes = relationship( + "ProductAttribute", + back_populates="product", + lazy="selectin", + cascade="all, delete-orphan", + ) + nutrition = relationship( + "ProductNutrition", + back_populates="product", + lazy="selectin", + cascade="all, delete-orphan", + ) + allergens = relationship( + "ProductAllergen", + back_populates="product", + lazy="selectin", + cascade="all, delete-orphan", + ) + + likes = relationship( + "ProductLike", + back_populates="product", + cascade="all, delete-orphan", + ) + cart_items: Mapped[List["CartItem"]] = relationship( + "CartItem", + back_populates="product", + cascade="all, delete-orphan", + ) + + # NEW: all order items that reference this product + order_items: Mapped[List["OrderItem"]] = relationship( + "OrderItem", + back_populates="product", + cascade="all, delete-orphan", + ) + +from sqlalchemy import Column + +class ProductLike(Base): + __tablename__ = "product_likes" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + product_slug: Mapped[str] = mapped_column(ForeignKey("products.slug", ondelete="CASCADE")) + + 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)) + product: Mapped["Product"] = relationship("Product", back_populates="likes", foreign_keys=[product_slug]) + + user = relationship("User", back_populates="liked_products") # optional, if you want reverse access + + +class ProductImage(Base): + __tablename__ = "product_images" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + url: Mapped[str] = mapped_column(Text, nullable=False) + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + kind: Mapped[str] = mapped_column(String(16), nullable=False, default="gallery") + 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)) + + product: Mapped["Product"] = relationship(back_populates="images") + + __table_args__ = ( + UniqueConstraint("product_id", "url", "kind", name="uq_product_images_product_url_kind"), + Index("ix_product_images_position", "position"), + ) + +class ProductSection(Base): + __tablename__ = "product_sections" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + html: Mapped[str] = mapped_column(Text, nullable=False) + 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)) + + product: Mapped["Product"] = relationship(back_populates="sections") + __table_args__ = ( + UniqueConstraint("product_id", "title", name="uq_product_sections_product_title"), + ) +# --- Nav & listings --- + +class NavTop(Base): + __tablename__ = "nav_tops" + 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"),) + +class NavSub(Base): + __tablename__ = "nav_subs" + id: Mapped[int] = mapped_column(primary_key=True) + top_id: Mapped[int] = mapped_column(ForeignKey("nav_tops.id", ondelete="CASCADE"), index=True, nullable=False) + label: Mapped[Optional[str]] = mapped_column(String(255)) + slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + href: Mapped[Optional[str]] = mapped_column(Text) + 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="sub", cascade="all, delete-orphan") + + __table_args__ = (UniqueConstraint("top_id", "slug", name="uq_nav_subs_top_slug"),) + +class Listing(Base): + __tablename__ = "listings" + + id: Mapped[int] = mapped_column(primary_key=True) + + # Old slug-based fields (optional: remove) + # top_slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + # sub_slug: Mapped[Optional[str]] = mapped_column(String(255), index=True) + + top_id: Mapped[int] = mapped_column(ForeignKey("nav_tops.id", ondelete="CASCADE"), index=True, nullable=False) + sub_id: Mapped[Optional[int]] = mapped_column(ForeignKey("nav_subs.id", ondelete="CASCADE"), index=True) + + total_pages: Mapped[Optional[int]] = mapped_column(Integer) + 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)) + + + top: Mapped["NavTop"] = relationship(back_populates="listings") + sub: Mapped[Optional["NavSub"]] = relationship(back_populates="listings") + + __table_args__ = ( + UniqueConstraint("top_id", "sub_id", name="uq_listings_top_sub"), + ) + +class ListingItem(Base): + __tablename__ = "listing_items" + id: Mapped[int] = mapped_column(primary_key=True) + listing_id: Mapped[int] = mapped_column(ForeignKey("listings.id", ondelete="CASCADE"), index=True, nullable=False) + 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)) + + slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + __table_args__ = (UniqueConstraint("listing_id", "slug", name="uq_listing_items_listing_slug"),) + +# --- Reports / redirects / logs --- + +class LinkError(Base): + __tablename__ = "link_errors" + id: Mapped[int] = mapped_column(primary_key=True) + product_slug: Mapped[Optional[str]] = mapped_column(String(255), index=True) + href: Mapped[Optional[str]] = mapped_column(Text) + text: Mapped[Optional[str]] = mapped_column(Text) + top: Mapped[Optional[str]] = mapped_column(String(255)) + sub: Mapped[Optional[str]] = mapped_column(String(255)) + target_slug: Mapped[Optional[str]] = mapped_column(String(255)) + type: Mapped[Optional[str]] = mapped_column(String(255)) + 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)) + +class LinkExternal(Base): + __tablename__ = "link_externals" + id: Mapped[int] = mapped_column(primary_key=True) + product_slug: Mapped[Optional[str]] = mapped_column(String(255), index=True) + href: Mapped[Optional[str]] = mapped_column(Text) + text: Mapped[Optional[str]] = mapped_column(Text) + host: Mapped[Optional[str]] = mapped_column(String(255)) + 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)) + +class SubcategoryRedirect(Base): + __tablename__ = "subcategory_redirects" + id: Mapped[int] = mapped_column(primary_key=True) + old_path: Mapped[str] = mapped_column(String(512), nullable=False, index=True) + new_path: Mapped[str] = mapped_column(String(512), nullable=False) + 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)) + +class ProductLog(Base): + __tablename__ = "product_logs" + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[Optional[str]] = mapped_column(String(255), index=True) + href_tried: Mapped[Optional[str]] = mapped_column(Text) + ok: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + error_type: Mapped[Optional[str]] = mapped_column(String(255)) + error_message: Mapped[Optional[str]] = mapped_column(Text) + http_status: Mapped[Optional[int]] = mapped_column(Integer) + final_url: Mapped[Optional[str]] = mapped_column(Text) + transport_error: Mapped[Optional[bool]] = mapped_column(Boolean) + title: Mapped[Optional[str]] = mapped_column(String(512)) + has_description_html: Mapped[Optional[bool]] = mapped_column(Boolean) + has_description_short: Mapped[Optional[bool]] = mapped_column(Boolean) + sections_count: Mapped[Optional[int]] = mapped_column(Integer) + images_count: Mapped[Optional[int]] = mapped_column(Integer) + embedded_images_count: Mapped[Optional[int]] = mapped_column(Integer) + all_images_count: Mapped[Optional[int]] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + + +# ...existing models... + +class ProductLabel(Base): + __tablename__ = "product_labels" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + 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)) + product: Mapped["Product"] = relationship(back_populates="labels") + + __table_args__ = (UniqueConstraint("product_id", "name", name="uq_product_labels_product_name"),) + +class ProductSticker(Base): + __tablename__ = "product_stickers" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + 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)) + product: Mapped["Product"] = relationship(back_populates="stickers") + + __table_args__ = (UniqueConstraint("product_id", "name", name="uq_product_stickers_product_name"),) + +class ProductAttribute(Base): + __tablename__ = "product_attributes" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + key: Mapped[str] = mapped_column(String(255), nullable=False) + 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)) + + value: Mapped[Optional[str]] = mapped_column(Text) + product = relationship("Product", back_populates="attributes") + __table_args__ = (UniqueConstraint("product_id", "key", name="uq_product_attributes_product_key"),) + +class ProductNutrition(Base): + __tablename__ = "product_nutrition" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + key: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[Optional[str]] = mapped_column(String(255)) + unit: Mapped[Optional[str]] = mapped_column(String(64)) + product = relationship("Product", back_populates="nutrition") + 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)) + __table_args__ = (UniqueConstraint("product_id", "key", name="uq_product_nutrition_product_key"),) + +class ProductAllergen(Base): + __tablename__ = "product_allergens" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + contains: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + 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)) + product: Mapped["Product"] = relationship(back_populates="allergens") + __table_args__ = (UniqueConstraint("product_id", "name", name="uq_product_allergens_product_name"),) + + +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(), + ) + 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", + ) + 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"), + ) diff --git a/models/market_place.py b/models/market_place.py new file mode 100644 index 0000000..8792e36 --- /dev/null +++ b/models/market_place.py @@ -0,0 +1,52 @@ +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 shared.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) + container_type: Mapped[str] = mapped_column( + String(32), nullable=False, server_default=text("'page'"), + ) + container_id: Mapped[int] = mapped_column(Integer, 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, + ) + + nav_tops: Mapped[List["NavTop"]] = relationship( + "NavTop", back_populates="market", + ) + + __table_args__ = ( + Index("ix_market_places_container", "container_type", "container_id"), + Index( + "ux_market_places_slug_active", + func.lower(slug), + unique=True, + postgresql_where=text("deleted_at IS NULL"), + ), + ) diff --git a/path_setup.py b/path_setup.py index 1d4c9ab..c7166f7 100644 --- a/path_setup.py +++ b/path_setup.py @@ -1,7 +1,9 @@ import sys import os -# Add the shared library submodule to the Python path -_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib") -if _shared not in sys.path: - sys.path.insert(0, _shared) +_app_dir = os.path.dirname(os.path.abspath(__file__)) +_project_root = os.path.dirname(_app_dir) + +for _p in (_project_root, _app_dir): + if _p not in sys.path: + sys.path.insert(0, _p) diff --git a/scrape/build_snapshot/build_snapshot.py b/scrape/build_snapshot/build_snapshot.py index b8a8ee6..3b7f623 100644 --- a/scrape/build_snapshot/build_snapshot.py +++ b/scrape/build_snapshot/build_snapshot.py @@ -7,9 +7,9 @@ from typing import Dict, Set from ..http_client import configure_cookies from ..get_auth import login -from config import config +from shared.config import config -from utils import log +from shared.utils import log # DB: persistence helpers diff --git a/scrape/build_snapshot/tools/_rewrite_links_fragment.py b/scrape/build_snapshot/tools/_rewrite_links_fragment.py index 24caf64..2d3a816 100644 --- a/scrape/build_snapshot/tools/_rewrite_links_fragment.py +++ b/scrape/build_snapshot/tools/_rewrite_links_fragment.py @@ -3,7 +3,7 @@ from bs4 import BeautifulSoup from urllib.parse import urlparse, urljoin from ._anchor_text import _anchor_text -from suma_browser.app.bp.browse.services.slugs import product_slug_from_href +from bp.browse.services.slugs import product_slug_from_href from .APP_ROOT_PLACEHOLDER import APP_ROOT_PLACEHOLDER def _rewrite_links_fragment( diff --git a/scrape/build_snapshot/tools/capture_category.py b/scrape/build_snapshot/tools/capture_category.py index 33344d1..84e51e7 100644 --- a/scrape/build_snapshot/tools/capture_category.py +++ b/scrape/build_snapshot/tools/capture_category.py @@ -1,6 +1,6 @@ from urllib.parse import urljoin -from config import config -from utils import log +from shared.config import config +from shared.utils import log from ...listings import scrape_products async def capture_category( diff --git a/scrape/build_snapshot/tools/capture_product_slugs.py b/scrape/build_snapshot/tools/capture_product_slugs.py index 7149dea..1592e1e 100644 --- a/scrape/build_snapshot/tools/capture_product_slugs.py +++ b/scrape/build_snapshot/tools/capture_product_slugs.py @@ -1,7 +1,7 @@ from typing import Dict, Set from .capture_category import capture_category from .capture_sub import capture_sub -from config import config +from shared.config import config async def capture_product_slugs( diff --git a/scrape/build_snapshot/tools/capture_sub.py b/scrape/build_snapshot/tools/capture_sub.py index e512cf3..5c14ca7 100644 --- a/scrape/build_snapshot/tools/capture_sub.py +++ b/scrape/build_snapshot/tools/capture_sub.py @@ -1,7 +1,7 @@ from urllib.parse import urljoin from urllib.parse import urljoin -from config import config -from utils import log +from shared.config import config +from shared.utils import log from ...listings import scrape_products async def capture_sub( diff --git a/scrape/build_snapshot/tools/fetch_and_upsert_product.py b/scrape/build_snapshot/tools/fetch_and_upsert_product.py index 7ae492d..0fb625c 100644 --- a/scrape/build_snapshot/tools/fetch_and_upsert_product.py +++ b/scrape/build_snapshot/tools/fetch_and_upsert_product.py @@ -6,12 +6,12 @@ import httpx from ...html_utils import to_fragment -from suma_browser.app.bp.browse.services.slugs import suma_href_from_html_slug - +from bp.browse.services.slugs import suma_href_from_html_slug -from config import config -from utils import log +from shared.config import config + +from shared.utils import log # DB: persistence helpers from ...product.product_detail import scrape_product_detail diff --git a/scrape/build_snapshot/tools/fetch_and_upsert_products.py b/scrape/build_snapshot/tools/fetch_and_upsert_products.py index 599b13a..836dde0 100644 --- a/scrape/build_snapshot/tools/fetch_and_upsert_products.py +++ b/scrape/build_snapshot/tools/fetch_and_upsert_products.py @@ -1,7 +1,7 @@ import asyncio from typing import Dict, List, Set -from config import config -from utils import log +from shared.config import config +from shared.utils import log from .fetch_and_upsert_product import fetch_and_upsert_product diff --git a/scrape/build_snapshot/tools/rewrite_nav.py b/scrape/build_snapshot/tools/rewrite_nav.py index bf592b2..aaa03da 100644 --- a/scrape/build_snapshot/tools/rewrite_nav.py +++ b/scrape/build_snapshot/tools/rewrite_nav.py @@ -1,7 +1,7 @@ from typing import Dict from urllib.parse import urljoin -from config import config +from shared.config import config def rewrite_nav(nav: Dict[str, Dict], nav_redirects:Dict[str, str]): if nav_redirects: diff --git a/scrape/get_auth.py b/scrape/get_auth.py index b6242e1..a57b66c 100644 --- a/scrape/get_auth.py +++ b/scrape/get_auth.py @@ -2,7 +2,7 @@ from typing import Optional, Dict, Any, List from urllib.parse import urljoin import httpx from bs4 import BeautifulSoup -from config import config +from shared.config import config class LoginFailed(Exception): def __init__(self, message: str, *, debug: Dict[str, Any]): diff --git a/scrape/html_utils.py b/scrape/html_utils.py index d565184..9d9d3ef 100644 --- a/scrape/html_utils.py +++ b/scrape/html_utils.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Optional from bs4 import BeautifulSoup from urllib.parse import urljoin -from config import config +from shared.config import config diff --git a/scrape/http_client.py b/scrape/http_client.py index 6001157..3865605 100644 --- a/scrape/http_client.py +++ b/scrape/http_client.py @@ -7,7 +7,7 @@ import secrets from typing import Optional, Dict import httpx -from config import config +from shared.config import config _CLIENT: httpx.AsyncClient | None = None diff --git a/scrape/listings.py b/scrape/listings.py index 4f86c54..0a7e197 100644 --- a/scrape/listings.py +++ b/scrape/listings.py @@ -7,8 +7,8 @@ from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse from .http_client import fetch -from suma_browser.app.bp.browse.services.slugs import product_slug_from_href -from suma_browser.app.bp.browse.services.state import ( +from bp.browse.services.slugs import product_slug_from_href +from bp.browse.services.state import ( KNOWN_PRODUCT_SLUGS, _listing_page_cache, _listing_page_ttl, @@ -16,8 +16,8 @@ from suma_browser.app.bp.browse.services.state import ( _listing_variant_ttl, now, ) -from utils import normalize_text, soup_of -from config import config +from shared.utils import normalize_text, soup_of +from shared.config import config def parse_total_pages_from_text(text: str) -> Optional[int]: diff --git a/scrape/nav.py b/scrape/nav.py index 1278465..7e187d6 100644 --- a/scrape/nav.py +++ b/scrape/nav.py @@ -5,7 +5,7 @@ from typing import Dict, List, Tuple, Optional from urllib.parse import urlparse, urljoin from bs4 import BeautifulSoup -from config import config +from shared.config import config from .http_client import fetch # only fetch; define soup_of locally #from .. import cache_backend as cb #from ..blacklist.category import is_category_blocked # Reverse map: slug -> label diff --git a/scrape/persist_snapshot/capture_listing.py b/scrape/persist_snapshot/capture_listing.py index d2b4fe4..c6948dc 100644 --- a/scrape/persist_snapshot/capture_listing.py +++ b/scrape/persist_snapshot/capture_listing.py @@ -17,7 +17,7 @@ from models.market import ( Listing, ListingItem, ) -from db.session import get_session +from shared.db.session import get_session # --- Models are unchanged, see original code --- diff --git a/scrape/persist_snapshot/log_product_result.py b/scrape/persist_snapshot/log_product_result.py index 4641e3b..88eb27b 100644 --- a/scrape/persist_snapshot/log_product_result.py +++ b/scrape/persist_snapshot/log_product_result.py @@ -5,7 +5,7 @@ from typing import Dict from models.market import ( ProductLog, ) -from db.session import get_session +from shared.db.session import get_session async def log_product_result(ok: bool, payload: Dict) -> None: diff --git a/scrape/persist_snapshot/save_link_reports.py b/scrape/persist_snapshot/save_link_reports.py index fba1e78..932b61a 100644 --- a/scrape/persist_snapshot/save_link_reports.py +++ b/scrape/persist_snapshot/save_link_reports.py @@ -7,7 +7,7 @@ from models.market import ( LinkError, LinkExternal, ) -from db.session import get_session +from shared.db.session import get_session # --- Models are unchanged, see original code --- diff --git a/scrape/persist_snapshot/save_nav.py b/scrape/persist_snapshot/save_nav.py index 3c0fb2b..5f73626 100644 --- a/scrape/persist_snapshot/save_nav.py +++ b/scrape/persist_snapshot/save_nav.py @@ -9,7 +9,7 @@ from models.market import ( NavTop, NavSub, ) -from db.session import get_session +from shared.db.session import get_session diff --git a/scrape/persist_snapshot/save_subcategory_redirects.py b/scrape/persist_snapshot/save_subcategory_redirects.py index a7b82f7..6ffdd7b 100644 --- a/scrape/persist_snapshot/save_subcategory_redirects.py +++ b/scrape/persist_snapshot/save_subcategory_redirects.py @@ -8,7 +8,7 @@ from sqlalchemy import ( from models.market import ( SubcategoryRedirect, ) -from db.session import get_session +from shared.db.session import get_session # --- Models are unchanged, see original code --- diff --git a/scrape/persist_snapshot/upsert_product.py b/scrape/persist_snapshot/upsert_product.py index a8d9188..4ab1613 100644 --- a/scrape/persist_snapshot/upsert_product.py +++ b/scrape/persist_snapshot/upsert_product.py @@ -17,7 +17,7 @@ from models.market import ( ProductNutrition, ProductAllergen ) -from db.session import get_session +from shared.db.session import get_session from ._get import _get from .log_product_result import _log_product_result diff --git a/scrape/product/extractors/breadcrumbs.py b/scrape/product/extractors/breadcrumbs.py index cb20c23..6aadefa 100644 --- a/scrape/product/extractors/breadcrumbs.py +++ b/scrape/product/extractors/breadcrumbs.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Dict, List, Union from urllib.parse import urlparse from bs4 import BeautifulSoup -from utils import normalize_text +from shared.utils import normalize_text from ..registry import extractor @extractor diff --git a/scrape/product/extractors/description_sections.py b/scrape/product/extractors/description_sections.py index 80301fc..719ed06 100644 --- a/scrape/product/extractors/description_sections.py +++ b/scrape/product/extractors/description_sections.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Dict, List from bs4 import BeautifulSoup -from utils import normalize_text +from shared.utils import normalize_text from ...html_utils import absolutize_fragment from ..registry import extractor from ..helpers.desc import ( diff --git a/scrape/product/extractors/info_table.py b/scrape/product/extractors/info_table.py index cb9a37e..e1a8ef0 100644 --- a/scrape/product/extractors/info_table.py +++ b/scrape/product/extractors/info_table.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Dict, Union from bs4 import BeautifulSoup -from utils import normalize_text +from shared.utils import normalize_text from ..registry import extractor from ..helpers.price import parse_price, parse_case_size diff --git a/scrape/product/extractors/labels.py b/scrape/product/extractors/labels.py index 7f3e5e5..b4e4bd1 100644 --- a/scrape/product/extractors/labels.py +++ b/scrape/product/extractors/labels.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Dict, List from bs4 import BeautifulSoup -from utils import normalize_text +from shared.utils import normalize_text from ..registry import extractor @extractor diff --git a/scrape/product/extractors/nutrition_ex.py b/scrape/product/extractors/nutrition_ex.py index 5339906..d39253d 100644 --- a/scrape/product/extractors/nutrition_ex.py +++ b/scrape/product/extractors/nutrition_ex.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Dict, List, Optional, Tuple import re from bs4 import BeautifulSoup -from utils import normalize_text +from shared.utils import normalize_text from ..registry import extractor from ..helpers.desc import ( split_description_container, find_description_container, diff --git a/scrape/product/extractors/short_description.py b/scrape/product/extractors/short_description.py index c3d577f..fefa827 100644 --- a/scrape/product/extractors/short_description.py +++ b/scrape/product/extractors/short_description.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Dict from bs4 import BeautifulSoup -from utils import normalize_text +from shared.utils import normalize_text from ..registry import extractor @extractor diff --git a/scrape/product/extractors/title.py b/scrape/product/extractors/title.py index 2df3ad9..f7677ab 100644 --- a/scrape/product/extractors/title.py +++ b/scrape/product/extractors/title.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Dict from bs4 import BeautifulSoup -from utils import normalize_text +from shared.utils import normalize_text from ..registry import extractor @extractor diff --git a/scrape/product/helpers/desc.py b/scrape/product/helpers/desc.py index 17bface..c093362 100644 --- a/scrape/product/helpers/desc.py +++ b/scrape/product/helpers/desc.py @@ -2,10 +2,10 @@ from __future__ import annotations from typing import Dict, List, Optional, Tuple from bs4 import BeautifulSoup, NavigableString, Tag -from utils import normalize_text +from shared.utils import normalize_text from ...html_utils import absolutize_fragment from .text import clean_title, is_blacklisted_heading -from config import config +from shared.config import config def split_description_container(desc_el: Tag) -> Tuple[str, List[Dict]]: diff --git a/scrape/product/helpers/html.py b/scrape/product/helpers/html.py index d334983..6f355c5 100644 --- a/scrape/product/helpers/html.py +++ b/scrape/product/helpers/html.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import List, Optional from urllib.parse import urljoin, urlparse -from config import config +from shared.config import config def first_from_srcset(val: str) -> Optional[str]: if not val: diff --git a/scrape/product/helpers/text.py b/scrape/product/helpers/text.py index 1339331..c8d6190 100644 --- a/scrape/product/helpers/text.py +++ b/scrape/product/helpers/text.py @@ -1,8 +1,8 @@ from __future__ import annotations import re -from utils import normalize_text -from config import config +from shared.utils import normalize_text +from shared.config import config def clean_title(t: str) -> str: t = normalize_text(t) diff --git a/scrape/product/product_core.py b/scrape/product/product_core.py index 39c03e5..9fbf5f2 100644 --- a/scrape/product/product_core.py +++ b/scrape/product/product_core.py @@ -1,10 +1,10 @@ from __future__ import annotations from typing import Dict, Tuple, Union -from utils import soup_of +from shared.utils import soup_of from ..http_client import fetch from ..html_utils import absolutize_fragment -from suma_browser.app.bp.browse.services.slugs import product_slug_from_href +from bp.browse.services.slugs import product_slug_from_href from .registry import REGISTRY, merge_missing from . import extractors as _auto_register # noqa: F401 (import-time side effects)