diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..17daae6 --- /dev/null +++ b/readme.md @@ -0,0 +1,18 @@ +

+ full-logo +

+ +

+ + + + + + + Python Version +

+ +## About + +An adaptive service for classifying and monitoring personal expenses. +It has the ability to synchronize with banks (Monobank) to receive expenses in real time. diff --git a/src/costy/adapters/bankapi/_mcc.json b/src/costy/adapters/bankapi/_mcc.json index e41e922..0aeaa0b 100644 --- a/src/costy/adapters/bankapi/_mcc.json +++ b/src/costy/adapters/bankapi/_mcc.json @@ -1,5 +1,5 @@ { - "Ветеринарні послуги": [ + "Ветеринар": [ 742 ], "Виноробники": [ @@ -8,7 +8,7 @@ "Виробники шампанського": [ 744 ], - "Сільскогосподарські кооперативи": [ + "Сільске господарство ": [ 763 ], "Садівництво. Ландшафтний дизайн": [ diff --git a/src/costy/adapters/db/category_gateway.py b/src/costy/adapters/db/category_gateway.py index c2725b3..4235804 100644 --- a/src/costy/adapters/db/category_gateway.py +++ b/src/costy/adapters/db/category_gateway.py @@ -1,55 +1,83 @@ from adaptix import Retort -from sqlalchemy import Table, delete, insert, or_, select, update +from sqlalchemy import Table, delete, insert, join, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from costy.application.common.category.category_gateway import ( + CategoriesFinder, CategoriesReader, CategoryDeleter, CategoryFinder, CategoryReader, CategorySaver, CategoryUpdater, + SentinelOptional, ) from costy.domain.models.category import Category, CategoryId from costy.domain.models.user import UserId +from costy.domain.sentinel import Sentinel class CategoryGateway( CategoryReader, + CategoryFinder, CategorySaver, CategoryDeleter, CategoriesReader, CategoryUpdater, - CategoryFinder + CategoriesFinder ): - def __init__(self, session: AsyncSession, table: Table, retort: Retort): + def __init__(self, session: AsyncSession, category_table: Table, mcc_table: Table, retort: Retort): self.session = session - self.table = table + self.category_table = category_table + self.mcc_table = mcc_table self.retort = retort - async def get_category(self, category_id: CategoryId) -> Category | None: - query = select(self.table).where(self.table.c.id == category_id) + async def get_category_by_id(self, category_id: CategoryId) -> Category | None: + query = select(self.category_table).where(self.category_table.c.id == category_id) result = await self.session.execute(query) data = next(result.mappings(), None) return self.retort.load(data, Category) if data else None + async def find_category( + self, + name: SentinelOptional[str] = Sentinel, + kind: SentinelOptional[str] = Sentinel, + user_id: SentinelOptional[UserId] = Sentinel + ) -> Category | None: + if not any(param is not Sentinel for param in (name, kind, user_id)): + return None + + params = { + "name": name, + "kind": kind, + "user_id": user_id + } + + stmt = select(self.category_table) + for param_name, param_value in params.items(): + if param_value is not Sentinel: + stmt = stmt.where(self.category_table.c[param_name] == param_value) + + result = (await self.session.execute(stmt)).fetchone() + return self.retort.load(result._mapping, Category) + async def save_category(self, category: Category) -> None: values = self.retort.dump(category) del values["id"] - query = insert(self.table).values(**values) + query = insert(self.category_table).values(**values) result = await self.session.execute(query) category.id = CategoryId(result.inserted_primary_key[0]) async def delete_category(self, category_id: CategoryId) -> None: - query = delete(self.table).where(self.table.c.id == category_id) + query = delete(self.category_table).where(self.category_table.c.id == category_id) await self.session.execute(query) async def find_categories(self, user_id: UserId) -> list[Category]: filter_expr = or_( - self.table.c.user_id == user_id, - self.table.c.user_id == None # noqa: E711 + self.category_table.c.user_id == user_id, + self.category_table.c.user_id == None # noqa: E711 ) - query = select(self.table).where(filter_expr) + query = select(self.category_table).where(filter_expr) result = await self.session.execute(query) return self.retort.load(result.mappings(), list[Category]) @@ -59,15 +87,20 @@ async def update_category(self, category_id: CategoryId, category: Category) -> if not values: return - query = update(self.table).where(self.table.c.id == category_id).values(**values) + query = update(self.category_table).where(self.category_table.c.id == category_id).values(**values) await self.session.execute(query) async def find_categories_by_mcc_codes(self, mcc_codes: tuple[int, ...]) -> dict[int, Category]: - stmt = select(self.table).where(self.table.c.mcc.in_(mcc_codes)) - result = (await self.session.execute(stmt)).mappings() + j = join(self.mcc_table, self.category_table) + stmt = ( + select(self.mcc_table, self.category_table) + .where(self.mcc_table.c.mcc.in_(mcc_codes)) + .select_from(j) + ) + result = tuple((await self.session.execute(stmt)).mappings()) if not result: return {} - category_map = {category.mcc: category for category in result} + category_map = {item["mcc"]: item for item in result} return self.retort.load(category_map, dict[int, Category]) diff --git a/src/costy/application/bankapi/update_bank_operations.py b/src/costy/application/bankapi/update_bank_operations.py index 586dc24..b84c3c0 100644 --- a/src/costy/application/bankapi/update_bank_operations.py +++ b/src/costy/application/bankapi/update_bank_operations.py @@ -1,7 +1,6 @@ from typing import Protocol from ...domain.models.operation import Operation -from ...domain.sentinel import Sentinel from ...domain.services.bankapi import BankAPIService from ...domain.services.operation import OperationService from ..common.bankapi.bankapi_gateway import ( @@ -9,7 +8,7 @@ BankAPIOperationsReader, BanksAPIReader, ) -from ..common.category.category_gateway import CategoryFinder +from ..common.category.category_gateway import CategoriesFinder, CategoryFinder from ..common.id_provider import IdProvider from ..common.interactor import Interactor from ..common.operation.operation_gateway import OperationsBulkSaver @@ -20,6 +19,10 @@ class BankAPIGateway(BankAPIBulkUpdater, BanksAPIReader, BankAPIOperationsReader pass +class CategoryGateway(CategoriesFinder, CategoryFinder, Protocol): + pass + + class UpdateBankOperations(Interactor[None, None]): def __init__( self, @@ -27,7 +30,7 @@ def __init__( operation_service: OperationService, bankapi_gateway: BankAPIGateway, operation_gateway: OperationsBulkSaver, - category_gateway: CategoryFinder, + category_gateway: CategoryGateway, id_provider: IdProvider, uow: UoW ): @@ -42,6 +45,7 @@ def __init__( async def __call__(self, data=None) -> None: user_id = await self._id_provider.get_current_user_id() bankapis = await self._bankapi_gateway.get_bankapi_list(user_id) + default_category = await self._category_gateway.find_category(name="Інше", kind="general") operations: list[Operation] = [] for bankapi in bankapis: @@ -50,10 +54,9 @@ async def __call__(self, data=None) -> None: mcc_categories = await self._category_gateway.find_categories_by_mcc_codes(mcc_codes) for bank_operation in bank_operations: - self._operation_service.set_category( - bank_operation.operation, - mcc_categories.get(bank_operation.mcc, Sentinel) - ) + category = mcc_categories.get(bank_operation.mcc, default_category) + if category: + self._operation_service.set_category(bank_operation.operation, category) operations.extend(bank_operation.operation for bank_operation in bank_operations) self._bankapi_service.update_time(bankapi) diff --git a/src/costy/application/category/create_category.py b/src/costy/application/category/create_category.py index 535cd00..956ca76 100644 --- a/src/costy/application/category/create_category.py +++ b/src/costy/application/category/create_category.py @@ -28,7 +28,7 @@ async def __call__(self, data: NewCategoryDTO) -> CategoryId: if not user_id: raise AuthenticationError("User not found") - category = self.category_service.create(data.name, CategoryType.PERSONAL, user_id) + category = self.category_service.create(data.name, CategoryType.PERSONAL, user_id, data.view) await self.category_db_gateway.save_category(category) category_id = category.id await self.uow.commit() diff --git a/src/costy/application/category/delete_category.py b/src/costy/application/category/delete_category.py index b0430b1..439f9ee 100644 --- a/src/costy/application/category/delete_category.py +++ b/src/costy/application/category/delete_category.py @@ -33,7 +33,7 @@ def __init__( async def __call__(self, category_id: CategoryId) -> None: user_id = await self.id_provider.get_current_user_id() - category = await self.category_gateway.get_category(category_id) + category = await self.category_gateway.get_category_by_id(category_id) if not category: raise InvalidRequestError("Category not exist") diff --git a/src/costy/application/category/update_category.py b/src/costy/application/category/update_category.py index 0daf450..77749b2 100644 --- a/src/costy/application/category/update_category.py +++ b/src/costy/application/category/update_category.py @@ -36,7 +36,7 @@ def __init__( async def __call__(self, data: UpdateCategoryDTO) -> None: user_id = await self.id_provider.get_current_user_id() - category = await self.category_db_gateway.get_category(data.category_id) + category = await self.category_db_gateway.get_category_by_id(data.category_id) if not category or not category.id: raise InvalidRequestError("Category not exist") @@ -44,6 +44,6 @@ async def __call__(self, data: UpdateCategoryDTO) -> None: if not self.access_service.ensure_can_edit(category, user_id): raise AccessDeniedError("User can't edit this operation.") - self.category_service.update(category, data.data.name) + self.category_service.update(category, data.data.name, data.data.view) await self.category_db_gateway.update_category(category.id, category) await self.uow.commit() diff --git a/src/costy/application/common/category/category_gateway.py b/src/costy/application/common/category/category_gateway.py index 14d9e97..009d0da 100644 --- a/src/costy/application/common/category/category_gateway.py +++ b/src/costy/application/common/category/category_gateway.py @@ -3,6 +3,7 @@ from costy.domain.models.category import Category, CategoryId from costy.domain.models.user import UserId +from costy.domain.sentinel import Sentinel, SentinelOptional @runtime_checkable @@ -15,7 +16,19 @@ async def save_category(self, category: Category) -> None: @runtime_checkable class CategoryReader(Protocol): @abstractmethod - async def get_category(self, category_id: CategoryId) -> Category | None: + async def get_category_by_id(self, category_id: CategoryId) -> Category | None: + raise NotImplementedError + + +@runtime_checkable +class CategoryFinder(Protocol): + @abstractmethod + async def find_category( + self, + name: SentinelOptional[str] = Sentinel, + kind: SentinelOptional[str] = Sentinel, + user_id: SentinelOptional[UserId] = Sentinel + ) -> Category | None: raise NotImplementedError @@ -41,7 +54,7 @@ async def update_category(self, category_id: CategoryId, category: Category) -> @runtime_checkable -class CategoryFinder(Protocol): +class CategoriesFinder(Protocol): @abstractmethod async def find_categories_by_mcc_codes(self, mcc_codes: tuple[int, ...]) -> dict[int, Category]: raise NotImplementedError diff --git a/src/costy/application/common/category/dto.py b/src/costy/application/common/category/dto.py index 6fb17fc..b3a94bc 100644 --- a/src/costy/application/common/category/dto.py +++ b/src/costy/application/common/category/dto.py @@ -1,11 +1,13 @@ from dataclasses import dataclass -from costy.domain.models.category import CategoryId, CategoryType +from costy.domain.models.category import CategoryId +from costy.domain.sentinel import Sentinel, SentinelOptional -@dataclass +@dataclass(slots=True, kw_only=True) class NewCategoryDTO: name: str + view: dict | None = None @dataclass @@ -13,16 +15,10 @@ class ReadAvailableCategoriesDTO: ... -@dataclass -class CategoryDTO: - id: CategoryId | None - name: str - kind: CategoryType - - -@dataclass +@dataclass(slots=True, kw_only=True) class UpdateCategoryData: - name: str + name: str | None = None + view: SentinelOptional[dict] = Sentinel @dataclass diff --git a/src/costy/domain/models/category.py b/src/costy/domain/models/category.py index 8987a54..77329ee 100644 --- a/src/costy/domain/models/category.py +++ b/src/costy/domain/models/category.py @@ -10,7 +10,6 @@ class CategoryType(Enum): GENERAL = "general" PERSONAL = "personal" - BANK = "bank" @dataclass(slots=True, kw_only=True) @@ -19,4 +18,4 @@ class Category: name: str kind: str = CategoryType.GENERAL.value user_id: UserId | None = None - mcc: int | None = None + view: dict | None = None diff --git a/src/costy/domain/sentinel.py b/src/costy/domain/sentinel.py index ad6ade3..fbaed29 100644 --- a/src/costy/domain/sentinel.py +++ b/src/costy/domain/sentinel.py @@ -1,2 +1,9 @@ +from typing import TypeAlias, TypeVar + + class Sentinel: pass + + +ParamT = TypeVar("ParamT", contravariant=True) +SentinelOptional: TypeAlias = ParamT | None | type[Sentinel] diff --git a/src/costy/domain/services/category.py b/src/costy/domain/services/category.py index d6fce90..e55e8cc 100644 --- a/src/costy/domain/services/category.py +++ b/src/costy/domain/services/category.py @@ -1,10 +1,25 @@ from costy.domain.models.category import Category, CategoryType from costy.domain.models.user import UserId +from costy.domain.sentinel import Sentinel, SentinelOptional class CategoryService: - def create(self, name: str, kind: CategoryType, user_id: UserId) -> Category: - return Category(id=None, name=name, kind=kind.value, user_id=user_id) + def create( + self, + name: str, + kind: CategoryType, + user_id: UserId, + view: dict | None + ) -> Category: + return Category(id=None, name=name, kind=kind.value, user_id=user_id, view=view) - def update(self, category: Category, name: str) -> None: - category.name = name + def update( + self, + category: Category, + name: str | None, + view: SentinelOptional[dict] + ) -> None: + if name: + category.name = name + if view is not Sentinel: + category.view = view # type: ignore diff --git a/src/costy/domain/services/operation.py b/src/costy/domain/services/operation.py index 8412017..76699ec 100644 --- a/src/costy/domain/services/operation.py +++ b/src/costy/domain/services/operation.py @@ -44,6 +44,5 @@ def update( for name, value in params.items(): setattr(operation, name, value) - def set_category(self, operation: Operation, category: Category | type[Sentinel]) -> None: - if category is not Sentinel: - operation.category_id = category.id # type: ignore + def set_category(self, operation: Operation, category: Category) -> None: + operation.category_id = category.id # type: ignore diff --git a/src/costy/infrastructure/db/_default_categories.json b/src/costy/infrastructure/db/_default_categories.json new file mode 100644 index 0000000..d1cc0d6 --- /dev/null +++ b/src/costy/infrastructure/db/_default_categories.json @@ -0,0 +1,1198 @@ +[ + { + "name": "Авто", + "view": { + "icon_name": "car", + "icon_color": "#7c7c7d" + }, + "mcc": [ + 5013, + 5531, + 5172, + 5511, + 5521, + 5561, + 5571, + 5592, + 5598, + 5599, + 5532, + 5533, + 5541, + 7538, + 7534, + 7535, + 7542, + 7549 + ] + }, + { + "name": "АЗС", + "view": { + "icon_name": "gasoline", + "icon_color": "#7f821d" + }, + "mcc": [ + 5172, + 5299, + 5542, + 5983 + ] + }, + { + "name": "Благодійність", + "view": { + "icon_name": "heart", + "icon_color": "#457bd1" + }, + "mcc": [ + 8398 + ] + }, + { + "name": "Податки", + "view": { + "icon_name": "law", + "icon_color": "#28c97c" + } + }, + { + "name": "Вантажні перевезення", + "view": { + "icon_name": "truck", + "icon_color": "#a3a3a3" + }, + "mcc": [ + 4214 + ] + }, + { + "name": "Готівка", + "view": { + "icon_name": "dollar", + "icon_color": "#006915" + }, + "mcc": [ + 3882 + ] + }, + { + "name": "Переказ коштів", + "view": { + "icon_name": "dollar", + "icon_color": "#04c92c" + }, + "mcc": [ + 4829, + 6531, + 6532, + 6533, + 6534, + 6535, + 6536, + 6537, + 6538, + 6539, + 6540, + 6611 + ] + }, + { + "name": "Інвестиції", + "view": { + "icon_name": "dollar", + "icon_color": "#003a9e" + } + }, + { + "name": "Інше", + "view": { + "icon_name": "info", + "icon_color": "#575757" + } + }, + { + "name": "Канцтовари", + "view": { + "icon_name": "pen", + "icon_color": "#d9324b" + }, + "mcc": [ + 5111, + 5943 + ] + }, + { + "name": "Кафе та ресторани", + "view": { + "icon_name": "food", + "icon_color": "#741cb8" + }, + "mcc": [ + 5309, + 5462, + 5715, + 5921, + 5812, + 5813, + 5814 + ] + }, + { + "name": "Квіти", + "view": { + "icon_name": "flower", + "icon_color": "#a918c9" + }, + "mcc": [ + 5193, + 5992 + ] + }, + { + "name": "Кіно", + "view": { + "icon_name": "ticket", + "icon_color": "#cc861d" + }, + "mcc": [ + 7832, + 7833 + ] + }, + { + "name": "Книги", + "view": { + "icon_name": "book", + "icon_color": "#f55192" + } + }, + { + "name": "Комуналка та інтернет", + "view": { + "icon_name": "house", + "icon_color": "#51a8f5" + } + }, + { + "name": "Краса та здоров'я", + "view": { + "icon_name": "heart", + "icon_color": "#ed0ea3" + }, + "mcc": [ + 5122, + 5698, + 5912, + 5975, + 5976, + 5977, + 7280, + 8062, + 7297, + 7298, + 8011, + 8031, + 8021, + 8041, + 8042, + 8043, + 8044, + 8049, + 8071, + 8099 + ] + }, + { + "name": "Кур'єрські послуги", + "view": { + "icon_name": "mail", + "icon_color": "#807e7f" + }, + "mcc": [ + 4215, + 5811 + ] + }, + { + "name": "Одяг та взуття", + "view": { + "icon_name": "clothes", + "icon_color": "#db4632" + }, + "mcc": [ + 5137, + 5651, + 5691, + 5139, + 5661, + 5611, + 5621, + 5631, + 5641, + 5655, + 5681, + 5697, + 5931, + 7251 + ] + }, + { + "name": "Побутова техніка", + "view": { + "icon_name": "washing-machine", + "icon_color": "#db6e32" + }, + "mcc": [ + 5722, + 5732 + ] + }, + { + "name": "Погашення кредиту", + "view": { + "icon_name": "dollar", + "icon_color": "#166335" + } + }, + { + "name": "Подорожі", + "view": { + "icon_name": "travel", + "icon_color": "#44c276" + }, + "mcc": [ + 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, + 4411, + 3124, + 4582, + 3125, + 4722, + 7991, + 3126, + 4723, + 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, + 5962 + ] + }, + { + "name": "Поповнення мобільного", + "view": { + "icon_name": "phone", + "icon_color": "#445575" + }, + "mcc": [ + 4814 + ] + }, + { + "name": "Продукти та супермаркети", + "view": { + "icon_name": "shopping-cart", + "icon_color": "#d49d3d" + }, + "mcc": [ + 5262, + 5297, + 5298, + 5311, + 5411, + 5499, + 5422, + 5441 + ] + }, + { + "name": "Ремонт", + "view": { + "icon_name": "hammer", + "icon_color": "#457a73" + }, + "mcc": [ + 1520, + 1711, + 1731, + 1740, + 1761, + 1771, + 1799, + 5039, + 5211, + 5051, + 5065, + 5074, + 5231, + 7699, + 7622, + 7629, + 7641 + ] + }, + { + "name": "Товари для дому", + "view": { + "icon_name": "taxi", + "icon_color": "#d1aa1b" + }, + "mcc": [ + 5251, + 5200, + 5261, + 5451 + ] + }, + { + "name": "Розваги та спорт", + "view": { + "icon_name": "ball", + "icon_color": "#1769cf" + }, + "mcc": [ + 5816, + 5940, + 5941, + 5945, + 5971, + 5994, + 5996, + 7802, + 9754, + 7801, + 7932, + 7933, + 7941, + 7992, + 7993, + 7994, + 7996, + 7997, + 7999, + 7998, + 8675 + ] + }, + { + "name": "Таксі", + "view": { + "icon_name": "taxi", + "icon_color": "#d1aa1b" + }, + "mcc": [ + 4121 + ] + }, + { + "name": "Тварини", + "view": { + "icon_name": "dog", + "icon_color": "#967d18" + }, + "mcc": [ + 742, + 5995 + ] + }, + { + "name": "Штрафи", + "view": { + "icon_name": "law", + "icon_color": "#ed282f" + }, + "mcc": [ + 9222 + ] + }, + { + "name": "Ювелірні вироби", + "view": { + "icon_name": "watch", + "icon_color": "#b5cc23" + }, + "mcc": [ + 5094, + 5933 + ] + }, + { + "name": "Громадський транспорт", + "view": { + "icon_name": "bus", + "icon_color": "#66645b" + }, + "mcc": [ + 4011, + 4789, + 4111, + 4112, + 4131 + ] + }, + { + "name": "Цифрові товари", + "view": { + "icon_name": "internet", + "icon_color": "#1583c2" + }, + "mcc": [ + 5968, + 7311, + 7372, + 7829, + 7841, + 7801 + ] + }, + { + "name": "Освіта", + "view": { + "icon_name": "education-cap", + "icon_color": "#363636" + }, + "mcc": [ + 8211, + 8220, + 8241, + 8244, + 8249, + 8299 + ] + }, + { + "name": "Пошта", + "view": { + "icon_name": "mail", + "icon_color": "#0087a6" + } + } +] diff --git a/src/costy/infrastructure/db/migrations/env.py b/src/costy/infrastructure/db/migrations/env.py index 033eaad..408685a 100644 --- a/src/costy/infrastructure/db/migrations/env.py +++ b/src/costy/infrastructure/db/migrations/env.py @@ -5,7 +5,7 @@ from costy.infrastructure.config import get_db_connection_url from costy.infrastructure.db.main import get_metadata -from costy.infrastructure.db.orm import create_tables +from costy.infrastructure.db.tables import create_tables # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/src/costy/infrastructure/db/migrations/versions/a1d7fb181137_category_mcc.py b/src/costy/infrastructure/db/migrations/versions/a1d7fb181137_category_mcc.py new file mode 100644 index 0000000..3d8e756 --- /dev/null +++ b/src/costy/infrastructure/db/migrations/versions/a1d7fb181137_category_mcc.py @@ -0,0 +1,89 @@ +"""category_mcc + +Revision ID: a1d7fb181137 +Revises: 8f0d001e35c6 +Create Date: 2024-03-24 23:53:50.157079 + +""" +import json +from importlib import resources +from operator import itemgetter +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import select + +from costy.infrastructure.db.main import get_metadata +from costy.infrastructure.db.tables import create_tables + +# revision identifiers, used by Alembic. +revision: str = 'a1d7fb181137' +down_revision: Union[str, None] = '8f0d001e35c6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('category_mcc', + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('mcc', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ) + ) + op.drop_column('categories', 'mcc') + # ### end Alembic commands ### + + with open(str(resources.files("costy.infrastructure.db") / "_default_categories.json")) as f: + data = json.load(f) + + conn = op.get_bind() + metadata = get_metadata() + tables = create_tables(metadata) + + bank_category_names = set(item["name"] for item in data) + + existed_bank_category_names = set( + map( + itemgetter(0), + conn.execute( + select(tables['categories'].c.name) + .where(tables['categories'].c.kind == "general") + .where(tables['categories'].c.name.in_(bank_category_names)) + ) + ) + ) + + op.bulk_insert( + tables['categories'], + [{ + "name": category_name, + "kind": "general", + "user_id": None, + } for category_name in (bank_category_names - existed_bank_category_names)] + ) + + categories = conn.execute( + select(tables['categories'].c.name, tables['categories'].c.id) + .where(tables['categories'].c.kind == "general") + ) + + data = {item["name"]: item for item in data} + + op.bulk_insert( + tables['category_mcc'], + [ + { + "category_id": category_id, + "mcc": mcc + } + for category_name, category_id in categories for mcc in data[category_name].get('mcc', []) + ] + ) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('categories', sa.Column('mcc', sa.INTEGER(), autoincrement=False, nullable=True)) + op.drop_table('category_mcc') + # ### end Alembic commands ### diff --git a/src/costy/infrastructure/db/migrations/versions/f1c4a04700d3_init_tables.py b/src/costy/infrastructure/db/migrations/versions/f1c4a04700d3_init_tables.py index 43754e9..1127e57 100644 --- a/src/costy/infrastructure/db/migrations/versions/f1c4a04700d3_init_tables.py +++ b/src/costy/infrastructure/db/migrations/versions/f1c4a04700d3_init_tables.py @@ -5,11 +5,16 @@ Create Date: 2024-01-30 22:55:38.115617 """ +import json +from importlib import resources from typing import Sequence, Union import sqlalchemy as sa from alembic import op +from costy.infrastructure.db.main import get_metadata +from costy.infrastructure.db.tables import create_tables + # revision identifiers, used by Alembic. revision: str = 'f1c4a04700d3' down_revision: Union[str, None] = None @@ -31,6 +36,7 @@ def upgrade() -> None: sa.Column('name', sa.String(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('kind', sa.String(), nullable=True), + sa.Column('view', sa.JSON(), nullable=True), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), sa.PrimaryKeyConstraint('id') ) @@ -47,6 +53,23 @@ def upgrade() -> None: ) # ### end Alembic commands ### + metadata = get_metadata() + tables = create_tables(metadata) + + with open(str(resources.files("costy.infrastructure.db") / "_default_categories.json")) as f: + categories_data: list[dict] = json.load(f) + + categories = [ + { + "name": item["name"], + "kind": "general", + "user_id": None, + "view": item["view"] + } for item in categories_data + ] + + op.bulk_insert(tables["categories"], categories) + def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### diff --git a/src/costy/infrastructure/db/orm.py b/src/costy/infrastructure/db/tables.py similarity index 83% rename from src/costy/infrastructure/db/orm.py rename to src/costy/infrastructure/db/tables.py index a99b086..9ba3b01 100644 --- a/src/costy/infrastructure/db/orm.py +++ b/src/costy/infrastructure/db/tables.py @@ -40,7 +40,7 @@ def create_tables(metadata: MetaData) -> dict[str, Table]: Column("name", String, nullable=False), Column("user_id", Integer, ForeignKey("users.id"), nullable=True), Column("kind", String, default="general"), - Column("mcc", Integer, nullable=True), + Column("view", JSON, nullable=True) ), "bankapis": Table( "bankapis", @@ -49,6 +49,12 @@ def create_tables(metadata: MetaData) -> dict[str, Table]: Column("name", String), Column("access_data", JSON), Column("updated_at", Integer, nullable=True), - Column("user_id", Integer, ForeignKey("users.id")) + Column("user_id", Integer, ForeignKey("users.id")), ), + "category_mcc": Table( + "category_mcc", + metadata, + Column("category_id", Integer, ForeignKey("categories.id")), + Column("mcc", Integer) + ) } diff --git a/src/costy/main/ioc.py b/src/costy/main/ioc.py index f0d672e..c0f1963 100644 --- a/src/costy/main/ioc.py +++ b/src/costy/main/ioc.py @@ -158,7 +158,12 @@ async def create_category( id_provider.user_gateway = depends.user_gateway # type: ignore yield CreateCategory( CategoryService(), - CategoryGateway(depends.session, self._tables["categories"], self._retort), + CategoryGateway( + depends.session, + self._tables["categories"], + self._tables["category_mcc"], + self._retort + ), id_provider, depends.uow ) @@ -171,7 +176,12 @@ async def delete_category( id_provider.user_gateway = depends.user_gateway # type: ignore yield DeleteCategory( AccessService(), - CategoryGateway(depends.session, self._tables["categories"], self._retort), + CategoryGateway( + depends.session, + self._tables["categories"], + self._tables["category_mcc"], + self._retort + ), id_provider, depends.uow ) @@ -185,7 +195,12 @@ async def update_category( yield UpdateCategory( CategoryService(), AccessService(), - CategoryGateway(depends.session, self._tables["categories"], self._retort), + CategoryGateway( + depends.session, + self._tables["categories"], + self._tables["category_mcc"], + self._retort + ), id_provider, depends.uow ) @@ -198,7 +213,12 @@ async def read_available_categories( id_provider.user_gateway = depends.user_gateway # type: ignore yield ReadAvailableCategories( CategoryService(), - CategoryGateway(depends.session, self._tables["categories"], self._retort), + CategoryGateway( + depends.session, + self._tables["categories"], + self._tables["category_mcc"], + self._retort + ), id_provider, depends.uow ) @@ -279,7 +299,12 @@ async def update_bank_operations( self._banks_conf ), OperationGateway(depends.session, self._tables["operations"], self._retort), - CategoryGateway(depends.session, self._tables["categories"], self._retort), + CategoryGateway( + depends.session, + self._tables["categories"], + self._tables["category_mcc"], + self._retort + ), id_provider, depends.uow, ) diff --git a/src/costy/main/web.py b/src/costy/main/web.py index 0494b29..9829fed 100644 --- a/src/costy/main/web.py +++ b/src/costy/main/web.py @@ -3,6 +3,7 @@ from adaptix import Retort from httpx import AsyncClient from litestar import Litestar +from litestar.config.cors import CORSConfig from litestar.di import Provide from costy.domain.exceptions.base import BaseError @@ -17,7 +18,7 @@ get_metadata, get_sessionmaker, ) -from costy.infrastructure.db.orm import create_tables +from costy.infrastructure.db.tables import create_tables from costy.main.ioc import IoC from costy.presentation.api.dependencies.id_provider import get_id_provider from costy.presentation.api.exception_handlers import base_error_handler @@ -59,6 +60,8 @@ def init_app() -> Litestar: web_session ) + cors_config = CORSConfig(allow_origins=["*"]) + async def finalization(): await web_session.aclose() @@ -79,5 +82,6 @@ async def finalization(): exception_handlers={ BaseError: base_error_handler }, - debug=True + debug=True, + cors_config=cors_config ) diff --git a/src/costy/presentation/api/routers/category.py b/src/costy/presentation/api/routers/category.py index 2bffe41..424c191 100644 --- a/src/costy/presentation/api/routers/category.py +++ b/src/costy/presentation/api/routers/category.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass, field + from litestar import Controller, delete, get, post, put from costy.application.common.category.dto import ( @@ -7,9 +9,16 @@ ) from costy.application.common.id_provider import IdProvider from costy.domain.models.category import Category, CategoryId +from costy.domain.sentinel import Sentinel from costy.presentation.interactor_factory import InteractorFactory +@dataclass(slots=True, kw_only=True) +class UpdateCategoryPureData: + name: str | None = None + view: dict | None = field(default_factory=dict) + + class CategoryController(Controller): path = '/categories' tags = ("Categories",) @@ -51,8 +60,14 @@ async def update_category( category_id: int, ioc: InteractorFactory, id_provider: IdProvider, - data: UpdateCategoryData, + data: UpdateCategoryPureData, ) -> None: async with ioc.update_category(id_provider) as update_category: - request_data = UpdateCategoryDTO(CategoryId(category_id), data) - await update_category(request_data) + input_data = UpdateCategoryDTO( + CategoryId(category_id), + UpdateCategoryData( + name=data.name, + view=data.view if data.view != {} else Sentinel + ) + ) + await update_category(input_data) diff --git a/src/costy/presentation/api/routers/operation.py b/src/costy/presentation/api/routers/operation.py index f237ab5..9b3c194 100644 --- a/src/costy/presentation/api/routers/operation.py +++ b/src/costy/presentation/api/routers/operation.py @@ -15,7 +15,7 @@ from costy.presentation.interactor_factory import InteractorFactory -@dataclass(kw_only=True) +@dataclass(slots=True, kw_only=True) class UpdateOperationPureData: """Dataclass without user defined types for OpenAPI""" amount: int | None = None @@ -73,7 +73,7 @@ async def update_operation( amount=pure_data.amount, description=pure_data.description if pure_data.description != "" else Sentinel, time=pure_data.time, - category_id=pure_data.category_id if pure_data.category_id is not None else Sentinel + category_id=pure_data.category_id or Sentinel ) request_data = UpdateOperationDTO(OperationId(operation_id), data) await update_operation(request_data) diff --git a/tests/adapters/test_category_adapter.py b/tests/adapters/test_category_adapter.py index c7ad32f..ff85b3f 100644 --- a/tests/adapters/test_category_adapter.py +++ b/tests/adapters/test_category_adapter.py @@ -1,4 +1,5 @@ import pytest +from sqlalchemy import insert from costy.domain.models.category import Category, CategoryType from costy.domain.models.user import UserId @@ -36,8 +37,8 @@ async def test_get_category(category_gateway, db_session, db_tables): await category_gateway.save_category(general_category) await category_gateway.save_category(personal_category) - created_general_category = await category_gateway.get_category(general_category.id) - created_personal_category = await category_gateway.get_category(personal_category.id) + created_general_category = await category_gateway.get_category_by_id(general_category.id) + created_personal_category = await category_gateway.get_category_by_id(personal_category.id) assert general_category == created_general_category assert personal_category == created_personal_category @@ -53,8 +54,8 @@ async def test_delete_category(category_gateway, db_session, db_tables): await category_gateway.delete_category(general_category.id) await category_gateway.delete_category(personal_category.id) - assert await category_gateway.get_category(general_category.id) is None - assert await category_gateway.get_category(personal_category.id) is None + assert await category_gateway.get_category_by_id(general_category.id) is None + assert await category_gateway.get_category_by_id(personal_category.id) is None @pytest.mark.asyncio @@ -90,7 +91,7 @@ async def test_update_category(category_gateway, db_session, db_tables): await category_gateway.update_category(category.id, updated_category) - assert await category_gateway.get_category(category.id) == updated_category + assert await category_gateway.get_category_by_id(category.id) == updated_category @pytest.mark.asyncio @@ -99,13 +100,20 @@ async def test_find_categories_by_mcc(category_gateway, db_session, db_tables): categories = [ Category( name=f"test #{mcc_code}", - kind=CategoryType.BANK.value, - mcc=mcc_code + kind=CategoryType.GENERAL.value, ) for mcc_code in mcc_codes ] for category in categories: await category_gateway.save_category(category) + await db_session.execute( + insert(db_tables["category_mcc"]), + [ + {"category_id": c.id, "mcc": mcc} + for c, mcc in zip(categories, mcc_codes) + ], + ) + mcc_codes.append(3) result = await category_gateway.find_categories_by_mcc_codes(mcc_codes) - assert result == {category.mcc: category for category in categories} + assert result == {mcc: category for category, mcc in zip(categories, mcc_codes)} diff --git a/tests/application/bankapi/test_update_bank_operations.py b/tests/application/bankapi/test_update_bank_operations.py index f440b7a..b63695e 100644 --- a/tests/application/bankapi/test_update_bank_operations.py +++ b/tests/application/bankapi/test_update_bank_operations.py @@ -8,9 +8,10 @@ UpdateBankOperations, ) from costy.application.common.bankapi.dto import BankOperationDTO +from costy.application.common.category.category_gateway import CategoryFinder from costy.application.common.uow import UoW from costy.domain.models.bankapi import BankAPI, BankApiId -from costy.domain.models.category import Category +from costy.domain.models.category import Category, CategoryId from costy.domain.models.operation import Operation from costy.domain.services.bankapi import BankAPIService from costy.domain.services.operation import OperationService @@ -26,7 +27,8 @@ async def interactor(id_provider, user_id, existing_mcc) -> UpdateBankOperations bankapi_gateway = Mock() bankapi_gateway.operations = [] operation_gateway = Mock() - category_gateway = Mock() + category_gateway = Mock(spec=CategoryFinder) + category_gateway.find_category.return_value = Category(id=CategoryId(9999), name="Other") mcc_list = existing_mcc + [4, 5] async def get_bankapi_list(*args, **kwargs): @@ -79,7 +81,6 @@ async def find_categories(mcc_codes): mcc: Category( id=mcc * 10, name="test", - mcc=mcc ) for mcc in mcc_codes if mcc in existing_mcc } @@ -107,6 +108,6 @@ async def test_update_bank_operations(interactor, existing_mcc): ( bank_operation.mcc in existing_mcc and operation.category_id ) or ( - bank_operation.mcc not in existing_mcc and not operation.category_id + bank_operation.mcc not in existing_mcc and operation.category_id == 9999 ) for bank_operation, operation in zip(bank_operations, result) ) diff --git a/tests/application/category/test_update_category.py b/tests/application/category/test_update_category.py index ba2a199..e6c2eb5 100644 --- a/tests/application/category/test_update_category.py +++ b/tests/application/category/test_update_category.py @@ -25,7 +25,7 @@ async def interactor( id_provider ) -> UpdateCategory: category_gateway = Mock(spec=CategoryGateway) - category_gateway.get_category.return_value = Category( + category_gateway.get_category_by_id.return_value = Category( id=category_id, name="test", user_id=user_id diff --git a/tests/common/adapters.py b/tests/common/adapters.py index d617d9d..6ab984e 100644 --- a/tests/common/adapters.py +++ b/tests/common/adapters.py @@ -36,7 +36,7 @@ async def user_gateway(db_session, db_tables, retort) -> UserGateway: @fixture async def category_gateway(db_session, db_tables, retort) -> CategoryGateway: - return CategoryGateway(db_session, db_tables["categories"], retort) + return CategoryGateway(db_session, db_tables["categories"], db_tables["category_mcc"], retort) @fixture diff --git a/tests/common/app.py b/tests/common/app.py index 5eb8be9..6eb61f9 100644 --- a/tests/common/app.py +++ b/tests/common/app.py @@ -21,7 +21,7 @@ get_metadata, get_sessionmaker, ) -from costy.infrastructure.db.orm import create_tables +from costy.infrastructure.db.tables import create_tables from costy.main.ioc import IoC from costy.main.web import singleton from costy.presentation.api.dependencies.id_provider import get_id_provider diff --git a/tests/common/data.py b/tests/common/data.py index 5785e13..0ae0160 100644 --- a/tests/common/data.py +++ b/tests/common/data.py @@ -42,7 +42,7 @@ async def user_entity() -> User: @fixture async def category_info() -> NewCategoryDTO: - return NewCategoryDTO("test") + return NewCategoryDTO(name="test", view=None) @fixture diff --git a/tests/common/infrastructure.py b/tests/common/infrastructure.py index 87afc17..222099d 100644 --- a/tests/common/infrastructure.py +++ b/tests/common/infrastructure.py @@ -17,7 +17,7 @@ ) from costy.infrastructure.db.main import get_metadata -from costy.infrastructure.db.orm import create_tables +from costy.infrastructure.db.tables import create_tables from tests.common.app import init_test_app diff --git a/tests/domain/test_create.py b/tests/domain/test_create.py index aa9b486..81a9120 100644 --- a/tests/domain/test_create.py +++ b/tests/domain/test_create.py @@ -31,8 +31,8 @@ ), ( CategoryService(), - ("test", CategoryType.GENERAL, UserId(9999)), - Category(id=None, name="test", kind=CategoryType.GENERAL.value, user_id=UserId(9999), mcc=None) + ("test", CategoryType.GENERAL, UserId(9999), None), + Category(id=None, name="test", kind=CategoryType.GENERAL.value, user_id=UserId(9999), view=None) ), ( BankAPIService(), diff --git a/tests/domain/test_update.py b/tests/domain/test_update.py index 3d5a3d1..fddc316 100644 --- a/tests/domain/test_update.py +++ b/tests/domain/test_update.py @@ -31,7 +31,7 @@ ( CategoryService(), Category(id=None, name="test", kind=CategoryType.GENERAL.value, user_id=UserId(9999)), - ("test_new",), + ("test_new", None), Category(id=None, name="test_new", kind=CategoryType.GENERAL.value, user_id=UserId(9999)) ), ])