From 4117196b49a44ed6388e69619d46b9e8afa4e254 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Jul 2023 14:42:46 +0300 Subject: [PATCH 1/6] add(comments): add comments to code --- src/app/adapters/orm.py | 13 ++-- src/app/repositories/absctract/bank_api.py | 15 ++--- src/app/services/bank_api.py | 37 ++++++---- src/auth/password.py | 16 +++-- src/auth/services.py | 15 +++-- src/database.py | 78 +++++++++++----------- src/depends.py | 18 +++-- src/main.py | 12 ++-- src/middlewares.py | 1 + src/routers/auth.py | 14 ++-- src/routers/bank_api.py | 36 ++++++++-- src/routers/categories.py | 22 +++--- src/routers/operations.py | 26 +++----- src/routers/users.py | 14 ++-- src/schemas/auth.py | 10 +-- src/schemas/operations.py | 7 -- src/schemas/statistic.py | 2 - src/schemas/users.py | 9 --- tests_playwright/__init__.py | 0 tests_playwright/test_auth.py | 78 ---------------------- 20 files changed, 185 insertions(+), 238 deletions(-) delete mode 100644 tests_playwright/__init__.py delete mode 100644 tests_playwright/test_auth.py diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 427dc87..7cdecb9 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -1,5 +1,5 @@ """ -Технічні особливості побудови таблиць в ORM +Technical aspects of table construction in ORM and mapping to domain models """ from sqlalchemy import Column, ForeignKey, Integer, String, Table @@ -27,11 +27,10 @@ def create_tables(mapper_registry) -> dict[str, Table]: Column("amount", Integer, nullable=False), Column("description", String), Column("time", Integer, nullable=False), - # mcc - код виду операції Column("source_type", String, nullable=False), - # Тип джерела. + # Source type. # value: "manual" | "" - # Операція може бути або додана вручну або за допомогою API банку + # The operation can either be added manually or through the bank's API. Column("user_id", Integer, ForeignKey("users.id")), Column("category_id", Integer, ForeignKey("categories.id"), nullable=True), ), @@ -74,11 +73,7 @@ def create_tables(mapper_registry) -> dict[str, Table]: def start_mappers(mapper_registry: registry, tables: dict[str, Table]): - """ - Прив'язка класів до таблиць. ORM буде з ними працювати як з моделями - Такий підхід відповідє SOLID, інверсуючи залежності. - Таким чином, частини програми нижчого рівня стають залежними від вищого рівня - """ + """Binding domain classes to tables. The ORM will work with them as models.""" mapper_registry.map_imperatively( User, tables["users"], diff --git a/src/app/repositories/absctract/bank_api.py b/src/app/repositories/absctract/bank_api.py index 07b2a59..d5130a7 100644 --- a/src/app/repositories/absctract/bank_api.py +++ b/src/app/repositories/absctract/bank_api.py @@ -1,16 +1,15 @@ """ -Репозиторії менеджерів банку +Bank Manager Repositories: ABankInfoRepository: - Відповідає за зберігання моделі BankInfo - яка містить інформацію про банк +Responsible for storing the BankInfo model, which contains information about the bank. ABankManagerRepository: - Відповідає за роботу з API банка. Для роботи потрібні поля - які находяться в BankInfo моделі +Responsible for working with the bank's API. +It requires fields present in the BankInfo model. - Кожен банк має свій алгоритм для отримання витрат, для якого потрібні різні поля. - Кожен нащадок ABankManagerRepository реалізує роботу з якимось API. +Each bank has its own algorithm for obtaining expenses, which requires different fields. +Each subclass of ABankManagerRepository implements the work with a specific API. """ from abc import ABC, abstractmethod @@ -61,7 +60,7 @@ async def get_costs(self, from_time=None, to_time=None) -> list[dict]: class BankManagerRepositoryFactory: """ - Фабрика для створення ABankInfoRepository на основі BankInfo моделі + Factory for creating ABankInfoRepository based on the BankInfo model. """ @staticmethod diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index 70a2698..6bb2955 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -13,19 +13,20 @@ async def add_bank_info(uow: AbstractUnitOfWork, user_id: int, props: dict): """ - Сторерння BankInfo екземпляра та збереження у базу + Storing a BankInfo instance and saving it to the database. + :param uow: Unit of Work - :param user_id: id користувача - :param props: властивості. Обов'язко повинні містити bank_name - :return: BankInfo instance + :param user_id: User ID + :param props: Properties. Must include bank_name. + :return: BankInfo instance. """ async with uow: bank_info = BankInfo(props.get("bank_name"), user_id) await uow.banks_info.add(bank_info) del props["bank_name"] for key, value in props.items(): - # Всі інші властивості, крім bank_name, - # записуються у вигляді BankInfoProperty із зовнішнім ключем до BankInfo + # All other properties, except bank_name, + # are stored as BankInfoProperty with a foreign key to BankInfo. await uow.banks_info.add( BankInfoProperty( prop_name=key, prop_value=value, prop_type="str", manager=bank_info @@ -38,10 +39,11 @@ async def get_bank_managers_by_user( uow: AbstractUnitOfWork, user_id: int ) -> list[ABankManagerRepository]: """ - Взяття об'єктів BankInfo та перетворення їх у BankManagerRepository + Converting BankInfo objects into BankManagerRepository. + :param uow: Unit of Work - :param user_id: id користувача - :return: список Bank Manager + :param user_id: User ID + :return: List of Bank Managers. """ async with uow: bank_info_list = await uow.banks_info.get_all_by_user(user_id) @@ -55,6 +57,12 @@ async def get_bank_managers_by_user( async def update_banks_costs( uow: AbstractUnitOfWork, managers: list[ABankManagerRepository] ): + """ + Updating the list of financial expenses using the banks' APIs. + :param uow: unit of work (fastapi depend) + :param managers: list of an ABankManagerRepository implements objects + :return: None + """ if len(managers) == 0: return async with uow: @@ -116,11 +124,12 @@ def create_operations_by_bank_costs(costs, mcc_categories) -> list[Operation]: def get_updated_time(manager: ABankManagerRepository) -> int | None: """ - Визначення корректної дати оновлення за допомогою валідацій - :param manager: BankManagerRepository - :return: - - date timestamp - - None: Оновлення даних відбувалось менш ніж 1 хвилину тому + Determining the correct update date using validations. + + :param manager: BankManagerRepository + :return: + - date timestamp + - None: Data was updated less than 1 minute ago. """ updated_time_prop = manager.properties.get("updated_time") max_update_period = datetime.now() - manager.MAX_UPDATE_PERIOD diff --git a/src/auth/password.py b/src/auth/password.py index ce8673d..531c38a 100644 --- a/src/auth/password.py +++ b/src/auth/password.py @@ -1,5 +1,5 @@ """ -Методи хешування паролів та їх порівняння +Methods of password hashing and their comparison. """ from passlib.context import CryptContext @@ -8,16 +8,20 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: """ - :param plain_password: Пароль, який користувач увів під час логіну - :param hashed_password: Зашифрований пароль, який зберігається у БД - :return: True якщо паролі співпадають, False якщо ні + Comparing the plain password with the hashed password. + + :param plain_password: The password entered by the user during login. + :param hashed_password: The hashed password stored in the database. + :return: True if the passwords match, False otherwise. """ return pwd_context.verify(plain_password, hashed_password) def get_password_hash(password: str) -> str: """ - :param password: Пароль, який потрібно зашифрувати - :return: Зашифрований пароль + Encrypting the password. + + :param password: The password that needs to be encrypted. + :return: The encrypted password. """ return pwd_context.hash(password) diff --git a/src/auth/services.py b/src/auth/services.py index eaa339b..a2d9542 100644 --- a/src/auth/services.py +++ b/src/auth/services.py @@ -11,12 +11,11 @@ def create_access_token(data: dict) -> str: """ - Створює JWT, - який надалі використовуватиметься для визначення залогіненого користувача. - Шифрує у собі email користувача та дату, до якого дійсний токен. + Creates a JWT (JSON Web Token) that will be used to identify the logged-in user. + It contains encrypted user's email and the expiration date for the token. - :param data: словник такого шаблону: {'sub': user.email} - :return: JWT у вигляді рядка + :param data: Dictionary with the following template: {'sub': user.email} + :return: JWT as a string. """ # Час у хвилинах, під час якого токен дійсний expire = datetime.utcnow() + timedelta(minutes=int(ACCESS_TOKEN_EXPIRE_MINUTES)) @@ -25,7 +24,11 @@ def create_access_token(data: dict) -> str: def decode_token_data(token: str) -> TokenData | None: - """Розшифровка JWT""" + """ + Decryption of JWT (JSON Web Token) + :param token: Json Web Token + :return: TokenData | None + """ try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) email = payload.get("sub") diff --git a/src/database.py b/src/database.py index 1cb2619..1f82c94 100644 --- a/src/database.py +++ b/src/database.py @@ -1,5 +1,5 @@ """ -Налаштування бази даних +Database Configuration """ import os @@ -12,8 +12,8 @@ def _get_url(test: bool = False, async_: bool = True) -> str: """ - :param test: Якщо True, то підставляє назву тестової бази в URL - :return: URL для підлключення до БД + :param test: If True, then substitutes the name of the test database into the URL. + :return: URL for connecting to the database. """ db_name = os.getenv("DB_NAME") if not test else os.getenv("TEST_DB_NAME") db_user, db_password, db_host = ( @@ -30,16 +30,16 @@ def _get_url(test: bool = False, async_: bool = True) -> str: class Database: """ - Клас роботи з базою даних, який використовується у програмі. - - Містить два головних атрибути: - engine - об'єкт для роботи з драйверами бази даних - sessionmaker - фабрика для створення сесії роботи з engine - - Тестовий режим: - Якщо вказати test = True під час ініціалізації, - то буде створений об'єкт тестової бази, яка використовує іншу базу в роботі. - Використовується під час pytest тестів. + Class for working with the database used in the program. + Contains two main attributes: + engine - an object for working with database drivers + sessionmaker - a factory for creating database sessions with the engine + + Test mode: + If 'test' is set to True during initialization, + then a test database object will be created, + which uses a different database in the program. + It is used during pytest tests. """ def __init__(self, mapper_registry, test: bool = False): @@ -48,17 +48,18 @@ def __init__(self, mapper_registry, test: bool = False): self.mapper_registry = mapper_registry self.test = test - # db_created використовується як прапорець того, створена база чи ні + # The variable db_created is used as a flag + # to indicate whether the database has been created or not. self.db_created = False async def init_models(self): """ - Створює базу даних в test режимі. + Creates the database in test mode. - AsyncSession не підтримує такі методи як - Base.metadata.drop_all - Base.metadata.create_all - тому ці методи виконуються у синхронному режимі за допомогою run_sync + AsyncSession does not support methods like + Base.metadata.drop_all and + Base.metadata.create_all. + Therefore, these methods are executed in synchronous mode using run_sync. """ async with self.engine.begin() as conn: await conn.run_sync(self.mapper_registry.metadata.drop_all) @@ -66,15 +67,15 @@ async def init_models(self): async def get_session(self) -> AsyncSession: """ - Дає AsyncSession для роботи з базою - Увага: - Призначена для FastApi Depends, тому що сесія повертається через yield - В якості Depends метод відпрацює правильно, - але виклили з інших мість можуть створити неочікувані наслідки. + Provides an AsyncSession for working with the database. + Attention: + Designed for FastApi Depends because the session is returned using yield. + When used as a Depends method, it works correctly. + However, calling it from other places may lead to unexpected consequences. :return: sqlalchemy.ext.asyncio.AsyncSession """ if self.test and not self.db_created: - # Тут це для того, щоб виконатись у async event loop + # Here, this is to be executed in the async event loop. await self.init_models() self.db_created = True async with self.sessionmaker() as session: @@ -92,13 +93,14 @@ def __call__(cls, *args, **kwargs): class DatabaseFactory(metaclass=SingletonMeta): """ - Фабрика для бази даних. - - Для налаштування бази даних, потрібно прив'язати класи до таблиць. - Таку операцію потрібно проводити один раз за сесію, - тому що ОРМ не дасть повторно прив'язати одні і ті самі класи. - Фабрика реалізує патерн "Singleton", що дозволяє їй один раз провести прив'язку - та за потребності повертати об'єкти бази даних. + Database factory. + + To configure the database, it is necessary to bind classes to tables. + This operation needs to be performed once per session since + the ORM does not allow re-binding the same classes. + The factory implements the "Singleton" pattern, + which allows it to perform the binding only + once and then return database objects as needed. """ def __init__(self): @@ -115,15 +117,15 @@ def get_database(self, test=False) -> Database: def bind_database_to_app(app: FastAPI, database: Database): """ - Перезапис залежності сесії бази даних. - В цілях уникнення глобальних змінних, в коді використовується функція-заглушка - для залежності сесії - А під час налаштування, викликається ця функція, яка перезаписує залежність - на працюючу функцію + Overriding the database session dependency. + To avoid global variables, a dummy function + is used in the code as a session dependency. + During setup, this function is called to + override the dependency with the actual working function. """ app.dependency_overrides[get_session_depend] = database.get_session async def get_session_depend(): - """Функція-заглушка""" + """Dummy function""" pass diff --git a/src/depends.py b/src/depends.py index 3944598..8aa0a8c 100644 --- a/src/depends.py +++ b/src/depends.py @@ -1,3 +1,9 @@ +""" +Dependencies for FastApi routers. +These methods are used with the Depends +construct to obtain the necessary resources for performing operations. +""" + from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy.ext.asyncio import AsyncSession @@ -12,7 +18,11 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") -def get_uow(session: AsyncSession = Depends(get_session_depend)): +def get_uow(session: AsyncSession = Depends(get_session_depend)) -> AbstractUnitOfWork: + """ + :param session: database session + :return: UnitOfWork + """ yield SqlAlchemyUnitOfWork(session) @@ -20,7 +30,7 @@ async def get_current_user( uow: AbstractUnitOfWork = Depends(get_uow), token: str = Depends(oauth2_scheme) ) -> User: """ - Залежність FastApi для отримання юзера по JWT + FastApi dependency to retrieve a user based on JWT (JSON Web Token) :return: User """ token_data = decode_token_data(token) @@ -31,8 +41,8 @@ async def get_current_user( def raise_credentials_exception(): """ - Піднімає HTTPException. - Викликається, якщо JWT, переданий в headers - невалідний + Raises HTTPException. + Called when the JWT passed in the headers is invalid. """ credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/src/main.py b/src/main.py index a295de8..8a8e539 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,3 @@ -""" -Головний файл програми -""" from fastapi import FastAPI from src.database import DatabaseFactory, bind_database_to_app @@ -8,18 +5,21 @@ def bootstrap_fastapi_app(db_factory=DatabaseFactory(), test=False) -> FastAPI: - """Налаштування FastApi для початку роботи, включаючи базу""" + """ + Setting up FastAPI for operation. + The setup includes connecting routers, middlewares, and the database. + """ fastapi_app = FastAPI() include_routers(fastapi_app) set_cors_middleware(fastapi_app) database = db_factory.get_database(test=test) bind_database_to_app(fastapi_app, database) - # Прив'язка залежності, яка віддає сесію бази + # Binding a dependency that provides the database session return fastapi_app def include_routers(fastapi_app): - """Підключення роутерів""" + """Router connections""" from src.routers.auth import router as auth_router # noqa: E402; from src.routers.bank_api import router as bankapi_router # noqa: E402; from src.routers.categories import router as categories_router # noqa: E402; diff --git a/src/middlewares.py b/src/middlewares.py index f199915..61ad65e 100644 --- a/src/middlewares.py +++ b/src/middlewares.py @@ -3,6 +3,7 @@ def set_cors_middleware(app: FastAPI): + """CORS settings allow the frontend part to send requests to the API.""" origins = ["*"] app.add_middleware( diff --git a/src/routers/auth.py b/src/routers/auth.py index 97cb8ba..b3b0a7e 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -1,6 +1,3 @@ -""" -Authentication endpoints -""" from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm @@ -20,15 +17,14 @@ async def login_for_access_token( uow: AbstractUnitOfWork = Depends(get_uow), ): """ - Логін користувача за допомогою формування JWT. - Якщо дані для входу не вірні - піднімається помилка + User login using JWT generation. + If the login credentials are incorrect, an error is raised. :param uow: Unit of Work - :param form_data: схема, яка формується на базі введених даних користувача. - :return: jwt і тип токену. Для використання потрібно вказати як header у вигляді - - Authorization: + :param form_data: Schema generated based on the user's input data. + :return: JWT and token type. To use, specify as a header: + Authorization: """ user = await get_user_by_email(uow, form_data.username) if not user or not verify_password(form_data.password, user.hashed_password): diff --git a/src/routers/bank_api.py b/src/routers/bank_api.py index 42ee31d..e0a1e1c 100644 --- a/src/routers/bank_api.py +++ b/src/routers/bank_api.py @@ -12,41 +12,67 @@ router = APIRouter(prefix="/bankapi") -@router.post("/add/", status_code=201) -async def add_bank_info_view( +@router.post("/", status_code=201) +async def create_bank_info_view( props: dict, current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): + """ + Create bank info record + :param props: bank info props + :param current_user: user + :param uow: unit of work + :return: 201 | 400 + """ await add_bank_info(uow, current_user.id, props) return {"status": "created"} -@router.put("/update_costs/", status_code=200) +@router.put("/costs/", status_code=200) async def update_costs_view( current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): + """ + Update costs by banks API + :param current_user: user + :param uow: unit of work + :return: 200 + """ managers = await get_bank_managers_by_user(uow, user_id=current_user.id) await update_banks_costs(uow, managers) return {"status": "ok"} -@router.get("/list/", status_code=200) +@router.get("/", status_code=200) async def get_connected_banks_names( current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ) -> list[str]: + """ + Get connected banks + :param current_user: + :param uow: + :return: 200, banks list + """ async with uow: managers = await uow.banks_info.get_all_by_user(current_user.id) return [manager.bank_name for manager in managers] -@router.delete("/delete/", status_code=204) +@router.delete("/", status_code=204) async def delete_bank( bank_name: str, current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): + """ + Delete bank info record + :param bank_name: bank name + :param current_user: user + :param uow: unit of work + :return: 204 + """ await uow.banks_info.delete(current_user.id, bank_name) await uow.commit() diff --git a/src/routers/categories.py b/src/routers/categories.py index c174b1c..c3d952b 100644 --- a/src/routers/categories.py +++ b/src/routers/categories.py @@ -16,17 +16,13 @@ async def create_operation_view( uow: AbstractUnitOfWork = Depends(get_uow), ): """ - Створює в БД та повертає операцію + Creates a record in the database and returns the operation. + :param uow: Unit of Work - :param category_schema: JSON, який буде спаршений у CategoryCretaeSchema - :param current_user: Користувач, - який розшифровується з токену у заголовку Authorization - :return: Category | Error 400 + :param category_schema: JSON, which will be parsed into CategoryCreateSchema. + :param current_user: User decrypted from the token in the Authorization header. + :return: Category object or Error 400. """ - # Результат create_category не може бути None, - # тому що user_id не може бути не правильним. - # У випадку помилки під час розшифровки токену - # буде повернута помилка 401 перед виконанням тіла. return await create_category(uow, current_user.id, category_schema) @@ -36,10 +32,10 @@ async def read_operations_view( uow: AbstractUnitOfWork = Depends(get_uow), ): """ - Повертає список операцій поточного користувача. + Returns a list of operations for the current user. + :param uow: Unit of Work - :param current_user: Користувач, - який розшифровується з токену у заголовку Authorization - :return: Category list + :param current_user: User decrypted from the token in the Authorization header. + :return: List of categories. """ return await get_availables_categories(uow, current_user.id) diff --git a/src/routers/operations.py b/src/routers/operations.py index fc7dc97..70d32a8 100644 --- a/src/routers/operations.py +++ b/src/routers/operations.py @@ -18,17 +18,13 @@ async def create_operation_view( uow: AbstractUnitOfWork = Depends(get_uow), ): """ - Створює в БД та повертає операцію + Create and return an operation. + :param uow: Unit of Work - :param operation_schema: JSON, який буде спаршений у OperationCretaeSchema - :param current_user: Користувач, - який розшифровується з токену у заголовку Authorization - :return: Operation | Error 400 + :param operation_schema: JSON, which will be parsed into OperationCreateSchema. + :param current_user: User decrypted from the token in the Authorization header. + :return: Operation object or Error 400. """ - # Результат create_operation не може бути None, - # тому що user_id не може бути не правильним. - # У випадку помилки під час розшифровки токену - # буде повернута помилка 401 перед виконанням тіла. return await create_operation(uow, current_user.id, operation_schema) @@ -40,13 +36,13 @@ async def read_operations_view( to_time: int | None = None, ): """ - Повертає список операцій поточного користувача. + Returns a list of operations for the current user. + :param uow: Unit of Work - :param current_user: Користувач, - який розшифровується з токену у заголовку Authorization - :param from_time: з якого часу в unix форматі - :param to_time: по який час в unix форматі - :return: Operation list + :param current_user: User decrypted from the token in the Authorization header. + :param from_time: Starting time in Unix format. + :param to_time: Ending time in Unix format. + :return: List of operations. """ from_time = ( from_time diff --git a/src/routers/users.py b/src/routers/users.py index e086848..5b9f888 100644 --- a/src/routers/users.py +++ b/src/routers/users.py @@ -12,20 +12,26 @@ router = APIRouter(prefix="/users") -@router.post("/create/", response_model=UserSchema, status_code=201) +@router.post("/", response_model=UserSchema, status_code=201) async def create_user_view( user: UserCreateSchema, uow: AbstractUnitOfWork = Depends(get_uow) ): """ - Створення і повернення користувача + Creation and retrieval of the user. + :param user: user create info + :param uow: unit of work (fastapi depend) + :return: UserSchema """ created_user = await create_user(uow, user) return created_user -@router.get("/me/", response_model=UserSchema) +@router.get("/", response_model=UserSchema) async def read_me(current_user: User = Depends(get_current_user)): """ - Повертає залогіненого юзера, або 401, якщо юзер не вказав заголовок Authorization + Returns the logged-in user or 401 + if the user did not provide the Authorization header. + :param current_user: user (fastapi depend) + :return: UserSchema or 401 """ return current_user diff --git a/src/schemas/auth.py b/src/schemas/auth.py index 277aca4..315cce0 100644 --- a/src/schemas/auth.py +++ b/src/schemas/auth.py @@ -3,10 +3,10 @@ class Token(BaseModel): """ - Схема JWT разом з ключовим словом. - В результаті, для аутентифікації користувача під час запиту, - потрібно вказати наступне поле в headers: - Authorization: + JWT schema along with the keyword. + As a result, for user authentication during a request, + you need to provide the following field in headers: + Authorization: """ access_token: str @@ -14,6 +14,6 @@ class Token(BaseModel): class TokenData(BaseModel): - """Схема дати, яка отримана з розшифрованого JWT.""" + """Date schema obtained from the decrypted JWT.""" email: str | None = None diff --git a/src/schemas/operations.py b/src/schemas/operations.py index c552b8a..b79814b 100644 --- a/src/schemas/operations.py +++ b/src/schemas/operations.py @@ -1,12 +1,7 @@ -""" -Operations schemas -""" from pydantic import BaseModel class OperationCreateSchema(BaseModel): - """Схема операції. Модель: src.domain.operations.Operation""" - amount: int description: str | None source_type: str @@ -15,8 +10,6 @@ class OperationCreateSchema(BaseModel): class OperationSchema(OperationCreateSchema): - """Схема операції, яка виокристовується під час завантаження даних з БД""" - id: int subcategory_id: int | None diff --git a/src/schemas/statistic.py b/src/schemas/statistic.py index 77bd5bd..abfa49e 100644 --- a/src/schemas/statistic.py +++ b/src/schemas/statistic.py @@ -2,8 +2,6 @@ class StatisticSchema(BaseModel): - """Схема операції. Модель: src.app.domain.unit.Statistic""" - costs_sum: int categories_costs: dict[int | None, int] costs_num_by_days: dict[str, int] diff --git a/src/schemas/users.py b/src/schemas/users.py index b600b9e..2993a0e 100644 --- a/src/schemas/users.py +++ b/src/schemas/users.py @@ -1,12 +1,7 @@ -""" -User schemas -""" from pydantic import BaseModel class UserBaseSchema(BaseModel): - """User base class""" - email: str class Config: @@ -14,8 +9,6 @@ class Config: class UserCreateSchema(UserBaseSchema): - """Class used for user create""" - password: str class Config: @@ -23,8 +16,6 @@ class Config: class UserSchema(UserBaseSchema): - """Class used for read user info""" - id: int class Config: diff --git a/tests_playwright/__init__.py b/tests_playwright/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests_playwright/test_auth.py b/tests_playwright/test_auth.py deleted file mode 100644 index dc5fdd4..0000000 --- a/tests_playwright/test_auth.py +++ /dev/null @@ -1,78 +0,0 @@ -import re -import time - -from playwright.sync_api import Page -from sqlalchemy import create_engine, text - -from src.database import _get_url - - -def delete_user(): - engine = create_engine(_get_url(async_=False)) - with engine.connect() as connection: - user_id = connection.execute( - text("SELECT id FROM public.users WHERE email = 'test';") - ).first() - user_id = user_id[0] if user_id else None - if user_id: - connection.execute( - text(f"DELETE FROM public.operations WHERE user_id = {user_id};") - ) - connection.execute(text("DELETE FROM public.users WHERE email = 'test';")) - connection.commit() - - -def login(page): - page.get_by_role("button", name="Sign In").click() - page.get_by_placeholder("email").fill("test") - page.get_by_placeholder("password").click() - page.get_by_placeholder("password").fill("test") - page.get_by_role("button", name="Login").click() - - -def test_auth(page: Page) -> None: - delete_user() - page.goto("http://127.0.0.1:3000/") - page.get_by_role("button", name="Sign Up").click() - page.get_by_placeholder("email").fill("test") - page.get_by_placeholder("password", exact=True).click() - page.get_by_placeholder("password", exact=True).fill("test") - page.get_by_placeholder("confirm password").click() - page.get_by_placeholder("confirm password").fill("test") - page.get_by_role("button", name="Register").click() - login(page) - assert page.get_by_role("button", name="Додати").is_enabled() - - -def test_add_operation(page: Page) -> None: - page.goto("http://127.0.0.1:3000/") - login(page) - page.get_by_role("button", name="Додати").click() - page.get_by_placeholder("сума").fill("100") - page.get_by_placeholder("опис").fill("test") - page.locator("svg").click() - time.sleep(0.3) - page.get_by_text("Ветеринарні послуги", exact=True).last.click() - page.get_by_role("button", name="Додати").click() - time.sleep(0.3) - assert ( - page.get_by_text( - re.compile("-100 ₴testВетеринарні послугиручний спосіб") - ).count() - > 0 - ) - - -def test_add_old_operation(page: Page) -> None: - page.goto("http://127.0.0.1:3000/") - login(page) - page.get_by_role("button", name="Додати").click() - page.get_by_placeholder("сума").fill("100") - page.get_by_placeholder("опис").fill("test") - page.get_by_placeholder("час").fill("2023-04-25T05:15") - page.locator("svg").click() - time.sleep(0.3) - page.get_by_text("Продукти", exact=True).click() - page.get_by_role("button", name="Додати").click() - page.get_by_placeholder("dashboard-time").fill("2023-04-25") - assert page.get_by_text("-100 ₴testПродуктиручний спосіб05:15").count() == 1 From 74e236494f238006312e8c51d202ffc905015e9d Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Jul 2023 15:03:51 +0300 Subject: [PATCH 2/6] refactor(categories): change endpoint paths --- src/routers/categories.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routers/categories.py b/src/routers/categories.py index c3d952b..5ae295e 100644 --- a/src/routers/categories.py +++ b/src/routers/categories.py @@ -9,7 +9,7 @@ router = APIRouter(prefix="/categories") -@router.post("/create/", response_model=CategorySchema, status_code=201) +@router.post("/", response_model=CategorySchema, status_code=201) async def create_operation_view( category_schema: CategoryCreateSchema, current_user: User = Depends(get_current_user), @@ -26,7 +26,7 @@ async def create_operation_view( return await create_category(uow, current_user.id, category_schema) -@router.get("/list/", response_model=list[CategorySchema]) +@router.get("/", response_model=list[CategorySchema]) async def read_operations_view( current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), From 2d2bc0b93aa493a806aa1456ab821a95afcfcf3d Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Jul 2023 15:05:21 +0300 Subject: [PATCH 3/6] refactor(categories): change endpoint names --- src/routers/categories.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routers/categories.py b/src/routers/categories.py index 5ae295e..3a95ce6 100644 --- a/src/routers/categories.py +++ b/src/routers/categories.py @@ -10,7 +10,7 @@ @router.post("/", response_model=CategorySchema, status_code=201) -async def create_operation_view( +async def create_category_view( category_schema: CategoryCreateSchema, current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), @@ -27,7 +27,7 @@ async def create_operation_view( @router.get("/", response_model=list[CategorySchema]) -async def read_operations_view( +async def read_categories_view( current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): From dbb494781363312addb294315df3a156ab653daa Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Jul 2023 15:24:16 +0300 Subject: [PATCH 4/6] refactor(operations): change endpoint paths --- src/routers/operations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routers/operations.py b/src/routers/operations.py index 70d32a8..adf5fac 100644 --- a/src/routers/operations.py +++ b/src/routers/operations.py @@ -11,7 +11,7 @@ router = APIRouter(prefix="/operations") -@router.post("/create/", response_model=OperationSchema, status_code=201) +@router.post("/", response_model=OperationSchema, status_code=201) async def create_operation_view( operation_schema: OperationCreateSchema, current_user: User = Depends(get_current_user), @@ -28,7 +28,7 @@ async def create_operation_view( return await create_operation(uow, current_user.id, operation_schema) -@router.get("/list/", response_model=list[OperationSchema]) +@router.get("/", response_model=list[OperationSchema]) async def read_operations_view( current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), From eb832476c85f24fd5450b4bbc77d6a26e577d8d8 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Jul 2023 16:03:44 +0300 Subject: [PATCH 5/6] feat: add readme.md --- README.md | 329 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..1509f2c --- /dev/null +++ b/README.md @@ -0,0 +1,329 @@ +

+ full-logo +

+ +

+ Python Version + Python Version + Python Version + Python Version +

+ +## Про проект + +Адаптивний сервіс для класифікації та моніторингу персональних витрат. +Має CRUD операції для керування операціями та категоріями. +Створює персональну статистику витрат, яку можна візуалізувати у діаграмах. +Дозволяє створювати обмеження на витрати по категоріям. +Має можливість синхронізуватись з банками (Monobank) для отримання витрат у реальному часі. + +## Про розробку + +Проектування проекту відбувалось по принципу DDD та чистої архітектури. +Він поділений на шари бізнес-логіки, доменів, адаптерів та інфраструктури. +За допомогою Dependency Inversion вдалось побудувати правильну ієрархію залежностей: +``` +Domens <- Uses cases <- adapters <- infrastructure +``` +Де стрілками показана залежність шару від іншого. +Тобто, інфраструктура залежить від вищих шарів, адаптери від бізнес логіки та доменів, +бізнес логіка тільки від доменів а домени це незалежні частини програмного продукту. + +Розроблявся додаток за принципом TDD, де спочатчку для правильного проектування структур і алгоритмів +розроблялись тести, де формувались вимоги до коду, а потім розроблявся сам код щоб ці вимоги задовольнити. + +Для автоматичних тестів та розгортання був застосований GitHub Actions. Для контейнеризації був застосований Docker. + + +## Документація + +### `GET` `/users/` - get current user info + +**Headers** +``` +Authorization: +``` + +**Status codes:** + +| Status Code | Description | +|:-------------:|:-------------:| +| `200` | Ok | +| `401` | Unauthorized | + +**Response:** +``` +{ + "id": int, + "email": string, +} +``` +--- +### `POST` `/users/` - create user + +**Request data:** +``` +{ + "email": string, + "password": string, +} +``` + +**Status codes:** + +| Status Code | Description | +|:-----------:|:-----------------:| +| `201` | Created | +| `422` | Validation Error | + +**Response data:** +``` +{ + "id": int, + "email": string, +} +``` +--- +### `POST` `/token/` - login for access token + +**Request data:** +``` +{ + "username": string (email), + "password": string, +} +``` + +**Status codes:** + +| Status Code | Description | +|:-----------:|:----------------:| +| `200` | Ok | +| `422` | Validation Error | + +**Response data:** +``` +{ + "access_token": "string", + "token_type": "string" +} +``` +--- +### `POST` `/operations/` - create operation + +**Headers** +``` +Authorization: +``` + +**Request data:** +``` +{ + "amount": int, + "description": "string", + "source_type": "string", + "time": int, + "category_id": int +} +``` + +**Status codes:** + +| Status Code | Description | +|:-----------:|:-----------------:| +| `201` | Created | +| `422` | Validation Error | + +**Response data:** +``` +{ + "id": int, + "amount": int, + "description": "string", + "source_type": "string", + "time": int, + "category_id": int, + "subcategory_id": int +} +``` +--- +### `GET` `/operations/` - get list of operations + +**Headers** +``` +Authorization: +``` + +**Request parameters:** +``` +from_time: int +to_time: int +``` + +**Status codes:** + +| Status Code | Description | +|:-----------:|:-----------------:| +| `200` | Created | +| `422` | Validation Error | + +**Response data:** +``` +[ + { + "amount": int, + "description": "string", + "source_type": "string", + "time": int, + "category_id": int, + "id": int, + "subcategory_id": int + } +] +``` +--- +### `GET` `/bankapi/` - get list of connected banks names + +**Headers** +``` +Authorization: +``` +**Status codes:** + +| Status Code | Description | +|:-----------:|:-----------:| +| `200` | Ok | + + +**Response data:** +``` +[ + "string" +] +``` +--- +### `DELETE` `/bankapi/` - delete record of connect to bank + +**Headers** +``` +Authorization: +``` + +**Request parameters:** +``` +bank_name: string +``` + +**Status codes:** + +| Status Code | Description | +|:-----------:|:----------------:| +| `204` | No content | +| `422` | Validation Error | + +--- +### `GET` `/bankapi/costs/` - update costs by banks API + +**Headers** +``` +Authorization: +``` + +**Status codes:** + +| Status Code | Description | +|:-----------:|:-----------:| +| `200` | Ok | +--- +### `GET` `/statistic/` - get statistic + +**Headers** +``` +Authorization: +``` + +**Request parameters:** +``` +from_time: int +to_time: int +``` + +**Status codes:** + +| Status Code | Description | +|:-----------:|:-----------------:| +| `200` | Created | + +**Response data:** +``` +{ + "costs_sum": 0, + "categories_costs": {}, + "costs_num_by_days": {}, + "costs_sum_by_days": {} +} +``` +--- +### `POST` `/categories/` - create category + +**Headers** +``` +Authorization: +``` + +**Request data:** +``` +{ + "name": "string", + "icon_name": "string", + "icon_color": "string" +} +``` + +**Status codes:** + +| Status Code | Description | +|:-----------:|:-----------------:| +| `201` | Created | +| `422` | Validation Error | + +**Response data:** +``` +{ + "name": "string", + "id": int, + "user_id": int, + "type": "string", + "icon_name": "string", + "icon_color": "string", + "parent_id": int +} +``` +--- +### `GET` `/categories/` - get list of categories + +**Headers** +``` +Authorization: +``` + +**Status codes:** + +| Status Code | Description | +|:-----------:|:----------------:| +| `200` | Ok | + +**Response data:** +``` +[ + { + "name": "string", + "id": int, + "user_id": int, + "type": "string", + "icon_name": "string", + "icon_color": "string", + "parent_id": int + } +] +``` From 3e2b1e10210fc964dce710fbf57b08d625b622f3 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 28 Jul 2023 16:09:59 +0300 Subject: [PATCH 6/6] refactor: refactor tests --- tests/integrations/auth_test.py | 2 +- tests/integrations/bank_api_test.py | 4 ++-- tests/integrations/categories_test.py | 6 ++---- tests/integrations/operations_test.py | 12 ++++-------- tests/integrations/users_test.py | 4 ++-- tests/patterns.py | 4 ++-- 6 files changed, 13 insertions(+), 19 deletions(-) diff --git a/tests/integrations/auth_test.py b/tests/integrations/auth_test.py index 9c849c5..beea588 100644 --- a/tests/integrations/auth_test.py +++ b/tests/integrations/auth_test.py @@ -12,7 +12,7 @@ async def test_auth_token(client_db: AsyncClient): # noqa: F811; Якщо ні, то повернути помилку 401. """ user_json = {"email": "test", "password": "test"} # nosec B106 - await client_db.post("/users/create/", json=user_json) + await client_db.post("/users/", json=user_json) auth_json = {"username": user_json["email"], "password": user_json["password"]} auth_result = await client_db.post("/token/", data=auth_json) diff --git a/tests/integrations/bank_api_test.py b/tests/integrations/bank_api_test.py index 3645916..0e661d1 100644 --- a/tests/integrations/bank_api_test.py +++ b/tests/integrations/bank_api_test.py @@ -50,7 +50,7 @@ async def test_update_costs_with_endpoint(client_db: AsyncClient, database: Data # Створення менеджера uow = SqlAlchemyUnitOfWork(session) await create_monobank_manager(uow, user_id) - response = await client_db.put("/bankapi/update_costs/", headers=headers) + response = await client_db.put("/bankapi/costs/", headers=headers) assert response.status_code == 200 @@ -61,7 +61,7 @@ async def test_add_bank_info_with_endpoint(client_db: AsyncClient): headers = {"Authorization": token} response = await client_db.post( - "/bankapi/add/", + "/bankapi/", json={ "bank_name": "monobank", "X-Token": "uZFOvRJNeXoVHYTUA_8NgHneWUz8IsG8dRPUbx60mbM4", diff --git a/tests/integrations/categories_test.py b/tests/integrations/categories_test.py index 9386e03..a368c61 100644 --- a/tests/integrations/categories_test.py +++ b/tests/integrations/categories_test.py @@ -15,9 +15,7 @@ async def test_create_category_endpoint(client_db: AsyncClient): headers = {"Authorization": token} category_data = {"name": "category name"} - response = await client_db.post( - "/categories/create/", json=category_data, headers=headers - ) + response = await client_db.post("/categories/", json=category_data, headers=headers) assert response.status_code == 201 created_category = response.json() @@ -37,6 +35,6 @@ async def test_read_categories_endpoint(database: Database, client_db: AsyncClie schema = CategoryCreateSchema(name=f"test category #{i}") await create_category(uow, user_id, schema) - response = await client_db.get("/categories/list/", headers=headers) + response = await client_db.get("/categories/", headers=headers) assert response.status_code == 200 assert len(response.json()) == 10 diff --git a/tests/integrations/operations_test.py b/tests/integrations/operations_test.py index 79c46d7..a7773bf 100644 --- a/tests/integrations/operations_test.py +++ b/tests/integrations/operations_test.py @@ -22,7 +22,7 @@ async def test_create_operation_endpoint(client_db: AsyncClient): # noqa: F811; "source_type": "manual", } response = await client_db.post( - "/operations/create/", json=operation_data, headers=headers + "/operations/", json=operation_data, headers=headers ) assert response.status_code == 201 @@ -37,9 +37,7 @@ async def test_create_operation_with_incorrect_data(client_db: AsyncClient): token = auth_data["token"] headers = {"Authorization": token} - incorrect_response = await client_db.post( - "/operations/create/", json={}, headers=headers - ) + incorrect_response = await client_db.post("/operations/", json={}, headers=headers) assert incorrect_response.status_code == 422 @@ -55,11 +53,9 @@ async def test_read_operations_endpoint(client_db: AsyncClient): # noqa: F811; "description": "description", "source_type": "manual", } - await client_db.post( - "/operations/create/", json=operation_data, headers=headers - ) + await client_db.post("/operations/", json=operation_data, headers=headers) - operations_response = await client_db.get("/operations/list/", headers=headers) + operations_response = await client_db.get("/operations/", headers=headers) assert operations_response.status_code == 200 operations = operations_response.json() diff --git a/tests/integrations/users_test.py b/tests/integrations/users_test.py index dcbc874..ae7ad71 100644 --- a/tests/integrations/users_test.py +++ b/tests/integrations/users_test.py @@ -15,7 +15,7 @@ async def test_create_user_endpoint(client_db: AsyncClient): # noqa: F811; Testing src.views.users.create_user_view """ data = {"email": "test@mail.test", "password": "123456"} # nosec B106 - result = await client_db.post("/users/create/", json=data) + result = await client_db.post("/users/", json=data) assert result.status_code == 201 assert all(key in result.json() for key in ["id", "email"]) @@ -28,7 +28,7 @@ async def test_read_user(client_db: AsyncClient): # noqa: F811; auth_result = await create_and_auth_func_user(client_db) created_user, token = auth_result["user"], auth_result["token"] - read_result = await client_db.get("/users/me/", headers={"Authorization": token}) + read_result = await client_db.get("/users/", headers={"Authorization": token}) assert read_result.status_code == 200 user_data = read_result.json() diff --git a/tests/patterns.py b/tests/patterns.py index a641a1e..be74afa 100644 --- a/tests/patterns.py +++ b/tests/patterns.py @@ -33,7 +33,7 @@ async def create_func_user(client: AsyncClient) -> dict: :return: dict з інформацією про користувача """ user_data = {"email": "test@mail.test", "password": "123456"} # nosec B106 - return (await client.post("/users/create/", json=user_data)).json() + return (await client.post("/users/", json=user_data)).json() async def create_and_auth_func_user(client: AsyncClient) -> dict: @@ -64,4 +64,4 @@ async def create_operations(headers: dict, client: AsyncClient): "mcc": random.randint(1000, 9999), "source_type": "manual", } - await client.post("/operations/create/", json=operation_data, headers=headers) + await client.post("/operations/", json=operation_data, headers=headers)