2760 lines
98 KiB
Python
2760 lines
98 KiB
Python
|
|
from fastapi import FastAPI, APIRouter, HTTPException, Depends, status, Query, Response, UploadFile, File, Form
|
||
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||
|
|
from fastapi.responses import StreamingResponse
|
||
|
|
from fastapi.staticfiles import StaticFiles
|
||
|
|
from dotenv import load_dotenv
|
||
|
|
from starlette.middleware.cors import CORSMiddleware
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
from sqlalchemy import select, func, and_, or_, desc, asc, distinct, delete
|
||
|
|
from sqlalchemy.orm import selectinload
|
||
|
|
import os
|
||
|
|
import logging
|
||
|
|
from pathlib import Path
|
||
|
|
from pydantic import BaseModel, Field, EmailStr, ConfigDict
|
||
|
|
from typing import List, Optional, Dict, Any
|
||
|
|
import uuid
|
||
|
|
from datetime import datetime, timezone, timedelta
|
||
|
|
import bcrypt
|
||
|
|
import jwt
|
||
|
|
import io
|
||
|
|
import csv
|
||
|
|
import base64
|
||
|
|
import shutil
|
||
|
|
from contextlib import asynccontextmanager
|
||
|
|
from reportlab.lib import colors
|
||
|
|
from reportlab.lib.pagesizes import letter, A4
|
||
|
|
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
||
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||
|
|
from reportlab.lib.units import inch
|
||
|
|
|
||
|
|
from database import get_db, init_db, AsyncSessionLocal
|
||
|
|
from models import (
|
||
|
|
User, Product, Service, CartItem, Order, OrderItem, OrderStatusHistory,
|
||
|
|
Review, Booking, Contact, InventoryLog, Category, SalesReport,
|
||
|
|
OrderStatus, UserRole, Base, ProductImage, ServiceImage,
|
||
|
|
AboutContent, TeamMember, CompanyValue
|
||
|
|
)
|
||
|
|
|
||
|
|
ROOT_DIR = Path(__file__).parent
|
||
|
|
load_dotenv(ROOT_DIR / '.env')
|
||
|
|
|
||
|
|
# Create uploads directory for images
|
||
|
|
UPLOAD_DIR = ROOT_DIR / 'uploads' / 'products'
|
||
|
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
# JWT Configuration
|
||
|
|
SECRET_KEY = os.environ.get('JWT_SECRET', 'techzone-super-secret-key-2024-production')
|
||
|
|
ALGORITHM = "HS256"
|
||
|
|
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||
|
|
|
||
|
|
# Configure logging
|
||
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
# Lifespan event handler for FastAPI
|
||
|
|
@asynccontextmanager
|
||
|
|
async def lifespan(app: FastAPI):
|
||
|
|
"""Initialize database with error handling and verification"""
|
||
|
|
try:
|
||
|
|
await init_db()
|
||
|
|
logger.info("Database initialized successfully")
|
||
|
|
|
||
|
|
# Verify database connection
|
||
|
|
try:
|
||
|
|
async with AsyncSessionLocal() as session:
|
||
|
|
result = await session.execute(select(func.count(User.id)))
|
||
|
|
user_count = result.scalar()
|
||
|
|
logger.info(f"Database connection verified - {user_count} users found")
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Database verification failed: {e}")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.critical(f"Database initialization failed: {e}", exc_info=True)
|
||
|
|
|
||
|
|
yield
|
||
|
|
|
||
|
|
# Cleanup on shutdown (if needed)
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Create the main app with lifespan
|
||
|
|
app = FastAPI(title="TechZone API", version="2.0.0", lifespan=lifespan)
|
||
|
|
|
||
|
|
# Create a router with the /api prefix
|
||
|
|
api_router = APIRouter(prefix="/api")
|
||
|
|
|
||
|
|
security = HTTPBearer()
|
||
|
|
|
||
|
|
# ================== PYDANTIC MODELS ==================
|
||
|
|
|
||
|
|
class UserCreate(BaseModel):
|
||
|
|
email: EmailStr
|
||
|
|
name: str
|
||
|
|
password: str
|
||
|
|
|
||
|
|
class UserLogin(BaseModel):
|
||
|
|
email: EmailStr
|
||
|
|
password: str
|
||
|
|
|
||
|
|
class TokenResponse(BaseModel):
|
||
|
|
access_token: str
|
||
|
|
token_type: str = "bearer"
|
||
|
|
user: dict
|
||
|
|
|
||
|
|
class ProductCreate(BaseModel):
|
||
|
|
name: str
|
||
|
|
description: str # Now supports HTML from rich text editor
|
||
|
|
price: float
|
||
|
|
category: str
|
||
|
|
image_url: str = "" # Optional - for backwards compatibility
|
||
|
|
stock: int = 10
|
||
|
|
low_stock_threshold: int = 5
|
||
|
|
brand: str = ""
|
||
|
|
specs: dict = {}
|
||
|
|
images: List[str] = [] # List of image URLs
|
||
|
|
|
||
|
|
class ProductUpdate(BaseModel):
|
||
|
|
name: Optional[str] = None
|
||
|
|
description: Optional[str] = None # Supports HTML
|
||
|
|
price: Optional[float] = None
|
||
|
|
category: Optional[str] = None
|
||
|
|
image_url: Optional[str] = None
|
||
|
|
stock: Optional[int] = None
|
||
|
|
low_stock_threshold: Optional[int] = None
|
||
|
|
brand: Optional[str] = None
|
||
|
|
specs: Optional[dict] = None
|
||
|
|
is_active: Optional[bool] = None
|
||
|
|
images: Optional[List[str]] = None # List of image URLs
|
||
|
|
|
||
|
|
class CategoryCreate(BaseModel):
|
||
|
|
name: str
|
||
|
|
slug: Optional[str] = None
|
||
|
|
description: Optional[str] = ""
|
||
|
|
|
||
|
|
class CategoryUpdate(BaseModel):
|
||
|
|
name: Optional[str] = None
|
||
|
|
slug: Optional[str] = None
|
||
|
|
description: Optional[str] = None
|
||
|
|
|
||
|
|
class ServiceCreate(BaseModel):
|
||
|
|
name: str
|
||
|
|
description: str
|
||
|
|
price: float
|
||
|
|
duration: str
|
||
|
|
image_url: Optional[str] = "" # Deprecated
|
||
|
|
category: str
|
||
|
|
images: List[str] = []
|
||
|
|
|
||
|
|
class ServiceUpdate(BaseModel):
|
||
|
|
name: Optional[str] = None
|
||
|
|
description: Optional[str] = None
|
||
|
|
price: Optional[float] = None
|
||
|
|
duration: Optional[str] = None
|
||
|
|
image_url: Optional[str] = None # Deprecated
|
||
|
|
category: Optional[str] = None
|
||
|
|
images: Optional[List[str]] = None
|
||
|
|
category: Optional[str] = None
|
||
|
|
images: Optional[List[str]] = None
|
||
|
|
is_active: Optional[bool] = None
|
||
|
|
|
||
|
|
class CartItemCreate(BaseModel):
|
||
|
|
product_id: str
|
||
|
|
quantity: int = 1
|
||
|
|
|
||
|
|
class OrderCreate(BaseModel):
|
||
|
|
shipping_address: dict = {}
|
||
|
|
notes: str = ""
|
||
|
|
|
||
|
|
class OrderStatusUpdate(BaseModel):
|
||
|
|
status: str
|
||
|
|
notes: str = ""
|
||
|
|
tracking_number: Optional[str] = None
|
||
|
|
|
||
|
|
class ReviewCreate(BaseModel):
|
||
|
|
product_id: Optional[str] = None
|
||
|
|
service_id: Optional[str] = None
|
||
|
|
rating: int
|
||
|
|
title: str = ""
|
||
|
|
comment: str = ""
|
||
|
|
|
||
|
|
class BookingCreate(BaseModel):
|
||
|
|
service_id: str
|
||
|
|
name: str
|
||
|
|
email: EmailStr
|
||
|
|
phone: str
|
||
|
|
preferred_date: str
|
||
|
|
notes: str = ""
|
||
|
|
|
||
|
|
class ContactCreate(BaseModel):
|
||
|
|
name: str
|
||
|
|
email: EmailStr
|
||
|
|
subject: str
|
||
|
|
message: str
|
||
|
|
|
||
|
|
class InventoryAdjust(BaseModel):
|
||
|
|
quantity_change: int
|
||
|
|
notes: str = ""
|
||
|
|
|
||
|
|
# ================== HELPERS ==================
|
||
|
|
|
||
|
|
def hash_password(password: str) -> str:
|
||
|
|
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||
|
|
|
||
|
|
def verify_password(password: str, hashed: str) -> bool:
|
||
|
|
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
||
|
|
|
||
|
|
def create_access_token(data: dict) -> str:
|
||
|
|
to_encode = data.copy()
|
||
|
|
expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||
|
|
to_encode.update({"exp": expire})
|
||
|
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||
|
|
|
||
|
|
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db)):
|
||
|
|
try:
|
||
|
|
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
||
|
|
user_id = payload.get("sub")
|
||
|
|
if user_id is None:
|
||
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
||
|
|
user = result.scalar_one_or_none()
|
||
|
|
if user is None:
|
||
|
|
raise HTTPException(status_code=401, detail="User not found")
|
||
|
|
return user
|
||
|
|
except jwt.ExpiredSignatureError:
|
||
|
|
raise HTTPException(status_code=401, detail="Token expired")
|
||
|
|
except jwt.InvalidTokenError:
|
||
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||
|
|
|
||
|
|
async def get_admin_user(user: User = Depends(get_current_user)):
|
||
|
|
"""Verify user has admin role with logging"""
|
||
|
|
if not user:
|
||
|
|
logger.warning("Admin access attempted with no user")
|
||
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||
|
|
|
||
|
|
if user.role != UserRole.ADMIN:
|
||
|
|
logger.warning(f"Non-admin user {user.id} attempted admin access")
|
||
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||
|
|
|
||
|
|
logger.debug(f"Admin access granted to user {user.id}")
|
||
|
|
return user
|
||
|
|
|
||
|
|
async def get_optional_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)), db: AsyncSession = Depends(get_db)):
|
||
|
|
if credentials is None:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
||
|
|
user_id = payload.get("sub")
|
||
|
|
if user_id:
|
||
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
||
|
|
return result.scalar_one_or_none()
|
||
|
|
except:
|
||
|
|
pass
|
||
|
|
return None
|
||
|
|
|
||
|
|
# ================== CRUD HELPERS ==================
|
||
|
|
|
||
|
|
async def _get_or_404(db: AsyncSession, model, record_id: str, error_message: str = "Record not found"):
|
||
|
|
"""Generic helper to fetch a record by ID or raise 404"""
|
||
|
|
result = await db.execute(select(model).where(model.id == record_id))
|
||
|
|
record = result.scalar_one_or_none()
|
||
|
|
if not record:
|
||
|
|
raise HTTPException(status_code=404, detail=error_message)
|
||
|
|
return record
|
||
|
|
|
||
|
|
async def _soft_delete(db: AsyncSession, record, commit: bool = True):
|
||
|
|
"""Generic helper for soft delete (set is_active=False)"""
|
||
|
|
record.is_active = False
|
||
|
|
if commit:
|
||
|
|
await db.commit()
|
||
|
|
return {"message": f"{record.__class__.__name__} deleted"}
|
||
|
|
|
||
|
|
def _build_response(message: str, **kwargs):
|
||
|
|
"""Build standardized API response"""
|
||
|
|
response = {"message": message}
|
||
|
|
response.update(kwargs)
|
||
|
|
return response
|
||
|
|
|
||
|
|
# ================== SERIALIZATION HELPERS ==================
|
||
|
|
|
||
|
|
def _safe_isoformat(dt) -> Optional[str]:
|
||
|
|
"""Safely convert datetime to ISO format"""
|
||
|
|
return dt.isoformat() if dt else None
|
||
|
|
|
||
|
|
def _safe_enum_value(enum_val, default="pending") -> str:
|
||
|
|
"""Safely extract enum value"""
|
||
|
|
return enum_val.value if enum_val else default
|
||
|
|
|
||
|
|
def _calculate_reviews_stats(reviews: list) -> dict:
|
||
|
|
"""Calculate review statistics"""
|
||
|
|
if not reviews:
|
||
|
|
return {"average_rating": 0, "review_count": 0}
|
||
|
|
return {
|
||
|
|
"average_rating": sum(r.rating for r in reviews) / len(reviews),
|
||
|
|
"review_count": len(reviews)
|
||
|
|
}
|
||
|
|
|
||
|
|
def user_to_dict(user: User) -> dict:
|
||
|
|
return {
|
||
|
|
"id": user.id,
|
||
|
|
"email": user.email,
|
||
|
|
"name": user.name,
|
||
|
|
"role": _safe_enum_value(user.role, "user"),
|
||
|
|
"is_active": getattr(user, 'is_active', True),
|
||
|
|
"created_at": _safe_isoformat(user.created_at)
|
||
|
|
}
|
||
|
|
|
||
|
|
def product_to_dict(product: Product, include_reviews: bool = False) -> dict:
|
||
|
|
# Get images if available
|
||
|
|
images = []
|
||
|
|
if hasattr(product, 'images') and product.images:
|
||
|
|
images = [
|
||
|
|
{
|
||
|
|
"id": img.id,
|
||
|
|
"url": img.image_url,
|
||
|
|
"display_order": img.display_order,
|
||
|
|
"is_primary": img.is_primary
|
||
|
|
}
|
||
|
|
for img in sorted(product.images, key=lambda x: x.display_order)
|
||
|
|
]
|
||
|
|
|
||
|
|
data = {
|
||
|
|
"id": product.id,
|
||
|
|
"name": product.name,
|
||
|
|
"description": product.description, # Contains HTML from rich text editor
|
||
|
|
"price": product.price,
|
||
|
|
"category": product.category,
|
||
|
|
"image_url": product.image_url or (images[0]["url"] if images else ""), # Fallback to first image
|
||
|
|
"images": images, # New: array of all images
|
||
|
|
"stock": product.stock,
|
||
|
|
"low_stock_threshold": product.low_stock_threshold,
|
||
|
|
"brand": product.brand,
|
||
|
|
"specs": product.specs or {},
|
||
|
|
"is_active": product.is_active,
|
||
|
|
"created_at": _safe_isoformat(product.created_at)
|
||
|
|
}
|
||
|
|
if include_reviews and product.reviews:
|
||
|
|
data["reviews"] = [review_to_dict(r) for r in product.reviews]
|
||
|
|
data.update(_calculate_reviews_stats(product.reviews))
|
||
|
|
return data
|
||
|
|
|
||
|
|
def service_to_dict(service: Service, include_reviews: bool = False) -> dict:
|
||
|
|
# Get images if available
|
||
|
|
images = []
|
||
|
|
if hasattr(service, 'images') and service.images:
|
||
|
|
images = [
|
||
|
|
{
|
||
|
|
"id": img.id,
|
||
|
|
"url": img.image_url,
|
||
|
|
"display_order": img.display_order,
|
||
|
|
"is_primary": img.is_primary
|
||
|
|
}
|
||
|
|
for img in sorted(service.images, key=lambda x: x.display_order)
|
||
|
|
]
|
||
|
|
|
||
|
|
# Set primary image_url for backwards compatibility
|
||
|
|
primary_image = images[0]["url"] if images else service.image_url
|
||
|
|
|
||
|
|
data = {
|
||
|
|
"id": service.id,
|
||
|
|
"name": service.name,
|
||
|
|
"description": service.description,
|
||
|
|
"price": service.price,
|
||
|
|
"duration": service.duration,
|
||
|
|
"image_url": primary_image,
|
||
|
|
"images": images,
|
||
|
|
"category": service.category,
|
||
|
|
"is_active": service.is_active,
|
||
|
|
"created_at": _safe_isoformat(service.created_at)
|
||
|
|
}
|
||
|
|
if include_reviews and service.reviews:
|
||
|
|
data["reviews"] = [review_to_dict(r) for r in service.reviews]
|
||
|
|
data.update(_calculate_reviews_stats(service.reviews))
|
||
|
|
return data
|
||
|
|
|
||
|
|
def order_to_dict(order: Order) -> dict:
|
||
|
|
return {
|
||
|
|
"id": order.id,
|
||
|
|
"user_id": order.user_id,
|
||
|
|
"status": _safe_enum_value(order.status),
|
||
|
|
"subtotal": order.subtotal,
|
||
|
|
"tax": order.tax,
|
||
|
|
"shipping": order.shipping,
|
||
|
|
"total": order.total,
|
||
|
|
"shipping_address": order.shipping_address or {},
|
||
|
|
"notes": order.notes,
|
||
|
|
"tracking_number": order.tracking_number,
|
||
|
|
"created_at": _safe_isoformat(order.created_at),
|
||
|
|
"updated_at": _safe_isoformat(order.updated_at),
|
||
|
|
"items": [order_item_to_dict(item) for item in order.items] if order.items else [],
|
||
|
|
"status_history": [status_history_to_dict(h) for h in order.status_history] if order.status_history else []
|
||
|
|
}
|
||
|
|
|
||
|
|
def order_item_to_dict(item: OrderItem) -> dict:
|
||
|
|
return {
|
||
|
|
"id": item.id,
|
||
|
|
"product_id": item.product_id,
|
||
|
|
"product_name": item.product_name,
|
||
|
|
"product_image": item.product_image,
|
||
|
|
"quantity": item.quantity,
|
||
|
|
"price": item.price
|
||
|
|
}
|
||
|
|
|
||
|
|
def status_history_to_dict(history: OrderStatusHistory) -> dict:
|
||
|
|
return {
|
||
|
|
"id": history.id,
|
||
|
|
"status": _safe_enum_value(history.status, None),
|
||
|
|
"notes": history.notes,
|
||
|
|
"created_at": _safe_isoformat(history.created_at)
|
||
|
|
}
|
||
|
|
|
||
|
|
def review_to_dict(review: Review) -> dict:
|
||
|
|
return {
|
||
|
|
"id": review.id,
|
||
|
|
"user_id": review.user_id,
|
||
|
|
"user_name": review.user.name if review.user else "Anonymous",
|
||
|
|
"product_id": review.product_id,
|
||
|
|
"service_id": review.service_id,
|
||
|
|
"rating": review.rating,
|
||
|
|
"title": review.title,
|
||
|
|
"comment": review.comment,
|
||
|
|
"is_verified_purchase": review.is_verified_purchase,
|
||
|
|
"created_at": _safe_isoformat(review.created_at)
|
||
|
|
}
|
||
|
|
|
||
|
|
def booking_to_dict(booking: Booking) -> dict:
|
||
|
|
return {
|
||
|
|
"id": booking.id,
|
||
|
|
"service_id": booking.service_id,
|
||
|
|
"service_name": booking.service_name,
|
||
|
|
"name": booking.name,
|
||
|
|
"email": booking.email,
|
||
|
|
"phone": booking.phone,
|
||
|
|
"preferred_date": booking.preferred_date,
|
||
|
|
"notes": booking.notes,
|
||
|
|
"status": booking.status,
|
||
|
|
"created_at": booking.created_at.isoformat() if booking.created_at else None
|
||
|
|
}
|
||
|
|
|
||
|
|
# ================== AUTH ROUTES ==================
|
||
|
|
|
||
|
|
@api_router.post("/auth/register", response_model=TokenResponse)
|
||
|
|
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(select(User).where(User.email == user_data.email))
|
||
|
|
if result.scalar_one_or_none():
|
||
|
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||
|
|
|
||
|
|
user = User(
|
||
|
|
email=user_data.email,
|
||
|
|
name=user_data.name,
|
||
|
|
password=hash_password(user_data.password),
|
||
|
|
role=UserRole.USER
|
||
|
|
)
|
||
|
|
db.add(user)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(user)
|
||
|
|
|
||
|
|
token = create_access_token({"sub": user.id})
|
||
|
|
return TokenResponse(access_token=token, user=user_to_dict(user))
|
||
|
|
|
||
|
|
@api_router.post("/auth/login", response_model=TokenResponse)
|
||
|
|
async def login(credentials: UserLogin, db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(select(User).where(User.email == credentials.email))
|
||
|
|
user = result.scalar_one_or_none()
|
||
|
|
if not user or not verify_password(credentials.password, user.password):
|
||
|
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||
|
|
|
||
|
|
token = create_access_token({"sub": user.id})
|
||
|
|
return TokenResponse(access_token=token, user=user_to_dict(user))
|
||
|
|
|
||
|
|
@api_router.get("/auth/me")
|
||
|
|
async def get_me(user: User = Depends(get_current_user)):
|
||
|
|
return user_to_dict(user)
|
||
|
|
|
||
|
|
# ================== PRODUCTS ROUTES ==================
|
||
|
|
|
||
|
|
@api_router.get("/products")
|
||
|
|
async def get_products(
|
||
|
|
response: Response,
|
||
|
|
category: Optional[str] = None,
|
||
|
|
search: Optional[str] = None,
|
||
|
|
min_price: Optional[float] = None,
|
||
|
|
max_price: Optional[float] = None,
|
||
|
|
in_stock: Optional[bool] = None,
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
# Add cache headers for better performance
|
||
|
|
response.headers["Cache-Control"] = "public, max-age=60" # Cache for 60 seconds
|
||
|
|
|
||
|
|
query = select(Product).where(Product.is_active == True).options(selectinload(Product.images))
|
||
|
|
|
||
|
|
if category and category != "all":
|
||
|
|
query = query.where(Product.category == category)
|
||
|
|
if search:
|
||
|
|
query = query.where(
|
||
|
|
or_(
|
||
|
|
Product.name.ilike(f"%{search}%"),
|
||
|
|
Product.description.ilike(f"%{search}%"),
|
||
|
|
Product.brand.ilike(f"%{search}%")
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if min_price is not None:
|
||
|
|
query = query.where(Product.price >= min_price)
|
||
|
|
if max_price is not None:
|
||
|
|
query = query.where(Product.price <= max_price)
|
||
|
|
if in_stock:
|
||
|
|
query = query.where(Product.stock > 0)
|
||
|
|
|
||
|
|
query = query.options(selectinload(Product.reviews).selectinload(Review.user))
|
||
|
|
result = await db.execute(query)
|
||
|
|
products = result.scalars().all()
|
||
|
|
return [product_to_dict(p, include_reviews=True) for p in products]
|
||
|
|
|
||
|
|
@api_router.get("/products/{product_id}")
|
||
|
|
async def get_product(product_id: str, db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(Product)
|
||
|
|
.where(Product.id == product_id)
|
||
|
|
.options(
|
||
|
|
selectinload(Product.images),
|
||
|
|
selectinload(Product.reviews).selectinload(Review.user)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
product = result.scalar_one_or_none()
|
||
|
|
if not product:
|
||
|
|
raise HTTPException(status_code=404, detail="Product not found")
|
||
|
|
return product_to_dict(product, include_reviews=True)
|
||
|
|
|
||
|
|
@api_router.get("/products/categories/list")
|
||
|
|
async def get_product_categories(db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(select(Product.category).distinct())
|
||
|
|
categories = [row[0] for row in result.fetchall()]
|
||
|
|
return categories
|
||
|
|
|
||
|
|
# ================== SERVICES ROUTES ==================
|
||
|
|
|
||
|
|
@api_router.get("/services")
|
||
|
|
async def get_services(
|
||
|
|
response: Response,
|
||
|
|
category: Optional[str] = None,
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
# Add cache headers for better performance
|
||
|
|
response.headers["Cache-Control"] = "public, max-age=60" # Cache for 60 seconds
|
||
|
|
|
||
|
|
query = select(Service).where(Service.is_active == True).options(selectinload(Service.images))
|
||
|
|
if category and category != "all":
|
||
|
|
query = query.where(Service.category == category)
|
||
|
|
query = query.options(selectinload(Service.reviews).selectinload(Review.user))
|
||
|
|
result = await db.execute(query)
|
||
|
|
services = result.scalars().all()
|
||
|
|
return [service_to_dict(s, include_reviews=True) for s in services]
|
||
|
|
|
||
|
|
@api_router.get("/services/{service_id}")
|
||
|
|
async def get_service(service_id: str, db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(Service)
|
||
|
|
.where(Service.id == service_id)
|
||
|
|
.options(
|
||
|
|
selectinload(Service.images),
|
||
|
|
selectinload(Service.reviews).selectinload(Review.user)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
service = result.scalar_one_or_none()
|
||
|
|
if not service:
|
||
|
|
raise HTTPException(status_code=404, detail="Service not found")
|
||
|
|
return service_to_dict(service, include_reviews=True)
|
||
|
|
|
||
|
|
@api_router.post("/services/book")
|
||
|
|
async def book_service(
|
||
|
|
booking_data: BookingCreate,
|
||
|
|
user: Optional[User] = Depends(get_optional_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
result = await db.execute(select(Service).where(Service.id == booking_data.service_id))
|
||
|
|
service = result.scalar_one_or_none()
|
||
|
|
if not service:
|
||
|
|
raise HTTPException(status_code=404, detail="Service not found")
|
||
|
|
|
||
|
|
booking = Booking(
|
||
|
|
service_id=booking_data.service_id,
|
||
|
|
user_id=user.id if user else None,
|
||
|
|
name=booking_data.name,
|
||
|
|
email=booking_data.email,
|
||
|
|
phone=booking_data.phone,
|
||
|
|
preferred_date=booking_data.preferred_date,
|
||
|
|
notes=booking_data.notes,
|
||
|
|
service_name=service.name
|
||
|
|
)
|
||
|
|
db.add(booking)
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Booking created successfully", "booking_id": booking.id}
|
||
|
|
|
||
|
|
# ================== CART ROUTES ==================
|
||
|
|
|
||
|
|
@api_router.get("/cart")
|
||
|
|
async def get_cart(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(CartItem)
|
||
|
|
.where(CartItem.user_id == user.id)
|
||
|
|
.options(
|
||
|
|
selectinload(CartItem.product).selectinload(Product.images)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
cart_items = result.scalars().all()
|
||
|
|
return [{
|
||
|
|
"id": item.id,
|
||
|
|
"product_id": item.product_id,
|
||
|
|
"quantity": item.quantity,
|
||
|
|
"product": product_to_dict(item.product) if item.product else None
|
||
|
|
} for item in cart_items]
|
||
|
|
|
||
|
|
@api_router.post("/cart/add")
|
||
|
|
async def add_to_cart(item: CartItemCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(select(Product).where(Product.id == item.product_id))
|
||
|
|
product = result.scalar_one_or_none()
|
||
|
|
if not product:
|
||
|
|
raise HTTPException(status_code=404, detail="Product not found")
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(CartItem).where(
|
||
|
|
and_(CartItem.user_id == user.id, CartItem.product_id == item.product_id)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
existing = result.scalar_one_or_none()
|
||
|
|
|
||
|
|
if existing:
|
||
|
|
existing.quantity += item.quantity
|
||
|
|
else:
|
||
|
|
cart_item = CartItem(user_id=user.id, product_id=item.product_id, quantity=item.quantity)
|
||
|
|
db.add(cart_item)
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Item added to cart"}
|
||
|
|
|
||
|
|
@api_router.put("/cart/{item_id}")
|
||
|
|
async def update_cart_item(item_id: str, quantity: int = Query(...), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(CartItem).where(and_(CartItem.id == item_id, CartItem.user_id == user.id))
|
||
|
|
)
|
||
|
|
item = result.scalar_one_or_none()
|
||
|
|
if not item:
|
||
|
|
raise HTTPException(status_code=404, detail="Cart item not found")
|
||
|
|
|
||
|
|
if quantity <= 0:
|
||
|
|
await db.delete(item)
|
||
|
|
else:
|
||
|
|
item.quantity = quantity
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Cart updated"}
|
||
|
|
|
||
|
|
@api_router.delete("/cart/{item_id}")
|
||
|
|
async def remove_from_cart(item_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(CartItem).where(and_(CartItem.id == item_id, CartItem.user_id == user.id))
|
||
|
|
)
|
||
|
|
item = result.scalar_one_or_none()
|
||
|
|
if item:
|
||
|
|
await db.delete(item)
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Item removed from cart"}
|
||
|
|
|
||
|
|
@api_router.delete("/cart")
|
||
|
|
async def clear_cart(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
await db.execute(
|
||
|
|
CartItem.__table__.delete().where(CartItem.user_id == user.id)
|
||
|
|
)
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Cart cleared"}
|
||
|
|
|
||
|
|
# ================== ORDERS ROUTES ==================
|
||
|
|
|
||
|
|
@api_router.post("/orders")
|
||
|
|
async def create_order(order_data: OrderCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
# Get cart items
|
||
|
|
result = await db.execute(
|
||
|
|
select(CartItem)
|
||
|
|
.where(CartItem.user_id == user.id)
|
||
|
|
.options(selectinload(CartItem.product))
|
||
|
|
)
|
||
|
|
cart_items = result.scalars().all()
|
||
|
|
|
||
|
|
if not cart_items:
|
||
|
|
raise HTTPException(status_code=400, detail="Cart is empty")
|
||
|
|
|
||
|
|
# Calculate totals
|
||
|
|
subtotal = sum(item.product.price * item.quantity for item in cart_items)
|
||
|
|
tax = subtotal * 0.08
|
||
|
|
shipping = 0 if subtotal > 100 else 9.99
|
||
|
|
total = subtotal + tax + shipping
|
||
|
|
|
||
|
|
# Create order
|
||
|
|
order = Order(
|
||
|
|
user_id=user.id,
|
||
|
|
status=OrderStatus.PENDING,
|
||
|
|
subtotal=subtotal,
|
||
|
|
tax=tax,
|
||
|
|
shipping=shipping,
|
||
|
|
total=total,
|
||
|
|
shipping_address=order_data.shipping_address,
|
||
|
|
notes=order_data.notes
|
||
|
|
)
|
||
|
|
db.add(order)
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
# Create order items and update inventory
|
||
|
|
for cart_item in cart_items:
|
||
|
|
product = cart_item.product
|
||
|
|
order_item = OrderItem(
|
||
|
|
order_id=order.id,
|
||
|
|
product_id=product.id,
|
||
|
|
quantity=cart_item.quantity,
|
||
|
|
price=product.price,
|
||
|
|
product_name=product.name,
|
||
|
|
product_image=product.image_url
|
||
|
|
)
|
||
|
|
db.add(order_item)
|
||
|
|
|
||
|
|
# Update stock
|
||
|
|
previous_stock = product.stock
|
||
|
|
product.stock = max(0, product.stock - cart_item.quantity)
|
||
|
|
|
||
|
|
# Log inventory change
|
||
|
|
inv_log = InventoryLog(
|
||
|
|
product_id=product.id,
|
||
|
|
action="sale",
|
||
|
|
quantity_change=-cart_item.quantity,
|
||
|
|
previous_stock=previous_stock,
|
||
|
|
new_stock=product.stock,
|
||
|
|
notes=f"Order {order.id}",
|
||
|
|
created_by=user.id
|
||
|
|
)
|
||
|
|
db.add(inv_log)
|
||
|
|
|
||
|
|
# Add status history
|
||
|
|
status_history = OrderStatusHistory(
|
||
|
|
order_id=order.id,
|
||
|
|
status=OrderStatus.PENDING,
|
||
|
|
notes="Order placed",
|
||
|
|
created_by=user.id
|
||
|
|
)
|
||
|
|
db.add(status_history)
|
||
|
|
|
||
|
|
# Clear cart
|
||
|
|
await db.execute(CartItem.__table__.delete().where(CartItem.user_id == user.id))
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(order)
|
||
|
|
|
||
|
|
return {"message": "Order created successfully", "order_id": order.id}
|
||
|
|
|
||
|
|
@api_router.get("/orders")
|
||
|
|
async def get_orders(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(Order)
|
||
|
|
.where(Order.user_id == user.id)
|
||
|
|
.options(selectinload(Order.items), selectinload(Order.status_history))
|
||
|
|
.order_by(desc(Order.created_at))
|
||
|
|
)
|
||
|
|
orders = result.scalars().all()
|
||
|
|
return [order_to_dict(o) for o in orders]
|
||
|
|
|
||
|
|
@api_router.get("/orders/{order_id}")
|
||
|
|
async def get_order(order_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(Order)
|
||
|
|
.where(and_(Order.id == order_id, Order.user_id == user.id))
|
||
|
|
.options(selectinload(Order.items), selectinload(Order.status_history))
|
||
|
|
)
|
||
|
|
order = result.scalar_one_or_none()
|
||
|
|
if not order:
|
||
|
|
raise HTTPException(status_code=404, detail="Order not found")
|
||
|
|
return order_to_dict(order)
|
||
|
|
|
||
|
|
# ================== REVIEWS ROUTES ==================
|
||
|
|
|
||
|
|
@api_router.post("/reviews")
|
||
|
|
async def create_review(review_data: ReviewCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
if not review_data.product_id and not review_data.service_id:
|
||
|
|
raise HTTPException(status_code=400, detail="Product or service ID required")
|
||
|
|
|
||
|
|
if review_data.rating < 1 or review_data.rating > 5:
|
||
|
|
raise HTTPException(status_code=400, detail="Rating must be between 1 and 5")
|
||
|
|
|
||
|
|
# Check for verified purchase
|
||
|
|
is_verified = False
|
||
|
|
if review_data.product_id:
|
||
|
|
result = await db.execute(
|
||
|
|
select(OrderItem)
|
||
|
|
.join(Order)
|
||
|
|
.where(
|
||
|
|
and_(
|
||
|
|
Order.user_id == user.id,
|
||
|
|
OrderItem.product_id == review_data.product_id,
|
||
|
|
Order.status.in_([OrderStatus.DELIVERED, OrderStatus.SHIPPED])
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if result.scalar_one_or_none():
|
||
|
|
is_verified = True
|
||
|
|
|
||
|
|
review = Review(
|
||
|
|
user_id=user.id,
|
||
|
|
product_id=review_data.product_id,
|
||
|
|
service_id=review_data.service_id,
|
||
|
|
rating=review_data.rating,
|
||
|
|
title=review_data.title,
|
||
|
|
comment=review_data.comment,
|
||
|
|
is_verified_purchase=is_verified
|
||
|
|
)
|
||
|
|
db.add(review)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(review)
|
||
|
|
|
||
|
|
return {"message": "Review submitted successfully", "review_id": review.id}
|
||
|
|
|
||
|
|
@api_router.get("/reviews/product/{product_id}")
|
||
|
|
async def get_product_reviews(product_id: str, db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(Review)
|
||
|
|
.where(and_(Review.product_id == product_id, Review.is_approved == True))
|
||
|
|
.options(selectinload(Review.user))
|
||
|
|
.order_by(desc(Review.created_at))
|
||
|
|
)
|
||
|
|
reviews = result.scalars().all()
|
||
|
|
return [review_to_dict(r) for r in reviews]
|
||
|
|
|
||
|
|
@api_router.get("/reviews/service/{service_id}")
|
||
|
|
async def get_service_reviews(service_id: str, db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(Review)
|
||
|
|
.where(and_(Review.service_id == service_id, Review.is_approved == True))
|
||
|
|
.options(selectinload(Review.user))
|
||
|
|
.order_by(desc(Review.created_at))
|
||
|
|
)
|
||
|
|
reviews = result.scalars().all()
|
||
|
|
return [review_to_dict(r) for r in reviews]
|
||
|
|
|
||
|
|
# ================== CONTACT ROUTES ==================
|
||
|
|
|
||
|
|
@api_router.post("/contact")
|
||
|
|
async def submit_contact(contact_data: ContactCreate, db: AsyncSession = Depends(get_db)):
|
||
|
|
contact = Contact(
|
||
|
|
name=contact_data.name,
|
||
|
|
email=contact_data.email,
|
||
|
|
subject=contact_data.subject,
|
||
|
|
message=contact_data.message
|
||
|
|
)
|
||
|
|
db.add(contact)
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Message sent successfully", "id": contact.id}
|
||
|
|
|
||
|
|
# ================== ADMIN ROUTES ==================
|
||
|
|
|
||
|
|
# Admin - Dashboard Stats
|
||
|
|
@api_router.get("/admin/dashboard")
|
||
|
|
async def get_admin_dashboard(user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
"""Admin dashboard with comprehensive stats and error handling"""
|
||
|
|
|
||
|
|
async def safe_scalar(query, default=0, error_msg=""):
|
||
|
|
"""Execute query and return scalar with error handling"""
|
||
|
|
try:
|
||
|
|
result = await db.execute(query)
|
||
|
|
value = result.scalar()
|
||
|
|
return float(value) if value is not None and isinstance(value, (int, float)) else default
|
||
|
|
except Exception as e:
|
||
|
|
if error_msg:
|
||
|
|
logger.error(f"{error_msg}: {e}")
|
||
|
|
return default
|
||
|
|
|
||
|
|
try:
|
||
|
|
today = datetime.now(timezone.utc).date()
|
||
|
|
month_ago = today - timedelta(days=30)
|
||
|
|
month_start = datetime.combine(month_ago, datetime.min.time())
|
||
|
|
|
||
|
|
# Batch count queries for better performance
|
||
|
|
counts_queries = {
|
||
|
|
"products": select(func.count(Product.id)),
|
||
|
|
"services": select(func.count(Service.id)),
|
||
|
|
"users": select(func.count(User.id)),
|
||
|
|
"orders": select(func.count(Order.id))
|
||
|
|
}
|
||
|
|
|
||
|
|
counts = {}
|
||
|
|
for key, query in counts_queries.items():
|
||
|
|
counts[key] = await safe_scalar(query, 0, f"Error fetching {key} count")
|
||
|
|
|
||
|
|
# Revenue queries
|
||
|
|
total_revenue = await safe_scalar(
|
||
|
|
select(func.sum(Order.total)),
|
||
|
|
0.0, "Error fetching total revenue"
|
||
|
|
)
|
||
|
|
monthly_revenue = await safe_scalar(
|
||
|
|
select(func.sum(Order.total)).where(Order.created_at >= month_start),
|
||
|
|
0.0, "Error fetching monthly revenue"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Today's stats - batch queries
|
||
|
|
today_orders = await safe_scalar(
|
||
|
|
select(func.count(Order.id)).where(func.date(Order.created_at) == today),
|
||
|
|
0, "Error fetching today's orders"
|
||
|
|
)
|
||
|
|
today_revenue = await safe_scalar(
|
||
|
|
select(func.sum(Order.total)).where(func.date(Order.created_at) == today),
|
||
|
|
0.0, "Error fetching today's revenue"
|
||
|
|
)
|
||
|
|
pending_bookings = await safe_scalar(
|
||
|
|
select(func.count(Booking.id)).where(Booking.status == "pending"),
|
||
|
|
0, "Error fetching pending bookings"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Low stock products
|
||
|
|
low_stock_products = []
|
||
|
|
try:
|
||
|
|
low_stock_result = await db.execute(
|
||
|
|
select(Product)
|
||
|
|
.where(Product.stock <= Product.low_stock_threshold, Product.is_active == True)
|
||
|
|
)
|
||
|
|
low_stock_products = [{
|
||
|
|
"id": p.id, "name": p.name, "stock": p.stock,
|
||
|
|
"low_stock_threshold": p.low_stock_threshold, "category": p.category
|
||
|
|
} for p in low_stock_result.scalars().all()]
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error fetching low stock products: {e}")
|
||
|
|
|
||
|
|
# Recent orders
|
||
|
|
recent_orders_data = []
|
||
|
|
try:
|
||
|
|
recent_orders_result = await db.execute(
|
||
|
|
select(Order)
|
||
|
|
.options(selectinload(Order.items))
|
||
|
|
.order_by(desc(Order.created_at))
|
||
|
|
.limit(10)
|
||
|
|
)
|
||
|
|
|
||
|
|
for order in recent_orders_result.scalars().all():
|
||
|
|
try:
|
||
|
|
recent_orders_data.append({
|
||
|
|
"id": order.id,
|
||
|
|
"status": _safe_enum_value(order.status),
|
||
|
|
"total": float(order.total) if order.total is not None else 0.0,
|
||
|
|
"created_at": _safe_isoformat(order.created_at),
|
||
|
|
"items": [{"id": i.id, "product_name": i.product_name, "quantity": i.quantity} for i in order.items] if order.items else []
|
||
|
|
})
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning(f"Error processing order {order.id}: {e}")
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error fetching recent orders: {e}")
|
||
|
|
|
||
|
|
# Build response
|
||
|
|
response = {
|
||
|
|
"stats": {
|
||
|
|
"total_products": int(counts["products"]),
|
||
|
|
"total_services": int(counts["services"]),
|
||
|
|
"total_users": int(counts["users"]),
|
||
|
|
"total_orders": int(counts["orders"]),
|
||
|
|
"total_revenue": total_revenue,
|
||
|
|
"monthly_revenue": monthly_revenue,
|
||
|
|
"today_orders": int(today_orders),
|
||
|
|
"today_revenue": today_revenue,
|
||
|
|
"pending_bookings": int(pending_bookings)
|
||
|
|
},
|
||
|
|
"low_stock_products": low_stock_products,
|
||
|
|
"recent_orders": recent_orders_data
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info(f"Dashboard data fetched successfully for user {user.id}")
|
||
|
|
return response
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Critical error in get_admin_dashboard: {e}", exc_info=True)
|
||
|
|
# Return safe defaults instead of crashing
|
||
|
|
return {
|
||
|
|
"stats": {
|
||
|
|
"total_products": 0,
|
||
|
|
"total_services": 0,
|
||
|
|
"total_users": 0,
|
||
|
|
"total_orders": 0,
|
||
|
|
"total_revenue": 0.0,
|
||
|
|
"monthly_revenue": 0.0,
|
||
|
|
"today_orders": 0,
|
||
|
|
"today_revenue": 0.0,
|
||
|
|
"pending_bookings": 0
|
||
|
|
},
|
||
|
|
"low_stock_products": [],
|
||
|
|
"recent_orders": [],
|
||
|
|
"error": "Failed to load some dashboard data"
|
||
|
|
}
|
||
|
|
|
||
|
|
# ================== IMAGE UPLOAD ROUTES ==================
|
||
|
|
|
||
|
|
@api_router.post("/upload/image")
|
||
|
|
async def upload_image(
|
||
|
|
file: UploadFile = File(...),
|
||
|
|
user: User = Depends(get_admin_user)
|
||
|
|
):
|
||
|
|
"""Upload a product image and return the URL"""
|
||
|
|
try:
|
||
|
|
logger.info(f"=== Image Upload Request ===")
|
||
|
|
logger.info(f"Filename: '{file.filename}'")
|
||
|
|
logger.info(f"Content-Type: {file.content_type}")
|
||
|
|
logger.info(f"File size: {file.size if hasattr(file, 'size') else 'unknown'}")
|
||
|
|
|
||
|
|
# Handle cases where filename might be None or empty
|
||
|
|
if not file.filename:
|
||
|
|
logger.error("No filename provided")
|
||
|
|
raise HTTPException(status_code=400, detail="No filename provided")
|
||
|
|
|
||
|
|
# Validate file extension (more reliable than content_type)
|
||
|
|
file_ext = Path(file.filename).suffix.lower()
|
||
|
|
logger.info(f"Extracted extension: '{file_ext}'")
|
||
|
|
|
||
|
|
# Handle cases where file has no extension - try to infer from content_type
|
||
|
|
if not file_ext and file.content_type:
|
||
|
|
content_type_map = {
|
||
|
|
'image/jpeg': '.jpg',
|
||
|
|
'image/jpg': '.jpg',
|
||
|
|
'image/png': '.png',
|
||
|
|
'image/gif': '.gif',
|
||
|
|
'image/webp': '.webp',
|
||
|
|
'image/bmp': '.bmp',
|
||
|
|
'image/svg+xml': '.svg',
|
||
|
|
'image/heic': '.heic',
|
||
|
|
'image/heif': '.heif'
|
||
|
|
}
|
||
|
|
file_ext = content_type_map.get(file.content_type, '')
|
||
|
|
logger.info(f"Inferred extension from content-type: '{file_ext}'")
|
||
|
|
|
||
|
|
# Support common image formats including iPhone formats
|
||
|
|
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.heic', '.heif'}
|
||
|
|
|
||
|
|
# Special handling for HEIC/HEIF - convert to JPG for better compatibility
|
||
|
|
convert_to_jpg = file_ext in {'.heic', '.heif'}
|
||
|
|
if convert_to_jpg:
|
||
|
|
logger.info(f"HEIC/HEIF file detected, will convert to JPG for compatibility")
|
||
|
|
|
||
|
|
if not file_ext or file_ext not in allowed_extensions:
|
||
|
|
logger.warning(f"Invalid or missing file extension: '{file_ext}'")
|
||
|
|
logger.warning(f"Full filename: '{file.filename}'")
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=400,
|
||
|
|
detail=f"File must be an image with a valid extension. Allowed: {', '.join(sorted(allowed_extensions))}"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Generate unique filename - convert HEIC to JPG extension
|
||
|
|
output_ext = '.jpg' if convert_to_jpg else file_ext
|
||
|
|
unique_filename = f"{uuid.uuid4()}{output_ext}"
|
||
|
|
file_path = UPLOAD_DIR / unique_filename
|
||
|
|
|
||
|
|
logger.info(f"Saving file to: {file_path}")
|
||
|
|
|
||
|
|
# Save file asynchronously
|
||
|
|
content = await file.read()
|
||
|
|
if len(content) == 0:
|
||
|
|
raise HTTPException(status_code=400, detail="File is empty")
|
||
|
|
|
||
|
|
with open(file_path, "wb") as buffer:
|
||
|
|
buffer.write(content)
|
||
|
|
|
||
|
|
# Return relative URL
|
||
|
|
# Convert HEIC/HEIF to JPG for better browser compatibility
|
||
|
|
if convert_to_jpg:
|
||
|
|
try:
|
||
|
|
from PIL import Image
|
||
|
|
from io import BytesIO
|
||
|
|
|
||
|
|
# Try to use pillow_heif if available
|
||
|
|
try:
|
||
|
|
import pillow_heif
|
||
|
|
pillow_heif.register_heif_opener()
|
||
|
|
except ImportError:
|
||
|
|
logger.warning("pillow_heif not installed, HEIC conversion may not work")
|
||
|
|
|
||
|
|
# Open and convert image
|
||
|
|
img = Image.open(BytesIO(content))
|
||
|
|
img = img.convert('RGB') # Ensure RGB mode for JPEG
|
||
|
|
img.save(file_path, 'JPEG', quality=90)
|
||
|
|
logger.info(f"Converted HEIC/HEIF to JPG successfully")
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Failed to convert HEIC/HEIF: {e}")
|
||
|
|
# Fallback: save as-is and let browser handle it
|
||
|
|
with open(file_path, "wb") as buffer:
|
||
|
|
buffer.write(content)
|
||
|
|
logger.info(f"Saved HEIC file as-is without conversion")
|
||
|
|
else:
|
||
|
|
with open(file_path, "wb") as buffer:
|
||
|
|
buffer.write(content)
|
||
|
|
|
||
|
|
# Return relative URL
|
||
|
|
image_url = f"/uploads/products/{unique_filename}"
|
||
|
|
logger.info(f"Image uploaded successfully: {image_url}, size: {len(content)} bytes")
|
||
|
|
return {"url": image_url, "filename": unique_filename}
|
||
|
|
|
||
|
|
except HTTPException:
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Failed to upload image: {str(e)}", exc_info=True)
|
||
|
|
raise HTTPException(status_code=500, detail=f"Failed to upload image: {str(e)}")
|
||
|
|
|
||
|
|
@api_router.post("/admin/products/{product_id}/images")
|
||
|
|
async def add_product_images(
|
||
|
|
product_id: str,
|
||
|
|
image_urls: List[str],
|
||
|
|
user: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Add multiple images to a product"""
|
||
|
|
product = await _get_or_404(db, Product, product_id, "Product not found")
|
||
|
|
|
||
|
|
# Get current max display order
|
||
|
|
result = await db.execute(
|
||
|
|
select(func.max(ProductImage.display_order))
|
||
|
|
.where(ProductImage.product_id == product_id)
|
||
|
|
)
|
||
|
|
max_order = result.scalar() or -1
|
||
|
|
|
||
|
|
# Add new images
|
||
|
|
for idx, url in enumerate(image_urls):
|
||
|
|
product_image = ProductImage(
|
||
|
|
product_id=product_id,
|
||
|
|
image_url=url,
|
||
|
|
display_order=max_order + idx + 1,
|
||
|
|
is_primary=(max_order == -1 and idx == 0) # First image is primary if no images exist
|
||
|
|
)
|
||
|
|
db.add(product_image)
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return {"message": f"Added {len(image_urls)} images", "product_id": product_id}
|
||
|
|
|
||
|
|
@api_router.delete("/admin/products/{product_id}/images/{image_id}")
|
||
|
|
async def delete_product_image(
|
||
|
|
product_id: str,
|
||
|
|
image_id: str,
|
||
|
|
user: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Delete a product image"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(ProductImage)
|
||
|
|
.where(and_(ProductImage.id == image_id, ProductImage.product_id == product_id))
|
||
|
|
)
|
||
|
|
image = result.scalar_one_or_none()
|
||
|
|
|
||
|
|
if not image:
|
||
|
|
raise HTTPException(status_code=404, detail="Image not found")
|
||
|
|
|
||
|
|
# Delete file from filesystem
|
||
|
|
try:
|
||
|
|
file_path = UPLOAD_DIR / Path(image.image_url).name
|
||
|
|
if file_path.exists():
|
||
|
|
file_path.unlink()
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning(f"Failed to delete image file: {e}")
|
||
|
|
|
||
|
|
await db.delete(image)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return {"message": "Image deleted"}
|
||
|
|
|
||
|
|
@api_router.put("/admin/products/{product_id}/images/reorder")
|
||
|
|
async def reorder_product_images(
|
||
|
|
product_id: str,
|
||
|
|
image_orders: Dict[str, int], # {image_id: display_order}
|
||
|
|
user: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Reorder product images"""
|
||
|
|
for image_id, order in image_orders.items():
|
||
|
|
result = await db.execute(
|
||
|
|
select(ProductImage)
|
||
|
|
.where(and_(ProductImage.id == image_id, ProductImage.product_id == product_id))
|
||
|
|
)
|
||
|
|
image = result.scalar_one_or_none()
|
||
|
|
if image:
|
||
|
|
image.display_order = order
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Images reordered"}
|
||
|
|
|
||
|
|
# Admin - Products CRUD
|
||
|
|
@api_router.get("/admin/products")
|
||
|
|
async def admin_get_products(
|
||
|
|
include_inactive: bool = False,
|
||
|
|
user: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
query = select(Product).options(selectinload(Product.images))
|
||
|
|
if not include_inactive:
|
||
|
|
query = query.where(Product.is_active == True)
|
||
|
|
query = query.order_by(desc(Product.created_at))
|
||
|
|
result = await db.execute(query)
|
||
|
|
products = result.scalars().all()
|
||
|
|
return [product_to_dict(p) for p in products]
|
||
|
|
|
||
|
|
@api_router.post("/admin/products")
|
||
|
|
async def admin_create_product(product_data: ProductCreate, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
data_dict = product_data.model_dump()
|
||
|
|
image_urls = data_dict.pop('images', [])
|
||
|
|
|
||
|
|
product = Product(**data_dict)
|
||
|
|
db.add(product)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(product)
|
||
|
|
|
||
|
|
# Add images if provided
|
||
|
|
if image_urls:
|
||
|
|
for idx, url in enumerate(image_urls):
|
||
|
|
product_image = ProductImage(
|
||
|
|
product_id=product.id,
|
||
|
|
image_url=url,
|
||
|
|
display_order=idx,
|
||
|
|
is_primary=(idx == 0)
|
||
|
|
)
|
||
|
|
db.add(product_image)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
# Log inventory
|
||
|
|
inv_log = InventoryLog(
|
||
|
|
product_id=product.id,
|
||
|
|
action="add",
|
||
|
|
quantity_change=product.stock,
|
||
|
|
previous_stock=0,
|
||
|
|
new_stock=product.stock,
|
||
|
|
notes="Initial stock",
|
||
|
|
created_by=user.id
|
||
|
|
)
|
||
|
|
db.add(inv_log)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
# Reload product with images relationship
|
||
|
|
result = await db.execute(
|
||
|
|
select(Product)
|
||
|
|
.where(Product.id == product.id)
|
||
|
|
.options(selectinload(Product.images))
|
||
|
|
)
|
||
|
|
product = result.scalar_one()
|
||
|
|
|
||
|
|
return product_to_dict(product)
|
||
|
|
|
||
|
|
@api_router.put("/admin/products/{product_id}")
|
||
|
|
async def admin_update_product(product_id: str, product_data: ProductUpdate, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
product = await _get_or_404(db, Product, product_id, "Product not found")
|
||
|
|
update_data = product_data.model_dump(exclude_unset=True)
|
||
|
|
image_urls = update_data.pop('images', None)
|
||
|
|
|
||
|
|
# Track stock changes
|
||
|
|
if "stock" in update_data and update_data["stock"] != product.stock:
|
||
|
|
inv_log = InventoryLog(
|
||
|
|
product_id=product.id,
|
||
|
|
action="adjust",
|
||
|
|
quantity_change=update_data["stock"] - product.stock,
|
||
|
|
previous_stock=product.stock,
|
||
|
|
new_stock=update_data["stock"],
|
||
|
|
notes="Manual adjustment",
|
||
|
|
created_by=user.id
|
||
|
|
)
|
||
|
|
db.add(inv_log)
|
||
|
|
|
||
|
|
for key, value in update_data.items():
|
||
|
|
setattr(product, key, value)
|
||
|
|
|
||
|
|
# Update images if provided
|
||
|
|
if image_urls is not None:
|
||
|
|
# Delete existing images
|
||
|
|
await db.execute(
|
||
|
|
delete(ProductImage).where(ProductImage.product_id == product_id)
|
||
|
|
)
|
||
|
|
|
||
|
|
# Add new images
|
||
|
|
for idx, url in enumerate(image_urls):
|
||
|
|
product_image = ProductImage(
|
||
|
|
product_id=product_id,
|
||
|
|
image_url=url,
|
||
|
|
display_order=idx,
|
||
|
|
is_primary=(idx == 0)
|
||
|
|
)
|
||
|
|
db.add(product_image)
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
# Reload product with images relationship
|
||
|
|
result = await db.execute(
|
||
|
|
select(Product)
|
||
|
|
.where(Product.id == product_id)
|
||
|
|
.options(selectinload(Product.images))
|
||
|
|
)
|
||
|
|
product = result.scalar_one()
|
||
|
|
|
||
|
|
return product_to_dict(product)
|
||
|
|
|
||
|
|
@api_router.delete("/admin/products/{product_id}")
|
||
|
|
async def admin_delete_product(product_id: str, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
product = await _get_or_404(db, Product, product_id, "Product not found")
|
||
|
|
|
||
|
|
# Hard delete - remove from database
|
||
|
|
await db.delete(product)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return {"message": "Product deleted"}
|
||
|
|
|
||
|
|
# Admin - Categories CRUD
|
||
|
|
@api_router.get("/admin/categories")
|
||
|
|
async def admin_get_categories(user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(select(Category).order_by(Category.name))
|
||
|
|
categories = result.scalars().all()
|
||
|
|
return [{
|
||
|
|
"id": str(c.id),
|
||
|
|
"name": c.name,
|
||
|
|
"description": c.description,
|
||
|
|
"created_at": c.created_at.isoformat() if c.created_at else None
|
||
|
|
} for c in categories]
|
||
|
|
|
||
|
|
@api_router.post("/admin/categories")
|
||
|
|
async def admin_create_category(category_data: CategoryCreate, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
data = category_data.model_dump()
|
||
|
|
# Auto-generate slug if not provided
|
||
|
|
if not data.get('slug'):
|
||
|
|
data['slug'] = data['name'].lower().replace(' ', '-').replace('&', 'and')
|
||
|
|
category = Category(**data)
|
||
|
|
db.add(category)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(category)
|
||
|
|
return {
|
||
|
|
"id": str(category.id),
|
||
|
|
"name": category.name,
|
||
|
|
"slug": category.slug,
|
||
|
|
"description": category.description,
|
||
|
|
"created_at": category.created_at.isoformat() if category.created_at else None
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.put("/admin/categories/{category_id}")
|
||
|
|
async def admin_update_category(category_id: str, category_data: CategoryUpdate, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
category = await _get_or_404(db, Category, category_id, "Category not found")
|
||
|
|
update_data = category_data.model_dump(exclude_unset=True)
|
||
|
|
for key, value in update_data.items():
|
||
|
|
setattr(category, key, value)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(category)
|
||
|
|
return {
|
||
|
|
"id": str(category.id),
|
||
|
|
"name": category.name,
|
||
|
|
"description": category.description,
|
||
|
|
"created_at": category.created_at.isoformat() if category.created_at else None
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.delete("/admin/categories/{category_id}")
|
||
|
|
async def admin_delete_category(category_id: str, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
category = await _get_or_404(db, Category, category_id, "Category not found")
|
||
|
|
await db.delete(category)
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Category deleted successfully"}
|
||
|
|
|
||
|
|
@api_router.get("/categories")
|
||
|
|
async def get_categories(db: AsyncSession = Depends(get_db)):
|
||
|
|
"""Public endpoint to get all categories"""
|
||
|
|
result = await db.execute(select(Category).order_by(Category.name))
|
||
|
|
categories = result.scalars().all()
|
||
|
|
return [{
|
||
|
|
"id": str(c.id),
|
||
|
|
"name": c.name,
|
||
|
|
"description": c.description
|
||
|
|
} for c in categories]
|
||
|
|
|
||
|
|
# Admin - Services CRUD
|
||
|
|
@api_router.get("/admin/services")
|
||
|
|
async def admin_get_services(include_inactive: bool = False, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
query = select(Service).options(selectinload(Service.images))
|
||
|
|
if not include_inactive:
|
||
|
|
query = query.where(Service.is_active == True)
|
||
|
|
query = query.order_by(desc(Service.created_at))
|
||
|
|
result = await db.execute(query)
|
||
|
|
services = result.scalars().all()
|
||
|
|
return [service_to_dict(s) for s in services]
|
||
|
|
|
||
|
|
@api_router.post("/admin/services")
|
||
|
|
async def admin_create_service(service_data: ServiceCreate, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
data_dict = service_data.model_dump()
|
||
|
|
image_urls = data_dict.pop('images', [])
|
||
|
|
|
||
|
|
service = Service(**data_dict)
|
||
|
|
db.add(service)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(service)
|
||
|
|
|
||
|
|
# Add images if provided
|
||
|
|
if image_urls:
|
||
|
|
for idx, url in enumerate(image_urls):
|
||
|
|
service_image = ServiceImage(
|
||
|
|
service_id=service.id,
|
||
|
|
image_url=url,
|
||
|
|
display_order=idx,
|
||
|
|
is_primary=(idx == 0)
|
||
|
|
)
|
||
|
|
db.add(service_image)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
# Reload service with images relationship
|
||
|
|
result = await db.execute(
|
||
|
|
select(Service)
|
||
|
|
.where(Service.id == service.id)
|
||
|
|
.options(selectinload(Service.images))
|
||
|
|
)
|
||
|
|
service = result.scalar_one()
|
||
|
|
|
||
|
|
return service_to_dict(service)
|
||
|
|
|
||
|
|
@api_router.put("/admin/services/{service_id}")
|
||
|
|
async def admin_update_service(service_id: str, service_data: ServiceUpdate, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
service = await _get_or_404(db, Service, service_id, "Service not found")
|
||
|
|
update_data = service_data.model_dump(exclude_unset=True)
|
||
|
|
image_urls = update_data.pop('images', None)
|
||
|
|
|
||
|
|
for key, value in update_data.items():
|
||
|
|
setattr(service, key, value)
|
||
|
|
|
||
|
|
# Update images if provided
|
||
|
|
if image_urls is not None:
|
||
|
|
# Delete existing images
|
||
|
|
await db.execute(
|
||
|
|
delete(ServiceImage).where(ServiceImage.service_id == service_id)
|
||
|
|
)
|
||
|
|
|
||
|
|
# Add new images
|
||
|
|
for idx, url in enumerate(image_urls):
|
||
|
|
service_image = ServiceImage(
|
||
|
|
service_id=service_id,
|
||
|
|
image_url=url,
|
||
|
|
display_order=idx,
|
||
|
|
is_primary=(idx == 0)
|
||
|
|
)
|
||
|
|
db.add(service_image)
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
# Reload service with images relationship
|
||
|
|
result = await db.execute(
|
||
|
|
select(Service)
|
||
|
|
.where(Service.id == service_id)
|
||
|
|
.options(selectinload(Service.images))
|
||
|
|
)
|
||
|
|
service = result.scalar_one()
|
||
|
|
|
||
|
|
return service_to_dict(service)
|
||
|
|
|
||
|
|
@api_router.delete("/admin/services/{service_id}")
|
||
|
|
async def admin_delete_service(service_id: str, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
service = await _get_or_404(db, Service, service_id, "Service not found")
|
||
|
|
|
||
|
|
# Hard delete - remove from database
|
||
|
|
await db.delete(service)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return {"message": "Service deleted"}
|
||
|
|
|
||
|
|
# Admin - Orders Management
|
||
|
|
@api_router.get("/admin/orders")
|
||
|
|
async def admin_get_orders(
|
||
|
|
status: Optional[str] = None,
|
||
|
|
limit: int = 50,
|
||
|
|
user: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
query = select(Order).options(selectinload(Order.items), selectinload(Order.status_history), selectinload(Order.user))
|
||
|
|
if status:
|
||
|
|
query = query.where(Order.status == OrderStatus(status))
|
||
|
|
query = query.order_by(desc(Order.created_at)).limit(limit)
|
||
|
|
result = await db.execute(query)
|
||
|
|
orders = result.scalars().all()
|
||
|
|
return [{
|
||
|
|
**order_to_dict(o),
|
||
|
|
"user_name": o.user.name if o.user else "Unknown",
|
||
|
|
"user_email": o.user.email if o.user else "Unknown"
|
||
|
|
} for o in orders]
|
||
|
|
|
||
|
|
@api_router.put("/admin/orders/{order_id}/status")
|
||
|
|
async def admin_update_order_status(order_id: str, status_data: OrderStatusUpdate, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(Order).where(Order.id == order_id).options(selectinload(Order.items))
|
||
|
|
)
|
||
|
|
order = result.scalar_one_or_none()
|
||
|
|
if not order:
|
||
|
|
raise HTTPException(status_code=404, detail="Order not found")
|
||
|
|
|
||
|
|
new_status = OrderStatus(status_data.status)
|
||
|
|
order.status = new_status
|
||
|
|
if status_data.tracking_number:
|
||
|
|
order.tracking_number = status_data.tracking_number
|
||
|
|
|
||
|
|
# Handle refunds - restore stock
|
||
|
|
if new_status == OrderStatus.REFUNDED:
|
||
|
|
for item in order.items:
|
||
|
|
result = await db.execute(select(Product).where(Product.id == item.product_id))
|
||
|
|
product = result.scalar_one_or_none()
|
||
|
|
if product:
|
||
|
|
previous_stock = product.stock
|
||
|
|
product.stock += item.quantity
|
||
|
|
inv_log = InventoryLog(
|
||
|
|
product_id=product.id,
|
||
|
|
action="refund",
|
||
|
|
quantity_change=item.quantity,
|
||
|
|
previous_stock=previous_stock,
|
||
|
|
new_stock=product.stock,
|
||
|
|
notes=f"Refund for order {order_id}",
|
||
|
|
created_by=user.id
|
||
|
|
)
|
||
|
|
db.add(inv_log)
|
||
|
|
|
||
|
|
# Add status history
|
||
|
|
status_history = OrderStatusHistory(
|
||
|
|
order_id=order.id,
|
||
|
|
status=new_status,
|
||
|
|
notes=status_data.notes,
|
||
|
|
created_by=user.id
|
||
|
|
)
|
||
|
|
db.add(status_history)
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Order status updated"}
|
||
|
|
|
||
|
|
# Admin - Inventory Management
|
||
|
|
@api_router.get("/admin/inventory")
|
||
|
|
async def admin_get_inventory(user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
try:
|
||
|
|
result = await db.execute(
|
||
|
|
select(Product)
|
||
|
|
.options(selectinload(Product.images))
|
||
|
|
.where(Product.is_active == True)
|
||
|
|
.order_by(Product.stock)
|
||
|
|
)
|
||
|
|
products = result.scalars().all()
|
||
|
|
|
||
|
|
inventory_data = []
|
||
|
|
for p in products:
|
||
|
|
product_dict = product_to_dict(p)
|
||
|
|
product_dict["is_low_stock"] = p.stock <= p.low_stock_threshold
|
||
|
|
inventory_data.append(product_dict)
|
||
|
|
|
||
|
|
return inventory_data
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error fetching inventory: {str(e)}")
|
||
|
|
raise HTTPException(status_code=500, detail=f"Failed to fetch inventory: {str(e)}")
|
||
|
|
|
||
|
|
@api_router.post("/admin/inventory/{product_id}/adjust")
|
||
|
|
async def admin_adjust_inventory(product_id: str, adjustment: InventoryAdjust, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
product = await _get_or_404(db, Product, product_id, "Product not found")
|
||
|
|
|
||
|
|
previous_stock = product.stock
|
||
|
|
product.stock = max(0, product.stock + adjustment.quantity_change)
|
||
|
|
|
||
|
|
inv_log = InventoryLog(
|
||
|
|
product_id=product.id,
|
||
|
|
action="adjust" if adjustment.quantity_change >= 0 else "remove",
|
||
|
|
quantity_change=adjustment.quantity_change,
|
||
|
|
previous_stock=previous_stock,
|
||
|
|
new_stock=product.stock,
|
||
|
|
notes=adjustment.notes,
|
||
|
|
created_by=user.id
|
||
|
|
)
|
||
|
|
db.add(inv_log)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return _build_response("Inventory adjusted", new_stock=product.stock)
|
||
|
|
|
||
|
|
@api_router.get("/admin/inventory/{product_id}/logs")
|
||
|
|
async def admin_get_inventory_logs(product_id: str, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(
|
||
|
|
select(InventoryLog)
|
||
|
|
.where(InventoryLog.product_id == product_id)
|
||
|
|
.order_by(desc(InventoryLog.created_at))
|
||
|
|
.limit(50)
|
||
|
|
)
|
||
|
|
logs = result.scalars().all()
|
||
|
|
return [{
|
||
|
|
"id": log.id,
|
||
|
|
"action": log.action,
|
||
|
|
"quantity_change": log.quantity_change,
|
||
|
|
"previous_stock": log.previous_stock,
|
||
|
|
"new_stock": log.new_stock,
|
||
|
|
"notes": log.notes,
|
||
|
|
"created_at": log.created_at.isoformat() if log.created_at else None
|
||
|
|
} for log in logs]
|
||
|
|
|
||
|
|
# Admin - Bookings Management
|
||
|
|
@api_router.get("/admin/bookings")
|
||
|
|
async def admin_get_bookings(status: Optional[str] = None, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
query = select(Booking).options(selectinload(Booking.service))
|
||
|
|
if status:
|
||
|
|
query = query.where(Booking.status == status)
|
||
|
|
query = query.order_by(desc(Booking.created_at))
|
||
|
|
result = await db.execute(query)
|
||
|
|
bookings = result.scalars().all()
|
||
|
|
return [booking_to_dict(b) for b in bookings]
|
||
|
|
|
||
|
|
@api_router.put("/admin/bookings/{booking_id}/status")
|
||
|
|
async def admin_update_booking_status(booking_id: str, status: str, user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||
|
|
result = await db.execute(select(Booking).where(Booking.id == booking_id))
|
||
|
|
booking = result.scalar_one_or_none()
|
||
|
|
if not booking:
|
||
|
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||
|
|
|
||
|
|
booking.status = status
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Booking status updated"}
|
||
|
|
|
||
|
|
# Admin - Users Management
|
||
|
|
|
||
|
|
# Admin - Reports
|
||
|
|
@api_router.get("/admin/reports/sales")
|
||
|
|
async def admin_get_sales_report(
|
||
|
|
period: str = "daily", # daily, weekly, monthly
|
||
|
|
start_date: Optional[str] = None,
|
||
|
|
end_date: Optional[str] = None,
|
||
|
|
user: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
now = datetime.now(timezone.utc)
|
||
|
|
|
||
|
|
if start_date:
|
||
|
|
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||
|
|
else:
|
||
|
|
if period == "daily":
|
||
|
|
start = now - timedelta(days=30)
|
||
|
|
elif period == "weekly":
|
||
|
|
start = now - timedelta(weeks=12)
|
||
|
|
else:
|
||
|
|
start = now - timedelta(days=365)
|
||
|
|
|
||
|
|
if end_date:
|
||
|
|
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||
|
|
else:
|
||
|
|
end = now
|
||
|
|
|
||
|
|
# Get orders in date range
|
||
|
|
result = await db.execute(
|
||
|
|
select(Order)
|
||
|
|
.where(and_(Order.created_at >= start, Order.created_at <= end))
|
||
|
|
.options(selectinload(Order.items))
|
||
|
|
.order_by(Order.created_at)
|
||
|
|
)
|
||
|
|
orders = result.scalars().all()
|
||
|
|
|
||
|
|
# Get bookings in date range
|
||
|
|
bookings_result = await db.execute(
|
||
|
|
select(Booking)
|
||
|
|
.where(and_(Booking.created_at >= start, Booking.created_at <= end))
|
||
|
|
)
|
||
|
|
bookings = bookings_result.scalars().all()
|
||
|
|
|
||
|
|
# Aggregate by period
|
||
|
|
report_data = {}
|
||
|
|
for order in orders:
|
||
|
|
if period == "daily":
|
||
|
|
key = order.created_at.strftime("%Y-%m-%d")
|
||
|
|
elif period == "weekly":
|
||
|
|
key = order.created_at.strftime("%Y-W%W")
|
||
|
|
else:
|
||
|
|
key = order.created_at.strftime("%Y-%m")
|
||
|
|
|
||
|
|
if key not in report_data:
|
||
|
|
report_data[key] = {
|
||
|
|
"period": key,
|
||
|
|
"orders": 0,
|
||
|
|
"revenue": 0,
|
||
|
|
"products_sold": 0,
|
||
|
|
"order_statuses": {}
|
||
|
|
}
|
||
|
|
|
||
|
|
report_data[key]["orders"] += 1
|
||
|
|
report_data[key]["revenue"] += order.total
|
||
|
|
report_data[key]["products_sold"] += sum(item.quantity for item in order.items)
|
||
|
|
|
||
|
|
status = order.status.value if order.status else "unknown"
|
||
|
|
report_data[key]["order_statuses"][status] = report_data[key]["order_statuses"].get(status, 0) + 1
|
||
|
|
|
||
|
|
# Add booking counts
|
||
|
|
for booking in bookings:
|
||
|
|
if period == "daily":
|
||
|
|
key = booking.created_at.strftime("%Y-%m-%d")
|
||
|
|
elif period == "weekly":
|
||
|
|
key = booking.created_at.strftime("%Y-W%W")
|
||
|
|
else:
|
||
|
|
key = booking.created_at.strftime("%Y-%m")
|
||
|
|
|
||
|
|
if key not in report_data:
|
||
|
|
report_data[key] = {
|
||
|
|
"period": key,
|
||
|
|
"orders": 0,
|
||
|
|
"revenue": 0,
|
||
|
|
"products_sold": 0,
|
||
|
|
"order_statuses": {}
|
||
|
|
}
|
||
|
|
|
||
|
|
report_data[key]["services_booked"] = report_data[key].get("services_booked", 0) + 1
|
||
|
|
|
||
|
|
# Calculate totals
|
||
|
|
total_orders = len(orders)
|
||
|
|
total_revenue = sum(o.total for o in orders)
|
||
|
|
total_products = sum(sum(item.quantity for item in o.items) for o in orders)
|
||
|
|
total_bookings = len(bookings)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"period": period,
|
||
|
|
"start_date": start.isoformat(),
|
||
|
|
"end_date": end.isoformat(),
|
||
|
|
"summary": {
|
||
|
|
"total_orders": total_orders,
|
||
|
|
"total_revenue": total_revenue,
|
||
|
|
"total_products_sold": total_products,
|
||
|
|
"total_services_booked": total_bookings,
|
||
|
|
"average_order_value": total_revenue / total_orders if total_orders > 0 else 0
|
||
|
|
},
|
||
|
|
"data": list(report_data.values())
|
||
|
|
}
|
||
|
|
|
||
|
|
# Admin - Export Reports
|
||
|
|
@api_router.get("/admin/reports/export/csv")
|
||
|
|
async def admin_export_csv(
|
||
|
|
report_type: str = "sales", # sales, inventory, orders
|
||
|
|
period: str = "monthly",
|
||
|
|
user: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
output = io.StringIO()
|
||
|
|
writer = csv.writer(output)
|
||
|
|
|
||
|
|
now = datetime.now(timezone.utc)
|
||
|
|
|
||
|
|
if report_type == "sales":
|
||
|
|
if period == "daily":
|
||
|
|
start = now - timedelta(days=30)
|
||
|
|
elif period == "weekly":
|
||
|
|
start = now - timedelta(weeks=12)
|
||
|
|
else:
|
||
|
|
start = now - timedelta(days=365)
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(Order)
|
||
|
|
.where(Order.created_at >= start)
|
||
|
|
.options(selectinload(Order.items), selectinload(Order.user))
|
||
|
|
.order_by(Order.created_at)
|
||
|
|
)
|
||
|
|
orders = result.scalars().all()
|
||
|
|
|
||
|
|
writer.writerow(["Date", "Order ID", "Customer", "Items", "Subtotal", "Tax", "Shipping", "Total", "Status"])
|
||
|
|
for order in orders:
|
||
|
|
writer.writerow([
|
||
|
|
order.created_at.strftime("%Y-%m-%d %H:%M"),
|
||
|
|
order.id,
|
||
|
|
order.user.name if order.user else "Guest",
|
||
|
|
sum(item.quantity for item in order.items),
|
||
|
|
f"${order.subtotal:.2f}",
|
||
|
|
f"${order.tax:.2f}",
|
||
|
|
f"${order.shipping:.2f}",
|
||
|
|
f"${order.total:.2f}",
|
||
|
|
order.status.value if order.status else "unknown"
|
||
|
|
])
|
||
|
|
|
||
|
|
elif report_type == "inventory":
|
||
|
|
result = await db.execute(select(Product).where(Product.is_active == True))
|
||
|
|
products = result.scalars().all()
|
||
|
|
|
||
|
|
writer.writerow(["Product ID", "Name", "Category", "Brand", "Price", "Stock", "Low Stock Threshold", "Status"])
|
||
|
|
for product in products:
|
||
|
|
writer.writerow([
|
||
|
|
product.id,
|
||
|
|
product.name,
|
||
|
|
product.category,
|
||
|
|
product.brand,
|
||
|
|
f"${product.price:.2f}",
|
||
|
|
product.stock,
|
||
|
|
product.low_stock_threshold,
|
||
|
|
"Low Stock" if product.stock <= product.low_stock_threshold else "In Stock"
|
||
|
|
])
|
||
|
|
|
||
|
|
elif report_type == "orders":
|
||
|
|
result = await db.execute(
|
||
|
|
select(Order)
|
||
|
|
.options(selectinload(Order.items), selectinload(Order.user))
|
||
|
|
.order_by(desc(Order.created_at))
|
||
|
|
.limit(500)
|
||
|
|
)
|
||
|
|
orders = result.scalars().all()
|
||
|
|
|
||
|
|
writer.writerow(["Order ID", "Date", "Customer", "Email", "Items", "Total", "Status", "Tracking"])
|
||
|
|
for order in orders:
|
||
|
|
writer.writerow([
|
||
|
|
order.id,
|
||
|
|
order.created_at.strftime("%Y-%m-%d %H:%M"),
|
||
|
|
order.user.name if order.user else "Guest",
|
||
|
|
order.user.email if order.user else "",
|
||
|
|
sum(item.quantity for item in order.items),
|
||
|
|
f"${order.total:.2f}",
|
||
|
|
order.status.value if order.status else "unknown",
|
||
|
|
order.tracking_number or ""
|
||
|
|
])
|
||
|
|
|
||
|
|
output.seek(0)
|
||
|
|
return StreamingResponse(
|
||
|
|
iter([output.getvalue()]),
|
||
|
|
media_type="text/csv",
|
||
|
|
headers={"Content-Disposition": f"attachment; filename={report_type}_report_{now.strftime('%Y%m%d')}.csv"}
|
||
|
|
)
|
||
|
|
|
||
|
|
@api_router.get("/admin/reports/export/pdf")
|
||
|
|
async def admin_export_pdf(
|
||
|
|
report_type: str = "sales",
|
||
|
|
period: str = "monthly",
|
||
|
|
user: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
buffer = io.BytesIO()
|
||
|
|
doc = SimpleDocTemplate(buffer, pagesize=A4)
|
||
|
|
styles = getSampleStyleSheet()
|
||
|
|
elements = []
|
||
|
|
|
||
|
|
now = datetime.now(timezone.utc)
|
||
|
|
|
||
|
|
# Title
|
||
|
|
title_style = ParagraphStyle(
|
||
|
|
'CustomTitle',
|
||
|
|
parent=styles['Heading1'],
|
||
|
|
fontSize=24,
|
||
|
|
spaceAfter=30
|
||
|
|
)
|
||
|
|
elements.append(Paragraph(f"TechZone {report_type.title()} Report", title_style))
|
||
|
|
elements.append(Paragraph(f"Generated: {now.strftime('%Y-%m-%d %H:%M')}", styles['Normal']))
|
||
|
|
elements.append(Spacer(1, 20))
|
||
|
|
|
||
|
|
if report_type == "sales":
|
||
|
|
if period == "daily":
|
||
|
|
start = now - timedelta(days=30)
|
||
|
|
elif period == "weekly":
|
||
|
|
start = now - timedelta(weeks=12)
|
||
|
|
else:
|
||
|
|
start = now - timedelta(days=365)
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(Order)
|
||
|
|
.where(Order.created_at >= start)
|
||
|
|
.options(selectinload(Order.items))
|
||
|
|
)
|
||
|
|
orders = result.scalars().all()
|
||
|
|
|
||
|
|
# Summary
|
||
|
|
total_orders = len(orders)
|
||
|
|
total_revenue = sum(o.total for o in orders)
|
||
|
|
total_products = sum(sum(item.quantity for item in o.items) for o in orders)
|
||
|
|
|
||
|
|
elements.append(Paragraph("Summary", styles['Heading2']))
|
||
|
|
summary_data = [
|
||
|
|
["Metric", "Value"],
|
||
|
|
["Total Orders", str(total_orders)],
|
||
|
|
["Total Revenue", f"${total_revenue:.2f}"],
|
||
|
|
["Products Sold", str(total_products)],
|
||
|
|
["Average Order Value", f"${total_revenue/total_orders:.2f}" if total_orders > 0 else "$0.00"]
|
||
|
|
]
|
||
|
|
summary_table = Table(summary_data, colWidths=[3*inch, 2*inch])
|
||
|
|
summary_table.setStyle(TableStyle([
|
||
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
||
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||
|
|
('FONTSIZE', (0, 0), (-1, 0), 12),
|
||
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||
|
|
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
||
|
|
('GRID', (0, 0), (-1, -1), 1, colors.black)
|
||
|
|
]))
|
||
|
|
elements.append(summary_table)
|
||
|
|
elements.append(Spacer(1, 20))
|
||
|
|
|
||
|
|
# Orders table
|
||
|
|
elements.append(Paragraph("Recent Orders", styles['Heading2']))
|
||
|
|
orders_data = [["Date", "Order ID", "Items", "Total", "Status"]]
|
||
|
|
for order in orders[:50]:
|
||
|
|
orders_data.append([
|
||
|
|
order.created_at.strftime("%Y-%m-%d"),
|
||
|
|
order.id[:8] + "...",
|
||
|
|
str(sum(item.quantity for item in order.items)),
|
||
|
|
f"${order.total:.2f}",
|
||
|
|
order.status.value if order.status else "unknown"
|
||
|
|
])
|
||
|
|
|
||
|
|
orders_table = Table(orders_data, colWidths=[1.2*inch, 1.2*inch, 0.8*inch, 1*inch, 1*inch])
|
||
|
|
orders_table.setStyle(TableStyle([
|
||
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
||
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||
|
|
('FONTSIZE', (0, 0), (-1, -1), 9),
|
||
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black)
|
||
|
|
]))
|
||
|
|
elements.append(orders_table)
|
||
|
|
|
||
|
|
elif report_type == "inventory":
|
||
|
|
result = await db.execute(select(Product).where(Product.is_active == True).order_by(Product.stock))
|
||
|
|
products = result.scalars().all()
|
||
|
|
|
||
|
|
elements.append(Paragraph("Inventory Status", styles['Heading2']))
|
||
|
|
inv_data = [["Product", "Category", "Price", "Stock", "Status"]]
|
||
|
|
for product in products:
|
||
|
|
status = "LOW STOCK" if product.stock <= product.low_stock_threshold else "In Stock"
|
||
|
|
inv_data.append([
|
||
|
|
product.name[:30] + "..." if len(product.name) > 30 else product.name,
|
||
|
|
product.category,
|
||
|
|
f"${product.price:.2f}",
|
||
|
|
str(product.stock),
|
||
|
|
status
|
||
|
|
])
|
||
|
|
|
||
|
|
inv_table = Table(inv_data, colWidths=[2*inch, 1*inch, 0.8*inch, 0.6*inch, 0.8*inch])
|
||
|
|
inv_table.setStyle(TableStyle([
|
||
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
||
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||
|
|
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black)
|
||
|
|
]))
|
||
|
|
elements.append(inv_table)
|
||
|
|
|
||
|
|
doc.build(elements)
|
||
|
|
buffer.seek(0)
|
||
|
|
|
||
|
|
return StreamingResponse(
|
||
|
|
buffer,
|
||
|
|
media_type="application/pdf",
|
||
|
|
headers={"Content-Disposition": f"attachment; filename={report_type}_report_{now.strftime('%Y%m%d')}.pdf"}
|
||
|
|
)
|
||
|
|
|
||
|
|
# ================== USER MANAGEMENT ==================
|
||
|
|
|
||
|
|
class UserCreateAdmin(BaseModel):
|
||
|
|
email: EmailStr
|
||
|
|
name: str
|
||
|
|
password: str
|
||
|
|
role: str
|
||
|
|
is_active: bool = True
|
||
|
|
password_never_change: bool = False
|
||
|
|
|
||
|
|
class UserUpdateAdmin(BaseModel):
|
||
|
|
email: Optional[EmailStr] = None
|
||
|
|
name: Optional[str] = None
|
||
|
|
password: Optional[str] = None # Allow password updates
|
||
|
|
role: Optional[str] = None
|
||
|
|
is_active: Optional[bool] = None
|
||
|
|
password_never_change: Optional[bool] = None
|
||
|
|
|
||
|
|
@api_router.get("/admin/users")
|
||
|
|
async def get_all_users(
|
||
|
|
skip: int = 0,
|
||
|
|
limit: int = 20,
|
||
|
|
search: str = "",
|
||
|
|
role: str = "",
|
||
|
|
status: str = "",
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get all users with filters"""
|
||
|
|
query = select(User)
|
||
|
|
|
||
|
|
# Apply search filter
|
||
|
|
if search:
|
||
|
|
query = query.where(
|
||
|
|
or_(
|
||
|
|
User.name.ilike(f"%{search}%"),
|
||
|
|
User.email.ilike(f"%{search}%")
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
# Apply role filter
|
||
|
|
if role:
|
||
|
|
try:
|
||
|
|
role_enum = UserRole[role.upper()]
|
||
|
|
query = query.where(User.role == role_enum)
|
||
|
|
except KeyError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Apply status filter
|
||
|
|
if status == "active":
|
||
|
|
query = query.where(User.is_active == True)
|
||
|
|
elif status == "inactive":
|
||
|
|
query = query.where(User.is_active == False)
|
||
|
|
|
||
|
|
# Get total count
|
||
|
|
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
|
||
|
|
total = count_result.scalar()
|
||
|
|
|
||
|
|
# Apply pagination
|
||
|
|
query = query.offset(skip).limit(limit).order_by(User.created_at.desc())
|
||
|
|
result = await db.execute(query)
|
||
|
|
users = result.scalars().all()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"users": [user_to_dict(user) for user in users],
|
||
|
|
"total": total,
|
||
|
|
"skip": skip,
|
||
|
|
"limit": limit
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.post("/admin/users")
|
||
|
|
async def create_user(
|
||
|
|
user_data: UserCreateAdmin,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Create a new user (admin only)"""
|
||
|
|
# Check if email already exists
|
||
|
|
result = await db.execute(select(User).where(User.email == user_data.email))
|
||
|
|
if result.scalar_one_or_none():
|
||
|
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||
|
|
|
||
|
|
# Validate role
|
||
|
|
try:
|
||
|
|
role_enum = UserRole[user_data.role.upper()]
|
||
|
|
except KeyError:
|
||
|
|
raise HTTPException(status_code=400, detail=f"Invalid role: {user_data.role}")
|
||
|
|
|
||
|
|
# Create new user
|
||
|
|
new_user = User(
|
||
|
|
email=user_data.email,
|
||
|
|
name=user_data.name,
|
||
|
|
password=hash_password(user_data.password),
|
||
|
|
role=role_enum,
|
||
|
|
is_active=user_data.is_active
|
||
|
|
)
|
||
|
|
|
||
|
|
db.add(new_user)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(new_user)
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} created user {new_user.email} with role {role_enum.value}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"message": "User created successfully",
|
||
|
|
"user": user_to_dict(new_user)
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.put("/admin/users/{user_id}")
|
||
|
|
async def update_user(
|
||
|
|
user_id: str,
|
||
|
|
user_data: UserUpdateAdmin,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Update user details (admin only)"""
|
||
|
|
user = await _get_or_404(db, User, user_id, "User not found")
|
||
|
|
|
||
|
|
# Update fields if provided
|
||
|
|
if user_data.email is not None:
|
||
|
|
# Check if new email already exists
|
||
|
|
result = await db.execute(
|
||
|
|
select(User).where(User.email == user_data.email, User.id != user_id)
|
||
|
|
)
|
||
|
|
if result.scalar_one_or_none():
|
||
|
|
raise HTTPException(status_code=400, detail="Email already in use")
|
||
|
|
user.email = user_data.email
|
||
|
|
|
||
|
|
if user_data.name is not None:
|
||
|
|
user.name = user_data.name
|
||
|
|
|
||
|
|
if user_data.password is not None and user_data.password.strip():
|
||
|
|
# Only update password if provided and not empty
|
||
|
|
user.password = hash_password(user_data.password)
|
||
|
|
|
||
|
|
if user_data.role is not None:
|
||
|
|
try:
|
||
|
|
role_enum = UserRole[user_data.role.upper()]
|
||
|
|
user.role = role_enum
|
||
|
|
except KeyError:
|
||
|
|
raise HTTPException(status_code=400, detail=f"Invalid role: {user_data.role}")
|
||
|
|
|
||
|
|
if user_data.is_active is not None:
|
||
|
|
user.is_active = user_data.is_active
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(user)
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} updated user {user.email}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"message": "User updated successfully",
|
||
|
|
"user": user_to_dict(user)
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.put("/admin/users/{user_id}/toggle-active")
|
||
|
|
async def toggle_user_active(
|
||
|
|
user_id: str,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Toggle user active status"""
|
||
|
|
user = await _get_or_404(db, User, user_id, "User not found")
|
||
|
|
|
||
|
|
# Prevent deactivating yourself
|
||
|
|
if user.id == admin.id:
|
||
|
|
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
|
||
|
|
|
||
|
|
user.is_active = not user.is_active
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(user)
|
||
|
|
|
||
|
|
status = "activated" if user.is_active else "deactivated"
|
||
|
|
logger.info(f"Admin {admin.email} {status} user {user.email}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"message": f"User {status} successfully",
|
||
|
|
"user": user_to_dict(user)
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.delete("/admin/users/{user_id}")
|
||
|
|
async def delete_user(
|
||
|
|
user_id: str,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Delete a user (admin only)"""
|
||
|
|
user = await _get_or_404(db, User, user_id, "User not found")
|
||
|
|
|
||
|
|
# Prevent deleting yourself
|
||
|
|
if user.id == admin.id:
|
||
|
|
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||
|
|
|
||
|
|
await db.delete(user)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} deleted user {user.email}")
|
||
|
|
|
||
|
|
return {"message": "User deleted successfully"}
|
||
|
|
|
||
|
|
# ================== ABOUT PAGE CMS ==================
|
||
|
|
|
||
|
|
# Pydantic schemas for About page
|
||
|
|
class AboutContentCreate(BaseModel):
|
||
|
|
section: str
|
||
|
|
title: Optional[str] = None
|
||
|
|
subtitle: Optional[str] = None
|
||
|
|
content: Optional[str] = None
|
||
|
|
image_url: Optional[str] = None
|
||
|
|
data: Optional[dict] = None
|
||
|
|
display_order: int = 0
|
||
|
|
is_active: bool = True
|
||
|
|
|
||
|
|
class AboutContentUpdate(BaseModel):
|
||
|
|
title: Optional[str] = None
|
||
|
|
subtitle: Optional[str] = None
|
||
|
|
content: Optional[str] = None
|
||
|
|
image_url: Optional[str] = None
|
||
|
|
data: Optional[dict] = None
|
||
|
|
display_order: Optional[int] = None
|
||
|
|
is_active: Optional[bool] = None
|
||
|
|
|
||
|
|
class TeamMemberCreate(BaseModel):
|
||
|
|
name: str
|
||
|
|
role: str
|
||
|
|
bio: Optional[str] = None
|
||
|
|
image_url: Optional[str] = None
|
||
|
|
email: Optional[str] = None
|
||
|
|
linkedin: Optional[str] = None
|
||
|
|
display_order: int = 0
|
||
|
|
is_active: bool = True
|
||
|
|
|
||
|
|
class TeamMemberUpdate(BaseModel):
|
||
|
|
name: Optional[str] = None
|
||
|
|
role: Optional[str] = None
|
||
|
|
bio: Optional[str] = None
|
||
|
|
image_url: Optional[str] = None
|
||
|
|
email: Optional[str] = None
|
||
|
|
linkedin: Optional[str] = None
|
||
|
|
display_order: Optional[int] = None
|
||
|
|
is_active: Optional[bool] = None
|
||
|
|
|
||
|
|
class CompanyValueCreate(BaseModel):
|
||
|
|
title: str
|
||
|
|
description: str
|
||
|
|
icon: str
|
||
|
|
display_order: int = 0
|
||
|
|
is_active: bool = True
|
||
|
|
|
||
|
|
class CompanyValueUpdate(BaseModel):
|
||
|
|
title: Optional[str] = None
|
||
|
|
description: Optional[str] = None
|
||
|
|
icon: Optional[str] = None
|
||
|
|
display_order: Optional[int] = None
|
||
|
|
is_active: Optional[bool] = None
|
||
|
|
|
||
|
|
# Public endpoints (no auth required)
|
||
|
|
|
||
|
|
@api_router.get("/about/content")
|
||
|
|
async def get_about_content(db: AsyncSession = Depends(get_db)):
|
||
|
|
"""Get all active about content sections"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(AboutContent)
|
||
|
|
.where(AboutContent.is_active == True)
|
||
|
|
.order_by(AboutContent.section, AboutContent.display_order)
|
||
|
|
)
|
||
|
|
content = result.scalars().all()
|
||
|
|
|
||
|
|
return [{
|
||
|
|
"id": str(item.id),
|
||
|
|
"section": item.section,
|
||
|
|
"title": item.title,
|
||
|
|
"subtitle": item.subtitle,
|
||
|
|
"content": item.content,
|
||
|
|
"image_url": item.image_url,
|
||
|
|
"data": item.data,
|
||
|
|
"display_order": item.display_order,
|
||
|
|
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||
|
|
"updated_at": item.updated_at.isoformat() if item.updated_at else None
|
||
|
|
} for item in content]
|
||
|
|
|
||
|
|
@api_router.get("/about/team")
|
||
|
|
async def get_team_members(db: AsyncSession = Depends(get_db)):
|
||
|
|
"""Get all active team members"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(TeamMember)
|
||
|
|
.where(TeamMember.is_active == True)
|
||
|
|
.order_by(TeamMember.display_order)
|
||
|
|
)
|
||
|
|
members = result.scalars().all()
|
||
|
|
|
||
|
|
return [{
|
||
|
|
"id": str(member.id),
|
||
|
|
"name": member.name,
|
||
|
|
"role": member.role,
|
||
|
|
"bio": member.bio,
|
||
|
|
"image_url": member.image_url,
|
||
|
|
"email": member.email,
|
||
|
|
"linkedin": member.linkedin,
|
||
|
|
"display_order": member.display_order
|
||
|
|
} for member in members]
|
||
|
|
|
||
|
|
@api_router.get("/about/values")
|
||
|
|
async def get_company_values(db: AsyncSession = Depends(get_db)):
|
||
|
|
"""Get all active company values"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(CompanyValue)
|
||
|
|
.where(CompanyValue.is_active == True)
|
||
|
|
.order_by(CompanyValue.display_order)
|
||
|
|
)
|
||
|
|
values = result.scalars().all()
|
||
|
|
|
||
|
|
return [{
|
||
|
|
"id": str(value.id),
|
||
|
|
"title": value.title,
|
||
|
|
"description": value.description,
|
||
|
|
"icon": value.icon,
|
||
|
|
"display_order": value.display_order
|
||
|
|
} for value in values]
|
||
|
|
|
||
|
|
# Admin endpoints (auth required)
|
||
|
|
|
||
|
|
@api_router.get("/admin/about/content")
|
||
|
|
async def admin_get_all_about_content(
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get all about content sections (including inactive)"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(AboutContent).order_by(AboutContent.section, AboutContent.display_order)
|
||
|
|
)
|
||
|
|
content = result.scalars().all()
|
||
|
|
|
||
|
|
return [{
|
||
|
|
"id": str(item.id),
|
||
|
|
"section": item.section,
|
||
|
|
"title": item.title,
|
||
|
|
"subtitle": item.subtitle,
|
||
|
|
"content": item.content,
|
||
|
|
"image_url": item.image_url,
|
||
|
|
"data": item.data,
|
||
|
|
"display_order": item.display_order,
|
||
|
|
"is_active": item.is_active,
|
||
|
|
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||
|
|
"updated_at": item.updated_at.isoformat() if item.updated_at else None
|
||
|
|
} for item in content]
|
||
|
|
|
||
|
|
@api_router.post("/admin/about/content")
|
||
|
|
async def admin_create_about_content(
|
||
|
|
content_data: AboutContentCreate,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Create new about content section"""
|
||
|
|
content = AboutContent(
|
||
|
|
section=content_data.section,
|
||
|
|
title=content_data.title,
|
||
|
|
subtitle=content_data.subtitle,
|
||
|
|
content=content_data.content,
|
||
|
|
image_url=content_data.image_url,
|
||
|
|
data=content_data.data,
|
||
|
|
display_order=content_data.display_order,
|
||
|
|
is_active=content_data.is_active
|
||
|
|
)
|
||
|
|
|
||
|
|
db.add(content)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(content)
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} created about content section: {content.section}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"id": str(content.id),
|
||
|
|
"section": content.section,
|
||
|
|
"title": content.title,
|
||
|
|
"subtitle": content.subtitle,
|
||
|
|
"content": content.content,
|
||
|
|
"image_url": content.image_url,
|
||
|
|
"data": content.data,
|
||
|
|
"display_order": content.display_order,
|
||
|
|
"is_active": content.is_active,
|
||
|
|
"created_at": content.created_at.isoformat() if content.created_at else None,
|
||
|
|
"updated_at": content.updated_at.isoformat() if content.updated_at else None
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.put("/admin/about/content/{content_id}")
|
||
|
|
async def admin_update_about_content(
|
||
|
|
content_id: str,
|
||
|
|
content_data: AboutContentUpdate,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Update about content section"""
|
||
|
|
try:
|
||
|
|
content_uuid = uuid.UUID(content_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid content ID format")
|
||
|
|
|
||
|
|
result = await db.execute(select(AboutContent).where(AboutContent.id == content_uuid))
|
||
|
|
content = result.scalar_one_or_none()
|
||
|
|
|
||
|
|
if not content:
|
||
|
|
raise HTTPException(status_code=404, detail="Content not found")
|
||
|
|
|
||
|
|
# Update fields
|
||
|
|
if content_data.title is not None:
|
||
|
|
content.title = content_data.title
|
||
|
|
if content_data.subtitle is not None:
|
||
|
|
content.subtitle = content_data.subtitle
|
||
|
|
if content_data.content is not None:
|
||
|
|
content.content = content_data.content
|
||
|
|
if content_data.image_url is not None:
|
||
|
|
content.image_url = content_data.image_url
|
||
|
|
if content_data.data is not None:
|
||
|
|
content.data = content_data.data
|
||
|
|
if content_data.display_order is not None:
|
||
|
|
content.display_order = content_data.display_order
|
||
|
|
if content_data.is_active is not None:
|
||
|
|
content.is_active = content_data.is_active
|
||
|
|
|
||
|
|
content.updated_at = datetime.utcnow()
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(content)
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} updated about content: {content.section}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"id": str(content.id),
|
||
|
|
"section": content.section,
|
||
|
|
"title": content.title,
|
||
|
|
"subtitle": content.subtitle,
|
||
|
|
"content": content.content,
|
||
|
|
"image_url": content.image_url,
|
||
|
|
"data": content.data,
|
||
|
|
"display_order": content.display_order,
|
||
|
|
"is_active": content.is_active,
|
||
|
|
"updated_at": content.updated_at.isoformat() if content.updated_at else None
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.delete("/admin/about/content/{content_id}")
|
||
|
|
async def admin_delete_about_content(
|
||
|
|
content_id: str,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Delete about content section"""
|
||
|
|
try:
|
||
|
|
content_uuid = uuid.UUID(content_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid content ID format")
|
||
|
|
|
||
|
|
result = await db.execute(select(AboutContent).where(AboutContent.id == content_uuid))
|
||
|
|
content = result.scalar_one_or_none()
|
||
|
|
|
||
|
|
if not content:
|
||
|
|
raise HTTPException(status_code=404, detail="Content not found")
|
||
|
|
|
||
|
|
await db.delete(content)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} deleted about content: {content.section}")
|
||
|
|
|
||
|
|
return {"message": "Content deleted successfully"}
|
||
|
|
|
||
|
|
# Team Members CRUD
|
||
|
|
|
||
|
|
@api_router.get("/admin/about/team")
|
||
|
|
async def admin_get_all_team_members(
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get all team members (including inactive)"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(TeamMember).order_by(TeamMember.display_order)
|
||
|
|
)
|
||
|
|
members = result.scalars().all()
|
||
|
|
|
||
|
|
return [{
|
||
|
|
"id": str(member.id),
|
||
|
|
"name": member.name,
|
||
|
|
"role": member.role,
|
||
|
|
"bio": member.bio,
|
||
|
|
"image_url": member.image_url,
|
||
|
|
"email": member.email,
|
||
|
|
"linkedin": member.linkedin,
|
||
|
|
"display_order": member.display_order,
|
||
|
|
"is_active": member.is_active,
|
||
|
|
"created_at": member.created_at.isoformat() if member.created_at else None,
|
||
|
|
"updated_at": member.updated_at.isoformat() if member.updated_at else None
|
||
|
|
} for member in members]
|
||
|
|
|
||
|
|
@api_router.post("/admin/about/team")
|
||
|
|
async def admin_create_team_member(
|
||
|
|
member_data: TeamMemberCreate,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Create new team member"""
|
||
|
|
member = TeamMember(
|
||
|
|
name=member_data.name,
|
||
|
|
role=member_data.role,
|
||
|
|
bio=member_data.bio,
|
||
|
|
image_url=member_data.image_url,
|
||
|
|
email=member_data.email,
|
||
|
|
linkedin=member_data.linkedin,
|
||
|
|
display_order=member_data.display_order,
|
||
|
|
is_active=member_data.is_active
|
||
|
|
)
|
||
|
|
|
||
|
|
db.add(member)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(member)
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} created team member: {member.name}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"id": str(member.id),
|
||
|
|
"name": member.name,
|
||
|
|
"role": member.role,
|
||
|
|
"bio": member.bio,
|
||
|
|
"image_url": member.image_url,
|
||
|
|
"email": member.email,
|
||
|
|
"linkedin": member.linkedin,
|
||
|
|
"display_order": member.display_order,
|
||
|
|
"is_active": member.is_active,
|
||
|
|
"created_at": member.created_at.isoformat() if member.created_at else None
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.put("/admin/about/team/{member_id}")
|
||
|
|
async def admin_update_team_member(
|
||
|
|
member_id: str,
|
||
|
|
member_data: TeamMemberUpdate,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Update team member"""
|
||
|
|
try:
|
||
|
|
member_uuid = uuid.UUID(member_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid member ID format")
|
||
|
|
|
||
|
|
result = await db.execute(select(TeamMember).where(TeamMember.id == member_uuid))
|
||
|
|
member = result.scalar_one_or_none()
|
||
|
|
|
||
|
|
if not member:
|
||
|
|
raise HTTPException(status_code=404, detail="Team member not found")
|
||
|
|
|
||
|
|
# Update fields
|
||
|
|
if member_data.name is not None:
|
||
|
|
member.name = member_data.name
|
||
|
|
if member_data.role is not None:
|
||
|
|
member.role = member_data.role
|
||
|
|
if member_data.bio is not None:
|
||
|
|
member.bio = member_data.bio
|
||
|
|
if member_data.image_url is not None:
|
||
|
|
member.image_url = member_data.image_url
|
||
|
|
if member_data.email is not None:
|
||
|
|
member.email = member_data.email
|
||
|
|
if member_data.linkedin is not None:
|
||
|
|
member.linkedin = member_data.linkedin
|
||
|
|
if member_data.display_order is not None:
|
||
|
|
member.display_order = member_data.display_order
|
||
|
|
if member_data.is_active is not None:
|
||
|
|
member.is_active = member_data.is_active
|
||
|
|
|
||
|
|
member.updated_at = datetime.utcnow()
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(member)
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} updated team member: {member.name}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"id": str(member.id),
|
||
|
|
"name": member.name,
|
||
|
|
"role": member.role,
|
||
|
|
"bio": member.bio,
|
||
|
|
"image_url": member.image_url,
|
||
|
|
"email": member.email,
|
||
|
|
"linkedin": member.linkedin,
|
||
|
|
"display_order": member.display_order,
|
||
|
|
"is_active": member.is_active,
|
||
|
|
"updated_at": member.updated_at.isoformat() if member.updated_at else None
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.delete("/admin/about/team/{member_id}")
|
||
|
|
async def admin_delete_team_member(
|
||
|
|
member_id: str,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Delete team member"""
|
||
|
|
try:
|
||
|
|
member_uuid = uuid.UUID(member_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid member ID format")
|
||
|
|
|
||
|
|
result = await db.execute(select(TeamMember).where(TeamMember.id == member_uuid))
|
||
|
|
member = result.scalar_one_or_none()
|
||
|
|
|
||
|
|
if not member:
|
||
|
|
raise HTTPException(status_code=404, detail="Team member not found")
|
||
|
|
|
||
|
|
await db.delete(member)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} deleted team member: {member.name}")
|
||
|
|
|
||
|
|
return {"message": "Team member deleted successfully"}
|
||
|
|
|
||
|
|
# Company Values CRUD
|
||
|
|
|
||
|
|
@api_router.get("/admin/about/values")
|
||
|
|
async def admin_get_all_company_values(
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get all company values (including inactive)"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(CompanyValue).order_by(CompanyValue.display_order)
|
||
|
|
)
|
||
|
|
values = result.scalars().all()
|
||
|
|
|
||
|
|
return [{
|
||
|
|
"id": str(value.id),
|
||
|
|
"title": value.title,
|
||
|
|
"description": value.description,
|
||
|
|
"icon": value.icon,
|
||
|
|
"display_order": value.display_order,
|
||
|
|
"is_active": value.is_active,
|
||
|
|
"created_at": value.created_at.isoformat() if value.created_at else None,
|
||
|
|
"updated_at": value.updated_at.isoformat() if value.updated_at else None
|
||
|
|
} for value in values]
|
||
|
|
|
||
|
|
@api_router.post("/admin/about/values")
|
||
|
|
async def admin_create_company_value(
|
||
|
|
value_data: CompanyValueCreate,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Create new company value"""
|
||
|
|
value = CompanyValue(
|
||
|
|
title=value_data.title,
|
||
|
|
description=value_data.description,
|
||
|
|
icon=value_data.icon,
|
||
|
|
display_order=value_data.display_order,
|
||
|
|
is_active=value_data.is_active
|
||
|
|
)
|
||
|
|
|
||
|
|
db.add(value)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(value)
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} created company value: {value.title}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"id": str(value.id),
|
||
|
|
"title": value.title,
|
||
|
|
"description": value.description,
|
||
|
|
"icon": value.icon,
|
||
|
|
"display_order": value.display_order,
|
||
|
|
"is_active": value.is_active,
|
||
|
|
"created_at": value.created_at.isoformat() if value.created_at else None
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.put("/admin/about/values/{value_id}")
|
||
|
|
async def admin_update_company_value(
|
||
|
|
value_id: str,
|
||
|
|
value_data: CompanyValueUpdate,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Update company value"""
|
||
|
|
try:
|
||
|
|
value_uuid = uuid.UUID(value_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid value ID format")
|
||
|
|
|
||
|
|
result = await db.execute(select(CompanyValue).where(CompanyValue.id == value_uuid))
|
||
|
|
value = result.scalar_one_or_none()
|
||
|
|
|
||
|
|
if not value:
|
||
|
|
raise HTTPException(status_code=404, detail="Company value not found")
|
||
|
|
|
||
|
|
# Update fields
|
||
|
|
if value_data.title is not None:
|
||
|
|
value.title = value_data.title
|
||
|
|
if value_data.description is not None:
|
||
|
|
value.description = value_data.description
|
||
|
|
if value_data.icon is not None:
|
||
|
|
value.icon = value_data.icon
|
||
|
|
if value_data.display_order is not None:
|
||
|
|
value.display_order = value_data.display_order
|
||
|
|
if value_data.is_active is not None:
|
||
|
|
value.is_active = value_data.is_active
|
||
|
|
|
||
|
|
value.updated_at = datetime.utcnow()
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(value)
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} updated company value: {value.title}")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"id": str(value.id),
|
||
|
|
"title": value.title,
|
||
|
|
"description": value.description,
|
||
|
|
"icon": value.icon,
|
||
|
|
"display_order": value.display_order,
|
||
|
|
"is_active": value.is_active,
|
||
|
|
"updated_at": value.updated_at.isoformat() if value.updated_at else None
|
||
|
|
}
|
||
|
|
|
||
|
|
@api_router.delete("/admin/about/values/{value_id}")
|
||
|
|
async def admin_delete_company_value(
|
||
|
|
value_id: str,
|
||
|
|
admin: User = Depends(get_admin_user),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Delete company value"""
|
||
|
|
try:
|
||
|
|
value_uuid = uuid.UUID(value_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid value ID format")
|
||
|
|
|
||
|
|
result = await db.execute(select(CompanyValue).where(CompanyValue.id == value_uuid))
|
||
|
|
value = result.scalar_one_or_none()
|
||
|
|
|
||
|
|
if not value:
|
||
|
|
raise HTTPException(status_code=404, detail="Company value not found")
|
||
|
|
|
||
|
|
await db.delete(value)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
logger.info(f"Admin {admin.email} deleted company value: {value.title}")
|
||
|
|
|
||
|
|
return {"message": "Company value deleted successfully"}
|
||
|
|
|
||
|
|
# ================== SEED DATA ==================
|
||
|
|
|
||
|
|
@api_router.post("/seed")
|
||
|
|
async def seed_data(db: AsyncSession = Depends(get_db)):
|
||
|
|
# Check if data exists
|
||
|
|
result = await db.execute(select(func.count(Product.id)))
|
||
|
|
if result.scalar() > 0:
|
||
|
|
return {"message": "Data already seeded"}
|
||
|
|
|
||
|
|
# Create admin user
|
||
|
|
admin = User(
|
||
|
|
email="admin@techzone.com",
|
||
|
|
name="Admin",
|
||
|
|
password=hash_password("admin123"),
|
||
|
|
role=UserRole.ADMIN
|
||
|
|
)
|
||
|
|
db.add(admin)
|
||
|
|
|
||
|
|
# Create products
|
||
|
|
products = [
|
||
|
|
Product(name="MacBook Pro 16\"", description="Powerful laptop with M3 Pro chip, 18GB RAM, 512GB SSD.", price=2499.99, category="laptops", image_url="https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=800", stock=15, brand="Apple", specs={"processor": "M3 Pro", "ram": "18GB", "storage": "512GB SSD"}),
|
||
|
|
Product(name="Dell XPS 15", description="Ultra-thin laptop with Intel Core i7, 16GB RAM, stunning OLED display.", price=1799.99, category="laptops", image_url="https://images.unsplash.com/photo-1593642632559-0c6d3fc62b89?w=800", stock=20, brand="Dell", specs={"processor": "Intel i7", "ram": "16GB", "storage": "512GB SSD"}),
|
||
|
|
Product(name="iPhone 15 Pro Max", description="Latest iPhone with titanium design, A17 Pro chip, 48MP camera.", price=1199.99, category="phones", image_url="https://images.unsplash.com/photo-1695048133142-1a20484d2569?w=800", stock=30, brand="Apple", specs={"display": "6.7\" OLED", "camera": "48MP", "storage": "256GB"}),
|
||
|
|
Product(name="Samsung Galaxy S24 Ultra", description="Premium Android phone with S Pen, 200MP camera, AI features.", price=1299.99, category="phones", image_url="https://images.unsplash.com/photo-1610945265064-0e34e5519bbf?w=800", stock=25, brand="Samsung", specs={"display": "6.8\" AMOLED", "camera": "200MP", "storage": "512GB"}),
|
||
|
|
Product(name="Sony WH-1000XM5", description="Industry-leading noise cancellation, 30-hour battery.", price=349.99, category="accessories", image_url="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=800", stock=40, brand="Sony", specs={"type": "Over-ear", "battery": "30 hours"}),
|
||
|
|
Product(name="iPad Pro 12.9\"", description="Powerful tablet with M2 chip, Liquid Retina XDR display.", price=1099.99, category="tablets", image_url="https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?w=800", stock=18, brand="Apple", specs={"processor": "M2", "display": "12.9\" XDR"}),
|
||
|
|
Product(name="Apple Watch Ultra 2", description="Rugged smartwatch with titanium case, GPS, 36-hour battery.", price=799.99, category="wearables", image_url="https://images.unsplash.com/photo-1434493789847-2f02dc6ca35d?w=800", stock=22, brand="Apple", specs={"display": "49mm", "battery": "36 hours"}),
|
||
|
|
Product(name="Logitech MX Master 3S", description="Premium wireless mouse with 8K DPI sensor, silent clicks.", price=99.99, category="accessories", image_url="https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=800", stock=50, brand="Logitech", specs={"sensor": "8K DPI", "battery": "70 days"}),
|
||
|
|
]
|
||
|
|
for p in products:
|
||
|
|
db.add(p)
|
||
|
|
|
||
|
|
# Create services
|
||
|
|
services = [
|
||
|
|
Service(name="Screen Repair", description="Professional screen replacement for phones, tablets, and laptops.", price=149.99, duration="1-2 hours", image_url="https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=800", category="repair"),
|
||
|
|
Service(name="Battery Replacement", description="Restore your device's battery life with genuine replacement.", price=79.99, duration="30-60 mins", image_url="https://images.unsplash.com/photo-1609091839311-d5365f9ff1c5?w=800", category="repair"),
|
||
|
|
Service(name="Data Recovery", description="Professional data recovery from damaged drives.", price=199.99, duration="2-5 days", image_url="https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800", category="data"),
|
||
|
|
Service(name="Virus Removal", description="Complete malware and virus removal with system optimization.", price=89.99, duration="1-3 hours", image_url="https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=800", category="software"),
|
||
|
|
Service(name="Hardware Upgrade", description="Upgrade your RAM, SSD, or other components.", price=49.99, duration="1-2 hours", image_url="https://images.unsplash.com/photo-1591799265444-d66432b91588?w=800", category="upgrade"),
|
||
|
|
Service(name="Device Setup", description="Complete setup service for new devices including data transfer.", price=59.99, duration="1-2 hours", image_url="https://images.unsplash.com/photo-1531297484001-80022131f5a1?w=800", category="setup"),
|
||
|
|
]
|
||
|
|
for s in services:
|
||
|
|
db.add(s)
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
return {"message": "Data seeded successfully"}
|
||
|
|
|
||
|
|
# ================== ROOT ==================
|
||
|
|
|
||
|
|
@api_router.get("/")
|
||
|
|
async def root():
|
||
|
|
return {"message": "TechZone API is running", "version": "2.0.0", "status": "healthy"}
|
||
|
|
|
||
|
|
@api_router.get("/health")
|
||
|
|
async def health_check(db: AsyncSession = Depends(get_db)):
|
||
|
|
"""Comprehensive health check endpoint"""
|
||
|
|
try:
|
||
|
|
# Test database connection
|
||
|
|
result = await db.execute(select(func.count(User.id)))
|
||
|
|
user_count = result.scalar()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"status": "healthy",
|
||
|
|
"database": "connected",
|
||
|
|
"api_version": "2.0.0",
|
||
|
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
||
|
|
}
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Health check failed: {e}")
|
||
|
|
return {
|
||
|
|
"status": "degraded",
|
||
|
|
"database": "error",
|
||
|
|
"error": str(e),
|
||
|
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
||
|
|
}
|
||
|
|
|
||
|
|
# Include the router
|
||
|
|
app.include_router(api_router)
|
||
|
|
|
||
|
|
# Mount static files for uploaded images
|
||
|
|
app.mount("/uploads", StaticFiles(directory=str(ROOT_DIR / "uploads")), name="uploads")
|
||
|
|
|
||
|
|
app.add_middleware(
|
||
|
|
CORSMiddleware,
|
||
|
|
allow_credentials=True,
|
||
|
|
allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','),
|
||
|
|
allow_methods=["*"],
|
||
|
|
allow_headers=["*"],
|
||
|
|
)
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
import uvicorn
|
||
|
|
uvicorn.run(app, host="0.0.0.0", port=8181)
|