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/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 ### 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/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/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/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 ### 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/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 ### diff --git a/requirements.txt b/requirements.txt index 11cc078..8060e57 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/src/app/adapters/orm.py b/src/app/adapters/orm.py index a381030..4f0bfa4 100644 --- a/src/app/adapters/orm.py +++ b/src/app/adapters/orm.py @@ -6,6 +6,8 @@ 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.limits import Limit from src.app.domain.operations import Operation from src.app.domain.users import User @@ -26,13 +28,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", @@ -45,11 +47,31 @@ 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")), ), + "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), + 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), + ), + "limits": 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), + ), } @@ -68,10 +90,11 @@ 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"], - properties={"manager": relationship(BankInfo)}, ) + mapper_registry.map_imperatively(Category, tables["categories"]) + mapper_registry.map_imperatively(Limit, tables["limits"]) 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/domain/categories.py b/src/app/domain/categories.py new file mode 100644 index 0000000..d333a84 --- /dev/null +++ b/src/app/domain/categories.py @@ -0,0 +1,25 @@ +class Category: + id: int | None + 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, + icon_name: str | None = None, + icon_color: str | None = None, + parent_id: int | 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 + self.parent_id = parent_id diff --git a/src/app/domain/limits.py b/src/app/domain/limits.py new file mode 100644 index 0000000..8690561 --- /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/domain/operations.py b/src/app/domain/operations.py index ce33cc8..2e5e103 100644 --- a/src/app/domain/operations.py +++ b/src/app/domain/operations.py @@ -4,16 +4,20 @@ def __init__( amount: int, description: str | None, time: int, - mcc: int | None, source_type: str, user_id: int, + category_id: int | None = None, + subcategory_id: int | None = None, + is_exceeded_limit: bool = False, 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 + self.subcategory_id = subcategory_id + self.is_exceeded_limit = is_exceeded_limit 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/repositories/absctract/bank_api.py b/src/app/repositories/absctract/bank_api.py index bf0b0ee..07b2a59 100644 --- a/src/app/repositories/absctract/bank_api.py +++ b/src/app/repositories/absctract/bank_api.py @@ -14,10 +14,8 @@ """ from abc import ABC, abstractmethod -from datetime import datetime from src.app.domain.bank_api import BankInfo -from src.app.domain.operations import Operation from src.app.repositories.absctract.base import AbstractRepository @@ -44,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 @@ -53,9 +55,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[dict]: raise NotImplementedError 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 new file mode 100644 index 0000000..fb24dc0 --- /dev/null +++ b/src/app/repositories/absctract/categories.py @@ -0,0 +1,28 @@ +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, **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 + + @abstractmethod + 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 new file mode 100644 index 0000000..33f18c4 --- /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, **kwargs): + raise NotImplementedError diff --git a/src/app/repositories/absctract/operations.py b/src/app/repositories/absctract/operations.py index 9ccb1bb..34d1b1a 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 @@ -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/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/bank_api/bank_info.py b/src/app/repositories/bank_api/bank_info.py index 330f307..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( @@ -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,15 @@ 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()))) ) + + 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/app/repositories/bank_api/monobank.py b/src/app/repositories/bank_api/monobank.py index 4dd08d8..5d90321 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,39 +10,54 @@ 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[dict] | None: + from_time, to_time = self._normalize_time_values(from_time, to_time) + operations = list( + chain.from_iterable(await 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 = [] - + 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() - costs = self.validate_operations(operations) - result_operations.append(*costs) - 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 [ - Operation( - **operation, - user_id=self.properties["user_id"], - source_type=self.properties["bank_name"], - ) - for operation in result_operations - ] + return res_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 + # Сума витрати в програмі оперуються як додатні величини del cost["id"] # Видалення id з операції, яке йому надає monobank return costs diff --git a/src/app/repositories/categories.py b/src/app/repositories/categories.py new file mode 100644 index 0000000..99413b7 --- /dev/null +++ b/src/app/repositories/categories.py @@ -0,0 +1,41 @@ +import json +import os + +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, **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 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)) + + async def delete(self, category_id: int): + await self._delete(Category, id=category_id) + + +class CategoryMccFacade: + @staticmethod + 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.items(): + if mcc in value: + return key diff --git a/src/app/repositories/limits.py b/src/app/repositories/limits.py new file mode 100644 index 0000000..42d4be0 --- /dev/null +++ b/src/app/repositories/limits.py @@ -0,0 +1,18 @@ +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 + + +class LimitRepository(SqlAlchemyRepository, ALimitRepository): + async def get(self, **kwargs): + return self._get(Limit, **kwargs) + + async def get_all(self, user_id: int): + return list( + await self.session.scalars(select(Limit).filter_by(user_id=user_id)) + ) + + async def delete(self, **kwargs): + await self.session.execute(delete(Limit).filter_by(**kwargs)) diff --git a/src/app/repositories/mcc.json b/src/app/repositories/mcc.json new file mode 100644 index 0000000..aa01fb9 --- /dev/null +++ b/src/app/repositories/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]} diff --git a/src/app/repositories/operations.py b/src/app/repositories/operations.py index 0a768be..466546c 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 @@ -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 1c18f6f..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 @@ -14,5 +14,8 @@ 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)) + + async def _delete(self, model, **kwargs): + await self.session.execute(delete(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/bank_api.py b/src/app/services/bank_api.py index 8eb1d7e..fe4ac44 100644 --- a/src/app/services/bank_api.py +++ b/src/app/services/bank_api.py @@ -1,10 +1,13 @@ 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, ) +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 @@ -25,9 +28,18 @@ 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 ) ) + # 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() @@ -58,17 +70,57 @@ async def update_banks_costs( costs = [] updated_managers = [] for manager in managers: - updated_time = get_updated_time(manager) - if updated_time: - costs.append(*await manager.get_costs(from_time=updated_time)) + 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.commit() - + 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] ) + 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 + + +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: @@ -83,7 +135,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 # У кожного банка є максиммальна дата, на яку можна запитувати витрати diff --git a/src/app/services/categories.py b/src/app/services/categories.py new file mode 100644 index 0000000..154a8e6 --- /dev/null +++ b/src/app/services/categories.py @@ -0,0 +1,49 @@ +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: + async with uow: + 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 + + +async def get_availables_categories( + uow: AbstractUnitOfWork, user_id: int +) -> 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) + + +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 diff --git a/src/app/services/limits.py b/src/app/services/limits.py new file mode 100644 index 0000000..cc4cc46 --- /dev/null +++ b/src/app/services/limits.py @@ -0,0 +1,30 @@ +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(user_id, **schema.dict()) + 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 list(limits) + + +async def delete_limit(uow: AbstractUnitOfWork, limit_id: int): + await uow.limits.delete(id=limit_id) diff --git a/src/app/services/operations.py b/src/app/services/operations.py index 2375a0b..599a5dd 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 @@ -35,4 +39,106 @@ 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) + 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 назначається батьківська + 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 = 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) + # Список операцій у кожному місяці + 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/app/services/statistic.py b/src/app/services/statistic.py index 8ee5bb5..188662b 100644 --- a/src/app/services/statistic.py +++ b/src/app/services/statistic.py @@ -15,6 +15,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 @@ -26,12 +27,16 @@ 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 } @@ -42,3 +47,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/app/services/uow/abstract.py b/src/app/services/uow/abstract.py index b64ca1c..fdf491f 100644 --- a/src/app/services/uow/abstract.py +++ b/src/app/services/uow/abstract.py @@ -10,10 +10,9 @@ 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.limits import ALimitRepository from src.app.repositories.absctract.operations import AOperationRepository from src.app.repositories.absctract.users import AUserRepository @@ -22,7 +21,8 @@ class AbstractUnitOfWork(ABC): users: AUserRepository operations: AOperationRepository banks_info: ABankInfoRepository - bank_managers: dict[str, ABankManagerRepository] + 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 b53c5b9..2dbe674 100644 --- a/src/app/services/uow/sqlalchemy.py +++ b/src/app/services/uow/sqlalchemy.py @@ -1,6 +1,10 @@ +import os + 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.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 @@ -14,12 +18,15 @@ 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) + self.limits = LimitRepository(self.session) return await super().__aenter__() 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() diff --git a/src/app/services/users.py b/src/app/services/users.py index 2e564ab..e97c4c9 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 @@ -19,3 +19,8 @@ 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: + 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/auth/services.py b/src/auth/services.py new file mode 100644 index 0000000..eaa339b --- /dev/null +++ b/src/auth/services.py @@ -0,0 +1,36 @@ +""" +CRUD account methods +""" +from datetime import datetime, timedelta + +from jose import JWTError, jwt + +from src.auth.config import ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY +from src.schemas.auth import TokenData + + +def create_access_token(data: dict) -> str: + """ + Створює JWT, + який надалі використовуватиметься для визначення залогіненого користувача. + Шифрує у собі email користувача та дату, до якого дійсний токен. + + :param data: словник такого шаблону: {'sub': user.email} + :return: JWT у вигляді рядка + """ + # Час у хвилинах, під час якого токен дійсний + expire = datetime.utcnow() + timedelta(minutes=int(ACCESS_TOKEN_EXPIRE_MINUTES)) + data.update({"exp": expire}) + return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM) + + +def decode_token_data(token: str) -> TokenData | None: + """Розшифровка JWT""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + email = payload.get("sub") + if email is None: + return + return TokenData(email=email) + except JWTError: + return 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 diff --git a/src/depends.py b/src/depends.py index 0097a13..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 get_current_user oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -15,17 +16,17 @@ 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 + return await get_user_by_email(uow, token_data.email) def raise_credentials_exception(): diff --git a/src/main.py b/src/main.py index edad578..20f3f82 100644 --- a/src/main.py +++ b/src/main.py @@ -22,6 +22,8 @@ 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; @@ -31,6 +33,8 @@ 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"]) + fastapi_app.include_router(limits_router, tags=["limits"]) app = bootstrap_fastapi_app() diff --git a/src/routers/auth.py b/src/routers/auth.py index 2b9e832..97cb8ba 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.auth.password import verify_password +from src.auth.services import create_access_token from src.depends import get_uow -from src.routers.authentication.services import authenticate_user, 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(form_data.password, user.hashed_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 deleted file mode 100644 index 74e146b..0000000 --- a/src/routers/authentication/services.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -CRUD account methods -""" -from datetime import datetime, timedelta - -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, - який надалі використовуватиметься для визначення залогіненого користувача. - Шифрує у собі email користувача та дату, до якого дійсний токен. - - :param data: словник такого шаблону: {'sub': user.email} - :return: JWT у вигляді рядка - """ - # Час у хвилинах, під час якого токен дійсний - expire = datetime.utcnow() + timedelta(minutes=int(ACCESS_TOKEN_EXPIRE_MINUTES)) - data.update({"exp": expire}) - 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: - """Розшифровка JWT""" - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - email = payload.get("sub") - if email is None: - return - return TokenData(email=email) - except JWTError: - return diff --git a/src/routers/bank_api.py b/src/routers/bank_api.py index 3e2623f..42ee31d 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,9 +24,29 @@ 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) await update_banks_costs(uow, managers) - return + return {"status": "ok"} + + +@router.get("/list/", status_code=200) +async def get_connected_banks_names( + current_user: User = Depends(get_current_user), + 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] + + +@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/src/routers/categories.py b/src/routers/categories.py new file mode 100644 index 0000000..26bbff6 --- /dev/null +++ b/src/routers/categories.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, Depends, HTTPException + +from src.app.domain.users import User +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 + +router = APIRouter(prefix="/categories") + + +@router.post("/create/", response_model=CategorySchema, status_code=201) +async def create_category_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_categories_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) + + +@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) 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() diff --git a/src/routers/operations.py b/src/routers/operations.py index f482d64..fc7dc97 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,14 +34,13 @@ 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, ): """ Повертає список операцій поточного користувача. - TODO: Реалізувати фільтрацію :param uow: Unit of Work :param current_user: Користувач, який розшифровується з токену у заголовку Authorization @@ -49,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) 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 """ diff --git a/src/schemas/categories.py b/src/schemas/categories.py new file mode 100644 index 0000000..4aa628c --- /dev/null +++ b/src/schemas/categories.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +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 + + class Config: + orm_mode = True 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 diff --git a/src/schemas/operations.py b/src/schemas/operations.py index 2dcafca..d145406 100644 --- a/src/schemas/operations.py +++ b/src/schemas/operations.py @@ -9,15 +9,17 @@ class OperationCreateSchema(BaseModel): amount: int description: str | None - mcc: int source_type: str time: int | None + category_id: int | None class OperationSchema(OperationCreateSchema): """Схема операції, яка виокристовується під час завантаження даних з БД""" id: int + subcategory_id: int | None + is_exceeded_limit: bool | None class Config: orm_mode = True diff --git a/src/schemas/statistic.py b/src/schemas/statistic.py index ef52349..77bd5bd 100644 --- a/src/schemas/statistic.py +++ b/src/schemas/statistic.py @@ -5,5 +5,6 @@ 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] + costs_sum_by_days: dict[str, int] 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/conftest.py b/tests/conftest.py index 9777609..7b7c2ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,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/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/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..034ee45 --- /dev/null +++ b/tests/fake_adapters/bank_info.py @@ -0,0 +1,25 @@ +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 + + async def delete(self, user_id: int, bank_name: str): + pass diff --git a/tests/fake_adapters/base.py b/tests/fake_adapters/base.py new file mode 100644 index 0000000..71b1d59 --- /dev/null +++ b/tests/fake_adapters/base.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod + + +class FakeRepository(ABC): + def __init__(self): + self.instances = [] + + @abstractmethod + async def get(self, **kwargs): + raise NotImplementedError + + async def add(self, instance): + self.instances.append(instance) + + async def _get(self, **kwargs): + result = list( + 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 new file mode 100644 index 0000000..134f113 --- /dev/null +++ b/tests/fake_adapters/categories.py @@ -0,0 +1,32 @@ +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 delete(self, category_id: int): + pass + + 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( + lambda category: category.user_id == user_id + or category.user_id is None, + self.instances, + ) + ) + + async def get_categories_in_values( + self, field: str, values: list + ) -> list[Category]: + return list( + filter( + lambda category: category.__dict__.get(field) in values, self.instances + ) + ) 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 + } +] 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/operations.py b/tests/fake_adapters/operations.py new file mode 100644 index 0000000..5e9619e --- /dev/null +++ b/tests/fake_adapters/operations.py @@ -0,0 +1,22 @@ +from src.app.domain.operations import Operation +from src.app.repositories.absctract.operations import AOperationRepository +from tests.fake_adapters.base import FakeRepository + + +class FakeOperationRepository(FakeRepository, AOperationRepository): + async def delete(self, **kwargs): + pass + + 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 + ) -> 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/uow.py b/tests/fake_adapters/uow.py new file mode 100644 index 0000000..fa88c23 --- /dev/null +++ b/tests/fake_adapters/uow.py @@ -0,0 +1,27 @@ +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 + + +class FakeUnitOfWork(AbstractUnitOfWork): + def __init__(self): + self.users = FakeUserRepository() + self.operations = FakeOperationRepository() + self.categories = FakeCategoryRepository() + self.banks_info = FakeBankInfoRepository() + self.limits = FakeLimitRepository() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + async def _commit(self): + pass + + async def rollback(self): + pass diff --git a/tests/fake_adapters/users.py b/tests/fake_adapters/users.py new file mode 100644 index 0000000..52466a3 --- /dev/null +++ b/tests/fake_adapters/users.py @@ -0,0 +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 FakeUserRepository(FakeRepository, AUserRepository): + async def get(self, **kwargs) -> User | None: + return await self._get(**kwargs) 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) - ) diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py new file mode 100644 index 0000000..e69de29 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/categories_test.py b/tests/integrations/adapters/categories_test.py new file mode 100644 index 0000000..0f57d10 --- /dev/null +++ b/tests/integrations/adapters/categories_test.py @@ -0,0 +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) + 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="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 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/integrations/adapters/users_test.py b/tests/integrations/adapters/users_test.py new file mode 100644 index 0000000..d88e20f --- /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/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 86% rename from tests/func/bank_api_test.py rename to tests/integrations/bank_api_test.py index 04c3480..3645916 100644 --- a/tests/func/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): @@ -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, ) ) @@ -29,15 +29,15 @@ 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: Запис в базу даних витрат за допомогою менеджерів 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 diff --git a/tests/integrations/categories_test.py b/tests/integrations/categories_test.py new file mode 100644 index 0000000..63148bc --- /dev/null +++ b/tests/integrations/categories_test.py @@ -0,0 +1,44 @@ +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", "icon_name": "", "icon_color": ""} + 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}", icon_name="", icon_color="" + ) + 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/func/operations_test.py b/tests/integrations/operations_test.py similarity index 85% rename from tests/func/operations_test.py rename to tests/integrations/operations_test.py index 8c5d4f4..79c46d7 100644 --- a/tests/func/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 @@ -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( @@ -31,7 +30,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 ) @@ -40,9 +45,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} @@ -51,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/func/statistic_test.py b/tests/integrations/statistic_test.py similarity index 96% rename from tests/func/statistic_test.py rename to tests/integrations/statistic_test.py index 97d4958..7e3351d 100644 --- a/tests/func/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/func/users_test.py b/tests/integrations/users_test.py similarity index 94% rename from tests/func/users_test.py rename to tests/integrations/users_test.py index 2a8eebe..dcbc874 100644 --- a/tests/func/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/patterns.py b/tests/patterns.py index 4686e5c..a641a1e 100644 --- a/tests/patterns.py +++ b/tests/patterns.py @@ -5,22 +5,24 @@ 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) diff --git a/tests/unit/auth_test.py b/tests/unit/auth_test.py index 78ae90c..f00be5f 100644 --- a/tests/unit/auth_test.py +++ b/tests/unit/auth_test.py @@ -1,77 +1,28 @@ -""" -Auth unit tests -""" -import pytest +from src.auth.password import get_password_hash, verify_password +from src.auth.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) diff --git a/tests/unit/bank_api_test.py b/tests/unit/bank_api_test.py new file mode 100644 index 0000000..9f06a1f --- /dev/null +++ b/tests/unit/bank_api_test.py @@ -0,0 +1,37 @@ +import json +import os +from pathlib import Path + +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): + root_dir = Path(__file__).parent.parent.parent + with open( + root_dir / "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 diff --git a/tests/unit/categories_test.py b/tests/unit/categories_test.py new file mode 100644 index 0000000..2abb4b8 --- /dev/null +++ b/tests/unit/categories_test.py @@ -0,0 +1,46 @@ +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", 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", 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", + icon_color="", + icon_name="", + ) + for i in range(10) + ] + categories = await get_availables_categories(uow, user_id=1) + assert len(categories) == 10 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/operations_test.py b/tests/unit/operations_test.py index 63b6e68..dabeb27 100644 --- a/tests/unit/operations_test.py +++ b/tests/unit/operations_test.py @@ -1,103 +1,55 @@ -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; +from tests.fake_adapters.uow import FakeUnitOfWork @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 +async def test_create_operation(): + uow = FakeUnitOfWork() + schema = OperationCreateSchema(amount=100, description="test", source_type="manual") + operation = await create_operation(uow, 1, schema) + assert operation @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), +async def test_read_operation(): + uow = FakeUnitOfWork() + async with uow: + uow.operations.instances = [ + Operation( + id=1, + amount=100, + description="test", source_type="manual", - ) - 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 - - -@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), + time=int(datetime.now().timestamp()), + user_id=1, + ), + Operation( + id=2, + amount=333, + description="test", 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() + time=int(datetime.now().timestamp()), + user_id=1, + ), + Operation( + id=3, + amount=150, + description="test", + source_type="manual", + time=int(datetime.now().timestamp()), + user_id=1, + ), + ] operations = await get_operations( - uow, user.id, from_time=int(today), to_time=int(tomorrow) + uow, + 1, + (datetime.now() - timedelta(minutes=1)).timestamp(), + datetime.now().timestamp(), ) - assert len(operations) == 1 + assert len(operations) == 3 diff --git a/tests/unit/statistic.py b/tests/unit/statistic_test.py similarity index 95% rename from tests/unit/statistic.py rename to tests/unit/statistic_test.py index 5977be1..f67f6b1 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 @@ -15,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, @@ -58,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, diff --git a/tests/unit/users_test.py b/tests/unit/users_test.py index ad2c679..15862f1 100644 --- a/tests/unit/users_test.py +++ b/tests/unit/users_test.py @@ -1,64 +1,23 @@ -""" -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.app.services.users import create_user, get_user_by_email from src.schemas.users import UserCreateSchema -from tests.conftest import precents_evn_variables # noqa: F401; +from tests.fake_adapters.uow import FakeUnitOfWork @pytest.mark.asyncio -@precents_evn_variables -async def test_user_create(database): # noqa: F401, F811; - """ - Перевірка методу src.crud.users.create_user - 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) +async def test_create_user(): + uow = FakeUnitOfWork() + schema = UserCreateSchema(email="test", password="test") + user = await create_user(uow, schema) + assert user @pytest.mark.asyncio -@precents_evn_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" - ) - 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 +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