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:
parent
420a6cdfaa
commit
1cfc8dbe38
@ -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"}
|
||||||
|
|||||||
@ -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])
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|||||||
@ -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"),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user