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

View File

@ -1,10 +1,11 @@
from collections.abc import Sequence from collections.abc import Sequence
from copy import deepcopy from copy import deepcopy
from sqlalchemy import Engine, create_engine, delete, select, update from sqlalchemy import Engine, create_engine, delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from typing import Any, Mapping, Optional, Type, TypeVar from typing import Any, Mapping, Optional, Type, TypeVar
# from pfbudget.db.exceptions import InsertError, SelectError from pfbudget.db.exceptions import InsertError
class DatabaseSession: class DatabaseSession:
@ -16,11 +17,15 @@ class DatabaseSession:
return self return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any): def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
if exc_type: try:
self.__session.rollback() if exc_type:
else: self.__session.rollback()
self.__session.commit() else:
self.__session.close() self.__session.commit()
except IntegrityError as e:
raise InsertError() from e
finally:
self.__session.close()
def close(self): def close(self):
self.__session.close() self.__session.close()
@ -38,6 +43,9 @@ class DatabaseSession:
return self.__session.scalars(stmt).unique().all() return self.__session.scalars(stmt).unique().all()
def delete(self, obj: Any) -> None:
self.__session.delete(obj)
class Client: class Client:
def __init__(self, url: str, **kwargs: Any): def __init__(self, url: str, **kwargs: Any):
@ -62,7 +70,7 @@ class Client:
with self._sessionmaker() as session, session.begin(): with self._sessionmaker() as session, session.begin():
session.execute(update(what), values) 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(): with self._sessionmaker() as session, session.begin():
session.execute(delete(what).where(column.in_(values))) 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)) BIC: Mapped[str] = mapped_column(String(8))
type: Mapped[AccountType] 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]: def serialize(self) -> Mapping[str, Any]:
nordigen = None nordigen = None
@ -91,7 +91,7 @@ class Bank(Base, Serializable):
def deserialize(cls, map: Mapping[str, Any]) -> Self: def deserialize(cls, map: Mapping[str, Any]) -> Self:
bank = cls(map["name"], map["BIC"], map["type"]) bank = cls(map["name"], map["BIC"], map["type"])
if map["nordigen"]: if map["nordigen"]:
bank.nordigen = Nordigen(**map["nordigen"]) bank.nordigen = NordigenBank(**map["nordigen"])
return bank return bank
@ -373,7 +373,7 @@ class Note(Base):
note: Mapped[str] note: Mapped[str]
class Nordigen(Base): class NordigenBank(Base):
__tablename__ = "banks_nordigen" __tablename__ = "banks_nordigen"
name: Mapped[bankfk] = mapped_column(primary_key=True, init=False) name: Mapped[bankfk] = mapped_column(primary_key=True, init=False)
@ -541,3 +541,11 @@ class TagRule(Rule):
"polymorphic_identity": "tag_rule", "polymorphic_identity": "tag_rule",
"polymorphic_load": "selectin", "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: with open("json/" + bank.name + ".json", "w") as f:
json.dump(downloaded, f) json.dump(downloaded, f)
def generate_token(self): def new_token(self):
self.token = self._client.generate_token() return self._client.generate_token()
print(f"New access token: {self.token}")
return self.token def refresh_token(self, token: str):
return self._client.exchange_token(token)
def requisition(self, id: str, country: str = "PT"): def requisition(self, id: str, country: str = "PT"):
requisition = self._client.initialize_session( requisition = self._client.initialize_session(
@ -97,13 +98,11 @@ class NordigenClient:
@property @property
def token(self): def token(self):
return self._token return self._client.token
@token.setter @token.setter
def token(self, value): def token(self, value: str):
if self._token: self._client.token = value
print("Replacing existing token with {value}")
self._token = value
class NordigenCredentialsManager: 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( 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) 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.client import Client
from pfbudget.db.model import Base from pfbudget.db.model import Base, Nordigen
class MockClient(Client): class MockClient(Client):
now = dt.datetime.now()
def __init__(self): def __init__(self):
url = "sqlite://" url = "sqlite://"
super().__init__( super().__init__(
url, execution_options={"schema_translate_map": {"pfbudget": None}} url, execution_options={"schema_translate_map": {"pfbudget": None}}
) )
Base.metadata.create_all(self.engine) 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 import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from typing import Any, cast
from mocks.client import MockClient
import mocks.transactions import mocks.transactions
from pfbudget.common.types import ExportFormat from pfbudget.common.types import ExportFormat
from pfbudget.core.command import ExportCommand, ImportCommand from pfbudget.core.command import ExportCommand, ImportCommand
from pfbudget.db.client import Client from pfbudget.db.client import Client
from pfbudget.db.exceptions import InsertError
from pfbudget.db.model import Transaction 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 @pytest.fixture
def client() -> Client: def client() -> Client:
return FakeClient() return MockClient()
class TestCommand: class TestCommand:
def test_export_json(self, tmp_path: Path, client: Client): def test_export_json(self, tmp_path: Path, client: Client):
client = FakeClient()
file = tmp_path / "test.json" file = tmp_path / "test.json"
client.insert(mocks.transactions.simple)
command = ExportCommand(client, Transaction, file, ExportFormat.JSON) command = ExportCommand(client, Transaction, file, ExportFormat.JSON)
command.execute() command.execute()
with open(file, newline="") as f: with open(file, newline="") as f:
result = json.load(f) result = json.load(f)
assert result == [t.serialize() for t in mocks.transactions.simple] assert result == [t.serialize() for t in client.select(Transaction)]
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
]
def test_export_pickle(self, tmp_path: Path, client: Client): def test_export_pickle(self, tmp_path: Path, client: Client):
file = tmp_path / "test.pickle" file = tmp_path / "test.pickle"
@ -72,15 +37,15 @@ class TestCommand:
def test_import_json(self, tmp_path: Path, client: Client): def test_import_json(self, tmp_path: Path, client: Client):
file = tmp_path / "test" file = tmp_path / "test"
client.insert(mocks.transactions.simple)
command = ExportCommand(client, Transaction, file, ExportFormat.JSON) command = ExportCommand(client, Transaction, file, ExportFormat.JSON)
command.execute() command.execute()
command = ImportCommand(client, Transaction, file, ExportFormat.JSON) # Since the transactions are already in the DB, we expect an insert error
command.execute() with pytest.raises(InsertError):
command = ImportCommand(client, Transaction, file, ExportFormat.JSON)
transactions = cast(FakeClient, client).transactions command.execute()
assert len(transactions) > 0
assert transactions == client.select(Transaction)
def test_import_pickle(self, tmp_path: Path, client: Client): def test_import_pickle(self, tmp_path: Path, client: Client):
file = tmp_path / "test" file = tmp_path / "test"

View File

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

View File

@ -6,7 +6,7 @@ import requests
import mocks.nordigen as mock 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.exceptions import BankError, CredentialsError
from pfbudget.extract.extract import Extractor from pfbudget.extract.extract import Extractor
from pfbudget.extract.nordigen import NordigenClient, NordigenCredentials from pfbudget.extract.nordigen import NordigenClient, NordigenCredentials
@ -64,7 +64,7 @@ def extractor() -> Extractor:
@pytest.fixture @pytest.fixture
def bank() -> Bank: 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 return bank