Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add balance cache #3

Merged
merged 11 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
*.pyc
__pycache__/
*.db
secrets.env
255 changes: 57 additions & 198 deletions Pipfile.lock

Large diffs are not rendered by default.

30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,21 @@ sum of all transactions. both confirmed and not. separated.
mark entities and transactions for quick search.

### security
`X-Token` header is used for authentication.

token will be sent to `telegram_id` of an entity. newly generated tokens do not revoke old ones.
- `X-Token` header is used for authentication.
- you may request a new token any time: `POST /tokens/send` with `name`, `id` or `telegram_id` of your entity — anything you remember.
- token (link) will be sent to `telegram_id` of the entity. newly generated tokens do NOT revoke old ones.

you may request a new token any time: with `name`, `id` or `telegram_id` of your entity — anything you remember.

> token — [jwt](http://jwt.io) with entity id & timestamp inside. basically it's a server-signed & verifiable json, base64'd.
> token — [jwt](http://jwt.io) string with entity id & timestamp inside. basically `base64(sign(json(id=123, date=now())))`.

## run
```console
docker compose up
```
API: http://localhost:8000/docs
UI: http://localhost:5000


## develop
### development
run backend & frontend with live code reload:
```
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
```
open http://localhost:8000/docs and http://localhost:5000
open http://localhost:8000/docs and http://localhost:9000

create local environment with all dependencies:
```console
Expand All @@ -65,6 +58,15 @@ cd api
pytest
```

### production
put secrets into `secrets.env`. see `secrets.env.example` as a reference.

```console
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
API: http://0.0.0.0:8000/docs
UI: http://0.0.0.0:9000


## todo
- [x] base classes
Expand All @@ -75,7 +77,7 @@ pytest
- [x] tags
- [x] transactions
- [x] balances
- [ ] balance cache
- [x] balance cache
- [x] date range search
- [ ] recurrent payments
- [ ] donation categories
Expand Down
9 changes: 5 additions & 4 deletions api/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

import logging

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import SQLAlchemyError

from app.config import Config, get_config
from app.errors.base import ApplicationError
from app.routes.balance import balance_router
from app.routes.entity import entity_router
from app.routes.tag import tag_router
from app.routes.token import token_router
from app.routes.transaction import transaction_router
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import SQLAlchemyError

logger = logging.getLogger(__name__)

Expand Down
5 changes: 3 additions & 2 deletions api/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

import os

from app.config import Config, get_config
from app.models.base import BaseModel
from fastapi import Depends
from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import Session, sessionmaker

from app.config import Config, get_config
from app.models.base import BaseModel


class DatabaseConnection:
engine: Engine
Expand Down
3 changes: 2 additions & 1 deletion api/app/middlewares/token.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Middleware for Entity authentication"""

from app.services.token import TokenService
from fastapi import Depends, Header

from app.services.token import TokenService


def get_entity_from_token(
x_token: str = Header(
Expand Down
5 changes: 3 additions & 2 deletions api/app/models/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

from typing import List

from app.models.base import BaseModel
from app.models.tag import Tag
from sqlalchemy import Column, ForeignKey, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.models.base import BaseModel
from app.models.tag import Tag

entities_tags = Table(
"entities_tags",
BaseModel.metadata,
Expand Down
3 changes: 2 additions & 1 deletion api/app/models/tag.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Tag model"""

from app.models.base import BaseModel
from sqlalchemy.orm import Mapped, mapped_column

from app.models.base import BaseModel


class Tag(BaseModel):
__tablename__ = "tags"
Expand Down
5 changes: 3 additions & 2 deletions api/app/models/transaction.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Transaction model"""

from sqlalchemy import DECIMAL, Column, ForeignKey, String, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.models.base import BaseModel
from app.models.entity import Entity
from app.models.tag import Tag
from sqlalchemy import DECIMAL, Column, ForeignKey, String, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship

transactions_tags = Table(
"transactions_tags",
Expand Down
9 changes: 3 additions & 6 deletions api/app/routes/balance.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
"""API routes for Balance observing"""

from datetime import datetime

from fastapi import APIRouter, Depends

from app.middlewares.token import get_entity_from_token
from app.models.entity import Entity
from app.schemas.balance import BalanceSchema
from app.services.balance import BalanceService
from fastapi import APIRouter, Depends

balance_router = APIRouter(prefix="/balances", tags=["Balances"])


@balance_router.get("/{entity_id}", response_model=BalanceSchema)
def get_balance(
entity_id: int,
specific_date: datetime | None = None,
balance_service: BalanceService = Depends(),
actor_entity: Entity = Depends(get_entity_from_token),
):
return balance_service.get_balances(
entity_id=entity_id, specific_date=specific_date
)
return balance_service.get_balances(entity_id=entity_id)
3 changes: 2 additions & 1 deletion api/app/routes/entity.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""API routes for Entity manipulation"""

from fastapi import APIRouter, Depends

from app.errors.entity import EntitiesAlreadyPresent
from app.middlewares.token import get_entity_from_token
from app.models.entity import Entity
Expand All @@ -12,7 +14,6 @@
)
from app.schemas.tag import TagSchema
from app.services.entity import EntityService
from fastapi import APIRouter, Depends

entity_router = APIRouter(prefix="/entities", tags=["Entities"])

Expand Down
3 changes: 2 additions & 1 deletion api/app/routes/tag.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""API routes for Tag manipulation"""

from fastapi import APIRouter, Depends

from app.middlewares.token import get_entity_from_token
from app.models.entity import Entity
from app.schemas.base import PaginationSchema
Expand All @@ -10,7 +12,6 @@
TagUpdateSchema,
)
from app.services.tag import TagService
from fastapi import APIRouter, Depends

tag_router = APIRouter(prefix="/tags", tags=["Tags"])

Expand Down
3 changes: 2 additions & 1 deletion api/app/routes/token.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""API routes for Token manipulation"""

from fastapi import APIRouter, Depends

from app.schemas.token import TokenSendReportSchema
from app.services.token import TokenService
from fastapi import APIRouter, Depends

token_router = APIRouter(prefix="/tokens", tags=["Tokens"])

Expand Down
3 changes: 2 additions & 1 deletion api/app/routes/transaction.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""API routes for Transaction manipulation"""

from fastapi import APIRouter, Depends

from app.middlewares.token import get_entity_from_token
from app.models.entity import Entity
from app.schemas.base import PaginationSchema
Expand All @@ -11,7 +13,6 @@
TransactionUpdateSchema,
)
from app.services.transaction import TransactionService
from fastapi import APIRouter, Depends

transaction_router = APIRouter(prefix="/transactions", tags=["Transactions"])

Expand Down
3 changes: 2 additions & 1 deletion api/app/schemas/mixins/tags_filter_mixin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Mixin of a schema of a model which supports item tagging"""

from app.schemas.base import BaseFilterSchema
from fastapi import Query
from pydantic import Field

from app.schemas.base import BaseFilterSchema


class TagsFilterSchemaMixin(BaseFilterSchema):
tags_ids: list[int] = Field(Query([]))
3 changes: 2 additions & 1 deletion api/app/schemas/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

from decimal import Decimal

from pydantic import field_validator, model_validator

from app.schemas.base import BaseFilterSchema, BaseReadSchema, BaseUpdateSchema
from app.schemas.entity import EntitySchema
from app.schemas.mixins.tags_filter_mixin import TagsFilterSchemaMixin
from app.schemas.tag import TagSchema
from pydantic import field_validator, model_validator


class TransactionSchema(BaseReadSchema):
Expand Down
48 changes: 28 additions & 20 deletions api/app/services/balance.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
"""Balance service"""

from datetime import datetime
from decimal import Decimal

from fastapi import Depends
from sqlalchemy.orm import Session
from sqlalchemy.sql import func, select

from app.db import get_db
from app.models.transaction import Transaction
from app.schemas.balance import BalanceSchema
from app.services.entity import EntityService
from fastapi import Depends
from sqlalchemy.orm import Session
from sqlalchemy.sql import func, select


class BalanceService:
_cache = {}

def __init__(
self,
db: Session = Depends(get_db),
Expand All @@ -21,17 +23,31 @@ def __init__(
self.db = db
self.entity_service = entity_service

def get_balances(
self, entity_id: int, specific_date: datetime | None = None
) -> BalanceSchema:
def invalidate_cache_entry(self, entity_id: int):
self._cache.pop(entity_id, None)

def get_balances(self, entity_id: int) -> BalanceSchema:
"""
Calculates the current balances for a given entity across all currencies.
Only confirmed transactions are considered.
Calculate momentary balances for a given entity across all currencies.
- confirmed and non-confirmed transactions are counted separately.
- currencies are counted separately.

Internal in-RAM cache stores balances of each entity.
Balance cache for a particular entity is invalidated when transaction from/to
entity is created, edited (confirmed) or deleted.
"""
if entity_id in self._cache:
return self._cache[entity_id]
else:
result = self._get_balances(entity_id)
self._cache[entity_id] = result
return result

def _get_balances(self, entity_id: int) -> BalanceSchema:
# Check that entity exists
self.entity_service.get(entity_id)

# Function to process transactions based on confirmation status
# Function to sum transactions based on confirmation status
def sum_transactions(confirmed: bool) -> dict[str, Decimal]:
# Query to get sum of all incoming transactions
credit_query = select(
Expand All @@ -51,15 +67,6 @@ def sum_transactions(confirmed: bool) -> dict[str, Decimal]:
Transaction.confirmed == confirmed,
)

# Date limiter — don't count transactions after this date
if specific_date is not None:
credit_query = credit_query.filter(
Transaction.created_at <= specific_date
)
debit_query = debit_query.filter(
Transaction.created_at <= specific_date
)

# Group sums by currency
credit_query = credit_query.group_by(Transaction.currency)
debit_query = debit_query.group_by(Transaction.currency)
Expand All @@ -86,7 +93,8 @@ def sum_transactions(confirmed: bool) -> dict[str, Decimal]:

return total_by_currency

return BalanceSchema(
result = BalanceSchema(
confirmed=sum_transactions(confirmed=True),
non_confirmed=sum_transactions(confirmed=False),
)
return result
Loading
Loading