healthcloud-tenantadmin 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.
- healthcloud_tenantadmin-0.1.0/.gitignore +81 -0
- healthcloud_tenantadmin-0.1.0/PKG-INFO +58 -0
- healthcloud_tenantadmin-0.1.0/README.md +42 -0
- healthcloud_tenantadmin-0.1.0/healthcloud_tenantadmin/__init__.py +16 -0
- healthcloud_tenantadmin-0.1.0/healthcloud_tenantadmin/clients/__init__.py +3 -0
- healthcloud_tenantadmin-0.1.0/healthcloud_tenantadmin/clients/tenant_admin.py +134 -0
- healthcloud_tenantadmin-0.1.0/healthcloud_tenantadmin/config.py +37 -0
- healthcloud_tenantadmin-0.1.0/healthcloud_tenantadmin/errors.py +13 -0
- healthcloud_tenantadmin-0.1.0/healthcloud_tenantadmin/http.py +69 -0
- healthcloud_tenantadmin-0.1.0/healthcloud_tenantadmin/models.py +102 -0
- healthcloud_tenantadmin-0.1.0/healthcloud_tenantadmin/sdk.py +55 -0
- healthcloud_tenantadmin-0.1.0/pyproject.toml +31 -0
- healthcloud_tenantadmin-0.1.0/tests/__init__.py +0 -0
- healthcloud_tenantadmin-0.1.0/tests/test_tenant_admin_client.py +70 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
logs/
|
|
3
|
+
*.log
|
|
4
|
+
npm-debug.log*
|
|
5
|
+
yarn-debug.log*
|
|
6
|
+
yarn-error.log*
|
|
7
|
+
pnpm-debug.log*
|
|
8
|
+
|
|
9
|
+
# Environment files
|
|
10
|
+
.env
|
|
11
|
+
.env.*
|
|
12
|
+
!.env.example
|
|
13
|
+
**/live-state.json
|
|
14
|
+
|
|
15
|
+
# Node / npm
|
|
16
|
+
node_modules/
|
|
17
|
+
.npm/
|
|
18
|
+
.pnpm-store/
|
|
19
|
+
dist/
|
|
20
|
+
!packages/npm/dist/
|
|
21
|
+
!packages/npm/dist/**
|
|
22
|
+
build/
|
|
23
|
+
|
|
24
|
+
# packages/cli/npm bundles the manifest from packages/cli/contracts at build/
|
|
25
|
+
# test time (scripts/copy-manifest.cjs) — that copy is generated, not source.
|
|
26
|
+
packages/cli/npm/contracts/
|
|
27
|
+
coverage/
|
|
28
|
+
*.tsbuildinfo
|
|
29
|
+
|
|
30
|
+
# Python / pip
|
|
31
|
+
__pycache__/
|
|
32
|
+
*.py[cod]
|
|
33
|
+
*.pyo
|
|
34
|
+
.Python
|
|
35
|
+
.venv/
|
|
36
|
+
venv/
|
|
37
|
+
env/
|
|
38
|
+
pip-wheel-metadata/
|
|
39
|
+
*.egg-info/
|
|
40
|
+
.pytest_cache/
|
|
41
|
+
.mypy_cache/
|
|
42
|
+
.ruff_cache/
|
|
43
|
+
htmlcov/
|
|
44
|
+
|
|
45
|
+
# Java / Maven
|
|
46
|
+
target/
|
|
47
|
+
.mvn/timing.properties
|
|
48
|
+
.mvn/wrapper/maven-wrapper.jar
|
|
49
|
+
*.class
|
|
50
|
+
*.jar
|
|
51
|
+
*.war
|
|
52
|
+
*.ear
|
|
53
|
+
|
|
54
|
+
# .NET / NuGet
|
|
55
|
+
# Scoped to packages/nuget/ specifically — an unscoped `bin/`/`obj/` matches
|
|
56
|
+
# a directory of that name at ANY depth (gitignore has no implicit anchor),
|
|
57
|
+
# which silently swallowed the real source file packages/cli/npm/src/bin/hc.ts
|
|
58
|
+
# and broke that package's CI build (the file was never committed).
|
|
59
|
+
packages/nuget/**/bin/
|
|
60
|
+
packages/nuget/**/obj/
|
|
61
|
+
TestResults/
|
|
62
|
+
*.nupkg
|
|
63
|
+
*.snupkg
|
|
64
|
+
*.user
|
|
65
|
+
*.suo
|
|
66
|
+
|
|
67
|
+
# IDEs and editors
|
|
68
|
+
.idea/
|
|
69
|
+
.vscode/
|
|
70
|
+
*.swp
|
|
71
|
+
*.swo
|
|
72
|
+
.DS_Store
|
|
73
|
+
Thumbs.db
|
|
74
|
+
|
|
75
|
+
# Build outputs and caches
|
|
76
|
+
out/
|
|
77
|
+
tmp/
|
|
78
|
+
temp/
|
|
79
|
+
.cache/
|
|
80
|
+
.parcel-cache/
|
|
81
|
+
.turbo/
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: healthcloud-tenantadmin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HealthCloud Tenant Admin SDK for Python — manage tenant admins and auth.
|
|
5
|
+
Project-URL: Repository, https://github.com/healthcloudservices/healthcloud-sdk
|
|
6
|
+
Author: Healthcheck Systems Inc
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: api,health-cloud,healthcare,healthcloud,sdk,tenant-admin
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Requires-Dist: httpx>=0.27.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# healthcloud-tenantadmin
|
|
18
|
+
|
|
19
|
+
Python SDK for the HealthCloud Tenant Admin API — Python counterpart to the
|
|
20
|
+
`@healthcloudai/hc-tenantadmin` npm package (`packages/npm-tenantadmin`).
|
|
21
|
+
Same routes, same behavior, same field names — kept in sync with
|
|
22
|
+
`HEALTH_CLOUD/healthcloud-services/hc/apis/tenantadmin/src/app.py`.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
This public package is published to PyPI alongside the other HealthCloud SDKs:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install healthcloud-tenantadmin
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Pin the version in applications and CI when reproducible installs are required:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
healthcloud-tenantadmin==0.1.0
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from healthcloud_tenantadmin import HCTenantAdminSDK
|
|
42
|
+
|
|
43
|
+
sdk = HCTenantAdminSDK(environment="dev", tenant_id="acme")
|
|
44
|
+
sdk.admin.login(email="admin@acme.com", password="...")
|
|
45
|
+
|
|
46
|
+
# List all Tenant Admins for the caller's own tenant (resolved from the
|
|
47
|
+
# verified JWT — tenant_id is never accepted as a parameter)
|
|
48
|
+
admins = sdk.admin.get_admins_for_tenant()
|
|
49
|
+
for a in admins:
|
|
50
|
+
print(a.admin_id, a.email, a.status)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install -e ".[dev]"
|
|
57
|
+
pytest
|
|
58
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# healthcloud-tenantadmin
|
|
2
|
+
|
|
3
|
+
Python SDK for the HealthCloud Tenant Admin API — Python counterpart to the
|
|
4
|
+
`@healthcloudai/hc-tenantadmin` npm package (`packages/npm-tenantadmin`).
|
|
5
|
+
Same routes, same behavior, same field names — kept in sync with
|
|
6
|
+
`HEALTH_CLOUD/healthcloud-services/hc/apis/tenantadmin/src/app.py`.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
This public package is published to PyPI alongside the other HealthCloud SDKs:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install healthcloud-tenantadmin
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Pin the version in applications and CI when reproducible installs are required:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
healthcloud-tenantadmin==0.1.0
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from healthcloud_tenantadmin import HCTenantAdminSDK
|
|
26
|
+
|
|
27
|
+
sdk = HCTenantAdminSDK(environment="dev", tenant_id="acme")
|
|
28
|
+
sdk.admin.login(email="admin@acme.com", password="...")
|
|
29
|
+
|
|
30
|
+
# List all Tenant Admins for the caller's own tenant (resolved from the
|
|
31
|
+
# verified JWT — tenant_id is never accepted as a parameter)
|
|
32
|
+
admins = sdk.admin.get_admins_for_tenant()
|
|
33
|
+
for a in admins:
|
|
34
|
+
print(a.admin_id, a.email, a.status)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Development
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e ".[dev]"
|
|
41
|
+
pytest
|
|
42
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .config import Environment, TenantAdminSDKConfig
|
|
4
|
+
from .errors import SDKError
|
|
5
|
+
from .models import TenantAdmin, TenantAdminProfile, TenantAccountProfile
|
|
6
|
+
from .sdk import HCTenantAdminSDK
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Environment",
|
|
10
|
+
"TenantAdminSDKConfig",
|
|
11
|
+
"SDKError",
|
|
12
|
+
"TenantAdmin",
|
|
13
|
+
"TenantAdminProfile",
|
|
14
|
+
"TenantAccountProfile",
|
|
15
|
+
"HCTenantAdminSDK",
|
|
16
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from ..config import TenantAdminSDKConfig, with_tenant_id
|
|
6
|
+
from ..http import HttpClient
|
|
7
|
+
from ..models import TenantAdmin, TenantAdminProfile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TenantAdminClient:
|
|
11
|
+
"""Service client for the HC Tenant Admin API.
|
|
12
|
+
|
|
13
|
+
Covers:
|
|
14
|
+
- Tenant/account registration (public — also creates the first admin)
|
|
15
|
+
- Tenant Admin auth: self-registration, invite, login, refresh,
|
|
16
|
+
verify-email, reset-password
|
|
17
|
+
- Profile: current admin (JWT identity) and admin CRUD by id, plus
|
|
18
|
+
listing all admins for the caller's own tenant
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, http: HttpClient, config: TenantAdminSDKConfig):
|
|
22
|
+
self._http = http
|
|
23
|
+
self._config = config
|
|
24
|
+
|
|
25
|
+
# ─── Tenant/account registration (public) ────────────────────────────
|
|
26
|
+
|
|
27
|
+
def register_tenant_account(self, **data: Any) -> dict:
|
|
28
|
+
"""Create a tenant/account, provision all four Cognito pools
|
|
29
|
+
(patient, provider, person, tenantadmin), and create the tenant's
|
|
30
|
+
first (primary) Tenant Admin user.
|
|
31
|
+
POST /tenants/register (public)"""
|
|
32
|
+
return self._http.post("/tenants/register", auth=False, json=data)
|
|
33
|
+
|
|
34
|
+
# ─── Tenant Admin registration — self vs. invite ─────────────────────
|
|
35
|
+
|
|
36
|
+
def self_register_tenant_admin(
|
|
37
|
+
self, *, tenant_id: str, first_name: str, last_name: str, email: str,
|
|
38
|
+
password: str, phone: Optional[str] = None,
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""Public self-registration into an existing tenant. Requires the
|
|
41
|
+
tenant and its tenantadmin Cognito pool to already exist (404
|
|
42
|
+
otherwise). No JWT required.
|
|
43
|
+
POST /auth/register, mode=self (public)"""
|
|
44
|
+
body = {
|
|
45
|
+
"tenant_id": tenant_id, "first_name": first_name, "last_name": last_name,
|
|
46
|
+
"email": email, "password": password, "phone": phone, "mode": "self",
|
|
47
|
+
}
|
|
48
|
+
return self._http.post("/auth/register", auth=False, json=body)
|
|
49
|
+
|
|
50
|
+
def invite_tenant_admin(self, *, first_name: str, last_name: str, email: str) -> dict:
|
|
51
|
+
"""Invite another Tenant Admin into the caller's own tenant. Requires
|
|
52
|
+
a Tenant Admin JWT (tenantadmin pool only); the tenant is always the
|
|
53
|
+
inviter's own.
|
|
54
|
+
POST /auth/register, mode=invite (Bearer required)"""
|
|
55
|
+
body = {"first_name": first_name, "last_name": last_name, "email": email, "mode": "invite"}
|
|
56
|
+
return self._http.post("/auth/register", json=body)
|
|
57
|
+
|
|
58
|
+
# ─── Auth (public, no Bearer token) ──────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def login(self, *, email: str, password: str, tenant_id: Optional[str] = None) -> dict:
|
|
61
|
+
"""Log in an existing tenant admin. Returns access, ID and refresh
|
|
62
|
+
tokens.
|
|
63
|
+
POST /auth/login (public)"""
|
|
64
|
+
body = with_tenant_id({"email": email, "password": password}, tenant_id or self._config.tenant_id)
|
|
65
|
+
response = self._http.post("/auth/login", auth=False, json=body)
|
|
66
|
+
if response and response.get("access_token"):
|
|
67
|
+
self._http.set_access_token(response["access_token"])
|
|
68
|
+
return response
|
|
69
|
+
|
|
70
|
+
def login_tenant_admin(self, *, email: str, password: str, tenant_id: Optional[str] = None) -> dict:
|
|
71
|
+
"""Alias of :meth:`login`, matching the SDK's cross-package naming."""
|
|
72
|
+
return self.login(email=email, password=password, tenant_id=tenant_id)
|
|
73
|
+
|
|
74
|
+
def refresh_tenant_admin_token(self, *, refresh_token: str, tenant_id: Optional[str] = None) -> dict:
|
|
75
|
+
"""Renew an access token via Cognito's refresh-token flow.
|
|
76
|
+
POST /auth/refresh (public)"""
|
|
77
|
+
body = with_tenant_id({"refresh_token": refresh_token}, tenant_id or self._config.tenant_id)
|
|
78
|
+
return self._http.post("/auth/refresh", auth=False, json=body)
|
|
79
|
+
|
|
80
|
+
def verify_email(self, *, email: str, otp: str, tenant_id: Optional[str] = None) -> dict:
|
|
81
|
+
"""Verify a tenant admin's email address with OTP.
|
|
82
|
+
POST /auth/verify-email (public)"""
|
|
83
|
+
body = with_tenant_id({"email": email, "otp": otp}, tenant_id or self._config.tenant_id)
|
|
84
|
+
return self._http.post("/auth/verify-email", auth=False, json=body)
|
|
85
|
+
|
|
86
|
+
def reset_password(self, *, email: str, tenant_id: Optional[str] = None) -> dict:
|
|
87
|
+
"""Initiate password reset flow (sends code to email).
|
|
88
|
+
POST /auth/reset-password (public)"""
|
|
89
|
+
body = with_tenant_id({"email": email}, tenant_id or self._config.tenant_id)
|
|
90
|
+
return self._http.post("/auth/reset-password", auth=False, json=body)
|
|
91
|
+
|
|
92
|
+
def confirm_password(
|
|
93
|
+
self, *, email: str, new_password: str, confirmation_code: str, tenant_id: Optional[str] = None,
|
|
94
|
+
) -> dict:
|
|
95
|
+
"""Confirm password reset with code.
|
|
96
|
+
POST /auth/confirm-reset-password (public)"""
|
|
97
|
+
body = with_tenant_id(
|
|
98
|
+
{"email": email, "new_password": new_password, "confirmation_code": confirmation_code},
|
|
99
|
+
tenant_id or self._config.tenant_id,
|
|
100
|
+
)
|
|
101
|
+
return self._http.post("/auth/confirm-reset-password", auth=False, json=body)
|
|
102
|
+
|
|
103
|
+
# ─── Current admin (authenticated — identity from the verified JWT) ──
|
|
104
|
+
|
|
105
|
+
def get_current_tenant_admin(self) -> TenantAdminProfile:
|
|
106
|
+
"""Get the currently authenticated Tenant Admin's own profile.
|
|
107
|
+
Identity is resolved server-side from the verified access token's
|
|
108
|
+
claims — there is no admin id to pass.
|
|
109
|
+
GET /admins/me (Bearer required, tenantadmin pool only)"""
|
|
110
|
+
return TenantAdminProfile.from_dict(self._http.get("/admins/me"))
|
|
111
|
+
|
|
112
|
+
# ─── Admin CRUD (authenticated) ───────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def get_admins_for_tenant(self) -> list[TenantAdmin]:
|
|
115
|
+
"""List all Tenant Admins for the caller's own tenant, resolved from
|
|
116
|
+
the verified Tenant Admin JWT — the tenant is never accepted as a
|
|
117
|
+
parameter. Sorted by email. Per-item `account` is not populated
|
|
118
|
+
here (unlike :meth:`get_admin`); fetch it via `get_admin` if needed.
|
|
119
|
+
GET /tenant-admins (Bearer required, tenantadmin pool only)"""
|
|
120
|
+
return [TenantAdmin.from_dict(item) for item in self._http.get("/tenant-admins")]
|
|
121
|
+
|
|
122
|
+
def get_admin(self, admin_id: str) -> TenantAdmin:
|
|
123
|
+
"""Get a tenant admin by ID, including the tenant's business info
|
|
124
|
+
(account.name/status/tier).
|
|
125
|
+
GET /admins/{admin_id}"""
|
|
126
|
+
data = self._http.get("/admins/{admin_id}", path_params={"admin_id": admin_id})
|
|
127
|
+
return TenantAdmin.from_dict(data)
|
|
128
|
+
|
|
129
|
+
def update_admin(self, admin_id: str, **fields: Any) -> dict:
|
|
130
|
+
"""Update a tenant admin's profile fields (first_name, last_name, phone).
|
|
131
|
+
PATCH /admins/{admin_id}"""
|
|
132
|
+
return self._http.patch(
|
|
133
|
+
"/admins/{admin_id}", path_params={"admin_id": admin_id}, json=fields,
|
|
134
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal, Optional
|
|
5
|
+
|
|
6
|
+
Environment = Literal["dev", "uat", "prod"]
|
|
7
|
+
|
|
8
|
+
SERVICE_NAME = "tenantadmin"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class TenantAdminSDKConfig:
|
|
13
|
+
environment: Environment = "dev"
|
|
14
|
+
tenant_id: Optional[str] = None
|
|
15
|
+
access_token: Optional[str] = None
|
|
16
|
+
timeout: float = 30.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def assert_environment(environment: Environment) -> None:
|
|
20
|
+
if environment not in ("dev", "uat", "prod"):
|
|
21
|
+
raise ValueError(f'Unsupported HealthCloud environment: "{environment}"')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_base_url(environment: Environment) -> str:
|
|
25
|
+
assert_environment(environment)
|
|
26
|
+
prefix = "" if environment == "prod" else f"{environment}-"
|
|
27
|
+
return f"https://{prefix}api-{SERVICE_NAME}.health.cloud"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def with_tenant_id(data: dict, tenant_id: Optional[str]) -> dict:
|
|
31
|
+
"""Always enforces the SDK-configured tenant on request bodies — never
|
|
32
|
+
lets a caller-supplied tenant_id override it."""
|
|
33
|
+
if not tenant_id:
|
|
34
|
+
return dict(data)
|
|
35
|
+
out = dict(data)
|
|
36
|
+
out["tenant_id"] = tenant_id
|
|
37
|
+
return out
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SDKError(Exception):
|
|
7
|
+
def __init__(self, message: str, status_code: int, response: Optional[Any] = None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.response = response
|
|
11
|
+
|
|
12
|
+
def __str__(self) -> str:
|
|
13
|
+
return f"[SDKError {self.status_code}] {super().__str__()}"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .config import TenantAdminSDKConfig, resolve_base_url
|
|
8
|
+
from .errors import SDKError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HttpClient:
|
|
12
|
+
def __init__(self, config: TenantAdminSDKConfig):
|
|
13
|
+
self._config = config
|
|
14
|
+
self._client = httpx.Client(timeout=config.timeout)
|
|
15
|
+
|
|
16
|
+
def set_access_token(self, token: Optional[str]) -> None:
|
|
17
|
+
self._config.access_token = token
|
|
18
|
+
|
|
19
|
+
def close(self) -> None:
|
|
20
|
+
self._client.close()
|
|
21
|
+
|
|
22
|
+
def _headers(self, auth: bool, headers: Optional[Mapping[str, str]] = None) -> dict:
|
|
23
|
+
out = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
24
|
+
if headers:
|
|
25
|
+
out.update(dict(headers))
|
|
26
|
+
if auth and self._config.access_token:
|
|
27
|
+
out["Authorization"] = f"Bearer {self._config.access_token}"
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
def request(
|
|
31
|
+
self,
|
|
32
|
+
method: str,
|
|
33
|
+
path: str,
|
|
34
|
+
*,
|
|
35
|
+
auth: bool = True,
|
|
36
|
+
path_params: Optional[Mapping[str, str]] = None,
|
|
37
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
38
|
+
json: Optional[Any] = None,
|
|
39
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
40
|
+
) -> Any:
|
|
41
|
+
url = f"{resolve_base_url(self._config.environment)}{path}"
|
|
42
|
+
if path_params:
|
|
43
|
+
for key, value in path_params.items():
|
|
44
|
+
url = url.replace(f"{{{key}}}", str(value))
|
|
45
|
+
|
|
46
|
+
resp = self._client.request(
|
|
47
|
+
method, url, params=params, json=json, headers=self._headers(auth, headers)
|
|
48
|
+
)
|
|
49
|
+
if resp.status_code >= 400:
|
|
50
|
+
try:
|
|
51
|
+
detail: Any = resp.json()
|
|
52
|
+
except Exception:
|
|
53
|
+
detail = resp.text
|
|
54
|
+
raise SDKError(f"HealthCloud API error {resp.status_code}: {detail}", resp.status_code, detail)
|
|
55
|
+
if not resp.content:
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
return resp.json()
|
|
59
|
+
except Exception:
|
|
60
|
+
return resp.text
|
|
61
|
+
|
|
62
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
63
|
+
return self.request("GET", path, **kwargs)
|
|
64
|
+
|
|
65
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
66
|
+
return self.request("POST", path, **kwargs)
|
|
67
|
+
|
|
68
|
+
def patch(self, path: str, **kwargs: Any) -> Any:
|
|
69
|
+
return self.request("PATCH", path, **kwargs)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class TenantAccountProfile:
|
|
9
|
+
tenant_id: str
|
|
10
|
+
name: str
|
|
11
|
+
status: str
|
|
12
|
+
tier: str
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_dict(cls, data: Optional[dict]) -> Optional["TenantAccountProfile"]:
|
|
16
|
+
if not data:
|
|
17
|
+
return None
|
|
18
|
+
return cls(
|
|
19
|
+
tenant_id=data["tenant_id"],
|
|
20
|
+
name=data["name"],
|
|
21
|
+
status=data["status"],
|
|
22
|
+
tier=data["tier"],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TenantAdmin:
|
|
28
|
+
"""A Tenant Admin resource, as returned by GET /tenant-admins (list) and
|
|
29
|
+
GET /admins/{admin_id} (single). Fields present differ slightly by
|
|
30
|
+
route — see field docstrings."""
|
|
31
|
+
|
|
32
|
+
admin_id: str
|
|
33
|
+
email: str
|
|
34
|
+
tenant_id: str
|
|
35
|
+
first_name: str
|
|
36
|
+
last_name: str
|
|
37
|
+
status: str
|
|
38
|
+
phone: Optional[str] = None
|
|
39
|
+
title: Optional[str] = None
|
|
40
|
+
# Cognito User Pool sub. Only present on GET /tenant-admins (list).
|
|
41
|
+
cognito_sub: Optional[str] = None
|
|
42
|
+
permissions: list[str] = field(default_factory=list)
|
|
43
|
+
is_primary: bool = False
|
|
44
|
+
created_at: Optional[str] = None
|
|
45
|
+
updated_at: Optional[str] = None
|
|
46
|
+
last_login_at: Optional[str] = None
|
|
47
|
+
# admin_id of the creator/inviter. Only present on GET /tenant-admins (list).
|
|
48
|
+
created_by: Optional[str] = None
|
|
49
|
+
invited_by: Optional[str] = None
|
|
50
|
+
account: Optional[TenantAccountProfile] = None
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, data: dict) -> "TenantAdmin":
|
|
54
|
+
return cls(
|
|
55
|
+
admin_id=data["admin_id"],
|
|
56
|
+
email=data["email"],
|
|
57
|
+
tenant_id=data["tenant_id"],
|
|
58
|
+
first_name=data["first_name"],
|
|
59
|
+
last_name=data["last_name"],
|
|
60
|
+
status=data["status"],
|
|
61
|
+
phone=data.get("phone"),
|
|
62
|
+
title=data.get("title"),
|
|
63
|
+
cognito_sub=data.get("cognito_sub"),
|
|
64
|
+
permissions=data.get("permissions", []),
|
|
65
|
+
is_primary=data.get("is_primary", False),
|
|
66
|
+
created_at=data.get("created_at"),
|
|
67
|
+
updated_at=data.get("updated_at"),
|
|
68
|
+
last_login_at=data.get("last_login_at"),
|
|
69
|
+
created_by=data.get("created_by"),
|
|
70
|
+
invited_by=data.get("invited_by"),
|
|
71
|
+
account=TenantAccountProfile.from_dict(data.get("account")),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class TenantAdminProfile:
|
|
77
|
+
"""GET /admins/me — identity resolved from the verified access token's
|
|
78
|
+
claims; no admin_id is ever accepted from the caller for this route."""
|
|
79
|
+
|
|
80
|
+
admin_id: str
|
|
81
|
+
tenant_id: str
|
|
82
|
+
email: str
|
|
83
|
+
first_name: str
|
|
84
|
+
last_name: str
|
|
85
|
+
status: str
|
|
86
|
+
is_primary: bool = False
|
|
87
|
+
permissions: list[str] = field(default_factory=list)
|
|
88
|
+
account: Optional[TenantAccountProfile] = None
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: dict) -> "TenantAdminProfile":
|
|
92
|
+
return cls(
|
|
93
|
+
admin_id=data["admin_id"],
|
|
94
|
+
tenant_id=data["tenant_id"],
|
|
95
|
+
email=data["email"],
|
|
96
|
+
first_name=data["first_name"],
|
|
97
|
+
last_name=data["last_name"],
|
|
98
|
+
status=data["status"],
|
|
99
|
+
is_primary=data.get("is_primary", False),
|
|
100
|
+
permissions=data.get("permissions", []),
|
|
101
|
+
account=TenantAccountProfile.from_dict(data.get("account")),
|
|
102
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .clients.tenant_admin import TenantAdminClient
|
|
6
|
+
from .config import Environment, TenantAdminSDKConfig, assert_environment
|
|
7
|
+
from .http import HttpClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HCTenantAdminSDK:
|
|
11
|
+
"""Entry point for the HealthCloud Tenant Admin SDK.
|
|
12
|
+
|
|
13
|
+
>>> sdk = HCTenantAdminSDK(environment="dev", tenant_id="acme")
|
|
14
|
+
>>> sdk.login(email="admin@acme.com", password="...")
|
|
15
|
+
>>> sdk.admin.get_admins_for_tenant()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
environment: Environment = "dev",
|
|
21
|
+
tenant_id: Optional[str] = None,
|
|
22
|
+
access_token: Optional[str] = None,
|
|
23
|
+
timeout: float = 30.0,
|
|
24
|
+
):
|
|
25
|
+
assert_environment(environment)
|
|
26
|
+
self._config = TenantAdminSDKConfig(
|
|
27
|
+
environment=environment, tenant_id=tenant_id, access_token=access_token, timeout=timeout,
|
|
28
|
+
)
|
|
29
|
+
self._http = HttpClient(self._config)
|
|
30
|
+
|
|
31
|
+
# Tenant admin auth + profile management
|
|
32
|
+
self.admin = TenantAdminClient(self._http, self._config)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def environment(self) -> str:
|
|
36
|
+
return self._config.environment
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def tenant_id(self) -> Optional[str]:
|
|
40
|
+
return self._config.tenant_id
|
|
41
|
+
|
|
42
|
+
def set_access_token(self, token: str) -> None:
|
|
43
|
+
self._http.set_access_token(token)
|
|
44
|
+
|
|
45
|
+
def clear_access_token(self) -> None:
|
|
46
|
+
self._http.set_access_token(None)
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
self._http.close()
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> "HCTenantAdminSDK":
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, *exc: object) -> None:
|
|
55
|
+
self.close()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "healthcloud-tenantadmin"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HealthCloud Tenant Admin SDK for Python — manage tenant admins and auth."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Healthcheck Systems Inc" }]
|
|
13
|
+
keywords = ["healthcloud", "health-cloud", "tenant-admin", "sdk", "api", "healthcare"]
|
|
14
|
+
dependencies = ["httpx>=0.27.0"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=8.0", "respx>=0.21", "ruff>=0.6"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Repository = "https://github.com/healthcloudservices/healthcloud-sdk"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["healthcloud_tenantadmin"]
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 100
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
markers = [
|
|
30
|
+
"live: live HealthCloud API integration tests, skipped unless HC_RUN_INTEGRATION=true",
|
|
31
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
import respx
|
|
6
|
+
|
|
7
|
+
from healthcloud_tenantadmin import HCTenantAdminSDK
|
|
8
|
+
|
|
9
|
+
BASE_URL = "https://dev-api-tenantadmin.health.cloud"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def sdk() -> HCTenantAdminSDK:
|
|
14
|
+
return HCTenantAdminSDK(environment="dev", tenant_id="acme", access_token="test-token")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@respx.mock
|
|
18
|
+
def test_get_admins_for_tenant_maps_all_fields(sdk: HCTenantAdminSDK) -> None:
|
|
19
|
+
payload = [
|
|
20
|
+
{
|
|
21
|
+
"admin_id": "11111111-1111-1111-1111-111111111111",
|
|
22
|
+
"tenant_id": "acme",
|
|
23
|
+
"email": "a@acme.com",
|
|
24
|
+
"first_name": "Ana",
|
|
25
|
+
"last_name": "Admin",
|
|
26
|
+
"phone": "+15551234567",
|
|
27
|
+
"title": "Owner",
|
|
28
|
+
"cognito_sub": "22222222-2222-2222-2222-222222222222",
|
|
29
|
+
"status": "active",
|
|
30
|
+
"is_primary": True,
|
|
31
|
+
"permissions": ["users:read", "users:write"],
|
|
32
|
+
"created_at": "2026-01-01T00:00:00",
|
|
33
|
+
"updated_at": "2026-01-02T00:00:00",
|
|
34
|
+
"last_login_at": None,
|
|
35
|
+
"created_by": None,
|
|
36
|
+
"invited_by": None,
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
route = respx.get(f"{BASE_URL}/tenant-admins").mock(
|
|
40
|
+
return_value=httpx.Response(200, json=payload)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
admins = sdk.admin.get_admins_for_tenant()
|
|
44
|
+
|
|
45
|
+
assert route.called
|
|
46
|
+
assert route.calls.last.request.headers["Authorization"] == "Bearer test-token"
|
|
47
|
+
assert len(admins) == 1
|
|
48
|
+
admin = admins[0]
|
|
49
|
+
assert admin.admin_id == payload[0]["admin_id"]
|
|
50
|
+
assert admin.email == "a@acme.com"
|
|
51
|
+
assert admin.cognito_sub == payload[0]["cognito_sub"]
|
|
52
|
+
assert admin.is_primary is True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@respx.mock
|
|
56
|
+
def test_get_admins_for_tenant_empty(sdk: HCTenantAdminSDK) -> None:
|
|
57
|
+
respx.get(f"{BASE_URL}/tenant-admins").mock(return_value=httpx.Response(200, json=[]))
|
|
58
|
+
assert sdk.admin.get_admins_for_tenant() == []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@respx.mock
|
|
62
|
+
def test_get_admins_for_tenant_unauthorized_raises(sdk: HCTenantAdminSDK) -> None:
|
|
63
|
+
from healthcloud_tenantadmin import SDKError
|
|
64
|
+
|
|
65
|
+
respx.get(f"{BASE_URL}/tenant-admins").mock(
|
|
66
|
+
return_value=httpx.Response(401, json={"error": "Authorization required"})
|
|
67
|
+
)
|
|
68
|
+
with pytest.raises(SDKError) as exc_info:
|
|
69
|
+
sdk.admin.get_admins_for_tenant()
|
|
70
|
+
assert exc_info.value.status_code == 401
|