Skip to content

Commit d1c5cb1

Browse files
authored
Merge pull request #1203 from Muizzyranking/feat/product_category_delete
[FEAT]: Implement Soft Delete, Permanent Delete, and Restore for Product Categories
2 parents 144bbfd + 394c14b commit d1c5cb1

6 files changed

Lines changed: 638 additions & 157 deletions

File tree

api/v1/models/product.py

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
""" The Product model
2-
"""
1+
"""The Product model"""
32

43
from sqlalchemy import (
54
Column,
@@ -24,21 +23,27 @@ class ProductStatusEnum(Enum):
2423
out_of_stock = "out_of_stock"
2524
low_on_stock = "low_on_stock"
2625

26+
2727
class ProductFilterStatusEnum(Enum):
2828
active = "active"
2929
draft = "draft"
3030

31+
3132
class Product(BaseTableModel):
3233
__tablename__ = "products"
3334

3435
name = Column(String, nullable=False)
3536
description = Column(Text, nullable=True)
3637
price = Column(Numeric, nullable=False)
3738
org_id = Column(
38-
String, ForeignKey("organisations.id", ondelete="CASCADE"), nullable=False
39+
String,
40+
ForeignKey("organisations.id", ondelete="CASCADE"),
41+
nullable=False,
3942
)
4043
category_id = Column(
41-
String, ForeignKey("product_categories.id", ondelete="CASCADE"), nullable=False
44+
String,
45+
ForeignKey("product_categories.id", ondelete="CASCADE"),
46+
nullable=False,
4247
)
4348
quantity = Column(Integer, default=0)
4449
image_url = Column(String, nullable=False)
@@ -47,18 +52,25 @@ class Product(BaseTableModel):
4752
)
4853
archived = Column(Boolean, default=False)
4954
filter_status = Column(
50-
SQLAlchemyEnum(ProductFilterStatusEnum), default=ProductFilterStatusEnum.active
55+
SQLAlchemyEnum(ProductFilterStatusEnum),
56+
default=ProductFilterStatusEnum.active,
5157
)
5258

5359
variants = relationship(
54-
"ProductVariant", back_populates="product", cascade="all, delete-orphan"
60+
"ProductVariant",
61+
back_populates="product",
62+
cascade="all, delete-orphan",
5563
)
5664
organisation = relationship("Organisation", back_populates="products")
5765
category = relationship("ProductCategory", back_populates="products")
58-
sales = relationship('Sales', back_populates='product',
59-
cascade='all, delete-orphan')
60-
comments = relationship("ProductComment", back_populates="product", cascade="all, delete-orphan")
61-
66+
sales = relationship(
67+
"Sales", back_populates="product", cascade="all, delete-orphan"
68+
)
69+
comments = relationship(
70+
"ProductComment",
71+
back_populates="product",
72+
cascade="all, delete-orphan",
73+
)
6274

6375
def __str__(self):
6476
return self.name
@@ -79,6 +91,7 @@ class ProductCategory(BaseTableModel):
7991

8092
name = Column(String, nullable=False, unique=True)
8193
products = relationship("Product", back_populates="category")
94+
is_deleted = Column(Boolean, default=False) # soft delete flag
8295

8396
def __str__(self):
8497
return self.name
@@ -87,14 +100,20 @@ def __str__(self):
87100
class ProductComment(BaseTableModel):
88101
__tablename__ = "product_comments"
89102

90-
product_id = Column(String, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
91-
user_id = Column(String, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
103+
product_id = Column(
104+
String, ForeignKey("products.id", ondelete="CASCADE"), nullable=False
105+
)
106+
user_id = Column(
107+
String, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
108+
)
92109
content = Column(Text, nullable=False)
93110
created_at = Column(DateTime, server_default=func.now())
94-
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
111+
updated_at = Column(
112+
DateTime, server_default=func.now(), onupdate=func.now()
113+
)
95114

96115
product = relationship("Product", back_populates="comments")
97-
user = relationship("User", back_populates="product_comments")
116+
user = relationship("User", back_populates="product_comments")
98117

99118
def __str__(self):
100-
return f"Comment by User ID: {self.user_id} on Product ID: {self.product_id}"
119+
return f"Comment by User ID: {self.user_id} on Product ID: {self.product_id}"

api/v1/routes/product.py

Lines changed: 136 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from api.utils.pagination import paginated_response
99
from api.utils.success_response import success_response
1010
from api.db.database import get_db
11-
from api.v1.models.product import Product, ProductFilterStatusEnum, ProductStatusEnum
11+
from api.v1.models.product import (
12+
Product,
13+
ProductFilterStatusEnum,
14+
ProductStatusEnum,
15+
)
1216
from api.v1.services.product import product_service, ProductCategoryService
1317
from api.v1.schemas.product import (
1418
ProductCategoryCreate,
@@ -30,13 +34,19 @@
3034
non_organisation_product = APIRouter(prefix="/products", tags=["Products"])
3135

3236

33-
@non_organisation_product.get("", response_model=success_response, status_code=200)
37+
@non_organisation_product.get(
38+
"", response_model=success_response, status_code=200
39+
)
3440
async def get_all_products(
35-
current_user: Annotated[User, Depends(user_service.get_current_super_admin)],
36-
limit: Annotated[int, Query(
37-
ge=1, description="Number of products per page")] = 10,
38-
skip: Annotated[int, Query(
39-
ge=1, description="Page number (starts from 1)")] = 0,
41+
current_user: Annotated[
42+
User, Depends(user_service.get_current_super_admin)
43+
],
44+
limit: Annotated[
45+
int, Query(ge=1, description="Number of products per page")
46+
] = 10,
47+
skip: Annotated[
48+
int, Query(ge=1, description="Page number (starts from 1)")
49+
] = 0,
4050
db: Session = Depends(get_db),
4151
):
4252
"""Endpoint to get all products. Only accessible to superadmin"""
@@ -45,7 +55,9 @@ async def get_all_products(
4555

4656

4757
# categories
48-
@non_organisation_product.post("/categories", status_code=status.HTTP_201_CREATED)
58+
@non_organisation_product.post(
59+
"/categories", status_code=status.HTTP_201_CREATED
60+
)
4961
def create_product_category(
5062
category_schema: ProductCategoryCreate,
5163
current_user: User = Depends(user_service.get_current_user),
@@ -65,7 +77,8 @@ def create_product_category(
6577
"""
6678

6779
new_category = ProductCategoryService.create(
68-
db, category_schema, current_user)
80+
db, category_schema, current_user
81+
)
6982

7083
return success_response(
7184
status_code=status.HTTP_201_CREATED,
@@ -101,8 +114,87 @@ def retrieve_categories(
101114
)
102115

103116

117+
@non_organisation_product.delete(
118+
"/categories/{category_name}", status_code=status.HTTP_204_NO_CONTENT
119+
)
120+
def soft_delete_product_category(
121+
category_name: str,
122+
current_user: User = Depends(user_service.get_current_user),
123+
db: Session = Depends(get_db),
124+
):
125+
"""Endpoint to soft delete a product category using its unique name.
126+
127+
This endpoint checks if the current user is an admin.
128+
If so, it marks the category as deleted (soft delete)
129+
rather than permanently removing it.
130+
131+
Args:
132+
category_name (str): The unique name of the product category to delete.
133+
current_user (User): The currently authenticated user.
134+
db (Session): The database session.
135+
136+
Returns:
137+
A success response with the soft-deleted product category data.
138+
"""
139+
user_service.require_admin(current_user)
140+
ProductCategoryService.soft_delete(db, category_name)
141+
return success_response(
142+
status_code=status.HTTP_204_NO_CONTENT,
143+
message="Category deleted successfully",
144+
)
145+
146+
147+
@non_organisation_product.patch(
148+
"/categories/{category_name}/restore", status_code=status.HTTP_200_OK
149+
)
150+
def restore_deleted_category(
151+
category_name: str,
152+
current_user: User = Depends(user_service.get_current_user),
153+
db: Session = Depends(get_db),
154+
):
155+
"""
156+
Endpoint to restore a soft-deleted product category using its name.
157+
158+
Checks if the current user is an admin and, if so, restores the
159+
category (sets is_deleted to False).
160+
"""
161+
user_service.require_admin(current_user)
162+
restored_category = ProductCategoryService.restore_deleted(
163+
db, category_name
164+
)
165+
return success_response(
166+
status_code=status.HTTP_200_OK,
167+
message="Category restored successfully",
168+
data=jsonable_encoder(restored_category),
169+
)
170+
171+
172+
@non_organisation_product.delete(
173+
"/categories/{category_name}/permanent",
174+
status_code=status.HTTP_204_NO_CONTENT,
175+
)
176+
def permanent_delete_product_category(
177+
category_name: str,
178+
current_user: User = Depends(user_service.get_current_user),
179+
db: Session = Depends(get_db),
180+
):
181+
"""
182+
Endpoint to permanently delete a product category using its unique name.
183+
184+
This endpoint checks if the current user is an admin. If so, it permanently
185+
removes the category from the database.
186+
"""
187+
user_service.require_admin(current_user)
188+
ProductCategoryService.permanent_delete(db, category_name)
189+
return success_response(
190+
status_code=status.HTTP_204_NO_CONTENT,
191+
message="Category permanently deleted successfully",
192+
)
193+
194+
104195
product = APIRouter(
105-
prefix="/organisations/{org_id}/products", tags=["Products"])
196+
prefix="/organisations/{org_id}/products", tags=["Products"]
197+
)
106198

107199

108200
# create
@@ -253,10 +345,12 @@ def delete_product(
253345
def get_organisation_products(
254346
org_id: str,
255347
current_user: Annotated[User, Depends(user_service.get_current_user)],
256-
limit: Annotated[int, Query(
257-
ge=1, description="Number of products per page")] = 10,
258-
page: Annotated[int, Query(
259-
ge=1, description="Page number (starts from 1)")] = 1,
348+
limit: Annotated[
349+
int, Query(ge=1, description="Number of products per page")
350+
] = 10,
351+
page: Annotated[
352+
int, Query(ge=1, description="Page number (starts from 1)")
353+
] = 1,
260354
db: Session = Depends(get_db),
261355
):
262356
"""
@@ -329,11 +423,14 @@ async def get_products_by_filter_status(
329423
db=db, org_id=org_id, filter_status=filter_status
330424
)
331425
return SuccessResponse(
332-
message="Products retrieved successfully", status_code=200, data=products
426+
message="Products retrieved successfully",
427+
status_code=200,
428+
data=products,
333429
)
334430
except Exception as e:
335431
raise HTTPException(
336-
status_code=500, detail="Failed to retrieve products")
432+
status_code=500, detail="Failed to retrieve products"
433+
)
337434

338435

339436
@product.get(
@@ -350,30 +447,41 @@ async def get_products_by_status(
350447
"""Endpoint to get products by status"""
351448
try:
352449
products = product_service.fetch_by_status(
353-
db=db, org_id=org_id, status=status)
450+
db=db, org_id=org_id, status=status
451+
)
354452
return SuccessResponse(
355-
message="Products retrieved successfully", status_code=200, data=products
453+
message="Products retrieved successfully",
454+
status_code=200,
455+
data=products,
356456
)
357457
except Exception as e:
358458
raise HTTPException(
359-
status_code=500, detail="Failed to retrieve products")
459+
status_code=500, detail="Failed to retrieve products"
460+
)
360461

361462

362-
@product.get("/search", status_code=status.HTTP_200_OK, response_model=ProductList)
463+
@product.get(
464+
"/search", status_code=status.HTTP_200_OK, response_model=ProductList
465+
)
363466
def search_products(
364467
org_id: str,
365468
name: Optional[str] = Query(None, description="Search by product name"),
366469
category: Optional[str] = Query(None, description="Filter by category"),
367470
min_price: Optional[float] = Query(
368-
None, description="Filter by minimum price"),
471+
None, description="Filter by minimum price"
472+
),
369473
max_price: Optional[float] = Query(
370-
None, description="Filter by maximum price"),
371-
limit: Annotated[int, Query(
372-
ge=1, description="Number of products per page")] = 10,
373-
page: Annotated[int, Query(
374-
ge=1, description="Page number (starts from 1)")] = 1,
375-
current_user: Annotated[User, Depends(
376-
user_service.get_current_user)] = None,
474+
None, description="Filter by maximum price"
475+
),
476+
limit: Annotated[
477+
int, Query(ge=1, description="Number of products per page")
478+
] = 10,
479+
page: Annotated[
480+
int, Query(ge=1, description="Page number (starts from 1)")
481+
] = 1,
482+
current_user: Annotated[
483+
User, Depends(user_service.get_current_user)
484+
] = None,
377485
db: Session = Depends(get_db),
378486
):
379487
"""

0 commit comments

Comments
 (0)