Files
Jonathan 486749a890 Initial project scaffold
Full-stack Dutch supermarket price tracker with FastAPI backend,
PostgreSQL/SQLAlchemy, Albert Heijn scraper, and Next.js frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:27:24 +02:00

84 lines
3.9 KiB
Python

from datetime import datetime
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class Store(Base):
__tablename__ = "stores"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
name: Mapped[str] = mapped_column(sa.String(100), nullable=False)
slug: Mapped[str] = mapped_column(sa.String(50), unique=True, nullable=False)
country: Mapped[str] = mapped_column(sa.String(2), default="NL")
website: Mapped[str | None] = mapped_column(sa.String(255))
products: Mapped[list["Product"]] = relationship(back_populates="store")
scrape_runs: Mapped[list["ScrapeRun"]] = relationship(back_populates="store")
class Product(Base):
__tablename__ = "products"
__table_args__ = (
sa.UniqueConstraint("store_id", "external_id", name="uq_products_store_external"),
)
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
store_id: Mapped[int] = mapped_column(sa.Integer, sa.ForeignKey("stores.id"), nullable=False)
external_id: Mapped[str] = mapped_column(sa.String(50), nullable=False)
ean: Mapped[str | None] = mapped_column(sa.String(20), index=True)
name: Mapped[str] = mapped_column(sa.String(255), nullable=False)
brand: Mapped[str | None] = mapped_column(sa.String(100))
category: Mapped[str | None] = mapped_column(sa.String(100))
unit_size: Mapped[str | None] = mapped_column(sa.String(50))
url: Mapped[str | None] = mapped_column(sa.String(500))
created_at: Mapped[datetime] = mapped_column(sa.DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
store: Mapped["Store"] = relationship(back_populates="products")
price_snapshots: Mapped[list["PriceSnapshot"]] = relationship(back_populates="product")
class ScrapeRun(Base):
__tablename__ = "scrape_runs"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
store_id: Mapped[int] = mapped_column(sa.Integer, sa.ForeignKey("stores.id"), nullable=False)
query: Mapped[str] = mapped_column(sa.String(255), nullable=False)
started_at: Mapped[datetime] = mapped_column(sa.DateTime, default=datetime.utcnow)
finished_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
status: Mapped[str] = mapped_column(sa.String(20), default="running")
products_found: Mapped[int] = mapped_column(sa.Integer, default=0)
error_message: Mapped[str | None] = mapped_column(sa.String(1000))
store: Mapped["Store"] = relationship(back_populates="scrape_runs")
price_snapshots: Mapped[list["PriceSnapshot"]] = relationship(back_populates="scrape_run")
class PriceSnapshot(Base):
__tablename__ = "price_snapshots"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
product_id: Mapped[int] = mapped_column(
sa.Integer, sa.ForeignKey("products.id"), nullable=False
)
scrape_run_id: Mapped[int] = mapped_column(
sa.Integer, sa.ForeignKey("scrape_runs.id"), nullable=False
)
price: Mapped[int] = mapped_column(sa.Integer, nullable=False) # euro cents
unit_price: Mapped[int | None] = mapped_column(sa.Integer) # euro cents
unit_description: Mapped[str | None] = mapped_column(sa.String(50))
currency: Mapped[str] = mapped_column(sa.String(3), default="EUR")
discount_label: Mapped[str | None] = mapped_column(sa.String(100))
discount_description: Mapped[str | None] = mapped_column(sa.String(255))
was_price: Mapped[int | None] = mapped_column(sa.Integer) # euro cents, original price
is_on_sale: Mapped[bool] = mapped_column(sa.Boolean, default=False)
timestamp: Mapped[datetime] = mapped_column(sa.DateTime, default=datetime.utcnow, index=True)
product: Mapped["Product"] = relationship(back_populates="price_snapshots")
scrape_run: Mapped["ScrapeRun"] = relationship(back_populates="price_snapshots")