Skip to content

Commit

Permalink
Merge pull request #66 from AndrewSergienko/2.x/develop
Browse files Browse the repository at this point in the history
2.x/develop
  • Loading branch information
andiserg authored Mar 27, 2024
2 parents 5992e9b + 49417da commit 964d06f
Show file tree
Hide file tree
Showing 31 changed files with 1,537 additions and 85 deletions.
18 changes: 18 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<p align="center">
<a href="https://ibb.co/KyLXkwW"><img src="https://i.ibb.co/9YVNLtW/full-logo.png" alt="full-logo" border="0"></a>
</p>

<p align="center">
<a href="https://codecov.io/gh/AndrewSergienko/costy" >
<img src="https://codecov.io/gh/AndrewSergienko/costy/graph/badge.svg?token=YQLTZLXL56"/>
</a>
<a href="https://github.com/AndrewSergienko/costy/actions/workflows/tests.yaml" >
<img src="https://github.com/AndrewSergienko/costy/actions/workflows/tests.yaml/badge.svg?branch=2.x%2Fmain"/>
</a>
<img src="https://img.shields.io/badge/python-3.10-blue" alt="Python Version">
</p>

## 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.
4 changes: 2 additions & 2 deletions src/costy/adapters/bankapi/_mcc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"Ветеринарні послуги": [
"Ветеринар": [
742
],
"Виноробники": [
Expand All @@ -8,7 +8,7 @@
"Виробники шампанського": [
744
],
"Сільскогосподарські кооперативи": [
"Сільске господарство ": [
763
],
"Садівництво. Ландшафтний дизайн": [
Expand Down
63 changes: 48 additions & 15 deletions src/costy/adapters/db/category_gateway.py
Original file line number Diff line number Diff line change
@@ -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])

Expand All @@ -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])
17 changes: 10 additions & 7 deletions src/costy/application/bankapi/update_bank_operations.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
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 (
BankAPIBulkUpdater,
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
Expand All @@ -20,14 +19,18 @@ class BankAPIGateway(BankAPIBulkUpdater, BanksAPIReader, BankAPIOperationsReader
pass


class CategoryGateway(CategoriesFinder, CategoryFinder, Protocol):
pass


class UpdateBankOperations(Interactor[None, None]):
def __init__(
self,
bankapi_service: BankAPIService,
operation_service: OperationService,
bankapi_gateway: BankAPIGateway,
operation_gateway: OperationsBulkSaver,
category_gateway: CategoryFinder,
category_gateway: CategoryGateway,
id_provider: IdProvider,
uow: UoW
):
Expand All @@ -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:
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/costy/application/category/create_category.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/costy/application/category/delete_category.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions src/costy/application/category/update_category.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ 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")

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()
17 changes: 15 additions & 2 deletions src/costy/application/common/category/category_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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
18 changes: 7 additions & 11 deletions src/costy/application/common/category/dto.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
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
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
Expand Down
3 changes: 1 addition & 2 deletions src/costy/domain/models/category.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
class CategoryType(Enum):
GENERAL = "general"
PERSONAL = "personal"
BANK = "bank"


@dataclass(slots=True, kw_only=True)
Expand All @@ -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
7 changes: 7 additions & 0 deletions src/costy/domain/sentinel.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
from typing import TypeAlias, TypeVar


class Sentinel:
pass


ParamT = TypeVar("ParamT", contravariant=True)
SentinelOptional: TypeAlias = ParamT | None | type[Sentinel]
23 changes: 19 additions & 4 deletions src/costy/domain/services/category.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 2 additions & 3 deletions src/costy/domain/services/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 964d06f

Please sign in to comment.