From 5658834043ed66253f2bb2792859a1486784d709 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Tue, 2 May 2023 23:50:17 +0300 Subject: [PATCH 01/80] added get_connected_banks_names router --- src/routers/bank_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/routers/bank_api.py b/src/routers/bank_api.py index 3e2623f..215bb16 100644 --- a/src/routers/bank_api.py +++ b/src/routers/bank_api.py @@ -30,3 +30,13 @@ async def update_costs_view( managers = await get_bank_managers_by_user(uow, user_id=current_user.id) await update_banks_costs(uow, managers) return + + +@router.get("/list/", status_code=200) +async def get_connected_banks_names( + current_user: User = Depends(get_current_user_depend), + uow: AbstractUnitOfWork = Depends(get_uow), +) -> list[str]: + async with uow: + managers = await uow.banks_info.get_all_by_user(current_user.id) + return [manager.bank_name for manager in managers] From eff49130413c6814effcfd5b38134e44b4e9429c Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Tue, 2 May 2023 23:53:13 +0300 Subject: [PATCH 02/80] fix get updated time method --- src/app/services/bank_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index 8eb1d7e..1c7a1f0 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -83,7 +83,7 @@ def get_updated_time(manager: ABankManagerRepository) -> int | None: max_update_period = datetime.now() - manager.MAX_UPDATE_PERIOD if updated_time_prop: updated_time = datetime.fromtimestamp(updated_time_prop) - if not datetime.now() - updated_time < max_update_period: + if not datetime.now() - updated_time < manager.MAX_UPDATE_PERIOD: # Якщо остання дата оновлення перевищує максимальний період оновлення updated_time = max_update_period # У кожного банка є максиммальна дата, на яку можна запитувати витрати From e697de5db823e490fca69f9aaa2cc984a645dd85 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 3 May 2023 00:11:00 +0300 Subject: [PATCH 03/80] renamed fields in BankInfoProperty model --- ...7e7_change_bankinfoproperty_field_names.py | 52 +++++++++++++++++++ src/app/adapters/orm.py | 6 +-- src/app/domain/bank_api.py | 14 ++--- src/app/repositories/bank_api/bank_info.py | 10 ++-- src/app/services/bank_api.py | 5 +- tests/func/bank_api_test.py | 6 +-- 6 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 migration/versions/79110a6de7e7_change_bankinfoproperty_field_names.py diff --git a/migration/versions/79110a6de7e7_change_bankinfoproperty_field_names.py b/migration/versions/79110a6de7e7_change_bankinfoproperty_field_names.py new file mode 100644 index 0000000..bab2f9e --- /dev/null +++ b/migration/versions/79110a6de7e7_change_bankinfoproperty_field_names.py @@ -0,0 +1,52 @@ +"""change BankInfoProperty field names + +Revision ID: 79110a6de7e7 +Revises: 86f9d6304908 +Create Date: 2023-05-02 23:57:37.004802 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "79110a6de7e7" +down_revision = "86f9d6304908" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "banks_info_properties", sa.Column("prop_name", sa.String(), nullable=False) + ) + op.add_column( + "banks_info_properties", sa.Column("prop_value", sa.String(), nullable=False) + ) + op.add_column( + "banks_info_properties", sa.Column("prop_type", sa.String(), nullable=False) + ) + op.drop_column("banks_info_properties", "name") + op.drop_column("banks_info_properties", "type") + op.drop_column("banks_info_properties", "value") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "banks_info_properties", + sa.Column("value", sa.VARCHAR(), autoincrement=False, nullable=False), + ) + op.add_column( + "banks_info_properties", + sa.Column("type", sa.VARCHAR(), autoincrement=False, nullable=False), + ) + op.add_column( + "banks_info_properties", + sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False), + ) + op.drop_column("banks_info_properties", "prop_type") + op.drop_column("banks_info_properties", "prop_value") + op.drop_column("banks_info_properties", "prop_name") + # ### end Alembic commands ### diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index a381030..17e6af3 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -45,9 +45,9 @@ def create_tables(mapper_registry) -> dict[str, Table]: "banks_info_properties", mapper_registry.metadata, Column("id", Integer, primary_key=True), - Column("name", String, nullable=False), - Column("value", String, nullable=False), - Column("type", String, nullable=False), + Column("prop_name", String, nullable=False), + Column("prop_value", String, nullable=False), + Column("prop_type", String, nullable=False), Column("manager_id", Integer, ForeignKey("banks_info.id")), ), } diff --git a/src/app/domain/bank_api.py b/src/app/domain/bank_api.py index 8fb6925..37aa3d4 100644 --- a/src/app/domain/bank_api.py +++ b/src/app/domain/bank_api.py @@ -27,20 +27,20 @@ class BankInfoProperty: def __init__( self, - name: str, - value: str, - value_type: str, + prop_name: str, + prop_value: str, + prop_type: str, manager_id: int | None = None, id: int = None, manager: BankInfo | None = None, ): - self.name = name - self.value = value - self.type = value_type + self.prop_name = prop_name + self.prop_value = prop_value + self.prop_type = prop_type self.manager_id = manager_id self.id = id self.manager = manager def as_dict(self): types = {"str": str, "int": int, "float": float} - return {f"{self.name}": types[self.type](self.value)} + return {f"{self.prop_name}": types[self.prop_type](self.prop_value)} diff --git a/src/app/repositories/bank_api/bank_info.py b/src/app/repositories/bank_api/bank_info.py index 330f307..a1831a3 100644 --- a/src/app/repositories/bank_api/bank_info.py +++ b/src/app/repositories/bank_api/bank_info.py @@ -39,9 +39,9 @@ async def add_property( types = {str: "str", int: "int", float: "float"} self.session.add( BankInfoProperty( - name=name, - value=str(value), - value_type=types[type(value)], + prop_name=name, + prop_value=str(value), + prop_type=types[type(value)], manager_id=manager.id, ) ) @@ -51,7 +51,7 @@ async def set_update_time_to_managers(self, ids: list[int]): update(BankInfoProperty) .filter( BankInfoProperty.manager_id.in_(ids), - BankInfoProperty.name == "updated_time", + BankInfoProperty.prop_name == "updated_time", ) - .values(updated_time=datetime.now().timestamp()) + .values(prop_value=str(int(datetime.now().timestamp()))) ) diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index 1c7a1f0..d4311e9 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -25,7 +25,7 @@ async def add_bank_info(uow: AbstractUnitOfWork, user_id: int, props: dict): # записуються у вигляді BankInfoProperty із зовнішнім ключем до BankInfo await uow.banks_info.add( BankInfoProperty( - name=key, value=value, value_type="str", manager=bank_info + prop_name=key, prop_value=value, prop_type="str", manager=bank_info ) ) await uow.commit() @@ -64,11 +64,10 @@ async def update_banks_costs( updated_managers.append(manager) for cost in costs: await uow.operations.add(cost) - await uow.commit() - await uow.banks_info.set_update_time_to_managers( [manager.properties["id"] for manager in updated_managers] ) + await uow.commit() def get_updated_time(manager: ABankManagerRepository) -> int | None: diff --git a/tests/func/bank_api_test.py b/tests/func/bank_api_test.py index 04c3480..c7e03cb 100644 --- a/tests/func/bank_api_test.py +++ b/tests/func/bank_api_test.py @@ -15,9 +15,9 @@ async def create_monobank_manager(uow: AbstractUnitOfWork, user_id: int): await uow.banks_info.add(bank_info) await uow.banks_info.add( BankInfoProperty( - name="X-Token", - value="uZFOvRJNeXoVHYTUA_8NgHneWUz8IsG8dRPUbx60mbM4", - value_type="str", + prop_name="X-Token", + prop_value="uZFOvRJNeXoVHYTUA_8NgHneWUz8IsG8dRPUbx60mbM4", + prop_type="str", manager=bank_info, ) ) From 9440ebd1772269fb885cc99d58f329a7c3260b1b Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 3 May 2023 00:22:45 +0300 Subject: [PATCH 04/80] fixed add operation method in list --- src/app/repositories/bank_api/monobank.py | 4 +++- src/app/services/bank_api.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/repositories/bank_api/monobank.py b/src/app/repositories/bank_api/monobank.py index 4dd08d8..b04e209 100644 --- a/src/app/repositories/bank_api/monobank.py +++ b/src/app/repositories/bank_api/monobank.py @@ -23,8 +23,10 @@ async def get_costs( while from_time: response = await session.get(f"{url}/{from_time}/{to_time}") operations = await response.json() + if not operations: + return [] costs = self.validate_operations(operations) - result_operations.append(*costs) + result_operations += costs from_time = operations[-1]["time"] if len(operations) == 500 else None # В одного запиту до Monobank API обмеження - 500 операцій # Тому, якщо вийшло 500 операцій то можливо, ще є операції diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index d4311e9..8d72b12 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -60,7 +60,7 @@ async def update_banks_costs( for manager in managers: updated_time = get_updated_time(manager) if updated_time: - costs.append(*await manager.get_costs(from_time=updated_time)) + costs += await manager.get_costs(from_time=updated_time) updated_managers.append(manager) for cost in costs: await uow.operations.add(cost) From 24483298b0bce217d5dd9bcce2638e33a02d43ce Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 3 May 2023 00:23:02 +0300 Subject: [PATCH 05/80] fixed tests --- tests/unit/operations_test.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/unit/operations_test.py b/tests/unit/operations_test.py index 63b6e68..35d9c0a 100644 --- a/tests/unit/operations_test.py +++ b/tests/unit/operations_test.py @@ -60,11 +60,15 @@ async def test_read_operations(database): # noqa: F811; ) await create_operation(uow, created_user.id, operation_schema) - async with uow: - operations = await uow.operations.get_all_by_user(created_user.id) - assert isinstance(operations, list) - assert isinstance(operations[0], Operation) - assert len(operations) == 10 + operations = await get_operations( + uow, + created_user.id, + from_time=int((datetime.now() - timedelta(minutes=1)).timestamp()), + to_time=int(datetime.now().timestamp()), + ) + assert isinstance(operations, list) + assert isinstance(operations[0], Operation) + assert len(operations) == 10 @pytest.mark.asyncio From ce4fa8fbafd2d2f19927c8af9df0c8c6ddb6d849 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 11:43:55 +0300 Subject: [PATCH 06/80] fixed initialize default parameters in get_costs method --- src/app/repositories/absctract/bank_api.py | 5 +---- src/app/repositories/bank_api/monobank.py | 11 ++++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/app/repositories/absctract/bank_api.py b/src/app/repositories/absctract/bank_api.py index bf0b0ee..6317ee2 100644 --- a/src/app/repositories/absctract/bank_api.py +++ b/src/app/repositories/absctract/bank_api.py @@ -14,7 +14,6 @@ """ from abc import ABC, abstractmethod -from datetime import datetime from src.app.domain.bank_api import BankInfo from src.app.domain.operations import Operation @@ -53,9 +52,7 @@ def __init__(self, properties=None): self.properties = properties if properties else {} @abstractmethod - async def get_costs( - self, from_time=datetime.now(), to_time=None - ) -> list[Operation]: + async def get_costs(self, from_time=None, to_time=None) -> list[Operation]: raise NotImplementedError diff --git a/src/app/repositories/bank_api/monobank.py b/src/app/repositories/bank_api/monobank.py index b04e209..0befb41 100644 --- a/src/app/repositories/bank_api/monobank.py +++ b/src/app/repositories/bank_api/monobank.py @@ -10,11 +10,12 @@ class MonobankManagerRepository(ABankManagerRepository): __bankname__ = "monobank" MAX_UPDATE_PERIOD = timedelta(30) - async def get_costs( - self, - from_time=int((datetime.now() - MAX_UPDATE_PERIOD).timestamp()), - to_time=int(datetime.now().timestamp()), - ) -> list[Operation]: + async def get_costs(self, from_time=None, to_time=None) -> list[Operation]: + if not from_time: + from_time = int((datetime.now() - self.MAX_UPDATE_PERIOD).timestamp()) + if not to_time: + to_time = int(datetime.now().timestamp()) + url = "https://api.monobank.ua/personal/statement/0" headers = {"X-Token": self.properties["X-Token"]} result_operations = [] From b3d0f756b75753185c877826e4de1cdfd2298c10 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 11:45:35 +0300 Subject: [PATCH 07/80] added handling of incorrect response from API --- src/app/repositories/bank_api/monobank.py | 2 ++ src/app/services/bank_api.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/repositories/bank_api/monobank.py b/src/app/repositories/bank_api/monobank.py index 0befb41..75f756d 100644 --- a/src/app/repositories/bank_api/monobank.py +++ b/src/app/repositories/bank_api/monobank.py @@ -26,6 +26,8 @@ async def get_costs(self, from_time=None, to_time=None) -> list[Operation]: operations = await response.json() if not operations: return [] + if "errorDescription" in operations: + return None costs = self.validate_operations(operations) result_operations += costs from_time = operations[-1]["time"] if len(operations) == 500 else None diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index 8d72b12..4191289 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -60,8 +60,10 @@ async def update_banks_costs( for manager in managers: updated_time = get_updated_time(manager) if updated_time: - costs += await manager.get_costs(from_time=updated_time) - updated_managers.append(manager) + bank_costs = await manager.get_costs(from_time=updated_time) + if bank_costs: + costs += bank_costs + updated_managers.append(manager) for cost in costs: await uow.operations.add(cost) await uow.banks_info.set_update_time_to_managers( From 22ec9a5adf917bb7206e7989f35427c3fe2161fa Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 11:46:26 +0300 Subject: [PATCH 08/80] added inverting costs in a positive value --- src/app/repositories/bank_api/monobank.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/repositories/bank_api/monobank.py b/src/app/repositories/bank_api/monobank.py index 75f756d..a7bc122 100644 --- a/src/app/repositories/bank_api/monobank.py +++ b/src/app/repositories/bank_api/monobank.py @@ -48,6 +48,8 @@ async def get_costs(self, from_time=None, to_time=None) -> list[Operation]: def validate_operations(self, operations: list[dict]) -> list[dict]: costs = list(filter(lambda operation: operation["amount"] < 0, operations)) for cost in costs: + cost["amount"] *= -1 + # Сума витрати в програмі оперуються як додатні величини del cost["id"] # Видалення id з операції, яке йому надає monobank return costs From f6da226500c8124e93dce475769585018e69dd22 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 14:04:30 +0300 Subject: [PATCH 09/80] renamed func package to integrations --- tests/integrations/__init__.py | 0 tests/{func => integrations}/auth_test.py | 1 - tests/{func => integrations}/bank_api_test.py | 0 tests/{func => integrations}/operations_test.py | 3 --- tests/{func => integrations}/statistic_test.py | 0 tests/{func => integrations}/users_test.py | 0 6 files changed, 4 deletions(-) create mode 100644 tests/integrations/__init__.py rename tests/{func => integrations}/auth_test.py (94%) rename tests/{func => integrations}/bank_api_test.py (100%) rename tests/{func => integrations}/operations_test.py (96%) rename tests/{func => integrations}/statistic_test.py (100%) rename tests/{func => integrations}/users_test.py (100%) diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/func/auth_test.py b/tests/integrations/auth_test.py similarity index 94% rename from tests/func/auth_test.py rename to tests/integrations/auth_test.py index f38dd25..9c849c5 100644 --- a/tests/func/auth_test.py +++ b/tests/integrations/auth_test.py @@ -8,7 +8,6 @@ @pytest.mark.asyncio async def test_auth_token(client_db: AsyncClient): # noqa: F811; """ - Тест src.views.account.login_for_access_token Функція повинна вернути JWT, якщо введені дані користувача вірні. Якщо ні, то повернути помилку 401. """ diff --git a/tests/func/bank_api_test.py b/tests/integrations/bank_api_test.py similarity index 100% rename from tests/func/bank_api_test.py rename to tests/integrations/bank_api_test.py diff --git a/tests/func/operations_test.py b/tests/integrations/operations_test.py similarity index 96% rename from tests/func/operations_test.py rename to tests/integrations/operations_test.py index 8c5d4f4..5f7e1a8 100644 --- a/tests/func/operations_test.py +++ b/tests/integrations/operations_test.py @@ -40,9 +40,6 @@ async def test_create_operation_endpoint(client_db: AsyncClient): # noqa: F811; @pytest.mark.asyncio async def test_read_operations_endpoint(client_db: AsyncClient): # noqa: F811; - """ - Testing src.views.operations.read_operations_view - """ auth_data = await create_and_auth_func_user(client_db) token = auth_data["token"] headers = {"Authorization": token} diff --git a/tests/func/statistic_test.py b/tests/integrations/statistic_test.py similarity index 100% rename from tests/func/statistic_test.py rename to tests/integrations/statistic_test.py diff --git a/tests/func/users_test.py b/tests/integrations/users_test.py similarity index 100% rename from tests/func/users_test.py rename to tests/integrations/users_test.py From dc916160d00ef1efc993728729aec9dd988eeae2 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 14:05:48 +0300 Subject: [PATCH 10/80] extracted and moved fake adapters --- tests/{func => fake_adapters}/__init__.py | 0 tests/fake_adapters/base.py | 19 ++++++++++++ tests/fake_adapters/operations.py | 16 ++++++++++ tests/fake_adapters/users.py | 7 +++++ tests/fake_repositories.py | 37 ----------------------- 5 files changed, 42 insertions(+), 37 deletions(-) rename tests/{func => fake_adapters}/__init__.py (100%) create mode 100644 tests/fake_adapters/base.py create mode 100644 tests/fake_adapters/operations.py create mode 100644 tests/fake_adapters/users.py delete mode 100644 tests/fake_repositories.py diff --git a/tests/func/__init__.py b/tests/fake_adapters/__init__.py similarity index 100% rename from tests/func/__init__.py rename to tests/fake_adapters/__init__.py diff --git a/tests/fake_adapters/base.py b/tests/fake_adapters/base.py new file mode 100644 index 0000000..29eb961 --- /dev/null +++ b/tests/fake_adapters/base.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod + + +class FakeRepository(ABC): + def __init__(self): + self.instances = [] + + @abstractmethod + def get(self, prop, value): + raise NotImplementedError + + def add(self, instance): + self.instances.append(instance) + + def _get(self, prop, value): + result = list( + filter(lambda instance: instance.__dict__()[prop] == value, self.instances) + ) + return result[0] if result else None diff --git a/tests/fake_adapters/operations.py b/tests/fake_adapters/operations.py new file mode 100644 index 0000000..0e8b2bf --- /dev/null +++ b/tests/fake_adapters/operations.py @@ -0,0 +1,16 @@ +from src.app.domain.operations import Operation +from tests.fake_adapters.base import FakeRepository + + +class OperationRepository(FakeRepository): + def get(self, prop, value) -> Operation | None: + return self._get(prop, value) + + def get_all_by_user(self, user_id, from_time: int, to_time: int) -> list[Operation]: + return list( + filter( + lambda instance: instance.user_id == user_id + and from_time < instance.time < to_time, + self.instances, + ) + ) diff --git a/tests/fake_adapters/users.py b/tests/fake_adapters/users.py new file mode 100644 index 0000000..95fe75a --- /dev/null +++ b/tests/fake_adapters/users.py @@ -0,0 +1,7 @@ +from src.app.domain.users import User +from tests.fake_adapters.base import FakeRepository + + +class UserRepository(FakeRepository): + def get(self, prop, value) -> User | None: + return self._get(prop, value) diff --git a/tests/fake_repositories.py b/tests/fake_repositories.py deleted file mode 100644 index 8ec63da..0000000 --- a/tests/fake_repositories.py +++ /dev/null @@ -1,37 +0,0 @@ -from abc import ABC, abstractmethod - -from src.app.account.users.models import User -from src.app.operations.models import Operation - - -class FakeRepository(ABC): - def __init__(self): - self.instances = [] - - @abstractmethod - def get(self, prop, value): - raise NotImplementedError - - def add(self, instance): - self.instances.append(instance) - - def _get(self, prop, value): - result = list( - filter(lambda instance: instance.__dict__()[prop] == value, self.instances) - ) - return result[0] if result else None - - -class UserRepository(FakeRepository): - def get(self, prop, value) -> User | None: - return self._get(prop, value) - - -class OperationRepository(FakeRepository): - def get(self, prop, value) -> Operation | None: - return self._get(prop, value) - - def get_all_by_user(self, user_id) -> list[Operation]: - return list( - filter(lambda instance: instance.user_id == user_id, self.instances) - ) From fe87cca244ccef7bfd18f96eaf49cfb7970731d7 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 14:06:17 +0300 Subject: [PATCH 11/80] renamed test statistic file --- tests/unit/{statistic.py => statistic_test.py} | 1 - 1 file changed, 1 deletion(-) rename tests/unit/{statistic.py => statistic_test.py} (98%) diff --git a/tests/unit/statistic.py b/tests/unit/statistic_test.py similarity index 98% rename from tests/unit/statistic.py rename to tests/unit/statistic_test.py index 5977be1..06b2f10 100644 --- a/tests/unit/statistic.py +++ b/tests/unit/statistic_test.py @@ -1,4 +1,3 @@ -from collections import Counter from datetime import datetime, timedelta from random import randint From 9c76cf73e65cb543ed574d28fb4795d877c677a1 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 14:08:40 +0300 Subject: [PATCH 12/80] deleted get_current_user method and refactoring get_current_user_depend --- src/depends.py | 13 +++++++------ src/routers/authentication/services.py | 16 +--------------- src/routers/bank_api.py | 8 ++++---- src/routers/operations.py | 6 +++--- src/routers/statistic.py | 4 ++-- src/routers/users.py | 4 ++-- 6 files changed, 19 insertions(+), 32 deletions(-) diff --git a/src/depends.py b/src/depends.py index 0097a13..99f5135 100644 --- a/src/depends.py +++ b/src/depends.py @@ -6,7 +6,7 @@ from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.database import get_session_depend -from src.routers.authentication.services import get_current_user +from src.routers.authentication.services import decode_token_data oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -15,17 +15,18 @@ def get_uow(session: AsyncSession = Depends(get_session_depend)): yield SqlAlchemyUnitOfWork(session) -async def get_current_user_depend( +async def get_current_user( uow: AbstractUnitOfWork = Depends(get_uow), token: str = Depends(oauth2_scheme) ) -> User: """ - Обгортка над get_current_user для використання в якості FastApi Depends + Залежність FastApi для отримання юзера по JWT :return: User """ - result = await get_current_user(uow, token) - if result is None: + token_data = decode_token_data(token) + if token_data is None: raise_credentials_exception() - return result + async with uow: + return await uow.users.get("email", token_data.email) def raise_credentials_exception(): diff --git a/src/routers/authentication/services.py b/src/routers/authentication/services.py index 74e146b..36f93f1 100644 --- a/src/routers/authentication/services.py +++ b/src/routers/authentication/services.py @@ -51,21 +51,7 @@ def create_access_token(data: dict) -> str: return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM) -async def get_current_user(uow: AbstractUnitOfWork, token: str) -> User | None: - """ - Отримати User на основі JWT. - - :param uow: Unit of Work - :param token: зашифрований JWT. - :return: User якщо інформація валідна, інакше None - """ - token_data = await decode_token_data(token) - if token_data: - async with uow: - return await uow.users.get("email", token_data.email) - - -async def decode_token_data(token: str) -> TokenData | None: +def decode_token_data(token: str) -> TokenData | None: """Розшифровка JWT""" try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) diff --git a/src/routers/bank_api.py b/src/routers/bank_api.py index 215bb16..1022a89 100644 --- a/src/routers/bank_api.py +++ b/src/routers/bank_api.py @@ -7,7 +7,7 @@ update_banks_costs, ) from src.app.services.uow.abstract import AbstractUnitOfWork -from src.depends import get_current_user_depend, get_uow +from src.depends import get_current_user, get_uow router = APIRouter(prefix="/bankapi") @@ -15,7 +15,7 @@ @router.post("/add/", status_code=201) async def add_bank_info_view( props: dict, - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): await add_bank_info(uow, current_user.id, props) @@ -24,7 +24,7 @@ async def add_bank_info_view( @router.put("/update_costs/", status_code=200) async def update_costs_view( - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): managers = await get_bank_managers_by_user(uow, user_id=current_user.id) @@ -34,7 +34,7 @@ async def update_costs_view( @router.get("/list/", status_code=200) async def get_connected_banks_names( - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ) -> list[str]: async with uow: diff --git a/src/routers/operations.py b/src/routers/operations.py index f482d64..d465a04 100644 --- a/src/routers/operations.py +++ b/src/routers/operations.py @@ -5,7 +5,7 @@ from src.app.domain.users import User from src.app.services.operations import create_operation, get_operations from src.app.services.uow.abstract import AbstractUnitOfWork -from src.depends import get_current_user_depend, get_uow +from src.depends import get_current_user, get_uow from src.schemas.operations import OperationCreateSchema, OperationSchema router = APIRouter(prefix="/operations") @@ -14,7 +14,7 @@ @router.post("/create/", response_model=OperationSchema, status_code=201) async def create_operation_view( operation_schema: OperationCreateSchema, - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): """ @@ -34,7 +34,7 @@ async def create_operation_view( @router.get("/list/", response_model=list[OperationSchema]) async def read_operations_view( - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), from_time: int | None = None, to_time: int | None = None, diff --git a/src/routers/statistic.py b/src/routers/statistic.py index 340a0ec..d439dd3 100644 --- a/src/routers/statistic.py +++ b/src/routers/statistic.py @@ -6,7 +6,7 @@ from src.app.services.operations import get_operations from src.app.services.statistic import get_statistic from src.app.services.uow.abstract import AbstractUnitOfWork -from src.depends import get_current_user_depend, get_uow +from src.depends import get_current_user, get_uow from src.schemas.statistic import StatisticSchema router = APIRouter(prefix="/statistic") @@ -14,7 +14,7 @@ @router.get("/", response_model=StatisticSchema, status_code=200) async def get_statistic_view( - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), from_time: int = None, to_time: int = None, diff --git a/src/routers/users.py b/src/routers/users.py index 6fa8fc6..e086848 100644 --- a/src/routers/users.py +++ b/src/routers/users.py @@ -6,7 +6,7 @@ from src.app.domain.users import User from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.users import create_user -from src.depends import get_current_user_depend, get_uow +from src.depends import get_current_user, get_uow from src.schemas.users import UserCreateSchema, UserSchema router = APIRouter(prefix="/users") @@ -24,7 +24,7 @@ async def create_user_view( @router.get("/me/", response_model=UserSchema) -async def read_me(current_user: User = Depends(get_current_user_depend)): +async def read_me(current_user: User = Depends(get_current_user)): """ Повертає залогіненого юзера, або 401, якщо юзер не вказав заголовок Authorization """ From bd9814e47b7ac00b1583a56ac7097b3c31fff556 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 14:42:56 +0300 Subject: [PATCH 13/80] refactored authenticate user algorithm --- src/app/services/users.py | 4 ++++ src/routers/auth.py | 8 +++++--- src/routers/authentication/services.py | 23 ----------------------- 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/src/app/services/users.py b/src/app/services/users.py index 2e564ab..c92aa74 100644 --- a/src/app/services/users.py +++ b/src/app/services/users.py @@ -19,3 +19,7 @@ async def create_user(uow: AbstractUnitOfWork, user: UserCreateSchema) -> User: await uow.users.add(db_user) await uow.commit() return db_user + + +async def get_user_by_email(uow: AbstractUnitOfWork, email: str) -> User | None: + return await uow.users.get(email, "email") diff --git a/src/routers/auth.py b/src/routers/auth.py index 2b9e832..d59de01 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -5,8 +5,10 @@ from fastapi.security import OAuth2PasswordRequestForm from src.app.services.uow.abstract import AbstractUnitOfWork +from src.app.services.users import get_user_by_email from src.depends import get_uow -from src.routers.authentication.services import authenticate_user, create_access_token +from src.routers.authentication.password import verify_password +from src.routers.authentication.services import create_access_token from src.schemas.auth import Token router = APIRouter() @@ -28,8 +30,8 @@ async def login_for_access_token( Authorization: """ - user = await authenticate_user(uow, form_data.username, form_data.password) - if not user: + user = await get_user_by_email(uow, form_data.username) + if not user or not verify_password(user.hashed_password, form_data.password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", diff --git a/src/routers/authentication/services.py b/src/routers/authentication/services.py index 36f93f1..949fcd8 100644 --- a/src/routers/authentication/services.py +++ b/src/routers/authentication/services.py @@ -5,37 +5,14 @@ from jose import JWTError, jwt -from src.app.domain.users import User -from src.app.services.uow.abstract import AbstractUnitOfWork from src.routers.authentication.config import ( ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY, ) -from src.routers.authentication.password import verify_password from src.schemas.auth import TokenData -async def authenticate_user( - uow: AbstractUnitOfWork, email: str, password: str -) -> User | None: - """ - Аутентифікація користувача. - По даним для входу шукається, перевіряється на співпадіння паролів - та вертається User модель - - :param uow: Unit of Work - :param email: email - :param password: password - :return: User якщо аутентифікація вдала, None якщо ні - """ - async with uow: - user = await uow.users.get("email", email) - if not user or not verify_password(password, user.hashed_password): - return - return user - - def create_access_token(data: dict) -> str: """ Створює JWT, From 17b1de2284896a180880860a52b205fd5e89e6d8 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 15:08:24 +0300 Subject: [PATCH 14/80] refactored auth tests --- tests/unit/auth_test.py | 85 +++++++++-------------------------------- 1 file changed, 18 insertions(+), 67 deletions(-) diff --git a/tests/unit/auth_test.py b/tests/unit/auth_test.py index 78ae90c..3242860 100644 --- a/tests/unit/auth_test.py +++ b/tests/unit/auth_test.py @@ -1,77 +1,28 @@ -""" -Auth unit tests -""" -import pytest +from src.routers.authentication.password import get_password_hash, verify_password +from src.routers.authentication.services import create_access_token, decode_token_data -from src.app.domain.users import User -from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork -from src.app.services.users import create_user -from src.routers.authentication.services import ( - authenticate_user, - create_access_token, - get_current_user, -) -from src.schemas.users import UserCreateSchema - -@pytest.mark.asyncio -async def test_auth_user(database): # noqa: F811; +def test_jwt_full_work(): """ - Testing user account func - authenticate_user(email, password) -> - User: якщо email є в базі і password сходиться - None: якщо email немає в базі або password не сходиться + Тест роботи методів, які використовують JWT. + Шифрує інформацію про користувача у JWT, потім його розшифровує. + Потім відбувається перевірка еквівалентності отриманого email з введеним """ - user_schema = UserCreateSchema(email="test", password="test") # nosec B106 - async with database.sessionmaker() as session: - uow = SqlAlchemyUnitOfWork(session) - - created_user = await create_user(uow, user_schema) - - # Перевірка з правильним даними - correct_auth = await authenticate_user( - uow, user_schema.email, user_schema.password - ) - assert isinstance(correct_auth, User) - assert created_user.email == correct_auth.email - - # Перевірка з неправильним email - incorrect_email_auth = await authenticate_user( - uow, "incorrect", user_schema.password - ) - assert incorrect_email_auth is None + email = "test@test.com" + token = create_access_token({"sub": email}) + assert decode_token_data(token).email == email - # Перевірка з неправильним password - incorrect_password_auth = await authenticate_user( - uow, user_schema.email, "incorrect" - ) - assert incorrect_password_auth is None - # Перевірка з неправильним email & password - incorrect_email_password_auth = await authenticate_user( - uow, "incorrect", "incorrect" - ) - assert incorrect_email_password_auth is None +def test_decode_jwt_with_incorrect_data(): + assert decode_token_data("incorrect") is None -@pytest.mark.asyncio -async def test_jwt_work(database): # noqa: F811; - """ - Тест роботи методів, які використовують JWT. - Шифрує інформацію про користувача у JWT, потім його розшифровує. - Якщо на виході отриманий той самий користувач що був на вході - test passed. - """ - async with database.sessionmaker() as session: - uow = SqlAlchemyUnitOfWork(session) - - user_schema = UserCreateSchema(email="test", password="test") # nosec B106 - created_user = await create_user(uow, user_schema) +def test_hash_and_verify_password(): + password = "test1234test" + hashed_password = get_password_hash(password) + assert verify_password(password, hashed_password) - token = create_access_token({"sub": created_user.email}) - decode_user = await get_current_user(uow, token) - assert isinstance(decode_user, User) - assert decode_user.id == created_user.id - # Спроба розшифрувати неправильний токен. Результат повинен бути None - decode_result = await get_current_user(uow, "incorrect") - assert decode_result is None +def test_verify_password_with_incorrect_data(): + hashed_password = get_password_hash("test1234test") + assert not verify_password("incorrect", hashed_password) From 8210707b52cefea3f2b16a920e44861bc1bceef1 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 16:04:09 +0300 Subject: [PATCH 15/80] bug fixes --- src/app/services/users.py | 5 +++-- src/{routers/authentication => auth}/__init__.py | 0 src/{routers/authentication => auth}/config.py | 0 src/{routers/authentication => auth}/password.py | 0 src/{routers/authentication => auth}/services.py | 6 +----- src/depends.py | 6 +++--- src/routers/auth.py | 6 +++--- tests/unit/auth_test.py | 4 ++-- 8 files changed, 12 insertions(+), 15 deletions(-) rename src/{routers/authentication => auth}/__init__.py (100%) rename src/{routers/authentication => auth}/config.py (100%) rename src/{routers/authentication => auth}/password.py (100%) rename src/{routers/authentication => auth}/services.py (91%) diff --git a/src/app/services/users.py b/src/app/services/users.py index c92aa74..0930078 100644 --- a/src/app/services/users.py +++ b/src/app/services/users.py @@ -3,7 +3,7 @@ """ from src.app.domain.users import User from src.app.services.uow.abstract import AbstractUnitOfWork -from src.routers.authentication.password import get_password_hash +from src.auth.password import get_password_hash from src.schemas.users import UserCreateSchema @@ -22,4 +22,5 @@ async def create_user(uow: AbstractUnitOfWork, user: UserCreateSchema) -> User: async def get_user_by_email(uow: AbstractUnitOfWork, email: str) -> User | None: - return await uow.users.get(email, "email") + async with uow: + return await uow.users.get("email", email) diff --git a/src/routers/authentication/__init__.py b/src/auth/__init__.py similarity index 100% rename from src/routers/authentication/__init__.py rename to src/auth/__init__.py diff --git a/src/routers/authentication/config.py b/src/auth/config.py similarity index 100% rename from src/routers/authentication/config.py rename to src/auth/config.py diff --git a/src/routers/authentication/password.py b/src/auth/password.py similarity index 100% rename from src/routers/authentication/password.py rename to src/auth/password.py diff --git a/src/routers/authentication/services.py b/src/auth/services.py similarity index 91% rename from src/routers/authentication/services.py rename to src/auth/services.py index 949fcd8..eaa339b 100644 --- a/src/routers/authentication/services.py +++ b/src/auth/services.py @@ -5,11 +5,7 @@ from jose import JWTError, jwt -from src.routers.authentication.config import ( - ACCESS_TOKEN_EXPIRE_MINUTES, - ALGORITHM, - SECRET_KEY, -) +from src.auth.config import ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY from src.schemas.auth import TokenData diff --git a/src/depends.py b/src/depends.py index 99f5135..3944598 100644 --- a/src/depends.py +++ b/src/depends.py @@ -5,8 +5,9 @@ from src.app.domain.users import User from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork +from src.app.services.users import get_user_by_email +from src.auth.services import decode_token_data from src.database import get_session_depend -from src.routers.authentication.services import decode_token_data oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -25,8 +26,7 @@ async def get_current_user( token_data = decode_token_data(token) if token_data is None: raise_credentials_exception() - async with uow: - return await uow.users.get("email", token_data.email) + return await get_user_by_email(uow, token_data.email) def raise_credentials_exception(): diff --git a/src/routers/auth.py b/src/routers/auth.py index d59de01..97cb8ba 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -6,9 +6,9 @@ from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.users import get_user_by_email +from src.auth.password import verify_password +from src.auth.services import create_access_token from src.depends import get_uow -from src.routers.authentication.password import verify_password -from src.routers.authentication.services import create_access_token from src.schemas.auth import Token router = APIRouter() @@ -31,7 +31,7 @@ async def login_for_access_token( """ user = await get_user_by_email(uow, form_data.username) - if not user or not verify_password(user.hashed_password, form_data.password): + if not user or not verify_password(form_data.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", diff --git a/tests/unit/auth_test.py b/tests/unit/auth_test.py index 3242860..f00be5f 100644 --- a/tests/unit/auth_test.py +++ b/tests/unit/auth_test.py @@ -1,5 +1,5 @@ -from src.routers.authentication.password import get_password_hash, verify_password -from src.routers.authentication.services import create_access_token, decode_token_data +from src.auth.password import get_password_hash, verify_password +from src.auth.services import create_access_token, decode_token_data def test_jwt_full_work(): From a424670a8829bd437ac927bf07b59d6ed1f6cd62 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 14:08:40 +0300 Subject: [PATCH 16/80] deleted get_current_user method and refactoring get_current_user_depend --- src/depends.py | 13 +++++++------ src/routers/authentication/services.py | 16 +--------------- src/routers/bank_api.py | 8 ++++---- src/routers/operations.py | 6 +++--- src/routers/statistic.py | 4 ++-- src/routers/users.py | 4 ++-- 6 files changed, 19 insertions(+), 32 deletions(-) diff --git a/src/depends.py b/src/depends.py index 0097a13..99f5135 100644 --- a/src/depends.py +++ b/src/depends.py @@ -6,7 +6,7 @@ from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.database import get_session_depend -from src.routers.authentication.services import get_current_user +from src.routers.authentication.services import decode_token_data oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -15,17 +15,18 @@ def get_uow(session: AsyncSession = Depends(get_session_depend)): yield SqlAlchemyUnitOfWork(session) -async def get_current_user_depend( +async def get_current_user( uow: AbstractUnitOfWork = Depends(get_uow), token: str = Depends(oauth2_scheme) ) -> User: """ - Обгортка над get_current_user для використання в якості FastApi Depends + Залежність FastApi для отримання юзера по JWT :return: User """ - result = await get_current_user(uow, token) - if result is None: + token_data = decode_token_data(token) + if token_data is None: raise_credentials_exception() - return result + async with uow: + return await uow.users.get("email", token_data.email) def raise_credentials_exception(): diff --git a/src/routers/authentication/services.py b/src/routers/authentication/services.py index 74e146b..36f93f1 100644 --- a/src/routers/authentication/services.py +++ b/src/routers/authentication/services.py @@ -51,21 +51,7 @@ def create_access_token(data: dict) -> str: return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM) -async def get_current_user(uow: AbstractUnitOfWork, token: str) -> User | None: - """ - Отримати User на основі JWT. - - :param uow: Unit of Work - :param token: зашифрований JWT. - :return: User якщо інформація валідна, інакше None - """ - token_data = await decode_token_data(token) - if token_data: - async with uow: - return await uow.users.get("email", token_data.email) - - -async def decode_token_data(token: str) -> TokenData | None: +def decode_token_data(token: str) -> TokenData | None: """Розшифровка JWT""" try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) diff --git a/src/routers/bank_api.py b/src/routers/bank_api.py index 215bb16..1022a89 100644 --- a/src/routers/bank_api.py +++ b/src/routers/bank_api.py @@ -7,7 +7,7 @@ update_banks_costs, ) from src.app.services.uow.abstract import AbstractUnitOfWork -from src.depends import get_current_user_depend, get_uow +from src.depends import get_current_user, get_uow router = APIRouter(prefix="/bankapi") @@ -15,7 +15,7 @@ @router.post("/add/", status_code=201) async def add_bank_info_view( props: dict, - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): await add_bank_info(uow, current_user.id, props) @@ -24,7 +24,7 @@ async def add_bank_info_view( @router.put("/update_costs/", status_code=200) async def update_costs_view( - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): managers = await get_bank_managers_by_user(uow, user_id=current_user.id) @@ -34,7 +34,7 @@ async def update_costs_view( @router.get("/list/", status_code=200) async def get_connected_banks_names( - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ) -> list[str]: async with uow: diff --git a/src/routers/operations.py b/src/routers/operations.py index f482d64..d465a04 100644 --- a/src/routers/operations.py +++ b/src/routers/operations.py @@ -5,7 +5,7 @@ from src.app.domain.users import User from src.app.services.operations import create_operation, get_operations from src.app.services.uow.abstract import AbstractUnitOfWork -from src.depends import get_current_user_depend, get_uow +from src.depends import get_current_user, get_uow from src.schemas.operations import OperationCreateSchema, OperationSchema router = APIRouter(prefix="/operations") @@ -14,7 +14,7 @@ @router.post("/create/", response_model=OperationSchema, status_code=201) async def create_operation_view( operation_schema: OperationCreateSchema, - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), ): """ @@ -34,7 +34,7 @@ async def create_operation_view( @router.get("/list/", response_model=list[OperationSchema]) async def read_operations_view( - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), from_time: int | None = None, to_time: int | None = None, diff --git a/src/routers/statistic.py b/src/routers/statistic.py index 340a0ec..d439dd3 100644 --- a/src/routers/statistic.py +++ b/src/routers/statistic.py @@ -6,7 +6,7 @@ from src.app.services.operations import get_operations from src.app.services.statistic import get_statistic from src.app.services.uow.abstract import AbstractUnitOfWork -from src.depends import get_current_user_depend, get_uow +from src.depends import get_current_user, get_uow from src.schemas.statistic import StatisticSchema router = APIRouter(prefix="/statistic") @@ -14,7 +14,7 @@ @router.get("/", response_model=StatisticSchema, status_code=200) async def get_statistic_view( - current_user: User = Depends(get_current_user_depend), + current_user: User = Depends(get_current_user), uow: AbstractUnitOfWork = Depends(get_uow), from_time: int = None, to_time: int = None, diff --git a/src/routers/users.py b/src/routers/users.py index 6fa8fc6..e086848 100644 --- a/src/routers/users.py +++ b/src/routers/users.py @@ -6,7 +6,7 @@ from src.app.domain.users import User from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.users import create_user -from src.depends import get_current_user_depend, get_uow +from src.depends import get_current_user, get_uow from src.schemas.users import UserCreateSchema, UserSchema router = APIRouter(prefix="/users") @@ -24,7 +24,7 @@ async def create_user_view( @router.get("/me/", response_model=UserSchema) -async def read_me(current_user: User = Depends(get_current_user_depend)): +async def read_me(current_user: User = Depends(get_current_user)): """ Повертає залогіненого юзера, або 401, якщо юзер не вказав заголовок Authorization """ From 13a691e9e04eb2a13d892d15333cb3f33f89c28c Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 14:42:56 +0300 Subject: [PATCH 17/80] refactored authenticate user algorithm --- src/app/services/users.py | 4 ++++ src/routers/auth.py | 8 +++++--- src/routers/authentication/services.py | 23 ----------------------- 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/src/app/services/users.py b/src/app/services/users.py index 2e564ab..c92aa74 100644 --- a/src/app/services/users.py +++ b/src/app/services/users.py @@ -19,3 +19,7 @@ async def create_user(uow: AbstractUnitOfWork, user: UserCreateSchema) -> User: await uow.users.add(db_user) await uow.commit() return db_user + + +async def get_user_by_email(uow: AbstractUnitOfWork, email: str) -> User | None: + return await uow.users.get(email, "email") diff --git a/src/routers/auth.py b/src/routers/auth.py index 2b9e832..d59de01 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -5,8 +5,10 @@ from fastapi.security import OAuth2PasswordRequestForm from src.app.services.uow.abstract import AbstractUnitOfWork +from src.app.services.users import get_user_by_email from src.depends import get_uow -from src.routers.authentication.services import authenticate_user, create_access_token +from src.routers.authentication.password import verify_password +from src.routers.authentication.services import create_access_token from src.schemas.auth import Token router = APIRouter() @@ -28,8 +30,8 @@ async def login_for_access_token( Authorization: """ - user = await authenticate_user(uow, form_data.username, form_data.password) - if not user: + user = await get_user_by_email(uow, form_data.username) + if not user or not verify_password(user.hashed_password, form_data.password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", diff --git a/src/routers/authentication/services.py b/src/routers/authentication/services.py index 36f93f1..949fcd8 100644 --- a/src/routers/authentication/services.py +++ b/src/routers/authentication/services.py @@ -5,37 +5,14 @@ from jose import JWTError, jwt -from src.app.domain.users import User -from src.app.services.uow.abstract import AbstractUnitOfWork from src.routers.authentication.config import ( ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY, ) -from src.routers.authentication.password import verify_password from src.schemas.auth import TokenData -async def authenticate_user( - uow: AbstractUnitOfWork, email: str, password: str -) -> User | None: - """ - Аутентифікація користувача. - По даним для входу шукається, перевіряється на співпадіння паролів - та вертається User модель - - :param uow: Unit of Work - :param email: email - :param password: password - :return: User якщо аутентифікація вдала, None якщо ні - """ - async with uow: - user = await uow.users.get("email", email) - if not user or not verify_password(password, user.hashed_password): - return - return user - - def create_access_token(data: dict) -> str: """ Створює JWT, From 6033ceb9d309e270767dfb72ee936c1e302698d7 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 15:08:24 +0300 Subject: [PATCH 18/80] refactored auth tests --- tests/unit/auth_test.py | 85 +++++++++-------------------------------- 1 file changed, 18 insertions(+), 67 deletions(-) diff --git a/tests/unit/auth_test.py b/tests/unit/auth_test.py index 78ae90c..3242860 100644 --- a/tests/unit/auth_test.py +++ b/tests/unit/auth_test.py @@ -1,77 +1,28 @@ -""" -Auth unit tests -""" -import pytest +from src.routers.authentication.password import get_password_hash, verify_password +from src.routers.authentication.services import create_access_token, decode_token_data -from src.app.domain.users import User -from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork -from src.app.services.users import create_user -from src.routers.authentication.services import ( - authenticate_user, - create_access_token, - get_current_user, -) -from src.schemas.users import UserCreateSchema - -@pytest.mark.asyncio -async def test_auth_user(database): # noqa: F811; +def test_jwt_full_work(): """ - Testing user account func - authenticate_user(email, password) -> - User: якщо email є в базі і password сходиться - None: якщо email немає в базі або password не сходиться + Тест роботи методів, які використовують JWT. + Шифрує інформацію про користувача у JWT, потім його розшифровує. + Потім відбувається перевірка еквівалентності отриманого email з введеним """ - user_schema = UserCreateSchema(email="test", password="test") # nosec B106 - async with database.sessionmaker() as session: - uow = SqlAlchemyUnitOfWork(session) - - created_user = await create_user(uow, user_schema) - - # Перевірка з правильним даними - correct_auth = await authenticate_user( - uow, user_schema.email, user_schema.password - ) - assert isinstance(correct_auth, User) - assert created_user.email == correct_auth.email - - # Перевірка з неправильним email - incorrect_email_auth = await authenticate_user( - uow, "incorrect", user_schema.password - ) - assert incorrect_email_auth is None + email = "test@test.com" + token = create_access_token({"sub": email}) + assert decode_token_data(token).email == email - # Перевірка з неправильним password - incorrect_password_auth = await authenticate_user( - uow, user_schema.email, "incorrect" - ) - assert incorrect_password_auth is None - # Перевірка з неправильним email & password - incorrect_email_password_auth = await authenticate_user( - uow, "incorrect", "incorrect" - ) - assert incorrect_email_password_auth is None +def test_decode_jwt_with_incorrect_data(): + assert decode_token_data("incorrect") is None -@pytest.mark.asyncio -async def test_jwt_work(database): # noqa: F811; - """ - Тест роботи методів, які використовують JWT. - Шифрує інформацію про користувача у JWT, потім його розшифровує. - Якщо на виході отриманий той самий користувач що був на вході - test passed. - """ - async with database.sessionmaker() as session: - uow = SqlAlchemyUnitOfWork(session) - - user_schema = UserCreateSchema(email="test", password="test") # nosec B106 - created_user = await create_user(uow, user_schema) +def test_hash_and_verify_password(): + password = "test1234test" + hashed_password = get_password_hash(password) + assert verify_password(password, hashed_password) - token = create_access_token({"sub": created_user.email}) - decode_user = await get_current_user(uow, token) - assert isinstance(decode_user, User) - assert decode_user.id == created_user.id - # Спроба розшифрувати неправильний токен. Результат повинен бути None - decode_result = await get_current_user(uow, "incorrect") - assert decode_result is None +def test_verify_password_with_incorrect_data(): + hashed_password = get_password_hash("test1234test") + assert not verify_password("incorrect", hashed_password) From 7a191c8d4f4a588e473145319a43ba52db4c2974 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Fri, 5 May 2023 16:04:09 +0300 Subject: [PATCH 19/80] bug fixes --- src/app/services/users.py | 5 +++-- src/{routers/authentication => auth}/__init__.py | 0 src/{routers/authentication => auth}/config.py | 0 src/{routers/authentication => auth}/password.py | 0 src/{routers/authentication => auth}/services.py | 6 +----- src/depends.py | 6 +++--- src/routers/auth.py | 6 +++--- tests/unit/auth_test.py | 4 ++-- 8 files changed, 12 insertions(+), 15 deletions(-) rename src/{routers/authentication => auth}/__init__.py (100%) rename src/{routers/authentication => auth}/config.py (100%) rename src/{routers/authentication => auth}/password.py (100%) rename src/{routers/authentication => auth}/services.py (91%) diff --git a/src/app/services/users.py b/src/app/services/users.py index c92aa74..0930078 100644 --- a/src/app/services/users.py +++ b/src/app/services/users.py @@ -3,7 +3,7 @@ """ from src.app.domain.users import User from src.app.services.uow.abstract import AbstractUnitOfWork -from src.routers.authentication.password import get_password_hash +from src.auth.password import get_password_hash from src.schemas.users import UserCreateSchema @@ -22,4 +22,5 @@ async def create_user(uow: AbstractUnitOfWork, user: UserCreateSchema) -> User: async def get_user_by_email(uow: AbstractUnitOfWork, email: str) -> User | None: - return await uow.users.get(email, "email") + async with uow: + return await uow.users.get("email", email) diff --git a/src/routers/authentication/__init__.py b/src/auth/__init__.py similarity index 100% rename from src/routers/authentication/__init__.py rename to src/auth/__init__.py diff --git a/src/routers/authentication/config.py b/src/auth/config.py similarity index 100% rename from src/routers/authentication/config.py rename to src/auth/config.py diff --git a/src/routers/authentication/password.py b/src/auth/password.py similarity index 100% rename from src/routers/authentication/password.py rename to src/auth/password.py diff --git a/src/routers/authentication/services.py b/src/auth/services.py similarity index 91% rename from src/routers/authentication/services.py rename to src/auth/services.py index 949fcd8..eaa339b 100644 --- a/src/routers/authentication/services.py +++ b/src/auth/services.py @@ -5,11 +5,7 @@ from jose import JWTError, jwt -from src.routers.authentication.config import ( - ACCESS_TOKEN_EXPIRE_MINUTES, - ALGORITHM, - SECRET_KEY, -) +from src.auth.config import ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY from src.schemas.auth import TokenData diff --git a/src/depends.py b/src/depends.py index 99f5135..3944598 100644 --- a/src/depends.py +++ b/src/depends.py @@ -5,8 +5,9 @@ from src.app.domain.users import User from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork +from src.app.services.users import get_user_by_email +from src.auth.services import decode_token_data from src.database import get_session_depend -from src.routers.authentication.services import decode_token_data oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -25,8 +26,7 @@ async def get_current_user( token_data = decode_token_data(token) if token_data is None: raise_credentials_exception() - async with uow: - return await uow.users.get("email", token_data.email) + return await get_user_by_email(uow, token_data.email) def raise_credentials_exception(): diff --git a/src/routers/auth.py b/src/routers/auth.py index d59de01..97cb8ba 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -6,9 +6,9 @@ from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.users import get_user_by_email +from src.auth.password import verify_password +from src.auth.services import create_access_token from src.depends import get_uow -from src.routers.authentication.password import verify_password -from src.routers.authentication.services import create_access_token from src.schemas.auth import Token router = APIRouter() @@ -31,7 +31,7 @@ async def login_for_access_token( """ user = await get_user_by_email(uow, form_data.username) - if not user or not verify_password(user.hashed_password, form_data.password): + if not user or not verify_password(form_data.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", diff --git a/tests/unit/auth_test.py b/tests/unit/auth_test.py index 3242860..f00be5f 100644 --- a/tests/unit/auth_test.py +++ b/tests/unit/auth_test.py @@ -1,5 +1,5 @@ -from src.routers.authentication.password import get_password_hash, verify_password -from src.routers.authentication.services import create_access_token, decode_token_data +from src.auth.password import get_password_hash, verify_password +from src.auth.services import create_access_token, decode_token_data def test_jwt_full_work(): From 82ede867dd921cd419593b048628736b97369015 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 11:29:50 +0300 Subject: [PATCH 20/80] renamed validate function --- tests/conftest.py | 3 ++- tests/integrations/operations_test.py | 2 +- tests/integrations/users_test.py | 2 +- tests/unit/database_test.py | 4 ++-- tests/unit/users_test.py | 9 +++------ 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9777609..54e80ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ import pytest_asyncio from httpx import AsyncClient +from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.database import Database, DatabaseFactory from src.main import bootstrap_fastapi_app @@ -23,7 +24,7 @@ def event_loop(): # Декоратор-перевірка на те, чи існує файл "pytest.ini" # або існують потрібні ENV параметри # Перевірка для того, щоб тести не крашились якщо не має потрібних env параметрів. -precents_evn_variables = pytest.mark.skipif( +precents_env_variables = pytest.mark.skipif( all( [ # Якщо результат буде True то тест пропуститься. diff --git a/tests/integrations/operations_test.py b/tests/integrations/operations_test.py index 5f7e1a8..ff71103 100644 --- a/tests/integrations/operations_test.py +++ b/tests/integrations/operations_test.py @@ -3,7 +3,7 @@ import pytest from httpx import AsyncClient -from tests.conftest import precents_evn_variables # noqa: F401; +from tests.conftest import precents_env_variables # noqa: F401; from tests.patterns import create_and_auth_func_user diff --git a/tests/integrations/users_test.py b/tests/integrations/users_test.py index 2a8eebe..dcbc874 100644 --- a/tests/integrations/users_test.py +++ b/tests/integrations/users_test.py @@ -5,7 +5,7 @@ import pytest from httpx import AsyncClient -from tests.conftest import precents_evn_variables # noqa: F401; +from tests.conftest import precents_env_variables # noqa: F401; from tests.patterns import create_and_auth_func_user diff --git a/tests/unit/database_test.py b/tests/unit/database_test.py index 9fe034f..b5e7b22 100644 --- a/tests/unit/database_test.py +++ b/tests/unit/database_test.py @@ -4,11 +4,11 @@ import pytest from sqlalchemy import text -from tests.conftest import precents_evn_variables # noqa: F401; +from tests.conftest import precents_env_variables # noqa: F401; @pytest.mark.asyncio -@precents_evn_variables +@precents_env_variables async def test_db_connect(database): # noqa: F811; """ Перевірка працездібності бази даних та підключення до неї diff --git a/tests/unit/users_test.py b/tests/unit/users_test.py index ad2c679..e79ee11 100644 --- a/tests/unit/users_test.py +++ b/tests/unit/users_test.py @@ -8,14 +8,13 @@ from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.app.services.users import create_user from src.schemas.users import UserCreateSchema -from tests.conftest import precents_evn_variables # noqa: F401; +from tests.conftest import precents_env_variables # noqa: F401; @pytest.mark.asyncio -@precents_evn_variables +@precents_env_variables async def test_user_create(database): # noqa: F401, F811; """ - Перевірка методу src.crud.users.create_user create_user повинен зберегти об'єкт User в БД та повернути його """ user_schema = UserCreateSchema( # nosec B106 @@ -28,12 +27,10 @@ async def test_user_create(database): # noqa: F401, F811; @pytest.mark.asyncio -@precents_evn_variables +@precents_env_variables async def test_user_read(database): # noqa: F401, F811; """ Перевірка методів - src.crud.users.get_user - src.crud.users.get_user_by_email """ user_schema = UserCreateSchema( # nosec B106 email="test@test.com", password="123456" From 6564bbe9d332589bafc60006655bcb120f676d80 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 11:30:51 +0300 Subject: [PATCH 21/80] refactored test help function --- tests/integrations/bank_api_test.py | 4 ++-- tests/patterns.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/integrations/bank_api_test.py b/tests/integrations/bank_api_test.py index c7e03cb..0086591 100644 --- a/tests/integrations/bank_api_test.py +++ b/tests/integrations/bank_api_test.py @@ -6,7 +6,7 @@ from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.database import Database -from tests.patterns import create_and_auth_func_user, create_model_user +from tests.patterns import create_and_auth_func_user, create_user_with_orm async def create_monobank_manager(uow: AbstractUnitOfWork, user_id: int): @@ -29,7 +29,7 @@ async def test_update_costs_with_banks(database: Database): async with database.sessionmaker() as session: # КРОК 1: Створення менеджерів uow = SqlAlchemyUnitOfWork(session) - user = await create_model_user(uow) + user = await create_user_with_orm(session) await create_monobank_manager(uow, user_id=user.id) # КРОК 2: Запис в базу даних витрат за допомогою менеджерів diff --git a/tests/patterns.py b/tests/patterns.py index 4686e5c..f24b44e 100644 --- a/tests/patterns.py +++ b/tests/patterns.py @@ -5,22 +5,25 @@ import random from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession from src.app.domain.users import User from src.app.services.uow.abstract import AbstractUnitOfWork +from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.app.services.users import create_user from src.schemas.users import UserCreateSchema -async def create_model_user(uow: AbstractUnitOfWork) -> User: +async def create_user_with_orm(session: AsyncSession) -> User: """ Створення та повернення юзера за допомогою AsyncSession - :param uow: Unit of Work + :param session: SqlAlchemy session :return: User """ user_schema = UserCreateSchema( # nosec B106 email="test@test.com", password="123456" ) + uow = SqlAlchemyUnitOfWork(session) return await create_user(uow, user_schema) From 89d08eee115dc55650bfe05973b2f04ed0e01540 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 11:32:36 +0300 Subject: [PATCH 22/80] converted operations tests to OperationAdapter tests --- tests/integrations/adapters/__init__.py | 0 .../integrations/adapters/operations_test.py | 34 ++++++ tests/unit/operations_test.py | 107 ------------------ 3 files changed, 34 insertions(+), 107 deletions(-) create mode 100644 tests/integrations/adapters/__init__.py create mode 100644 tests/integrations/adapters/operations_test.py delete mode 100644 tests/unit/operations_test.py diff --git a/tests/integrations/adapters/__init__.py b/tests/integrations/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integrations/adapters/operations_test.py b/tests/integrations/adapters/operations_test.py new file mode 100644 index 0000000..7cd2e57 --- /dev/null +++ b/tests/integrations/adapters/operations_test.py @@ -0,0 +1,34 @@ +import random +from datetime import datetime, timedelta + +import pytest + +from src.app.domain.operations import Operation +from src.app.repositories.operations import OperationRepository +from tests.patterns import create_user_with_orm + + +@pytest.mark.asyncio +async def test_create_and_read_operations(database): + async with database.sessionmaker() as session: + repository = OperationRepository(session) + created_user = await create_user_with_orm(session) + for _ in range(10): + await repository.add( + Operation( + amount=random.randint(10, 10000), + description="description", + time=int(datetime.now().timestamp()), + mcc=random.randint(1000, 9999), + source_type="manual", + user_id=created_user.id, + ) + ) + await session.commit() + + operations = await repository.get_all_by_user( + created_user.id, + int((datetime.now() - timedelta(minutes=1)).timestamp()), + int(datetime.now().timestamp()), + ) + assert len(operations) == 10 diff --git a/tests/unit/operations_test.py b/tests/unit/operations_test.py deleted file mode 100644 index 35d9c0a..0000000 --- a/tests/unit/operations_test.py +++ /dev/null @@ -1,107 +0,0 @@ -import random -import time -from datetime import datetime, timedelta - -import pytest - -from src.app.domain.operations import Operation -from src.app.services.operations import create_operation, get_operations -from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork -from src.app.services.users import create_user -from src.schemas.operations import OperationCreateSchema -from src.schemas.users import UserCreateSchema -from tests.conftest import precents_evn_variables # noqa: F401; - - -@pytest.mark.asyncio -@precents_evn_variables -async def test_create_operation(database): # noqa: F811; - """ - Перевірка створення Operation - """ - async with database.sessionmaker() as session: - uow = SqlAlchemyUnitOfWork(session) - user_schema = UserCreateSchema(email="test", password="test") # nosec B106 - created_user = await create_user(uow, user_schema) - - operation_schema = OperationCreateSchema( - amount=-1000, - description="description", - unix_time=time.time(), - mcc=9999, - source_type="manual", - ) - operation = await create_operation(uow, created_user.id, operation_schema) - assert isinstance(operation, Operation) - - # Спроба створити операції з неправильним ID користувача - incorrect_operation = await create_operation(uow, 999, operation_schema) - assert incorrect_operation is None - - -@pytest.mark.asyncio -@precents_evn_variables -async def test_read_operations(database): # noqa: F811; - """ - Перевірка різних методів отримання списку операцій, з фільтраціями і без. - """ - async with database.sessionmaker() as session: - uow = SqlAlchemyUnitOfWork(session) - user_schema = UserCreateSchema(email="test", password="test") # nosec B106 - created_user = await create_user(uow, user_schema) - - for _ in range(10): - operation_schema = OperationCreateSchema( - amount=random.randint(-10000, -10), - description="description", - unix_time=datetime.now().timestamp(), - mcc=random.randint(1000, 9999), - source_type="manual", - ) - await create_operation(uow, created_user.id, operation_schema) - - operations = await get_operations( - uow, - created_user.id, - from_time=int((datetime.now() - timedelta(minutes=1)).timestamp()), - to_time=int(datetime.now().timestamp()), - ) - assert isinstance(operations, list) - assert isinstance(operations[0], Operation) - assert len(operations) == 10 - - -@pytest.mark.asyncio -@precents_evn_variables -async def test_read_operations_in_date_range(database): - async with database.sessionmaker() as session: - uow = SqlAlchemyUnitOfWork(session) - user_schema = UserCreateSchema(email="test", password="test") # nosec B106 - user = await create_user(uow, user_schema) - - todays_operation_schema = OperationCreateSchema( - amount=random.randint(-10000, -10), - description="description", - time=datetime.now().timestamp(), - mcc=random.randint(1000, 9999), - source_type="manual", - ) - await create_operation(uow, user.id, todays_operation_schema) - async with uow: - yesterdays_operation = Operation( - amount=random.randint(-10000, -10), - description="description", - time=int((datetime.now() - timedelta(days=1)).timestamp()), - mcc=random.randint(1000, 9999), - source_type="manual", - user_id=user.id, - ) - await uow.operations.add(yesterdays_operation) - await uow.commit() - - today = datetime.today().timestamp() - tomorrow = (datetime.today() + timedelta(days=1)).timestamp() - operations = await get_operations( - uow, user.id, from_time=int(today), to_time=int(tomorrow) - ) - assert len(operations) == 1 From 5f5a8601f417606c0fe64e0c2c26b8c9154b3ae8 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 12:18:30 +0300 Subject: [PATCH 23/80] converted users tests to UserAdapter tests --- tests/integrations/adapters/users_test.py | 29 +++++++++++ tests/unit/users_test.py | 61 ----------------------- 2 files changed, 29 insertions(+), 61 deletions(-) create mode 100644 tests/integrations/adapters/users_test.py delete mode 100644 tests/unit/users_test.py diff --git a/tests/integrations/adapters/users_test.py b/tests/integrations/adapters/users_test.py new file mode 100644 index 0000000..1d401b8 --- /dev/null +++ b/tests/integrations/adapters/users_test.py @@ -0,0 +1,29 @@ +import pytest + +from src.app.domain.users import User +from src.app.repositories.users import UserRepository +from tests.conftest import precents_env_variables + + +@pytest.mark.asyncio +@precents_env_variables +async def test_create_and_read_user_by_all_fields(database): + async with database.sessionmaker() as session: + user = User(email="test", hashed_password="fake_hashed_password") + repository = UserRepository(session) + await repository.add(user) + await session.commit() + + results = [ + await repository.get("id", user.id), + await repository.get("email", user.email), + ] + assert all(results) + + +@pytest.mark.asyncio +async def test_read_user_with_incorrect_data(database): + async with database.sessionmaker() as session: + repository = UserRepository(session) + assert await repository.get("id", 100000) is None + assert await repository.get("email", "incorrect") is None diff --git a/tests/unit/users_test.py b/tests/unit/users_test.py deleted file mode 100644 index e79ee11..0000000 --- a/tests/unit/users_test.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -users methods test -""" -import pytest - -from src.app.domain.users import User -from src.app.repositories.users import UserRepository -from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork -from src.app.services.users import create_user -from src.schemas.users import UserCreateSchema -from tests.conftest import precents_env_variables # noqa: F401; - - -@pytest.mark.asyncio -@precents_env_variables -async def test_user_create(database): # noqa: F401, F811; - """ - create_user повинен зберегти об'єкт User в БД та повернути його - """ - user_schema = UserCreateSchema( # nosec B106 - email="test@test.com", password="123456" - ) - async with database.sessionmaker() as session: - uow = SqlAlchemyUnitOfWork(session) - result = await create_user(uow, user_schema) - assert isinstance(result, User) - - -@pytest.mark.asyncio -@precents_env_variables -async def test_user_read(database): # noqa: F401, F811; - """ - Перевірка методів - """ - user_schema = UserCreateSchema( # nosec B106 - email="test@test.com", password="123456" - ) - async with database.sessionmaker() as session: - uow = SqlAlchemyUnitOfWork(session) - users = UserRepository(session) - - # Створення користувача - created_user: User = await create_user(uow, user_schema) - - # Запит існуючого користувача по ID - user = await users.get("id", created_user.id) - assert isinstance(user, User) - assert user.email == created_user.email - - # Запит існуючого користувача по ID - user = await users.get("email", created_user.email) - assert isinstance(user, User) - assert user.email == created_user.email - - # Запит неіснуючого користувача по ID - user = await users.get("id", 9999) - assert user is None - - # Запит неіснуючого користувача по ID - user = await users.get("email", "incorrect@test.com") - assert user is None From a804e43960caa2c3ef346bcb05eccc8449aaaee8 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 13:00:28 +0300 Subject: [PATCH 24/80] refactored fake adapters --- tests/fake_adapters/base.py | 4 ++-- tests/fake_adapters/operations.py | 11 +++++++---- tests/fake_adapters/uow.py | 19 +++++++++++++++++++ tests/fake_adapters/users.py | 3 ++- 4 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 tests/fake_adapters/uow.py diff --git a/tests/fake_adapters/base.py b/tests/fake_adapters/base.py index 29eb961..8d6b088 100644 --- a/tests/fake_adapters/base.py +++ b/tests/fake_adapters/base.py @@ -9,10 +9,10 @@ def __init__(self): def get(self, prop, value): raise NotImplementedError - def add(self, instance): + async def add(self, instance): self.instances.append(instance) - def _get(self, prop, value): + async def _get(self, prop, value): result = list( filter(lambda instance: instance.__dict__()[prop] == value, self.instances) ) diff --git a/tests/fake_adapters/operations.py b/tests/fake_adapters/operations.py index 0e8b2bf..b0cbebd 100644 --- a/tests/fake_adapters/operations.py +++ b/tests/fake_adapters/operations.py @@ -1,12 +1,15 @@ from src.app.domain.operations import Operation +from src.app.repositories.absctract.operations import AOperationRepository from tests.fake_adapters.base import FakeRepository -class OperationRepository(FakeRepository): - def get(self, prop, value) -> Operation | None: - return self._get(prop, value) +class FakeOperationRepository(FakeRepository, AOperationRepository): + async def get(self, prop, value) -> Operation | None: + return await self._get(prop, value) - def get_all_by_user(self, user_id, from_time: int, to_time: int) -> list[Operation]: + async def get_all_by_user( + self, user_id, from_time: int, to_time: int + ) -> list[Operation]: return list( filter( lambda instance: instance.user_id == user_id diff --git a/tests/fake_adapters/uow.py b/tests/fake_adapters/uow.py new file mode 100644 index 0000000..7449932 --- /dev/null +++ b/tests/fake_adapters/uow.py @@ -0,0 +1,19 @@ +from src.app.services.uow.abstract import AbstractUnitOfWork +from tests.fake_adapters.operations import FakeOperationRepository +from tests.fake_adapters.users import FakeUserRepository + + +class FakeUnitOfWork(AbstractUnitOfWork): + async def __aenter__(self): + self.users = FakeUserRepository() + self.operations = FakeOperationRepository() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return True + + async def _commit(self): + pass + + async def rollback(self): + pass diff --git a/tests/fake_adapters/users.py b/tests/fake_adapters/users.py index 95fe75a..c5eb61e 100644 --- a/tests/fake_adapters/users.py +++ b/tests/fake_adapters/users.py @@ -1,7 +1,8 @@ from src.app.domain.users import User +from src.app.repositories.absctract.users import AUserRepository from tests.fake_adapters.base import FakeRepository -class UserRepository(FakeRepository): +class FakeUserRepository(FakeRepository, AUserRepository): def get(self, prop, value) -> User | None: return self._get(prop, value) From eeb5edf60f19ffdc9653c9a35d92f6ee870ec11f Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 13:01:27 +0300 Subject: [PATCH 25/80] extracted operation test from another --- tests/integrations/operations_test.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/integrations/operations_test.py b/tests/integrations/operations_test.py index ff71103..5f78d5a 100644 --- a/tests/integrations/operations_test.py +++ b/tests/integrations/operations_test.py @@ -31,7 +31,13 @@ async def test_create_operation_endpoint(client_db: AsyncClient): # noqa: F811; for key, value in operation_data.items(): assert created_operation[key] == value - # Запит з невірними даними + +@pytest.mark.asyncio +async def test_create_operation_with_incorrect_data(client_db: AsyncClient): + auth_data = await create_and_auth_func_user(client_db) + token = auth_data["token"] + headers = {"Authorization": token} + incorrect_response = await client_db.post( "/operations/create/", json={}, headers=headers ) From efe577e025ec792d91fce4925f0372f712138da9 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 13:01:57 +0300 Subject: [PATCH 26/80] created operation tests with fakes adapters --- tests/unit/operations_test.py | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/unit/operations_test.py diff --git a/tests/unit/operations_test.py b/tests/unit/operations_test.py new file mode 100644 index 0000000..d2d22bc --- /dev/null +++ b/tests/unit/operations_test.py @@ -0,0 +1,60 @@ +from datetime import datetime, timedelta + +import pytest + +from src.app.domain.operations import Operation +from src.app.services.operations import create_operation, get_operations +from src.schemas.operations import OperationCreateSchema +from tests.fake_adapters.uow import FakeUnitOfWork + + +@pytest.mark.asyncio +async def test_create_operation(): + uow = FakeUnitOfWork() + schema = OperationCreateSchema( + amount=100, description="test", mcc=9999, source_type="manual" + ) + operation = await create_operation(uow, 1, schema) + assert operation + + +@pytest.mark.asyncio +async def test_read_operation(): + uow = FakeUnitOfWork() + async with uow: + uow.operations.instances = [ + Operation( + id=1, + amount=100, + description="test", + mcc=9999, + source_type="manual", + time=int(datetime.now().timestamp()), + user_id=1, + ), + Operation( + id=2, + amount=333, + description="test", + mcc=9998, + source_type="manual", + time=int(datetime.now().timestamp()), + user_id=1, + ), + Operation( + id=3, + amount=150, + description="test", + mcc=9999, + source_type="manual", + time=int(datetime.now().timestamp()), + user_id=1, + ), + ] + operations = await get_operations( + uow, + 1, + (datetime.now() - timedelta(minutes=1)).timestamp(), + datetime.now().timestamp(), + ) + assert len(operations) == 3 From eab9cb3a57967e1f1c2c97791f5209005b9da1ac Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 13:26:57 +0300 Subject: [PATCH 27/80] changed initialize adapters place in fake uow --- tests/fake_adapters/uow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/fake_adapters/uow.py b/tests/fake_adapters/uow.py index 7449932..82d3cf6 100644 --- a/tests/fake_adapters/uow.py +++ b/tests/fake_adapters/uow.py @@ -4,13 +4,15 @@ class FakeUnitOfWork(AbstractUnitOfWork): - async def __aenter__(self): + def __init__(self): self.users = FakeUserRepository() self.operations = FakeOperationRepository() + + async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): - return True + pass async def _commit(self): pass From 8b0015e3bdaa2a5869bd3d531ab33b58512c8b26 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 13:27:07 +0300 Subject: [PATCH 28/80] bug fix --- tests/fake_adapters/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fake_adapters/base.py b/tests/fake_adapters/base.py index 8d6b088..66b741e 100644 --- a/tests/fake_adapters/base.py +++ b/tests/fake_adapters/base.py @@ -14,6 +14,6 @@ async def add(self, instance): async def _get(self, prop, value): result = list( - filter(lambda instance: instance.__dict__()[prop] == value, self.instances) + filter(lambda instance: instance.__dict__[prop] == value, self.instances) ) return result[0] if result else None From 4927da096a97630efbfeac48dc110aad4ac279d9 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 8 May 2023 13:27:36 +0300 Subject: [PATCH 29/80] created users tests with fake adapters --- tests/fake_adapters/users.py | 4 ++-- tests/unit/users_test.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/unit/users_test.py diff --git a/tests/fake_adapters/users.py b/tests/fake_adapters/users.py index c5eb61e..c8146df 100644 --- a/tests/fake_adapters/users.py +++ b/tests/fake_adapters/users.py @@ -4,5 +4,5 @@ class FakeUserRepository(FakeRepository, AUserRepository): - def get(self, prop, value) -> User | None: - return self._get(prop, value) + async def get(self, prop, value) -> User | None: + return await self._get(prop, value) diff --git a/tests/unit/users_test.py b/tests/unit/users_test.py new file mode 100644 index 0000000..15862f1 --- /dev/null +++ b/tests/unit/users_test.py @@ -0,0 +1,23 @@ +import pytest + +from src.app.domain.users import User +from src.app.services.users import create_user, get_user_by_email +from src.schemas.users import UserCreateSchema +from tests.fake_adapters.uow import FakeUnitOfWork + + +@pytest.mark.asyncio +async def test_create_user(): + uow = FakeUnitOfWork() + schema = UserCreateSchema(email="test", password="test") + user = await create_user(uow, schema) + assert user + + +@pytest.mark.asyncio +async def test_read_user(): + uow = FakeUnitOfWork() + async with uow: + uow.users.instances = [User(email="test", hashed_password="test")] + user = await get_user_by_email(uow, "test") + assert user From ee03a846aa08ce49bafd4ef2a22846e05700e042 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Tue, 9 May 2023 13:01:43 +0300 Subject: [PATCH 30/80] created category structure --- src/app/adapters/orm.py | 9 +++++ src/app/domain/categories.py | 9 +++++ src/app/repositories/absctract/categories.py | 14 ++++++++ src/app/repositories/categories.py | 22 +++++++++++++ src/app/services/categories.py | 20 +++++++++++ src/app/services/uow/abstract.py | 8 ++--- src/app/services/uow/sqlalchemy.py | 2 ++ src/schemas/categories.py | 10 ++++++ tests/fake_adapters/categories.py | 17 ++++++++++ tests/fake_adapters/uow.py | 2 ++ .../integrations/adapters/categories_test.py | 29 ++++++++++++++++ tests/unit/categories_test.py | 33 +++++++++++++++++++ 12 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 src/app/domain/categories.py create mode 100644 src/app/repositories/absctract/categories.py create mode 100644 src/app/repositories/categories.py create mode 100644 src/app/services/categories.py create mode 100644 src/schemas/categories.py create mode 100644 tests/fake_adapters/categories.py create mode 100644 tests/integrations/adapters/categories_test.py create mode 100644 tests/unit/categories_test.py diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 17e6af3..0c2a791 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import registry, relationship from src.app.domain.bank_api import BankInfo, BankInfoProperty +from src.app.domain.categories import Category from src.app.domain.operations import Operation from src.app.domain.users import User @@ -50,6 +51,13 @@ def create_tables(mapper_registry) -> dict[str, Table]: Column("prop_type", String, nullable=False), Column("manager_id", Integer, ForeignKey("banks_info.id")), ), + "categories": Table( + "categories", + mapper_registry.metadata, + Column("id", Integer, primary_key=True), + Column("name", String, nullable=False), + Column("user_id", Integer, ForeignKey("users.id"), nullable=True), + ), } @@ -75,3 +83,4 @@ def start_mappers(mapper_registry: registry, tables: dict[str, Table]): tables["banks_info_properties"], properties={"manager": relationship(BankInfo)}, ) + mapper_registry.map_imperatively(Category, tables["categories"]) diff --git a/src/app/domain/categories.py b/src/app/domain/categories.py new file mode 100644 index 0000000..0776443 --- /dev/null +++ b/src/app/domain/categories.py @@ -0,0 +1,9 @@ +class Category: + id: int | None + name: str + user_id: int | None + + def __init__(self, name: str, user_id: int | None, id: int | None = None): + self.id = id + self.name = name + self.user_id = user_id diff --git a/src/app/repositories/absctract/categories.py b/src/app/repositories/absctract/categories.py new file mode 100644 index 0000000..8060ecc --- /dev/null +++ b/src/app/repositories/absctract/categories.py @@ -0,0 +1,14 @@ +from abc import abstractmethod + +from src.app.domain.categories import Category +from src.app.repositories.absctract.base import AbstractRepository + + +class ACategoryRepository(AbstractRepository): + @abstractmethod + async def get(self, field, value) -> Category: + raise NotImplementedError + + @abstractmethod + async def get_availables(self, user_id) -> list[Category]: + raise NotImplementedError diff --git a/src/app/repositories/categories.py b/src/app/repositories/categories.py new file mode 100644 index 0000000..534b9bc --- /dev/null +++ b/src/app/repositories/categories.py @@ -0,0 +1,22 @@ +from sqlalchemy import or_, select + +from src.app.domain.categories import Category +from src.app.repositories.absctract.categories import ACategoryRepository +from src.app.repositories.sqlalchemy import SqlAlchemyRepository + + +class CategoryRepository(SqlAlchemyRepository, ACategoryRepository): + async def get(self, field, value) -> Category: + return await self._get(Category, field, value) + + async def get_availables(self, user_id) -> list[Category]: + return list( + await self.session.scalars( + select(Category).filter( + or_( + Category.user_id == user_id, + Category.user_id == None, # noqa: E711 + ) + ) + ) + ) diff --git a/src/app/services/categories.py b/src/app/services/categories.py new file mode 100644 index 0000000..9beb683 --- /dev/null +++ b/src/app/services/categories.py @@ -0,0 +1,20 @@ +from src.app.domain.categories import Category +from src.app.services.uow.abstract import AbstractUnitOfWork +from src.schemas.categories import CategoryCreateSchema + + +async def create_category( + uow: AbstractUnitOfWork, user_id: int, schema: CategoryCreateSchema +) -> Category | None: + async with uow: + category = Category(name=schema.name, user_id=user_id) + await uow.categories.add(category) + await uow.commit() + return category + + +async def get_availables_categories( + uow: AbstractUnitOfWork, user_id: int +) -> list[Category]: + async with uow: + return await uow.categories.get_availables(user_id) diff --git a/src/app/services/uow/abstract.py b/src/app/services/uow/abstract.py index b64ca1c..ce2ee1e 100644 --- a/src/app/services/uow/abstract.py +++ b/src/app/services/uow/abstract.py @@ -10,10 +10,8 @@ from abc import ABC, abstractmethod -from src.app.repositories.absctract.bank_api import ( - ABankInfoRepository, - ABankManagerRepository, -) +from src.app.repositories.absctract.bank_api import ABankInfoRepository +from src.app.repositories.absctract.categories import ACategoryRepository from src.app.repositories.absctract.operations import AOperationRepository from src.app.repositories.absctract.users import AUserRepository @@ -22,7 +20,7 @@ class AbstractUnitOfWork(ABC): users: AUserRepository operations: AOperationRepository banks_info: ABankInfoRepository - bank_managers: dict[str, ABankManagerRepository] + categories: ACategoryRepository async def __aenter__(self): return self diff --git a/src/app/services/uow/sqlalchemy.py b/src/app/services/uow/sqlalchemy.py index b53c5b9..b63e3c4 100644 --- a/src/app/services/uow/sqlalchemy.py +++ b/src/app/services/uow/sqlalchemy.py @@ -1,6 +1,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.app.repositories.bank_api.bank_info import BankInfoRepository +from src.app.repositories.categories import CategoryRepository from src.app.repositories.operations import OperationRepository from src.app.repositories.users import UserRepository from src.app.services.uow.abstract import AbstractUnitOfWork @@ -14,6 +15,7 @@ async def __aenter__(self): self.users = UserRepository(self.session) self.operations = OperationRepository(self.session) self.banks_info = BankInfoRepository(self.session) + self.categories = CategoryRepository(self.session) return await super().__aenter__() async def __aexit__(self, exc_type, exc_val, exc_tb): diff --git a/src/schemas/categories.py b/src/schemas/categories.py new file mode 100644 index 0000000..f8854f3 --- /dev/null +++ b/src/schemas/categories.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class CategoryCreateSchema(BaseModel): + name: str + + +class CategorySchema(CategoryCreateSchema): + id: int + user_id: int | None diff --git a/tests/fake_adapters/categories.py b/tests/fake_adapters/categories.py new file mode 100644 index 0000000..1c13d5a --- /dev/null +++ b/tests/fake_adapters/categories.py @@ -0,0 +1,17 @@ +from src.app.domain.categories import Category +from src.app.repositories.absctract.categories import ACategoryRepository +from tests.fake_adapters.base import FakeRepository + + +class FakeCategoryRepository(FakeRepository, ACategoryRepository): + async def get(self, prop, value) -> Category | None: + return await self._get(prop, value) + + async def get_availables(self, user_id) -> list[Category]: + return list( + filter( + lambda category: category.user_id == user_id + or category.user_id is None, + self.instances, + ) + ) diff --git a/tests/fake_adapters/uow.py b/tests/fake_adapters/uow.py index 82d3cf6..d691109 100644 --- a/tests/fake_adapters/uow.py +++ b/tests/fake_adapters/uow.py @@ -1,4 +1,5 @@ from src.app.services.uow.abstract import AbstractUnitOfWork +from tests.fake_adapters.categories import FakeCategoryRepository from tests.fake_adapters.operations import FakeOperationRepository from tests.fake_adapters.users import FakeUserRepository @@ -7,6 +8,7 @@ class FakeUnitOfWork(AbstractUnitOfWork): def __init__(self): self.users = FakeUserRepository() self.operations = FakeOperationRepository() + self.categories = FakeCategoryRepository() async def __aenter__(self): return self diff --git a/tests/integrations/adapters/categories_test.py b/tests/integrations/adapters/categories_test.py new file mode 100644 index 0000000..90d22d2 --- /dev/null +++ b/tests/integrations/adapters/categories_test.py @@ -0,0 +1,29 @@ +import pytest + +from src.app.domain.categories import Category +from src.app.repositories.categories import CategoryRepository +from tests.patterns import create_user_with_orm + + +@pytest.mark.asyncio +async def test_create_and_read_categories(database): + async with database.sessionmaker() as session: + repository = CategoryRepository(session) + created_user = await create_user_with_orm(session) + for i in range(10): + await repository.add( + Category( + name=f"Test category #{i}", + user_id=created_user.id, + ) + ) + await repository.add( + Category( + name=f"Test common category", + user_id=None, + ) + ) + await session.commit() + + categories = await repository.get_availables(created_user.id) + assert len(categories) == 11 diff --git a/tests/unit/categories_test.py b/tests/unit/categories_test.py new file mode 100644 index 0000000..10214c9 --- /dev/null +++ b/tests/unit/categories_test.py @@ -0,0 +1,33 @@ +import pytest + +from src.app.domain.categories import Category +from src.app.services.categories import create_category, get_availables_categories +from src.schemas.categories import CategoryCreateSchema +from tests.fake_adapters.uow import FakeUnitOfWork + + +@pytest.mark.asyncio +async def test_create_category(): + uow = FakeUnitOfWork() + schema = CategoryCreateSchema(name="Test category") + category = await create_category(uow, 1, schema) + assert isinstance(category, Category) + + +@pytest.mark.asyncio +async def test_create_dublicate_category(): + uow = FakeUnitOfWork() + schema = CategoryCreateSchema(name="Test category") + await create_category(uow, 1, schema) + category = await create_category(uow, 1, schema) + assert category is None + + +@pytest.mark.asyncio +async def test_read_availables_categories(): + uow = FakeUnitOfWork() + uow.categories.instances = [ + Category(id=i, name=f"Test category #{i}", user_id=1) for i in range(10) + ] + categories = await get_availables_categories(uow, user_id=1) + assert len(categories) == 10 From fe7327eea3e9c39d6e109e2e26bc41f97fd42ed2 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Tue, 9 May 2023 13:14:17 +0300 Subject: [PATCH 31/80] refactored get method in adapters --- src/app/repositories/absctract/base.py | 2 +- src/app/repositories/absctract/categories.py | 2 +- src/app/repositories/absctract/operations.py | 2 +- src/app/repositories/absctract/users.py | 2 +- src/app/repositories/categories.py | 4 ++-- src/app/repositories/operations.py | 4 ++-- src/app/repositories/sqlalchemy.py | 4 ++-- src/app/repositories/users.py | 4 ++-- src/app/services/users.py | 2 +- tests/fake_adapters/base.py | 14 +++++++++++--- tests/fake_adapters/categories.py | 4 ++-- tests/fake_adapters/operations.py | 4 ++-- tests/fake_adapters/users.py | 4 ++-- tests/integrations/adapters/users_test.py | 8 ++++---- 14 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/app/repositories/absctract/base.py b/src/app/repositories/absctract/base.py index 7bf0fbc..a7df109 100644 --- a/src/app/repositories/absctract/base.py +++ b/src/app/repositories/absctract/base.py @@ -7,5 +7,5 @@ async def add(self, model_object): raise NotImplementedError @abstractmethod - async def get(self, field, value): + async def get(self, **kwargs): raise NotImplementedError diff --git a/src/app/repositories/absctract/categories.py b/src/app/repositories/absctract/categories.py index 8060ecc..31d36c4 100644 --- a/src/app/repositories/absctract/categories.py +++ b/src/app/repositories/absctract/categories.py @@ -6,7 +6,7 @@ class ACategoryRepository(AbstractRepository): @abstractmethod - async def get(self, field, value) -> Category: + async def get(self, **kwargs) -> Category: raise NotImplementedError @abstractmethod diff --git a/src/app/repositories/absctract/operations.py b/src/app/repositories/absctract/operations.py index 9ccb1bb..e0652ff 100644 --- a/src/app/repositories/absctract/operations.py +++ b/src/app/repositories/absctract/operations.py @@ -6,7 +6,7 @@ class AOperationRepository(AbstractRepository): @abstractmethod - async def get(self, field, value) -> Operation: + async def get(self, **kwargs) -> Operation: raise NotImplementedError @abstractmethod diff --git a/src/app/repositories/absctract/users.py b/src/app/repositories/absctract/users.py index 13ed88d..42773fa 100644 --- a/src/app/repositories/absctract/users.py +++ b/src/app/repositories/absctract/users.py @@ -6,5 +6,5 @@ class AUserRepository(AbstractRepository): @abstractmethod - async def get(self, field, value) -> User: + async def get(self, **kwargs) -> User: raise NotImplementedError diff --git a/src/app/repositories/categories.py b/src/app/repositories/categories.py index 534b9bc..e6be853 100644 --- a/src/app/repositories/categories.py +++ b/src/app/repositories/categories.py @@ -6,8 +6,8 @@ class CategoryRepository(SqlAlchemyRepository, ACategoryRepository): - async def get(self, field, value) -> Category: - return await self._get(Category, field, value) + async def get(self, **kwargs) -> Category: + return await self._get(Category, **kwargs) async def get_availables(self, user_id) -> list[Category]: return list( diff --git a/src/app/repositories/operations.py b/src/app/repositories/operations.py index 0a768be..7690961 100644 --- a/src/app/repositories/operations.py +++ b/src/app/repositories/operations.py @@ -6,8 +6,8 @@ class OperationRepository(SqlAlchemyRepository, AOperationRepository): - async def get(self, field, value) -> Operation: - return await self._get(Operation, field, value) + async def get(self, **kwargs) -> Operation: + return await self._get(Operation, **kwargs) async def get_all_by_user( self, user_id, from_time: int, to_time: int diff --git a/src/app/repositories/sqlalchemy.py b/src/app/repositories/sqlalchemy.py index 1c18f6f..b6c2210 100644 --- a/src/app/repositories/sqlalchemy.py +++ b/src/app/repositories/sqlalchemy.py @@ -14,5 +14,5 @@ def __init__(self, session: AsyncSession): async def add(self, model_object): self.session.add(model_object) - async def _get(self, model, field, value): - return await self.session.scalar(select(model).filter_by(**{field: value})) + async def _get(self, model, **kwargs): + return await self.session.scalar(select(model).filter_by(**kwargs)) diff --git a/src/app/repositories/users.py b/src/app/repositories/users.py index a79e738..e357917 100644 --- a/src/app/repositories/users.py +++ b/src/app/repositories/users.py @@ -4,5 +4,5 @@ class UserRepository(SqlAlchemyRepository, AUserRepository): - async def get(self, field, value) -> User: - return await self._get(User, field, value) + async def get(self, **kwargs) -> User: + return await self._get(User, **kwargs) diff --git a/src/app/services/users.py b/src/app/services/users.py index 0930078..e97c4c9 100644 --- a/src/app/services/users.py +++ b/src/app/services/users.py @@ -23,4 +23,4 @@ async def create_user(uow: AbstractUnitOfWork, user: UserCreateSchema) -> User: async def get_user_by_email(uow: AbstractUnitOfWork, email: str) -> User | None: async with uow: - return await uow.users.get("email", email) + return await uow.users.get(email=email) diff --git a/tests/fake_adapters/base.py b/tests/fake_adapters/base.py index 66b741e..71b1d59 100644 --- a/tests/fake_adapters/base.py +++ b/tests/fake_adapters/base.py @@ -6,14 +6,22 @@ def __init__(self): self.instances = [] @abstractmethod - def get(self, prop, value): + async def get(self, **kwargs): raise NotImplementedError async def add(self, instance): self.instances.append(instance) - async def _get(self, prop, value): + async def _get(self, **kwargs): result = list( - filter(lambda instance: instance.__dict__[prop] == value, self.instances) + filter( + lambda instance: all( + [ + instance.__dict__[prop] == value + for (prop, value) in kwargs.items() + ] + ), + self.instances, + ) ) return result[0] if result else None diff --git a/tests/fake_adapters/categories.py b/tests/fake_adapters/categories.py index 1c13d5a..7b74fd4 100644 --- a/tests/fake_adapters/categories.py +++ b/tests/fake_adapters/categories.py @@ -4,8 +4,8 @@ class FakeCategoryRepository(FakeRepository, ACategoryRepository): - async def get(self, prop, value) -> Category | None: - return await self._get(prop, value) + async def get(self, **kwargs) -> Category | None: + return await self._get(**kwargs) async def get_availables(self, user_id) -> list[Category]: return list( diff --git a/tests/fake_adapters/operations.py b/tests/fake_adapters/operations.py index b0cbebd..49d15e7 100644 --- a/tests/fake_adapters/operations.py +++ b/tests/fake_adapters/operations.py @@ -4,8 +4,8 @@ class FakeOperationRepository(FakeRepository, AOperationRepository): - async def get(self, prop, value) -> Operation | None: - return await self._get(prop, value) + async def get(self, **kwargs) -> Operation | None: + return await self._get(**kwargs) async def get_all_by_user( self, user_id, from_time: int, to_time: int diff --git a/tests/fake_adapters/users.py b/tests/fake_adapters/users.py index c8146df..52466a3 100644 --- a/tests/fake_adapters/users.py +++ b/tests/fake_adapters/users.py @@ -4,5 +4,5 @@ class FakeUserRepository(FakeRepository, AUserRepository): - async def get(self, prop, value) -> User | None: - return await self._get(prop, value) + async def get(self, **kwargs) -> User | None: + return await self._get(**kwargs) diff --git a/tests/integrations/adapters/users_test.py b/tests/integrations/adapters/users_test.py index 1d401b8..d88e20f 100644 --- a/tests/integrations/adapters/users_test.py +++ b/tests/integrations/adapters/users_test.py @@ -15,8 +15,8 @@ async def test_create_and_read_user_by_all_fields(database): await session.commit() results = [ - await repository.get("id", user.id), - await repository.get("email", user.email), + await repository.get(id=user.id), + await repository.get(email=user.email), ] assert all(results) @@ -25,5 +25,5 @@ async def test_create_and_read_user_by_all_fields(database): async def test_read_user_with_incorrect_data(database): async with database.sessionmaker() as session: repository = UserRepository(session) - assert await repository.get("id", 100000) is None - assert await repository.get("email", "incorrect") is None + assert await repository.get(id=100000) is None + assert await repository.get(email="incorrect") is None From 9959af251b3c9cb9585fc132d2db0d7a7f858cb5 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Tue, 9 May 2023 13:25:32 +0300 Subject: [PATCH 32/80] created method to prevent the creation of duplicate categories --- src/app/services/categories.py | 3 +++ tests/unit/categories_test.py | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/services/categories.py b/src/app/services/categories.py index 9beb683..084150d 100644 --- a/src/app/services/categories.py +++ b/src/app/services/categories.py @@ -7,6 +7,9 @@ async def create_category( uow: AbstractUnitOfWork, user_id: int, schema: CategoryCreateSchema ) -> Category | None: async with uow: + created_category = await uow.categories.get(name=schema.name, user_id=user_id) + if created_category: + return created_category category = Category(name=schema.name, user_id=user_id) await uow.categories.add(category) await uow.commit() diff --git a/tests/unit/categories_test.py b/tests/unit/categories_test.py index 10214c9..5712291 100644 --- a/tests/unit/categories_test.py +++ b/tests/unit/categories_test.py @@ -16,11 +16,16 @@ async def test_create_category(): @pytest.mark.asyncio async def test_create_dublicate_category(): + """ + Якщо відбувається спроба створити категорію-дублікат, + то категорія не створюється, а метод повинен повернути категорію-оригінал + :return: + """ uow = FakeUnitOfWork() schema = CategoryCreateSchema(name="Test category") - await create_category(uow, 1, schema) + created_category = await create_category(uow, 1, schema) category = await create_category(uow, 1, schema) - assert category is None + assert category == created_category @pytest.mark.asyncio From c594d80728d2046abd8ee1bc6a958d576041403f Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Tue, 9 May 2023 16:07:39 +0300 Subject: [PATCH 33/80] included category to operation structure --- src/app/adapters/orm.py | 2 +- src/app/domain/operations.py | 4 +-- src/app/services/categories.py | 2 +- src/app/services/statistic.py | 11 ++++--- src/main.py | 2 ++ src/routers/categories.py | 45 +++++++++++++++++++++++++++ src/routers/operations.py | 1 - src/schemas/categories.py | 3 ++ src/schemas/operations.py | 2 +- tests/integrations/categories_test.py | 42 +++++++++++++++++++++++++ tests/integrations/operations_test.py | 2 -- tests/integrations/statistic_test.py | 2 -- tests/unit/operations_test.py | 7 +---- 13 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 src/routers/categories.py create mode 100644 tests/integrations/categories_test.py diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 0c2a791..96fe2f4 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -27,13 +27,13 @@ def create_tables(mapper_registry) -> dict[str, Table]: Column("amount", Integer, nullable=False), Column("description", String), Column("time", Integer, nullable=False), - Column("mcc", Integer), # mcc - код виду операції Column("source_type", String, nullable=False), # Тип джерела. # value: "manual" | "" # Операція може бути або додана вручну або за допомогою API банку Column("user_id", Integer, ForeignKey("users.id")), + Column("category_id", Integer, ForeignKey("categories.id"), nullable=True), ), "banks_info": Table( "banks_info", diff --git a/src/app/domain/operations.py b/src/app/domain/operations.py index ce33cc8..58fd561 100644 --- a/src/app/domain/operations.py +++ b/src/app/domain/operations.py @@ -4,16 +4,16 @@ def __init__( amount: int, description: str | None, time: int, - mcc: int | None, source_type: str, user_id: int, + category_id: int | None = None, id: int = None, **kwargs, ): self.amount = amount self.description = description self.time = time - self.mcc = mcc self.source_type = source_type self.user_id = user_id self.id = id + self.category_id = category_id diff --git a/src/app/services/categories.py b/src/app/services/categories.py index 084150d..4e9001a 100644 --- a/src/app/services/categories.py +++ b/src/app/services/categories.py @@ -5,7 +5,7 @@ async def create_category( uow: AbstractUnitOfWork, user_id: int, schema: CategoryCreateSchema -) -> Category | None: +) -> Category: async with uow: created_category = await uow.categories.get(name=schema.name, user_id=user_id) if created_category: diff --git a/src/app/services/statistic.py b/src/app/services/statistic.py index 8ee5bb5..80377c2 100644 --- a/src/app/services/statistic.py +++ b/src/app/services/statistic.py @@ -26,12 +26,15 @@ def get_costs_sum(operations: list[Operation]) -> int: def get_categories_costs(operations: list[Operation]) -> dict[int, int]: """Словник у форматі {<категорія>: <сума витрат по категорії>}""" - operations_mcc = [operation.mcc for operation in operations] + categories_id = [operation.category_id for operation in operations] return { - mcc: sum( - operation.amount for operation in filter(lambda x: x.mcc == mcc, operations) + category_id: sum( + operation.amount + for operation in filter( + lambda op: op.category_id == category_id, operations + ) ) - for mcc in operations_mcc + for category_id in categories_id } diff --git a/src/main.py b/src/main.py index edad578..a295de8 100644 --- a/src/main.py +++ b/src/main.py @@ -22,6 +22,7 @@ def include_routers(fastapi_app): """Підключення роутерів""" 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; from src.routers.operations import router as operations_router # noqa: E402; from src.routers.statistic import router as statistic_router # noqa: E402; from src.routers.users import router as users_router # noqa: E402; @@ -31,6 +32,7 @@ def include_routers(fastapi_app): fastapi_app.include_router(operations_router, tags=["operations"]) fastapi_app.include_router(bankapi_router, tags=["bankapi"]) fastapi_app.include_router(statistic_router, tags=["statistic"]) + fastapi_app.include_router(categories_router, tags=["categories"]) app = bootstrap_fastapi_app() diff --git a/src/routers/categories.py b/src/routers/categories.py new file mode 100644 index 0000000..c174b1c --- /dev/null +++ b/src/routers/categories.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends + +from src.app.domain.users import User +from src.app.services.categories import create_category, get_availables_categories +from src.app.services.uow.abstract import AbstractUnitOfWork +from src.depends import get_current_user, get_uow +from src.schemas.categories import CategoryCreateSchema, CategorySchema + +router = APIRouter(prefix="/categories") + + +@router.post("/create/", response_model=CategorySchema, status_code=201) +async def create_operation_view( + category_schema: CategoryCreateSchema, + current_user: User = Depends(get_current_user), + uow: AbstractUnitOfWork = Depends(get_uow), +): + """ + Створює в БД та повертає операцію + :param uow: Unit of Work + :param category_schema: JSON, який буде спаршений у CategoryCretaeSchema + :param current_user: Користувач, + який розшифровується з токену у заголовку Authorization + :return: Category | Error 400 + """ + # Результат create_category не може бути None, + # тому що user_id не може бути не правильним. + # У випадку помилки під час розшифровки токену + # буде повернута помилка 401 перед виконанням тіла. + return await create_category(uow, current_user.id, category_schema) + + +@router.get("/list/", response_model=list[CategorySchema]) +async def read_operations_view( + current_user: User = Depends(get_current_user), + uow: AbstractUnitOfWork = Depends(get_uow), +): + """ + Повертає список операцій поточного користувача. + :param uow: Unit of Work + :param current_user: Користувач, + який розшифровується з токену у заголовку Authorization + :return: Category list + """ + return await get_availables_categories(uow, current_user.id) diff --git a/src/routers/operations.py b/src/routers/operations.py index d465a04..50a9ac7 100644 --- a/src/routers/operations.py +++ b/src/routers/operations.py @@ -41,7 +41,6 @@ async def read_operations_view( ): """ Повертає список операцій поточного користувача. - TODO: Реалізувати фільтрацію :param uow: Unit of Work :param current_user: Користувач, який розшифровується з токену у заголовку Authorization diff --git a/src/schemas/categories.py b/src/schemas/categories.py index f8854f3..703ac25 100644 --- a/src/schemas/categories.py +++ b/src/schemas/categories.py @@ -8,3 +8,6 @@ class CategoryCreateSchema(BaseModel): class CategorySchema(CategoryCreateSchema): id: int user_id: int | None + + class Config: + orm_mode = True diff --git a/src/schemas/operations.py b/src/schemas/operations.py index 2dcafca..856bb33 100644 --- a/src/schemas/operations.py +++ b/src/schemas/operations.py @@ -9,9 +9,9 @@ class OperationCreateSchema(BaseModel): amount: int description: str | None - mcc: int source_type: str time: int | None + category_id: int | None class OperationSchema(OperationCreateSchema): diff --git a/tests/integrations/categories_test.py b/tests/integrations/categories_test.py new file mode 100644 index 0000000..9386e03 --- /dev/null +++ b/tests/integrations/categories_test.py @@ -0,0 +1,42 @@ +import pytest +from httpx import AsyncClient + +from src.app.services.categories import create_category +from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork +from src.database import Database +from src.schemas.categories import CategoryCreateSchema +from tests.patterns import create_and_auth_func_user + + +@pytest.mark.asyncio +async def test_create_category_endpoint(client_db: AsyncClient): + auth_data = await create_and_auth_func_user(client_db) + token = auth_data["token"] + headers = {"Authorization": token} + + category_data = {"name": "category name"} + response = await client_db.post( + "/categories/create/", json=category_data, headers=headers + ) + assert response.status_code == 201 + + created_category = response.json() + assert created_category["name"] == category_data["name"] + + +@pytest.mark.asyncio +async def test_read_categories_endpoint(database: Database, client_db: AsyncClient): + auth_data = await create_and_auth_func_user(client_db) + token = auth_data["token"] + user_id = auth_data["user"]["id"] + headers = {"Authorization": token} + + async with database.sessionmaker() as session: + uow = SqlAlchemyUnitOfWork(session) + for i in range(10): + schema = CategoryCreateSchema(name=f"test category #{i}") + await create_category(uow, user_id, schema) + + response = await client_db.get("/categories/list/", 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 5f78d5a..79c46d7 100644 --- a/tests/integrations/operations_test.py +++ b/tests/integrations/operations_test.py @@ -19,7 +19,6 @@ async def test_create_operation_endpoint(client_db: AsyncClient): # noqa: F811; operation_data = { "amount": random.randint(-10000, -10), "description": "description", - "mcc": random.randint(1000, 9999), "source_type": "manual", } response = await client_db.post( @@ -54,7 +53,6 @@ async def test_read_operations_endpoint(client_db: AsyncClient): # noqa: F811; operation_data = { "amount": random.randint(-10000, -10), "description": "description", - "mcc": random.randint(1000, 9999), "source_type": "manual", } await client_db.post( diff --git a/tests/integrations/statistic_test.py b/tests/integrations/statistic_test.py index 97d4958..7e3351d 100644 --- a/tests/integrations/statistic_test.py +++ b/tests/integrations/statistic_test.py @@ -1,5 +1,3 @@ -import random - import pytest from httpx import AsyncClient diff --git a/tests/unit/operations_test.py b/tests/unit/operations_test.py index d2d22bc..dabeb27 100644 --- a/tests/unit/operations_test.py +++ b/tests/unit/operations_test.py @@ -11,9 +11,7 @@ @pytest.mark.asyncio async def test_create_operation(): uow = FakeUnitOfWork() - schema = OperationCreateSchema( - amount=100, description="test", mcc=9999, source_type="manual" - ) + schema = OperationCreateSchema(amount=100, description="test", source_type="manual") operation = await create_operation(uow, 1, schema) assert operation @@ -27,7 +25,6 @@ async def test_read_operation(): id=1, amount=100, description="test", - mcc=9999, source_type="manual", time=int(datetime.now().timestamp()), user_id=1, @@ -36,7 +33,6 @@ async def test_read_operation(): id=2, amount=333, description="test", - mcc=9998, source_type="manual", time=int(datetime.now().timestamp()), user_id=1, @@ -45,7 +41,6 @@ async def test_read_operation(): id=3, amount=150, description="test", - mcc=9999, source_type="manual", time=int(datetime.now().timestamp()), user_id=1, From 6d4a937f1f495badcd6914642e96d72f7d6c51bd Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 10 May 2023 10:33:59 +0300 Subject: [PATCH 34/80] deleted mcc in statistic tests --- tests/unit/statistic_test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/unit/statistic_test.py b/tests/unit/statistic_test.py index 06b2f10..f67f6b1 100644 --- a/tests/unit/statistic_test.py +++ b/tests/unit/statistic_test.py @@ -14,7 +14,6 @@ async def test_get_statistic(): Operation( amount=randint(-10000, -10), description="description", - mcc=randint(9996, 9999), source_type="manual", time=int(datetime.now().timestamp()), user_id=1, @@ -57,7 +56,6 @@ async def test_statistic_by_days(): Operation( amount=randint(-10000, -10), description="description", - mcc=randint(9990, 9999), source_type="manual", time=int(date.timestamp() + randint(1, 8399)), # random time of day user_id=1, From 2ff64efadf3a01e182abeb19ad7dbaa2f197f798 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sat, 13 May 2023 10:04:57 +0300 Subject: [PATCH 35/80] add type column to Category entity --- src/app/adapters/orm.py | 1 + src/app/domain/categories.py | 6 +++++- src/app/services/categories.py | 2 +- src/schemas/categories.py | 1 + src/schemas/statistic.py | 2 +- tests/conftest.py | 1 - tests/integrations/adapters/categories_test.py | 8 ++------ tests/unit/categories_test.py | 3 ++- 8 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 96fe2f4..cd6efe0 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -57,6 +57,7 @@ def create_tables(mapper_registry) -> dict[str, Table]: Column("id", Integer, primary_key=True), Column("name", String, nullable=False), Column("user_id", Integer, ForeignKey("users.id"), nullable=True), + Column("type", String, default="system"), ), } diff --git a/src/app/domain/categories.py b/src/app/domain/categories.py index 0776443..5c7e452 100644 --- a/src/app/domain/categories.py +++ b/src/app/domain/categories.py @@ -2,8 +2,12 @@ class Category: id: int | None name: str user_id: int | None + type: str - def __init__(self, name: str, user_id: int | None, id: int | None = None): + def __init__( + self, name: str, user_id: int | None, type: str, id: int | None = None + ): self.id = id self.name = name self.user_id = user_id + self.type = type diff --git a/src/app/services/categories.py b/src/app/services/categories.py index 4e9001a..95d8aac 100644 --- a/src/app/services/categories.py +++ b/src/app/services/categories.py @@ -10,7 +10,7 @@ async def create_category( created_category = await uow.categories.get(name=schema.name, user_id=user_id) if created_category: return created_category - category = Category(name=schema.name, user_id=user_id) + category = Category(name=schema.name, user_id=user_id, type="user") await uow.categories.add(category) await uow.commit() return category diff --git a/src/schemas/categories.py b/src/schemas/categories.py index 703ac25..5eddf5c 100644 --- a/src/schemas/categories.py +++ b/src/schemas/categories.py @@ -8,6 +8,7 @@ class CategoryCreateSchema(BaseModel): class CategorySchema(CategoryCreateSchema): id: int user_id: int | None + type: str class Config: orm_mode = True diff --git a/src/schemas/statistic.py b/src/schemas/statistic.py index ef52349..f2fef97 100644 --- a/src/schemas/statistic.py +++ b/src/schemas/statistic.py @@ -5,5 +5,5 @@ class StatisticSchema(BaseModel): """Схема операції. Модель: src.app.domain.unit.Statistic""" costs_sum: int - categories_costs: dict[int, int] + categories_costs: dict[int | None, int] costs_num_by_days: dict[str, int] diff --git a/tests/conftest.py b/tests/conftest.py index 54e80ad..7b7c2ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,6 @@ import pytest_asyncio from httpx import AsyncClient -from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.database import Database, DatabaseFactory from src.main import bootstrap_fastapi_app diff --git a/tests/integrations/adapters/categories_test.py b/tests/integrations/adapters/categories_test.py index 90d22d2..d8ab5da 100644 --- a/tests/integrations/adapters/categories_test.py +++ b/tests/integrations/adapters/categories_test.py @@ -13,15 +13,11 @@ async def test_create_and_read_categories(database): for i in range(10): await repository.add( Category( - name=f"Test category #{i}", - user_id=created_user.id, + name=f"Test category #{i}", user_id=created_user.id, type="user" ) ) await repository.add( - Category( - name=f"Test common category", - user_id=None, - ) + Category(name=f"Test common category", user_id=None, type="user") ) await session.commit() diff --git a/tests/unit/categories_test.py b/tests/unit/categories_test.py index 5712291..325dc2a 100644 --- a/tests/unit/categories_test.py +++ b/tests/unit/categories_test.py @@ -32,7 +32,8 @@ async def test_create_dublicate_category(): async def test_read_availables_categories(): uow = FakeUnitOfWork() uow.categories.instances = [ - Category(id=i, name=f"Test category #{i}", user_id=1) for i in range(10) + Category(id=i, name=f"Test category #{i}", user_id=1, type="user") + for i in range(10) ] categories = await get_availables_categories(uow, user_id=1) assert len(categories) == 10 From 2bfc4d5986a944a82eb2efeead2f56a019d9c661 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sat, 13 May 2023 12:40:00 +0300 Subject: [PATCH 36/80] add get_categories method in Category Repository --- src/app/repositories/absctract/categories.py | 4 ++++ src/app/repositories/categories.py | 12 +++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/repositories/absctract/categories.py b/src/app/repositories/absctract/categories.py index 31d36c4..82bf9ae 100644 --- a/src/app/repositories/absctract/categories.py +++ b/src/app/repositories/absctract/categories.py @@ -9,6 +9,10 @@ class ACategoryRepository(AbstractRepository): async def get(self, **kwargs) -> Category: raise NotImplementedError + @abstractmethod + async def get_categories(self, *args) -> list[Category]: + raise NotImplementedError + @abstractmethod async def get_availables(self, user_id) -> list[Category]: raise NotImplementedError diff --git a/src/app/repositories/categories.py b/src/app/repositories/categories.py index e6be853..55b0a57 100644 --- a/src/app/repositories/categories.py +++ b/src/app/repositories/categories.py @@ -9,14 +9,12 @@ class CategoryRepository(SqlAlchemyRepository, ACategoryRepository): async def get(self, **kwargs) -> Category: return await self._get(Category, **kwargs) + async def get_categories(self, *args) -> list[Category]: + return list(await self.session.scalars(select(Category).filter(*args))) + async def get_availables(self, user_id) -> list[Category]: return list( - await self.session.scalars( - select(Category).filter( - or_( - Category.user_id == user_id, - Category.user_id == None, # noqa: E711 - ) - ) + await self.get_categories( + or_(Category.user_id == user_id, Category.user_id == None) # noqa: E711 ) ) From 9538ba4718acc86153b69be8668884bc5052060c Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 15 May 2023 11:43:38 +0300 Subject: [PATCH 37/80] extracted get costs code to method --- src/app/services/bank_api.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index 4191289..da4c6c9 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from src.app.domain.bank_api import BankInfo, BankInfoProperty +from src.app.domain.operations import Operation from src.app.repositories.absctract.bank_api import ( ABankManagerRepository, BankManagerRepositoryFactory, @@ -58,12 +59,11 @@ async def update_banks_costs( costs = [] updated_managers = [] for manager in managers: - updated_time = get_updated_time(manager) - if updated_time: - bank_costs = await manager.get_costs(from_time=updated_time) - if bank_costs: - costs += bank_costs - updated_managers.append(manager) + bank_costs = await get_costs_by_bank(manager) + if bank_costs: + costs += bank_costs + updated_managers.append(manager) + for cost in costs: await uow.operations.add(cost) await uow.banks_info.set_update_time_to_managers( @@ -72,6 +72,14 @@ async def update_banks_costs( await uow.commit() +async def get_costs_by_bank(manager) -> list[Operation] | None: + updated_time = get_updated_time(manager) + if updated_time: + bank_costs = await manager.get_costs(from_time=updated_time) + if bank_costs: + return bank_costs + + def get_updated_time(manager: ABankManagerRepository) -> int | None: """ Визначення корректної дати оновлення за допомогою валідацій From 83635c4c25faba9f32444b184241e300b8a4d734 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 17 May 2023 14:15:50 +0300 Subject: [PATCH 38/80] refactored bankapi methods --- src/app/repositories/bank_api/monobank.py | 44 +++++++++++++---------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/src/app/repositories/bank_api/monobank.py b/src/app/repositories/bank_api/monobank.py index a7bc122..92c5bb5 100644 --- a/src/app/repositories/bank_api/monobank.py +++ b/src/app/repositories/bank_api/monobank.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta +from itertools import chain import aiohttp -from src.app.domain.operations import Operation from src.app.services.bank_api import ABankManagerRepository @@ -10,42 +10,48 @@ class MonobankManagerRepository(ABankManagerRepository): __bankname__ = "monobank" MAX_UPDATE_PERIOD = timedelta(30) - async def get_costs(self, from_time=None, to_time=None) -> list[Operation]: + async def get_costs(self, from_time=None, to_time=None) -> list[dict] | None: + from_time, to_time = self._normalize_time_values(from_time, to_time) + + operations = list( + chain.from_iterable(self._send_request_to_api(from_time, to_time)) + ) + if not operations: + return [] + if "errorDescription" in operations: + return None + costs = self.validate_operations(operations) + + for opeation in costs: + opeation["user_id"] = self.properties["user_id"] + opeation["bank_name"] = self.properties["bank_name"] + opeation["source_type"] = "monobank" + return costs + + def _normalize_time_values(self, from_time, to_time): + """Якщо аргументи None - створення дефолтних значень""" if not from_time: from_time = int((datetime.now() - self.MAX_UPDATE_PERIOD).timestamp()) if not to_time: to_time = int(datetime.now().timestamp()) + return from_time, to_time + async def _send_request_to_api(self, from_time, to_time) -> list[dict]: url = "https://api.monobank.ua/personal/statement/0" headers = {"X-Token": self.properties["X-Token"]} - result_operations = [] - async with aiohttp.ClientSession(headers=headers) as session: while from_time: response = await session.get(f"{url}/{from_time}/{to_time}") operations = await response.json() - if not operations: - return [] - if "errorDescription" in operations: - return None - costs = self.validate_operations(operations) - result_operations += costs + yield operations from_time = operations[-1]["time"] if len(operations) == 500 else None # В одного запиту до Monobank API обмеження - 500 операцій # Тому, якщо вийшло 500 операцій то можливо, ще є операції # В такому випадку, потрібно зробити ще один запит # Від часу остальної операцій до to_time - return [ - Operation( - **operation, - user_id=self.properties["user_id"], - source_type=self.properties["bank_name"], - ) - for operation in result_operations - ] - def validate_operations(self, operations: list[dict]) -> list[dict]: + """Фільтрація витрат та заміна мінусових значень amount на додатні""" costs = list(filter(lambda operation: operation["amount"] < 0, operations)) for cost in costs: cost["amount"] *= -1 From cc410b58cf3ad8a308bdb0c4aeae1b850dc80e37 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 17 May 2023 14:17:12 +0300 Subject: [PATCH 39/80] create get_categories_in_values method to CategoryRepository --- src/app/repositories/absctract/categories.py | 8 +++- src/app/repositories/categories.py | 25 +++++++++--- src/app/services/categories.py | 7 ++++ tests/fake_adapters/categories.py | 8 ++++ .../integrations/adapters/categories_test.py | 40 +++++++++++++++---- 5 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/app/repositories/absctract/categories.py b/src/app/repositories/absctract/categories.py index 82bf9ae..29b6c57 100644 --- a/src/app/repositories/absctract/categories.py +++ b/src/app/repositories/absctract/categories.py @@ -10,9 +10,15 @@ async def get(self, **kwargs) -> Category: raise NotImplementedError @abstractmethod - async def get_categories(self, *args) -> list[Category]: + async def _get_categories(self, *args) -> list[Category]: raise NotImplementedError @abstractmethod async def get_availables(self, user_id) -> list[Category]: raise NotImplementedError + + @abstractmethod + async def get_categories_in_values( + self, field: str, values: list + ) -> list[Category]: + raise NotImplementedError diff --git a/src/app/repositories/categories.py b/src/app/repositories/categories.py index 55b0a57..dd45827 100644 --- a/src/app/repositories/categories.py +++ b/src/app/repositories/categories.py @@ -1,3 +1,5 @@ +import json + from sqlalchemy import or_, select from src.app.domain.categories import Category @@ -9,12 +11,25 @@ class CategoryRepository(SqlAlchemyRepository, ACategoryRepository): async def get(self, **kwargs) -> Category: return await self._get(Category, **kwargs) - async def get_categories(self, *args) -> list[Category]: + async def _get_categories(self, *args) -> list[Category]: return list(await self.session.scalars(select(Category).filter(*args))) async def get_availables(self, user_id) -> list[Category]: - return list( - await self.get_categories( - or_(Category.user_id == user_id, Category.user_id == None) # noqa: E711 - ) + return await self._get_categories( + or_(Category.user_id == user_id, Category.user_id == None) # noqa: E711 ) + + async def get_categories_in_values( + self, field: str, values: list + ) -> list[Category]: + return await self._get_categories(Category.__dict__.get(field).in_(values)) + + +class CategoryMccFacade: + @staticmethod + async def get_category_name_by_mcc(mcc: int): + with open("mcc.json", encoding="utf-8") as json_file: + data = json.load(json_file) + for key, value in data: + if mcc in value: + return key diff --git a/src/app/services/categories.py b/src/app/services/categories.py index 95d8aac..04e7670 100644 --- a/src/app/services/categories.py +++ b/src/app/services/categories.py @@ -21,3 +21,10 @@ async def get_availables_categories( ) -> list[Category]: async with uow: return await uow.categories.get_availables(user_id) + + +async def get_categories_in_values( + uow: AbstractUnitOfWork, field: str, values: list +) -> list[Category]: + async with uow: + return await uow.categories.get_categories_in_values(field, values) diff --git a/tests/fake_adapters/categories.py b/tests/fake_adapters/categories.py index 7b74fd4..de9aca3 100644 --- a/tests/fake_adapters/categories.py +++ b/tests/fake_adapters/categories.py @@ -7,6 +7,9 @@ class FakeCategoryRepository(FakeRepository, ACategoryRepository): async def get(self, **kwargs) -> Category | None: return await self._get(**kwargs) + async def _get_categories(self, *args) -> list[Category]: + pass + async def get_availables(self, user_id) -> list[Category]: return list( filter( @@ -15,3 +18,8 @@ async def get_availables(self, user_id) -> list[Category]: self.instances, ) ) + + async def get_categories_in_values( + self, field: str, values: list + ) -> list[Category]: + pass diff --git a/tests/integrations/adapters/categories_test.py b/tests/integrations/adapters/categories_test.py index d8ab5da..0f57d10 100644 --- a/tests/integrations/adapters/categories_test.py +++ b/tests/integrations/adapters/categories_test.py @@ -1,25 +1,51 @@ +from random import choices +from string import ascii_lowercase, digits + import pytest +from sqlalchemy.ext.asyncio import AsyncSession from src.app.domain.categories import Category from src.app.repositories.categories import CategoryRepository from tests.patterns import create_user_with_orm +async def create_categories(session: AsyncSession, num=10, user_id=None, type="system"): + for i in range(num): + session.add( + Category( + name=f"Test {type} category #{i}-{''.join(choices(ascii_lowercase + digits, k=5))}", + user_id=user_id, + type=type, + ) + ) + await session.commit() + + @pytest.mark.asyncio async def test_create_and_read_categories(database): async with database.sessionmaker() as session: repository = CategoryRepository(session) created_user = await create_user_with_orm(session) - for i in range(10): - await repository.add( - Category( - name=f"Test category #{i}", user_id=created_user.id, type="user" - ) - ) + await create_categories(session, num=10, user_id=created_user.id, type="user") + await repository.add( - Category(name=f"Test common category", user_id=None, type="user") + Category(name=f"Test common category", user_id=None, type="system") ) await session.commit() categories = await repository.get_availables(created_user.id) assert len(categories) == 11 + + +@pytest.mark.asyncio +async def test_read_categories_in_id_list(database): + async with database.sessionmaker() as session: + repository = CategoryRepository(session) + created_user = await create_user_with_orm(session) + await create_categories(session, num=10, user_id=created_user.id, type="user") + categories = await repository.get_categories_in_values( + "id", [6, 7, 8, 9, 10, 11] + ) + # Створено лише 10 категорій, до 10 id. + # 11 id немає, тому повинно бути повернено 5 категорій. + assert len(categories) == 5 From c070f5cd6eae3173b16f4bc72ab6af4f1d8fb8c5 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 17 May 2023 14:17:45 +0300 Subject: [PATCH 40/80] refactored update_costs method --- src/app/services/bank_api.py | 40 +++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index da4c6c9..70a2698 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -6,6 +6,8 @@ ABankManagerRepository, BankManagerRepositoryFactory, ) +from src.app.repositories.categories import CategoryMccFacade +from src.app.services.categories import get_categories_in_values from src.app.services.uow.abstract import AbstractUnitOfWork @@ -63,9 +65,10 @@ async def update_banks_costs( if bank_costs: costs += bank_costs updated_managers.append(manager) - - for cost in costs: - await uow.operations.add(cost) + mcc_categories = await get_categories_id_by_mcc(uow, costs) + operations = create_operations_by_bank_costs(costs, mcc_categories) + for operation in operations: + await uow.operations.add(operation) await uow.banks_info.set_update_time_to_managers( [manager.properties["id"] for manager in updated_managers] ) @@ -80,6 +83,37 @@ async def get_costs_by_bank(manager) -> list[Operation] | None: return bank_costs +async def get_categories_id_by_mcc(uow: AbstractUnitOfWork, costs) -> dict[int, int]: + """Створення та повернення словника вигляду {mcc: category_id}""" + categories_names_dct = { + cost["mcc"]: CategoryMccFacade.get_category_name_by_mcc(cost["mcc"]) + for cost in costs + } + categories_list = await get_categories_in_values( + uow, "name", list(categories_names_dct.values()) + ) + result_dct = {} + for key, value in categories_names_dct.items(): + result_dct[key] = next( + category.id for category in categories_list if category.name == value + ) + return result_dct + + +def create_operations_by_bank_costs(costs, mcc_categories) -> list[Operation]: + return [ + Operation( + amount=cost["amount"], + description=cost["description"], + time=cost["time"], + source_type=cost["source_type"], + user_id=cost["user_id"], + category_id=mcc_categories[cost["mcc"]], + ) + for cost in costs + ] + + def get_updated_time(manager: ABankManagerRepository) -> int | None: """ Визначення корректної дати оновлення за допомогою валідацій From 8cf99f63b94634b26130fd26995f73a9e6409f7b Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 17 May 2023 14:18:16 +0300 Subject: [PATCH 41/80] refactored tests --- tests/integrations/bank_api_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integrations/bank_api_test.py b/tests/integrations/bank_api_test.py index 0086591..3645916 100644 --- a/tests/integrations/bank_api_test.py +++ b/tests/integrations/bank_api_test.py @@ -36,8 +36,8 @@ async def test_update_costs_with_banks(database: Database): managers = await get_bank_managers_by_user(uow, user_id=user.id) await update_banks_costs(uow, managers) - assert True - # Якщо не виникло помилки - тест пройдений + assert True + # Якщо не виникло помилки - тест пройдений @pytest.mark.asyncio From 13349c05c884bdbbf9a09fd6664389e44eec2071 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 17 May 2023 15:09:18 +0300 Subject: [PATCH 42/80] refactored orm adapter --- src/app/adapters/orm.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index cd6efe0..22381ee 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -82,6 +82,5 @@ def start_mappers(mapper_registry: registry, tables: dict[str, Table]): mapper_registry.map_imperatively( BankInfoProperty, tables["banks_info_properties"], - properties={"manager": relationship(BankInfo)}, ) mapper_registry.map_imperatively(Category, tables["categories"]) From 63cbe732b731a5d68f65afe45397546959581c5a Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 17 May 2023 15:09:39 +0300 Subject: [PATCH 43/80] created migrations --- migration/versions/725589cc0adb_categories.py | 45 +++++++++++++++++++ migration/versions/f2d154c7bc55_.py | 27 +++++++++++ 2 files changed, 72 insertions(+) create mode 100644 migration/versions/725589cc0adb_categories.py create mode 100644 migration/versions/f2d154c7bc55_.py diff --git a/migration/versions/725589cc0adb_categories.py b/migration/versions/725589cc0adb_categories.py new file mode 100644 index 0000000..8c6a0ed --- /dev/null +++ b/migration/versions/725589cc0adb_categories.py @@ -0,0 +1,45 @@ +"""categories + +Revision ID: 725589cc0adb +Revises: 79110a6de7e7 +Create Date: 2023-05-10 11:00:59.952790 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "725589cc0adb" +down_revision = "79110a6de7e7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "categories", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column("operations", sa.Column("category_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "operations", "categories", ["category_id"], ["id"]) + op.drop_column("operations", "mcc") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "operations", sa.Column("mcc", sa.INTEGER(), autoincrement=False, nullable=True) + ) + op.drop_constraint(None, "operations", type_="foreignkey") + op.drop_column("operations", "category_id") + op.drop_table("categories") + # ### end Alembic commands ### diff --git a/migration/versions/f2d154c7bc55_.py b/migration/versions/f2d154c7bc55_.py new file mode 100644 index 0000000..f9c00eb --- /dev/null +++ b/migration/versions/f2d154c7bc55_.py @@ -0,0 +1,27 @@ +"""empty message + +Revision ID: f2d154c7bc55 +Revises: 725589cc0adb +Create Date: 2023-05-17 14:49:41.201539 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f2d154c7bc55" +down_revision = "725589cc0adb" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("categories", sa.Column("type", sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("categories", "type") + # ### end Alembic commands ### From 294c6f23c502aee5bfc91e4c720442c73d278bbd Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 17 May 2023 15:10:10 +0300 Subject: [PATCH 44/80] added mcc.json --- mcc.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 mcc.json diff --git a/mcc.json b/mcc.json new file mode 100644 index 0000000..aa01fb9 --- /dev/null +++ b/mcc.json @@ -0,0 +1 @@ +{"Ветеринарні послуги": [742], "Виноробники": [743], "Виробники шампанського": [744], "Сільскогосподарські кооперативи": [763], "Садівництво. Ландшафтний дизайн": [780], "Будівництво. Підрядники": [1520], "Кондиціонери. Встановлення": [1711], "Електрики": [1731], "Будівники. Облицювання": [1740, 1761], "Столярні роботи": [1750], "Будівництво. Бетон": [1771], "Спеціалізовані підрядники": [1799], "Друкарська справа": [2741, 2744], "Набір текстів та друк": [2791], "Спеціалізоване прибирання": [2842], "Авіалінії": [3000, 3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3010, 3011, 3012, 3013, 3014, 3015, 3016, 3017, 3018, 3019, 3020, 3021, 3022, 3023, 3024, 3025, 3026, 3027, 3028, 3029, 3030, 3031, 3032, 3033, 3034, 3035, 3036, 3037, 3038, 3039, 3040, 3041, 3042, 3043, 3044, 3045, 3046, 3047, 3048, 3049, 3050, 3051, 3052, 3053, 3054, 3055, 3056, 3057, 3058, 3059, 3060, 3061, 3062, 3063, 3064, 3065, 3066, 3067, 3068, 3069, 3070, 3071, 3072, 3073, 3074, 3075, 3076, 3077, 3078, 3079, 3080, 3081, 3082, 3083, 3084, 3085, 3086, 3087, 3088, 3089, 3090, 3091, 3092, 3093, 3094, 3095, 3096, 3097, 3098, 3099, 3100, 3101, 3102, 3103, 3104, 3105, 3106, 3107, 3108, 3109, 3110, 3111, 3112, 3113, 3114, 3115, 3116, 3117, 3118, 3119, 3120, 3121, 3122, 3123, 3124, 3125, 3126, 3127, 3128, 3129, 3130, 3131, 3132, 3133, 3134, 3135, 3136, 3137, 3138, 3139, 3140, 3141, 3142, 3143, 3144, 3145, 3146, 3147, 3148, 3149, 3150, 3151, 3152, 3153, 3154, 3155, 3156, 3157, 3158, 3159, 3160, 3161, 3162, 3163, 3164, 3165, 3166, 3167, 3168, 3169, 3170, 3171, 3172, 3173, 3174, 3175, 3176, 3177, 3178, 3179, 3180, 3181, 3182, 3183, 3184, 3185, 3186, 3187, 3188, 3189, 3190, 3191, 3192, 3193, 3194, 3195, 3196, 3197, 3198, 3199, 3200, 3201, 3202, 3203, 3204, 3205, 3206, 3207, 3208, 3209, 3210, 3211, 3212, 3213, 3214, 3215, 3216, 3217, 3218, 3219, 3220, 3221, 3222, 3223, 3224, 3225, 3226, 3227, 3228, 3229, 3230, 3231, 3232, 3233, 3234, 3235, 3236, 3237, 3238, 3239, 3240, 3241, 3242, 3243, 3244, 3245, 3246, 3247, 3248, 3249, 3250, 3251, 3252, 3253, 3254, 3255, 3256, 3257, 3258, 3259, 3260, 3261, 3262, 3263, 3264, 3265, 3266, 3267, 3268, 3269, 3270, 3271, 3272, 3273, 3274, 3275, 3276, 3277, 3278, 3279, 3280, 3281, 3282, 3283, 3284, 3285, 3286, 3287, 3288, 3289, 3290, 3291, 3292, 3293, 3294, 3295, 3296, 3297, 3298, 3299, 3300, 3301, 3302, 4511], "Оренда автомобілів": [3351, 3352, 3353, 3354, 3355, 3356, 3357, 3358, 3359, 3360, 3361, 3362, 3363, 3364, 3365, 3366, 3367, 3368, 3369, 3370, 3371, 3372, 3373, 3374, 3375, 3376, 3377, 3378, 3379, 3380, 3381, 3382, 3383, 3384, 3385, 3386, 3387, 3388, 3389, 3390, 3391, 3392, 3393, 3394, 3395, 3396, 3397, 3398, 3399, 3400, 3401, 3402, 3403, 3404, 3405, 3406, 3407, 3408, 3409, 3410, 3411, 3412, 3413, 3414, 3415, 3416, 3417, 3418, 3419, 3420, 3421, 3422, 3423, 3424, 3425, 3426, 3427, 3428, 3429, 3430, 3431, 3432, 3433, 3434, 3435, 3436, 3437, 3438, 3439, 3440, 3441, 7512, 7519], "Готелі та курорти": [3501, 3502, 3503, 3504, 3505, 3506, 3507, 3508, 3509, 3510, 3511, 3512, 3513, 3514, 3515, 3516, 3517, 3518, 3519, 3520, 3521, 3522, 3523, 3524, 3525, 3526, 3527, 3528, 3529, 3530, 3531, 3532, 3533, 3534, 3535, 3536, 3537, 3538, 3539, 3540, 3541, 3542, 3543, 3544, 3545, 3546, 3547, 3548, 3549, 3550, 3551, 3552, 3553, 3554, 3555, 3556, 3557, 3558, 3559, 3560, 3561, 3562, 3563, 3564, 3565, 3566, 3567, 3568, 3569, 3570, 3571, 3572, 3573, 3574, 3575, 3576, 3577, 3578, 3579, 3580, 3581, 3582, 3583, 3584, 3585, 3586, 3587, 3588, 3589, 3590, 3591, 3592, 3593, 3594, 3595, 3596, 3597, 3598, 3599, 3600, 3601, 3602, 3603, 3604, 3605, 3606, 3607, 3608, 3609, 3610, 3611, 3612, 3613, 3614, 3615, 3616, 3617, 3618, 3619, 3620, 3621, 3622, 3623, 3624, 3625, 3626, 3627, 3628, 3629, 3630, 3631, 3632, 3633, 3634, 3635, 3636, 3637, 3638, 3639, 3640, 3641, 3642, 3643, 3644, 3645, 3646, 3647, 3648, 3649, 3650, 3651, 3652, 3653, 3654, 3655, 3656, 3657, 3658, 3659, 3660, 3661, 3662, 3663, 3664, 3665, 3666, 3667, 3668, 3669, 3670, 3671, 3672, 3673, 3674, 3675, 3676, 3677, 3678, 3679, 3680, 3681, 3682, 3683, 3684, 3685, 3686, 3687, 3688, 3689, 3690, 3691, 3692, 3693, 3694, 3695, 3696, 3697, 3698, 3699, 3700, 3701, 3702, 3703, 3704, 3705, 3706, 3707, 3708, 3709, 3710, 3711, 3712, 3713, 3714, 3715, 3716, 3717, 3718, 3719, 3720, 3721, 3722, 3723, 3724, 3725, 3726, 3727, 3728, 3729, 3730, 3731, 3732, 3733, 3734, 3735, 3736, 3737, 3738, 3739, 3740, 3741, 3742, 3743, 3744, 3745, 3746, 3747, 3748, 3749, 3750, 3751, 3752, 3753, 3754, 3755, 3756, 3757, 3758, 3759, 3760, 3761, 3762, 3763, 3764, 3765, 3766, 3767, 3768, 3769, 3770, 3771, 3772, 3773, 3774, 3775, 3776, 3777, 3778, 3779, 3780, 3781, 3782, 3783, 3784, 3785, 3786, 3787, 3788, 3789, 3790, 3791, 3792, 3793, 3794, 3795, 3796, 3797, 3798, 3799, 3800, 3801, 3802, 3803, 3804, 3805, 3806, 3807, 3808, 3809, 3810, 3811, 3812, 3813, 3814, 3815, 3816, 3817, 3818, 3819, 3820, 3821, 3822, 3823, 3824, 3825, 3826, 3827, 3828, 3829, 3830, 3831, 3832, 3833, 3834, 3835, 3836, 3837, 3838, 7011], "Переведення в готівку": [3882], "Залізниця": [4011, 4789], "Пасажирські перевезення": [4111], "Пасажирські залізничні перевезення": [4112], "Швидка допомога": [4119], "Таксі": [4121], "Перевезення. Автобус": [4131], "Транспортування. Доставка": [4214], "Кур'єрська служба": [4215], "Сховище": [4225], "Різне": [4304, 4785, 5292, 5295, 5415, 5999, 7299, 8664, 9999], "Круїзні лінії": [4411], "Оренда суден": [4457], "Яхтинговий сервіс": [4468], "Аеропорти": [4582], "Туризм": [4722, 7991], "Туроператори": [4723], "Транспортні послуги": [4729], "Телемаркетинг": [4761], "Платні дороги": [4784], "Телекомунікаційне обладнання": [4812], "Торгові точки з телефонією": [4813], "Мобільний зв'язок": [4814], "Телефонні послуги": [4815], "Інформаційні послуги": [4816, 5967, 7375], "Телеграф": [4821], "Переказ коштів": [4829, 6531, 6532, 6533, 6534, 6535, 6536, 6537, 6538, 6539, 6540, 6611], "Телебачення": [4899], "Комунальні послуги": [4900], "Автозапчастини": [5013, 5531], "Меблі": [5021], "Будматеріали": [5039, 5211], "Офісне приладдя": [5044], "Комп'ютери та програмне забезпечення": [5045], "Обладнання": [5046], "Медичне обладнання": [5047], "Обробка металу": [5051], "Електроніка": [5065], "Апаратура": [5072], "Сантехніка": [5074], "Промисловість": [5085], "Дорогоцінності": [5094], "Товари": [5099, 5199], "Канцелярія": [5111], "Ліки": [5122], "Галантерея": [5131], "Одяг": [5137, 5651, 5691], "Взуття": [5139, 5661], "Хімія": [5169], "Бензин": [5172], "Книги. Преса": [5192], "Квіти": [5193], "Фарби": [5198], "Товари для дому": [5200], "Ремонт": [5231, 7699], "Господарських товари": [5251], "Садове приладдя": [5261], "Маркетплейси": [5262], "Будинки на колесах": [5271], "Роздрібні магазини": [5297, 5298], "Продаж газу": [5299], "Оптовики": [5300], "Duty Free": [5309], "Дискаунтери": [5310], "Універмаги": [5311], "Універсальні магазини": [5331, 5399], "Продукти": [5411, 5499], "М'ясо": [5422], "Солодощі": [5441], "Фермерські товари": [5451], "Пекарні": [5462], "Автосалони": [5511, 5521, 5561, 5571, 5592, 5598, 5599], "Шини": [5532], "Автомагазини": [5533], "СТО": [5541, 7538], "АЗС": [5542], "Човни": [5551], "Зарядка електромобілів": [5552], "Чоловічий одяг": [5611], "Жіночий одяг": [5621, 5631], "Дитячий одяг": [5641], "Спортивний одяг": [5655], "Хутро": [5681], "Ательє": [5697], "Перуки": [5698], "Аксесуари": [5699], "Фурнітура": [5712, 5719], "Покриття для підлоги": [5713], "Штори. Фіранки": [5714], "Спиртні напої": [5715, 5921], "Каміни": [5718], "Побутова техніка": [5722, 5732], "Музичні інструменти": [5733], "Комп'ютерне ПЗ": [5734], "Магазини звукозапису": [5735], "Доставка їжі": [5811], "Кафе. Ресторани": [5812], "Бари": [5813], "Фаст-фуд": [5814], "Цифрові товари": [5815, 5818], "Ігри": [5816], "Програмне забезпечення": [5817], "Антикваріат": [5832, 5932], "Аптеки": [5912], "Секонд-хенд": [5931], "Ломбарди": [5933], "Автозвалище": [5935], "Магазини репродукцій": [5937], "Велосипеди": [5940], "Товари для спорту": [5941], "Книгарні": [5942], "Канцтовари": [5943], "Годинники": [5944], "Іграшки": [5945], "Фототовари": [5946], "Сувеніри": [5947], "Шкіряні вироби": [5948], "Товари для шиття": [5949], "Кришталь / посуд": [5950], "Страхування": [5960, 6300, 6381, 6399], "Товари поштою": [5961, 5964, 5965, 5966, 5969], "Подорожі": [5962], "Комівояжери": [5963], "Підписки": [5968], "Художні товари": [5970], "Галереї": [5971], "Філателістика": [5972], "Церковні лавки": [5973], "Штампи": [5974], "Слухові апарати": [5975], "Протези": [5976], "Косметика": [5977], "Друкарські машини": [5978], "Паливо": [5983], "Флористика": [5992], "Тютюнові вироби": [5993], "Газети. Журнали": [5994], "Зоотовари": [5995], "Басейни": [5996], "Бритви": [5997], "Тенти": [5998], "Каси": [6010, 6011], "Фінансові послуги": [6012], "Банки": [6022, 6023, 6025, 6026, 6028], "Квазі-кеш": [6050, 6051], "Цінні папери": [6211, 6236], "Оренда нерухомості": [6513], "Поповнення картки": [6529, 6530], "Облігації": [6760], "Тайм-шери": [7012], "Рекреація": [7032], "Кемпінг": [7033], "Прання й прибирання": [7210], "Прання": [7211], "Хімчистка": [7216], "Чистка": [7217], "Фотостудія": [7221], "Краса": [7230], "Ремонт одягу": [7251], "Ритуальні послуги": [7261], "Ескорт": [7272], "Знайомства. Ескорт": [7273], "Податки": [7276, 9311], "Консультація": [7277], "Покупки": [7278], "Лікарні": [7280, 8062], "Няні": [7295], "Прокат одягу": [7296], "Масаж": [7297], "Здоров'я й краса": [7298], "Реклама": [7311], "БКІ": [7321], "Колектори": [7322], "Копі-центри": [7332, 7338], "Фотографія й мистецтво": [7333], "Стенографія": [7339], "Дезінфекція": [7342], "Чистка й обслуговування": [7349], "Працевлаштування": [7361], "Програмування": [7372], "Ремонт комп'ютерів": [7379], "Бізнес послуги": [7389, 7399], "Консультації, PR": [7392], "Детективні агентства": [7393], "Оренда спорядження": [7394], "Фотодрук": [7395], "Паркування": [7511, 7523, 7524], "Оренда вантажівок": [7513], "Ремонт авто": [7531], "Шиномонтаж": [7534], "Автомобільні фарби": [7535], "Автомийки": [7542], "Евакуатор": [7549], "Ремонт техніки": [7622, 7629], "Ремонт кліматичної техніки": [7623], "Ремонт годинників й ювелірних виробів": [7631], "Ремонт меблів": [7641], "Зравювальні роботи": [7692], "Державна лотерея": [7800, 9406], "Онлайн-казино": [7801], "Скачки": [7802, 9754], "Прокат відео": [7829, 7841], "Кінотеатри": [7832, 7833], "Танцювальні студії. Школи танцю": [7911], "Квитки": [7922], "Музичні групи. Оркестри": [7929], "Більярд": [7932], "Боулінг-клуби": [7933], "Спортклуби": [7941], "Гольф": [7992], "Відеоігри": [7993, 7994], "Азартні ігри": [7995], "Розваги": [7996], "Розваги та спорт": [7997, 7999], "Акваріуми. Дельфінарії": [7998], "Медецина": [8011, 8031], "Стоматологія": [8021], "Хіропрактики": [8041], "Оптика": [8042, 8043, 8044], "Ортопеди": [8049], "Доглядальниці": [8050], "Медицина та стоматологія": [8071], "Медичні послуги": [8099], "Юристи. Адвокати": [8110, 8111], "Школа": [8211], "Освіта. Університет": [8220], "Дистанційна освіта": [8241], "Освіта. Бізнес": [8244], "Освіта": [8249, 8299], "Дитячий садок": [8351], "Благодійність": [8398], "Громадські організації": [8641], "Політичні організації": [8651], "Релігійні організації": [8661], "Автоклуб": [8675], "Членські організації": [8699], "Випробувальні лабораторії": [8734, 8743], "Архітектори": [8911], "Бухгалтерія. Аудит": [8931], "Професійні послуги": [8999], "I-Purchasing Pilot": [9034, 9401], "Суд": [9211], "Штрафи": [9222], "Виплати. Облігації": [9223], "Державні послуги": [9399, 9411], "Пошта": [9402], "Урядові закупівлі": [9405], "Кешбек": [9700], "VISA": [9701], "Аварійні служби": [9702], "Документообіг": [9751, 9752], "Купівлі всередині компанії": [9950]} From 3141af6993bcf39fcaf6c4be0a2a90da78220de5 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 18 May 2023 14:58:10 +0300 Subject: [PATCH 45/80] changed type return value from get_costs --- src/app/repositories/absctract/bank_api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/repositories/absctract/bank_api.py b/src/app/repositories/absctract/bank_api.py index 6317ee2..94b10ad 100644 --- a/src/app/repositories/absctract/bank_api.py +++ b/src/app/repositories/absctract/bank_api.py @@ -16,7 +16,6 @@ from abc import ABC, abstractmethod from src.app.domain.bank_api import BankInfo -from src.app.domain.operations import Operation from src.app.repositories.absctract.base import AbstractRepository @@ -52,7 +51,7 @@ def __init__(self, properties=None): self.properties = properties if properties else {} @abstractmethod - async def get_costs(self, from_time=None, to_time=None) -> list[Operation]: + async def get_costs(self, from_time=None, to_time=None) -> list[dict]: raise NotImplementedError From b56c4d45c149081eec9c711ceaed1111e9075f9d Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 18 May 2023 14:58:51 +0300 Subject: [PATCH 46/80] created and moved categories and mcc json files --- mcc.json => src/app/repositories/mcc.json | 0 tests/fake_adapters/costs.json | 135 ++++++++++++++++++++++ 2 files changed, 135 insertions(+) rename mcc.json => src/app/repositories/mcc.json (100%) create mode 100644 tests/fake_adapters/costs.json diff --git a/mcc.json b/src/app/repositories/mcc.json similarity index 100% rename from mcc.json rename to src/app/repositories/mcc.json diff --git a/tests/fake_adapters/costs.json b/tests/fake_adapters/costs.json new file mode 100644 index 0000000..1406e5c --- /dev/null +++ b/tests/fake_adapters/costs.json @@ -0,0 +1,135 @@ +[ + { + "id": "3fOqulIUyAIw6vyy", + "time": 1684323686, + "description": "YouTube", + "mcc": 5815, + "originalMcc": 5817, + "amount": -14900, + "operationAmount": -14900, + "currencyCode": 980, + "commissionRate": 0, + "cashbackAmount": 0, + "balance": 332638, + "hold": true, + "receiptId": "3A9X-B5E8-P7BA-M4E5" + }, + { + "id": "IuQB06uVl4ikUgFL", + "time": 1684314396, + "description": "Delikat", + "mcc": 5499, + "originalMcc": 5499, + "amount": -20800, + "operationAmount": -20800, + "currencyCode": 980, + "commissionRate": 0, + "cashbackAmount": 208, + "balance": 347538, + "hold": true, + "receiptId": "CKA1-3T0E-2KEX-TT7K" + }, + { + "id": "c1GgAnA4AOWC3knF", + "time": 1684078935, + "description": "Delikat", + "mcc": 5499, + "originalMcc": 5499, + "amount": -14500, + "operationAmount": -14500, + "currencyCode": 980, + "commissionRate": 0, + "cashbackAmount": 145, + "balance": 368338, + "hold": false, + "receiptId": "2X65-K6P4-A031-6H1A" + }, + { + "id": "BrYWJNcBaDJAsJJa", + "time": 1684073790, + "description": "Delikat", + "mcc": 5499, + "originalMcc": 5499, + "amount": -1800, + "operationAmount": -1800, + "currencyCode": 980, + "commissionRate": 0, + "cashbackAmount": 18, + "balance": 382838, + "hold": false, + "receiptId": "HX74-HPK4-6M33-T09T" + }, + { + "id": "e9tqdLIj7EiOHbeg", + "time": 1683992722, + "description": "Delikat", + "mcc": 5499, + "originalMcc": 5499, + "amount": -4000, + "operationAmount": -4000, + "currencyCode": 980, + "commissionRate": 0, + "cashbackAmount": 40, + "balance": 384638, + "hold": false, + "receiptId": "4K59-698T-X2PP-TKH1" + }, + { + "id": "vJGn4rMGQ0HFPqN5", + "time": 1683960035, + "description": "Delikat", + "mcc": 5499, + "originalMcc": 5499, + "amount": -9350, + "operationAmount": -9350, + "currencyCode": 980, + "commissionRate": 0, + "cashbackAmount": 0, + "balance": 388638, + "hold": false, + "receiptId": "BTCK-9A4B-9BA9-9E32" + }, + { + "id": "5ZDcRVdZrJaT2JrD", + "time": 1683902977, + "description": "Delikat", + "mcc": 5499, + "originalMcc": 5499, + "amount": -3000, + "operationAmount": -3000, + "currencyCode": 980, + "commissionRate": 0, + "cashbackAmount": 0, + "balance": 397988, + "hold": false, + "receiptId": "0885-P7EX-575E-E326" + }, + { + "id": "FwgugfFV3yAfKWAk", + "time": 1683275166, + "description": "Переказ на картку", + "mcc": 4829, + "originalMcc": 4829, + "amount": -100, + "operationAmount": -100, + "currencyCode": 980, + "commissionRate": 0, + "cashbackAmount": 0, + "balance": 988, + "hold": true + }, + { + "id": "WjpEo4QWPQfCR8oQ", + "time": 1683274878, + "description": "Переказ на картку", + "mcc": 4829, + "originalMcc": 4829, + "amount": -100, + "operationAmount": -100, + "currencyCode": 980, + "commissionRate": 0, + "cashbackAmount": 0, + "balance": 1088, + "hold": true + } +] From 23214ffb597e67284527fce7b7f343fdbef7bb6f Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 18 May 2023 14:59:44 +0300 Subject: [PATCH 47/80] updated fake adapters for new tests --- tests/fake_adapters/bank_api.py | 35 +++++++++++++++++++++++++++++++ tests/fake_adapters/bank_info.py | 22 +++++++++++++++++++ tests/fake_adapters/categories.py | 6 +++++- tests/fake_adapters/uow.py | 2 ++ tests/unit/bank_api_test.py | 32 ++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/fake_adapters/bank_api.py create mode 100644 tests/fake_adapters/bank_info.py create mode 100644 tests/unit/bank_api_test.py diff --git a/tests/fake_adapters/bank_api.py b/tests/fake_adapters/bank_api.py new file mode 100644 index 0000000..ac5d10d --- /dev/null +++ b/tests/fake_adapters/bank_api.py @@ -0,0 +1,35 @@ +import json +import os +from datetime import datetime, timedelta + +from src.app.repositories.absctract.bank_api import ABankManagerRepository + + +class FakeBankManagerRepository(ABankManagerRepository): + __bankname__ = "fake_bank" + MAX_UPDATE_PERIOD = ( + datetime.now() - datetime.fromtimestamp(1683274878) + timedelta(days=1) + ) # На 1 день раніше найстарішої операції в cost.json + + async def get_costs(self, from_time=None, to_time=None) -> list[dict]: + if not from_time: + from_time = int((datetime.now() - self.MAX_UPDATE_PERIOD).timestamp()) + if not to_time: + to_time = int(datetime.now().timestamp()) + + costs = self._get_costs_by_json() + filtered_costs = list( + filter(lambda cost: from_time < cost["time"] < to_time, costs) + ) + + for cost in filtered_costs: + cost["user_id"] = self.properties["user_id"] + cost["bank_name"] = self.__bankname__ + cost["source_type"] = "fake_bank" + return filtered_costs + + def _get_costs_by_json(self): + dirname = os.path.dirname(__file__) + filename = os.path.join(dirname, "costs.json") + with open(filename, encoding="utf-8") as f: + return json.load(f) diff --git a/tests/fake_adapters/bank_info.py b/tests/fake_adapters/bank_info.py new file mode 100644 index 0000000..5ddd2c5 --- /dev/null +++ b/tests/fake_adapters/bank_info.py @@ -0,0 +1,22 @@ +from src.app.domain.bank_api import BankInfo +from src.app.repositories.absctract.bank_api import ABankInfoRepository +from tests.fake_adapters.base import FakeRepository + + +class FakeBankInfoRepository(FakeRepository, ABankInfoRepository): + async def get(self, **kwargs): + pass + + async def get_all_by_user(self, user_id) -> list[BankInfo]: + pass + + async def get_properties(self, manager: BankInfo) -> dict[str, str | int | float]: + pass + + async def add_property( + self, manager: BankInfo, name: str, value: str | int | float + ): + pass + + async def set_update_time_to_managers(self, ids: list[int]): + pass diff --git a/tests/fake_adapters/categories.py b/tests/fake_adapters/categories.py index de9aca3..52efec2 100644 --- a/tests/fake_adapters/categories.py +++ b/tests/fake_adapters/categories.py @@ -22,4 +22,8 @@ async def get_availables(self, user_id) -> list[Category]: async def get_categories_in_values( self, field: str, values: list ) -> list[Category]: - pass + return list( + filter( + lambda category: category.__dict__.get(field) in values, self.instances + ) + ) diff --git a/tests/fake_adapters/uow.py b/tests/fake_adapters/uow.py index d691109..869cda4 100644 --- a/tests/fake_adapters/uow.py +++ b/tests/fake_adapters/uow.py @@ -1,4 +1,5 @@ from src.app.services.uow.abstract import AbstractUnitOfWork +from tests.fake_adapters.bank_info import FakeBankInfoRepository from tests.fake_adapters.categories import FakeCategoryRepository from tests.fake_adapters.operations import FakeOperationRepository from tests.fake_adapters.users import FakeUserRepository @@ -9,6 +10,7 @@ def __init__(self): self.users = FakeUserRepository() self.operations = FakeOperationRepository() self.categories = FakeCategoryRepository() + self.banks_info = FakeBankInfoRepository() async def __aenter__(self): return self diff --git a/tests/unit/bank_api_test.py b/tests/unit/bank_api_test.py new file mode 100644 index 0000000..cbda5eb --- /dev/null +++ b/tests/unit/bank_api_test.py @@ -0,0 +1,32 @@ +import json + +import pytest + +from src.app.domain.categories import Category +from src.app.services.bank_api import get_costs_by_bank, update_banks_costs +from tests.fake_adapters.bank_api import FakeBankManagerRepository +from tests.fake_adapters.uow import FakeUnitOfWork + + +def set_mcc_categories_to_repo(uow: FakeUnitOfWork): + with open("../../src/app/repositories/mcc.json", encoding="utf-8") as json_file: + data = json.load(json_file) + uow.categories.instances = [ + Category(name=key, type="mcc", user_id=None) for key in data.keys() + ] + + +@pytest.mark.asyncio +async def test_get_costs(): + repo = FakeBankManagerRepository(properties={"id": 1, "user_id": 1}) + costs = await get_costs_by_bank(repo) + assert len(costs) == 9 + + +@pytest.mark.asyncio +async def test_update_costs(): + uow = FakeUnitOfWork() + set_mcc_categories_to_repo(uow) + repo = FakeBankManagerRepository(properties={"id": 1, "user_id": 1}) + await update_banks_costs(uow, [repo]) + assert len(uow.operations.instances) == 9 From c7a0f06750cb6a3ccfd46d2b4bf6e98406b52020 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 18 May 2023 15:00:05 +0300 Subject: [PATCH 48/80] refactored send request to api method --- src/app/repositories/bank_api/monobank.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/repositories/bank_api/monobank.py b/src/app/repositories/bank_api/monobank.py index 92c5bb5..5d90321 100644 --- a/src/app/repositories/bank_api/monobank.py +++ b/src/app/repositories/bank_api/monobank.py @@ -12,9 +12,8 @@ class MonobankManagerRepository(ABankManagerRepository): async def get_costs(self, from_time=None, to_time=None) -> list[dict] | None: from_time, to_time = self._normalize_time_values(from_time, to_time) - operations = list( - chain.from_iterable(self._send_request_to_api(from_time, to_time)) + chain.from_iterable(await self._send_request_to_api(from_time, to_time)) ) if not operations: return [] @@ -39,16 +38,19 @@ def _normalize_time_values(self, from_time, to_time): async def _send_request_to_api(self, from_time, to_time) -> list[dict]: url = "https://api.monobank.ua/personal/statement/0" headers = {"X-Token": self.properties["X-Token"]} + res_operations = [] async with aiohttp.ClientSession(headers=headers) as session: while from_time: response = await session.get(f"{url}/{from_time}/{to_time}") - operations = await response.json() - yield operations - from_time = operations[-1]["time"] if len(operations) == 500 else None + res_operations.append(await response.json()) + from_time = ( + res_operations[-1]["time"] if len(res_operations) == 500 else None + ) # В одного запиту до Monobank API обмеження - 500 операцій # Тому, якщо вийшло 500 операцій то можливо, ще є операції # В такому випадку, потрібно зробити ще один запит # Від часу остальної операцій до to_time + return res_operations def validate_operations(self, operations: list[dict]) -> list[dict]: """Фільтрація витрат та заміна мінусових значень amount на додатні""" From 5faf5564b26b2ad93303ed6f443469d272dfb2b0 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 18 May 2023 15:00:45 +0300 Subject: [PATCH 49/80] refactored get category name by mcc method --- src/app/repositories/categories.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/repositories/categories.py b/src/app/repositories/categories.py index dd45827..a074809 100644 --- a/src/app/repositories/categories.py +++ b/src/app/repositories/categories.py @@ -1,4 +1,5 @@ import json +import os from sqlalchemy import or_, select @@ -27,9 +28,11 @@ async def get_categories_in_values( class CategoryMccFacade: @staticmethod - async def get_category_name_by_mcc(mcc: int): - with open("mcc.json", encoding="utf-8") as json_file: + def get_category_name_by_mcc(mcc: int): + dirname = os.path.dirname(__file__) + filename = os.path.join(dirname, "mcc.json") + with open(filename, encoding="utf-8") as json_file: data = json.load(json_file) - for key, value in data: + for key, value in data.items(): if mcc in value: return key From 66d2d8b79767e7bb2ea4d6eed50c321edc6f557f Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 18 May 2023 15:01:26 +0300 Subject: [PATCH 50/80] changed return value from update costs endpoint --- src/routers/bank_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routers/bank_api.py b/src/routers/bank_api.py index 1022a89..3ce9529 100644 --- a/src/routers/bank_api.py +++ b/src/routers/bank_api.py @@ -29,7 +29,7 @@ async def update_costs_view( ): managers = await get_bank_managers_by_user(uow, user_id=current_user.id) await update_banks_costs(uow, managers) - return + return {"status": "ok"} @router.get("/list/", status_code=200) From 14dee2e54c6f9919986e33e351f70f8889d06ad8 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 18 May 2023 15:06:02 +0300 Subject: [PATCH 51/80] changed file path in test depend --- tests/unit/bank_api_test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/bank_api_test.py b/tests/unit/bank_api_test.py index cbda5eb..7ae476c 100644 --- a/tests/unit/bank_api_test.py +++ b/tests/unit/bank_api_test.py @@ -1,4 +1,5 @@ import json +import os import pytest @@ -9,7 +10,10 @@ def set_mcc_categories_to_repo(uow: FakeUnitOfWork): - with open("../../src/app/repositories/mcc.json", encoding="utf-8") as json_file: + dirname = os.path.dirname(__file__) + with open( + dirname + "\\..\\..\\src\\app\\repositories\\mcc.json", encoding="utf-8" + ) as json_file: data = json.load(json_file) uow.categories.instances = [ Category(name=key, type="mcc", user_id=None) for key in data.keys() From a390a36396c42fff2eec71b7a04d4b13cf894c68 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 18 May 2023 15:17:27 +0300 Subject: [PATCH 52/80] changed file path in test depend --- tests/unit/bank_api_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/bank_api_test.py b/tests/unit/bank_api_test.py index 7ae476c..9f06a1f 100644 --- a/tests/unit/bank_api_test.py +++ b/tests/unit/bank_api_test.py @@ -1,5 +1,6 @@ import json import os +from pathlib import Path import pytest @@ -10,9 +11,9 @@ def set_mcc_categories_to_repo(uow: FakeUnitOfWork): - dirname = os.path.dirname(__file__) + root_dir = Path(__file__).parent.parent.parent with open( - dirname + "\\..\\..\\src\\app\\repositories\\mcc.json", encoding="utf-8" + root_dir / "src" / "app" / "repositories" / "mcc.json", encoding="utf-8" ) as json_file: data = json.load(json_file) uow.categories.instances = [ From 4bf1f2d235ed77b5deff6a3548877c02bf494424 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 18 May 2023 15:56:31 +0300 Subject: [PATCH 53/80] fixed incorrect default time initialization --- src/routers/operations.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/routers/operations.py b/src/routers/operations.py index 50a9ac7..fc7dc97 100644 --- a/src/routers/operations.py +++ b/src/routers/operations.py @@ -48,8 +48,10 @@ async def read_operations_view( :param to_time: по який час в unix форматі :return: Operation list """ - from_time = from_time if from_time else int(datetime.today().timestamp()) - to_time = ( - to_time if to_time else int((datetime.today() + timedelta(days=1)).timestamp()) + from_time = ( + from_time + if from_time + else int((datetime.now() - timedelta(minutes=1)).timestamp()) ) + to_time = to_time if to_time else int(datetime.now().timestamp()) return await get_operations(uow, current_user.id, from_time, to_time) From 6968c06f10b7ae2228b47afa3859281020f41008 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 25 May 2023 22:33:01 +0300 Subject: [PATCH 54/80] added new field to statistic --- src/app/domain/statistic.py | 1 + src/app/services/statistic.py | 12 ++++++++++++ src/schemas/statistic.py | 1 + 3 files changed, 14 insertions(+) diff --git a/src/app/domain/statistic.py b/src/app/domain/statistic.py index 7123bcb..2117016 100644 --- a/src/app/domain/statistic.py +++ b/src/app/domain/statistic.py @@ -6,3 +6,4 @@ class Statistic: costs_sum: int categories_costs: dict[int, int] costs_num_by_days: dict[str, int] + costs_sum_by_days: dict[str, int] diff --git a/src/app/services/statistic.py b/src/app/services/statistic.py index 80377c2..c9cd40d 100644 --- a/src/app/services/statistic.py +++ b/src/app/services/statistic.py @@ -8,6 +8,8 @@ def get_statistic(operations: list[Operation]) -> Statistic: """ Створення статистики витрат користувача + :param from_time: timestamp початку проміжка + :param to_time: timestamp кінця проміжка :param operations: Список операцій :return: Statistic """ @@ -15,6 +17,7 @@ def get_statistic(operations: list[Operation]) -> Statistic: costs_sum=get_costs_sum(operations), categories_costs=get_categories_costs(operations), costs_num_by_days=get_costs_num_by_days(operations), + costs_sum_by_days=get_costs_sum_by_days(operations), ) return statistic @@ -45,3 +48,12 @@ def get_costs_num_by_days(operations: list[Operation]) -> dict[str, int]: datetime.fromtimestamp(operation.time).strftime("%Y-%m-%d") ] += 1 return dict(costs_num_by_days) + + +def get_costs_sum_by_days(operations: list[Operation]) -> dict[str, int]: + costs_num_by_days = Counter() + for operation in operations: + costs_num_by_days[ + datetime.fromtimestamp(operation.time).strftime("%Y-%m-%d") + ] += operation.amount + return dict(costs_num_by_days) diff --git a/src/schemas/statistic.py b/src/schemas/statistic.py index f2fef97..77bd5bd 100644 --- a/src/schemas/statistic.py +++ b/src/schemas/statistic.py @@ -7,3 +7,4 @@ class StatisticSchema(BaseModel): costs_sum: int categories_costs: dict[int | None, int] costs_num_by_days: dict[str, int] + costs_sum_by_days: dict[str, int] From 1500a5783fb89506eede57ed3bdc894cf034e98b Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 1 Jun 2023 11:40:06 +0300 Subject: [PATCH 55/80] added icon fields to category entity --- src/app/adapters/orm.py | 7 +++---- src/app/domain/categories.py | 12 +++++++++++- src/schemas/categories.py | 2 ++ src/schemas/users.py | 6 ++++++ tests/patterns.py | 1 - 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 22381ee..4072328 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -58,6 +58,8 @@ def create_tables(mapper_registry) -> dict[str, Table]: Column("name", String, nullable=False), Column("user_id", Integer, ForeignKey("users.id"), nullable=True), Column("type", String, default="system"), + Column("icon_name", String, nullable=True, default=None), + Column("icon_color", String, nullable=True, default=None), ), } @@ -79,8 +81,5 @@ def start_mappers(mapper_registry: registry, tables: dict[str, Table]): tables["banks_info"], properties={"properties": relationship(BankInfoProperty)}, ) - mapper_registry.map_imperatively( - BankInfoProperty, - tables["banks_info_properties"], - ) + mapper_registry.map_imperatively(BankInfoProperty, tables["banks_info_properties"]) mapper_registry.map_imperatively(Category, tables["categories"]) diff --git a/src/app/domain/categories.py b/src/app/domain/categories.py index 5c7e452..9bdda4d 100644 --- a/src/app/domain/categories.py +++ b/src/app/domain/categories.py @@ -3,11 +3,21 @@ class Category: name: str user_id: int | None type: str + icon_name: str | None + icon_color: str | None def __init__( - self, name: str, user_id: int | None, type: str, id: int | None = None + self, + name: str, + user_id: int | None, + type: str, + id: int | None = None, + icon_name: str | None = None, + icon_color: str | None = None, ): self.id = id self.name = name self.user_id = user_id self.type = type + self.icon_name = icon_name + self.icon_color = icon_color diff --git a/src/schemas/categories.py b/src/schemas/categories.py index 5eddf5c..2e5b214 100644 --- a/src/schemas/categories.py +++ b/src/schemas/categories.py @@ -9,6 +9,8 @@ class CategorySchema(CategoryCreateSchema): id: int user_id: int | None type: str + icon_name: str | None + icon_color: str | None class Config: orm_mode = True diff --git a/src/schemas/users.py b/src/schemas/users.py index 69a3403..b600b9e 100644 --- a/src/schemas/users.py +++ b/src/schemas/users.py @@ -18,8 +18,14 @@ class UserCreateSchema(UserBaseSchema): password: str + class Config: + orm_mode = True + class UserSchema(UserBaseSchema): """Class used for read user info""" id: int + + class Config: + orm_mode = True diff --git a/tests/patterns.py b/tests/patterns.py index f24b44e..a641a1e 100644 --- a/tests/patterns.py +++ b/tests/patterns.py @@ -8,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.app.domain.users import User -from src.app.services.uow.abstract import AbstractUnitOfWork from src.app.services.uow.sqlalchemy import SqlAlchemyUnitOfWork from src.app.services.users import create_user from src.schemas.users import UserCreateSchema From 6a208f93cf31a448e233daa318f8c66bb2d93737 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 1 Jun 2023 11:42:05 +0300 Subject: [PATCH 56/80] created docker configs --- Dockerfile | 11 +++++++++++ requirements.txt | Bin 3218 -> 3264 bytes 2 files changed, 11 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5c2327 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.10 + +ENV PYTHONUNBUFFERED 1 + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install -r requirements.txt --default-timeout=100 + +COPY . ./ + +EXPOSE 8000 diff --git a/requirements.txt b/requirements.txt index 11cc0783516737d11c8163482fc59970e75b11e4..8060e57dd272475470331c1e22115fb9c46b5f95 100644 GIT binary patch delta 55 zcmbOvc|dZ57ms!ULoq`oLo!1?LjglNgAs!+LlQ$KLmopSLlIEi76^?P^cXCG*le;p Gk2e5imJ3G! delta 11 ScmX>gIZ1MZ7tiDh9uEK-3j^u^ From b06a640d0eb1b20c08933e3b5c5383302b2a547b Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 1 Jun 2023 11:42:46 +0300 Subject: [PATCH 57/80] created a category fields migration --- migration/versions/804ce544203d_.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 migration/versions/804ce544203d_.py diff --git a/migration/versions/804ce544203d_.py b/migration/versions/804ce544203d_.py new file mode 100644 index 0000000..3f2fc74 --- /dev/null +++ b/migration/versions/804ce544203d_.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: 804ce544203d +Revises: f2d154c7bc55 +Create Date: 2023-06-01 11:36:35.436792 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "804ce544203d" +down_revision = "f2d154c7bc55" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("categories", sa.Column("icon_name", sa.String(), nullable=True)) + op.add_column("categories", sa.Column("icon_color", sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("categories", "icon_color") + op.drop_column("categories", "icon_name") + # ### end Alembic commands ### From c40fd8a0266b9d9cadd14c0757f8ae2610d78ebd Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 11:49:20 +0300 Subject: [PATCH 58/80] add a parent_id field to category entity --- src/app/adapters/orm.py | 1 + src/app/domain/categories.py | 2 ++ src/schemas/categories.py | 1 + 3 files changed, 4 insertions(+) diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 4072328..315611f 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -60,6 +60,7 @@ def create_tables(mapper_registry) -> dict[str, Table]: Column("type", String, default="system"), Column("icon_name", String, nullable=True, default=None), Column("icon_color", String, nullable=True, default=None), + Column("parent_id", Integer, ForeignKey("categories.id"), nullable=True), ), } diff --git a/src/app/domain/categories.py b/src/app/domain/categories.py index 9bdda4d..d333a84 100644 --- a/src/app/domain/categories.py +++ b/src/app/domain/categories.py @@ -14,6 +14,7 @@ def __init__( id: int | None = None, icon_name: str | None = None, icon_color: str | None = None, + parent_id: int | None = None, ): self.id = id self.name = name @@ -21,3 +22,4 @@ def __init__( self.type = type self.icon_name = icon_name self.icon_color = icon_color + self.parent_id = parent_id diff --git a/src/schemas/categories.py b/src/schemas/categories.py index 2e5b214..951910a 100644 --- a/src/schemas/categories.py +++ b/src/schemas/categories.py @@ -11,6 +11,7 @@ class CategorySchema(CategoryCreateSchema): type: str icon_name: str | None icon_color: str | None + parent_id: int | None class Config: orm_mode = True From d59b8e5928dbb174400d1d3436eca1b49430711f Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 11:50:16 +0300 Subject: [PATCH 59/80] add a subcategory_id field to Operation entity --- src/app/domain/operations.py | 2 ++ src/app/services/operations.py | 15 ++++++++++++++- src/schemas/operations.py | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/domain/operations.py b/src/app/domain/operations.py index 58fd561..d98a047 100644 --- a/src/app/domain/operations.py +++ b/src/app/domain/operations.py @@ -7,6 +7,7 @@ def __init__( source_type: str, user_id: int, category_id: int | None = None, + subcategory_id: int | None = None, id: int = None, **kwargs, ): @@ -17,3 +18,4 @@ def __init__( self.user_id = user_id self.id = id self.category_id = category_id + self.subcategory_id = subcategory_id diff --git a/src/app/services/operations.py b/src/app/services/operations.py index 2375a0b..7103956 100644 --- a/src/app/services/operations.py +++ b/src/app/services/operations.py @@ -35,4 +35,17 @@ async def get_operations( :return: список об'єктів моделі Opetation. Якщо операцій немає, то пустий список """ async with uow: - return await uow.operations.get_all_by_user(user_id, from_time, to_time) + categories = await uow.categories.get_availables(user_id) + operations = await uow.operations.get_all_by_user(user_id, from_time, to_time) + for operation in operations: + # Якщо категорія операції - підкатегорія, + # то на category_id назначається батьківська + category = next( + category + for category in categories + if category.id == operation.category_id + ) + if category.parent_id: + operation.category_id = category.parent_id + operation.subcategory_id = category.id + return operations diff --git a/src/schemas/operations.py b/src/schemas/operations.py index 856bb33..c552b8a 100644 --- a/src/schemas/operations.py +++ b/src/schemas/operations.py @@ -18,6 +18,7 @@ class OperationSchema(OperationCreateSchema): """Схема операції, яка виокристовується під час завантаження даних з БД""" id: int + subcategory_id: int | None class Config: orm_mode = True From 27b5bf6fef7ab974d7ba4869c6cea5af91edb339 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 11:50:32 +0300 Subject: [PATCH 60/80] refactor code --- src/app/services/statistic.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/services/statistic.py b/src/app/services/statistic.py index c9cd40d..188662b 100644 --- a/src/app/services/statistic.py +++ b/src/app/services/statistic.py @@ -8,8 +8,6 @@ def get_statistic(operations: list[Operation]) -> Statistic: """ Створення статистики витрат користувача - :param from_time: timestamp початку проміжка - :param to_time: timestamp кінця проміжка :param operations: Список операцій :return: Statistic """ @@ -30,6 +28,7 @@ def get_costs_sum(operations: list[Operation]) -> int: def get_categories_costs(operations: list[Operation]) -> dict[int, int]: """Словник у форматі {<категорія>: <сума витрат по категорії>}""" categories_id = [operation.category_id for operation in operations] + return { category_id: sum( operation.amount From 2c64a770d40687b469490c19088fc1e07599c3fd Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 11:51:11 +0300 Subject: [PATCH 61/80] create subcategories migration --- migration/versions/0c6eed52399f_.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 migration/versions/0c6eed52399f_.py diff --git a/migration/versions/0c6eed52399f_.py b/migration/versions/0c6eed52399f_.py new file mode 100644 index 0000000..eade1d8 --- /dev/null +++ b/migration/versions/0c6eed52399f_.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: 0c6eed52399f +Revises: 804ce544203d +Create Date: 2023-06-11 10:12:39.465349 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0c6eed52399f" +down_revision = "804ce544203d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("categories", sa.Column("parent_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "categories", "categories", ["parent_id"], ["id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "categories", type_="foreignkey") + op.drop_column("categories", "parent_id") + # ### end Alembic commands ### From 08581af0ac3c7186433997154c21f04f7a1d69c3 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 12:30:12 +0300 Subject: [PATCH 62/80] disable echo from db engine --- src/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database.py b/src/database.py index 7ab566b..1cb2619 100644 --- a/src/database.py +++ b/src/database.py @@ -43,7 +43,7 @@ class Database: """ def __init__(self, mapper_registry, test: bool = False): - self.engine = create_async_engine(_get_url(test), echo=True) + self.engine = create_async_engine(_get_url(test)) self.sessionmaker = async_sessionmaker(self.engine, expire_on_commit=False) self.mapper_registry = mapper_registry From 1d00cba44ae0c1ac72e6448b12f433d5792a2ddc Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 13:11:34 +0300 Subject: [PATCH 63/80] refactor the get_operations method for tests --- src/app/services/operations.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/app/services/operations.py b/src/app/services/operations.py index 7103956..2e61869 100644 --- a/src/app/services/operations.py +++ b/src/app/services/operations.py @@ -40,12 +40,13 @@ async def get_operations( for operation in operations: # Якщо категорія операції - підкатегорія, # то на category_id назначається батьківська - category = next( - category - for category in categories - if category.id == operation.category_id - ) - if category.parent_id: - operation.category_id = category.parent_id - operation.subcategory_id = category.id + if operation.category_id: + category = next( + category + for category in categories + if category.id == operation.category_id + ) + if category.parent_id: + operation.category_id = category.parent_id + operation.subcategory_id = category.id return operations From 44e2b30bf06414914ecd0ed2ec836204c1e1aadf Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 15:07:59 +0300 Subject: [PATCH 64/80] add delete method to bank info repository --- src/app/repositories/absctract/bank_api.py | 4 ++++ src/app/repositories/bank_api/bank_info.py | 14 +++++++++++--- src/routers/bank_api.py | 10 ++++++++++ tests/fake_adapters/bank_info.py | 3 +++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/app/repositories/absctract/bank_api.py b/src/app/repositories/absctract/bank_api.py index 94b10ad..07b2a59 100644 --- a/src/app/repositories/absctract/bank_api.py +++ b/src/app/repositories/absctract/bank_api.py @@ -42,6 +42,10 @@ async def add_property( async def set_update_time_to_managers(self, ids: list[int]): raise NotImplementedError + @abstractmethod + async def delete(self, user_id: int, bank_name: str): + raise NotImplementedError + class ABankManagerRepository(ABC): __bankname__ = None diff --git a/src/app/repositories/bank_api/bank_info.py b/src/app/repositories/bank_api/bank_info.py index a1831a3..15ae4c5 100644 --- a/src/app/repositories/bank_api/bank_info.py +++ b/src/app/repositories/bank_api/bank_info.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import select, update +from sqlalchemy import delete, select, update from sqlalchemy.orm import subqueryload from src.app.domain.bank_api import BankInfo, BankInfoProperty @@ -9,8 +9,8 @@ class BankInfoRepository(SqlAlchemyRepository, ABankInfoRepository): - async def get(self, field, value) -> BankInfo: - return await self._get(BankInfo, field, value) + async def get(self, **kwargs) -> BankInfo: + return await self._get(BankInfo, **kwargs) async def get_all_by_user(self, user_id): return list( @@ -55,3 +55,11 @@ async def set_update_time_to_managers(self, ids: list[int]): ) .values(prop_value=str(int(datetime.now().timestamp()))) ) + + async def delete(self, user_id: int, bank_name: str): + bank = await self.get(user_id=user_id, bank_name=bank_name) + # Видалення властивостей банку + await self.session.execute( + delete(BankInfoProperty).filter_by(manager_id=bank.id) + ) + await self.session.execute(delete(BankInfo).filter_by(id=bank.id)) diff --git a/src/routers/bank_api.py b/src/routers/bank_api.py index 3ce9529..42ee31d 100644 --- a/src/routers/bank_api.py +++ b/src/routers/bank_api.py @@ -40,3 +40,13 @@ async def get_connected_banks_names( 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) +async def delete_bank( + bank_name: str, + current_user: User = Depends(get_current_user), + uow: AbstractUnitOfWork = Depends(get_uow), +): + await uow.banks_info.delete(current_user.id, bank_name) + await uow.commit() diff --git a/tests/fake_adapters/bank_info.py b/tests/fake_adapters/bank_info.py index 5ddd2c5..034ee45 100644 --- a/tests/fake_adapters/bank_info.py +++ b/tests/fake_adapters/bank_info.py @@ -20,3 +20,6 @@ async def add_property( async def set_update_time_to_managers(self, ids: list[int]): pass + + async def delete(self, user_id: int, bank_name: str): + pass From 2dbe09f30f56f0636beed5603a300914feaff55f Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 15:14:16 +0300 Subject: [PATCH 65/80] refactor orm columns --- src/app/adapters/orm.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 315611f..385e553 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -80,7 +80,10 @@ def start_mappers(mapper_registry: registry, tables: dict[str, Table]): mapper_registry.map_imperatively( BankInfo, tables["banks_info"], - properties={"properties": relationship(BankInfoProperty)}, + properties={"properties": relationship(BankInfoProperty, backref="manager")}, + ) + mapper_registry.map_imperatively( + BankInfoProperty, + tables["banks_info_properties"], ) - mapper_registry.map_imperatively(BankInfoProperty, tables["banks_info_properties"]) mapper_registry.map_imperatively(Category, tables["categories"]) From 14f552f9f128300f9ba400eb94bb1c9c7b9c7215 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 16:50:04 +0300 Subject: [PATCH 66/80] create limits structure --- src/app/adapters/orm.py | 8 ++++++++ src/app/domain/limits.py | 14 +++++++++++++ src/app/repositories/absctract/limits.py | 13 ++++++++++++ src/app/repositories/limits.py | 14 +++++++++++++ src/app/services/limits.py | 26 ++++++++++++++++++++++++ src/app/services/uow/abstract.py | 2 ++ src/app/services/uow/sqlalchemy.py | 2 ++ src/schemas/limits.py | 14 +++++++++++++ 8 files changed, 93 insertions(+) create mode 100644 src/app/domain/limits.py create mode 100644 src/app/repositories/absctract/limits.py create mode 100644 src/app/repositories/limits.py create mode 100644 src/app/services/limits.py create mode 100644 src/schemas/limits.py diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 315611f..17fad88 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -62,6 +62,14 @@ def create_tables(mapper_registry) -> dict[str, Table]: Column("icon_color", String, nullable=True, default=None), Column("parent_id", Integer, ForeignKey("categories.id"), nullable=True), ), + "limits": Table( + "limits", + mapper_registry.metadata, + Column("id", Integer, primary_key=True), + Column("category_id", Integer, ForeignKey("categories.id"), nullable=True), + Column("limit", Integer), + Column("date_range", String), + ), } diff --git a/src/app/domain/limits.py b/src/app/domain/limits.py new file mode 100644 index 0000000..f871500 --- /dev/null +++ b/src/app/domain/limits.py @@ -0,0 +1,14 @@ +class Limit: + def __init__( + self, + user_id: int, + category_id: int, + limit: int, + date_range: str, + id: int | None = None, + ): + self.user_id = user_id + self.category_id = (category_id,) + self.limit = (limit,) + self.date_range = date_range + self.id = id diff --git a/src/app/repositories/absctract/limits.py b/src/app/repositories/absctract/limits.py new file mode 100644 index 0000000..5084090 --- /dev/null +++ b/src/app/repositories/absctract/limits.py @@ -0,0 +1,13 @@ +from abc import abstractmethod + +from src.app.repositories.absctract.base import AbstractRepository + + +class ALimitRepository(AbstractRepository): + @abstractmethod + async def get_all(self, user_id: int): + raise NotImplementedError + + @abstractmethod + async def delete(self, limit_id: int): + raise NotImplementedError diff --git a/src/app/repositories/limits.py b/src/app/repositories/limits.py new file mode 100644 index 0000000..fb659d0 --- /dev/null +++ b/src/app/repositories/limits.py @@ -0,0 +1,14 @@ +from src.app.domain.limits import Limit +from src.app.repositories.absctract.limits import ALimitRepository +from src.app.repositories.sqlalchemy import SqlAlchemyRepository + + +class LimitRepository(SqlAlchemyRepository, ALimitRepository): + async def get(self, **kwargs): + return self._get(Limit, **kwargs) + + async def get_all(self, user_id: int): + pass + + async def delete(self, limit_id: int): + pass diff --git a/src/app/services/limits.py b/src/app/services/limits.py new file mode 100644 index 0000000..4f4480b --- /dev/null +++ b/src/app/services/limits.py @@ -0,0 +1,26 @@ +from src.app.domain.limits import Limit +from src.app.services.uow.abstract import AbstractUnitOfWork +from src.schemas.limits import LimitCreateSchema + + +async def create_limit( + uow: AbstractUnitOfWork, user_id: int, schema: LimitCreateSchema +) -> Limit | None: + """ + Створення та добавлення в БД сутності Limit + :param uow: Unit Of Work + :param user_id: ID користувача + :param schema: LimitCreateSchema + :return: Limit якщо user_id існує в базі. None, якщо ні + """ + async with uow: + limit = Limit(**schema.dict(), user_id=user_id) + await uow.limits.add(limit) + await uow.commit() + return limit + + +async def get_limits(uow: AbstractUnitOfWork, user_id: int) -> list[Limit]: + async with uow: + limits = await uow.limits.get_all(user_id) + return limits diff --git a/src/app/services/uow/abstract.py b/src/app/services/uow/abstract.py index ce2ee1e..fdf491f 100644 --- a/src/app/services/uow/abstract.py +++ b/src/app/services/uow/abstract.py @@ -12,6 +12,7 @@ from src.app.repositories.absctract.bank_api import ABankInfoRepository from src.app.repositories.absctract.categories import ACategoryRepository +from src.app.repositories.absctract.limits import ALimitRepository from src.app.repositories.absctract.operations import AOperationRepository from src.app.repositories.absctract.users import AUserRepository @@ -21,6 +22,7 @@ class AbstractUnitOfWork(ABC): operations: AOperationRepository banks_info: ABankInfoRepository categories: ACategoryRepository + limits: ALimitRepository async def __aenter__(self): return self diff --git a/src/app/services/uow/sqlalchemy.py b/src/app/services/uow/sqlalchemy.py index b63e3c4..58fac8f 100644 --- a/src/app/services/uow/sqlalchemy.py +++ b/src/app/services/uow/sqlalchemy.py @@ -2,6 +2,7 @@ from src.app.repositories.bank_api.bank_info import BankInfoRepository from src.app.repositories.categories import CategoryRepository +from src.app.repositories.limits import LimitRepository from src.app.repositories.operations import OperationRepository from src.app.repositories.users import UserRepository from src.app.services.uow.abstract import AbstractUnitOfWork @@ -16,6 +17,7 @@ async def __aenter__(self): self.operations = OperationRepository(self.session) self.banks_info = BankInfoRepository(self.session) self.categories = CategoryRepository(self.session) + self.limits = LimitRepository(self.session) return await super().__aenter__() async def __aexit__(self, exc_type, exc_val, exc_tb): diff --git a/src/schemas/limits.py b/src/schemas/limits.py new file mode 100644 index 0000000..921eb42 --- /dev/null +++ b/src/schemas/limits.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class LimitCreateSchema(BaseModel): + category_id: int + limit: int + date_range: str + + +class LimitSchema(LimitCreateSchema): + id: int + + class Config: + orm_mode = True From a90cae096021477b481d847ccf2edcffa2a9d067 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 15:07:59 +0300 Subject: [PATCH 67/80] add delete method to bank info repository --- src/app/repositories/absctract/bank_api.py | 4 ++++ src/app/repositories/bank_api/bank_info.py | 14 +++++++++++--- src/routers/bank_api.py | 10 ++++++++++ tests/fake_adapters/bank_info.py | 3 +++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/app/repositories/absctract/bank_api.py b/src/app/repositories/absctract/bank_api.py index 94b10ad..07b2a59 100644 --- a/src/app/repositories/absctract/bank_api.py +++ b/src/app/repositories/absctract/bank_api.py @@ -42,6 +42,10 @@ async def add_property( async def set_update_time_to_managers(self, ids: list[int]): raise NotImplementedError + @abstractmethod + async def delete(self, user_id: int, bank_name: str): + raise NotImplementedError + class ABankManagerRepository(ABC): __bankname__ = None diff --git a/src/app/repositories/bank_api/bank_info.py b/src/app/repositories/bank_api/bank_info.py index a1831a3..15ae4c5 100644 --- a/src/app/repositories/bank_api/bank_info.py +++ b/src/app/repositories/bank_api/bank_info.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import select, update +from sqlalchemy import delete, select, update from sqlalchemy.orm import subqueryload from src.app.domain.bank_api import BankInfo, BankInfoProperty @@ -9,8 +9,8 @@ class BankInfoRepository(SqlAlchemyRepository, ABankInfoRepository): - async def get(self, field, value) -> BankInfo: - return await self._get(BankInfo, field, value) + async def get(self, **kwargs) -> BankInfo: + return await self._get(BankInfo, **kwargs) async def get_all_by_user(self, user_id): return list( @@ -55,3 +55,11 @@ async def set_update_time_to_managers(self, ids: list[int]): ) .values(prop_value=str(int(datetime.now().timestamp()))) ) + + async def delete(self, user_id: int, bank_name: str): + bank = await self.get(user_id=user_id, bank_name=bank_name) + # Видалення властивостей банку + await self.session.execute( + delete(BankInfoProperty).filter_by(manager_id=bank.id) + ) + await self.session.execute(delete(BankInfo).filter_by(id=bank.id)) diff --git a/src/routers/bank_api.py b/src/routers/bank_api.py index 3ce9529..42ee31d 100644 --- a/src/routers/bank_api.py +++ b/src/routers/bank_api.py @@ -40,3 +40,13 @@ async def get_connected_banks_names( 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) +async def delete_bank( + bank_name: str, + current_user: User = Depends(get_current_user), + uow: AbstractUnitOfWork = Depends(get_uow), +): + await uow.banks_info.delete(current_user.id, bank_name) + await uow.commit() diff --git a/tests/fake_adapters/bank_info.py b/tests/fake_adapters/bank_info.py index 5ddd2c5..034ee45 100644 --- a/tests/fake_adapters/bank_info.py +++ b/tests/fake_adapters/bank_info.py @@ -20,3 +20,6 @@ async def add_property( async def set_update_time_to_managers(self, ids: list[int]): pass + + async def delete(self, user_id: int, bank_name: str): + pass From 93b8e60644fafa89e691620f4cf4038f4f66185c Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 15:14:16 +0300 Subject: [PATCH 68/80] refactor orm columns --- src/app/adapters/orm.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 17fad88..427dc87 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -88,7 +88,10 @@ def start_mappers(mapper_registry: registry, tables: dict[str, Table]): mapper_registry.map_imperatively( BankInfo, tables["banks_info"], - properties={"properties": relationship(BankInfoProperty)}, + properties={"properties": relationship(BankInfoProperty, backref="manager")}, + ) + mapper_registry.map_imperatively( + BankInfoProperty, + tables["banks_info_properties"], ) - mapper_registry.map_imperatively(BankInfoProperty, tables["banks_info_properties"]) mapper_registry.map_imperatively(Category, tables["categories"]) From afb9ee80abff2a083a73bb3307df4f5776c4c8be Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Sun, 11 Jun 2023 23:30:51 +0300 Subject: [PATCH 69/80] upgrade limits structure --- migration/versions/48d00b4868c7_.py | 29 ++++++++++++++++++++++ migration/versions/8db0b3c70d3d_.py | 38 +++++++++++++++++++++++++++++ src/app/adapters/orm.py | 3 +++ src/app/domain/limits.py | 4 +-- src/app/repositories/limits.py | 6 +++-- src/app/services/limits.py | 8 ++++-- src/main.py | 2 ++ src/routers/limits.py | 36 +++++++++++++++++++++++++++ 8 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 migration/versions/48d00b4868c7_.py create mode 100644 migration/versions/8db0b3c70d3d_.py create mode 100644 src/routers/limits.py diff --git a/migration/versions/48d00b4868c7_.py b/migration/versions/48d00b4868c7_.py new file mode 100644 index 0000000..a68ef63 --- /dev/null +++ b/migration/versions/48d00b4868c7_.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: 48d00b4868c7 +Revises: 8db0b3c70d3d +Create Date: 2023-06-11 17:47:49.545536 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "48d00b4868c7" +down_revision = "8db0b3c70d3d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("limits", sa.Column("user_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "limits", "users", ["user_id"], ["id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "limits", type_="foreignkey") + op.drop_column("limits", "user_id") + # ### end Alembic commands ### diff --git a/migration/versions/8db0b3c70d3d_.py b/migration/versions/8db0b3c70d3d_.py new file mode 100644 index 0000000..b4d0a79 --- /dev/null +++ b/migration/versions/8db0b3c70d3d_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 8db0b3c70d3d +Revises: 0c6eed52399f +Create Date: 2023-06-11 17:45:07.399501 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8db0b3c70d3d" +down_revision = "0c6eed52399f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "limits", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("category_id", sa.Integer(), nullable=True), + sa.Column("limit", sa.Integer(), nullable=True), + sa.Column("date_range", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["category_id"], + ["categories.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("limits") + # ### end Alembic commands ### diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index 427dc87..4f0bfa4 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -7,6 +7,7 @@ from src.app.domain.bank_api import BankInfo, BankInfoProperty from src.app.domain.categories import Category +from src.app.domain.limits import Limit from src.app.domain.operations import Operation from src.app.domain.users import User @@ -66,6 +67,7 @@ def create_tables(mapper_registry) -> dict[str, Table]: "limits", mapper_registry.metadata, Column("id", Integer, primary_key=True), + Column("user_id", Integer, ForeignKey("users.id")), Column("category_id", Integer, ForeignKey("categories.id"), nullable=True), Column("limit", Integer), Column("date_range", String), @@ -95,3 +97,4 @@ def start_mappers(mapper_registry: registry, tables: dict[str, Table]): tables["banks_info_properties"], ) mapper_registry.map_imperatively(Category, tables["categories"]) + mapper_registry.map_imperatively(Limit, tables["limits"]) diff --git a/src/app/domain/limits.py b/src/app/domain/limits.py index f871500..8690561 100644 --- a/src/app/domain/limits.py +++ b/src/app/domain/limits.py @@ -8,7 +8,7 @@ def __init__( id: int | None = None, ): self.user_id = user_id - self.category_id = (category_id,) - self.limit = (limit,) + self.category_id = category_id + self.limit = limit self.date_range = date_range self.id = id diff --git a/src/app/repositories/limits.py b/src/app/repositories/limits.py index fb659d0..6ff33be 100644 --- a/src/app/repositories/limits.py +++ b/src/app/repositories/limits.py @@ -1,3 +1,5 @@ +from sqlalchemy import delete, select + from src.app.domain.limits import Limit from src.app.repositories.absctract.limits import ALimitRepository from src.app.repositories.sqlalchemy import SqlAlchemyRepository @@ -8,7 +10,7 @@ async def get(self, **kwargs): return self._get(Limit, **kwargs) async def get_all(self, user_id: int): - pass + return await self.session.scalars(select(Limit).filter_by(user_id=user_id)) async def delete(self, limit_id: int): - pass + await self.session.execute(delete(Limit).filter_by(id=limit_id)) diff --git a/src/app/services/limits.py b/src/app/services/limits.py index 4f4480b..cd4b627 100644 --- a/src/app/services/limits.py +++ b/src/app/services/limits.py @@ -14,7 +14,7 @@ async def create_limit( :return: Limit якщо user_id існує в базі. None, якщо ні """ async with uow: - limit = Limit(**schema.dict(), user_id=user_id) + limit = Limit(user_id, **schema.dict()) await uow.limits.add(limit) await uow.commit() return limit @@ -23,4 +23,8 @@ async def create_limit( async def get_limits(uow: AbstractUnitOfWork, user_id: int) -> list[Limit]: async with uow: limits = await uow.limits.get_all(user_id) - return limits + return list(limits) + + +async def delete_limit(uow: AbstractUnitOfWork, limit_id: int): + await uow.limits.delete(limit_id) diff --git a/src/main.py b/src/main.py index a295de8..20f3f82 100644 --- a/src/main.py +++ b/src/main.py @@ -23,6 +23,7 @@ def include_routers(fastapi_app): 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; + from src.routers.limits import router as limits_router from src.routers.operations import router as operations_router # noqa: E402; from src.routers.statistic import router as statistic_router # noqa: E402; from src.routers.users import router as users_router # noqa: E402; @@ -33,6 +34,7 @@ def include_routers(fastapi_app): fastapi_app.include_router(bankapi_router, tags=["bankapi"]) fastapi_app.include_router(statistic_router, tags=["statistic"]) fastapi_app.include_router(categories_router, tags=["categories"]) + fastapi_app.include_router(limits_router, tags=["limits"]) app = bootstrap_fastapi_app() diff --git a/src/routers/limits.py b/src/routers/limits.py new file mode 100644 index 0000000..d059515 --- /dev/null +++ b/src/routers/limits.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, Depends + +from src.app.domain.users import User +from src.app.services.limits import create_limit, delete_limit, get_limits +from src.app.services.uow.abstract import AbstractUnitOfWork +from src.depends import get_current_user, get_uow +from src.schemas.limits import LimitCreateSchema, LimitSchema + +router = APIRouter(prefix="/limits") + + +@router.post("/create/", response_model=LimitSchema, status_code=201) +async def create_limit_view( + limit_schema: LimitCreateSchema, + current_user: User = Depends(get_current_user), + uow: AbstractUnitOfWork = Depends(get_uow), +): + return await create_limit(uow, current_user.id, limit_schema) + + +@router.get("/list/", response_model=list[LimitSchema]) +async def read_limits_view( + current_user: User = Depends(get_current_user), + uow: AbstractUnitOfWork = Depends(get_uow), +): + return await get_limits(uow, current_user.id) + + +@router.delete("/delete/", status_code=204) +async def delete_limit_view( + limit_id: int, + current_user: User = Depends(get_current_user), + uow: AbstractUnitOfWork = Depends(get_uow), +): + await delete_limit(uow, limit_id) + await uow.commit() From 5cf906578708d90c35148d0436484ae7a7ee917b Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 12 Jun 2023 01:33:51 +0300 Subject: [PATCH 70/80] create fake limits repository to tests --- tests/fake_adapters/limits.py | 13 +++++++++++++ tests/fake_adapters/uow.py | 2 ++ 2 files changed, 15 insertions(+) create mode 100644 tests/fake_adapters/limits.py diff --git a/tests/fake_adapters/limits.py b/tests/fake_adapters/limits.py new file mode 100644 index 0000000..e9e7c72 --- /dev/null +++ b/tests/fake_adapters/limits.py @@ -0,0 +1,13 @@ +from src.app.repositories.absctract.limits import ALimitRepository +from tests.fake_adapters.base import FakeRepository + + +class FakeLimitRepository(FakeRepository, ALimitRepository): + async def get(self, **kwargs): + pass + + async def get_all(self, user_id: int): + return [] + + async def delete(self, limit_id: int): + pass diff --git a/tests/fake_adapters/uow.py b/tests/fake_adapters/uow.py index 869cda4..fa88c23 100644 --- a/tests/fake_adapters/uow.py +++ b/tests/fake_adapters/uow.py @@ -1,6 +1,7 @@ from src.app.services.uow.abstract import AbstractUnitOfWork from tests.fake_adapters.bank_info import FakeBankInfoRepository from tests.fake_adapters.categories import FakeCategoryRepository +from tests.fake_adapters.limits import FakeLimitRepository from tests.fake_adapters.operations import FakeOperationRepository from tests.fake_adapters.users import FakeUserRepository @@ -11,6 +12,7 @@ def __init__(self): self.operations = FakeOperationRepository() self.categories = FakeCategoryRepository() self.banks_info = FakeBankInfoRepository() + self.limits = FakeLimitRepository() async def __aenter__(self): return self From 34804b9934b2008c6ae434002cfa870eac1ecf87 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 12 Jun 2023 01:34:28 +0300 Subject: [PATCH 71/80] create exceeded limits algorithm --- src/app/domain/operations.py | 2 + src/app/services/operations.py | 107 ++++++++++++++++++++++++++++++--- src/schemas/operations.py | 1 + 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/src/app/domain/operations.py b/src/app/domain/operations.py index d98a047..2e5e103 100644 --- a/src/app/domain/operations.py +++ b/src/app/domain/operations.py @@ -8,6 +8,7 @@ def __init__( user_id: int, category_id: int | None = None, subcategory_id: int | None = None, + is_exceeded_limit: bool = False, id: int = None, **kwargs, ): @@ -19,3 +20,4 @@ def __init__( self.id = id self.category_id = category_id self.subcategory_id = subcategory_id + self.is_exceeded_limit = is_exceeded_limit diff --git a/src/app/services/operations.py b/src/app/services/operations.py index 2e61869..cf52572 100644 --- a/src/app/services/operations.py +++ b/src/app/services/operations.py @@ -1,6 +1,10 @@ +from collections import defaultdict from datetime import datetime +from src.app.domain.categories import Category +from src.app.domain.limits import Limit from src.app.domain.operations import Operation +from src.app.services.statistic import get_categories_costs from src.app.services.uow.abstract import AbstractUnitOfWork from src.schemas.operations import OperationCreateSchema @@ -36,17 +40,102 @@ async def get_operations( """ async with uow: categories = await uow.categories.get_availables(user_id) + limits = await uow.limits.get_all(user_id) operations = await uow.operations.get_all_by_user(user_id, from_time, to_time) for operation in operations: # Якщо категорія операції - підкатегорія, # то на category_id назначається батьківська - if operation.category_id: - category = next( - category - for category in categories - if category.id == operation.category_id - ) - if category.parent_id: - operation.category_id = category.parent_id - operation.subcategory_id = category.id + operation = set_subcategory_to_operation(operation, categories) + exceeded_limits = get_exceeded_limits(operations, limits, from_time, to_time) + operations = [ + set_exceeded_limit_prop(exceeded_limits, operation) + for operation in operations + ] return operations + + +def set_subcategory_to_operation( + operation: Operation, categories: list[Category] +) -> Operation: + if operation.category_id: + category = next( + category for category in categories if category.id == operation.category_id + ) + if category.parent_id: + operation.category_id = category.parent_id + operation.subcategory_id = category.id + return operation + + +def get_exceeded_limits( + operations: list[Operation], limits: list[Limit], from_time=None, to_time=None +) -> dict[datetime, list[int]]: + """ + Формування та списку категорій, які перевищили обмеження + :param operations: список операцій + :param limits: список обмежень + :param from_time: з якого часу + :param to_time: по який час + :return: список перевищених лімітів + """ + # from_time - Початок первого місяця періоду + from_time = datetime.fromtimestamp(from_time).replace(day=1) + to_time = datetime.fromtimestamp(to_time) + # to_time - Первий день наступного місяця після остального місяця періоду + to_time.replace(day=1, month=to_time.month + 1) + # Список початків місяців у періоді + months_list = get_months_list(from_time, to_time) + # Список операцій у кожному місяці + operations_by_months = get_operations_by_months(operations, months_list) + operations_categories_sum_by_months = { + key: get_categories_costs(value) for key, value in operations_by_months.items() + } + exceeded_limits = defaultdict(list) + for start_of_month, categories_costs in operations_categories_sum_by_months.items(): + for ( + category_id, + category_costs_sum, + ) in categories_costs.items(): + limit = list(limit for limit in limits if limit.category_id == category_id) + if limit and category_costs_sum > limit[0].limit: + exceeded_limits[start_of_month].append(category_id) + return dict(exceeded_limits) + + +def get_months_list( + from_time: datetime = None, to_time: datetime = None +) -> list[datetime]: + months = [] + time = from_time + while time < to_time: + months.append(time) + time = time.replace(month=time.month + 1) + return months + + +def get_operations_by_months( + operations: list[Operation], months: list[datetime] +) -> dict[datetime, list[Operation]]: + result = {} + for start_of_month in months: + end_of_month = start_of_month.replace(month=start_of_month.month + 1) + result[start_of_month] = [ + operation + for operation in operations + if start_of_month.timestamp() < operation.time < end_of_month.timestamp() + ] + return result + + +def set_exceeded_limit_prop( + exceeded_limits: dict[datetime, list[int]], operation: Operation +) -> Operation: + operation_time = datetime.fromtimestamp(operation.time) + for start_of_month, categories in exceeded_limits.items(): + end_of_month = start_of_month.replace(month=start_of_month.month + 1) + if ( + start_of_month < operation_time < end_of_month + and operation.category_id in categories + ): + operation.is_exceeded_limit = True + return operation diff --git a/src/schemas/operations.py b/src/schemas/operations.py index c552b8a..d145406 100644 --- a/src/schemas/operations.py +++ b/src/schemas/operations.py @@ -19,6 +19,7 @@ class OperationSchema(OperationCreateSchema): id: int subcategory_id: int | None + is_exceeded_limit: bool | None class Config: orm_mode = True From 91bbf6f9dabd36d12b00356b5210f5f548d72b52 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Mon, 12 Jun 2023 19:13:38 +0300 Subject: [PATCH 72/80] black refactor --- src/app/repositories/limits.py | 4 +++- src/app/services/operations.py | 13 ++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/app/repositories/limits.py b/src/app/repositories/limits.py index 6ff33be..05b8756 100644 --- a/src/app/repositories/limits.py +++ b/src/app/repositories/limits.py @@ -10,7 +10,9 @@ async def get(self, **kwargs): return self._get(Limit, **kwargs) async def get_all(self, user_id: int): - return await self.session.scalars(select(Limit).filter_by(user_id=user_id)) + return list( + await self.session.scalars(select(Limit).filter_by(user_id=user_id)) + ) async def delete(self, limit_id: int): await self.session.execute(delete(Limit).filter_by(id=limit_id)) diff --git a/src/app/services/operations.py b/src/app/services/operations.py index cf52572..599a5dd 100644 --- a/src/app/services/operations.py +++ b/src/app/services/operations.py @@ -82,7 +82,13 @@ def get_exceeded_limits( from_time = datetime.fromtimestamp(from_time).replace(day=1) to_time = datetime.fromtimestamp(to_time) # to_time - Первий день наступного місяця після остального місяця періоду - to_time.replace(day=1, month=to_time.month + 1) + to_time = to_time.replace( + day=1, + month=to_time.month + 1 if to_time.month < 12 else 12, + hour=0, + minute=0, + second=0, + ) # Список початків місяців у періоді months_list = get_months_list(from_time, to_time) # Список операцій у кожному місяці @@ -92,10 +98,7 @@ def get_exceeded_limits( } exceeded_limits = defaultdict(list) for start_of_month, categories_costs in operations_categories_sum_by_months.items(): - for ( - category_id, - category_costs_sum, - ) in categories_costs.items(): + for category_id, category_costs_sum in categories_costs.items(): limit = list(limit for limit in limits if limit.category_id == category_id) if limit and category_costs_sum > limit[0].limit: exceeded_limits[start_of_month].append(category_id) From 4ff74deb3283c8f161f604d85784945e348ca287 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 14 Jun 2023 22:39:44 +0300 Subject: [PATCH 73/80] perf(Delete): Add delete method to entities --- src/app/repositories/absctract/categories.py | 4 +++ src/app/repositories/absctract/limits.py | 2 +- src/app/repositories/absctract/operations.py | 4 +++ src/app/repositories/categories.py | 3 +++ src/app/repositories/limits.py | 4 +-- src/app/repositories/operations.py | 3 +++ src/app/repositories/sqlalchemy.py | 5 +++- src/app/services/categories.py | 27 +++++++++++++++++--- 8 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/app/repositories/absctract/categories.py b/src/app/repositories/absctract/categories.py index 29b6c57..fb24dc0 100644 --- a/src/app/repositories/absctract/categories.py +++ b/src/app/repositories/absctract/categories.py @@ -22,3 +22,7 @@ async def get_categories_in_values( self, field: str, values: list ) -> list[Category]: raise NotImplementedError + + @abstractmethod + async def delete(self, category_id: int): + raise NotImplementedError diff --git a/src/app/repositories/absctract/limits.py b/src/app/repositories/absctract/limits.py index 5084090..33f18c4 100644 --- a/src/app/repositories/absctract/limits.py +++ b/src/app/repositories/absctract/limits.py @@ -9,5 +9,5 @@ async def get_all(self, user_id: int): raise NotImplementedError @abstractmethod - async def delete(self, limit_id: int): + async def delete(self, **kwargs): raise NotImplementedError diff --git a/src/app/repositories/absctract/operations.py b/src/app/repositories/absctract/operations.py index e0652ff..34d1b1a 100644 --- a/src/app/repositories/absctract/operations.py +++ b/src/app/repositories/absctract/operations.py @@ -14,3 +14,7 @@ async def get_all_by_user( self, user_id, from_time: int, to_time: int ) -> list[Operation]: raise NotImplementedError + + @abstractmethod + async def delete(self, **kwargs): + raise NotImplementedError diff --git a/src/app/repositories/categories.py b/src/app/repositories/categories.py index a074809..99413b7 100644 --- a/src/app/repositories/categories.py +++ b/src/app/repositories/categories.py @@ -25,6 +25,9 @@ async def get_categories_in_values( ) -> list[Category]: return await self._get_categories(Category.__dict__.get(field).in_(values)) + async def delete(self, category_id: int): + await self._delete(Category, id=category_id) + class CategoryMccFacade: @staticmethod diff --git a/src/app/repositories/limits.py b/src/app/repositories/limits.py index 05b8756..42d4be0 100644 --- a/src/app/repositories/limits.py +++ b/src/app/repositories/limits.py @@ -14,5 +14,5 @@ async def get_all(self, user_id: int): await self.session.scalars(select(Limit).filter_by(user_id=user_id)) ) - async def delete(self, limit_id: int): - await self.session.execute(delete(Limit).filter_by(id=limit_id)) + async def delete(self, **kwargs): + await self.session.execute(delete(Limit).filter_by(**kwargs)) diff --git a/src/app/repositories/operations.py b/src/app/repositories/operations.py index 7690961..466546c 100644 --- a/src/app/repositories/operations.py +++ b/src/app/repositories/operations.py @@ -19,3 +19,6 @@ async def get_all_by_user( .filter_by(user_id=user_id) ) ) + + async def delete(self, **kwargs): + await self._delete(Operation, **kwargs) diff --git a/src/app/repositories/sqlalchemy.py b/src/app/repositories/sqlalchemy.py index b6c2210..1516b8b 100644 --- a/src/app/repositories/sqlalchemy.py +++ b/src/app/repositories/sqlalchemy.py @@ -1,6 +1,6 @@ from abc import ABC -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from src.app.repositories.absctract.base import AbstractRepository @@ -16,3 +16,6 @@ async def add(self, model_object): async def _get(self, model, **kwargs): return await self.session.scalar(select(model).filter_by(**kwargs)) + + async def _delete(self, model, **kwargs): + await self.session.execute(delete(model).filter_by(**kwargs)) diff --git a/src/app/services/categories.py b/src/app/services/categories.py index 04e7670..154a8e6 100644 --- a/src/app/services/categories.py +++ b/src/app/services/categories.py @@ -7,10 +7,13 @@ async def create_category( uow: AbstractUnitOfWork, user_id: int, schema: CategoryCreateSchema ) -> Category: async with uow: - created_category = await uow.categories.get(name=schema.name, user_id=user_id) - if created_category: - return created_category - category = Category(name=schema.name, user_id=user_id, type="user") + category = Category( + name=schema.name, + user_id=user_id, + type="user", + icon_name=schema.icon_name, + icon_color=schema.icon_color.upper(), + ) await uow.categories.add(category) await uow.commit() return category @@ -28,3 +31,19 @@ async def get_categories_in_values( ) -> list[Category]: async with uow: return await uow.categories.get_categories_in_values(field, values) + + +async def delete_category( + uow: AbstractUnitOfWork, user_id: int, category_id: int +) -> bool: + async with uow: + category = await uow.categories.get( + id=category_id, user_id=user_id, type="user" + ) + if category: + await uow.operations.delete(category_id=category_id) + await uow.limits.delete(category_id=category_id) + await uow.categories.delete(category_id=category_id) + await uow.commit() + return True + return False From 7d2ea89eb955674f7f48ebd1d9ba064190a5e8c8 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 14 Jun 2023 22:40:47 +0300 Subject: [PATCH 74/80] fix(Categories): Change fields in category schema --- src/schemas/categories.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/schemas/categories.py b/src/schemas/categories.py index 951910a..4aa628c 100644 --- a/src/schemas/categories.py +++ b/src/schemas/categories.py @@ -3,15 +3,15 @@ class CategoryCreateSchema(BaseModel): name: str + icon_name: str | None + icon_color: str | None class CategorySchema(CategoryCreateSchema): id: int + parent_id: int | None user_id: int | None type: str - icon_name: str | None - icon_color: str | None - parent_id: int | None class Config: orm_mode = True From a16549495e36c0c153a765b711f4af8999adb7eb Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 14 Jun 2023 22:46:32 +0300 Subject: [PATCH 75/80] fix(Bank-api): add updated_time algorithm --- src/app/services/bank_api.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/services/bank_api.py b/src/app/services/bank_api.py index 70a2698..fe4ac44 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -31,6 +31,15 @@ async def add_bank_info(uow: AbstractUnitOfWork, user_id: int, props: dict): prop_name=key, prop_value=value, prop_type="str", manager=bank_info ) ) + # TEMP + await uow.banks_info.add( + BankInfoProperty( + prop_name="updated_time", + prop_value=str(int((datetime.now() - timedelta(30)).timestamp())), + prop_type="int", + manager=bank_info, + ) + ) await uow.commit() From 6a3445164e221c333e10eb7ee656abfa0fa62ca9 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Wed, 14 Jun 2023 22:50:42 +0300 Subject: [PATCH 76/80] feat(Categories): add delete category endpoint --- src/routers/categories.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/routers/categories.py b/src/routers/categories.py index c174b1c..26bbff6 100644 --- a/src/routers/categories.py +++ b/src/routers/categories.py @@ -1,7 +1,11 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from src.app.domain.users import User -from src.app.services.categories import create_category, get_availables_categories +from src.app.services.categories import ( + create_category, + delete_category, + get_availables_categories, +) from src.app.services.uow.abstract import AbstractUnitOfWork from src.depends import get_current_user, get_uow from src.schemas.categories import CategoryCreateSchema, CategorySchema @@ -10,7 +14,7 @@ @router.post("/create/", 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), @@ -31,7 +35,7 @@ async def create_operation_view( @router.get("/list/", 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), ): @@ -43,3 +47,22 @@ async def read_operations_view( :return: Category list """ return await get_availables_categories(uow, current_user.id) + + +@router.delete("/delete/", status_code=204) +async def delete_category_view( + category_id: int, + current_user: User = Depends(get_current_user), + uow: AbstractUnitOfWork = Depends(get_uow), +): + """ + Повертає список операцій поточного користувача. + :param category_id: ID категорії + :param uow: Unit of Work + :param current_user: Користувач, + який розшифровується з токену у заголовку Authorization + :return: 204 | 400 + """ + result = await delete_category(uow, current_user.id, category_id) + if not result: + raise HTTPException(status_code=400) From f364fd0dc1e9b90d7493ce7732f6282ea17b4679 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 27 Jul 2023 13:29:37 +0300 Subject: [PATCH 77/80] refactor: refactor fake repos --- tests/fake_adapters/categories.py | 3 +++ tests/fake_adapters/operations.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/fake_adapters/categories.py b/tests/fake_adapters/categories.py index 52efec2..134f113 100644 --- a/tests/fake_adapters/categories.py +++ b/tests/fake_adapters/categories.py @@ -4,6 +4,9 @@ class FakeCategoryRepository(FakeRepository, ACategoryRepository): + async def delete(self, category_id: int): + pass + async def get(self, **kwargs) -> Category | None: return await self._get(**kwargs) diff --git a/tests/fake_adapters/operations.py b/tests/fake_adapters/operations.py index 49d15e7..5e9619e 100644 --- a/tests/fake_adapters/operations.py +++ b/tests/fake_adapters/operations.py @@ -4,6 +4,9 @@ class FakeOperationRepository(FakeRepository, AOperationRepository): + async def delete(self, **kwargs): + pass + async def get(self, **kwargs) -> Operation | None: return await self._get(**kwargs) From 2c578c4fa2bb0ffb20df103adc25f12f9ebb65a6 Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 27 Jul 2023 13:29:55 +0300 Subject: [PATCH 78/80] refactor: refactor tests --- tests/integrations/categories_test.py | 6 +++-- tests/unit/categories_test.py | 35 ++++++++++++++++----------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/tests/integrations/categories_test.py b/tests/integrations/categories_test.py index 9386e03..63148bc 100644 --- a/tests/integrations/categories_test.py +++ b/tests/integrations/categories_test.py @@ -14,7 +14,7 @@ async def test_create_category_endpoint(client_db: AsyncClient): token = auth_data["token"] headers = {"Authorization": token} - category_data = {"name": "category name"} + category_data = {"name": "category name", "icon_name": "", "icon_color": ""} response = await client_db.post( "/categories/create/", json=category_data, headers=headers ) @@ -34,7 +34,9 @@ async def test_read_categories_endpoint(database: Database, client_db: AsyncClie async with database.sessionmaker() as session: uow = SqlAlchemyUnitOfWork(session) for i in range(10): - schema = CategoryCreateSchema(name=f"test category #{i}") + schema = CategoryCreateSchema( + name=f"test category #{i}", icon_name="", icon_color="" + ) await create_category(uow, user_id, schema) response = await client_db.get("/categories/list/", headers=headers) diff --git a/tests/unit/categories_test.py b/tests/unit/categories_test.py index 325dc2a..2abb4b8 100644 --- a/tests/unit/categories_test.py +++ b/tests/unit/categories_test.py @@ -9,30 +9,37 @@ @pytest.mark.asyncio async def test_create_category(): uow = FakeUnitOfWork() - schema = CategoryCreateSchema(name="Test category") + schema = CategoryCreateSchema(name="Test category", icon_color="", icon_name="") category = await create_category(uow, 1, schema) assert isinstance(category, Category) -@pytest.mark.asyncio -async def test_create_dublicate_category(): - """ - Якщо відбувається спроба створити категорію-дублікат, - то категорія не створюється, а метод повинен повернути категорію-оригінал - :return: - """ - uow = FakeUnitOfWork() - schema = CategoryCreateSchema(name="Test category") - created_category = await create_category(uow, 1, schema) - category = await create_category(uow, 1, schema) - assert category == created_category +# @pytest.mark.asyncio +# async def test_create_dublicate_category(): +# """ +# Якщо відбувається спроба створити категорію-дублікат, +# то категорія не створюється, а метод повинен повернути категорію-оригінал +# :return: +# """ +# uow = FakeUnitOfWork() +# schema = CategoryCreateSchema(name="Test category", icon_color="", icon_name="") +# created_category = await create_category(uow, 1, schema) +# category = await create_category(uow, 1, schema) +# assert category == created_category @pytest.mark.asyncio async def test_read_availables_categories(): uow = FakeUnitOfWork() uow.categories.instances = [ - Category(id=i, name=f"Test category #{i}", user_id=1, type="user") + Category( + id=i, + name=f"Test category #{i}", + user_id=1, + type="user", + icon_color="", + icon_name="", + ) for i in range(10) ] categories = await get_availables_categories(uow, user_id=1) From f58a806fafec9825f9a237f3f37d9b7cbc41066d Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 27 Jul 2023 13:30:39 +0300 Subject: [PATCH 79/80] feat: add debug mode variable --- src/app/services/uow/sqlalchemy.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/services/uow/sqlalchemy.py b/src/app/services/uow/sqlalchemy.py index 58fac8f..2dbe674 100644 --- a/src/app/services/uow/sqlalchemy.py +++ b/src/app/services/uow/sqlalchemy.py @@ -1,3 +1,5 @@ +import os + from sqlalchemy.ext.asyncio import AsyncSession from src.app.repositories.bank_api.bank_info import BankInfoRepository @@ -23,7 +25,8 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc_val, exc_tb): await super().__aexit__(exc_type, exc_val, exc_tb) await self.session.close() - return True + if os.environ.get("DEBUG_MODE") == "TRUE": + return True async def _commit(self): await self.session.commit() From ea0647d6fb1b6cb3a195dd950fee0d404739cf8f Mon Sep 17 00:00:00 2001 From: Andrew Sergienko Date: Thu, 27 Jul 2023 13:31:30 +0300 Subject: [PATCH 80/80] fix: fix method call code in delete_limit service --- src/app/services/limits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/limits.py b/src/app/services/limits.py index cd4b627..cc4cc46 100644 --- a/src/app/services/limits.py +++ b/src/app/services/limits.py @@ -27,4 +27,4 @@ async def get_limits(uow: AbstractUnitOfWork, user_id: int) -> list[Limit]: async def delete_limit(uow: AbstractUnitOfWork, limit_id: int): - await uow.limits.delete(limit_id) + await uow.limits.delete(id=limit_id)