diff --git a/Dockerfile b/Dockerfile index be09a305..c691e80b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ COPY pyproject.toml pyproject.toml RUN pip install poetry RUN poetry config virtualenvs.create false -RUN poetry install --no-dev +RUN poetry install --no-root COPY . /app diff --git a/migrations/versions/5cb1f4f47531_add_additionalproducts_and_.py b/migrations/versions/5cb1f4f47531_add_additionalproducts_and_.py new file mode 100644 index 00000000..075e9747 --- /dev/null +++ b/migrations/versions/5cb1f4f47531_add_additionalproducts_and_.py @@ -0,0 +1,233 @@ +"""Add AdditionalProducts and RecommendedProducts models + +Revision ID: 5cb1f4f47531 +Revises: 799909f2b79d +Create Date: 2025-09-08 14:47:17.898500 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '5cb1f4f47531' +down_revision: Union[str, None] = '799909f2b79d' +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.create_table('company', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('hashed_password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('is_admin', sa.Boolean(), nullable=False), + sa.Column('is_staff', sa.Boolean(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('first_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('last_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.alter_column('categories', 'title', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('categories', 'description', + existing_type=sa.TEXT(), + type_=sqlmodel.sql.sqltypes.AutoString(), + existing_nullable=True) + op.alter_column('categories', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=False) + op.drop_index(op.f('ix_categories_id'), table_name='categories') + op.alter_column('product_categories', 'product_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('product_categories', 'category_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_index(op.f('ix_product_categories_id'), table_name='product_categories') + op.alter_column('product_discounts', 'product_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('product_discounts', 'discount_amount', + existing_type=sa.NUMERIC(), + type_=sa.Float(), + existing_nullable=True) + op.alter_column('product_discounts', 'valid_from', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.alter_column('product_discounts', 'valid_to', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.drop_index(op.f('ix_product_discounts_id'), table_name='product_discounts') + op.alter_column('product_images', 'product_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_index(op.f('ix_product_images_id'), table_name='product_images') + op.alter_column('products', 'title', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('products', 'description', + existing_type=sa.TEXT(), + type_=sqlmodel.sql.sqltypes.AutoString(), + existing_nullable=True) + op.alter_column('products', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=False) + op.drop_index(op.f('ix_products_id'), table_name='products') + op.alter_column('stock_records', 'product_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('stock_records', 'price', + existing_type=sa.NUMERIC(), + type_=sa.Float(), + nullable=False) + op.alter_column('stock_records', 'quantity', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('stock_records', 'date_created', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.drop_index(op.f('ix_stock_records_id'), table_name='stock_records') + op.alter_column('user_addresses', 'city', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('user_addresses', 'street', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('user_addresses', 'house', + existing_type=sa.VARCHAR(), + nullable=False) + op.drop_index(op.f('ix_user_addresses_id'), table_name='user_addresses') + op.alter_column('users', 'email', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('users', 'phone_number', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('users', 'is_admin', + existing_type=sa.BOOLEAN(), + nullable=False) + op.alter_column('users', 'is_staff', + existing_type=sa.BOOLEAN(), + nullable=False) + op.alter_column('users', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=False) + op.alter_column('users', 'date_joined', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.alter_column('users', 'is_temporary', + existing_type=sa.BOOLEAN(), + nullable=False) + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_phone_number'), table_name='users') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_users_phone_number'), 'users', ['phone_number'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.alter_column('users', 'is_temporary', + existing_type=sa.BOOLEAN(), + nullable=True) + op.alter_column('users', 'date_joined', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.alter_column('users', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=True) + op.alter_column('users', 'is_staff', + existing_type=sa.BOOLEAN(), + nullable=True) + op.alter_column('users', 'is_admin', + existing_type=sa.BOOLEAN(), + nullable=True) + op.alter_column('users', 'phone_number', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('users', 'email', + existing_type=sa.VARCHAR(), + nullable=True) + op.create_index(op.f('ix_user_addresses_id'), 'user_addresses', ['id'], unique=False) + op.alter_column('user_addresses', 'house', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('user_addresses', 'street', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('user_addresses', 'city', + existing_type=sa.VARCHAR(), + nullable=True) + op.create_index(op.f('ix_stock_records_id'), 'stock_records', ['id'], unique=False) + op.alter_column('stock_records', 'date_created', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.alter_column('stock_records', 'quantity', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('stock_records', 'price', + existing_type=sa.Float(), + type_=sa.NUMERIC(), + nullable=True) + op.alter_column('stock_records', 'product_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False) + op.alter_column('products', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=True) + op.alter_column('products', 'description', + existing_type=sqlmodel.sql.sqltypes.AutoString(), + type_=sa.TEXT(), + existing_nullable=True) + op.alter_column('products', 'title', + existing_type=sa.VARCHAR(), + nullable=True) + op.create_index(op.f('ix_product_images_id'), 'product_images', ['id'], unique=False) + op.alter_column('product_images', 'product_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_product_discounts_id'), 'product_discounts', ['id'], unique=False) + op.alter_column('product_discounts', 'valid_to', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.alter_column('product_discounts', 'valid_from', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.alter_column('product_discounts', 'discount_amount', + existing_type=sa.Float(), + type_=sa.NUMERIC(), + existing_nullable=True) + op.alter_column('product_discounts', 'product_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_product_categories_id'), 'product_categories', ['id'], unique=False) + op.alter_column('product_categories', 'category_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('product_categories', 'product_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False) + op.alter_column('categories', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=True) + op.alter_column('categories', 'description', + existing_type=sqlmodel.sql.sqltypes.AutoString(), + type_=sa.TEXT(), + existing_nullable=True) + op.alter_column('categories', 'title', + existing_type=sa.VARCHAR(), + nullable=True) + op.drop_table('company') + # ### end Alembic commands ### diff --git a/src/catalogue/models/database.py b/src/catalogue/models/database.py index cc5ba136..3fdb8a21 100644 --- a/src/catalogue/models/database.py +++ b/src/catalogue/models/database.py @@ -24,6 +24,26 @@ class Product(SQLModel, table=True): images: List["ProductImage"] = Relationship(back_populates="product") stock_records: List["StockRecord"] = Relationship(back_populates="product") discounts: List["ProductDiscount"] = Relationship(back_populates="product") + + # Relationships for Additional Products + additional_products_as_primary: List["AdditionalProducts"] = Relationship( + back_populates="primary_product", + sa_relationship_kwargs={"foreign_keys": "AdditionalProducts.primary_id"} + ) + additional_products_as_additional: List["AdditionalProducts"] = Relationship( + back_populates="additional_product", + sa_relationship_kwargs={"foreign_keys": "AdditionalProducts.additional_id"} + ) + + # Relationships for Recommended Products + recommended_products_as_primary: List["RecommendedProducts"] = Relationship( + back_populates="primary_product", + sa_relationship_kwargs={"foreign_keys": "RecommendedProducts.primary_id"} + ) + recommended_products_as_recommended: List["RecommendedProducts"] = Relationship( + back_populates="recommended_product", + sa_relationship_kwargs={"foreign_keys": "RecommendedProducts.recommended_id"} + ) class ProductCategory(SQLModel, table=True): __tablename__ = 'product_categories' @@ -90,3 +110,41 @@ class ProductDiscount(SQLModel, table=True): valid_to: datetime product: Product = Relationship(back_populates="discounts") + + +class AdditionalProducts(SQLModel, table=True): + """Model for additional products.""" + __tablename__ = 'additional_products' + + id: Optional[int] = Field(default=None, primary_key=True) + primary_id: int = Field(foreign_key="products.id") + additional_id: int = Field(foreign_key="products.id") + + # Relationships + primary_product: Product = Relationship( + back_populates="additional_products_as_primary", + sa_relationship_kwargs={"foreign_keys": "AdditionalProducts.primary_id"} + ) + additional_product: Product = Relationship( + back_populates="additional_products_as_additional", + sa_relationship_kwargs={"foreign_keys": "AdditionalProducts.additional_id"} + ) + + +class RecommendedProducts(SQLModel, table=True): + """Model for recommended products.""" + __tablename__ = 'recommended_products' + + id: Optional[int] = Field(default=None, primary_key=True) + primary_id: int = Field(foreign_key="products.id") + recommended_id: int = Field(foreign_key="products.id") + + # Relationships + primary_product: Product = Relationship( + back_populates="recommended_products_as_primary", + sa_relationship_kwargs={"foreign_keys": "RecommendedProducts.primary_id"} + ) + recommended_product: Product = Relationship( + back_populates="recommended_products_as_recommended", + sa_relationship_kwargs={"foreign_keys": "RecommendedProducts.recommended_id"} + ) diff --git a/src/catalogue/repository.py b/src/catalogue/repository.py index fab3dcd5..0d0dcd68 100644 --- a/src/catalogue/repository.py +++ b/src/catalogue/repository.py @@ -1,7 +1,11 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from src.catalogue.models.database import Product +from src.catalogue.models.database import ( + Product, + AdditionalProducts, + RecommendedProducts, +) from src.common.databases.postgres import get_session from src.common.repository.sqlalchemy import BaseSQLAlchemyRepository @@ -11,5 +15,27 @@ def __init__(self, session: AsyncSession): super().__init__(model=Product, session=session) +class AdditionalProductsRepository(BaseSQLAlchemyRepository[AdditionalProducts]): + def __init__(self, session: AsyncSession): + super().__init__(model=AdditionalProducts, session=session) + + +class RecommendedProductsRepository(BaseSQLAlchemyRepository[RecommendedProducts]): + def __init__(self, session: AsyncSession): + super().__init__(model=RecommendedProducts, session=session) + + def get_product_repository(session: AsyncSession = Depends(get_session)) -> ProductRepository: return ProductRepository(session=session) + + +def get_additional_products_repository( + session: AsyncSession = Depends(get_session) +) -> AdditionalProductsRepository: + return AdditionalProductsRepository(session=session) + + +def get_recommended_products_repository( + session: AsyncSession = Depends(get_session) +) -> RecommendedProductsRepository: + return RecommendedProductsRepository(session=session) diff --git a/src/catalogue/services.py b/src/catalogue/services.py index 53810517..abfdfd47 100644 --- a/src/catalogue/services.py +++ b/src/catalogue/services.py @@ -1,17 +1,180 @@ +from typing import Optional from fastapi import Depends -from src.catalogue.models.database import Product +from src.catalogue.models.database import ( + Product, + AdditionalProducts, + RecommendedProducts, +) from src.catalogue.repository import ( ProductRepository, get_product_repository, + AdditionalProductsRepository, + get_additional_products_repository, + RecommendedProductsRepository, + get_recommended_products_repository, ) from src.common.service import BaseService class ProductService(BaseService[Product]): - def __init__(self, repository: ProductRepository): + def __init__( + self, + repository: ProductRepository, + additional_products_service: Optional["AdditionalProductsService"] = None, + recommended_products_service: Optional["RecommendedProductsService"] = None + ): + super().__init__(repository) + self._additional_products_service = additional_products_service + self._recommended_products_service = recommended_products_service + + async def get_product_with_related(self, product_id: int) -> dict: + """Отримати товар разом із додатковими та рекомендованими товарами.""" + product = await self.repository.get(pk=product_id) + if not product: + return None + + result = { + "product": product, + "additional_products": [], + "recommended_products": [] + } + + if self._additional_products_service: + result["additional_products"] = await self._additional_products_service.get_additional_products_for_product(product_id) + + if self._recommended_products_service: + result["recommended_products"] = await self._recommended_products_service.get_recommended_products_for_product(product_id) + + return result + + +class AdditionalProductsService(BaseService[AdditionalProducts]): + def __init__(self, repository: AdditionalProductsRepository): + super().__init__(repository) + + async def get_additional_products_for_product(self, product_id: int) -> list[AdditionalProducts]: + """Get additional products for product by ID.""" + return await self.repository.filter(primary_id=product_id) + + async def get_additional_product_by_id(self, additional_product_id: int) -> AdditionalProducts | None: + """Get additional product by ID.""" + return await self.repository.get(pk=additional_product_id) + + async def add_additional_product(self, primary_id: int, additional_id: int) -> AdditionalProducts: + """Add additional product to main product by ID.""" + + existing = await self.repository.filter(primary_id=primary_id, additional_id=additional_id) + if existing: + raise ValueError(f"Additional product {additional_id} already related to {primary_id}") + + + if primary_id == additional_id: + raise ValueError("Can't add additional product to the same product.") + + additional_product = AdditionalProducts(primary_id=primary_id, additional_id=additional_id) + return await self.repository.create(additional_product) + + async def update_additional_product(self, additional_product_id: int, primary_id: int, additional_id: int) -> AdditionalProducts: + """Update additional product by ID. It's possible to change primary_id and additional_id at the same time.""" + if primary_id == additional_id: + raise ValueError("Product can't be related to itself.") + + existing = await self.repository.filter(primary_id=primary_id, additional_id=additional_id) + if existing and any(item.id != additional_product_id for item in existing): + raise ValueError(f"Addtiional product {additional_id} already related to {primary_id}") + + update_data = AdditionalProducts(primary_id=primary_id, additional_id=additional_id) + return await self.repository.update(pk=additional_product_id, update_data=update_data) + + async def remove_additional_product(self, additional_product_id: int) -> None: + """Remove additional product by ID.""" + await self.repository.delete(pk=additional_product_id) + + async def remove_additional_product_by_ids(self, primary_id: int, additional_id: int) -> bool: + """Remove additional product by ID. It's possible to remove multiple additional products for one product.""" + existing = await self.repository.filter(primary_id=primary_id, additional_id=additional_id) + if not existing: + return False + + for item in existing: + await self.repository.delete(pk=item.id) + return True + + async def count_additional_products_for_product(self, product_id: int) -> int: + """Calculate count of additional products for product by ID.""" + additional_products = await self.repository.filter(primary_id=product_id) + return len(additional_products) + + +class RecommendedProductsService(BaseService[RecommendedProducts]): + def __init__(self, repository: RecommendedProductsRepository): super().__init__(repository) + + async def get_recommended_products_for_product(self, product_id: int) -> list[RecommendedProducts]: + """Get recommended products for product by ID.""" + return await self.repository.filter(primary_id=product_id) + + async def get_recommended_product_by_id(self, recommended_product_id: int) -> RecommendedProducts | None: + """Get recommended product by ID.""" + return await self.repository.get(pk=recommended_product_id) + + async def add_recommended_product(self, primary_id: int, recommended_id: int) -> RecommendedProducts: + """Add recommended product to main product by ID.""" + existing = await self.repository.filter(primary_id=primary_id, recommended_id=recommended_id) + if existing: + raise ValueError(f"Recommended {recommended_id} already related to {primary_id}") + + + if primary_id == recommended_id: + raise ValueError("Product can't be recommended to itself.") + + recommended_product = RecommendedProducts(primary_id=primary_id, recommended_id=recommended_id) + return await self.repository.create(recommended_product) + + async def update_recommended_product(self, recommended_product_id: int, primary_id: int, recommended_id: int) -> RecommendedProducts: + if primary_id == recommended_id: + raise ValueError("Product can't be recommended to itself.") + + + existing = await self.repository.filter(primary_id=primary_id, recommended_id=recommended_id) + if existing and any(item.id != recommended_product_id for item in existing): + raise ValueError(f"Recommended product {recommended_id} already related {primary_id}") + + update_data = RecommendedProducts(primary_id=primary_id, recommended_id=recommended_id) + return await self.repository.update(pk=recommended_product_id, update_data=update_data) + + async def remove_recommended_product(self, recommended_product_id: int) -> None: + """Remove recommended product by ID. It's possible to remove multiple recommended products for one product by ID.""" + await self.repository.delete(pk=recommended_product_id) + + async def remove_recommended_product_by_ids(self, primary_id: int, recommended_id: int) -> bool: + """Remove recommended product relationship by ID. It's possible to remove multiple recommended products for one product by ID.""" + existing = await self.repository.filter(primary_id=primary_id, recommended_id=recommended_id) + if not existing: + return False + + for item in existing: + await self.repository.delete(pk=item.id) + return True + + async def count_recommended_products_for_product(self, product_id: int) -> int: + """Calculate count of recommended products for product by ID.""" + recommended_products = await self.repository.filter(primary_id=product_id) + return len(recommended_products) def get_product_service(repo: ProductRepository = Depends(get_product_repository)) -> ProductService: return ProductService(repository=repo) + + +def get_additional_products_service( + repo: AdditionalProductsRepository = Depends(get_additional_products_repository) +) -> AdditionalProductsService: + return AdditionalProductsService(repository=repo) + + +def get_recommended_products_service( + repo: RecommendedProductsRepository = Depends(get_recommended_products_repository) +) -> RecommendedProductsService: + return RecommendedProductsService(repository=repo)