From 05dd2520fac791eeae7b36a4c3b9d1290b36d46d Mon Sep 17 00:00:00 2001 From: sarunas-llm Date: Thu, 28 Aug 2025 17:20:54 +0300 Subject: [PATCH] feat: add categories and products --- ...8c83f2a0c24_add_categories_and_products.py | 36 +++++++ backend/app/api/main.py | 12 ++- backend/app/api/routes/categories.py | 97 +++++++++++++++++++ backend/app/api/routes/products.py | 94 ++++++++++++++++++ backend/app/crud.py | 28 +++++- backend/app/models.py | 63 ++++++++++++ backend/app/tests/api/test_categories.py | 49 ++++++++++ backend/app/tests/api/test_products.py | 52 ++++++++++ backend/app/tests/conftest.py | 6 +- backend/app/tests/utils/category.py | 12 +++ backend/app/tests/utils/product.py | 20 ++++ 11 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 backend/app/alembic/versions/78c83f2a0c24_add_categories_and_products.py create mode 100644 backend/app/api/routes/categories.py create mode 100644 backend/app/api/routes/products.py create mode 100644 backend/app/tests/api/test_categories.py create mode 100644 backend/app/tests/api/test_products.py create mode 100644 backend/app/tests/utils/category.py create mode 100644 backend/app/tests/utils/product.py diff --git a/backend/app/alembic/versions/78c83f2a0c24_add_categories_and_products.py b/backend/app/alembic/versions/78c83f2a0c24_add_categories_and_products.py new file mode 100644 index 0000000000..8350a5e69a --- /dev/null +++ b/backend/app/alembic/versions/78c83f2a0c24_add_categories_and_products.py @@ -0,0 +1,36 @@ +"""Add categories and products tables""" + +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +revision = "78c83f2a0c24" +down_revision = "1a31ce608336" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "category", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "product", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("price", sa.Float(), nullable=False), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("category_id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["category.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("product") + op.drop_table("category") diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..7fed386a0a 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,14 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import ( + categories, + items, + login, + private, + products, + users, + utils, +) from app.core.config import settings api_router = APIRouter() @@ -8,6 +16,8 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(categories.router) +api_router.include_router(products.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/categories.py b/backend/app/api/routes/categories.py new file mode 100644 index 0000000000..77e9d6c957 --- /dev/null +++ b/backend/app/api/routes/categories.py @@ -0,0 +1,97 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import func, select + +from app.api.deps import SessionDep, get_current_active_superuser +from app.models import ( + CategoriesPublic, + Category, + CategoryCreate, + CategoryPublic, + CategoryUpdate, + Message, + Product, +) + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.get("/", response_model=CategoriesPublic) +def read_categories(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + """Retrieve categories.""" + + count_statement = select(func.count()).select_from(Category) + count = session.exec(count_statement).one() + statement = select(Category).offset(skip).limit(limit) + categories = session.exec(statement).all() + return CategoriesPublic(data=categories, count=count) + + +@router.get("/{category_id}", response_model=CategoryPublic) +def read_category(category_id: uuid.UUID, session: SessionDep) -> Any: + """Get category by ID.""" + + category = session.get(Category, category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + return category + + +@router.post( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=CategoryPublic, +) +def create_category(*, session: SessionDep, category_in: CategoryCreate) -> Any: + """Create new category.""" + + category = Category.model_validate(category_in) + session.add(category) + session.commit() + session.refresh(category) + return category + + +@router.put( + "/{category_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=CategoryPublic, +) +def update_category( + *, session: SessionDep, category_id: uuid.UUID, category_in: CategoryUpdate +) -> Any: + """Update a category.""" + + category = session.get(Category, category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + update_data = category_in.model_dump(exclude_unset=True) + category.sqlmodel_update(update_data) + session.add(category) + session.commit() + session.refresh(category) + return category + + +@router.delete("/{category_id}", dependencies=[Depends(get_current_active_superuser)]) +def delete_category(session: SessionDep, category_id: uuid.UUID) -> Message: + """Delete a category without products.""" + + category = session.get(Category, category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + count_products = session.exec( + select(func.count()) + .select_from(Product) + .where(Product.category_id == category_id) + ).one() + if count_products: + raise HTTPException( + status_code=400, + detail="Cannot delete category with existing products", + ) + session.delete(category) + session.commit() + return Message(message="Category deleted successfully") diff --git a/backend/app/api/routes/products.py b/backend/app/api/routes/products.py new file mode 100644 index 0000000000..bc0a6fbb84 --- /dev/null +++ b/backend/app/api/routes/products.py @@ -0,0 +1,94 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import func, select + +from app.api.deps import SessionDep, get_current_active_superuser +from app.models import ( + Category, + Message, + Product, + ProductCreate, + ProductPublic, + ProductsPublic, + ProductUpdate, +) + +router = APIRouter(prefix="/products", tags=["products"]) + + +@router.get("/", response_model=ProductsPublic) +def read_products(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + """Retrieve products.""" + + count_statement = select(func.count()).select_from(Product) + count = session.exec(count_statement).one() + statement = select(Product).offset(skip).limit(limit) + products = session.exec(statement).all() + return ProductsPublic(data=products, count=count) + + +@router.get("/{product_id}", response_model=ProductPublic) +def read_product(product_id: uuid.UUID, session: SessionDep) -> Any: + """Get product by ID.""" + + product = session.get(Product, product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + return product + + +@router.post( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=ProductPublic, +) +def create_product(*, session: SessionDep, product_in: ProductCreate) -> Any: + """Create new product.""" + + category = session.get(Category, product_in.category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + product = Product.model_validate(product_in) + session.add(product) + session.commit() + session.refresh(product) + return product + + +@router.put( + "/{product_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=ProductPublic, +) +def update_product( + *, session: SessionDep, product_id: uuid.UUID, product_in: ProductUpdate +) -> Any: + """Update a product.""" + + product = session.get(Product, product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + if product_in.category_id: + category = session.get(Category, product_in.category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + update_data = product_in.model_dump(exclude_unset=True) + product.sqlmodel_update(update_data) + session.add(product) + session.commit() + session.refresh(product) + return product + + +@router.delete("/{product_id}", dependencies=[Depends(get_current_active_superuser)]) +def delete_product(session: SessionDep, product_id: uuid.UUID) -> Message: + """Delete a product.""" + + product = session.get(Product, product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + session.delete(product) + session.commit() + return Message(message="Product deleted successfully") diff --git a/backend/app/crud.py b/backend/app/crud.py index 905bf48724..37a7b98266 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -4,7 +4,17 @@ from sqlmodel import Session, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import ( + Category, + CategoryCreate, + Item, + ItemCreate, + Product, + ProductCreate, + User, + UserCreate, + UserUpdate, +) def create_user(*, session: Session, user_create: UserCreate) -> User: @@ -52,3 +62,19 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) - session.commit() session.refresh(db_item) return db_item + + +def create_category(*, session: Session, category_in: CategoryCreate) -> Category: + db_category = Category.model_validate(category_in) + session.add(db_category) + session.commit() + session.refresh(db_category) + return db_category + + +def create_product(*, session: Session, product_in: ProductCreate) -> Product: + db_product = Product.model_validate(product_in) + session.add(db_product) + session.commit() + session.refresh(db_product) + return db_product diff --git a/backend/app/models.py b/backend/app/models.py index 2389b4a532..277ad9640a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,69 @@ class ItemsPublic(SQLModel): count: int +# Shared properties +class CategoryBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + + +class CategoryCreate(CategoryBase): + pass + + +class CategoryUpdate(CategoryBase): + name: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore + + +class Category(CategoryBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + products: list["Product"] = Relationship(back_populates="category") + + +class CategoryPublic(CategoryBase): + id: uuid.UUID + + +class CategoriesPublic(SQLModel): + data: list[CategoryPublic] + count: int + + +# Shared properties +class ProductBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + price: float + + +class ProductCreate(ProductBase): + category_id: uuid.UUID + + +class ProductUpdate(ProductBase): + name: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore + price: float | None = None + category_id: uuid.UUID | None = None + + +class Product(ProductBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + category_id: uuid.UUID = Field( + foreign_key="category.id", nullable=False, ondelete="RESTRICT" + ) + category: Category | None = Relationship(back_populates="products") + + +class ProductPublic(ProductBase): + id: uuid.UUID + category_id: uuid.UUID + + +class ProductsPublic(SQLModel): + data: list[ProductPublic] + count: int + + # Generic message class Message(SQLModel): message: str diff --git a/backend/app/tests/api/test_categories.py b/backend/app/tests/api/test_categories.py new file mode 100644 index 0000000000..77a13275ac --- /dev/null +++ b/backend/app/tests/api/test_categories.py @@ -0,0 +1,49 @@ +from sqlmodel import Session + +from app.tests.utils.product import create_random_product +from app.tests.utils.utils import random_lower_string + + +def test_create_category_requires_superuser(client, normal_user_token_headers) -> None: + data = {"name": random_lower_string(), "description": random_lower_string()} + response = client.post( + "/api/v1/categories/", headers=normal_user_token_headers, json=data + ) + assert response.status_code == 403 + + +def test_crud_category(client, superuser_token_headers) -> None: + data = {"name": random_lower_string(), "description": random_lower_string()} + response = client.post( + "/api/v1/categories/", headers=superuser_token_headers, json=data + ) + assert response.status_code == 200 + category = response.json() + category_id = category["id"] + + response = client.get(f"/api/v1/categories/{category_id}") + assert response.status_code == 200 + + update = {"name": random_lower_string()} + response = client.put( + f"/api/v1/categories/{category_id}", + headers=superuser_token_headers, + json=update, + ) + assert response.status_code == 200 + + response = client.delete( + f"/api/v1/categories/{category_id}", headers=superuser_token_headers + ) + assert response.status_code == 200 + + +def test_delete_category_with_products_fails( + client, superuser_token_headers, db: Session +) -> None: + product = create_random_product(db) + category_id = product.category_id + response = client.delete( + f"/api/v1/categories/{category_id}", headers=superuser_token_headers + ) + assert response.status_code == 400 diff --git a/backend/app/tests/api/test_products.py b/backend/app/tests/api/test_products.py new file mode 100644 index 0000000000..c036b7e369 --- /dev/null +++ b/backend/app/tests/api/test_products.py @@ -0,0 +1,52 @@ +from sqlmodel import Session + +from app.tests.utils.category import create_random_category +from app.tests.utils.utils import random_lower_string + + +def test_create_product_requires_superuser( + client, normal_user_token_headers, db: Session +) -> None: + category = create_random_category(db) + data = { + "name": random_lower_string(), + "description": random_lower_string(), + "price": 1.0, + "category_id": str(category.id), + } + response = client.post( + "/api/v1/products/", headers=normal_user_token_headers, json=data + ) + assert response.status_code == 403 + + +def test_crud_product(client, superuser_token_headers, db: Session) -> None: + category = create_random_category(db) + data = { + "name": random_lower_string(), + "description": random_lower_string(), + "price": 1.0, + "category_id": str(category.id), + } + response = client.post( + "/api/v1/products/", headers=superuser_token_headers, json=data + ) + assert response.status_code == 200 + product = response.json() + product_id = product["id"] + + response = client.get(f"/api/v1/products/{product_id}") + assert response.status_code == 200 + + update = {"name": random_lower_string()} + response = client.put( + f"/api/v1/products/{product_id}", + headers=superuser_token_headers, + json=update, + ) + assert response.status_code == 200 + + response = client.delete( + f"/api/v1/products/{product_id}", headers=superuser_token_headers + ) + assert response.status_code == 200 diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 90ab39a357..cbfa6a8aa7 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -7,7 +7,7 @@ from app.core.config import settings from app.core.db import engine, init_db from app.main import app -from app.models import Item, User +from app.models import Category, Item, Product, User from app.tests.utils.user import authentication_token_from_email from app.tests.utils.utils import get_superuser_token_headers @@ -17,6 +17,10 @@ def db() -> Generator[Session, None, None]: with Session(engine) as session: init_db(session) yield session + statement = delete(Product) + session.execute(statement) + statement = delete(Category) + session.execute(statement) statement = delete(Item) session.execute(statement) statement = delete(User) diff --git a/backend/app/tests/utils/category.py b/backend/app/tests/utils/category.py new file mode 100644 index 0000000000..0286ca73cc --- /dev/null +++ b/backend/app/tests/utils/category.py @@ -0,0 +1,12 @@ +from sqlmodel import Session + +from app import crud +from app.models import Category, CategoryCreate +from app.tests.utils.utils import random_lower_string + + +def create_random_category(db: Session) -> Category: + name = random_lower_string() + description = random_lower_string() + category_in = CategoryCreate(name=name, description=description) + return crud.create_category(session=db, category_in=category_in) diff --git a/backend/app/tests/utils/product.py b/backend/app/tests/utils/product.py new file mode 100644 index 0000000000..0c99152d30 --- /dev/null +++ b/backend/app/tests/utils/product.py @@ -0,0 +1,20 @@ +from sqlmodel import Session + +from app import crud +from app.models import Product, ProductCreate +from app.tests.utils.category import create_random_category +from app.tests.utils.utils import random_lower_string + + +def create_random_product(db: Session) -> Product: + category = create_random_category(db) + name = random_lower_string() + description = random_lower_string() + price = 1.0 + product_in = ProductCreate( + name=name, + description=description, + price=price, + category_id=category.id, + ) + return crud.create_product(session=db, product_in=product_in)