fixtureqa 0.1.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.
- fixture/__init__.py +22 -0
- fixture/__main__.py +161 -0
- fixture/api/__init__.py +0 -0
- fixture/api/app.py +95 -0
- fixture/api/connection_manager.py +161 -0
- fixture/api/deps.py +73 -0
- fixture/api/routers/__init__.py +0 -0
- fixture/api/routers/admin.py +178 -0
- fixture/api/routers/auth.py +74 -0
- fixture/api/routers/branding.py +33 -0
- fixture/api/routers/fix_spec.py +41 -0
- fixture/api/routers/messages.py +137 -0
- fixture/api/routers/scenarios.py +65 -0
- fixture/api/routers/sessions.py +272 -0
- fixture/api/routers/setup.py +42 -0
- fixture/api/routers/templates.py +36 -0
- fixture/api/routers/ws.py +129 -0
- fixture/api/schemas.py +289 -0
- fixture/config/__init__.py +0 -0
- fixture/core/__init__.py +0 -0
- fixture/core/auth.py +68 -0
- fixture/core/config_store.py +85 -0
- fixture/core/events.py +22 -0
- fixture/core/fix_application.py +67 -0
- fixture/core/fix_parser.py +79 -0
- fixture/core/fix_spec_parser.py +172 -0
- fixture/core/fix_tags.py +297 -0
- fixture/core/housekeeping.py +107 -0
- fixture/core/message_log.py +115 -0
- fixture/core/message_store.py +246 -0
- fixture/core/models.py +87 -0
- fixture/core/scenario_runner.py +331 -0
- fixture/core/scenario_store.py +70 -0
- fixture/core/session.py +278 -0
- fixture/core/session_manager.py +173 -0
- fixture/core/template_store.py +70 -0
- fixture/core/user_store.py +186 -0
- fixture/core/venue_responses.py +94 -0
- fixture/fix_specs/FIX42.xml +2746 -0
- fixture/fix_specs/FIX44.xml +6593 -0
- fixture/server.py +37 -0
- fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
- fixture/static/assets/index-B31-1dt-.css +1 -0
- fixture/static/assets/index-CTsKxGdI.js +87 -0
- fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
- fixture/static/favicon.svg +12 -0
- fixture/static/index.html +15 -0
- fixture/ui/__init__.py +0 -0
- fixtureqa-0.1.0.dist-info/METADATA +16 -0
- fixtureqa-0.1.0.dist-info/RECORD +54 -0
- fixtureqa-0.1.0.dist-info/WHEEL +5 -0
- fixtureqa-0.1.0.dist-info/entry_points.txt +2 -0
- fixtureqa-0.1.0.dist-info/licenses/LICENSE +21 -0
- fixtureqa-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from typing import Annotated, Optional
|
|
2
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
3
|
+
|
|
4
|
+
from ...core.session_manager import SessionManager
|
|
5
|
+
from ...core.models import SessionConfig, ConnectionType, SessionRole
|
|
6
|
+
from ...core.user_store import User, UserStore
|
|
7
|
+
from ..schemas import SessionConfigRequest, SessionResponse, UpdateSessionRequest, SendMessageRequest, SeqNumRequest, SeqNumResponse
|
|
8
|
+
from ..deps import get_session_manager, get_current_user, get_user_store
|
|
9
|
+
|
|
10
|
+
router = APIRouter(tags=["sessions"])
|
|
11
|
+
|
|
12
|
+
SM = Annotated[SessionManager, Depends(get_session_manager)]
|
|
13
|
+
US = Annotated[UserStore, Depends(get_user_store)]
|
|
14
|
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStore] = None) -> SessionResponse:
|
|
18
|
+
cfg_list = [c for c in sm.list_sessions() if c.session_id == session_id]
|
|
19
|
+
if not cfg_list:
|
|
20
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
21
|
+
cfg = cfg_list[0]
|
|
22
|
+
state = sm.get_state(session_id)
|
|
23
|
+
log = state.message_log
|
|
24
|
+
owner_username: Optional[str] = None
|
|
25
|
+
if us is not None:
|
|
26
|
+
owner = us.get_by_uid(cfg.owner_uid)
|
|
27
|
+
owner_username = owner.username if owner else None
|
|
28
|
+
return SessionResponse(
|
|
29
|
+
session_id=cfg.session_id,
|
|
30
|
+
display_name=cfg.display_name,
|
|
31
|
+
begin_string=cfg.begin_string,
|
|
32
|
+
sender_comp_id=cfg.sender_comp_id,
|
|
33
|
+
target_comp_id=cfg.target_comp_id,
|
|
34
|
+
connection_type=cfg.connection_type.value,
|
|
35
|
+
host=cfg.host,
|
|
36
|
+
port=cfg.port,
|
|
37
|
+
heartbeat_interval=cfg.heartbeat_interval,
|
|
38
|
+
reconnect_interval=cfg.reconnect_interval,
|
|
39
|
+
use_file_log=cfg.use_file_log,
|
|
40
|
+
start_time=cfg.start_time,
|
|
41
|
+
end_time=cfg.end_time,
|
|
42
|
+
reset_on_logon=cfg.reset_on_logon,
|
|
43
|
+
reset_on_logout=cfg.reset_on_logout,
|
|
44
|
+
session_role=cfg.session_role.value,
|
|
45
|
+
auto_ack=cfg.auto_ack,
|
|
46
|
+
auto_ack_delay_ms=cfg.auto_ack_delay_ms,
|
|
47
|
+
auto_fill=cfg.auto_fill,
|
|
48
|
+
auto_fill_delay_ms=cfg.auto_fill_delay_ms,
|
|
49
|
+
data_dictionary_path=cfg.data_dictionary_path,
|
|
50
|
+
status=state.status.name,
|
|
51
|
+
last_sent_seq_num=state.last_sent_seq_num,
|
|
52
|
+
last_recv_seq_num=state.last_recv_seq_num,
|
|
53
|
+
message_count=len(log) if log else 0,
|
|
54
|
+
status_changed_at=state.status_changed_at,
|
|
55
|
+
last_heartbeat_at=state.last_heartbeat_at,
|
|
56
|
+
owner_username=owner_username,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_ownership(sm: SessionManager, session_id: str, user: User) -> SessionConfig:
|
|
61
|
+
"""Return the SessionConfig if the user owns it (or is admin). Raises 404/403 otherwise."""
|
|
62
|
+
configs = sm.list_sessions()
|
|
63
|
+
cfg = next((c for c in configs if c.session_id == session_id), None)
|
|
64
|
+
if cfg is None:
|
|
65
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
66
|
+
if not user.is_admin and cfg.owner_uid != user.uid:
|
|
67
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
68
|
+
return cfg
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/", response_model=list[SessionResponse])
|
|
72
|
+
def list_sessions(sm: SM, us: US, user: CurrentUser):
|
|
73
|
+
configs = sm.list_sessions_for_user(user.uid, is_admin=user.is_admin)
|
|
74
|
+
user_store = us if user.is_admin else None
|
|
75
|
+
return [_session_response(sm, cfg.session_id, user_store) for cfg in configs]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.post("/", response_model=SessionResponse, status_code=201)
|
|
79
|
+
async def create_session(body: SessionConfigRequest, sm: SM, user: CurrentUser):
|
|
80
|
+
try:
|
|
81
|
+
ct = ConnectionType(body.connection_type)
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise HTTPException(status_code=422, detail=f"Invalid connection_type: {body.connection_type!r}")
|
|
84
|
+
try:
|
|
85
|
+
role = SessionRole(body.session_role)
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise HTTPException(status_code=422, detail=f"Invalid session_role: {body.session_role!r}")
|
|
88
|
+
|
|
89
|
+
cfg = SessionConfig(
|
|
90
|
+
display_name=body.display_name,
|
|
91
|
+
begin_string=body.begin_string,
|
|
92
|
+
sender_comp_id=body.sender_comp_id,
|
|
93
|
+
target_comp_id=body.target_comp_id,
|
|
94
|
+
connection_type=ct,
|
|
95
|
+
host=body.host,
|
|
96
|
+
port=body.port,
|
|
97
|
+
heartbeat_interval=body.heartbeat_interval,
|
|
98
|
+
reconnect_interval=body.reconnect_interval,
|
|
99
|
+
data_dictionary_path=body.data_dictionary_path,
|
|
100
|
+
use_file_log=body.use_file_log,
|
|
101
|
+
start_time=body.start_time,
|
|
102
|
+
end_time=body.end_time,
|
|
103
|
+
reset_on_logon=body.reset_on_logon,
|
|
104
|
+
reset_on_logout=body.reset_on_logout,
|
|
105
|
+
session_role=role,
|
|
106
|
+
auto_ack=body.auto_ack,
|
|
107
|
+
auto_ack_delay_ms=body.auto_ack_delay_ms,
|
|
108
|
+
auto_fill=body.auto_fill,
|
|
109
|
+
auto_fill_delay_ms=body.auto_fill_delay_ms,
|
|
110
|
+
owner_uid=user.uid,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
session_id = sm.add_session(cfg)
|
|
115
|
+
except (ValueError, KeyError) as e:
|
|
116
|
+
raise HTTPException(status_code=422, detail=str(e))
|
|
117
|
+
|
|
118
|
+
if body.auto_start:
|
|
119
|
+
try:
|
|
120
|
+
await sm.start_session(session_id)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
raise HTTPException(status_code=500, detail=f"Session added but failed to start: {e}")
|
|
123
|
+
|
|
124
|
+
return _session_response(sm, session_id)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.post("/import", response_model=list[SessionResponse], status_code=201)
|
|
128
|
+
def import_sessions(body: list[SessionConfigRequest], sm: SM, user: CurrentUser):
|
|
129
|
+
"""
|
|
130
|
+
Bulk-create sessions from an exported config file.
|
|
131
|
+
Each entry is processed independently; invalid entries are skipped.
|
|
132
|
+
Returns the list of successfully created sessions.
|
|
133
|
+
"""
|
|
134
|
+
created = []
|
|
135
|
+
for item in body:
|
|
136
|
+
try:
|
|
137
|
+
ct = ConnectionType(item.connection_type)
|
|
138
|
+
role = SessionRole(item.session_role)
|
|
139
|
+
cfg = SessionConfig(
|
|
140
|
+
display_name=item.display_name,
|
|
141
|
+
begin_string=item.begin_string,
|
|
142
|
+
sender_comp_id=item.sender_comp_id,
|
|
143
|
+
target_comp_id=item.target_comp_id,
|
|
144
|
+
connection_type=ct,
|
|
145
|
+
host=item.host,
|
|
146
|
+
port=item.port,
|
|
147
|
+
heartbeat_interval=item.heartbeat_interval,
|
|
148
|
+
reconnect_interval=item.reconnect_interval,
|
|
149
|
+
data_dictionary_path=item.data_dictionary_path,
|
|
150
|
+
use_file_log=item.use_file_log,
|
|
151
|
+
start_time=item.start_time,
|
|
152
|
+
end_time=item.end_time,
|
|
153
|
+
reset_on_logon=item.reset_on_logon,
|
|
154
|
+
reset_on_logout=item.reset_on_logout,
|
|
155
|
+
session_role=role,
|
|
156
|
+
auto_ack=item.auto_ack,
|
|
157
|
+
auto_ack_delay_ms=item.auto_ack_delay_ms,
|
|
158
|
+
auto_fill=item.auto_fill,
|
|
159
|
+
auto_fill_delay_ms=item.auto_fill_delay_ms,
|
|
160
|
+
owner_uid=user.uid,
|
|
161
|
+
)
|
|
162
|
+
session_id = sm.add_session(cfg)
|
|
163
|
+
created.append(_session_response(sm, session_id))
|
|
164
|
+
except Exception:
|
|
165
|
+
pass # skip invalid / conflicting entries
|
|
166
|
+
return created
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@router.get("/{session_id}", response_model=SessionResponse)
|
|
170
|
+
def get_session(session_id: str, sm: SM, user: CurrentUser):
|
|
171
|
+
_check_ownership(sm, session_id, user)
|
|
172
|
+
return _session_response(sm, session_id)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@router.patch("/{session_id}", response_model=SessionResponse)
|
|
176
|
+
def update_session(session_id: str, body: UpdateSessionRequest, sm: SM, user: CurrentUser):
|
|
177
|
+
_check_ownership(sm, session_id, user)
|
|
178
|
+
try:
|
|
179
|
+
sm.update_session(session_id, body)
|
|
180
|
+
except RuntimeError as e:
|
|
181
|
+
raise HTTPException(status_code=409, detail=str(e))
|
|
182
|
+
except (ValueError, KeyError) as e:
|
|
183
|
+
raise HTTPException(status_code=422, detail=str(e))
|
|
184
|
+
return _session_response(sm, session_id)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@router.delete("/{session_id}", status_code=204)
|
|
188
|
+
async def remove_session(session_id: str, sm: SM, user: CurrentUser):
|
|
189
|
+
_check_ownership(sm, session_id, user)
|
|
190
|
+
try:
|
|
191
|
+
await sm.remove_session(session_id)
|
|
192
|
+
except KeyError:
|
|
193
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@router.post("/{session_id}/start", response_model=SessionResponse)
|
|
197
|
+
async def start_session(session_id: str, sm: SM, user: CurrentUser):
|
|
198
|
+
_check_ownership(sm, session_id, user)
|
|
199
|
+
try:
|
|
200
|
+
await sm.start_session(session_id)
|
|
201
|
+
except RuntimeError as e:
|
|
202
|
+
raise HTTPException(status_code=409, detail=str(e))
|
|
203
|
+
return _session_response(sm, session_id)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router.post("/{session_id}/stop", response_model=SessionResponse)
|
|
207
|
+
async def stop_session(
|
|
208
|
+
session_id: str,
|
|
209
|
+
sm: SM,
|
|
210
|
+
user: CurrentUser,
|
|
211
|
+
force: bool = Query(default=False),
|
|
212
|
+
):
|
|
213
|
+
_check_ownership(sm, session_id, user)
|
|
214
|
+
await sm.stop_session(session_id, force=force)
|
|
215
|
+
return _session_response(sm, session_id)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@router.post("/{session_id}/send")
|
|
219
|
+
async def send_message(session_id: str, body: SendMessageRequest, sm: SM, user: CurrentUser):
|
|
220
|
+
_check_ownership(sm, session_id, user)
|
|
221
|
+
from ...core.fix_parser import parse_raw
|
|
222
|
+
from fixcore.message.message import Message
|
|
223
|
+
from datetime import datetime, timezone
|
|
224
|
+
|
|
225
|
+
raw = body.raw
|
|
226
|
+
if "\x01" not in raw:
|
|
227
|
+
raw = raw.replace("|", "\x01")
|
|
228
|
+
|
|
229
|
+
fields = parse_raw(raw) # {tag_int: value_str}
|
|
230
|
+
msg_type = fields.get(35)
|
|
231
|
+
if not msg_type:
|
|
232
|
+
raise HTTPException(status_code=422, detail="Missing MsgType (tag 35)")
|
|
233
|
+
|
|
234
|
+
if not body.no_auto_ts and 60 not in fields:
|
|
235
|
+
fields[60] = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S")
|
|
236
|
+
|
|
237
|
+
SKIP = {8, 9, 10, 34, 35, 49, 52, 56}
|
|
238
|
+
msg = Message()
|
|
239
|
+
msg.header.set(35, msg_type)
|
|
240
|
+
for tag, value in fields.items():
|
|
241
|
+
if tag not in SKIP:
|
|
242
|
+
try:
|
|
243
|
+
msg.set_field(int(tag), str(value))
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
ok = await sm.send_message(session_id, msg)
|
|
248
|
+
if not ok:
|
|
249
|
+
raise HTTPException(status_code=409, detail="Session not logged on")
|
|
250
|
+
return {"ok": True}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@router.get("/{session_id}/seqnums", response_model=SeqNumResponse)
|
|
254
|
+
def get_seqnums(session_id: str, sm: SM, user: CurrentUser):
|
|
255
|
+
_check_ownership(sm, session_id, user)
|
|
256
|
+
try:
|
|
257
|
+
sender, target = sm.get_seqnums(session_id)
|
|
258
|
+
except KeyError:
|
|
259
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
260
|
+
return SeqNumResponse(sender=sender, target=target)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@router.patch("/{session_id}/seqnums", response_model=SeqNumResponse)
|
|
264
|
+
def set_seqnums(session_id: str, body: SeqNumRequest, sm: SM, user: CurrentUser):
|
|
265
|
+
_check_ownership(sm, session_id, user)
|
|
266
|
+
if body.sender is None and body.target is None:
|
|
267
|
+
raise HTTPException(status_code=422, detail="Provide sender, target, or both")
|
|
268
|
+
try:
|
|
269
|
+
sender, target = sm.set_seqnums(session_id, body.sender, body.target)
|
|
270
|
+
except KeyError:
|
|
271
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
272
|
+
return SeqNumResponse(sender=sender, target=target)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
4
|
+
|
|
5
|
+
from ...core.auth import hash_password
|
|
6
|
+
from ...core.user_store import UserStore
|
|
7
|
+
from ..deps import get_user_store
|
|
8
|
+
from ..schemas import CreateAdminRequest, SetupStatusResponse, TokenResponse, UserResponse
|
|
9
|
+
from ...core.auth import create_token
|
|
10
|
+
|
|
11
|
+
router = APIRouter(tags=["setup"])
|
|
12
|
+
|
|
13
|
+
US = Annotated[UserStore, Depends(get_user_store)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/status", response_model=SetupStatusResponse)
|
|
17
|
+
def setup_status(us: US):
|
|
18
|
+
return SetupStatusResponse(setup_required=not us.has_any_users())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post("/create-admin", response_model=TokenResponse, status_code=201)
|
|
22
|
+
def create_admin(body: CreateAdminRequest, us: US):
|
|
23
|
+
if us.has_any_users():
|
|
24
|
+
raise HTTPException(
|
|
25
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
26
|
+
detail="Setup already complete",
|
|
27
|
+
)
|
|
28
|
+
if not body.username or not body.password:
|
|
29
|
+
raise HTTPException(status_code=422, detail="Username and password are required")
|
|
30
|
+
if len(body.password) < 8:
|
|
31
|
+
raise HTTPException(status_code=422, detail="Password must be at least 8 characters")
|
|
32
|
+
|
|
33
|
+
user = us.create_user(
|
|
34
|
+
username=body.username,
|
|
35
|
+
password_hash=hash_password(body.password),
|
|
36
|
+
role="platform_admin",
|
|
37
|
+
)
|
|
38
|
+
token = create_token(user)
|
|
39
|
+
return TokenResponse(
|
|
40
|
+
access_token=token,
|
|
41
|
+
user=UserResponse(uid=user.uid, username=user.username, role=user.role),
|
|
42
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
2
|
+
|
|
3
|
+
from ...core.template_store import TemplateStore
|
|
4
|
+
from ...core.user_store import User
|
|
5
|
+
from ..deps import get_current_user, get_template_store
|
|
6
|
+
from ..schemas import TemplateRequest, TemplateResponse
|
|
7
|
+
|
|
8
|
+
router = APIRouter(tags=["templates"])
|
|
9
|
+
|
|
10
|
+
TS = Depends(get_template_store)
|
|
11
|
+
CurrentUser = Depends(get_current_user)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("", response_model=list[TemplateResponse])
|
|
15
|
+
def list_templates(store: TemplateStore = TS, user: User = CurrentUser):
|
|
16
|
+
return store.list_templates(user.uid)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
|
20
|
+
def create_template(body: TemplateRequest, store: TemplateStore = TS, user: User = CurrentUser):
|
|
21
|
+
return store.create_template(user.uid, body.model_dump())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.put("/{template_id}", response_model=TemplateResponse)
|
|
25
|
+
def update_template(template_id: str, body: TemplateRequest, store: TemplateStore = TS, user: User = CurrentUser):
|
|
26
|
+
updated = store.update_template(user.uid, template_id, body.model_dump())
|
|
27
|
+
if updated is None:
|
|
28
|
+
raise HTTPException(status_code=404, detail="Template not found")
|
|
29
|
+
return updated
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
33
|
+
def delete_template(template_id: str, store: TemplateStore = TS, user: User = CurrentUser):
|
|
34
|
+
if not store.delete_template(user.uid, template_id):
|
|
35
|
+
raise HTTPException(status_code=404, detail="Template not found")
|
|
36
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Annotated, Optional
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
|
5
|
+
from jose import JWTError
|
|
6
|
+
|
|
7
|
+
from ...core.session_manager import SessionManager
|
|
8
|
+
from ..connection_manager import ConnectionManager
|
|
9
|
+
from ..deps import get_session_manager, get_conn_manager
|
|
10
|
+
from ...core.auth import decode_token
|
|
11
|
+
from ...core.user_store import UserStore
|
|
12
|
+
|
|
13
|
+
router = APIRouter(tags=["websocket"])
|
|
14
|
+
|
|
15
|
+
SM = Annotated[SessionManager, Depends(get_session_manager)]
|
|
16
|
+
CM = Annotated[ConnectionManager, Depends(get_conn_manager)]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _send_snapshot(websocket: WebSocket, sm: SessionManager, uid: str, is_admin: bool, session_id: str = "*") -> None:
|
|
20
|
+
"""Send current state of all (or one) session(s) immediately on connect."""
|
|
21
|
+
configs = sm.list_sessions_for_user(uid, is_admin=is_admin)
|
|
22
|
+
if session_id != "*":
|
|
23
|
+
configs = [c for c in configs if c.session_id == session_id]
|
|
24
|
+
|
|
25
|
+
for cfg in configs:
|
|
26
|
+
state = sm.get_state(cfg.session_id)
|
|
27
|
+
log = state.message_log
|
|
28
|
+
await websocket.send_json({
|
|
29
|
+
"type": "session_status",
|
|
30
|
+
"session_id": cfg.session_id,
|
|
31
|
+
"status": state.status.name,
|
|
32
|
+
"last_sent_seq_num": state.last_sent_seq_num,
|
|
33
|
+
"last_recv_seq_num": state.last_recv_seq_num,
|
|
34
|
+
"message_count": len(log) if log else 0,
|
|
35
|
+
"status_changed_at": state.status_changed_at,
|
|
36
|
+
"last_heartbeat_at": state.last_heartbeat_at,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _authenticate_ws(websocket: WebSocket, token: Optional[str]) -> tuple[str, bool]:
|
|
41
|
+
"""Validate JWT token from query param. Returns (uid, is_admin) or closes with 403."""
|
|
42
|
+
if not token:
|
|
43
|
+
return None, False
|
|
44
|
+
try:
|
|
45
|
+
payload = decode_token(token)
|
|
46
|
+
uid = payload.get("sub", "")
|
|
47
|
+
role = payload.get("role", "")
|
|
48
|
+
if not uid:
|
|
49
|
+
return None, False
|
|
50
|
+
return uid, role == "platform_admin"
|
|
51
|
+
except JWTError:
|
|
52
|
+
return None, False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.websocket("/ws")
|
|
56
|
+
async def ws_all_sessions(
|
|
57
|
+
websocket: WebSocket,
|
|
58
|
+
sm: SM,
|
|
59
|
+
conn_mgr: CM,
|
|
60
|
+
token: Optional[str] = Query(default=None),
|
|
61
|
+
):
|
|
62
|
+
"""Subscribe to events from all sessions."""
|
|
63
|
+
uid, is_admin = _authenticate_ws(websocket, token)
|
|
64
|
+
if not uid:
|
|
65
|
+
await websocket.close(code=4003, reason="Unauthorized")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Also validate user is still active
|
|
69
|
+
store: UserStore = websocket.app.state.user_store
|
|
70
|
+
user = store.get_by_uid(uid)
|
|
71
|
+
if user is None or not user.is_active:
|
|
72
|
+
await websocket.close(code=4003, reason="Unauthorized")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
await conn_mgr.connect(websocket, session_id="*", uid=uid, is_admin=is_admin)
|
|
76
|
+
try:
|
|
77
|
+
await _send_snapshot(websocket, sm, uid, is_admin)
|
|
78
|
+
while True:
|
|
79
|
+
await asyncio.sleep(15)
|
|
80
|
+
try:
|
|
81
|
+
await websocket.send_json({"type": "ping"})
|
|
82
|
+
except Exception:
|
|
83
|
+
break
|
|
84
|
+
except WebSocketDisconnect:
|
|
85
|
+
pass
|
|
86
|
+
finally:
|
|
87
|
+
conn_mgr.disconnect(websocket, session_id="*")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.websocket("/ws/{session_id}")
|
|
91
|
+
async def ws_single_session(
|
|
92
|
+
websocket: WebSocket,
|
|
93
|
+
session_id: str,
|
|
94
|
+
sm: SM,
|
|
95
|
+
conn_mgr: CM,
|
|
96
|
+
token: Optional[str] = Query(default=None),
|
|
97
|
+
):
|
|
98
|
+
"""Subscribe to events from a single session."""
|
|
99
|
+
uid, is_admin = _authenticate_ws(websocket, token)
|
|
100
|
+
if not uid:
|
|
101
|
+
await websocket.close(code=4003, reason="Unauthorized")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
store: UserStore = websocket.app.state.user_store
|
|
105
|
+
user = store.get_by_uid(uid)
|
|
106
|
+
if user is None or not user.is_active:
|
|
107
|
+
await websocket.close(code=4003, reason="Unauthorized")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Check ownership of this specific session
|
|
111
|
+
configs = sm.list_sessions()
|
|
112
|
+
cfg = next((c for c in configs if c.session_id == session_id), None)
|
|
113
|
+
if cfg is None or (not is_admin and cfg.owner_uid != uid):
|
|
114
|
+
await websocket.close(code=4003, reason="Access denied")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
await conn_mgr.connect(websocket, session_id=session_id, uid=uid, is_admin=is_admin)
|
|
118
|
+
try:
|
|
119
|
+
await _send_snapshot(websocket, sm, uid, is_admin, session_id=session_id)
|
|
120
|
+
while True:
|
|
121
|
+
await asyncio.sleep(15)
|
|
122
|
+
try:
|
|
123
|
+
await websocket.send_json({"type": "ping"})
|
|
124
|
+
except Exception:
|
|
125
|
+
break
|
|
126
|
+
except WebSocketDisconnect:
|
|
127
|
+
pass
|
|
128
|
+
finally:
|
|
129
|
+
conn_mgr.disconnect(websocket, session_id=session_id)
|