Create EUA and request user access permission

Move the token creation/renewal code into the NordigenClient and remove
external access. The NordigenClient now also takes the DB client in the
constructor.

While creating the unit test, noticed that the nordigen mocks for
downloaded transactions could match the simple transactions mock, thus
helping in the online download command.
Also created the nordigen.NordigenClient mock, with the used methods
mocked and raising a NotImplement when a new endpoint is requested.
This commit is contained in:
Luís Murta 2023-05-26 23:58:44 +01:00
parent 420a6cdfaa
commit 1cfc8dbe38
Signed by: satprog
GPG Key ID: 169EF1BBD7049F94
7 changed files with 170 additions and 65 deletions

View File

@ -38,10 +38,10 @@ if __name__ == "__main__":
params = [args["path"], args["bank"], args["creditcard"]] params = [args["path"], args["bank"], args["creditcard"]]
case Operation.RequisitionId: case Operation.RequisitionId:
keys = {"name", "country"} keys = {"bank"}
assert args.keys() >= keys, f"missing {args.keys() - keys}" assert args.keys() >= keys, f"missing {args.keys() - keys}"
params = [args["name"][0], args["country"][0]] params = [args["bank"][0]]
case Operation.Download: case Operation.Download:
keys = {"all", "banks", "interval", "start", "end", "year", "dry_run"} keys = {"all", "banks", "interval", "start", "end", "year", "dry_run"}

View File

@ -133,8 +133,7 @@ def argparser() -> argparse.ArgumentParser:
# PSD2 requisition id # PSD2 requisition id
requisition = subparsers.add_parser("eua") requisition = subparsers.add_parser("eua")
requisition.set_defaults(op=Operation.RequisitionId) requisition.set_defaults(op=Operation.RequisitionId)
requisition.add_argument("id", nargs=1, type=str) requisition.add_argument("bank", nargs=1, type=str)
requisition.add_argument("country", nargs=1, type=str)
# Download through the PSD2 API # Download through the PSD2 API
download = subparsers.add_parser("download", parents=[period]) download = subparsers.add_parser("download", parents=[period])

View File

@ -79,7 +79,7 @@ class Manager:
else: else:
banks = self.database.select(Bank, Bank.nordigen) banks = self.database.select(Bank, Bank.nordigen)
extractor = PSD2Extractor(Manager.nordigen_client()) extractor = PSD2Extractor(self.nordigen_client())
transactions = [] transactions = []
for bank in banks: for bank in banks:
@ -122,16 +122,26 @@ class Manager:
case Operation.PSD2Del: case Operation.PSD2Del:
self.database.delete(NordigenBank, NordigenBank.name, params) self.database.delete(NordigenBank, NordigenBank.name, params)
case Operation.Token:
Manager.nordigen_client().new_token()
case Operation.RequisitionId: case Operation.RequisitionId:
link, _ = Manager.nordigen_client().requisition(params[0], params[1]) bank_name = params[0]
print(f"Opening {link} to request access to {params[0]}") bank = self.database.select(Bank, (lambda: Bank.name == bank_name))[0]
if not bank.nordigen or not bank.nordigen.bank_id:
raise ValueError(f"{bank} doesn't have a Nordigen ID")
link, req_id = self.nordigen_client().new_requisition(
bank.nordigen.bank_id
)
self.database.update(
NordigenBank,
[{"name": bank.nordigen.name, "requisition_id": req_id}],
)
webbrowser.open(link) webbrowser.open(link)
case Operation.PSD2CountryBanks: case Operation.PSD2CountryBanks:
banks = Manager.nordigen_client().country_banks(params[0]) banks = self.nordigen_client().country_banks(params[0])
print(banks) print(banks)
case ( case (
@ -387,6 +397,5 @@ class Manager:
self._database = Client(self._db, echo=self._verbosity > 2) self._database = Client(self._db, echo=self._verbosity > 2)
return self._database return self._database
@staticmethod def nordigen_client(self) -> NordigenClient:
def nordigen_client() -> NordigenClient: return NordigenClient(NordigenCredentialsManager.default, self.database)
return NordigenClient(NordigenCredentialsManager.default)

View File

@ -1,12 +1,17 @@
from dataclasses import dataclass from dataclasses import dataclass
import datetime as dt
import dotenv import dotenv
import json import json
import nordigen import nordigen
import os import os
import requests import requests
import time import time
from typing import Optional, Tuple
import uuid import uuid
from pfbudget.db.client import Client
from pfbudget.db.model import Nordigen
from .exceptions import CredentialsError, DownloadError from .exceptions import CredentialsError, DownloadError
dotenv.load_dotenv() dotenv.load_dotenv()
@ -16,38 +21,35 @@ dotenv.load_dotenv()
class NordigenCredentials: class NordigenCredentials:
id: str id: str
key: str key: str
token: str = ""
def valid(self) -> bool: def valid(self) -> bool:
return self.id and self.key return len(self.id) != 0 and len(self.key) != 0
class NordigenClient: class NordigenClient:
redirect_url = "https://murta.dev" redirect_url = "https://murta.dev"
def __init__(self, credentials: NordigenCredentials): def __init__(self, credentials: NordigenCredentials, client: Client):
super().__init__()
if not credentials.valid(): if not credentials.valid():
raise CredentialsError raise CredentialsError
self._client = nordigen.NordigenClient( self.__client = nordigen.NordigenClient(
secret_key=credentials.key, secret_id=credentials.id, timeout=5 secret_key=credentials.key, secret_id=credentials.id, timeout=5
) )
self.__client.token = self.__token(client)
if credentials.token:
self._client.token = credentials.token
def download(self, requisition_id): def download(self, requisition_id):
try: try:
requisition = self._client.requisition.get_requisition_by_id(requisition_id) requisition = self.__client.requisition.get_requisition_by_id(
requisition_id
)
print(requisition) print(requisition)
except requests.HTTPError as e: except requests.HTTPError as e:
raise DownloadError(e) raise DownloadError(e)
transactions = {} transactions = {}
for acc in requisition["accounts"]: for acc in requisition["accounts"]:
account = self._client.account_api(acc) account = self.__client.account_api(acc)
retries = 0 retries = 0
while retries < 3: while retries < 3:
@ -71,43 +73,75 @@ 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 new_token(self): def new_requisition(
return self._client.generate_token() self,
institution_id: str,
max_historical_days: Optional[int] = None,
access_valid_for_days: Optional[int] = None,
) -> Tuple[str, str]:
kwargs = {
"max_historical_days": max_historical_days,
"access_valid_for_days": access_valid_for_days,
}
kwargs = {k: v for k, v in kwargs.items() if v is not None}
def refresh_token(self, token: str): req = self.__client.initialize_session(
return self._client.exchange_token(token) self.redirect_url, institution_id, str(uuid.uuid4()), **kwargs
def requisition(self, id: str, country: str = "PT"):
requisition = self._client.initialize_session(
redirect_uri=self.redirect_url,
institution_id=id,
reference_id=str(uuid.uuid4()),
) )
return requisition.link, requisition.requisition_id return req.link, req.requisition_id
def country_banks(self, country: str): def country_banks(self, country: str):
return self._client.institution.get_institutions(country) return self.__client.institution.get_institutions(country)
# def __token(self): def __token(self, client: Client) -> str:
# if token := os.environ.get("TOKEN"): with client.session as session:
# return token token = session.select(Nordigen)
# else:
# token = self._client.generate_token()
# print(f"New access token: {token}")
# return token["access"]
@property def datetime(seconds: int) -> dt.datetime:
def token(self): return dt.datetime.now() + dt.timedelta(seconds=seconds)
return self._client.token
@token.setter if not len(token):
def token(self, value: str): print("First time nordigen token setup")
self._client.token = value new = self.__client.generate_token()
session.insert(
[
Nordigen(
"access",
new["access"],
datetime(new["access_expires"]),
),
Nordigen(
"refresh",
new["refresh"],
datetime(new["refresh_expires"]),
),
]
)
return new["access"]
else:
access = next(t for t in token if t.type == "access")
refresh = next(t for t in token if t.type == "refresh")
if access.expires > dt.datetime.now():
pass
elif refresh.expires > dt.datetime.now():
new = self.__client.exchange_token(refresh.token)
access.token = new["access"]
access.expires = datetime(new["access_expires"])
else:
new = self.__client.generate_token()
access.token = new["access"]
access.expires = datetime(new["access_expires"])
refresh.token = new["refresh"]
refresh.expires = datetime(new["refresh_expires"])
return access.token
class NordigenCredentialsManager: class NordigenCredentialsManager:
default = NordigenCredentials( default = NordigenCredentials(
os.environ.get("SECRET_ID"), os.environ.get("SECRET_ID", ""),
os.environ.get("SECRET_KEY"), os.environ.get("SECRET_KEY", ""),
os.environ.get("TOKEN"),
) )

View File

@ -1,3 +1,8 @@
from typing import Any, Dict, List, Optional
import nordigen
from nordigen.types.http_enums import HTTPMethod
from nordigen.types.types import RequisitionDto, TokenType
from pfbudget.extract.nordigen import NordigenCredentials from pfbudget.extract.nordigen import NordigenCredentials
@ -13,6 +18,7 @@ accounts_id = {
"owner_name": "string", "owner_name": "string",
} }
# The downloaded transactions match the simple and simple_transformed mocks
accounts_id_transactions = { accounts_id_transactions = {
"transactions": { "transactions": {
"booked": [ "booked": [
@ -84,4 +90,57 @@ requisitions_id = {
"redirect_immediate": False, "redirect_immediate": False,
} }
credentials = NordigenCredentials("ID", "KEY", "TOKEN") credentials = NordigenCredentials("ID", "KEY")
class MockNordigenClient(nordigen.NordigenClient):
def __init__(
self,
secret_key: str = "ID",
secret_id: str = "KEY",
timeout: int = 10,
base_url: str = "https://ob.nordigen.com/api/v2",
) -> None:
super().__init__(secret_key, secret_id, timeout, base_url)
def generate_token(self) -> TokenType:
return {
"access": "access_token",
"refresh": "refresh_token",
"access_expires": 86400,
"refresh_expires": 2592000,
}
def exchange_token(self, refresh_token: str) -> TokenType:
assert len(refresh_token) > 0, "invalid refresh token"
return {
"access": "access_token",
"refresh": "refresh_token",
"access_expires": 86400,
"refresh_expires": 2592000,
}
def request(
self,
method: HTTPMethod,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
) -> Any:
if endpoint == "requisitions/" + "requisition_id" + "/":
return requisitions_id
elif endpoint == "accounts/" + id + "/transactions/":
return accounts_id_transactions
else:
raise NotImplementedError(endpoint)
def initialize_session(
self,
redirect_uri: str,
institution_id: str,
reference_id: str,
max_historical_days: int = 90,
access_valid_for_days: int = 90,
access_scope: List[str] | None = None,
) -> RequisitionDto:
return RequisitionDto("http://random", "requisition_id")

View File

@ -12,22 +12,25 @@ from pfbudget.db.model import (
TransactionTag, TransactionTag,
) )
# The simple and simple_transformed match the nordigen mocks
simple = [ simple = [
Transaction(date(2023, 1, 1), "", Decimal("-10")), BankTransaction(date(2023, 1, 14), "string", Decimal("328.18"), bank="bank"),
Transaction(date(2023, 1, 2), "", Decimal("-50")), BankTransaction(date(2023, 2, 14), "string", Decimal("947.26"), bank="bank"),
] ]
simple_transformed = [ simple_transformed = [
Transaction( BankTransaction(
date(2023, 1, 1), date(2023, 1, 14),
"", "",
Decimal("-10"), Decimal("328.18"),
bank="bank",
category=TransactionCategory("category#1", CategorySelector.algorithm), category=TransactionCategory("category#1", CategorySelector.algorithm),
), ),
Transaction( BankTransaction(
date(2023, 1, 2), date(2023, 2, 14),
"", "",
Decimal("-50"), Decimal("947.26"),
bank="bank",
category=TransactionCategory("category#2", CategorySelector.algorithm), category=TransactionCategory("category#2", CategorySelector.algorithm),
), ),
] ]

View File

@ -4,6 +4,7 @@ from typing import Any, Optional
import pytest import pytest
import requests import requests
from mocks.client import MockClient
import mocks.nordigen as mock import mocks.nordigen as mock
from pfbudget.db.model import AccountType, Bank, BankTransaction, NordigenBank from pfbudget.db.model import AccountType, Bank, BankTransaction, NordigenBank
@ -58,8 +59,8 @@ def mock_requests(monkeypatch: pytest.MonkeyPatch):
@pytest.fixture @pytest.fixture
def extractor() -> Extractor: def extractor() -> Extractor:
credentials = NordigenCredentials("ID", "KEY", "TOKEN") credentials = NordigenCredentials("ID", "KEY")
return PSD2Extractor(NordigenClient(credentials)) return PSD2Extractor(NordigenClient(credentials, MockClient()))
@pytest.fixture @pytest.fixture
@ -72,7 +73,7 @@ class TestExtractPSD2:
def test_empty_credentials(self): def test_empty_credentials(self):
cred = NordigenCredentials("", "") cred = NordigenCredentials("", "")
with pytest.raises(CredentialsError): with pytest.raises(CredentialsError):
NordigenClient(cred) NordigenClient(cred, MockClient())
def test_no_psd2_bank(self, extractor: Extractor): def test_no_psd2_bank(self, extractor: Extractor):
with pytest.raises(BankError): with pytest.raises(BankError):