diff --git a/alembic/versions/325b901ac712_nordigen_tokens.py b/alembic/versions/325b901ac712_nordigen_tokens.py new file mode 100644 index 0000000..a267f8e --- /dev/null +++ b/alembic/versions/325b901ac712_nordigen_tokens.py @@ -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 ### diff --git a/pfbudget/core/manager.py b/pfbudget/core/manager.py index 4c75308..dee4fbb 100644 --- a/pfbudget/core/manager.py +++ b/pfbudget/core/manager.py @@ -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): diff --git a/pfbudget/db/client.py b/pfbudget/db/client.py index 86fb88a..71fa11b 100644 --- a/pfbudget/db/client.py +++ b/pfbudget/db/client.py @@ -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,11 +17,15 @@ class DatabaseSession: return self def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any): - if exc_type: - self.__session.rollback() - else: - self.__session.commit() - self.__session.close() + 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): self.__session.close() @@ -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))) diff --git a/pfbudget/db/model.py b/pfbudget/db/model.py index 83fe446..97cef52 100644 --- a/pfbudget/db/model.py +++ b/pfbudget/db/model.py @@ -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] diff --git a/pfbudget/extract/nordigen.py b/pfbudget/extract/nordigen.py index b4aaa14..716c5b2 100644 --- a/pfbudget/extract/nordigen.py +++ b/pfbudget/extract/nordigen.py @@ -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: diff --git a/tests/mocks/banks.py b/tests/mocks/banks.py index e1f4c1d..bedf13b 100644 --- a/tests/mocks/banks.py +++ b/tests/mocks/banks.py @@ -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) diff --git a/tests/mocks/client.py b/tests/mocks/client.py index f8118db..3b8b455 100644 --- a/tests/mocks/client.py +++ b/tests/mocks/client.py @@ -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)), + ] + ) diff --git a/tests/test_command.py b/tests/test_command.py index b62d75a..56db47a 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -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,15 +37,15 @@ 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() - command = ImportCommand(client, Transaction, file, ExportFormat.JSON) - command.execute() - - transactions = cast(FakeClient, client).transactions - assert len(transactions) > 0 - assert transactions == client.select(Transaction) + # 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() def test_import_pickle(self, tmp_path: Path, client: Client): file = tmp_path / "test" diff --git a/tests/test_database.py b/tests/test_database.py index 848ed74..422c9db 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -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) diff --git a/tests/test_psd2.py b/tests/test_psd2.py index 8a3e3c0..b1bf764 100644 --- a/tests/test_psd2.py +++ b/tests/test_psd2.py @@ -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