diff --git a/.DS_Store b/.DS_Store index 69c8143..81bd850 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/app.db b/backend/app.db new file mode 100644 index 0000000..819cd93 Binary files /dev/null and b/backend/app.db differ diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc index 6d7f6cc..7e31dfc 100644 Binary files a/backend/app/__pycache__/config.cpython-310.pyc and b/backend/app/__pycache__/config.cpython-310.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py index 721f798..5aa6aba 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,10 +1,52 @@ import os from pydantic_settings import BaseSettings from dotenv import load_dotenv +from pathlib import Path # Загружаем переменные окружения из .env файла load_dotenv() +# Базовые настройки +API_PREFIX = "/api" +DEBUG = True + +# Настройки безопасности +SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey") # Для JWT +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней + +# Настройки базы данных +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db") + +# Настройки почты +MAIL_USERNAME = os.getenv("MAIL_USERNAME", "test@example.com") +MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", "test_password") +MAIL_FROM = os.getenv("MAIL_FROM", "noreply@example.com") +MAIL_PORT = int(os.getenv("MAIL_PORT", "587")) +MAIL_SERVER = os.getenv("MAIL_SERVER", "smtp.example.com") +MAIL_TLS = True +MAIL_SSL = False + +# Настройки загрузки файлов +UPLOAD_DIRECTORY = os.getenv("UPLOAD_DIRECTORY", "uploads") +ALLOWED_UPLOAD_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} +MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 МБ + +# Создаем директорию для загрузок при запуске, если её нет +uploads_dir = Path(UPLOAD_DIRECTORY) +uploads_dir.mkdir(parents=True, exist_ok=True) + +products_dir = uploads_dir / "products" +products_dir.mkdir(parents=True, exist_ok=True) + +# Настройки корзины +CART_EXPIRATION_DAYS = 30 # Срок хранения корзины + +# Настройки API +MAX_PAGE_SIZE = 100 # Максимальный размер страницы для пагинации + +# Базовый URL фронтенда +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") class Settings(BaseSettings): # Основные настройки приложения @@ -16,9 +58,9 @@ class Settings(BaseSettings): DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5434/shop_db") # Настройки безопасности - SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-for-jwt-please-change-in-production") - ALGORITHM: str = "HS256" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 30*60*24 + SECRET_KEY: str = SECRET_KEY + ALGORITHM: str = ALGORITHM + ACCESS_TOKEN_EXPIRE_MINUTES: int = ACCESS_TOKEN_EXPIRE_MINUTES # Настройки CORS CORS_ORIGINS: list = [ @@ -29,20 +71,20 @@ class Settings(BaseSettings): ] # Настройки для загрузки файлов - UPLOAD_DIRECTORY: str = "uploads" - MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5 MB - ALLOWED_UPLOAD_EXTENSIONS: list = ["jpg", "jpeg", "png", "gif", "webp"] + UPLOAD_DIRECTORY: str = UPLOAD_DIRECTORY + MAX_UPLOAD_SIZE: int = MAX_UPLOAD_SIZE + ALLOWED_UPLOAD_EXTENSIONS: list = list(ALLOWED_UPLOAD_EXTENSIONS) # Настройки для платежных систем (пример) PAYMENT_GATEWAY_API_KEY: str = os.getenv("PAYMENT_GATEWAY_API_KEY", "") PAYMENT_GATEWAY_SECRET: str = os.getenv("PAYMENT_GATEWAY_SECRET", "") # Настройки для отправки email (пример) - SMTP_SERVER: str = os.getenv("SMTP_SERVER", "") - SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587")) - SMTP_USERNAME: str = os.getenv("SMTP_USERNAME", "") - SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "") - EMAIL_FROM: str = os.getenv("EMAIL_FROM", "noreply@example.com") + SMTP_SERVER: str = MAIL_SERVER + SMTP_PORT: int = MAIL_PORT + SMTP_USERNAME: str = MAIL_USERNAME + SMTP_PASSWORD: str = MAIL_PASSWORD + EMAIL_FROM: str = MAIL_FROM class Config: env_file = ".env" diff --git a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc index 529d69e..b7e6ecd 100644 Binary files a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc index 1498564..4dbfdeb 100644 Binary files a/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/catalog_repo.py b/backend/app/repositories/catalog_repo.py index f241f74..b5b7d88 100644 --- a/backend/app/repositories/catalog_repo.py +++ b/backend/app/repositories/catalog_repo.py @@ -727,7 +727,7 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage ) -def update_product_image(db: Session, image_id: int, is_primary: bool) -> ProductImage: +def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) -> ProductImage: db_image = get_product_image(db, image_id) if not db_image: raise HTTPException( @@ -736,14 +736,17 @@ def update_product_image(db: Session, image_id: int, is_primary: bool) -> Produc ) # Если изображение отмечается как основное, сбрасываем флаг у других изображений - if is_primary and not db_image.is_primary: + if image.is_primary and not db_image.is_primary: db.query(ProductImage).filter( ProductImage.product_id == db_image.product_id, ProductImage.is_primary == True ).update({"is_primary": False}) - # Обновляем флаг - db_image.is_primary = is_primary + # Обновляем поля + if image.alt_text is not None: + db_image.alt_text = image.alt_text + if image.is_primary is not None: + db_image.is_primary = image.is_primary try: db.commit() diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py index 8cf48f5..d39cd5b 100644 --- a/backend/app/repositories/user_repo.py +++ b/backend/app/repositories/user_repo.py @@ -14,6 +14,11 @@ def get_user(db: Session, user_id: int) -> Optional[User]: def get_user_by_email(db: Session, email: str) -> Optional[User]: + print(email) + users = db.query(User).all() + print(users) + for user in users: + print(f"ID: {user.id}, Email: {user.email}, Name: {user.first_name} {user.last_name}") return db.query(User).filter(User.email == email).first() diff --git a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc index 79b249d..97c36cf 100644 Binary files a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc and b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc differ diff --git a/backend/app/routers/catalog_router.py b/backend/app/routers/catalog_router.py index 44af916..856c4b6 100644 --- a/backend/app/routers/catalog_router.py +++ b/backend/app/routers/catalog_router.py @@ -1,6 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query from sqlalchemy.orm import Session from typing import List, Optional, Dict, Any +from fastapi.responses import JSONResponse +import logging +import traceback from app.core import get_db, get_current_admin_user from app import services @@ -10,7 +13,8 @@ from app.schemas.catalog_schemas import ( ProductVariantCreate, ProductVariantUpdate, ProductVariant, ProductImageCreate, ProductImageUpdate, ProductImage, CollectionCreate, CollectionUpdate, Collection, - SizeCreate, SizeUpdate, Size + SizeCreate, SizeUpdate, Size, + ProductCreateComplete, ProductUpdateComplete ) from app.models.user_models import User as UserModel from app.repositories.catalog_repo import get_products, get_product_by_slug @@ -18,7 +22,10 @@ from app.repositories.catalog_repo import get_products, get_product_by_slug # Роутер для каталога catalog_router = APIRouter(prefix="/catalog", tags=["Каталог"]) -# Маршруты для коллекций +######################### +# Маршруты для коллекций # +######################### + @catalog_router.post("/collections", response_model=Dict[str, Any]) async def create_collection_endpoint(collection: CollectionCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): return services.create_collection(db, collection) @@ -39,6 +46,10 @@ async def get_collections_endpoint(skip: int = 0, limit: int = 100, db: Session return services.get_collections(db, skip, limit) +######################### +# Маршруты для категорий # +######################### + @catalog_router.post("/categories", response_model=Dict[str, Any]) async def create_category_endpoint(category: CategoryCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): return services.create_category(db, category) @@ -59,6 +70,10 @@ async def get_categories_tree(db: Session = Depends(get_db)): return services.get_category_tree(db) +######################### +# Маршруты для продуктов # +######################### + @catalog_router.post("/products", response_model=Dict[str, Any]) async def create_product_endpoint(product: ProductCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): return services.create_product(db, product) @@ -122,19 +137,100 @@ async def delete_product_variant_endpoint( return services.delete_product_variant(db, variant_id) -@catalog_router.post("/products/{product_id}/images", response_model=Dict[str, Any]) +@catalog_router.post("/products/{product_id}/images", description="Upload a product image") async def upload_product_image_endpoint( - product_id: int, - file: UploadFile = File(...), - is_primary: bool = Form(False), - current_user: UserModel = Depends(get_current_admin_user), - db: Session = Depends(get_db) + product_id: int, + is_primary: bool = Form(default=False), + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_admin_user) ): - return services.upload_product_image(db, product_id, file, is_primary) + """ + Загружает изображение для продукта. + + Args: + product_id: ID продукта + is_primary: Является ли изображение основным + file: Загружаемый файл + db: Сессия базы данных + current_user: Текущий пользователь + + Returns: + Объект с флагом success и данными изображения: + { + "success": true, + "image": { + "id": int, + "product_id": int, + "image_url": str, + "alt_text": str, + "is_primary": bool, + "created_at": datetime, + "updated_at": datetime + } + } + + В случае ошибки: + { + "success": false, + "error": str + } + """ + try: + logging.info(f"Начало обработки загрузки изображения для продукта {product_id}") + logging.info(f"Данные файла: имя={file.filename}, тип={file.content_type}, размер={file.size if hasattr(file, 'size') else 'unknown'}") + logging.info(f"is_primary: {is_primary}") + + # Удостоверяемся, что продукт существует + from app.models.catalog_models import Product + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + error_msg = f"Продукт с ID {product_id} не найден" + logging.error(error_msg) + return JSONResponse( + status_code=404, + content={"success": False, "error": error_msg} + ) + + # Используем сервис для загрузки изображения + logging.info("Вызов сервиса upload_product_image") + result = services.upload_product_image( + db, product_id, file, is_primary, alt_text="") + + logging.info(f"Результат загрузки изображения: {result}") + + # Возвращаем успешный ответ с данными изображения + return { + "success": True, + "image": result + } + except HTTPException as http_exc: + # Обрабатываем HTTP-исключения + logging.error(f"HTTP ошибка при загрузке изображения: {http_exc.detail}, код: {http_exc.status_code}") + return JSONResponse( + status_code=http_exc.status_code, + content={"success": False, "error": http_exc.detail} + ) + except Exception as e: + # Логируем ошибку с полным трейсбеком + error_msg = f"Неожиданная ошибка при загрузке изображения: {str(e)}" + logging.error(error_msg) + logging.error(traceback.format_exc()) + + # Возвращаем ошибку с кодом 400 + return JSONResponse( + status_code=400, + content={"success": False, "error": str(e)} + ) @catalog_router.put("/images/{image_id}", response_model=Dict[str, Any]) -async def update_product_image_endpoint(image_id: int, image: ProductImageUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): +async def update_product_image_endpoint( + image_id: int, + image: ProductImageUpdate, + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): return services.update_product_image(db, image_id, image) @@ -158,7 +254,7 @@ async def get_products_endpoint( ): products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active, include_variants) # Преобразуем объекты SQLAlchemy в схемы Pydantic - return [Product.model_validate(product) for product in products] + return [Product.from_orm(product) for product in products] # Маршруты для размеров @@ -207,4 +303,30 @@ def delete_size( ): """Удалить размер""" success = services.delete_size(db, size_id) - return {"success": success} \ No newline at end of file + return {"success": success} + + +# Маршруты для комплексного создания и обновления продуктов +@catalog_router.post("/products/complete", response_model=Dict[str, Any]) +async def create_product_complete_endpoint( + product: ProductCreateComplete, + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Создание продукта вместе с его вариантами и изображениями в одном запросе. + """ + return services.create_product_complete(db, product) + + +@catalog_router.put("/products/{product_id}/complete", response_model=Dict[str, Any]) +async def update_product_complete_endpoint( + product_id: int, + product: ProductUpdateComplete, + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Обновление продукта вместе с его вариантами и изображениями в одном запросе. + """ + return services.update_product_complete(db, product_id, product) \ No newline at end of file diff --git a/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc index 89ba868..078fc6d 100644 Binary files a/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc and b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/catalog_schemas.py b/backend/app/schemas/catalog_schemas.py index fa9c90b..3a79e4b 100644 --- a/backend/app/schemas/catalog_schemas.py +++ b/backend/app/schemas/catalog_schemas.py @@ -128,7 +128,8 @@ class ProductImageBase(BaseModel): class ProductImageCreate(ProductImageBase): - pass + id: Optional[int] = None # Опциональное поле id для обеспечения совместимости с фронтендом + created_at: Optional[datetime] = None # Опциональное поле created_at class ProductImageUpdate(BaseModel): @@ -183,6 +184,8 @@ class Product(ProductBase): class Config: from_attributes = True + populate_by_name = True + orm_mode = True # Расширенные схемы для отображения @@ -205,4 +208,49 @@ class ProductWithDetails(Product): # Рекурсивное обновление для CategoryWithChildren -CategoryWithSubcategories.update_forward_refs() \ No newline at end of file +CategoryWithSubcategories.update_forward_refs() + +# Схемы для продуктов с вложенными объектами +class ProductVariantCreateNested(BaseModel): + size_id: int + sku: str + stock: int = 0 + is_active: bool = True + +class ProductImageCreateNested(BaseModel): + image_url: str + alt_text: Optional[str] = None + is_primary: bool = False + +class ProductCreateComplete(ProductBase): + variants: Optional[List[ProductVariantCreateNested]] = [] + images: Optional[List[ProductImageCreateNested]] = [] + # Остальные поля наследуются из ProductBase + +class ProductVariantUpdateNested(BaseModel): + id: Optional[int] = None # Если id присутствует, обновляем существующий вариант + size_id: Optional[int] = None + sku: Optional[str] = None + stock: Optional[int] = None + is_active: Optional[bool] = None + +class ProductImageUpdateNested(BaseModel): + id: Optional[int] = None # Если id присутствует, обновляем существующее изображение + image_url: Optional[str] = None + alt_text: Optional[str] = None + is_primary: Optional[bool] = None + +class ProductUpdateComplete(BaseModel): + name: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + price: Optional[float] = None + discount_price: Optional[float] = None + care_instructions: Optional[Dict[str, Any]] = None + is_active: Optional[bool] = None + category_id: Optional[int] = None + collection_id: Optional[int] = None + variants: Optional[List[ProductVariantUpdateNested]] = None + images: Optional[List[ProductImageUpdateNested]] = None + variants_to_remove: Optional[List[int]] = None # ID вариантов для удаления + images_to_remove: Optional[List[int]] = None # ID изображений для удаления \ No newline at end of file diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 450e493..3c6b600 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -10,7 +10,8 @@ from app.services.catalog_service import ( add_product_variant, update_product_variant, delete_product_variant, upload_product_image, update_product_image, delete_product_image, create_collection, update_collection, delete_collection, get_collections, - get_size, get_size_by_code, get_sizes, create_size, update_size, delete_size + get_size, get_size_by_code, get_sizes, create_size, update_size, delete_size, + create_product_complete, update_product_complete ) from app.services.order_service import ( diff --git a/backend/app/services/__pycache__/__init__.cpython-310.pyc b/backend/app/services/__pycache__/__init__.cpython-310.pyc index 73637b4..a36e9a9 100644 Binary files a/backend/app/services/__pycache__/__init__.cpython-310.pyc and b/backend/app/services/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/services/__pycache__/catalog_service.cpython-310.pyc b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc index 7182117..56cce42 100644 Binary files a/backend/app/services/__pycache__/catalog_service.cpython-310.pyc and b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc differ diff --git a/backend/app/services/__pycache__/user_service.cpython-310.pyc b/backend/app/services/__pycache__/user_service.cpython-310.pyc index 1eede0d..a5e2430 100644 Binary files a/backend/app/services/__pycache__/user_service.cpython-310.pyc and b/backend/app/services/__pycache__/user_service.cpython-310.pyc differ diff --git a/backend/app/services/catalog_service.py b/backend/app/services/catalog_service.py index e2e2f4b..569b333 100644 --- a/backend/app/services/catalog_service.py +++ b/backend/app/services/catalog_service.py @@ -5,6 +5,8 @@ import os import uuid import shutil from pathlib import Path +import logging +import traceback from app.config import settings from app.repositories import catalog_repo, review_repo @@ -14,7 +16,8 @@ from app.schemas.catalog_schemas import ( ProductVariantCreate, ProductVariantUpdate, ProductVariant, ProductImageCreate, ProductImageUpdate, ProductImage, CollectionCreate, CollectionUpdate, Collection, - SizeCreate, SizeUpdate, Size + SizeCreate, SizeUpdate, Size, + ProductCreateComplete, ProductUpdateComplete, ) @@ -382,62 +385,220 @@ def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]: return {"success": success} -def upload_product_image(db: Session, product_id: int, file: UploadFile, is_primary: bool = False) -> Dict[str, Any]: - from app.schemas.catalog_schemas import ProductImage as ProductImageSchema +def upload_product_image( + db: Session, + product_id: int, + file: UploadFile, + is_primary: bool = False, + alt_text: str = "" +) -> dict: + """ + Загружает изображение для продукта и создает запись в базе данных. - # Проверяем, что продукт существует - product = catalog_repo.get_product(db, product_id) - if not product: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Продукт не найден" + Args: + db: Сессия базы данных + product_id: ID продукта + file: Загружаемый файл + is_primary: Является ли изображение основным + alt_text: Альтернативный текст для изображения + + Returns: + dict: Словарь с данными созданного изображения + + Raises: + HTTPException: В случае ошибки при загрузке или сохранении изображения + """ + try: + logging.info(f"Попытка загрузки изображения для продукта с id={product_id}") + + if file is None: + error_msg = "Файл не предоставлен" + logging.error(error_msg) + raise HTTPException(status_code=400, detail=error_msg) + + if not file.filename: + error_msg = "У файла отсутствует имя" + logging.error(error_msg) + raise HTTPException(status_code=400, detail=error_msg) + + logging.info(f"Получен файл: {file.filename}, размер: {file.size if hasattr(file, 'size') else 'unknown'}") + + # Исправляем импорт модели Product для ORM запроса + from app.models.catalog_models import Product as ProductModel, ProductImage as ProductImageModel + + # Проверяем, что продукт существует + product = db.query(ProductModel).filter(ProductModel.id == product_id).first() + if not product: + error_msg = f"Продукт с ID {product_id} не найден" + logging.error(error_msg) + raise HTTPException(status_code=404, detail=error_msg) + + # Создаем директорию для изображений, если ее нет + os.makedirs(settings.UPLOAD_DIRECTORY, exist_ok=True) + logging.info(f"Директория для загрузки: {settings.UPLOAD_DIRECTORY}") + + # Получаем расширение файла + file_ext = os.path.splitext(file.filename)[1].lower() if file.filename else "" + + logging.info(f"Расширение файла: {file_ext}") + + # Проверяем допустимость расширения (удаляем точку перед сравнением) + if file_ext.lstrip('.') not in settings.ALLOWED_UPLOAD_EXTENSIONS: + error_msg = f"Расширение файла {file_ext} не разрешено. Разрешенные расширения: {', '.join(settings.ALLOWED_UPLOAD_EXTENSIONS)}" + logging.error(error_msg) + raise HTTPException(status_code=400, detail=error_msg) + + # Генерируем безопасное имя файла + secure_filename = f"{uuid.uuid4()}{file_ext}" + file_path = os.path.join(settings.UPLOAD_DIRECTORY, secure_filename) + logging.info(f"Генерация имени файла: {secure_filename}, полный путь: {file_path}") + + # Сохраняем файл на диск + try: + with open(file_path, "wb") as f: + content = file.file.read() + if not content: + error_msg = "Загруженный файл пуст" + logging.error(error_msg) + raise HTTPException(status_code=400, detail=error_msg) + + f.write(content) + logging.info(f"Файл успешно сохранен на диск, размер содержимого: {len(content)} байт") + except Exception as e: + error_msg = f"Ошибка при сохранении файла: {str(e)}" + logging.error(error_msg) + logging.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=error_msg) + + # Получаем URL для файла + file_url = f"/uploads/{secure_filename}" + logging.info(f"URL файла: {file_url}") + + # Если это основное изображение, обновляем остальные изображения продукта + if is_primary: + logging.info("Обновление флагов для других изображений (is_primary=False)") + db.query(ProductImageModel).filter( + ProductImageModel.product_id == product_id, + ProductImageModel.is_primary == True + ).update({"is_primary": False}) + + # Создаем запись в базе данных + logging.info("Создание записи изображения в базе данных") + new_image = ProductImageModel( + product_id=product_id, + image_url=file_url, + alt_text=alt_text, + is_primary=is_primary ) - - # Проверяем расширение файла - file_extension = file.filename.split(".")[-1].lower() - if file_extension not in settings.ALLOWED_UPLOAD_EXTENSIONS: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Неподдерживаемый формат файла. Разрешены: {', '.join(settings.ALLOWED_UPLOAD_EXTENSIONS)}" - ) - - # Создаем директорию для загрузок, если она не существует - upload_dir = Path(settings.UPLOAD_DIRECTORY) / "products" / str(product_id) - upload_dir.mkdir(parents=True, exist_ok=True) - - # Генерируем уникальное имя файла - unique_filename = f"{uuid.uuid4()}.{file_extension}" - file_path = upload_dir / unique_filename - - # Сохраняем файл - with file_path.open("wb") as buffer: - shutil.copyfileobj(file.file, buffer) - - # Создаем запись об изображении в БД - image_data = ProductImageCreate( - product_id=product_id, - image_url=f"/uploads/products/{product_id}/{unique_filename}", - alt_text=file.filename, - is_primary=is_primary - ) - - new_image = catalog_repo.create_product_image(db, image_data) - - # Преобразуем объект SQLAlchemy в схему Pydantic - image_schema = ProductImageSchema.model_validate(new_image) - return {"image": image_schema} + + try: + db.add(new_image) + db.commit() + logging.info("Запись успешно добавлена в базу данных") + db.refresh(new_image) + except Exception as e: + error_msg = f"Ошибка при сохранении записи в базу данных: {str(e)}" + logging.error(error_msg) + logging.error(traceback.format_exc()) + + # Удаляем загруженный файл при ошибке + try: + os.remove(file_path) + logging.info(f"Файл {file_path} успешно удален из-за ошибки при создании записи в БД") + except: + logging.error(f"Не удалось удалить файл {file_path} после ошибки") + + raise HTTPException(status_code=500, detail=error_msg) + + # Возвращаем данные изображения напрямую как словарь + # вместо использования Pydantic-моделей + result = { + "id": new_image.id, + "product_id": new_image.product_id, + "image_url": new_image.image_url, + "alt_text": new_image.alt_text, + "is_primary": new_image.is_primary, + "created_at": new_image.created_at.isoformat() if new_image.created_at else None, + "updated_at": new_image.updated_at.isoformat() if new_image.updated_at else None + } + + logging.info(f"Возвращается результат: {result}") + return result + + except HTTPException as http_error: + # Пробрасываем HTTP-исключения далее + raise http_error + except Exception as e: + # Логируем ошибки и преобразуем их в HTTP-исключения + error_msg = f"Неожиданная ошибка при загрузке изображения: {str(e)}" + logging.error(error_msg) + logging.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=error_msg) -def update_product_image(db: Session, image_id: int, is_primary: bool) -> Dict[str, Any]: +def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) -> Dict[str, Any]: from app.schemas.catalog_schemas import ProductImage as ProductImageSchema + from app.models.catalog_models import ProductImage as ProductImageModel - updated_image = catalog_repo.update_product_image(db, image_id, is_primary) - # Преобразуем объект SQLAlchemy в схему Pydantic - image_schema = ProductImageSchema.model_validate(updated_image) - return {"image": image_schema} + try: + # Получаем существующее изображение + db_image = catalog_repo.get_product_image(db, image_id) + if not db_image: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Изображение продукта не найдено" + ) + + # Если изображение отмечается как основное, сбрасываем флаг у других изображений + if image.is_primary and not db_image.is_primary: + db.query(ProductImageModel).filter( + ProductImageModel.product_id == db_image.product_id, + ProductImageModel.is_primary == True + ).update({"is_primary": False}) + db.flush() + + # Обновляем поля + if image.alt_text is not None: + db_image.alt_text = image.alt_text + if image.is_primary is not None: + db_image.is_primary = image.is_primary + + db.commit() + db.refresh(db_image) + + # Создаем словарь для ответа вместо использования from_orm + image_dict = { + "id": db_image.id, + "product_id": db_image.product_id, + "image_url": db_image.image_url, + "alt_text": db_image.alt_text, + "is_primary": db_image.is_primary, + "created_at": db_image.created_at, + "updated_at": db_image.updated_at + } + + return { + "success": True, + "image": image_dict + } + except HTTPException as e: + db.rollback() + return { + "success": False, + "error": e.detail + } + except Exception as e: + db.rollback() + return { + "success": False, + "error": str(e) + } def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]: + # Добавляем импорт ORM-модели для корректной работы с БД + from app.models.catalog_models import ProductImage as ProductImageModel + # Получаем информацию об изображении перед удалением image = catalog_repo.get_product_image(db, image_id) if not image: @@ -499,4 +660,331 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size: def delete_size(db: Session, size_id: int) -> bool: - return catalog_repo.delete_size(db, size_id) \ No newline at end of file + return catalog_repo.delete_size(db, size_id) + + +def create_product_complete(db: Session, product_data: ProductCreateComplete) -> Dict[str, Any]: + """ + Создает продукт вместе с его вариантами и изображениями в одной транзакции. + """ + try: + # Откатываем любую существующую транзакцию и начинаем новую + db.rollback() + + # Проверяем наличие категории + category = catalog_repo.get_category(db, product_data.category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Категория с ID {product_data.category_id} не найдена" + ) + + # Проверяем наличие коллекции, если она указана + if product_data.collection_id: + collection = catalog_repo.get_collection(db, product_data.collection_id) + if not collection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Коллекция с ID {product_data.collection_id} не найдена" + ) + + # 1. Создаем базовую информацию о продукте используя репозиторий + product_create = ProductCreate( + name=product_data.name, + slug=product_data.slug, + description=product_data.description, + price=product_data.price, + discount_price=product_data.discount_price, + care_instructions=product_data.care_instructions, + is_active=product_data.is_active, + category_id=product_data.category_id, + collection_id=product_data.collection_id + ) + + # Создаем продукт через репозиторий + db_product = catalog_repo.create_product(db, product_create) + + # 2. Создаем варианты продукта + variants = [] + if product_data.variants: + for variant_data in product_data.variants: + # Проверяем наличие размера + size = catalog_repo.get_size(db, variant_data.size_id) + if not size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с ID {variant_data.size_id} не найден" + ) + + # Создаем вариант продукта через репозиторий + variant_create = ProductVariantCreate( + product_id=db_product.id, + size_id=variant_data.size_id, + sku=variant_data.sku, + stock=variant_data.stock, + is_active=variant_data.is_active if variant_data.is_active is not None else True + ) + db_variant = catalog_repo.create_product_variant(db, variant_create) + variants.append(db_variant) + + # 3. Создаем изображения продукта + images = [] + if product_data.images: + # Отслеживаем, было ли уже отмечено изображение как основное + had_primary = False + + for image_data in product_data.images: + # Определяем, должно ли изображение быть основным + is_primary = image_data.is_primary + if is_primary and had_primary: + # Если уже есть основное изображение, делаем это не основным + is_primary = False + elif is_primary: + had_primary = True + elif not had_primary and image_data == product_data.images[0]: + # Если это первое изображение и нет основного, делаем его основным + is_primary = True + had_primary = True + + # Создаем запись об изображении через репозиторий + image_create = ProductImageCreate( + product_id=db_product.id, + image_url=image_data.image_url, + alt_text=image_data.alt_text, + is_primary=is_primary + ) + db_image = catalog_repo.create_product_image(db, image_create) + images.append(db_image) + + # Создаем словарь для ответа с прямым извлечением данных из ORM-объектов + product_dict = { + "id": db_product.id, + "name": db_product.name, + "slug": db_product.slug, + "description": db_product.description, + "price": db_product.price, + "discount_price": db_product.discount_price, + "care_instructions": db_product.care_instructions, + "is_active": db_product.is_active, + "category_id": db_product.category_id, + "collection_id": db_product.collection_id, + "created_at": db_product.created_at, + "updated_at": db_product.updated_at + } + + # Создаем списки вариантов и изображений + variants_list = [] + for variant in variants: + variants_list.append({ + "id": variant.id, + "product_id": variant.product_id, + "size_id": variant.size_id, + "sku": variant.sku, + "stock": variant.stock, + "is_active": variant.is_active, + "created_at": variant.created_at, + "updated_at": variant.updated_at + }) + + images_list = [] + for image in images: + images_list.append({ + "id": image.id, + "product_id": image.product_id, + "image_url": image.image_url, + "alt_text": image.alt_text, + "is_primary": image.is_primary, + "created_at": image.created_at, + "updated_at": image.updated_at + }) + + return { + "success": True, + "id": db_product.id, + "product": product_dict, + "variants": variants_list, + "images": images_list + } + + except HTTPException as e: + db.rollback() + return { + "success": False, + "error": e.detail + } + except Exception as e: + db.rollback() + return { + "success": False, + "error": str(e) + } + + +def update_product_complete(db: Session, product_id: int, product_data: ProductUpdateComplete) -> Dict[str, Any]: + """ + Обновляет продукт вместе с его вариантами и изображениями в одной транзакции. + """ + try: + # Откатываем любую существующую транзакцию и начинаем новую + db.rollback() + + # Проверяем, что продукт существует + db_product = catalog_repo.get_product(db, product_id) + if not db_product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт с ID {product_id} не найден" + ) + + # Если меняется категория, проверяем, что она существует + if product_data.category_id is not None: + category = catalog_repo.get_category(db, product_data.category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Категория с ID {product_data.category_id} не найдена" + ) + + # Если меняется коллекция, проверяем, что она существует + if product_data.collection_id is not None: + if product_data.collection_id > 0: + collection = catalog_repo.get_collection(db, product_data.collection_id) + if not collection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Коллекция с ID {product_data.collection_id} не найдена" + ) + + # 1. Обновляем базовую информацию о продукте + product_update = ProductUpdate( + name=product_data.name, + slug=product_data.slug, + description=product_data.description, + price=product_data.price, + discount_price=product_data.discount_price, + care_instructions=product_data.care_instructions, + is_active=product_data.is_active, + category_id=product_data.category_id, + collection_id=product_data.collection_id + ) + db_product = catalog_repo.update_product(db, product_id, product_update) + + # 2. Обрабатываем варианты продукта + variants_updated = [] + variants_created = [] + + if product_data.variants: + for variant_data in product_data.variants: + # Проверяем наличие размера + if variant_data.size_id: + size = catalog_repo.get_size(db, variant_data.size_id) + if not size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с ID {variant_data.size_id} не найден" + ) + + # Если есть ID, обновляем существующий вариант + if variant_data.id: + variant_update = ProductVariantUpdate( + size_id=variant_data.size_id, + sku=variant_data.sku, + stock=variant_data.stock, + is_active=variant_data.is_active + ) + db_variant = catalog_repo.update_product_variant(db, variant_data.id, variant_update) + if db_variant: + variants_updated.append(db_variant) + else: + # Создаем новый вариант + variant_create = ProductVariantCreate( + product_id=product_id, + size_id=variant_data.size_id, + sku=variant_data.sku, + stock=variant_data.stock if variant_data.stock is not None else 0, + is_active=variant_data.is_active if variant_data.is_active is not None else True + ) + db_variant = catalog_repo.create_product_variant(db, variant_create) + variants_created.append(db_variant) + + # 3. Удаляем указанные варианты + variants_removed = [] + if product_data.variants_to_remove: + for variant_id in product_data.variants_to_remove: + # Проверяем, что вариант принадлежит данному продукту + variant = catalog_repo.get_product_variant(db, variant_id) + if variant and variant.product_id == product_id: + success = catalog_repo.delete_product_variant(db, variant_id) + if success: + variants_removed.append(variant_id) + + # 4. Обрабатываем изображения продукта + images_updated = [] + images_created = [] + + if product_data.images: + for image_data in product_data.images: + # Если есть ID, обновляем существующее изображение + if image_data.id: + image_update = ProductImageUpdate( + alt_text=image_data.alt_text, + is_primary=image_data.is_primary + ) + db_image = catalog_repo.update_product_image(db, image_data.id, image_update) + if db_image: + images_updated.append(db_image) + else: + # Создаем новое изображение + image_create = ProductImageCreate( + product_id=product_id, + image_url=image_data.image_url, + alt_text=image_data.alt_text, + is_primary=image_data.is_primary if image_data.is_primary is not None else False + ) + db_image = catalog_repo.create_product_image(db, image_create) + images_created.append(db_image) + + # 5. Удаляем указанные изображения + images_removed = [] + if product_data.images_to_remove: + for image_id in product_data.images_to_remove: + # Проверяем, что изображение принадлежит данному продукту + image = catalog_repo.get_product_image(db, image_id) + if image and image.product_id == product_id: + success = catalog_repo.delete_product_image(db, image_id) + if success: + images_removed.append(image_id) + + # Коммитим транзакцию + db.commit() + + # Собираем полный ответ + product_schema = Product.from_orm(db_product) + + return { + "success": True, + "product": product_schema, + "variants": { + "updated": [ProductVariant.from_orm(variant) for variant in variants_updated], + "created": [ProductVariant.from_orm(variant) for variant in variants_created], + "removed": variants_removed + }, + "images": { + "updated": [ProductImage.from_orm(image) for image in images_updated], + "created": [ProductImage.from_orm(image) for image in images_created], + "removed": images_removed + } + } + + except HTTPException as e: + db.rollback() + return { + "success": False, + "error": e.detail + } + except Exception as e: + db.rollback() + return { + "success": False, + "error": str(e) + } \ No newline at end of file diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index f1fc03f..ff5dba1 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -174,12 +174,15 @@ def request_password_reset(db: Session, email: str) -> Dict[str, Any]: """Запрос на сброс пароля""" # Проверяем, существует ли пользователь с таким email user = user_repo.get_user_by_email(db, email) + print(user, email) if not user: # Не сообщаем, что пользователь не существует (безопасность) return {"message": "Если указанный email зарегистрирован, на него будет отправлена инструкция по сбросу пароля"} # Генерируем токен для сброса пароля reset_token = user_repo.create_password_reset_token(db, user.id) + + print(reset_token) # В реальном приложении здесь должна быть отправка email # Для примера просто возвращаем токен diff --git a/backend/uploads/085fdb66-69d5-4018-9bbf-5406e5b254f5.jpg b/backend/uploads/085fdb66-69d5-4018-9bbf-5406e5b254f5.jpg new file mode 100644 index 0000000..545e7e1 Binary files /dev/null and b/backend/uploads/085fdb66-69d5-4018-9bbf-5406e5b254f5.jpg differ diff --git a/backend/uploads/30ceb76c-36b8-47b5-8146-5d0e9ac71148.jpg b/backend/uploads/30ceb76c-36b8-47b5-8146-5d0e9ac71148.jpg new file mode 100644 index 0000000..fbfb28d Binary files /dev/null and b/backend/uploads/30ceb76c-36b8-47b5-8146-5d0e9ac71148.jpg differ diff --git a/backend/uploads/319c9b8c-b8e5-47d5-a564-2d512369da39.jpg b/backend/uploads/319c9b8c-b8e5-47d5-a564-2d512369da39.jpg new file mode 100644 index 0000000..fbfb28d Binary files /dev/null and b/backend/uploads/319c9b8c-b8e5-47d5-a564-2d512369da39.jpg differ diff --git a/backend/uploads/31a915c7-b30e-449b-8aab-3b51b27a3733.jpg b/backend/uploads/31a915c7-b30e-449b-8aab-3b51b27a3733.jpg new file mode 100644 index 0000000..fbfb28d Binary files /dev/null and b/backend/uploads/31a915c7-b30e-449b-8aab-3b51b27a3733.jpg differ diff --git a/backend/uploads/49f0876e-d3e6-4f26-bc67-6ce798ff2d2b.jpg b/backend/uploads/49f0876e-d3e6-4f26-bc67-6ce798ff2d2b.jpg new file mode 100644 index 0000000..fbfb28d Binary files /dev/null and b/backend/uploads/49f0876e-d3e6-4f26-bc67-6ce798ff2d2b.jpg differ diff --git a/backend/uploads/50acbf60-121a-4263-a151-40cc8a9e923d.jpg b/backend/uploads/50acbf60-121a-4263-a151-40cc8a9e923d.jpg new file mode 100644 index 0000000..a030751 Binary files /dev/null and b/backend/uploads/50acbf60-121a-4263-a151-40cc8a9e923d.jpg differ diff --git a/backend/uploads/5ad23472-20f1-45a0-bb80-43be4bda576f.jpg b/backend/uploads/5ad23472-20f1-45a0-bb80-43be4bda576f.jpg new file mode 100644 index 0000000..02039d9 Binary files /dev/null and b/backend/uploads/5ad23472-20f1-45a0-bb80-43be4bda576f.jpg differ diff --git a/backend/uploads/74def36f-4c4f-4c39-8833-6295cf018591.jpg b/backend/uploads/74def36f-4c4f-4c39-8833-6295cf018591.jpg new file mode 100644 index 0000000..fbfb28d Binary files /dev/null and b/backend/uploads/74def36f-4c4f-4c39-8833-6295cf018591.jpg differ diff --git a/backend/uploads/799fb9e3-c9a1-4648-8e1c-7757049a401d.jpg b/backend/uploads/799fb9e3-c9a1-4648-8e1c-7757049a401d.jpg new file mode 100644 index 0000000..6faa819 Binary files /dev/null and b/backend/uploads/799fb9e3-c9a1-4648-8e1c-7757049a401d.jpg differ diff --git a/backend/uploads/8a3dcd05-d0b1-4c96-a7e6-9de7fee14404.jpg b/backend/uploads/8a3dcd05-d0b1-4c96-a7e6-9de7fee14404.jpg new file mode 100644 index 0000000..02039d9 Binary files /dev/null and b/backend/uploads/8a3dcd05-d0b1-4c96-a7e6-9de7fee14404.jpg differ diff --git a/backend/uploads/91b1a897-5c31-46bf-89c2-597d89213717.jpg b/backend/uploads/91b1a897-5c31-46bf-89c2-597d89213717.jpg new file mode 100644 index 0000000..02039d9 Binary files /dev/null and b/backend/uploads/91b1a897-5c31-46bf-89c2-597d89213717.jpg differ diff --git a/backend/uploads/b0bb7f35-07d1-427c-a741-4d082ca67f2a.jpg b/backend/uploads/b0bb7f35-07d1-427c-a741-4d082ca67f2a.jpg new file mode 100644 index 0000000..fbfb28d Binary files /dev/null and b/backend/uploads/b0bb7f35-07d1-427c-a741-4d082ca67f2a.jpg differ diff --git a/backend/uploads/b3285a6e-827a-450a-b9eb-061f5a769cb9.jpg b/backend/uploads/b3285a6e-827a-450a-b9eb-061f5a769cb9.jpg new file mode 100644 index 0000000..02039d9 Binary files /dev/null and b/backend/uploads/b3285a6e-827a-450a-b9eb-061f5a769cb9.jpg differ diff --git a/backend/uploads/bdf75991-a135-4bad-8eac-5506ab490813.jpg b/backend/uploads/bdf75991-a135-4bad-8eac-5506ab490813.jpg new file mode 100644 index 0000000..02039d9 Binary files /dev/null and b/backend/uploads/bdf75991-a135-4bad-8eac-5506ab490813.jpg differ diff --git a/backend/uploads/c099fd0e-6f5d-4cc7-963b-e5d3e2e427e0.jpg b/backend/uploads/c099fd0e-6f5d-4cc7-963b-e5d3e2e427e0.jpg new file mode 100644 index 0000000..02039d9 Binary files /dev/null and b/backend/uploads/c099fd0e-6f5d-4cc7-963b-e5d3e2e427e0.jpg differ diff --git a/backend/uploads/e18e4ae6-81eb-47d6-8b8b-e32bbca42b84.jpg b/backend/uploads/e18e4ae6-81eb-47d6-8b8b-e32bbca42b84.jpg new file mode 100644 index 0000000..02039d9 Binary files /dev/null and b/backend/uploads/e18e4ae6-81eb-47d6-8b8b-e32bbca42b84.jpg differ diff --git a/backend/uploads/efa57b58-3736-45a3-9ee1-ca26135fa0fe.jpg b/backend/uploads/efa57b58-3736-45a3-9ee1-ca26135fa0fe.jpg new file mode 100644 index 0000000..1a1d2a2 Binary files /dev/null and b/backend/uploads/efa57b58-3736-45a3-9ee1-ca26135fa0fe.jpg differ diff --git a/frontend/.DS_Store b/frontend/.DS_Store index 86a179d..ba3d771 100644 Binary files a/frontend/.DS_Store and b/frontend/.DS_Store differ diff --git a/frontend/app/(main)/catalog/[slug]/page.tsx b/frontend/app/(main)/catalog/[slug]/page.tsx index af05b4d..68b30ec 100644 --- a/frontend/app/(main)/catalog/[slug]/page.tsx +++ b/frontend/app/(main)/catalog/[slug]/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react" import { notFound, useRouter } from "next/navigation" import { Product } from "@/lib/catalog" import { Skeleton } from "@/components/ui/skeleton" -import { fetchProduct } from "@/lib/api" +import { fetchProduct, ApiResponse } from "@/lib/api" import { ImageSlider } from "@/components/product/ImageSlider" import { ProductDetails } from "@/components/product/ProductDetails" @@ -18,20 +18,37 @@ export default function ProductPage({ params }: { params: { slug: string } }) { const loadProduct = async () => { setLoading(true) try { - // fetchProduct возвращает объект Product напрямую, а не обертку ApiResponse - const productData = await fetchProduct(params.slug) + console.log(`Загрузка товара по slug: ${params.slug}`); - // Проверяем, что получены данные - if (!productData) { - setError("Продукт не найден") - return + // fetchProduct возвращает ApiResponse + const response = await fetchProduct(params.slug); + console.log("Ответ API:", response); + + // Проверяем структуру ответа + if (!response.success || !response.data) { + const errorMsg = response.error || "Продукт не найден"; + console.error("Ошибка при загрузке продукта:", errorMsg); + setError(errorMsg); + return; } - // Преобразуем тип API Product в тип из catalog.ts - setProduct(productData as unknown as Product) + const productData = response.data; + console.log("Данные продукта:", productData); + + // Проверяем изображения продукта + if (productData.images && Array.isArray(productData.images)) { + console.log("Изображения продукта:", productData.images); + } else { + console.warn("Продукт не содержит изображений или images не является массивом"); + // Устанавливаем пустой массив, чтобы избежать ошибок + productData.images = []; + } + + // Устанавливаем данные продукта + setProduct(productData as Product); } catch (err) { - console.error("Error fetching product:", err) - setError("Ошибка при загрузке продукта") + console.error("Ошибка при загрузке товара:", err) + setError(err instanceof Error ? err.message : "Ошибка при загрузке продукта") } finally { setLoading(false) } diff --git a/frontend/app/(main)/catalog/page.tsx b/frontend/app/(main)/catalog/page.tsx index 1fc17df..fdb6bda 100644 --- a/frontend/app/(main)/catalog/page.tsx +++ b/frontend/app/(main)/catalog/page.tsx @@ -81,8 +81,12 @@ export default function CatalogPage() { useEffect(() => { const loadCategories = async () => { try { - const categoriesData = await categoryService.getCategories(); - setCategories(categoriesData); + const response = await categoryService.getCategories(); + if (response.success && response.data) { + setCategories(response.data); + } else { + setError(response.error || "Не удалось загрузить категории"); + } } catch (err) { console.error("Ошибка при загрузке категорий:", err); setError("Не удалось загрузить категории"); @@ -123,7 +127,28 @@ export default function CatalogPage() { params.search = searchQuery; } - const productsData = await productService.getProducts(params); + // Получаем продукты через сервис + const response: any = await productService.getProducts(params); + let productsData: Product[] = []; + + // Проверяем формат ответа и извлекаем продукты + if (Array.isArray(response)) { + // Старый формат - массив продуктов + productsData = response; + } else if (response && typeof response === 'object' && 'success' in response) { + // Новый формат - ApiResponse с данными в поле data + if (response.success && response.data) { + productsData = response.data; + } else { + setError(response.error || "Не удалось загрузить продукты"); + setLoading(false); + return; + } + } else { + setError("Не удалось загрузить продукты: неизвестный формат ответа"); + setLoading(false); + return; + } // Сортировка полученных продуктов let sortedProducts = [...productsData]; @@ -131,15 +156,15 @@ export default function CatalogPage() { switch (sortOption) { case 'price_asc': sortedProducts.sort((a, b) => { - const priceA = a.variants?.[0]?.price || 0; - const priceB = b.variants?.[0]?.price || 0; + const priceA = a.price || (a.variants && a.variants[0]?.price) || 0; + const priceB = b.price || (b.variants && b.variants[0]?.price) || 0; return priceA - priceB; }); break; case 'price_desc': sortedProducts.sort((a, b) => { - const priceA = a.variants?.[0]?.price || 0; - const priceB = b.variants?.[0]?.price || 0; + const priceA = a.price || (a.variants && a.variants[0]?.price) || 0; + const priceB = b.price || (b.variants && b.variants[0]?.price) || 0; return priceB - priceA; }); break; @@ -506,13 +531,18 @@ export default function CatalogPage() { key={product.id} id={product.id} name={product.name} - price={product.variants?.[0]?.price || 0} - salePrice={product.variants?.[0]?.discount_price || undefined} - image={product.images && product.images.length > 0 ? - product.images[0].url : "/placeholder.svg?height=600&width=400"} + price={product.price || (product.variants?.[0]?.price) || 0} + salePrice={product.discount_price || product.variants?.[0]?.discount_price} + image={ + product.images && product.images.length > 0 + ? (typeof product.images[0] === 'string' + ? product.images[0] + : product.images[0].image_url || product.images[0].url || '/placeholder.svg?height=600&width=400') + : '/placeholder.svg?height=600&width=400' + } slug={product.slug} - isNew={false} // Нужно добавить поле в API - isOnSale={product.variants?.[0]?.discount_price ? true : false} + isNew={false} + isOnSale={Boolean(product.discount_price || product.variants?.[0]?.discount_price)} category={product.category?.name || ""} /> ) : ( @@ -522,11 +552,17 @@ export default function CatalogPage() {
0 ? - (product.images[0].url.startsWith('http') ? - product.images[0].url : - `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:8000'}${product.images[0].url}`) : - "/placeholder.svg?height=600&width=400"} + src={ + product.images && product.images.length > 0 + ? (typeof product.images[0] === 'string' + ? product.images[0] + : (product.images[0].image_url || product.images[0].url || '') && + ((product.images[0].image_url || product.images[0].url || '').startsWith('http') + ? (product.images[0].image_url || product.images[0].url) + : `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:8000'}${product.images[0].image_url || product.images[0].url}`) + ) + : '/placeholder.svg?height=600&width=400' + } alt={product.name} className="object-cover w-full h-full" width={400} diff --git a/frontend/app/(main)/product/[id]/page.tsx b/frontend/app/(main)/product/[id]/page.tsx deleted file mode 100644 index 91dae0f..0000000 --- a/frontend/app/(main)/product/[id]/page.tsx +++ /dev/null @@ -1,415 +0,0 @@ -"use client" - -import { useState } from "react" -import Image from "next/image" -import Link from "next/link" -import { Button } from "@/components/ui/button" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { - ShoppingBag, - Heart, - Share2, - Star, - Truck, - RefreshCw, - Check, - ChevronRight, - Minus, - Plus, - ArrowRight -} from "lucide-react" -import { motion } from "framer-motion" -import { ProductCard } from "@/components/ui/product-card" - -export default function ProductPage({ params }: { params: { id: string } }) { - const [selectedSize, setSelectedSize] = useState("") - const [selectedColor, setSelectedColor] = useState("") - const [quantity, setQuantity] = useState(1) - const [activeTab, setActiveTab] = useState("description") - const [activeImage, setActiveImage] = useState(0) - const [isZoomed, setIsZoomed] = useState(false) - const [zoomPosition, setZoomPosition] = useState({ x: 0, y: 0 }) - - - const incrementQuantity = () => { - setQuantity(quantity + 1) - } - - const decrementQuantity = () => { - if (quantity > 1) { - setQuantity(quantity - 1) - } - } - - const handleImageHover = (e: React.MouseEvent) => { - if (!isZoomed) return - - const { left, top, width, height } = e.currentTarget.getBoundingClientRect() - const x = ((e.clientX - left) / width) * 100 - const y = ((e.clientY - top) / height) * 100 - - setZoomPosition({ x, y }) - } - - return ( -
- {/* Хлебные крошки */} -
-
- -
-
- - {/* Информация о продукте */} -
-
-
- {/* Галерея изображений */} -
-
- {/* Миниатюры */} -
- {product.images.map((image, index) => ( - - ))} -
- - {/* Основное изображение */} -
setIsZoomed(true)} - onMouseLeave={() => setIsZoomed(false)} - onMouseMove={handleImageHover} - > -
- - {product.discount > 0 && ( -
- -{product.discount}% -
- )} -
-
-
- - {/* Информация о товаре */} -
-
-
{product.category}
-

{product.name}

- - {/* Рейтинг */} -
-
- {[1, 2, 3, 4, 5].map((star) => ( - - ))} -
- - {product.rating} ({product.reviewCount} отзывов) - -
- - {/* Цена */} -
- {product.oldPrice ? ( - <> - {product.price.toLocaleString()} ₽ - {product.oldPrice.toLocaleString()} ₽ - - ) : ( - {product.price.toLocaleString()} ₽ - )} -
- - {/* Артикул и наличие */} -
- Артикул: {product.sku} - - {product.inStock ? ( - <> - - В наличии - - ) : ( - "Нет в наличии" - )} - -
- - {/* Выбор цвета */} -
-
- Цвет - {selectedColor || "Выберите цвет"} -
-
- {product.colors.map((color) => ( -
-
- - {/* Выбор размера */} -
-
- Размер - - Таблица размеров - -
-
- {product.sizes.map((size) => ( - - ))} -
-
- - {/* Количество */} -
-
- Количество -
-
- -
- {quantity} -
- -
-
- - {/* Кнопки действий */} -
- -
- - -
-
- - {/* Доставка и возврат */} -
-
- -
-

Доставка

-

{product.delivery}

-
-
-
- -
-

Возврат

-

{product.returns}

-
-
-
-
-
-
-
-
- - {/* Информация о товаре в табах */} -
-
- - - setActiveTab("description")} - > - Описание - - setActiveTab("details")} - > - Детали - - setActiveTab("care")} - > - Уход - - - -
- -
-

{product.description}

-
-
- - -
-
-
-

Характеристики

-
    - {product.details.map((detail, index) => ( -
  • - - {detail} -
  • - ))} -
-
-
-

Состав

-

{product.composition}

-
-
-
-
- - -
-

Рекомендации по уходу

-

{product.care}

-
-
- Стирка при 30°C -
-
- Не отбеливать -
-
- Гладить при низкой температуре -
-
- Не подвергать химической чистке -
-
-
-
-
-
-
-
- - {/* Рекомендуемые товары */} -
-
-
-

С ЭТИМ ТОВАРОМ ПОКУПАЮТ

- -
- -
- {recommendedProducts.map((product) => ( - - ))} -
-
-
-
- ) -} - diff --git a/frontend/app/(main)/product/layout.tsx b/frontend/app/(main)/product/layout.tsx deleted file mode 100644 index a14e64f..0000000 --- a/frontend/app/(main)/product/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export const metadata = { - title: 'Next.js', - description: 'Generated by Next.js', -} - -export default function RootLayout({ - children, -}: { - children: React.ReactNode -}) { - return ( - - {children} - - ) -} diff --git a/frontend/app/admin/categories/page.tsx b/frontend/app/admin/categories/page.tsx index 4bb3f90..3bbf8c3 100644 --- a/frontend/app/admin/categories/page.tsx +++ b/frontend/app/admin/categories/page.tsx @@ -1,118 +1,141 @@ 'use client'; import { useState, useEffect } from 'react'; -import Link from 'next/link'; -import { Plus, Edit, Trash, ChevronRight, ChevronDown } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Trash, Edit, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; -// Типы для категорий -interface Category { - id: number; - name: string; - slug: string; - parent_id: number | null; - is_active: boolean; - subcategories?: Category[]; -} +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { categoryService } from '@/lib/catalog'; +import { Category, CategoryCreate, CategoryUpdate } from '@/lib/catalog'; -// Временные данные до реализации API -const mockCategories: Category[] = [ - { - id: 1, - name: 'Женская одежда', - slug: 'womens-clothing', - parent_id: null, - is_active: true, - subcategories: [ - { id: 2, name: 'Платья', slug: 'dresses', parent_id: 1, is_active: true }, - { id: 3, name: 'Блузки', slug: 'blouses', parent_id: 1, is_active: true }, - { id: 4, name: 'Юбки', slug: 'skirts', parent_id: 1, is_active: false } - ] - }, - { - id: 5, - name: 'Мужская одежда', - slug: 'mens-clothing', - parent_id: null, - is_active: true, - subcategories: [ - { id: 6, name: 'Рубашки', slug: 'shirts', parent_id: 5, is_active: true }, - { id: 7, name: 'Брюки', slug: 'pants', parent_id: 5, is_active: true } - ] - }, - { - id: 8, - name: 'Аксессуары', - slug: 'accessories', - parent_id: null, - is_active: true, - subcategories: [] - } -]; +// Схема валидации для формы категории +const categoryFormSchema = z.object({ + name: z.string().min(2, { message: 'Название должно содержать минимум 2 символа' }), + slug: z.string().min(2, { message: 'Slug должен содержать минимум 2 символа' }).optional(), + description: z.string().optional(), + parent_id: z.number().nullable().optional(), + is_active: z.boolean().default(true), +}); -// Компонент для отображения категории в дереве -const CategoryTreeItem = ({ - category, - level = 0, - onEdit, +type CategoryFormValues = z.infer; + +// Компонент для отображения категории в виде дерева +const CategoryItem = ({ + category, + level = 0, + onEdit, onDelete, - expandedCategories, - toggleCategory -}: { - category: Category; + onAddSubcategory, + categories, +}: { + category: Category; level?: number; - onEdit: (id: number) => void; - onDelete: (id: number) => void; - expandedCategories: number[]; - toggleCategory: (id: number) => void; + onEdit: (category: Category) => void; + onDelete: (categoryId: number) => void; + onAddSubcategory: (parentId: number) => void; + categories: Category[]; }) => { - const isExpanded = expandedCategories.includes(category.id); + const [isExpanded, setIsExpanded] = useState(false); const hasSubcategories = category.subcategories && category.subcategories.length > 0; - + return ( -
-
-
- {hasSubcategories && ( - - )} - {!hasSubcategories &&
} - {category.name} -
-
- {category.slug} +
+
+ {hasSubcategories ? ( + ) : ( +
+ )} +
{category.name}
+
+ + - +
+ {isExpanded && hasSubcategories && (
- {category.subcategories!.map(subcategory => ( - ( + ))}
@@ -121,115 +144,374 @@ const CategoryTreeItem = ({ ); }; +// Компонент диалогового окна для редактирования категории +const EditCategoryDialog = ({ + isOpen, + onClose, + category, + onSave, + categories, + mode, + parentId, +}: { + isOpen: boolean; + onClose: () => void; + category: Category | null; + onSave: (data: CategoryFormValues) => void; + categories: Category[]; + mode: 'edit' | 'create'; + parentId?: number | null; +}) => { + const form = useForm({ + resolver: zodResolver(categoryFormSchema), + defaultValues: category + ? { + name: category.name, + slug: category.slug || '', + description: category.description || '', + parent_id: category.parent_id, + is_active: category.is_active, + } + : { + name: '', + slug: '', + description: '', + parent_id: parentId || null, + is_active: true, + }, + }); + + useEffect(() => { + if (isOpen) { + form.reset( + category + ? { + name: category.name, + slug: category.slug || '', + description: category.description || '', + parent_id: category.parent_id, + is_active: category.is_active, + } + : { + name: '', + slug: '', + description: '', + parent_id: parentId || null, + is_active: true, + } + ); + } + }, [category, form, isOpen, parentId]); + + const handleSubmit = (data: CategoryFormValues) => { + onSave(data); + }; + + // Функция для получения плоского списка категорий для выбора родителя + const getSelectableCategories = ( + categories: Category[], + excludeId: number | null = null + ): { id: number; name: string; level: number }[] => { + const result: { id: number; name: string; level: number }[] = []; + + const traverse = (cats: Category[], level = 0) => { + cats.forEach((cat) => { + if (excludeId === null || cat.id !== excludeId) { + result.push({ id: cat.id, name: cat.name, level }); + if (cat.subcategories && cat.subcategories.length > 0) { + traverse(cat.subcategories, level + 1); + } + } + }); + }; + + traverse(categories); + return result; + }; + + const selectableCategories = getSelectableCategories( + categories, + mode === 'edit' ? category?.id : null + ); + + return ( + !open && onClose()}> + + + + {mode === 'edit' ? 'Редактирование категории' : 'Создание категории'} + + + {mode === 'edit' + ? 'Отредактируйте информацию о категории' + : 'Создайте новую категорию'} + + +
+ + ( + + Название категории + + + + + + )} + /> + ( + + Slug (для URL) + + + + + Используется в URL. Только латинские буквы, цифры и дефисы. + + + + )} + /> + ( + + Описание + +