Nordigen token generation/refresh logic

Adds a new table, nordigen, with the access and refresh token, along
with their validity.
The Export/Import test would raise an integrety with the use of a real
DB and the export of the transaction IDs, so add a try-except block to
the database session to catch the error and re-raise an ImportError.
This commit is contained in:
Luís Murta 2023-05-23 22:23:21 +01:00
parent ea546fc2df
commit 420a6cdfaa
Signed by: satprog
GPG Key ID: 169EF1BBD7049F94
10 changed files with 107 additions and 81 deletions

View File

@ -0,0 +1,35 @@
"""nordigen tokens
Revision ID: 325b901ac712
Revises: 60469d5dd2b0
Create Date: 2023-05-25 19:10:10.374008+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "325b901ac712"
down_revision = "60469d5dd2b0"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"nordigen",
sa.Column("type", sa.String(), nullable=False),
sa.Column("token", sa.String(), nullable=False),
sa.Column("expires", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("type", name=op.f("pk_nordigen")),
schema="pfbudget",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("nordigen", schema="pfbudget")
# ### end Alembic commands ###

View File

@ -15,7 +15,7 @@ from pfbudget.db.model import (
CategorySchedule,
Link,
MoneyTransaction,
Nordigen,
NordigenBank,
Rule,
CategorySelector,
SplitTransaction,
@ -114,16 +114,16 @@ class Manager:
self.database.update(Bank, params)
case Operation.PSD2Mod:
self.database.update(Nordigen, params)
self.database.update(NordigenBank, params)
case Operation.BankDel:
self.database.delete(Bank, Bank.name, params)
case Operation.PSD2Del:
self.database.delete(Nordigen, Nordigen.name, params)
self.database.delete(NordigenBank, NordigenBank.name, params)
case Operation.Token:
Manager.nordigen_client().generate_token()
Manager.nordigen_client().new_token()
case Operation.RequisitionId:
link, _ = Manager.nordigen_client().requisition(params[0], params[1])
@ -285,7 +285,7 @@ class Manager:
for row in self.load(params[0], params[1]):
bank = Bank(row["name"], row["BIC"], row["type"])
if row["nordigen"]:
bank.nordigen = Nordigen(**row["nordigen"])
bank.nordigen = NordigenBank(**row["nordigen"])
banks.append(bank)
if self.certify(banks):

View File

@ -1,10 +1,11 @@
from collections.abc import Sequence
from copy import deepcopy
from sqlalchemy import Engine, create_engine, delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from typing import Any, Mapping, Optional, Type, TypeVar
# from pfbudget.db.exceptions import InsertError, SelectError
from pfbudget.db.exceptions import InsertError
class DatabaseSession:
@ -16,10 +17,14 @@ class DatabaseSession:
return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
try:
if exc_type:
self.__session.rollback()
else:
self.__session.commit()
except IntegrityError as e:
raise InsertError() from e
finally:
self.__session.close()
def close(self):
@ -38,6 +43,9 @@ class DatabaseSession:
return self.__session.scalars(stmt).unique().all()
def delete(self, obj: Any) -> None:
self.__session.delete(obj)
class Client:
def __init__(self, url: str, **kwargs: Any):
@ -62,7 +70,7 @@ class Client:
with self._sessionmaker() as session, session.begin():
session.execute(update(what), values)
def delete(self, what: Type[Any], column: Any, values: Sequence[str]) -> None:
def delete(self, what: Type[Any], column: Any, values: Sequence[Any]) -> None:
with self._sessionmaker() as session, session.begin():
session.execute(delete(what).where(column.in_(values)))

View File

@ -69,7 +69,7 @@ class Bank(Base, Serializable):
BIC: Mapped[str] = mapped_column(String(8))
type: Mapped[AccountType]
nordigen: Mapped[Optional[Nordigen]] = relationship(default=None, lazy="joined")
nordigen: Mapped[Optional[NordigenBank]] = relationship(default=None, lazy="joined")
def serialize(self) -> Mapping[str, Any]:
nordigen = None
@ -91,7 +91,7 @@ class Bank(Base, Serializable):
def deserialize(cls, map: Mapping[str, Any]) -> Self:
bank = cls(map["name"], map["BIC"], map["type"])
if map["nordigen"]:
bank.nordigen = Nordigen(**map["nordigen"])
bank.nordigen = NordigenBank(**map["nordigen"])
return bank
@ -373,7 +373,7 @@ class Note(Base):
note: Mapped[str]
class Nordigen(Base):
class NordigenBank(Base):
__tablename__ = "banks_nordigen"
name: Mapped[bankfk] = mapped_column(primary_key=True, init=False)
@ -541,3 +541,11 @@ class TagRule(Rule):
"polymorphic_identity": "tag_rule",
"polymorphic_load": "selectin",
}
class Nordigen(Base):
__tablename__ = "nordigen"
type: Mapped[str] = mapped_column(primary_key=True)
token: Mapped[str]
expires: Mapped[dt.datetime]

View File

@ -71,10 +71,11 @@ class NordigenClient:
with open("json/" + bank.name + ".json", "w") as f:
json.dump(downloaded, f)
def generate_token(self):
self.token = self._client.generate_token()
print(f"New access token: {self.token}")
return self.token
def new_token(self):
return self._client.generate_token()
def refresh_token(self, token: str):
return self._client.exchange_token(token)
def requisition(self, id: str, country: str = "PT"):
requisition = self._client.initialize_session(
@ -97,13 +98,11 @@ class NordigenClient:
@property
def token(self):
return self._token
return self._client.token
@token.setter
def token(self, value):
if self._token:
print("Replacing existing token with {value}")
self._token = value
def token(self, value: str):
self._client.token = value
class NordigenCredentialsManager:

View File

@ -1,8 +1,8 @@
from pfbudget.db.model import AccountType, Bank, Nordigen
from pfbudget.db.model import AccountType, Bank, NordigenBank
checking = Bank(
"bank", "BANK", AccountType.checking, Nordigen("bank_id", "requisition_id")
"bank", "BANK", AccountType.checking, NordigenBank("bank_id", "requisition_id")
)
cc = Bank("cc", "CC", AccountType.MASTERCARD)

View File

@ -1,11 +1,22 @@
import datetime as dt
from pfbudget.db.client import Client
from pfbudget.db.model import Base
from pfbudget.db.model import Base, Nordigen
class MockClient(Client):
now = dt.datetime.now()
def __init__(self):
url = "sqlite://"
super().__init__(
url, execution_options={"schema_translate_map": {"pfbudget": None}}
)
Base.metadata.create_all(self.engine)
self.insert(
[
Nordigen("access", "token#1", self.now + dt.timedelta(days=1)),
Nordigen("refresh", "token#2", self.now + dt.timedelta(days=30)),
]
)

View File

@ -1,68 +1,33 @@
from collections.abc import Sequence
import json
from pathlib import Path
import pytest
from typing import Any, cast
from mocks.client import MockClient
import mocks.transactions
from pfbudget.common.types import ExportFormat
from pfbudget.core.command import ExportCommand, ImportCommand
from pfbudget.db.client import Client
from pfbudget.db.exceptions import InsertError
from pfbudget.db.model import Transaction
class FakeClient(Client):
def __init__(self):
self._transactions = mocks.transactions.simple
def select(self, what: Any, *_) -> Sequence[Any]:
if what == Transaction:
return self.transactions
return []
def insert(self, *_):
pass
def update(self, *_):
pass
def delete(self, *_):
pass
@property
def transactions(self):
return self._transactions
@transactions.setter
def transactions(self, value: Sequence[Transaction]):
self._transactions = value
@pytest.fixture
def client() -> Client:
return FakeClient()
return MockClient()
class TestCommand:
def test_export_json(self, tmp_path: Path, client: Client):
client = FakeClient()
file = tmp_path / "test.json"
client.insert(mocks.transactions.simple)
command = ExportCommand(client, Transaction, file, ExportFormat.JSON)
command.execute()
with open(file, newline="") as f:
result = json.load(f)
assert result == [t.serialize() for t in mocks.transactions.simple]
cast(FakeClient, client).transactions = mocks.transactions.simple_transformed
command.execute()
with open(file, newline="") as f:
result = json.load(f)
assert result == [
t.serialize() for t in mocks.transactions.simple_transformed
]
assert result == [t.serialize() for t in client.select(Transaction)]
def test_export_pickle(self, tmp_path: Path, client: Client):
file = tmp_path / "test.pickle"
@ -72,16 +37,16 @@ class TestCommand:
def test_import_json(self, tmp_path: Path, client: Client):
file = tmp_path / "test"
client.insert(mocks.transactions.simple)
command = ExportCommand(client, Transaction, file, ExportFormat.JSON)
command.execute()
# Since the transactions are already in the DB, we expect an insert error
with pytest.raises(InsertError):
command = ImportCommand(client, Transaction, file, ExportFormat.JSON)
command.execute()
transactions = cast(FakeClient, client).transactions
assert len(transactions) > 0
assert transactions == client.select(Transaction)
def test_import_pickle(self, tmp_path: Path, client: Client):
file = tmp_path / "test"
command = ExportCommand(client, Transaction, file, ExportFormat.pickle)

View File

@ -8,7 +8,7 @@ from pfbudget.db.client import Client
from pfbudget.db.model import (
AccountType,
Bank,
Nordigen,
NordigenBank,
CategorySelector,
Transaction,
TransactionCategory,
@ -23,7 +23,7 @@ def client() -> Client:
@pytest.fixture
def banks(client: Client) -> list[Bank]:
banks = [
Bank("bank", "BANK", AccountType.checking, Nordigen(None, "req", None)),
Bank("bank", "BANK", AccountType.checking, NordigenBank(None, "req", None)),
Bank("broker", "BROKER", AccountType.investment),
Bank("creditcard", "CC", AccountType.MASTERCARD),
]
@ -125,13 +125,13 @@ class TestDatabase:
def test_update_nordigen(self, client: Client, banks: list[Bank]):
name = banks[0].name
result = client.select(Nordigen, lambda: Nordigen.name == name)
result = client.select(NordigenBank, lambda: NordigenBank.name == name)
assert result[0].requisition_id == "req"
update = {"name": name, "requisition_id": "anotherreq"}
client.update(Nordigen, [update])
client.update(NordigenBank, [update])
result = client.select(Nordigen, lambda: Nordigen.name == name)
result = client.select(NordigenBank, lambda: NordigenBank.name == name)
assert result[0].requisition_id == "anotherreq"
result = client.select(Bank, lambda: Bank.name == name)

View File

@ -6,7 +6,7 @@ import requests
import mocks.nordigen as mock
from pfbudget.db.model import AccountType, Bank, BankTransaction, Nordigen
from pfbudget.db.model import AccountType, Bank, BankTransaction, NordigenBank
from pfbudget.extract.exceptions import BankError, CredentialsError
from pfbudget.extract.extract import Extractor
from pfbudget.extract.nordigen import NordigenClient, NordigenCredentials
@ -64,7 +64,7 @@ def extractor() -> Extractor:
@pytest.fixture
def bank() -> Bank:
bank = Bank("Bank#1", "", AccountType.checking, Nordigen("", mock.id, False))
bank = Bank("Bank#1", "", AccountType.checking, NordigenBank("", mock.id, False))
return bank