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:
parent
ea546fc2df
commit
420a6cdfaa
35
alembic/versions/325b901ac712_nordigen_tokens.py
Normal file
35
alembic/versions/325b901ac712_nordigen_tokens.py
Normal 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 ###
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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)))
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user