Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Disclaimer: The async client current capabilities are limited to:
* Getting Feature groups
* Getting Sources
* Getting Source Assignments (aka SubjectSource resources)
* Deleting Subjects
* Deleting Sources
```
from erclient import AsyncERClient

Expand Down
142 changes: 106 additions & 36 deletions erclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ def __init__(self, **kwargs):
self.client_id = kwargs.get('client_id')
self.provider_key = kwargs.get('provider_key')

self.token_url = kwargs.get('token_url') or f"{self.service_root.rstrip('/')}/oauth2/token"
self.token_url = kwargs.get(
'token_url') or f"{self.service_root.rstrip('/')}/oauth2/token"
self.username = kwargs.get('username')
self.password = kwargs.get('password')
self.realtime_url = kwargs.get('realtime_url')
Expand Down Expand Up @@ -1032,6 +1033,53 @@ def get_subjectgroups(self, include_inactive=False,

return self._get('subjectgroups', params=p)

def add_subjects_to_subjectgroup(self, group_id, subjects):
"""
Add subjects to a subject group.

:param group_id: The subject group UUID
:param subjects: List of subject dicts with 'id' key (e.g., [{"id": "subject-uuid"}])
:return: Response data
"""
self.logger.debug(
f'Adding subjects to subjectgroup {group_id}: {subjects}')
return self._post(f'subjectgroup/{group_id}/subjects/', payload=subjects)

def remove_subjects_from_subjectgroup(self, group_id, subjects):
"""
Remove subjects from a subject group. _delete method is not used, because it does not support a body.

:param group_id: The subject group UUID
:param subjects: List of subject dicts with 'id' key (e.g., [{"id": "subject-uuid"}])
:return: True on success
"""
self.logger.debug(
f'Removing subjects from subjectgroup {group_id}: {subjects}')
headers = {'User-Agent': self.user_agent,
'Content-Type': 'application/json'}
headers.update(self.auth_headers())
url = self._er_url(f'subjectgroup/{group_id}/subjects/')
if self._http_session:
response = self._http_session.delete(
url, headers=headers, json=subjects)
else:
response = requests.delete(url, headers=headers, json=subjects)
if response.ok:
return True
if response.status_code == 404:
self.logger.error(
f"404 when calling subjectgroup/{group_id}/subjects/")
raise ERClientNotFound()
if response.status_code == 403:
try:
_ = json.loads(response.text)
reason = _['status']['detail']
except Exception:
reason = 'unknown reason'
raise ERClientPermissionDenied(reason)
raise ERClientException(
f'Failed to delete: {response.status_code} {response.text}')

def get_sources(self, page_size=100):
"""Return all sources"""
params = dict(page_size=page_size)
Expand Down Expand Up @@ -1108,7 +1156,8 @@ def __init__(self, **kwargs):
self.client_id = kwargs.get('client_id')
self.provider_key = kwargs.get('provider_key')

self.token_url = kwargs.get('token_url') or f"{self.service_root.rstrip('/')}/oauth2/token"
self.token_url = kwargs.get(
'token_url') or f"{self.service_root.rstrip('/')}/oauth2/token"
self.username = kwargs.get('username')
self.password = kwargs.get('password')
self.realtime_url = kwargs.get('realtime_url')
Expand Down Expand Up @@ -1364,6 +1413,56 @@ async def patch_subject(self, subject_id, data):
self.logger.debug(f'Patching subject {subject_id}: {data}')
return await self._patch(f'subject/{subject_id}', payload=data)

async def delete_subject(self, subject_id):
"""
Delete a subject from EarthRanger.

WARNING: Irreversible — deleted subjects lose all observation history.

:param subject_id: The subject UUID
"""
self.logger.debug(f'Deleting subject {subject_id}')
return await self._delete(f'subject/{subject_id}/')

async def delete_source(self, source_id):
"""
Delete a source from EarthRanger.

WARNING: Irreversible — deleted sources lose all linked observation history.

:param source_id: The source UUID
"""
self.logger.debug(f'Deleting source {source_id}')
return await self._delete(f'source/{source_id}/')

async def add_subjects_to_subjectgroup(self, group_id, subjects):
"""
Add subjects to a subject group.

:param group_id: The subject group UUID
:param subjects: List of subject dicts with 'id' key (e.g., [{"id": "subject-uuid"}])
:return: Response data
"""
self.logger.debug(
f'Adding subjects to subjectgroup {group_id}: {subjects}')
return await self._post(f'subjectgroup/{group_id}/subjects/', payload=subjects)

async def remove_subjects_from_subjectgroup(self, group_id, subjects):
"""
Remove subjects from a subject group.

:param group_id: The subject group UUID
:param subjects: List of subject dicts with 'id' key (e.g., [{"id": "subject-uuid"}])
:return: True on success
"""
self.logger.debug(
f'Removing subjects from subjectgroup {group_id}: {subjects}')
return await self._call(
f'subjectgroup/{group_id}/subjects/',
payload=subjects,
method="DELETE"
)

def _clean_observation(self, observation):
if hasattr(observation['recorded_at'], 'isoformat'):
observation['recorded_at'] = observation['recorded_at'].isoformat()
Expand Down Expand Up @@ -1646,38 +1745,6 @@ async def _get_data(self, endpoint, params, batch_size=0):
async def _get(self, path, base_url=None, params=None):
return await self._call(path=path, payload=None, method="GET", params=params, base_url=base_url)

async def _delete(self, path):
"""Issue DELETE request. Returns True on success; raises ERClient* on error."""
try:
auth_headers = await self.auth_headers()
except httpx.HTTPStatusError as e:
self._handle_http_status_error(path, "DELETE", e)
headers = {'User-Agent': self.user_agent, **auth_headers}
if not path.startswith('http'):
path = self._er_url(path)
try:
response = await self._http_session.delete(path, headers=headers)
except httpx.RequestError as e:
reason = str(e)
self.logger.error('Request to ER failed', extra=dict(provider_key=self.provider_key,
url=path,
reason=reason))
raise ERClientException(f'Request to ER failed: {reason}')
if response.is_success:
return True
if response.status_code == 404:
self.logger.error("404 when calling %s", path)
raise ERClientNotFound()
if response.status_code == 403:
try:
reason = response.json().get('status', {}).get('detail', 'unknown reason')
except Exception:
reason = 'unknown reason'
raise ERClientPermissionDenied(reason)
raise ERClientException(
f'Failed to delete: {response.status_code} {response.text}'
)

async def get_file(self, url):
"""
Download a file (e.g. attachment URL). Returns the httpx response; body is read into memory.
Expand Down Expand Up @@ -1742,7 +1809,8 @@ async def _call(self, path, payload, method, params=None, base_url=None):
request_url,
# payload is automatically encoded as json data
json=payload if method in [
"POST", "PUT", "PATCH"] else None,
"POST", "PUT", "PATCH"] or (
method == "DELETE" and payload is not None) else None,
params=params,
headers=headers
)
Expand All @@ -1765,7 +1833,9 @@ async def _call(self, path, payload, method, params=None, base_url=None):
return True # DELETE/empty success

json_response = response.json()
return json_response.get('data', json_response)
if isinstance(json_response, dict):
return json_response.get('data', json_response)
return json_response

def _get_batches(self, data, batch_size):
for i in range(0, len(data), batch_size):
Expand Down
191 changes: 191 additions & 0 deletions tests/async_client/test_add_subjects_to_subjectgroup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import json

import httpx
import pytest
import pytest_asyncio
import respx

from erclient import (ERClientBadCredentials, ERClientInternalError,
ERClientNotFound, ERClientPermissionDenied)

GROUP_ID = "ac1413b9-8177-4a81-85d6-a46fc95bdd70"
SUBJECT_ID = "1fed139e-070d-464c-9652-e9420437b068"
SUBJECTS_PAYLOAD = [{"id": SUBJECT_ID}]


@pytest.fixture
def subjectgroup_subjects_response():
return [{"id": SUBJECT_ID, "name": "MMVessel"}]


@pytest_asyncio.fixture(autouse=True)
async def close_client(er_client):
yield
await er_client.close()


@pytest.mark.asyncio
async def test_add_subjects_to_subjectgroup_success(er_client, subjectgroup_subjects_response):
"""Test successful subject assignment to a subject group."""
async with respx.mock(
base_url=er_client._api_root("v1.0"), assert_all_called=False
) as respx_mock:
route = respx_mock.post(f'subjectgroup/{GROUP_ID}/subjects/')
route.return_value = httpx.Response(
httpx.codes.OK, json=subjectgroup_subjects_response
)

result = await er_client.add_subjects_to_subjectgroup(
group_id=GROUP_ID,
subjects=SUBJECTS_PAYLOAD,
)

assert result == subjectgroup_subjects_response
assert route.called


@pytest.mark.asyncio
async def test_add_subjects_to_subjectgroup_not_found(er_client):
"""Test 404 raises ERClientNotFound."""
async with respx.mock(
base_url=er_client._api_root("v1.0"), assert_all_called=False
) as respx_mock:
route = respx_mock.post(f'subjectgroup/{GROUP_ID}/subjects/')
route.return_value = httpx.Response(httpx.codes.NOT_FOUND, json={})

with pytest.raises(ERClientNotFound):
await er_client.add_subjects_to_subjectgroup(
group_id=GROUP_ID,
subjects=SUBJECTS_PAYLOAD,
)

assert route.called


@pytest.mark.asyncio
async def test_add_subjects_to_subjectgroup_bad_credentials(er_client):
"""Test 401 raises ERClientBadCredentials."""
async with respx.mock(
base_url=er_client._api_root("v1.0"), assert_all_called=False
) as respx_mock:
route = respx_mock.post(f'subjectgroup/{GROUP_ID}/subjects/')
route.return_value = httpx.Response(httpx.codes.UNAUTHORIZED, json={})

with pytest.raises(ERClientBadCredentials):
await er_client.add_subjects_to_subjectgroup(
group_id=GROUP_ID,
subjects=SUBJECTS_PAYLOAD,
)

assert route.called


@pytest.mark.asyncio
async def test_add_subjects_to_subjectgroup_permission_denied(er_client):
"""Test 403 raises ERClientPermissionDenied."""
async with respx.mock(
base_url=er_client._api_root("v1.0"), assert_all_called=False
) as respx_mock:
route = respx_mock.post(f'subjectgroup/{GROUP_ID}/subjects/')
route.return_value = httpx.Response(httpx.codes.FORBIDDEN, json={})

with pytest.raises(ERClientPermissionDenied):
await er_client.add_subjects_to_subjectgroup(
group_id=GROUP_ID,
subjects=SUBJECTS_PAYLOAD,
)

assert route.called


@pytest.mark.asyncio
async def test_add_subjects_to_subjectgroup_internal_error(er_client):
"""Test 500 raises ERClientInternalError."""
async with respx.mock(
base_url=er_client._api_root("v1.0"), assert_all_called=False
) as respx_mock:
route = respx_mock.post(f'subjectgroup/{GROUP_ID}/subjects/')
route.return_value = httpx.Response(
httpx.codes.INTERNAL_SERVER_ERROR, json={})

with pytest.raises(ERClientInternalError):
await er_client.add_subjects_to_subjectgroup(
group_id=GROUP_ID,
subjects=SUBJECTS_PAYLOAD,
)

assert route.called


@pytest.mark.asyncio
async def test_remove_subjects_from_subjectgroup_success(er_client):
"""Test successful removal of subjects from a subject group."""
async with respx.mock(
base_url=er_client._api_root("v1.0"), assert_all_called=False
) as respx_mock:
route = respx_mock.delete(f'subjectgroup/{GROUP_ID}/subjects/')
route.return_value = httpx.Response(httpx.codes.NO_CONTENT)

result = await er_client.remove_subjects_from_subjectgroup(
group_id=GROUP_ID,
subjects=SUBJECTS_PAYLOAD,
)

assert result is True
assert route.called
assert json.loads(route.calls.last.request.content) == SUBJECTS_PAYLOAD


@pytest.mark.asyncio
async def test_remove_subjects_from_subjectgroup_not_found(er_client):
"""Test 404 raises ERClientNotFound."""
async with respx.mock(
base_url=er_client._api_root("v1.0"), assert_all_called=False
) as respx_mock:
route = respx_mock.delete(f'subjectgroup/{GROUP_ID}/subjects/')
route.return_value = httpx.Response(httpx.codes.NOT_FOUND, json={})

with pytest.raises(ERClientNotFound):
await er_client.remove_subjects_from_subjectgroup(
group_id=GROUP_ID,
subjects=SUBJECTS_PAYLOAD,
)

assert route.called


@pytest.mark.asyncio
async def test_remove_subjects_from_subjectgroup_permission_denied(er_client):
"""Test 403 raises ERClientPermissionDenied."""
async with respx.mock(
base_url=er_client._api_root("v1.0"), assert_all_called=False
) as respx_mock:
route = respx_mock.delete(f'subjectgroup/{GROUP_ID}/subjects/')
route.return_value = httpx.Response(httpx.codes.FORBIDDEN, json={})

with pytest.raises(ERClientPermissionDenied):
await er_client.remove_subjects_from_subjectgroup(
group_id=GROUP_ID,
subjects=SUBJECTS_PAYLOAD,
)

assert route.called


@pytest.mark.asyncio
async def test_remove_subjects_from_subjectgroup_internal_error(er_client):
"""Test 500 raises ERClientInternalError."""
async with respx.mock(
base_url=er_client._api_root("v1.0"), assert_all_called=False
) as respx_mock:
route = respx_mock.delete(f'subjectgroup/{GROUP_ID}/subjects/')
route.return_value = httpx.Response(
httpx.codes.INTERNAL_SERVER_ERROR, json={})

with pytest.raises(ERClientInternalError):
await er_client.remove_subjects_from_subjectgroup(
group_id=GROUP_ID,
subjects=SUBJECTS_PAYLOAD,
)

assert route.called
Loading
Loading