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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SessionDict(t.TypedDict):
|
|
7
|
+
"""Session data structure."""
|
|
8
|
+
|
|
9
|
+
id: str
|
|
10
|
+
doctor_id: str
|
|
11
|
+
patient_name: str | None
|
|
12
|
+
clinic_number: str | None
|
|
13
|
+
medical_record_number: str | None
|
|
14
|
+
diagnosis: str | None
|
|
15
|
+
status: str
|
|
16
|
+
started_at: str | None
|
|
17
|
+
ended_at: str | None
|
|
18
|
+
created_at: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UtteranceDict(t.TypedDict):
|
|
22
|
+
"""Utterance data structure."""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
sequence: int
|
|
26
|
+
speaker: str
|
|
27
|
+
text: str
|
|
28
|
+
confidence: float | None
|
|
29
|
+
start_time_ms: int
|
|
30
|
+
end_time_ms: int
|
|
31
|
+
duration_ms: int
|
|
32
|
+
timestamp: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SegmentDict(t.TypedDict):
|
|
36
|
+
"""Segment data structure."""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
sequence: int
|
|
40
|
+
audio_key: str
|
|
41
|
+
started_at: str
|
|
42
|
+
ended_at: str | None
|
|
43
|
+
duration_ms: int | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SessionExportData(t.TypedDict):
|
|
47
|
+
"""Complete session export data."""
|
|
48
|
+
|
|
49
|
+
session: SessionDict
|
|
50
|
+
utterances: list[UtteranceDict]
|
|
51
|
+
segments: list[SegmentDict]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConversationJSON(t.TypedDict):
|
|
55
|
+
"""conversation.json format."""
|
|
56
|
+
|
|
57
|
+
session: SessionDict
|
|
58
|
+
utterances: list[UtteranceDict]
|
|
59
|
+
total_utterances: int
|
|
60
|
+
total_segments: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AudioMetadataItem(t.TypedDict):
|
|
64
|
+
"""Audio file metadata item."""
|
|
65
|
+
|
|
66
|
+
filename: str
|
|
67
|
+
sequence: int
|
|
68
|
+
duration_ms: int | None
|
|
69
|
+
started_at: str
|
|
70
|
+
ended_at: str | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AudioMetadataJSON(t.TypedDict):
|
|
74
|
+
"""audio/metadata.json format."""
|
|
75
|
+
|
|
76
|
+
session_id: str
|
|
77
|
+
total_segments: int
|
|
78
|
+
segments: list[AudioMetadataItem]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SessionListResponse(t.TypedDict):
|
|
82
|
+
"""API response for session list."""
|
|
83
|
+
|
|
84
|
+
sessions: list[SessionDict]
|
|
85
|
+
total: int
|
|
86
|
+
page: int
|
|
87
|
+
page_size: int
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ErrorResponse(t.TypedDict):
|
|
91
|
+
"""API error response."""
|
|
92
|
+
|
|
93
|
+
error: str
|
|
94
|
+
details: str | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ExportMultipleRequest(t.TypedDict):
|
|
98
|
+
"""Request body for exporting multiple sessions."""
|
|
99
|
+
|
|
100
|
+
session_ids: list[str]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LoginRequest(t.TypedDict):
|
|
104
|
+
"""Login request body."""
|
|
105
|
+
|
|
106
|
+
eid: str
|
|
107
|
+
password: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class LoginResponse(t.TypedDict):
|
|
111
|
+
"""Login response."""
|
|
112
|
+
|
|
113
|
+
success: bool
|
|
114
|
+
doctor_id: str | None
|
|
115
|
+
doctor_name: str | None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class DoctorSessionData(t.TypedDict):
|
|
119
|
+
"""Doctor session data stored in cookie."""
|
|
120
|
+
|
|
121
|
+
doctor_id: str
|
|
122
|
+
eid: str
|
|
123
|
+
doctor_name: str
|
audex/lib/session.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import datetime
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import pathlib
|
|
9
|
+
import tempfile
|
|
10
|
+
import typing as t
|
|
11
|
+
|
|
12
|
+
from audex import __title__
|
|
13
|
+
from audex import utils
|
|
14
|
+
from audex.exceptions import AudexError
|
|
15
|
+
from audex.helper.mixin import AsyncContextMixin
|
|
16
|
+
from audex.helper.mixin import LoggingMixin
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SessionData(t.NamedTuple):
|
|
20
|
+
"""Session data structure.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
doctor_id: Doctor's unique identifier.
|
|
24
|
+
doctor_name: Doctor's display name.
|
|
25
|
+
eid: Doctor's eid.
|
|
26
|
+
created_at: Session creation timestamp (ISO format).
|
|
27
|
+
expires_at: Session expiration timestamp (ISO format).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
doctor_id: str
|
|
31
|
+
doctor_name: str | None
|
|
32
|
+
eid: str
|
|
33
|
+
created_at: str
|
|
34
|
+
expires_at: str
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict[str, str | None]:
|
|
37
|
+
"""Convert to dictionary for JSON serialization."""
|
|
38
|
+
return {
|
|
39
|
+
"doctor_id": self.doctor_id,
|
|
40
|
+
"doctor_name": self.doctor_name,
|
|
41
|
+
"eid": self.eid,
|
|
42
|
+
"created_at": self.created_at,
|
|
43
|
+
"expires_at": self.expires_at,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: dict[str, str]) -> t.Self:
|
|
48
|
+
"""Create from dictionary."""
|
|
49
|
+
return cls(
|
|
50
|
+
doctor_id=data["doctor_id"],
|
|
51
|
+
doctor_name=data["doctor_name"],
|
|
52
|
+
eid=data["eid"],
|
|
53
|
+
created_at=data["created_at"],
|
|
54
|
+
expires_at=data["expires_at"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SessionManager(LoggingMixin, AsyncContextMixin):
|
|
59
|
+
"""Secure local session manager with automatic state management.
|
|
60
|
+
|
|
61
|
+
This manager maintains session state automatically - no need to manually
|
|
62
|
+
manage tokens. Session persists across application restarts until it
|
|
63
|
+
expires. Uses system temp directory with encryption for security.
|
|
64
|
+
|
|
65
|
+
Security features:
|
|
66
|
+
1. Uses system temp directory (auto-cleaned on reboot)
|
|
67
|
+
2. File permissions restricted to current user (0o600)
|
|
68
|
+
3. Session data encrypted with machine-specific key
|
|
69
|
+
4. Automatic expiration and cleanup
|
|
70
|
+
5. Process-specific session binding
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
session_dir: Directory for session files (in system temp).
|
|
74
|
+
session_file: Path to the encrypted session file.
|
|
75
|
+
ttl: Default time-to-live for sessions.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
```python
|
|
79
|
+
# Initialize manager
|
|
80
|
+
manager = SessionManager(
|
|
81
|
+
app_name="audex",
|
|
82
|
+
default_ttl=timedelta(hours=8),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
await manager.init()
|
|
86
|
+
|
|
87
|
+
# Login
|
|
88
|
+
await manager.login(
|
|
89
|
+
doctor_id="doctor-abc123",
|
|
90
|
+
doctor_name="张医生",
|
|
91
|
+
eid="dr_zhang",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Check if logged in (no token needed!)
|
|
95
|
+
if await manager.is_logged_in():
|
|
96
|
+
session = await manager.get_session()
|
|
97
|
+
print(f"Logged in as: {session.doctor_name}")
|
|
98
|
+
|
|
99
|
+
# Works across app restarts (if not expired)
|
|
100
|
+
# ... restart app ...
|
|
101
|
+
if await manager.is_logged_in():
|
|
102
|
+
print("Auto-logged in!")
|
|
103
|
+
|
|
104
|
+
# Logout
|
|
105
|
+
await manager.logout()
|
|
106
|
+
|
|
107
|
+
# Cleanup
|
|
108
|
+
await manager.close()
|
|
109
|
+
```
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
__logtag__ = "audex.lib.session"
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
app_name: str = __title__,
|
|
117
|
+
*,
|
|
118
|
+
ttl: datetime.timedelta = datetime.timedelta(hours=8),
|
|
119
|
+
) -> None:
|
|
120
|
+
super().__init__()
|
|
121
|
+
self.app_name = app_name
|
|
122
|
+
self.ttl = ttl
|
|
123
|
+
|
|
124
|
+
# Use system temp directory
|
|
125
|
+
self.session_dir = pathlib.Path(tempfile.gettempdir()) / f".{app_name}_session"
|
|
126
|
+
self.session_file = self.session_dir / "session.enc"
|
|
127
|
+
|
|
128
|
+
# Machine-specific encryption key (derived from machine ID)
|
|
129
|
+
self._encryption_key = self._generate_machine_key()
|
|
130
|
+
|
|
131
|
+
self._cleanup_task: asyncio.Task[None] | None = None
|
|
132
|
+
self._lock = asyncio.Lock()
|
|
133
|
+
|
|
134
|
+
async def init(self) -> None:
|
|
135
|
+
"""Initialize the session manager.
|
|
136
|
+
|
|
137
|
+
Creates session directory with restricted permissions and starts
|
|
138
|
+
automatic cleanup task.
|
|
139
|
+
"""
|
|
140
|
+
# Create session directory with restricted permissions
|
|
141
|
+
self.session_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
142
|
+
|
|
143
|
+
# Start automatic cleanup task
|
|
144
|
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
|
145
|
+
|
|
146
|
+
self.logger.info(
|
|
147
|
+
"Session manager initialized",
|
|
148
|
+
session_dir=str(self.session_dir),
|
|
149
|
+
ttl_hours=self.ttl.total_seconds() / 3600,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def close(self) -> None:
|
|
153
|
+
"""Close the session manager.
|
|
154
|
+
|
|
155
|
+
Stops the cleanup task. Session file is preserved for next
|
|
156
|
+
startup.
|
|
157
|
+
"""
|
|
158
|
+
if self._cleanup_task:
|
|
159
|
+
self._cleanup_task.cancel()
|
|
160
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
161
|
+
await self._cleanup_task
|
|
162
|
+
self._cleanup_task = None
|
|
163
|
+
|
|
164
|
+
self.logger.info("Session manager closed")
|
|
165
|
+
|
|
166
|
+
async def login(
|
|
167
|
+
self,
|
|
168
|
+
doctor_id: str,
|
|
169
|
+
eid: str,
|
|
170
|
+
*,
|
|
171
|
+
doctor_name: str | None = None,
|
|
172
|
+
ttl: datetime.timedelta | None = None,
|
|
173
|
+
) -> SessionData:
|
|
174
|
+
"""Create a login session.
|
|
175
|
+
|
|
176
|
+
If a session already exists, it will be replaced with the new one.
|
|
177
|
+
Session persists across application restarts until expiration.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
doctor_id: Doctor's unique identifier.
|
|
181
|
+
doctor_name: Doctor's display name.
|
|
182
|
+
eid: Doctor's eid.
|
|
183
|
+
ttl: Time-to-live for the session. If None, uses default.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Created SessionData.
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
```python
|
|
190
|
+
session = await manager.login(
|
|
191
|
+
doctor_id="doctor-abc123",
|
|
192
|
+
doctor_name="张医生",
|
|
193
|
+
eid="dr_zhang",
|
|
194
|
+
)
|
|
195
|
+
print(f"Logged in as: {session.doctor_name}")
|
|
196
|
+
```
|
|
197
|
+
"""
|
|
198
|
+
async with self._lock:
|
|
199
|
+
ttl = ttl or self.ttl
|
|
200
|
+
now = utils.utcnow()
|
|
201
|
+
expires_at = now + ttl
|
|
202
|
+
|
|
203
|
+
session_data = SessionData(
|
|
204
|
+
doctor_id=doctor_id,
|
|
205
|
+
doctor_name=doctor_name,
|
|
206
|
+
eid=eid,
|
|
207
|
+
created_at=now.isoformat(),
|
|
208
|
+
expires_at=expires_at.isoformat(),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Encrypt and write session data
|
|
212
|
+
await self._write_session(session_data)
|
|
213
|
+
|
|
214
|
+
self.logger.bind(
|
|
215
|
+
doctor_id=doctor_id,
|
|
216
|
+
eid=eid,
|
|
217
|
+
expires_at=expires_at.isoformat(),
|
|
218
|
+
).info(f"Session created for {eid}")
|
|
219
|
+
|
|
220
|
+
return session_data
|
|
221
|
+
|
|
222
|
+
async def logout(self) -> bool:
|
|
223
|
+
"""Logout by deleting the session.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if session was deleted, False if no session exists.
|
|
227
|
+
|
|
228
|
+
Example:
|
|
229
|
+
```python
|
|
230
|
+
logged_out = await manager.logout()
|
|
231
|
+
if logged_out:
|
|
232
|
+
print("Successfully logged out")
|
|
233
|
+
```
|
|
234
|
+
"""
|
|
235
|
+
async with self._lock:
|
|
236
|
+
if not self.session_file.exists():
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
self.session_file.unlink()
|
|
241
|
+
self.logger.info("Session deleted (logout)")
|
|
242
|
+
return True
|
|
243
|
+
except Exception as e:
|
|
244
|
+
self.logger.error(f"Failed to delete session: {e}", exc_info=True)
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
async def is_logged_in(self) -> bool:
|
|
248
|
+
"""Check if there's an active (non-expired) session.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
True if there's an active session, False otherwise.
|
|
252
|
+
|
|
253
|
+
Example:
|
|
254
|
+
```python
|
|
255
|
+
if await manager.is_logged_in():
|
|
256
|
+
print("User is logged in")
|
|
257
|
+
else:
|
|
258
|
+
print("Please login")
|
|
259
|
+
```
|
|
260
|
+
"""
|
|
261
|
+
session = await self.get_session()
|
|
262
|
+
return session is not None
|
|
263
|
+
|
|
264
|
+
async def get_session(self) -> SessionData | None:
|
|
265
|
+
"""Get current session if exists and not expired.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
SessionData if session exists and is valid, None otherwise.
|
|
269
|
+
|
|
270
|
+
Example:
|
|
271
|
+
```python
|
|
272
|
+
session = await manager.get_session()
|
|
273
|
+
if session:
|
|
274
|
+
print(f"Logged in as: {session.doctor_name}")
|
|
275
|
+
print(f"Doctor ID: {session.doctor_id}")
|
|
276
|
+
else:
|
|
277
|
+
print("No active session")
|
|
278
|
+
```
|
|
279
|
+
"""
|
|
280
|
+
async with self._lock:
|
|
281
|
+
if not self.session_file.exists():
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
# Read and decrypt session data
|
|
286
|
+
session_data = await self._read_session()
|
|
287
|
+
if session_data is None:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
# Check if expired
|
|
291
|
+
expires_at = datetime.datetime.fromisoformat(session_data.expires_at)
|
|
292
|
+
if utils.utcnow() >= expires_at:
|
|
293
|
+
self.logger.debug("Session expired, removing file")
|
|
294
|
+
self.session_file.unlink()
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
return session_data
|
|
298
|
+
|
|
299
|
+
except Exception as e:
|
|
300
|
+
self.logger.warning(f"Failed to read session: {e}", exc_info=True)
|
|
301
|
+
# If data is corrupted, remove file
|
|
302
|
+
if self.session_file.exists():
|
|
303
|
+
self.session_file.unlink()
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
async def get_doctor_id(self) -> str | None:
|
|
307
|
+
"""Get current logged-in doctor's ID.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Doctor ID if logged in, None otherwise.
|
|
311
|
+
|
|
312
|
+
Example:
|
|
313
|
+
```python
|
|
314
|
+
doctor_id = await manager.get_doctor_id()
|
|
315
|
+
if doctor_id:
|
|
316
|
+
print(f"Current doctor: {doctor_id}")
|
|
317
|
+
```
|
|
318
|
+
"""
|
|
319
|
+
session = await self.get_session()
|
|
320
|
+
return session.doctor_id if session else None
|
|
321
|
+
|
|
322
|
+
async def extend_session(self, extra_ttl: datetime.timedelta) -> bool:
|
|
323
|
+
"""Extend current session expiration time.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
extra_ttl: Additional time to add to expiration.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
True if session was extended, False if no session exists.
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
```python
|
|
333
|
+
# Extend by 2 hours
|
|
334
|
+
extended = await manager.extend_session(timedelta(hours=2))
|
|
335
|
+
if extended:
|
|
336
|
+
print("Session extended")
|
|
337
|
+
```
|
|
338
|
+
"""
|
|
339
|
+
async with self._lock:
|
|
340
|
+
session = await self.get_session()
|
|
341
|
+
if session is None:
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
# Calculate new expiration
|
|
345
|
+
current_expires = datetime.datetime.fromisoformat(session.expires_at)
|
|
346
|
+
new_expires = current_expires + extra_ttl
|
|
347
|
+
|
|
348
|
+
# Create updated session
|
|
349
|
+
updated_session = SessionData(
|
|
350
|
+
doctor_id=session.doctor_id,
|
|
351
|
+
doctor_name=session.doctor_name,
|
|
352
|
+
eid=session.eid,
|
|
353
|
+
created_at=session.created_at,
|
|
354
|
+
expires_at=new_expires.isoformat(),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
await self._write_session(updated_session)
|
|
358
|
+
self.logger.info(
|
|
359
|
+
"Session extended",
|
|
360
|
+
new_expires_at=new_expires.isoformat(),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
async def _read_session(self) -> SessionData | None:
|
|
366
|
+
"""Read and decrypt session from file.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
SessionData if successful, None otherwise.
|
|
370
|
+
"""
|
|
371
|
+
try:
|
|
372
|
+
encrypted_data = self.session_file.read_bytes()
|
|
373
|
+
decrypted_json = self._decrypt_data(encrypted_data)
|
|
374
|
+
data = json.loads(decrypted_json)
|
|
375
|
+
return SessionData.from_dict(data)
|
|
376
|
+
except Exception as e:
|
|
377
|
+
self.logger.warning(f"Failed to decrypt session: {e}")
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
async def _write_session(self, session_data: SessionData) -> None:
|
|
381
|
+
"""Encrypt and write session to file.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
session_data: Session data to write.
|
|
385
|
+
"""
|
|
386
|
+
json_data = json.dumps(session_data.to_dict())
|
|
387
|
+
encrypted_data = self._encrypt_data(json_data)
|
|
388
|
+
|
|
389
|
+
self.session_file.write_bytes(encrypted_data)
|
|
390
|
+
self.session_file.chmod(0o600) # Restrict to owner only
|
|
391
|
+
|
|
392
|
+
async def _cleanup_loop(self) -> None:
|
|
393
|
+
"""Automatic cleanup loop that runs every 5 minutes.
|
|
394
|
+
|
|
395
|
+
Checks for expired sessions and removes them.
|
|
396
|
+
"""
|
|
397
|
+
while True:
|
|
398
|
+
try:
|
|
399
|
+
await asyncio.sleep(300) # Check every 5 minutes
|
|
400
|
+
|
|
401
|
+
if self.session_file.exists():
|
|
402
|
+
session = await self.get_session()
|
|
403
|
+
if session is None:
|
|
404
|
+
# File exists but session is invalid/expired
|
|
405
|
+
self.logger.debug("Auto-cleanup: removed expired/invalid session")
|
|
406
|
+
|
|
407
|
+
except asyncio.CancelledError:
|
|
408
|
+
break
|
|
409
|
+
except Exception as e:
|
|
410
|
+
self.logger.error(f"Error in cleanup loop: {e}", exc_info=True)
|
|
411
|
+
|
|
412
|
+
def _generate_machine_key(self) -> bytes:
|
|
413
|
+
"""Generate machine-specific encryption key.
|
|
414
|
+
|
|
415
|
+
Uses machine ID and app name to create a deterministic key that's
|
|
416
|
+
unique to this machine and application.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
32-byte encryption key.
|
|
420
|
+
"""
|
|
421
|
+
# Try to get machine ID from various sources
|
|
422
|
+
machine_id = None
|
|
423
|
+
|
|
424
|
+
# Try /etc/machine-id (Linux)
|
|
425
|
+
machine_id_file = pathlib.Path("/etc/machine-id")
|
|
426
|
+
if machine_id_file.exists():
|
|
427
|
+
with contextlib.suppress(Exception):
|
|
428
|
+
machine_id = machine_id_file.read_text().strip()
|
|
429
|
+
|
|
430
|
+
# Try /var/lib/dbus/machine-id (Linux alternative)
|
|
431
|
+
if machine_id is None:
|
|
432
|
+
dbus_id_file = pathlib.Path("/var/lib/dbus/machine-id")
|
|
433
|
+
if dbus_id_file.exists():
|
|
434
|
+
with contextlib.suppress(Exception):
|
|
435
|
+
machine_id = dbus_id_file.read_text().strip()
|
|
436
|
+
|
|
437
|
+
# Fallback: use hostname + eid
|
|
438
|
+
if machine_id is None:
|
|
439
|
+
import getpass
|
|
440
|
+
import socket
|
|
441
|
+
|
|
442
|
+
machine_id = f"{socket.gethostname()}-{getpass.getuser()}"
|
|
443
|
+
|
|
444
|
+
# Derive key from machine ID and app name
|
|
445
|
+
key_material = f"{machine_id}:{self.app_name}".encode()
|
|
446
|
+
return hashlib.sha256(key_material).digest()
|
|
447
|
+
|
|
448
|
+
def _encrypt_data(self, data: str) -> bytes:
|
|
449
|
+
"""Encrypt session data using machine-specific key.
|
|
450
|
+
|
|
451
|
+
Uses XOR encryption with the machine key. This provides basic
|
|
452
|
+
protection against casual inspection and prevents session files
|
|
453
|
+
from being copied to other machines.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
data: Plain text data to encrypt.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
Encrypted bytes.
|
|
460
|
+
"""
|
|
461
|
+
data_bytes = data.encode()
|
|
462
|
+
|
|
463
|
+
encrypted = bytearray()
|
|
464
|
+
for i, byte in enumerate(data_bytes):
|
|
465
|
+
encrypted.append(byte ^ self._encryption_key[i % len(self._encryption_key)])
|
|
466
|
+
|
|
467
|
+
return bytes(encrypted)
|
|
468
|
+
|
|
469
|
+
def _decrypt_data(self, data: bytes) -> str:
|
|
470
|
+
"""Decrypt session data.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
data: Encrypted bytes.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Decrypted string.
|
|
477
|
+
|
|
478
|
+
Raises:
|
|
479
|
+
Exception: If decryption fails (wrong machine or corrupted data).
|
|
480
|
+
"""
|
|
481
|
+
decrypted = bytearray()
|
|
482
|
+
for i, byte in enumerate(data):
|
|
483
|
+
decrypted.append(byte ^ self._encryption_key[i % len(self._encryption_key)])
|
|
484
|
+
|
|
485
|
+
return decrypted.decode()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class SessionError(AudexError):
|
|
489
|
+
"""Base exception for session errors."""
|
|
490
|
+
|
|
491
|
+
default_message = "Session error occurred"
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class SessionExpiredError(SessionError):
|
|
495
|
+
"""Raised when session has expired."""
|
|
496
|
+
|
|
497
|
+
default_message = "Session has expired"
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class SessionNotFoundError(SessionError):
|
|
501
|
+
"""Raised when session is not found."""
|
|
502
|
+
|
|
503
|
+
default_message = "No active session found"
|