diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..e49474e Binary files /dev/null and b/.DS_Store differ diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000..74d7153 Binary files /dev/null and b/backend/.DS_Store differ diff --git a/backend/app/.DS_Store b/backend/app/.DS_Store new file mode 100644 index 0000000..b507094 Binary files /dev/null and b/backend/app/.DS_Store differ diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__pycache__/__init__.cpython-310.pyc b/backend/app/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..355953a Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..041cf73 Binary files /dev/null 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 new file mode 100644 index 0000000..0386479 Binary files /dev/null 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 new file mode 100644 index 0000000..c335ab5 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-310.pyc differ diff --git a/backend/app/__pycache__/routers.cpython-310.pyc b/backend/app/__pycache__/routers.cpython-310.pyc new file mode 100644 index 0000000..40350e0 Binary files /dev/null and b/backend/app/__pycache__/routers.cpython-310.pyc differ diff --git a/backend/app/__pycache__/services.cpython-310.pyc b/backend/app/__pycache__/services.cpython-310.pyc new file mode 100644 index 0000000..6a12618 Binary files /dev/null and b/backend/app/__pycache__/services.cpython-310.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..448a63e --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,53 @@ +import os +from pydantic_settings import BaseSettings +from dotenv import load_dotenv + +# Загружаем переменные окружения из .env файла +load_dotenv() + + +class Settings(BaseSettings): + # Основные настройки приложения + APP_NAME: str = "Интернет-магазин API" + APP_VERSION: str = "0.1.0" + APP_DESCRIPTION: str = "API для интернет-магазина на FastAPI" + + # Настройки базы данных + DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5434/shop_db") + + # Настройки безопасности + SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-for-jwt-please-change-in-production") + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + # Настройки CORS + CORS_ORIGINS: list = [ + "http://localhost", + "http://localhost:3000", + "http://localhost:8000", + "http://localhost:8080", + ] + + # Настройки для загрузки файлов + UPLOAD_DIRECTORY: str = "uploads" + MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5 MB + ALLOWED_UPLOAD_EXTENSIONS: list = ["jpg", "jpeg", "png", "gif", "webp"] + + # Настройки для платежных систем (пример) + PAYMENT_GATEWAY_API_KEY: str = os.getenv("PAYMENT_GATEWAY_API_KEY", "") + PAYMENT_GATEWAY_SECRET: str = os.getenv("PAYMENT_GATEWAY_SECRET", "") + + # Настройки для отправки email (пример) + SMTP_SERVER: str = os.getenv("SMTP_SERVER", "") + SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587")) + SMTP_USERNAME: str = os.getenv("SMTP_USERNAME", "") + SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "") + EMAIL_FROM: str = os.getenv("EMAIL_FROM", "noreply@example.com") + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +# Создаем экземпляр настроек +settings = Settings() \ No newline at end of file diff --git a/backend/app/core.py b/backend/app/core.py new file mode 100644 index 0000000..72c903b --- /dev/null +++ b/backend/app/core.py @@ -0,0 +1,102 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from passlib.context import CryptContext +from jose import JWTError, jwt +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, Union +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session + +from app.config import settings + +# Настройка SQLAlchemy +SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL + +engine = create_engine(SQLALCHEMY_DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +# Зависимость для получения сессии БД +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# Настройка безопасности +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") + +# Функции для работы с паролями +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + +# Функции для работы с JWT токенами +def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + return encoded_jwt + +def verify_token(token: str) -> Dict[str, Any]: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Невалидный токен аутентификации", + headers={"WWW-Authenticate": "Bearer"}, + ) + +# Функция для получения текущего пользователя +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Невалидные учетные данные", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = verify_token(token) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + from app.repositories.user_repo import get_user_by_username + user = get_user_by_username(db, username) + + if user is None: + raise credentials_exception + + return user + +# Функция для проверки активного пользователя +async def get_current_active_user(current_user = Depends(get_current_user)): + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Неактивный пользователь") + return current_user + +# Функция для проверки прав администратора +async def get_current_admin_user(current_user = Depends(get_current_active_user)): + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для выполнения операции" + ) + return current_user \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3845e22 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,57 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import JSONResponse +import os +from pathlib import Path + +from app.config import settings +from app.routers import router +from app.core import Base, engine + +# Создаем таблицы в базе данных +Base.metadata.create_all(bind=engine) + +# Создаем экземпляр приложения FastAPI +app = FastAPI( + title=settings.APP_NAME, + description=settings.APP_DESCRIPTION, + version=settings.APP_VERSION +) + +# Настраиваем CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Обработчик исключений +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + # В реальном приложении здесь должно быть логирование ошибок + return JSONResponse( + status_code=500, + content={"detail": "Внутренняя ошибка сервера"} + ) + +# Подключаем роутеры +app.include_router(router, prefix="/api") + +# Создаем директорию для загрузок, если она не существует +uploads_dir = Path(settings.UPLOAD_DIRECTORY) +uploads_dir.mkdir(parents=True, exist_ok=True) + +# Монтируем статические файлы +app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIRECTORY), name="uploads") + +# Корневой маршрут +@app.get("/") +async def root(): + return { + "message": "Добро пожаловать в API интернет-магазина", + "docs_url": "/docs", + "redoc_url": "/redoc" + } \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/__pycache__/__init__.cpython-310.pyc b/backend/app/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..a091f91 Binary files /dev/null and b/backend/app/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/catalog_models.cpython-310.pyc b/backend/app/models/__pycache__/catalog_models.cpython-310.pyc new file mode 100644 index 0000000..8d383f7 Binary files /dev/null and b/backend/app/models/__pycache__/catalog_models.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/content_models.cpython-310.pyc b/backend/app/models/__pycache__/content_models.cpython-310.pyc new file mode 100644 index 0000000..a7ec942 Binary files /dev/null and b/backend/app/models/__pycache__/content_models.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/order_models.cpython-310.pyc b/backend/app/models/__pycache__/order_models.cpython-310.pyc new file mode 100644 index 0000000..f210656 Binary files /dev/null and b/backend/app/models/__pycache__/order_models.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/review_models.cpython-310.pyc b/backend/app/models/__pycache__/review_models.cpython-310.pyc new file mode 100644 index 0000000..ef987bd Binary files /dev/null and b/backend/app/models/__pycache__/review_models.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/user_models.cpython-310.pyc b/backend/app/models/__pycache__/user_models.cpython-310.pyc new file mode 100644 index 0000000..9283a83 Binary files /dev/null and b/backend/app/models/__pycache__/user_models.cpython-310.pyc differ diff --git a/backend/app/models/catalog_models.py b/backend/app/models/catalog_models.py new file mode 100644 index 0000000..ca2c9fc --- /dev/null +++ b/backend/app/models/catalog_models.py @@ -0,0 +1,73 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core import Base + + +class Category(Base): + __tablename__ = "categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + slug = Column(String, unique=True, index=True, nullable=False) + description = Column(Text, nullable=True) + parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Отношения + parent = relationship("Category", remote_side=[id], backref="subcategories") + products = relationship("Product", back_populates="category") + + +class Product(Base): + __tablename__ = "products" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + slug = Column(String, unique=True, index=True, nullable=False) + description = Column(Text, nullable=True) + price = Column(Float, nullable=False) + discount_price = Column(Float, nullable=True) + stock = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Отношения + category = relationship("Category", back_populates="products") + variants = relationship("ProductVariant", back_populates="product", cascade="all, delete-orphan") + reviews = relationship("Review", back_populates="product", cascade="all, delete-orphan") + images = relationship("ProductImage", backref="product", cascade="all, delete-orphan") + + +class ProductVariant(Base): + __tablename__ = "product_variants" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + name = Column(String, nullable=False) + sku = Column(String, unique=True, nullable=False) + price_adjustment = Column(Float, default=0.0) + stock = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Отношения + product = relationship("Product", back_populates="variants") + + +class ProductImage(Base): + __tablename__ = "product_images" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + image_url = Column(String, nullable=False) + alt_text = Column(String, nullable=True) + is_primary = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file diff --git a/backend/app/models/content_models.py b/backend/app/models/content_models.py new file mode 100644 index 0000000..8a43409 --- /dev/null +++ b/backend/app/models/content_models.py @@ -0,0 +1,40 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text, Boolean, JSON +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core import Base + + +class Page(Base): + __tablename__ = "pages" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + slug = Column(String, unique=True, index=True, nullable=False) + content = Column(Text, nullable=False) + meta_title = Column(String, nullable=True) + meta_description = Column(String, nullable=True) + is_published = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + +class AnalyticsLog(Base): + __tablename__ = "analytics_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + event_type = Column(String, nullable=False) # page_view, product_view, add_to_cart, etc. + page_url = Column(String, nullable=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=True) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) + ip_address = Column(String, nullable=True) + user_agent = Column(String, nullable=True) + referrer = Column(String, nullable=True) + additional_data = Column(JSON, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Отношения + user = relationship("User") + product = relationship("Product") + category = relationship("Category") \ No newline at end of file diff --git a/backend/app/models/order_models.py b/backend/app/models/order_models.py new file mode 100644 index 0000000..1d5dbc7 --- /dev/null +++ b/backend/app/models/order_models.py @@ -0,0 +1,73 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, Enum +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import enum + +from app.core import Base + + +class OrderStatus(str, enum.Enum): + PENDING = "pending" + PROCESSING = "processing" + SHIPPED = "shipped" + DELIVERED = "delivered" + CANCELLED = "cancelled" + REFUNDED = "refunded" + + +class PaymentMethod(str, enum.Enum): + CREDIT_CARD = "credit_card" + PAYPAL = "paypal" + BANK_TRANSFER = "bank_transfer" + CASH_ON_DELIVERY = "cash_on_delivery" + + +class CartItem(Base): + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + variant_id = Column(Integer, ForeignKey("product_variants.id"), nullable=False) + quantity = Column(Integer, default=1) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Отношения + user = relationship("User", back_populates="cart_items") + variant = relationship("ProductVariant") + + +class Order(Base): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column(Enum(OrderStatus), default=OrderStatus.PENDING) + total_amount = Column(Float, nullable=False) + shipping_address_id = Column(Integer, ForeignKey("user_addresses.id"), nullable=True) + payment_method = Column(Enum(PaymentMethod), nullable=True) + payment_details = Column(Text, nullable=True) + tracking_number = Column(String, nullable=True) + notes = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Отношения + user = relationship("User", back_populates="orders") + shipping_address = relationship("UserAddress") + items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + + +class OrderItem(Base): + __tablename__ = "order_items" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + variant_id = Column(Integer, ForeignKey("product_variants.id"), nullable=False) + quantity = Column(Integer, default=1) + price = Column(Float, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Отношения + order = relationship("Order", back_populates="items") + variant = relationship("ProductVariant") \ No newline at end of file diff --git a/backend/app/models/review_models.py b/backend/app/models/review_models.py new file mode 100644 index 0000000..e1fcbb7 --- /dev/null +++ b/backend/app/models/review_models.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Text, Boolean +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core import Base + + +class Review(Base): + __tablename__ = "reviews" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + rating = Column(Integer, nullable=False) # От 1 до 5 + title = Column(String, nullable=True) + comment = Column(Text, nullable=True) + is_verified_purchase = Column(Boolean, default=False) + is_approved = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Отношения + user = relationship("User", back_populates="reviews") + product = relationship("Product", back_populates="reviews") \ No newline at end of file diff --git a/backend/app/models/user_models.py b/backend/app/models/user_models.py new file mode 100644 index 0000000..a2a483c --- /dev/null +++ b/backend/app/models/user_models.py @@ -0,0 +1,45 @@ +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + phone = Column(String, unique=True, nullable=True) + password = Column(String, nullable=False) + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Отношения + addresses = relationship("UserAddress", back_populates="user", cascade="all, delete-orphan") + cart_items = relationship("CartItem", back_populates="user", cascade="all, delete-orphan") + orders = relationship("Order", back_populates="user") + reviews = relationship("Review", back_populates="user", cascade="all, delete-orphan") + + +class UserAddress(Base): + __tablename__ = "user_addresses" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + address_line1 = Column(String, nullable=False) + address_line2 = Column(String, nullable=True) + city = Column(String, nullable=False) + state = Column(String, nullable=False) + postal_code = Column(String, nullable=False) + country = Column(String, nullable=False) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Отношения + user = relationship("User", back_populates="addresses") \ No newline at end of file diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repositories/__pycache__/__init__.cpython-310.pyc b/backend/app/repositories/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..8893841 Binary files /dev/null and b/backend/app/repositories/__pycache__/__init__.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 new file mode 100644 index 0000000..634dca2 Binary files /dev/null and b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc new file mode 100644 index 0000000..768ed39 Binary files /dev/null and b/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc new file mode 100644 index 0000000..81df5aa Binary files /dev/null and b/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/__pycache__/review_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/review_repo.cpython-310.pyc new file mode 100644 index 0000000..de70c33 Binary files /dev/null and b/backend/app/repositories/__pycache__/review_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 new file mode 100644 index 0000000..721ee41 Binary files /dev/null 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 new file mode 100644 index 0000000..9b16d4f --- /dev/null +++ b/backend/app/repositories/catalog_repo.py @@ -0,0 +1,542 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional, Dict, Any +import re +from datetime import datetime + +from app.models.catalog_models import Category, Product, ProductVariant, ProductImage +from app.schemas.catalog_schemas import ( + CategoryCreate, CategoryUpdate, + ProductCreate, ProductUpdate, + ProductVariantCreate, ProductVariantUpdate, + ProductImageCreate, ProductImageUpdate +) + + +# Вспомогательная функция для генерации slug +def generate_slug(name: str) -> str: + # Преобразуем в нижний регистр + slug = name.lower() + # Заменяем пробелы на дефисы + slug = re.sub(r'\s+', '-', slug) + # Удаляем все символы, кроме букв, цифр и дефисов + slug = re.sub(r'[^a-z0-9-]', '', slug) + # Удаляем повторяющиеся дефисы + slug = re.sub(r'-+', '-', slug) + # Удаляем дефисы в начале и конце + slug = slug.strip('-') + + return slug + + +# Функции для работы с категориями +def get_category(db: Session, category_id: int) -> Optional[Category]: + return db.query(Category).filter(Category.id == category_id).first() + + +def get_category_by_slug(db: Session, slug: str) -> Optional[Category]: + return db.query(Category).filter(Category.slug == slug).first() + + +def get_categories( + 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() + + +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, + slug=category.slug, + description=category.description, + parent_id=category.parent_id, + is_active=category.is_active + ) + + try: + db.add(db_category) + db.commit() + db.refresh(db_category) + return db_category + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании категории" + ) + + +def update_category(db: Session, category_id: int, category: CategoryUpdate) -> Category: + db_category = get_category(db, category_id) + if not db_category: + raise HTTPException( + 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"]): + raise HTTPException( + 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"]) + # Проверяем уникальность сгенерированного slug + if get_category_by_slug(db, update_data["slug"]) and get_category_by_slug(db, update_data["slug"]).id != category_id: + raise HTTPException( + 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) + return db_category + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении категории" + ) + + +def delete_category(db: Session, category_id: int) -> bool: + db_category = get_category(db, category_id) + if not db_category: + raise HTTPException( + 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() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении категории" + ) + + +# Функции для работы с продуктами +def get_product(db: Session, product_id: int) -> Optional[Product]: + return db.query(Product).filter(Product.id == product_id).first() + + +def get_product_by_slug(db: Session, slug: str) -> Optional[Product]: + return db.query(Product).filter(Product.slug == slug).first() + + +def get_products( + db: Session, + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None, + search: Optional[str] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + is_active: Optional[bool] = True +) -> List[Product]: + query = db.query(Product) + + # Применяем фильтры + if category_id: + query = query.filter(Product.category_id == category_id) + + if search: + query = query.filter(Product.name.ilike(f"%{search}%")) + + if min_price is not None: + query = query.filter(Product.price >= min_price) + + if max_price is not None: + query = query.filter(Product.price <= max_price) + + if is_active is not None: + query = query.filter(Product.is_active == is_active) + + return query.offset(skip).limit(limit).all() + + +def create_product(db: Session, product: ProductCreate) -> Product: + # Если slug не предоставлен, генерируем его из имени + if not product.slug: + product.slug = generate_slug(product.name) + + # Проверяем, что продукт с таким slug не существует + if get_product_by_slug(db, product.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Продукт с таким slug уже существует" + ) + + # Проверяем, что категория существует + if not get_category(db, product.category_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Категория не найдена" + ) + + # Создаем новый продукт + db_product = Product( + name=product.name, + slug=product.slug, + description=product.description, + price=product.price, + discount_price=product.discount_price, + stock=product.stock, + is_active=product.is_active, + category_id=product.category_id + ) + + try: + db.add(db_product) + db.commit() + db.refresh(db_product) + return db_product + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании продукта" + ) + + +def update_product(db: Session, product_id: int, product: ProductUpdate) -> Product: + db_product = get_product(db, product_id) + if not db_product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Обновляем только предоставленные поля + update_data = product.dict(exclude_unset=True) + + # Если slug изменяется, проверяем его уникальность + if "slug" in update_data and update_data["slug"] != db_product.slug: + if get_product_by_slug(db, update_data["slug"]): + raise HTTPException( + 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"]) + # Проверяем уникальность сгенерированного slug + if get_product_by_slug(db, update_data["slug"]) and get_product_by_slug(db, update_data["slug"]).id != product_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Продукт с таким slug уже существует" + ) + + # Проверяем, что категория существует, если указана + if "category_id" in update_data and not get_category(db, update_data["category_id"]): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Категория не найдена" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_product, key, value) + + try: + db.commit() + db.refresh(db_product) + return db_product + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении продукта" + ) + + +def delete_product(db: Session, product_id: int) -> bool: + db_product = get_product(db, product_id) + if not db_product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + try: + db.delete(db_product) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении продукта" + ) + + +# Функции для работы с вариантами продуктов +def get_product_variant(db: Session, variant_id: int) -> Optional[ProductVariant]: + return db.query(ProductVariant).filter(ProductVariant.id == variant_id).first() + + +def get_product_variants(db: Session, product_id: int) -> List[ProductVariant]: + return db.query(ProductVariant).filter(ProductVariant.product_id == product_id).all() + + +def create_product_variant(db: Session, variant: ProductVariantCreate) -> ProductVariant: + # Проверяем, что продукт существует + if not get_product(db, variant.product_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Проверяем, что вариант с таким SKU не существует + if db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Вариант с таким SKU уже существует" + ) + + # Создаем новый вариант продукта + db_variant = ProductVariant( + product_id=variant.product_id, + name=variant.name, + sku=variant.sku, + price_adjustment=variant.price_adjustment, + stock=variant.stock, + is_active=variant.is_active + ) + + try: + db.add(db_variant) + db.commit() + db.refresh(db_variant) + return db_variant + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании варианта продукта" + ) + + +def update_product_variant(db: Session, variant_id: int, variant: ProductVariantUpdate) -> ProductVariant: + db_variant = get_product_variant(db, variant_id) + if not db_variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Вариант продукта не найден" + ) + + # Обновляем только предоставленные поля + update_data = variant.dict(exclude_unset=True) + + # Если SKU изменяется, проверяем его уникальность + if "sku" in update_data and update_data["sku"] != db_variant.sku: + if db.query(ProductVariant).filter(ProductVariant.sku == update_data["sku"]).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Вариант с таким SKU уже существует" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_variant, key, value) + + try: + db.commit() + db.refresh(db_variant) + return db_variant + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении варианта продукта" + ) + + +def delete_product_variant(db: Session, variant_id: int) -> bool: + db_variant = get_product_variant(db, variant_id) + if not db_variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Вариант продукта не найден" + ) + + try: + db.delete(db_variant) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении варианта продукта" + ) + + +# Функции для работы с изображениями продуктов +def get_product_image(db: Session, image_id: int) -> Optional[ProductImage]: + return db.query(ProductImage).filter(ProductImage.id == image_id).first() + + +def get_product_images(db: Session, product_id: int) -> List[ProductImage]: + return db.query(ProductImage).filter(ProductImage.product_id == product_id).all() + + +def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage: + # Проверяем, что продукт существует + if not get_product(db, image.product_id): + raise HTTPException( + 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, + image_url=image.image_url, + alt_text=image.alt_text, + is_primary=image.is_primary + ) + + try: + db.add(db_image) + db.commit() + db.refresh(db_image) + return db_image + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании изображения продукта" + ) + + +def update_product_image(db: Session, image_id: int, is_primary: bool) -> ProductImage: + db_image = get_product_image(db, image_id) + if not db_image: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Изображение продукта не найдено" + ) + + # Если изображение отмечается как основное, сбрасываем флаг у других изображений + if is_primary and not db_image.is_primary: + db.query(ProductImage).filter( + ProductImage.product_id == db_image.product_id, + ProductImage.is_primary == True + ).update({"is_primary": False}) + + # Обновляем флаг + db_image.is_primary = is_primary + + try: + db.commit() + db.refresh(db_image) + return db_image + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении изображения продукта" + ) + + +def delete_product_image(db: Session, image_id: int) -> bool: + db_image = get_product_image(db, image_id) + if not db_image: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Изображение продукта не найдено" + ) + + try: + db.delete(db_image) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении изображения продукта" + ) \ No newline at end of file diff --git a/backend/app/repositories/content_repo.py b/backend/app/repositories/content_repo.py new file mode 100644 index 0000000..7465731 --- /dev/null +++ b/backend/app/repositories/content_repo.py @@ -0,0 +1,285 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional, Dict, Any +import re +from datetime import datetime, timedelta + +from app.models.content_models import Page, AnalyticsLog +from app.schemas.content_schemas import PageCreate, PageUpdate, AnalyticsLogCreate + + +# Вспомогательная функция для генерации slug +def generate_slug(title: str) -> str: + # Преобразуем в нижний регистр + slug = title.lower() + # Заменяем пробелы на дефисы + slug = re.sub(r'\s+', '-', slug) + # Удаляем все символы, кроме букв, цифр и дефисов + slug = re.sub(r'[^a-z0-9-]', '', slug) + # Удаляем повторяющиеся дефисы + slug = re.sub(r'-+', '-', slug) + # Удаляем дефисы в начале и конце + slug = slug.strip('-') + + return slug + + +# Функции для работы со страницами +def get_page(db: Session, page_id: int) -> Optional[Page]: + return db.query(Page).filter(Page.id == page_id).first() + + +def get_page_by_slug(db: Session, slug: str) -> Optional[Page]: + return db.query(Page).filter(Page.slug == slug).first() + + +def get_pages( + db: Session, + skip: int = 0, + limit: int = 100, + published_only: bool = True +) -> List[Page]: + query = db.query(Page) + + if published_only: + query = query.filter(Page.is_published == True) + + return query.order_by(Page.title).offset(skip).limit(limit).all() + + +def create_page(db: Session, page: PageCreate) -> Page: + # Если slug не предоставлен, генерируем его из заголовка + if not page.slug: + page.slug = generate_slug(page.title) + + # Проверяем, что страница с таким slug не существует + if get_page_by_slug(db, page.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Страница с таким slug уже существует" + ) + + # Создаем новую страницу + db_page = Page( + title=page.title, + slug=page.slug, + content=page.content, + meta_title=page.meta_title, + meta_description=page.meta_description, + is_published=page.is_published + ) + + try: + db.add(db_page) + db.commit() + db.refresh(db_page) + return db_page + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании страницы" + ) + + +def update_page(db: Session, page_id: int, page: PageUpdate) -> Page: + db_page = get_page(db, page_id) + if not db_page: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Страница не найдена" + ) + + # Обновляем только предоставленные поля + update_data = page.dict(exclude_unset=True) + + # Если slug изменяется, проверяем его уникальность + if "slug" in update_data and update_data["slug"] != db_page.slug: + if get_page_by_slug(db, update_data["slug"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Страница с таким slug уже существует" + ) + + # Если заголовок изменяется и slug не предоставлен, генерируем новый slug + if "title" in update_data and "slug" not in update_data: + update_data["slug"] = generate_slug(update_data["title"]) + # Проверяем уникальность сгенерированного slug + if get_page_by_slug(db, update_data["slug"]) and get_page_by_slug(db, update_data["slug"]).id != page_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Страница с таким slug уже существует" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_page, key, value) + + try: + db.commit() + db.refresh(db_page) + return db_page + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении страницы" + ) + + +def delete_page(db: Session, page_id: int) -> bool: + db_page = get_page(db, page_id) + if not db_page: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Страница не найдена" + ) + + try: + db.delete(db_page) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении страницы" + ) + + +# Функции для работы с аналитикой +def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog: + # Создаем новую запись аналитики + db_log = AnalyticsLog( + user_id=log.user_id, + event_type=log.event_type, + page_url=log.page_url, + product_id=log.product_id, + category_id=log.category_id, + ip_address=log.ip_address, + user_agent=log.user_agent, + referrer=log.referrer, + additional_data=log.additional_data + ) + + try: + db.add(db_log) + db.commit() + db.refresh(db_log) + return db_log + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при логировании события" + ) + + +def get_analytics_logs( + db: Session, + skip: int = 0, + limit: int = 100, + event_type: Optional[str] = None, + user_id: Optional[int] = None, + product_id: Optional[int] = None, + category_id: Optional[int] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None +) -> List[AnalyticsLog]: + query = db.query(AnalyticsLog) + + # Применяем фильтры + if event_type: + query = query.filter(AnalyticsLog.event_type == event_type) + + if user_id: + query = query.filter(AnalyticsLog.user_id == user_id) + + if product_id: + query = query.filter(AnalyticsLog.product_id == product_id) + + if category_id: + query = query.filter(AnalyticsLog.category_id == category_id) + + if start_date: + query = query.filter(AnalyticsLog.created_at >= start_date) + + if end_date: + query = query.filter(AnalyticsLog.created_at <= end_date) + + return query.order_by(AnalyticsLog.created_at.desc()).offset(skip).limit(limit).all() + + +def get_analytics_report( + db: Session, + period: str = "day", + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None +) -> Dict[str, Any]: + # Устанавливаем период по умолчанию, если не указан + if not start_date: + if period == "day": + start_date = datetime.utcnow() - timedelta(days=1) + elif period == "week": + start_date = datetime.utcnow() - timedelta(weeks=1) + elif period == "month": + start_date = datetime.utcnow() - timedelta(days=30) + elif period == "year": + start_date = datetime.utcnow() - timedelta(days=365) + else: + start_date = datetime.utcnow() - timedelta(days=30) # По умолчанию 30 дней + + if not end_date: + end_date = datetime.utcnow() + + # Получаем все события за указанный период + logs = db.query(AnalyticsLog).filter( + AnalyticsLog.created_at >= start_date, + AnalyticsLog.created_at <= end_date + ).all() + + # Подсчитываем статистику + total_visits = len(logs) + unique_visitors = len(set([log.ip_address for log in logs if log.ip_address])) + + # Подсчитываем просмотры страниц + page_views = {} + for log in logs: + if log.event_type == "page_view" and log.page_url: + page_views[log.page_url] = page_views.get(log.page_url, 0) + 1 + + # Подсчитываем просмотры продуктов + product_views = {} + for log in logs: + if log.event_type == "product_view" and log.product_id: + product_id = str(log.product_id) + product_views[product_id] = product_views.get(product_id, 0) + 1 + + # Подсчитываем добавления в корзину + cart_additions = sum(1 for log in logs if log.event_type == "add_to_cart") + + # Подсчитываем заказы и выручку + orders_count = sum(1 for log in logs if log.event_type == "order_created") + + # Для расчета выручки и среднего чека нам нужны данные о заказах + # В данном примере мы просто используем заглушки + revenue = 0 + average_order_value = 0 + + # Формируем отчет + report = { + "period": period, + "start_date": start_date, + "end_date": end_date, + "total_visits": total_visits, + "unique_visitors": unique_visitors, + "page_views": page_views, + "product_views": product_views, + "cart_additions": cart_additions, + "orders_count": orders_count, + "revenue": revenue, + "average_order_value": average_order_value + } + + return report \ No newline at end of file diff --git a/backend/app/repositories/order_repo.py b/backend/app/repositories/order_repo.py new file mode 100644 index 0000000..1253c6d --- /dev/null +++ b/backend/app/repositories/order_repo.py @@ -0,0 +1,533 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional, Dict, Any +from datetime import datetime + +from app.models.order_models import CartItem, Order, OrderItem, OrderStatus, PaymentMethod +from app.models.catalog_models import Product, ProductImage, ProductVariant +from app.models.user_models import User, UserAddress +from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate + + +# Функции для работы с корзиной +def get_cart_item(db: Session, cart_item_id: int) -> Optional[CartItem]: + return db.query(CartItem).filter(CartItem.id == cart_item_id).first() + + +def get_user_cart(db: Session, user_id: int) -> List[CartItem]: + return db.query(CartItem).filter(CartItem.user_id == user_id).all() + + +def get_cart_item_by_variant(db: Session, user_id: int, variant_id: int) -> Optional[CartItem]: + return db.query(CartItem).filter( + CartItem.user_id == user_id, + CartItem.variant_id == variant_id + ).first() + + +def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> CartItem: + # Проверяем, что вариант существует + variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first() + if not variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Вариант продукта не найден" + ) + + # Проверяем, что продукт активен + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product or not product.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Продукт не активен или не найден" + ) + + # Проверяем, есть ли уже такой товар в корзине + existing_item = get_cart_item_by_variant(db, user_id, cart_item.variant_id) + if existing_item: + # Обновляем количество + existing_item.quantity += cart_item.quantity + try: + db.commit() + db.refresh(existing_item) + return existing_item + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении элемента корзины" + ) + + # Создаем новый элемент корзины + db_cart_item = CartItem( + user_id=user_id, + variant_id=cart_item.variant_id, + quantity=cart_item.quantity + ) + + try: + db.add(db_cart_item) + db.commit() + db.refresh(db_cart_item) + return db_cart_item + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при добавлении товара в корзину" + ) + + +def update_cart_item(db: Session, cart_item_id: int, cart_item: CartItemUpdate, user_id: int) -> CartItem: + db_cart_item = db.query(CartItem).filter( + CartItem.id == cart_item_id, + CartItem.user_id == user_id + ).first() + + if not db_cart_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Элемент корзины не найден или не принадлежит пользователю" + ) + + # Обновляем только предоставленные поля + update_data = cart_item.dict(exclude_unset=True) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_cart_item, key, value) + + try: + db.commit() + db.refresh(db_cart_item) + return db_cart_item + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении элемента корзины" + ) + + +def delete_cart_item(db: Session, cart_item_id: int, user_id: int) -> bool: + db_cart_item = db.query(CartItem).filter( + CartItem.id == cart_item_id, + CartItem.user_id == user_id + ).first() + + if not db_cart_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Элемент корзины не найден или не принадлежит пользователю" + ) + + try: + db.delete(db_cart_item) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении элемента корзины" + ) + + +def clear_cart(db: Session, user_id: int) -> bool: + try: + db.query(CartItem).filter(CartItem.user_id == user_id).delete() + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при очистке корзины" + ) + + +# Функции для работы с заказами +def get_order(db: Session, order_id: int) -> Optional[Order]: + return db.query(Order).filter(Order.id == order_id).first() + + +def get_user_orders(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[Order]: + return db.query(Order).filter(Order.user_id == user_id).order_by(Order.created_at.desc()).offset(skip).limit(limit).all() + + +def get_all_orders( + db: Session, + skip: int = 0, + limit: int = 100, + status: Optional[OrderStatus] = None +) -> List[Order]: + query = db.query(Order) + + if status: + query = query.filter(Order.status == status) + + return query.order_by(Order.created_at.desc()).offset(skip).limit(limit).all() + + +def create_order(db: Session, order: OrderCreate, user_id: int) -> Order: + # Проверяем, что адрес доставки существует и принадлежит пользователю, если указан + if order.shipping_address_id: + address = db.query(UserAddress).filter( + UserAddress.id == order.shipping_address_id, + UserAddress.user_id == user_id + ).first() + if not address: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Адрес доставки не найден или не принадлежит пользователю" + ) + + # Получаем элементы заказа + order_items = [] + total_amount = 0 + + # Если указаны элементы корзины, используем их + if order.cart_items: + cart_items = db.query(CartItem).filter( + CartItem.id.in_(order.cart_items), + CartItem.user_id == user_id + ).all() + + if len(cart_items) != len(order.cart_items): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Некоторые элементы корзины не найдены или не принадлежат пользователю" + ) + + for cart_item in cart_items: + # Получаем вариант и продукт + variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first() + if not variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Вариант продукта с ID {cart_item.variant_id} не найден" + ) + + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт для варианта с ID {cart_item.variant_id} не найден" + ) + + # Рассчитываем цену + price = product.discount_price if product.discount_price else product.price + price += variant.price_adjustment + + # Создаем элемент заказа + order_item = OrderItem( + variant_id=cart_item.variant_id, + quantity=cart_item.quantity, + price=price + ) + order_items.append(order_item) + + # Обновляем общую сумму + total_amount += price * cart_item.quantity + + # Если указаны прямые элементы заказа, используем их + elif order.items: + for item in order.items: + # Проверяем, что вариант существует + variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first() + if not variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Вариант продукта с ID {item.variant_id} не найден" + ) + + # Создаем элемент заказа + order_item = OrderItem( + variant_id=item.variant_id, + quantity=item.quantity, + price=item.price + ) + order_items.append(order_item) + + # Обновляем общую сумму + total_amount += item.price * item.quantity + + else: + # Если не указаны ни элементы корзины, ни прямые элементы заказа, используем всю корзину пользователя + cart_items = get_user_cart(db, user_id) + + if not cart_items: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Корзина пуста" + ) + + for cart_item in cart_items: + # Получаем вариант и продукт + variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first() + if not variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Вариант продукта с ID {cart_item.variant_id} не найден" + ) + + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт для варианта с ID {cart_item.variant_id} не найден" + ) + + # Рассчитываем цену + price = product.discount_price if product.discount_price else product.price + price += variant.price_adjustment + + # Создаем элемент заказа + order_item = OrderItem( + variant_id=cart_item.variant_id, + quantity=cart_item.quantity, + price=price + ) + order_items.append(order_item) + + # Обновляем общую сумму + total_amount += price * cart_item.quantity + + # Создаем заказ + db_order = Order( + user_id=user_id, + status=OrderStatus.PENDING, + total_amount=total_amount, + shipping_address_id=order.shipping_address_id, + payment_method=order.payment_method, + notes=order.notes + ) + + try: + # Добавляем заказ + db.add(db_order) + db.flush() # Получаем ID заказа, не фиксируя транзакцию + + # Добавляем элементы заказа + for item in order_items: + item.order_id = db_order.id + db.add(item) + + # Очищаем корзину, если заказ создан из корзины + if not order.items: + db.query(CartItem).filter(CartItem.user_id == user_id).delete() + + db.commit() + db.refresh(db_order) + return db_order + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании заказа" + ) + + +def update_order(db: Session, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Order: + db_order = get_order(db, order_id) + if not db_order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Заказ не найден" + ) + + # Обычные пользователи могут только отменить заказ + if not is_admin and order.status and order.status != OrderStatus.CANCELLED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для изменения статуса заказа" + ) + + # Нельзя изменить статус заказа с CANCELLED или REFUNDED + if db_order.status in [OrderStatus.CANCELLED, OrderStatus.REFUNDED] and order.status: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Нельзя изменить статус заказа, который уже {db_order.status}" + ) + + # Обновляем только предоставленные поля + update_data = order.dict(exclude_unset=True) + + # Проверяем, что адрес доставки существует и принадлежит пользователю, если указан + if "shipping_address_id" in update_data and update_data["shipping_address_id"]: + address = db.query(UserAddress).filter( + UserAddress.id == update_data["shipping_address_id"], + UserAddress.user_id == db_order.user_id + ).first() + if not address: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Адрес доставки не найден или не принадлежит пользователю" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_order, key, value) + + try: + db.commit() + db.refresh(db_order) + return db_order + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении заказа" + ) + + +def delete_order(db: Session, order_id: int, is_admin: bool = False) -> bool: + db_order = get_order(db, order_id) + if not db_order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Заказ не найден" + ) + + # Только администраторы могут удалять заказы + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для удаления заказа" + ) + + try: + db.delete(db_order) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении заказа" + ) + + +# Функции для получения детальной информации +def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, Any]]: + cart_items = get_user_cart(db, user_id) + result = [] + + for item in cart_items: + # Получаем информацию о варианте и продукте + variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first() + if not variant: + continue + + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + continue + + # Получаем основное изображение продукта + image = db.query(ProductImage).filter( + ProductImage.product_id == product.id, + ProductImage.is_primary == True + ).first() + + # Если нет основного изображения, берем первое доступное + if not image: + image = db.query(ProductImage).filter( + ProductImage.product_id == product.id + ).first() + + # Рассчитываем цену + price = product.discount_price if product.discount_price else product.price + price += variant.price_adjustment + + # Формируем результат + result.append({ + "id": item.id, + "user_id": item.user_id, + "variant_id": item.variant_id, + "quantity": item.quantity, + "created_at": item.created_at, + "updated_at": item.updated_at, + "product_id": product.id, + "product_name": product.name, + "product_price": price, + "product_image": image.image_url if image else None, + "variant_name": variant.name, + "variant_price_adjustment": variant.price_adjustment, + "total_price": price * item.quantity + }) + + return result + + +def get_order_with_details(db: Session, order_id: int) -> Dict[str, Any]: + order = get_order(db, order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Заказ не найден" + ) + + # Получаем пользователя + user = db.query(User).filter(User.id == order.user_id).first() + + # Получаем адрес доставки + shipping_address = None + if order.shipping_address_id: + address = db.query(UserAddress).filter(UserAddress.id == order.shipping_address_id).first() + if address: + shipping_address = { + "id": address.id, + "address_line1": address.address_line1, + "address_line2": address.address_line2, + "city": address.city, + "state": address.state, + "postal_code": address.postal_code, + "country": address.country + } + + # Получаем элементы заказа с деталями + items = [] + for item in order.items: + # Получаем информацию о варианте и продукте + variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first() + if not variant: + continue + + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + continue + + # Формируем элемент заказа + items.append({ + "id": item.id, + "order_id": item.order_id, + "variant_id": item.variant_id, + "quantity": item.quantity, + "price": item.price, + "created_at": item.created_at, + "product_id": product.id, + "product_name": product.name, + "variant_name": variant.name, + "total_price": item.price * item.quantity + }) + + # Формируем результат + result = { + "id": order.id, + "user_id": order.user_id, + "status": order.status, + "total_amount": order.total_amount, + "shipping_address_id": order.shipping_address_id, + "payment_method": order.payment_method, + "payment_details": order.payment_details, + "tracking_number": order.tracking_number, + "notes": order.notes, + "created_at": order.created_at, + "updated_at": order.updated_at, + "user_email": user.email if user else None, + "shipping_address": shipping_address, + "items": items + } + + return result \ No newline at end of file diff --git a/backend/app/repositories/review_repo.py b/backend/app/repositories/review_repo.py new file mode 100644 index 0000000..51c895d --- /dev/null +++ b/backend/app/repositories/review_repo.py @@ -0,0 +1,264 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional, Dict, Any + +from app.models.review_models import Review +from app.models.user_models import User +from app.models.catalog_models import Product +from app.schemas.review_schemas import ReviewCreate, ReviewUpdate + + +def get_review(db: Session, review_id: int) -> Optional[Review]: + return db.query(Review).filter(Review.id == review_id).first() + + +def get_product_reviews( + db: Session, + product_id: int, + skip: int = 0, + limit: int = 100, + approved_only: bool = True +) -> List[Review]: + query = db.query(Review).filter(Review.product_id == product_id) + + if approved_only: + query = query.filter(Review.is_approved == True) + + return query.order_by(Review.created_at.desc()).offset(skip).limit(limit).all() + + +def get_user_reviews(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[Review]: + return db.query(Review).filter(Review.user_id == user_id).order_by(Review.created_at.desc()).offset(skip).limit(limit).all() + + +def get_all_reviews( + db: Session, + skip: int = 0, + limit: int = 100, + approved_only: bool = False +) -> List[Review]: + query = db.query(Review) + + if approved_only: + query = query.filter(Review.is_approved == True) + + return query.order_by(Review.created_at.desc()).offset(skip).limit(limit).all() + + +def create_review(db: Session, review: ReviewCreate, user_id: int) -> Review: + # Проверяем, что продукт существует + product = db.query(Product).filter(Product.id == review.product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Проверяем, не оставлял ли пользователь уже отзыв на этот продукт + existing_review = db.query(Review).filter( + Review.user_id == user_id, + Review.product_id == review.product_id + ).first() + + if existing_review: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Вы уже оставили отзыв на этот продукт" + ) + + # Проверяем, покупал ли пользователь этот продукт (для verified_purchase) + # Это можно реализовать, проверив наличие завершенных заказов с этим продуктом + # Для простоты пока устанавливаем is_verified_purchase = False + is_verified_purchase = False + + # Создаем отзыв + db_review = Review( + user_id=user_id, + product_id=review.product_id, + rating=review.rating, + title=review.title, + comment=review.comment, + is_verified_purchase=is_verified_purchase, + is_approved=False # По умолчанию отзыв требует одобрения + ) + + try: + db.add(db_review) + db.commit() + db.refresh(db_review) + return db_review + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании отзыва" + ) + + +def update_review(db: Session, review_id: int, review: ReviewUpdate, user_id: int, is_admin: bool = False) -> Review: + db_review = get_review(db, review_id) + if not db_review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Отзыв не найден" + ) + + # Проверяем права на редактирование отзыва + if not is_admin and db_review.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для редактирования этого отзыва" + ) + + # Обновляем только предоставленные поля + update_data = review.dict(exclude_unset=True) + + # Обычные пользователи не могут менять статус одобрения + if not is_admin and "is_approved" in update_data: + del update_data["is_approved"] + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_review, key, value) + + try: + db.commit() + db.refresh(db_review) + return db_review + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении отзыва" + ) + + +def delete_review(db: Session, review_id: int, user_id: int, is_admin: bool = False) -> bool: + db_review = get_review(db, review_id) + if not db_review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Отзыв не найден" + ) + + # Проверяем права на удаление отзыва + if not is_admin and db_review.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для удаления этого отзыва" + ) + + try: + db.delete(db_review) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении отзыва" + ) + + +def approve_review(db: Session, review_id: int) -> Review: + db_review = get_review(db, review_id) + if not db_review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Отзыв не найден" + ) + + db_review.is_approved = True + + try: + db.commit() + db.refresh(db_review) + return db_review + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при одобрении отзыва" + ) + + +def get_review_with_user(db: Session, review_id: int) -> Dict[str, Any]: + review = get_review(db, review_id) + if not review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Отзыв не найден" + ) + + # Получаем пользователя + user = db.query(User).filter(User.id == review.user_id).first() + + return { + "id": review.id, + "user_id": review.user_id, + "product_id": review.product_id, + "rating": review.rating, + "title": review.title, + "comment": review.comment, + "is_verified_purchase": review.is_verified_purchase, + "is_approved": review.is_approved, + "created_at": review.created_at, + "updated_at": review.updated_at, + "user_username": user.username if user else "Неизвестный пользователь" + } + + +def get_product_rating(db: Session, product_id: int) -> Dict[str, Any]: + # Проверяем, что продукт существует + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Получаем все одобренные отзывы для продукта + reviews = db.query(Review).filter( + Review.product_id == product_id, + Review.is_approved == True + ).all() + + # Рассчитываем средний рейтинг и количество отзывов + total_reviews = len(reviews) + if total_reviews == 0: + return { + "product_id": product_id, + "average_rating": 0, + "total_reviews": 0, + "rating_distribution": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0 + } + } + + # Рассчитываем распределение рейтингов + rating_distribution = { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0 + } + + total_rating = 0 + for review in reviews: + total_rating += review.rating + rating_distribution[str(review.rating)] += 1 + + average_rating = total_rating / total_reviews + + return { + "product_id": product_id, + "average_rating": round(average_rating, 1), + "total_reviews": total_reviews, + "rating_distribution": rating_distribution + } \ No newline at end of file diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py new file mode 100644 index 0000000..df14ce7 --- /dev/null +++ b/backend/app/repositories/user_repo.py @@ -0,0 +1,244 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional + +from app.models.user_models import User, UserAddress +from app.schemas.user_schemas import UserCreate, UserUpdate, AddressCreate, AddressUpdate +from app.core import get_password_hash, verify_password + + +# Функции для работы с пользователями +def get_user(db: Session, user_id: int) -> Optional[User]: + return db.query(User).filter(User.id == user_id).first() + + +def get_user_by_email(db: Session, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + +def get_user_by_username(db: Session, username: str) -> Optional[User]: + return db.query(User).filter(User.username == username).first() + + +def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]: + return db.query(User).offset(skip).limit(limit).all() + + +def create_user(db: Session, user: UserCreate) -> User: + # Проверяем, что пользователь с таким email или username не существует + if get_user_by_email(db, user.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь с таким email уже существует" + ) + + if get_user_by_username(db, user.username): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь с таким username уже существует" + ) + + # Создаем нового пользователя + hashed_password = get_password_hash(user.password) + db_user = User( + email=user.email, + username=user.username, + hashed_password=hashed_password, + is_active=user.is_active, + is_admin=user.is_admin + ) + + try: + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании пользователя" + ) + + +def update_user(db: Session, user_id: int, user: UserUpdate) -> User: + db_user = get_user(db, user_id) + if not db_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + # Обновляем только предоставленные поля + update_data = user.dict(exclude_unset=True) + + # Если предоставлен новый пароль, хешируем его + if "password" in update_data and update_data["password"]: + update_data["hashed_password"] = get_password_hash(update_data.pop("password")) + + # Удаляем поле password_confirm, если оно есть + update_data.pop("password_confirm", None) + + # Проверяем уникальность email и username, если они изменяются + if "email" in update_data and update_data["email"] != db_user.email: + if get_user_by_email(db, update_data["email"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь с таким email уже существует" + ) + + if "username" in update_data and update_data["username"] != db_user.username: + if get_user_by_username(db, update_data["username"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь с таким username уже существует" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_user, key, value) + + try: + db.commit() + db.refresh(db_user) + return db_user + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении пользователя" + ) + + +def delete_user(db: Session, user_id: int) -> bool: + db_user = get_user(db, user_id) + if not db_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + try: + db.delete(db_user) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении пользователя" + ) + + +def authenticate_user(db: Session, username: str, password: str) -> Optional[User]: + user = get_user_by_username(db, username) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + +# Функции для работы с адресами пользователей +def get_address(db: Session, address_id: int) -> Optional[UserAddress]: + return db.query(UserAddress).filter(UserAddress.id == address_id).first() + + +def get_user_addresses(db: Session, user_id: int) -> List[UserAddress]: + return db.query(UserAddress).filter(UserAddress.user_id == user_id).all() + + +def create_address(db: Session, address: AddressCreate, user_id: int) -> UserAddress: + # Если новый адрес помечен как дефолтный, сбрасываем дефолтный статус у других адресов пользователя + if address.is_default: + db.query(UserAddress).filter( + UserAddress.user_id == user_id, + UserAddress.is_default == True + ).update({"is_default": False}) + + db_address = UserAddress( + user_id=user_id, + address_line1=address.address_line1, + address_line2=address.address_line2, + city=address.city, + state=address.state, + postal_code=address.postal_code, + country=address.country, + is_default=address.is_default + ) + + try: + db.add(db_address) + db.commit() + db.refresh(db_address) + return db_address + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании адреса" + ) + + +def update_address(db: Session, address_id: int, address: AddressUpdate, user_id: int) -> UserAddress: + db_address = db.query(UserAddress).filter( + UserAddress.id == address_id, + UserAddress.user_id == user_id + ).first() + + if not db_address: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Адрес не найден или не принадлежит пользователю" + ) + + # Обновляем только предоставленные поля + update_data = address.dict(exclude_unset=True) + + # Если адрес становится дефолтным, сбрасываем дефолтный статус у других адресов пользователя + if "is_default" in update_data and update_data["is_default"]: + db.query(UserAddress).filter( + UserAddress.user_id == user_id, + UserAddress.id != address_id, + UserAddress.is_default == True + ).update({"is_default": False}) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_address, key, value) + + try: + db.commit() + db.refresh(db_address) + return db_address + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении адреса" + ) + + +def delete_address(db: Session, address_id: int, user_id: int) -> bool: + db_address = db.query(UserAddress).filter( + UserAddress.id == address_id, + UserAddress.user_id == user_id + ).first() + + if not db_address: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Адрес не найден или не принадлежит пользователю" + ) + + try: + db.delete(db_address) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении адреса" + ) \ No newline at end of file diff --git a/backend/app/routers.py b/backend/app/routers.py new file mode 100644 index 0000000..c6203b2 --- /dev/null +++ b/backend/app/routers.py @@ -0,0 +1,354 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query, Body, Request +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from typing import List, Optional, Dict, Any +from datetime import datetime + +from app.core import get_db, get_current_user, get_current_active_user, get_current_admin_user +from app.services import ( + register_user, login_user, get_user_profile, update_user_profile, + add_user_address, update_user_address, delete_user_address, + create_category, update_category, delete_category, get_category_tree, + create_product, update_product, delete_product, get_product_details, + add_product_variant, update_product_variant, delete_product_variant, + upload_product_image, update_product_image, delete_product_image, + add_to_cart, update_cart_item, remove_from_cart, clear_cart, get_cart, + create_order, get_order, update_order, cancel_order, + create_review, update_review, delete_review, approve_review, get_product_reviews, + create_page, update_page, delete_page, get_page_by_slug, + log_event, get_analytics_report +) +from app.schemas.user_schemas import ( + UserCreate, UserUpdate, User, AddressCreate, AddressUpdate, Address, Token +) +from app.schemas.catalog_schemas import ( + CategoryCreate, CategoryUpdate, Category, + ProductCreate, ProductUpdate, Product, + ProductVariantCreate, ProductVariantUpdate, ProductVariant, + ProductImageCreate, ProductImageUpdate, ProductImage +) +from app.schemas.order_schemas import ( + CartItemCreate, CartItemUpdate, CartItem, CartItemWithProduct, + OrderCreate, OrderUpdate, Order, OrderWithDetails +) +from app.schemas.review_schemas import ( + ReviewCreate, ReviewUpdate, Review, ReviewWithUser +) +from app.schemas.content_schemas import ( + PageCreate, PageUpdate, Page, AnalyticsLogCreate, AnalyticsLog, AnalyticsReport +) +from app.models.user_models import User as UserModel + + +# Создаем основной роутер +router = APIRouter() + + +# Роутеры для аутентификации и пользователей +auth_router = APIRouter(prefix="/auth", tags=["Аутентификация"]) + +@auth_router.post("/register", response_model=Dict[str, Any]) +async def register(user: UserCreate, db: Session = Depends(get_db)): + return register_user(db, user) + + +@auth_router.post("/token", response_model=Token) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + result = login_user(db, form_data.username, form_data.password) + return result + + +user_router = APIRouter(prefix="/users", tags=["Пользователи"]) + +@user_router.get("/me", response_model=Dict[str, Any]) +async def read_users_me(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return get_user_profile(db, current_user.id) + + +@user_router.put("/me", response_model=Dict[str, Any]) +async def update_user_me(user_data: UserUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_user_profile(db, current_user.id, user_data) + + +@user_router.post("/me/addresses", response_model=Dict[str, Any]) +async def create_user_address(address: AddressCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return add_user_address(db, current_user.id, address) + + +@user_router.put("/me/addresses/{address_id}", response_model=Dict[str, Any]) +async def update_user_address_endpoint(address_id: int, address: AddressUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_user_address(db, current_user.id, address_id, address) + + +@user_router.delete("/me/addresses/{address_id}", response_model=Dict[str, Any]) +async def delete_user_address_endpoint(address_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return delete_user_address(db, current_user.id, address_id) + + +@user_router.get("/{user_id}", response_model=Dict[str, Any]) +async def read_user(user_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return get_user_profile(db, user_id) + + +# Роутеры для каталога +catalog_router = APIRouter(prefix="/catalog", tags=["Каталог"]) + +@catalog_router.post("/categories", response_model=Dict[str, Any]) +async def create_category_endpoint(category: CategoryCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return create_category(db, category) + + +@catalog_router.put("/categories/{category_id}", response_model=Dict[str, Any]) +async def update_category_endpoint(category_id: int, category: CategoryUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return update_category(db, category_id, category) + + +@catalog_router.delete("/categories/{category_id}", response_model=Dict[str, Any]) +async def delete_category_endpoint(category_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return 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 get_category_tree(db) + + +@catalog_router.post("/products", response_model=Dict[str, Any]) +async def create_product_endpoint(product: ProductCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return 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 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 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 get_product_details(db, product_id) + + +@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), db: Session = Depends(get_db)): + variant.product_id = product_id + return add_product_variant(db, variant) + + +@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), db: Session = Depends(get_db)): + return update_product_variant(db, variant_id, variant) + + +@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), db: Session = Depends(get_db)): + return delete_product_variant(db, variant_id) + + +@catalog_router.post("/products/{product_id}/images", response_model=Dict[str, Any]) +async def upload_product_image_endpoint( + product_id: int, + file: UploadFile = File(...), + is_primary: bool = Form(False), + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + return upload_product_image(db, product_id, file, is_primary) + + +@catalog_router.put("/images/{image_id}", response_model=Dict[str, Any]) +async def update_product_image_endpoint(image_id: int, image: ProductImageUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return update_product_image(db, image_id, image) + + +@catalog_router.delete("/images/{image_id}", response_model=Dict[str, Any]) +async def delete_product_image_endpoint(image_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return delete_product_image(db, image_id) + + +@catalog_router.get("/products", response_model=List[Product]) +async def get_products( + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None, + search: Optional[str] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + is_active: Optional[bool] = True, + db: Session = Depends(get_db) +): + from app.repositories.catalog_repo import get_products + return get_products(db, skip, limit, category_id, search, min_price, max_price, is_active) + + +# Роутеры для корзины и заказов +cart_router = APIRouter(prefix="/cart", tags=["Корзина"]) + +@cart_router.post("/items", response_model=Dict[str, Any]) +async def add_to_cart_endpoint(cart_item: CartItemCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return add_to_cart(db, current_user.id, cart_item) + + +@cart_router.put("/items/{cart_item_id}", response_model=Dict[str, Any]) +async def update_cart_item_endpoint(cart_item_id: int, cart_item: CartItemUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_cart_item(db, current_user.id, cart_item_id, cart_item) + + +@cart_router.delete("/items/{cart_item_id}", response_model=Dict[str, Any]) +async def remove_from_cart_endpoint(cart_item_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return remove_from_cart(db, current_user.id, cart_item_id) + + +@cart_router.delete("/clear", response_model=Dict[str, Any]) +async def clear_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return clear_cart(db, current_user.id) + + +@cart_router.get("/", response_model=Dict[str, Any]) +async def get_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return get_cart(db, current_user.id) + + +order_router = APIRouter(prefix="/orders", tags=["Заказы"]) + +@order_router.post("/", response_model=Dict[str, Any]) +async def create_order_endpoint(order: OrderCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return create_order(db, current_user.id, order) + + +@order_router.get("/{order_id}", response_model=Dict[str, Any]) +async def get_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return get_order(db, current_user.id, order_id, current_user.is_admin) + + +@order_router.put("/{order_id}", response_model=Dict[str, Any]) +async def update_order_endpoint(order_id: int, order: OrderUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_order(db, current_user.id, order_id, order, current_user.is_admin) + + +@order_router.post("/{order_id}/cancel", response_model=Dict[str, Any]) +async def cancel_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return cancel_order(db, current_user.id, order_id) + + +@order_router.get("/", response_model=List[Order]) +async def get_orders( + skip: int = 0, + limit: int = 100, + status: Optional[str] = None, + current_user: UserModel = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + if current_user.is_admin: + from app.repositories.order_repo import get_all_orders + return get_all_orders(db, skip, limit, status) + else: + from app.repositories.order_repo import get_user_orders + return get_user_orders(db, current_user.id, skip, limit) + + +# Роутеры для отзывов +review_router = APIRouter(prefix="/reviews", tags=["Отзывы"]) + +@review_router.post("/", response_model=Dict[str, Any]) +async def create_review_endpoint(review: ReviewCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return create_review(db, current_user.id, review) + + +@review_router.put("/{review_id}", response_model=Dict[str, Any]) +async def update_review_endpoint(review_id: int, review: ReviewUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_review(db, current_user.id, review_id, review, current_user.is_admin) + + +@review_router.delete("/{review_id}", response_model=Dict[str, Any]) +async def delete_review_endpoint(review_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return delete_review(db, current_user.id, review_id, current_user.is_admin) + + +@review_router.post("/{review_id}/approve", response_model=Dict[str, Any]) +async def approve_review_endpoint(review_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return approve_review(db, review_id) + + +@review_router.get("/products/{product_id}", response_model=Dict[str, Any]) +async def get_product_reviews_endpoint(product_id: int, skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): + return get_product_reviews(db, product_id, skip, limit) + + +# Роутеры для информационных страниц +content_router = APIRouter(prefix="/content", tags=["Контент"]) + +@content_router.post("/pages", response_model=Dict[str, Any]) +async def create_page_endpoint(page: PageCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return create_page(db, page) + + +@content_router.put("/pages/{page_id}", response_model=Dict[str, Any]) +async def update_page_endpoint(page_id: int, page: PageUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return update_page(db, page_id, page) + + +@content_router.delete("/pages/{page_id}", response_model=Dict[str, Any]) +async def delete_page_endpoint(page_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return delete_page(db, page_id) + + +@content_router.get("/pages/{slug}", response_model=Dict[str, Any]) +async def get_page_endpoint(slug: str, db: Session = Depends(get_db)): + return get_page_by_slug(db, slug) + + +@content_router.get("/pages", response_model=List[Page]) +async def get_pages( + skip: int = 0, + limit: int = 100, + published_only: bool = True, + current_user: Optional[UserModel] = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Если пользователь не админ, показываем только опубликованные страницы + is_admin = current_user and current_user.is_admin + from app.repositories.content_repo import get_pages + return get_pages(db, skip, limit, published_only=(not is_admin) and published_only) + + +# Роутеры для аналитики +analytics_router = APIRouter(prefix="/analytics", tags=["Аналитика"]) + +@analytics_router.post("/events", response_model=Dict[str, Any]) +async def log_event_endpoint(log: AnalyticsLogCreate, request: Request, db: Session = Depends(get_db)): + # Добавляем IP-адрес и User-Agent, если они не указаны + if not log.ip_address: + log.ip_address = request.client.host + if not log.user_agent: + user_agent = request.headers.get("user-agent") + if user_agent: + log.user_agent = user_agent + + return log_event(db, log) + + +@analytics_router.get("/reports", response_model=Dict[str, Any]) +async def get_analytics_report_endpoint( + period: str = "day", + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + return get_analytics_report(db, period, start_date, end_date) + + +# Включаем все роутеры в основной роутер +router.include_router(auth_router) +router.include_router(user_router) +router.include_router(catalog_router) +router.include_router(cart_router) +router.include_router(order_router) +router.include_router(review_router) +router.include_router(content_router) +router.include_router(analytics_router) \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/__pycache__/__init__.cpython-310.pyc b/backend/app/schemas/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..adb1322 Binary files /dev/null and b/backend/app/schemas/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc new file mode 100644 index 0000000..4fa3ddc Binary files /dev/null and b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/content_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/content_schemas.cpython-310.pyc new file mode 100644 index 0000000..0806d7d Binary files /dev/null and b/backend/app/schemas/__pycache__/content_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc new file mode 100644 index 0000000..7e408c6 Binary files /dev/null and b/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/review_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/review_schemas.cpython-310.pyc new file mode 100644 index 0000000..0bda271 Binary files /dev/null and b/backend/app/schemas/__pycache__/review_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/user_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/user_schemas.cpython-310.pyc new file mode 100644 index 0000000..15b9d0e Binary files /dev/null and b/backend/app/schemas/__pycache__/user_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/catalog_schemas.py b/backend/app/schemas/catalog_schemas.py new file mode 100644 index 0000000..d479937 --- /dev/null +++ b/backend/app/schemas/catalog_schemas.py @@ -0,0 +1,142 @@ +from pydantic import BaseModel, Field, validator +from typing import Optional, List, Union +from datetime import datetime + + +# Схемы для категорий +class CategoryBase(BaseModel): + name: str + slug: Optional[str] = None + description: Optional[str] = None + parent_id: Optional[int] = None + is_active: bool = True + + +class CategoryCreate(CategoryBase): + pass + + +class CategoryUpdate(CategoryBase): + name: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + parent_id: Optional[int] = None + is_active: Optional[bool] = None + + +class Category(CategoryBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Схемы для продуктов +class ProductBase(BaseModel): + name: str + slug: Optional[str] = None + description: Optional[str] = None + price: float + discount_price: Optional[float] = None + stock: int = 0 + is_active: bool = True + category_id: int + + +class ProductCreate(ProductBase): + pass + + +class ProductUpdate(ProductBase): + name: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + price: Optional[float] = None + discount_price: Optional[float] = None + stock: Optional[int] = None + is_active: Optional[bool] = None + category_id: Optional[int] = None + + +class Product(ProductBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Схемы для вариантов продуктов +class ProductVariantBase(BaseModel): + product_id: int + name: str + sku: str + price_adjustment: float = 0.0 + stock: int = 0 + is_active: bool = True + + +class ProductVariantCreate(ProductVariantBase): + pass + + +class ProductVariantUpdate(ProductVariantBase): + product_id: Optional[int] = None + name: Optional[str] = None + sku: Optional[str] = None + price_adjustment: Optional[float] = None + stock: Optional[int] = None + is_active: Optional[bool] = None + + +class ProductVariant(ProductVariantBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Схемы для изображений продуктов +class ProductImageBase(BaseModel): + product_id: int + image_url: str + alt_text: Optional[str] = None + is_primary: bool = False + + +class ProductImageCreate(ProductImageBase): + pass + + +class ProductImageUpdate(BaseModel): + alt_text: Optional[str] = None + is_primary: Optional[bool] = None + + +class ProductImage(ProductImageBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Расширенные схемы для отображения +class CategoryWithSubcategories(Category): + subcategories: List['CategoryWithSubcategories'] = [] + + +class ProductWithDetails(Product): + category: Category + variants: List[ProductVariant] = [] + images: List[ProductImage] = [] + + +# Рекурсивное обновление для CategoryWithChildren +CategoryWithSubcategories.update_forward_refs() \ No newline at end of file diff --git a/backend/app/schemas/content_schemas.py b/backend/app/schemas/content_schemas.py new file mode 100644 index 0000000..493df0c --- /dev/null +++ b/backend/app/schemas/content_schemas.py @@ -0,0 +1,75 @@ +from pydantic import BaseModel +from typing import Optional, Dict, Any +from datetime import datetime + + +# Схемы для информационных страниц +class PageBase(BaseModel): + title: str + slug: str + content: str + meta_title: Optional[str] = None + meta_description: Optional[str] = None + is_published: bool = True + + +class PageCreate(PageBase): + pass + + +class PageUpdate(BaseModel): + title: Optional[str] = None + slug: Optional[str] = None + content: Optional[str] = None + meta_title: Optional[str] = None + meta_description: Optional[str] = None + is_published: Optional[bool] = None + + +class Page(PageBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + + +# Схемы для аналитики +class AnalyticsLogBase(BaseModel): + event_type: str + page_url: Optional[str] = None + product_id: Optional[int] = None + category_id: Optional[int] = None + ip_address: Optional[str] = None + user_agent: Optional[str] = None + referrer: Optional[str] = None + additional_data: Optional[Dict[str, Any]] = None + + +class AnalyticsLogCreate(AnalyticsLogBase): + user_id: Optional[int] = None + + +class AnalyticsLog(AnalyticsLogBase): + id: int + user_id: Optional[int] = None + created_at: datetime + + class Config: + orm_mode = True + + +# Схемы для аналитических отчетов +class AnalyticsReport(BaseModel): + period: str # day, week, month, year + start_date: datetime + end_date: datetime + total_visits: int + unique_visitors: int + page_views: Dict[str, int] + product_views: Dict[str, int] + cart_additions: int + orders_count: int + revenue: float + average_order_value: float \ No newline at end of file diff --git a/backend/app/schemas/order_schemas.py b/backend/app/schemas/order_schemas.py new file mode 100644 index 0000000..beb40da --- /dev/null +++ b/backend/app/schemas/order_schemas.py @@ -0,0 +1,133 @@ +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime + +from app.models.order_models import OrderStatus, PaymentMethod + + +# Схемы для элементов корзины +class CartItemBase(BaseModel): + variant_id: int + quantity: int = 1 + + +class CartItemCreate(CartItemBase): + pass + + +class CartItemUpdate(BaseModel): + quantity: Optional[int] = None + + +class CartItem(CartItemBase): + id: int + user_id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class CartItemWithProduct(CartItem): + product_name: str + product_price: float + product_image: Optional[str] = None + variant_name: str + variant_price_adjustment: float + total_price: float + + +# Схемы для элементов заказа +class OrderItemBase(BaseModel): + variant_id: int + quantity: int = 1 + price: float + + +class OrderItemCreate(OrderItemBase): + pass + + +class OrderItem(OrderItemBase): + id: int + order_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class OrderItemWithProduct(OrderItem): + product_name: str + variant_name: Optional[str] = None + + +# Схемы для заказов +class OrderBase(BaseModel): + shipping_address_id: Optional[int] = None + payment_method: Optional[PaymentMethod] = None + notes: Optional[str] = None + + +class OrderCreate(OrderBase): + cart_items: Optional[List[int]] = None # ID элементов корзины + items: Optional[List[OrderItemCreate]] = None # Прямые элементы заказа + + +class OrderUpdate(BaseModel): + status: Optional[OrderStatus] = None + shipping_address_id: Optional[int] = None + payment_method: Optional[PaymentMethod] = None + payment_details: Optional[str] = None + tracking_number: Optional[str] = None + notes: Optional[str] = None + + +class Order(OrderBase): + id: int + user_id: int + status: OrderStatus + total_amount: float + payment_details: Optional[str] = None + tracking_number: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + items: List[OrderItem] = [] + + class Config: + from_attributes = True + + +# Расширенные схемы для отображения +class CartItemWithDetails(BaseModel): + id: int + user_id: int + variant_id: int + quantity: int + created_at: datetime + updated_at: Optional[datetime] = None + product_id: int + product_name: str + product_price: float + product_image: Optional[str] = None + variant_name: str + variant_price_adjustment: float + total_price: float + + +class OrderWithDetails(BaseModel): + id: int + user_id: int + status: OrderStatus + total_amount: float + shipping_address_id: Optional[int] = None + payment_method: Optional[PaymentMethod] = None + payment_details: Optional[str] = None + tracking_number: Optional[str] = None + notes: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + user_email: Optional[str] = None + shipping_address: Optional[Dict[str, Any]] = None + items: List[Dict[str, Any]] = [] \ No newline at end of file diff --git a/backend/app/schemas/review_schemas.py b/backend/app/schemas/review_schemas.py new file mode 100644 index 0000000..a8a5755 --- /dev/null +++ b/backend/app/schemas/review_schemas.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel, Field, validator +from typing import Optional +from datetime import datetime + + +class ReviewBase(BaseModel): + product_id: int + rating: int = Field(..., ge=1, le=5) + title: Optional[str] = None + comment: Optional[str] = None + + +class ReviewCreate(ReviewBase): + pass + + +class ReviewUpdate(BaseModel): + rating: Optional[int] = Field(None, ge=1, le=5) + title: Optional[str] = None + comment: Optional[str] = None + is_approved: Optional[bool] = None + + +class Review(ReviewBase): + id: int + user_id: int + is_verified_purchase: bool + is_approved: bool + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + + +class ReviewWithUser(Review): + user_username: str \ No newline at end of file diff --git a/backend/app/schemas/user_schemas.py b/backend/app/schemas/user_schemas.py new file mode 100644 index 0000000..c5ce3e5 --- /dev/null +++ b/backend/app/schemas/user_schemas.py @@ -0,0 +1,95 @@ +from pydantic import BaseModel, EmailStr, Field, validator +from typing import Optional, List +from datetime import datetime + + +# Базовые схемы для адреса +class AddressBase(BaseModel): + address_line1: str + address_line2: Optional[str] = None + city: str + state: str + postal_code: str + country: str + is_default: bool = False + + +class AddressCreate(AddressBase): + pass + + +class AddressUpdate(AddressBase): + address_line1: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + postal_code: Optional[str] = None + country: Optional[str] = None + + +class Address(AddressBase): + id: int + user_id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + + +# Базовые схемы для пользователя +class UserBase(BaseModel): + email: EmailStr + username: str + is_active: bool = True + is_admin: bool = False + + +class UserCreate(UserBase): + password: str = Field(..., min_length=8) + password_confirm: str + + @validator('password_confirm') + def passwords_match(cls, v, values, **kwargs): + if 'password' in values and v != values['password']: + raise ValueError('Пароли не совпадают') + return v + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = None + is_active: Optional[bool] = None + is_admin: Optional[bool] = None + password: Optional[str] = Field(None, min_length=8) + password_confirm: Optional[str] = None + + @validator('password_confirm') + def passwords_match(cls, v, values, **kwargs): + if 'password' in values and values['password'] is not None and v != values['password']: + raise ValueError('Пароли не совпадают') + return v + + +class User(UserBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + addresses: List[Address] = [] + + class Config: + orm_mode = True + + +class UserInDB(User): + hashed_password: str + + +# Схемы для аутентификации +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: Optional[str] = None + user_id: Optional[int] = None \ No newline at end of file diff --git a/backend/app/services.py b/backend/app/services.py new file mode 100644 index 0000000..0fe9bd7 --- /dev/null +++ b/backend/app/services.py @@ -0,0 +1,482 @@ +from sqlalchemy.orm import Session +from fastapi import HTTPException, status, UploadFile +from typing import List, Optional, Dict, Any, Union +from datetime import datetime, timedelta +import os +import uuid +import shutil +from pathlib import Path + +from app.config import settings +from app.core import create_access_token, get_password_hash, verify_password +from app.repositories import ( + user_repo, catalog_repo, order_repo, review_repo, content_repo +) +from app.schemas.user_schemas import UserCreate, UserUpdate, AddressCreate, AddressUpdate, Token +from app.schemas.catalog_schemas import ( + CategoryCreate, CategoryUpdate, + ProductCreate, ProductUpdate, + ProductVariantCreate, ProductVariantUpdate, + ProductImageCreate, ProductImageUpdate +) +from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate +from app.schemas.review_schemas import ReviewCreate, ReviewUpdate +from app.schemas.content_schemas import PageCreate, PageUpdate, AnalyticsLogCreate + + +# Сервисы аутентификации и пользователей +def register_user(db: Session, user: UserCreate) -> Dict[str, Any]: + # Создаем пользователя + db_user = user_repo.create_user(db, user) + + # Создаем токен доступа + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": db_user.username}, expires_delta=access_token_expires + ) + + return { + "user": db_user, + "access_token": access_token, + "token_type": "bearer" + } + + +def login_user(db: Session, username: str, password: str) -> Dict[str, Any]: + # Аутентифицируем пользователя + user = user_repo.authenticate_user(db, username, password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверное имя пользователя или пароль", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Проверяем, что пользователь активен + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Неактивный пользователь" + ) + + # Создаем токен доступа + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + + return { + "access_token": access_token, + "token_type": "bearer" + } + + +def get_user_profile(db: Session, user_id: int) -> Dict[str, Any]: + user = user_repo.get_user(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + # Получаем адреса пользователя + addresses = user_repo.get_user_addresses(db, user_id) + + # Получаем заказы пользователя + orders = order_repo.get_user_orders(db, user_id) + + # Получаем отзывы пользователя + reviews = review_repo.get_user_reviews(db, user_id) + + return { + "user": user, + "addresses": addresses, + "orders": orders, + "reviews": reviews + } + + +def update_user_profile(db: Session, user_id: int, user_data: UserUpdate) -> Dict[str, Any]: + updated_user = user_repo.update_user(db, user_id, user_data) + return {"user": updated_user} + + +def add_user_address(db: Session, user_id: int, address: AddressCreate) -> Dict[str, Any]: + new_address = user_repo.create_address(db, address, user_id) + return {"address": new_address} + + +def update_user_address(db: Session, user_id: int, address_id: int, address: AddressUpdate) -> Dict[str, Any]: + updated_address = user_repo.update_address(db, address_id, address, user_id) + return {"address": updated_address} + + +def delete_user_address(db: Session, user_id: int, address_id: int) -> Dict[str, Any]: + success = user_repo.delete_address(db, address_id, user_id) + return {"success": success} + + +# Сервисы каталога +def create_category(db: Session, category: CategoryCreate) -> Dict[str, Any]: + new_category = catalog_repo.create_category(db, category) + return {"category": new_category} + + +def update_category(db: Session, category_id: int, category: CategoryUpdate) -> Dict[str, Any]: + updated_category = catalog_repo.update_category(db, category_id, category) + return {"category": updated_category} + + +def delete_category(db: Session, category_id: int) -> Dict[str, Any]: + success = catalog_repo.delete_category(db, category_id) + return {"success": success} + + +def get_category_tree(db: Session) -> List[Dict[str, Any]]: + # Получаем все категории верхнего уровня + root_categories = catalog_repo.get_categories(db, parent_id=None) + + result = [] + for category in root_categories: + # Рекурсивно получаем подкатегории + category_dict = { + "id": category.id, + "name": category.name, + "slug": category.slug, + "description": category.description, + "is_active": category.is_active, + "subcategories": _get_subcategories(db, category.id) + } + result.append(category_dict) + + return result + + +def _get_subcategories(db: Session, parent_id: int) -> List[Dict[str, Any]]: + subcategories = catalog_repo.get_categories(db, parent_id=parent_id) + + result = [] + for category in subcategories: + category_dict = { + "id": category.id, + "name": category.name, + "slug": category.slug, + "description": category.description, + "is_active": category.is_active, + "subcategories": _get_subcategories(db, category.id) + } + result.append(category_dict) + + return result + + +def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]: + new_product = catalog_repo.create_product(db, product) + return {"product": new_product} + + +def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict[str, Any]: + updated_product = catalog_repo.update_product(db, product_id, product) + return {"product": updated_product} + + +def delete_product(db: Session, product_id: int) -> Dict[str, Any]: + success = catalog_repo.delete_product(db, product_id) + return {"success": success} + + +def get_product_details(db: Session, product_id: int) -> Dict[str, Any]: + product = catalog_repo.get_product(db, product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Получаем варианты продукта + variants = catalog_repo.get_product_variants(db, product_id) + + # Получаем изображения продукта + images = catalog_repo.get_product_images(db, product_id) + + # Получаем рейтинг продукта + rating = review_repo.get_product_rating(db, product_id) + + # Получаем отзывы продукта + reviews = review_repo.get_product_reviews(db, product_id, limit=5) + + return { + "product": product, + "variants": variants, + "images": images, + "rating": rating, + "reviews": reviews + } + + +def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str, Any]: + new_variant = catalog_repo.create_variant(db, variant) + return {"variant": new_variant} + + +def update_product_variant(db: Session, variant_id: int, variant: ProductVariantUpdate) -> Dict[str, Any]: + updated_variant = catalog_repo.update_variant(db, variant_id, variant) + return {"variant": updated_variant} + + +def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]: + success = catalog_repo.delete_variant(db, variant_id) + return {"success": success} + + +def upload_product_image(db: Session, product_id: int, file: UploadFile, is_primary: bool = False) -> Dict[str, Any]: + # Проверяем, что продукт существует + product = catalog_repo.get_product(db, product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Проверяем расширение файла + file_extension = file.filename.split(".")[-1].lower() + if file_extension not in settings.ALLOWED_UPLOAD_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Неподдерживаемый формат файла. Разрешены: {', '.join(settings.ALLOWED_UPLOAD_EXTENSIONS)}" + ) + + # Создаем директорию для загрузок, если она не существует + upload_dir = Path(settings.UPLOAD_DIRECTORY) / "products" / str(product_id) + upload_dir.mkdir(parents=True, exist_ok=True) + + # Генерируем уникальное имя файла + unique_filename = f"{uuid.uuid4()}.{file_extension}" + file_path = upload_dir / unique_filename + + # Сохраняем файл + with file_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + # Создаем запись об изображении в БД + image_data = ProductImageCreate( + product_id=product_id, + image_url=f"/uploads/products/{product_id}/{unique_filename}", + alt_text=file.filename, + is_primary=is_primary + ) + + new_image = catalog_repo.create_image(db, image_data) + + return {"image": new_image} + + +def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) -> Dict[str, Any]: + updated_image = catalog_repo.update_image(db, image_id, image) + return {"image": updated_image} + + +def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]: + # Получаем информацию об изображении перед удалением + image = catalog_repo.get_image(db, image_id) + if not image: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Изображение не найдено" + ) + + # Удаляем запись из БД + success = catalog_repo.delete_image(db, image_id) + + # Удаляем файл с диска + if success: + try: + # Получаем путь к файлу из URL + file_path = Path(settings.UPLOAD_DIRECTORY) / image.image_url.lstrip("/uploads/") + if file_path.exists(): + file_path.unlink() + except Exception: + # Если не удалось удалить файл, просто логируем ошибку + # В реальном приложении здесь должно быть логирование + pass + + return {"success": success} + + +# Сервисы корзины и заказов +def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[str, Any]: + new_cart_item = order_repo.create_cart_item(db, cart_item, user_id) + + # Логируем событие добавления в корзину + log_data = AnalyticsLogCreate( + user_id=user_id, + event_type="add_to_cart", + product_id=cart_item.product_id, + additional_data={"quantity": cart_item.quantity} + ) + content_repo.log_analytics_event(db, log_data) + + return {"cart_item": new_cart_item} + + +def update_cart_item(db: Session, user_id: int, cart_item_id: int, cart_item: CartItemUpdate) -> Dict[str, Any]: + updated_cart_item = order_repo.update_cart_item(db, cart_item_id, cart_item, user_id) + return {"cart_item": updated_cart_item} + + +def remove_from_cart(db: Session, user_id: int, cart_item_id: int) -> Dict[str, Any]: + success = order_repo.delete_cart_item(db, cart_item_id, user_id) + return {"success": success} + + +def clear_cart(db: Session, user_id: int) -> Dict[str, Any]: + success = order_repo.clear_cart(db, user_id) + return {"success": success} + + +def get_cart(db: Session, user_id: int) -> Dict[str, Any]: + cart_items = order_repo.get_cart_with_product_details(db, user_id) + + # Рассчитываем общую сумму корзины + total_amount = sum(item["total_price"] for item in cart_items) + + return { + "items": cart_items, + "total_amount": total_amount, + "items_count": len(cart_items) + } + + +def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any]: + new_order = order_repo.create_order(db, order, user_id) + + # Логируем событие создания заказа + log_data = AnalyticsLogCreate( + user_id=user_id, + event_type="order_created", + additional_data={"order_id": new_order.id, "total_amount": new_order.total_amount} + ) + content_repo.log_analytics_event(db, log_data) + + return {"order": new_order} + + +def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]: + # Получаем заказ с деталями + order_details = order_repo.get_order_with_details(db, order_id) + + # Проверяем права доступа + if not is_admin and order_details["user_id"] != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для просмотра этого заказа" + ) + + return {"order": order_details} + + +def update_order(db: Session, user_id: int, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Dict[str, Any]: + updated_order = order_repo.update_order(db, order_id, order, is_admin) + + # Проверяем права доступа + if not is_admin and updated_order.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для обновления этого заказа" + ) + + return {"order": updated_order} + + +def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]: + # Отменяем заказ (обычный пользователь может только отменить заказ) + order_update = OrderUpdate(status="cancelled") + updated_order = order_repo.update_order(db, order_id, order_update, is_admin=False) + + # Проверяем права доступа + if updated_order.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для отмены этого заказа" + ) + + return {"order": updated_order} + + +# Сервисы отзывов +def create_review(db: Session, user_id: int, review: ReviewCreate) -> Dict[str, Any]: + new_review = review_repo.create_review(db, review, user_id) + return {"review": new_review} + + +def update_review(db: Session, user_id: int, review_id: int, review: ReviewUpdate, is_admin: bool = False) -> Dict[str, Any]: + updated_review = review_repo.update_review(db, review_id, review, user_id, is_admin) + return {"review": updated_review} + + +def delete_review(db: Session, user_id: int, review_id: int, is_admin: bool = False) -> Dict[str, Any]: + success = review_repo.delete_review(db, review_id, user_id, is_admin) + return {"success": success} + + +def approve_review(db: Session, review_id: int) -> Dict[str, Any]: + approved_review = review_repo.approve_review(db, review_id) + return {"review": approved_review} + + +def get_product_reviews(db: Session, product_id: int, skip: int = 0, limit: int = 10) -> Dict[str, Any]: + reviews = review_repo.get_product_reviews(db, product_id, skip, limit) + + # Получаем рейтинг продукта + rating = review_repo.get_product_rating(db, product_id) + + return { + "reviews": reviews, + "rating": rating, + "total": rating["total_reviews"], + "skip": skip, + "limit": limit + } + + +# Сервисы информационных страниц +def create_page(db: Session, page: PageCreate) -> Dict[str, Any]: + new_page = content_repo.create_page(db, page) + return {"page": new_page} + + +def update_page(db: Session, page_id: int, page: PageUpdate) -> Dict[str, Any]: + updated_page = content_repo.update_page(db, page_id, page) + return {"page": updated_page} + + +def delete_page(db: Session, page_id: int) -> Dict[str, Any]: + success = content_repo.delete_page(db, page_id) + return {"success": success} + + +def get_page_by_slug(db: Session, slug: str) -> Dict[str, Any]: + page = content_repo.get_page_by_slug(db, slug) + if not page: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Страница не найдена" + ) + + return {"page": page} + + +# Сервисы аналитики +def log_event(db: Session, log: AnalyticsLogCreate) -> Dict[str, Any]: + new_log = content_repo.log_analytics_event(db, log) + return {"log": new_log} + + +def get_analytics_report( + db: Session, + period: str = "day", + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None +) -> Dict[str, Any]: + report = content_repo.get_analytics_report(db, period, start_date, end_date) + return {"report": report} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2d486eb --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.95.1 +uvicorn==0.22.0 +sqlalchemy==2.0.12 +pydantic==1.10.7 +python-jose==3.3.0 +passlib==1.7.4 +python-multipart==0.0.6 +email-validator==2.0.0 +psycopg2-binary==2.9.6 +alembic==1.10.4 +python-dotenv==1.0.0 +bcrypt==4.0.1 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b2bcab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,129 @@ + +version: '3.8' +services: + # backend: + # build: + # context: ./backend + # dockerfile: Dockerfile + # container_name: backend + # expose: + # - "8000" + # restart: always + + # frontend: + # build: + # context: ./frontend + # dockerfile: Dockerfile + # container_name: frontend + # expose: + # - "3000" + # environment: + # - NODE_ENV=production + # restart: always + # networks: + # - sta_network + + # nginx: + # image: nginx:latest + # container_name: nginx + # ports: + # - "80:80" + # volumes: + # - ./nginx/sta_test.conf:/etc/nginx/conf.d/sta_test.conf:ro + # depends_on: + # - backend + # - frontend + # restart: always + # networks: + # - sta_network + + + # backend: + # build: + # context: ./backend + # dockerfile: Dockerfile + # container_name: backend + # ports: + # - "8000:8000" + # expose: + # - "8000" + # environment: + # - DEBUG=1 + # - DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5433/sta + # - IS_DOCKER=1 + # depends_on: + # postgres: + # condition: service_healthy + # redis: + # condition: service_healthy + # networks: + # - sta_network + # # dns: + # # - 8.8.8.8 + # # - 8.8.4.4 + # # dns_opt: + # # - ndots:1 + # # - timeout:3 + # # - attempts:5 + # # sysctls: + # # - net.ipv4.tcp_keepalive_time=60 + # # - net.ipv4.tcp_keepalive_intvl=10 + # # - net.ipv4.tcp_keepalive_probes=6 + + postgres: + image: postgres:15 + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5434:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + + # redis: + # image: redis:7 + # ports: + # - "6380:6379" + # healthcheck: + # test: ["CMD", "redis-cli", "ping"] + # interval: 5s + # timeout: 5s + # retries: 5 + # networks: + # - sta_network + + # elasticsearch: + # image: elasticsearch:8.17.2 + # environment: + # - discovery.type=single-node + # - xpack.security.enabled=false + # - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + # ports: + # - "9200:9200" + # volumes: + # - elasticsearch_data:/usr/share/elasticsearch/data + # healthcheck: + # test: ["CMD", "curl", "-f", "http://localhost:9200"] + # interval: 10s + # timeout: 5s + # retries: 5 + # networks: + # - sta_network + +# networks: +# sta_network: +# name: sta_network +# driver: bridge + +volumes: + postgres_data: + driver: local + # elasticsearch_data: + # driver: local \ No newline at end of file diff --git a/.dockerignore b/frontend/.dockerignore similarity index 100% rename from .dockerignore rename to frontend/.dockerignore diff --git a/.gitignore b/frontend/.gitignore similarity index 100% rename from .gitignore rename to frontend/.gitignore diff --git a/Dockerfile b/frontend/Dockerfile similarity index 100% rename from Dockerfile rename to frontend/Dockerfile diff --git a/README.md b/frontend/README.md similarity index 100% rename from README.md rename to frontend/README.md diff --git a/app.json b/frontend/app.json similarity index 100% rename from app.json rename to frontend/app.json diff --git a/components/Collections.tsx b/frontend/components/Collections.tsx similarity index 100% rename from components/Collections.tsx rename to frontend/components/Collections.tsx diff --git a/components/CookieNotification.tsx b/frontend/components/CookieNotification.tsx similarity index 100% rename from components/CookieNotification.tsx rename to frontend/components/CookieNotification.tsx diff --git a/components/Footer.tsx b/frontend/components/Footer.tsx similarity index 100% rename from components/Footer.tsx rename to frontend/components/Footer.tsx diff --git a/components/Header.tsx b/frontend/components/Header.tsx similarity index 100% rename from components/Header.tsx rename to frontend/components/Header.tsx diff --git a/components/Hero.tsx b/frontend/components/Hero.tsx similarity index 100% rename from components/Hero.tsx rename to frontend/components/Hero.tsx diff --git a/components/NewArrivals.tsx b/frontend/components/NewArrivals.tsx similarity index 100% rename from components/NewArrivals.tsx rename to frontend/components/NewArrivals.tsx diff --git a/components/PopularCategories.tsx b/frontend/components/PopularCategories.tsx similarity index 100% rename from components/PopularCategories.tsx rename to frontend/components/PopularCategories.tsx diff --git a/components/TabSelector.tsx b/frontend/components/TabSelector.tsx similarity index 100% rename from components/TabSelector.tsx rename to frontend/components/TabSelector.tsx diff --git a/data/categories.ts b/frontend/data/categories.ts similarity index 100% rename from data/categories.ts rename to frontend/data/categories.ts diff --git a/data/collections.ts b/frontend/data/collections.ts similarity index 100% rename from data/collections.ts rename to frontend/data/collections.ts diff --git a/data/products.ts b/frontend/data/products.ts similarity index 100% rename from data/products.ts rename to frontend/data/products.ts diff --git a/next.config.js b/frontend/next.config.js similarity index 100% rename from next.config.js rename to frontend/next.config.js diff --git a/package-lock.json b/frontend/package-lock.json similarity index 100% rename from package-lock.json rename to frontend/package-lock.json diff --git a/package.json b/frontend/package.json similarity index 100% rename from package.json rename to frontend/package.json diff --git a/pages/_app.tsx b/frontend/pages/_app.tsx similarity index 100% rename from pages/_app.tsx rename to frontend/pages/_app.tsx diff --git a/pages/api/hello.js b/frontend/pages/api/hello.js similarity index 100% rename from pages/api/hello.js rename to frontend/pages/api/hello.js diff --git a/pages/category/[slug].tsx b/frontend/pages/category/[slug].tsx similarity index 100% rename from pages/category/[slug].tsx rename to frontend/pages/category/[slug].tsx diff --git a/pages/category/index.tsx b/frontend/pages/category/index.tsx similarity index 100% rename from pages/category/index.tsx rename to frontend/pages/category/index.tsx diff --git a/pages/collections/[slug].tsx b/frontend/pages/collections/[slug].tsx similarity index 100% rename from pages/collections/[slug].tsx rename to frontend/pages/collections/[slug].tsx diff --git a/pages/collections/index.tsx b/frontend/pages/collections/index.tsx similarity index 100% rename from pages/collections/index.tsx rename to frontend/pages/collections/index.tsx diff --git a/pages/index.tsx b/frontend/pages/index.tsx similarity index 100% rename from pages/index.tsx rename to frontend/pages/index.tsx diff --git a/pages/new-arrivals/index.tsx b/frontend/pages/new-arrivals/index.tsx similarity index 100% rename from pages/new-arrivals/index.tsx rename to frontend/pages/new-arrivals/index.tsx diff --git a/pages/product/[slug].tsx b/frontend/pages/product/[slug].tsx similarity index 100% rename from pages/product/[slug].tsx rename to frontend/pages/product/[slug].tsx diff --git a/postcss.config.js b/frontend/postcss.config.js similarity index 100% rename from postcss.config.js rename to frontend/postcss.config.js diff --git a/public/category/dress.jpg b/frontend/public/category/dress.jpg similarity index 100% rename from public/category/dress.jpg rename to frontend/public/category/dress.jpg diff --git a/public/category/hat.jpg b/frontend/public/category/hat.jpg similarity index 100% rename from public/category/hat.jpg rename to frontend/public/category/hat.jpg diff --git a/public/category/jacket.jpg b/frontend/public/category/jacket.jpg similarity index 100% rename from public/category/jacket.jpg rename to frontend/public/category/jacket.jpg diff --git a/public/category/pants.jpg b/frontend/public/category/pants.jpg similarity index 100% rename from public/category/pants.jpg rename to frontend/public/category/pants.jpg diff --git a/public/category/scarf.jpg b/frontend/public/category/scarf.jpg similarity index 100% rename from public/category/scarf.jpg rename to frontend/public/category/scarf.jpg diff --git a/public/category/shoes.jpg b/frontend/public/category/shoes.jpg similarity index 100% rename from public/category/shoes.jpg rename to frontend/public/category/shoes.jpg diff --git a/public/category/silk.jpg b/frontend/public/category/silk.jpg similarity index 100% rename from public/category/silk.jpg rename to frontend/public/category/silk.jpg diff --git a/public/category/sweaters.jpg b/frontend/public/category/sweaters.jpg similarity index 100% rename from public/category/sweaters.jpg rename to frontend/public/category/sweaters.jpg diff --git a/public/favicon.ico b/frontend/public/favicon.ico similarity index 100% rename from public/favicon.ico rename to frontend/public/favicon.ico diff --git a/public/hero_photos/hero1.png b/frontend/public/hero_photos/hero1.png similarity index 100% rename from public/hero_photos/hero1.png rename to frontend/public/hero_photos/hero1.png diff --git a/public/hero_photos/photo_main_main_1.png b/frontend/public/hero_photos/photo_main_main_1.png similarity index 100% rename from public/hero_photos/photo_main_main_1.png rename to frontend/public/hero_photos/photo_main_main_1.png diff --git a/public/hero_photos/photo_main_main_3.png b/frontend/public/hero_photos/photo_main_main_3.png similarity index 100% rename from public/hero_photos/photo_main_main_3.png rename to frontend/public/hero_photos/photo_main_main_3.png diff --git a/public/logo.png b/frontend/public/logo.png similarity index 100% rename from public/logo.png rename to frontend/public/logo.png diff --git a/public/logotip.png b/frontend/public/logotip.png similarity index 100% rename from public/logotip.png rename to frontend/public/logotip.png diff --git a/public/photos/autumn_winter.jpg b/frontend/public/photos/autumn_winter.jpg similarity index 100% rename from public/photos/autumn_winter.jpg rename to frontend/public/photos/autumn_winter.jpg diff --git a/public/photos/based_outfit.jpg b/frontend/public/photos/based_outfit.jpg similarity index 100% rename from public/photos/based_outfit.jpg rename to frontend/public/photos/based_outfit.jpg diff --git a/public/photos/business_outfit.jpg b/frontend/public/photos/business_outfit.jpg similarity index 100% rename from public/photos/business_outfit.jpg rename to frontend/public/photos/business_outfit.jpg diff --git a/public/photos/head_photo.png b/frontend/public/photos/head_photo.png similarity index 100% rename from public/photos/head_photo.png rename to frontend/public/photos/head_photo.png diff --git a/public/photos/night_dress.jpg b/frontend/public/photos/night_dress.jpg similarity index 100% rename from public/photos/night_dress.jpg rename to frontend/public/photos/night_dress.jpg diff --git a/public/photos/photo1.jpg b/frontend/public/photos/photo1.jpg similarity index 100% rename from public/photos/photo1.jpg rename to frontend/public/photos/photo1.jpg diff --git a/public/photos/photo2.jpg b/frontend/public/photos/photo2.jpg similarity index 100% rename from public/photos/photo2.jpg rename to frontend/public/photos/photo2.jpg diff --git a/public/vercel.svg b/frontend/public/vercel.svg similarity index 100% rename from public/vercel.svg rename to frontend/public/vercel.svg diff --git a/public/wear/bag1.jpg b/frontend/public/wear/bag1.jpg similarity index 100% rename from public/wear/bag1.jpg rename to frontend/public/wear/bag1.jpg diff --git a/public/wear/bag2.jpg b/frontend/public/wear/bag2.jpg similarity index 100% rename from public/wear/bag2.jpg rename to frontend/public/wear/bag2.jpg diff --git a/public/wear/classic_bruk1.jpg b/frontend/public/wear/classic_bruk1.jpg similarity index 100% rename from public/wear/classic_bruk1.jpg rename to frontend/public/wear/classic_bruk1.jpg diff --git a/public/wear/classic_bruk2.jpg b/frontend/public/wear/classic_bruk2.jpg similarity index 100% rename from public/wear/classic_bruk2.jpg rename to frontend/public/wear/classic_bruk2.jpg diff --git a/public/wear/coat1.jpg b/frontend/public/wear/coat1.jpg similarity index 100% rename from public/wear/coat1.jpg rename to frontend/public/wear/coat1.jpg diff --git a/public/wear/coat2.jpg b/frontend/public/wear/coat2.jpg similarity index 100% rename from public/wear/coat2.jpg rename to frontend/public/wear/coat2.jpg diff --git a/public/wear/hat1.jpg b/frontend/public/wear/hat1.jpg similarity index 100% rename from public/wear/hat1.jpg rename to frontend/public/wear/hat1.jpg diff --git a/public/wear/jumpsuit_1.jpg b/frontend/public/wear/jumpsuit_1.jpg similarity index 100% rename from public/wear/jumpsuit_1.jpg rename to frontend/public/wear/jumpsuit_1.jpg diff --git a/public/wear/jumpsuit_2.jpg b/frontend/public/wear/jumpsuit_2.jpg similarity index 100% rename from public/wear/jumpsuit_2.jpg rename to frontend/public/wear/jumpsuit_2.jpg diff --git a/public/wear/kozh_boots1.jpg b/frontend/public/wear/kozh_boots1.jpg similarity index 100% rename from public/wear/kozh_boots1.jpg rename to frontend/public/wear/kozh_boots1.jpg diff --git a/public/wear/kozh_boots2.jpg b/frontend/public/wear/kozh_boots2.jpg similarity index 100% rename from public/wear/kozh_boots2.jpg rename to frontend/public/wear/kozh_boots2.jpg diff --git a/public/wear/palto1.jpg b/frontend/public/wear/palto1.jpg similarity index 100% rename from public/wear/palto1.jpg rename to frontend/public/wear/palto1.jpg diff --git a/public/wear/palto2.jpg b/frontend/public/wear/palto2.jpg similarity index 100% rename from public/wear/palto2.jpg rename to frontend/public/wear/palto2.jpg diff --git a/public/wear/pidzak1.jpg b/frontend/public/wear/pidzak1.jpg similarity index 100% rename from public/wear/pidzak1.jpg rename to frontend/public/wear/pidzak1.jpg diff --git a/public/wear/pidzak2.jpg b/frontend/public/wear/pidzak2.jpg similarity index 100% rename from public/wear/pidzak2.jpg rename to frontend/public/wear/pidzak2.jpg diff --git a/public/wear/sherst_sweater1.jpg b/frontend/public/wear/sherst_sweater1.jpg similarity index 100% rename from public/wear/sherst_sweater1.jpg rename to frontend/public/wear/sherst_sweater1.jpg diff --git a/public/wear/sherst_sweater2.jpg b/frontend/public/wear/sherst_sweater2.jpg similarity index 100% rename from public/wear/sherst_sweater2.jpg rename to frontend/public/wear/sherst_sweater2.jpg diff --git a/public/wear/silk1.jpg b/frontend/public/wear/silk1.jpg similarity index 100% rename from public/wear/silk1.jpg rename to frontend/public/wear/silk1.jpg diff --git a/public/wear/silk2.jpg b/frontend/public/wear/silk2.jpg similarity index 100% rename from public/wear/silk2.jpg rename to frontend/public/wear/silk2.jpg diff --git a/public/wear/silk_scarf1.jpg b/frontend/public/wear/silk_scarf1.jpg similarity index 100% rename from public/wear/silk_scarf1.jpg rename to frontend/public/wear/silk_scarf1.jpg diff --git a/public/wear/silk_scarf2.jpg b/frontend/public/wear/silk_scarf2.jpg similarity index 100% rename from public/wear/silk_scarf2.jpg rename to frontend/public/wear/silk_scarf2.jpg diff --git a/public/wear/sorochka1.jpg b/frontend/public/wear/sorochka1.jpg similarity index 100% rename from public/wear/sorochka1.jpg rename to frontend/public/wear/sorochka1.jpg diff --git a/public/wear/sorochka2.jpg b/frontend/public/wear/sorochka2.jpg similarity index 100% rename from public/wear/sorochka2.jpg rename to frontend/public/wear/sorochka2.jpg diff --git a/styles/Home.module.css b/frontend/styles/Home.module.css similarity index 100% rename from styles/Home.module.css rename to frontend/styles/Home.module.css diff --git a/styles/globals.css b/frontend/styles/globals.css similarity index 100% rename from styles/globals.css rename to frontend/styles/globals.css diff --git a/tailwind.config.js b/frontend/tailwind.config.js similarity index 100% rename from tailwind.config.js rename to frontend/tailwind.config.js diff --git a/tsconfig.json b/frontend/tsconfig.json similarity index 100% rename from tsconfig.json rename to frontend/tsconfig.json