cli-anything-medcare 0.1.0__tar.gz
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_medcare-0.1.0/PKG-INFO +10 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/__init__.py +0 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/__main__.py +4 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/core/__init__.py +0 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/core/case_notes.py +63 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/core/client.py +45 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/core/lab_reports.py +46 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/core/medication.py +20 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/core/patients.py +32 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/core/staff.py +12 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/medcare_cli.py +419 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/tests/test_core.py +159 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/tests/test_full_e2e.py +182 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/utils/auth.py +42 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/utils/repl_skin.py +76 -0
- cli_anything_medcare-0.1.0/cli_anything/medcare/utils/session.py +106 -0
- cli_anything_medcare-0.1.0/cli_anything_medcare.egg-info/PKG-INFO +10 -0
- cli_anything_medcare-0.1.0/cli_anything_medcare.egg-info/SOURCES.txt +23 -0
- cli_anything_medcare-0.1.0/cli_anything_medcare.egg-info/dependency_links.txt +1 -0
- cli_anything_medcare-0.1.0/cli_anything_medcare.egg-info/entry_points.txt +3 -0
- cli_anything_medcare-0.1.0/cli_anything_medcare.egg-info/requires.txt +2 -0
- cli_anything_medcare-0.1.0/cli_anything_medcare.egg-info/top_level.txt +1 -0
- cli_anything_medcare-0.1.0/pyproject.toml +3 -0
- cli_anything_medcare-0.1.0/setup.cfg +4 -0
- cli_anything_medcare-0.1.0/setup.py +19 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-anything-medcare
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI-Anything harness for the Medcare 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
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Case notes domain operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
from cli_anything.medcare.core.client import MedcareClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def list_case_notes(client: MedcareClient, patient_uuid: str) -> Any:
|
|
8
|
+
return client.get(f"/patient/case_notes/v2/{patient_uuid}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_case_note(client: MedcareClient, note_uuid: str) -> Any:
|
|
12
|
+
return client.get(f"/patient/case_notes/v2/note/{note_uuid}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_case_note(
|
|
16
|
+
client: MedcareClient,
|
|
17
|
+
patient_uuid: str,
|
|
18
|
+
note_date: str,
|
|
19
|
+
note_time: str,
|
|
20
|
+
title: Optional[str] = None,
|
|
21
|
+
details: Optional[str] = None,
|
|
22
|
+
remarks: Optional[str] = None,
|
|
23
|
+
actions: Optional[str] = None,
|
|
24
|
+
is_private: bool = False,
|
|
25
|
+
note_categories: Optional[List[str]] = None,
|
|
26
|
+
) -> Any:
|
|
27
|
+
payload = {
|
|
28
|
+
"patient_uuid": patient_uuid,
|
|
29
|
+
"note_date": note_date,
|
|
30
|
+
"note_time": note_time,
|
|
31
|
+
"title": title,
|
|
32
|
+
"details": details,
|
|
33
|
+
"remarks": remarks,
|
|
34
|
+
"actions": actions,
|
|
35
|
+
"is_private": is_private,
|
|
36
|
+
"note_categories": note_categories or [],
|
|
37
|
+
}
|
|
38
|
+
return client.post("/patient/case_notes/v2/create", data=payload)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def update_case_note(client: MedcareClient, note_uuid: str, payload: dict) -> Any:
|
|
42
|
+
return client.put(f"/patient/case_notes/v2/update/{note_uuid}", data=payload)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def delete_case_note(client: MedcareClient, note_uuid: str) -> Any:
|
|
46
|
+
return client.delete(f"/patient/case_notes/v2/delete/{note_uuid}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generate_case_note_report(
|
|
50
|
+
client: MedcareClient, patient_id: str, design_type: str = "new"
|
|
51
|
+
) -> Any:
|
|
52
|
+
return client.post(
|
|
53
|
+
f"/patient/case_notes/v2/report/{patient_id}",
|
|
54
|
+
data={"designtype": design_type},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_case_note_categories(client: MedcareClient) -> Any:
|
|
59
|
+
return client.get("/patient/case_notes/v2/categories")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def list_case_note_templates(client: MedcareClient) -> Any:
|
|
63
|
+
return client.get("/patient/case_notes/v2/templates")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""HTTP client wrapper for the Medcare FastAPI backend."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
import requests
|
|
6
|
+
from cli_anything.medcare.utils.session import require_token
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MedcareClient:
|
|
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,46 @@
|
|
|
1
|
+
"""Lab report domain operations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
from cli_anything.medcare.core.client import MedcareClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_lab_reports(client: MedcareClient, patient_uuid: str) -> Any:
|
|
9
|
+
return client.get(f"/health_info/lab_report/patient/{patient_uuid}")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_lab_report(client: MedcareClient, report_uuid: str) -> Any:
|
|
13
|
+
return client.get(f"/health_info/lab_report/get/{report_uuid}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_lab_trends(client: MedcareClient, patient_uuid: str) -> Any:
|
|
17
|
+
return client.get(f"/health_info/lab_report/patient/{patient_uuid}/trends")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def upload_lab_report(client: MedcareClient, patient_uuid: str, pdf_path: str) -> Any:
|
|
21
|
+
path = Path(pdf_path)
|
|
22
|
+
if not path.exists():
|
|
23
|
+
raise FileNotFoundError(f"PDF not found: {pdf_path}")
|
|
24
|
+
with open(path, "rb") as f:
|
|
25
|
+
return client.post(
|
|
26
|
+
f"/health_info/lab_report/upload/{patient_uuid}",
|
|
27
|
+
files={"file": (path.name, f, "application/pdf")},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def update_lab_report(
|
|
32
|
+
client: MedcareClient,
|
|
33
|
+
report_uuid: str,
|
|
34
|
+
remarks: Optional[str] = None,
|
|
35
|
+
is_verified: Optional[bool] = None,
|
|
36
|
+
) -> Any:
|
|
37
|
+
payload: dict = {}
|
|
38
|
+
if remarks is not None:
|
|
39
|
+
payload["remarks"] = remarks
|
|
40
|
+
if is_verified is not None:
|
|
41
|
+
payload["is_verified"] = is_verified
|
|
42
|
+
return client.put(f"/health_info/lab_report/update/{report_uuid}", data=payload)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def delete_lab_report(client: MedcareClient, report_uuid: str) -> Any:
|
|
46
|
+
return client.delete(f"/health_info/lab_report/delete/{report_uuid}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Medication domain operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from cli_anything.medcare.core.client import MedcareClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_medications(client: MedcareClient, patient_id: str) -> Any:
|
|
8
|
+
return client.get(f"/patient/v3/medication/{patient_id}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_daily_medication(client: MedcareClient, patient_id: str) -> Any:
|
|
12
|
+
return client.get(f"/daily-medication/{patient_id}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_medication_monitoring(client: MedcareClient, patient_id: str) -> Any:
|
|
16
|
+
return client.get(f"/medication-monitoring/{patient_id}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_health_summary(client: MedcareClient, patient_id: str) -> Any:
|
|
20
|
+
return client.get(f"/patient/v5/health-summary/{patient_id}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Patient domain operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from cli_anything.medcare.core.client import MedcareClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def search_patients(client: MedcareClient, term: str) -> Any:
|
|
8
|
+
return client.get(f"/patient/v2/search/{term}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_patients(client: MedcareClient, page: int = 1) -> Any:
|
|
12
|
+
return client.get(f"/patient/v2/all/{page}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_patient(client: MedcareClient, patient_id: str) -> Any:
|
|
16
|
+
return client.get(f"/patient/v2/get/{patient_id}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_patient(client: MedcareClient, payload: dict) -> Any:
|
|
20
|
+
return client.post("/patient/v2/create", data=payload)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def update_patient(client: MedcareClient, patient_id: str, payload: dict) -> Any:
|
|
24
|
+
return client.put(f"/patient/v2/update/{patient_id}", data=payload)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def delete_patient(client: MedcareClient, patient_id: str) -> Any:
|
|
28
|
+
return client.delete(f"/patient/v2/delete/{patient_id}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_patients_by_condition(client: MedcareClient, condition: str, page: int = 1) -> Any:
|
|
32
|
+
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.medcare.core.client import MedcareClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def list_staff(client: MedcareClient) -> Any:
|
|
8
|
+
return client.get("/admin/staffs")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_staff(client: MedcareClient, firebase_uid: str) -> Any:
|
|
12
|
+
return client.get(f"/admin/staff/{firebase_uid}")
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Medcare CLI — read-only data access for patients, lab reports, case notes, medication, staff."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from cli_anything.medcare.core.client import MedcareClient
|
|
11
|
+
from cli_anything.medcare.core import patients, lab_reports, case_notes, medication, staff
|
|
12
|
+
from cli_anything.medcare.utils.session import save_token, save_session, clear_session, get_session
|
|
13
|
+
from cli_anything.medcare.utils import auth as _auth
|
|
14
|
+
from cli_anything.medcare.utils.repl_skin import ReplSkin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
def _client(ctx: click.Context) -> MedcareClient:
|
|
20
|
+
return MedcareClient()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _out(ctx: click.Context, data) -> None:
|
|
24
|
+
if ctx.obj.get("json"):
|
|
25
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
26
|
+
else:
|
|
27
|
+
if isinstance(data, (dict, list)):
|
|
28
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
29
|
+
else:
|
|
30
|
+
click.echo(str(data))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _handle_error(e: Exception) -> None:
|
|
34
|
+
if isinstance(e, requests.HTTPError):
|
|
35
|
+
try:
|
|
36
|
+
detail = e.response.json().get("detail", str(e))
|
|
37
|
+
except Exception:
|
|
38
|
+
detail = str(e)
|
|
39
|
+
click.echo(f"API error {e.response.status_code}: {detail}", err=True)
|
|
40
|
+
else:
|
|
41
|
+
click.echo(f"Error: {e}", err=True)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── Root group ────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
@click.group()
|
|
48
|
+
@click.option("--json", "json_mode", is_flag=True, default=False, help="Output raw JSON.")
|
|
49
|
+
@click.pass_context
|
|
50
|
+
def cli(ctx: click.Context, json_mode: bool) -> None:
|
|
51
|
+
"""Medcare CLI — read-only data access for the Medcare backend."""
|
|
52
|
+
ctx.ensure_object(dict)
|
|
53
|
+
ctx.obj["json"] = json_mode
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Auth ──────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
@cli.command()
|
|
59
|
+
@click.option("--url", default="https://api.aipharm.xyz", show_default=True, help="Medcare API base URL.")
|
|
60
|
+
@click.option("--token", default=None, hidden=True, help="Pre-issued Firebase ID token (agent/CI use only — skips role check).")
|
|
61
|
+
def login(url: str, token: str) -> None:
|
|
62
|
+
"""Log in to Medcare. Prompts for email and password."""
|
|
63
|
+
# Agent / CI mode: token provided directly, skip interactive flow.
|
|
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
|
+
# Role check — only branch managers and admin users may use the CLI.
|
|
83
|
+
click.echo("Checking access…")
|
|
84
|
+
try:
|
|
85
|
+
c = MedcareClient(base_url=url, token=id_token)
|
|
86
|
+
profile = staff.get_staff(c, local_id)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
click.echo(f"Could not verify role: {e}", err=True)
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
is_branch_manager = profile.get("isBranchManager", False)
|
|
92
|
+
staff_role = profile.get("StaffRole", "")
|
|
93
|
+
|
|
94
|
+
if not (is_branch_manager or staff_role == "user"):
|
|
95
|
+
click.echo(
|
|
96
|
+
f"Access denied — role '{staff_role}' is not authorised to use this CLI.\n"
|
|
97
|
+
"Only branch managers and admin users are allowed.",
|
|
98
|
+
err=True,
|
|
99
|
+
)
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
save_session(url, id_token, resp["refresh_token"], local_id, email)
|
|
103
|
+
click.echo(f"Logged in as {email} (role: {staff_role}, branch manager: {is_branch_manager})")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@cli.command()
|
|
107
|
+
def logout() -> None:
|
|
108
|
+
"""Log out and revoke tokens server-side."""
|
|
109
|
+
data = get_session()
|
|
110
|
+
if data.get("token") and data.get("base_url"):
|
|
111
|
+
_auth.logout_server(data["base_url"], data["token"])
|
|
112
|
+
clear_session()
|
|
113
|
+
click.echo("Logged out.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@cli.command()
|
|
117
|
+
def whoami() -> None:
|
|
118
|
+
"""Show current session info."""
|
|
119
|
+
data = get_session()
|
|
120
|
+
if not data or not data.get("token"):
|
|
121
|
+
click.echo("Not logged in.")
|
|
122
|
+
return
|
|
123
|
+
click.echo(f"URL : {data.get('base_url', 'n/a')}")
|
|
124
|
+
click.echo(f"Email: {data.get('email', '(token mode)')}")
|
|
125
|
+
issued = data.get("issued_at")
|
|
126
|
+
if issued:
|
|
127
|
+
import time
|
|
128
|
+
age_min = int((time.time() - issued) / 60)
|
|
129
|
+
click.echo(f"Token: {age_min} min old (auto-refreshes at 55 min)")
|
|
130
|
+
else:
|
|
131
|
+
click.echo(f"Token: {data.get('token', '')[:24]}… (manual token mode)")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Patient commands ──────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
@cli.group()
|
|
137
|
+
def patient() -> None:
|
|
138
|
+
"""Patient search and lookup."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@patient.command("search")
|
|
142
|
+
@click.argument("term")
|
|
143
|
+
@click.pass_context
|
|
144
|
+
def patient_search(ctx, term: str) -> None:
|
|
145
|
+
"""Search patients by name, IC, or ref ID."""
|
|
146
|
+
try:
|
|
147
|
+
_out(ctx, patients.search_patients(_client(ctx), term))
|
|
148
|
+
except Exception as e:
|
|
149
|
+
_handle_error(e)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@patient.command("list")
|
|
153
|
+
@click.option("--page", default=1, show_default=True, help="Page number.")
|
|
154
|
+
@click.pass_context
|
|
155
|
+
def patient_list(ctx, page: int) -> None:
|
|
156
|
+
"""List patients (paginated)."""
|
|
157
|
+
try:
|
|
158
|
+
_out(ctx, patients.list_patients(_client(ctx), page))
|
|
159
|
+
except Exception as e:
|
|
160
|
+
_handle_error(e)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@patient.command("get")
|
|
164
|
+
@click.argument("patient_id")
|
|
165
|
+
@click.pass_context
|
|
166
|
+
def patient_get(ctx, patient_id: str) -> None:
|
|
167
|
+
"""Get a patient by ID."""
|
|
168
|
+
try:
|
|
169
|
+
_out(ctx, patients.get_patient(_client(ctx), patient_id))
|
|
170
|
+
except Exception as e:
|
|
171
|
+
_handle_error(e)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@patient.command("by-condition")
|
|
175
|
+
@click.argument("condition")
|
|
176
|
+
@click.option("--page", default=1, show_default=True)
|
|
177
|
+
@click.pass_context
|
|
178
|
+
def patient_by_condition(ctx, condition: str, page: int) -> None:
|
|
179
|
+
"""List patients by medical condition tag."""
|
|
180
|
+
try:
|
|
181
|
+
_out(ctx, patients.list_patients_by_condition(_client(ctx), condition, page))
|
|
182
|
+
except Exception as e:
|
|
183
|
+
_handle_error(e)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── Lab report commands ───────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
@cli.group()
|
|
189
|
+
def lab() -> None:
|
|
190
|
+
"""Lab report data and trends."""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@lab.command("list")
|
|
194
|
+
@click.argument("patient_uuid")
|
|
195
|
+
@click.pass_context
|
|
196
|
+
def lab_list(ctx, patient_uuid: str) -> None:
|
|
197
|
+
"""List all lab reports for a patient."""
|
|
198
|
+
try:
|
|
199
|
+
_out(ctx, lab_reports.get_lab_reports(_client(ctx), patient_uuid))
|
|
200
|
+
except Exception as e:
|
|
201
|
+
_handle_error(e)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@lab.command("get")
|
|
205
|
+
@click.argument("report_uuid")
|
|
206
|
+
@click.pass_context
|
|
207
|
+
def lab_get(ctx, report_uuid: str) -> None:
|
|
208
|
+
"""Get a single lab report with all results."""
|
|
209
|
+
try:
|
|
210
|
+
_out(ctx, lab_reports.get_lab_report(_client(ctx), report_uuid))
|
|
211
|
+
except Exception as e:
|
|
212
|
+
_handle_error(e)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@lab.command("trends")
|
|
216
|
+
@click.argument("patient_uuid")
|
|
217
|
+
@click.pass_context
|
|
218
|
+
def lab_trends(ctx, patient_uuid: str) -> None:
|
|
219
|
+
"""Show lab result trends for a patient."""
|
|
220
|
+
try:
|
|
221
|
+
_out(ctx, lab_reports.get_lab_trends(_client(ctx), patient_uuid))
|
|
222
|
+
except Exception as e:
|
|
223
|
+
_handle_error(e)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── Case note commands ────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
@cli.group()
|
|
229
|
+
def note() -> None:
|
|
230
|
+
"""Case note lookup and reference data."""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@note.command("list")
|
|
234
|
+
@click.argument("patient_uuid")
|
|
235
|
+
@click.pass_context
|
|
236
|
+
def note_list(ctx, patient_uuid: str) -> None:
|
|
237
|
+
"""List all case notes for a patient."""
|
|
238
|
+
try:
|
|
239
|
+
_out(ctx, case_notes.list_case_notes(_client(ctx), patient_uuid))
|
|
240
|
+
except Exception as e:
|
|
241
|
+
_handle_error(e)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@note.command("get")
|
|
245
|
+
@click.argument("note_uuid")
|
|
246
|
+
@click.pass_context
|
|
247
|
+
def note_get(ctx, note_uuid: str) -> None:
|
|
248
|
+
"""Get a single case note."""
|
|
249
|
+
try:
|
|
250
|
+
_out(ctx, case_notes.get_case_note(_client(ctx), note_uuid))
|
|
251
|
+
except Exception as e:
|
|
252
|
+
_handle_error(e)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@note.command("report")
|
|
256
|
+
@click.argument("patient_id")
|
|
257
|
+
@click.option("--design", default="new", type=click.Choice(["old", "new", "markdown"]), show_default=True)
|
|
258
|
+
@click.pass_context
|
|
259
|
+
def note_report(ctx, patient_id: str, design: str) -> None:
|
|
260
|
+
"""Generate a PDF case note report for a patient."""
|
|
261
|
+
try:
|
|
262
|
+
_out(ctx, case_notes.generate_case_note_report(_client(ctx), patient_id, design))
|
|
263
|
+
except Exception as e:
|
|
264
|
+
_handle_error(e)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@note.command("categories")
|
|
268
|
+
@click.pass_context
|
|
269
|
+
def note_categories(ctx) -> None:
|
|
270
|
+
"""List available case note categories."""
|
|
271
|
+
try:
|
|
272
|
+
_out(ctx, case_notes.list_case_note_categories(_client(ctx)))
|
|
273
|
+
except Exception as e:
|
|
274
|
+
_handle_error(e)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@note.command("templates")
|
|
278
|
+
@click.pass_context
|
|
279
|
+
def note_templates(ctx) -> None:
|
|
280
|
+
"""List available case note templates."""
|
|
281
|
+
try:
|
|
282
|
+
_out(ctx, case_notes.list_case_note_templates(_client(ctx)))
|
|
283
|
+
except Exception as e:
|
|
284
|
+
_handle_error(e)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ── Medication commands ───────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
@cli.group()
|
|
290
|
+
def med() -> None:
|
|
291
|
+
"""Medication and health summary."""
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@med.command("list")
|
|
295
|
+
@click.argument("patient_id")
|
|
296
|
+
@click.pass_context
|
|
297
|
+
def med_list(ctx, patient_id: str) -> None:
|
|
298
|
+
"""List medications for a patient."""
|
|
299
|
+
try:
|
|
300
|
+
_out(ctx, medication.get_medications(_client(ctx), patient_id))
|
|
301
|
+
except Exception as e:
|
|
302
|
+
_handle_error(e)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@med.command("daily")
|
|
306
|
+
@click.argument("patient_id")
|
|
307
|
+
@click.pass_context
|
|
308
|
+
def med_daily(ctx, patient_id: str) -> None:
|
|
309
|
+
"""Show daily medication schedule for a patient."""
|
|
310
|
+
try:
|
|
311
|
+
_out(ctx, medication.get_daily_medication(_client(ctx), patient_id))
|
|
312
|
+
except Exception as e:
|
|
313
|
+
_handle_error(e)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@med.command("monitoring")
|
|
317
|
+
@click.argument("patient_id")
|
|
318
|
+
@click.pass_context
|
|
319
|
+
def med_monitoring(ctx, patient_id: str) -> None:
|
|
320
|
+
"""Show medication monitoring data for a patient."""
|
|
321
|
+
try:
|
|
322
|
+
_out(ctx, medication.get_medication_monitoring(_client(ctx), patient_id))
|
|
323
|
+
except Exception as e:
|
|
324
|
+
_handle_error(e)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@med.command("summary")
|
|
328
|
+
@click.argument("patient_id")
|
|
329
|
+
@click.pass_context
|
|
330
|
+
def med_summary(ctx, patient_id: str) -> None:
|
|
331
|
+
"""Show health summary for a patient."""
|
|
332
|
+
try:
|
|
333
|
+
_out(ctx, medication.get_health_summary(_client(ctx), patient_id))
|
|
334
|
+
except Exception as e:
|
|
335
|
+
_handle_error(e)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ── Staff commands ────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
@cli.group()
|
|
341
|
+
def staff_cmd() -> None:
|
|
342
|
+
"""Staff and organisation lookup."""
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
staff_cmd.name = "staff"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@staff_cmd.command("list")
|
|
349
|
+
@click.pass_context
|
|
350
|
+
def staff_list(ctx) -> None:
|
|
351
|
+
"""List all staff in the organisation."""
|
|
352
|
+
try:
|
|
353
|
+
_out(ctx, staff.list_staff(_client(ctx)))
|
|
354
|
+
except Exception as e:
|
|
355
|
+
_handle_error(e)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@staff_cmd.command("get")
|
|
359
|
+
@click.argument("firebase_uid")
|
|
360
|
+
@click.pass_context
|
|
361
|
+
def staff_get(ctx, firebase_uid: str) -> None:
|
|
362
|
+
"""Get a staff member by Firebase UID."""
|
|
363
|
+
try:
|
|
364
|
+
_out(ctx, staff.get_staff(_client(ctx), firebase_uid))
|
|
365
|
+
except Exception as e:
|
|
366
|
+
_handle_error(e)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ── Preview command ───────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
@cli.command("preview")
|
|
372
|
+
@click.argument("patient_id")
|
|
373
|
+
@click.pass_context
|
|
374
|
+
def preview(ctx, patient_id: str) -> None:
|
|
375
|
+
"""Print a compact health snapshot for a patient."""
|
|
376
|
+
c = _client(ctx)
|
|
377
|
+
lines = []
|
|
378
|
+
try:
|
|
379
|
+
p = patients.get_patient(c, patient_id)
|
|
380
|
+
info = p.get("personal_info", p)
|
|
381
|
+
lines.append(f"Patient : {info.get('name', patient_id)}")
|
|
382
|
+
lines.append(f"IC : {info.get('ic', 'n/a')}")
|
|
383
|
+
lines.append(f"Gender : {info.get('gender', 'n/a')}")
|
|
384
|
+
lines.append(f"DOB : {info.get('date_of_birth', 'n/a')}")
|
|
385
|
+
except Exception:
|
|
386
|
+
lines.append(f"Patient : {patient_id} (fetch failed)")
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
labs = lab_reports.get_lab_reports(c, patient_id)
|
|
390
|
+
count = len(labs) if isinstance(labs, list) else labs.get("total", "?")
|
|
391
|
+
lines.append(f"Lab Rpts: {count}")
|
|
392
|
+
except Exception:
|
|
393
|
+
lines.append("Lab Rpts: (unavailable)")
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
notes = case_notes.list_case_notes(c, patient_id)
|
|
397
|
+
count = len(notes) if isinstance(notes, list) else notes.get("total", "?")
|
|
398
|
+
lines.append(f"Notes : {count}")
|
|
399
|
+
except Exception:
|
|
400
|
+
lines.append("Notes : (unavailable)")
|
|
401
|
+
|
|
402
|
+
if ctx.obj.get("json"):
|
|
403
|
+
click.echo(json.dumps({"patient_id": patient_id, "summary": lines}))
|
|
404
|
+
else:
|
|
405
|
+
click.echo("\n".join(lines))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ── REPL entry ────────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
@cli.command("repl")
|
|
411
|
+
@click.option("--json", "json_mode", is_flag=True, default=False)
|
|
412
|
+
@click.pass_context
|
|
413
|
+
def repl(ctx, json_mode: bool) -> None:
|
|
414
|
+
"""Start interactive REPL session."""
|
|
415
|
+
skin = ReplSkin(cli, json_mode=json_mode or ctx.obj.get("json", False))
|
|
416
|
+
skin.run()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
cli.add_command(staff_cmd, name="staff")
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Unit tests — no network, no real backend required."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
# ── Session helpers ───────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def _patch_session_file(tmp_path):
|
|
14
|
+
session_path = tmp_path / ".medcare_session.json"
|
|
15
|
+
return patch("cli_anything.medcare.utils.session.SESSION_FILE", session_path)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_session_save_load(tmp_path):
|
|
19
|
+
with _patch_session_file(tmp_path):
|
|
20
|
+
from cli_anything.medcare.utils.session import save_token, get_session
|
|
21
|
+
save_token("http://localhost:8000", "tok123")
|
|
22
|
+
data = get_session()
|
|
23
|
+
assert data["base_url"] == "http://localhost:8000"
|
|
24
|
+
assert data["token"] == "tok123"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_session_clear(tmp_path):
|
|
28
|
+
with _patch_session_file(tmp_path):
|
|
29
|
+
from cli_anything.medcare.utils.session import save_token, clear_session, get_session
|
|
30
|
+
save_token("http://localhost:8000", "tok123")
|
|
31
|
+
clear_session()
|
|
32
|
+
data = get_session()
|
|
33
|
+
assert data == {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_require_token_missing(tmp_path):
|
|
37
|
+
with _patch_session_file(tmp_path):
|
|
38
|
+
from cli_anything.medcare.utils.session import require_token
|
|
39
|
+
with pytest.raises(RuntimeError, match="Not logged in"):
|
|
40
|
+
require_token()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Client headers ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def test_client_headers():
|
|
46
|
+
from cli_anything.medcare.core.client import MedcareClient
|
|
47
|
+
c = MedcareClient(base_url="http://localhost:8000", token="mytoken")
|
|
48
|
+
assert c._headers() == {"Authorization": "Bearer mytoken"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── REPL skin ─────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def test_repl_skin_undo():
|
|
54
|
+
import click
|
|
55
|
+
from cli_anything.medcare.utils.repl_skin import ReplSkin
|
|
56
|
+
|
|
57
|
+
@click.group()
|
|
58
|
+
def dummy_cli():
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
skin = ReplSkin(dummy_cli)
|
|
62
|
+
skin.push_history("patient list")
|
|
63
|
+
skin.push_history("lab list abc")
|
|
64
|
+
assert skin.undo() == "lab list abc"
|
|
65
|
+
assert skin.undo() == "patient list"
|
|
66
|
+
assert skin.undo() is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_repl_skin_dispatch_calls_cli():
|
|
70
|
+
import click
|
|
71
|
+
from cli_anything.medcare.utils.repl_skin import ReplSkin
|
|
72
|
+
|
|
73
|
+
@click.group()
|
|
74
|
+
def dummy_cli():
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
called_with = []
|
|
78
|
+
|
|
79
|
+
def fake_main(args, standalone_mode):
|
|
80
|
+
called_with.extend(args)
|
|
81
|
+
|
|
82
|
+
dummy_cli.main = fake_main
|
|
83
|
+
skin = ReplSkin(dummy_cli)
|
|
84
|
+
skin._dispatch("patient list --page 2")
|
|
85
|
+
assert "patient" in called_with
|
|
86
|
+
assert "list" in called_with
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Patient functions ─────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def test_patient_search_url():
|
|
92
|
+
from cli_anything.medcare.core import patients
|
|
93
|
+
mock_client = MagicMock()
|
|
94
|
+
mock_client.get.return_value = []
|
|
95
|
+
patients.search_patients(mock_client, "john")
|
|
96
|
+
mock_client.get.assert_called_once_with("/patient/v2/search/john")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_patient_list_url():
|
|
100
|
+
from cli_anything.medcare.core import patients
|
|
101
|
+
mock_client = MagicMock()
|
|
102
|
+
mock_client.get.return_value = []
|
|
103
|
+
patients.list_patients(mock_client, page=3)
|
|
104
|
+
mock_client.get.assert_called_once_with("/patient/v2/all/3")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Lab report functions ──────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def test_lab_upload_missing_file():
|
|
110
|
+
from cli_anything.medcare.core import lab_reports
|
|
111
|
+
mock_client = MagicMock()
|
|
112
|
+
with pytest.raises(FileNotFoundError):
|
|
113
|
+
lab_reports.upload_lab_report(mock_client, "patient-uuid", "/nonexistent/file.pdf")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_lab_get_url():
|
|
117
|
+
from cli_anything.medcare.core import lab_reports
|
|
118
|
+
mock_client = MagicMock()
|
|
119
|
+
mock_client.get.return_value = {}
|
|
120
|
+
lab_reports.get_lab_report(mock_client, "report-uuid-123")
|
|
121
|
+
mock_client.get.assert_called_once_with("/health_info/lab_report/get/report-uuid-123")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Case note functions ───────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def test_case_note_create_payload():
|
|
127
|
+
from cli_anything.medcare.core import case_notes
|
|
128
|
+
mock_client = MagicMock()
|
|
129
|
+
mock_client.post.return_value = {"id": "note-uuid"}
|
|
130
|
+
case_notes.create_case_note(
|
|
131
|
+
mock_client,
|
|
132
|
+
patient_uuid="p-uuid",
|
|
133
|
+
note_date="2025-01-15",
|
|
134
|
+
note_time="10.30 A.M.",
|
|
135
|
+
title="Follow-up",
|
|
136
|
+
details="Patient stable.",
|
|
137
|
+
)
|
|
138
|
+
call_kwargs = mock_client.post.call_args
|
|
139
|
+
payload = call_kwargs[1]["data"] if call_kwargs[1] else call_kwargs[0][1]
|
|
140
|
+
assert payload["patient_uuid"] == "p-uuid"
|
|
141
|
+
assert payload["note_date"] == "2025-01-15"
|
|
142
|
+
assert payload["title"] == "Follow-up"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── Medication functions ──────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def test_medication_paths():
|
|
148
|
+
from cli_anything.medcare.core import medication
|
|
149
|
+
mock_client = MagicMock()
|
|
150
|
+
mock_client.get.return_value = {}
|
|
151
|
+
|
|
152
|
+
medication.get_medications(mock_client, "pat-1")
|
|
153
|
+
mock_client.get.assert_called_with("/patient/v3/medication/pat-1")
|
|
154
|
+
|
|
155
|
+
medication.get_daily_medication(mock_client, "pat-1")
|
|
156
|
+
mock_client.get.assert_called_with("/daily-medication/pat-1")
|
|
157
|
+
|
|
158
|
+
medication.get_health_summary(mock_client, "pat-1")
|
|
159
|
+
mock_client.get.assert_called_with("/patient/v5/health-summary/pat-1")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-end tests against a live Medcare backend.
|
|
3
|
+
|
|
4
|
+
Required env vars:
|
|
5
|
+
MEDCARE_TEST_URL — e.g. http://localhost:8000
|
|
6
|
+
MEDCARE_TEST_TOKEN — valid Firebase ID token
|
|
7
|
+
MEDCARE_TEST_PATIENT_UUID — UUID of an existing patient
|
|
8
|
+
|
|
9
|
+
Run with:
|
|
10
|
+
MEDCARE_TEST_URL=... MEDCARE_TEST_TOKEN=... MEDCARE_TEST_PATIENT_UUID=... pytest test_full_e2e.py -v
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
URL = os.environ.get("MEDCARE_TEST_URL", "")
|
|
22
|
+
TOKEN = os.environ.get("MEDCARE_TEST_TOKEN", "")
|
|
23
|
+
PATIENT_UUID = os.environ.get("MEDCARE_TEST_PATIENT_UUID", "")
|
|
24
|
+
|
|
25
|
+
skip_live = pytest.mark.skipif(
|
|
26
|
+
not (URL and TOKEN),
|
|
27
|
+
reason="MEDCARE_TEST_URL and MEDCARE_TEST_TOKEN not set",
|
|
28
|
+
)
|
|
29
|
+
skip_patient = pytest.mark.skipif(
|
|
30
|
+
not PATIENT_UUID,
|
|
31
|
+
reason="MEDCARE_TEST_PATIENT_UUID not set",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_cli() -> str:
|
|
36
|
+
"""Return the installed CLI path or fall back to python -m."""
|
|
37
|
+
force_installed = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED")
|
|
38
|
+
installed = shutil.which("medcare-agent")
|
|
39
|
+
if installed:
|
|
40
|
+
return installed
|
|
41
|
+
if force_installed:
|
|
42
|
+
pytest.fail("CLI_ANYTHING_FORCE_INSTALLED=1 but medcare-agent not found in PATH")
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run(*args: str) -> subprocess.CompletedProcess:
|
|
47
|
+
cli_path = _resolve_cli()
|
|
48
|
+
if cli_path:
|
|
49
|
+
cmd = [cli_path] + list(args)
|
|
50
|
+
else:
|
|
51
|
+
cmd = [sys.executable, "-m", "cli_anything.medcare"] + list(args)
|
|
52
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _run_json(*args: str) -> dict | list:
|
|
56
|
+
result = _run(*args, "--json")
|
|
57
|
+
assert result.returncode == 0, result.stderr
|
|
58
|
+
return json.loads(result.stdout)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Auth ──────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
@skip_live
|
|
64
|
+
def test_login_logout():
|
|
65
|
+
result = _run("login", "--url", URL, "--token", TOKEN)
|
|
66
|
+
assert result.returncode == 0
|
|
67
|
+
assert "Session saved" in result.stdout
|
|
68
|
+
|
|
69
|
+
result = _run("whoami")
|
|
70
|
+
assert result.returncode == 0
|
|
71
|
+
assert URL in result.stdout
|
|
72
|
+
|
|
73
|
+
result = _run("logout")
|
|
74
|
+
assert result.returncode == 0
|
|
75
|
+
|
|
76
|
+
# Re-login for subsequent tests
|
|
77
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── Patients ──────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
@skip_live
|
|
83
|
+
def test_patient_list():
|
|
84
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
85
|
+
data = _run_json("patient", "list")
|
|
86
|
+
assert isinstance(data, (list, dict))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@skip_live
|
|
90
|
+
def test_patient_search():
|
|
91
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
92
|
+
result = _run("--json", "patient", "search", "a")
|
|
93
|
+
assert result.returncode == 0
|
|
94
|
+
data = json.loads(result.stdout)
|
|
95
|
+
assert isinstance(data, (list, dict))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@skip_live
|
|
99
|
+
@skip_patient
|
|
100
|
+
def test_patient_get():
|
|
101
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
102
|
+
result = _run("--json", "patient", "get", PATIENT_UUID)
|
|
103
|
+
assert result.returncode == 0
|
|
104
|
+
data = json.loads(result.stdout)
|
|
105
|
+
assert "personal_info" in data or isinstance(data, dict)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── Lab Reports ───────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
@skip_live
|
|
111
|
+
@skip_patient
|
|
112
|
+
def test_lab_list():
|
|
113
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
114
|
+
result = _run("--json", "lab", "list", PATIENT_UUID)
|
|
115
|
+
assert result.returncode == 0
|
|
116
|
+
data = json.loads(result.stdout)
|
|
117
|
+
assert isinstance(data, (list, dict))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@skip_live
|
|
121
|
+
@skip_patient
|
|
122
|
+
def test_lab_trends():
|
|
123
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
124
|
+
result = _run("--json", "lab", "trends", PATIENT_UUID)
|
|
125
|
+
assert result.returncode == 0
|
|
126
|
+
data = json.loads(result.stdout)
|
|
127
|
+
assert isinstance(data, (list, dict))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── Case Notes ────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
@skip_live
|
|
133
|
+
@skip_patient
|
|
134
|
+
def test_note_list():
|
|
135
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
136
|
+
result = _run("--json", "note", "list", PATIENT_UUID)
|
|
137
|
+
assert result.returncode == 0
|
|
138
|
+
data = json.loads(result.stdout)
|
|
139
|
+
assert isinstance(data, (list, dict))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@skip_live
|
|
143
|
+
def test_note_categories():
|
|
144
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
145
|
+
result = _run("--json", "note", "categories")
|
|
146
|
+
assert result.returncode == 0
|
|
147
|
+
data = json.loads(result.stdout)
|
|
148
|
+
assert isinstance(data, (list, dict))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── Staff ─────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
@skip_live
|
|
154
|
+
def test_staff_list():
|
|
155
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
156
|
+
result = _run("--json", "staff", "list")
|
|
157
|
+
assert result.returncode == 0
|
|
158
|
+
data = json.loads(result.stdout)
|
|
159
|
+
assert isinstance(data, (list, dict))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Preview ───────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
@skip_live
|
|
165
|
+
@skip_patient
|
|
166
|
+
def test_preview():
|
|
167
|
+
_run("login", "--url", URL, "--token", TOKEN)
|
|
168
|
+
result = _run("preview", PATIENT_UUID)
|
|
169
|
+
assert result.returncode == 0
|
|
170
|
+
assert "Patient" in result.stdout
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── Installed command ─────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def test_installed_command_help():
|
|
176
|
+
"""Verify the installed CLI entry point runs."""
|
|
177
|
+
cli_path = _resolve_cli()
|
|
178
|
+
if not cli_path:
|
|
179
|
+
pytest.skip("medcare-agent not installed; install with: pip install -e .")
|
|
180
|
+
result = subprocess.run([cli_path, "--help"], capture_output=True, text=True)
|
|
181
|
+
assert result.returncode == 0
|
|
182
|
+
assert "Medcare" in result.stdout
|
|
@@ -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": "medcare-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() # LoginResponse shape
|
|
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() # RefreshTokenResponse shape
|
|
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 # best-effort; local session is cleared regardless
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Unified REPL skin for the Medcare CLI harness."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
BANNER = """\
|
|
10
|
+
╔══════════════════════════════════════════╗
|
|
11
|
+
║ Medcare CLI • type 'help' ║
|
|
12
|
+
╚══════════════════════════════════════════╝
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
PROMPT = "medcare> "
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReplSkin:
|
|
19
|
+
"""Interactive REPL that delegates lines to a Click group."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, cli_group: click.Group, json_mode: bool = False):
|
|
22
|
+
self.cli = cli_group
|
|
23
|
+
self.json_mode = json_mode
|
|
24
|
+
self._history: list[str] = []
|
|
25
|
+
|
|
26
|
+
# ── History ──────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def push_history(self, line: str) -> None:
|
|
29
|
+
self._history.append(line)
|
|
30
|
+
|
|
31
|
+
def undo(self) -> Optional[str]:
|
|
32
|
+
return self._history.pop() if self._history else None
|
|
33
|
+
|
|
34
|
+
# ── Dispatch ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def _dispatch(self, line: str) -> None:
|
|
37
|
+
args = line.strip().split()
|
|
38
|
+
if not args:
|
|
39
|
+
return
|
|
40
|
+
if self.json_mode and "--json" not in args:
|
|
41
|
+
args.append("--json")
|
|
42
|
+
try:
|
|
43
|
+
self.cli.main(args=args, standalone_mode=False)
|
|
44
|
+
except click.UsageError as e:
|
|
45
|
+
click.echo(f"Error: {e}", err=True)
|
|
46
|
+
except SystemExit:
|
|
47
|
+
pass
|
|
48
|
+
except Exception as e:
|
|
49
|
+
click.echo(f"Error: {e}", err=True)
|
|
50
|
+
|
|
51
|
+
# ── Run ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def run(self) -> None:
|
|
54
|
+
click.echo(BANNER)
|
|
55
|
+
while True:
|
|
56
|
+
try:
|
|
57
|
+
line = input(PROMPT).strip()
|
|
58
|
+
except (EOFError, KeyboardInterrupt):
|
|
59
|
+
click.echo("\nBye.")
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
if not line:
|
|
63
|
+
continue
|
|
64
|
+
if line in ("exit", "quit", "q"):
|
|
65
|
+
click.echo("Bye.")
|
|
66
|
+
break
|
|
67
|
+
if line == "undo":
|
|
68
|
+
last = self.undo()
|
|
69
|
+
click.echo(f"Removed from history: {last}" if last else "Nothing to undo.")
|
|
70
|
+
continue
|
|
71
|
+
if line in ("help", "?"):
|
|
72
|
+
self._dispatch("--help")
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
self.push_history(line)
|
|
76
|
+
self._dispatch(line)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session management: persist credentials to ~/.medcare_session.json.
|
|
3
|
+
Locked writes prevent concurrent JSON corruption.
|
|
4
|
+
Auto-refreshes the Firebase ID 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 time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
SESSION_FILE = Path.home() / ".medcare_session.json"
|
|
14
|
+
|
|
15
|
+
_TOKEN_TTL = 55 * 60 # refresh 5 min before Firebase's 1-hour expiry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_raw() -> dict:
|
|
19
|
+
if not SESSION_FILE.exists():
|
|
20
|
+
return {}
|
|
21
|
+
try:
|
|
22
|
+
with open(SESSION_FILE, "r") as f:
|
|
23
|
+
return json.load(f)
|
|
24
|
+
except (json.JSONDecodeError, OSError):
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _save_raw(data: dict) -> None:
|
|
29
|
+
tmp = SESSION_FILE.with_suffix(".tmp")
|
|
30
|
+
with open(tmp, "w") as f:
|
|
31
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
32
|
+
json.dump(data, f, indent=2)
|
|
33
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
34
|
+
tmp.replace(SESSION_FILE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_session() -> dict:
|
|
38
|
+
return _load_raw()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_session(
|
|
42
|
+
base_url: str,
|
|
43
|
+
token: str,
|
|
44
|
+
refresh_token: str,
|
|
45
|
+
local_id: str,
|
|
46
|
+
email: str,
|
|
47
|
+
) -> None:
|
|
48
|
+
data = _load_raw()
|
|
49
|
+
data.update(
|
|
50
|
+
base_url=base_url.rstrip("/"),
|
|
51
|
+
token=token,
|
|
52
|
+
refresh_token=refresh_token,
|
|
53
|
+
local_id=local_id,
|
|
54
|
+
email=email,
|
|
55
|
+
issued_at=time.time(),
|
|
56
|
+
)
|
|
57
|
+
_save_raw(data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_token(base_url: str, token: str) -> None:
|
|
61
|
+
"""Agent / CI mode: store only base_url + token, no auto-refresh."""
|
|
62
|
+
data = _load_raw()
|
|
63
|
+
data["base_url"] = base_url.rstrip("/")
|
|
64
|
+
data["token"] = token
|
|
65
|
+
data.pop("issued_at", None) # absent = skip auto-refresh
|
|
66
|
+
_save_raw(data)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def clear_session() -> None:
|
|
70
|
+
if SESSION_FILE.exists():
|
|
71
|
+
SESSION_FILE.unlink()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def require_token() -> tuple[str, str]:
|
|
75
|
+
"""Return (base_url, token). Auto-refreshes via backend if token is near expiry."""
|
|
76
|
+
data = _load_raw()
|
|
77
|
+
base_url = data.get("base_url")
|
|
78
|
+
token = data.get("token")
|
|
79
|
+
|
|
80
|
+
if not base_url or not token:
|
|
81
|
+
raise RuntimeError("Not logged in. Run: medcare-agent login")
|
|
82
|
+
|
|
83
|
+
issued_at = data.get("issued_at")
|
|
84
|
+
refresh_token = data.get("refresh_token")
|
|
85
|
+
|
|
86
|
+
if issued_at and refresh_token and (time.time() - issued_at) > _TOKEN_TTL:
|
|
87
|
+
try:
|
|
88
|
+
from cli_anything.medcare.utils.auth import refresh as backend_refresh
|
|
89
|
+
new = backend_refresh(base_url, refresh_token)
|
|
90
|
+
token = new["firebase_token"]
|
|
91
|
+
data["token"] = token
|
|
92
|
+
data["refresh_token"] = new["refresh_token"]
|
|
93
|
+
data["issued_at"] = time.time()
|
|
94
|
+
_save_raw(data)
|
|
95
|
+
except Exception:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
"Session expired and token refresh failed. Run: medcare-agent login"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return base_url, token
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def update_session_key(key: str, value) -> None:
|
|
104
|
+
data = _load_raw()
|
|
105
|
+
data[key] = value
|
|
106
|
+
_save_raw(data)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-anything-medcare
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI-Anything harness for the Medcare 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,23 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
setup.py
|
|
3
|
+
cli_anything/medcare/__init__.py
|
|
4
|
+
cli_anything/medcare/__main__.py
|
|
5
|
+
cli_anything/medcare/medcare_cli.py
|
|
6
|
+
cli_anything/medcare/core/__init__.py
|
|
7
|
+
cli_anything/medcare/core/case_notes.py
|
|
8
|
+
cli_anything/medcare/core/client.py
|
|
9
|
+
cli_anything/medcare/core/lab_reports.py
|
|
10
|
+
cli_anything/medcare/core/medication.py
|
|
11
|
+
cli_anything/medcare/core/patients.py
|
|
12
|
+
cli_anything/medcare/core/staff.py
|
|
13
|
+
cli_anything/medcare/tests/test_core.py
|
|
14
|
+
cli_anything/medcare/tests/test_full_e2e.py
|
|
15
|
+
cli_anything/medcare/utils/auth.py
|
|
16
|
+
cli_anything/medcare/utils/repl_skin.py
|
|
17
|
+
cli_anything/medcare/utils/session.py
|
|
18
|
+
cli_anything_medcare.egg-info/PKG-INFO
|
|
19
|
+
cli_anything_medcare.egg-info/SOURCES.txt
|
|
20
|
+
cli_anything_medcare.egg-info/dependency_links.txt
|
|
21
|
+
cli_anything_medcare.egg-info/entry_points.txt
|
|
22
|
+
cli_anything_medcare.egg-info/requires.txt
|
|
23
|
+
cli_anything_medcare.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cli_anything
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from setuptools import setup, find_namespace_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="cli-anything-medcare",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
description="CLI-Anything harness for the Medcare FastAPI backend",
|
|
7
|
+
packages=find_namespace_packages(include=["cli_anything.*"]),
|
|
8
|
+
install_requires=[
|
|
9
|
+
"click>=8.0",
|
|
10
|
+
"requests>=2.28",
|
|
11
|
+
],
|
|
12
|
+
entry_points={
|
|
13
|
+
"console_scripts": [
|
|
14
|
+
"medcare-agent=cli_anything.medcare.medcare_cli:cli",
|
|
15
|
+
"medcare=cli_anything.medcare.medcare_cli:cli",
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
python_requires=">=3.10",
|
|
19
|
+
)
|