beancount-gocardless 0.1.13__py3-none-any.whl → 0.1.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- beancount_gocardless/__main__.py +4 -0
- beancount_gocardless/cli.py +614 -113
- beancount_gocardless/client.py +13 -0
- beancount_gocardless/importer.py +96 -39
- beancount_gocardless/mock_client.py +220 -0
- beancount_gocardless/models.py +19 -8
- beancount_gocardless/utils.py +39 -0
- beancount_gocardless-0.1.14.dist-info/METADATA +252 -0
- beancount_gocardless-0.1.14.dist-info/RECORD +14 -0
- {beancount_gocardless-0.1.13.dist-info → beancount_gocardless-0.1.14.dist-info}/entry_points.txt +0 -1
- beancount_gocardless-0.1.14.dist-info/licenses/LICENSE +21 -0
- beancount_gocardless/tui.py +0 -669
- beancount_gocardless-0.1.13.dist-info/METADATA +0 -108
- beancount_gocardless-0.1.13.dist-info/RECORD +0 -12
- beancount_gocardless-0.1.13.dist-info/licenses/LICENSE +0 -24
- {beancount_gocardless-0.1.13.dist-info → beancount_gocardless-0.1.14.dist-info}/WHEEL +0 -0
beancount_gocardless/tui.py
DELETED
|
@@ -1,669 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import sys
|
|
3
|
-
from textual.app import App, ComposeResult
|
|
4
|
-
from textual.containers import Container, Vertical, ScrollableContainer
|
|
5
|
-
from textual.widgets import Header, Footer, Button, Static, Input, Label
|
|
6
|
-
from textual.message import Message
|
|
7
|
-
from textual.binding import Binding
|
|
8
|
-
from rich.table import Table
|
|
9
|
-
from rich.text import Text
|
|
10
|
-
from typing import Optional
|
|
11
|
-
from beancount_gocardless.client import GoCardlessClient
|
|
12
|
-
from requests.exceptions import HTTPError as HttpServiceException
|
|
13
|
-
import logging
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class ActionMessage(Message):
|
|
19
|
-
"""Custom message to signal actions within the TUI."""
|
|
20
|
-
|
|
21
|
-
def __init__(self, action: str, payload: Optional[dict] = None) -> None:
|
|
22
|
-
super().__init__()
|
|
23
|
-
self.action = action
|
|
24
|
-
self.payload = payload or {}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class MenuView(Static):
|
|
28
|
-
"""Main menu view."""
|
|
29
|
-
|
|
30
|
-
def compose(self) -> ComposeResult:
|
|
31
|
-
yield Vertical(
|
|
32
|
-
Label("GoCardLess TUI - Main Menu", id="title"),
|
|
33
|
-
Button("List Banks", id="list_banks", variant="primary"),
|
|
34
|
-
Button("List Linked Accounts", id="list_accounts", variant="primary"),
|
|
35
|
-
Button("Get Account Balance", id="get_balance", variant="primary"),
|
|
36
|
-
Button("Create New Link", id="create_link", variant="success"),
|
|
37
|
-
Button("Delete Existing Link", id="delete_link", variant="error"),
|
|
38
|
-
Button("Quit", id="quit", variant="default"),
|
|
39
|
-
id="menu_view_vertical",
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
43
|
-
self.post_message(ActionMessage(event.button.id))
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class BaseSubView(Static):
|
|
47
|
-
"""Base class for sub-views with a back button."""
|
|
48
|
-
|
|
49
|
-
def compose(self) -> ComposeResult:
|
|
50
|
-
yield from self.compose_content()
|
|
51
|
-
|
|
52
|
-
def compose_content(self) -> ComposeResult:
|
|
53
|
-
# To be overridden by subclasses
|
|
54
|
-
yield Static("Content goes here")
|
|
55
|
-
|
|
56
|
-
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
57
|
-
if event.button.id == "back_to_menu":
|
|
58
|
-
self.post_message(ActionMessage("show_menu"))
|
|
59
|
-
else:
|
|
60
|
-
# Allow subclasses to handle other buttons
|
|
61
|
-
pass
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class BanksView(BaseSubView):
|
|
65
|
-
"""View to display list of banks with country and name filtering."""
|
|
66
|
-
|
|
67
|
-
def __init__(self, client: GoCardlessClient, country: Optional[str] = None):
|
|
68
|
-
super().__init__()
|
|
69
|
-
self.client = client
|
|
70
|
-
self.country = country
|
|
71
|
-
self.all_banks = None # Store all banks
|
|
72
|
-
self.status_message = Static("Loading banks...", id="banks_status_message")
|
|
73
|
-
self.banks_table = Static("", id="banks_table")
|
|
74
|
-
|
|
75
|
-
def compose_content(self) -> ComposeResult:
|
|
76
|
-
yield Label("Banks", classes="view_title")
|
|
77
|
-
yield Input(placeholder="Filter banks by name...", id="bank_filter_input")
|
|
78
|
-
yield Input(
|
|
79
|
-
value=self.country,
|
|
80
|
-
placeholder="Country (e.g. FR)",
|
|
81
|
-
id="country_filter_input",
|
|
82
|
-
max_length=2,
|
|
83
|
-
)
|
|
84
|
-
yield self.status_message
|
|
85
|
-
yield ScrollableContainer(
|
|
86
|
-
self.banks_table,
|
|
87
|
-
id="banks_scrollable_area",
|
|
88
|
-
classes="horizontal-scroll",
|
|
89
|
-
)
|
|
90
|
-
yield Button("Back to Menu", id="back_to_menu", classes="back_button")
|
|
91
|
-
|
|
92
|
-
# Override the base class compose to avoid duplicate back button
|
|
93
|
-
def compose(self) -> ComposeResult:
|
|
94
|
-
yield Vertical(
|
|
95
|
-
*self.compose_content(),
|
|
96
|
-
id="banks_view_vertical",
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
async def on_mount(self) -> None:
|
|
100
|
-
"""Load all banks on mount."""
|
|
101
|
-
await self.filter_banks("", "")
|
|
102
|
-
|
|
103
|
-
async def filter_banks(
|
|
104
|
-
self, name_filter: Optional[str] = "", country_code: Optional[str] = None
|
|
105
|
-
) -> None:
|
|
106
|
-
"""Filters the banks based on current country and name filter."""
|
|
107
|
-
try:
|
|
108
|
-
if not self.all_banks:
|
|
109
|
-
self.status_message.update("Loading all banks...")
|
|
110
|
-
self.all_banks = [
|
|
111
|
-
inst.model_dump()
|
|
112
|
-
for inst in self.client.get_institutions(country=None)
|
|
113
|
-
]
|
|
114
|
-
|
|
115
|
-
self.banks_table.update(
|
|
116
|
-
f"Loading banks... {name_filter} {country_code} {len(self.all_banks)}"
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
name_filtered = [
|
|
120
|
-
b
|
|
121
|
-
for b in self.all_banks
|
|
122
|
-
if (name_filter.upper() in b.get("name", "").upper() or not name_filter)
|
|
123
|
-
and (country_code in b.get("countries", []) or not country_code)
|
|
124
|
-
]
|
|
125
|
-
|
|
126
|
-
if not name_filtered:
|
|
127
|
-
filter_msg = f" matching '{name_filter}'" if name_filter else ""
|
|
128
|
-
self.banks_table.update(
|
|
129
|
-
f"No banks found in {country_code}{filter_msg}. Total: {len(self.all_banks)} available."
|
|
130
|
-
)
|
|
131
|
-
self.status_message.update("")
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
# Create a formatted table for display
|
|
135
|
-
table = Table(title=None, expand=True, min_width=80)
|
|
136
|
-
table.add_column("Name", overflow="ellipsis", min_width=30)
|
|
137
|
-
table.add_column("ID", overflow="ellipsis", min_width=20)
|
|
138
|
-
table.add_column("Countries", overflow="ellipsis", min_width=15)
|
|
139
|
-
|
|
140
|
-
for bank in name_filtered:
|
|
141
|
-
countries = ", ".join(bank.get("countries", []))
|
|
142
|
-
table.add_row(bank.get("name", "N/A"), bank.get("id", "N/A"), countries)
|
|
143
|
-
|
|
144
|
-
# Update the table content
|
|
145
|
-
self.banks_table.update(table)
|
|
146
|
-
self.status_message.update(f"Showing {len(name_filtered)} banks")
|
|
147
|
-
|
|
148
|
-
except Exception as e:
|
|
149
|
-
self.status_message.update(
|
|
150
|
-
Text(f"Error filtering banks: {e}", style="bold red")
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
154
|
-
"""Event handler for input changes in either filter input."""
|
|
155
|
-
input_id = event.input.id
|
|
156
|
-
if input_id in ["country_filter_input", "bank_filter_input"]:
|
|
157
|
-
name_input = self.query_one("#bank_filter_input", Input)
|
|
158
|
-
country_input = self.query_one("#country_filter_input", Input)
|
|
159
|
-
country_code = country_input.value.upper()
|
|
160
|
-
omit_country = len(country_code) != 2
|
|
161
|
-
|
|
162
|
-
await self.filter_banks(
|
|
163
|
-
name_filter=name_input.value,
|
|
164
|
-
country_code=country_code if not omit_country else None,
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
168
|
-
"""Handle button presses within this view."""
|
|
169
|
-
if event.button.id == "back_to_menu":
|
|
170
|
-
self.post_message(ActionMessage("show_menu"))
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
class AccountsView(BaseSubView):
|
|
174
|
-
"""View to display linked accounts with complete information."""
|
|
175
|
-
|
|
176
|
-
def __init__(self, client: GoCardlessClient):
|
|
177
|
-
super().__init__()
|
|
178
|
-
self.client = client
|
|
179
|
-
self.status_message = Static(
|
|
180
|
-
"Loading accounts...", id="accounts_status_message"
|
|
181
|
-
)
|
|
182
|
-
self.accounts_table = Static("", id="accounts_table")
|
|
183
|
-
self.pending_delete_ref = None
|
|
184
|
-
self.accounts_list = []
|
|
185
|
-
|
|
186
|
-
def compose_content(self) -> ComposeResult:
|
|
187
|
-
yield Label("Linked Accounts", classes="view_title")
|
|
188
|
-
yield self.status_message
|
|
189
|
-
yield ScrollableContainer(
|
|
190
|
-
self.accounts_table,
|
|
191
|
-
id="accounts_scrollable_area",
|
|
192
|
-
classes="horizontal-scroll",
|
|
193
|
-
)
|
|
194
|
-
yield Vertical(
|
|
195
|
-
Label("Select Account by # and Delete Link"),
|
|
196
|
-
Input(placeholder="Enter account #", id="select_account_input"),
|
|
197
|
-
Button("Select", id="select_account_button"),
|
|
198
|
-
Input(placeholder="Reference", id="delete_ref_input", disabled=True),
|
|
199
|
-
Button(
|
|
200
|
-
"Delete Link", id="delete_link_button", variant="error", disabled=True
|
|
201
|
-
),
|
|
202
|
-
Button(
|
|
203
|
-
"Confirm Delete",
|
|
204
|
-
id="confirm_delete_button",
|
|
205
|
-
variant="error",
|
|
206
|
-
disabled=True,
|
|
207
|
-
),
|
|
208
|
-
Button("Cancel", id="cancel_delete_button", disabled=True),
|
|
209
|
-
id="delete_section",
|
|
210
|
-
)
|
|
211
|
-
yield Button("Back to Menu", id="back_to_menu", classes="back_button")
|
|
212
|
-
|
|
213
|
-
# Override the base class compose to avoid duplicate back button
|
|
214
|
-
def compose(self) -> ComposeResult:
|
|
215
|
-
yield Vertical(
|
|
216
|
-
*self.compose_content(),
|
|
217
|
-
id="accounts_view_vertical",
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
async def on_mount(self) -> None:
|
|
221
|
-
await self.load_accounts()
|
|
222
|
-
|
|
223
|
-
async def load_accounts(self) -> None:
|
|
224
|
-
"""Loads and displays the linked accounts with detailed information."""
|
|
225
|
-
try:
|
|
226
|
-
self.status_message.update("Loading accounts...")
|
|
227
|
-
accounts = self.client.get_all_accounts()
|
|
228
|
-
self.accounts_list = accounts
|
|
229
|
-
if not accounts:
|
|
230
|
-
self.accounts_table.update("No accounts found.")
|
|
231
|
-
self.status_message.update("")
|
|
232
|
-
return
|
|
233
|
-
|
|
234
|
-
# Create a formatted table for display
|
|
235
|
-
table = Table(title=None, expand=True, min_width=100)
|
|
236
|
-
table.add_column("#", style="dim", width=3)
|
|
237
|
-
table.add_column("Bank", overflow="ellipsis", min_width=20)
|
|
238
|
-
table.add_column("Name", overflow="ellipsis", min_width=20)
|
|
239
|
-
table.add_column("ID", overflow="ellipsis", min_width=20)
|
|
240
|
-
table.add_column("IBAN", overflow="ellipsis", min_width=20)
|
|
241
|
-
table.add_column("Reference", overflow="ellipsis", min_width=15)
|
|
242
|
-
table.add_column("Status", width=6)
|
|
243
|
-
table.add_column("Last Accessed", min_width=12)
|
|
244
|
-
|
|
245
|
-
# Format and add accounts to the table
|
|
246
|
-
for idx, account in enumerate(accounts, start=1):
|
|
247
|
-
# Get values with fallbacks for any missing keys
|
|
248
|
-
institution_id = account.get("institution_id", "N/A")
|
|
249
|
-
name = account.get("name", account.get("owner_name", "N/A"))
|
|
250
|
-
iban = account.get("iban", "N/A")
|
|
251
|
-
account_id = account.get("id", "N/A")
|
|
252
|
-
reference = account.get("requisition_reference", "N/A")
|
|
253
|
-
status = account.get("status", "N/A")
|
|
254
|
-
last_accessed = account.get("last_accessed", "")
|
|
255
|
-
if last_accessed:
|
|
256
|
-
try:
|
|
257
|
-
last_accessed = last_accessed.split("T")[0]
|
|
258
|
-
except Exception:
|
|
259
|
-
pass
|
|
260
|
-
|
|
261
|
-
table.add_row(
|
|
262
|
-
str(idx),
|
|
263
|
-
institution_id,
|
|
264
|
-
name,
|
|
265
|
-
account_id,
|
|
266
|
-
iban,
|
|
267
|
-
reference,
|
|
268
|
-
status,
|
|
269
|
-
last_accessed,
|
|
270
|
-
)
|
|
271
|
-
|
|
272
|
-
# Update the table content
|
|
273
|
-
self.accounts_table.update(table)
|
|
274
|
-
if not accounts:
|
|
275
|
-
self.accounts_table.add_row(
|
|
276
|
-
"No accounts found.", "", "", "", "", "", "", ""
|
|
277
|
-
)
|
|
278
|
-
self.status_message.update("")
|
|
279
|
-
return
|
|
280
|
-
|
|
281
|
-
# Format and add accounts to the table
|
|
282
|
-
for idx, account in enumerate(accounts, start=1):
|
|
283
|
-
# Get values with fallbacks for any missing keys
|
|
284
|
-
institution_id = account.get("institution_id", "N/A")
|
|
285
|
-
name = account.get("name", account.get("owner_name", "N/A"))
|
|
286
|
-
iban = account.get("iban", "N/A")
|
|
287
|
-
account_id = account.get("id", "N/A")
|
|
288
|
-
reference = account.get("requisition_reference", "N/A")
|
|
289
|
-
status = account.get("status", "N/A")
|
|
290
|
-
last_accessed = account.get("last_accessed", "")
|
|
291
|
-
if last_accessed:
|
|
292
|
-
try:
|
|
293
|
-
last_accessed = last_accessed.split("T")[0]
|
|
294
|
-
except Exception:
|
|
295
|
-
pass
|
|
296
|
-
|
|
297
|
-
self.accounts_table.add_row(
|
|
298
|
-
str(idx),
|
|
299
|
-
institution_id,
|
|
300
|
-
name,
|
|
301
|
-
account_id,
|
|
302
|
-
iban,
|
|
303
|
-
reference,
|
|
304
|
-
status,
|
|
305
|
-
last_accessed,
|
|
306
|
-
)
|
|
307
|
-
self.status_message.update("")
|
|
308
|
-
|
|
309
|
-
except HttpServiceException as e:
|
|
310
|
-
self.status_message.update(Text(f"API Error: {e}", style="bold red"))
|
|
311
|
-
except Exception as e:
|
|
312
|
-
self.status_message.update(Text(f"Unexpected error: {e}", style="bold red"))
|
|
313
|
-
|
|
314
|
-
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
315
|
-
"""Handle button presses within this view."""
|
|
316
|
-
if event.button.id == "back_to_menu":
|
|
317
|
-
self.post_message(ActionMessage("show_menu"))
|
|
318
|
-
|
|
319
|
-
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
320
|
-
"""Handle button presses within this view."""
|
|
321
|
-
if event.button.id == "back_to_menu":
|
|
322
|
-
self.post_message(ActionMessage("show_menu"))
|
|
323
|
-
elif event.button.id == "select_account_button":
|
|
324
|
-
select_input = self.query_one("#select_account_input", Input)
|
|
325
|
-
try:
|
|
326
|
-
idx = int(select_input.value.strip()) - 1 # 1-based to 0-based
|
|
327
|
-
if 0 <= idx < len(self.accounts_list):
|
|
328
|
-
reference = self.accounts_list[idx].get("requisition_reference", "")
|
|
329
|
-
ref_input = self.query_one("#delete_ref_input", Input)
|
|
330
|
-
ref_input.value = reference
|
|
331
|
-
delete_btn = self.query_one("#delete_link_button", Button)
|
|
332
|
-
delete_btn.disabled = False
|
|
333
|
-
self.status_message.update(
|
|
334
|
-
f"Selected account {idx + 1}, reference: {reference}"
|
|
335
|
-
)
|
|
336
|
-
else:
|
|
337
|
-
self.status_message.update("Invalid account number.")
|
|
338
|
-
except ValueError:
|
|
339
|
-
self.status_message.update("Please enter a valid number.")
|
|
340
|
-
elif event.button.id == "delete_link_button":
|
|
341
|
-
ref_input = self.query_one("#delete_ref_input", Input)
|
|
342
|
-
reference = ref_input.value.strip()
|
|
343
|
-
if reference:
|
|
344
|
-
self.pending_delete_ref = reference
|
|
345
|
-
self.status_message.update(
|
|
346
|
-
Text(
|
|
347
|
-
f"Are you sure to delete link with reference '{reference}'?",
|
|
348
|
-
style="bold yellow",
|
|
349
|
-
)
|
|
350
|
-
)
|
|
351
|
-
confirm_btn = self.query_one("#confirm_delete_button", Button)
|
|
352
|
-
cancel_btn = self.query_one("#cancel_delete_button", Button)
|
|
353
|
-
confirm_btn.disabled = False
|
|
354
|
-
cancel_btn.disabled = False
|
|
355
|
-
else:
|
|
356
|
-
self.status_message.update(
|
|
357
|
-
Text("No reference selected.", style="bold yellow")
|
|
358
|
-
)
|
|
359
|
-
elif event.button.id == "confirm_delete_button":
|
|
360
|
-
if self.pending_delete_ref:
|
|
361
|
-
await self.process_delete_link(self.pending_delete_ref)
|
|
362
|
-
select_input = self.query_one("#select_account_input", Input)
|
|
363
|
-
ref_input = self.query_one("#delete_ref_input", Input)
|
|
364
|
-
select_input.value = ""
|
|
365
|
-
ref_input.value = ""
|
|
366
|
-
delete_btn = self.query_one("#delete_link_button", Button)
|
|
367
|
-
delete_btn.disabled = True
|
|
368
|
-
self.pending_delete_ref = None
|
|
369
|
-
confirm_btn = self.query_one("#confirm_delete_button", Button)
|
|
370
|
-
cancel_btn = self.query_one("#cancel_delete_button", Button)
|
|
371
|
-
confirm_btn.disabled = True
|
|
372
|
-
cancel_btn.disabled = True
|
|
373
|
-
elif event.button.id == "cancel_delete_button":
|
|
374
|
-
self.pending_delete_ref = None
|
|
375
|
-
self.status_message.update("Delete cancelled.")
|
|
376
|
-
confirm_btn = self.query_one("#confirm_delete_button", Button)
|
|
377
|
-
cancel_btn = self.query_one("#cancel_delete_button", Button)
|
|
378
|
-
confirm_btn.disabled = True
|
|
379
|
-
cancel_btn.disabled = True
|
|
380
|
-
elif event.button.id == "cancel_delete_button":
|
|
381
|
-
self.pending_delete_ref = None
|
|
382
|
-
self.status_message.update("Delete cancelled.")
|
|
383
|
-
confirm_btn = self.query_one("#confirm_delete_button", Button)
|
|
384
|
-
cancel_btn = self.query_one("#cancel_delete_button", Button)
|
|
385
|
-
confirm_btn.disabled = True
|
|
386
|
-
cancel_btn.disabled = True
|
|
387
|
-
elif event.button.id == "do_create_link_button":
|
|
388
|
-
await self.process_create_link()
|
|
389
|
-
|
|
390
|
-
async def process_create_link(self) -> None:
|
|
391
|
-
bank_input = self.query_one("#bank_id_input", Input)
|
|
392
|
-
ref_input = self.query_one("#ref_input", Input)
|
|
393
|
-
result_message_widget = self.query_one("#link_result_message", Static)
|
|
394
|
-
create_button = self.query_one("#do_create_link_button", Button)
|
|
395
|
-
|
|
396
|
-
bank_id = bank_input.value.strip()
|
|
397
|
-
reference = ref_input.value.strip()
|
|
398
|
-
|
|
399
|
-
if not bank_id or not reference:
|
|
400
|
-
result_message_widget.update(
|
|
401
|
-
Text("Bank ID and Reference are required.", style="bold red")
|
|
402
|
-
)
|
|
403
|
-
return
|
|
404
|
-
|
|
405
|
-
try:
|
|
406
|
-
bank_input.disabled = True
|
|
407
|
-
ref_input.disabled = True
|
|
408
|
-
create_button.disabled = True
|
|
409
|
-
result_message_widget.update("Creating link...")
|
|
410
|
-
|
|
411
|
-
link_url = self.client.create_bank_link(reference, bank_id) # API call
|
|
412
|
-
|
|
413
|
-
if link_url:
|
|
414
|
-
link_info = {
|
|
415
|
-
"status": "created",
|
|
416
|
-
"message": "Link created successfully",
|
|
417
|
-
"link": link_url,
|
|
418
|
-
}
|
|
419
|
-
else:
|
|
420
|
-
link_info = {
|
|
421
|
-
"status": "exists",
|
|
422
|
-
"message": "Link already exists for this reference",
|
|
423
|
-
"link": None,
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
msg_parts = [
|
|
427
|
-
Text(f"Status: {link_info.get('status', 'N/A')}\n", style="bold")
|
|
428
|
-
]
|
|
429
|
-
if link_info.get("message"):
|
|
430
|
-
msg_parts.append(Text(f"Message: {link_info['message']}\n"))
|
|
431
|
-
if link_info.get("link"):
|
|
432
|
-
msg_parts.append(
|
|
433
|
-
Text(
|
|
434
|
-
"Link URL (copy and open in browser to authorize):\n",
|
|
435
|
-
style="bold yellow",
|
|
436
|
-
)
|
|
437
|
-
)
|
|
438
|
-
msg_parts.append(Text(f"{link_info['link']}", style="underline blue"))
|
|
439
|
-
|
|
440
|
-
result_message_widget.update(Text.assemble(*msg_parts))
|
|
441
|
-
|
|
442
|
-
except HttpServiceException as e:
|
|
443
|
-
result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
|
|
444
|
-
except Exception as e:
|
|
445
|
-
result_message_widget.update(
|
|
446
|
-
Text(f"Unexpected Error:\n{e}", style="bold red")
|
|
447
|
-
)
|
|
448
|
-
finally:
|
|
449
|
-
bank_input.disabled = False
|
|
450
|
-
ref_input.disabled = False
|
|
451
|
-
create_button.disabled = False
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
class DeleteLinkView(BaseSubView):
|
|
455
|
-
"""View to delete an existing bank link."""
|
|
456
|
-
|
|
457
|
-
def __init__(self, client: GoCardlessClient):
|
|
458
|
-
super().__init__()
|
|
459
|
-
self.client = client
|
|
460
|
-
|
|
461
|
-
def compose(self) -> ComposeResult:
|
|
462
|
-
"""Override compose to yield widgets from compose_content."""
|
|
463
|
-
for widget in self.compose_content():
|
|
464
|
-
yield widget
|
|
465
|
-
|
|
466
|
-
def compose_content(self) -> ComposeResult:
|
|
467
|
-
yield Label("Delete Existing Link", classes="view_title")
|
|
468
|
-
yield Vertical(
|
|
469
|
-
Input(placeholder="Reference of link to delete", id="del_ref_input"),
|
|
470
|
-
Button("Delete Link", id="do_delete_link_button", variant="error"),
|
|
471
|
-
Static(id="delete_link_result_message", classes="result_message_area"),
|
|
472
|
-
id="delete_link_form_area",
|
|
473
|
-
)
|
|
474
|
-
yield Button("Back to Menu", id="back_to_menu", classes="back_button")
|
|
475
|
-
|
|
476
|
-
async def on_mount(self) -> None:
|
|
477
|
-
self.query_one("#del_ref_input", Input).focus()
|
|
478
|
-
|
|
479
|
-
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
480
|
-
if event.button.id == "back_to_menu":
|
|
481
|
-
self.post_message(ActionMessage("show_menu"))
|
|
482
|
-
elif event.button.id == "do_delete_link_button":
|
|
483
|
-
await self.process_delete_link()
|
|
484
|
-
|
|
485
|
-
async def process_delete_link(self) -> None:
|
|
486
|
-
ref_input = self.query_one("#del_ref_input", Input)
|
|
487
|
-
result_message_widget = self.query_one("#delete_link_result_message", Static)
|
|
488
|
-
delete_button = self.query_one("#do_delete_link_button", Button)
|
|
489
|
-
|
|
490
|
-
reference = ref_input.value.strip()
|
|
491
|
-
|
|
492
|
-
if not reference:
|
|
493
|
-
result_message_widget.update(
|
|
494
|
-
Text("Reference is required to delete a link.", style="bold red")
|
|
495
|
-
)
|
|
496
|
-
return
|
|
497
|
-
|
|
498
|
-
try:
|
|
499
|
-
ref_input.disabled = True
|
|
500
|
-
delete_button.disabled = True
|
|
501
|
-
result_message_widget.update(
|
|
502
|
-
f"Deleting link with reference '{reference}'..."
|
|
503
|
-
)
|
|
504
|
-
|
|
505
|
-
req = self.client.find_requisition_by_reference(reference)
|
|
506
|
-
if req:
|
|
507
|
-
self.client.delete_requisition(req.id) # API call
|
|
508
|
-
result = {"status": "deleted", "message": "Link deleted successfully"}
|
|
509
|
-
else:
|
|
510
|
-
result = {"status": "not_found", "message": "Link not found"}
|
|
511
|
-
|
|
512
|
-
style = "bold green" if result["status"] == "deleted" else "bold yellow"
|
|
513
|
-
result_message_widget.update(
|
|
514
|
-
Text(
|
|
515
|
-
f"Status: {result['status']}\nMessage: {result['message']}",
|
|
516
|
-
style=style,
|
|
517
|
-
)
|
|
518
|
-
)
|
|
519
|
-
|
|
520
|
-
except HttpServiceException as e:
|
|
521
|
-
result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
|
|
522
|
-
except Exception as e:
|
|
523
|
-
result_message_widget.update(
|
|
524
|
-
Text(f"Unexpected Error:\n{e}", style="bold red")
|
|
525
|
-
)
|
|
526
|
-
finally:
|
|
527
|
-
ref_input.disabled = False
|
|
528
|
-
delete_button.disabled = False
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
class GoCardLessApp(App):
|
|
532
|
-
TITLE = "GoCardLess API TUI"
|
|
533
|
-
CSS = """
|
|
534
|
-
Screen {
|
|
535
|
-
align: center middle;
|
|
536
|
-
}
|
|
537
|
-
#app_container {
|
|
538
|
-
width: 100%;
|
|
539
|
-
max-width: 300; /* Max width for the main content container */
|
|
540
|
-
height: auto;
|
|
541
|
-
border: round $primary;
|
|
542
|
-
padding: 1 2;
|
|
543
|
-
}
|
|
544
|
-
#title { /* For MenuView title */
|
|
545
|
-
width: 100%;
|
|
546
|
-
text-align: center;
|
|
547
|
-
padding: 1 0 2 0;
|
|
548
|
-
text-style: bold;
|
|
549
|
-
}
|
|
550
|
-
.view_title { /* For sub-view titles */
|
|
551
|
-
width: 100%;
|
|
552
|
-
text-align: center;
|
|
553
|
-
padding: 0 0 1 0;
|
|
554
|
-
text-style: bold underline;
|
|
555
|
-
}
|
|
556
|
-
#menu_view_vertical > Button {
|
|
557
|
-
width: 100%;
|
|
558
|
-
margin-bottom: 1;
|
|
559
|
-
}
|
|
560
|
-
.back_button {
|
|
561
|
-
width: 100%;
|
|
562
|
-
margin-top: 2;
|
|
563
|
-
}
|
|
564
|
-
Input, Button { /* General spacing for inputs and buttons in forms */
|
|
565
|
-
margin-bottom: 1;
|
|
566
|
-
}
|
|
567
|
-
.result_message_area {
|
|
568
|
-
margin-top: 1;
|
|
569
|
-
padding: 1;
|
|
570
|
-
border: round $primary-background-darken-2;
|
|
571
|
-
min-height: 3;
|
|
572
|
-
width: 100%;
|
|
573
|
-
}
|
|
574
|
-
Vertical { /* Ensure vertical containers take full width by default */
|
|
575
|
-
width: 100%;
|
|
576
|
-
}
|
|
577
|
-
Table {
|
|
578
|
-
margin-top: 1;
|
|
579
|
-
}
|
|
580
|
-
"""
|
|
581
|
-
BINDINGS = [
|
|
582
|
-
Binding("q", "quit", "Quit App", show=True, priority=True),
|
|
583
|
-
Binding(
|
|
584
|
-
"escape", "show_menu_escape", "Back to Menu", show=True
|
|
585
|
-
), # Fixed: Always show, we'll control visibility elsewhere
|
|
586
|
-
]
|
|
587
|
-
|
|
588
|
-
def __init__(self, secret_id=None, secret_key=None):
|
|
589
|
-
super().__init__()
|
|
590
|
-
sid = secret_id or os.getenv("GOCARDLESS_SECRET_ID")
|
|
591
|
-
sk = secret_key or os.getenv("GOCARDLESS_SECRET_KEY")
|
|
592
|
-
if not sid or not sk:
|
|
593
|
-
logger.error(
|
|
594
|
-
"Error: GoCardLess credentials (GOCARDLESS_SECRET_ID, GOCARDLESS_SECRET_KEY) not found.",
|
|
595
|
-
)
|
|
596
|
-
logger.error(
|
|
597
|
-
"Please set them as environment variables or pass via arguments (not implemented in this TUI version).",
|
|
598
|
-
)
|
|
599
|
-
sys.exit(1) # Exit if no creds
|
|
600
|
-
self.client = GoCardlessClient(sid, sk, {})
|
|
601
|
-
self._current_view_is_menu = True
|
|
602
|
-
|
|
603
|
-
def compose(self) -> ComposeResult:
|
|
604
|
-
yield Header()
|
|
605
|
-
yield Container(MenuView(), id="app_container")
|
|
606
|
-
yield Footer()
|
|
607
|
-
|
|
608
|
-
async def on_mount(self) -> None:
|
|
609
|
-
# Fixed: removed the call to update_escape_binding since we're showing escape binding always
|
|
610
|
-
# and managing its behavior in action_show_menu_escape
|
|
611
|
-
pass
|
|
612
|
-
|
|
613
|
-
async def switch_view(self, new_view_widget: Static) -> None:
|
|
614
|
-
container = self.query_one("#app_container", Container)
|
|
615
|
-
await container.remove_children()
|
|
616
|
-
await container.mount(new_view_widget)
|
|
617
|
-
self._current_view_is_menu = isinstance(new_view_widget, MenuView)
|
|
618
|
-
|
|
619
|
-
async def action_show_menu_escape(self) -> None:
|
|
620
|
-
"""Handle Escape key press to go back to menu."""
|
|
621
|
-
if not self._current_view_is_menu:
|
|
622
|
-
await self.switch_view(MenuView())
|
|
623
|
-
|
|
624
|
-
async def on_action_message(self, msg: ActionMessage) -> None:
|
|
625
|
-
action_to_view_map = {
|
|
626
|
-
"show_menu": MenuView,
|
|
627
|
-
"list_banks": lambda: BanksView(self.client),
|
|
628
|
-
"list_accounts": lambda: AccountsView(self.client),
|
|
629
|
-
"get_balance": lambda: BalanceView(self.client),
|
|
630
|
-
"create_link": lambda: LinkView(self.client),
|
|
631
|
-
"delete_link": lambda: DeleteLinkView(self.client),
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
if msg.action == "quit":
|
|
635
|
-
self.exit()
|
|
636
|
-
return
|
|
637
|
-
|
|
638
|
-
if msg.action in action_to_view_map:
|
|
639
|
-
view_constructor = action_to_view_map[msg.action]
|
|
640
|
-
new_view = view_constructor()
|
|
641
|
-
await self.switch_view(new_view)
|
|
642
|
-
else:
|
|
643
|
-
# Fallback for unknown action, though should not happen with defined buttons
|
|
644
|
-
container = self.query_one("#app_container", Container)
|
|
645
|
-
await container.remove_children()
|
|
646
|
-
await container.mount(
|
|
647
|
-
Static(Text(f"Unknown action: {msg.action}", style="bold red"))
|
|
648
|
-
)
|
|
649
|
-
self._current_view_is_menu = False # Assuming it's not menu
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
logger.info(os.getenv("GOCARDLESS_SECRET_ID"), os.getenv("GOCARDLESS_SECRET_KEY"))
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
def main():
|
|
656
|
-
# For this TUI, credentials must be set as environment variables
|
|
657
|
-
# GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY
|
|
658
|
-
if not os.getenv("GOCARDLESS_SECRET_ID") or not os.getenv("GOCARDLESS_SECRET_KEY"):
|
|
659
|
-
logger.error(
|
|
660
|
-
"Error: GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY environment variables must be set.",
|
|
661
|
-
)
|
|
662
|
-
sys.exit(1)
|
|
663
|
-
|
|
664
|
-
app = GoCardLessApp()
|
|
665
|
-
app.run()
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if __name__ == "__main__":
|
|
669
|
-
main()
|