diff --git a/.DS_Store b/.DS_Store index 99840b4..5ba35b8 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/.DS_Store b/backend/.DS_Store index 5918e07..3c06a03 100644 Binary files a/backend/.DS_Store and b/backend/.DS_Store differ diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..b7329b9 --- /dev/null +++ b/backend/.env @@ -0,0 +1,13 @@ +# Настройки базы данных +DATABASE_URL=postgresql://postgres:postgres@localhost:5434/shop_db + +# Настройки безопасности +SECRET_KEY=supersecretkey +DEBUG=1 + +# Настройки загрузки файлов +UPLOAD_DIRECTORY=uploads + +# Настройки Meilisearch +MEILISEARCH_URL=http://localhost:7700 +MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM diff --git a/backend/.env.docker b/backend/.env.docker index 76e4300..e8c8b23 100644 --- a/backend/.env.docker +++ b/backend/.env.docker @@ -7,4 +7,8 @@ SECRET_KEY=supersecretkey # UPLOAD_DIRECTORY=/app/uploads # Настройки CORS -FRONTEND_URL=http://frontend:3000 \ No newline at end of file +FRONTEND_URL=http://frontend:3000 + +# Настройки Meilisearch +MEILISEARCH_URL=http://meilisearch:7700 +MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM diff --git a/backend/app/.DS_Store b/backend/app/.DS_Store index b3916a8..55d04c0 100644 Binary files a/backend/app/.DS_Store and b/backend/app/.DS_Store differ diff --git a/backend/app/.env b/backend/app/.env index da639d8..133860c 100644 --- a/backend/app/.env +++ b/backend/app/.env @@ -7,4 +7,7 @@ MINIO_ENDPOINT_URL = http://45.129.128.113:9000 MINIO_ACCESS_KEY = ZIK_DressedForSuccess MINIO_SECRET_KEY = ZIK_DressedForSuccess_/////ZIK_DressedForSuccess_! MINIO_BUCKET_NAME = dressedforsuccess -MINIO_USE_SSL = false \ No newline at end of file +MINIO_USE_SSL = false + +MEILISEARCH_KEY = dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM +MEILISEARCH_URL = http://localhost:7700 \ No newline at end of file diff --git a/backend/app/__pycache__/cache_with_logging.cpython-310.pyc b/backend/app/__pycache__/cache_with_logging.cpython-310.pyc new file mode 100644 index 0000000..698b126 Binary files /dev/null and b/backend/app/__pycache__/cache_with_logging.cpython-310.pyc differ diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc index 7160e95..0200aea 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/__pycache__/core.cpython-310.pyc b/backend/app/__pycache__/core.cpython-310.pyc index 315f529..7647237 100644 Binary files a/backend/app/__pycache__/core.cpython-310.pyc and b/backend/app/__pycache__/core.cpython-310.pyc differ diff --git a/backend/app/__pycache__/main.cpython-310.pyc b/backend/app/__pycache__/main.cpython-310.pyc index 9756554..dddbef9 100644 Binary files a/backend/app/__pycache__/main.cpython-310.pyc and b/backend/app/__pycache__/main.cpython-310.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py index 3324835..5bde74e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ load_dotenv() # Базовые настройки API_PREFIX = "/api" -DEBUG = True +DEBUG = False # Настройки безопасности SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey") # Для JWT @@ -53,15 +53,16 @@ class Settings(BaseSettings): APP_NAME: str = "Интернет-магазин API" APP_VERSION: str = "0.1.0" APP_DESCRIPTION: str = "API для интернет-магазина на FastAPI" - + DEBUG: bool = False + # Настройки базы данных DATABASE_URL: str = "postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db" - + # Настройки безопасности SECRET_KEY: str = SECRET_KEY ALGORITHM: str = ALGORITHM ACCESS_TOKEN_EXPIRE_MINUTES: int = ACCESS_TOKEN_EXPIRE_MINUTES - + # Настройки CORS CORS_ORIGINS: list = [ "http://localhost", @@ -69,39 +70,44 @@ class Settings(BaseSettings): "http://localhost:8000", "http://localhost:8080", ] - - # Старые настройки для загрузки файлов (удалить или закомментировать) - # UPLOAD_DIRECTORY: str = UPLOAD_DIRECTORY + + # Настройки для загрузки файлов + UPLOAD_DIRECTORY: str = "uploads" MAX_UPLOAD_SIZE: int = MAX_UPLOAD_SIZE ALLOWED_UPLOAD_EXTENSIONS: list = list(ALLOWED_UPLOAD_EXTENSIONS) - + # Настройки MinIO/S3 MINIO_ENDPOINT_URL: str = MINIO_ENDPOINT_URL MINIO_ACCESS_KEY: str = MINIO_ACCESS_KEY MINIO_SECRET_KEY: str = MINIO_SECRET_KEY MINIO_BUCKET_NAME: str = MINIO_BUCKET_NAME MINIO_USE_SSL: bool = MINIO_USE_SSL - + # Настройки для платежных систем (пример) PAYMENT_GATEWAY_API_KEY: str = os.getenv("PAYMENT_GATEWAY_API_KEY", "") PAYMENT_GATEWAY_SECRET: str = os.getenv("PAYMENT_GATEWAY_SECRET", "") - + # Настройки для CDEK API CDEK_LOGIN: str = os.getenv("CDEK_LOGIN", "cdek-login") CDEK_PASSWORD: str = os.getenv("CDEK_PASSWORD", "cdek-pass") CDEK_BASE_URL: str = os.getenv("CDEK_BASE_URL", "https://api.cdek.ru/v2") - + # Настройки для отправки email (пример) 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 - + + # Настройки Meilisearch + MEILISEARCH_URL: str = os.getenv("MEILISEARCH_URL", "http://localhost:7700") + MEILISEARCH_KEY: str = os.getenv("MEILISEARCH_KEY", "masterKey") + + class Config: env_file = ".env" env_file_encoding = "utf-8" # Создаем экземпляр настроек -settings = Settings() \ No newline at end of file +settings = Settings() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 7eb0260..9153a09 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,23 +1,57 @@ import os +import logging from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.exc import SQLAlchemyError +from contextlib import asynccontextmanager from app.config import settings from app.routers import router -from app.core import Base, engine +from app.core import Base, engine, SessionLocal +from app.services import meilisearch_service +from app.scripts.sync_meilisearch import sync_products, sync_categories, sync_collections, sync_sizes + +logging.basicConfig(level=logging.INFO) # Создаем таблицы в базе данных Base.metadata.create_all(bind=engine) + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Инициализируем Meilisearch + logging.info("Инициализация Meilisearch...") + try: + # Инициализируем индексы + meilisearch_service.initialize_indexes() + + # Создаем сессию базы данных + db = SessionLocal() + try: + # Синхронизируем данные с Meilisearch + sync_categories(db) + sync_collections(db) + sync_sizes(db) + sync_products(db) + logging.info("Синхронизация с Meilisearch завершена успешно") + except Exception as e: + logging.error(f"Ошибка при синхронизации данных с Meilisearch: {str(e)}") + finally: + db.close() + except Exception as e: + logging.error(f"Ошибка при инициализации Meilisearch: {str(e)}") + + yield + # Создаем экземпляр приложения FastAPI app = FastAPI( title="Dressed for Success API", description="API для интернет-магазина одежды", - version="1.0.0" + version="1.0.0", + lifespan=lifespan ) # Настраиваем CORS @@ -40,7 +74,10 @@ async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError): # Подключаем роутеры app.include_router(router, prefix="/api") + + + # Корневой маршрут @app.get("/") async def root(): - return {"message": "Добро пожаловать в API интернет-магазина Dressed for Success"} \ No newline at end of file + return {"message": "Добро пожаловать в API интернет-магазина Dressed for Success"} diff --git a/backend/app/repositories/__pycache__/async_catalog_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/async_catalog_repo.cpython-310.pyc new file mode 100644 index 0000000..f89ab80 Binary files /dev/null and b/backend/app/repositories/__pycache__/async_catalog_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc index b7e6ecd..6b2be9e 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 4dbfdeb..8296209 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 b5b7d88..aa35244 100644 --- a/backend/app/repositories/catalog_repo.py +++ b/backend/app/repositories/catalog_repo.py @@ -28,7 +28,7 @@ def generate_slug(name: str) -> str: slug = re.sub(r'-+', '-', slug) # Удаляем дефисы в начале и конце slug = slug.strip('-') - + return slug @@ -42,16 +42,16 @@ def get_collection_by_slug(db: Session, slug: str) -> Optional[Collection]: def get_collections( - db: Session, - skip: int = 0, - limit: int = 100, + db: Session, + skip: int = 0, + limit: int = 100, is_active: Optional[bool] = True ) -> List[Collection]: query = db.query(Collection) - + if is_active is not None: query = query.filter(Collection.is_active == is_active) - + return query.offset(skip).limit(limit).all() @@ -59,14 +59,14 @@ def create_collection(db: Session, collection: CollectionCreate) -> Collection: # Если slug не предоставлен, генерируем его из имени if not collection.slug: collection.slug = generate_slug(collection.name) - + # Проверяем, что коллекция с таким slug не существует if get_collection_by_slug(db, collection.slug): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Коллекция с таким slug уже существует" ) - + # Создаем новую коллекцию db_collection = Collection( name=collection.name, @@ -74,7 +74,7 @@ def create_collection(db: Session, collection: CollectionCreate) -> Collection: description=collection.description, is_active=collection.is_active ) - + try: db.add(db_collection) db.commit() @@ -95,10 +95,10 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd status_code=status.HTTP_404_NOT_FOUND, detail="Коллекция не найдена" ) - + # Обновляем только предоставленные поля update_data = collection.dict(exclude_unset=True) - + # Если slug изменяется, проверяем его уникальность if "slug" in update_data and update_data["slug"] != db_collection.slug: if get_collection_by_slug(db, update_data["slug"]): @@ -106,7 +106,7 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd status_code=status.HTTP_400_BAD_REQUEST, detail="Коллекция с таким slug уже существует" ) - + # Если имя изменяется и slug не предоставлен, генерируем новый slug if "name" in update_data and "slug" not in update_data: update_data["slug"] = generate_slug(update_data["name"]) @@ -116,11 +116,11 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd status_code=status.HTTP_400_BAD_REQUEST, detail="Коллекция с таким slug уже существует" ) - + # Применяем обновления for key, value in update_data.items(): setattr(db_collection, key, value) - + try: db.commit() db.refresh(db_collection) @@ -140,14 +140,14 @@ def delete_collection(db: Session, collection_id: int) -> bool: status_code=status.HTTP_404_NOT_FOUND, detail="Коллекция не найдена" ) - + # Проверяем, есть ли у коллекции продукты if db.query(Product).filter(Product.collection_id == collection_id).count() > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Нельзя удалить коллекцию, у которой есть продукты" ) - + try: db.delete(db_collection) db.commit() @@ -170,18 +170,18 @@ def get_category_by_slug(db: Session, slug: str) -> Optional[Category]: def get_categories( - db: Session, - skip: int = 0, - limit: int = 100, + db: Session, + skip: int = 0, + limit: int = 100, parent_id: Optional[int] = None ) -> List[Category]: query = db.query(Category) - + if parent_id is not None: query = query.filter(Category.parent_id == parent_id) else: query = query.filter(Category.parent_id == None) - + return query.offset(skip).limit(limit).all() @@ -189,21 +189,21 @@ def create_category(db: Session, category: CategoryCreate) -> Category: # Если slug не предоставлен, генерируем его из имени if not category.slug: category.slug = generate_slug(category.name) - + # Проверяем, что категория с таким slug не существует if get_category_by_slug(db, category.slug): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Категория с таким slug уже существует" ) - + # Проверяем, что родительская категория существует, если указана if category.parent_id and not get_category(db, category.parent_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Родительская категория не найдена" ) - + # Создаем новую категорию db_category = Category( name=category.name, @@ -212,7 +212,7 @@ def create_category(db: Session, category: CategoryCreate) -> Category: parent_id=category.parent_id, is_active=category.is_active ) - + try: db.add(db_category) db.commit() @@ -233,10 +233,10 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) -> status_code=status.HTTP_404_NOT_FOUND, detail="Категория не найдена" ) - + # Обновляем только предоставленные поля update_data = category.dict(exclude_unset=True) - + # Если slug изменяется, проверяем его уникальность if "slug" in update_data and update_data["slug"] != db_category.slug: if get_category_by_slug(db, update_data["slug"]): @@ -244,7 +244,7 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) -> status_code=status.HTTP_400_BAD_REQUEST, detail="Категория с таким slug уже существует" ) - + # Если имя изменяется и slug не предоставлен, генерируем новый slug if "name" in update_data and "slug" not in update_data: update_data["slug"] = generate_slug(update_data["name"]) @@ -254,25 +254,25 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) -> status_code=status.HTTP_400_BAD_REQUEST, detail="Категория с таким slug уже существует" ) - + # Проверяем, что родительская категория существует, если указана if "parent_id" in update_data and update_data["parent_id"] and not get_category(db, update_data["parent_id"]): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Родительская категория не найдена" ) - + # Проверяем, что категория не становится своим собственным родителем if "parent_id" in update_data and update_data["parent_id"] == category_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Категория не может быть своим собственным родителем" ) - + # Применяем обновления for key, value in update_data.items(): setattr(db_category, key, value) - + try: db.commit() db.refresh(db_category) @@ -292,21 +292,21 @@ def delete_category(db: Session, category_id: int) -> bool: status_code=status.HTTP_404_NOT_FOUND, detail="Категория не найдена" ) - + # Проверяем, есть ли у категории подкатегории if db.query(Category).filter(Category.parent_id == category_id).count() > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Нельзя удалить категорию, у которой есть подкатегории" ) - + # Проверяем, есть ли у категории продукты if db.query(Product).filter(Product.category_id == category_id).count() > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Нельзя удалить категорию, у которой есть продукты" ) - + try: db.delete(db_category) db.commit() @@ -329,50 +329,52 @@ def get_product_by_slug(db: Session, slug: str) -> Optional[Product]: def get_products( - db: Session, - skip: int = 0, - limit: int = 100, + db: Session, + skip: int = 0, + limit: int = 100, category_id: Optional[int] = None, collection_id: Optional[int] = None, search: Optional[str] = None, min_price: Optional[float] = None, max_price: Optional[float] = None, - is_active: Optional[bool] = True, - include_variants: Optional[bool] = False + is_active: Optional[bool] = True ) -> List[Product]: query = db.query(Product) - + # Применяем фильтры if category_id: query = query.filter(Product.category_id == category_id) - + if collection_id: query = query.filter(Product.collection_id == collection_id) - + if search: query = query.filter(Product.name.ilike(f"%{search}%")) - + if is_active is not None: query = query.filter(Product.is_active == is_active) - + # Фильтрация по цене теперь должна быть через варианты продукта if min_price is not None or max_price is not None: query = query.join(ProductVariant) - + if min_price is not None: query = query.filter(ProductVariant.price >= min_price) - + if max_price is not None: query = query.filter(ProductVariant.price <= max_price) - + products = query.offset(skip).limit(limit).all() - - # Если нужно включить варианты, загружаем их для каждого продукта - if include_variants: - for product in products: - product.variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all() - product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all() - + + # Всегда загружаем варианты и изображения для каждого продукта + for product in products: + # Загружаем варианты с размерами + variants = db.query(ProductVariant).join(Size, ProductVariant.size_id == Size.id).filter(ProductVariant.product_id == product.id).all() + product.variants = variants + + # Загружаем изображения + product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all() + return products @@ -380,28 +382,28 @@ def create_product(db: Session, product: ProductCreate) -> Product: # Если slug не предоставлен, генерируем его из имени if not product.slug: product.slug = generate_slug(product.name) - + # Проверяем, что slug уникален if db.query(Product).filter(Product.slug == product.slug).first(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Продукт с slug '{product.slug}' уже существует" ) - + # Проверяем, что категория существует if not get_category(db, product.category_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Категория с ID {product.category_id} не найдена" ) - + # Проверяем, что коллекция существует, если она указана if product.collection_id and not get_collection(db, product.collection_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Коллекция с ID {product.collection_id} не найдена" ) - + db_product = Product( name=product.name, slug=product.slug, @@ -413,7 +415,7 @@ def create_product(db: Session, product: ProductCreate) -> Product: category_id=product.category_id, collection_id=product.collection_id ) - + try: db.add(db_product) db.commit() @@ -434,7 +436,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod status_code=status.HTTP_404_NOT_FOUND, detail=f"Продукт с ID {product_id} не найден" ) - + # Если slug изменяется, проверяем его уникальность if product.slug is not None and product.slug != db_product.slug: if db.query(Product).filter(Product.slug == product.slug).first(): @@ -442,7 +444,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod status_code=status.HTTP_400_BAD_REQUEST, detail=f"Продукт с slug '{product.slug}' уже существует" ) - + # Если имя изменяется и slug не предоставлен, обновляем slug if product.name is not None and product.name != db_product.name and product.slug is None: product.slug = generate_slug(product.name) @@ -452,7 +454,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod status_code=status.HTTP_400_BAD_REQUEST, detail=f"Продукт с slug '{product.slug}' уже существует" ) - + # Проверяем, что категория существует, если она изменяется if product.category_id is not None and product.category_id != db_product.category_id: if not get_category(db, product.category_id): @@ -460,7 +462,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod status_code=status.HTTP_404_NOT_FOUND, detail=f"Категория с ID {product.category_id} не найдена" ) - + # Проверяем, что коллекция существует, если она изменяется if product.collection_id is not None and product.collection_id != db_product.collection_id: if product.collection_id and not get_collection(db, product.collection_id): @@ -468,7 +470,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod status_code=status.HTTP_404_NOT_FOUND, detail=f"Коллекция с ID {product.collection_id} не найдена" ) - + # Обновляем поля if product.name is not None: db_product.name = product.name @@ -488,7 +490,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod db_product.category_id = product.category_id if product.collection_id is not None: db_product.collection_id = product.collection_id - + try: db.commit() db.refresh(db_product) @@ -508,7 +510,7 @@ def delete_product(db: Session, product_id: int) -> bool: status_code=status.HTTP_404_NOT_FOUND, detail="Продукт не найден" ) - + try: db.delete(db_product) db.commit() @@ -527,7 +529,8 @@ def get_product_variant(db: Session, variant_id: int) -> Optional[ProductVariant def get_product_variants(db: Session, product_id: int) -> List[ProductVariant]: - return db.query(ProductVariant).filter(ProductVariant.product_id == product_id).all() + # Загружаем варианты с размерами + return db.query(ProductVariant).join(Size, ProductVariant.size_id == Size.id).filter(ProductVariant.product_id == product_id).all() def create_product_variant(db: Session, variant: ProductVariantCreate) -> ProductVariant: @@ -538,7 +541,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc status_code=status.HTTP_404_NOT_FOUND, detail=f"Продукт с ID {variant.product_id} не найден" ) - + # Проверяем, что размер существует db_size = get_size(db, variant.size_id) if not db_size: @@ -546,7 +549,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc status_code=status.HTTP_404_NOT_FOUND, detail=f"Размер с ID {variant.size_id} не найден" ) - + # Проверяем, что вариант с таким SKU не существует existing_variant = db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first() if existing_variant: @@ -554,7 +557,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc status_code=status.HTTP_400_BAD_REQUEST, detail=f"Вариант продукта с SKU {variant.sku} уже существует" ) - + # Проверяем, что вариант с таким размером для этого продукта не существует existing_size_variant = db.query(ProductVariant).filter( ProductVariant.product_id == variant.product_id, @@ -565,7 +568,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc status_code=status.HTTP_400_BAD_REQUEST, detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта" ) - + db_variant = ProductVariant( product_id=variant.product_id, size_id=variant.size_id, @@ -573,7 +576,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc stock=variant.stock, is_active=variant.is_active ) - + try: db.add(db_variant) db.commit() @@ -594,7 +597,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant status_code=status.HTTP_404_NOT_FOUND, detail=f"Вариант продукта с ID {variant_id} не найден" ) - + # Проверяем, что продукт существует, если ID продукта изменяется if variant.product_id is not None and variant.product_id != db_variant.product_id: db_product = get_product(db, variant.product_id) @@ -603,7 +606,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant status_code=status.HTTP_404_NOT_FOUND, detail=f"Продукт с ID {variant.product_id} не найден" ) - + # Проверяем, что размер существует, если ID размера изменяется if variant.size_id is not None and variant.size_id != db_variant.size_id: db_size = get_size(db, variant.size_id) @@ -612,7 +615,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant status_code=status.HTTP_404_NOT_FOUND, detail=f"Размер с ID {variant.size_id} не найден" ) - + # Проверяем, что вариант с таким размером для этого продукта не существует product_id = variant.product_id if variant.product_id is not None else db_variant.product_id existing_size_variant = db.query(ProductVariant).filter( @@ -625,7 +628,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant status_code=status.HTTP_400_BAD_REQUEST, detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта" ) - + # Проверяем, что SKU уникален, если он изменяется if variant.sku is not None and variant.sku != db_variant.sku: existing_variant = db.query(ProductVariant).filter( @@ -637,7 +640,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant status_code=status.HTTP_400_BAD_REQUEST, detail=f"Вариант продукта с SKU {variant.sku} уже существует" ) - + # Обновляем поля if variant.product_id is not None: db_variant.product_id = variant.product_id @@ -649,7 +652,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant db_variant.stock = variant.stock if variant.is_active is not None: db_variant.is_active = variant.is_active - + try: db.commit() db.refresh(db_variant) @@ -669,7 +672,7 @@ def delete_product_variant(db: Session, variant_id: int) -> bool: status_code=status.HTTP_404_NOT_FOUND, detail="Вариант продукта не найден" ) - + try: db.delete(db_variant) db.commit() @@ -698,14 +701,14 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage status_code=status.HTTP_404_NOT_FOUND, detail="Продукт не найден" ) - + # Если изображение отмечено как основное, сбрасываем флаг у других изображений if image.is_primary: db.query(ProductImage).filter( ProductImage.product_id == image.product_id, ProductImage.is_primary == True ).update({"is_primary": False}) - + # Создаем новое изображение продукта db_image = ProductImage( product_id=image.product_id, @@ -713,7 +716,7 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage alt_text=image.alt_text, is_primary=image.is_primary ) - + try: db.add(db_image) db.commit() @@ -734,20 +737,20 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) status_code=status.HTTP_404_NOT_FOUND, detail="Изображение продукта не найдено" ) - + # Если изображение отмечается как основное, сбрасываем флаг у других изображений 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}) - + # Обновляем поля 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() db.refresh(db_image) @@ -767,7 +770,7 @@ def delete_product_image(db: Session, image_id: int) -> bool: status_code=status.HTTP_404_NOT_FOUND, detail="Изображение продукта не найдено" ) - + try: db.delete(db_image) db.commit() @@ -801,13 +804,13 @@ def create_size(db: Session, size: SizeCreate) -> Size: status_code=status.HTTP_400_BAD_REQUEST, detail=f"Размер с кодом {size.code} уже существует" ) - + db_size = Size( name=size.name, code=size.code, description=size.description ) - + try: db.add(db_size) db.commit() @@ -828,7 +831,7 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size: status_code=status.HTTP_404_NOT_FOUND, detail=f"Размер с ID {size_id} не найден" ) - + # Проверяем, что код размера уникален, если он изменяется if size.code and size.code != db_size.code: existing_size = get_size_by_code(db, size.code) @@ -837,7 +840,7 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size: status_code=status.HTTP_400_BAD_REQUEST, detail=f"Размер с кодом {size.code} уже существует" ) - + # Обновляем поля if size.name is not None: db_size.name = size.name @@ -845,7 +848,7 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size: db_size.code = size.code if size.description is not None: db_size.description = size.description - + try: db.commit() db.refresh(db_size) @@ -865,7 +868,7 @@ def delete_size(db: Session, size_id: int) -> bool: status_code=status.HTTP_404_NOT_FOUND, detail=f"Размер с ID {size_id} не найден" ) - + # Проверяем, используется ли размер в вариантах продуктов variants_with_size = db.query(ProductVariant).filter(ProductVariant.size_id == size_id).count() if variants_with_size > 0: @@ -873,7 +876,7 @@ def delete_size(db: Session, size_id: int) -> bool: status_code=status.HTTP_400_BAD_REQUEST, detail=f"Невозможно удалить размер, так как он используется в {variants_with_size} вариантах продуктов" ) - + try: db.delete(db_size) db.commit() @@ -883,4 +886,4 @@ def delete_size(db: Session, size_id: int) -> bool: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Ошибка при удалении размера: {str(e)}" - ) \ No newline at end of file + ) \ No newline at end of file diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py index d39cd5b..3c67287 100644 --- a/backend/app/repositories/user_repo.py +++ b/backend/app/repositories/user_repo.py @@ -14,11 +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) + # 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}") + # 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 97c36cf..4de452d 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 856c4b6..9b2090b 100644 --- a/backend/app/routers/catalog_router.py +++ b/backend/app/routers/catalog_router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query +from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File, Form, Query from sqlalchemy.orm import Session from typing import List, Optional, Dict, Any from fastapi.responses import JSONResponse @@ -17,6 +17,7 @@ from app.schemas.catalog_schemas import ( ProductCreateComplete, ProductUpdateComplete ) from app.models.user_models import User as UserModel +from app.models.catalog_models import Category, Size, Collection from app.repositories.catalog_repo import get_products, get_product_by_slug # Роутер для каталога @@ -42,8 +43,64 @@ async def delete_collection_endpoint(collection_id: int, current_user: UserModel @catalog_router.get("/collections", response_model=Dict[str, Any]) -async def get_collections_endpoint(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): - return services.get_collections(db, skip, limit) +async def get_collections_endpoint( + *, + skip: int = 0, + limit: int = 100, + search: Optional[str] = None, + is_active: Optional[bool] = None, + db: Session = Depends(get_db) +): + # Формируем фильтры для Meilisearch + filters = [] + + if is_active is not None: + filters.append(f"is_active = {str(is_active).lower()}") + + # Используем Meilisearch для поиска коллекций + from app.services import meilisearch_service + + # Объединяем фильтры в строку + filter_str = " AND ".join(filters) if filters else None + + # Выполняем поиск в Meilisearch + result = meilisearch_service.search_collections( + query=search or "", + filters=filter_str, + limit=limit, + offset=skip + ) + + # Если поиск в Meilisearch успешен, возвращаем результаты + if result["success"]: + return { + "success": True, + "collections": result["collections"], + "total": result["total"] + } + + # Если поиск в Meilisearch не удался, используем старый метод + logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.") + + # Получаем коллекции из базы данных + collections_db = db.query(Collection).all() + + # Преобразуем коллекции в список словарей + from app.schemas.catalog_schemas import Collection as CollectionSchema + collections_list = [CollectionSchema.model_validate(collection).model_dump() for collection in collections_db] + + # Синхронизируем коллекции с Meilisearch для будущих запросов + try: + from app.scripts.sync_meilisearch import sync_collections + sync_collections(db) + except Exception as e: + logging.error(f"Failed to sync collections with Meilisearch: {str(e)}") + + return { + "success": True, + "collections": collections_list, + "total": len(collections_list) + } ######################### @@ -65,9 +122,72 @@ async def delete_category_endpoint(category_id: int, current_user: UserModel = D return services.delete_category(db, category_id) -@catalog_router.get("/categories", response_model=List[Dict[str, Any]]) -async def get_categories_tree(db: Session = Depends(get_db)): - return services.get_category_tree(db) +@catalog_router.get("/categories", response_model=Dict[str, Any]) +async def get_categories_endpoint( + *, + skip: int = 0, + limit: int = 100, + search: Optional[str] = None, + parent_id: Optional[int] = None, + is_active: Optional[bool] = None, + db: Session = Depends(get_db) +): + # Формируем фильтры для Meilisearch + filters = [] + + if parent_id is not None: + filters.append(f"parent_id = {parent_id}") + + if is_active is not None: + filters.append(f"is_active = {str(is_active).lower()}") + + # Используем Meilisearch для поиска категорий + from app.services import meilisearch_service + + # Объединяем фильтры в строку + filter_str = " AND ".join(filters) if filters else None + + # Выполняем поиск в Meilisearch + result = meilisearch_service.search_categories( + query=search or "", + filters=filter_str, + limit=limit, + offset=skip + ) + + # Добавляем логирование для отладки + logging.info(f"Meilisearch search result: {result}") + + # Если поиск в Meilisearch успешен, возвращаем результаты + if result["success"] and result["categories"]: + return { + "success": True, + "categories": result["categories"], + "total": result["total"] + } + + # Если поиск в Meilisearch не удался, используем старый метод + logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.") + + # Получаем категории из базы данных + categories = db.query(Category).all() + + # Преобразуем категории в список словарей + from app.schemas.catalog_schemas import Category as CategorySchema + categories_list = [CategorySchema.model_validate(category).model_dump() for category in categories] + + # Синхронизируем категории с Meilisearch для будущих запросов + try: + from app.scripts.sync_meilisearch import sync_categories + sync_categories(db) + except Exception as e: + logging.error(f"Failed to sync categories with Meilisearch: {str(e)}") + + return { + "success": True, + "categories": categories_list, + "total": len(categories_list) + } ######################### @@ -76,40 +196,73 @@ async def get_categories_tree(db: Session = Depends(get_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) @catalog_router.put("/products/{product_id}", response_model=Dict[str, Any]) async def update_product_endpoint(product_id: int, product: ProductUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + # Используем синхронную версию для обновления продукта return services.update_product(db, product_id, product) @catalog_router.delete("/products/{product_id}", response_model=Dict[str, Any]) async def delete_product_endpoint(product_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + # Используем синхронную версию для удаления продукта return services.delete_product(db, product_id) @catalog_router.get("/products/{product_id}", response_model=Dict[str, Any]) -async def get_product_details_endpoint(product_id: int, db: Session = Depends(get_db)): - return services.get_product_details(db, product_id) +async def get_product_details_endpoint(*, product_id: int, db: Session = Depends(get_db)): + # Используем синхронную версию для получения деталей продукта + product = services.get_product_details(db, product_id) + return {"success": True, "product": product} @catalog_router.get("/products/slug/{slug}", response_model=Dict[str, Any]) -async def get_product_by_slug_endpoint(slug: str, db: Session = Depends(get_db)): +async def get_product_by_slug_endpoint(*, slug: str, db: Session = Depends(get_db)): + # Сначала пробуем найти продукт в Meilisearch + from app.services import meilisearch_service + + # Используем поиск по slug + result = meilisearch_service.get_product_by_slug(slug) + + # Если продукт найден в Meilisearch, возвращаем его + if result["success"]: + return { + "success": True, + "product": result["product"] + } + + # Если продукт не найден в Meilisearch, используем старый метод + logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.") + + # Используем синхронную версию для получения продукта по slug product = get_product_by_slug(db, slug) if not product: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Продукт не найден" + detail=f"Продукт с slug {slug} не найден" ) - return services.get_product_details(db, product.id) + + # Получаем детали продукта + product_details = services.get_product_details(db, product.id) + + # Синхронизируем продукт с Meilisearch для будущих запросов + try: + from app.scripts.sync_meilisearch import sync_products + sync_products(db) + except Exception as e: + logging.error(f"Failed to sync products with Meilisearch: {str(e)}") + + return {"success": True, "product": product_details} @catalog_router.post("/products/{product_id}/variants", response_model=Dict[str, Any]) async def add_product_variant_endpoint( - product_id: int, - variant: ProductVariantCreate, - current_user: UserModel = Depends(get_current_admin_user), + product_id: int, + variant: ProductVariantCreate, + current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db) ): # Убедимся, что product_id в пути совпадает с product_id в данных @@ -120,9 +273,9 @@ async def add_product_variant_endpoint( @catalog_router.put("/variants/{variant_id}", response_model=Dict[str, Any]) async def update_product_variant_endpoint( - variant_id: int, - variant: ProductVariantUpdate, - current_user: UserModel = Depends(get_current_admin_user), + variant_id: int, + variant: ProductVariantUpdate, + current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db) ): return services.update_product_variant(db, variant_id, variant) @@ -130,8 +283,8 @@ async def update_product_variant_endpoint( @catalog_router.delete("/variants/{variant_id}", response_model=Dict[str, Any]) async def delete_product_variant_endpoint( - variant_id: int, - current_user: UserModel = Depends(get_current_admin_user), + variant_id: int, + current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db) ): return services.delete_product_variant(db, variant_id) @@ -180,7 +333,7 @@ async def upload_product_image_endpoint( 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() @@ -196,7 +349,7 @@ async def upload_product_image_endpoint( logging.info("Вызов сервиса upload_product_image") result = services.upload_product_image( db, product_id, file, is_primary, alt_text="") - + logging.info(f"Результат загрузки изображения: {result}") # Возвращаем успешный ответ с данными изображения @@ -216,7 +369,7 @@ async def upload_product_image_endpoint( error_msg = f"Неожиданная ошибка при загрузке изображения: {str(e)}" logging.error(error_msg) logging.error(traceback.format_exc()) - + # Возвращаем ошибку с кодом 400 return JSONResponse( status_code=400, @@ -226,9 +379,9 @@ async def upload_product_image_endpoint( @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), + 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) @@ -239,78 +392,231 @@ async def delete_product_image_endpoint(image_id: int, current_user: UserModel = return services.delete_product_image(db, image_id) -@catalog_router.get("/products", response_model=List[Product]) +@catalog_router.get("/products", response_model=Dict[str, Any]) async def get_products_endpoint( - skip: int = 0, - limit: int = 100, + *, + skip: int = 0, + limit: int = 100, category_id: Optional[int] = None, collection_id: Optional[int] = None, search: Optional[str] = None, min_price: Optional[float] = None, max_price: Optional[float] = None, is_active: Optional[bool] = True, - include_variants: Optional[bool] = False, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "asc", db: Session = Depends(get_db) ): - products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active, include_variants) - # Преобразуем объекты SQLAlchemy в схемы Pydantic - return [Product.from_orm(product) for product in products] + # Формируем фильтры для Meilisearch + filters = [] + + if category_id is not None: + filters.append(f"category_id = {category_id}") + + if collection_id is not None: + filters.append(f"collection_id = {collection_id}") + + if is_active is not None: + filters.append(f"is_active = {str(is_active).lower()}") + + if min_price is not None: + filters.append(f"price >= {min_price}") + + if max_price is not None: + filters.append(f"price <= {max_price}") + + # Формируем параметры сортировки + sort = None + if sort_by: + sort = [f"{sort_by}:{sort_order}"] + + # Используем Meilisearch для поиска продуктов + from app.services import meilisearch_service + + # Объединяем фильтры в строку + filter_str = " AND ".join(filters) if filters else None + + # Выполняем поиск в Meilisearch + result = meilisearch_service.search_products( + query=search or "", + filters=filter_str, + sort=sort, + limit=limit, + offset=skip + ) + + # Если поиск в Meilisearch успешен, возвращаем результаты + if result["success"]: + return { + "success": True, + "products": result["products"], + "total": result["total"], + "skip": skip, + "limit": limit + } + + # Если поиск в Meilisearch не удался, используем старый метод + logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.") + products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active) + products_list = [services.format_product(product) for product in products] + + # Синхронизируем продукты с Meilisearch для будущих запросов + try: + from app.scripts.sync_meilisearch import sync_products + sync_products(db) + except Exception as e: + logging.error(f"Failed to sync products with Meilisearch: {str(e)}") + + return { + "success": True, + "products": products_list, + "total": len(products_list), + "skip": skip, + "limit": limit + } # Маршруты для размеров -@catalog_router.get("/sizes", response_model=List[Size]) -def get_sizes( - skip: int = 0, +@catalog_router.get("/sizes", response_model=Dict[str, Any]) +async def get_sizes(*, + skip: int = 0, limit: int = 100, + search: Optional[str] = None, db: Session = Depends(get_db) ): """Получить список всех размеров""" - return services.get_sizes(db, skip, limit) + # Используем Meilisearch для поиска размеров + from app.services import meilisearch_service + + # Выполняем поиск в Meilisearch + result = meilisearch_service.search_sizes( + query=search or "", + filters=None, + limit=limit, + offset=skip + ) + + # Если поиск в Meilisearch успешен, возвращаем результаты + if result["success"]: + return { + "success": True, + "sizes": result["sizes"], + "total": result["total"] + } + + # Если поиск в Meilisearch не удался, используем старый метод + logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.") + + # Получаем размеры из базы данных + from app.models.catalog_models import Size + sizes_db = db.query(Size).all() + + # Преобразуем размеры в список словарей + from app.schemas.catalog_schemas import Size as SizeSchema + sizes_list = [SizeSchema.model_validate(size).model_dump() for size in sizes_db] + + # Синхронизируем размеры с Meilisearch для будущих запросов + try: + from app.scripts.sync_meilisearch import sync_sizes + sync_sizes(db) + except Exception as e: + logging.error(f"Failed to sync sizes with Meilisearch: {str(e)}") + + return { + "success": True, + "sizes": sizes_list, + "total": len(sizes_list) + } -@catalog_router.get("/sizes/{size_id}", response_model=Size) -def get_size( +@catalog_router.get("/sizes/{size_id}", response_model=Dict[str, Any]) +async def get_size( size_id: int, db: Session = Depends(get_db) ): """Получить размер по ID""" - return services.get_size(db, size_id) + # Пробуем получить размер из Meilisearch + from app.services import meilisearch_service + result = meilisearch_service.get_size(size_id) + + if result["success"]: + return { + "success": True, + "size": result["size"] + } + + # Если размер не найден в Meilisearch, получаем его из базы данных + size = services.get_size(db, size_id) + + # Преобразуем объект SQLAlchemy в словарь + from app.schemas.catalog_schemas import Size as SizeSchema + size_dict = SizeSchema.model_validate(size).model_dump() + + return { + "success": True, + "size": size_dict + } -@catalog_router.post("/sizes", response_model=Size, status_code=status.HTTP_201_CREATED) +@catalog_router.post("/sizes", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED) def create_size( size: SizeCreate, db: Session = Depends(get_db) ): """Создать новый размер""" - return services.create_size(db, size) + new_size = services.create_size(db, size) + + # Преобразуем объект SQLAlchemy в словарь + from app.schemas.catalog_schemas import Size as SizeSchema + size_dict = SizeSchema.model_validate(new_size).model_dump() + + return { + "success": True, + "size": size_dict + } -@catalog_router.put("/sizes/{size_id}", response_model=Size) +@catalog_router.put("/sizes/{size_id}", response_model=Dict[str, Any]) def update_size( size_id: int, size: SizeUpdate, db: Session = Depends(get_db) ): """Обновить размер""" - return services.update_size(db, size_id, size) + updated_size = services.update_size(db, size_id, size) + + # Преобразуем объект SQLAlchemy в словарь + from app.schemas.catalog_schemas import Size as SizeSchema + size_dict = SizeSchema.model_validate(updated_size).model_dump() + + return { + "success": True, + "size": size_dict + } -@catalog_router.delete("/sizes/{size_id}", response_model=Dict[str, bool]) +@catalog_router.delete("/sizes/{size_id}", response_model=Dict[str, Any]) def delete_size( size_id: int, db: Session = Depends(get_db) ): """Удалить размер""" + # Удаляем размер из базы данных success = services.delete_size(db, size_id) + + # Если удаление прошло успешно, удаляем размер из Meilisearch + if success: + from app.services import meilisearch_service + meilisearch_service.delete_size(size_id) + 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), + product: ProductCreateComplete, + current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ @@ -321,12 +627,13 @@ async def create_product_complete_endpoint( @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), + 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 + result = services.update_product_complete(db, product_id, product) + return result diff --git a/backend/app/scripts/__init__.py b/backend/app/scripts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/scripts/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/scripts/__pycache__/__init__.cpython-310.pyc b/backend/app/scripts/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..fbaedd0 Binary files /dev/null and b/backend/app/scripts/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/scripts/__pycache__/sync_meilisearch.cpython-310.pyc b/backend/app/scripts/__pycache__/sync_meilisearch.cpython-310.pyc new file mode 100644 index 0000000..abb170f Binary files /dev/null and b/backend/app/scripts/__pycache__/sync_meilisearch.cpython-310.pyc differ diff --git a/backend/app/scripts/sync_meilisearch.py b/backend/app/scripts/sync_meilisearch.py new file mode 100644 index 0000000..baefc21 --- /dev/null +++ b/backend/app/scripts/sync_meilisearch.py @@ -0,0 +1,249 @@ +import sys +import os +import logging +from pathlib import Path + +# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения +sys.path.append(str(Path(__file__).parent.parent.parent)) + +from sqlalchemy.orm import Session +from app.core import SessionLocal, engine +from app.models.catalog_models import Product, Category, Collection, Size, ProductVariant, ProductImage +from app.services import meilisearch_service +from app.schemas.catalog_schemas import Product as ProductSchema, Category as CategorySchema +from app.schemas.catalog_schemas import Collection as CollectionSchema, Size as SizeSchema + +# Настройка логгера +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def format_product_for_meilisearch(product, variants, images): + """ + Форматирует продукт для индексации в Meilisearch. + """ + # Преобразуем объект SQLAlchemy в словарь + product_dict = { + "id": product.id, + "name": product.name, + "slug": product.slug, + "description": product.description, + "price": product.price, + "discount_price": product.discount_price, + "care_instructions": product.care_instructions, + "is_active": product.is_active, + "category_id": product.category_id, + "collection_id": product.collection_id, + "created_at": product.created_at.isoformat() if product.created_at else None, + "updated_at": product.updated_at.isoformat() if product.updated_at else None, + "variants": [], + "images": [] + } + + # Добавляем варианты продукта + for variant in variants: + variant_dict = { + "id": variant.id, + "size_id": variant.size_id, + "sku": variant.sku, + "stock": variant.stock, + "is_active": variant.is_active + } + + # Добавляем информацию о размере, если она доступна + if variant.size: + variant_dict["size"] = { + "id": variant.size.id, + "name": variant.size.name, + "code": variant.size.code + } + + product_dict["variants"].append(variant_dict) + + # Добавляем изображения продукта + for image in images: + image_dict = { + "id": image.id, + "image_url": image.image_url, + "alt_text": image.alt_text, + "is_primary": image.is_primary + } + product_dict["images"].append(image_dict) + + # Добавляем основное изображение в корень продукта для удобства + if image.is_primary: + product_dict["primary_image"] = image.image_url + + return product_dict + + +def format_category_for_meilisearch(category): + """ + Форматирует категорию для индексации в Meilisearch. + """ + return { + "id": category.id, + "name": category.name, + "slug": category.slug, + "description": category.description, + "parent_id": category.parent_id, + "is_active": category.is_active, + "created_at": category.created_at.isoformat() if category.created_at else None, + "updated_at": category.updated_at.isoformat() if category.updated_at else None + } + + +def format_collection_for_meilisearch(collection): + """ + Форматирует коллекцию для индексации в Meilisearch. + """ + return { + "id": collection.id, + "name": collection.name, + "slug": collection.slug, + "description": collection.description, + "is_active": collection.is_active, + "created_at": collection.created_at.isoformat() if collection.created_at else None, + "updated_at": collection.updated_at.isoformat() if collection.updated_at else None + } + + +def format_size_for_meilisearch(size): + """ + Форматирует размер для индексации в Meilisearch. + """ + return { + "id": size.id, + "name": size.name, + "code": size.code, + "description": size.description, + "created_at": size.created_at.isoformat() if size.created_at else None, + "updated_at": size.updated_at.isoformat() if size.updated_at else None + } + + +def sync_products(db: Session): + """ + Синхронизирует все продукты с Meilisearch. + """ + logger.info("Syncing products...") + + # Получаем все продукты из базы данных + products = db.query(Product).all() + + # Форматируем продукты для Meilisearch + products_data = [] + for product in products: + variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all() + images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all() + product_data = format_product_for_meilisearch(product, variants, images) + products_data.append(product_data) + + # Синхронизируем продукты с Meilisearch + success = meilisearch_service.sync_all_products(products_data) + + if success: + logger.info(f"Successfully synced {len(products_data)} products") + else: + logger.error("Failed to sync products") + + return success + + +def sync_categories(db: Session): + """ + Синхронизирует все категории с Meilisearch. + """ + logger.info("Syncing categories...") + + # Получаем все категории из базы данных + categories = db.query(Category).all() + + # Форматируем категории для Meilisearch + categories_data = [format_category_for_meilisearch(category) for category in categories] + + # Синхронизируем категории с Meilisearch + success = meilisearch_service.sync_all_categories(categories_data) + + if success: + logger.info(f"Successfully synced {len(categories_data)} categories") + else: + logger.error("Failed to sync categories") + + return success + + +def sync_collections(db: Session): + """ + Синхронизирует все коллекции с Meilisearch. + """ + logger.info("Syncing collections...") + + # Получаем все коллекции из базы данных + collections = db.query(Collection).all() + + # Форматируем коллекции для Meilisearch + collections_data = [format_collection_for_meilisearch(collection) for collection in collections] + + # Синхронизируем коллекции с Meilisearch + success = meilisearch_service.sync_all_collections(collections_data) + + if success: + logger.info(f"Successfully synced {len(collections_data)} collections") + else: + logger.error("Failed to sync collections") + + return success + + +def sync_sizes(db: Session): + """ + Синхронизирует все размеры с Meilisearch. + """ + logger.info("Syncing sizes...") + + # Получаем все размеры из базы данных + sizes = db.query(Size).all() + + # Форматируем размеры для Meilisearch + sizes_data = [format_size_for_meilisearch(size) for size in sizes] + + # Синхронизируем размеры с Meilisearch + success = meilisearch_service.sync_all_sizes(sizes_data) + + if success: + logger.info(f"Successfully synced {len(sizes_data)} sizes") + else: + logger.error("Failed to sync sizes") + + return success + + +def main(): + """ + Основная функция для синхронизации всех данных с Meilisearch. + """ + logger.info("Starting Meilisearch synchronization...") + + # Инициализируем индексы в Meilisearch + meilisearch_service.initialize_indexes() + + # Создаем сессию базы данных + db = SessionLocal() + + try: + # Синхронизируем все данные + sync_categories(db) + sync_collections(db) + sync_sizes(db) + sync_products(db) + + logger.info("Meilisearch synchronization completed successfully") + except Exception as e: + logger.error(f"Error during Meilisearch synchronization: {str(e)}") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index c98de76..6d2ce0a 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -11,7 +11,7 @@ from app.services.catalog_service import ( 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, - create_product_complete, update_product_complete + create_product_complete, update_product_complete, format_product ) from app.services.order_service import ( @@ -30,5 +30,6 @@ from app.services.content_service import ( from app.services.delivery_service import get_cdek_service + # Импорт репозиториев для прямого доступа -from app.repositories import order_repo \ No newline at end of file +from app.repositories import order_repo \ No newline at end of file diff --git a/backend/app/services/__pycache__/__init__.cpython-310.pyc b/backend/app/services/__pycache__/__init__.cpython-310.pyc index 21cec70..841da53 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__/async_catalog_service.cpython-310.pyc b/backend/app/services/__pycache__/async_catalog_service.cpython-310.pyc new file mode 100644 index 0000000..1bd39e6 Binary files /dev/null and b/backend/app/services/__pycache__/async_catalog_service.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 f134e57..3118b6a 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__/meilisearch_service.cpython-310.pyc b/backend/app/services/__pycache__/meilisearch_service.cpython-310.pyc new file mode 100644 index 0000000..6d5715c Binary files /dev/null and b/backend/app/services/__pycache__/meilisearch_service.cpython-310.pyc differ diff --git a/backend/app/services/__pycache__/product_cache_service.cpython-310.pyc b/backend/app/services/__pycache__/product_cache_service.cpython-310.pyc new file mode 100644 index 0000000..f0f97d6 Binary files /dev/null and b/backend/app/services/__pycache__/product_cache_service.cpython-310.pyc differ diff --git a/backend/app/services/catalog_service.py b/backend/app/services/catalog_service.py index b1b3ffb..c3f6360 100644 --- a/backend/app/services/catalog_service.py +++ b/backend/app/services/catalog_service.py @@ -10,6 +10,13 @@ from botocore.exceptions import ClientError from app.config import settings from app.repositories import catalog_repo, review_repo +from app.services import meilisearch_service +from app.scripts.sync_meilisearch import ( + format_product_for_meilisearch, + format_category_for_meilisearch, + format_collection_for_meilisearch, + format_size_for_meilisearch +) from app.schemas.catalog_schemas import ( CategoryCreate, CategoryUpdate, Category, CategoryWithSubcategories, ProductCreate, ProductUpdate, Product, ProductWithDetails, @@ -21,33 +28,76 @@ from app.schemas.catalog_schemas import ( ) +def format_product(product): + """ + Форматирует продукт для отображения в API. + Преобразует объект SQLAlchemy в словарь с нужными полями. + """ + from app.schemas.catalog_schemas import Product as ProductSchema + + # Преобразуем объект SQLAlchemy в схему Pydantic + product_schema = ProductSchema.model_validate(product) + + # Получаем основное изображение, если есть + primary_image = None + for image in product.images: + if image.is_primary: + primary_image = image.image_url + break + + # Если нет основного изображения, но есть другие изображения, берем первое + if primary_image is None and product.images: + primary_image = product.images[0].image_url + + # Создаем словарь с данными продукта + result = product_schema.model_dump() + result["primary_image"] = primary_image + + return result + + # Сервисы для коллекций def create_collection(db: Session, collection: CollectionCreate) -> Dict[str, Any]: from app.schemas.catalog_schemas import Collection as CollectionSchema - + new_collection = catalog_repo.create_collection(db, collection) # Преобразуем объект SQLAlchemy в схему Pydantic collection_schema = CollectionSchema.model_validate(new_collection) + + # Индексируем коллекцию в Meilisearch + collection_data = format_collection_for_meilisearch(new_collection) + meilisearch_service.index_collection(collection_data) + return {"collection": collection_schema} def update_collection(db: Session, collection_id: int, collection: CollectionUpdate) -> Dict[str, Any]: from app.schemas.catalog_schemas import Collection as CollectionSchema - + updated_collection = catalog_repo.update_collection(db, collection_id, collection) # Преобразуем объект SQLAlchemy в схему Pydantic collection_schema = CollectionSchema.model_validate(updated_collection) + + # Обновляем коллекцию в Meilisearch + collection_data = format_collection_for_meilisearch(updated_collection) + meilisearch_service.index_collection(collection_data) + return {"collection": collection_schema} def delete_collection(db: Session, collection_id: int) -> Dict[str, Any]: success = catalog_repo.delete_collection(db, collection_id) + + # Удаляем коллекцию из Meilisearch + if success: + meilisearch_service.delete_collection(collection_id) + return {"success": success} def get_collections(db: Session, skip: int = 0, limit: int = 100) -> Dict[str, Any]: from app.schemas.catalog_schemas import Collection as CollectionSchema - + collections = catalog_repo.get_collections(db, skip, limit) # Преобразуем объекты SQLAlchemy в схемы Pydantic collections_schema = [CollectionSchema.model_validate(collection) for collection in collections] @@ -57,24 +107,39 @@ def get_collections(db: Session, skip: int = 0, limit: int = 100) -> Dict[str, A # Сервисы каталога def create_category(db: Session, category: CategoryCreate) -> Dict[str, Any]: from app.schemas.catalog_schemas import Category as CategorySchema - + new_category = catalog_repo.create_category(db, category) # Преобразуем объект SQLAlchemy в схему Pydantic category_schema = CategorySchema.model_validate(new_category) + + # Индексируем категорию в Meilisearch + category_data = format_category_for_meilisearch(new_category) + meilisearch_service.index_category(category_data) + return {"category": category_schema} def update_category(db: Session, category_id: int, category: CategoryUpdate) -> Dict[str, Any]: from app.schemas.catalog_schemas import Category as CategorySchema - + updated_category = catalog_repo.update_category(db, category_id, category) # Преобразуем объект SQLAlchemy в схему Pydantic category_schema = CategorySchema.model_validate(updated_category) + + # Обновляем категорию в Meilisearch + category_data = format_category_for_meilisearch(updated_category) + meilisearch_service.index_category(category_data) + return {"category": category_schema} def delete_category(db: Session, category_id: int) -> Dict[str, Any]: success = catalog_repo.delete_category(db, category_id) + + # Удаляем категорию из Meilisearch + if success: + meilisearch_service.delete_category(category_id) + return {"success": success} @@ -82,10 +147,10 @@ def get_category_tree(db: Session) -> List[Dict[str, Any]]: from app.schemas.catalog_schemas import Category as CategorySchema from sqlalchemy import func from app.models.catalog_models import Product - + # Получаем все категории верхнего уровня root_categories = catalog_repo.get_categories(db, parent_id=None) - + result = [] for category in root_categories: # Преобразуем объект SQLAlchemy в схему Pydantic @@ -95,14 +160,14 @@ def get_category_tree(db: Session) -> List[Dict[str, Any]]: Product.category_id == category.id, Product.is_active == True ).scalar() or 0 - + # Рекурсивно получаем подкатегории category_dict = category_schema.model_dump() subcategories, subcategories_products_count = _get_subcategories(db, category.id) category_dict["subcategories"] = subcategories category_dict["products_count"] = products_count + subcategories_products_count result.append(category_dict) - + return result @@ -110,33 +175,33 @@ def _get_subcategories(db: Session, parent_id: int) -> tuple[List[Dict[str, Any] from app.schemas.catalog_schemas import Category as CategorySchema from sqlalchemy import func from app.models.catalog_models import Product - + subcategories = catalog_repo.get_categories(db, parent_id=parent_id) - + result = [] total_products_count = 0 - + for category in subcategories: # Преобразуем объект SQLAlchemy в схему Pydantic category_schema = CategorySchema.model_validate(category) - + # Получаем количество продуктов в категории products_count = db.query(func.count(Product.id)).filter( Product.category_id == category.id, Product.is_active == True ).scalar() or 0 - + category_dict = category_schema.model_dump() sub_subcategories, sub_products_count = _get_subcategories(db, category.id) - + total_products_in_category = products_count + sub_products_count total_products_count += total_products_in_category - + category_dict["subcategories"] = sub_subcategories category_dict["products_count"] = total_products_in_category - + result.append(category_dict) - + return result, total_products_count @@ -150,7 +215,7 @@ def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]: status_code=status.HTTP_404_NOT_FOUND, detail=f"Категория с ID {product.category_id} не найдена" ) - + # Проверяем, что коллекция существует, если она указана if product.collection_id: collection = catalog_repo.get_collection(db, product.collection_id) @@ -159,13 +224,19 @@ def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]: status_code=status.HTTP_404_NOT_FOUND, detail=f"Коллекция с ID {product.collection_id} не найдена" ) - + # Создаем продукт db_product = catalog_repo.create_product(db, product) - + + # Индексируем продукт в Meilisearch + variants = db_product.variants + images = db_product.images + product_data = format_product_for_meilisearch(db_product, variants, images) + meilisearch_service.index_product(product_data) + return { "success": True, - "product": Product.from_orm(db_product) + "product": Product.model_validate(db_product) } except HTTPException as e: return { @@ -189,7 +260,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict status_code=status.HTTP_404_NOT_FOUND, detail=f"Продукт с ID {product_id} не найден" ) - + # Если меняется категория, проверяем, что она существует if product.category_id is not None: category = catalog_repo.get_category(db, product.category_id) @@ -198,7 +269,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict status_code=status.HTTP_404_NOT_FOUND, detail=f"Категория с ID {product.category_id} не найдена" ) - + # Если меняется коллекция, проверяем, что она существует if product.collection_id is not None: if product.collection_id: @@ -208,13 +279,19 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict status_code=status.HTTP_404_NOT_FOUND, detail=f"Коллекция с ID {product.collection_id} не найдена" ) - + # Обновляем продукт updated_product = catalog_repo.update_product(db, product_id, product) - + + # Обновляем продукт в Meilisearch + variants = updated_product.variants + images = updated_product.images + product_data = format_product_for_meilisearch(updated_product, variants, images) + meilisearch_service.index_product(product_data) + return { "success": True, - "product": Product.from_orm(updated_product) + "product": Product.model_validate(updated_product) } except HTTPException as e: return { @@ -229,13 +306,52 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict def delete_product(db: Session, product_id: int) -> Dict[str, Any]: - success = catalog_repo.delete_product(db, product_id) - return {"success": success} + """Удалить продукт""" + try: + # Проверяем, что продукт существует + 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} не найден" + ) + + # Удаляем продукт + success = catalog_repo.delete_product(db, product_id) + + # Удаляем продукт из Meilisearch + if success: + meilisearch_service.delete_product(product_id) + + return { + "success": success + } + except HTTPException as e: + return { + "success": False, + "error": e.detail + } + except Exception as e: + return { + "success": False, + "error": str(e) + } def get_product_details(db: Session, product_id: int) -> Dict[str, Any]: """Получить детальную информацию о продукте""" try: + # Пробуем получить продукт из Meilisearch + meilisearch_result = meilisearch_service.get_product(product_id) + + if meilisearch_result["success"]: + # Если продукт найден в Meilisearch, возвращаем его + return { + "success": True, + "product": meilisearch_result["product"] + } + + # Если продукт не найден в Meilisearch, получаем его из базы данных # Получаем продукт product = catalog_repo.get_product(db, product_id) if not product: @@ -243,21 +359,21 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]: status_code=status.HTTP_404_NOT_FOUND, detail=f"Продукт с ID {product_id} не найден" ) - + # Получаем варианты продукта variants = catalog_repo.get_product_variants(db, product_id) - + # Получаем изображения продукта images = catalog_repo.get_product_images(db, product_id) - + # Получаем категорию category = catalog_repo.get_category(db, product.category_id) - + # Получаем коллекцию, если она указана collection = None if product.collection_id: collection = catalog_repo.get_collection(db, product.collection_id) - + # Создаем детальное представление продукта product_details = ProductWithDetails( id=product.id, @@ -272,12 +388,16 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]: collection_id=product.collection_id, created_at=product.created_at, updated_at=product.updated_at, - category=Category.from_orm(category), - collection=Collection.from_orm(collection) if collection else None, - variants=[ProductVariant.from_orm(variant) for variant in variants], - images=[ProductImage.from_orm(image) for image in images] + category=Category.model_validate(category), + collection=Collection.model_validate(collection) if collection else None, + variants=[ProductVariant.model_validate(variant) for variant in variants], + images=[ProductImage.model_validate(image) for image in images] ) - + + # Индексируем продукт в Meilisearch для будущих запросов + product_data = format_product_for_meilisearch(product, variants, images) + meilisearch_service.index_product(product_data) + return { "success": True, "product": product_details @@ -304,7 +424,7 @@ def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str, status_code=status.HTTP_404_NOT_FOUND, detail=f"Продукт с ID {variant.product_id} не найден" ) - + # Проверяем, что размер существует size = catalog_repo.get_size(db, variant.size_id) if not size: @@ -312,10 +432,10 @@ def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str, status_code=status.HTTP_404_NOT_FOUND, detail=f"Размер с ID {variant.size_id} не найден" ) - + # Создаем вариант продукта db_variant = catalog_repo.create_product_variant(db, variant) - + return { "success": True, "variant": ProductVariant.from_orm(db_variant) @@ -342,7 +462,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant status_code=status.HTTP_404_NOT_FOUND, detail=f"Вариант продукта с ID {variant_id} не найден" ) - + # Если меняется продукт, проверяем, что он существует if variant.product_id is not None: product = catalog_repo.get_product(db, variant.product_id) @@ -351,7 +471,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant status_code=status.HTTP_404_NOT_FOUND, detail=f"Продукт с ID {variant.product_id} не найден" ) - + # Если меняется размер, проверяем, что он существует if variant.size_id is not None: size = catalog_repo.get_size(db, variant.size_id) @@ -360,10 +480,10 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant status_code=status.HTTP_404_NOT_FOUND, detail=f"Размер с ID {variant.size_id} не найден" ) - + # Обновляем вариант продукта updated_variant = catalog_repo.update_product_variant(db, variant_id, variant) - + return { "success": True, "variant": ProductVariant.from_orm(updated_variant) @@ -386,25 +506,25 @@ def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]: def upload_product_image( - db: Session, - product_id: int, - file: UploadFile, - is_primary: bool = False, + db: Session, + product_id: int, + file: UploadFile, + is_primary: bool = False, alt_text: str = "" ) -> dict: """ Загружает изображение для продукта в MinIO и создает запись в базе данных. - + Args: db: Сессия базы данных product_id: ID продукта file: Загружаемый файл is_primary: Является ли изображение основным alt_text: Альтернативный текст для изображения - + Returns: dict: Словарь с данными созданного изображения (image_url содержит ключ объекта) - + Raises: HTTPException: В случае ошибки при загрузке или сохранении изображения """ @@ -412,36 +532,36 @@ def upload_product_image( secure_filename = "" try: logging.info(f"Попытка загрузки изображения для продукта с id={product_id} в MinIO") - + 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}, content_type: {file.content_type}") - + 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) - + file_ext = os.path.splitext(file.filename)[1].lower() if file.filename else "" if file_ext.lstrip('.') not in settings.ALLOWED_UPLOAD_EXTENSIONS: error_msg = f"Расширение файла {file_ext} не разрешено." logging.error(error_msg) raise HTTPException(status_code=400, detail=error_msg) - + secure_filename = f"{uuid.uuid4()}{file_ext}" logging.info(f"Генерация ключа объекта MinIO: {secure_filename}") - + try: s3_client = boto3.client( 's3', @@ -457,7 +577,7 @@ def upload_product_image( logging.error(error_msg) logging.error(traceback.format_exc()) raise HTTPException(status_code=500, detail="Ошибка конфигурации хранилища") - + try: s3_client.upload_fileobj( file.file, @@ -476,17 +596,17 @@ def upload_product_image( logging.error(error_msg) logging.error(traceback.format_exc()) raise HTTPException(status_code=500, detail="Ошибка при сохранении файла в хранилище") - + image_object_key = secure_filename logging.info(f"Ключ объекта для сохранения в БД: {image_object_key}") - + 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, @@ -494,7 +614,7 @@ def upload_product_image( alt_text=alt_text, is_primary=is_primary ) - + try: db.add(new_image) db.commit() @@ -505,20 +625,20 @@ def upload_product_image( error_msg = f"Ошибка при сохранении записи в базу данных: {str(e)}" logging.error(error_msg) logging.error(traceback.format_exc()) - + logging.warning(f"Пытаемся удалить объект '{secure_filename}' из MinIO из-за ошибки БД.") try: if s3_client and secure_filename: s3_client.delete_object( - Bucket=settings.MINIO_BUCKET_NAME, + Bucket=settings.MINIO_BUCKET_NAME, Key=secure_filename ) logging.info(f"Объект '{secure_filename}' успешно удален из MinIO.") except Exception as delete_err: logging.error(f"Не удалось удалить объект '{secure_filename}' из MinIO после ошибки БД: {str(delete_err)}") - + raise HTTPException(status_code=500, detail="Ошибка сохранения данных изображения") - + result = { "id": new_image.id, "product_id": new_image.product_id, @@ -528,10 +648,10 @@ def upload_product_image( "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: raise http_error except Exception as e: @@ -542,7 +662,7 @@ def upload_product_image( logging.warning(f"Пытаемся удалить объект '{secure_filename}' из MinIO из-за неожиданной ошибки.") try: s3_client.delete_object( - Bucket=settings.MINIO_BUCKET_NAME, + Bucket=settings.MINIO_BUCKET_NAME, Key=secure_filename ) logging.info(f"Объект '{secure_filename}' успешно удален из MinIO.") @@ -554,7 +674,7 @@ def upload_product_image( 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 - + try: # Получаем существующее изображение db_image = catalog_repo.get_product_image(db, image_id) @@ -563,7 +683,7 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) status_code=status.HTTP_404_NOT_FOUND, detail="Изображение продукта не найдено" ) - + # Если изображение отмечается как основное, сбрасываем флаг у других изображений if image.is_primary and not db_image.is_primary: db.query(ProductImageModel).filter( @@ -571,16 +691,16 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) 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, @@ -591,7 +711,7 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) "created_at": db_image.created_at, "updated_at": db_image.updated_at } - + return { "success": True, "image": image_dict @@ -612,10 +732,10 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]: from app.models.catalog_models import ProductImage as ProductImageModel - + image_key_to_delete = None # Сохраняем ключ для удаления из MinIO s3_client = None - + try: # Получаем информацию об изображении перед удалением db_image = catalog_repo.get_product_image(db, image_id) @@ -624,13 +744,13 @@ def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]: status_code=status.HTTP_404_NOT_FOUND, detail="Изображение не найдено" ) - + image_key_to_delete = db_image.image_url # Получаем ключ объекта MinIO из БД logging.info(f"Получен ключ объекта для удаления из MinIO: {image_key_to_delete}") # Удаляем запись из БД success = catalog_repo.delete_product_image(db, image_id) - + # Если запись из БД удалена успешно, удаляем объект из MinIO if success and image_key_to_delete: logging.info(f"Запись из БД удалена, попытка удаления объекта '{image_key_to_delete}' из MinIO.") @@ -644,9 +764,9 @@ def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]: use_ssl=settings.MINIO_USE_SSL, config=boto3.session.Config(signature_version='s3v4') ) - + s3_client.delete_object( - Bucket=settings.MINIO_BUCKET_NAME, + Bucket=settings.MINIO_BUCKET_NAME, Key=image_key_to_delete ) logging.info(f"Объект '{image_key_to_delete}' успешно удален из MinIO бакета '{settings.MINIO_BUCKET_NAME}'.") @@ -706,15 +826,33 @@ def get_sizes(db: Session, skip: int = 0, limit: int = 100) -> List[Size]: def create_size(db: Session, size: SizeCreate) -> Size: - return Size.from_orm(catalog_repo.create_size(db, size)) + new_size = catalog_repo.create_size(db, size) + + # Индексируем размер в Meilisearch + size_data = format_size_for_meilisearch(new_size) + meilisearch_service.index_size(size_data) + + return Size.model_validate(new_size) def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size: - return Size.from_orm(catalog_repo.update_size(db, size_id, size)) + updated_size = catalog_repo.update_size(db, size_id, size) + + # Обновляем размер в Meilisearch + size_data = format_size_for_meilisearch(updated_size) + meilisearch_service.index_size(size_data) + + return Size.model_validate(updated_size) def delete_size(db: Session, size_id: int) -> bool: - return catalog_repo.delete_size(db, size_id) + success = catalog_repo.delete_size(db, size_id) + + # Удаляем размер из Meilisearch + if success: + meilisearch_service.delete_size(size_id) + + return success def create_product_complete(db: Session, product_data: ProductCreateComplete) -> Dict[str, Any]: @@ -724,7 +862,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> try: # Откатываем любую существующую транзакцию и начинаем новую db.rollback() - + # Проверяем наличие категории category = catalog_repo.get_category(db, product_data.category_id) if not category: @@ -732,7 +870,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> 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) @@ -741,7 +879,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> status_code=status.HTTP_404_NOT_FOUND, detail=f"Коллекция с ID {product_data.collection_id} не найдена" ) - + # 1. Создаем базовую информацию о продукте используя репозиторий product_create = ProductCreate( name=product_data.name, @@ -754,10 +892,10 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> 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: @@ -769,7 +907,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> status_code=status.HTTP_404_NOT_FOUND, detail=f"Размер с ID {variant_data.size_id} не найден" ) - + # Создаем вариант продукта через репозиторий variant_create = ProductVariantCreate( product_id=db_product.id, @@ -780,13 +918,13 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> ) 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 @@ -799,7 +937,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> # Если это первое изображение и нет основного, делаем его основным is_primary = True had_primary = True - + # Создаем запись об изображении через репозиторий image_create = ProductImageCreate( product_id=db_product.id, @@ -809,7 +947,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> ) db_image = catalog_repo.create_product_image(db, image_create) images.append(db_image) - + # Создаем словарь для ответа с прямым извлечением данных из ORM-объектов product_dict = { "id": db_product.id, @@ -825,7 +963,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> "created_at": db_product.created_at, "updated_at": db_product.updated_at } - + # Создаем списки вариантов и изображений variants_list = [] for variant in variants: @@ -839,7 +977,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> "created_at": variant.created_at, "updated_at": variant.updated_at }) - + images_list = [] for image in images: images_list.append({ @@ -851,7 +989,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> "created_at": image.created_at, "updated_at": image.updated_at }) - + return { "success": True, "id": db_product.id, @@ -859,7 +997,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> "variants": variants_list, "images": images_list } - + except HTTPException as e: db.rollback() return { @@ -872,7 +1010,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) -> "success": False, "error": str(e) } - + def update_product_complete(db: Session, product_id: int, product_data: ProductUpdateComplete) -> Dict[str, Any]: """ @@ -881,7 +1019,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU try: # Откатываем любую существующую транзакцию и начинаем новую db.rollback() - + # Проверяем, что продукт существует db_product = catalog_repo.get_product(db, product_id) if not db_product: @@ -889,7 +1027,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU 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) @@ -898,7 +1036,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU 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: @@ -908,7 +1046,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU status_code=status.HTTP_404_NOT_FOUND, detail=f"Коллекция с ID {product_data.collection_id} не найдена" ) - + # 1. Обновляем базовую информацию о продукте product_update = ProductUpdate( name=product_data.name, @@ -922,11 +1060,11 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU 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: # Проверяем наличие размера @@ -937,7 +1075,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU status_code=status.HTTP_404_NOT_FOUND, detail=f"Размер с ID {variant_data.size_id} не найден" ) - + # Если есть ID, обновляем существующий вариант if variant_data.id: variant_update = ProductVariantUpdate( @@ -960,7 +1098,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU ) db_variant = catalog_repo.create_product_variant(db, variant_create) variants_created.append(db_variant) - + # 3. Удаляем указанные варианты variants_removed = [] if product_data.variants_to_remove: @@ -971,11 +1109,11 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU 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, обновляем существующее изображение @@ -997,7 +1135,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU ) db_image = catalog_repo.create_product_image(db, image_create) images_created.append(db_image) - + # 5. Удаляем указанные изображения images_removed = [] if product_data.images_to_remove: @@ -1008,13 +1146,13 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU 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, @@ -1029,7 +1167,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU "removed": images_removed } } - + except HTTPException as e: db.rollback() return { @@ -1041,4 +1179,4 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU return { "success": False, "error": str(e) - } \ No newline at end of file + } \ No newline at end of file diff --git a/backend/app/services/meilisearch_service.py b/backend/app/services/meilisearch_service.py new file mode 100644 index 0000000..84729b2 --- /dev/null +++ b/backend/app/services/meilisearch_service.py @@ -0,0 +1,649 @@ +from meilisearch import Client +from typing import Dict, List, Any, Optional, Union +import logging +from app.config import settings + +# Инициализация клиента Meilisearch +client = Client(settings.MEILISEARCH_URL, settings.MEILISEARCH_KEY) + +# Определение индексов +PRODUCT_INDEX = "products" +CATEGORY_INDEX = "categories" +COLLECTION_INDEX = "collections" +SIZE_INDEX = "sizes" + +# Настройка логгера +logger = logging.getLogger(__name__) + + +def initialize_indexes(): + """ + Инициализирует индексы в Meilisearch, если они не существуют. + Настраивает фильтруемые и сортируемые атрибуты. + """ + try: + # Создаем индекс продуктов, если он не существует + if PRODUCT_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]: + client.create_index(PRODUCT_INDEX, {"primaryKey": "id"}) + + # Настраиваем фильтруемые атрибуты для продуктов + client.index(PRODUCT_INDEX).update_filterable_attributes([ + "category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id" + ]) + + # Настраиваем сортируемые атрибуты для продуктов + client.index(PRODUCT_INDEX).update_sortable_attributes([ + "price", "discount_price", "created_at", "name" + ]) + + # Настраиваем поисковые атрибуты для продуктов + client.index(PRODUCT_INDEX).update_searchable_attributes([ + "name", "description", "slug" + ]) + + # Создаем индекс категорий, если он не существует + if CATEGORY_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]: + client.create_index(CATEGORY_INDEX, {"primaryKey": "id"}) + + # Настраиваем фильтруемые атрибуты для категорий + client.index(CATEGORY_INDEX).update_filterable_attributes([ + "parent_id", "is_active", "id", "slug" + ]) + + # Настраиваем поисковые атрибуты для категорий + client.index(CATEGORY_INDEX).update_searchable_attributes([ + "name", "description", "slug" + ]) + + # Создаем индекс коллекций, если он не существует + if COLLECTION_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]: + client.create_index(COLLECTION_INDEX, {"primaryKey": "id"}) + + # Настраиваем фильтруемые атрибуты для коллекций + client.index(COLLECTION_INDEX).update_filterable_attributes([ + "is_active" + ]) + + # Настраиваем поисковые атрибуты для коллекций + client.index(COLLECTION_INDEX).update_searchable_attributes([ + "name", "description", "slug" + ]) + + # Создаем индекс размеров, если он не существует + if SIZE_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]: + client.create_index(SIZE_INDEX, {"primaryKey": "id"}) + + # Настраиваем поисковые атрибуты для размеров + client.index(SIZE_INDEX).update_searchable_attributes([ + "name", "code", "description" + ]) + + logger.info("Meilisearch indexes initialized successfully") + return True + except Exception as e: + logger.error(f"Error initializing Meilisearch indexes: {str(e)}") + return False + + +def index_product(product_data: Dict[str, Any]) -> bool: + """ + Индексирует продукт в Meilisearch. + + Args: + product_data: Данные продукта для индексации + + Returns: + bool: True, если индексация прошла успешно, иначе False + """ + try: + client.index(PRODUCT_INDEX).add_documents([product_data]) + logger.info(f"Product {product_data.get('id')} indexed successfully") + return True + except Exception as e: + logger.error(f"Error indexing product {product_data.get('id')}: {str(e)}") + return False + + +def index_category(category_data: Dict[str, Any]) -> bool: + """ + Индексирует категорию в Meilisearch. + + Args: + category_data: Данные категории для индексации + + Returns: + bool: True, если индексация прошла успешно, иначе False + """ + try: + client.index(CATEGORY_INDEX).add_documents([category_data]) + logger.info(f"Category {category_data.get('id')} indexed successfully") + return True + except Exception as e: + logger.error(f"Error indexing category {category_data.get('id')}: {str(e)}") + return False + + +def index_collection(collection_data: Dict[str, Any]) -> bool: + """ + Индексирует коллекцию в Meilisearch. + + Args: + collection_data: Данные коллекции для индексации + + Returns: + bool: True, если индексация прошла успешно, иначе False + """ + try: + client.index(COLLECTION_INDEX).add_documents([collection_data]) + logger.info(f"Collection {collection_data.get('id')} indexed successfully") + return True + except Exception as e: + logger.error(f"Error indexing collection {collection_data.get('id')}: {str(e)}") + return False + + +def index_size(size_data: Dict[str, Any]) -> bool: + """ + Индексирует размер в Meilisearch. + + Args: + size_data: Данные размера для индексации + + Returns: + bool: True, если индексация прошла успешно, иначе False + """ + try: + client.index(SIZE_INDEX).add_documents([size_data]) + logger.info(f"Size {size_data.get('id')} indexed successfully") + return True + except Exception as e: + logger.error(f"Error indexing size {size_data.get('id')}: {str(e)}") + return False + + +def delete_product(product_id: int) -> bool: + """ + Удаляет продукт из индекса Meilisearch. + + Args: + product_id: ID продукта для удаления + + Returns: + bool: True, если удаление прошло успешно, иначе False + """ + try: + client.index(PRODUCT_INDEX).delete_document(product_id) + logger.info(f"Product {product_id} deleted from index successfully") + return True + except Exception as e: + logger.error(f"Error deleting product {product_id} from index: {str(e)}") + return False + + +def delete_category(category_id: int) -> bool: + """ + Удаляет категорию из индекса Meilisearch. + + Args: + category_id: ID категории для удаления + + Returns: + bool: True, если удаление прошло успешно, иначе False + """ + try: + client.index(CATEGORY_INDEX).delete_document(category_id) + logger.info(f"Category {category_id} deleted from index successfully") + return True + except Exception as e: + logger.error(f"Error deleting category {category_id} from index: {str(e)}") + return False + + +def delete_collection(collection_id: int) -> bool: + """ + Удаляет коллекцию из индекса Meilisearch. + + Args: + collection_id: ID коллекции для удаления + + Returns: + bool: True, если удаление прошло успешно, иначе False + """ + try: + client.index(COLLECTION_INDEX).delete_document(collection_id) + logger.info(f"Collection {collection_id} deleted from index successfully") + return True + except Exception as e: + logger.error(f"Error deleting collection {collection_id} from index: {str(e)}") + return False + + +def delete_size(size_id: int) -> bool: + """ + Удаляет размер из индекса Meilisearch. + + Args: + size_id: ID размера для удаления + + Returns: + bool: True, если удаление прошло успешно, иначе False + """ + try: + client.index(SIZE_INDEX).delete_document(size_id) + logger.info(f"Size {size_id} deleted from index successfully") + return True + except Exception as e: + logger.error(f"Error deleting size {size_id} from index: {str(e)}") + return False + + +def search_products( + query: str = "", + filters: Optional[str] = None, + sort: Optional[List[str]] = None, + limit: int = 20, + offset: int = 0 +) -> Dict[str, Any]: + """ + Поиск продуктов в Meilisearch. + + Args: + query: Поисковый запрос + filters: Фильтры в формате Meilisearch (например, "category_id = 1 AND price < 1000") + sort: Список полей для сортировки (например, ["price:asc"]) + limit: Максимальное количество результатов + offset: Смещение для пагинации + + Returns: + Dict[str, Any]: Результаты поиска + """ + try: + search_params = { + "limit": limit, + "offset": offset + } + + if filters: + search_params["filter"] = filters + + if sort: + search_params["sort"] = sort + + results = client.index(PRODUCT_INDEX).search(query, search_params) + return { + "success": True, + "products": results["hits"], + "total": results["estimatedTotalHits"], + "limit": limit, + "offset": offset + } + except Exception as e: + logger.error(f"Error searching products: {str(e)}") + return { + "success": False, + "error": str(e), + "products": [], + "total": 0, + "limit": limit, + "offset": offset + } + + +def search_categories( + query: str = "", + filters: Optional[str] = None, + limit: int = 100, + offset: int = 0 +) -> Dict[str, Any]: + """ + Поиск категорий в Meilisearch. + + Args: + query: Поисковый запрос + filters: Фильтры в формате Meilisearch (например, "parent_id = 1") + limit: Максимальное количество результатов + offset: Смещение для пагинации + + Returns: + Dict[str, Any]: Результаты поиска + """ + try: + search_params = { + "limit": limit, + "offset": offset + } + + if filters: + search_params["filter"] = filters + + results = client.index(CATEGORY_INDEX).search(query, search_params) + return { + "success": True, + "categories": results["hits"], + "total": results["estimatedTotalHits"] + } + except Exception as e: + logger.error(f"Error searching categories: {str(e)}") + return { + "success": False, + "error": str(e), + "categories": [], + "total": 0 + } + + +def search_collections( + query: str = "", + filters: Optional[str] = None, + limit: int = 100, + offset: int = 0 +) -> Dict[str, Any]: + """ + Поиск коллекций в Meilisearch. + + Args: + query: Поисковый запрос + filters: Фильтры в формате Meilisearch (например, "is_active = true") + limit: Максимальное количество результатов + offset: Смещение для пагинации + + Returns: + Dict[str, Any]: Результаты поиска + """ + try: + search_params = { + "limit": limit, + "offset": offset + } + + if filters: + search_params["filter"] = filters + + results = client.index(COLLECTION_INDEX).search(query, search_params) + return { + "success": True, + "collections": results["hits"], + "total": results["estimatedTotalHits"] + } + except Exception as e: + logger.error(f"Error searching collections: {str(e)}") + return { + "success": False, + "error": str(e), + "collections": [], + "total": 0 + } + + +def search_sizes( + query: str = "", + filters: Optional[str] = None, + limit: int = 100, + offset: int = 0 +) -> Dict[str, Any]: + """ + Поиск размеров в Meilisearch. + + Args: + query: Поисковый запрос + filters: Фильтры в формате Meilisearch + limit: Максимальное количество результатов + offset: Смещение для пагинации + + Returns: + Dict[str, Any]: Результаты поиска + """ + try: + search_params = { + "limit": limit, + "offset": offset + } + + if filters: + search_params["filter"] = filters + + results = client.index(SIZE_INDEX).search(query, search_params) + return { + "success": True, + "sizes": results["hits"], + "total": results["estimatedTotalHits"] + } + except Exception as e: + logger.error(f"Error searching sizes: {str(e)}") + return { + "success": False, + "error": str(e), + "sizes": [], + "total": 0 + } + + +def get_product(product_id: int) -> Dict[str, Any]: + """ + Получает продукт из Meilisearch по ID. + + Args: + product_id: ID продукта + + Returns: + Dict[str, Any]: Данные продукта или ошибка + """ + try: + product = client.index(PRODUCT_INDEX).get_document(product_id) + # Преобразуем объект Document в словарь + product_dict = dict(product) + return { + "success": True, + "product": product_dict + } + except Exception as e: + logger.error(f"Error getting product {product_id}: {str(e)}") + return { + "success": False, + "error": str(e), + "product": None + } + + +def get_product_by_slug(slug: str) -> Dict[str, Any]: + """ + Получает продукт из Meilisearch по slug. + + Args: + slug: Slug продукта + + Returns: + Dict[str, Any]: Данные продукта или ошибка + """ + try: + # Вместо фильтрации по slug, используем поиск по slug + results = client.index(PRODUCT_INDEX).search(slug, { + "attributesToSearchOn": ["slug"], + "limit": 1 + }) + + if results["hits"]: + # Преобразуем объект Document в словарь + product_dict = dict(results["hits"][0]) + return { + "success": True, + "product": product_dict + } + else: + return { + "success": False, + "error": f"Product with slug '{slug}' not found", + "product": None + } + except Exception as e: + logger.error(f"Error getting product by slug {slug}: {str(e)}") + return { + "success": False, + "error": str(e), + "product": None + } + + +def get_category(category_id: int) -> Dict[str, Any]: + """ + Получает категорию из Meilisearch по ID. + + Args: + category_id: ID категории + + Returns: + Dict[str, Any]: Данные категории или ошибка + """ + try: + category = client.index(CATEGORY_INDEX).get_document(category_id) + # Преобразуем объект Document в словарь + category_dict = dict(category) + return { + "success": True, + "category": category_dict + } + except Exception as e: + logger.error(f"Error getting category {category_id}: {str(e)}") + return { + "success": False, + "error": str(e), + "category": None + } + + +def get_collection(collection_id: int) -> Dict[str, Any]: + """ + Получает коллекцию из Meilisearch по ID. + + Args: + collection_id: ID коллекции + + Returns: + Dict[str, Any]: Данные коллекции или ошибка + """ + try: + collection = client.index(COLLECTION_INDEX).get_document(collection_id) + # Преобразуем объект Document в словарь + collection_dict = dict(collection) + return { + "success": True, + "collection": collection_dict + } + except Exception as e: + logger.error(f"Error getting collection {collection_id}: {str(e)}") + return { + "success": False, + "error": str(e), + "collection": None + } + + +def get_size(size_id: int) -> Dict[str, Any]: + """ + Получает размер из Meilisearch по ID. + + Args: + size_id: ID размера + + Returns: + Dict[str, Any]: Данные размера или ошибка + """ + try: + size = client.index(SIZE_INDEX).get_document(size_id) + # Преобразуем объект Document в словарь + size_dict = dict(size) + return { + "success": True, + "size": size_dict + } + except Exception as e: + logger.error(f"Error getting size {size_id}: {str(e)}") + return { + "success": False, + "error": str(e), + "size": None + } + + +def sync_all_products(products_data: List[Dict[str, Any]]) -> bool: + """ + Синхронизирует все продукты с Meilisearch. + + Args: + products_data: Список данных продуктов для синхронизации + + Returns: + bool: True, если синхронизация прошла успешно, иначе False + """ + try: + client.index(PRODUCT_INDEX).delete_all_documents() + if products_data: + client.index(PRODUCT_INDEX).add_documents(products_data) + logger.info(f"All products synced successfully ({len(products_data)} products)") + return True + except Exception as e: + logger.error(f"Error syncing all products: {str(e)}") + return False + + +def sync_all_categories(categories_data: List[Dict[str, Any]]) -> bool: + """ + Синхронизирует все категории с Meilisearch. + + Args: + categories_data: Список данных категорий для синхронизации + + Returns: + bool: True, если синхронизация прошла успешно, иначе False + """ + try: + client.index(CATEGORY_INDEX).delete_all_documents() + if categories_data: + client.index(CATEGORY_INDEX).add_documents(categories_data) + logger.info(f"All categories synced successfully ({len(categories_data)} categories)") + return True + except Exception as e: + logger.error(f"Error syncing all categories: {str(e)}") + return False + + +def sync_all_collections(collections_data: List[Dict[str, Any]]) -> bool: + """ + Синхронизирует все коллекции с Meilisearch. + + Args: + collections_data: Список данных коллекций для синхронизации + + Returns: + bool: True, если синхронизация прошла успешно, иначе False + """ + try: + client.index(COLLECTION_INDEX).delete_all_documents() + if collections_data: + client.index(COLLECTION_INDEX).add_documents(collections_data) + logger.info(f"All collections synced successfully ({len(collections_data)} collections)") + return True + except Exception as e: + logger.error(f"Error syncing all collections: {str(e)}") + return False + + +def sync_all_sizes(sizes_data: List[Dict[str, Any]]) -> bool: + """ + Синхронизирует все размеры с Meilisearch. + + Args: + sizes_data: Список данных размеров для синхронизации + + Returns: + bool: True, если синхронизация прошла успешно, иначе False + """ + try: + client.index(SIZE_INDEX).delete_all_documents() + if sizes_data: + client.index(SIZE_INDEX).add_documents(sizes_data) + logger.info(f"All sizes synced successfully ({len(sizes_data)} sizes)") + return True + except Exception as e: + logger.error(f"Error syncing all sizes: {str(e)}") + return False diff --git a/backend/check_data.py b/backend/check_data.py new file mode 100644 index 0000000..5c1aea8 --- /dev/null +++ b/backend/check_data.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +import sys +import os +from pathlib import Path + +# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения +sys.path.append(str(Path(__file__).parent)) + +from app.core import SessionLocal +from app.models.catalog_models import Product, Category, Collection, Size + +def main(): + """ + Скрипт для проверки данных в базе данных. + """ + print("Проверка данных в базе данных...") + + # Создаем сессию базы данных + db = SessionLocal() + + try: + # Проверяем наличие категорий + categories = db.query(Category).all() + print(f"Категории: {len(categories)}") + for category in categories: + print(f" - {category.id}: {category.name} (slug: {category.slug})") + + # Проверяем наличие коллекций + collections = db.query(Collection).all() + print(f"Коллекции: {len(collections)}") + for collection in collections: + print(f" - {collection.id}: {collection.name} (slug: {collection.slug})") + + # Проверяем наличие размеров + sizes = db.query(Size).all() + print(f"Размеры: {len(sizes)}") + for size in sizes: + print(f" - {size.id}: {size.name} (code: {size.code})") + + # Проверяем наличие продуктов + products = db.query(Product).all() + print(f"Продукты: {len(products)}") + for product in products[:5]: # Выводим только первые 5 продуктов + print(f" - {product.id}: {product.name} (slug: {product.slug})") + + except Exception as e: + print(f"Ошибка при проверке данных: {str(e)}") + finally: + db.close() + +if __name__ == "__main__": + main() diff --git a/backend/check_meilisearch.py b/backend/check_meilisearch.py new file mode 100644 index 0000000..044a91c --- /dev/null +++ b/backend/check_meilisearch.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +import sys +import os +from pathlib import Path + +# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения +sys.path.append(str(Path(__file__).parent)) + +from app.services.meilisearch_service import client, PRODUCT_INDEX, CATEGORY_INDEX, COLLECTION_INDEX, SIZE_INDEX + +def main(): + """ + Скрипт для проверки данных в Meilisearch. + """ + print("Проверка данных в Meilisearch...") + + try: + # Проверяем наличие индексов + try: + indexes = client.get_indexes() + print(f"Индексы: {len(indexes['results'])}") + for index in indexes["results"]: + print(f" - {index['uid']}: {index['primaryKey']}") + except Exception as e: + print(f"Ошибка при получении индексов: {str(e)}") + + # Проверяем наличие категорий + try: + # Проверяем настройки индекса категорий + print("\nНастройки индекса категорий:") + filterable_attributes = client.index(CATEGORY_INDEX).get_filterable_attributes() + print(f" Фильтруемые атрибуты: {filterable_attributes}") + + searchable_attributes = client.index(CATEGORY_INDEX).get_searchable_attributes() + print(f" Поисковые атрибуты: {searchable_attributes}") + + # Получаем категории + categories = client.index(CATEGORY_INDEX).get_documents({"limit": 100}) + print(f"\nКатегории: {len(categories['results'])}") + for category in categories["results"]: + print(f" - {category['id']}: {category['name']} (slug: {category['slug']})") + + # Проверяем поиск категорий + print("\nПоиск категорий:") + search_result = client.index(CATEGORY_INDEX).search("", {"limit": 100}) + print(f" Найдено категорий: {len(search_result['hits'])}") + print(f" Общее количество категорий: {search_result['estimatedTotalHits']}") + except Exception as e: + print(f"Ошибка при получении категорий: {str(e)}") + + # Проверяем наличие коллекций + try: + collections = client.index(COLLECTION_INDEX).get_documents({"limit": 100}) + print(f"Коллекции: {len(collections['results'])}") + for collection in collections["results"]: + print(f" - {collection['id']}: {collection['name']} (slug: {collection['slug']})") + except Exception as e: + print(f"Ошибка при получении коллекций: {str(e)}") + + # Проверяем наличие размеров + try: + sizes = client.index(SIZE_INDEX).get_documents({"limit": 100}) + print(f"Размеры: {len(sizes['results'])}") + for size in sizes["results"]: + print(f" - {size['id']}: {size['name']} (code: {size['code']})") + except Exception as e: + print(f"Ошибка при получении размеров: {str(e)}") + + # Проверяем наличие продуктов + try: + products = client.index(PRODUCT_INDEX).get_documents({"limit": 5}) + print(f"Продукты: {len(products['results'])}") + for product in products["results"]: + print(f" - {product['id']}: {product['name']} (slug: {product['slug']})") + except Exception as e: + print(f"Ошибка при получении продуктов: {str(e)}") + + # Проверяем настройки индексов + try: + print("\nНастройки индекса продуктов:") + filterable_attributes = client.index(PRODUCT_INDEX).get_filterable_attributes() + print(f" Фильтруемые атрибуты: {filterable_attributes}") + + searchable_attributes = client.index(PRODUCT_INDEX).get_searchable_attributes() + print(f" Поисковые атрибуты: {searchable_attributes}") + + sortable_attributes = client.index(PRODUCT_INDEX).get_sortable_attributes() + print(f" Сортируемые атрибуты: {sortable_attributes}") + except Exception as e: + print(f"Ошибка при получении настроек индекса продуктов: {str(e)}") + + except Exception as e: + print(f"Ошибка при проверке данных в Meilisearch: {str(e)}") + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index 04caf44..d8a6dca 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -79,7 +79,7 @@ SQLAlchemy==2.0.25 starlette==0.35.1 taskiq==0.11.10 taskiq-dependencies==1.5.6 -taskiq-redis==1.0.2 + tenacity==9.0.0 texttable==1.7.0 tqdm==4.67.1 @@ -104,5 +104,6 @@ bcrypt<4.0.0 python-multipart==0.0.6 email-validator==2.1.0 setuptools==75.8.0 -cachetools boto3 +meilisearch==0.28.0 + diff --git a/backend/sync_categories.py b/backend/sync_categories.py new file mode 100644 index 0000000..a6e9b84 --- /dev/null +++ b/backend/sync_categories.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +import sys +import os +from pathlib import Path + +# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения +sys.path.append(str(Path(__file__).parent)) + +from app.core import SessionLocal +from app.scripts.sync_meilisearch import sync_categories +from app.services.meilisearch_service import initialize_indexes + +def main(): + """ + Скрипт для принудительной синхронизации категорий с Meilisearch. + """ + print("Принудительная синхронизация категорий с Meilisearch...") + + # Инициализируем индексы в Meilisearch + initialize_indexes() + + # Создаем сессию базы данных + db = SessionLocal() + + try: + # Синхронизируем категории с Meilisearch + success = sync_categories(db) + + if success: + print("Категории успешно синхронизированы с Meilisearch") + else: + print("Ошибка при синхронизации категорий с Meilisearch") + except Exception as e: + print(f"Ошибка при синхронизации категорий с Meilisearch: {str(e)}") + finally: + db.close() + +if __name__ == "__main__": + main() diff --git a/backend/sync_categories_direct.py b/backend/sync_categories_direct.py new file mode 100644 index 0000000..477ad8e --- /dev/null +++ b/backend/sync_categories_direct.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python +import sys +import os +from pathlib import Path + +# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения +sys.path.append(str(Path(__file__).parent)) + +from app.core import SessionLocal +from app.models.catalog_models import Category +from app.services.meilisearch_service import client, CATEGORY_INDEX + +def format_category_for_meilisearch(category): + """ + Форматирует категорию для индексации в Meilisearch. + """ + return { + "id": category.id, + "name": category.name, + "slug": category.slug, + "description": category.description, + "parent_id": category.parent_id, + "is_active": category.is_active, + "created_at": category.created_at.isoformat() if category.created_at else None, + "updated_at": category.updated_at.isoformat() if category.updated_at else None + } + +def sync_categories(db): + """ + Синхронизирует все категории с Meilisearch. + """ + print("Синхронизация категорий...") + + # Получаем все категории из базы данных + categories = db.query(Category).all() + + # Форматируем категории для Meilisearch + categories_data = [format_category_for_meilisearch(category) for category in categories] + + # Синхронизируем категории с Meilisearch + try: + # Удаляем все документы из индекса категорий + client.index(CATEGORY_INDEX).delete_all_documents() + + # Добавляем новые документы + if categories_data: + client.index(CATEGORY_INDEX).add_documents(categories_data) + + print(f"Успешно синхронизировано {len(categories_data)} категорий") + return True + except Exception as e: + print(f"Ошибка при синхронизации категорий: {str(e)}") + return False + +def initialize_category_index(): + """ + Инициализирует индекс категорий в Meilisearch. + """ + try: + # Проверяем, существует ли индекс категорий + indexes = client.get_indexes() + index_exists = False + + for index in indexes["results"]: + if index["uid"] == CATEGORY_INDEX: + index_exists = True + break + + # Если индекс не существует, создаем его + if not index_exists: + client.create_index(CATEGORY_INDEX, {"primaryKey": "id"}) + + # Настраиваем фильтруемые атрибуты для категорий + client.index(CATEGORY_INDEX).update_filterable_attributes([ + "parent_id", "is_active", "id", "slug" + ]) + + # Настраиваем поисковые атрибуты для категорий + client.index(CATEGORY_INDEX).update_searchable_attributes([ + "name", "description", "slug" + ]) + + print("Индекс категорий инициализирован успешно") + return True + except Exception as e: + print(f"Ошибка при инициализации индекса категорий: {str(e)}") + return False + +def main(): + """ + Скрипт для принудительной синхронизации категорий с Meilisearch. + """ + print("Принудительная синхронизация категорий с Meilisearch...") + + # Инициализируем индекс категорий в Meilisearch + initialize_category_index() + + # Создаем сессию базы данных + db = SessionLocal() + + try: + # Синхронизируем категории с Meilisearch + success = sync_categories(db) + + if success: + print("Категории успешно синхронизированы с Meilisearch") + else: + print("Ошибка при синхронизации категорий с Meilisearch") + except Exception as e: + print(f"Ошибка при синхронизации категорий с Meilisearch: {str(e)}") + finally: + db.close() + +if __name__ == "__main__": + main() diff --git a/backend/sync_meilisearch.py b/backend/sync_meilisearch.py new file mode 100644 index 0000000..8c0c023 --- /dev/null +++ b/backend/sync_meilisearch.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +import sys +import os +from pathlib import Path + +# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения +sys.path.append(str(Path(__file__).parent)) + +from app.core import SessionLocal +from app.scripts.sync_meilisearch import sync_products, sync_categories, sync_collections, sync_sizes +from app.services import meilisearch_service + +def main(): + """ + Скрипт для ручной синхронизации данных с Meilisearch. + """ + print("Инициализация индексов Meilisearch...") + meilisearch_service.initialize_indexes() + + # Создаем сессию базы данных + db = SessionLocal() + + try: + print("Синхронизация категорий...") + sync_categories(db) + + print("Синхронизация коллекций...") + sync_collections(db) + + print("Синхронизация размеров...") + sync_sizes(db) + + print("Синхронизация продуктов...") + sync_products(db) + + print("Синхронизация с Meilisearch завершена успешно!") + except Exception as e: + print(f"Ошибка при синхронизации данных с Meilisearch: {str(e)}") + finally: + db.close() + +if __name__ == "__main__": + main() diff --git a/backend/sync_sizes_direct.py b/backend/sync_sizes_direct.py new file mode 100644 index 0000000..38c47e2 --- /dev/null +++ b/backend/sync_sizes_direct.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +import sys +import os +from pathlib import Path + +# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения +sys.path.append(str(Path(__file__).parent)) + +from app.core import SessionLocal +from app.models.catalog_models import Size +from app.services.meilisearch_service import client, SIZE_INDEX + +def format_size_for_meilisearch(size): + """ + Форматирует размер для индексации в Meilisearch. + """ + return { + "id": size.id, + "name": size.name, + "code": size.code, + "description": size.description, + "created_at": size.created_at.isoformat() if size.created_at else None, + "updated_at": size.updated_at.isoformat() if size.updated_at else None + } + +def sync_sizes(db): + """ + Синхронизирует все размеры с Meilisearch. + """ + print("Синхронизация размеров...") + + # Получаем все размеры из базы данных + sizes = db.query(Size).all() + + # Форматируем размеры для Meilisearch + sizes_data = [format_size_for_meilisearch(size) for size in sizes] + + # Синхронизируем размеры с Meilisearch + try: + # Удаляем все документы из индекса размеров + client.index(SIZE_INDEX).delete_all_documents() + + # Добавляем новые документы + if sizes_data: + client.index(SIZE_INDEX).add_documents(sizes_data) + + print(f"Успешно синхронизировано {len(sizes_data)} размеров") + return True + except Exception as e: + print(f"Ошибка при синхронизации размеров: {str(e)}") + return False + +def initialize_size_index(): + """ + Инициализирует индекс размеров в Meilisearch. + """ + try: + # Проверяем, существует ли индекс размеров + indexes = client.get_indexes() + index_exists = False + + for index in indexes["results"]: + if index["uid"] == SIZE_INDEX: + index_exists = True + break + + # Если индекс не существует, создаем его + if not index_exists: + client.create_index(SIZE_INDEX, {"primaryKey": "id"}) + + # Настраиваем фильтруемые атрибуты для размеров + client.index(SIZE_INDEX).update_filterable_attributes([ + "id", "code" + ]) + + # Настраиваем поисковые атрибуты для размеров + client.index(SIZE_INDEX).update_searchable_attributes([ + "name", "code", "description" + ]) + + print("Индекс размеров инициализирован успешно") + return True + except Exception as e: + print(f"Ошибка при инициализации индекса размеров: {str(e)}") + return False + +def main(): + """ + Скрипт для принудительной синхронизации размеров с Meilisearch. + """ + print("Принудительная синхронизация размеров с Meilisearch...") + + # Инициализируем индекс размеров в Meilisearch + initialize_size_index() + + # Создаем сессию базы данных + db = SessionLocal() + + try: + # Синхронизируем размеры с Meilisearch + success = sync_sizes(db) + + if success: + print("Размеры успешно синхронизированы с Meilisearch") + else: + print("Ошибка при синхронизации размеров с Meilisearch") + except Exception as e: + print(f"Ошибка при синхронизации размеров с Meilisearch: {str(e)}") + finally: + db.close() + +if __name__ == "__main__": + main() diff --git a/backend/update_meilisearch_settings.py b/backend/update_meilisearch_settings.py new file mode 100644 index 0000000..157b4c8 --- /dev/null +++ b/backend/update_meilisearch_settings.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +import sys +import os +from pathlib import Path + +# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения +sys.path.append(str(Path(__file__).parent)) + +from app.services.meilisearch_service import client, PRODUCT_INDEX, CATEGORY_INDEX, COLLECTION_INDEX, SIZE_INDEX + +def main(): + """ + Скрипт для обновления настроек индексов Meilisearch. + """ + print("Обновление настроек индексов Meilisearch...") + + try: + # Обновляем фильтруемые атрибуты для продуктов + print("Обновление фильтруемых атрибутов для продуктов...") + client.index(PRODUCT_INDEX).update_filterable_attributes([ + "category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id" + ]) + + # Обновляем поисковые атрибуты для продуктов + print("Обновление поисковых атрибутов для продуктов...") + client.index(PRODUCT_INDEX).update_searchable_attributes([ + "name", "description", "slug" + ]) + + # Обновляем сортируемые атрибуты для продуктов + print("Обновление сортируемых атрибутов для продуктов...") + client.index(PRODUCT_INDEX).update_sortable_attributes([ + "price", "discount_price", "created_at", "name" + ]) + + # Обновляем фильтруемые атрибуты для категорий + print("Обновление фильтруемых атрибутов для категорий...") + client.index(CATEGORY_INDEX).update_filterable_attributes([ + "parent_id", "is_active", "id", "slug" + ]) + + # Обновляем поисковые атрибуты для категорий + print("Обновление поисковых атрибутов для категорий...") + client.index(CATEGORY_INDEX).update_searchable_attributes([ + "name", "description", "slug" + ]) + + # Обновляем фильтруемые атрибуты для коллекций + print("Обновление фильтруемых атрибутов для коллекций...") + client.index(COLLECTION_INDEX).update_filterable_attributes([ + "is_active", "id", "slug" + ]) + + # Обновляем поисковые атрибуты для коллекций + print("Обновление поисковых атрибутов для коллекций...") + client.index(COLLECTION_INDEX).update_searchable_attributes([ + "name", "description", "slug" + ]) + + # Обновляем поисковые атрибуты для размеров + print("Обновление поисковых атрибутов для размеров...") + client.index(SIZE_INDEX).update_searchable_attributes([ + "name", "code", "description" + ]) + + print("Настройки индексов Meilisearch обновлены успешно!") + except Exception as e: + print(f"Ошибка при обновлении настроек индексов Meilisearch: {str(e)}") + +if __name__ == "__main__": + main() diff --git a/backend/uploads/062aaa80-fae1-4f50-8b14-489edfcfac3f.jpeg b/backend/uploads/062aaa80-fae1-4f50-8b14-489edfcfac3f.jpeg new file mode 100644 index 0000000..24fa56e Binary files /dev/null and b/backend/uploads/062aaa80-fae1-4f50-8b14-489edfcfac3f.jpeg differ diff --git a/backend/uploads/27d93165-7b8f-48d5-869c-42754ce7cc63.jpeg b/backend/uploads/27d93165-7b8f-48d5-869c-42754ce7cc63.jpeg new file mode 100644 index 0000000..b136d17 Binary files /dev/null and b/backend/uploads/27d93165-7b8f-48d5-869c-42754ce7cc63.jpeg differ diff --git a/backend/uploads/8ef8a3e2-a7f3-4427-a5e6-b3d7710a8234.jpg b/backend/uploads/8ef8a3e2-a7f3-4427-a5e6-b3d7710a8234.jpg new file mode 100644 index 0000000..0e34057 Binary files /dev/null and b/backend/uploads/8ef8a3e2-a7f3-4427-a5e6-b3d7710a8234.jpg differ diff --git a/backend/uploads/caf6b988-17ef-4d52-8a4f-bdc207029309.jpeg b/backend/uploads/caf6b988-17ef-4d52-8a4f-bdc207029309.jpeg new file mode 100644 index 0000000..29be554 Binary files /dev/null and b/backend/uploads/caf6b988-17ef-4d52-8a4f-bdc207029309.jpeg differ diff --git a/backend/uploads/f25d0924-931d-4ccf-94d4-43f0abd6abc2.jpeg b/backend/uploads/f25d0924-931d-4ccf-94d4-43f0abd6abc2.jpeg new file mode 100644 index 0000000..dad336b Binary files /dev/null and b/backend/uploads/f25d0924-931d-4ccf-94d4-43f0abd6abc2.jpeg differ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8ec138d..c4fa331 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -105,10 +105,22 @@ services: - postgres restart: always + redis: + image: redis:alpine + container_name: dressed-for-success-redis + hostname: redis + expose: + - "6379" + restart: always + networks: + app_network: + aliases: + - redis + networks: app_network: driver: bridge volumes: postgres_data: - driver: local \ No newline at end of file + driver: local \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 93e1b1c..d53d20e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,9 +13,13 @@ services: - DEBUG=0 - SECRET_KEY=supersecretkey - UPLOAD_DIRECTORY=/app/uploads + - MEILISEARCH_URL=http://meilisearch:7700 + - MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM depends_on: postgres: condition: service_healthy + meilisearch: + condition: service_healthy volumes: - ./backend/uploads:/app/uploads networks: @@ -93,6 +97,32 @@ services: dns_search: . restart: always + meilisearch: + image: getmeili/meilisearch:latest + container_name: dressed-for-success-meilisearch + hostname: meilisearch + ports: + - "7700:7700" + environment: + - MEILI_MASTER_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM + - MEILI_NO_ANALYTICS=true + - MEILI_ENV=production + volumes: + - meilisearch_data:/meili_data + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + networks: + app_network: + aliases: + - meilisearch + dns_search: . + restart: always + + networks: app_network: driver: bridge @@ -101,4 +131,6 @@ volumes: postgres_data: driver: local backend_uploads: + driver: local + meilisearch_data: driver: local \ No newline at end of file diff --git a/frontend/.DS_Store b/frontend/.DS_Store index 35db19d..b10d16a 100644 Binary files a/frontend/.DS_Store and b/frontend/.DS_Store differ diff --git a/frontend/app/(main)/about/page.tsx b/frontend/app/(main)/about/page.tsx index 1b2aef4..80d99b2 100644 --- a/frontend/app/(main)/about/page.tsx +++ b/frontend/app/(main)/about/page.tsx @@ -4,371 +4,259 @@ import Image from "next/image" import Link from "next/link" import { Button } from "@/components/ui/button" import { motion } from "framer-motion" -import { useInView } from "react-intersection-observer" -import { ArrowRight } from "lucide-react" export default function AboutPage() { - const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.2 }) - const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.2 }) - const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.2 }) - const [ref4, inView4] = useInView({ triggerOnce: true, threshold: 0.2 }) - const [ref5, inView5] = useInView({ triggerOnce: true, threshold: 0.2 }) - return ( -
- {/* Hero Section */} -
-
-
-
- -
- -

О нашем бренде

-

Мы создаем одежду, которая становится частью вашей истории и отражает вашу индивидуальность

-
- - Качество - - - Стиль - - - Устойчивое развитие - -
- -
-
-
- - {/* Our Story */} -
-
- -
- Наша история -

Путь к созданию уникального бренда

-
-

- Наш бренд был основан в 2015 году с простой, но амбициозной целью: создавать одежду, которая сочетает - в себе элегантность, комфорт и устойчивое развитие. Мы начали с небольшой коллекции базовых предметов - гардероба, созданных из экологически чистых материалов. -

-

- С годами мы росли, но наши ценности оставались неизменными. Мы по-прежнему стремимся создавать одежду, - которая не только выглядит стильно, но и производится с уважением к людям и планете. -

-

- Сегодня наш бренд представлен в нескольких странах, но мы по-прежнему сохраняем индивидуальный подход - к каждому изделию, уделяя особое внимание деталям и качеству. -

-
-
- -
-
-
-
- История бренда -
-
-

2015

-

Год основания нашего бренда

-
-
-
-
-
- - {/* Our Values */} -
-
-
- Наши ценности -

Принципы, которыми мы руководствуемся

-

- Наши ценности определяют все, что мы делаем — от выбора материалов до отношений с клиентами и партнерами. -

-
- - + {/* Вступление */} +
+
+
+
+ - -
- - - -
-

Качество превыше всего

-

- Мы не идем на компромиссы, когда речь идет о качестве. Каждое изделие проходит строгий контроль, чтобы - гарантировать долговечность и безупречный внешний вид. -

-
- - -
- - - -
-

Устойчивое развитие

-

- Мы заботимся о планете и стремимся минимизировать наше воздействие на окружающую среду. Мы используем экологически чистые материалы и этичные методы производства. -

-
- - -
- - - - - - -
-

Клиентоориентированность

-

- Наши клиенты — в центре всего, что мы делаем. Мы стремимся превзойти ожидания и создать положительный опыт на каждом этапе взаимодействия с нашим брендом. -

-
- + Наша Философия: Dressed for Success – Это Вы +
+ + Dressed for Success – это не просто одежда, это философия и образ жизни. Бренд создает одежду для женщин, которые сочетают в себе все грани: силу и нежность, элегантность и практичность, уверенность и чувственность. Коллекции – это воплощение идеи, что каждая женщина заслуживает чувствовать себя особенной каждый день. +
- {/* Team Section */} -
-
-
- Наша команда -

Люди, создающие наш бренд

-

- За каждым успешным брендом стоит команда талантливых и преданных своему делу людей. Познакомьтесь с некоторыми из них. -

-
- - - {[ - { name: "Анна Смирнова", role: "Основатель и креативный директор" }, - { name: "Михаил Петров", role: "Директор по производству" }, - { name: "Елена Иванова", role: "Главный дизайнер" }, - { name: "Дмитрий Козлов", role: "Директор по маркетингу" } - ].map((member, index) => ( - -
- {member.name} -
-
-

{member.name}

-

{member.role}

-
-
-
-

{member.name}

-

{member.role}

-
- ))} -
-
-
- - {/* Milestones */} -
-
-
- Наши достижения -

Ключевые моменты нашей истории

-

- Путь нашего бренда отмечен важными вехами, которые сформировали нас такими, какие мы есть сегодня. -

-
- - -
- - {[ - { year: "2015", title: "Основание бренда", description: "Запуск первой коллекции базовых предметов гардероба." }, - { year: "2017", title: "Открытие первого магазина", description: "Открытие нашего флагманского магазина в центре города." }, - { year: "2019", title: "Международная экспансия", description: "Выход на международный рынок и запуск онлайн-магазина." }, - { year: "2021", title: "Устойчивое развитие", description: "Переход на 100% экологически чистые материалы во всех коллекциях." }, - { year: "2023", title: "Новая эра", description: "Запуск инновационной линейки одежды с использованием передовых технологий." } - ].map((milestone, index) => ( - -
-
-
- {milestone.year} -
- -
-
-
-

{milestone.title}

-

{milestone.description}

-
-
- ))} -
-
-
- - {/* CTA Section */} -
-
- -

Станьте частью нашей истории

-

- Присоединяйтесь к нам в нашем стремлении создавать одежду, которая не только выглядит прекрасно, но и производится с заботой о людях и планете. -

-
- - -
-
+ + +
+ Путь к созданию +
+
+
+
+
-
+ + {/* Наши Ценности */} +
+
+
+
+ + Наши Ценности + + +
+ {/* Натуральные ткани */} + +

Натуральные ткани

+

+ Только лучшие натуральные материалы (тенсел, лён, вискоза), которые дарят комфорт и заботятся о самочувствии. В коллекциях используются ткани, которые приятны к телу и позволяют коже дышать. +

+
+ + {/* Внимание к деталям */} + +

Внимание к деталям

+

+ Всё продумано до мелочей: от кроя до последней строчки и 17 пуговиц на блузах SHIK. Каждый элемент одежды создан с любовью и вниманием, чтобы подчеркнуть индивидуальность. +

+
+ + {/* Малые тиражи */} + +

Малые тиражи

+

+ Уникальные вещи создаются небольшими партиями, чтобы каждая клиентка чувствовала себя особенной. Это позволяет контролировать качество каждого изделия и гарантировать, что каждая вещь создана с душой. +

+
+ + {/* Комфорт и Стиль */} + +

Комфорт и Стиль

+

+ Баланс между элегантностью и практичностью. Одежда, в которой удобно и красиво жить. Философия бренда заключается в том, что стильная одежда должна быть комфортной, а комфортная одежда может быть стильной. +

+
+
+
+
+ + {/* Наша Миссия */} +
+
+
+ +
+ Наша миссия +
+
+
+
+ +

Наша Миссия

+

+ Вдохновлять на победы, предлагая стильные, качественные и комфортные вещи, которые подчеркивают индивидуальность и помогают чувствовать себя уверенно. +

+
+

+ "В Dressed for Success создается не просто одежда, а настроение и уверенность. Каждая женщина заслуживает чувствовать себя особенной каждый день, и одежда бренда помогает ей в этом." +

+

— Команда Dressed for Success

+
+ +
+
+
+
+ + {/* Подписка */} +
+
+
+
+
+
+
+ + Следите за нами + + + Подпишитесь на наши соцсети, чтобы быть в курсе новых коллекций, специальных предложений и вдохновляющего контента. + + + + Instagram + + + Telegram + + +
+
+
+ ) } diff --git a/frontend/app/(main)/cart/page.tsx b/frontend/app/(main)/cart/page.tsx index 2f6f8d8..8462b1f 100644 --- a/frontend/app/(main)/cart/page.tsx +++ b/frontend/app/(main)/cart/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useState, useRef, useEffect } from "react" import Image from "next/image" import Link from "next/link" import { Trash2, Plus, Minus, ArrowRight, ShoppingBag, Heart, ChevronLeft, Clock, ShieldCheck, Truck, Package } from "lucide-react" @@ -12,9 +12,40 @@ import { useInView } from "react-intersection-observer" import { useCart } from "@/hooks/useCart" import { useRouter } from "next/navigation" import { formatPrice } from "@/lib/utils" -import { CartItem as CartItemType } from "@/types/cart" import { toast } from "@/components/ui/use-toast" import { ProductCard } from "@/components/product/product-card" +import { normalizeProductImage } from "@/lib/catalog" // Импортируем нормализацию +import React from "react" // Добавляем импорт React для React.memo + +// Компонент для анимации изменения чисел +const AnimatedNumber = ({ value, className }: { value: number, className?: string }) => { + return ( + + {value} + + ); +}; + +// Компонент для анимации изменения цены +const AnimatedPrice = ({ price, className }: { price: number, className?: string }) => { + return ( + + {formatPrice(price)} + + ); +}; + +// Убираем компонент CartItemImage interface RecommendedProduct { id: number @@ -26,11 +57,22 @@ interface RecommendedProduct { export default function CartPage() { const { cart, loading, updateCartItem, removeFromCart, clearCart } = useCart() - const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 }) - const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 }) - const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.1 }) + // Убираем useInView для ref1 и ref2, так как анимация будет управляться isFirstRender + // const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 }) + // const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 }) + const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.1 }) // Оставляем для будущих секций, если нужно const router = useRouter() const [processing, setProcessing] = useState<{ [key: number]: boolean }>({}) + + // Флаг для отслеживания первого рендера + const isFirstRender = useRef(true); + + // Сбрасываем флаг первого рендера после монтирования + useEffect(() => { + return () => { + isFirstRender.current = false; + }; + }, []); const handleQuantityChange = async (itemId: number, newQuantity: number) => { if (processing[itemId]) return @@ -59,7 +101,7 @@ export default function CartPage() { } // Calculate totals - const subtotal = cart.total_price + const subtotal = cart.total_amount ?? 0 const shipping = subtotal >= 5000 ? 0 : 500 // Free shipping for orders over 5000 const total = subtotal + shipping @@ -126,18 +168,19 @@ export default function CartPage() { ) : (
{/* Cart Items */} + {/* Убираем ref1 и меняем логику animate */}
-

Товары в корзине ({cart.items.length})

+

Товары в корзине ({cart.items_count})

- {item.quantity} + + + + {/* Удалена кнопка-переключатель */} -
+
{/* Убран класс 'hidden' */}

Сводка заказа @@ -374,8 +404,10 @@ export default function CartPage() {
- Товары ({cart.total_items}): - {formatPrice(cart.total_price)} + Товары ({cart.items_count}): + + +
@@ -389,7 +421,9 @@ export default function CartPage() {
Итого: - {formatPrice(total)} + + +
@@ -414,4 +448,3 @@ export default function CartPage() {
) } - diff --git a/frontend/app/(main)/catalog/[slug]/page.tsx b/frontend/app/(main)/catalog/[slug]/page.tsx index 613c314..533e348 100644 --- a/frontend/app/(main)/catalog/[slug]/page.tsx +++ b/frontend/app/(main)/catalog/[slug]/page.tsx @@ -1,6 +1,6 @@ "use client" -import { Suspense, useState } from "react" +import { Suspense, useState, useEffect } from "react" import Link from "next/link" import { notFound } from "next/navigation" import { ArrowLeft, ChevronRight, Truck, RotateCcw, Heart } from "lucide-react" @@ -34,7 +34,7 @@ export default function ProductPage({ params }: ProductPageProps) { const { addToCart, loading: cartLoading } = useCart() // Загрузка товара при монтировании компонента - useState(() => { + useEffect(() => { const fetchProduct = async () => { try { setLoading(true) @@ -54,7 +54,7 @@ export default function ProductPage({ params }: ProductPageProps) { } fetchProduct() - }) + }, [params.slug]) // Если все еще загружается, показываем скелетон if (loading) { @@ -373,4 +373,4 @@ function ProductSkeleton() {

) -} \ No newline at end of file +} diff --git a/frontend/app/(main)/catalog/page.tsx b/frontend/app/(main)/catalog/page.tsx index 9da199a..15f1c07 100644 --- a/frontend/app/(main)/catalog/page.tsx +++ b/frontend/app/(main)/catalog/page.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useState, useEffect } from "react" +import React, { useState, useEffect, useMemo } from "react" import Image from "next/image" import Link from "next/link" import { useSearchParams } from 'next/navigation' @@ -25,6 +25,8 @@ import { Input } from "@/components/ui/input" import { useInView } from "react-intersection-observer" import catalogService, { Product, Category, Collection, Size } from "@/lib/catalog" import { Skeleton } from "@/components/ui/skeleton" +import { useCatalogData, ProductQueryParams } from "@/hooks/useCatalogData" +import { useDebounce } from "@/hooks/useDebounce" // Расширение интерфейса Product для поддержки дополнительных свойств interface ExtendedProduct extends Product { @@ -50,25 +52,31 @@ interface ProductsResponse { export default function CatalogPage({ searchParams }: { searchParams?: { [key: string]: string } }) { const searchParamsObject = useSearchParams(); - + + // Используем наш новый хук для работы с данными каталога + const catalogData = useCatalogData(); + const [activeFilters, setActiveFilters] = useState([]) const [searchQuery, setSearchQuery] = useState("") + // Применяем дебаунсинг к поисковому запросу + const debouncedSearchQuery = useDebounce(searchQuery, 500); + const [sortOption, setSortOption] = useState("popular") const [currentPage, setCurrentPage] = useState(1) const [heroRef, heroInView] = useInView({ triggerOnce: true, threshold: 0.1 }) const [isMobile, setIsMobile] = useState(false) - - // Состояния для реальных данных + + // Состояния для отображения продуктов const [products, setProducts] = useState([]) - const [categories, setCategories] = useState([]) - const [collections, setCollections] = useState([]) - const [sizes, setSizes] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) const [totalProducts, setTotalProducts] = useState(0) const [selectedCategory, setSelectedCategory] = useState(null) const [selectedCollection, setSelectedCollection] = useState(null) const [selectedSizes, setSelectedSizes] = useState([]) + const [isLoadingProducts, setIsLoadingProducts] = useState(false) + const [productsError, setProductsError] = useState(null) + + // Получаем данные из хука + const { categories, collections, sizes, loading, error } = catalogData; // Инициализация фильтров из параметров URL useEffect(() => { @@ -77,25 +85,25 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s if (categoryId) { setSelectedCategory(Number(categoryId)); } - + // Проверяем наличие коллекции в URL const collectionId = searchParamsObject.get('collection_id'); if (collectionId) { setSelectedCollection(Number(collectionId)); } - + // Проверяем наличие размеров в URL const sizeIds = searchParamsObject.get('size_ids'); if (sizeIds) { setSelectedSizes(sizeIds.split(',').map(id => Number(id))); } - + // Проверяем наличие поискового запроса const search = searchParamsObject.get('search'); if (search) { setSearchQuery(search); } - + // Проверяем сортировку const sort = searchParamsObject.get('sort'); if (sort) { @@ -108,183 +116,112 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s const checkIsMobile = () => { setIsMobile(window.innerWidth < 768) } - + checkIsMobile() window.addEventListener('resize', checkIsMobile) - + return () => { window.removeEventListener('resize', checkIsMobile) } }, []) - // Загрузка категорий + // Загрузка начальных данных каталога при монтировании компонента useEffect(() => { - const loadCategories = async () => { - try { - console.log('Загрузка категорий...'); - const categoriesData = await catalogService.getCategoriesTree(); - - if (categoriesData && categoriesData.length > 0) { - setCategories(categoriesData); - } else { - setError("Не удалось загрузить категории с сервера"); - } - } catch (err) { - console.error("Ошибка при загрузке категорий:", err); - setError("Не удалось загрузить категории с сервера"); - } + // Загружаем категории, коллекции и размеры при первой загрузке страницы + const loadInitialData = async () => { + await Promise.all([ + catalogData.fetchCategories(), + catalogData.fetchCollections(), + catalogData.fetchSizes() + ]); }; - loadCategories(); + loadInitialData(); }, []); - - // Загрузка коллекций - useEffect(() => { - const loadCollections = async () => { - try { - console.log('Загрузка коллекций...'); - const collectionsResponse = await catalogService.getCollections(); - - if (collectionsResponse && collectionsResponse.collections) { - setCollections(collectionsResponse.collections); - } - } catch (err) { - console.error("Ошибка при загрузке коллекций:", err); - } + + // Подготавливаем параметры запроса + const queryParams = useMemo((): ProductQueryParams => { + const params: ProductQueryParams = { + limit: 12, + skip: (currentPage - 1) * 12, + include_variants: true }; - loadCollections(); - }, []); - - // Загрузка размеров - useEffect(() => { - const loadSizes = async () => { - try { - console.log('Загрузка размеров...'); - const sizesData = await catalogService.getSizes(); - - if (sizesData && sizesData.length > 0) { - setSizes(sizesData); - } - } catch (err) { - console.error("Ошибка при загрузке размеров:", err); - } - }; + if (selectedCategory) { + params.category_id = selectedCategory; + } - loadSizes(); - }, []); + if (selectedCollection) { + params.collection_id = selectedCollection; + } - // Загрузка продуктов с учетом фильтров + if (debouncedSearchQuery) { + params.search = debouncedSearchQuery; + } + + // Добавляем сортировку + if (sortOption) { + params.sort = sortOption; + } + + // Добавляем фильтр по размерам, если они выбраны + if (selectedSizes.length > 0) { + params.size_ids = selectedSizes; + } + + return params; + }, [currentPage, selectedCategory, selectedCollection, debouncedSearchQuery, sortOption, selectedSizes]); // Добавляем selectedSizes в зависимости + + // Загрузка продуктов при изменении параметров запроса useEffect(() => { const loadProducts = async () => { try { - setLoading(true); - console.log('Загрузка продуктов...'); - - // Формируем параметры запроса - const params: any = { - limit: 12, - skip: (currentPage - 1) * 12, - include_variants: true - }; - - // Добавляем фильтр по категории - if (selectedCategory) { - params.category_id = selectedCategory; - } - - // Добавляем фильтр по коллекции - if (selectedCollection) { - params.collection_id = selectedCollection; - } - - // Добавляем поисковый запрос - if (searchQuery) { - params.search = searchQuery; - } - - // Здесь можно добавить фильтрацию по размерам на стороне клиента, - // так как API не поддерживает прямую фильтрацию по размерам - - console.log('Параметры запроса:', params); - - // Получаем продукты через сервис - const response = await catalogService.getProducts(params) as ProductsResponse; - console.log('Полученный ответ:', response); - + setIsLoadingProducts(true); + setProductsError(null); + + // Получаем продукты через кэширующий сервис + const response = await catalogData.fetchProducts(queryParams); + if (!response || !response.products) { - setError("Товары не найдены"); - setLoading(false); + setProductsError("Товары не найдены"); setProducts([]); setTotalProducts(0); return; } - - let productsData = response.products; + + // Фильтрация по размерам теперь выполняется на бэкенде + setProducts(response.products as ExtendedProduct[]); setTotalProducts(response.total); - - // Фильтрация по размерам на стороне клиента, если выбраны размеры - if (selectedSizes.length > 0) { - productsData = productsData.filter(product => { - // Проверяем, есть ли у продукта варианты с выбранными размерами - if (!product.variants) return false; - - return product.variants.some(variant => - selectedSizes.includes(variant.size_id) && variant.is_active && variant.stock > 0 - ); - }); - } - - // Сортировка полученных продуктов - let sortedProducts = [...productsData]; - - switch (sortOption) { - case 'price_asc': - sortedProducts.sort((a, b) => a.price - b.price); - break; - case 'price_desc': - sortedProducts.sort((a, b) => b.price - a.price); - break; - case 'newest': - sortedProducts.sort((a, b) => { - const dateA = new Date(a.created_at || '').getTime(); - const dateB = new Date(b.created_at || '').getTime(); - return dateB - dateA; - }); - break; - default: // popular - оставляем как есть - break; - } - - setProducts(sortedProducts); - setLoading(false); + } catch (err) { console.error("Ошибка при загрузке продуктов:", err); - setError("Не удалось загрузить продукты"); - setLoading(false); + setProductsError("Не удалось загрузить продукты"); + } finally { + setIsLoadingProducts(false); } }; loadProducts(); - }, [currentPage, selectedCategory, selectedCollection, selectedSizes, searchQuery, sortOption]); + // Зависим только от параметров запроса и самой функции fetchProducts + }, [queryParams, catalogData.fetchProducts]); // Обработчик выбора категории const handleCategorySelect = (categoryId: number) => { setSelectedCategory(categoryId === selectedCategory ? null : categoryId); setCurrentPage(1); // Сбрасываем страницу на первую при изменении категории }; - + // Обработчик выбора коллекции const handleCollectionSelect = (collectionId: number) => { setSelectedCollection(collectionId === selectedCollection ? null : collectionId); setCurrentPage(1); // Сбрасываем страницу на первую при изменении коллекции }; - + // Обработчик выбора размера const handleSizeSelect = (sizeId: number) => { - setSelectedSizes(prev => - prev.includes(sizeId) - ? prev.filter(id => id !== sizeId) + setSelectedSizes(prev => + prev.includes(sizeId) + ? prev.filter(id => id !== sizeId) : [...prev, sizeId] ); setCurrentPage(1); // Сбрасываем страницу на первую при изменении размера @@ -324,35 +261,55 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s Категории - {categories.map((category) => ( -
- handleCategorySelect(category.id)} - className="border-primary/30 data-[state=checked]:border-primary data-[state=checked]:bg-primary" - /> - -
- ))} + {loading.categories ? ( + // Скелетон для загрузки категорий + Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ )) + ) : categories.length > 0 ? ( + categories.map((category) => ( +
+ handleCategorySelect(category.id)} + className="border-primary/30 data-[state=checked]:border-primary data-[state=checked]:bg-primary" + /> + +
+ )) + ) : ( +
Категории не найдены
+ )}
- + {/* Фильтр по коллекциям */} - {collections.length > 0 && ( - - - - Коллекции - - - {collections.map((collection) => ( + + + + Коллекции + + + {loading.collections ? ( + // Скелетон для загрузки коллекций + Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ )) + ) : collections.length > 0 ? ( + collections.map((collection) => (
- ))} -
-
-
- )} - + )) + ) : ( +
Коллекции не найдены
+ )} +
+
+
+ {/* Фильтр по размерам */} - {sizes.length > 0 && ( - - - - Размеры - - -
- {sizes.map((size) => ( + + + + Размеры + + +
+ {loading.sizes ? ( + // Скелетон для загрузки размеров + Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ )) + ) : sizes.length > 0 ? ( + sizes.map((size) => (
- {size.name} ({size.value}) + {size.name} {size.code ? `(${size.code})` : ''}
- ))} -
-
-
-
- )} + )) + ) : ( +
Размеры не найдены
+ )} +
+
+
+
) // Компонент активных фильтров const ActiveFilters = () => { const filters = []; - + if (selectedCategory) { const category = categories.find(c => c.id === selectedCategory); if (category) { @@ -420,7 +389,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s }); } } - + if (selectedCollection) { const collection = collections.find(c => c.id === selectedCollection); if (collection) { @@ -431,20 +400,20 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s }); } } - + if (selectedSizes.length > 0) { const sizeNames = selectedSizes .map(id => sizes.find(s => s.id === id)?.value) .filter(Boolean) .join(', '); - + filters.push({ id: 'sizes', name: `Размеры: ${sizeNames}`, onRemove: () => setSelectedSizes([]) }); } - + if (searchQuery) { filters.push({ id: 'search', @@ -452,9 +421,9 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s onRemove: () => setSearchQuery('') }); } - + if (filters.length === 0) return null; - + return (
{filters.map(filter => ( @@ -468,7 +437,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
))} - + {filters.length > 0 && (
- +
{/* Сортировка */} - + {/* Фильтр для мобильной версии */} -
- + {/* Отображение активных фильтров */} - +
{/* Фильтры (сайдбар) - только на десктопе */}
@@ -605,15 +574,15 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
- + {/* Основной контент с товарами */}
- {loading ? ( - - ) : error ? ( + {isLoadingProducts ? ( + + ) : productsError ? (

Ошибка при загрузке товаров

-

{error}

+

{productsError}

@@ -630,8 +599,8 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
) : ( <> - - + + {/* Пагинация */} {totalProducts > 12 && (
@@ -644,7 +613,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s Пред. - +
{Array.from({ length: Math.min(5, Math.ceil(totalProducts / 12)) }).map((_, i) => { // Логика для отображения страниц вокруг текущей @@ -655,15 +624,15 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s pageToShow = Math.ceil(totalProducts / 12) - (5 - i - 1); } } - + if (pageToShow > 0 && pageToShow <= Math.ceil(totalProducts / 12)) { return (
- +
{currentPage} из {Math.ceil(totalProducts / 12)}
- +
) -} \ No newline at end of file +} diff --git a/frontend/app/(main)/company-info/page.tsx b/frontend/app/(main)/company-info/page.tsx new file mode 100644 index 0000000..0afaba3 --- /dev/null +++ b/frontend/app/(main)/company-info/page.tsx @@ -0,0 +1,90 @@ +"use client" + +import Link from "next/link" +import { motion } from "framer-motion" +import { ArrowLeft } from "lucide-react" + +export default function CompanyInfoPage() { + return ( +
+ {/* Декоративные круги */} +
+
+ +
+ + Реквизиты компании + + + +
+

1. Наименование предприятия

+

Индивидуальный предприниматель Плотников Михаил Владимирович

+

ИП Плотников Михаил Владимирович

+
+ +
+

2. ИНН

+

421713424512

+
+ +
+

3. Адрес

+

654066, Кемеровская область-Кузбасс, г. Новокузнецк, ул. Грдины, дом 23, кв. 186

+
+ +
+

4. Телефон

+

8 905 966 2401

+
+ +
+

5. Эл. почта

+

Pmv-84@yandex.ru

+
+ +
+

6. Регистрация ИП

+

Межрайонная инспекция ФНС №15 по Кемеровской области-Кузбассу

+

ОГРНИП 325420500025878 от 17.03.2025 г.

+
+ +
+

7. Банк

+

Филиал «Новосибирский» АО «АЛЬФА-БАНК» г. Новосибирск

+

БИК 045004774

+

Корр. счет 30101810600000000774

+

Расчетный счет 40802810023070009086

+
+ +
+

8. Руководитель

+

Плотников Михаил Владимирович

+
+
+ + + + + На главную + + +
+
+ ) +} diff --git a/frontend/app/(main)/contact/page.tsx b/frontend/app/(main)/contact/page.tsx deleted file mode 100644 index 44c5f8b..0000000 --- a/frontend/app/(main)/contact/page.tsx +++ /dev/null @@ -1,415 +0,0 @@ -"use client" - -import type React from "react" - -import { useState } from "react" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Label } from "@/components/ui/label" -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" -import { motion } from "framer-motion" -import { Check, Mail, MapPin, Phone, ArrowRight, Send, Clock, ChevronRight } from "lucide-react" -import { useInView } from "react-intersection-observer" -import Image from "next/image" -import Link from "next/link" - -export default function ContactPage() { - const [formSubmitted, setFormSubmitted] = useState(false) - const [formData, setFormData] = useState({ - name: "", - email: "", - phone: "", - subject: "general", - message: "", - }) - const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.2 }) - const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.2 }) - const [mapRef, mapInView] = useInView({ triggerOnce: true, threshold: 0.2 }) - const [faqRef, faqInView] = useInView({ triggerOnce: true, threshold: 0.2 }) - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target - setFormData((prev) => ({ ...prev, [name]: value })) - } - - const handleRadioChange = (value: string) => { - setFormData((prev) => ({ ...prev, subject: value })) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - // Here you would normally send the form data to your backend - console.log("Form submitted:", formData) - setFormSubmitted(true) - // Reset form after submission - setFormData({ - name: "", - email: "", - phone: "", - subject: "general", - message: "", - }) - // Reset submission status after 5 seconds - setTimeout(() => setFormSubmitted(false), 5000) - } - - // Анимационные варианты - const fadeIn = { - hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.6 } } - } - - const staggerContainer = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1 - } - } - } - - return ( -
- {/* Hero Section */} -
-
-
-
- -
- -

СВЯЗАТЬСЯ С НАМИ

-
-

Мы всегда рады ответить на ваши вопросы и услышать ваше мнение

-
-
-
- -
-
-
- {/* Contact Information */} - -

КОНТАКТНАЯ ИНФОРМАЦИЯ

-
- -
-
-
- -
-
-

Адрес

-

ул. Ленина, 123, Москва, 123456

-

Россия

-
-
- -
-
- -
-
-

Email

-

info@dressedforsuccessstore.com

-

support@dressedforsuccessstore.com

-
-
- -
-
- -
-
-

Телефон

-

+7 (495) 123-45-67

-

Пн-Пт: 9:00 - 18:00

-
-
-
- -
-
- -

Часы работы

-
-
-
- Понедельник - Пятница: - 10:00 - 20:00 -
-
- Суббота: - 10:00 - 18:00 -
-
- Воскресенье: - 11:00 - 17:00 -
-
-
- - -
- - {/* Contact Form */} - -
-

НАПИШИТЕ НАМ

-
- - {formSubmitted ? ( - - -
-

Сообщение отправлено!

-

Спасибо за ваше сообщение. Мы свяжемся с вами в ближайшее время.

-
-
- ) : ( -
-
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
- -