cli-anything-drms 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.
- cli_anything/drms/__main__.py +4 -0
- cli_anything/drms/core/case_notes.py +27 -0
- cli_anything/drms/core/client.py +45 -0
- cli_anything/drms/core/lab_reports.py +16 -0
- cli_anything/drms/core/medication.py +20 -0
- cli_anything/drms/core/patients.py +20 -0
- cli_anything/drms/core/staff.py +12 -0
- cli_anything/drms/core/stats.py +40 -0
- cli_anything/drms/drms_cli.py +516 -0
- cli_anything/drms/drms_feature/discovery.py +57 -0
- cli_anything/drms/drms_feature/member.py +29 -0
- cli_anything/drms/drms_feature/recommend.py +14 -0
- cli_anything/drms/utils/auth.py +42 -0
- cli_anything/drms/utils/repl_skin.py +68 -0
- cli_anything/drms/utils/session.py +108 -0
- cli_anything_drms-0.1.0.dist-info/METADATA +10 -0
- cli_anything_drms-0.1.0.dist-info/RECORD +20 -0
- cli_anything_drms-0.1.0.dist-info/WHEEL +5 -0
- cli_anything_drms-0.1.0.dist-info/entry_points.txt +3 -0
- cli_anything_drms-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Case notes domain operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def list_case_notes(client: DrmsClient, patient_uuid: str) -> Any:
|
|
8
|
+
return client.get(f"/patient/case_notes/v2/{patient_uuid}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_case_note(client: DrmsClient, note_uuid: str) -> Any:
|
|
12
|
+
return client.get(f"/patient/case_notes/v2/note/{note_uuid}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_case_note_report(client: DrmsClient, patient_id: str, design_type: str = "new") -> Any:
|
|
16
|
+
return client.post(
|
|
17
|
+
f"/patient/case_notes/v2/report/{patient_id}",
|
|
18
|
+
data={"designtype": design_type},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_case_note_categories(client: DrmsClient) -> Any:
|
|
23
|
+
return client.get("/patient/case_notes/v2/categories")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_case_note_templates(client: DrmsClient) -> Any:
|
|
27
|
+
return client.get("/patient/case_notes/v2/templates")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""HTTP client wrapper for the DRMS / HTM FastAPI backend."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
import requests
|
|
6
|
+
from cli_anything.drms.utils.session import require_token
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DrmsClient:
|
|
10
|
+
def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None):
|
|
11
|
+
if base_url and token:
|
|
12
|
+
self.base_url = base_url.rstrip("/")
|
|
13
|
+
self.token = token
|
|
14
|
+
else:
|
|
15
|
+
self.base_url, self.token = require_token()
|
|
16
|
+
|
|
17
|
+
def _headers(self) -> dict:
|
|
18
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
19
|
+
|
|
20
|
+
def get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
21
|
+
url = f"{self.base_url}{path}"
|
|
22
|
+
r = requests.get(url, headers=self._headers(), params=params, timeout=30)
|
|
23
|
+
r.raise_for_status()
|
|
24
|
+
return r.json()
|
|
25
|
+
|
|
26
|
+
def post(self, path: str, data: Any = None, files: Optional[dict] = None) -> Any:
|
|
27
|
+
url = f"{self.base_url}{path}"
|
|
28
|
+
if files:
|
|
29
|
+
r = requests.post(url, headers=self._headers(), data=data, files=files, timeout=60)
|
|
30
|
+
else:
|
|
31
|
+
r = requests.post(url, headers=self._headers(), json=data, timeout=30)
|
|
32
|
+
r.raise_for_status()
|
|
33
|
+
return r.json()
|
|
34
|
+
|
|
35
|
+
def put(self, path: str, data: Any = None) -> Any:
|
|
36
|
+
url = f"{self.base_url}{path}"
|
|
37
|
+
r = requests.put(url, headers=self._headers(), json=data, timeout=30)
|
|
38
|
+
r.raise_for_status()
|
|
39
|
+
return r.json()
|
|
40
|
+
|
|
41
|
+
def delete(self, path: str) -> Any:
|
|
42
|
+
url = f"{self.base_url}{path}"
|
|
43
|
+
r = requests.delete(url, headers=self._headers(), timeout=30)
|
|
44
|
+
r.raise_for_status()
|
|
45
|
+
return r.json()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Lab report domain operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_lab_reports(client: DrmsClient, patient_uuid: str) -> Any:
|
|
8
|
+
return client.get(f"/health_info/lab_report/patient/{patient_uuid}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_lab_report(client: DrmsClient, report_uuid: str) -> Any:
|
|
12
|
+
return client.get(f"/health_info/lab_report/get/{report_uuid}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_lab_trends(client: DrmsClient, patient_uuid: str) -> Any:
|
|
16
|
+
return client.get(f"/health_info/lab_report/patient/{patient_uuid}/trends")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Medication domain operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_medications(client: DrmsClient, patient_id: str) -> Any:
|
|
8
|
+
return client.get(f"/patient/v3/medication/{patient_id}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_daily_medication(client: DrmsClient, patient_id: str) -> Any:
|
|
12
|
+
return client.get(f"/daily-medication/{patient_id}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_medication_monitoring(client: DrmsClient, patient_id: str) -> Any:
|
|
16
|
+
return client.get(f"/medication-monitoring/{patient_id}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_health_summary(client: DrmsClient, patient_id: str) -> Any:
|
|
20
|
+
return client.get(f"/patient/v5/health-summary/{patient_id}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Patient domain operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def search_patients(client: DrmsClient, term: str) -> Any:
|
|
8
|
+
return client.get(f"/patient/v2/search/{term}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_patients(client: DrmsClient, page: int = 1) -> Any:
|
|
12
|
+
return client.get(f"/patient/v2/all/{page}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_patient(client: DrmsClient, patient_id: str) -> Any:
|
|
16
|
+
return client.get(f"/patient/v2/get/{patient_id}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def list_patients_by_condition(client: DrmsClient, condition: str, page: int = 1) -> Any:
|
|
20
|
+
return client.get("/patient-list-by-conditions/by-condition", params={"condition": condition, "page": page})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Staff/organisation domain operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def list_staff(client: DrmsClient) -> Any:
|
|
8
|
+
return client.get("/admin/staffs")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_staff(client: DrmsClient, firebase_uid: str) -> Any:
|
|
12
|
+
return client.get(f"/admin/staff/{firebase_uid}")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Organisation statistics — action logs, health conditions, CPD analytics."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_action_stats(client: DrmsClient, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Any:
|
|
8
|
+
params = {}
|
|
9
|
+
if start_date:
|
|
10
|
+
params["start_date"] = start_date
|
|
11
|
+
if end_date:
|
|
12
|
+
params["end_date"] = end_date
|
|
13
|
+
return client.get("/admin/action_stats_v2", params=params or None)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_condition_stats(client: DrmsClient, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Any:
|
|
17
|
+
params = {}
|
|
18
|
+
if start_date:
|
|
19
|
+
params["start_date"] = start_date
|
|
20
|
+
if end_date:
|
|
21
|
+
params["end_date"] = end_date
|
|
22
|
+
return client.get("/admin/condition_stats", params=params or None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_cpd_analytics(
|
|
26
|
+
client: DrmsClient,
|
|
27
|
+
start_date: Optional[str] = None,
|
|
28
|
+
end_date: Optional[str] = None,
|
|
29
|
+
premise_ids: Optional[List[str]] = None,
|
|
30
|
+
include_inactive: bool = False,
|
|
31
|
+
top_n: int = 100,
|
|
32
|
+
) -> Any:
|
|
33
|
+
params: dict = {"top_n": top_n, "include_inactive_staff": str(include_inactive).lower()}
|
|
34
|
+
if start_date:
|
|
35
|
+
params["start_date"] = start_date
|
|
36
|
+
if end_date:
|
|
37
|
+
params["end_date"] = end_date
|
|
38
|
+
if premise_ids:
|
|
39
|
+
params["premise_ids"] = premise_ids
|
|
40
|
+
return client.get("/admin/cpd_analytics", params=params)
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""DRMS / HTM CLI — member search, discovery, recommendations, and patient data."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
11
|
+
from cli_anything.drms.core import patients, lab_reports, case_notes, medication, staff, stats
|
|
12
|
+
from cli_anything.drms.drms_feature import member, discovery, recommend
|
|
13
|
+
from cli_anything.drms.utils.session import save_token, save_session, clear_session, get_session
|
|
14
|
+
from cli_anything.drms.utils import auth as _auth
|
|
15
|
+
from cli_anything.drms.utils.repl_skin import ReplSkin
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
def _client(ctx: click.Context) -> DrmsClient:
|
|
21
|
+
return DrmsClient()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _out(ctx: click.Context, data) -> None:
|
|
25
|
+
if ctx.obj.get("json"):
|
|
26
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
27
|
+
else:
|
|
28
|
+
if isinstance(data, (dict, list)):
|
|
29
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
30
|
+
else:
|
|
31
|
+
click.echo(str(data))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _handle_error(e: Exception) -> None:
|
|
35
|
+
if isinstance(e, requests.HTTPError):
|
|
36
|
+
try:
|
|
37
|
+
detail = e.response.json().get("detail", str(e))
|
|
38
|
+
except Exception:
|
|
39
|
+
detail = str(e)
|
|
40
|
+
click.echo(f"API error {e.response.status_code}: {detail}", err=True)
|
|
41
|
+
else:
|
|
42
|
+
click.echo(f"Error: {e}", err=True)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Root group ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
@click.group()
|
|
49
|
+
@click.option("--json", "json_mode", is_flag=True, default=False, help="Output raw JSON.")
|
|
50
|
+
@click.pass_context
|
|
51
|
+
def cli(ctx: click.Context, json_mode: bool) -> None:
|
|
52
|
+
"""DRMS / HTM CLI — member search, discovery, drug recommendations, and patient data."""
|
|
53
|
+
ctx.ensure_object(dict)
|
|
54
|
+
ctx.obj["json"] = json_mode
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── Auth ──────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
@cli.command()
|
|
60
|
+
@click.option("--url", default="https://htm.aipharm.xyz", show_default=True, help="HTM API base URL.")
|
|
61
|
+
@click.option("--token", default=None, hidden=True, help="Pre-issued token (CI/agent use only).")
|
|
62
|
+
def login(url: str, token: str) -> None:
|
|
63
|
+
"""Log in to DRMS / HTM. Prompts for email and password."""
|
|
64
|
+
if token:
|
|
65
|
+
save_token(url, token)
|
|
66
|
+
click.echo(f"Session saved (token mode). Base URL: {url}")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
email = click.prompt("Email")
|
|
70
|
+
password = click.prompt("Password", hide_input=True)
|
|
71
|
+
|
|
72
|
+
click.echo("Authenticating…")
|
|
73
|
+
try:
|
|
74
|
+
resp = _auth.login(url, email, password)
|
|
75
|
+
except ValueError as e:
|
|
76
|
+
click.echo(str(e), err=True)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
id_token = resp["firebase_token"]
|
|
80
|
+
local_id = resp["user_id"]
|
|
81
|
+
|
|
82
|
+
save_session(url, id_token, resp["refresh_token"], local_id, email)
|
|
83
|
+
click.echo(f"Logged in as {email}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cli.command()
|
|
87
|
+
def logout() -> None:
|
|
88
|
+
"""Log out and revoke tokens server-side."""
|
|
89
|
+
data = get_session()
|
|
90
|
+
if data.get("token") and data.get("base_url"):
|
|
91
|
+
_auth.logout_server(data["base_url"], data["token"])
|
|
92
|
+
clear_session()
|
|
93
|
+
click.echo("Logged out.")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@cli.command()
|
|
97
|
+
def whoami() -> None:
|
|
98
|
+
"""Show current session info."""
|
|
99
|
+
data = get_session()
|
|
100
|
+
if not data or not data.get("token"):
|
|
101
|
+
click.echo("Not logged in.")
|
|
102
|
+
return
|
|
103
|
+
click.echo(f"URL : {data.get('base_url', 'n/a')}")
|
|
104
|
+
click.echo(f"Email: {data.get('email', '(token mode)')}")
|
|
105
|
+
issued = data.get("issued_at")
|
|
106
|
+
if issued:
|
|
107
|
+
import time
|
|
108
|
+
age_min = int((time.time() - issued) / 60)
|
|
109
|
+
click.echo(f"Token: {age_min} min old (auto-refreshes at 55 min)")
|
|
110
|
+
else:
|
|
111
|
+
click.echo(f"Token: {data.get('token', '')[:24]}… (manual token mode)")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── Patient commands ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
@cli.group()
|
|
117
|
+
def patient() -> None:
|
|
118
|
+
"""Patient search and lookup."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@patient.command("search")
|
|
122
|
+
@click.argument("term")
|
|
123
|
+
@click.pass_context
|
|
124
|
+
def patient_search(ctx, term: str) -> None:
|
|
125
|
+
"""Search patients by name, IC, or ref ID."""
|
|
126
|
+
try:
|
|
127
|
+
_out(ctx, patients.search_patients(_client(ctx), term))
|
|
128
|
+
except Exception as e:
|
|
129
|
+
_handle_error(e)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@patient.command("list")
|
|
133
|
+
@click.option("--page", default=1, show_default=True)
|
|
134
|
+
@click.pass_context
|
|
135
|
+
def patient_list(ctx, page: int) -> None:
|
|
136
|
+
"""List patients (paginated)."""
|
|
137
|
+
try:
|
|
138
|
+
_out(ctx, patients.list_patients(_client(ctx), page))
|
|
139
|
+
except Exception as e:
|
|
140
|
+
_handle_error(e)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@patient.command("get")
|
|
144
|
+
@click.argument("patient_id")
|
|
145
|
+
@click.pass_context
|
|
146
|
+
def patient_get(ctx, patient_id: str) -> None:
|
|
147
|
+
"""Get a patient by ID."""
|
|
148
|
+
try:
|
|
149
|
+
_out(ctx, patients.get_patient(_client(ctx), patient_id))
|
|
150
|
+
except Exception as e:
|
|
151
|
+
_handle_error(e)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@patient.command("by-condition")
|
|
155
|
+
@click.argument("condition")
|
|
156
|
+
@click.option("--page", default=1, show_default=True)
|
|
157
|
+
@click.pass_context
|
|
158
|
+
def patient_by_condition(ctx, condition: str, page: int) -> None:
|
|
159
|
+
"""List patients by medical condition tag."""
|
|
160
|
+
try:
|
|
161
|
+
_out(ctx, patients.list_patients_by_condition(_client(ctx), condition, page))
|
|
162
|
+
except Exception as e:
|
|
163
|
+
_handle_error(e)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Lab report commands ───────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
@cli.group()
|
|
169
|
+
def lab() -> None:
|
|
170
|
+
"""Lab report data and trends."""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@lab.command("list")
|
|
174
|
+
@click.argument("patient_uuid")
|
|
175
|
+
@click.pass_context
|
|
176
|
+
def lab_list(ctx, patient_uuid: str) -> None:
|
|
177
|
+
"""List all lab reports for a patient."""
|
|
178
|
+
try:
|
|
179
|
+
_out(ctx, lab_reports.get_lab_reports(_client(ctx), patient_uuid))
|
|
180
|
+
except Exception as e:
|
|
181
|
+
_handle_error(e)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@lab.command("get")
|
|
185
|
+
@click.argument("report_uuid")
|
|
186
|
+
@click.pass_context
|
|
187
|
+
def lab_get(ctx, report_uuid: str) -> None:
|
|
188
|
+
"""Get a single lab report with all results."""
|
|
189
|
+
try:
|
|
190
|
+
_out(ctx, lab_reports.get_lab_report(_client(ctx), report_uuid))
|
|
191
|
+
except Exception as e:
|
|
192
|
+
_handle_error(e)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@lab.command("trends")
|
|
196
|
+
@click.argument("patient_uuid")
|
|
197
|
+
@click.pass_context
|
|
198
|
+
def lab_trends(ctx, patient_uuid: str) -> None:
|
|
199
|
+
"""Show lab result trends for a patient."""
|
|
200
|
+
try:
|
|
201
|
+
_out(ctx, lab_reports.get_lab_trends(_client(ctx), patient_uuid))
|
|
202
|
+
except Exception as e:
|
|
203
|
+
_handle_error(e)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Case note commands ────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
@cli.group()
|
|
209
|
+
def note() -> None:
|
|
210
|
+
"""Case note lookup and reference data."""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@note.command("list")
|
|
214
|
+
@click.argument("patient_uuid")
|
|
215
|
+
@click.pass_context
|
|
216
|
+
def note_list(ctx, patient_uuid: str) -> None:
|
|
217
|
+
"""List all case notes for a patient."""
|
|
218
|
+
try:
|
|
219
|
+
_out(ctx, case_notes.list_case_notes(_client(ctx), patient_uuid))
|
|
220
|
+
except Exception as e:
|
|
221
|
+
_handle_error(e)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@note.command("get")
|
|
225
|
+
@click.argument("note_uuid")
|
|
226
|
+
@click.pass_context
|
|
227
|
+
def note_get(ctx, note_uuid: str) -> None:
|
|
228
|
+
"""Get a single case note."""
|
|
229
|
+
try:
|
|
230
|
+
_out(ctx, case_notes.get_case_note(_client(ctx), note_uuid))
|
|
231
|
+
except Exception as e:
|
|
232
|
+
_handle_error(e)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@note.command("report")
|
|
236
|
+
@click.argument("patient_id")
|
|
237
|
+
@click.option("--design", default="new", type=click.Choice(["old", "new", "markdown"]), show_default=True)
|
|
238
|
+
@click.pass_context
|
|
239
|
+
def note_report(ctx, patient_id: str, design: str) -> None:
|
|
240
|
+
"""Generate a PDF case note report for a patient."""
|
|
241
|
+
try:
|
|
242
|
+
_out(ctx, case_notes.generate_case_note_report(_client(ctx), patient_id, design))
|
|
243
|
+
except Exception as e:
|
|
244
|
+
_handle_error(e)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ── Medication commands ───────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
@cli.group()
|
|
250
|
+
def med() -> None:
|
|
251
|
+
"""Medication and health summary."""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@med.command("list")
|
|
255
|
+
@click.argument("patient_id")
|
|
256
|
+
@click.pass_context
|
|
257
|
+
def med_list(ctx, patient_id: str) -> None:
|
|
258
|
+
"""List medications for a patient."""
|
|
259
|
+
try:
|
|
260
|
+
_out(ctx, medication.get_medications(_client(ctx), patient_id))
|
|
261
|
+
except Exception as e:
|
|
262
|
+
_handle_error(e)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@med.command("daily")
|
|
266
|
+
@click.argument("patient_id")
|
|
267
|
+
@click.pass_context
|
|
268
|
+
def med_daily(ctx, patient_id: str) -> None:
|
|
269
|
+
"""Show daily medication schedule for a patient."""
|
|
270
|
+
try:
|
|
271
|
+
_out(ctx, medication.get_daily_medication(_client(ctx), patient_id))
|
|
272
|
+
except Exception as e:
|
|
273
|
+
_handle_error(e)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@med.command("summary")
|
|
277
|
+
@click.argument("patient_id")
|
|
278
|
+
@click.pass_context
|
|
279
|
+
def med_summary(ctx, patient_id: str) -> None:
|
|
280
|
+
"""Show health summary for a patient."""
|
|
281
|
+
try:
|
|
282
|
+
_out(ctx, medication.get_health_summary(_client(ctx), patient_id))
|
|
283
|
+
except Exception as e:
|
|
284
|
+
_handle_error(e)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ── Staff commands ────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
@cli.group("staff")
|
|
290
|
+
def staff_cmd() -> None:
|
|
291
|
+
"""Staff and organisation lookup."""
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@staff_cmd.command("list")
|
|
295
|
+
@click.pass_context
|
|
296
|
+
def staff_list(ctx) -> None:
|
|
297
|
+
"""List all staff in the organisation."""
|
|
298
|
+
try:
|
|
299
|
+
_out(ctx, staff.list_staff(_client(ctx)))
|
|
300
|
+
except Exception as e:
|
|
301
|
+
_handle_error(e)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@staff_cmd.command("get")
|
|
305
|
+
@click.argument("firebase_uid")
|
|
306
|
+
@click.pass_context
|
|
307
|
+
def staff_get(ctx, firebase_uid: str) -> None:
|
|
308
|
+
"""Get a staff member by Firebase UID."""
|
|
309
|
+
try:
|
|
310
|
+
_out(ctx, staff.get_staff(_client(ctx), firebase_uid))
|
|
311
|
+
except Exception as e:
|
|
312
|
+
_handle_error(e)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ── Stats commands ────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
@cli.group("stats")
|
|
318
|
+
def stats_group() -> None:
|
|
319
|
+
"""Organisation statistics (admin / branch manager only)."""
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@stats_group.command("actions")
|
|
323
|
+
@click.option("--start", default=None, help="Start date ISO-8601 (default: 7 days ago).")
|
|
324
|
+
@click.option("--end", default=None, help="End date ISO-8601 (default: now).")
|
|
325
|
+
@click.pass_context
|
|
326
|
+
def stats_actions(ctx, start, end) -> None:
|
|
327
|
+
"""Action-log analytics: staff performance, branch activeness, behaviour insights."""
|
|
328
|
+
try:
|
|
329
|
+
_out(ctx, stats.get_action_stats(_client(ctx), start, end))
|
|
330
|
+
except Exception as e:
|
|
331
|
+
_handle_error(e)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@stats_group.command("conditions")
|
|
335
|
+
@click.option("--start", default=None, help="Start date ISO-8601 (default: 7 days ago).")
|
|
336
|
+
@click.option("--end", default=None, help="End date ISO-8601 (default: now).")
|
|
337
|
+
@click.pass_context
|
|
338
|
+
def stats_conditions(ctx, start, end) -> None:
|
|
339
|
+
"""Health condition frequency: counts by condition, branch, age group, and gender."""
|
|
340
|
+
try:
|
|
341
|
+
_out(ctx, stats.get_condition_stats(_client(ctx), start, end))
|
|
342
|
+
except Exception as e:
|
|
343
|
+
_handle_error(e)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@stats_group.command("cpd")
|
|
347
|
+
@click.option("--start", default=None)
|
|
348
|
+
@click.option("--end", default=None)
|
|
349
|
+
@click.option("--premise", "premise_ids", multiple=True)
|
|
350
|
+
@click.option("--include-inactive", is_flag=True, default=False)
|
|
351
|
+
@click.option("--top-n", default=100, show_default=True)
|
|
352
|
+
@click.pass_context
|
|
353
|
+
def stats_cpd(ctx, start, end, premise_ids, include_inactive, top_n) -> None:
|
|
354
|
+
"""CPD analytics: enrolments, completions, leaderboard, at-risk cohort."""
|
|
355
|
+
try:
|
|
356
|
+
_out(ctx, stats.get_cpd_analytics(_client(ctx), start, end, list(premise_ids) or None, include_inactive, top_n))
|
|
357
|
+
except Exception as e:
|
|
358
|
+
_handle_error(e)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ── HTM Member commands ───────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
@cli.group("member")
|
|
364
|
+
def member_group() -> None:
|
|
365
|
+
"""HTM member search and purchase history."""
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@member_group.command("search")
|
|
369
|
+
@click.argument("query")
|
|
370
|
+
@click.option("--by-name", is_flag=True, default=False, help="Search by name instead of IC.")
|
|
371
|
+
@click.pass_context
|
|
372
|
+
def member_search(ctx, query: str, by_name: bool) -> None:
|
|
373
|
+
"""Search HTM members by IC number (or name with --by-name)."""
|
|
374
|
+
try:
|
|
375
|
+
_out(ctx, member.search_members(_client(ctx), query, by_name))
|
|
376
|
+
except Exception as e:
|
|
377
|
+
_handle_error(e)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@member_group.command("search-card")
|
|
381
|
+
@click.argument("query")
|
|
382
|
+
@click.option("--by-name", is_flag=True, default=False)
|
|
383
|
+
@click.pass_context
|
|
384
|
+
def member_search_card(ctx, query: str, by_name: bool) -> None:
|
|
385
|
+
"""Search HTM members by loyalty card number."""
|
|
386
|
+
try:
|
|
387
|
+
_out(ctx, member.search_members_by_card(_client(ctx), query, by_name))
|
|
388
|
+
except Exception as e:
|
|
389
|
+
_handle_error(e)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@member_group.command("purchases")
|
|
393
|
+
@click.argument("member_id")
|
|
394
|
+
@click.pass_context
|
|
395
|
+
def member_purchases(ctx, member_id: str) -> None:
|
|
396
|
+
"""Show purchase history for a member."""
|
|
397
|
+
try:
|
|
398
|
+
_out(ctx, member.get_purchases(_client(ctx), member_id))
|
|
399
|
+
except Exception as e:
|
|
400
|
+
_handle_error(e)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@member_group.command("labs")
|
|
404
|
+
@click.argument("patient_id")
|
|
405
|
+
@click.pass_context
|
|
406
|
+
def member_labs(ctx, patient_id: str) -> None:
|
|
407
|
+
"""Show HTM lab reports for a patient."""
|
|
408
|
+
try:
|
|
409
|
+
_out(ctx, member.get_lab_reports(_client(ctx), patient_id))
|
|
410
|
+
except Exception as e:
|
|
411
|
+
_handle_error(e)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@member_group.command("labs-all")
|
|
415
|
+
@click.pass_context
|
|
416
|
+
def member_labs_all(ctx) -> None:
|
|
417
|
+
"""List all HTM lab reports for the organisation."""
|
|
418
|
+
try:
|
|
419
|
+
_out(ctx, member.list_lab_reports(_client(ctx)))
|
|
420
|
+
except Exception as e:
|
|
421
|
+
_handle_error(e)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ── Discovery commands ────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
@cli.group("discovery")
|
|
427
|
+
def discovery_group() -> None:
|
|
428
|
+
"""DRMS illness discovery — symptoms, illness search, checker, analyses."""
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@discovery_group.command("complaints")
|
|
432
|
+
@click.option("--invalidate", is_flag=True, default=False, help="Bust the server-side cache.")
|
|
433
|
+
@click.pass_context
|
|
434
|
+
def discovery_complaints(ctx, invalidate: bool) -> None:
|
|
435
|
+
"""List all symptoms and risk factors."""
|
|
436
|
+
try:
|
|
437
|
+
_out(ctx, discovery.get_complaints(_client(ctx), invalidate))
|
|
438
|
+
except Exception as e:
|
|
439
|
+
_handle_error(e)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@discovery_group.command("illnesses")
|
|
443
|
+
@click.option("--q", default=None, help="Keyword search.")
|
|
444
|
+
@click.option("--category", default=None, help="Filter by category.")
|
|
445
|
+
@click.option("--limit", default=20, show_default=True)
|
|
446
|
+
@click.option("--offset", default=0, show_default=True)
|
|
447
|
+
@click.pass_context
|
|
448
|
+
def discovery_illnesses(ctx, q, category, limit, offset) -> None:
|
|
449
|
+
"""Search illnesses by keyword or category."""
|
|
450
|
+
try:
|
|
451
|
+
_out(ctx, discovery.search_illnesses(_client(ctx), q, category, limit, offset))
|
|
452
|
+
except Exception as e:
|
|
453
|
+
_handle_error(e)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@discovery_group.command("recommendations")
|
|
457
|
+
@click.argument("illness_id", type=int)
|
|
458
|
+
@click.pass_context
|
|
459
|
+
def discovery_recommendations(ctx, illness_id: int) -> None:
|
|
460
|
+
"""Show health recommendations for a specific illness ID."""
|
|
461
|
+
try:
|
|
462
|
+
_out(ctx, discovery.get_illness_recommendations(_client(ctx), illness_id))
|
|
463
|
+
except Exception as e:
|
|
464
|
+
_handle_error(e)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@discovery_group.command("analyses")
|
|
468
|
+
@click.argument("patient_id")
|
|
469
|
+
@click.pass_context
|
|
470
|
+
def discovery_analyses(ctx, patient_id: str) -> None:
|
|
471
|
+
"""Show all saved discovery analyses for a patient."""
|
|
472
|
+
try:
|
|
473
|
+
_out(ctx, discovery.get_patient_analyses(_client(ctx), patient_id))
|
|
474
|
+
except Exception as e:
|
|
475
|
+
_handle_error(e)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@discovery_group.command("check")
|
|
479
|
+
@click.argument("symptom_ids", nargs=-1, type=int, required=True)
|
|
480
|
+
@click.pass_context
|
|
481
|
+
def discovery_check(ctx, symptom_ids) -> None:
|
|
482
|
+
"""Run illness checker for the given symptom IDs (space-separated)."""
|
|
483
|
+
try:
|
|
484
|
+
_out(ctx, discovery.check_illnesses(_client(ctx), list(symptom_ids)))
|
|
485
|
+
except Exception as e:
|
|
486
|
+
_handle_error(e)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ── Recommend commands ────────────────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
@cli.group("recommend")
|
|
492
|
+
def recommend_group() -> None:
|
|
493
|
+
"""HTM ML-based drug/product recommendations."""
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@recommend_group.command("get")
|
|
497
|
+
@click.argument("query_items")
|
|
498
|
+
@click.option("--top-n", default=3, show_default=True, help="Recommendations per item.")
|
|
499
|
+
@click.pass_context
|
|
500
|
+
def recommend_get(ctx, query_items: str, top_n: int) -> None:
|
|
501
|
+
"""Get ML recommendations for comma-separated product/drug names."""
|
|
502
|
+
try:
|
|
503
|
+
_out(ctx, recommend.get_recommendations(_client(ctx), query_items, top_n))
|
|
504
|
+
except Exception as e:
|
|
505
|
+
_handle_error(e)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ── REPL ──────────────────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
@cli.command("repl")
|
|
511
|
+
@click.option("--json", "json_mode", is_flag=True, default=False)
|
|
512
|
+
@click.pass_context
|
|
513
|
+
def repl(ctx, json_mode: bool) -> None:
|
|
514
|
+
"""Start interactive REPL session."""
|
|
515
|
+
skin = ReplSkin(cli, json_mode=json_mode or ctx.obj.get("json", False))
|
|
516
|
+
skin.run()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""DRMS Discovery v2 domain operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_complaints(client: DrmsClient, invalidate: bool = False) -> Any:
|
|
8
|
+
"""GET /discovery/v2/complaints — all symptoms and risk factors."""
|
|
9
|
+
return client.get("/discovery/v2/complaints", params={"invalidate": invalidate})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_screening_data(client: DrmsClient) -> Any:
|
|
13
|
+
"""GET /discovery/v2/screening — full screening dataset (illnesses, symptoms, risk factors)."""
|
|
14
|
+
return client.get("/discovery/v2/screening")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def search_illnesses(
|
|
18
|
+
client: DrmsClient,
|
|
19
|
+
q: Optional[str] = None,
|
|
20
|
+
category: Optional[str] = None,
|
|
21
|
+
limit: int = 20,
|
|
22
|
+
offset: int = 0,
|
|
23
|
+
) -> Any:
|
|
24
|
+
"""GET /discovery/v2/possible_illnesses_list — search illnesses by keyword or category."""
|
|
25
|
+
params: dict = {"limit": limit, "offset": offset}
|
|
26
|
+
if q:
|
|
27
|
+
params["q"] = q
|
|
28
|
+
if category:
|
|
29
|
+
params["category"] = category
|
|
30
|
+
return client.get("/discovery/v2/possible_illnesses_list", params=params)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_illness_recommendations(client: DrmsClient, illness_id: int) -> Any:
|
|
34
|
+
"""GET /discovery/v2/illness_recommendation/{illness_id} — health recommendations for an illness."""
|
|
35
|
+
return client.get(f"/discovery/v2/illness_recommendation/{illness_id}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_patient_analyses(client: DrmsClient, patient_id: str) -> Any:
|
|
39
|
+
"""GET /discovery/v2/get_all_analyses/{patient_id} — all saved analyses for a patient."""
|
|
40
|
+
return client.get(f"/discovery/v2/get_all_analyses/{patient_id}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_analysis(client: DrmsClient, patient_id: str, analyzed_id: str) -> Any:
|
|
44
|
+
"""GET /discovery/v2/get_analysis/{patient_id}/{analyzed_id} — single analysis record."""
|
|
45
|
+
return client.get(f"/discovery/v2/get_analysis/{patient_id}/{analyzed_id}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_illnesses(
|
|
49
|
+
client: DrmsClient,
|
|
50
|
+
symptom_ids: List[int],
|
|
51
|
+
known_illness_ids: Optional[List[int]] = None,
|
|
52
|
+
) -> Any:
|
|
53
|
+
"""POST /discovery/v2/checker — match illnesses from selected symptoms."""
|
|
54
|
+
return client.post("/discovery/v2/checker", data={
|
|
55
|
+
"main_sympt_ids": symptom_ids,
|
|
56
|
+
"known_illness_ids": known_illness_ids or [],
|
|
57
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""HTM member search and purchase history operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def search_members(client: DrmsClient, query: str, by_name: bool = False) -> Any:
|
|
8
|
+
"""GET /htm/search_members_v2 — search HTM members by IC or name."""
|
|
9
|
+
return client.get("/htm/search_members_v2", params={"query": query, "searchname": by_name})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def search_members_by_card(client: DrmsClient, query: str, by_name: bool = False) -> Any:
|
|
13
|
+
"""GET /htm/search_members_by_card_v2 — search members by loyalty card."""
|
|
14
|
+
return client.get("/htm/search_members_by_card_v2", params={"query": query, "searchname": by_name})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_purchases(client: DrmsClient, member_id: str) -> Any:
|
|
18
|
+
"""GET /htm/v3/search_purchases — purchase history for a member (v3 with remarks)."""
|
|
19
|
+
return client.get("/htm/v3/search_purchases", params={"member_id": member_id})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_lab_reports(client: DrmsClient, patient_id: str) -> Any:
|
|
23
|
+
"""GET /htm/htm_labreports/by_patient_id — HTM lab reports for a patient."""
|
|
24
|
+
return client.get("/htm/htm_labreports/by_patient_id", params={"PatientID": patient_id})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_lab_reports(client: DrmsClient) -> Any:
|
|
28
|
+
"""GET /htm/htm_labreports — all uploaded lab reports for the organisation."""
|
|
29
|
+
return client.get("/htm/htm_labreports")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""HTM ML-based drug/product recommendation operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from cli_anything.drms.core.client import DrmsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_recommendations(client: DrmsClient, query_items: str, top_n: int = 3) -> Any:
|
|
8
|
+
"""GET /htm/recommendations — ML-based product/drug recommendations.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
query_items: Comma-separated product/drug names or codes.
|
|
12
|
+
top_n: Number of recommendations to return per item.
|
|
13
|
+
"""
|
|
14
|
+
return client.get("/htm/recommendations", params={"query_items": query_items, "top_n": top_n})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Backend auth — calls /v1/auth/* endpoints instead of Firebase directly."""
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def login(base_url: str, email: str, password: str) -> dict:
|
|
7
|
+
"""POST /v1/auth/login → {firebase_token, refresh_token, user_id, email, display_name}"""
|
|
8
|
+
resp = requests.post(
|
|
9
|
+
f"{base_url}/v1/auth/login",
|
|
10
|
+
json={"email": email, "password": password, "origin": "drms-cli"},
|
|
11
|
+
timeout=15,
|
|
12
|
+
)
|
|
13
|
+
if resp.status_code == 401:
|
|
14
|
+
detail = resp.json().get("detail", "Authentication failed.")
|
|
15
|
+
raise ValueError(detail)
|
|
16
|
+
resp.raise_for_status()
|
|
17
|
+
return resp.json()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def refresh(base_url: str, refresh_token: str) -> dict:
|
|
21
|
+
"""POST /v1/auth/refresh → {firebase_token, refresh_token}"""
|
|
22
|
+
resp = requests.post(
|
|
23
|
+
f"{base_url}/v1/auth/refresh",
|
|
24
|
+
json={"refresh_token": refresh_token},
|
|
25
|
+
timeout=15,
|
|
26
|
+
)
|
|
27
|
+
if resp.status_code in (400, 401):
|
|
28
|
+
raise ValueError("Refresh token expired — please log in again.")
|
|
29
|
+
resp.raise_for_status()
|
|
30
|
+
return resp.json()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def logout_server(base_url: str, token: str) -> None:
|
|
34
|
+
"""POST /v1/auth/logout — revokes all refresh tokens server-side."""
|
|
35
|
+
try:
|
|
36
|
+
requests.post(
|
|
37
|
+
f"{base_url}/v1/auth/logout",
|
|
38
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
39
|
+
timeout=10,
|
|
40
|
+
)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Unified REPL skin for the DRMS CLI harness."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
BANNER = """\
|
|
8
|
+
╔══════════════════════════════════════════╗
|
|
9
|
+
║ DRMS / HTM CLI • type 'help' ║
|
|
10
|
+
╚══════════════════════════════════════════╝
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
PROMPT = "drms> "
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReplSkin:
|
|
17
|
+
"""Interactive REPL that delegates lines to a Click group."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, cli_group: click.Group, json_mode: bool = False):
|
|
20
|
+
self.cli = cli_group
|
|
21
|
+
self.json_mode = json_mode
|
|
22
|
+
self._history: list[str] = []
|
|
23
|
+
|
|
24
|
+
def push_history(self, line: str) -> None:
|
|
25
|
+
self._history.append(line)
|
|
26
|
+
|
|
27
|
+
def undo(self) -> Optional[str]:
|
|
28
|
+
return self._history.pop() if self._history else None
|
|
29
|
+
|
|
30
|
+
def _dispatch(self, line: str) -> None:
|
|
31
|
+
args = line.strip().split()
|
|
32
|
+
if not args:
|
|
33
|
+
return
|
|
34
|
+
if self.json_mode and "--json" not in args:
|
|
35
|
+
args.append("--json")
|
|
36
|
+
try:
|
|
37
|
+
self.cli.main(args=args, standalone_mode=False)
|
|
38
|
+
except click.UsageError as e:
|
|
39
|
+
click.echo(f"Error: {e}", err=True)
|
|
40
|
+
except SystemExit:
|
|
41
|
+
pass
|
|
42
|
+
except Exception as e:
|
|
43
|
+
click.echo(f"Error: {e}", err=True)
|
|
44
|
+
|
|
45
|
+
def run(self) -> None:
|
|
46
|
+
click.echo(BANNER)
|
|
47
|
+
while True:
|
|
48
|
+
try:
|
|
49
|
+
line = input(PROMPT).strip()
|
|
50
|
+
except (EOFError, KeyboardInterrupt):
|
|
51
|
+
click.echo("\nBye.")
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
if not line:
|
|
55
|
+
continue
|
|
56
|
+
if line in ("exit", "quit", "q"):
|
|
57
|
+
click.echo("Bye.")
|
|
58
|
+
break
|
|
59
|
+
if line == "undo":
|
|
60
|
+
last = self.undo()
|
|
61
|
+
click.echo(f"Removed from history: {last}" if last else "Nothing to undo.")
|
|
62
|
+
continue
|
|
63
|
+
if line in ("help", "?"):
|
|
64
|
+
self._dispatch("--help")
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
self.push_history(line)
|
|
68
|
+
self._dispatch(line)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session management: persist credentials to ~/.drms_session.json.
|
|
3
|
+
Locked writes prevent concurrent JSON corruption.
|
|
4
|
+
Auto-refreshes the token when it is older than 55 minutes
|
|
5
|
+
by calling the backend /v1/auth/refresh endpoint.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import fcntl
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SESSION_FILE = Path.home() / ".drms_session.json"
|
|
15
|
+
|
|
16
|
+
_TOKEN_TTL = 55 * 60 # refresh 5 min before Firebase's 1-hour expiry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_raw() -> dict:
|
|
20
|
+
if not SESSION_FILE.exists():
|
|
21
|
+
return {}
|
|
22
|
+
try:
|
|
23
|
+
with open(SESSION_FILE, "r") as f:
|
|
24
|
+
return json.load(f)
|
|
25
|
+
except (json.JSONDecodeError, OSError):
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _save_raw(data: dict) -> None:
|
|
30
|
+
tmp = SESSION_FILE.with_suffix(".tmp")
|
|
31
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
32
|
+
with os.fdopen(fd, "w") as f:
|
|
33
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
34
|
+
json.dump(data, f, indent=2)
|
|
35
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
36
|
+
tmp.replace(SESSION_FILE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_session() -> dict:
|
|
40
|
+
return _load_raw()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_session(
|
|
44
|
+
base_url: str,
|
|
45
|
+
token: str,
|
|
46
|
+
refresh_token: str,
|
|
47
|
+
local_id: str,
|
|
48
|
+
email: str,
|
|
49
|
+
) -> None:
|
|
50
|
+
data = _load_raw()
|
|
51
|
+
data.update(
|
|
52
|
+
base_url=base_url.rstrip("/"),
|
|
53
|
+
token=token,
|
|
54
|
+
refresh_token=refresh_token,
|
|
55
|
+
local_id=local_id,
|
|
56
|
+
email=email,
|
|
57
|
+
issued_at=time.time(),
|
|
58
|
+
)
|
|
59
|
+
_save_raw(data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_token(base_url: str, token: str) -> None:
|
|
63
|
+
"""Agent / CI mode: store only base_url + token, no auto-refresh."""
|
|
64
|
+
data = _load_raw()
|
|
65
|
+
data["base_url"] = base_url.rstrip("/")
|
|
66
|
+
data["token"] = token
|
|
67
|
+
data.pop("issued_at", None)
|
|
68
|
+
_save_raw(data)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clear_session() -> None:
|
|
72
|
+
if SESSION_FILE.exists():
|
|
73
|
+
SESSION_FILE.unlink()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def require_token() -> tuple[str, str]:
|
|
77
|
+
"""Return (base_url, token). Auto-refreshes via backend if token is near expiry."""
|
|
78
|
+
data = _load_raw()
|
|
79
|
+
base_url = data.get("base_url")
|
|
80
|
+
token = data.get("token")
|
|
81
|
+
|
|
82
|
+
if not base_url or not token:
|
|
83
|
+
raise RuntimeError("Not logged in. Run: drms-agent login")
|
|
84
|
+
|
|
85
|
+
issued_at = data.get("issued_at")
|
|
86
|
+
refresh_token = data.get("refresh_token")
|
|
87
|
+
|
|
88
|
+
if issued_at and refresh_token and (time.time() - issued_at) > _TOKEN_TTL:
|
|
89
|
+
try:
|
|
90
|
+
from cli_anything.drms.utils.auth import refresh as backend_refresh
|
|
91
|
+
new = backend_refresh(base_url, refresh_token)
|
|
92
|
+
token = new["firebase_token"]
|
|
93
|
+
data["token"] = token
|
|
94
|
+
data["refresh_token"] = new["refresh_token"]
|
|
95
|
+
data["issued_at"] = time.time()
|
|
96
|
+
_save_raw(data)
|
|
97
|
+
except Exception:
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
"Session expired and token refresh failed. Run: drms-agent login"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return base_url, token
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def update_session_key(key: str, value) -> None:
|
|
106
|
+
data = _load_raw()
|
|
107
|
+
data[key] = value
|
|
108
|
+
_save_raw(data)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-anything-drms
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI-Anything harness for the DRMS / HTM FastAPI backend
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click>=8.0
|
|
7
|
+
Requires-Dist: requests>=2.28
|
|
8
|
+
Dynamic: requires-dist
|
|
9
|
+
Dynamic: requires-python
|
|
10
|
+
Dynamic: summary
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
cli_anything/drms/__main__.py,sha256=qU3J1c7JySVCV4Io8SNGxSpSZiRrhxcOBwlK0fhspSg,81
|
|
2
|
+
cli_anything/drms/drms_cli.py,sha256=4Kt-Xi8rW_PlaLpfRUpHk-MeglEK5T-s1lP3va65XAY,17767
|
|
3
|
+
cli_anything/drms/core/case_notes.py,sha256=zjr3O5d7aDBoHJcjKxYBra8V7Z1c4jR4T5NwpzM4QwE,842
|
|
4
|
+
cli_anything/drms/core/client.py,sha256=s_Gk3ZNzHbAmdQWQrlUx7EHlx2RjN4vJNNog2rIhg5w,1620
|
|
5
|
+
cli_anything/drms/core/lab_reports.py,sha256=nbDrdW7FZ_bhBj9UAFZIg1s4wsCryutSbmSos6QdZJk,538
|
|
6
|
+
cli_anything/drms/core/medication.py,sha256=zIuBsRNAzBoPtX55ATL82vaSf_t2xoi4tfyK15KRnDE,646
|
|
7
|
+
cli_anything/drms/core/patients.py,sha256=GxTzBtzRQnQK5h7L6byJzYe8xR5MvYza2GY1zVeNI4E,658
|
|
8
|
+
cli_anything/drms/core/staff.py,sha256=E76I5kFxxI1TxxIhb66A377y6aHMPlnE30bJ4y9cr-g,322
|
|
9
|
+
cli_anything/drms/core/stats.py,sha256=yVwvHbivSAu9qaxmGAGh6QZY5W-klBoQeoN2MFgoDCM,1387
|
|
10
|
+
cli_anything/drms/drms_feature/discovery.py,sha256=xEqbc3686cppuUhXqwmuVEiPMNAsMFKM523vspVBzEA,2187
|
|
11
|
+
cli_anything/drms/drms_feature/member.py,sha256=c3fQThWlj9Rezfqot_r_hfUNVH9vDwVAlmPG_vExPvU,1329
|
|
12
|
+
cli_anything/drms/drms_feature/recommend.py,sha256=Z70NBQ0E30ul0tTjmqwc3gEYCVeh84vmcPILyc8oymg,544
|
|
13
|
+
cli_anything/drms/utils/auth.py,sha256=NDBg017hqmbDDWC5lXZmsqIPYCsJXtfwB6a8e3CuRK0,1366
|
|
14
|
+
cli_anything/drms/utils/repl_skin.py,sha256=rDOizguNoFcj4u3ovDny4A-GJYhW9Srgu4_1iFWicdg,2136
|
|
15
|
+
cli_anything/drms/utils/session.py,sha256=P7B-gXoyPzCxIdSk23PtzUM6T4ct8RxdmlCcZWbOyqo,2898
|
|
16
|
+
cli_anything_drms-0.1.0.dist-info/METADATA,sha256=0HPvi44jvy09uPjsQNvcWMllWnjDCkdqt6BgvKe09xQ,271
|
|
17
|
+
cli_anything_drms-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
18
|
+
cli_anything_drms-0.1.0.dist-info/entry_points.txt,sha256=nw6vskpiHOg3AetUUK5oRxqKWE_QPj3Yx33wERUJhVU,100
|
|
19
|
+
cli_anything_drms-0.1.0.dist-info/top_level.txt,sha256=LI1GTe19xehXrxQtg-3ltETALXYkoctC4Y_iuDiCSRo,13
|
|
20
|
+
cli_anything_drms-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cli_anything
|