Skip to content

Commit 41d67c9

Browse files
inital commit to add ckan client and capability to get autoregister org list
1 parent 0a52781 commit 41d67c9

5 files changed

Lines changed: 215 additions & 0 deletions

File tree

config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ class Settings(BaseSettings):
4040
"bpa-wheat-pathogens-transcript": "Wheat Pathogens Transcript",
4141
}
4242

43+
ckan_base_url: str
44+
ckan_api_key: Optional[str] = None
45+
ckan_timeout_s: float = 10.0
46+
ckan_verify_ssl: bool = True
47+
4348
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
4449

4550

routers/bpa_register.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from schemas.bpa import BPARegistrationRequest
1717
from schemas.responses import RegistrationErrorResponse, RegistrationResponse
1818
from schemas.service import Resource, Service
19+
from services.ckan_client import CKANClient, get_ckan_client
1920

2021
logger = logging.getLogger(__name__)
2122

@@ -142,3 +143,23 @@ def _create_bpa_user_record(auth0_user_data: Auth0UserData, session: Session) ->
142143
session.add(bpa_membership)
143144
session.commit()
144145
return db_user
146+
147+
def _get_bpa_autoregister_list(ckan_client: CKANClient, settings: Settings) -> dict[str, str]:
148+
"""
149+
Returns a mapping of organization slug -> title for organizations that
150+
are enabled for auto-registration.
151+
152+
Prefers CKAN (single source of truth). Falls back to settings.organizations
153+
if CKAN is unreachable or returns an unexpected payload.
154+
"""
155+
try:
156+
orgs = ckan_client.get_autoregister_organizations()
157+
# Keep a simple slug->title map; use CKAN's group "name" (slug) as the ID we store
158+
mapping = {o.name: o.title for o in orgs}
159+
if mapping:
160+
return mapping
161+
logger.warning("CKAN returned an empty autoregister org list; falling back to settings.organizations")
162+
except Exception as e:
163+
logger.warning("Failed to fetch autoregister orgs from CKAN: %s; falling back to settings.organizations", e)
164+
# Fallback (legacy)
165+
return settings.organizations

services/ckan_client.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
__all__ = ["CKANClient", "get_ckan_client"]
2+
3+
from typing import Optional
4+
5+
import httpx
6+
from fastapi import Depends
7+
from pydantic import BaseModel
8+
9+
from config import Settings, get_settings
10+
11+
12+
class OrgOut(BaseModel):
13+
"""
14+
Minimal org payload for the portal dropdown.
15+
"""
16+
id: str
17+
name: str
18+
title: str
19+
20+
21+
class CKANClient:
22+
"""
23+
Tiny client for CKAN Action API calls used by aai-backend.
24+
"""
25+
26+
ACTION_AUTOREGISTER_ORGS = "/api/3/action/ytp_request_autoregister_organization_list"
27+
28+
def __init__(self, base_url: str, api_key: Optional[str], timeout_s: float, verify_ssl: bool):
29+
self.base_url = base_url.rstrip("/")
30+
self.api_key = api_key
31+
headers = {}
32+
if api_key:
33+
# CKAN expects the API key in the Authorization header (no Bearer prefix)
34+
headers["Authorization"] = api_key
35+
# Mirror the style of Auth0Client: keep a single sync client instance
36+
self._client = httpx.Client(headers=headers, timeout=timeout_s, verify=verify_ssl)
37+
38+
def get_autoregister_organizations(self) -> list[OrgOut]:
39+
"""
40+
Calls the CKAN action exposed by ckanext-ytp-request to fetch the
41+
list of orgs eligible for auto-registration.
42+
"""
43+
url = f"{self.base_url}{self.ACTION_AUTOREGISTER_ORGS}"
44+
resp = self._client.post(url, json={})
45+
resp.raise_for_status()
46+
payload = resp.json()
47+
if not payload.get("success"):
48+
# CKAN Action API returns {"success": false, "error": {...}} on failure
49+
raise ValueError("CKAN action reported success=false")
50+
result = payload.get("result") or []
51+
return [OrgOut(**item) for item in result]
52+
53+
54+
def get_ckan_client(settings: Settings = Depends(get_settings)) -> CKANClient:
55+
"""
56+
FastAPI dependency that wires CKAN config from Settings.
57+
"""
58+
return CKANClient(
59+
base_url=settings.ckan_base_url,
60+
api_key=settings.ckan_api_key,
61+
timeout_s=settings.ckan_timeout_s,
62+
verify_ssl=settings.ckan_verify_ssl,
63+
)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import types
2+
3+
import bpa_register
4+
5+
6+
class _Org:
7+
def __init__(self, name: str, title: str):
8+
self.name = name
9+
self.title = title
10+
11+
12+
class _OKCkanClient:
13+
def get_autoregister_organizations(self):
14+
return [_Org(name="bpa", title="Bioplatforms Australia"),
15+
_Org(name="fungi", title="Fungi Functional 'Omics")]
16+
17+
18+
class _FailingCkanClient:
19+
def get_autoregister_organizations(self):
20+
raise RuntimeError("ckan is down")
21+
22+
23+
def test_get_bpa_autoregister_list_prefers_ckan():
24+
ckan = _OKCkanClient()
25+
settings = types.SimpleNamespace(
26+
# Would be ignored since CKAN succeeds:
27+
organizations={"legacy": "Legacy Title"}
28+
)
29+
mapping = bpa_register._get_bpa_autoregister_list(ckan, settings)
30+
assert mapping == {
31+
"bpa": "Bioplatforms Australia",
32+
"fungi": "Fungi Functional 'Omics",
33+
}
34+
35+
36+
def test_get_bpa_autoregister_list_fallback_on_error():
37+
ckan = _FailingCkanClient()
38+
settings = types.SimpleNamespace(
39+
organizations={"legacy": "Legacy Title", "bpa": "Bioplatforms Australia"}
40+
)
41+
mapping = bpa_register._get_bpa_autoregister_list(ckan, settings)
42+
# Falls back entirely to the static settings
43+
assert mapping == settings.organizations

tests/test_ckan_client.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import json
2+
import types
3+
import pytest
4+
5+
import ckan_client
6+
7+
8+
class _FakeResponse:
9+
def __init__(self, data: dict, status_code: int = 200):
10+
self._data = data
11+
self.status_code = status_code
12+
self.text = json.dumps(data)
13+
14+
def json(self):
15+
return self._data
16+
17+
def raise_for_status(self):
18+
# Simulate httpx behavior: raise for 4xx/5xx
19+
if self.status_code >= 400:
20+
raise ckan_client.httpx.HTTPStatusError(
21+
"error", request=None, response=self
22+
)
23+
24+
25+
class _FakeHttpxClientOK:
26+
"""httpx.Client mock that returns success=true with one org."""
27+
28+
def __init__(self, **kwargs):
29+
pass
30+
31+
def post(self, url, json=None):
32+
# Ensure we're calling the expected CKAN action path
33+
assert url.endswith(ckan_client.CKANClient.ACTION_AUTOREGISTER_ORGS)
34+
return _FakeResponse(
35+
{
36+
"success": True,
37+
"result": [{"id": "1", "name": "bpa", "title": "Bioplatforms Australia"}],
38+
}
39+
)
40+
41+
42+
class _FakeHttpxClientSuccessFalse:
43+
"""httpx.Client mock that returns success=false (CKAN action error)."""
44+
45+
def __init__(self, **kwargs):
46+
pass
47+
48+
def post(self, url, json=None):
49+
return _FakeResponse({"success": False, "error": {"message": "boom"}})
50+
51+
52+
def test_ckan_client_success(monkeypatch):
53+
# Replace httpx.Client with our fake
54+
monkeypatch.setattr(
55+
ckan_client, "httpx", types.SimpleNamespace(Client=_FakeHttpxClientOK)
56+
)
57+
cli = ckan_client.CKANClient(
58+
base_url="https://ckan.example.org",
59+
api_key=None,
60+
timeout_s=5,
61+
verify_ssl=True,
62+
)
63+
64+
orgs = cli.get_autoregister_organizations()
65+
assert len(orgs) == 1
66+
assert orgs[0].id == "1"
67+
assert orgs[0].name == "bpa"
68+
assert orgs[0].title == "Bioplatforms Australia"
69+
70+
71+
def test_ckan_client_success_false_raises(monkeypatch):
72+
monkeypatch.setattr(
73+
ckan_client, "httpx", types.SimpleNamespace(Client=_FakeHttpxClientSuccessFalse)
74+
)
75+
cli = ckan_client.CKANClient(
76+
base_url="https://ckan.example.org",
77+
api_key="secret",
78+
timeout_s=5,
79+
verify_ssl=True,
80+
)
81+
82+
with pytest.raises(ValueError):
83+
_ = cli.get_autoregister_organizations()

0 commit comments

Comments
 (0)