Skip to content
Snippets Groups Projects
Commit ef94f641 authored by Vincent Texier's avatar Vincent Texier
Browse files

[feat] add Export as OFX in transfer_history_menu

parent 46593c06
No related branches found
No related tags found
No related merge requests found
......@@ -34,83 +34,81 @@ class IndexerTransfers(IndexerTransfersInterface):
def list(
self,
address,
address: str,
limit: int,
offset: int = 0,
sort_column: str = IndexerTransfersInterface.COLUMN_TIMESTAMP,
sort_order: str = IndexerTransfersInterface.SORT_ORDER_DESCENDING,
since: Optional[datetime] = None,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None,
) -> List[Transfer]:
__doc__ = ( # pylint: disable=redefined-builtin, unused-variable
IndexerTransfersInterface.list.__doc__
)
if (
not self.indexer.connection.is_connected()
or self.indexer.connection.client is None
):
raise IndexerTransfersException(NetworkConnectionError())
since_str = ""
if since is not None:
since_str = (
"""_and: {
timestamp: {_gte: \""""
+ since.isoformat()
+ """\"}
}"""
# Construire la condition de filtrage pour la date
date_filter = []
if from_datetime:
date_filter.append(
f'{{ timestamp: {{ _gte: "{from_datetime.isoformat()}" }} }}'
)
if to_datetime:
date_filter.append(
f'{{ timestamp: {{ _lte: "{to_datetime.isoformat()}" }} }}'
)
date_filter_str = (
"_and: [" + ", ".join(date_filter) + "]" if date_filter else ""
)
# Construire la requête GraphQL
query = gql(
"""
query {
f"""
query {{
transfer(
limit: """
+ str(limit)
+ """
orderBy: {
"""
+ sort_column
+ """: """
+ sort_order
+ """
}
where: {
limit: {limit}
offset: {offset}
orderBy: {{
{sort_column}: {sort_order}
}}
where: {{
_or: [
{fromId: {_eq: \""""
+ address
+ """\"}}
{toId: {_eq: \""""
+ address
+ """\"}}
{{ fromId: {{ _eq: \"{address}\" }} }}
{{ toId: {{ _eq: \"{address}\" }} }}
]
"""
+ since_str
+ """
}
) {
{date_filter_str}
}}
) {{
id
fromId
from {
identity {
from {{
identity {{
index
name
}
}
}}
}}
toId
to {
identity {
to {{
identity {{
index
name
}
}
}}
}}
amount
timestamp
comment {
comment {{
type
remark
remarkBytes
}
}
}
}}
}}
}}
"""
)
......@@ -119,43 +117,38 @@ class IndexerTransfers(IndexerTransfersInterface):
except Exception as exception:
raise NetworkIndexerException(exception)
# {'transfer': [
# {'id': '0000056159-1d1ed-000003',
# 'fromId': '5Dq8xjvkmbz7q4g2LbZgyExD26VSCutfEc6n4W4AfQeVHZqz',
# 'from': {'identity': {'index': 344, 'name': 'xxxx'}},
# 'toId': '5CQ8T4qpbYJq7uVsxGPQ5q2df7x3Wa4aRY6HUWMBYjfLZhnn',
# 'to': {'identity': {'index': 61, 'name': 'yyyy'}},
# 'amount': 123,
# 'timestamp': '2024-02-08T14:59:00.001+00:00',
# 'comment': {'remark': 'test 2', 'type': 'ASCII'},
# ]}
transfers = []
for transfer in result["transfer"]:
if transfer["from"]["identity"] is not None:
issuer_identity_index = transfer["from"]["identity"]["index"]
issuer_identity_name = transfer["from"]["identity"]["name"]
else:
issuer_identity_index = None
issuer_identity_name = None
if transfer["to"]["identity"] is not None:
receiver_identity_index = transfer["to"]["identity"]["index"]
receiver_identity_name = transfer["to"]["identity"]["name"]
else:
receiver_identity_index = None
receiver_identity_name = None
if transfer["comment"] is not None:
issuer_identity_index = (
transfer["from"]["identity"]["index"]
if transfer["from"]["identity"]
else None
)
issuer_identity_name = (
transfer["from"]["identity"]["name"]
if transfer["from"]["identity"]
else None
)
receiver_identity_index = (
transfer["to"]["identity"]["index"]
if transfer["to"]["identity"]
else None
)
receiver_identity_name = (
transfer["to"]["identity"]["name"]
if transfer["to"]["identity"]
else None
)
comment = None
comment_type = None
if transfer["comment"]:
comment = (
transfer["comment"]["remarkBytes"]
if transfer["comment"]["type"] == "RAW"
else transfer["comment"]["remark"]
)
comment_type = transfer["comment"]["type"]
else:
comment = None
comment_type = None
transfers.append(
Transfer(
......@@ -172,6 +165,7 @@ class IndexerTransfers(IndexerTransfersInterface):
comment_type=comment_type,
)
)
return transfers
def count(self, address) -> int:
......
......@@ -116,6 +116,7 @@ class Application:
self.transfers = Transfers(
self.wallets,
self.repository.transfers,
self.currencies,
self.network.node.transfers,
self.network.indexer.transfers,
self.event_dispatcher,
......@@ -139,7 +140,6 @@ class Application:
self.nodes = Nodes(
self.repository.nodes,
self.preferences,
self.connections,
self.network.node,
self.config,
self.currencies,
......@@ -148,7 +148,6 @@ class Application:
self.indexers = Indexers(
self.repository.indexers,
self.preferences,
self.connections,
self.network.indexer,
self.config,
self.currencies,
......
......@@ -21,7 +21,6 @@ from urllib import request
from tikka.adapters.network.indexer.indexer import NetworkIndexer
from tikka.domains.config import Config
from tikka.domains.connections import Connections
from tikka.domains.currencies import Currencies
from tikka.domains.entities.constants import (
INDEXERS_CURRENT_ENTRY_POINT_URL_PREFERENCES_KEY,
......@@ -46,8 +45,7 @@ class Indexers:
self,
repository: IndexersRepositoryInterface,
preferences: Preferences,
connections: Connections,
network: NetworkIndexerInterface,
network_indexer: NetworkIndexerInterface,
config: Config,
currencies: Currencies,
event_dispatcher: EventDispatcher,
......@@ -57,16 +55,14 @@ class Indexers:
:param repository: EntryPointsRepositoryInterface instance
:param preferences: Preferences domain instance
:param connections: Connections instance
:param network: Network adapter instance for handling indexers
:param network_indexer: Network adapter instance for handling indexers
:param config: Config instance
:param currencies: Currencies instance
:param event_dispatcher: EventDispatcher instance
"""
self.repository = repository
self.preferences = preferences
self.connections = connections
self.network = network
self.network_indexer = network_indexer
self.config = config
self.currencies = currencies
self.event_dispatcher = event_dispatcher
......@@ -79,7 +75,7 @@ class Indexers:
# events
self.event_dispatcher.add_event_listener(
ConnectionsEvent.EVENT_TYPE_INDEXER_CONNECTED,
self._on_connections_connected,
self._on_indexer_connected_event,
)
def init_repository(self):
......@@ -135,7 +131,7 @@ class Indexers:
:return:
"""
current_indexer = self.repository.get(self.get_current_url())
network_indexer = self.network.get()
network_indexer = self.network_indexer.get()
if network_indexer is None:
return None
......@@ -180,7 +176,7 @@ class Indexers:
# never choose localhost randomly...
if "localhost" not in url:
self.set_current_url(self.list()[index].url)
if self.connections.indexer.is_connected():
if self.network_indexer.connection.is_connected():
break
def add(self, indexer: Indexer) -> None:
......@@ -291,12 +287,12 @@ class Indexers:
self._current_url,
)
# switch current connection
self.connections.indexer.disconnect()
self.network_indexer.connection.disconnect()
indexer = self.get(self._current_url)
if indexer is not None:
self.connections.indexer.connect(indexer)
self.network_indexer.connection.connect(indexer)
def _on_connections_connected(self, _: ConnectionsEvent):
def _on_indexer_connected_event(self, _: ConnectionsEvent):
"""
Triggered when the connection is established
......
......@@ -21,7 +21,6 @@ from urllib import request
from tikka.adapters.network.node.node import NetworkNode
from tikka.domains.config import Config
from tikka.domains.connections import Connections
from tikka.domains.currencies import Currencies
from tikka.domains.entities.constants import (
NODES_CURRENT_ENTRY_POINT_URL_PREFERENCES_KEY,
......@@ -46,8 +45,7 @@ class Nodes:
self,
repository: NodesRepositoryInterface,
preferences: Preferences,
connections: Connections,
network: NetworkNodeInterface,
network_node: NetworkNodeInterface,
config: Config,
currencies: Currencies,
event_dispatcher: EventDispatcher,
......@@ -57,16 +55,14 @@ class Nodes:
:param repository: EntryPointsRepositoryInterface instance
:param preferences: Preferences domain instance
:param connections: Connections instance
:param network: NetworkNodeInterface adapter instance for handling nodes
:param network_node: NetworkNodeInterface adapter instance for handling nodes
:param config: Config instance
:param currencies: Currencies instance
:param event_dispatcher: EventDispatcher instance
"""
self.repository = repository
self.preferences = preferences
self.connections = connections
self.network = network
self.network_node = network_node
self.config = config
self.currencies = currencies
self.event_dispatcher = event_dispatcher
......@@ -78,7 +74,7 @@ class Nodes:
# events
self.event_dispatcher.add_event_listener(
ConnectionsEvent.EVENT_TYPE_NODE_CONNECTED, self._on_connections_connected
ConnectionsEvent.EVENT_TYPE_NODE_CONNECTED, self._on_node_connected_event
)
def init_repository(self):
......@@ -138,7 +134,7 @@ class Nodes:
:return:
"""
current_node = self.repository.get(self.get_current_url())
network_node = self.network.get()
network_node = self.network_node.get()
if network_node is None:
return None
......@@ -191,7 +187,7 @@ class Nodes:
# never choose localhost randomly...
if "localhost" not in url:
self.set_current_url(self.list()[index].url)
if self.connections.node.is_connected():
if self.network_node.connection.is_connected():
break
def add(self, node: Node) -> None:
......@@ -297,12 +293,12 @@ class Nodes:
self._current_url,
)
# switch current connection
self.connections.node.disconnect()
self.network_node.connection.disconnect()
node = self.get(self._current_url)
if node is not None:
self.connections.node.connect(node)
self.network_node.connection.connect(node)
def _on_connections_connected(self, _: ConnectionsEvent):
def _on_node_connected_event(self, _: ConnectionsEvent):
"""
Triggered when the connection is established
......
......@@ -12,9 +12,12 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from tikka.adapters.network.node.substrate_client import ExtrinsicReceipt
from tikka.domains.currencies import Currencies
from tikka.domains.entities.account import Account
from tikka.domains.entities.events import TransferEvent
from tikka.domains.entities.transfer import Transfer
......@@ -28,6 +31,16 @@ from tikka.interfaces.adapters.repository.transfers import TransfersRepositoryIn
from tikka.libs.keypair import Keypair
def format_datetime(dt: datetime) -> str:
"""
Formate une date en OFX (YYYYMMDDHHMMSS).
:param dt: Datetime data
:return:
"""
return dt.strftime("%Y%m%d%H%M%S")
class Transfers:
"""
Transfers domain class
......@@ -37,6 +50,7 @@ class Transfers:
self,
wallets: Wallets,
repository: TransfersRepositoryInterface,
currencies: Currencies,
node_transfers: NodeTransfersInterface,
indexer_transfers: IndexerTransfersInterface,
event_dispatcher: EventDispatcher,
......@@ -46,11 +60,13 @@ class Transfers:
:param wallets: Wallets domain
:param repository: TransfersRepositoryInterface adapter instance
:param currencies: Currencies domain instance
:param node_transfers: NodeTransfersInterface adapter instance
:param event_dispatcher: EventDispatcher instance
"""
self.wallets = wallets
self.repository = repository
self.currencies = currencies
self.node_transfers = node_transfers
self.indexer_transfers = indexer_transfers
self.event_dispatcher = event_dispatcher
......@@ -225,3 +241,120 @@ class Transfers:
self.event_dispatcher.dispatch_event(event)
return receipt
def export_as_ofx(
self,
filepath: str,
address: str,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None,
):
"""
Export transfers for address in an OFX format file
:param filepath: Path of the file
:param address: Account address
:param from_datetime: Optional period start date
:param to_datetime: Optional period end date
:return:
"""
batch_size = 100
offset = 0
with open(filepath, "w", encoding="utf-8") as f:
# Écriture de l'en-tête OFX
f.write(
f"""OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<BANKMSGSRSV1>
<STMTTRNRS>
<STMTRS>
<CURDEF>EUR</CURDEF>
<BANKACCTFROM>
<BANKID>{self.currencies.get_current().name}</BANKID>
<ACCTID>{address}</ACCTID>
<ACCTTYPE>CHECKING</ACCTTYPE>
</BANKACCTFROM>
<BANKTRANLIST>
"""
)
while True:
# Récupérer un lot de 100 transferts depuis GraphQL
transfers = self.indexer_transfers.list(
address=address,
limit=batch_size,
offset=offset,
from_datetime=from_datetime,
to_datetime=to_datetime,
)
if not transfers:
break # Arrêter si plus de transferts à récupérer
total_amount: float = 0.0
for transfer in transfers:
if transfer.issuer_address != address:
amount_sign = 1
name = (
f"{transfer.issuer_address} - {transfer.issuer_identity_name}#{transfer.issuer_identity_index}"
if transfer.issuer_identity_name
else transfer.issuer_address
)
else:
amount_sign = -1
name = (
f"{transfer.receiver_address} - {transfer.receiver_identity_name}#{transfer.receiver_identity_index}"
if transfer.receiver_identity_name
else transfer.receiver_address
)
total_amount += (
transfer.amount / 100
) * amount_sign # Convert cents to dollars
f.write(
f"""
<STMTTRN>
<TRNTYPE>XFER</TRNTYPE>
<DTPOSTED>{format_datetime(transfer.timestamp)}</DTPOSTED>
<TRNAMT>{(transfer.amount / 100)*amount_sign:.2f}</TRNAMT>
<FITID>{transfer.id}</FITID>
<NAME>{name}</NAME>
<MEMO>{transfer.comment or ''}</MEMO>
</STMTTRN>
"""
)
offset += batch_size # Passer au lot suivant
current_datetime = format_datetime(datetime.utcnow())
# Écriture du pied de fichier OFX
f.write(
f"""
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>{total_amount:.2f}</BALAMT>
<DTASOF>{current_datetime}</DTASOF>
</LEDGERBAL>
<AVAILBAL>
<BALAMT>{total_amount:.2f}</BALAMT>
<DTASOF>{current_datetime}</DTASOF>
</AVAILBAL>
</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>
"""
)
logging.debug(f"Export OFX terminé : {filepath}")
......@@ -43,20 +43,24 @@ class IndexerTransfersInterface(abc.ABC):
@abc.abstractmethod
def list(
self,
address,
address: str,
limit: int,
offset: int = 0,
sort_column: str = COLUMN_TIMESTAMP,
sort_order: str = SORT_ORDER_DESCENDING,
since: Optional[datetime] = None,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None,
) -> List[Transfer]:
"""
Return list of transfers from and to address
:param address: Account address
:param limit: Max number of transfers to return
:param offset: Offset to paginate results
:param sort_column: Sort column, default to IndexerTransfersInterface.COLUMN_TIMESTAMP
:param sort_order: Sort order, default to IndexerTransfersInterface.SORT_ORDER_DESCENDING
:param since: Only transfers since datetime, default to None
:param from_datetime: Only transfers from datetime, default to None
:param to_datetime: Only transfers until datetime, default to None
:return:
"""
raise NotImplementedError
......
......@@ -60,3 +60,6 @@ INDEXERS_TABLE_SORT_COLUMN_PREFERENCES_KEY = "indexers_table_sort_column"
INDEXERS_TABLE_SORT_ORDER_PREFERENCES_KEY = "indexers_table_sort_order"
TABS_PREFERENCES_KEY = "tabs_opened"
SAVE_DATA_DEFAULT_DIR_PREFERENCES_KEY = "save_data_default_dir"
TRANSFERS_EXPORT_DEFAULT_DIRECTORY_PREFERENCES_KEY = (
"transfers_export_default_directory"
)
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>transfersExportDialog</class>
<widget class="QDialog" name="transfersExportDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>801</width>
<height>457</height>
</rect>
</property>
<property name="windowTitle">
<string>Export as</string>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="fromLabel">
<property name="text">
<string>From</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QCalendarWidget" name="fromCalendarWidget"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="toLabel">
<property name="text">
<string>To</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QCalendarWidget" name="toCalendarWidget"/>
</item>
<item row="2" column="1">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="todayButton">
<property name="text">
<string>Today</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
......@@ -24,6 +24,7 @@ from tikka.domains.entities.account import Account
from tikka.domains.entities.constants import DATA_PATH
from tikka.domains.entities.transfer import Transfer
from tikka.slots.pyqt.windows.transfer import TransferWindow
from tikka.slots.pyqt.windows.transfers_export import TransfersExportWindow
class TransferHistoryPopupMenu(QMenu):
......@@ -96,6 +97,10 @@ class TransferHistoryPopupMenu(QMenu):
)
add_account_address_action.triggered.connect(self.add_address_to_accounts)
if self.application.connections.indexer.is_connected():
export_ofx_action = self.addAction(self._("Export in OFX file"))
export_ofx_action.triggered.connect(self.export_ofx)
def copy_address_to_clipboard(self):
"""
Copy address of selected row to clipboard
......@@ -149,6 +154,16 @@ class TransferHistoryPopupMenu(QMenu):
self.application.accounts.add(self.contact_account)
def export_ofx(self):
"""
Export transfers in an OFX file
:return:
"""
TransfersExportWindow(
self.application, self.account.address, self.parentWidget()
).exec_()
if __name__ == "__main__":
qapp = QApplication(sys.argv)
......
# Copyright 2021 Vincent Texier <vit@free.fr>
#
# This software is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
from typing import Optional
from PyQt5.QtCore import QDate
from PyQt5.QtWidgets import QDialog, QFileDialog, QWidget
from tikka.domains.application import Application
from tikka.slots.pyqt.entities.constants import (
TRANSFERS_EXPORT_DEFAULT_DIRECTORY_PREFERENCES_KEY,
)
from tikka.slots.pyqt.resources.gui.windows.transfers_export_rc import (
Ui_transfersExportDialog,
)
class TransfersExportWindow(QDialog, Ui_transfersExportDialog):
"""
TransfersExportWindow class
"""
MIN_PERIOD_IN_WEEKS = 4
MAX_PERIOD_IN_WEEKS = 104
def __init__(
self, application: Application, address: str, parent: Optional[QWidget] = None
):
"""
Init transfers export window instance
:param application: Application instance
:param address: Account address
:param parent: QWidget instance
"""
super().__init__(parent=parent)
self.setupUi(self)
self.application = application
self._ = self.application.translator.gettext
self.address = address
# buttons
self.buttonBox.accepted.connect(self.on_accepted_button)
self.buttonBox.rejected.connect(self.close)
self.fromCalendarWidget.selectionChanged.connect(self.update_date_constraints)
self.toCalendarWidget.selectionChanged.connect(self.update_date_constraints)
self.todayButton.clicked.connect(self.init_calendar_limits)
self.init_calendar_limits()
def init_calendar_limits(self):
"""
Définit les dates minimales et maximales autorisées pour les calendriers,
en se basant sur la période maximale définie.
"""
today = QDate.currentDate()
default_min_date = today.addDays(-self.MIN_PERIOD_IN_WEEKS * 7)
# Définir la plage de dates pour le calendrier "from"
# self.fromCalendarWidget.setDateRange(max_min_date, today)
self.fromCalendarWidget.setSelectedDate(default_min_date)
# Définir la plage de dates pour le calendrier "to"
# self.toCalendarWidget.setDateRange(min_date, max_date)
self.toCalendarWidget.setSelectedDate(today)
def update_date_constraints(self):
"""
Update calendars with date constraints
"""
from_date = self.fromCalendarWidget.selectedDate()
to_date = self.toCalendarWidget.selectedDate()
min_to_date = from_date.addDays(self.MIN_PERIOD_IN_WEEKS * 7)
max_from_date = to_date.addDays(-self.MAX_PERIOD_IN_WEEKS * 7)
if from_date > to_date:
self.fromCalendarWidget.setSelectedDate(to_date)
elif to_date < min_to_date:
self.toCalendarWidget.setSelectedDate(min_to_date)
max_to_date = from_date.addDays(self.MAX_PERIOD_IN_WEEKS * 7)
if to_date > max_to_date:
self.toCalendarWidget.setSelectedDate(max_to_date)
if from_date < max_from_date:
self.fromCalendarWidget.setSelectedDate(max_from_date)
# self.toCalendarWidget.setMinimumDate(from_date)
# self.toCalendarWidget.setMaximumDate(max_to_date)
# self.fromCalendarWidget.setMaximumDate(to_date)
def open_file_dialog(self) -> Optional[str]:
"""
Open file dialog and return the selected filepath or None
:return:
"""
default_dir = self.application.repository.preferences.get(
TRANSFERS_EXPORT_DEFAULT_DIRECTORY_PREFERENCES_KEY
)
if default_dir is not None:
default_dir = str(Path(default_dir).expanduser().absolute())
else:
default_dir = ""
result = QFileDialog.getSaveFileName(
self, self._("Export file"), default_dir, "OFX Files (*.ofx)"
)
if result[0] == "":
return None
self.application.repository.preferences.set(
TRANSFERS_EXPORT_DEFAULT_DIRECTORY_PREFERENCES_KEY,
str(Path(result[0]).parent),
)
return result[0]
def on_accepted_button(self) -> None:
"""
Triggered when user click on ok button
:return:
"""
# open file dialog
filepath = self.open_file_dialog()
if filepath is not None:
self.application.transfers.export_as_ofx(
filepath,
self.address,
self.fromCalendarWidget.selectedDate().toPyDate(),
self.toCalendarWidget.selectedDate().toPyDate(),
)
self.close()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment