-
Notifications
You must be signed in to change notification settings - Fork 6
feat: Add featured products carousel #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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")) | ||
| 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 ### | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = [] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
||
| 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 |
There was a problem hiding this comment.
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. Usesa.text("0")for consistency and to ensure proper SQL generation across different databases.