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
fixture/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .core.session_manager import SessionManager
|
|
2
|
+
from .core.models import SessionConfig, SessionState, SessionStatus, ConnectionType
|
|
3
|
+
from .core.events import SessionEvent, EventType
|
|
4
|
+
from .core.message_log import MessageLog, LogEntry
|
|
5
|
+
from .core.fix_parser import parse_raw, pretty_print
|
|
6
|
+
from .core.fix_tags import tag_name, msg_type_name
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"SessionManager",
|
|
10
|
+
"SessionConfig",
|
|
11
|
+
"SessionState",
|
|
12
|
+
"SessionStatus",
|
|
13
|
+
"ConnectionType",
|
|
14
|
+
"SessionEvent",
|
|
15
|
+
"EventType",
|
|
16
|
+
"MessageLog",
|
|
17
|
+
"LogEntry",
|
|
18
|
+
"parse_raw",
|
|
19
|
+
"pretty_print",
|
|
20
|
+
"tag_name",
|
|
21
|
+
"msg_type_name",
|
|
22
|
+
]
|
fixture/__main__.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
python -m fixture [--host HOST] [--port PORT]
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import logging.handlers
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from .core.session_manager import SessionManager
|
|
13
|
+
from .core.config_store import ConfigStore
|
|
14
|
+
from .server import start_server
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("fixture")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _configure_logging(level_name: str, log_file: str | None = None) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Configure the root logger and (optionally) a rotating file handler.
|
|
22
|
+
|
|
23
|
+
Passing log_config=None to uvicorn.Config prevents uvicorn from
|
|
24
|
+
overriding this setup, so uvicorn's own loggers propagate here too.
|
|
25
|
+
"""
|
|
26
|
+
level = getattr(logging, level_name.upper(), logging.INFO)
|
|
27
|
+
fmt = "%(asctime)s %(levelname)-8s %(name)s — %(message)s"
|
|
28
|
+
datefmt = "%Y-%m-%d %H:%M:%S"
|
|
29
|
+
formatter = logging.Formatter(fmt, datefmt)
|
|
30
|
+
|
|
31
|
+
root = logging.getLogger()
|
|
32
|
+
root.setLevel(level)
|
|
33
|
+
|
|
34
|
+
# Stdout handler
|
|
35
|
+
sh = logging.StreamHandler()
|
|
36
|
+
sh.setFormatter(formatter)
|
|
37
|
+
root.addHandler(sh)
|
|
38
|
+
|
|
39
|
+
# Optional rotating file handler
|
|
40
|
+
if log_file:
|
|
41
|
+
fh = logging.handlers.RotatingFileHandler(
|
|
42
|
+
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
|
43
|
+
)
|
|
44
|
+
fh.setFormatter(formatter)
|
|
45
|
+
root.addHandler(fh)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _migrate_legacy(sessions_file: str, data_dir: str, db_path: str) -> None:
|
|
49
|
+
"""
|
|
50
|
+
One-time migration: read a legacy sessions.json, group by owner_uid,
|
|
51
|
+
write to data_dir/<uid>/sessions.json, then rename the legacy file.
|
|
52
|
+
Sessions with no owner_uid are assigned to the platform_admin.
|
|
53
|
+
"""
|
|
54
|
+
if not os.path.isfile(sessions_file):
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Check if data_dir already has any sessions (migration already done)
|
|
58
|
+
if os.path.isdir(data_dir) and any(
|
|
59
|
+
os.path.isfile(os.path.join(data_dir, uid, "sessions.json"))
|
|
60
|
+
for uid in os.listdir(data_dir)
|
|
61
|
+
if os.path.isdir(os.path.join(data_dir, uid))
|
|
62
|
+
):
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
logger.info("Migrating %s → %s/<uid>/sessions.json ...", sessions_file, data_dir)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with open(sessions_file) as f:
|
|
69
|
+
data = json.load(f)
|
|
70
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
71
|
+
logger.warning("Migration skipped: could not read %s: %s", sessions_file, e)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# Find the platform_admin uid to assign orphaned sessions
|
|
75
|
+
fallback_uid = _get_platform_admin_uid(db_path)
|
|
76
|
+
|
|
77
|
+
by_user: dict = {}
|
|
78
|
+
for entry in data:
|
|
79
|
+
uid = entry.get("owner_uid") or fallback_uid
|
|
80
|
+
if not uid:
|
|
81
|
+
continue
|
|
82
|
+
# Remove owner_uid from stored JSON (canonical from directory name)
|
|
83
|
+
entry.pop("owner_uid", None)
|
|
84
|
+
by_user.setdefault(uid, []).append(entry)
|
|
85
|
+
|
|
86
|
+
for uid, entries in by_user.items():
|
|
87
|
+
user_dir = os.path.join(data_dir, uid)
|
|
88
|
+
os.makedirs(user_dir, exist_ok=True)
|
|
89
|
+
path = os.path.join(user_dir, "sessions.json")
|
|
90
|
+
tmp = path + ".tmp"
|
|
91
|
+
with open(tmp, "w") as f:
|
|
92
|
+
json.dump(entries, f, indent=2)
|
|
93
|
+
os.replace(tmp, path)
|
|
94
|
+
logger.info(" Wrote %d session(s) for user %s", len(entries), uid)
|
|
95
|
+
|
|
96
|
+
migrated = sessions_file + ".migrated"
|
|
97
|
+
os.rename(sessions_file, migrated)
|
|
98
|
+
logger.info("Migration complete. Legacy file renamed to %s", migrated)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _get_platform_admin_uid(db_path: str) -> str:
|
|
102
|
+
"""Return the uid of the first platform_admin, or '' if not found."""
|
|
103
|
+
try:
|
|
104
|
+
import sqlite3
|
|
105
|
+
conn = sqlite3.connect(db_path)
|
|
106
|
+
row = conn.execute(
|
|
107
|
+
"SELECT uid FROM users WHERE role='platform_admin' ORDER BY created_at LIMIT 1"
|
|
108
|
+
).fetchone()
|
|
109
|
+
conn.close()
|
|
110
|
+
return row[0] if row else ""
|
|
111
|
+
except Exception:
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main():
|
|
116
|
+
parser = argparse.ArgumentParser(description="FIXture FIX testing tool")
|
|
117
|
+
parser.add_argument("--host", default="0.0.0.0")
|
|
118
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
119
|
+
parser.add_argument("--log-level", default="info")
|
|
120
|
+
parser.add_argument("--log-file", default=None,
|
|
121
|
+
help="Path for rotating log file (default: <data-dir>/fixture.log)")
|
|
122
|
+
parser.add_argument("--data-dir", default="./data",
|
|
123
|
+
help="Directory for per-user session data")
|
|
124
|
+
parser.add_argument("--db-path", default="./users.db",
|
|
125
|
+
help="Path to SQLite database for user accounts")
|
|
126
|
+
args = parser.parse_args()
|
|
127
|
+
|
|
128
|
+
# Ensure data dir exists before opening the log file inside it
|
|
129
|
+
os.makedirs(args.data_dir, exist_ok=True)
|
|
130
|
+
|
|
131
|
+
# Default log file lives next to the other data files
|
|
132
|
+
log_file = args.log_file or os.path.join(args.data_dir, "fixture.log")
|
|
133
|
+
|
|
134
|
+
# Configure logging before anything else so all modules inherit the setup
|
|
135
|
+
_configure_logging(args.log_level, log_file)
|
|
136
|
+
|
|
137
|
+
# Auto-migrate legacy sessions.json if present
|
|
138
|
+
_migrate_legacy("./sessions.json", args.data_dir, args.db_path)
|
|
139
|
+
|
|
140
|
+
store = ConfigStore(args.data_dir)
|
|
141
|
+
msg_db_path = os.path.join(args.data_dir, "messages.db")
|
|
142
|
+
sm = SessionManager(config_store=store, msg_db_path=msg_db_path)
|
|
143
|
+
sm.load_persisted()
|
|
144
|
+
server = start_server(sm, host=args.host, port=args.port, log_level=args.log_level,
|
|
145
|
+
db_path=args.db_path, msg_db_path=msg_db_path, log_dir="./log",
|
|
146
|
+
data_dir=args.data_dir)
|
|
147
|
+
|
|
148
|
+
logger.info("FIXture running at http://%s:%s", args.host, args.port)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
while not server.should_exit:
|
|
152
|
+
time.sleep(0.5)
|
|
153
|
+
except KeyboardInterrupt:
|
|
154
|
+
pass
|
|
155
|
+
finally:
|
|
156
|
+
server.should_exit = True
|
|
157
|
+
logger.info("Shutting down...")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
main()
|
fixture/api/__init__.py
ADDED
|
File without changes
|
fixture/api/app.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.staticfiles import StaticFiles
|
|
6
|
+
from fastapi.responses import FileResponse
|
|
7
|
+
|
|
8
|
+
from ..core.housekeeping import HousekeepingService
|
|
9
|
+
from ..core.session_manager import SessionManager
|
|
10
|
+
from ..core.scenario_store import ScenarioStore
|
|
11
|
+
from ..core.scenario_runner import ScenarioRunner
|
|
12
|
+
from ..core.template_store import TemplateStore
|
|
13
|
+
from ..core.user_store import UserStore
|
|
14
|
+
from .connection_manager import ConnectionManager
|
|
15
|
+
from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@asynccontextmanager
|
|
19
|
+
async def lifespan(app: FastAPI):
|
|
20
|
+
# Register event bridge with SessionManager
|
|
21
|
+
sm: SessionManager = app.state.session_manager
|
|
22
|
+
cm: ConnectionManager = app.state.connection_manager
|
|
23
|
+
sm.subscribe(cm.sync_event_handler)
|
|
24
|
+
# Start background housekeeping thread
|
|
25
|
+
app.state.housekeeping.start()
|
|
26
|
+
yield
|
|
27
|
+
sm.unsubscribe(cm.sync_event_handler)
|
|
28
|
+
sm.close() # flush SQLite writer
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_app(
|
|
32
|
+
session_manager: SessionManager,
|
|
33
|
+
db_path: str = "./users.db",
|
|
34
|
+
msg_db_path: str = "./data/messages.db",
|
|
35
|
+
log_dir: str = "./log",
|
|
36
|
+
data_dir: str = "./data",
|
|
37
|
+
) -> FastAPI:
|
|
38
|
+
app = FastAPI(
|
|
39
|
+
title="FIXture",
|
|
40
|
+
description="FIX Protocol Testing Tool",
|
|
41
|
+
version="0.1.0",
|
|
42
|
+
lifespan=lifespan,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Store singletons on app state (injected via Depends)
|
|
46
|
+
app.state.session_manager = session_manager
|
|
47
|
+
app.state.connection_manager = ConnectionManager()
|
|
48
|
+
user_store = UserStore(db_path)
|
|
49
|
+
app.state.user_store = user_store
|
|
50
|
+
app.state.housekeeping = HousekeepingService(msg_db_path, log_dir, user_store)
|
|
51
|
+
app.state.template_store = TemplateStore(data_dir)
|
|
52
|
+
app.state.scenario_store = ScenarioStore(data_dir)
|
|
53
|
+
conn_manager: ConnectionManager = app.state.connection_manager
|
|
54
|
+
app.state.scenario_runner = ScenarioRunner(
|
|
55
|
+
session_manager, app.state.template_store, conn_manager.broadcast_scenario_event
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# API routers
|
|
59
|
+
app.include_router(auth.router, prefix="/api/auth")
|
|
60
|
+
app.include_router(setup.router, prefix="/api/setup")
|
|
61
|
+
app.include_router(admin.router, prefix="/api/admin")
|
|
62
|
+
app.include_router(sessions.router, prefix="/api/sessions")
|
|
63
|
+
app.include_router(messages.router, prefix="/api/sessions")
|
|
64
|
+
app.include_router(fix_spec.router, prefix="/api/fix-spec")
|
|
65
|
+
app.include_router(templates.router, prefix="/api/templates")
|
|
66
|
+
app.include_router(scenarios.router, prefix="/api/scenarios")
|
|
67
|
+
app.include_router(branding.router, prefix="/api")
|
|
68
|
+
app.include_router(ws.router)
|
|
69
|
+
|
|
70
|
+
# Serve built React app from fixture/static/
|
|
71
|
+
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
|
|
72
|
+
static_dir = os.path.abspath(static_dir)
|
|
73
|
+
|
|
74
|
+
if os.path.isdir(static_dir) and os.listdir(static_dir):
|
|
75
|
+
# Serve hashed JS/CSS bundles
|
|
76
|
+
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
|
|
77
|
+
|
|
78
|
+
# Serve favicon explicitly
|
|
79
|
+
@app.get("/favicon.svg")
|
|
80
|
+
async def favicon():
|
|
81
|
+
return FileResponse(os.path.join(static_dir, "favicon.svg"))
|
|
82
|
+
|
|
83
|
+
# Catch-all: return index.html for all SPA routes (/login, /dashboard, etc.)
|
|
84
|
+
@app.get("/{path_name:path}")
|
|
85
|
+
async def serve_spa(path_name: str):
|
|
86
|
+
return FileResponse(os.path.join(static_dir, "index.html"))
|
|
87
|
+
else:
|
|
88
|
+
# Frontend not built yet — serve a placeholder
|
|
89
|
+
@app.get("/")
|
|
90
|
+
def root():
|
|
91
|
+
return {
|
|
92
|
+
"message": "FIXture API is running. Build the frontend with: cd frontend && npm run build"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return app
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bridges the SessionManager event system to async WebSocket clients.
|
|
3
|
+
|
|
4
|
+
fixcore callbacks fire on the asyncio event loop — no thread-crossing needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from fastapi import WebSocket
|
|
11
|
+
from starlette.websockets import WebSocketState
|
|
12
|
+
|
|
13
|
+
from ..core.events import SessionEvent, EventType
|
|
14
|
+
from ..core.message_log import LogEntry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _now_iso() -> str:
|
|
18
|
+
return datetime.now(tz=timezone.utc).isoformat()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _serialize_event(event: SessionEvent) -> dict:
|
|
22
|
+
p = event.payload
|
|
23
|
+
if event.event_type == EventType.SESSION_STATUS_CHANGED:
|
|
24
|
+
return {
|
|
25
|
+
"type": "session_status",
|
|
26
|
+
"session_id": event.session_id,
|
|
27
|
+
"status": p.get("status"),
|
|
28
|
+
"status_changed_at": p.get("status_changed_at"),
|
|
29
|
+
"last_heartbeat_at": p.get("last_heartbeat_at"),
|
|
30
|
+
"ts": _now_iso(),
|
|
31
|
+
}
|
|
32
|
+
elif event.event_type in (EventType.MESSAGE_RECEIVED, EventType.MESSAGE_SENT):
|
|
33
|
+
return {
|
|
34
|
+
"type": "message",
|
|
35
|
+
"session_id": event.session_id,
|
|
36
|
+
"direction": "IN" if event.event_type == EventType.MESSAGE_RECEIVED else "OUT",
|
|
37
|
+
"admin": p.get("admin", False),
|
|
38
|
+
"seq_num": p.get("seq_num", 0),
|
|
39
|
+
"msg_type": p.get("msg_type", ""),
|
|
40
|
+
"msg_type_name": p.get("msg_type_name", ""),
|
|
41
|
+
"ts": _now_iso(),
|
|
42
|
+
"fields": {str(k): v for k, v in p.get("fields", {}).items()},
|
|
43
|
+
"raw": p.get("raw", ""),
|
|
44
|
+
}
|
|
45
|
+
elif event.event_type == EventType.ENGINE_ERROR:
|
|
46
|
+
return {
|
|
47
|
+
"type": "engine_error",
|
|
48
|
+
"session_id": event.session_id,
|
|
49
|
+
"error": p.get("error", ""),
|
|
50
|
+
"ts": _now_iso(),
|
|
51
|
+
}
|
|
52
|
+
return {"type": "unknown", "session_id": event.session_id, "ts": _now_iso()}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _ConnInfo:
|
|
56
|
+
__slots__ = ("uid", "is_admin")
|
|
57
|
+
|
|
58
|
+
def __init__(self, uid: str, is_admin: bool):
|
|
59
|
+
self.uid = uid
|
|
60
|
+
self.is_admin = is_admin
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ConnectionManager:
|
|
64
|
+
"""
|
|
65
|
+
Tracks all active WebSocket connections and fans out events to them.
|
|
66
|
+
|
|
67
|
+
Connections can subscribe to a specific session_id or to all sessions ("*").
|
|
68
|
+
Each connection carries a uid and is_admin flag for broadcast filtering.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self):
|
|
72
|
+
# session_id (or "*") -> dict[WebSocket, _ConnInfo]
|
|
73
|
+
self._clients: dict[str, dict[WebSocket, _ConnInfo]] = {}
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Called from async context (WS handler)
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
async def connect(self, websocket: WebSocket, session_id: str = "*",
|
|
80
|
+
uid: str = "", is_admin: bool = False) -> None:
|
|
81
|
+
await websocket.accept()
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._clients.setdefault(session_id, {})[websocket] = _ConnInfo(uid, is_admin)
|
|
84
|
+
|
|
85
|
+
def disconnect(self, websocket: WebSocket, session_id: str = "*") -> None:
|
|
86
|
+
with self._lock:
|
|
87
|
+
clients = self._clients.get(session_id, {})
|
|
88
|
+
clients.pop(websocket, None)
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Called from the asyncio event loop (fixcore callbacks / scenario runner)
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def sync_event_handler(self, event: SessionEvent) -> None:
|
|
95
|
+
"""Registered with SessionManager.subscribe(). Called on the event loop."""
|
|
96
|
+
payload = _serialize_event(event)
|
|
97
|
+
asyncio.create_task(self._broadcast(event.session_id, payload, event.owner_uid))
|
|
98
|
+
|
|
99
|
+
def broadcast_scenario_event(self, uid: str, data: dict) -> None:
|
|
100
|
+
"""Schedule delivery to all WS connections for uid."""
|
|
101
|
+
asyncio.create_task(self._broadcast_to_user(uid, data))
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Internal async broadcast
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
async def _broadcast(self, session_id: str, payload: dict, owner_uid: str) -> None:
|
|
108
|
+
with self._lock:
|
|
109
|
+
# Specific-session subscribers
|
|
110
|
+
specific = dict(self._clients.get(session_id, {}))
|
|
111
|
+
# Wildcard subscribers
|
|
112
|
+
wildcard = dict(self._clients.get("*", {}))
|
|
113
|
+
|
|
114
|
+
# Build target set: connections that may receive this event
|
|
115
|
+
# A connection receives the event if:
|
|
116
|
+
# - it is an admin, OR
|
|
117
|
+
# - its uid matches the session owner
|
|
118
|
+
targets: dict[WebSocket, _ConnInfo] = {}
|
|
119
|
+
for ws, info in {**specific, **wildcard}.items():
|
|
120
|
+
if info.is_admin or info.uid == owner_uid:
|
|
121
|
+
targets[ws] = info
|
|
122
|
+
|
|
123
|
+
dead: list[tuple[WebSocket, str]] = []
|
|
124
|
+
for ws in targets:
|
|
125
|
+
try:
|
|
126
|
+
if ws.client_state == WebSocketState.CONNECTED:
|
|
127
|
+
await ws.send_json(payload)
|
|
128
|
+
except Exception:
|
|
129
|
+
with self._lock:
|
|
130
|
+
for key, sockets in self._clients.items():
|
|
131
|
+
if ws in sockets:
|
|
132
|
+
dead.append((ws, key))
|
|
133
|
+
|
|
134
|
+
with self._lock:
|
|
135
|
+
for ws, key in dead:
|
|
136
|
+
self._clients.get(key, {}).pop(ws, None)
|
|
137
|
+
|
|
138
|
+
async def _broadcast_to_user(self, uid: str, data: dict) -> None:
|
|
139
|
+
"""Send data to all WS connections belonging to uid."""
|
|
140
|
+
with self._lock:
|
|
141
|
+
all_clients = {}
|
|
142
|
+
for sockets in self._clients.values():
|
|
143
|
+
for ws, info in sockets.items():
|
|
144
|
+
all_clients[ws] = info
|
|
145
|
+
|
|
146
|
+
dead: list[tuple[WebSocket, str]] = []
|
|
147
|
+
for ws, info in all_clients.items():
|
|
148
|
+
if info.uid != uid and not info.is_admin:
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
if ws.client_state == WebSocketState.CONNECTED:
|
|
152
|
+
await ws.send_json(data)
|
|
153
|
+
except Exception:
|
|
154
|
+
with self._lock:
|
|
155
|
+
for key, sockets in self._clients.items():
|
|
156
|
+
if ws in sockets:
|
|
157
|
+
dead.append((ws, key))
|
|
158
|
+
|
|
159
|
+
with self._lock:
|
|
160
|
+
for ws, key in dead:
|
|
161
|
+
self._clients.get(key, {}).pop(ws, None)
|
fixture/api/deps.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException, status
|
|
2
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
3
|
+
from jose import JWTError
|
|
4
|
+
from starlette.requests import HTTPConnection
|
|
5
|
+
|
|
6
|
+
from ..core.session_manager import SessionManager
|
|
7
|
+
from ..core.user_store import User, UserStore
|
|
8
|
+
from ..core.auth import decode_token
|
|
9
|
+
from ..core.housekeeping import HousekeepingService
|
|
10
|
+
from ..core.template_store import TemplateStore
|
|
11
|
+
from ..core.scenario_store import ScenarioStore
|
|
12
|
+
from ..core.scenario_runner import ScenarioRunner
|
|
13
|
+
from .connection_manager import ConnectionManager
|
|
14
|
+
|
|
15
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_session_manager(request: HTTPConnection) -> SessionManager:
|
|
19
|
+
return request.app.state.session_manager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_conn_manager(request: HTTPConnection) -> ConnectionManager:
|
|
23
|
+
return request.app.state.connection_manager
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_user_store(request: HTTPConnection) -> UserStore:
|
|
27
|
+
return request.app.state.user_store
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_current_user(
|
|
31
|
+
request: HTTPConnection,
|
|
32
|
+
token: str = Depends(oauth2_scheme),
|
|
33
|
+
) -> User:
|
|
34
|
+
credentials_exception = HTTPException(
|
|
35
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
36
|
+
detail="Invalid or expired token",
|
|
37
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
38
|
+
)
|
|
39
|
+
try:
|
|
40
|
+
payload = decode_token(token)
|
|
41
|
+
uid: str = payload.get("sub", "")
|
|
42
|
+
if not uid:
|
|
43
|
+
raise credentials_exception
|
|
44
|
+
except JWTError:
|
|
45
|
+
raise credentials_exception
|
|
46
|
+
|
|
47
|
+
store: UserStore = request.app.state.user_store
|
|
48
|
+
user = store.get_by_uid(uid)
|
|
49
|
+
if user is None or not user.is_active:
|
|
50
|
+
raise credentials_exception
|
|
51
|
+
return user
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require_platform_admin(user: User = Depends(get_current_user)) -> User:
|
|
55
|
+
if user.role != "platform_admin":
|
|
56
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
57
|
+
return user
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_housekeeping(request: HTTPConnection) -> HousekeepingService:
|
|
61
|
+
return request.app.state.housekeeping
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_template_store(request: HTTPConnection) -> TemplateStore:
|
|
65
|
+
return request.app.state.template_store
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_scenario_store(request: HTTPConnection) -> ScenarioStore:
|
|
69
|
+
return request.app.state.scenario_store
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_scenario_runner(request: HTTPConnection) -> ScenarioRunner:
|
|
73
|
+
return request.app.state.scenario_runner
|
|
File without changes
|