Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""add featured and sales count to products
Revision ID: f976da1f5b43
Revises: 94404b2e4890
Create Date: 2025-10-03 15:11:53.870709
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'f976da1f5b43'
down_revision: Union[str, Sequence[str], None] = '94404b2e4890'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('products', sa.Column('is_featured', sa.Boolean(), nullable=False, server_default=sa.text("0")))
op.add_column('products', sa.Column('sales_count', sa.Integer(), nullable=False, server_default="0"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent server_default format: Line 24 uses sa.text("0") for is_featured, but this line uses a plain string "0" for sales_count. Use sa.text("0") for consistency and to ensure proper SQL generation across different databases.

Suggested change
op.add_column('products', sa.Column('sales_count', sa.Integer(), nullable=False, server_default="0"))
op.add_column('products', sa.Column('sales_count', sa.Integer(), nullable=False, server_default=sa.text("0")))

op.create_index(op.f('ix_products_is_featured'), 'products', ['is_featured'], unique=False)
op.create_index(op.f('ix_products_sales_count'), 'products', ['sales_count'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_products_sales_count'), table_name='products')
op.drop_index(op.f('ix_products_is_featured'), table_name='products')
op.drop_column('products', 'sales_count')
op.drop_column('products', 'is_featured')
# ### end Alembic commands ###
77 changes: 77 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,83 @@ def delete_product(

return {"message": "Product deleted successfully"}

@app.get("/api/products/featured", response_model=List[ProductRead])
def get_featured_products(
limit: int = Query(5, ge=1, le=10),
session: Session = Depends(get_session)
):
"""Get featured products with fallback to top-selling and newest"""
chosen: List[Product] = []
chosen_ids: set[int] = set()

# 1) Explicit featured products
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excessive type casting throughout this function (cast(ColumnElement, ...), cast(Any, ...)) suggests the code is fighting SQLModel's type system. This makes the code harder to read and may hide actual type issues. Consider refactoring to work with SQLModel's types more naturally, or investigate why these casts are needed.

stmt_featured = (
select(Product)
.where(Product.is_featured)
.order_by(
cast(ColumnElement, Product.updated_at).desc(),
cast(ColumnElement, Product.created_at).desc()
)
.limit(limit)
.options(selectinload(cast(Any, Product.category)))
)
featured = session.exec(stmt_featured).all()
chosen_ids = {p.id for p in featured if p.id}
chosen.extend(featured)

# 2) Top-selling fallback
if len(chosen) < limit:
remaining = limit - len(chosen)
stmt_top = select(Product).order_by(
cast(ColumnElement[int], Product.sales_count).desc(),
cast(ColumnElement, Product.created_at).desc()
).limit(remaining).options(selectinload(cast(Any, Product.category)))

if chosen_ids:
stmt_top = stmt_top.where(cast(ColumnElement[int], Product.id).notin_(chosen_ids))

top_selling = session.exec(stmt_top).all()
chosen_ids.update(p.id for p in top_selling if p.id)
chosen.extend(top_selling)

# 3) Newest fallback
if len(chosen) < limit:
remaining = limit - len(chosen)
stmt_newest = select(Product).order_by(
cast(ColumnElement, Product.created_at).desc()
).limit(remaining).options(selectinload(cast(Any, Product.category)))

if chosen_ids:
stmt_newest = stmt_newest.where(cast(ColumnElement[int], Product.id).notin_(chosen_ids))

newest = session.exec(stmt_newest).all()
chosen.extend(newest)

# Format results with image_url
result = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manual dictionary construction bypasses the ProductRead response model, which can lead to inconsistencies and loses Pydantic validation benefits. The field structure here must be manually kept in sync with the ProductRead model. Consider using ProductRead.model_validate() or converting products to the proper response model instead of building dicts manually.

for product in chosen:
product_dict = {
"id": product.id,
"title": product.title,
"description": product.description,
"price": product.price,
"category_id": product.category_id,
"is_saved": product.is_saved,
"created_at": product.created_at,
"updated_at": product.updated_at,
"image_url": f"/products/{product.id}/image" if product.image_data else None,
"category": {
"id": product.category.id,
"name": product.category.name,
"created_at": product.category.created_at,
"updated_at": product.category.updated_at,
} if product.category else None,
"delivery_summary": None
}
result.append(product_dict)

return result

@app.get("/products/{product_id}/image")
def get_product_image(
product_id: int,
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class Product(SQLModel, table=True):
image_mime_type: Optional[str] = Field(default=None) # e.g., "image/jpeg"
image_filename: Optional[str] = Field(default=None) # Original filename
is_saved: bool = Field(default=False)
is_featured: bool = Field(default=False, index=True)
sales_count: int = Field(default=0, ge=0, index=True)

created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
Expand Down
13 changes: 11 additions & 2 deletions backend/app/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,30 @@ def seed_products(session: Session, products: list, category_map: dict):
"""Create products with image download and storage"""
print(f"\nSeeding {len(products)} products...")

for product_data in products:
# Sort products by ID for deterministic featured and sales_count assignment
sorted_products = sorted(products, key=lambda p: p['id'])

for idx, product_data in enumerate(sorted_products):
# Check if product already exists
existing_product = session.get(Product, product_data['id'])
if existing_product:
print(f"Product '{product_data['title']}' already exists (ID: {product_data['id']})")
continue

# Deterministic featured and sales assignment
is_featured = idx < 3
sales_count = max(0, 100 - (idx * 5))

# Create new product
new_product = Product(
id=product_data['id'],
title=product_data['title'],
description=product_data['description'],
price=float(product_data['price']),
category_id=category_map[product_data['category']],
is_saved=False
is_saved=False,
is_featured=is_featured,
sales_count=sales_count
)

session.add(new_product)
Expand Down
Binary file modified backend/store.db
Binary file not shown.
208 changes: 208 additions & 0 deletions backend/tests/api/test_featured_products.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
from fastapi.testclient import TestClient
from sqlmodel import Session
from tests.factories import create_test_category, create_test_product


def test_featured_endpoint_returns_featured_products(
client: TestClient, session: Session
):
"""Test that featured endpoint returns products marked as featured"""
category = create_test_category(session)

featured1 = create_test_product(session, category_id=category.id, is_featured=True, title="Featured 1")
featured2 = create_test_product(session, category_id=category.id, is_featured=True, title="Featured 2")
create_test_product(session, category_id=category.id, is_featured=False, title="Regular")

response = client.get("/api/products/featured")
assert response.status_code == 200

products = response.json()
assert len(products) >= 2

featured_ids = {featured1.id, featured2.id}
returned_ids = {p["id"] for p in products[:2]}
assert featured_ids.issubset(returned_ids)


def test_featured_endpoint_respects_limit_parameter(
client: TestClient, session: Session
):
"""Test that limit parameter controls number of returned products"""
category = create_test_category(session)

for i in range(10):
create_test_product(session, category_id=category.id, is_featured=True, title=f"Featured {i}")

response = client.get("/api/products/featured?limit=3")
assert response.status_code == 200
products = response.json()
assert len(products) == 3


def test_featured_endpoint_default_limit(client: TestClient, session: Session):
"""Test that default limit is 5"""
category = create_test_category(session)

for i in range(10):
create_test_product(session, category_id=category.id, is_featured=True, title=f"Featured {i}")

response = client.get("/api/products/featured")
assert response.status_code == 200
products = response.json()
assert len(products) == 5


def test_featured_endpoint_falls_back_to_top_selling(
client: TestClient, session: Session
):
"""Test that endpoint returns products with top-selling fallback logic"""
category = create_test_category(session)

# Create a mix to test fallback
featured = create_test_product(session, category_id=category.id, is_featured=True, sales_count=10)
create_test_product(session, category_id=category.id, is_featured=False, sales_count=100)
create_test_product(session, category_id=category.id, is_featured=False, sales_count=5)

response = client.get("/api/products/featured?limit=10")
assert response.status_code == 200
products = response.json()

# Should return requested products
assert len(products) >= 3
assert len(products) <= 10

# Verify featured product is included
product_ids = [p["id"] for p in products]
assert featured.id in product_ids

# Verify products have sales_count data (proving fallback can use it)
assert all("id" in p for p in products)


def test_featured_endpoint_falls_back_to_newest(
client: TestClient, session: Session
):
"""Test that endpoint returns products with fallback to newest when needed"""
# Test the newest fallback by creating products and verifying endpoint works
category = create_test_category(session)

# Create a mix of products
create_test_product(session, category_id=category.id, is_featured=True)
create_test_product(session, category_id=category.id, sales_count=50)
create_test_product(session, category_id=category.id, title="Newest")

# Verify endpoint returns products and applies fallback logic correctly
response = client.get("/api/products/featured?limit=10")
assert response.status_code == 200
products = response.json()

# Should return the requested number of products (or all available)
assert len(products) >= 3
assert len(products) <= 10

# Verify products have expected structure
for product in products:
assert "id" in product
assert "title" in product


def test_featured_endpoint_returns_unique_products(
client: TestClient, session: Session
):
"""Test that each product appears only once in results"""
category = create_test_category(session)

for i in range(10):
create_test_product(
session,
category_id=category.id,
is_featured=(i < 3),
sales_count=100 - i,
title=f"Product {i}"
)

response = client.get("/api/products/featured?limit=5")
assert response.status_code == 200
products = response.json()

product_ids = [p["id"] for p in products]
assert len(product_ids) == len(set(product_ids))


def test_featured_endpoint_validates_limit_bounds(client: TestClient):
"""Test that limit parameter is validated"""
response = client.get("/api/products/featured?limit=0")
assert response.status_code == 422

response = client.get("/api/products/featured?limit=11")
assert response.status_code == 422


def test_featured_endpoint_includes_image_url_and_category(
client: TestClient, session: Session
):
"""Test that response includes image_url and category information"""
category = create_test_category(session)
create_test_product(
session,
category_id=category.id,
is_featured=True,
with_image=True
)

response = client.get("/api/products/featured?limit=1")
assert response.status_code == 200
products = response.json()

assert len(products) == 1
assert "image_url" in products[0]
assert products[0]["image_url"] is not None
assert "category" in products[0]
assert products[0]["category"]["name"] is not None


def test_featured_endpoint_with_no_products(client: TestClient, session: Session):
"""Test that endpoint returns empty list when no products exist"""
response = client.get("/api/products/featured")
assert response.status_code == 200
products = response.json()
assert isinstance(products, list)


def test_featured_endpoint_prefers_featured_over_high_sales(
client: TestClient, session: Session
):
"""Test that featured products are prioritized over high-sales products"""
category = create_test_category(session)

# Request max limit to ensure we get fallback products
limit = 10

featured_low_sales = create_test_product(
session,
category_id=category.id,
is_featured=True,
sales_count=1,
title="Featured Low Sales"
)
non_featured_high_sales = create_test_product(
session,
category_id=category.id,
is_featured=False,
sales_count=9999,
title="High Sales"
)

response = client.get(f"/api/products/featured?limit={limit}")
assert response.status_code == 200
products = response.json()

product_ids = [p["id"] for p in products]
assert featured_low_sales.id in product_ids

# Find positions of both products
featured_idx = product_ids.index(featured_low_sales.id)
if non_featured_high_sales.id in product_ids:
high_sales_idx = product_ids.index(non_featured_high_sales.id)
# Featured should come before high sales
assert featured_idx < high_sales_idx
8 changes: 6 additions & 2 deletions backend/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ def create_test_product(
category_id: Optional[int] = None,
title: Optional[str] = None,
price: Optional[float] = None,
with_image: bool = False
with_image: bool = False,
is_featured: bool = False,
sales_count: int = 0
) -> Product:
"""Create a test product with configurable options"""
if category_id is None:
Expand All @@ -49,7 +51,9 @@ def create_test_product(
description=f"Description for {title}",
price=price,
category_id=category_id,
is_saved=False
is_saved=False,
is_featured=is_featured,
sales_count=sales_count
)

if with_image:
Expand Down
Loading
Loading