diff --git a/.env.sample b/.env.sample index c20cd63..8b410f5 100644 --- a/.env.sample +++ b/.env.sample @@ -10,6 +10,12 @@ POSTGRES_PASSWORD= POSTGRES_HOST= POSTGRES_PORT= +TEST_POSTGRES_DB= +TEST_POSTGRES_USER= +TEST_POSTGRES_PASSWORD= +TEST_POSTGRES_HOST= +TEST_POSTGRES_PORT= + CORS_ALLOWED_HOST= CORS_ALLOW_ALL_ORIGINS= diff --git a/README.md b/README.md index 87d9aa9..5f71de8 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,10 @@ License Telegram Support me on Paypal + Support me on Paypal

- -[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/tigran-saatchyan/UniMart_by_EvoQ) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - ## Table of Contents * [Background / Overview](#background--overview) @@ -25,12 +22,10 @@ * [Documentation](#documentation) * [Browser Support](#browser-support) * [Dependencies](#dependencies) -* [Todo](#todo) * [Release History](#release-history) * [Changelog](#changelog) * [Issues](#issues) * [Bugs](#bugs) -* [Deployment](#deployment) * [Translations](#translations) * [Authors](#authors) * [Acknowledgments](#acknowledgments) @@ -38,7 +33,12 @@ * [License](#license) ## Background / Overview - +**UniMart by EvoQ API** is a tool that allows +developers to integrate e-commerce features +like product management, inventory control, +order processing, and customer data management +into their applications or websites, making it +easier to run online stores. ## Features @@ -61,9 +61,29 @@ Open your terminal and type in. ### Setting up - ### Structure / Scaffolding ```text + ========= Tigran Saatchyan ~ git version 2.34.1 + =============== ------------------------------------- + ================= Project: UniMart_by_EvoQ (2 branches) + === ============== + =================== Created: 5 days ago + ========== Language: + ========================== ======= ● Python (100.0 %) + ============================ ======== Authors: 100% Tigran Saatchyan 20 +============================= ========= URL: git@github.com:tigran-saatchyan/UniMart_by_EvoQ.git +============================ ========== Commits: 20 +========== ============================ +========= ============================= Lines of code: 1369 + ======== ============================ Size: 244.97 KiB (60 files) + ======= ========================== License: MIT + ========== + =================== + ============== === + ================= + =============== + ========= + ```
@@ -72,15 +92,58 @@ Open your terminal and type in. ```text UniMart_by_EvoQ app +├── __init__.py ├── api +│ ├── __init__.py +│ └── v1 +│ ├── __init__.py +│ ├── auth.py +│ ├── cart.py +│ ├── dependencies.py +│ ├── products.py +│ └── routers.py ├── db +│ ├── __init__.py +│ └── db.py ├── main.py +├── migrations +│ ├── env.py +│ ├── README +│ ├── script.py.mako +│ └── versions ├── models +│ ├── __init__.py +│ ├── base_model.py +│ ├── cart.py +│ ├── products.py +│ └── users.py ├── repositories +│ ├── __init__.py +│ ├── cart.py +│ ├── products.py +│ └── users.py ├── schemas +│ ├── __init__.py +│ ├── cart.py +│ ├── products.py +│ └── users.py ├── services +│ ├── __init__.py +│ ├── cart.py +│ ├── managers.py +│ ├── products.py +│ └── validators.py ├── settings +│ ├── __init__.py +│ ├── auth.py +│ └── config.py └── utils + ├── __init__.py + ├── factories.py + ├── repository.py + ├── unitofwork.py + └── utils.py + ``` @@ -90,36 +153,27 @@ app Note: The scaffolding was generated with tree. ## Documentation +- http://localhost:8000/ - http://localhost:8000/docs -- http://localhost:8000/redoc ## Dependencies List of dependencies used in the project -[//]: # () -[//]: # (| **Main Libraries** | **Other Libraries** |) - -[//]: # (|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|) - -[//]: # (| ![Python Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FAtomicProgress_by_Evoq%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.python&style=flat&logo=python&label=Python) | ![Pillow Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FAtomicProgress_by_Evoq%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.Pillow&style=flat&label=Pillow) |) -[//]: # (| ![Django Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FAtomicProgress_by_Evoq%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.Django&style=flat&logo=django&label=Django) | ![psycopg Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FAtomicProgress_by_Evoq%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.psycopg&style=flat&label=psycopg) |) +| **Main Libraries** | **Other Libraries** | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ![Python Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FUniMart_by_EvoQ%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.python&style=flat&logo=python&label=Python) | ![Alembic](https://img.shields.io/badge/FastAPI--users-%5E2.8.2-blue?logo=FastAPI) | +| ![FastAPI](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FUniMart_by_EvoQ%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.fastapi.version&style=flat&logo=fastapi&label=FastAPI) | ![asyncpg](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FUniMart_by_EvoQ%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.asyncpg&logo=PostgreSQL&style=flat&label=asyncpg) | +| ![SQLAlchemy](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FUniMart_by_EvoQ%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.sqlalchemy&style=flat&logo=sqlalchemy&label=SQLAlchemy) | ![pytest](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FUniMart_by_EvoQ%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.group.develop.dependencies.pytest&logo=pytest&style=flat&label=pytest) | +| ![Alembic](https://img.shields.io/badge/Alembic-%5E1.12.1-blue?logo=Alembic) | ![pytest](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FUniMart_by_EvoQ%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.group.develop.dependencies.ruff&logo=ruff&style=flat&label=ruff) | +| | ![pytest](https://img.shields.io/badge/pre--commit-%5E3.5.0-blue?logo=pre-commit) | +| | ![pytest](https://img.shields.io/badge/pytest--postgresql-%5E5.0.0-blue?logo=pytest) | +| | ![pytest](https://img.shields.io/badge/pytest--asyncio-%5E0.21.1-blue?logo=pytest) | +| | ![pytest](https://img.shields.io/badge/pytest--cov-%5E4.1.0-blue?logo=pytest) | -[//]: # (| ![Redis Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FAtomicProgress_by_Evoq%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.redis&style=flat&logo=redis&label=Redis) | ![Celery Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FAtomicProgress_by_Evoq%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.celery&style=flat&logo=Celery&label=Celery) |) -[//]: # (| ![Django-Celery-Beat Badge](https://img.shields.io/badge/Django--Celery--Beat-%5E2.5.0-blue?logo=Django) | ![drf-yasg Badge](https://img.shields.io/badge/drf--yasg-%5E1.21.7-blue?logo=django) |) - -[//]: # (| ![DRF Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FAtomicProgress_by_Evoq%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.djangorestframework&style=flat&logo=django&label=DRF) | ![django-filter Badge](https://img.shields.io/badge/django--filter-%5E23.3-blue?logo=django) |) - -[//]: # (| | ![DRFSimpleJWT Badge](https://img.shields.io/badge/DRFSimpleJWT-%5E5.3.0-blue?logo=django) |) - -[//]: # (| | ![requests Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FAtomicProgress_by_Evoq%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.requests&style=flat&label=requests) |) - -[//]: # (| | ![pyTelegramBotAPI Badge](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftigran-saatchyan%2FAtomicProgress_by_Evoq%2Fdevelop%2Fpyproject.toml&query=%24.tool.poetry.dependencies.pytelegrambotapi&style=flat&label=pyTelegramBotAPI) |) - - -## Todo +[//]: # (## Todo) ## Release History @@ -143,8 +197,7 @@ Please make sure to read the [Issue Reporting Checklist](https://github.com/tigr If you have questions, feature requests or a bug you want to report, please click [here](https://github.com/tigran-saatchyan/UniMart_by_Evoq/issues) to file an issue. -## Deployment - +[//]: # (## Deployment) ## Translations diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 59eea1f..ca42ee6 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -1,3 +1,5 @@ +"""Authentication routes for the FastAPI application.""" + from fastapi import FastAPI from fastapi_users import FastAPIUsers @@ -7,7 +9,7 @@ from app.settings.auth import auth_backend PREFIX = "/api/v1" -AUTH_TAG = "Authication" +AUTH_TAG = "Authentication" fastapi_users = FastAPIUsers[User, int]( @@ -17,6 +19,11 @@ def set_up_auth_routes(application: FastAPI): + """Set up authentication routes for the FastAPI application. + + Args: + application (FastAPI): The FastAPI application instance. + """ application.include_router( fastapi_users.get_auth_router(auth_backend), prefix=f"{PREFIX}/jwt", diff --git a/app/api/v1/cart.py b/app/api/v1/cart.py index 0c412b9..b33f338 100644 --- a/app/api/v1/cart.py +++ b/app/api/v1/cart.py @@ -1,3 +1,5 @@ +"""Cart API endpoints.""" + from typing import Annotated, List, Union from fastapi import APIRouter, Depends @@ -19,6 +21,17 @@ async def add_product_to_cart( product: Union[CartCreate, List[CartCreate]], uow: UOWDependency, ) -> Union[int, List[int]]: + """Add one or multiple products to the user's cart. + + Args: + user (User): The authenticated user. + product (Union[CartCreate, List[CartCreate]]): The product(s) + to be added to the cart. + uow (UOWDependency): Unit of Work dependency. + + Returns: + Union[int, List[int]]: The ID(s) of the added product(s) in the cart. + """ return await CartService().add(uow, product, user) @@ -27,6 +40,15 @@ async def get_all_products_from_cart( uow: UOWDependency, user: Annotated[User, Depends(current_user)], ): + """Get all products from the user's cart. + + Args: + uow (UOWDependency): Unit of Work dependency. + user (User): The authenticated user. + + Returns: + List: List of products in the user's cart. + """ return await CartService().get_all(uow, user) @@ -36,6 +58,16 @@ async def get_one_product_from_cart( product_id: int, user: Annotated[User, Depends(current_user)], ): + """Get details of a specific product in the user's cart. + + Args: + uow (UOWDependency): Unit of Work dependency. + product_id (int): ID of the product to retrieve. + user (User): The authenticated user. + + Returns: + dict: Details of the requested product in the cart. + """ return await CartService().get(uow, product_id, user) @@ -46,6 +78,17 @@ async def update_product_quantity( product: CartUpdate, user: Annotated[User, Depends(current_user)], ): + """Update the quantity of a product in the user's cart. + + Args: + uow (UOWDependency): Unit of Work dependency. + product_id (int): ID of the product to update. + product (CartUpdate): Updated product information. + user (User): The authenticated user. + + Returns: + dict: Details of the updated product in the cart. + """ return await CartService().update(uow, product_id, product, user) @@ -55,6 +98,16 @@ async def remove_product_from_cart( product_id: int, user: Annotated[User, Depends(current_user)], ): + """Remove a product from the user's cart. + + Args: + uow (UOWDependency): Unit of Work dependency. + product_id (int): ID of the product to remove. + user (User): The authenticated user. + + Returns: + bool: True if the product is successfully removed. + """ return await CartService().delete(uow, product_id, user) @@ -63,6 +116,15 @@ async def clear_cart( uow: UOWDependency, user: Annotated[User, Depends(current_user)], ): + """Clear all products from the user's cart. + + Args: + uow (UOWDependency): Unit of Work dependency. + user (User): The authenticated user. + + Returns: + bool: True if the cart is successfully cleared. + """ return await CartService().delete_all(uow, user) @@ -71,4 +133,13 @@ async def get_total_cart_price( user: Annotated[User, Depends(current_user)], uow: UOWDependency, ) -> float: + """Get the total price of all products in the user's cart. + + Args: + user (User): The authenticated user. + uow (UOWDependency): Unit of Work dependency. + + Returns: + float: The total price of all products in the cart. + """ return await CartService().get_total_price(uow, user) diff --git a/app/api/v1/dependencies.py b/app/api/v1/dependencies.py index 7241702..3806fc6 100644 --- a/app/api/v1/dependencies.py +++ b/app/api/v1/dependencies.py @@ -1,3 +1,5 @@ +"""Dependencies for FastAPI endpoints.""" + from typing import Annotated from fastapi import Depends diff --git a/app/api/v1/products.py b/app/api/v1/products.py index effa883..f37392a 100644 --- a/app/api/v1/products.py +++ b/app/api/v1/products.py @@ -1,3 +1,5 @@ +"""API endpoints for products.""" + from typing import Annotated from fastapi import APIRouter, Depends @@ -19,6 +21,16 @@ async def add_product( product: ProductsCreate, uow: UOWDependency, ) -> int: + """Add a new product. + + Args: + user (User): The authenticated user. + product (ProductsCreate): The product information to be added. + uow (UOWDependency): Unit of Work dependency. + + Returns: + int: The ID of the added product. + """ return await ProductsService().add(uow, product, user) @@ -27,6 +39,15 @@ async def get_products( uow: UOWDependency, user: Annotated[User, Depends(current_user)], ): + """Get all products. + + Args: + uow (UOWDependency): Unit of Work dependency. + user (User): The authenticated user. + + Returns: + List: List of products. + """ return await ProductsService().get_all(uow, user) @@ -36,6 +57,16 @@ async def get_product( uow: UOWDependency, user: Annotated[User, Depends(current_user)], ): + """Get details of a specific product. + + Args: + product_id (int): ID of the product to retrieve. + uow (UOWDependency): Unit of Work dependency. + user (User): The authenticated user. + + Returns: + dict: Details of the requested product. + """ return await ProductsService().get(uow, product_id, user) @@ -46,6 +77,17 @@ async def update_product( uow: UOWDependency, user: Annotated[User, Depends(current_user)], ): + """Update a product. + + Args: + product_id (int): ID of the product to update. + product (ProductsUpdate): Updated product information. + uow (UOWDependency): Unit of Work dependency. + user (User): The authenticated user. + + Returns: + dict: A message indicating the success of the update. + """ await ProductsService().update(uow, product_id, product, user) return {"message": "Product updated successfully"} @@ -56,5 +98,15 @@ async def delete_product( uow: UOWDependency, user: Annotated[User, Depends(current_user)], ): + """Delete a product. + + Args: + product_id (int): ID of the product to delete. + uow (UOWDependency): Unit of Work dependency. + user (User): The authenticated user. + + Returns: + dict: A message indicating the success of the deletion. + """ await ProductsService().delete(uow, product_id, user) return {"message": "Product deleted successfully"} diff --git a/app/api/v1/routers.py b/app/api/v1/routers.py index e468328..d9d8efb 100644 --- a/app/api/v1/routers.py +++ b/app/api/v1/routers.py @@ -1,3 +1,5 @@ +"""Authentication routes for the FastAPI application.""" + from app.api.v1.cart import router as cart_router from app.api.v1.products import router as product_router diff --git a/app/db/db.py b/app/db/db.py index 39860f4..24d8410 100644 --- a/app/db/db.py +++ b/app/db/db.py @@ -1,7 +1,6 @@ -from sqlalchemy.ext.asyncio import ( - async_sessionmaker, - create_async_engine, -) +"""Database module.""" + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import declarative_base from app.settings import config @@ -13,5 +12,10 @@ async def get_async_session(): + """Get an asynchronous database session. + + Yields: + Session: An asynchronous database session. + """ async with async_session_maker() as session: yield session diff --git a/app/main.py b/app/main.py index 403dab6..65181aa 100644 --- a/app/main.py +++ b/app/main.py @@ -1,16 +1,16 @@ +"""Main module for the application.""" + import uvicorn from app.utils.factories import ( create_app, custom_openapi, setup_cors, - setup_database, setup_routes, ) app = create_app() -setup_database(app) setup_cors(app) setup_routes(app) diff --git a/app/models/base_model.py b/app/models/base_model.py index f402880..1f41361 100644 --- a/app/models/base_model.py +++ b/app/models/base_model.py @@ -1,3 +1,5 @@ +"""Base model class for all database models.""" + from datetime import datetime from sqlalchemy import TIMESTAMP, Integer, func @@ -7,6 +9,14 @@ class BaseModel(Base): + """Base model class for all database models. + + Attributes: + id (int): The primary key of the model. + created_at (datetime): The timestamp when the model was created. + updated_at (datetime): The timestamp when the model was last updated. + """ + __abstract__ = True id: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False) diff --git a/app/models/cart.py b/app/models/cart.py index ef1af25..f75a411 100644 --- a/app/models/cart.py +++ b/app/models/cart.py @@ -1,3 +1,5 @@ +"""Database model representing a shopping cart.""" + from sqlalchemy import Boolean, Float, ForeignKey, Integer from sqlalchemy.orm import Mapped, mapped_column @@ -6,6 +8,22 @@ class Cart(BaseModel): + """Database model representing a shopping cart. + + Attributes: + id (int): The primary key of the cart. + price (float): The total price of the products in the cart. + quantity (int): The quantity of the product in the cart. + product_id (int): The foreign key referencing the associated + product. + owner_id (int): The foreign key referencing the owner (user) + of the cart. + is_active (bool): Indicates whether the cart is active. + created_at (datetime): The timestamp when the cart was created. + updated_at (datetime): The timestamp when the cart was last + updated. + """ + __tablename__ = "cart" price: Mapped[float] = mapped_column( @@ -23,6 +41,11 @@ class Cart(BaseModel): ) def to_pydantic_model(self): + """Convert the database model to a Pydantic model (CartRead). + + Returns: + CartRead: The Pydantic model representing the cart. + """ return CartRead( id=self.id, price=self.price, diff --git a/app/models/products.py b/app/models/products.py index 2d83613..be33197 100644 --- a/app/models/products.py +++ b/app/models/products.py @@ -1,13 +1,29 @@ +"""Database model representing a product.""" + from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column -from app.models.base_model import ( - BaseModel, -) +from app.models.base_model import BaseModel from app.schemas.products import ProductRead class Product(BaseModel): + """Database model representing a product. + + Attributes: + id (int): The primary key of the product. + name (str): The name of the product. + description (str): The description of the product. + price (float): The price of the product. + owner_id (int): The foreign key referencing the owner (user) + of the product. + is_active (bool): Indicates whether the product is active. + created_at (datetime): The timestamp when the product was + created. + updated_at (datetime): The timestamp when the product was + last updated. + """ + __tablename__ = "product" name: Mapped[str] = mapped_column(String(150), nullable=False) @@ -21,6 +37,11 @@ class Product(BaseModel): ) def to_pydantic_model(self): + """Convert the database model to a Pydantic model (ProductRead). + + Returns: + ProductRead: The Pydantic model representing the product. + """ return ProductRead( id=self.id, name=self.name, diff --git a/app/models/users.py b/app/models/users.py index 7535eec..7b7b11d 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -1,3 +1,5 @@ +"""Database model representing a user.""" + from datetime import datetime from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable @@ -9,6 +11,28 @@ class User(SQLAlchemyBaseUserTable[int], BaseModel): + """Database model representing a user. + + Attributes: + id (int): The primary key of the user. + email (str): The email address of the user. + hashed_password (str): The hashed password of the user. + telephone (str): The telephone number of the user. + first_name (str): The first name of the user. + last_name (str): The last name of the user. + last_login (datetime): The timestamp of the last login. + full_name (str): The full name of the user. + telegram_user_id (str): The Telegram user ID of the user. + role (str): The role of the user. + country (str): The country of the user. + city (str): The city of the user. + is_superuser (bool): Indicates whether the user is a superuser. + is_active (bool): Indicates whether the user is active. + is_verified (bool): Indicates whether the user is verified. + created_at (datetime): The timestamp when the user was created. + updated_at (datetime): The timestamp when the user was last updated. + """ + telephone: Mapped[str] = mapped_column( String(50), unique=True, nullable=False ) @@ -19,6 +43,11 @@ class User(SQLAlchemyBaseUserTable[int], BaseModel): ) def to_pydantic_model(self): + """Convert the database model to a Pydantic model (UserRead). + + Returns: + UserRead: The Pydantic model representing the user. + """ return UserRead( id=self.id, email=self.email, diff --git a/app/repositories/cart.py b/app/repositories/cart.py index 00be27c..193afae 100644 --- a/app/repositories/cart.py +++ b/app/repositories/cart.py @@ -1,3 +1,5 @@ +"""Repository for interacting with the Cart model in the database.""" + from sqlalchemy import and_, delete, func, select, update from app.models import User @@ -6,9 +8,24 @@ class CartRepository(BaseRepository): + """Repository for interacting with the Cart model in the database. + + Attributes: + model: The Cart model. + session: The database session. + """ + model = Cart async def get_total_price(self, user: User): + """Get the total price of all products in the user's cart. + + Args: + user (User): The user for whom to calculate the total price. + + Returns: + float: The total price of all products in the user's cart. + """ statement = select(func.sum(Cart.price)).where( Cart.owner_id == user.id ) @@ -16,6 +33,16 @@ async def get_total_price(self, user: User): return result.scalar_one_or_none() async def get_by_product_id(self, product_id: int, user: User): + """Get a cart item by product ID for a specific user. + + Args: + product_id (int): The ID of the product. + user (User): The user for whom to retrieve the cart item. + + Returns: + CartRead: The Pydantic model representing the cart item, + or None if not found. + """ statement = select(self.model).where( and_( self.model.product_id == product_id, @@ -29,6 +56,16 @@ async def get_by_product_id(self, product_id: int, user: User): async def update_by_product_id( self, product_id: int, data: dict, user: User ): + """Update a cart item by product ID for a specific user. + + Args: + product_id (int): The ID of the product. + data (dict): The data to update in the cart item. + user (User): The user for whom to update the cart item. + + Returns: + int: The product ID of the updated cart item. + """ statement = ( update(self.model) .where( @@ -47,6 +84,15 @@ async def update_by_product_id( async def delete_by_owner_id_and_product_id( self, product_id: int, user: User ): + """Delete a cart item by product ID for a specific user. + + Args: + product_id (int): The ID of the product. + user (User): The user for whom to delete the cart item. + + Returns: + None: The result of the deletion operation. + """ statement = ( delete(self.model) .where( @@ -60,6 +106,14 @@ async def delete_by_owner_id_and_product_id( return await self.session.execute(statement) async def delete_all_by_owner_id(self, user: User): + """Delete all cart items for a specific user. + + Args: + user (User): The user for whom to delete all cart items. + + Returns: + None: The result of the deletion operation. + """ statement = ( delete(self.model) .where( diff --git a/app/repositories/products.py b/app/repositories/products.py index dfd6580..6c9684c 100644 --- a/app/repositories/products.py +++ b/app/repositories/products.py @@ -1,6 +1,15 @@ +"""Repository for interacting with the Product model in the database.""" + from app.models import Product from app.utils.repository import BaseRepository class ProductsRepository(BaseRepository): + """Repository for interacting with the Product model in the database. + + Attributes: + model: The Product model. + session: The database session. + """ + model = Product diff --git a/app/repositories/users.py b/app/repositories/users.py index 3cdae60..1b7ece1 100644 --- a/app/repositories/users.py +++ b/app/repositories/users.py @@ -1,3 +1,5 @@ +"""Repository for interacting with the User model in the database.""" + from sqlalchemy import select from app.models import User @@ -5,9 +7,25 @@ class UsersRepository(BaseRepository): + """Repository for interacting with the User model in the database. + + Attributes: + model: The User model. + session: The database session. + """ + model = User async def get_by_telephone(self, telephone: str): + """Get a user by telephone number. + + Args: + telephone (str): The telephone number to search for. + + Returns: + User: The user with the specified telephone number, + or None if not found. + """ if telephone: statement = select(self.model).where( self.model.telephone == telephone diff --git a/app/schemas/cart.py b/app/schemas/cart.py index 66dc897..7c81928 100644 --- a/app/schemas/cart.py +++ b/app/schemas/cart.py @@ -1,9 +1,29 @@ +"""Pydantic models representing the shopping cart.""" + from datetime import datetime from pydantic import BaseModel, Field class CartRead(BaseModel): + """Pydantic model representing a read-only view of a shopping cart item. + + Attributes: + id (int): The unique identifier of the cart item. + quantity (int): The quantity of the product in the cart. + product_id (int): The ID of the associated product. + price (float): The price of the cart item. + owner_id (int): The ID of the owner (user) of the cart. + is_active (bool): Indicates whether the cart item is active. + created_at (datetime): The timestamp when the cart item was created. + updated_at (datetime): The timestamp when the cart item was + last updated. + + Config: + from_attributes (bool): Enable attribute assignment from + class attributes. + """ + id: int quantity: int product_id: int @@ -18,9 +38,30 @@ class Config: class CartCreate(BaseModel): + """Pydantic model representing the creation of a shopping cart item. + + Attributes: + quantity (int): The quantity of the product in the cart + (default: 1). + product_id (int): The ID of the associated product. + + Config: + ge (int): Quantity must be greater than or equal to 0. + """ + quantity: int = Field(1, ge=0) product_id: int class CartUpdate(BaseModel): + """Pydantic model representing the update of a shopping cart item. + + Attributes: + quantity (int): The new quantity of the product in the + cart (default: 1). + + Config: + gt (int): Quantity must be greater than 0. + """ + quantity: int = Field(1, gt=0) diff --git a/app/schemas/products.py b/app/schemas/products.py index eb3fd22..47fc5c0 100644 --- a/app/schemas/products.py +++ b/app/schemas/products.py @@ -1,9 +1,30 @@ +"""Pydantic models representing the products schema.""" + from datetime import datetime from pydantic import BaseModel class ProductRead(BaseModel): + """Pydantic model representing a read-only view of a product. + + Attributes: + id (int): The unique identifier of the product. + name (str): The name of the product. + description (str): The description of the product. + price (float): The price of the product. + owner_id (int): The ID of the owner (user) of the product. + is_active (bool): Indicates whether the product is active. + created_at (datetime): The timestamp when the product was + created. + updated_at (datetime): The timestamp when the product was + last updated. + + Config: + from_attributes (bool): Enable attribute assignment from + class attributes. + """ + id: int name: str description: str @@ -18,12 +39,28 @@ class Config: class ProductsCreate(BaseModel): + """Pydantic model representing the creation of a product. + + Attributes: + name (str): The name of the product. + description (str): The description of the product. + price (float): The price of the product. + """ + name: str description: str price: float class ProductsUpdate(BaseModel): + """Pydantic model representing the update of a product. + + Attributes: + name (str): The new name of the product. + description (str): The new description of the product. + price (float): The new price of the product. + """ + name: str description: str price: float diff --git a/app/schemas/users.py b/app/schemas/users.py index 7df210e..c9e8356 100644 --- a/app/schemas/users.py +++ b/app/schemas/users.py @@ -1,3 +1,5 @@ +"""Pydantic models for the user schema.""" + from datetime import datetime from typing import Optional @@ -6,6 +8,28 @@ class UserRead(schemas.BaseUser[int]): + """Pydantic model representing the read-only view of a user. + + Attributes: + id (int): The unique identifier of the user. + email (str): The email address of the user. + hashed_password (str): The hashed password of the user. + first_name (str): The first name of the user. + last_name (str): The last name of the user. + telephone (str): The telephone number of the user. + last_login (Optional[datetime]): The timestamp of the last login. + is_superuser (bool): Indicates whether the user is a superuser. + is_active (bool): Indicates whether the user is active. + is_verified (bool): Indicates whether the user is verified. + created_at (datetime): The timestamp when the user was created. + updated_at (datetime): The timestamp when the user was last + updated. + + Config: + from_attributes (bool): Enable attribute assignment from class + attributes. + """ + id: int first_name: str last_name: str @@ -19,6 +43,20 @@ class Config: class UserCreate(schemas.BaseUserCreate): + """Pydantic model representing the creation of a user. + + Attributes: + email (str): The email address of the user. + password (str): The password of the user. + confirm_password (str): The confirmation of the password. + first_name (Optional[str]): The first name of the user. + last_name (Optional[str]): The last name of the user. + telephone (Optional[str]): The telephone number of the user. + + Config: + schema_extra (dict): Additional information for OpenAPI schema. + """ + confirm_password: str first_name: Optional[str] = Field(None) last_name: Optional[str] = Field(None) @@ -26,6 +64,19 @@ class UserCreate(schemas.BaseUserCreate): class UserUpdate(schemas.BaseUserUpdate): + """Pydantic model representing the update of a user. + + Attributes: + password (Optional[str]): The new password of the user. + confirm_password (str): The confirmation of the new password. + first_name (Optional[str]): The new first name of the user. + last_name (Optional[str]): The new last name of the user. + telephone (Optional[str]): The new telephone number of the user. + + Config: + schema_extra (dict): Additional information for OpenAPI schema. + """ + confirm_password: str first_name: Optional[str] = Field(None) last_name: Optional[str] = Field(None) diff --git a/app/services/cart.py b/app/services/cart.py index 7a4ba9d..5361d94 100644 --- a/app/services/cart.py +++ b/app/services/cart.py @@ -1,3 +1,5 @@ +"""Service class for managing user shopping carts.""" + from typing import List, Union, overload from fastapi import HTTPException, status @@ -7,12 +9,26 @@ from app.models import User from app.schemas.cart import CartCreate, CartUpdate from app.services.products import ProductsService -from app.services.validators import ( - ProductInCartValidator, -) +from app.services.validators import ProductInCartValidator class CartService: + """Service class for managing user shopping carts. + + Methods: + add: Add one or multiple products to the user's cart. + add_one: Add a single product to the user's cart. + add_many: Add multiple products to the user's cart. + get_all: Get all products from the user's cart. + get: Get a specific product from the user's cart. + update: Update the quantity of a product in the user's cart. + delete: Remove a product from the user's cart. + delete_all: Remove all products from the user's cart. + get_total_price: Get the total price of all products in the + user's cart. + is_in_cart: Check if a product is already in the user's cart. + """ + @overload def add(self, uow: UOWDependency, product: CartCreate, user: User) -> int: pass @@ -29,6 +45,18 @@ async def add( products: Union[CartCreate, List[CartCreate]], user: User, ) -> Union[int, List[int]]: + """Add one or multiple products to the user's cart. + + Args: + uow (UOWDependency): The unit of work dependency. + products (Union[CartCreate, List[CartCreate]]): The product + or list of products to add. + user (User): The user for whom to add the products. + + Returns: + Union[int, List[int]]: The product ID(s) that were added + to the cart. + """ if isinstance(products, list): async with uow: result = await self.add_many(uow, products, user) @@ -40,6 +68,16 @@ async def add( async def add_one( self, uow: UOWDependency, product: CartCreate, user: User ): + """Add a single product to the user's cart. + + Args: + uow (UOWDependency): The unit of work dependency. + product (CartCreate): The product to add. + user (User): The user for whom to add the product. + + Returns: + int: The product ID that was added to the cart. + """ cart_dict = product.model_dump() cart_dict["owner_id"] = user.id is_product_in_cart, _product = await self.is_in_cart( @@ -55,6 +93,16 @@ async def add_one( async def add_many( self, uow: UOWDependency, products: List[CartCreate], user: User ): + """Add multiple products to the user's cart. + + Args: + uow (UOWDependency): The unit of work dependency. + products (List[CartCreate]): The list of products to add. + user (User): The user for whom to add the products. + + Returns: + List[int]: The list of product IDs that were added to the cart. + """ result = [] for product in products: product_id = await self.add_one(uow, product, user) @@ -63,11 +111,30 @@ async def add_many( @staticmethod async def get_all(uow: UOWDependency, user: User): + """Get all products from the user's cart. + + Args: + uow (UOWDependency): The unit of work dependency. + user (User): The user for whom to retrieve the cart items. + + Returns: + List: The list of cart items. + """ async with uow: return await uow.cart.get_all(user) @staticmethod async def get(uow: UOWDependency, product_id: int, user: User): + """Get a specific product from the user's cart. + + Args: + uow (UOWDependency): The unit of work dependency. + product_id (int): The ID of the product to retrieve. + user (User): The user for whom to retrieve the cart item. + + Returns: + dict: The cart item. + """ async with uow: result = await uow.cart.get_by_product_id(product_id, user) if result is not None: @@ -85,6 +152,17 @@ async def update( product: CartUpdate, user: User, ): + """Update the quantity of a product in the user's cart. + + Args: + uow (UOWDependency): The unit of work dependency. + product_id (int): The ID of the product to update. + product (CartUpdate): The updated product information. + user (User): The user for whom to update the cart item. + + Returns: + int: The updated product ID. + """ product_dict = product.model_dump() is_product_in_cart, _product = await self.is_in_cart( uow, product_id, user @@ -102,6 +180,16 @@ async def update( @staticmethod async def delete(uow: UOWDependency, product_id: int, user: User): + """Remove a product from the user's cart. + + Args: + uow (UOWDependency): The unit of work dependency. + product_id (int): The ID of the product to remove. + user (User): The user for whom to remove the cart item. + + Returns: + dict: The result of the deletion operation. + """ async with uow: result = await uow.cart.delete_by_owner_id_and_product_id( product_id, user @@ -111,6 +199,15 @@ async def delete(uow: UOWDependency, product_id: int, user: User): @staticmethod async def delete_all(uow: UOWDependency, user: User): + """Remove all products from the user's cart. + + Args: + uow (UOWDependency): The unit of work dependency. + user (User): The user for whom to remove all cart items. + + Returns: + dict: The result of the deletion operation. + """ async with uow: result = await uow.cart.delete_all_by_owner_id(user) await uow.commit() @@ -118,6 +215,15 @@ async def delete_all(uow: UOWDependency, user: User): @staticmethod async def get_total_price(uow: UOWDependency, user: User) -> float: + """Get the total price of all products in the user's cart. + + Args: + uow (UOWDependency): The unit of work dependency. + user (User): The user for whom to calculate the total price. + + Returns: + float: The total price of all products in the cart. + """ async with uow: total_price = await uow.cart.get_total_price(user) if total_price is not None: @@ -130,6 +236,17 @@ async def get_total_price(uow: UOWDependency, user: User) -> float: @staticmethod async def is_in_cart(uow, product_id, user): + """Check if a product is already in the user's cart. + + Args: + uow: The unit of work dependency. + product_id: The ID of the product to check. + user: The user for whom to check the cart. + + Returns: + tuple: A tuple containing a boolean indicating if the + product is in the cart and the product information. + """ try: _product = await ProductsService().get(uow, product_id, user) except NoResultFound: diff --git a/app/services/managers.py b/app/services/managers.py index 2f0b914..bcff6e6 100644 --- a/app/services/managers.py +++ b/app/services/managers.py @@ -1,3 +1,5 @@ +"""User manager module.""" + from typing import Any, Dict, Optional, Union from fastapi import Depends, HTTPException, Request, status @@ -18,12 +20,31 @@ class UserManager(IntegerIDMixin, BaseUserManager[User, int], UsersRepository): + """User manager class responsible for handling user-related operations. + + Attributes: + reset_password_token_secret (str): The secret key for reset + password tokens. + verification_token_secret (str): The secret key for + verification tokens. + """ + reset_password_token_secret = config.JWT_SECRET verification_token_secret = config.JWT_SECRET async def validate_password( self, password: str, user: Union[schemas.UC, models.UP, Dict[str, Any]] ) -> None: + """Validate the password against the user's confirmation password. + + Args: + password (str): The password to validate. + user (Union[schemas.UC, models.UP, Dict[str, Any]]): The + user information. + + Raises: + HTTPException: If the password validation fails. + """ validator = PasswordValidator() if isinstance(user, dict): validator(password, user["confirm_password"]) @@ -34,6 +55,15 @@ async def validate_password( async def validate_telephone( user: Union[schemas.UC, models.UP, Dict[str, Any]] ) -> None: + """Validate the telephone number. + + Args: + user (Union[schemas.UC, models.UP, Dict[str, Any]]): The + user information. + + Raises: + HTTPException: If the telephone validation fails. + """ validator = TelephoneValidator() if isinstance(user, dict): validator(user["telephone"]) @@ -46,6 +76,20 @@ async def create( safe: bool = False, request: Optional[Request] = None, ) -> models.UP: + """Create a new user. + + Args: + user_create (schemas.UC): The user creation schema. + safe (bool): Flag indicating if it's a safe creation + (default is False). + request (Optional[Request]): The request object. + + Returns: + models.UP: The created user. + + Raises: + HTTPException: If the user creation fails. + """ await self.validate_password(user_create.password, user_create) await self.validate_telephone(user_create) @@ -73,6 +117,19 @@ async def create( async def _update( self, user: models.UP, update_dict: Dict[str, Any] ) -> models.UP: + """Update user information. + + Args: + user (models.UP): The user to update. + update_dict (Dict[str, Any]): The dictionary containing + the updated information. + + Returns: + models.UP: The updated user. + + Raises: + HTTPException: If the update fails. + """ validated_update_dict = {} for field, value in update_dict.items(): if field == "email" and value != user.email: @@ -90,11 +147,18 @@ async def _update( elif field == "telephone" and value is not None: await self.validate_telephone(update_dict) validated_update_dict[field] = value - else: validated_update_dict[field] = value return await self.user_db.update(user, validated_update_dict) async def get_user_manager(user_db=Depends(get_user_table)): + """Get an instance of the UserManager. + + Args: + user_db: The user database. + + Yields: + UserManager: The user manager instance. + """ yield UserManager(user_db) diff --git a/app/services/products.py b/app/services/products.py index af79d7c..8b29c14 100644 --- a/app/services/products.py +++ b/app/services/products.py @@ -1,13 +1,30 @@ +"""Service class for handling product-related operations.""" + from app.api.v1.dependencies import UOWDependency from app.models import User from app.schemas.products import ProductsCreate, ProductsUpdate class ProductsService: + """Service class for handling product-related operations.""" + @staticmethod async def add( uow: UOWDependency, product: ProductsCreate, user: User ) -> int: + """Add a new product. + + Args: + uow (UOWDependency): The unit of work dependency. + product (ProductsCreate): The product creation schema. + user (User): The user adding the product. + + Returns: + int: The ID of the added product. + + Raises: + HTTPException: If the product addition fails. + """ product_dict = product.model_dump() product_dict["owner_id"] = user.id async with uow: @@ -17,11 +34,37 @@ async def add( @staticmethod async def get_all(uow: UOWDependency, user: User): + """Get all products for a given user. + + Args: + uow (UOWDependency): The unit of work dependency. + user (User): The user for whom to retrieve products. + + Returns: + List[Product]: The list of products. + + Raises: + HTTPException: If the product retrieval fails. + """ async with uow: return await uow.products.get_all(user) @staticmethod async def get(uow: UOWDependency, product_id: int, user: User): + """Get a specific product by ID for a given user. + + Args: + uow (UOWDependency): The unit of work dependency. + product_id (int): The ID of the product to retrieve. + user (User): The user for whom to retrieve the product. + + Returns: + Product: The retrieved product. + + Raises: + HTTPException: If the product retrieval fails or the + product is not found. + """ async with uow: return await uow.products.get(product_id, user) @@ -32,6 +75,21 @@ async def update( product: ProductsUpdate, user: User, ): + """Update a specific product by ID for a given user. + + Args: + uow (UOWDependency): The unit of work dependency. + product_id (int): The ID of the product to update. + product (ProductsUpdate): The updated product information. + user (User): The user performing the update. + + Returns: + int: The ID of the updated product. + + Raises: + HTTPException: If the product update fails or the + product is not found. + """ product_dict = product.model_dump() async with uow: await uow.products.update(product_id, product_dict, user) @@ -40,6 +98,20 @@ async def update( @staticmethod async def delete(uow: UOWDependency, product_id: int, user: User): + """Delete a specific product by ID for a given user. + + Args: + uow (UOWDependency): The unit of work dependency. + product_id (int): The ID of the product to delete. + user (User): The user performing the deletion. + + Returns: + bool: True if the deletion is successful. + + Raises: + HTTPException: If the product deletion fails or the + product is not found. + """ async with uow: result = await uow.products.delete(product_id, user) await uow.commit() diff --git a/app/services/validators.py b/app/services/validators.py index f2ff228..7dbd8e5 100644 --- a/app/services/validators.py +++ b/app/services/validators.py @@ -1,3 +1,5 @@ +"""Validators for checking the validity of data.""" + import re from fastapi import HTTPException @@ -5,6 +7,16 @@ class PasswordValidator: + """Validator for checking the validity of passwords. + + Args: + password (str): The password to validate. + confirm_password (str): The confirmation password. + + Raises: + HTTPException: If the password is not valid or passwords do not match. + """ + def __call__(self, password: str, confirm_password: str): if not re.search(r"^(?=.*[A-Z])(?=.*[$%&!]).{8,}$", password): raise HTTPException( @@ -20,6 +32,15 @@ def __call__(self, password: str, confirm_password: str): class TelephoneValidator: + """Validator for checking the validity of telephone numbers. + + Args: + telephone (str): The telephone number to validate. + + Raises: + HTTPException: If the telephone number is not valid. + """ + def __call__(self, telephone: str): if not re.search(r"^\+7\d{10}$", telephone): raise HTTPException( @@ -30,6 +51,15 @@ def __call__(self, telephone: str): class ProductInCartValidator: + """Validator for checking if a product is already in the cart. + + Args: + product: The product to check. + + Raises: + HTTPException: If the product is already in the cart. + """ + def __call__(self, product): if product is not None: raise HTTPException( diff --git a/app/settings/auth.py b/app/settings/auth.py index 453069f..32cccfb 100644 --- a/app/settings/auth.py +++ b/app/settings/auth.py @@ -1,3 +1,5 @@ +"""Authentication settings for the FastAPI Users package.""" + from typing import Annotated from fastapi import Depends @@ -19,6 +21,11 @@ def get_jwt_strategy() -> JWTStrategy: + """Get the JWT (JSON Web Token) authentication strategy. + + Returns: + JWTStrategy: The JWT authentication strategy. + """ return JWTStrategy(secret=config.JWT_SECRET, lifetime_seconds=3600) @@ -32,4 +39,12 @@ def get_jwt_strategy() -> JWTStrategy: async def get_user_table( session: Annotated[AsyncSession, Depends(get_async_session)] ): + """Get the SQLAlchemyUserDatabase instance for the User model. + + Args: + session (AsyncSession): The asynchronous database session. + + Yields: + SQLAlchemyUserDatabase: The SQLAlchemyUserDatabase instance. + """ yield SQLAlchemyUserDatabase(session, User) diff --git a/app/settings/config.py b/app/settings/config.py index fd45993..6421e17 100644 --- a/app/settings/config.py +++ b/app/settings/config.py @@ -1,3 +1,5 @@ +"""Configuration classes for the application.""" + import os from pathlib import Path from typing import ClassVar, Type @@ -11,6 +13,20 @@ class Config: + """Base configuration class for the application. + + Attributes: + DEBUG (bool): Indicates whether the application is in debug mode. + ACCESS_TOKEN_MINUTES (int): The expiration time for access + tokens in minutes. + REFRESH_TOKEN_DAYS (int): The expiration time for refresh + tokens in days. + FASTAPI_SETTINGS (ClassVar[dict]): FastAPI settings for the + application. + ... (Other attributes for email settings, database connection, etc.) + + """ + DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "True") ACCESS_TOKEN_MINUTES = 60 @@ -98,23 +114,76 @@ class Config: class DevelopmentConfig(Config): + """Development-specific configuration class. + + Inherits from the base Config class. + + Attributes: + DEBUG (bool): Indicates whether the application is in debug + mode (set to True in development). + """ + DEBUG = True class TestingConfig(Config): + """Testing-specific configuration class. + + Inherits from the base Config class. + + Attributes: + DEBUG (bool): Indicates whether the application is in debug + mode (set to True in testing). + TESTING (bool): Indicates that the application is in testing mode. + """ + DEBUG = True TESTING = True class ProductionConfig(Config): + """Production-specific configuration class. + + Inherits from the base Config class. + + Attributes: + DEBUG (bool): Indicates whether the application is in debug + mode (set to False in production). + """ + DEBUG = False class ConfigFactory: + """Factory class for obtaining the appropriate configuration class + based on the environment. + + Attributes: + fastapi_env (str): The environment variable indicating the + FastAPI environment. + + Methods: + get_config(): Get the configuration class based on the + FastAPI environment. + + Raises: + NotImplementedError: If the FastAPI environment is not recognized. + + """ + fastapi_env = os.getenv("FASTAPI_ENV") @classmethod def get_config(cls) -> Type[Config]: + """Get the configuration class based on the FastAPI environment. + + Returns: + Type[Config]: The configuration class for the + current environment. + + Raises: + NotImplementedError: If the FastAPI environment is not recognized. + """ if cls.fastapi_env == "development": env_config = DevelopmentConfig elif cls.fastapi_env == "production": diff --git a/app/utils/factories.py b/app/utils/factories.py index b020699..864d595 100644 --- a/app/utils/factories.py +++ b/app/utils/factories.py @@ -1,3 +1,5 @@ +"""Factories for creating FastAPI application instances.""" + from typing import Dict from fastapi import FastAPI @@ -9,15 +11,21 @@ def create_app() -> FastAPI: - return FastAPI(**config.FASTAPI_SETTINGS) + """Create a FastAPI application instance with configuration settings. - -def setup_database(application: FastAPI) -> None: - # TODO @Tigran_Saatchyan: implement database setup - ... + Returns: + FastAPI: The FastAPI application instance. + """ + return FastAPI(**config.FASTAPI_SETTINGS) def setup_cors(application: FastAPI) -> None: + """Set up Cross-Origin Resource Sharing (CORS) middleware for + the FastAPI application. + + Args: + application (FastAPI): The FastAPI application instance. + """ application.add_middleware( CORSMiddleware, allow_origins=config.CORS_ALLOWED_ORIGINS, @@ -28,6 +36,11 @@ def setup_cors(application: FastAPI) -> None: def setup_routes(application: FastAPI) -> None: + """Set up API routes for the FastAPI application. + + Args: + application (FastAPI): The FastAPI application instance. + """ from app.api.v1.routers import all_routers set_up_auth_routes(application) @@ -36,6 +49,14 @@ def setup_routes(application: FastAPI) -> None: def custom_openapi(application: FastAPI) -> Dict[str, dict]: + """Customize the OpenAPI schema for the FastAPI application. + + Args: + application (FastAPI): The FastAPI application instance. + + Returns: + Dict[str, dict]: The customized OpenAPI schema. + """ if application.openapi_schema: return application.openapi_schema openapi_schema = get_openapi( diff --git a/app/utils/repository.py b/app/utils/repository.py index a08db2f..01a3cf6 100644 --- a/app/utils/repository.py +++ b/app/utils/repository.py @@ -1,3 +1,5 @@ +"""Repository module.""" + from abc import ABC, abstractmethod from sqlalchemy import and_, delete, insert, select, update @@ -9,19 +11,65 @@ class AbstractRepository(ABC): @abstractmethod async def add(self, data: dict) -> int: + """Add a new record to the repository. + + Args: + data (dict): The data to be added. + + Returns: + int: The ID of the added record. + """ raise NotImplementedError @abstractmethod async def get_all(self, owner: User): + """Get all records from the repository for a specific owner. + + Args: + owner (User): The owner of the records. + + Returns: + list: A list of records. + """ raise NotImplementedError async def get(self, id: int, owner: User): + """Get a specific record from the repository. + + Args: + id (int): The ID of the record. + owner (User): The owner of the record. + + Returns: + pydantic.Model: The retrieved record. + """ raise NotImplementedError + @abstractmethod async def update(self, id: int, data: dict, owner: User): + """Update a specific record in the repository. + + Args: + id (int): The ID of the record to be updated. + data (dict): The data to be updated. + owner (User): The owner of the record. + + Returns: + int: The ID of the updated record. + """ raise NotImplementedError + @abstractmethod async def delete(self, id: int, owner: User): + """Delete a specific record from the repository. + + Args: + id (int): The ID of the record to be deleted. + owner (User): The owner of the record. + + Returns: + None + """ raise NotImplementedError @@ -31,21 +79,42 @@ class BaseRepository(AbstractRepository): def __init__(self, session: AsyncSession): self.session = session - async def add( - self, - data: dict, - ) -> int: + async def add(self, data: dict) -> int: + """Add a new record to the repository. + + Args: + data (dict): The data to be added. + + Returns: + int: The ID of the added record. + """ statement = insert(self.model).values(**data).returning(self.model.id) result = await self.session.execute(statement) - return result.scalar_one() async def get_all(self, owner: User): + """Get all records from the repository for a specific owner. + + Args: + owner (User): The owner of the records. + + Returns: + list: A list of records. + """ statement = select(self.model).where(self.model.owner_id == owner.id) result = await self.session.execute(statement) return [row[0].to_pydantic_model() for row in result.all()] async def get(self, id: int, owner: User): + """Get a specific record from the repository. + + Args: + id (int): The ID of the record. + owner (User): The owner of the record. + + Returns: + pydantic.Model: The retrieved record. + """ statement = select(self.model).where( and_( self.model.id == id, @@ -57,6 +126,16 @@ async def get(self, id: int, owner: User): return result.to_pydantic_model() async def update(self, id: int, data: dict, owner: User): + """Update a specific record in the repository. + + Args: + id (int): The ID of the record to be updated. + data (dict): The data to be updated. + owner (User): The owner of the record. + + Returns: + int: The ID of the updated record. + """ statement = ( update(self.model) .where( @@ -69,10 +148,18 @@ async def update(self, id: int, data: dict, owner: User): .returning(self.model.product_id) ) result = await self.session.execute(statement) - return result.scalar_one() async def delete(self, id: int, owner: User): + """Delete a specific record from the repository. + + Args: + id (int): The ID of the record to be deleted. + owner (User): The owner of the record. + + Returns: + None + """ statement = ( delete(self.model) .where( @@ -83,4 +170,4 @@ async def delete(self, id: int, owner: User): ) .returning() ) - return await self.session.execute(statement) + await self.session.execute(statement) diff --git a/app/utils/unitofwork.py b/app/utils/unitofwork.py index ec13628..967a345 100644 --- a/app/utils/unitofwork.py +++ b/app/utils/unitofwork.py @@ -1,3 +1,5 @@ +"""Unit of Work module.""" + from abc import ABC, abstractmethod from typing import Type @@ -13,20 +15,28 @@ class IUnitOfWork(ABC): cart: Type[CartRepository] @abstractmethod - def __init__(self): ... + def __init__(self): + """Initialize the Unit of Work.""" + raise NotImplementedError @abstractmethod - async def __aenter__(self): ... + async def __aenter__(self): + """Enter the asynchronous context.""" + raise NotImplementedError @abstractmethod - async def __aexit__(self, *args): ... + async def __aexit__(self, *args): + """Exit the asynchronous context.""" + raise NotImplementedError @abstractmethod async def commit(self): + """Commit changes made during the Unit of Work.""" raise NotImplementedError @abstractmethod async def rollback(self): + """Rollback changes made during the Unit of Work.""" raise NotImplementedError @@ -35,17 +45,23 @@ def __init__(self): self.session_factory = async_session_maker async def __aenter__(self): + """Enter the asynchronous context, creating repositories.""" self.session = self.session_factory() self.products = ProductsRepository(self.session) self.users = UsersRepository(self.session) self.cart = CartRepository(self.session) async def __aexit__(self, *args): + """Exit the asynchronous context, rolling back changes and + closing the session. + """ await self.rollback() await self.session.close() async def commit(self): + """Commit changes made during the Unit of Work.""" await self.session.commit() async def rollback(self): + """Rollback changes made during the Unit of Work.""" await self.session.rollback() diff --git a/app/utils/utils.py b/app/utils/utils.py index 4a79b24..0403c5a 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -1,3 +1,5 @@ +"""Utility functions for the application.""" + import base64 import hashlib import hmac @@ -6,6 +8,16 @@ def hash_password(password, pwd_salt=config.PWD_HASH_SALT): + """Hash a password using PBKDF2-HMAC with a specified hash function, + salt, and number of iterations. + + Args: + password (str): The password to be hashed. + pwd_salt (bytes): The salt to be used in the hashing process. + + Returns: + dict: A dictionary containing the hashed password and salt. + """ hashed_password = hashlib.pbkdf2_hmac( hash_name=config.CRYPTOGRAPHIC_HASH_FUNCTION, password=password.encode("utf-8"), @@ -19,5 +31,15 @@ def hash_password(password, pwd_salt=config.PWD_HASH_SALT): async def compare_passwords(db_pwd, received_pwd, pwd_salt) -> bool: + """Compare a stored hashed password with a newly hashed password. + + Args: + db_pwd (str): The stored hashed password. + received_pwd (str): The newly hashed password to be compared. + pwd_salt (bytes): The salt used in the original hashing process. + + Returns: + bool: True if the passwords match, False otherwise. + """ received_pwd = hash_password(received_pwd, pwd_salt)["hashed_password"] return hmac.compare_digest(db_pwd, received_pwd) diff --git a/pyproject.toml b/pyproject.toml index 7db88c4..594096e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,11 @@ ignore = [ "D417", # Missing argument descriptions in the docstring "D415", # First line should end with a period "D205", # 1 blank line required between summary line and description - "D1", # Missing docstring + "D104", # Missing docstring + "D101", # Missing docstring + "D102", # Missing docstring + "D106", # Missing docstring + "D107", # Missing docstring "TD003", # Missing issue link on the line following this TODOs "FIX002", # todos found "FIX003", # XXX found diff --git a/tests/conftest.py b/tests/conftest.py index 320a7a8..5278252 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +"""Pytest configuration file for the application.""" + import asyncio from typing import AsyncGenerator @@ -15,15 +17,12 @@ from app.main import app from app.settings import config -# DATABASE - engine_test = create_async_engine(config.TEST_DATABASE_URI, poolclass=NullPool) async_session_maker = async_sessionmaker( engine_test, class_=AsyncSession, expire_on_commit=False ) Base.metadata.bind = engine_test - USER_EMAIL = "user3@example.com" USER_PHONE = "+79999999999" USER_PASSWORD = "Q1!string" @@ -42,6 +41,11 @@ async def override_get_async_session() -> AsyncGenerator[AsyncSession, None]: + """Override the get_async_session dependency to use a testing session. + + Yields: + AsyncSession: An asynchronous session for testing purposes. + """ async with async_session_maker() as session: yield session @@ -50,10 +54,16 @@ async def override_get_async_session() -> AsyncGenerator[AsyncSession, None]: async def login_user(ac: AsyncClient): - await ac.post( - "/api/v1/register", - json=USER_DATA, - ) + """Simulate user registration and login, returning the + authentication cookies. + + Args: + ac (AsyncClient): The asynchronous HTTP client. + + Returns: + http.cookies: Authentication cookies. + """ + await ac.post("/api/v1/register", json=USER_DATA) response = await ac.post( "/api/v1/jwt/login", @@ -64,6 +74,7 @@ async def login_user(ac: AsyncClient): @pytest.fixture(autouse=True, scope="class") async def prepare_database(): + """Fixture to set up and tear down the test database.""" async with engine_test.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield @@ -85,5 +96,10 @@ def event_loop(request): @pytest.fixture(scope="session") async def ac() -> AsyncGenerator[AsyncClient, None]: + """AsyncClient fixture for testing asynchronous endpoints. + + Yields: + AsyncClient: An asynchronous HTTP client for testing. + """ async with AsyncClient(app=app, base_url="http://test") as ac: yield ac diff --git a/tests/test_auth.py b/tests/test_auth.py index 99819cd..7b545fc 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,3 +1,5 @@ +"""Test authentication endpoints.""" + from httpx import AsyncClient from starlette import status @@ -7,6 +9,14 @@ class TestAuth: @staticmethod async def test_register(ac: AsyncClient): + """Test user registration endpoint. + + Args: + ac (AsyncClient): The asynchronous HTTP client. + + Returns: + None + """ response = await ac.post( "/api/v1/register", json=USER_DATA, @@ -17,6 +27,14 @@ async def test_register(ac: AsyncClient): class TestLogin: async def test_login_and_logout(self, ac: AsyncClient): + """Test user login and logout endpoints. + + Args: + ac (AsyncClient): The asynchronous HTTP client. + + Returns: + None + """ await ac.post( "/api/v1/register", json=USER_DATA, diff --git a/tests/test_cart.py b/tests/test_cart.py index 56885c3..5c26cf2 100644 --- a/tests/test_cart.py +++ b/tests/test_cart.py @@ -1,3 +1,5 @@ +"""Tests for the cart endpoints.""" + from httpx import AsyncClient from starlette import status @@ -7,6 +9,14 @@ class TestCart: @staticmethod async def test_add_to_cart(ac: AsyncClient): + """Test adding a product to the user's cart. + + Args: + ac (AsyncClient): The asynchronous HTTP client. + + Returns: + None + """ auth_cookies = await login_user(ac) test_product = { "name": "Test product", @@ -36,6 +46,14 @@ async def test_add_to_cart(ac: AsyncClient): @staticmethod async def test_get_all_from_cart(ac: AsyncClient): + """Test retrieving all products from the user's cart. + + Args: + ac (AsyncClient): The asynchronous HTTP client. + + Returns: + None + """ auth_cookies = await login_user(ac) response = await ac.get( diff --git a/tests/test_product.py b/tests/test_product.py index 423cf2a..649028e 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -1,3 +1,5 @@ +"""Tests for the product endpoints.""" + from httpx import AsyncClient from starlette import status @@ -7,6 +9,14 @@ class TestProducts: @staticmethod async def test_add_product(ac: AsyncClient): + """Test adding a new product. + + Args: + ac (AsyncClient): The asynchronous HTTP client. + + Returns: + None + """ auth_cookies = await login_user(ac) test_product = { "name": "Test product", diff --git a/tests/test_users.py b/tests/test_users.py index eae5b2f..9680cba 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -1,3 +1,5 @@ +"""Tests for the users endpoints.""" + from httpx import AsyncClient from starlette import status @@ -7,6 +9,14 @@ class TestUsers: @staticmethod async def test_get_current_user(ac: AsyncClient): + """Test retrieving information about the current user. + + Args: + ac (AsyncClient): The asynchronous HTTP client. + + Returns: + None + """ auth_cookies = await login_user(ac) response = await ac.get( "/users/me",