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>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import PriceSnapshot, Product
|
||||
from ..schemas import CheapestProduct, Product as ProductSchema
|
||||
|
||||
router = APIRouter(prefix="/api/prices", tags=["prices"])
|
||||
|
||||
|
||||
@router.get("/cheapest", response_model=list[CheapestProduct])
|
||||
def get_cheapest(
|
||||
date_filter: date = Query(default=None, alias="date"),
|
||||
limit: int = Query(default=20, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
target = date_filter or date.today()
|
||||
day_start = datetime(target.year, target.month, target.day, 0, 0, 0)
|
||||
day_end = datetime(target.year, target.month, target.day, 23, 59, 59)
|
||||
|
||||
min_per_product = (
|
||||
select(
|
||||
PriceSnapshot.product_id,
|
||||
func.min(PriceSnapshot.price).label("min_price"),
|
||||
)
|
||||
.where(PriceSnapshot.timestamp.between(day_start, day_end))
|
||||
.group_by(PriceSnapshot.product_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = db.execute(
|
||||
select(PriceSnapshot, Product)
|
||||
.join(
|
||||
min_per_product,
|
||||
(PriceSnapshot.product_id == min_per_product.c.product_id)
|
||||
& (PriceSnapshot.price == min_per_product.c.min_price),
|
||||
)
|
||||
.join(Product, PriceSnapshot.product_id == Product.id)
|
||||
.options(selectinload(Product.store))
|
||||
.order_by(PriceSnapshot.price.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
return [
|
||||
CheapestProduct(
|
||||
product=ProductSchema.model_validate(product),
|
||||
price=snapshot.price,
|
||||
unit_price=snapshot.unit_price,
|
||||
unit_description=snapshot.unit_description,
|
||||
is_on_sale=snapshot.is_on_sale,
|
||||
timestamp=snapshot.timestamp,
|
||||
)
|
||||
for snapshot, product in rows
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import PriceSnapshot, Product
|
||||
from ..schemas import PriceSnapshot as PriceSnapshotSchema, ProductWithLatestPrice
|
||||
|
||||
router = APIRouter(prefix="/api/products", tags=["products"])
|
||||
|
||||
|
||||
def _attach_latest_price(product: Product, db: Session) -> ProductWithLatestPrice:
|
||||
p = ProductWithLatestPrice.model_validate(product)
|
||||
latest = db.scalar(
|
||||
select(PriceSnapshot)
|
||||
.where(PriceSnapshot.product_id == product.id)
|
||||
.order_by(PriceSnapshot.timestamp.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if latest:
|
||||
p.latest_price = latest.price
|
||||
p.latest_price_timestamp = latest.timestamp
|
||||
p.is_on_sale = latest.is_on_sale
|
||||
return p
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProductWithLatestPrice])
|
||||
def search_products(
|
||||
search: str = Query(default=""),
|
||||
limit: int = Query(default=20, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = select(Product).options(selectinload(Product.store))
|
||||
if search:
|
||||
q = q.where(Product.name.ilike(f"%{search}%"))
|
||||
q = q.order_by(Product.name).limit(limit)
|
||||
products = db.scalars(q).all()
|
||||
return [_attach_latest_price(p, db) for p in products]
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=ProductWithLatestPrice)
|
||||
def get_product(product_id: int, db: Session = Depends(get_db)):
|
||||
product = db.scalar(
|
||||
select(Product)
|
||||
.where(Product.id == product_id)
|
||||
.options(selectinload(Product.store))
|
||||
)
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
return _attach_latest_price(product, db)
|
||||
|
||||
|
||||
@router.get("/{product_id}/prices", response_model=list[PriceSnapshotSchema])
|
||||
def get_product_prices(
|
||||
product_id: int,
|
||||
limit: int = Query(default=200, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return db.scalars(
|
||||
select(PriceSnapshot)
|
||||
.where(PriceSnapshot.product_id == product_id)
|
||||
.order_by(PriceSnapshot.timestamp.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import ScrapeRun
|
||||
from ..schemas import ScrapeRun as ScrapeRunSchema
|
||||
|
||||
router = APIRouter(prefix="/api/scrape-runs", tags=["scrape-runs"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScrapeRunSchema])
|
||||
def list_scrape_runs(
|
||||
limit: int = Query(default=20, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return db.scalars(
|
||||
select(ScrapeRun).order_by(ScrapeRun.started_at.desc()).limit(limit)
|
||||
).all()
|
||||
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Store
|
||||
from ..schemas import Store as StoreSchema
|
||||
|
||||
router = APIRouter(prefix="/api/stores", tags=["stores"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[StoreSchema])
|
||||
def list_stores(db: Session = Depends(get_db)):
|
||||
return db.scalars(select(Store).order_by(Store.name)).all()
|
||||
Reference in New Issue
Block a user