Skip to content

Commit

Permalink
support multiple central configs
Browse files Browse the repository at this point in the history
  • Loading branch information
copelco committed Jan 14, 2025
1 parent 74c2f52 commit e6cd71e
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 11 deletions.
65 changes: 57 additions & 8 deletions apps/odk_publish/etl/odk/client.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import structlog
import os
from pathlib import Path

from django.conf import settings
import structlog
from pydantic import BaseModel, SecretStr, field_validator
from pyodk._utils import config
from pyodk.client import Client, Session

from .publish import PublishService


logger = structlog.getLogger(__name__)

CONFIG_TOML = """
Expand All @@ -19,6 +19,19 @@
"""


class CentralConfig(BaseModel):
"""Model to validate ODK Central server configuration."""

base_url: str
username: str
password: SecretStr

@field_validator("base_url")
@classmethod
def always_strip_trailing_slash(cls, value: str) -> str:
return value.rstrip("/")


class ODKPublishClient(Client):
"""Extended pyODK Client for interacting with ODK Central."""

Expand All @@ -30,11 +43,12 @@ def __init__(self, base_url: str, project_id: int | None = None):
config_path.write_text(CONFIG_TOML)
# Create a session with the given authentication details and supply the
# session to the super class, so it doesn't try and create one itself
server_config = self.get_config(base_url=base_url)
session = Session(
base_url=base_url,
base_url=server_config.base_url,
api_version="v1",
username=settings.ODK_CENTRAL_USERNAME,
password=settings.ODK_CENTRAL_PASSWORD,
username=server_config.username,
password=server_config.password.get_secret_value(),
cache_path=None,
)
super().__init__(config_path=str(config_path), session=session, project_id=project_id)
Expand All @@ -44,8 +58,8 @@ def __init__(self, base_url: str, project_id: int | None = None):
{
"central": {
"base_url": base_url,
"username": settings.ODK_CENTRAL_USERNAME,
"password": settings.ODK_CENTRAL_PASSWORD,
"username": server_config.username,
"password": server_config.password.get_secret_value(),
}
}
)
Expand All @@ -56,3 +70,38 @@ def __init__(self, base_url: str, project_id: int | None = None):

def __enter__(self) -> "ODKPublishClient":
return super().__enter__() # type: ignore

@classmethod
def get_config(cls, base_url: str) -> CentralConfig:
"""Return the CentralConfig for the matching base URL."""
available_configs = cls.get_configs()
config = available_configs[base_url.rstrip("/")]
logger.debug("Retrieved ODK Central config", config=config)
return config

@staticmethod
def get_configs() -> dict[str, CentralConfig]:
"""
Parse the ODK_CENTRAL_CREDENTIALS environment variable and return a dictionary
of available server configurations.
Example environment variable:
export ODK_CENTRAL_CREDENTIALS="base_url=https://myserver.com;username=user1;password=pass1,base_url=https://otherserver.com;username=user2;password=pass2"
Returns:
{
"https://myserver.com": CentralConfig(base_url="https://myserver.com", username="user1", password=SecretStr('**********')
"https://otherserver.com": CentralConfig(base_url="https://otherserver.com", username="user2", password=SecretStr('**********')
}
""" # noqa
servers = {}
for server in os.getenv("ODK_CENTRAL_CREDENTIALS", "").split(","):
server = server.split(";")
server = {
key: value for key, value in [item.split("=") for item in server if "=" in item]
}
if server:
config = CentralConfig.model_validate(server)
servers[config.base_url] = config
logger.debug("Parsed ODK Central credentials", servers=servers.keys())
return servers
8 changes: 5 additions & 3 deletions tests/odk_publish/etl/odk/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@


@pytest.fixture(autouse=True)
def disable_client_auth(mocker, settings):
def disable_client_auth(mocker, monkeypatch):
# Never attempt to authenticate with ODK Central
mocker.patch("pyodk._utils.session.Auth.login")
settings.ODK_CENTRAL_USERNAME = "username"
settings.ODK_CENTRAL_PASSWORD = "password"
monkeypatch.setenv(
"ODK_CENTRAL_CREDENTIALS",
"base_url=https://central;username=username;password=password",
)
56 changes: 56 additions & 0 deletions tests/odk_publish/etl/odk/test_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from pathlib import Path
import pytest
from pydantic import ValidationError

from apps.odk_publish.etl.odk.client import ODKPublishClient

Expand Down Expand Up @@ -32,3 +34,57 @@ def test_set_client_project_id(self):
assert client.forms.default_project_id == 1
assert client.odk_publish.project_users.default_project_id == 1
assert client.odk_publish.form_assignments.default_project_id == 1


class TestODKCentralCredentials:
def test_single_server(self, monkeypatch):
monkeypatch.setenv(
"ODK_CENTRAL_CREDENTIALS",
"base_url=https://myserver.com/;username=user1;password=pass1",
)
configs = ODKPublishClient.get_configs()
assert set(configs.keys()) == {"https://myserver.com"}
assert configs["https://myserver.com"].username == "user1"
assert configs["https://myserver.com"].password.get_secret_value() == "pass1"

def test_multiple_servers(self, monkeypatch):
monkeypatch.setenv(
"ODK_CENTRAL_CREDENTIALS",
"base_url=https://myserver.com;username=user1;password=pass1,base_url=https://otherserver.com;username=user2;password=pass2",
)
configs = ODKPublishClient.get_configs()
assert set(configs.keys()) == {"https://myserver.com", "https://otherserver.com"}
assert configs["https://myserver.com"].username == "user1"
assert configs["https://myserver.com"].password.get_secret_value() == "pass1"
assert configs["https://otherserver.com"].username == "user2"
assert configs["https://otherserver.com"].password.get_secret_value() == "pass2"

def test_no_credentials(self, monkeypatch):
monkeypatch.delenv("ODK_CENTRAL_CREDENTIALS", raising=False)
assert ODKPublishClient.get_configs() == {}

def test_invalid_credentials(self, monkeypatch):
monkeypatch.setenv("ODK_CENTRAL_CREDENTIALS", "invalid")
assert ODKPublishClient.get_configs() == {}

def test_missing_full_server_config(self, monkeypatch):
monkeypatch.setenv("ODK_CENTRAL_CREDENTIALS", "base_url=https://onlyserver.com")
with pytest.raises(ValidationError):
ODKPublishClient.get_configs()

def test_get_config(self, monkeypatch):
monkeypatch.setenv(
"ODK_CENTRAL_CREDENTIALS",
"base_url=https://myserver.com;username=user1;password=pass1",
)
config = ODKPublishClient.get_config("https://myserver.com/")
assert config.username == "user1"
assert config.password.get_secret_value() == "pass1"

def test_get_config_missing(self, monkeypatch):
monkeypatch.setenv(
"ODK_CENTRAL_CREDENTIALS",
"base_url=https://myserver.com;username=user1;password=pass1",
)
with pytest.raises(KeyError):
ODKPublishClient.get_config("https://otherserver.com/")

0 comments on commit e6cd71e

Please sign in to comment.