xt-manager 0.2.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.
XTManager/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """MT5-style import for the XT Manager client.
2
+
3
+ import XTManager
4
+ manager = XTManager.ManagerAPI()
5
+ manager.Connect("127.0.0.1:8443", login, password, 0, 30000)
6
+ """
7
+
8
+ from xt_manager.manager import ManagerAPI
9
+ from xt_manager.online import Online
10
+ from xt_manager.records import Account, Deal, Group, Position, Symbol, TradeOrder
11
+ from xt_manager.user import User
12
+
13
+
14
+ class EnPumpModes:
15
+ PUMP_MODE_NONE = 0
16
+ PUMP_MODE_USERS = 1
17
+ PUMP_MODE_ACTIVITY = 2
18
+ PUMP_MODE_MAIL = 4
19
+ PUMP_MODE_NEWS = 8
20
+ PUMP_MODE_ORDERS = 16
21
+ PUMP_MODE_GROUPS = 32
22
+ PUMP_MODE_SYMBOLS = 64
23
+ PUMP_MODE_FULL = 65535
24
+
25
+
26
+ ManagerAPI.EnPumpModes = EnPumpModes
27
+
28
+ __all__ = [
29
+ "Account",
30
+ "Deal",
31
+ "EnPumpModes",
32
+ "Group",
33
+ "ManagerAPI",
34
+ "Online",
35
+ "Position",
36
+ "Symbol",
37
+ "TradeOrder",
38
+ "User",
39
+ ]
xt_manager/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Python library for connecting to XT with a Manager-style session."""
2
+
3
+ from xt_manager.cli import main
4
+ from xt_manager.manager import ManagerAPI
5
+ from xt_manager.online import Online
6
+ from xt_manager.records import Account, Deal, Group, Position, Symbol, TradeOrder
7
+ from xt_manager.user import User
8
+
9
+ __all__ = [
10
+ "Account",
11
+ "Deal",
12
+ "Group",
13
+ "ManagerAPI",
14
+ "Online",
15
+ "Position",
16
+ "Symbol",
17
+ "TradeOrder",
18
+ "User",
19
+ "main",
20
+ ]
xt_manager/api.py ADDED
@@ -0,0 +1,464 @@
1
+ """XT Manager HTTP API. Routes belong to this app only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from uuid import uuid4
7
+
8
+ from fastapi import FastAPI, Header, HTTPException, Request
9
+ from pydantic import BaseModel, Field, ValidationError
10
+
11
+ from xt_manager.broker_sync import apply_balance, post_balance, store_trading_account
12
+ from xt_manager.online import Online
13
+ from xt_manager.store import MemoryStore
14
+ from xt_manager.trading_api import register_trading
15
+ from xt_manager.user import User
16
+
17
+ _SESSION_HEADER = "X-XT-Manager-Session"
18
+
19
+
20
+ class ManagerBody(BaseModel):
21
+ login: str = Field(examples=["2504"])
22
+ role: str = Field(examples=["manager"])
23
+ name: str = Field(default="", examples=["Demo Manager"])
24
+
25
+
26
+ class ManagerResult(BaseModel):
27
+ ok: bool = True
28
+ login: str
29
+ role: str
30
+ name: str
31
+
32
+
33
+ class AccountBody(BaseModel):
34
+ manager_login: str = Field(examples=["2504"])
35
+ password: str = Field(examples=["TradePass1!"])
36
+ group: str = Field(default="demo", examples=["demo"])
37
+ name: str = Field(default="", examples=["Amit Shah"])
38
+ balance: str = Field(default="0.00", examples=["10500.50"])
39
+ leverage: str = Field(default="100", examples=["100"])
40
+
41
+
42
+ class AccountResult(BaseModel):
43
+ ok: bool = True
44
+ login: str
45
+ manager_login: str
46
+
47
+
48
+ class ConnectBody(BaseModel):
49
+ login: str = Field(examples=["2504"])
50
+ password: str = Field(examples=["TradePass1!"])
51
+
52
+
53
+ class ConnectResult(BaseModel):
54
+ ok: bool = True
55
+ session_id: str
56
+
57
+
58
+ class UserResult(BaseModel):
59
+ ok: bool = True
60
+ user: User
61
+
62
+
63
+ class OnlineResult(BaseModel):
64
+ ok: bool = True
65
+ total: int
66
+ connections: list[Online]
67
+
68
+
69
+ class UsersResult(BaseModel):
70
+ ok: bool = True
71
+ total: int
72
+ users: list[User]
73
+
74
+
75
+ class LoginsResult(BaseModel):
76
+ ok: bool = True
77
+ logins: list[str]
78
+
79
+
80
+ class LoginsBody(BaseModel):
81
+ logins: list[str] = Field(examples=[["2504"]])
82
+
83
+
84
+ class PasswordBody(BaseModel):
85
+ type: int = Field(default=0, examples=[0])
86
+ password: str = Field(examples=["TradePass1!"])
87
+
88
+
89
+ class UserCreateBody(BaseModel):
90
+ password: str = Field(examples=["TradePass1!"])
91
+ group: str = Field(default="demo", examples=["demo\\testing\\BB\\SWAP\\ALPHA"])
92
+ name: str = Field(default="", examples=["Amit Shah"])
93
+ first_name: str = Field(default="", examples=["Amit"])
94
+ last_name: str = Field(default="", examples=["Shah"])
95
+ leverage: str = Field(default="100", examples=["100"])
96
+ balance: str = Field(default="0.00", examples=["10000.00"])
97
+ email: str = Field(default="", examples=["amit.shah@example.com"])
98
+ country: str = Field(default="", examples=["India"])
99
+
100
+
101
+ class BalanceBody(BaseModel):
102
+ amount: str = Field(examples=["10000.00"])
103
+ comment: str = Field(default="", examples=["Initial Deposit"])
104
+ type: int = Field(default=2, examples=[2])
105
+ raw: bool = False
106
+
107
+
108
+ class BalanceResult(BaseModel):
109
+ ok: bool = True
110
+ user: User
111
+ deal: str
112
+
113
+
114
+ class StatusResult(BaseModel):
115
+ ok: bool = True
116
+ status: str
117
+
118
+
119
+ def _restore_balance(store: MemoryStore, manager_login: str, login: str, body: BalanceBody) -> BalanceResult | str:
120
+ """Apply a deposit for a login that lives in the terminal database only."""
121
+ deal = f"restore-{login}-{uuid4().hex}"
122
+ status, account, error = post_balance(login, body.amount, body.comment, body.type or 2, deal)
123
+ if status == 0 and error is None:
124
+ return "missing"
125
+ if status == 404:
126
+ return "missing"
127
+ if error or account is None:
128
+ return error or "terminal balance store rejected the change"
129
+ user = User(
130
+ Login=str(login).strip(),
131
+ Group=str(account.get("comment") or "demo"),
132
+ Name=str(login).strip(),
133
+ Balance=str(account.get("balance") or "0.00"),
134
+ Leverage=str(account.get("leverage") or "100"),
135
+ Credit=str(account.get("credit") or "0.00"),
136
+ ManagerLogin=manager_login,
137
+ )
138
+ store.remember_client(manager_login, user)
139
+ return BalanceResult(user=user, deal=deal)
140
+
141
+
142
+ def create_app(store: MemoryStore) -> FastAPI:
143
+ app = FastAPI(title="XT Manager API", version="0.1.0")
144
+ app.state.store = store
145
+
146
+ @app.post("/xt-manager/v1/managers", response_model=ManagerResult)
147
+ def create_manager(body: ManagerBody) -> ManagerResult:
148
+ created = store.add_manager(body.login, body.role.strip().lower(), body.name)
149
+ if created is None:
150
+ raise HTTPException(
151
+ status_code=409,
152
+ detail={"ok": False, "code": "manager.not_created", "message": "Manager id was not assigned"},
153
+ )
154
+ return ManagerResult(login=created.login, role=created.role, name=created.name)
155
+
156
+ @app.post("/xt-manager/v1/accounts", response_model=AccountResult)
157
+ def create_account(body: AccountBody) -> AccountResult:
158
+ if store.get_manager(body.manager_login) is None:
159
+ raise HTTPException(
160
+ status_code=404,
161
+ detail={"ok": False, "code": "manager.not_found", "message": "Assign a manager id before creating an account"},
162
+ )
163
+ manager_login = body.manager_login.strip()
164
+ created = store.add_account(
165
+ manager_login,
166
+ body.password,
167
+ User(
168
+ Login=manager_login,
169
+ Group=body.group.strip() or "demo",
170
+ Name=body.name.strip() or manager_login,
171
+ Balance=body.balance.strip() or "0.00",
172
+ Leverage=body.leverage.strip() or "100",
173
+ ManagerLogin=manager_login,
174
+ ),
175
+ )
176
+ if created is None:
177
+ raise HTTPException(
178
+ status_code=409,
179
+ detail={"ok": False, "code": "account.not_created", "message": "Trading account was not created"},
180
+ )
181
+ return AccountResult(login=created.login, manager_login=created.manager_login)
182
+
183
+ @app.post("/xt-manager/v1/users", response_model=UserResult)
184
+ def user_add(
185
+ body: UserCreateBody,
186
+ session: str | None = Header(default=None, alias=_SESSION_HEADER),
187
+ ) -> UserResult:
188
+ manager_login = _require_session(session)
189
+ name = body.name.strip() or " ".join(part for part in (body.first_name.strip(), body.last_name.strip()) if part)
190
+ created, error = store.add_client_user(
191
+ manager_login,
192
+ body.password,
193
+ User(
194
+ Login="",
195
+ Group=body.group.strip() or "demo",
196
+ Name=name or manager_login,
197
+ FirstName=body.first_name.strip(),
198
+ LastName=body.last_name.strip(),
199
+ Balance=body.balance.strip() or "0.00",
200
+ Leverage=body.leverage.strip() or "100",
201
+ Email=body.email.strip(),
202
+ Country=body.country.strip(),
203
+ ManagerLogin=manager_login,
204
+ ),
205
+ )
206
+ if error == "password.invalid":
207
+ raise HTTPException(
208
+ status_code=400,
209
+ detail={
210
+ "ok": False,
211
+ "code": error,
212
+ "message": "Password must be 8 to 16 characters and include lower, upper, digit, and special characters",
213
+ },
214
+ )
215
+ if created is None:
216
+ raise HTTPException(
217
+ status_code=404,
218
+ detail={"ok": False, "code": "manager.not_found", "message": "Assign a manager id before creating an account"},
219
+ )
220
+ stored = store_trading_account(
221
+ created.login,
222
+ body.password,
223
+ created.user.Group,
224
+ created.user.Name,
225
+ created.user.Leverage,
226
+ created.user.Balance,
227
+ created.user.Email,
228
+ )
229
+ if stored is not None:
230
+ store.delete_user(created.login)
231
+ raise HTTPException(
232
+ status_code=502,
233
+ detail={"ok": False, "code": "terminal.store_failed", "message": stored},
234
+ )
235
+ print(f"user created login={created.login} manager={created.manager_login}", flush=True)
236
+ return UserResult(user=created.user)
237
+
238
+ @app.post("/xt-manager/v1/users/{login}/balance", response_model=BalanceResult)
239
+ def user_balance(
240
+ login: str,
241
+ body: BalanceBody,
242
+ session: str | None = Header(default=None, alias=_SESSION_HEADER),
243
+ ) -> BalanceResult:
244
+ manager_login = _require_session(session)
245
+ try:
246
+ updated = store.adjust_balance(login, body.amount, body.comment, body.type, body.raw)
247
+ except Exception:
248
+ raise HTTPException(
249
+ status_code=400,
250
+ detail={"ok": False, "code": "invalid_argument", "message": "Balance amount is not valid"},
251
+ ) from None
252
+ if updated == "balance.unsupported":
253
+ raise HTTPException(
254
+ status_code=400,
255
+ detail={"ok": False, "code": updated, "message": "Balance type must be 2 for balance or 3 for credit"},
256
+ )
257
+ if updated == "balance.insufficient":
258
+ raise HTTPException(
259
+ status_code=400,
260
+ detail={"ok": False, "code": updated, "message": "Balance cannot go below zero"},
261
+ )
262
+ if updated is None:
263
+ restored = _restore_balance(store, manager_login, login, body)
264
+ if isinstance(restored, BalanceResult):
265
+ return restored
266
+ if restored == "missing":
267
+ raise HTTPException(
268
+ status_code=404,
269
+ detail={"ok": False, "code": "user.not_found", "message": f"User {login} was not found"},
270
+ )
271
+ raise HTTPException(
272
+ status_code=502,
273
+ detail={"ok": False, "code": "terminal.store_failed", "message": str(restored)},
274
+ )
275
+ if isinstance(updated, str):
276
+ raise HTTPException(
277
+ status_code=404,
278
+ detail={"ok": False, "code": "user.not_found", "message": f"User {login} was not found"},
279
+ )
280
+ user, ticket = updated
281
+ stored = apply_balance(login, body.amount, body.comment, body.type, ticket)
282
+ if stored is not None:
283
+ try:
284
+ store.adjust_balance(login, str(-Decimal(body.amount)), body.comment, body.type, body.raw)
285
+ except Exception:
286
+ pass
287
+ raise HTTPException(
288
+ status_code=502,
289
+ detail={"ok": False, "code": "terminal.store_failed", "message": stored},
290
+ )
291
+ return BalanceResult(user=user, deal=ticket)
292
+
293
+ @app.post("/xt-manager/v1/connect", response_model=ConnectResult)
294
+ def connect(body: ConnectBody, request: Request) -> ConnectResult:
295
+ address = request.client.host if request.client is not None else ""
296
+ session_id = store.open_session(body.login, body.password, address)
297
+ if session_id is None:
298
+ raise HTTPException(
299
+ status_code=401,
300
+ detail={"ok": False, "code": "auth.invalid_credentials", "message": "Invalid credentials"},
301
+ )
302
+ print(f"online login={body.login.strip()} address={address}", flush=True)
303
+ return ConnectResult(session_id=session_id)
304
+
305
+ def _require_session(session: str | None) -> str:
306
+ login = store.session_login(session or "")
307
+ if login is None:
308
+ raise HTTPException(
309
+ status_code=401,
310
+ detail={"ok": False, "code": "auth.session_required", "message": "Manager session is required"},
311
+ )
312
+ return login
313
+
314
+ @app.get("/xt-manager/v1/users/total", response_model=UsersResult)
315
+ def user_total(session: str | None = Header(default=None, alias=_SESSION_HEADER)) -> UsersResult:
316
+ _require_session(session)
317
+ return UsersResult(total=store.user_total(), users=[])
318
+
319
+ @app.get("/xt-manager/v1/users/by-group", response_model=UsersResult)
320
+ def users_by_group(
321
+ group: str,
322
+ session: str | None = Header(default=None, alias=_SESSION_HEADER),
323
+ ) -> UsersResult:
324
+ _require_session(session)
325
+ users = store.users_by_group(group)
326
+ return UsersResult(total=len(users), users=users)
327
+
328
+ @app.post("/xt-manager/v1/users/by-logins", response_model=UsersResult)
329
+ def users_by_logins(
330
+ body: LoginsBody,
331
+ session: str | None = Header(default=None, alias=_SESSION_HEADER),
332
+ ) -> UsersResult:
333
+ _require_session(session)
334
+ if not body.logins:
335
+ raise HTTPException(
336
+ status_code=400,
337
+ detail={"ok": False, "code": "invalid_argument", "message": "At least one login is required"},
338
+ )
339
+ users = store.users_by_logins(body.logins)
340
+ return UsersResult(total=len(users), users=users)
341
+
342
+ @app.get("/xt-manager/v1/groups/{group}/logins", response_model=LoginsResult)
343
+ def user_logins(group: str, session: str | None = Header(default=None, alias=_SESSION_HEADER)) -> LoginsResult:
344
+ _require_session(session)
345
+ return LoginsResult(logins=store.user_logins(group))
346
+
347
+ @app.get("/xt-manager/v1/users/{login}", response_model=UserResult)
348
+ def user_request(
349
+ login: str,
350
+ session: str | None = Header(default=None, alias=_SESSION_HEADER),
351
+ ) -> UserResult:
352
+ if store.session_login(session or "") is None:
353
+ raise HTTPException(
354
+ status_code=401,
355
+ detail={"ok": False, "code": "auth.session_required", "message": "Manager session is required"},
356
+ )
357
+ user = store.get_user(login)
358
+ if user is None:
359
+ raise HTTPException(
360
+ status_code=404,
361
+ detail={"ok": False, "code": "user.not_found", "message": f"User {login} was not found"},
362
+ )
363
+ return UserResult(user=user)
364
+
365
+ @app.put("/xt-manager/v1/users/{login}", response_model=UserResult)
366
+ def user_update(
367
+ login: str,
368
+ body: dict[str, object],
369
+ session: str | None = Header(default=None, alias=_SESSION_HEADER),
370
+ ) -> UserResult:
371
+ _require_session(session)
372
+ sent_login = body.get("Login")
373
+ if sent_login is not None and str(sent_login) != login:
374
+ raise HTTPException(
375
+ status_code=400,
376
+ detail={"ok": False, "code": "invalid_argument", "message": "User login must match the path"},
377
+ )
378
+ try:
379
+ updated = store.update_user(login, body)
380
+ except ValidationError:
381
+ raise HTTPException(
382
+ status_code=400,
383
+ detail={"ok": False, "code": "invalid_argument", "message": "User fields are not valid"},
384
+ ) from None
385
+ if updated is None:
386
+ raise HTTPException(
387
+ status_code=404,
388
+ detail={"ok": False, "code": "user.not_found", "message": f"User {login} was not found"},
389
+ )
390
+ return UserResult(user=updated)
391
+
392
+ @app.post("/xt-manager/v1/users/{login}/password/check", response_model=StatusResult)
393
+ def user_password_check(
394
+ login: str,
395
+ body: PasswordBody,
396
+ session: str | None = Header(default=None, alias=_SESSION_HEADER),
397
+ ) -> StatusResult:
398
+ _require_session(session)
399
+ if body.type != 0:
400
+ raise HTTPException(
401
+ status_code=400,
402
+ detail={"ok": False, "code": "password.unsupported", "message": "Only the main password can be checked"},
403
+ )
404
+ matched = store.password_matches_login(login, body.password, body.type)
405
+ if matched is None:
406
+ raise HTTPException(
407
+ status_code=404,
408
+ detail={"ok": False, "code": "user.not_found", "message": f"User {login} was not found"},
409
+ )
410
+ if not matched:
411
+ raise HTTPException(
412
+ status_code=401,
413
+ detail={"ok": False, "code": "auth.invalid_credentials", "message": "Invalid credentials"},
414
+ )
415
+ return StatusResult(status="ok")
416
+
417
+ @app.post("/xt-manager/v1/users/{login}/password", response_model=StatusResult)
418
+ def user_password_change(
419
+ login: str,
420
+ body: PasswordBody,
421
+ session: str | None = Header(default=None, alias=_SESSION_HEADER),
422
+ ) -> StatusResult:
423
+ _require_session(session)
424
+ error = store.change_password(login, body.password, body.type)
425
+ if error == "password.unsupported":
426
+ raise HTTPException(
427
+ status_code=400,
428
+ detail={"ok": False, "code": error, "message": "Only the main password can be changed"},
429
+ )
430
+ if error == "password.invalid":
431
+ raise HTTPException(
432
+ status_code=400,
433
+ detail={
434
+ "ok": False,
435
+ "code": error,
436
+ "message": "Password must be 8 to 16 characters and include lower, upper, digit, and special characters",
437
+ },
438
+ )
439
+ if error is not None:
440
+ raise HTTPException(
441
+ status_code=404,
442
+ detail={"ok": False, "code": "user.not_found", "message": f"User {login} was not found"},
443
+ )
444
+ return StatusResult(status="ok")
445
+
446
+ @app.get("/xt-manager/v1/online", response_model=OnlineResult)
447
+ def online(session: str | None = Header(default=None, alias=_SESSION_HEADER)) -> OnlineResult:
448
+ if store.session_login(session or "") is None:
449
+ raise HTTPException(
450
+ status_code=401,
451
+ detail={"ok": False, "code": "auth.session_required", "message": "Manager session is required"},
452
+ )
453
+ connections = store.online()
454
+ return OnlineResult(total=len(connections), connections=connections)
455
+
456
+ @app.post("/xt-manager/v1/disconnect", response_model=StatusResult)
457
+ def disconnect(session: str | None = Header(default=None, alias=_SESSION_HEADER)) -> StatusResult:
458
+ closed = store.close_session(session) if session else None
459
+ if closed is not None:
460
+ print(f"offline login={closed.login} address={closed.address}", flush=True)
461
+ return StatusResult(status="disconnected")
462
+
463
+ register_trading(app, store, _require_session)
464
+ return app
@@ -0,0 +1,106 @@
1
+ """Store a manager-created login in the broker database the terminal reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import httpx
8
+
9
+ _URL = "XT_BROKER_API_URL"
10
+ _TOKEN = "XT_BROKER_SYNC_TOKEN"
11
+
12
+
13
+ def store_trading_account(
14
+ login: str,
15
+ password: str,
16
+ group: str,
17
+ name: str,
18
+ leverage: str,
19
+ balance: str,
20
+ email: str = "",
21
+ ) -> str | None:
22
+ """Return an error message when the terminal database rejects the account.
23
+
24
+ When the broker URL or sync token is unset, the account stays in this process only.
25
+ """
26
+ origin = os.environ.get(_URL, "").strip().rstrip("/")
27
+ token = os.environ.get(_TOKEN, "").strip()
28
+ if not origin or not token:
29
+ return None
30
+ try:
31
+ response = httpx.post(
32
+ f"{origin}/api/v1/internal/manager/trading-accounts",
33
+ headers={"X-XT-Manager-Sync": token},
34
+ json={
35
+ "login": login,
36
+ "group": group,
37
+ "name": name,
38
+ "email": email,
39
+ "leverage": leverage,
40
+ "master_password": password,
41
+ "balance": balance,
42
+ },
43
+ timeout=30,
44
+ )
45
+ except httpx.HTTPError as exc:
46
+ return f"terminal account store failed: {exc}"
47
+ if response.status_code in {200, 201}:
48
+ return None
49
+ message = "terminal account store rejected the login"
50
+ try:
51
+ body = response.json()
52
+ except ValueError:
53
+ return message
54
+ if isinstance(body, dict):
55
+ detail = body.get("message") or body.get("user_message")
56
+ if isinstance(body.get("detail"), dict):
57
+ detail = body["detail"].get("message") or detail
58
+ if detail:
59
+ message = str(detail)
60
+ return message
61
+
62
+
63
+ def apply_balance(login: str, amount: str, comment: str, kind: int, deal: str) -> str | None:
64
+ """Return an error message when the terminal database rejects the balance change."""
65
+ _status, _account, error = post_balance(login, amount, comment, kind, deal)
66
+ return error
67
+
68
+
69
+ def post_balance(
70
+ login: str, amount: str, comment: str, kind: int, deal: str
71
+ ) -> tuple[int, dict | None, str | None]:
72
+ """Post a balance change and return ``(status, account, error)``.
73
+
74
+ Status ``0`` means the broker URL or token is unset.
75
+ """
76
+ origin = os.environ.get(_URL, "").strip().rstrip("/")
77
+ token = os.environ.get(_TOKEN, "").strip()
78
+ if not origin or not token:
79
+ return 0, None, None
80
+ try:
81
+ response = httpx.post(
82
+ f"{origin}/api/v1/internal/manager/trading-accounts/{login}/balance",
83
+ headers={"X-XT-Manager-Sync": token},
84
+ json={"amount": amount, "comment": comment, "type": kind, "deal": deal},
85
+ timeout=30,
86
+ )
87
+ except httpx.HTTPError as exc:
88
+ return 0, None, f"terminal balance store failed: {exc}"
89
+ try:
90
+ body = response.json()
91
+ except ValueError:
92
+ body = {}
93
+ if response.status_code in {200, 201}:
94
+ account = body.get("data") if isinstance(body, dict) else None
95
+ return response.status_code, account if isinstance(account, dict) else {}, None
96
+ message = "terminal balance store rejected the change"
97
+ if isinstance(body, dict):
98
+ detail = body.get("message") or body.get("user_message")
99
+ nested = body.get("detail")
100
+ if isinstance(nested, dict):
101
+ detail = nested.get("message") or detail
102
+ if isinstance(detail, dict):
103
+ detail = detail.get("message") or detail.get("user_message")
104
+ if detail:
105
+ message = str(detail)
106
+ return response.status_code, None, message