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/d9f8524aadcd_.py b/migrations/versions/d9f8524aadcd_.py new file mode 100644 index 00000000..c4156c14 --- /dev/null +++ b/migrations/versions/d9f8524aadcd_.py @@ -0,0 +1,245 @@ +"""empty message + +Revision ID: d9f8524aadcd +Revises: 799909f2b79d +Create Date: 2025-09-18 16:07:12.322371 + +""" +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 = 'd9f8524aadcd' +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.add_column('additional_products', sa.Column('primary_id', sa.Integer(), nullable=False)) + op.create_foreign_key(None, 'additional_products', 'products', ['primary_id'], ['id']) + op.drop_column('additional_products', '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.add_column('recommended_products', sa.Column('primary_id', sa.Integer(), nullable=False)) + op.create_foreign_key(None, 'recommended_products', 'products', ['primary_id'], ['id']) + op.drop_column('recommended_products', 'id') + 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.add_column('recommended_products', sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False)) + op.drop_constraint(None, 'recommended_products', type_='foreignkey') + op.drop_column('recommended_products', 'primary_id') + 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.add_column('additional_products', sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False)) + op.drop_constraint(None, 'additional_products', type_='foreignkey') + op.drop_column('additional_products', 'primary_id') + op.drop_table('company') + # ### end Alembic commands ### diff --git a/src/catalogue/models/database.py b/src/catalogue/models/database.py index cc5ba136..5c0ecff7 100644 --- a/src/catalogue/models/database.py +++ b/src/catalogue/models/database.py @@ -11,6 +11,22 @@ ) +class AdditionalProducts(SQLModel, table=True): + __tablename__ = 'additional_products' + + primary_id: int = Field(foreign_key="products.id", primary_key=True) + additional_id: int = Field(foreign_key="products.id", primary_key=True) + + + +class RecommendedProducts(SQLModel, table=True): + __tablename__ = 'recommended_products' + + primary_id: int = Field(foreign_key="products.id", primary_key=True) + recommended_id: int = Field(foreign_key="products.id", primary_key=True) + + + class Product(SQLModel, table=True): __tablename__ = 'products' @@ -25,6 +41,27 @@ class Product(SQLModel, table=True): stock_records: List["StockRecord"] = Relationship(back_populates="product") discounts: List["ProductDiscount"] = Relationship(back_populates="product") + primary_id: List["Product"] = Relationship(back_populates="additional_id", + link_model=AdditionalProducts, + sa_relationship_kwargs={"primaryjoin": "AdditionalProducts.primary_id==Product.id", + "secondaryjoin": "AdditionalProducts.additional_id==Product.id"}) + additional_id: List["Product"] = Relationship(back_populates="primary_id", + link_model=AdditionalProducts, + sa_relationship_kwargs={"primaryjoin": "AdditionalProducts.additional_id==Product.id", + "secondaryjoin": "AdditionalProducts.primary_id==Product.id"}) + + + recommended_primary_id: List["Product"] = Relationship(back_populates="recommended_id", + link_model=RecommendedProducts, + sa_relationship_kwargs={"primaryjoin": "RecommendedProducts.primary_id==Product.id", + "secondaryjoin": "RecommendedProducts.recommended_id==Product.id"}) + recommended_id: List["Product"] = Relationship(back_populates="recommended_primary_id", + link_model=RecommendedProducts, + sa_relationship_kwargs={"primaryjoin": "RecommendedProducts.recommended_id==Product.id", + "secondaryjoin": "RecommendedProducts.primary_id==Product.id"}) + + + class ProductCategory(SQLModel, table=True): __tablename__ = 'product_categories' @@ -90,3 +127,7 @@ class ProductDiscount(SQLModel, table=True): valid_to: datetime product: Product = Relationship(back_populates="discounts") + + + + diff --git a/src/catalogue/repository.py b/src/catalogue/repository.py index fab3dcd5..b44709e9 100644 --- a/src/catalogue/repository.py +++ b/src/catalogue/repository.py @@ -1,7 +1,7 @@ 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 @@ -13,3 +13,21 @@ def __init__(self, session: AsyncSession): def get_product_repository(session: AsyncSession = Depends(get_session)) -> ProductRepository: return ProductRepository(session=session) + + +class AdditionalProductRepository(BaseSQLAlchemyRepository[AdditionalProducts]): + def __init__(self, session: AsyncSession): + super().__init__(model=AdditionalProducts, session=session) + + +def get_additional_product_repository(session: AsyncSession = Depends(get_session)) -> AdditionalProductRepository: + return AdditionalProductRepository(session=session) + + +class RecommendedProductRepository(BaseSQLAlchemyRepository[RecommendedProducts]): + def __init__(self, session: AsyncSession): + super().__init__(model=RecommendedProducts, session=session) + + +def get_recommended_product_repository(session: AsyncSession = Depends(get_session)) -> RecommendedProductRepository: + return RecommendedProductRepository(session=session) \ No newline at end of file diff --git a/src/catalogue/services.py b/src/catalogue/services.py index 53810517..2e2bd8fc 100644 --- a/src/catalogue/services.py +++ b/src/catalogue/services.py @@ -1,9 +1,10 @@ from fastapi import Depends -from src.catalogue.models.database import Product +from src.catalogue.models.database import Product, RecommendedProducts, AdditionalProducts from src.catalogue.repository import ( ProductRepository, - get_product_repository, + get_product_repository, AdditionalProductRepository, RecommendedProductRepository, + get_additional_product_repository, get_recommended_product_repository, ) from src.common.service import BaseService @@ -15,3 +16,22 @@ def __init__(self, repository: ProductRepository): def get_product_service(repo: ProductRepository = Depends(get_product_repository)) -> ProductService: return ProductService(repository=repo) + + + +class AdditionalProductService(BaseService[AdditionalProducts]): + def __init__(self, repository: AdditionalProductRepository): + super().__init__(repository) + + +def get_additional_product_service(repo: AdditionalProductRepository = Depends(get_additional_product_repository)) -> AdditionalProductService: + return AdditionalProductService(repository=repo) + + +class RecommendedProductService(BaseService[RecommendedProducts]): + def __init__(self, repository: RecommendedProductRepository): + super().__init__(repository) + + +def get_recommended_product_service(repo: RecommendedProductRepository = Depends(get_recommended_product_repository)) -> RecommendedProductService: + return RecommendedProductService(repository=repo) \ No newline at end of file diff --git a/src/catalogue/views/product.py b/src/catalogue/views/product.py index a1d9e6ca..ce2c2cdc 100644 --- a/src/catalogue/views/product.py +++ b/src/catalogue/views/product.py @@ -1,6 +1,6 @@ from typing import ( Annotated, - Union, + Union, List, ) from fastapi import ( @@ -10,12 +10,13 @@ status, ) -from src.catalogue.models.database import Product +from src.catalogue.models.database import Product, AdditionalProducts, RecommendedProducts + from src.catalogue.routes import ( CatalogueRoutesPrefixes, ProductRoutesPrefixes, ) -from src.catalogue.services import get_product_service +from src.catalogue.services import get_product_service, get_additional_product_service, get_recommended_product_service from src.common.exceptions.base import ObjectDoesNotExistException from src.common.schemas.common import ErrorResponse @@ -38,6 +39,30 @@ async def product_list(product_service: Annotated[get_product_service, Depends() return await product_service.list() +@router.get( + ProductRoutesPrefixes.additional, + status_code=status.HTTP_200_OK, + response_model=list[AdditionalProducts], +) +async def additional_list(additional_service: Annotated[get_additional_product_service, Depends()]) -> list[AdditionalProducts]: + return await additional_service.list() + + +@router.get( + ProductRoutesPrefixes.recommended, + responses={ + status.HTTP_200_OK: {'model': RecommendedProducts}, + status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}, + }, + status_code=status.HTTP_200_OK, + response_model=Union[RecommendedProducts, ErrorResponse], +) +async def recommended_products( + service: Annotated[get_recommended_product_service, Depends()]) -> Union[Response, ErrorResponse]: + + return await service.list() + + @router.get( ProductRoutesPrefixes.detail, responses={ @@ -65,3 +90,22 @@ async def product_detail( return ErrorResponse(message=exc.message) return response + + +@router.post( + ProductRoutesPrefixes.additional, + response_model=AdditionalProducts, + status_code=status.HTTP_201_CREATED, +) +async def add_additional_product( + data: AdditionalProducts, + service: Annotated[get_additional_product_service, Depends()]): + return await service.create(data) + + + + + + + + diff --git a/src/common/routes.py b/src/common/routes.py index 2a12733c..0f434a49 100644 --- a/src/common/routes.py +++ b/src/common/routes.py @@ -1,3 +1,5 @@ class BaseCrudPrefixes: root: str = '/' detail: str = '/{pk}' + additional: str = '/additional' + recommended: str = '/recommended'