diff --git a/Dockerfile b/Dockerfile index be09a305..c36f37ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,10 @@ WORKDIR /app COPY pyproject.toml pyproject.toml -RUN pip install poetry -RUN poetry config virtualenvs.create false -RUN poetry install --no-dev +RUN pip install --no-cache-dir poetry \ + && poetry config virtualenvs.create false + +RUN poetry install --no-root --no-interaction --no-ansi COPY . /app diff --git a/docker-compose.yml b/docker-compose.yml index 8fcb739c..db5fab03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: web: build: . @@ -23,6 +21,8 @@ services: POSTGRES_USER: user POSTGRES_PASSWORD: password POSTGRES_DB: fastapi_shop + ports: + - "5432:5432" networks: - web_network diff --git a/src/catalogue/admin.py b/src/catalogue/admin.py index 3b9ab8f8..aff85978 100644 --- a/src/catalogue/admin.py +++ b/src/catalogue/admin.py @@ -1,11 +1,13 @@ from sqladmin import ModelView from src.catalogue.models.database import ( + AdditionalProduct, Category, Product, ProductCategory, ProductDiscount, ProductImage, + RecommendedProduct, StockRecord, ) @@ -62,6 +64,22 @@ class ProductDiscountAdmin(ModelView, model=ProductDiscount): category = CATALOGUE_CATEGORY +class AdditionalProductsAdmin(ModelView, model=AdditionalProduct): + column_list = [AdditionalProduct.primary_product, AdditionalProduct.additional_product] + column_searchable_list = [AdditionalProduct.primary_id, AdditionalProduct.additional_id] + form_columns = ['primary_product', 'additional_product'] + icon = 'fa-solid fa-plus' + category = CATALOGUE_CATEGORY + + +class RecommendedProductsAdmin(ModelView, model=RecommendedProduct): + column_list = [RecommendedProduct.primary_product, RecommendedProduct.recommended_product] + column_searchable_list = [RecommendedProduct.primary_id, RecommendedProduct.recommended_id] + form_columns = ['primary_product', 'recommended_product'] + icon = 'fa-solid fa-star' + category = CATALOGUE_CATEGORY + + def register_products_admin_views(admin): admin.add_view(ProductAdmin) admin.add_view(ProductCategoryAdmin) @@ -69,3 +87,5 @@ def register_products_admin_views(admin): admin.add_view(ProductImageAdmin) admin.add_view(StockRecordAdmin) admin.add_view(ProductDiscountAdmin) + admin.add_view(AdditionalProductsAdmin) + admin.add_view(RecommendedProductsAdmin) diff --git a/src/catalogue/models/database.py b/src/catalogue/models/database.py index cc5ba136..424983e7 100644 --- a/src/catalogue/models/database.py +++ b/src/catalogue/models/database.py @@ -24,6 +24,17 @@ 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") + additional_products: List["AdditionalProduct"] = Relationship( + back_populates="primary_product", + sa_relationship_kwargs={"foreign_keys": "AdditionalProduct.primary_id"} + ) + recommended_products: List["RecommendedProduct"] = Relationship( + back_populates="primary_product", + sa_relationship_kwargs={"foreign_keys": "RecommendedProduct.primary_id"} + ) + + def __str__(self) -> str: + return f"{self.id} - {self.title}" class ProductCategory(SQLModel, table=True): __tablename__ = 'product_categories' @@ -90,3 +101,35 @@ class ProductDiscount(SQLModel, table=True): valid_to: datetime product: Product = Relationship(back_populates="discounts") + + +class AdditionalProduct(SQLModel, table=True): + __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") + + primary_product: Product = Relationship( + back_populates="additional_products", + sa_relationship_kwargs={"foreign_keys": "AdditionalProduct.primary_id"} + ) + additional_product: Product = Relationship( + sa_relationship_kwargs={"foreign_keys": "AdditionalProduct.additional_id"} + ) + + +class RecommendedProduct(SQLModel, table=True): + __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") + + primary_product: Product = Relationship( + back_populates="recommended_products", + sa_relationship_kwargs={"foreign_keys": "RecommendedProduct.primary_id"} + ) + recommended_product: Product = Relationship( + sa_relationship_kwargs={"foreign_keys": "RecommendedProduct.recommended_id"} + ) diff --git a/src/catalogue/repository.py b/src/catalogue/repository.py index fab3dcd5..3c7ea55c 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, AdditionalProduct, RecommendedProduct 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 AdditionalProductsRepository(BaseSQLAlchemyRepository[AdditionalProduct]): + def __init__(self, session: AsyncSession): + super().__init__(model=AdditionalProduct, session=session) + + +def get_additional_products_repository(session: AsyncSession = Depends(get_session)) -> AdditionalProductsRepository: + return AdditionalProductsRepository(session=session) + + +class RecommendedProductsRepository(BaseSQLAlchemyRepository[RecommendedProduct]): + def __init__(self, session: AsyncSession): + super().__init__(model=RecommendedProduct, session=session) + + +def get_recommended_products_repository(session: AsyncSession = Depends(get_session)) -> RecommendedProductsRepository: + return RecommendedProductsRepository(session=session) diff --git a/src/catalogue/routes.py b/src/catalogue/routes.py index c40fe3ac..c54aa256 100644 --- a/src/catalogue/routes.py +++ b/src/catalogue/routes.py @@ -3,7 +3,17 @@ class CatalogueRoutesPrefixes: product: str = '/product' + additional_products: str = '/additional-products' + recommended_products: str = '/recommended-products' class ProductRoutesPrefixes(BaseCrudPrefixes): ... + + +class AdditionalProductsRoutesPrefixes(BaseCrudPrefixes): + ... + + +class RecommendedProductsRoutesPrefixes(BaseCrudPrefixes): + ... diff --git a/src/catalogue/services.py b/src/catalogue/services.py index 53810517..f22f9999 100644 --- a/src/catalogue/services.py +++ b/src/catalogue/services.py @@ -1,9 +1,13 @@ from fastapi import Depends -from src.catalogue.models.database import Product +from src.catalogue.models.database import Product, AdditionalProduct, RecommendedProduct 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 @@ -15,3 +19,21 @@ def __init__(self, repository: ProductRepository): def get_product_service(repo: ProductRepository = Depends(get_product_repository)) -> ProductService: return ProductService(repository=repo) + + +class AdditionalProductsService(BaseService[AdditionalProduct]): + def __init__(self, repository: AdditionalProductsRepository): + super().__init__(repository) + + +def get_additional_products_service(repo: AdditionalProductsRepository = Depends(get_additional_products_repository)) -> AdditionalProductsService: + return AdditionalProductsService(repository=repo) + + +class RecommendedProductsService(BaseService[RecommendedProduct]): + def __init__(self, repository: RecommendedProductsRepository): + super().__init__(repository) + + +def get_recommended_products_service(repo: RecommendedProductsRepository = Depends(get_recommended_products_repository)) -> RecommendedProductsService: + return RecommendedProductsService(repository=repo) diff --git a/src/catalogue/views/__init__.py b/src/catalogue/views/__init__.py index ab8e1772..c6dd9460 100644 --- a/src/catalogue/views/__init__.py +++ b/src/catalogue/views/__init__.py @@ -1 +1,3 @@ from .product import router as product_router +from .additional_products import router as additional_products_router +from .recommended_products import router as recommended_products_router diff --git a/src/catalogue/views/additional_products.py b/src/catalogue/views/additional_products.py new file mode 100644 index 00000000..39880826 --- /dev/null +++ b/src/catalogue/views/additional_products.py @@ -0,0 +1,139 @@ +from typing import ( + Annotated, + Union, +) + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Response, + status, +) + +from src.catalogue.models.database import AdditionalProduct +from src.catalogue.routes import ( + CatalogueRoutesPrefixes, + AdditionalProductsRoutesPrefixes, +) +from src.catalogue.services import get_additional_products_service +from src.common.exceptions.base import ObjectDoesNotExistException +from src.common.schemas.common import ErrorResponse + + +router = APIRouter(prefix=CatalogueRoutesPrefixes.additional_products) + + +@router.get( + AdditionalProductsRoutesPrefixes.root, + status_code=status.HTTP_200_OK, + response_model=list[AdditionalProduct], +) +async def additional_products_list(service: Annotated[get_additional_products_service, Depends()]) -> list[AdditionalProduct]: + """ + Get list of additional products. + + Returns: + Response with list of additional products. + """ + return await service.list() + + +@router.get( + AdditionalProductsRoutesPrefixes.detail, + responses={ + status.HTTP_200_OK: {'model': AdditionalProduct}, + status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}, + }, + status_code=status.HTTP_200_OK, + response_model=Union[AdditionalProduct, ErrorResponse], +) +async def additional_products_detail( + response: Response, + pk: int, + service: Annotated[get_additional_products_service, Depends()], +) -> Union[Response, ErrorResponse]: + """ + Retrieve additional product. + + Returns: + Response with additional product details. + """ + try: + response = await service.detail(pk=pk) + except ObjectDoesNotExistException as exc: + response.status_code = status.HTTP_404_NOT_FOUND + return ErrorResponse(message=exc.message) + + return response + + +@router.post( + AdditionalProductsRoutesPrefixes.root, + status_code=status.HTTP_201_CREATED, + response_model=AdditionalProduct, +) +async def additional_products_create( + additional_product_data: AdditionalProduct, + service: Annotated[get_additional_products_service, Depends()], +) -> AdditionalProduct: + """ + Create additional product. + + Returns: + Response with created additional product. + """ + return await service.create(additional_product_data) + + +@router.put( + AdditionalProductsRoutesPrefixes.detail, + responses={ + status.HTTP_200_OK: {'model': AdditionalProduct}, + status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}, + }, + status_code=status.HTTP_200_OK, + response_model=Union[AdditionalProduct, ErrorResponse], +) +async def additional_products_update( + response: Response, + pk: int, + additional_product_data: AdditionalProduct, + service: Annotated[get_additional_products_service, Depends()], +) -> Union[Response, ErrorResponse]: + """ + Update additional product. + + Returns: + Response with updated additional product. + """ + try: + response = await service.update(pk=pk, update_data=additional_product_data) + except ObjectDoesNotExistException as exc: + response.status_code = status.HTTP_404_NOT_FOUND + return ErrorResponse(message=exc.message) + + return response + + +@router.delete( + AdditionalProductsRoutesPrefixes.detail, + status_code=status.HTTP_204_NO_CONTENT, +) +async def additional_products_delete( + pk: int, + service: Annotated[get_additional_products_service, Depends()], +) -> None: + """ + Delete additional product. + + Returns: + Response confirming deletion. + """ + try: + await service.delete(pk=pk) + except ObjectDoesNotExistException as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=exc.message + ) \ No newline at end of file diff --git a/src/catalogue/views/recommended_products.py b/src/catalogue/views/recommended_products.py new file mode 100644 index 00000000..bf81d34e --- /dev/null +++ b/src/catalogue/views/recommended_products.py @@ -0,0 +1,139 @@ +from typing import ( + Annotated, + Union, +) + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Response, + status, +) + +from src.catalogue.models.database import RecommendedProduct +from src.catalogue.routes import ( + CatalogueRoutesPrefixes, + RecommendedProductsRoutesPrefixes, +) +from src.catalogue.services import get_recommended_products_service +from src.common.exceptions.base import ObjectDoesNotExistException +from src.common.schemas.common import ErrorResponse + + +router = APIRouter(prefix=CatalogueRoutesPrefixes.recommended_products) + + +@router.get( + RecommendedProductsRoutesPrefixes.root, + status_code=status.HTTP_200_OK, + response_model=list[RecommendedProduct], +) +async def recommended_products_list(service: Annotated[get_recommended_products_service, Depends()]) -> list[RecommendedProduct]: + """ + Get list of recommended products. + + Returns: + Response with list of recommended products. + """ + return await service.list() + + +@router.get( + RecommendedProductsRoutesPrefixes.detail, + responses={ + status.HTTP_200_OK: {'model': RecommendedProduct}, + status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}, + }, + status_code=status.HTTP_200_OK, + response_model=Union[RecommendedProduct, ErrorResponse], +) +async def recommended_products_detail( + response: Response, + pk: int, + service: Annotated[get_recommended_products_service, Depends()], +) -> Union[Response, ErrorResponse]: + """ + Retrieve recommended product. + + Returns: + Response with recommended product details. + """ + try: + response = await service.detail(pk=pk) + except ObjectDoesNotExistException as exc: + response.status_code = status.HTTP_404_NOT_FOUND + return ErrorResponse(message=exc.message) + + return response + + +@router.post( + RecommendedProductsRoutesPrefixes.root, + status_code=status.HTTP_201_CREATED, + response_model=RecommendedProduct, +) +async def recommended_products_create( + recommended_product_data: RecommendedProduct, + service: Annotated[get_recommended_products_service, Depends()], +) -> RecommendedProduct: + """ + Create recommended product. + + Returns: + Response with created recommended product. + """ + return await service.create(recommended_product_data) + + +@router.put( + RecommendedProductsRoutesPrefixes.detail, + responses={ + status.HTTP_200_OK: {'model': RecommendedProduct}, + status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}, + }, + status_code=status.HTTP_200_OK, + response_model=Union[RecommendedProduct, ErrorResponse], +) +async def recommended_products_update( + response: Response, + pk: int, + recommended_product_data: RecommendedProduct, + service: Annotated[get_recommended_products_service, Depends()], +) -> Union[Response, ErrorResponse]: + """ + Update recommended product. + + Returns: + Response with updated recommended product. + """ + try: + response = await service.update(pk=pk, update_data=recommended_product_data) + except ObjectDoesNotExistException as exc: + response.status_code = status.HTTP_404_NOT_FOUND + return ErrorResponse(message=exc.message) + + return response + + +@router.delete( + RecommendedProductsRoutesPrefixes.detail, + status_code=status.HTTP_204_NO_CONTENT, +) +async def recommended_products_delete( + pk: int, + service: Annotated[get_recommended_products_service, Depends()], +) -> None: + """ + Delete recommended product. + + Returns: + Response confirming deletion. + """ + try: + await service.delete(pk=pk) + except ObjectDoesNotExistException as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=exc.message + ) \ No newline at end of file diff --git a/src/main.py b/src/main.py index bd2fab34..49cb7946 100644 --- a/src/main.py +++ b/src/main.py @@ -6,7 +6,7 @@ from src.admin import register_admin_views from src.authentication.views import router as auth_router from src.base_settings import base_settings -from src.catalogue.views import product_router +from src.catalogue.views import product_router, additional_products_router, recommended_products_router from src.common.databases.postgres import ( engine, init_db, @@ -37,6 +37,16 @@ def include_routes(application: FastAPI) -> None: prefix=BaseRoutesPrefixes.catalogue, tags=['Catalogue'], ) + application.include_router( + router=additional_products_router, + prefix=BaseRoutesPrefixes.catalogue, + tags=['Catalogue'], + ) + application.include_router( + router=recommended_products_router, + prefix=BaseRoutesPrefixes.catalogue, + tags=['Catalogue'], + ) application.include_router( router=user_router, prefix=BaseRoutesPrefixes.account, diff --git a/src/users/services.py b/src/users/services.py index 4a3e0ead..2e08ba41 100644 --- a/src/users/services.py +++ b/src/users/services.py @@ -3,6 +3,7 @@ from fastapi import Depends from src.authentication.security import verify_password +from src.common.exceptions.base import ObjectDoesNotExistException from src.common.service import BaseService from src.users.models.database import User from src.users.repository import ( @@ -15,8 +16,11 @@ class UserService(BaseService[User]): def __init__(self, repository: UserRepository): super().__init__(repository) - async def get_by_email(self, email: str): - return await self.repository.get_by_email(email=email) + async def get_by_email(self, email: str) -> Optional[User]: + try: + return await self.repository.get_by_email(email=email) + except ObjectDoesNotExistException: + return None async def authenticate(self, email: str, password: str) -> Optional[User]: user = await self.get_by_email(email=email)