diff --git a/.DS_Store b/.DS_Store index ac86e42..b626346 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/.DS_Store b/backend/.DS_Store index a950caf..7604996 100644 Binary files a/backend/.DS_Store and b/backend/.DS_Store differ diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..1766512 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql://postgres:postgres@localhost:5434/shop_db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/__pycache__/env.cpython-310.pyc b/backend/alembic/__pycache__/env.cpython-310.pyc new file mode 100644 index 0000000..d2c8974 Binary files /dev/null and b/backend/alembic/__pycache__/env.cpython-310.pyc differ diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..1993cde --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,85 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from app.core import Base + +from app.models.catalog_models import * +from app.models.order_models import * +from app.models.user_models import * +from app.models.review_models import * +from app.models.content_models import * + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/2325dd0f1bd5_init.py b/backend/alembic/versions/2325dd0f1bd5_init.py new file mode 100644 index 0000000..caa275d --- /dev/null +++ b/backend/alembic/versions/2325dd0f1bd5_init.py @@ -0,0 +1,30 @@ +"""init + +Revision ID: 2325dd0f1bd5 +Revises: +Create Date: 2025-03-02 16:06:23.805347 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2325dd0f1bd5' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc b/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc new file mode 100644 index 0000000..db598a6 Binary files /dev/null and b/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc differ diff --git a/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc b/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc new file mode 100644 index 0000000..16b070d Binary files /dev/null and b/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc differ diff --git a/backend/alembic/versions/ef40913679bd_update_products.py b/backend/alembic/versions/ef40913679bd_update_products.py new file mode 100644 index 0000000..5b087ca --- /dev/null +++ b/backend/alembic/versions/ef40913679bd_update_products.py @@ -0,0 +1,52 @@ +"""update products + +Revision ID: ef40913679bd +Revises: 2325dd0f1bd5 +Create Date: 2025-03-02 16:44:41.367742 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ef40913679bd' +down_revision: Union[str, None] = '2325dd0f1bd5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('collections', sa.Column('description', sa.Text(), nullable=True)) + op.add_column('collections', sa.Column('is_active', sa.Boolean(), nullable=True)) + op.add_column('collections', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True)) + op.add_column('collections', sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('product_variants', sa.Column('price', sa.Float(), nullable=False)) + op.add_column('product_variants', sa.Column('discount_price', sa.Float(), nullable=True)) + op.drop_column('product_variants', 'price_adjustment') + op.add_column('products', sa.Column('collection_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'products', 'collections', ['collection_id'], ['id']) + op.drop_column('products', 'discount_price') + op.drop_column('products', 'price') + op.drop_column('products', 'stock') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('products', sa.Column('stock', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('products', sa.Column('price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False)) + op.add_column('products', sa.Column('discount_price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'products', type_='foreignkey') + op.drop_column('products', 'collection_id') + op.add_column('product_variants', sa.Column('price_adjustment', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True)) + op.drop_column('product_variants', 'discount_price') + op.drop_column('product_variants', 'price') + op.drop_column('collections', 'updated_at') + op.drop_column('collections', 'created_at') + op.drop_column('collections', 'is_active') + op.drop_column('collections', 'description') + # ### end Alembic commands ### diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc index 041cf73..f449340 100644 Binary files a/backend/app/__pycache__/config.cpython-310.pyc and b/backend/app/__pycache__/config.cpython-310.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py index 448a63e..721f798 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,7 +18,7 @@ class Settings(BaseSettings): # Настройки безопасности 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 + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30*60*24 # Настройки CORS CORS_ORIGINS: list = [ diff --git a/backend/app/models/__pycache__/catalog_models.cpython-310.pyc b/backend/app/models/__pycache__/catalog_models.cpython-310.pyc index 8d383f7..f490f20 100644 Binary files a/backend/app/models/__pycache__/catalog_models.cpython-310.pyc and b/backend/app/models/__pycache__/catalog_models.cpython-310.pyc differ diff --git a/backend/app/models/catalog_models.py b/backend/app/models/catalog_models.py index ca2c9fc..a204857 100644 --- a/backend/app/models/catalog_models.py +++ b/backend/app/models/catalog_models.py @@ -5,6 +5,21 @@ from sqlalchemy.sql import func from app.core import Base +class Collection(Base): + __tablename__ = "collections" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + slug = Column(String, unique=True, index=True, nullable=False) + 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()) + + # Отношения + products = relationship("Product", back_populates="collection") + + class Category(Base): __tablename__ = "categories" @@ -29,16 +44,16 @@ class Product(Base): 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) + + collection_id = Column(Integer, ForeignKey("collections.id")) 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") + collection = relationship("Collection", 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") @@ -49,9 +64,10 @@ class ProductVariant(Base): 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) + name = Column(String, nullable=False) + sku = Column(String, unique=True, nullable=False) # артикул + price = Column(Float, nullable=False) + discount_price = Column(Float, nullable=True) stock = Column(Integer, default=0) is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc index 634dca2..e6d1551 100644 Binary files a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/catalog_repo.py b/backend/app/repositories/catalog_repo.py index 9b16d4f..e2c0b6b 100644 --- a/backend/app/repositories/catalog_repo.py +++ b/backend/app/repositories/catalog_repo.py @@ -5,12 +5,13 @@ 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.models.catalog_models import Category, Product, ProductVariant, ProductImage, Collection from app.schemas.catalog_schemas import ( CategoryCreate, CategoryUpdate, ProductCreate, ProductUpdate, ProductVariantCreate, ProductVariantUpdate, - ProductImageCreate, ProductImageUpdate + ProductImageCreate, ProductImageUpdate, + CollectionCreate, CollectionUpdate ) @@ -30,6 +31,134 @@ def generate_slug(name: str) -> str: return slug +# Функции для работы с коллекциями +def get_collection(db: Session, collection_id: int) -> Optional[Collection]: + return db.query(Collection).filter(Collection.id == collection_id).first() + + +def get_collection_by_slug(db: Session, slug: str) -> Optional[Collection]: + return db.query(Collection).filter(Collection.slug == slug).first() + + +def get_collections( + db: Session, + skip: int = 0, + limit: int = 100, + is_active: Optional[bool] = True +) -> List[Collection]: + query = db.query(Collection) + + if is_active is not None: + query = query.filter(Collection.is_active == is_active) + + return query.offset(skip).limit(limit).all() + + +def create_collection(db: Session, collection: CollectionCreate) -> Collection: + # Если slug не предоставлен, генерируем его из имени + if not collection.slug: + collection.slug = generate_slug(collection.name) + + # Проверяем, что коллекция с таким slug не существует + if get_collection_by_slug(db, collection.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Коллекция с таким slug уже существует" + ) + + # Создаем новую коллекцию + db_collection = Collection( + name=collection.name, + slug=collection.slug, + description=collection.description, + is_active=collection.is_active + ) + + try: + db.add(db_collection) + db.commit() + db.refresh(db_collection) + return db_collection + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании коллекции" + ) + + +def update_collection(db: Session, collection_id: int, collection: CollectionUpdate) -> Collection: + db_collection = get_collection(db, collection_id) + if not db_collection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Коллекция не найдена" + ) + + # Обновляем только предоставленные поля + update_data = collection.dict(exclude_unset=True) + + # Если slug изменяется, проверяем его уникальность + if "slug" in update_data and update_data["slug"] != db_collection.slug: + if get_collection_by_slug(db, update_data["slug"]): + 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_collection_by_slug(db, update_data["slug"]) and get_collection_by_slug(db, update_data["slug"]).id != collection_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Коллекция с таким slug уже существует" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_collection, key, value) + + try: + db.commit() + db.refresh(db_collection) + return db_collection + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении коллекции" + ) + + +def delete_collection(db: Session, collection_id: int) -> bool: + db_collection = get_collection(db, collection_id) + if not db_collection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Коллекция не найдена" + ) + + # Проверяем, есть ли у коллекции продукты + if db.query(Product).filter(Product.collection_id == collection_id).count() > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Нельзя удалить коллекцию, у которой есть продукты" + ) + + try: + db.delete(db_collection) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении коллекции" + ) + + # Функции для работы с категориями def get_category(db: Session, category_id: int) -> Optional[Category]: return db.query(Category).filter(Category.id == category_id).first() @@ -203,10 +332,12 @@ def get_products( skip: int = 0, limit: int = 100, category_id: Optional[int] = None, + collection_id: Optional[int] = None, search: Optional[str] = None, min_price: Optional[float] = None, max_price: Optional[float] = None, - is_active: Optional[bool] = True + is_active: Optional[bool] = True, + include_variants: Optional[bool] = False ) -> List[Product]: query = db.query(Product) @@ -214,19 +345,34 @@ def get_products( if category_id: query = query.filter(Product.category_id == category_id) + if collection_id: + query = query.filter(Product.collection_id == collection_id) + if search: query = query.filter(Product.name.ilike(f"%{search}%")) - if 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() + # Фильтрация по цене теперь должна быть через варианты продукта + if min_price is not None or max_price is not None: + query = query.join(ProductVariant) + + if min_price is not None: + query = query.filter(ProductVariant.price >= min_price) + + if max_price is not None: + query = query.filter(ProductVariant.price <= max_price) + + products = query.offset(skip).limit(limit).all() + + # Если нужно включить варианты, загружаем их для каждого продукта + if include_variants: + for product in products: + product.variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all() + product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all() + + return products def create_product(db: Session, product: ProductCreate) -> Product: @@ -248,16 +394,21 @@ def create_product(db: Session, product: ProductCreate) -> Product: detail="Категория не найдена" ) + # Проверяем, что коллекция существует, если указана + if product.collection_id and not get_collection(db, product.collection_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 + category_id=product.category_id, + collection_id=product.collection_id ) try: @@ -303,12 +454,19 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod ) # Проверяем, что категория существует, если указана - if "category_id" in update_data and not get_category(db, update_data["category_id"]): + if "category_id" in update_data and update_data["category_id"] and not get_category(db, update_data["category_id"]): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Категория не найдена" ) + # Проверяем, что коллекция существует, если указана + if "collection_id" in update_data and update_data["collection_id"] and not get_collection(db, update_data["collection_id"]): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Коллекция не найдена" + ) + # Применяем обновления for key, value in update_data.items(): setattr(db_product, key, value) @@ -374,7 +532,8 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc product_id=variant.product_id, name=variant.name, sku=variant.sku, - price_adjustment=variant.price_adjustment, + price=variant.price, + discount_price=variant.discount_price, stock=variant.stock, is_active=variant.is_active ) @@ -403,6 +562,14 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant # Обновляем только предоставленные поля update_data = variant.dict(exclude_unset=True) + # Если product_id изменяется, проверяем, что продукт существует + if "product_id" in update_data and update_data["product_id"] != db_variant.product_id: + if not get_product(db, update_data["product_id"]): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + # Если SKU изменяется, проверяем его уникальность if "sku" in update_data and update_data["sku"] != db_variant.sku: if db.query(ProductVariant).filter(ProductVariant.sku == update_data["sku"]).first(): diff --git a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc index dab1384..1dac9db 100644 Binary files a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc and b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc differ diff --git a/backend/app/routers/catalog_router.py b/backend/app/routers/catalog_router.py index e82e00b..8f69936 100644 --- a/backend/app/routers/catalog_router.py +++ b/backend/app/routers/catalog_router.py @@ -8,14 +8,36 @@ from app.schemas.catalog_schemas import ( CategoryCreate, CategoryUpdate, Category, ProductCreate, ProductUpdate, Product, ProductVariantCreate, ProductVariantUpdate, ProductVariant, - ProductImageCreate, ProductImageUpdate, ProductImage + ProductImageCreate, ProductImageUpdate, ProductImage, + CollectionCreate, CollectionUpdate, Collection ) from app.models.user_models import User as UserModel -from app.repositories.catalog_repo import get_products +from app.repositories.catalog_repo import get_products, get_product_by_slug # Роутер для каталога catalog_router = APIRouter(prefix="/catalog", tags=["Каталог"]) +# Маршруты для коллекций +@catalog_router.post("/collections", response_model=Dict[str, Any]) +async def create_collection_endpoint(collection: CollectionCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return services.create_collection(db, collection) + + +@catalog_router.put("/collections/{collection_id}", response_model=Dict[str, Any]) +async def update_collection_endpoint(collection_id: int, collection: CollectionUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return services.update_collection(db, collection_id, collection) + + +@catalog_router.delete("/collections/{collection_id}", response_model=Dict[str, Any]) +async def delete_collection_endpoint(collection_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return services.delete_collection(db, collection_id) + + +@catalog_router.get("/collections", response_model=Dict[str, Any]) +async def get_collections_endpoint(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + return services.get_collections(db, skip, limit) + + @catalog_router.post("/categories", response_model=Dict[str, Any]) async def create_category_endpoint(category: CategoryCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): return services.create_category(db, category) @@ -56,6 +78,17 @@ async def get_product_details_endpoint(product_id: int, db: Session = Depends(ge return services.get_product_details(db, product_id) +@catalog_router.get("/products/slug/{slug}", response_model=Dict[str, Any]) +async def get_product_by_slug_endpoint(slug: str, db: Session = Depends(get_db)): + product = get_product_by_slug(db, slug) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + return services.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 @@ -98,12 +131,14 @@ async def get_products_endpoint( skip: int = 0, limit: int = 100, category_id: Optional[int] = None, + collection_id: Optional[int] = None, search: Optional[str] = None, min_price: Optional[float] = None, max_price: Optional[float] = None, is_active: Optional[bool] = True, + include_variants: Optional[bool] = False, db: Session = Depends(get_db) ): - products = get_products(db, skip, limit, category_id, search, min_price, max_price, is_active) + products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active, include_variants) # Преобразуем объекты SQLAlchemy в схемы Pydantic return [Product.model_validate(product) for product in products] \ No newline at end of file diff --git a/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc index 348937d..1936503 100644 Binary files a/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc and b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/catalog_schemas.py b/backend/app/schemas/catalog_schemas.py index 8d7540d..b604302 100644 --- a/backend/app/schemas/catalog_schemas.py +++ b/backend/app/schemas/catalog_schemas.py @@ -3,6 +3,34 @@ from typing import Optional, List, Union from datetime import datetime +# Схемы для коллекций +class CollectionBase(BaseModel): + name: str + slug: Optional[str] = None + description: Optional[str] = None + is_active: bool = True + + +class CollectionCreate(CollectionBase): + pass + + +class CollectionUpdate(CollectionBase): + name: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + + +class Collection(CollectionBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + # Схемы для категорий class CategoryBase(BaseModel): name: str @@ -28,53 +56,20 @@ class Category(CategoryBase): id: int created_at: datetime updated_at: Optional[datetime] = None + products_count: Optional[int] = 0 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 + price: float + discount_price: Optional[float] = None stock: int = 0 is_active: bool = True @@ -87,7 +82,8 @@ class ProductVariantUpdate(ProductVariantBase): product_id: Optional[int] = None name: Optional[str] = None sku: Optional[str] = None - price_adjustment: Optional[float] = None + price: Optional[float] = None + discount_price: Optional[float] = None stock: Optional[int] = None is_active: Optional[bool] = None @@ -127,9 +123,45 @@ class ProductImage(ProductImageBase): from_attributes = True + +# Схемы для продуктов +class ProductBase(BaseModel): + name: str + slug: Optional[str] = None + description: Optional[str] = None + is_active: bool = True + category_id: int + collection_id: Optional[int] = None + + +class ProductCreate(ProductBase): + pass + + +class ProductUpdate(ProductBase): + name: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + category_id: Optional[int] = None + collection_id: Optional[int] = None + + +class Product(ProductBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + variants: Optional[List[ProductVariant]] = [] + images: Optional[List[ProductImage]] = [] + + class Config: + from_attributes = True + + # Расширенные схемы для отображения class CategoryWithSubcategories(Category): subcategories: List['CategoryWithSubcategories'] = [] + products_count: Optional[int] = 0 class Config: from_attributes = True @@ -137,6 +169,7 @@ class CategoryWithSubcategories(Category): class ProductWithDetails(Product): category: Category + collection: Optional[Collection] = None variants: List[ProductVariant] = [] images: List[ProductImage] = [] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 8b20b10..53c9f4b 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -8,7 +8,8 @@ from app.services.catalog_service import ( 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 + upload_product_image, update_product_image, delete_product_image, + create_collection, update_collection, delete_collection, get_collections ) from app.services.order_service import ( diff --git a/backend/app/services/__pycache__/__init__.cpython-310.pyc b/backend/app/services/__pycache__/__init__.cpython-310.pyc index b2ab191..690a72e 100644 Binary files a/backend/app/services/__pycache__/__init__.cpython-310.pyc and b/backend/app/services/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/services/__pycache__/catalog_service.cpython-310.pyc b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc index f01d205..891c188 100644 Binary files a/backend/app/services/__pycache__/catalog_service.cpython-310.pyc and b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc differ diff --git a/backend/app/services/catalog_service.py b/backend/app/services/catalog_service.py index ca43992..d47fbc4 100644 --- a/backend/app/services/catalog_service.py +++ b/backend/app/services/catalog_service.py @@ -12,10 +12,44 @@ from app.schemas.catalog_schemas import ( CategoryCreate, CategoryUpdate, ProductCreate, ProductUpdate, ProductVariantCreate, ProductVariantUpdate, - ProductImageCreate, ProductImageUpdate + ProductImageCreate, ProductImageUpdate, + CollectionCreate, CollectionUpdate ) +# Сервисы для коллекций +def create_collection(db: Session, collection: CollectionCreate) -> Dict[str, Any]: + from app.schemas.catalog_schemas import Collection as CollectionSchema + + new_collection = catalog_repo.create_collection(db, collection) + # Преобразуем объект SQLAlchemy в схему Pydantic + collection_schema = CollectionSchema.model_validate(new_collection) + return {"collection": collection_schema} + + +def update_collection(db: Session, collection_id: int, collection: CollectionUpdate) -> Dict[str, Any]: + from app.schemas.catalog_schemas import Collection as CollectionSchema + + updated_collection = catalog_repo.update_collection(db, collection_id, collection) + # Преобразуем объект SQLAlchemy в схему Pydantic + collection_schema = CollectionSchema.model_validate(updated_collection) + return {"collection": collection_schema} + + +def delete_collection(db: Session, collection_id: int) -> Dict[str, Any]: + success = catalog_repo.delete_collection(db, collection_id) + return {"success": success} + + +def get_collections(db: Session, skip: int = 0, limit: int = 100) -> Dict[str, Any]: + from app.schemas.catalog_schemas import Collection as CollectionSchema + + collections = catalog_repo.get_collections(db, skip, limit) + # Преобразуем объекты SQLAlchemy в схемы Pydantic + collections_schema = [CollectionSchema.model_validate(collection) for collection in collections] + return {"collections": collections_schema, "total": len(collections_schema)} + + # Сервисы каталога def create_category(db: Session, category: CategoryCreate) -> Dict[str, Any]: from app.schemas.catalog_schemas import Category as CategorySchema @@ -42,6 +76,8 @@ def delete_category(db: Session, category_id: int) -> Dict[str, Any]: def get_category_tree(db: Session) -> List[Dict[str, Any]]: from app.schemas.catalog_schemas import Category as CategorySchema + from sqlalchemy import func + from app.models.catalog_models import Product # Получаем все категории верхнего уровня root_categories = catalog_repo.get_categories(db, parent_id=None) @@ -50,28 +86,54 @@ def get_category_tree(db: Session) -> List[Dict[str, Any]]: for category in root_categories: # Преобразуем объект SQLAlchemy в схему Pydantic category_schema = CategorySchema.model_validate(category) + # Получаем количество продуктов в категории + products_count = db.query(func.count(Product.id)).filter( + Product.category_id == category.id, + Product.is_active == True + ).scalar() or 0 + # Рекурсивно получаем подкатегории category_dict = category_schema.model_dump() - category_dict["subcategories"] = _get_subcategories(db, category.id) + subcategories, subcategories_products_count = _get_subcategories(db, category.id) + category_dict["subcategories"] = subcategories + category_dict["products_count"] = products_count + subcategories_products_count result.append(category_dict) return result -def _get_subcategories(db: Session, parent_id: int) -> List[Dict[str, Any]]: +def _get_subcategories(db: Session, parent_id: int) -> tuple[List[Dict[str, Any]], int]: from app.schemas.catalog_schemas import Category as CategorySchema + from sqlalchemy import func + from app.models.catalog_models import Product subcategories = catalog_repo.get_categories(db, parent_id=parent_id) result = [] + total_products_count = 0 + for category in subcategories: # Преобразуем объект SQLAlchemy в схему Pydantic category_schema = CategorySchema.model_validate(category) + + # Получаем количество продуктов в категории + products_count = db.query(func.count(Product.id)).filter( + Product.category_id == category.id, + Product.is_active == True + ).scalar() or 0 + category_dict = category_schema.model_dump() - category_dict["subcategories"] = _get_subcategories(db, category.id) + sub_subcategories, sub_products_count = _get_subcategories(db, category.id) + + total_products_in_category = products_count + sub_products_count + total_products_count += total_products_in_category + + category_dict["subcategories"] = sub_subcategories + category_dict["products_count"] = total_products_in_category + result.append(category_dict) - return result + return result, total_products_count def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]: @@ -101,6 +163,7 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]: from app.schemas.catalog_schemas import Product as ProductSchema, Category as CategorySchema from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema from app.schemas.catalog_schemas import ProductImage as ProductImageSchema + from app.schemas.catalog_schemas import Collection as CollectionSchema product = catalog_repo.get_product(db, product_id) if not product: @@ -126,10 +189,18 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]: variants_schema = [ProductVariantSchema.model_validate(variant) for variant in variants] images_schema = [ProductImageSchema.model_validate(image) for image in images] + # Добавляем информацию о коллекции, если она есть + collection_schema = None + if product.collection_id: + collection = catalog_repo.get_collection(db, product.collection_id) + if collection: + collection_schema = CollectionSchema.model_validate(collection) + return { "product": product_schema, "variants": variants_schema, "images": images_schema, + "collection": collection_schema, "rating": rating, "reviews": reviews } diff --git a/backend/uploads/products/2/cbd95632-a794-47d2-a926-3b8fc0ad0f49.jpg b/backend/uploads/products/2/cbd95632-a794-47d2-a926-3b8fc0ad0f49.jpg new file mode 100644 index 0000000..02039d9 Binary files /dev/null and b/backend/uploads/products/2/cbd95632-a794-47d2-a926-3b8fc0ad0f49.jpg differ diff --git a/backend/uploads/products/4/4a3407a0-ec18-4231-8bf8-718853b82c0b.jpg b/backend/uploads/products/4/4a3407a0-ec18-4231-8bf8-718853b82c0b.jpg new file mode 100644 index 0000000..545e7e1 Binary files /dev/null and b/backend/uploads/products/4/4a3407a0-ec18-4231-8bf8-718853b82c0b.jpg differ diff --git a/backend/uploads/products/4/809315dc-7783-4a64-b683-0f6faea11044.jpg b/backend/uploads/products/4/809315dc-7783-4a64-b683-0f6faea11044.jpg new file mode 100644 index 0000000..fbfb28d Binary files /dev/null and b/backend/uploads/products/4/809315dc-7783-4a64-b683-0f6faea11044.jpg differ diff --git a/backend/uploads/products/7/4aa5e62e-d202-450e-955e-c1905543461b.jpg b/backend/uploads/products/7/4aa5e62e-d202-450e-955e-c1905543461b.jpg new file mode 100644 index 0000000..4b6f767 Binary files /dev/null and b/backend/uploads/products/7/4aa5e62e-d202-450e-955e-c1905543461b.jpg differ diff --git a/backend/uploads/products/7/5f82bada-b66d-4ee5-8fae-372aecd63424.jpg b/backend/uploads/products/7/5f82bada-b66d-4ee5-8fae-372aecd63424.jpg new file mode 100644 index 0000000..1a1d2a2 Binary files /dev/null and b/backend/uploads/products/7/5f82bada-b66d-4ee5-8fae-372aecd63424.jpg differ diff --git a/backend/uploads/products/7/e6b60a4e-be79-472f-8f67-47849a4b1079.jpg b/backend/uploads/products/7/e6b60a4e-be79-472f-8f67-47849a4b1079.jpg new file mode 100644 index 0000000..4b6f767 Binary files /dev/null and b/backend/uploads/products/7/e6b60a4e-be79-472f-8f67-47849a4b1079.jpg differ diff --git a/backend/uploads/products/7/fd1e78dc-b38f-4acf-835e-85c6f60611b7.jpg b/backend/uploads/products/7/fd1e78dc-b38f-4acf-835e-85c6f60611b7.jpg new file mode 100644 index 0000000..a030751 Binary files /dev/null and b/backend/uploads/products/7/fd1e78dc-b38f-4acf-835e-85c6f60611b7.jpg differ diff --git a/backend/uploads/products/8/9440cf88-43a0-4e09-a4ce-44695f07917e.jpg b/backend/uploads/products/8/9440cf88-43a0-4e09-a4ce-44695f07917e.jpg new file mode 100644 index 0000000..1a1d2a2 Binary files /dev/null and b/backend/uploads/products/8/9440cf88-43a0-4e09-a4ce-44695f07917e.jpg differ diff --git a/backend/uploads/products/8/a46e2215-fd5e-4be9-bf83-9dd916b97e1d.jpg b/backend/uploads/products/8/a46e2215-fd5e-4be9-bf83-9dd916b97e1d.jpg new file mode 100644 index 0000000..fbfb28d Binary files /dev/null and b/backend/uploads/products/8/a46e2215-fd5e-4be9-bf83-9dd916b97e1d.jpg differ diff --git a/backend/uploads/products/8/fc5f27d9-11b1-44c6-8a97-126284b4ef97.jpg b/backend/uploads/products/8/fc5f27d9-11b1-44c6-8a97-126284b4ef97.jpg new file mode 100644 index 0000000..a030751 Binary files /dev/null and b/backend/uploads/products/8/fc5f27d9-11b1-44c6-8a97-126284b4ef97.jpg differ diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx index 4afe6d4..5468e5f 100644 --- a/frontend/components/Header.tsx +++ b/frontend/components/Header.tsx @@ -75,6 +75,9 @@ export default function Header() { Каталог + + Все товары + Коллекции diff --git a/frontend/components/admin/AdminLayout.tsx b/frontend/components/admin/AdminLayout.tsx index a680a64..ef36443 100644 --- a/frontend/components/admin/AdminLayout.tsx +++ b/frontend/components/admin/AdminLayout.tsx @@ -13,7 +13,8 @@ import { LogOut, Menu, X, - Home + Home, + Grid } from 'lucide-react'; import Image from 'next/image'; import authService from '../../services/auth'; @@ -122,6 +123,12 @@ export default function AdminLayout({ children, title }: AdminLayoutProps) { text: 'Настройки', href: '/admin/settings', active: currentPath.startsWith('/admin/settings') + }, + { + icon: , + text: 'Коллекции', + href: '/admin/collections', + active: currentPath.startsWith('/admin/collections') } ]; @@ -202,6 +209,13 @@ export default function AdminLayout({ children, title }: AdminLayoutProps) {
+ + + На главную +
+ + + На главную +