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,
|
||||
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):
|
||||
|
||||
@ -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)))
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)),
|
||||
]
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user