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.
Files changed (54) hide show
  1. fixture/__init__.py +22 -0
  2. fixture/__main__.py +161 -0
  3. fixture/api/__init__.py +0 -0
  4. fixture/api/app.py +95 -0
  5. fixture/api/connection_manager.py +161 -0
  6. fixture/api/deps.py +73 -0
  7. fixture/api/routers/__init__.py +0 -0
  8. fixture/api/routers/admin.py +178 -0
  9. fixture/api/routers/auth.py +74 -0
  10. fixture/api/routers/branding.py +33 -0
  11. fixture/api/routers/fix_spec.py +41 -0
  12. fixture/api/routers/messages.py +137 -0
  13. fixture/api/routers/scenarios.py +65 -0
  14. fixture/api/routers/sessions.py +272 -0
  15. fixture/api/routers/setup.py +42 -0
  16. fixture/api/routers/templates.py +36 -0
  17. fixture/api/routers/ws.py +129 -0
  18. fixture/api/schemas.py +289 -0
  19. fixture/config/__init__.py +0 -0
  20. fixture/core/__init__.py +0 -0
  21. fixture/core/auth.py +68 -0
  22. fixture/core/config_store.py +85 -0
  23. fixture/core/events.py +22 -0
  24. fixture/core/fix_application.py +67 -0
  25. fixture/core/fix_parser.py +79 -0
  26. fixture/core/fix_spec_parser.py +172 -0
  27. fixture/core/fix_tags.py +297 -0
  28. fixture/core/housekeeping.py +107 -0
  29. fixture/core/message_log.py +115 -0
  30. fixture/core/message_store.py +246 -0
  31. fixture/core/models.py +87 -0
  32. fixture/core/scenario_runner.py +331 -0
  33. fixture/core/scenario_store.py +70 -0
  34. fixture/core/session.py +278 -0
  35. fixture/core/session_manager.py +173 -0
  36. fixture/core/template_store.py +70 -0
  37. fixture/core/user_store.py +186 -0
  38. fixture/core/venue_responses.py +94 -0
  39. fixture/fix_specs/FIX42.xml +2746 -0
  40. fixture/fix_specs/FIX44.xml +6593 -0
  41. fixture/server.py +37 -0
  42. fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
  43. fixture/static/assets/index-B31-1dt-.css +1 -0
  44. fixture/static/assets/index-CTsKxGdI.js +87 -0
  45. fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
  46. fixture/static/favicon.svg +12 -0
  47. fixture/static/index.html +15 -0
  48. fixture/ui/__init__.py +0 -0
  49. fixtureqa-0.1.0.dist-info/METADATA +16 -0
  50. fixtureqa-0.1.0.dist-info/RECORD +54 -0
  51. fixtureqa-0.1.0.dist-info/WHEEL +5 -0
  52. fixtureqa-0.1.0.dist-info/entry_points.txt +2 -0
  53. fixtureqa-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. fixtureqa-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,178 @@
1
+ from typing import Annotated
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException
4
+
5
+ from ...core.auth import hash_password
6
+ from ...core.housekeeping import HousekeepingService
7
+ from ...core.user_store import UserStore
8
+ from ..deps import get_housekeeping, get_user_store, require_platform_admin
9
+ from ..schemas import (
10
+ AdminSettingsRequest,
11
+ BrandingRequest,
12
+ BrandingResponse,
13
+ CreateUserRequest,
14
+ HousekeepingRunResponse,
15
+ HousekeepingSettingsRequest,
16
+ HousekeepingSettingsResponse,
17
+ RegistrationStatusResponse,
18
+ UpdateUserRequest,
19
+ UserDetailResponse,
20
+ )
21
+ from .branding import _branding_response
22
+
23
+ router = APIRouter(tags=["admin"])
24
+
25
+ US = Annotated[UserStore, Depends(get_user_store)]
26
+ HK = Annotated[HousekeepingService, Depends(get_housekeeping)]
27
+ Admin = Annotated[object, Depends(require_platform_admin)]
28
+
29
+ _VALID_ROLES = {"user", "user_admin", "platform_admin"}
30
+
31
+
32
+ def _user_detail(user) -> UserDetailResponse:
33
+ return UserDetailResponse(
34
+ uid=user.uid,
35
+ username=user.username,
36
+ role=user.role,
37
+ is_active=user.is_active,
38
+ created_at=user.created_at,
39
+ )
40
+
41
+
42
+ @router.get("/users", response_model=list[UserDetailResponse])
43
+ def list_users(us: US, _admin=Depends(require_platform_admin)):
44
+ return [_user_detail(u) for u in us.list_users()]
45
+
46
+
47
+ @router.post("/users", response_model=UserDetailResponse, status_code=201)
48
+ def create_user(body: CreateUserRequest, us: US, _admin=Depends(require_platform_admin)):
49
+ if body.role not in _VALID_ROLES:
50
+ raise HTTPException(status_code=422, detail=f"Invalid role: {body.role!r}")
51
+ if len(body.password) < 8:
52
+ raise HTTPException(status_code=422, detail="Password must be at least 8 characters")
53
+ if us.get_by_username(body.username):
54
+ raise HTTPException(status_code=409, detail="Username already taken")
55
+ user = us.create_user(body.username, hash_password(body.password), role=body.role)
56
+ return _user_detail(user)
57
+
58
+
59
+ @router.patch("/users/{uid}", response_model=UserDetailResponse)
60
+ def update_user(uid: str, body: UpdateUserRequest, us: US, admin=Depends(require_platform_admin)):
61
+ target = us.get_by_uid(uid)
62
+ if target is None:
63
+ raise HTTPException(status_code=404, detail="User not found")
64
+
65
+ fields: dict = {}
66
+ if body.is_active is not None:
67
+ # Prevent admin from deactivating their own account
68
+ if not body.is_active and uid == admin.uid:
69
+ raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
70
+ fields["is_active"] = body.is_active
71
+ if body.role is not None:
72
+ if body.role not in _VALID_ROLES:
73
+ raise HTTPException(status_code=422, detail=f"Invalid role: {body.role!r}")
74
+ fields["role"] = body.role
75
+ if body.password is not None:
76
+ if len(body.password) < 8:
77
+ raise HTTPException(status_code=422, detail="Password must be at least 8 characters")
78
+ fields["password_hash"] = hash_password(body.password)
79
+
80
+ updated = us.update_user(uid, **fields)
81
+ return _user_detail(updated)
82
+
83
+
84
+ @router.delete("/users/{uid}", status_code=204)
85
+ def delete_user(uid: str, us: US, admin=Depends(require_platform_admin)):
86
+ if uid == admin.uid:
87
+ raise HTTPException(status_code=400, detail="Cannot delete your own account")
88
+ if us.get_by_uid(uid) is None:
89
+ raise HTTPException(status_code=404, detail="User not found")
90
+ us.delete_user(uid)
91
+
92
+
93
+ @router.get("/settings", response_model=RegistrationStatusResponse)
94
+ def get_settings(us: US, _admin=Depends(require_platform_admin)):
95
+ return RegistrationStatusResponse(
96
+ enabled=us.get_setting("registration_enabled", "0") == "1",
97
+ max_users=int(us.get_setting("max_users", "50")),
98
+ current_users=us.count_users(),
99
+ )
100
+
101
+
102
+ @router.patch("/settings", response_model=RegistrationStatusResponse)
103
+ def update_settings(body: AdminSettingsRequest, us: US, _admin=Depends(require_platform_admin)):
104
+ if body.registration_enabled is not None:
105
+ us.set_setting("registration_enabled", "1" if body.registration_enabled else "0")
106
+ if body.max_users is not None:
107
+ if body.max_users < 1:
108
+ raise HTTPException(status_code=422, detail="max_users must be at least 1")
109
+ us.set_setting("max_users", str(body.max_users))
110
+ return RegistrationStatusResponse(
111
+ enabled=us.get_setting("registration_enabled", "0") == "1",
112
+ max_users=int(us.get_setting("max_users", "50")),
113
+ current_users=us.count_users(),
114
+ )
115
+
116
+
117
+ def _housekeeping_response(us: UserStore) -> HousekeepingSettingsResponse:
118
+ return HousekeepingSettingsResponse(
119
+ enabled=us.get_setting("housekeeping_enabled", "1") == "1",
120
+ msg_retention_days=int(us.get_setting("msg_retention_days", "90")),
121
+ log_retention_days=int(us.get_setting("log_retention_days", "30")),
122
+ )
123
+
124
+
125
+ @router.get("/housekeeping", response_model=HousekeepingSettingsResponse)
126
+ def get_housekeeping_settings(us: US, _admin=Depends(require_platform_admin)):
127
+ return _housekeeping_response(us)
128
+
129
+
130
+ @router.patch("/housekeeping", response_model=HousekeepingSettingsResponse)
131
+ def update_housekeeping_settings(
132
+ body: HousekeepingSettingsRequest, us: US, _admin=Depends(require_platform_admin)
133
+ ):
134
+ if body.enabled is not None:
135
+ us.set_setting("housekeeping_enabled", "1" if body.enabled else "0")
136
+ if body.msg_retention_days is not None:
137
+ us.set_setting("msg_retention_days", str(body.msg_retention_days))
138
+ if body.log_retention_days is not None:
139
+ us.set_setting("log_retention_days", str(body.log_retention_days))
140
+ return _housekeeping_response(us)
141
+
142
+
143
+ @router.post("/housekeeping/run", response_model=HousekeepingRunResponse)
144
+ def run_housekeeping(hk: HK, _admin=Depends(require_platform_admin)):
145
+ result = hk.run_now()
146
+ return HousekeepingRunResponse(**result)
147
+
148
+
149
+ _MAX_BRANDING_FIELD_BYTES = 2 * 1024 * 1024 # 2 MB
150
+
151
+
152
+ @router.get("/branding", response_model=BrandingResponse)
153
+ def get_branding_settings(us: US, _admin=Depends(require_platform_admin)):
154
+ return _branding_response(us)
155
+
156
+
157
+ @router.patch("/branding", response_model=BrandingResponse)
158
+ def update_branding_settings(body: BrandingRequest, us: US, _admin=Depends(require_platform_admin)):
159
+ for field in ("logo_dark", "logo_light", "favicon"):
160
+ val = getattr(body, field)
161
+ if val is not None and len(val.encode()) > _MAX_BRANDING_FIELD_BYTES:
162
+ raise HTTPException(status_code=422, detail=f"{field} exceeds 2 MB limit")
163
+ for key, val in (
164
+ ("branding_prefix", body.prefix.strip() if body.prefix is not None else None),
165
+ ("branding_accent", body.accent.strip() if body.accent is not None else None),
166
+ ("branding_bg", body.bg.strip() if body.bg is not None else None),
167
+ ("branding_bg2", body.bg2.strip() if body.bg2 is not None else None),
168
+ ("branding_bg3", body.bg3.strip() if body.bg3 is not None else None),
169
+ ("branding_border", body.border.strip() if body.border is not None else None),
170
+ ("branding_text", body.text.strip() if body.text is not None else None),
171
+ ("branding_text2", body.text2.strip() if body.text2 is not None else None),
172
+ ("branding_logo_dark", body.logo_dark),
173
+ ("branding_logo_light", body.logo_light),
174
+ ("branding_favicon", body.favicon),
175
+ ):
176
+ if val is not None:
177
+ us.set_setting(key, val)
178
+ return _branding_response(us)
@@ -0,0 +1,74 @@
1
+ from typing import Annotated
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, status
4
+
5
+ from ...core.auth import create_token, hash_password, verify_password
6
+ from ...core.user_store import User, UserStore
7
+ from ..deps import get_current_user, get_user_store
8
+ from ..schemas import (
9
+ LoginRequest,
10
+ RegisterRequest,
11
+ RegistrationStatusResponse,
12
+ TokenResponse,
13
+ UserResponse,
14
+ )
15
+
16
+ router = APIRouter(tags=["auth"])
17
+
18
+ US = Annotated[UserStore, Depends(get_user_store)]
19
+
20
+
21
+ @router.post("/login", response_model=TokenResponse)
22
+ def login(body: LoginRequest, us: US):
23
+ user = us.get_by_username(body.username)
24
+ if user is None or not user.is_active or not verify_password(body.password, user.password_hash):
25
+ raise HTTPException(
26
+ status_code=status.HTTP_401_UNAUTHORIZED,
27
+ detail="Invalid username or password",
28
+ )
29
+ token = create_token(user)
30
+ return TokenResponse(
31
+ access_token=token,
32
+ user=UserResponse(uid=user.uid, username=user.username, role=user.role),
33
+ )
34
+
35
+
36
+ @router.get("/me", response_model=UserResponse)
37
+ def me(user: User = Depends(get_current_user)):
38
+ return UserResponse(uid=user.uid, username=user.username, role=user.role)
39
+
40
+
41
+ @router.get("/register/status", response_model=RegistrationStatusResponse)
42
+ def register_status(us: US):
43
+ return RegistrationStatusResponse(
44
+ enabled=us.get_setting("registration_enabled", "0") == "1",
45
+ max_users=int(us.get_setting("max_users", "50")),
46
+ current_users=us.count_users(),
47
+ )
48
+
49
+
50
+ @router.post("/register", response_model=TokenResponse, status_code=201)
51
+ def register(body: RegisterRequest, us: US):
52
+ if us.get_setting("registration_enabled", "0") != "1":
53
+ raise HTTPException(
54
+ status_code=status.HTTP_403_FORBIDDEN,
55
+ detail="Registration is currently closed",
56
+ )
57
+ max_users = int(us.get_setting("max_users", "50"))
58
+ if us.count_users() >= max_users:
59
+ raise HTTPException(
60
+ status_code=status.HTTP_403_FORBIDDEN,
61
+ detail="Registration limit reached",
62
+ )
63
+ if not body.username or not body.password:
64
+ raise HTTPException(status_code=422, detail="Username and password are required")
65
+ if len(body.password) < 8:
66
+ raise HTTPException(status_code=422, detail="Password must be at least 8 characters")
67
+ if us.get_by_username(body.username):
68
+ raise HTTPException(status_code=409, detail="Username already taken")
69
+ user = us.create_user(body.username, hash_password(body.password), role="user")
70
+ token = create_token(user)
71
+ return TokenResponse(
72
+ access_token=token,
73
+ user=UserResponse(uid=user.uid, username=user.username, role=user.role),
74
+ )
@@ -0,0 +1,33 @@
1
+ from typing import Annotated
2
+
3
+ from fastapi import APIRouter, Depends
4
+
5
+ from ...core.user_store import UserStore
6
+ from ..deps import get_user_store
7
+ from ..schemas import BrandingResponse
8
+
9
+ router = APIRouter(tags=["branding"])
10
+
11
+ US = Annotated[UserStore, Depends(get_user_store)]
12
+
13
+
14
+ def _branding_response(us: UserStore) -> BrandingResponse:
15
+ return BrandingResponse(
16
+ prefix=us.get_setting("branding_prefix", ""),
17
+ accent=us.get_setting("branding_accent", ""),
18
+ bg=us.get_setting("branding_bg", ""),
19
+ bg2=us.get_setting("branding_bg2", ""),
20
+ bg3=us.get_setting("branding_bg3", ""),
21
+ border=us.get_setting("branding_border", ""),
22
+ text=us.get_setting("branding_text", ""),
23
+ text2=us.get_setting("branding_text2", ""),
24
+ logo_dark=us.get_setting("branding_logo_dark", ""),
25
+ logo_light=us.get_setting("branding_logo_light", ""),
26
+ favicon=us.get_setting("branding_favicon", ""),
27
+ )
28
+
29
+
30
+ @router.get("/branding", response_model=BrandingResponse)
31
+ def get_branding(us: US):
32
+ """Public endpoint — no auth required (needed for login page styling)."""
33
+ return _branding_response(us)
@@ -0,0 +1,41 @@
1
+ from fastapi import APIRouter, HTTPException, Query
2
+
3
+ from ...core.fix_spec_parser import get_messages, get_message
4
+ from ..schemas import MessageSummaryResponse, MessageDefResponse, FieldRefResponse
5
+
6
+ router = APIRouter(tags=["fix-spec"])
7
+
8
+ SUPPORTED = {"FIX.4.2", "FIX.4.4"}
9
+
10
+
11
+ def _validate_begin_string(begin_string: str) -> None:
12
+ if begin_string not in SUPPORTED:
13
+ raise HTTPException(status_code=400, detail=f"Unsupported begin_string '{begin_string}'. Use: {sorted(SUPPORTED)}")
14
+
15
+
16
+ @router.get("/messages", response_model=list[MessageSummaryResponse])
17
+ def list_messages(begin_string: str = Query("FIX.4.2")):
18
+ _validate_begin_string(begin_string)
19
+ return [
20
+ MessageSummaryResponse(msg_type=m.msg_type, name=m.name, category=m.category)
21
+ for m in get_messages(begin_string)
22
+ ]
23
+
24
+
25
+ @router.get("/messages/{msg_type}", response_model=MessageDefResponse)
26
+ def get_message_def(msg_type: str, begin_string: str = Query("FIX.4.2")):
27
+ _validate_begin_string(begin_string)
28
+ msg = get_message(begin_string, msg_type)
29
+ if msg is None:
30
+ raise HTTPException(status_code=404, detail=f"Message type '{msg_type}' not found in {begin_string}")
31
+ return MessageDefResponse(
32
+ msg_type=msg.msg_type,
33
+ name=msg.name,
34
+ category=msg.category,
35
+ fields=[
36
+ FieldRefResponse(
37
+ tag=f.tag, name=f.name, type=f.type, required=f.required, values=f.values
38
+ )
39
+ for f in msg.fields
40
+ ],
41
+ )
@@ -0,0 +1,137 @@
1
+ import csv
2
+ import io
3
+ from typing import Annotated, Optional
4
+ from fastapi import APIRouter, Depends, HTTPException, Query
5
+ from fastapi.responses import StreamingResponse
6
+
7
+ from ...core.session_manager import SessionManager
8
+ from ...core.user_store import User
9
+ from ..schemas import MessageEntryResponse
10
+ from ..deps import get_session_manager, get_current_user
11
+
12
+ router = APIRouter(tags=["messages"])
13
+
14
+ SM = Annotated[SessionManager, Depends(get_session_manager)]
15
+ CurrentUser = Annotated[User, Depends(get_current_user)]
16
+
17
+
18
+ def _check_ownership(sm: SessionManager, session_id: str, user: User) -> None:
19
+ configs = sm.list_sessions()
20
+ cfg = next((c for c in configs if c.session_id == session_id), None)
21
+ if cfg is None:
22
+ raise HTTPException(status_code=404, detail="Session not found")
23
+ if not user.is_admin and cfg.owner_uid != user.uid:
24
+ raise HTTPException(status_code=403, detail="Access denied")
25
+
26
+
27
+ @router.get("/{session_id}/messages", response_model=list[MessageEntryResponse])
28
+ def get_messages(
29
+ session_id: str,
30
+ sm: SM,
31
+ user: CurrentUser,
32
+ direction: Optional[str] = Query(default=None, description="IN or OUT"),
33
+ admin: Optional[bool] = Query(default=None),
34
+ msg_type: Optional[str] = Query(default=None),
35
+ limit: int = Query(default=200, ge=1, le=5000),
36
+ before_id: Optional[int] = Query(default=None, description="Cursor: return rows with id < before_id"),
37
+ ):
38
+ _check_ownership(sm, session_id, user)
39
+ try:
40
+ state = sm.get_state(session_id)
41
+ except KeyError:
42
+ raise HTTPException(status_code=404, detail="Session not found")
43
+
44
+ log = state.message_log
45
+ if log is None:
46
+ return []
47
+
48
+ entries = log.get_entries(
49
+ direction=direction,
50
+ admin=admin,
51
+ msg_type=msg_type,
52
+ limit=limit,
53
+ before_id=before_id,
54
+ )
55
+
56
+ return [
57
+ MessageEntryResponse(
58
+ id=e.id,
59
+ direction=e.direction,
60
+ admin=e.admin,
61
+ seq_num=e.seq_num,
62
+ msg_type=e.msg_type,
63
+ msg_type_name=e.msg_type_name,
64
+ ts=e.timestamp.isoformat(),
65
+ fields={str(k): v for k, v in e.fields.items()},
66
+ raw=e.raw,
67
+ )
68
+ for e in entries
69
+ ]
70
+
71
+
72
+ @router.get("/{session_id}/messages/export")
73
+ def export_messages(
74
+ session_id: str,
75
+ sm: SM,
76
+ user: CurrentUser,
77
+ fmt: str = Query(default="csv", description="Export format: csv or fix"),
78
+ ):
79
+ _check_ownership(sm, session_id, user)
80
+ try:
81
+ state = sm.get_state(session_id)
82
+ except KeyError:
83
+ raise HTTPException(status_code=404, detail="Session not found")
84
+
85
+ log = state.message_log
86
+ entries = log.get_entries() if log is not None else []
87
+
88
+ safe_id = session_id.replace("/", "_")
89
+
90
+ if fmt == "fix":
91
+ def generate_fix():
92
+ for e in entries:
93
+ yield e.raw.replace("\x01", "|") + "\n"
94
+
95
+ return StreamingResponse(
96
+ generate_fix(),
97
+ media_type="text/plain",
98
+ headers={"Content-Disposition": f'attachment; filename="{safe_id}.fix"'},
99
+ )
100
+
101
+ # Default: CSV
102
+ def generate_csv():
103
+ buf = io.StringIO()
104
+ writer = csv.writer(buf)
105
+ writer.writerow(["id", "ts", "direction", "admin", "seq_num", "msg_type", "msg_type_name", "raw"])
106
+ yield buf.getvalue()
107
+ for e in entries:
108
+ buf = io.StringIO()
109
+ writer = csv.writer(buf)
110
+ writer.writerow([
111
+ e.id,
112
+ e.timestamp.isoformat(),
113
+ e.direction,
114
+ "Y" if e.admin else "N",
115
+ e.seq_num,
116
+ e.msg_type,
117
+ e.msg_type_name,
118
+ e.raw.replace("\x01", "|"),
119
+ ])
120
+ yield buf.getvalue()
121
+
122
+ return StreamingResponse(
123
+ generate_csv(),
124
+ media_type="text/csv",
125
+ headers={"Content-Disposition": f'attachment; filename="{safe_id}.csv"'},
126
+ )
127
+
128
+
129
+ @router.delete("/{session_id}/messages", status_code=204)
130
+ def clear_messages(session_id: str, sm: SM, user: CurrentUser):
131
+ _check_ownership(sm, session_id, user)
132
+ try:
133
+ state = sm.get_state(session_id)
134
+ except KeyError:
135
+ raise HTTPException(status_code=404, detail="Session not found")
136
+ if state.message_log:
137
+ state.message_log.clear()
@@ -0,0 +1,65 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, Response, status
2
+ from typing import Annotated
3
+
4
+ from ...core.scenario_store import ScenarioStore
5
+ from ...core.scenario_runner import ScenarioRunner
6
+ from ...core.user_store import User
7
+ from ..deps import get_current_user, get_scenario_store, get_scenario_runner
8
+ from ..schemas import ScenarioRequest, ScenarioResponse
9
+
10
+ router = APIRouter(tags=["scenarios"])
11
+
12
+ SS = Depends(get_scenario_store)
13
+ SR = Depends(get_scenario_runner)
14
+ CurrentUser = Depends(get_current_user)
15
+
16
+
17
+ @router.get("", response_model=list[ScenarioResponse])
18
+ def list_scenarios(store: ScenarioStore = SS, user: User = CurrentUser):
19
+ return store.list_scenarios(user.uid)
20
+
21
+
22
+ @router.post("", response_model=ScenarioResponse, status_code=status.HTTP_201_CREATED)
23
+ def create_scenario(body: ScenarioRequest, store: ScenarioStore = SS, user: User = CurrentUser):
24
+ data = body.model_dump()
25
+ return store.create_scenario(user.uid, data)
26
+
27
+
28
+ @router.put("/{scenario_id}", response_model=ScenarioResponse)
29
+ def update_scenario(
30
+ scenario_id: str,
31
+ body: ScenarioRequest,
32
+ store: ScenarioStore = SS,
33
+ user: User = CurrentUser,
34
+ ):
35
+ updated = store.update_scenario(user.uid, scenario_id, body.model_dump())
36
+ if updated is None:
37
+ raise HTTPException(status_code=404, detail="Scenario not found")
38
+ return updated
39
+
40
+
41
+ @router.delete("/{scenario_id}", status_code=status.HTTP_204_NO_CONTENT)
42
+ def delete_scenario(scenario_id: str, store: ScenarioStore = SS, user: User = CurrentUser):
43
+ if not store.delete_scenario(user.uid, scenario_id):
44
+ raise HTTPException(status_code=404, detail="Scenario not found")
45
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
46
+
47
+
48
+ @router.post("/{scenario_id}/run")
49
+ def run_scenario(
50
+ scenario_id: str,
51
+ store: ScenarioStore = SS,
52
+ runner: ScenarioRunner = SR,
53
+ user: User = CurrentUser,
54
+ ):
55
+ scenario = store.get_scenario(user.uid, scenario_id)
56
+ if scenario is None:
57
+ raise HTTPException(status_code=404, detail="Scenario not found")
58
+ run_id = runner.run(scenario, user.uid)
59
+ return {"run_id": run_id}
60
+
61
+
62
+ @router.delete("/runs/{run_id}", status_code=status.HTTP_204_NO_CONTENT)
63
+ def abort_run(run_id: str, runner: ScenarioRunner = SR, user: User = CurrentUser):
64
+ runner.abort(run_id)
65
+ return Response(status_code=status.HTTP_204_NO_CONTENT)