audex 1.0.7a3__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.
- audex/__init__.py +9 -0
- audex/__main__.py +7 -0
- audex/cli/__init__.py +189 -0
- audex/cli/apis/__init__.py +12 -0
- audex/cli/apis/init/__init__.py +34 -0
- audex/cli/apis/init/gencfg.py +130 -0
- audex/cli/apis/init/setup.py +330 -0
- audex/cli/apis/init/vprgroup.py +125 -0
- audex/cli/apis/serve.py +141 -0
- audex/cli/args.py +356 -0
- audex/cli/exceptions.py +44 -0
- audex/cli/helper/__init__.py +0 -0
- audex/cli/helper/ansi.py +193 -0
- audex/cli/helper/display.py +288 -0
- audex/config/__init__.py +64 -0
- audex/config/core/__init__.py +30 -0
- audex/config/core/app.py +29 -0
- audex/config/core/audio.py +45 -0
- audex/config/core/logging.py +163 -0
- audex/config/core/session.py +11 -0
- audex/config/helper/__init__.py +1 -0
- audex/config/helper/client/__init__.py +1 -0
- audex/config/helper/client/http.py +28 -0
- audex/config/helper/client/websocket.py +21 -0
- audex/config/helper/provider/__init__.py +1 -0
- audex/config/helper/provider/dashscope.py +13 -0
- audex/config/helper/provider/unisound.py +18 -0
- audex/config/helper/provider/xfyun.py +23 -0
- audex/config/infrastructure/__init__.py +31 -0
- audex/config/infrastructure/cache.py +51 -0
- audex/config/infrastructure/database.py +48 -0
- audex/config/infrastructure/recorder.py +32 -0
- audex/config/infrastructure/store.py +19 -0
- audex/config/provider/__init__.py +18 -0
- audex/config/provider/transcription.py +109 -0
- audex/config/provider/vpr.py +99 -0
- audex/container.py +40 -0
- audex/entity/__init__.py +468 -0
- audex/entity/doctor.py +109 -0
- audex/entity/doctor.pyi +51 -0
- audex/entity/fields.py +401 -0
- audex/entity/segment.py +115 -0
- audex/entity/segment.pyi +38 -0
- audex/entity/session.py +133 -0
- audex/entity/session.pyi +47 -0
- audex/entity/utterance.py +142 -0
- audex/entity/utterance.pyi +48 -0
- audex/entity/vp.py +68 -0
- audex/entity/vp.pyi +35 -0
- audex/exceptions.py +157 -0
- audex/filters/__init__.py +692 -0
- audex/filters/generated/__init__.py +21 -0
- audex/filters/generated/doctor.py +987 -0
- audex/filters/generated/segment.py +723 -0
- audex/filters/generated/session.py +978 -0
- audex/filters/generated/utterance.py +939 -0
- audex/filters/generated/vp.py +815 -0
- audex/helper/__init__.py +1 -0
- audex/helper/hash.py +33 -0
- audex/helper/mixin.py +65 -0
- audex/helper/net.py +19 -0
- audex/helper/settings/__init__.py +830 -0
- audex/helper/settings/fields.py +317 -0
- audex/helper/stream.py +153 -0
- audex/injectors/__init__.py +1 -0
- audex/injectors/config.py +12 -0
- audex/injectors/lifespan.py +7 -0
- audex/lib/__init__.py +1 -0
- audex/lib/cache/__init__.py +383 -0
- audex/lib/cache/inmemory.py +513 -0
- audex/lib/database/__init__.py +83 -0
- audex/lib/database/sqlite.py +406 -0
- audex/lib/exporter.py +189 -0
- audex/lib/injectors/__init__.py +1 -0
- audex/lib/injectors/cache.py +25 -0
- audex/lib/injectors/container.py +47 -0
- audex/lib/injectors/exporter.py +26 -0
- audex/lib/injectors/recorder.py +33 -0
- audex/lib/injectors/server.py +17 -0
- audex/lib/injectors/session.py +18 -0
- audex/lib/injectors/sqlite.py +24 -0
- audex/lib/injectors/store.py +13 -0
- audex/lib/injectors/transcription.py +42 -0
- audex/lib/injectors/usb.py +12 -0
- audex/lib/injectors/vpr.py +65 -0
- audex/lib/injectors/wifi.py +7 -0
- audex/lib/recorder.py +844 -0
- audex/lib/repos/__init__.py +149 -0
- audex/lib/repos/container.py +23 -0
- audex/lib/repos/database/__init__.py +1 -0
- audex/lib/repos/database/sqlite.py +672 -0
- audex/lib/repos/decorators.py +74 -0
- audex/lib/repos/doctor.py +286 -0
- audex/lib/repos/segment.py +302 -0
- audex/lib/repos/session.py +285 -0
- audex/lib/repos/tables/__init__.py +70 -0
- audex/lib/repos/tables/doctor.py +137 -0
- audex/lib/repos/tables/segment.py +113 -0
- audex/lib/repos/tables/session.py +140 -0
- audex/lib/repos/tables/utterance.py +131 -0
- audex/lib/repos/tables/vp.py +102 -0
- audex/lib/repos/utterance.py +288 -0
- audex/lib/repos/vp.py +286 -0
- audex/lib/restful.py +251 -0
- audex/lib/server/__init__.py +97 -0
- audex/lib/server/auth.py +98 -0
- audex/lib/server/handlers.py +248 -0
- audex/lib/server/templates/index.html.j2 +226 -0
- audex/lib/server/templates/login.html.j2 +111 -0
- audex/lib/server/templates/static/script.js +68 -0
- audex/lib/server/templates/static/style.css +579 -0
- audex/lib/server/types.py +123 -0
- audex/lib/session.py +503 -0
- audex/lib/store/__init__.py +238 -0
- audex/lib/store/localfile.py +411 -0
- audex/lib/transcription/__init__.py +33 -0
- audex/lib/transcription/dashscope.py +525 -0
- audex/lib/transcription/events.py +62 -0
- audex/lib/usb.py +554 -0
- audex/lib/vpr/__init__.py +38 -0
- audex/lib/vpr/unisound/__init__.py +185 -0
- audex/lib/vpr/unisound/types.py +469 -0
- audex/lib/vpr/xfyun/__init__.py +483 -0
- audex/lib/vpr/xfyun/types.py +679 -0
- audex/lib/websocket/__init__.py +8 -0
- audex/lib/websocket/connection.py +485 -0
- audex/lib/websocket/pool.py +991 -0
- audex/lib/wifi.py +1146 -0
- audex/lifespan.py +75 -0
- audex/service/__init__.py +27 -0
- audex/service/decorators.py +73 -0
- audex/service/doctor/__init__.py +652 -0
- audex/service/doctor/const.py +36 -0
- audex/service/doctor/exceptions.py +96 -0
- audex/service/doctor/types.py +54 -0
- audex/service/export/__init__.py +236 -0
- audex/service/export/const.py +17 -0
- audex/service/export/exceptions.py +34 -0
- audex/service/export/types.py +21 -0
- audex/service/injectors/__init__.py +1 -0
- audex/service/injectors/container.py +53 -0
- audex/service/injectors/doctor.py +34 -0
- audex/service/injectors/export.py +27 -0
- audex/service/injectors/session.py +49 -0
- audex/service/session/__init__.py +754 -0
- audex/service/session/const.py +34 -0
- audex/service/session/exceptions.py +67 -0
- audex/service/session/types.py +91 -0
- audex/types.py +39 -0
- audex/utils.py +287 -0
- audex/valueobj/__init__.py +81 -0
- audex/valueobj/common/__init__.py +1 -0
- audex/valueobj/common/auth.py +84 -0
- audex/valueobj/common/email.py +16 -0
- audex/valueobj/common/ops.py +22 -0
- audex/valueobj/common/phone.py +84 -0
- audex/valueobj/common/version.py +72 -0
- audex/valueobj/session.py +19 -0
- audex/valueobj/utterance.py +15 -0
- audex/view/__init__.py +51 -0
- audex/view/container.py +17 -0
- audex/view/decorators.py +303 -0
- audex/view/pages/__init__.py +1 -0
- audex/view/pages/dashboard/__init__.py +286 -0
- audex/view/pages/dashboard/wifi.py +407 -0
- audex/view/pages/login.py +110 -0
- audex/view/pages/recording.py +348 -0
- audex/view/pages/register.py +202 -0
- audex/view/pages/sessions/__init__.py +196 -0
- audex/view/pages/sessions/details.py +224 -0
- audex/view/pages/sessions/export.py +443 -0
- audex/view/pages/settings.py +374 -0
- audex/view/pages/voiceprint/__init__.py +1 -0
- audex/view/pages/voiceprint/enroll.py +195 -0
- audex/view/pages/voiceprint/update.py +195 -0
- audex/view/static/css/dashboard.css +452 -0
- audex/view/static/css/glass.css +22 -0
- audex/view/static/css/global.css +541 -0
- audex/view/static/css/login.css +386 -0
- audex/view/static/css/recording.css +439 -0
- audex/view/static/css/register.css +293 -0
- audex/view/static/css/sessions/styles.css +501 -0
- audex/view/static/css/settings.css +186 -0
- audex/view/static/css/voiceprint/enroll.css +43 -0
- audex/view/static/css/voiceprint/styles.css +209 -0
- audex/view/static/css/voiceprint/update.css +44 -0
- audex/view/static/images/logo.svg +95 -0
- audex/view/static/js/recording.js +42 -0
- audex-1.0.7a3.dist-info/METADATA +361 -0
- audex-1.0.7a3.dist-info/RECORD +192 -0
- audex-1.0.7a3.dist-info/WHEEL +4 -0
- audex-1.0.7a3.dist-info/entry_points.txt +3 -0
audex/lib/server/auth.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import typing as t
|
|
6
|
+
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.responses import RedirectResponse
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
from audex.helper.mixin import LoggingMixin
|
|
13
|
+
from audex.lib.repos.doctor import DoctorRepository
|
|
14
|
+
from audex.lib.server.types import DoctorSessionData
|
|
15
|
+
|
|
16
|
+
if t.TYPE_CHECKING:
|
|
17
|
+
from starlette.types import ASGIApp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthMiddleware(BaseHTTPMiddleware, LoggingMixin):
|
|
21
|
+
"""Authentication middleware using cookie-based sessions."""
|
|
22
|
+
|
|
23
|
+
__logtag__ = "audex.lib.http.auth"
|
|
24
|
+
|
|
25
|
+
COOKIE_NAME: t.ClassVar[str] = "audex_session"
|
|
26
|
+
COOKIE_MAX_AGE: t.ClassVar[int] = 86400 * 7 # 7 days
|
|
27
|
+
|
|
28
|
+
# Public routes that don't require auth
|
|
29
|
+
PUBLIC_ROUTES: t.ClassVar[set[str]] = {"/login", "/api/login", "/static"}
|
|
30
|
+
|
|
31
|
+
def __init__(self, app: ASGIApp, doctor_repo: DoctorRepository):
|
|
32
|
+
super().__init__(app)
|
|
33
|
+
self.doctor_repo = doctor_repo
|
|
34
|
+
|
|
35
|
+
async def dispatch(
|
|
36
|
+
self, request: Request, call_next: t.Callable[[Request], t.Awaitable[Response]]
|
|
37
|
+
) -> Response:
|
|
38
|
+
"""Process request with authentication check."""
|
|
39
|
+
# Check if route is public
|
|
40
|
+
if self._is_public_route(request.url.path):
|
|
41
|
+
return await call_next(request)
|
|
42
|
+
|
|
43
|
+
# Get session from cookie
|
|
44
|
+
session_data = self._get_session_from_cookie(request)
|
|
45
|
+
|
|
46
|
+
if not session_data:
|
|
47
|
+
# Not authenticated, redirect to login
|
|
48
|
+
if request.url.path.startswith("/api/"):
|
|
49
|
+
return Response(
|
|
50
|
+
content=json.dumps({"error": "Unauthorized"}),
|
|
51
|
+
status_code=401,
|
|
52
|
+
media_type="application/json",
|
|
53
|
+
)
|
|
54
|
+
return RedirectResponse(url="/login", status_code=303)
|
|
55
|
+
|
|
56
|
+
# Verify doctor still exists and is active
|
|
57
|
+
doctor = await self.doctor_repo.read(session_data["doctor_id"])
|
|
58
|
+
if not doctor or not doctor.is_active:
|
|
59
|
+
# Session invalid, clear cookie
|
|
60
|
+
response = RedirectResponse(url="/login", status_code=303)
|
|
61
|
+
response.delete_cookie(self.COOKIE_NAME)
|
|
62
|
+
return response
|
|
63
|
+
|
|
64
|
+
# Attach session data to request state
|
|
65
|
+
request.state.doctor_session = session_data
|
|
66
|
+
|
|
67
|
+
return await call_next(request)
|
|
68
|
+
|
|
69
|
+
def _is_public_route(self, path: str) -> bool:
|
|
70
|
+
"""Check if route is public."""
|
|
71
|
+
return any(path.startswith(public) for public in self.PUBLIC_ROUTES)
|
|
72
|
+
|
|
73
|
+
def _get_session_from_cookie(self, request: Request) -> DoctorSessionData | None:
|
|
74
|
+
"""Extract and decode session from cookie."""
|
|
75
|
+
cookie_value = request.cookies.get(self.COOKIE_NAME)
|
|
76
|
+
if not cookie_value:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
# Decode base64
|
|
81
|
+
decoded = base64.b64decode(cookie_value).decode("utf-8")
|
|
82
|
+
session_dict = json.loads(decoded)
|
|
83
|
+
|
|
84
|
+
# Validate required fields
|
|
85
|
+
if not all(k in session_dict for k in ["doctor_id", "eid", "doctor_name"]):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
return t.cast(DoctorSessionData, session_dict)
|
|
89
|
+
|
|
90
|
+
except Exception as e:
|
|
91
|
+
self.logger.warning(f"Failed to decode session cookie: {e}")
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def create_session_cookie(session_data: DoctorSessionData) -> str:
|
|
96
|
+
"""Create encoded session cookie value."""
|
|
97
|
+
json_str = json.dumps(session_data)
|
|
98
|
+
return base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import typing as t
|
|
5
|
+
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
from starlette.responses import Response
|
|
8
|
+
from starlette.templating import Jinja2Templates
|
|
9
|
+
|
|
10
|
+
from audex.filters.generated import doctor_filter
|
|
11
|
+
from audex.filters.generated import session_filter
|
|
12
|
+
from audex.helper.mixin import LoggingMixin
|
|
13
|
+
from audex.lib.server.auth import AuthMiddleware
|
|
14
|
+
from audex.lib.server.types import DoctorSessionData
|
|
15
|
+
from audex.lib.server.types import ErrorResponse
|
|
16
|
+
from audex.lib.server.types import ExportMultipleRequest
|
|
17
|
+
from audex.lib.server.types import LoginRequest
|
|
18
|
+
from audex.lib.server.types import LoginResponse
|
|
19
|
+
from audex.lib.server.types import SessionListResponse
|
|
20
|
+
from audex.valueobj.common.auth import Password
|
|
21
|
+
|
|
22
|
+
if t.TYPE_CHECKING:
|
|
23
|
+
from audex.lib.exporter import Exporter
|
|
24
|
+
from audex.lib.repos.doctor import DoctorRepository
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RequestHandlers(LoggingMixin):
|
|
28
|
+
"""HTTP request handlers."""
|
|
29
|
+
|
|
30
|
+
__logtag__ = "audex.lib.http.handlers"
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
templates: Jinja2Templates,
|
|
35
|
+
doctor_repo: DoctorRepository,
|
|
36
|
+
exporter: Exporter,
|
|
37
|
+
):
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.templates = templates
|
|
40
|
+
self.doctor_repo = doctor_repo
|
|
41
|
+
self.exporter = exporter
|
|
42
|
+
|
|
43
|
+
async def login_page(self, request: Request) -> Response:
|
|
44
|
+
"""Render login page."""
|
|
45
|
+
return self.templates.TemplateResponse("login.html.j2", {"request": request})
|
|
46
|
+
|
|
47
|
+
async def index_page(self, request: Request) -> Response:
|
|
48
|
+
"""Render main export page."""
|
|
49
|
+
session_data: DoctorSessionData = request.state.doctor_session
|
|
50
|
+
return self.templates.TemplateResponse(
|
|
51
|
+
"index.html.j2",
|
|
52
|
+
{
|
|
53
|
+
"request": request,
|
|
54
|
+
"doctor_name": session_data["doctor_name"],
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def api_login(self, request: Request) -> Response:
|
|
59
|
+
"""Handle login request."""
|
|
60
|
+
try:
|
|
61
|
+
body = await request.json()
|
|
62
|
+
login_req = t.cast(LoginRequest, body)
|
|
63
|
+
|
|
64
|
+
eid = login_req.get("eid")
|
|
65
|
+
password = login_req.get("password")
|
|
66
|
+
|
|
67
|
+
if not eid or not password:
|
|
68
|
+
return self._error_response("Missing eid or password", 400)
|
|
69
|
+
|
|
70
|
+
# Find doctor
|
|
71
|
+
f = doctor_filter().eid.eq(eid)
|
|
72
|
+
doctor = await self.doctor_repo.first(f.build())
|
|
73
|
+
|
|
74
|
+
if not doctor:
|
|
75
|
+
return self._error_response("Invalid credentials", 401)
|
|
76
|
+
|
|
77
|
+
if not doctor.is_active:
|
|
78
|
+
return self._error_response("Account inactive", 401)
|
|
79
|
+
|
|
80
|
+
# Verify password
|
|
81
|
+
if not doctor.verify_password(Password.parse(password)):
|
|
82
|
+
return self._error_response("Invalid credentials", 401)
|
|
83
|
+
|
|
84
|
+
# Create session
|
|
85
|
+
session_data = DoctorSessionData(
|
|
86
|
+
doctor_id=doctor.id,
|
|
87
|
+
eid=doctor.eid,
|
|
88
|
+
doctor_name=doctor.name,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
login_response = LoginResponse(
|
|
92
|
+
success=True,
|
|
93
|
+
doctor_id=doctor.id,
|
|
94
|
+
doctor_name=doctor.name,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
response = Response(
|
|
98
|
+
content=json.dumps(login_response, ensure_ascii=False),
|
|
99
|
+
media_type="application/json",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Set cookie
|
|
103
|
+
response.set_cookie(
|
|
104
|
+
key=AuthMiddleware.COOKIE_NAME,
|
|
105
|
+
value=AuthMiddleware.create_session_cookie(session_data),
|
|
106
|
+
max_age=AuthMiddleware.COOKIE_MAX_AGE,
|
|
107
|
+
httponly=True,
|
|
108
|
+
samesite="lax",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self.logger.info(f"Doctor {eid} logged in")
|
|
112
|
+
return response
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
self.logger.error(f"Login failed: {e}")
|
|
116
|
+
return self._error_response(str(e), 500)
|
|
117
|
+
|
|
118
|
+
async def api_logout(self, _: Request) -> Response:
|
|
119
|
+
"""Handle logout request."""
|
|
120
|
+
response = Response(
|
|
121
|
+
content=json.dumps({"success": True}),
|
|
122
|
+
media_type="application/json",
|
|
123
|
+
)
|
|
124
|
+
response.delete_cookie(AuthMiddleware.COOKIE_NAME)
|
|
125
|
+
return response
|
|
126
|
+
|
|
127
|
+
async def api_list_sessions(self, request: Request) -> Response:
|
|
128
|
+
"""List sessions for current doctor."""
|
|
129
|
+
try:
|
|
130
|
+
session_data: DoctorSessionData = request.state.doctor_session
|
|
131
|
+
doctor_id = session_data["doctor_id"]
|
|
132
|
+
|
|
133
|
+
# Get query params
|
|
134
|
+
page = int(request.query_params.get("page", "0"))
|
|
135
|
+
page_size = int(request.query_params.get("page_size", "50"))
|
|
136
|
+
|
|
137
|
+
# Build filter
|
|
138
|
+
f = session_filter().doctor_id.eq(doctor_id).created_at.desc()
|
|
139
|
+
|
|
140
|
+
# Get sessions
|
|
141
|
+
sessions = await self.exporter.session_repo.list(
|
|
142
|
+
f.build(),
|
|
143
|
+
page_index=page,
|
|
144
|
+
page_size=page_size,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Get total count
|
|
148
|
+
total = await self.exporter.session_repo.count(
|
|
149
|
+
session_filter().doctor_id.eq(doctor_id).build()
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Convert to response
|
|
153
|
+
list_response = SessionListResponse(
|
|
154
|
+
sessions=[self.exporter._session_to_dict(s) for s in sessions],
|
|
155
|
+
total=total,
|
|
156
|
+
page=page,
|
|
157
|
+
page_size=page_size,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return Response(
|
|
161
|
+
content=json.dumps(list_response, ensure_ascii=False),
|
|
162
|
+
media_type="application/json",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
self.logger.error(f"Failed to list sessions: {e}")
|
|
167
|
+
return self._error_response(str(e), 500)
|
|
168
|
+
|
|
169
|
+
async def api_export_session(self, request: Request) -> Response:
|
|
170
|
+
"""Export single session."""
|
|
171
|
+
try:
|
|
172
|
+
session_data: DoctorSessionData = request.state.doctor_session
|
|
173
|
+
doctor_id = session_data["doctor_id"]
|
|
174
|
+
session_id = request.path_params["session_id"]
|
|
175
|
+
|
|
176
|
+
# Verify ownership
|
|
177
|
+
session = await self.exporter.session_repo.read(session_id)
|
|
178
|
+
if not session:
|
|
179
|
+
return self._error_response("Session not found", 404)
|
|
180
|
+
|
|
181
|
+
if session.doctor_id != doctor_id:
|
|
182
|
+
return self._error_response("Access denied", 403)
|
|
183
|
+
|
|
184
|
+
# Generate ZIP
|
|
185
|
+
zip_data = await self.exporter.export_session_zip(session_id)
|
|
186
|
+
|
|
187
|
+
# Generate filename
|
|
188
|
+
filename = f"{session_id}"
|
|
189
|
+
if session.patient_name:
|
|
190
|
+
filename = f"{session.patient_name}_{session_id}"
|
|
191
|
+
filename += ".zip"
|
|
192
|
+
|
|
193
|
+
self.logger.info(f"Exported session {session_id} for doctor {doctor_id}")
|
|
194
|
+
|
|
195
|
+
return Response(
|
|
196
|
+
content=zip_data,
|
|
197
|
+
media_type="application/zip",
|
|
198
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
except Exception as e:
|
|
202
|
+
self.logger.error(f"Failed to export session: {e}")
|
|
203
|
+
return self._error_response(str(e), 500)
|
|
204
|
+
|
|
205
|
+
async def api_export_multiple(self, request: Request) -> Response:
|
|
206
|
+
"""Export multiple sessions."""
|
|
207
|
+
try:
|
|
208
|
+
session_data: DoctorSessionData = request.state.doctor_session
|
|
209
|
+
doctor_id = session_data["doctor_id"]
|
|
210
|
+
|
|
211
|
+
body = await request.json()
|
|
212
|
+
export_req = t.cast(ExportMultipleRequest, body)
|
|
213
|
+
session_ids = export_req.get("session_ids", [])
|
|
214
|
+
|
|
215
|
+
if not session_ids:
|
|
216
|
+
return self._error_response("No session IDs provided", 400)
|
|
217
|
+
|
|
218
|
+
# Verify all sessions belong to doctor
|
|
219
|
+
for session_id in session_ids:
|
|
220
|
+
session = await self.exporter.session_repo.read(session_id)
|
|
221
|
+
if not session or session.doctor_id != doctor_id:
|
|
222
|
+
return self._error_response(f"Access denied for session {session_id}", 403)
|
|
223
|
+
|
|
224
|
+
# Generate ZIP
|
|
225
|
+
zip_data = await self.exporter.export_multiple_sessions_zip(session_ids)
|
|
226
|
+
|
|
227
|
+
filename = f"sessions_export_{len(session_ids)}.zip"
|
|
228
|
+
|
|
229
|
+
self.logger.info(f"Exported {len(session_ids)} sessions for doctor {doctor_id}")
|
|
230
|
+
|
|
231
|
+
return Response(
|
|
232
|
+
content=zip_data,
|
|
233
|
+
media_type="application/zip",
|
|
234
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
self.logger.error(f"Failed to export multiple sessions: {e}")
|
|
239
|
+
return self._error_response(str(e), 500)
|
|
240
|
+
|
|
241
|
+
def _error_response(self, message: str, status_code: int = 500) -> Response:
|
|
242
|
+
"""Create error response."""
|
|
243
|
+
error: ErrorResponse = {"error": message, "details": None}
|
|
244
|
+
return Response(
|
|
245
|
+
content=json.dumps(error, ensure_ascii=False),
|
|
246
|
+
status_code=status_code,
|
|
247
|
+
media_type="application/json",
|
|
248
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>会话导出系统</title>
|
|
7
|
+
<link rel="stylesheet" href="/static/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="container">
|
|
11
|
+
<header class="page-header">
|
|
12
|
+
<div class="header-content">
|
|
13
|
+
<div class="header-left">
|
|
14
|
+
<h1>会话导出系统</h1>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="header-right">
|
|
17
|
+
<span class="doctor-name">{{ doctor_name }}</span>
|
|
18
|
+
<button id="logoutBtn" class="btn btn-ghost">退出</button>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="session-count" id="sessionCount">正在加载...</div>
|
|
22
|
+
</header>
|
|
23
|
+
|
|
24
|
+
<div class="actions-bar">
|
|
25
|
+
<button id="exportSelected" class="btn btn-primary" disabled>
|
|
26
|
+
<span>导出选中</span>
|
|
27
|
+
<span id="selectedCount" class="count-badge" style="display: none;">0</span>
|
|
28
|
+
</button>
|
|
29
|
+
<button id="selectAll" class="btn btn-secondary">全选</button>
|
|
30
|
+
<button id="deselectAll" class="btn btn-secondary">取消全选</button>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div id="sessionsGrid" class="sessions-grid">
|
|
34
|
+
<div class="loading">
|
|
35
|
+
<div class="spinner"></div>
|
|
36
|
+
<p>加载中</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<script src="/static/script.js"></script>
|
|
42
|
+
<script>
|
|
43
|
+
let sessions = [];
|
|
44
|
+
let selectedSessions = new Set();
|
|
45
|
+
|
|
46
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
47
|
+
loadSessions();
|
|
48
|
+
setupEventListeners();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function setupEventListeners() {
|
|
52
|
+
document.getElementById('exportSelected').addEventListener('click', exportMultiple);
|
|
53
|
+
document.getElementById('selectAll').addEventListener('click', selectAll);
|
|
54
|
+
document.getElementById('deselectAll').addEventListener('click', deselectAll);
|
|
55
|
+
document.getElementById('logoutBtn').addEventListener('click', logout);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function loadSessions() {
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch('/api/sessions? page_size=100');
|
|
61
|
+
|
|
62
|
+
if (response.status === 401) {
|
|
63
|
+
window.location.href = '/login';
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data = await response.json();
|
|
68
|
+
sessions = data.sessions;
|
|
69
|
+
|
|
70
|
+
// Update count
|
|
71
|
+
const countText = sessions.length === 0 ? '暂无会话' : `共 ${sessions.length} 个会话`;
|
|
72
|
+
document.getElementById('sessionCount').textContent = countText;
|
|
73
|
+
|
|
74
|
+
renderSessions();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error('Failed to load sessions:', error);
|
|
77
|
+
document.getElementById('sessionCount').textContent = '加载失败';
|
|
78
|
+
document.getElementById('sessionsGrid').innerHTML =
|
|
79
|
+
'<div class="error-state"><p>加载失败</p></div>';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderSessions() {
|
|
84
|
+
const grid = document.getElementById('sessionsGrid');
|
|
85
|
+
|
|
86
|
+
if (sessions.length === 0) {
|
|
87
|
+
grid.innerHTML = '<div class="empty-state"><p>暂无会话记录</p></div>';
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
grid.innerHTML = sessions.map((session, index) => `
|
|
92
|
+
<div class="session-card" style="animation-delay: ${index * 0.03}s">
|
|
93
|
+
<div class="session-header">
|
|
94
|
+
<div class="checkbox-wrapper">
|
|
95
|
+
<input
|
|
96
|
+
type="checkbox"
|
|
97
|
+
class="checkbox session-checkbox"
|
|
98
|
+
data-session-id="${session.id}"
|
|
99
|
+
id="checkbox-${session.id}"
|
|
100
|
+
onchange="toggleSession('${session.id}')">
|
|
101
|
+
<label for="checkbox-${session.id}" class="checkbox-custom"></label>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="session-info">
|
|
104
|
+
<h3>${escapeHtml(session.patient_name || '未知患者')}</h3>
|
|
105
|
+
${session.clinic_number ?
|
|
106
|
+
`<div class="session-meta">门诊号: ${escapeHtml(session.clinic_number)}</div>` : ''}
|
|
107
|
+
${session.medical_record_number ?
|
|
108
|
+
`<div class="session-meta">病历号: ${escapeHtml(session.medical_record_number)}</div>` : ''}
|
|
109
|
+
${session.diagnosis ?
|
|
110
|
+
`<div class="session-meta">诊断: ${escapeHtml(session.diagnosis)}</div>` : ''}
|
|
111
|
+
<div class="session-meta">${formatDate(session.created_at)}</div>
|
|
112
|
+
<span class="status-badge status-${session.status.toLowerCase().replace('_', '-')}">
|
|
113
|
+
${getStatusText(session.status)}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<button class="export-btn" onclick="exportSingle('${session.id}')">
|
|
118
|
+
导出
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
`).join('');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toggleSession(sessionId) {
|
|
125
|
+
if (selectedSessions.has(sessionId)) {
|
|
126
|
+
selectedSessions.delete(sessionId);
|
|
127
|
+
} else {
|
|
128
|
+
selectedSessions.add(sessionId);
|
|
129
|
+
}
|
|
130
|
+
updateSelectedCount();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function selectAll() {
|
|
134
|
+
sessions.forEach(session => {
|
|
135
|
+
selectedSessions.add(session.id);
|
|
136
|
+
const checkbox = document.querySelector(`[data-session-id="${session.id}"]`);
|
|
137
|
+
if (checkbox) checkbox.checked = true;
|
|
138
|
+
});
|
|
139
|
+
updateSelectedCount();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function deselectAll() {
|
|
143
|
+
selectedSessions.clear();
|
|
144
|
+
document.querySelectorAll('.session-checkbox').forEach(cb => cb.checked = false);
|
|
145
|
+
updateSelectedCount();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function updateSelectedCount() {
|
|
149
|
+
const count = selectedSessions.size;
|
|
150
|
+
const countBadge = document.getElementById('selectedCount');
|
|
151
|
+
const exportBtn = document.getElementById('exportSelected');
|
|
152
|
+
|
|
153
|
+
if (! countBadge || !exportBtn) return;
|
|
154
|
+
|
|
155
|
+
if (count > 0) {
|
|
156
|
+
countBadge.textContent = count;
|
|
157
|
+
countBadge.style.display = 'flex';
|
|
158
|
+
exportBtn.disabled = false;
|
|
159
|
+
} else {
|
|
160
|
+
countBadge.style.display = 'none';
|
|
161
|
+
exportBtn.disabled = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function exportSingle(sessionId) {
|
|
166
|
+
window.location.href = `/api/sessions/${sessionId}/export`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function exportMultiple() {
|
|
170
|
+
if (selectedSessions.size === 0) return;
|
|
171
|
+
|
|
172
|
+
const btn = document.getElementById('exportSelected');
|
|
173
|
+
const originalHTML = btn.innerHTML;
|
|
174
|
+
btn.disabled = true;
|
|
175
|
+
btn.innerHTML = '<span>导出中...</span>';
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const response = await fetch('/api/sessions/export-multiple', {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
session_ids: Array.from(selectedSessions)
|
|
183
|
+
})
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (! response.ok) {
|
|
187
|
+
const error = await response.json();
|
|
188
|
+
throw new Error(error.error || 'Export failed');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const blob = await response.blob();
|
|
192
|
+
downloadBlob(blob, `sessions_export_${selectedSessions.size}.zip`);
|
|
193
|
+
|
|
194
|
+
deselectAll();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error('Export failed:', error);
|
|
197
|
+
alert('导出失败: ' + error.message);
|
|
198
|
+
} finally {
|
|
199
|
+
btn.disabled = false;
|
|
200
|
+
btn.innerHTML = originalHTML;
|
|
201
|
+
updateSelectedCount();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function logout() {
|
|
206
|
+
try {
|
|
207
|
+
await fetch('/api/logout', { method: 'POST' });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error('Logout failed:', error);
|
|
210
|
+
} finally {
|
|
211
|
+
window.location.href = '/login';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getStatusText(status) {
|
|
216
|
+
const map = {
|
|
217
|
+
'COMPLETED': 'completed',
|
|
218
|
+
'IN_PROGRESS': 'in_progress',
|
|
219
|
+
'DRAFT': 'draft',
|
|
220
|
+
'CANCELLED': 'cancelled'
|
|
221
|
+
};
|
|
222
|
+
return map[status] || status.toLowerCase();
|
|
223
|
+
}
|
|
224
|
+
</script>
|
|
225
|
+
</body>
|
|
226
|
+
</html>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>登录 - 会话导出系统</title>
|
|
7
|
+
<link rel="stylesheet" href="/static/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body class="login-body">
|
|
10
|
+
<div class="login-container">
|
|
11
|
+
<div class="login-card">
|
|
12
|
+
<div class="login-header">
|
|
13
|
+
<h1>会话导出系统</h1>
|
|
14
|
+
<p>登录以访问导出功能</p>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<form id="loginForm" class="login-form">
|
|
18
|
+
<div class="form-group">
|
|
19
|
+
<label for="eid">工号</label>
|
|
20
|
+
<input
|
|
21
|
+
type="text"
|
|
22
|
+
id="eid"
|
|
23
|
+
name="eid"
|
|
24
|
+
required
|
|
25
|
+
autocomplete="username"
|
|
26
|
+
placeholder="请输入工号">
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="form-group">
|
|
30
|
+
<label for="password">密码</label>
|
|
31
|
+
<input
|
|
32
|
+
type="password"
|
|
33
|
+
id="password"
|
|
34
|
+
name="password"
|
|
35
|
+
required
|
|
36
|
+
autocomplete="current-password"
|
|
37
|
+
placeholder="请输入密码">
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div id="errorMessage" class="error-message" style="display: none;"></div>
|
|
41
|
+
|
|
42
|
+
<button type="submit" class="btn btn-primary btn-block" id="loginBtn">
|
|
43
|
+
登录
|
|
44
|
+
</button>
|
|
45
|
+
</form>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<script src="/static/script.js"></script>
|
|
50
|
+
<script>
|
|
51
|
+
const form = document.getElementById('loginForm');
|
|
52
|
+
const eidInput = document.getElementById('eid');
|
|
53
|
+
const passwordInput = document.getElementById('password');
|
|
54
|
+
const errorMessage = document.getElementById('errorMessage');
|
|
55
|
+
const loginBtn = document.getElementById('loginBtn');
|
|
56
|
+
|
|
57
|
+
form.addEventListener('submit', async (e) => {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
|
|
60
|
+
const eid = eidInput.value.trim();
|
|
61
|
+
const password = passwordInput.value;
|
|
62
|
+
|
|
63
|
+
if (!eid || ! password) {
|
|
64
|
+
showError('请输入工号和密码');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
hideError();
|
|
69
|
+
loginBtn.disabled = true;
|
|
70
|
+
loginBtn.textContent = '登录中...';
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch('/api/login', {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ eid, password }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const data = await response.json();
|
|
80
|
+
|
|
81
|
+
if (response.ok && data.success) {
|
|
82
|
+
loginBtn.textContent = '登录成功';
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
window.location.href = '/';
|
|
85
|
+
}, 200);
|
|
86
|
+
} else {
|
|
87
|
+
showError(data.error || '登录失败,请检查工号和密码');
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
showError('网络错误,请稍后重试');
|
|
91
|
+
} finally {
|
|
92
|
+
if (! errorMessage.style.display || errorMessage.style.display === 'none') {
|
|
93
|
+
loginBtn.disabled = false;
|
|
94
|
+
loginBtn.textContent = '登录';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function showError(message) {
|
|
100
|
+
errorMessage.textContent = message;
|
|
101
|
+
errorMessage.style.display = 'block';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hideError() {
|
|
105
|
+
errorMessage.style.display = 'none';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
eidInput.focus();
|
|
109
|
+
</script>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|