diff --git a/backend/app/alembic/versions/787343a5a78d_add_category_and_product_models.py b/backend/app/alembic/versions/787343a5a78d_add_category_and_product_models.py new file mode 100644 index 0000000000..7e20feaf3c --- /dev/null +++ b/backend/app/alembic/versions/787343a5a78d_add_category_and_product_models.py @@ -0,0 +1,47 @@ +"""Add Category and Product models + +Revision ID: 787343a5a78d +Revises: 1a31ce608336 +Create Date: 2025-08-27 12:34:36.443129 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '787343a5a78d' +down_revision = '1a31ce608336' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('category', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('parent_category_id', sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint(['parent_category_id'], ['category.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('product', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('discount_price', sa.Float(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['category.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('product') + op.drop_table('category') + # ### end Alembic commands ### diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..57f1ac36ea 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ 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 +8,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..dc41f388b4 --- /dev/null +++ b/backend/app/api/routes/categories.py @@ -0,0 +1,104 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Category, + CategoryCreate, + CategoryPublic, + CategoriesPublic, + CategoryUpdate, + Message, +) + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.get("/", response_model=CategoriesPublic) +def read_categories( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve categories. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + 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("/{id}", response_model=CategoryPublic) +def read_category(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Get category by ID. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + category = session.get(Category, id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + return category + + +@router.post("/", response_model=CategoryPublic) +def create_category( + *, session: SessionDep, current_user: CurrentUser, category_in: CategoryCreate +) -> Any: + """ + Create new category. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + category = Category.model_validate(category_in) + session.add(category) + session.commit() + session.refresh(category) + return category + + +@router.put("/{id}", response_model=CategoryPublic) +def update_category( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + category_in: CategoryUpdate, +) -> Any: + """ + Update a category. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + category = session.get(Category, id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + update_dict = category_in.model_dump(exclude_unset=True) + category.sqlmodel_update(update_dict) + session.add(category) + session.commit() + session.refresh(category) + return category + + +@router.delete("/{id}") +def delete_category( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Delete a category. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + category = session.get(Category, id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + 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..f20476dd9d --- /dev/null +++ b/backend/app/api/routes/products.py @@ -0,0 +1,104 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Product, + ProductCreate, + ProductPublic, + ProductsPublic, + ProductUpdate, + Message, +) + +router = APIRouter(prefix="/products", tags=["products"]) + + +@router.get("/", response_model=ProductsPublic) +def read_products( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve products. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + 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("/{id}", response_model=ProductPublic) +def read_product(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Get product by ID. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + product = session.get(Product, id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + return product + + +@router.post("/", response_model=ProductPublic) +def create_product( + *, session: SessionDep, current_user: CurrentUser, product_in: ProductCreate +) -> Any: + """ + Create new product. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + product = Product.model_validate(product_in) + session.add(product) + session.commit() + session.refresh(product) + return product + + +@router.put("/{id}", response_model=ProductPublic) +def update_product( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + product_in: ProductUpdate, +) -> Any: + """ + Update a product. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + product = session.get(Product, id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + update_dict = product_in.model_dump(exclude_unset=True) + product.sqlmodel_update(update_dict) + session.add(product) + session.commit() + session.refresh(product) + return product + + +@router.delete("/{id}") +def delete_product( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Delete a product. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + product = session.get(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/models.py b/backend/app/models.py index 2389b4a532..7c01720fad 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,84 @@ class ItemsPublic(SQLModel): count: int +# Category properties +class CategoryBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + + +# Properties to receive on category creation +class CategoryCreate(CategoryBase): + pass + + +# Properties to receive on category update +class CategoryUpdate(CategoryBase): + name: str | None = Field(default=None, min_length=1, max_length=255) + parent_category_id: uuid.UUID | None = None + + +# Database model +class Category(CategoryBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + parent_category_id: uuid.UUID | None = Field( + default=None, foreign_key="category.id" + ) + parent_category: "Category" = Relationship( + back_populates="sub_categories", sa_relationship_kwargs=dict(remote_side="Category.id") + ) + sub_categories: list["Category"] = Relationship(back_populates="parent_category") + products: list["Product"] = Relationship(back_populates="category") + + +# Properties to return via API +class CategoryPublic(CategoryBase): + id: uuid.UUID + parent_category_id: uuid.UUID | None + + +class CategoriesPublic(SQLModel): + data: list[CategoryPublic] + count: int + + +# Product properties +class ProductBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + price: float + discount_price: float | None = None + + +# Properties to receive on product creation +class ProductCreate(ProductBase): + category_id: uuid.UUID + + +# Properties to receive on product update +class ProductUpdate(ProductBase): + name: str | None = Field(default=None, min_length=1, max_length=255) + price: float | None = None + + +# Database model +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") + category: Category = Relationship(back_populates="products") + + +# Properties to return via API +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/routes/test_categories.py b/backend/app/tests/api/routes/test_categories.py new file mode 100644 index 0000000000..3faa227f5b --- /dev/null +++ b/backend/app/tests/api/routes/test_categories.py @@ -0,0 +1,175 @@ +import uuid + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.tests.utils.category import create_random_category + + +def test_create_category( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"name": "Foo", "description": "Fighters"} + response = client.post( + f"{settings.API_V1_STR}/categories/", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["name"] == data["name"] + assert content["description"] == data["description"] + assert "id" in content + + +def test_create_category_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + data = {"name": "Foo", "description": "Fighters"} + response = client.post( + f"{settings.API_V1_STR}/categories/", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" + + +def test_read_category( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + category = create_random_category(db) + response = client.get( + f"{settings.API_V1_STR}/categories/{category.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["name"] == category.name + assert content["description"] == category.description + assert content["id"] == str(category.id) + + +def test_read_category_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.get( + f"{settings.API_V1_STR}/categories/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Category not found" + + +def test_read_category_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + category = create_random_category(db) + response = client.get( + f"{settings.API_V1_STR}/categories/{category.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" + + +def test_read_categories( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + create_random_category(db) + create_random_category(db) + response = client.get( + f"{settings.API_V1_STR}/categories/", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert len(content["data"]) >= 2 + + +def test_update_category( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + category = create_random_category(db) + data = {"name": "Updated name", "description": "Updated description"} + response = client.put( + f"{settings.API_V1_STR}/categories/{category.id}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["name"] == data["name"] + assert content["description"] == data["description"] + assert content["id"] == str(category.id) + + +def test_update_category_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"name": "Updated name", "description": "Updated description"} + response = client.put( + f"{settings.API_V1_STR}/categories/{uuid.uuid4()}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Category not found" + + +def test_update_category_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + category = create_random_category(db) + data = {"name": "Updated name", "description": "Updated description"} + response = client.put( + f"{settings.API_V1_STR}/categories/{category.id}", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" + + +def test_delete_category( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + category = create_random_category(db) + response = client.delete( + f"{settings.API_V1_STR}/categories/{category.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["message"] == "Category deleted successfully" + + +def test_delete_category_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.delete( + f"{settings.API_V1_STR}/categories/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Category not found" + + +def test_delete_category_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + category = create_random_category(db) + response = client.delete( + f"{settings.API_V1_STR}/categories/{category.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" diff --git a/backend/app/tests/api/routes/test_products.py b/backend/app/tests/api/routes/test_products.py new file mode 100644 index 0000000000..f8bb914ef1 --- /dev/null +++ b/backend/app/tests/api/routes/test_products.py @@ -0,0 +1,193 @@ +import uuid + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.tests.utils.product import create_random_product +from app.tests.utils.category import create_random_category + + +def test_create_product( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + category = create_random_category(db) + data = { + "name": "Foo", + "description": "Fighters", + "price": 10.0, + "category_id": str(category.id), + } + response = client.post( + f"{settings.API_V1_STR}/products/", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["name"] == data["name"] + assert content["description"] == data["description"] + assert content["price"] == data["price"] + assert content["category_id"] == data["category_id"] + assert "id" in content + + +def test_create_product_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + category = create_random_category(db) + data = { + "name": "Foo", + "description": "Fighters", + "price": 10.0, + "category_id": str(category.id), + } + response = client.post( + f"{settings.API_V1_STR}/products/", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" + + +def test_read_product( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + product = create_random_product(db) + response = client.get( + f"{settings.API_V1_STR}/products/{product.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["name"] == product.name + assert content["description"] == product.description + assert content["price"] == product.price + assert content["category_id"] == str(product.category_id) + assert content["id"] == str(product.id) + + +def test_read_product_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.get( + f"{settings.API_V1_STR}/products/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Product not found" + + +def test_read_product_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + product = create_random_product(db) + response = client.get( + f"{settings.API_V1_STR}/products/{product.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" + + +def test_read_products( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + create_random_product(db) + create_random_product(db) + response = client.get( + f"{settings.API_V1_STR}/products/", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert len(content["data"]) >= 2 + + +def test_update_product( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + product = create_random_product(db) + data = {"name": "Updated name", "price": 20.0} + response = client.put( + f"{settings.API_V1_STR}/products/{product.id}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["name"] == data["name"] + assert content["price"] == data["price"] + assert content["id"] == str(product.id) + assert content["category_id"] == str(product.category_id) + + +def test_update_product_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"name": "Updated name", "price": 20.0} + response = client.put( + f"{settings.API_V1_STR}/products/{uuid.uuid4()}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Product not found" + + +def test_update_product_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + product = create_random_product(db) + data = {"name": "Updated name", "price": 20.0} + response = client.put( + f"{settings.API_V1_STR}/products/{product.id}", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" + + +def test_delete_product( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + product = create_random_product(db) + response = client.delete( + f"{settings.API_V1_STR}/products/{product.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["message"] == "Product deleted successfully" + + +def test_delete_product_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.delete( + f"{settings.API_V1_STR}/products/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Product not found" + + +def test_delete_product_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + product = create_random_product(db) + response = client.delete( + f"{settings.API_V1_STR}/products/{product.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" diff --git a/backend/app/tests/utils/category.py b/backend/app/tests/utils/category.py new file mode 100644 index 0000000000..32c85f583e --- /dev/null +++ b/backend/app/tests/utils/category.py @@ -0,0 +1,15 @@ +from sqlmodel import Session + +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) + db_obj = Category.model_validate(category_in) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj diff --git a/backend/app/tests/utils/product.py b/backend/app/tests/utils/product.py new file mode 100644 index 0000000000..2f0ece1b0f --- /dev/null +++ b/backend/app/tests/utils/product.py @@ -0,0 +1,23 @@ +import random + +from sqlmodel import Session + +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) + category_id = category.id + name = random_lower_string() + description = random_lower_string() + price = random.uniform(10, 100) + product_in = ProductCreate( + name=name, description=description, price=price, category_id=category_id + ) + db_obj = Product.model_validate(product_in) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 156003aec9..83a178ca4f 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -4,6 +4,16 @@ import type { CancelablePromise } from "./core/CancelablePromise" import { OpenAPI } from "./core/OpenAPI" import { request as __request } from "./core/request" import type { + CategoriesReadCategoriesData, + CategoriesReadCategoriesResponse, + CategoriesCreateCategoryData, + CategoriesCreateCategoryResponse, + CategoriesReadCategoryData, + CategoriesReadCategoryResponse, + CategoriesUpdateCategoryData, + CategoriesUpdateCategoryResponse, + CategoriesDeleteCategoryData, + CategoriesDeleteCategoryResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, @@ -25,6 +35,16 @@ import type { LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, + ProductsReadProductsData, + ProductsReadProductsResponse, + ProductsCreateProductData, + ProductsCreateProductResponse, + ProductsReadProductData, + ProductsReadProductResponse, + ProductsUpdateProductData, + ProductsUpdateProductResponse, + ProductsDeleteProductData, + ProductsDeleteProductResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, @@ -48,6 +68,127 @@ import type { UtilsHealthCheckResponse, } from "./types.gen" +export class CategoriesService { + /** + * Read Categories + * Retrieve categories. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns CategoriesPublic Successful Response + * @throws ApiError + */ + public static readCategories( + data: CategoriesReadCategoriesData = {}, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/categories/", + query: { + skip: data.skip, + limit: data.limit, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Create Category + * Create new category. + * @param data The data for the request. + * @param data.requestBody + * @returns CategoryPublic Successful Response + * @throws ApiError + */ + public static createCategory( + data: CategoriesCreateCategoryData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/api/v1/categories/", + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Read Category + * Get category by ID. + * @param data The data for the request. + * @param data.id + * @returns CategoryPublic Successful Response + * @throws ApiError + */ + public static readCategory( + data: CategoriesReadCategoryData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/categories/{id}", + path: { + id: data.id, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Update Category + * Update a category. + * @param data The data for the request. + * @param data.id + * @param data.requestBody + * @returns CategoryPublic Successful Response + * @throws ApiError + */ + public static updateCategory( + data: CategoriesUpdateCategoryData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/api/v1/categories/{id}", + path: { + id: data.id, + }, + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Delete Category + * Delete a category. + * @param data The data for the request. + * @param data.id + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteCategory( + data: CategoriesDeleteCategoryData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "DELETE", + url: "/api/v1/categories/{id}", + path: { + id: data.id, + }, + errors: { + 422: "Validation Error", + }, + }) + } +} + export class ItemsService { /** * Read Items @@ -298,6 +439,127 @@ export class PrivateService { } } +export class ProductsService { + /** + * Read Products + * Retrieve products. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns ProductsPublic Successful Response + * @throws ApiError + */ + public static readProducts( + data: ProductsReadProductsData = {}, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/products/", + query: { + skip: data.skip, + limit: data.limit, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Create Product + * Create new product. + * @param data The data for the request. + * @param data.requestBody + * @returns ProductPublic Successful Response + * @throws ApiError + */ + public static createProduct( + data: ProductsCreateProductData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/api/v1/products/", + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Read Product + * Get product by ID. + * @param data The data for the request. + * @param data.id + * @returns ProductPublic Successful Response + * @throws ApiError + */ + public static readProduct( + data: ProductsReadProductData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/products/{id}", + path: { + id: data.id, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Update Product + * Update a product. + * @param data The data for the request. + * @param data.id + * @param data.requestBody + * @returns ProductPublic Successful Response + * @throws ApiError + */ + public static updateProduct( + data: ProductsUpdateProductData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/api/v1/products/{id}", + path: { + id: data.id, + }, + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Delete Product + * Delete a product. + * @param data The data for the request. + * @param data.id + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteProduct( + data: ProductsDeleteProductData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "DELETE", + url: "/api/v1/products/{id}", + path: { + id: data.id, + }, + errors: { + 422: "Validation Error", + }, + }) + } +} + export class UsersService { /** * Read Users diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 67d4abd286..c7300ec01a 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -9,6 +9,28 @@ export type Body_login_login_access_token = { client_secret?: string | null } +export type CategoriesPublic = { + data: Array + count: number +} + +export type CategoryCreate = { + name: string + description?: string | null +} + +export type CategoryPublic = { + name: string + description?: string | null + id: string + parent_category_id: string | null +} + +export type CategoryUpdate = { + name?: string | null + description?: string | null +} + export type HTTPValidationError = { detail?: Array } @@ -51,6 +73,35 @@ export type PrivateUserCreate = { is_verified?: boolean } +export type ProductCreate = { + name: string + description?: string | null + price: number + discount_price?: number | null + category_id: string +} + +export type ProductPublic = { + name: string + description?: string | null + price: number + discount_price?: number | null + id: string + category_id: string +} + +export type ProductsPublic = { + data: Array + count: number +} + +export type ProductUpdate = { + name?: string | null + description?: string | null + price?: number | null + discount_price?: number | null +} + export type Token = { access_token: string token_type?: string @@ -107,6 +158,38 @@ export type ValidationError = { type: string } +export type CategoriesReadCategoriesData = { + limit?: number + skip?: number +} + +export type CategoriesReadCategoriesResponse = CategoriesPublic + +export type CategoriesCreateCategoryData = { + requestBody: CategoryCreate +} + +export type CategoriesCreateCategoryResponse = CategoryPublic + +export type CategoriesReadCategoryData = { + id: string +} + +export type CategoriesReadCategoryResponse = CategoryPublic + +export type CategoriesUpdateCategoryData = { + id: string + requestBody: CategoryUpdate +} + +export type CategoriesUpdateCategoryResponse = CategoryPublic + +export type CategoriesDeleteCategoryData = { + id: string +} + +export type CategoriesDeleteCategoryResponse = Message + export type ItemsReadItemsData = { limit?: number skip?: number @@ -171,6 +254,38 @@ export type PrivateCreateUserData = { export type PrivateCreateUserResponse = UserPublic +export type ProductsReadProductsData = { + limit?: number + skip?: number +} + +export type ProductsReadProductsResponse = ProductsPublic + +export type ProductsCreateProductData = { + requestBody: ProductCreate +} + +export type ProductsCreateProductResponse = ProductPublic + +export type ProductsReadProductData = { + id: string +} + +export type ProductsReadProductResponse = ProductPublic + +export type ProductsUpdateProductData = { + id: string + requestBody: ProductUpdate +} + +export type ProductsUpdateProductResponse = ProductPublic + +export type ProductsDeleteProductData = { + id: string +} + +export type ProductsDeleteProductResponse = Message + export type UsersReadUsersData = { limit?: number skip?: number diff --git a/frontend/src/components/Categories/CategoryList.tsx b/frontend/src/components/Categories/CategoryList.tsx new file mode 100644 index 0000000000..58db14c4cf --- /dev/null +++ b/frontend/src/components/Categories/CategoryList.tsx @@ -0,0 +1,85 @@ +import { + List, + ListItem, + Spinner, + Text, + UnorderedList, +} from "@chakra-ui/react" +import { useQuery } from "@tanstack/react-query" +import { Link as RouterLink } from "@tanstack/react-router" + +import { type CategoryPublic, CategoriesService } from "@/client" + +interface CategoryNode extends CategoryPublic { + children: CategoryNode[] +} + +const buildCategoryTree = (categories: CategoryPublic[]): CategoryNode[] => { + const categoryMap = new Map() + const rootCategories: CategoryNode[] = [] + + categories.forEach((category) => { + const categoryId = category.id + if (categoryId) { + categoryMap.set(categoryId, { ...category, children: [] }) + } + }) + + categories.forEach((category) => { + const categoryId = category.id + if (!categoryId) return + + const node = categoryMap.get(categoryId) + if (!node) return + + if (category.parent_category_id && categoryMap.has(category.parent_category_id)) { + const parent = categoryMap.get(category.parent_category_id) + parent?.children.push(node) + } else { + rootCategories.push(node) + } + }) + + return rootCategories +} + + +const CategoryTree = ({ categories }: { categories: CategoryNode[] }) => { + if (!categories || categories.length === 0) { + return null + } + + return ( + + {categories.map((category) => ( + + + {category.name} + + + + ))} + + ) +} + +const CategoryList = () => { + const { data, isLoading, isError } = useQuery({ + queryKey: ["categories"], + queryFn: () => CategoriesService.readCategories({}), + }) + + if (isLoading) { + return + } + + if (isError) { + return Error loading categories. + } + + const categoryTree = buildCategoryTree(data?.data || []) + + return +} + +export default CategoryList diff --git a/frontend/src/components/Categories/CreateCategoryForm.tsx b/frontend/src/components/Categories/CreateCategoryForm.tsx new file mode 100644 index 0000000000..5d5b6122e0 --- /dev/null +++ b/frontend/src/components/Categories/CreateCategoryForm.tsx @@ -0,0 +1,83 @@ +import { + Button, + FormControl, + FormErrorMessage, + FormLabel, + Input, + Textarea, + VStack, +} from "@chakra-ui/react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useForm } from "react-hook-form" + +import { type ApiError, type CategoryCreate, CategoriesService } from "@/client" +import useCustomToast from "@/hooks/useCustomToast" + +const CreateCategoryForm = () => { + const queryClient = useQueryClient() + const showToast = useCustomToast() + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + mode: "onBlur", + criteriaMode: "all", + defaultValues: { + name: "", + description: "", + }, + }) + + const mutation = useMutation({ + mutationFn: (data: CategoryCreate) => + CategoriesService.createCategory({ requestBody: data }), + onSuccess: () => { + showToast("Success!", "Category created successfully.", "success") + queryClient.invalidateQueries({ queryKey: ["categories"] }) + }, + onError: (err: ApiError) => { + const errDetail = (err.body as any)?.detail + showToast("Something went wrong.", `${errDetail}`, "error") + }, + }) + + const onSubmit = (data: CategoryCreate) => { + mutation.mutate(data) + } + + return ( +
+ + + Name + + {errors.name && ( + {errors.name.message} + )} + + + Description +