@josephyan/qingflow-app-builder-mcp 0.1.0-beta.9
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.
- package/README.md +21 -0
- package/docs/local-agent-install.md +228 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow-app-builder-mcp.mjs +7 -0
- package/npm/lib/runtime.mjs +146 -0
- package/npm/scripts/postinstall.mjs +12 -0
- package/package.json +33 -0
- package/pyproject.toml +64 -0
- package/qingflow-app-builder-mcp +15 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +336 -0
- package/src/qingflow_mcp/config.py +182 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +52 -0
- package/src/qingflow_mcp/server.py +70 -0
- package/src/qingflow_mcp/server_app_builder.py +352 -0
- package/src/qingflow_mcp/server_app_user.py +334 -0
- package/src/qingflow_mcp/session_store.py +249 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2065 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/run_store.py +221 -0
- package/src/qingflow_mcp/solution/spec_models.py +853 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/app_tools.py +406 -0
- package/src/qingflow_mcp/tools/approval_tools.py +498 -0
- package/src/qingflow_mcp/tools/auth_tools.py +514 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/directory_tools.py +476 -0
- package/src/qingflow_mcp/tools/file_tools.py +375 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +142 -0
- package/src/qingflow_mcp/tools/portal_tools.py +100 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +235 -0
- package/src/qingflow_mcp/tools/record_tools.py +4307 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +2680 -0
- package/src/qingflow_mcp/tools/task_tools.py +692 -0
- package/src/qingflow_mcp/tools/view_tools.py +280 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +238 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +170 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
from .backend_client import BackendClient
|
|
6
|
+
from .config import DEFAULT_PROFILE
|
|
7
|
+
from .session_store import SessionStore
|
|
8
|
+
from .tools.approval_tools import ApprovalTools
|
|
9
|
+
from .tools.auth_tools import AuthTools
|
|
10
|
+
from .tools.directory_tools import DirectoryTools
|
|
11
|
+
from .tools.file_tools import FileTools
|
|
12
|
+
from .tools.record_tools import RecordTools
|
|
13
|
+
from .tools.task_tools import TaskTools
|
|
14
|
+
from .tools.workspace_tools import WorkspaceTools
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_user_server() -> FastMCP:
|
|
18
|
+
server = FastMCP(
|
|
19
|
+
"Qingflow App User MCP",
|
|
20
|
+
instructions=(
|
|
21
|
+
"Use this server for Qingflow record queries, record writes, task center operations, "
|
|
22
|
+
"directory lookups, and approval actions. Avoid builder-side app or schema changes here."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
sessions = SessionStore()
|
|
26
|
+
backend = BackendClient()
|
|
27
|
+
auth = AuthTools(sessions, backend)
|
|
28
|
+
workspace = WorkspaceTools(sessions, backend)
|
|
29
|
+
files = FileTools(sessions, backend)
|
|
30
|
+
approvals = ApprovalTools(sessions, backend)
|
|
31
|
+
tasks = TaskTools(sessions, backend)
|
|
32
|
+
|
|
33
|
+
@server.tool()
|
|
34
|
+
def auth_login(
|
|
35
|
+
profile: str = DEFAULT_PROFILE,
|
|
36
|
+
base_url: str | None = None,
|
|
37
|
+
qf_version: str | None = None,
|
|
38
|
+
email: str = "",
|
|
39
|
+
password: str = "",
|
|
40
|
+
persist: bool = True,
|
|
41
|
+
) -> dict:
|
|
42
|
+
return auth.auth_login(
|
|
43
|
+
profile=profile,
|
|
44
|
+
base_url=base_url,
|
|
45
|
+
qf_version=qf_version,
|
|
46
|
+
email=email,
|
|
47
|
+
password=password,
|
|
48
|
+
persist=persist,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@server.tool()
|
|
52
|
+
def auth_use_token(
|
|
53
|
+
profile: str = DEFAULT_PROFILE,
|
|
54
|
+
base_url: str | None = None,
|
|
55
|
+
qf_version: str | None = None,
|
|
56
|
+
token: str = "",
|
|
57
|
+
ws_id: int | None = None,
|
|
58
|
+
persist: bool = False,
|
|
59
|
+
) -> dict:
|
|
60
|
+
return auth.auth_use_token(
|
|
61
|
+
profile=profile,
|
|
62
|
+
base_url=base_url,
|
|
63
|
+
qf_version=qf_version,
|
|
64
|
+
token=token,
|
|
65
|
+
ws_id=ws_id,
|
|
66
|
+
persist=persist,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@server.tool()
|
|
70
|
+
def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict:
|
|
71
|
+
return auth.auth_whoami(profile=profile)
|
|
72
|
+
|
|
73
|
+
@server.tool()
|
|
74
|
+
def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
|
|
75
|
+
return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
|
|
76
|
+
|
|
77
|
+
@server.tool()
|
|
78
|
+
def workspace_list(
|
|
79
|
+
profile: str = DEFAULT_PROFILE,
|
|
80
|
+
page_num: int = 1,
|
|
81
|
+
page_size: int = 20,
|
|
82
|
+
include_external: bool = False,
|
|
83
|
+
) -> dict:
|
|
84
|
+
return workspace.workspace_list(
|
|
85
|
+
profile=profile,
|
|
86
|
+
page_num=page_num,
|
|
87
|
+
page_size=page_size,
|
|
88
|
+
include_external=include_external,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@server.tool()
|
|
92
|
+
def workspace_select(profile: str = DEFAULT_PROFILE, ws_id: int = 0) -> dict:
|
|
93
|
+
return workspace.workspace_select(profile=profile, ws_id=ws_id)
|
|
94
|
+
|
|
95
|
+
@server.tool()
|
|
96
|
+
def file_get_upload_info(
|
|
97
|
+
profile: str = DEFAULT_PROFILE,
|
|
98
|
+
upload_kind: str = "attachment",
|
|
99
|
+
file_name: str = "",
|
|
100
|
+
file_size: int = 0,
|
|
101
|
+
upload_mark: str | None = None,
|
|
102
|
+
content_type: str | None = None,
|
|
103
|
+
bucket_type: str | None = None,
|
|
104
|
+
path_id: int | None = None,
|
|
105
|
+
file_related_url: str | None = None,
|
|
106
|
+
) -> dict:
|
|
107
|
+
return files.file_get_upload_info(
|
|
108
|
+
profile=profile,
|
|
109
|
+
upload_kind=upload_kind,
|
|
110
|
+
file_name=file_name,
|
|
111
|
+
file_size=file_size,
|
|
112
|
+
upload_mark=upload_mark,
|
|
113
|
+
content_type=content_type,
|
|
114
|
+
bucket_type=bucket_type,
|
|
115
|
+
path_id=path_id,
|
|
116
|
+
file_related_url=file_related_url,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@server.tool()
|
|
120
|
+
def file_upload_local(
|
|
121
|
+
profile: str = DEFAULT_PROFILE,
|
|
122
|
+
upload_kind: str = "attachment",
|
|
123
|
+
file_path: str = "",
|
|
124
|
+
upload_mark: str | None = None,
|
|
125
|
+
content_type: str | None = None,
|
|
126
|
+
bucket_type: str | None = None,
|
|
127
|
+
path_id: int | None = None,
|
|
128
|
+
file_related_url: str | None = None,
|
|
129
|
+
) -> dict:
|
|
130
|
+
return files.file_upload_local(
|
|
131
|
+
profile=profile,
|
|
132
|
+
upload_kind=upload_kind,
|
|
133
|
+
file_path=file_path,
|
|
134
|
+
upload_mark=upload_mark,
|
|
135
|
+
content_type=content_type,
|
|
136
|
+
bucket_type=bucket_type,
|
|
137
|
+
path_id=path_id,
|
|
138
|
+
file_related_url=file_related_url,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
RecordTools(sessions, backend).register(server)
|
|
142
|
+
DirectoryTools(sessions, backend).register(server)
|
|
143
|
+
|
|
144
|
+
@server.tool()
|
|
145
|
+
def record_comment_add(
|
|
146
|
+
profile: str = DEFAULT_PROFILE,
|
|
147
|
+
app_key: str = "",
|
|
148
|
+
apply_id: int = 0,
|
|
149
|
+
payload: dict | None = None,
|
|
150
|
+
) -> dict:
|
|
151
|
+
return approvals.record_comment_add(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
|
|
152
|
+
|
|
153
|
+
@server.tool()
|
|
154
|
+
def record_comment_list(
|
|
155
|
+
profile: str = DEFAULT_PROFILE,
|
|
156
|
+
app_key: str = "",
|
|
157
|
+
apply_id: int = 0,
|
|
158
|
+
page_size: int = 20,
|
|
159
|
+
list_type: int | None = None,
|
|
160
|
+
page_num: int | None = 1,
|
|
161
|
+
) -> dict:
|
|
162
|
+
return approvals.record_comment_list(
|
|
163
|
+
profile=profile,
|
|
164
|
+
app_key=app_key,
|
|
165
|
+
apply_id=apply_id,
|
|
166
|
+
page_size=page_size,
|
|
167
|
+
list_type=list_type,
|
|
168
|
+
page_num=page_num,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@server.tool()
|
|
172
|
+
def record_comment_mention_candidates(
|
|
173
|
+
profile: str = DEFAULT_PROFILE,
|
|
174
|
+
app_key: str = "",
|
|
175
|
+
apply_id: int = 0,
|
|
176
|
+
page_size: int = 20,
|
|
177
|
+
page_num: int = 1,
|
|
178
|
+
list_type: int | None = None,
|
|
179
|
+
keyword: str | None = None,
|
|
180
|
+
) -> dict:
|
|
181
|
+
return approvals.record_comment_mention_candidates(
|
|
182
|
+
profile=profile,
|
|
183
|
+
app_key=app_key,
|
|
184
|
+
apply_id=apply_id,
|
|
185
|
+
page_size=page_size,
|
|
186
|
+
page_num=page_num,
|
|
187
|
+
list_type=list_type,
|
|
188
|
+
keyword=keyword,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@server.tool()
|
|
192
|
+
def record_comment_mark_read(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict:
|
|
193
|
+
return approvals.record_comment_mark_read(profile=profile, app_key=app_key, apply_id=apply_id)
|
|
194
|
+
|
|
195
|
+
@server.tool()
|
|
196
|
+
def record_comment_stats(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict:
|
|
197
|
+
return approvals.record_comment_stats(profile=profile, app_key=app_key, apply_id=apply_id)
|
|
198
|
+
|
|
199
|
+
@server.tool()
|
|
200
|
+
def task_list(
|
|
201
|
+
profile: str = DEFAULT_PROFILE,
|
|
202
|
+
type: int = 1,
|
|
203
|
+
process_status: int = 1,
|
|
204
|
+
app_key: str | None = None,
|
|
205
|
+
node_id: int | None = None,
|
|
206
|
+
search_key: str | None = None,
|
|
207
|
+
page_num: int = 1,
|
|
208
|
+
page_size: int = 20,
|
|
209
|
+
create_time_asc: bool | None = None,
|
|
210
|
+
) -> dict:
|
|
211
|
+
return tasks.task_list(
|
|
212
|
+
profile=profile,
|
|
213
|
+
type=type,
|
|
214
|
+
process_status=process_status,
|
|
215
|
+
app_key=app_key,
|
|
216
|
+
node_id=node_id,
|
|
217
|
+
search_key=search_key,
|
|
218
|
+
page_num=page_num,
|
|
219
|
+
page_size=page_size,
|
|
220
|
+
create_time_asc=create_time_asc,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@server.tool()
|
|
224
|
+
def task_list_grouped(
|
|
225
|
+
profile: str = DEFAULT_PROFILE,
|
|
226
|
+
type: int = 1,
|
|
227
|
+
process_status: int = 1,
|
|
228
|
+
app_key: str | None = None,
|
|
229
|
+
node_id: int | None = None,
|
|
230
|
+
search_key: str | None = None,
|
|
231
|
+
page_num: int = 1,
|
|
232
|
+
page_size: int = 20,
|
|
233
|
+
) -> dict:
|
|
234
|
+
return tasks.task_list_grouped(
|
|
235
|
+
profile=profile,
|
|
236
|
+
type=type,
|
|
237
|
+
process_status=process_status,
|
|
238
|
+
app_key=app_key,
|
|
239
|
+
node_id=node_id,
|
|
240
|
+
search_key=search_key,
|
|
241
|
+
page_num=page_num,
|
|
242
|
+
page_size=page_size,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@server.tool()
|
|
246
|
+
def task_statistics(profile: str = DEFAULT_PROFILE, app_key: str | None = None) -> dict:
|
|
247
|
+
return tasks.task_statistics(profile=profile, app_key=app_key)
|
|
248
|
+
|
|
249
|
+
@server.tool()
|
|
250
|
+
def task_urge(profile: str = DEFAULT_PROFILE, app_key: str = "", row_record_id: int = 0) -> dict:
|
|
251
|
+
return tasks.task_urge(profile=profile, app_key=app_key, row_record_id=row_record_id)
|
|
252
|
+
|
|
253
|
+
@server.tool(description=approvals._high_risk_tool_description(operation="approve", target="workflow task"))
|
|
254
|
+
def task_approve(
|
|
255
|
+
profile: str = DEFAULT_PROFILE,
|
|
256
|
+
app_key: str = "",
|
|
257
|
+
apply_id: int = 0,
|
|
258
|
+
payload: dict | None = None,
|
|
259
|
+
) -> dict:
|
|
260
|
+
return approvals.record_approve(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
|
|
261
|
+
|
|
262
|
+
@server.tool(description=approvals._high_risk_tool_description(operation="reject", target="workflow task"))
|
|
263
|
+
def task_reject(
|
|
264
|
+
profile: str = DEFAULT_PROFILE,
|
|
265
|
+
app_key: str = "",
|
|
266
|
+
apply_id: int = 0,
|
|
267
|
+
payload: dict | None = None,
|
|
268
|
+
) -> dict:
|
|
269
|
+
return approvals.record_reject(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
|
|
270
|
+
|
|
271
|
+
@server.tool()
|
|
272
|
+
def task_rollback_candidates(
|
|
273
|
+
profile: str = DEFAULT_PROFILE,
|
|
274
|
+
app_key: str = "",
|
|
275
|
+
apply_id: int = 0,
|
|
276
|
+
audit_node_id: int = 0,
|
|
277
|
+
) -> dict:
|
|
278
|
+
return approvals.record_rollback_candidates(
|
|
279
|
+
profile=profile,
|
|
280
|
+
app_key=app_key,
|
|
281
|
+
apply_id=apply_id,
|
|
282
|
+
audit_node_id=audit_node_id,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
@server.tool()
|
|
286
|
+
def task_rollback(
|
|
287
|
+
profile: str = DEFAULT_PROFILE,
|
|
288
|
+
app_key: str = "",
|
|
289
|
+
apply_id: int = 0,
|
|
290
|
+
payload: dict | None = None,
|
|
291
|
+
) -> dict:
|
|
292
|
+
return approvals.record_rollback(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
|
|
293
|
+
|
|
294
|
+
@server.tool()
|
|
295
|
+
def task_transfer_candidates(
|
|
296
|
+
profile: str = DEFAULT_PROFILE,
|
|
297
|
+
app_key: str = "",
|
|
298
|
+
apply_id: int = 0,
|
|
299
|
+
page_size: int = 20,
|
|
300
|
+
page_num: int = 1,
|
|
301
|
+
audit_node_id: int = 0,
|
|
302
|
+
keyword: str | None = None,
|
|
303
|
+
) -> dict:
|
|
304
|
+
return approvals.record_transfer_candidates(
|
|
305
|
+
profile=profile,
|
|
306
|
+
app_key=app_key,
|
|
307
|
+
apply_id=apply_id,
|
|
308
|
+
page_size=page_size,
|
|
309
|
+
page_num=page_num,
|
|
310
|
+
audit_node_id=audit_node_id,
|
|
311
|
+
keyword=keyword,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
@server.tool()
|
|
315
|
+
def task_transfer(
|
|
316
|
+
profile: str = DEFAULT_PROFILE,
|
|
317
|
+
app_key: str = "",
|
|
318
|
+
apply_id: int = 0,
|
|
319
|
+
payload: dict | None = None,
|
|
320
|
+
) -> dict:
|
|
321
|
+
return approvals.record_transfer(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
|
|
322
|
+
|
|
323
|
+
return server
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
mcp = build_user_server()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main() -> None:
|
|
330
|
+
mcp.run()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
main()
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import keyring
|
|
10
|
+
except ImportError:
|
|
11
|
+
keyring = None
|
|
12
|
+
|
|
13
|
+
from .config import get_profiles_path, normalize_base_url
|
|
14
|
+
from .json_types import JSONObject, KeyringBackend
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
KEYRING_SERVICE_NAME = "qingflow-mcp"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _utcnow() -> str:
|
|
21
|
+
return datetime.now(timezone.utc).isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class SessionProfile:
|
|
26
|
+
profile: str
|
|
27
|
+
base_url: str
|
|
28
|
+
qf_version: str | None
|
|
29
|
+
qf_version_source: str | None
|
|
30
|
+
uid: int
|
|
31
|
+
email: str | None
|
|
32
|
+
nick_name: str | None
|
|
33
|
+
selected_ws_id: int | None
|
|
34
|
+
selected_ws_name: str | None
|
|
35
|
+
persisted: bool
|
|
36
|
+
created_at: str
|
|
37
|
+
updated_at: str
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, value: JSONObject) -> "SessionProfile":
|
|
41
|
+
return cls(
|
|
42
|
+
profile=value["profile"],
|
|
43
|
+
base_url=value["base_url"],
|
|
44
|
+
qf_version=value.get("qf_version"),
|
|
45
|
+
qf_version_source=value.get("qf_version_source"),
|
|
46
|
+
uid=value["uid"],
|
|
47
|
+
email=value.get("email"),
|
|
48
|
+
nick_name=value.get("nick_name"),
|
|
49
|
+
selected_ws_id=value.get("selected_ws_id"),
|
|
50
|
+
selected_ws_name=value.get("selected_ws_name"),
|
|
51
|
+
persisted=bool(value.get("persisted", False)),
|
|
52
|
+
created_at=value.get("created_at", _utcnow()),
|
|
53
|
+
updated_at=value.get("updated_at", _utcnow()),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class BackendSession:
|
|
59
|
+
token: str
|
|
60
|
+
login_token: str | None
|
|
61
|
+
profile: str
|
|
62
|
+
base_url: str
|
|
63
|
+
qf_version: str | None
|
|
64
|
+
qf_version_source: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SessionStore:
|
|
68
|
+
def __init__(self, base_dir: Path | None = None, keyring_backend: KeyringBackend | None = None) -> None:
|
|
69
|
+
profiles_path = get_profiles_path() if base_dir is None else Path(base_dir) / "profiles.json"
|
|
70
|
+
self._profiles_path = profiles_path
|
|
71
|
+
self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
self._keyring = keyring_backend if keyring_backend is not None else keyring
|
|
73
|
+
self._memory_sessions: dict[str, BackendSession] = {}
|
|
74
|
+
self._logged_out_profiles: set[str] = set()
|
|
75
|
+
|
|
76
|
+
def save_session(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
profile: str,
|
|
80
|
+
base_url: str,
|
|
81
|
+
qf_version: str | None,
|
|
82
|
+
qf_version_source: str | None = None,
|
|
83
|
+
token: str,
|
|
84
|
+
login_token: str | None,
|
|
85
|
+
uid: int,
|
|
86
|
+
email: str | None,
|
|
87
|
+
nick_name: str | None,
|
|
88
|
+
persist: bool,
|
|
89
|
+
) -> SessionProfile:
|
|
90
|
+
now = _utcnow()
|
|
91
|
+
previous = self.get_profile(profile)
|
|
92
|
+
persisted = False
|
|
93
|
+
if persist:
|
|
94
|
+
persisted = self._set_secret(self._token_key(profile), token)
|
|
95
|
+
if login_token:
|
|
96
|
+
self._set_secret(self._login_token_key(profile), login_token)
|
|
97
|
+
else:
|
|
98
|
+
self._delete_secret(self._login_token_key(profile))
|
|
99
|
+
else:
|
|
100
|
+
self._delete_secret(self._token_key(profile))
|
|
101
|
+
self._delete_secret(self._login_token_key(profile))
|
|
102
|
+
session_profile = SessionProfile(
|
|
103
|
+
profile=profile,
|
|
104
|
+
base_url=normalize_base_url(base_url) or base_url,
|
|
105
|
+
qf_version=(str(qf_version).strip() or None) if qf_version is not None else None,
|
|
106
|
+
qf_version_source=(str(qf_version_source).strip() or None) if qf_version_source is not None else None,
|
|
107
|
+
uid=uid,
|
|
108
|
+
email=email,
|
|
109
|
+
nick_name=nick_name,
|
|
110
|
+
selected_ws_id=None,
|
|
111
|
+
selected_ws_name=None,
|
|
112
|
+
persisted=persisted,
|
|
113
|
+
created_at=previous.created_at if previous else now,
|
|
114
|
+
updated_at=now,
|
|
115
|
+
)
|
|
116
|
+
self._memory_sessions[profile] = BackendSession(
|
|
117
|
+
token=token,
|
|
118
|
+
login_token=login_token,
|
|
119
|
+
profile=profile,
|
|
120
|
+
base_url=session_profile.base_url,
|
|
121
|
+
qf_version=session_profile.qf_version,
|
|
122
|
+
qf_version_source=session_profile.qf_version_source,
|
|
123
|
+
)
|
|
124
|
+
self._logged_out_profiles.discard(profile)
|
|
125
|
+
self._upsert_profile(session_profile)
|
|
126
|
+
return session_profile
|
|
127
|
+
|
|
128
|
+
def get_profile(self, profile: str) -> SessionProfile | None:
|
|
129
|
+
payload = self._load_profiles()
|
|
130
|
+
raw_profile = payload.get("profiles", {}).get(profile)
|
|
131
|
+
if not raw_profile:
|
|
132
|
+
return None
|
|
133
|
+
return SessionProfile.from_dict(raw_profile)
|
|
134
|
+
|
|
135
|
+
def get_backend_session(self, profile: str) -> BackendSession | None:
|
|
136
|
+
if profile in self._logged_out_profiles:
|
|
137
|
+
return None
|
|
138
|
+
memory_session = self._memory_sessions.get(profile)
|
|
139
|
+
if memory_session:
|
|
140
|
+
return memory_session
|
|
141
|
+
session_profile = self.get_profile(profile)
|
|
142
|
+
if not session_profile or not session_profile.persisted:
|
|
143
|
+
return None
|
|
144
|
+
token = self._get_secret(self._token_key(profile))
|
|
145
|
+
if not token:
|
|
146
|
+
return None
|
|
147
|
+
backend_session = BackendSession(
|
|
148
|
+
token=token,
|
|
149
|
+
login_token=self._get_secret(self._login_token_key(profile)),
|
|
150
|
+
profile=profile,
|
|
151
|
+
base_url=session_profile.base_url,
|
|
152
|
+
qf_version=session_profile.qf_version,
|
|
153
|
+
qf_version_source=session_profile.qf_version_source,
|
|
154
|
+
)
|
|
155
|
+
self._memory_sessions[profile] = backend_session
|
|
156
|
+
return backend_session
|
|
157
|
+
|
|
158
|
+
def select_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
|
|
159
|
+
session_profile = self.get_profile(profile)
|
|
160
|
+
if session_profile is None:
|
|
161
|
+
raise KeyError(profile)
|
|
162
|
+
session_profile.selected_ws_id = ws_id
|
|
163
|
+
session_profile.selected_ws_name = ws_name
|
|
164
|
+
session_profile.updated_at = _utcnow()
|
|
165
|
+
self._upsert_profile(session_profile)
|
|
166
|
+
return session_profile
|
|
167
|
+
|
|
168
|
+
def update_route(self, profile: str, *, qf_version: str | None, qf_version_source: str | None) -> SessionProfile:
|
|
169
|
+
session_profile = self.get_profile(profile)
|
|
170
|
+
if session_profile is None:
|
|
171
|
+
raise KeyError(profile)
|
|
172
|
+
session_profile.qf_version = (str(qf_version).strip() or None) if qf_version is not None else None
|
|
173
|
+
session_profile.qf_version_source = (str(qf_version_source).strip() or None) if qf_version_source is not None else None
|
|
174
|
+
session_profile.updated_at = _utcnow()
|
|
175
|
+
self._upsert_profile(session_profile)
|
|
176
|
+
backend_session = self._memory_sessions.get(profile)
|
|
177
|
+
if backend_session is not None:
|
|
178
|
+
backend_session.qf_version = session_profile.qf_version
|
|
179
|
+
backend_session.qf_version_source = session_profile.qf_version_source
|
|
180
|
+
return session_profile
|
|
181
|
+
|
|
182
|
+
def logout(self, profile: str, forget_persisted: bool = False) -> None:
|
|
183
|
+
self._memory_sessions.pop(profile, None)
|
|
184
|
+
if forget_persisted:
|
|
185
|
+
self.invalidate(profile)
|
|
186
|
+
return
|
|
187
|
+
if self.get_profile(profile):
|
|
188
|
+
self._logged_out_profiles.add(profile)
|
|
189
|
+
|
|
190
|
+
def invalidate(self, profile: str) -> None:
|
|
191
|
+
self._memory_sessions.pop(profile, None)
|
|
192
|
+
self._logged_out_profiles.discard(profile)
|
|
193
|
+
self._delete_secret(self._token_key(profile))
|
|
194
|
+
self._delete_secret(self._login_token_key(profile))
|
|
195
|
+
payload = self._load_profiles()
|
|
196
|
+
profiles = payload.get("profiles", {})
|
|
197
|
+
if profile in profiles:
|
|
198
|
+
profiles.pop(profile)
|
|
199
|
+
self._save_profiles(payload)
|
|
200
|
+
|
|
201
|
+
def has_profile(self, profile: str) -> bool:
|
|
202
|
+
return self.get_profile(profile) is not None
|
|
203
|
+
|
|
204
|
+
def _token_key(self, profile: str) -> str:
|
|
205
|
+
return f"{profile}:token"
|
|
206
|
+
|
|
207
|
+
def _login_token_key(self, profile: str) -> str:
|
|
208
|
+
return f"{profile}:login-token"
|
|
209
|
+
|
|
210
|
+
def _upsert_profile(self, profile: SessionProfile) -> None:
|
|
211
|
+
payload = self._load_profiles()
|
|
212
|
+
payload.setdefault("profiles", {})[profile.profile] = asdict(profile)
|
|
213
|
+
self._save_profiles(payload)
|
|
214
|
+
|
|
215
|
+
def _load_profiles(self) -> JSONObject:
|
|
216
|
+
if not self._profiles_path.exists():
|
|
217
|
+
return {"profiles": {}}
|
|
218
|
+
with self._profiles_path.open("r", encoding="utf-8") as handle:
|
|
219
|
+
return json.load(handle)
|
|
220
|
+
|
|
221
|
+
def _save_profiles(self, payload: JSONObject) -> None:
|
|
222
|
+
self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
with self._profiles_path.open("w", encoding="utf-8") as handle:
|
|
224
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
225
|
+
|
|
226
|
+
def _set_secret(self, key: str, value: str) -> bool:
|
|
227
|
+
if self._keyring is None:
|
|
228
|
+
return False
|
|
229
|
+
try:
|
|
230
|
+
self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
|
|
231
|
+
return True
|
|
232
|
+
except Exception:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
def _get_secret(self, key: str) -> str | None:
|
|
236
|
+
if self._keyring is None:
|
|
237
|
+
return None
|
|
238
|
+
try:
|
|
239
|
+
return self._keyring.get_password(KEYRING_SERVICE_NAME, key)
|
|
240
|
+
except Exception:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def _delete_secret(self, key: str) -> None:
|
|
244
|
+
if self._keyring is None:
|
|
245
|
+
return
|
|
246
|
+
try:
|
|
247
|
+
self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
|
|
248
|
+
except Exception:
|
|
249
|
+
return
|