Nordigen -> PostgreSQL path completed

Can now download from all banks registered on the banks/nordigen table
and write to the PostgresSQL DB (or any DB, since we're now agnostic).

Commented out most of the managers functions until the integration with
the new DB client is complete.
Set Optional relationships. Remove the DB types as dataclasses, it only
increased the verbosity of the types w/o much going for it.
Change the name Original to Transaction, since the type is the
placeholder for the rest of the transaction information.
This commit is contained in:
Luís Murta 2022-12-03 23:55:37 +00:00
parent 91514f71b1
commit e379d77995
Signed by: satprog
GPG Key ID: 169EF1BBD7049F94
5 changed files with 169 additions and 92 deletions

View File

@ -1,47 +1,51 @@
from pfbudget.input.input import Input from pfbudget.input.input import Input
from pfbudget.input.parsers import parse_data from pfbudget.input.parsers import parse_data
from pfbudget.common.types import Bank, Banks, Transaction, Transactions from pfbudget.db.client import DbClient
from pfbudget.db.sqlite import DatabaseClient
from pfbudget.utils import convert from pfbudget.utils import convert
class Manager: class Manager:
def __init__(self, db: str): def __init__(self, url: str):
self.__db = db self._db = DbClient(url)
def init(self): # def init(self):
client = DatabaseClient(self.__db) # client = DatabaseClient(self.__db)
client.init() # client.init()
def register(self, args: dict): # def register(self, args: dict):
bank = Bank(args["bank"][0], "", args["requisition"][0], args["invert"]) # bank = Bank(args["bank"][0], "", args["requisition"][0], args["invert"])
client = DatabaseClient(self.__db) # client = DatabaseClient(self.__db)
client.register_bank(convert(bank)) # client.register_bank(convert(bank))
def unregister(self, args: dict): # def unregister(self, args: dict):
client = DatabaseClient(self.__db) # client = DatabaseClient(self.__db)
client.unregister_bank(args["bank"][0]) # client.unregister_bank(args["bank"][0])
def parser(self, parser: Input): def parser(self, parser: Input):
transactions = parser.parse() transactions = parser.parse()
self.add_transactions(transactions) print(transactions)
# self.add_transactions(transactions)
def parse(self, filename: str, args: dict): # def parse(self, filename: str, args: dict):
transactions = parse_data(filename, args) # transactions = parse_data(filename, args)
self.add_transactions(transactions) # self.add_transactions(transactions)
def transactions() -> list[Transaction]: # def transactions() -> list[Transaction]:
pass # pass
def add_transactions(self, transactions: Transactions): def add_transactions(self, transactions):
client = DatabaseClient(self.__db) with self.db.session() as session:
client.insert_transactions([convert(t) for t in transactions]) session.add(transactions)
session.commit()
def get_bank_by(self, key: str, value: str) -> Bank: # def get_bank_by(self, key: str, value: str) -> Bank:
client = DatabaseClient(self.__db) # client = DatabaseClient(self.__db)
bank = client.get_bank(key, value) # bank = client.get_bank(key, value)
return convert(bank) # return convert(bank)
def get_banks(self) -> Banks: def get_banks(self):
client = DatabaseClient(self.__db) return self.db.get_nordigen_banks()
return [convert(bank) for bank in client.get_banks()]
@property
def db(self):
return self._db

57
pfbudget/db/client.py Normal file
View File

@ -0,0 +1,57 @@
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, joinedload, selectinload
from pfbudget.db.model import Bank, Transaction
# import logging
# logging.basicConfig()
# logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
class DbClient:
"""
General database client using sqlalchemy
"""
__sessions: list[Session]
def __init__(self, url: str) -> None:
self._engine = create_engine(url)
def get_transactions(self):
"""¿Non-optimized? get_transactions, will load the entire Transaction"""
with Session(self.engine) as session:
stmt = select(Transaction).options(
joinedload("*"), selectinload(Transaction.tags)
)
return session.scalars(stmt).all()
def get_uncategorized(self):
with Session(self.engine) as session:
stmt = select(Transaction).where(~Transaction.category.has())
return session.scalars(stmt).all()
def get_categorized(self):
with Session(self.engine) as session:
stmt = select(Transaction).where(Transaction.category.has())
return session.scalars(stmt).all()
def insert_transactions(self, input: list[Transaction]):
with Session(self.engine) as session:
session.add_all(input)
session.commit()
def get_banks(self):
with Session(self.engine) as session:
stmt = select(Bank)
return session.scalars(stmt).all()
def get_nordigen_banks(self):
with Session(self.engine) as session:
stmt = select(Bank).where(Bank.nordigen.has())
return session.scalars(stmt).all()
@property
def engine(self):
return self._engine

View File

@ -9,13 +9,7 @@ from sqlalchemy import (
String, String,
Text, Text,
) )
from sqlalchemy.orm import ( from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
DeclarativeBase,
Mapped,
mapped_column,
MappedAsDataclass,
relationship,
)
from decimal import Decimal from decimal import Decimal
from typing import Annotated, Optional from typing import Annotated, Optional
@ -23,7 +17,7 @@ import datetime as dt
import enum import enum
class Base(MappedAsDataclass, DeclarativeBase): class Base(DeclarativeBase):
__table_args__ = {"schema": "transactions"} __table_args__ = {"schema": "transactions"}
metadata = MetaData( metadata = MetaData(
naming_convention={ naming_convention={
@ -58,6 +52,13 @@ class Bank(Base):
BIC: Mapped[str] = mapped_column(String(8), primary_key=True) BIC: Mapped[str] = mapped_column(String(8), primary_key=True)
type: Mapped[accounttype] = mapped_column(primary_key=True) type: Mapped[accounttype] = mapped_column(primary_key=True)
nordigen: Mapped[Optional[Nordigen]] = relationship(
back_populates="bank", lazy="joined"
)
def __repr__(self) -> str:
return f"Bank(name={self.name}, BIC={self.BIC}, type={self.type}, nordigen={self.nordigen})"
bankfk = Annotated[str, mapped_column(Text, ForeignKey(Bank.name))] bankfk = Annotated[str, mapped_column(Text, ForeignKey(Bank.name))]
@ -65,7 +66,7 @@ idpk = Annotated[int, mapped_column(BigInteger, primary_key=True)]
money = Annotated[Decimal, mapped_column(Numeric(16, 2), nullable=False)] money = Annotated[Decimal, mapped_column(Numeric(16, 2), nullable=False)]
class Original(Base): class Transaction(Base):
__tablename__ = "originals" __tablename__ = "originals"
id: Mapped[idpk] = mapped_column(autoincrement=True) id: Mapped[idpk] = mapped_column(autoincrement=True)
@ -74,15 +75,20 @@ class Original(Base):
bank: Mapped[bankfk] bank: Mapped[bankfk]
amount: Mapped[money] amount: Mapped[money]
category: Mapped[Category] = relationship(back_populates="original") category: Mapped[Optional[Category]] = relationship(
note: Mapped[Note] = relationship(back_populates="original") back_populates="original", lazy="joined"
tags: Mapped[set[Tag]] = relationship( )
note: Mapped[Optional[Note]] = relationship(back_populates="original")
tags: Mapped[Optional[set[Tag]]] = relationship(
back_populates="original", cascade="all, delete-orphan", passive_deletes=True back_populates="original", cascade="all, delete-orphan", passive_deletes=True
) )
def __repr__(self) -> str:
return f"Transaction(date={self.date}, description={self.description}, bank={self.bank}, amount={self.amount}, category={self.category})"
idfk = Annotated[ idfk = Annotated[
int, mapped_column(BigInteger, ForeignKey(Original.id, ondelete="CASCADE")) int, mapped_column(BigInteger, ForeignKey(Transaction.id, ondelete="CASCADE"))
] ]
@ -92,7 +98,10 @@ class Category(Base):
id: Mapped[idfk] = mapped_column(primary_key=True) id: Mapped[idfk] = mapped_column(primary_key=True)
category: Mapped[str] category: Mapped[str]
original: Mapped[Original] = relationship(back_populates="category") original: Mapped[Transaction] = relationship(back_populates="category")
def __repr__(self) -> str:
return f"Category({self.category})"
class Note(Base): class Note(Base):
@ -101,7 +110,7 @@ class Note(Base):
id: Mapped[idfk] = mapped_column(primary_key=True) id: Mapped[idfk] = mapped_column(primary_key=True)
note: Mapped[str] note: Mapped[str]
original: Mapped[Original] = relationship(back_populates="note") original: Mapped[Transaction] = relationship(back_populates="note")
class Nordigen(Base): class Nordigen(Base):
@ -112,6 +121,11 @@ class Nordigen(Base):
requisition_id: Mapped[Optional[str]] requisition_id: Mapped[Optional[str]]
invert: Mapped[Optional[bool]] invert: Mapped[Optional[bool]]
bank: Mapped[Bank] = relationship(back_populates="nordigen")
def __repr__(self) -> str:
return f"(bank_id={self.bank_id}, requisition_id={self.requisition_id}, invert={self.invert})"
class Tag(Base): class Tag(Base):
__tablename__ = "tags" __tablename__ = "tags"
@ -119,4 +133,4 @@ class Tag(Base):
id: Mapped[idfk] = mapped_column(primary_key=True) id: Mapped[idfk] = mapped_column(primary_key=True)
tag: Mapped[str] = mapped_column(primary_key=True) tag: Mapped[str] = mapped_column(primary_key=True)
original: Mapped[Original] = relationship(back_populates="tags") original: Mapped[Transaction] = relationship(back_populates="tags")

View File

@ -9,7 +9,8 @@ import os
import webbrowser import webbrowser
from .input import Input from .input import Input
from pfbudget.common.types import NoBankSelected, Transactions from pfbudget.common.types import NoBankSelected
from pfbudget.db.model import Transaction
from pfbudget.utils import convert from pfbudget.utils import convert
load_dotenv() load_dotenv()
@ -43,7 +44,7 @@ class NordigenInput(Input):
self.__from = start self.__from = start
self.__to = end self.__to = end
def parse(self) -> Transactions: def parse(self) -> list[Transaction]:
transactions = [] transactions = []
if not self.__banks: if not self.__banks:
raise NoBankSelected raise NoBankSelected
@ -51,7 +52,7 @@ class NordigenInput(Input):
for bank in self.__banks: for bank in self.__banks:
print(f"Downloading from {bank}...") print(f"Downloading from {bank}...")
requisition = self.client.requisition.get_requisition_by_id( requisition = self.client.requisition.get_requisition_by_id(
bank.requisition_id bank.nordigen.requisition_id
) )
for acc in requisition["accounts"]: for acc in requisition["accounts"]:

View File

@ -1,7 +1,8 @@
from datetime import timedelta from datetime import date, timedelta
from functools import singledispatch from functools import singledispatch
from pfbudget.common.types import Bank, Transaction, TransactionError from pfbudget.common.types import TransactionError
from pfbudget.db.model import Bank, Transaction
from pfbudget.db.schema import DbBank, DbTransaction from pfbudget.db.schema import DbBank, DbTransaction
from .utils import parse_decimal from .utils import parse_decimal
@ -12,60 +13,60 @@ def convert(t):
pass pass
@convert.register # @convert.register
def _(t: Transaction) -> DbTransaction: # def _(t: Transaction) -> DbTransaction:
return DbTransaction( # return DbTransaction(
t.date, # t.date,
t.description, # t.description,
t.bank, # t.bank,
t.value, # t.value,
t.category, # t.category,
t.original, # t.original,
t.additional_comment, # t.additional_comment,
) # )
@convert.register # @convert.register
def _(db: DbTransaction) -> Transaction: # def _(db: DbTransaction) -> Transaction:
try: # try:
return Transaction(db) # return Transaction(db)
except TransactionError: # except TransactionError:
print(f"{db} is in the wrong format") # print(f"{db} is in the wrong format")
@convert.register # @convert.register
def _(db: DbBank, key: str = "") -> Bank: # def _(db: DbBank, key: str = "") -> Bank:
bank = Bank(db.name, db.bic, db.requisition_id, db.invert, db.offset, key=key) # bank = Bank(db.name, db.bic, db.requisition_id, db.invert, db.offset, key=key)
if not bank.invert: # if not bank.invert:
bank.invert = False # bank.invert = False
if not bank.offset: # if not bank.offset:
bank.offset = 0 # bank.offset = 0
return bank # return bank
@convert.register # @convert.register
def _(bank: Bank) -> DbBank: # def _(bank: Bank) -> DbBank:
bank = DbBank( # bank = DbBank(
bank.name, bank.bic, "", "", bank.requisition_id, bank.invert, bank.offset # bank.name, bank.bic, "", "", bank.requisition_id, bank.invert, bank.offset
) # )
if not bank.invert: # if not bank.invert:
bank.invert = False # bank.invert = False
if not bank.offset: # if not bank.offset:
bank.offset = 0 # bank.offset = 0
return bank # return bank
@convert.register @convert.register
def _(json: dict, bank: Bank) -> Transaction: def _(json: dict, bank: Bank) -> Transaction:
i = -1 if bank.invert else 1 i = -1 if bank.nordigen.invert else 1
try: try:
transaction = Transaction( transaction = Transaction(
json["bookingDate"], date=date.fromisoformat(json["bookingDate"]),
json["remittanceInformationUnstructured"], description=json["remittanceInformationUnstructured"],
bank.name, bank=bank.name,
i * parse_decimal(json["transactionAmount"]["amount"]), amount=i * parse_decimal(json["transactionAmount"]["amount"]),
) )
transaction.date += timedelta(days=bank.offset) # transaction.date += timedelta(days=bank.offset)
return transaction return transaction
except TransactionError: except TransactionError: