314 lines
10 KiB
Python

import logging
import os
import re
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.dependencies.auth import get_current_admin
from app.models.category import Category
from app.models.user import User
from app.schemas.category import (
Category as CategorySchema,
)
from app.schemas.category import (
CategoryCreate,
CategoryUpdate,
CategoryWithChildren,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def slugify(text):
"""Convert a string to a URL-friendly slug."""
# Remove non-alphanumeric characters
text = re.sub(r'[^\w\s-]', '', text.lower())
# Replace spaces with hyphens
text = re.sub(r'[\s]+', '-', text)
# Remove consecutive hyphens
text = re.sub(r'[-]+', '-', text)
# Add timestamp to ensure uniqueness
timestamp = int(datetime.now().timestamp())
return f"{text}-{timestamp}"
def count_products_in_category(category, include_subcategories=True):
"""Count products in a category and optionally in its subcategories."""
product_count = len(category.products)
if include_subcategories:
for subcategory in category.subcategories:
product_count += count_products_in_category(subcategory)
return product_count
def build_category_tree(categories, parent_id=None):
"""Recursively build a category tree structure."""
tree = []
for category in categories:
if category.parent_id == parent_id:
# Convert to CategoryWithChildren schema
category_dict = {
"id": category.id,
"name": category.name,
"slug": category.slug,
"description": category.description,
"image": category.image,
"parent_id": category.parent_id,
"is_active": category.is_active,
"display_order": category.display_order,
"created_at": category.created_at,
"updated_at": category.updated_at,
"subcategories": build_category_tree(categories, category.id),
"product_count": count_products_in_category(category)
}
tree.append(category_dict)
# Sort by display_order
tree.sort(key=lambda x: x["display_order"])
return tree
@router.get("/", response_model=list[CategorySchema])
async def get_categories(
skip: int = 0,
limit: int = 100,
active_only: bool = True,
db: Session = Depends(get_db)
):
"""
Get all categories.
"""
query = db.query(Category)
if active_only:
query = query.filter(Category.is_active == True)
categories = query.order_by(Category.display_order).offset(skip).limit(limit).all()
return categories
@router.get("/tree", response_model=list[CategoryWithChildren])
async def get_category_tree(
active_only: bool = True,
db: Session = Depends(get_db)
):
"""
Get categories in a hierarchical tree structure.
"""
query = db.query(Category)
if active_only:
query = query.filter(Category.is_active == True)
categories = query.all()
tree = build_category_tree(categories)
return tree
@router.get("/{category_id}", response_model=CategorySchema)
async def get_category(
category_id: str,
db: Session = Depends(get_db)
):
"""
Get a specific category by ID.
"""
category = db.query(Category).filter(Category.id == category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
return category
@router.post("/", response_model=CategorySchema)
async def create_category(
category_in: CategoryCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Create a new category (admin only).
"""
# Check if slug already exists
existing_category = db.query(Category).filter(Category.slug == category_in.slug).first()
if existing_category:
# If slug exists, create a unique one
category_in.slug = slugify(category_in.name)
# Check if parent category exists (if a parent is specified)
if category_in.parent_id:
parent_category = db.query(Category).filter(Category.id == category_in.parent_id).first()
if not parent_category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Parent category not found"
)
# Create new category
db_category = Category(
name=category_in.name,
slug=category_in.slug,
description=category_in.description,
image=category_in.image,
parent_id=category_in.parent_id,
is_active=category_in.is_active,
display_order=category_in.display_order
)
db.add(db_category)
db.commit()
db.refresh(db_category)
logger.info(f"Category created: {db_category.name} (ID: {db_category.id})")
return db_category
@router.put("/{category_id}", response_model=CategorySchema)
async def update_category(
category_id: str,
category_in: CategoryUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Update a category (admin only).
"""
category = db.query(Category).filter(Category.id == category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# Check if slug already exists (if updating slug)
if category_in.slug and category_in.slug != category.slug:
existing_category = db.query(Category).filter(Category.slug == category_in.slug).first()
if existing_category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Slug already exists"
)
# Check if parent category exists (if updating parent)
if category_in.parent_id and category_in.parent_id != category.parent_id:
# Prevent setting a category as its own parent
if category_in.parent_id == category_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category cannot be its own parent"
)
parent_category = db.query(Category).filter(Category.id == category_in.parent_id).first()
if not parent_category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Parent category not found"
)
# Prevent circular references
current_parent = parent_category
while current_parent:
if current_parent.id == category_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Circular reference in category hierarchy"
)
current_parent = db.query(Category).filter(Category.id == current_parent.parent_id).first()
# Update category attributes
for key, value in category_in.dict(exclude_unset=True).items():
setattr(category, key, value)
db.commit()
db.refresh(category)
logger.info(f"Category updated: {category.name} (ID: {category.id})")
return category
@router.delete("/{category_id}", response_model=dict)
async def delete_category(
category_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Delete a category (admin only).
"""
category = db.query(Category).filter(Category.id == category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# Check if category has products
if category.products:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete category with products. Remove products first or reassign them."
)
# Check if category has subcategories
if category.subcategories:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete category with subcategories. Delete subcategories first."
)
# Delete the category
db.delete(category)
db.commit()
logger.info(f"Category deleted: {category.name} (ID: {category.id})")
return {"message": "Category successfully deleted"}
@router.post("/{category_id}/image", response_model=CategorySchema)
async def upload_category_image(
category_id: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Upload an image for a category (admin only).
"""
category = db.query(Category).filter(Category.id == category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# Validate file
content_type = file.content_type
if not content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Create category images directory
category_images_dir = settings.STORAGE_DIR / "category_images"
category_images_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = category_images_dir / unique_filename
# Save the file
with open(file_path, "wb") as buffer:
buffer.write(await file.read())
# Update the category's image in the database
relative_path = f"/storage/category_images/{unique_filename}"
category.image = relative_path
db.commit()
db.refresh(category)
logger.info(f"Image uploaded for category ID {category_id}: {unique_filename}")
return category