feat: merge cartsnitch/api into api/ subdirectory
Consolidate API gateway service into monorepo. Squashed from https://github.com/cartsnitch/api main (89bacb1). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""SQLAlchemy ORM models — re-exports all models for convenience."""
|
||||
|
||||
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from cartsnitch_api.models.coupon import Coupon
|
||||
from cartsnitch_api.models.price import PriceHistory
|
||||
from cartsnitch_api.models.product import NormalizedProduct
|
||||
from cartsnitch_api.models.purchase import Purchase, PurchaseItem
|
||||
from cartsnitch_api.models.shrinkflation import ShrinkflationEvent
|
||||
from cartsnitch_api.models.store import Store, StoreLocation
|
||||
from cartsnitch_api.models.user import User, UserStoreAccount
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"TimestampMixin",
|
||||
"UUIDPrimaryKeyMixin",
|
||||
"Store",
|
||||
"StoreLocation",
|
||||
"User",
|
||||
"UserStoreAccount",
|
||||
"Purchase",
|
||||
"PurchaseItem",
|
||||
"NormalizedProduct",
|
||||
"PriceHistory",
|
||||
"Coupon",
|
||||
"ShrinkflationEvent",
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Base model and mixins for all CartSnitch ORM models."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all CartSnitch models."""
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin providing created_at / updated_at columns."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
"""Mixin providing a UUID primary key."""
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
primary_key=True, default=uuid.uuid4, server_default=func.gen_random_uuid()
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Coupon model."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.constants import DiscountType
|
||||
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cartsnitch_api.models.product import NormalizedProduct
|
||||
from cartsnitch_api.models.store import Store
|
||||
|
||||
|
||||
class Coupon(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""A coupon or deal for a product at a store."""
|
||||
|
||||
__tablename__ = "coupons"
|
||||
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
normalized_product_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("normalized_products.id")
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(1000))
|
||||
discount_type: Mapped[DiscountType] = mapped_column(String(20), nullable=False)
|
||||
discount_value: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
min_purchase: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
valid_from: Mapped[date | None] = mapped_column(Date)
|
||||
valid_to: Mapped[date | None] = mapped_column(Date)
|
||||
requires_clip: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
coupon_code: Mapped[str | None] = mapped_column(String(100))
|
||||
source_url: Mapped[str | None] = mapped_column(String(500))
|
||||
scraped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Relationships
|
||||
store: Mapped["Store"] = relationship(back_populates="coupons")
|
||||
normalized_product: Mapped["NormalizedProduct | None"] = relationship(back_populates="coupons")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""PriceHistory model — tracks product prices over time."""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.constants import PriceSource
|
||||
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cartsnitch_api.models.product import NormalizedProduct
|
||||
from cartsnitch_api.models.purchase import PurchaseItem
|
||||
from cartsnitch_api.models.store import Store
|
||||
|
||||
|
||||
class PriceHistory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""A single price observation for a product at a store on a date."""
|
||||
|
||||
__tablename__ = "price_history"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_price_history_product_store_date",
|
||||
"normalized_product_id",
|
||||
"store_id",
|
||||
"observed_date",
|
||||
),
|
||||
)
|
||||
|
||||
normalized_product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("normalized_products.id"), nullable=False
|
||||
)
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
observed_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
regular_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
sale_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
loyalty_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
coupon_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
source: Mapped[PriceSource] = mapped_column(String(20), nullable=False)
|
||||
purchase_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("purchase_items.id"))
|
||||
|
||||
# Relationships
|
||||
normalized_product: Mapped["NormalizedProduct"] = relationship(back_populates="price_histories")
|
||||
store: Mapped["Store"] = relationship(back_populates="price_histories")
|
||||
purchase_item: Mapped["PurchaseItem | None"] = relationship(
|
||||
back_populates="price_history_entries"
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""NormalizedProduct model — the canonical product identity."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.constants import ProductCategory, SizeUnit
|
||||
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cartsnitch_api.models.coupon import Coupon
|
||||
from cartsnitch_api.models.price import PriceHistory
|
||||
from cartsnitch_api.models.purchase import PurchaseItem
|
||||
from cartsnitch_api.models.shrinkflation import ShrinkflationEvent
|
||||
|
||||
|
||||
class NormalizedProduct(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""Canonical product identity — matches products across retailers."""
|
||||
|
||||
__tablename__ = "normalized_products"
|
||||
|
||||
canonical_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
category: Mapped[ProductCategory | None] = mapped_column(String(50))
|
||||
subcategory: Mapped[str | None] = mapped_column(String(100))
|
||||
brand: Mapped[str | None] = mapped_column(String(200))
|
||||
size: Mapped[str | None] = mapped_column(String(50))
|
||||
size_unit: Mapped[SizeUnit | None] = mapped_column(String(10))
|
||||
upc_variants: Mapped[list[str] | None] = mapped_column(JSON, default=list)
|
||||
|
||||
# Relationships
|
||||
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
|
||||
price_histories: Mapped[list["PriceHistory"]] = relationship(
|
||||
back_populates="normalized_product"
|
||||
)
|
||||
coupons: Mapped[list["Coupon"]] = relationship(back_populates="normalized_product")
|
||||
shrinkflation_events: Mapped[list["ShrinkflationEvent"]] = relationship(
|
||||
back_populates="normalized_product"
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Purchase and PurchaseItem models."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cartsnitch_api.models.price import PriceHistory
|
||||
from cartsnitch_api.models.product import NormalizedProduct
|
||||
from cartsnitch_api.models.store import Store, StoreLocation
|
||||
from cartsnitch_api.models.user import User
|
||||
|
||||
|
||||
class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""A single shopping trip / receipt."""
|
||||
|
||||
__tablename__ = "purchases"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
|
||||
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
purchase_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
total: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
subtotal: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
tax: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
savings_total: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
source_url: Mapped[str | None] = mapped_column(String(500))
|
||||
raw_data: Mapped[dict | None] = mapped_column(JSON)
|
||||
ingested_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(back_populates="purchases")
|
||||
store: Mapped["Store"] = relationship(back_populates="purchases")
|
||||
store_location: Mapped["StoreLocation | None"] = relationship(back_populates="purchases")
|
||||
items: Mapped[list["PurchaseItem"]] = relationship(back_populates="purchase")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_purchases_user_store", "user_id", "store_id"),
|
||||
UniqueConstraint("user_id", "store_id", "receipt_id", name="uq_purchase_receipt"),
|
||||
)
|
||||
|
||||
|
||||
class PurchaseItem(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""Individual line item on a receipt."""
|
||||
|
||||
__tablename__ = "purchase_items"
|
||||
|
||||
purchase_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("purchases.id"), nullable=False)
|
||||
product_name_raw: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
upc: Mapped[str | None] = mapped_column(String(20))
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=False, default=1)
|
||||
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
extended_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
regular_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
sale_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
coupon_discount: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
loyalty_discount: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
category_raw: Mapped[str | None] = mapped_column(String(100))
|
||||
normalized_product_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("normalized_products.id")
|
||||
)
|
||||
|
||||
# Relationships
|
||||
purchase: Mapped["Purchase"] = relationship(back_populates="items")
|
||||
normalized_product: Mapped["NormalizedProduct | None"] = relationship(
|
||||
back_populates="purchase_items"
|
||||
)
|
||||
price_history_entries: Mapped[list["PriceHistory"]] = relationship(
|
||||
back_populates="purchase_item"
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""ShrinkflationEvent model."""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.constants import SizeUnit
|
||||
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cartsnitch_api.models.product import NormalizedProduct
|
||||
|
||||
|
||||
class ShrinkflationEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""Detected shrinkflation event — product size changed while price held or rose."""
|
||||
|
||||
__tablename__ = "shrinkflation_events"
|
||||
|
||||
normalized_product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("normalized_products.id"), nullable=False
|
||||
)
|
||||
detected_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
old_size: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
new_size: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
old_unit: Mapped[SizeUnit] = mapped_column(String(10), nullable=False)
|
||||
new_unit: Mapped[SizeUnit] = mapped_column(String(10), nullable=False)
|
||||
price_at_old_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
price_at_new_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
confidence: Mapped[Decimal] = mapped_column(
|
||||
Numeric(3, 2), nullable=False, default=Decimal("1.00")
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(String(1000))
|
||||
|
||||
# Relationships
|
||||
normalized_product: Mapped["NormalizedProduct"] = relationship(
|
||||
back_populates="shrinkflation_events"
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Store and StoreLocation models."""
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Float, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.constants import StoreSlug
|
||||
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cartsnitch_api.models.coupon import Coupon
|
||||
from cartsnitch_api.models.price import PriceHistory
|
||||
from cartsnitch_api.models.purchase import Purchase
|
||||
from cartsnitch_api.models.user import UserStoreAccount
|
||||
|
||||
|
||||
class Store(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""Supported retailer."""
|
||||
|
||||
__tablename__ = "stores"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
slug: Mapped[StoreSlug] = mapped_column(String(20), nullable=False, unique=True)
|
||||
logo_url: Mapped[str | None] = mapped_column(String(500))
|
||||
website_url: Mapped[str | None] = mapped_column(String(500))
|
||||
|
||||
# Relationships
|
||||
locations: Mapped[list["StoreLocation"]] = relationship(back_populates="store")
|
||||
purchases: Mapped[list["Purchase"]] = relationship(back_populates="store")
|
||||
user_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="store")
|
||||
price_histories: Mapped[list["PriceHistory"]] = relationship(back_populates="store")
|
||||
coupons: Mapped[list["Coupon"]] = relationship(back_populates="store")
|
||||
|
||||
|
||||
class StoreLocation(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""Physical store location."""
|
||||
|
||||
__tablename__ = "store_locations"
|
||||
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
address: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
city: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
state: Mapped[str] = mapped_column(String(2), nullable=False)
|
||||
zip: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
lat: Mapped[float | None] = mapped_column(Float)
|
||||
lng: Mapped[float | None] = mapped_column(Float)
|
||||
|
||||
# Relationships
|
||||
store: Mapped["Store"] = relationship(back_populates="locations")
|
||||
purchases: Mapped[list["Purchase"]] = relationship(back_populates="store_location")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""User and UserStoreAccount models."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.constants import AccountStatus
|
||||
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from cartsnitch_api.types import EncryptedJSON
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cartsnitch_api.models.purchase import Purchase
|
||||
from cartsnitch_api.models.store import Store
|
||||
|
||||
|
||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""Application user."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||
|
||||
# Relationships
|
||||
store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user")
|
||||
purchases: Mapped[list["Purchase"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""Link between a user and their retailer account credentials."""
|
||||
|
||||
__tablename__ = "user_store_accounts"
|
||||
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
|
||||
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status: Mapped[AccountStatus] = mapped_column(
|
||||
String(20), nullable=False, default=AccountStatus.ACTIVE
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(back_populates="store_accounts")
|
||||
store: Mapped["Store"] = relationship(back_populates="user_accounts")
|
||||
Reference in New Issue
Block a user