odsbox-pilot 1.0.0__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.
- odsbox_pilot/__init__.py +3 -0
- odsbox_pilot/__main__.py +23 -0
- odsbox_pilot/app.py +77 -0
- odsbox_pilot/connection/__init__.py +0 -0
- odsbox_pilot/connection/connect_dialog.py +401 -0
- odsbox_pilot/connection/manager.py +107 -0
- odsbox_pilot/connection/server_list_dialog.py +169 -0
- odsbox_pilot/models.py +74 -0
- odsbox_pilot/query/__init__.py +0 -0
- odsbox_pilot/query/editor_panel.py +213 -0
- odsbox_pilot/query/examples.py +369 -0
- odsbox_pilot/query/history.py +89 -0
- odsbox_pilot/query/main_frame.py +186 -0
- odsbox_pilot/query/result_grid.py +153 -0
- odsbox_pilot/static/codemirror/bundle.js +21461 -0
- odsbox_pilot/static/editor.html +47 -0
- odsbox_pilot-1.0.0.dist-info/METADATA +92 -0
- odsbox_pilot-1.0.0.dist-info/RECORD +21 -0
- odsbox_pilot-1.0.0.dist-info/WHEEL +4 -0
- odsbox_pilot-1.0.0.dist-info/entry_points.txt +3 -0
- odsbox_pilot-1.0.0.dist-info/licenses/LICENSE +192 -0
odsbox_pilot/__init__.py
ADDED
odsbox_pilot/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Entry point: python -m odsbox_pilot"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
try:
|
|
8
|
+
from odsbox_pilot.app import OdsPilotApp
|
|
9
|
+
except ImportError as exc:
|
|
10
|
+
print(
|
|
11
|
+
"wxPython is required to run odsbox-pilot.\n"
|
|
12
|
+
"Install it with: pip install odsbox-pilot[gui]",
|
|
13
|
+
file=sys.stderr,
|
|
14
|
+
)
|
|
15
|
+
raise SystemExit(1) from exc
|
|
16
|
+
|
|
17
|
+
app = OdsPilotApp()
|
|
18
|
+
app.MainLoop()
|
|
19
|
+
sys.exit(0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
odsbox_pilot/app.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""OdsPilotApp: application bootstrap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
|
|
7
|
+
import wx # type: ignore[import-untyped]
|
|
8
|
+
|
|
9
|
+
from odsbox_pilot.connection.manager import ServerConfigManager
|
|
10
|
+
from odsbox_pilot.connection.server_list_dialog import ServerListDialog
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OdsPilotApp(wx.App):
|
|
14
|
+
def OnInit(self) -> bool: # noqa: N802
|
|
15
|
+
manager = ServerConfigManager()
|
|
16
|
+
dlg = ServerListDialog(None, manager)
|
|
17
|
+
|
|
18
|
+
while True:
|
|
19
|
+
result = dlg.ShowModal()
|
|
20
|
+
if result != wx.ID_OK:
|
|
21
|
+
dlg.Destroy()
|
|
22
|
+
return False # user closed the dialog — exit
|
|
23
|
+
|
|
24
|
+
config = dlg.selected_config
|
|
25
|
+
if config is None:
|
|
26
|
+
dlg.Destroy()
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
# Try to connect (opens ConnectDialog flow via Save & Connect path,
|
|
30
|
+
# but here the user clicked "Connect" from the list which means the
|
|
31
|
+
# config already exists — we just need to open ConnectDialog in
|
|
32
|
+
# connect-only mode to resolve the secret and build a ConI).
|
|
33
|
+
con_i = self._connect(dlg, manager, config)
|
|
34
|
+
if con_i is None:
|
|
35
|
+
# Connection cancelled or failed — go back to server list
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
dlg.Destroy()
|
|
39
|
+
self._open_main_frame(con_i, config.name)
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
# Helpers
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def _connect(self, parent, manager: ServerConfigManager, config): # type: ignore[return]
|
|
47
|
+
"""Connect using saved credentials; open ConnectDialog only on failure."""
|
|
48
|
+
from odsbox_pilot.connection.connect_dialog import ConnectDialog, do_connect
|
|
49
|
+
|
|
50
|
+
secret = manager.load_secret(config) or ""
|
|
51
|
+
try:
|
|
52
|
+
wx.BeginBusyCursor()
|
|
53
|
+
con_i = do_connect(config, secret)
|
|
54
|
+
return con_i
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
wx.MessageBox(
|
|
57
|
+
f"Connection failed:\n\n{exc}\n\nPlease check your settings.",
|
|
58
|
+
"Connection Error",
|
|
59
|
+
wx.OK | wx.ICON_ERROR,
|
|
60
|
+
parent,
|
|
61
|
+
)
|
|
62
|
+
# Fall back to edit dialog so user can fix credentials
|
|
63
|
+
connect_dlg = ConnectDialog(parent, manager, config=config)
|
|
64
|
+
result = connect_dlg.ShowModal()
|
|
65
|
+
con_i = connect_dlg.con_i if result == wx.ID_OK else None
|
|
66
|
+
connect_dlg.Destroy()
|
|
67
|
+
return con_i
|
|
68
|
+
finally:
|
|
69
|
+
with contextlib.suppress(Exception):
|
|
70
|
+
wx.EndBusyCursor()
|
|
71
|
+
|
|
72
|
+
def _open_main_frame(self, con_i, server_name: str) -> None:
|
|
73
|
+
from odsbox_pilot.query.main_frame import MainFrame
|
|
74
|
+
|
|
75
|
+
frame = MainFrame(con_i, server_name)
|
|
76
|
+
frame.Show()
|
|
77
|
+
self.SetTopWindow(frame)
|
|
File without changes
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""ConnectDialog: create or edit an ODS server config.
|
|
2
|
+
|
|
3
|
+
Three tabs: Basic (username/password), M2M (client credentials), OIDC.
|
|
4
|
+
On OK the config is saved and, if the user clicked "Save & Connect",
|
|
5
|
+
the returned ConI is available via the `con_i` property.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
import wx # type: ignore[import-untyped]
|
|
14
|
+
|
|
15
|
+
from odsbox_pilot.connection.manager import ServerConfigManager
|
|
16
|
+
from odsbox_pilot.models import AuthType, ServerConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def do_connect(config: ServerConfig, secret: str): # type: ignore[return]
|
|
20
|
+
"""Create a live ConI from *config* + *secret* without any UI."""
|
|
21
|
+
from odsbox.con_i_factory import ConIFactory # type: ignore[import-untyped]
|
|
22
|
+
|
|
23
|
+
if config.auth_type == AuthType.BASIC:
|
|
24
|
+
return ConIFactory.basic(
|
|
25
|
+
url=config.url,
|
|
26
|
+
username=config.username,
|
|
27
|
+
password=secret,
|
|
28
|
+
verify_certificate=config.verify_certificate,
|
|
29
|
+
)
|
|
30
|
+
elif config.auth_type == AuthType.M2M:
|
|
31
|
+
return ConIFactory.m2m(
|
|
32
|
+
url=config.url,
|
|
33
|
+
token_endpoint=config.token_endpoint,
|
|
34
|
+
client_id=config.client_id,
|
|
35
|
+
client_secret=secret,
|
|
36
|
+
scope=config.scope or None,
|
|
37
|
+
verify_certificate=config.verify_certificate,
|
|
38
|
+
)
|
|
39
|
+
else: # OIDC
|
|
40
|
+
return ConIFactory.oidc(
|
|
41
|
+
url=config.url,
|
|
42
|
+
client_id=config.client_id,
|
|
43
|
+
redirect_uri=config.redirect_uri,
|
|
44
|
+
redirect_url_allow_insecure=config.redirect_url_allow_insecure,
|
|
45
|
+
webfinger_path_prefix=config.webfinger_path_prefix,
|
|
46
|
+
verify_certificate=config.verify_certificate,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConnectDialog(wx.Dialog):
|
|
51
|
+
"""Dialog to create or edit an ODS server connection config."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
parent: wx.Window | None,
|
|
56
|
+
manager: ServerConfigManager,
|
|
57
|
+
config: ServerConfig | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
title = "Edit Server" if config else "New Server"
|
|
60
|
+
super().__init__(
|
|
61
|
+
parent,
|
|
62
|
+
title=title,
|
|
63
|
+
size=(480, 480),
|
|
64
|
+
style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER,
|
|
65
|
+
)
|
|
66
|
+
self._manager = manager
|
|
67
|
+
self._original_config = config
|
|
68
|
+
self._con_i = None # set when "Save & Connect" succeeds
|
|
69
|
+
|
|
70
|
+
self._build_ui(config)
|
|
71
|
+
self._populate(config)
|
|
72
|
+
self.Centre()
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Public result
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def con_i(self): # type: ignore[return]
|
|
80
|
+
"""The live ConI instance after a successful 'Save & Connect'."""
|
|
81
|
+
return self._con_i
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# UI
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _build_ui(self, config: ServerConfig | None) -> None:
|
|
88
|
+
panel = wx.Panel(self)
|
|
89
|
+
vbox = wx.BoxSizer(wx.VERTICAL)
|
|
90
|
+
|
|
91
|
+
# --- Common fields (name + URL) ---
|
|
92
|
+
grid = wx.FlexGridSizer(cols=2, hgap=8, vgap=6)
|
|
93
|
+
grid.AddGrowableCol(1)
|
|
94
|
+
|
|
95
|
+
grid.Add(wx.StaticText(panel, label="Name:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
96
|
+
self._txt_name = wx.TextCtrl(panel, size=(300, -1))
|
|
97
|
+
grid.Add(self._txt_name, flag=wx.EXPAND)
|
|
98
|
+
|
|
99
|
+
grid.Add(wx.StaticText(panel, label="URL:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
100
|
+
self._txt_url = wx.TextCtrl(panel, size=(300, -1))
|
|
101
|
+
grid.Add(self._txt_url, flag=wx.EXPAND)
|
|
102
|
+
|
|
103
|
+
self._chk_verify = wx.CheckBox(panel, label="Verify TLS certificate")
|
|
104
|
+
self._chk_verify.SetValue(True)
|
|
105
|
+
grid.Add((0, 0))
|
|
106
|
+
grid.Add(self._chk_verify)
|
|
107
|
+
|
|
108
|
+
vbox.Add(grid, flag=wx.EXPAND | wx.ALL, border=10)
|
|
109
|
+
|
|
110
|
+
# --- Auth notebook ---
|
|
111
|
+
self._notebook = wx.Notebook(panel)
|
|
112
|
+
self._page_basic = self._build_basic_page()
|
|
113
|
+
self._page_m2m = self._build_m2m_page()
|
|
114
|
+
self._page_oidc = self._build_oidc_page()
|
|
115
|
+
self._notebook.AddPage(self._page_basic, "Basic")
|
|
116
|
+
self._notebook.AddPage(self._page_m2m, "M2M")
|
|
117
|
+
self._notebook.AddPage(self._page_oidc, "OIDC")
|
|
118
|
+
vbox.Add(self._notebook, proportion=1, flag=wx.EXPAND | wx.LEFT | wx.RIGHT, border=10)
|
|
119
|
+
|
|
120
|
+
# --- Buttons ---
|
|
121
|
+
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
|
122
|
+
self._btn_save_connect = wx.Button(panel, label="Save && Connect")
|
|
123
|
+
self._btn_save_connect.SetDefault()
|
|
124
|
+
btn_save = wx.Button(panel, label="Save only")
|
|
125
|
+
btn_cancel = wx.Button(panel, wx.ID_CANCEL, label="Cancel")
|
|
126
|
+
|
|
127
|
+
btn_sizer.AddStretchSpacer()
|
|
128
|
+
btn_sizer.Add(btn_cancel, flag=wx.RIGHT, border=4)
|
|
129
|
+
btn_sizer.Add(btn_save, flag=wx.RIGHT, border=4)
|
|
130
|
+
btn_sizer.Add(self._btn_save_connect)
|
|
131
|
+
|
|
132
|
+
vbox.Add(btn_sizer, flag=wx.EXPAND | wx.ALL, border=10)
|
|
133
|
+
panel.SetSizer(vbox)
|
|
134
|
+
|
|
135
|
+
self._btn_save_connect.Bind(wx.EVT_BUTTON, self._on_save_connect)
|
|
136
|
+
btn_save.Bind(wx.EVT_BUTTON, self._on_save_only)
|
|
137
|
+
|
|
138
|
+
def _build_basic_page(self) -> wx.Panel:
|
|
139
|
+
page = wx.Panel(self._notebook)
|
|
140
|
+
grid = wx.FlexGridSizer(cols=2, hgap=8, vgap=6)
|
|
141
|
+
grid.AddGrowableCol(1)
|
|
142
|
+
|
|
143
|
+
grid.Add(wx.StaticText(page, label="Username:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
144
|
+
self._txt_basic_user = wx.TextCtrl(page)
|
|
145
|
+
grid.Add(self._txt_basic_user, flag=wx.EXPAND)
|
|
146
|
+
|
|
147
|
+
grid.Add(wx.StaticText(page, label="Password:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
148
|
+
self._txt_basic_pass = wx.TextCtrl(page, style=wx.TE_PASSWORD)
|
|
149
|
+
grid.Add(self._txt_basic_pass, flag=wx.EXPAND)
|
|
150
|
+
|
|
151
|
+
page.SetSizer(self._wrap_page(page, grid))
|
|
152
|
+
return page
|
|
153
|
+
|
|
154
|
+
def _build_m2m_page(self) -> wx.Panel:
|
|
155
|
+
page = wx.Panel(self._notebook)
|
|
156
|
+
grid = wx.FlexGridSizer(cols=2, hgap=8, vgap=6)
|
|
157
|
+
grid.AddGrowableCol(1)
|
|
158
|
+
|
|
159
|
+
grid.Add(wx.StaticText(page, label="Token endpoint:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
160
|
+
self._txt_m2m_token_ep = wx.TextCtrl(page)
|
|
161
|
+
grid.Add(self._txt_m2m_token_ep, flag=wx.EXPAND)
|
|
162
|
+
|
|
163
|
+
grid.Add(wx.StaticText(page, label="Client ID:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
164
|
+
self._txt_m2m_client_id = wx.TextCtrl(page)
|
|
165
|
+
grid.Add(self._txt_m2m_client_id, flag=wx.EXPAND)
|
|
166
|
+
|
|
167
|
+
grid.Add(wx.StaticText(page, label="Client secret:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
168
|
+
self._txt_m2m_secret = wx.TextCtrl(page, style=wx.TE_PASSWORD)
|
|
169
|
+
grid.Add(self._txt_m2m_secret, flag=wx.EXPAND)
|
|
170
|
+
|
|
171
|
+
grid.Add(
|
|
172
|
+
wx.StaticText(page, label="Scope (space-separated):"),
|
|
173
|
+
flag=wx.ALIGN_CENTER_VERTICAL,
|
|
174
|
+
)
|
|
175
|
+
self._txt_m2m_scope = wx.TextCtrl(page)
|
|
176
|
+
grid.Add(self._txt_m2m_scope, flag=wx.EXPAND)
|
|
177
|
+
|
|
178
|
+
page.SetSizer(self._wrap_page(page, grid))
|
|
179
|
+
return page
|
|
180
|
+
|
|
181
|
+
def _build_oidc_page(self) -> wx.Panel:
|
|
182
|
+
page = wx.Panel(self._notebook)
|
|
183
|
+
grid = wx.FlexGridSizer(cols=2, hgap=8, vgap=6)
|
|
184
|
+
grid.AddGrowableCol(1)
|
|
185
|
+
|
|
186
|
+
grid.Add(wx.StaticText(page, label="Client ID:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
187
|
+
self._txt_oidc_client_id = wx.TextCtrl(page)
|
|
188
|
+
grid.Add(self._txt_oidc_client_id, flag=wx.EXPAND)
|
|
189
|
+
|
|
190
|
+
grid.Add(wx.StaticText(page, label="Redirect URI:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
191
|
+
self._txt_oidc_redirect = wx.TextCtrl(page)
|
|
192
|
+
grid.Add(self._txt_oidc_redirect, flag=wx.EXPAND)
|
|
193
|
+
|
|
194
|
+
grid.Add(wx.StaticText(page, label="WebFinger prefix:"), flag=wx.ALIGN_CENTER_VERTICAL)
|
|
195
|
+
self._txt_oidc_webfinger = wx.TextCtrl(page)
|
|
196
|
+
grid.Add(self._txt_oidc_webfinger, flag=wx.EXPAND)
|
|
197
|
+
|
|
198
|
+
self._chk_oidc_insecure = wx.CheckBox(page, label="Allow insecure redirect (localhost)")
|
|
199
|
+
self._chk_oidc_insecure.SetValue(True)
|
|
200
|
+
grid.Add((0, 0))
|
|
201
|
+
grid.Add(self._chk_oidc_insecure)
|
|
202
|
+
|
|
203
|
+
note = wx.StaticText(
|
|
204
|
+
page,
|
|
205
|
+
label="OIDC re-authenticates via your browser on each app launch.",
|
|
206
|
+
style=wx.ST_ELLIPSIZE_END,
|
|
207
|
+
)
|
|
208
|
+
note.SetForegroundColour(wx.Colour(100, 100, 100))
|
|
209
|
+
grid.Add((0, 0))
|
|
210
|
+
grid.Add(note, flag=wx.EXPAND)
|
|
211
|
+
|
|
212
|
+
page.SetSizer(self._wrap_page(page, grid))
|
|
213
|
+
return page
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _wrap_page(page: wx.Panel, inner: wx.Sizer) -> wx.BoxSizer:
|
|
217
|
+
outer = wx.BoxSizer(wx.VERTICAL)
|
|
218
|
+
outer.Add(inner, proportion=1, flag=wx.EXPAND | wx.ALL, border=10)
|
|
219
|
+
return outer
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# Populate from existing config
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def _populate(self, config: ServerConfig | None) -> None:
|
|
226
|
+
if config is None:
|
|
227
|
+
self._txt_oidc_redirect.SetValue("http://127.0.0.1:12345")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
self._txt_name.SetValue(config.name)
|
|
231
|
+
self._txt_url.SetValue(config.url)
|
|
232
|
+
self._chk_verify.SetValue(config.verify_certificate)
|
|
233
|
+
|
|
234
|
+
if config.auth_type == AuthType.BASIC:
|
|
235
|
+
self._notebook.SetSelection(0)
|
|
236
|
+
self._txt_basic_user.SetValue(config.username)
|
|
237
|
+
secret = self._manager.load_secret(config)
|
|
238
|
+
if secret:
|
|
239
|
+
self._txt_basic_pass.SetValue(secret)
|
|
240
|
+
|
|
241
|
+
elif config.auth_type == AuthType.M2M:
|
|
242
|
+
self._notebook.SetSelection(1)
|
|
243
|
+
self._txt_m2m_token_ep.SetValue(config.token_endpoint)
|
|
244
|
+
self._txt_m2m_client_id.SetValue(config.client_id)
|
|
245
|
+
self._txt_m2m_scope.SetValue(" ".join(config.scope))
|
|
246
|
+
secret = self._manager.load_secret(config)
|
|
247
|
+
if secret:
|
|
248
|
+
self._txt_m2m_secret.SetValue(secret)
|
|
249
|
+
|
|
250
|
+
elif config.auth_type == AuthType.OIDC:
|
|
251
|
+
self._notebook.SetSelection(2)
|
|
252
|
+
self._txt_oidc_client_id.SetValue(config.client_id)
|
|
253
|
+
self._txt_oidc_redirect.SetValue(config.redirect_uri)
|
|
254
|
+
self._txt_oidc_webfinger.SetValue(config.webfinger_path_prefix)
|
|
255
|
+
self._chk_oidc_insecure.SetValue(config.redirect_url_allow_insecure)
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# Build ServerConfig from current form values
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _build_config(self) -> tuple[ServerConfig, str] | None:
|
|
262
|
+
"""Return (config, secret) or None if validation fails."""
|
|
263
|
+
name = self._txt_name.GetValue().strip()
|
|
264
|
+
url = self._txt_url.GetValue().strip()
|
|
265
|
+
if not name:
|
|
266
|
+
wx.MessageBox("Name is required.", "Validation", wx.OK | wx.ICON_WARNING, self)
|
|
267
|
+
return None
|
|
268
|
+
if not url:
|
|
269
|
+
wx.MessageBox("URL is required.", "Validation", wx.OK | wx.ICON_WARNING, self)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
tab = self._notebook.GetSelection()
|
|
273
|
+
config_id = self._original_config.id if self._original_config else str(uuid.uuid4())
|
|
274
|
+
verify = self._chk_verify.GetValue()
|
|
275
|
+
|
|
276
|
+
if tab == 0: # Basic
|
|
277
|
+
username = self._txt_basic_user.GetValue().strip()
|
|
278
|
+
password = self._txt_basic_pass.GetValue()
|
|
279
|
+
if not username:
|
|
280
|
+
wx.MessageBox("Username is required.", "Validation", wx.OK | wx.ICON_WARNING, self)
|
|
281
|
+
return None
|
|
282
|
+
cfg = ServerConfig(
|
|
283
|
+
id=config_id,
|
|
284
|
+
name=name,
|
|
285
|
+
url=url,
|
|
286
|
+
auth_type=AuthType.BASIC,
|
|
287
|
+
username=username,
|
|
288
|
+
verify_certificate=verify,
|
|
289
|
+
)
|
|
290
|
+
return cfg, password
|
|
291
|
+
|
|
292
|
+
elif tab == 1: # M2M
|
|
293
|
+
token_ep = self._txt_m2m_token_ep.GetValue().strip()
|
|
294
|
+
client_id = self._txt_m2m_client_id.GetValue().strip()
|
|
295
|
+
secret = self._txt_m2m_secret.GetValue()
|
|
296
|
+
scope_str = self._txt_m2m_scope.GetValue().strip()
|
|
297
|
+
scope = scope_str.split() if scope_str else []
|
|
298
|
+
if not token_ep or not client_id:
|
|
299
|
+
wx.MessageBox(
|
|
300
|
+
"Token endpoint and Client ID are required.",
|
|
301
|
+
"Validation",
|
|
302
|
+
wx.OK | wx.ICON_WARNING,
|
|
303
|
+
self,
|
|
304
|
+
)
|
|
305
|
+
return None
|
|
306
|
+
cfg = ServerConfig(
|
|
307
|
+
id=config_id,
|
|
308
|
+
name=name,
|
|
309
|
+
url=url,
|
|
310
|
+
auth_type=AuthType.M2M,
|
|
311
|
+
token_endpoint=token_ep,
|
|
312
|
+
client_id=client_id,
|
|
313
|
+
scope=scope,
|
|
314
|
+
verify_certificate=verify,
|
|
315
|
+
)
|
|
316
|
+
return cfg, secret
|
|
317
|
+
|
|
318
|
+
else: # OIDC
|
|
319
|
+
client_id = self._txt_oidc_client_id.GetValue().strip()
|
|
320
|
+
redirect = self._txt_oidc_redirect.GetValue().strip()
|
|
321
|
+
webfinger = self._txt_oidc_webfinger.GetValue().strip()
|
|
322
|
+
insecure = self._chk_oidc_insecure.GetValue()
|
|
323
|
+
if not client_id or not redirect:
|
|
324
|
+
wx.MessageBox(
|
|
325
|
+
"Client ID and Redirect URI are required.",
|
|
326
|
+
"Validation",
|
|
327
|
+
wx.OK | wx.ICON_WARNING,
|
|
328
|
+
self,
|
|
329
|
+
)
|
|
330
|
+
return None
|
|
331
|
+
cfg = ServerConfig(
|
|
332
|
+
id=config_id,
|
|
333
|
+
name=name,
|
|
334
|
+
url=url,
|
|
335
|
+
auth_type=AuthType.OIDC,
|
|
336
|
+
client_id=client_id,
|
|
337
|
+
redirect_uri=redirect,
|
|
338
|
+
webfinger_path_prefix=webfinger,
|
|
339
|
+
redirect_url_allow_insecure=insecure,
|
|
340
|
+
verify_certificate=verify,
|
|
341
|
+
)
|
|
342
|
+
return cfg, "" # no secret stored for OIDC
|
|
343
|
+
|
|
344
|
+
return None # unreachable
|
|
345
|
+
|
|
346
|
+
# ------------------------------------------------------------------
|
|
347
|
+
# Save helper
|
|
348
|
+
# ------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
def _save_config(self, config: ServerConfig, secret: str) -> None:
|
|
351
|
+
if self._original_config:
|
|
352
|
+
self._manager.update(config)
|
|
353
|
+
else:
|
|
354
|
+
self._manager.add(config)
|
|
355
|
+
if secret:
|
|
356
|
+
self._manager.save_secret(config, secret)
|
|
357
|
+
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
# Connect helper
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
def _do_connect(self, config: ServerConfig, secret: str): # type: ignore[return]
|
|
363
|
+
return do_connect(config, secret)
|
|
364
|
+
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
# Button handlers
|
|
367
|
+
# ------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
def _on_save_only(self, _event: wx.Event) -> None:
|
|
370
|
+
result = self._build_config()
|
|
371
|
+
if result is None:
|
|
372
|
+
return
|
|
373
|
+
config, secret = result
|
|
374
|
+
self._save_config(config, secret)
|
|
375
|
+
self.EndModal(wx.ID_OK)
|
|
376
|
+
|
|
377
|
+
def _on_save_connect(self, _event: wx.Event) -> None:
|
|
378
|
+
result = self._build_config()
|
|
379
|
+
if result is None:
|
|
380
|
+
return
|
|
381
|
+
config, secret = result
|
|
382
|
+
self._save_config(config, secret)
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
wx.BeginBusyCursor()
|
|
386
|
+
con_i = self._do_connect(config, secret)
|
|
387
|
+
except Exception as exc:
|
|
388
|
+
wx.EndBusyCursor()
|
|
389
|
+
wx.MessageBox(
|
|
390
|
+
f"Connection failed:\n\n{exc}",
|
|
391
|
+
"Connection Error",
|
|
392
|
+
wx.OK | wx.ICON_ERROR,
|
|
393
|
+
self,
|
|
394
|
+
)
|
|
395
|
+
return
|
|
396
|
+
finally:
|
|
397
|
+
with contextlib.suppress(Exception):
|
|
398
|
+
wx.EndBusyCursor()
|
|
399
|
+
|
|
400
|
+
self._con_i = con_i
|
|
401
|
+
self.EndModal(wx.ID_OK)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""ServerConfigManager: CRUD for saved ODS server configs.
|
|
2
|
+
|
|
3
|
+
Non-secret fields are persisted to ~/.ods-pilot/servers.json.
|
|
4
|
+
Secrets (passwords, client secrets) are stored in the OS keyring under
|
|
5
|
+
service "ods-pilot".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import keyring
|
|
16
|
+
|
|
17
|
+
from odsbox_pilot.models import SERVERS_FILE, ServerConfig
|
|
18
|
+
|
|
19
|
+
_KEYRING_SERVICE = "ods-pilot"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ServerConfigManager:
|
|
23
|
+
"""Load, save, and manage ODS server configurations."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, path: Path = SERVERS_FILE) -> None:
|
|
26
|
+
self._path = path
|
|
27
|
+
self._configs: list[ServerConfig] = []
|
|
28
|
+
self._load()
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Public API
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def configs(self) -> list[ServerConfig]:
|
|
36
|
+
return list(self._configs)
|
|
37
|
+
|
|
38
|
+
def add(self, config: ServerConfig) -> None:
|
|
39
|
+
"""Add a new server config. Raises ValueError if id already exists."""
|
|
40
|
+
if any(c.id == config.id for c in self._configs):
|
|
41
|
+
raise ValueError(f"Config with id {config.id!r} already exists.")
|
|
42
|
+
self._configs.append(config)
|
|
43
|
+
self._save()
|
|
44
|
+
|
|
45
|
+
def update(self, config: ServerConfig) -> None:
|
|
46
|
+
"""Replace an existing config by id. Raises KeyError if not found."""
|
|
47
|
+
for i, c in enumerate(self._configs):
|
|
48
|
+
if c.id == config.id:
|
|
49
|
+
self._configs[i] = config
|
|
50
|
+
self._save()
|
|
51
|
+
return
|
|
52
|
+
raise KeyError(f"Config {config.id!r} not found.")
|
|
53
|
+
|
|
54
|
+
def remove(self, config_id: str) -> None:
|
|
55
|
+
"""Remove a config and its associated keyring secret."""
|
|
56
|
+
config = self.get(config_id)
|
|
57
|
+
self._configs = [c for c in self._configs if c.id != config_id]
|
|
58
|
+
self._save()
|
|
59
|
+
# Best-effort cleanup of keyring secret
|
|
60
|
+
with contextlib.suppress(keyring.errors.PasswordDeleteError):
|
|
61
|
+
keyring.delete_password(_KEYRING_SERVICE, config.keyring_account)
|
|
62
|
+
|
|
63
|
+
def get(self, config_id: str) -> ServerConfig:
|
|
64
|
+
"""Return a config by id. Raises KeyError if not found."""
|
|
65
|
+
for c in self._configs:
|
|
66
|
+
if c.id == config_id:
|
|
67
|
+
return c
|
|
68
|
+
raise KeyError(f"Config {config_id!r} not found.")
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# Keyring helpers
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def save_secret(self, config: ServerConfig, secret: str) -> None:
|
|
75
|
+
"""Store the password or client secret in the OS keyring."""
|
|
76
|
+
keyring.set_password(_KEYRING_SERVICE, config.keyring_account, secret)
|
|
77
|
+
|
|
78
|
+
def load_secret(self, config: ServerConfig) -> str | None:
|
|
79
|
+
"""Retrieve the password or client secret from the OS keyring."""
|
|
80
|
+
return keyring.get_password(_KEYRING_SERVICE, config.keyring_account)
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Factory helpers
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def new_id() -> str:
|
|
88
|
+
return str(uuid.uuid4())
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Persistence
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _load(self) -> None:
|
|
95
|
+
if not self._path.exists():
|
|
96
|
+
self._configs = []
|
|
97
|
+
return
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
100
|
+
self._configs = [ServerConfig.from_dict(d) for d in data]
|
|
101
|
+
except Exception:
|
|
102
|
+
self._configs = []
|
|
103
|
+
|
|
104
|
+
def _save(self) -> None:
|
|
105
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
data = [c.to_dict() for c in self._configs]
|
|
107
|
+
self._path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|