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