Skip to content

Commit

Permalink
add: support for entities and entity_lists
Browse files Browse the repository at this point in the history
  • Loading branch information
lindsay-stevens committed Mar 28, 2024
1 parent fa3ddff commit 43df2d5
Show file tree
Hide file tree
Showing 8 changed files with 425 additions and 0 deletions.
194 changes: 194 additions & 0 deletions pyodk/_endpoints/entities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import logging
from datetime import datetime
from uuid import uuid4

from pyodk._endpoints import bases
from pyodk._utils import validators as pv
from pyodk._utils.session import Session
from pyodk.errors import PyODKError

log = logging.getLogger(__name__)


class CurrentVersion(bases.Model):
label: str
current: bool
creatorId: int
userAgent: str
version: int
baseVersion: int | None = None
conflictingProperties: list[str] | None = None


class Entity(bases.Model):
uuid: str
creatorId: int
createdAt: datetime
currentVersion: CurrentVersion
updatedAt: datetime | None = None
deletedAt: datetime | None = None


class URLs(bases.Model):
class Config:
frozen = True

_entity_name: str = "projects/{project_id}/datasets/{el_name}"
list: str = f"{_entity_name}/entities"
post: str = f"{_entity_name}/entities"
get_table: str = f"{_entity_name}.svc/Entities"


class EntityService(bases.Service):
"""
Entity-related functionality is accessed through `client.entities`. For example:
```python
from pyodk.client import Client
client = Client()
data = client.entities.list()
```
An EntityList is a list of Entities, e.g. `list[Entity]`.
"""

__slots__ = ("urls", "session", "default_project_id", "default_entity_list_name")

def __init__(
self,
session: Session,
default_project_id: int | None = None,
default_entity_list_name: str | None = None,
urls: URLs = None,
):
self.urls: URLs = urls if urls is not None else URLs()
self.session: Session = session
self.default_project_id: int | None = default_project_id
self.default_entity_list_name: str | None = default_entity_list_name

def list(
self, entity_list_name: str | None = None, project_id: int | None = None
) -> list[Entity]:
"""
Read all Entity metadata.
:param entity_list_name: The name of the Entity List (Dataset) being referenced.
:param project_id: The id of the project the Entity belongs to.
:return: A list of the object representation of all Entity metadata.
"""
try:
pid = pv.validate_project_id(project_id, self.default_project_id)
eln = pv.validate_entity_list_name(
entity_list_name, self.default_entity_list_name
)
except PyODKError as err:
log.error(err, exc_info=True)
raise

response = self.session.response_or_error(
method="GET",
url=self.session.urlformat(self.urls.list, project_id=pid, el_name=eln),
logger=log,
)
data = response.json()
return [Entity(**r) for r in data]

def create(
self,
label: str,
data: dict,
entity_list_name: str | None = None,
project_id: int | None = None,
uuid: str | None = None,
) -> Entity:
"""
Create an Entity.
:param label: Label of the Entity.
:param data: Data to store for the Entity.
:param entity_list_name: The name of the Entity List (Dataset) being referenced.
:param project_id: The id of the project this form belongs to.
:param uuid: An optional unique identifier for the Entity. If not provided then
a uuid will be generated and sent by the client.
"""
try:
pid = pv.validate_project_id(project_id, self.default_project_id)
eln = pv.validate_entity_list_name(
entity_list_name, self.default_entity_list_name
)
req_data = {
"uuid": pv.validate_str(uuid, str(uuid4()), key="uuid"),
"label": pv.validate_str(label, key="label"),
"data": pv.validate_dict(data, key="data"),
}
except PyODKError as err:
log.error(err, exc_info=True)
raise

response = self.session.response_or_error(
method="POST",
url=self.session.urlformat(self.urls.post, project_id=pid, el_name=eln),
logger=log,
data=req_data,
)
data = response.json()
return Entity(**data)

def get_table(
self,
entity_list_name: str | None = None,
project_id: int | None = None,
skip: int | None = None,
top: int | None = None,
count: bool | None = None,
filter: str | None = None,
select: str | None = None,
) -> dict:
"""
Read Entity List data.
:param entity_list_name: The name of the Entity List (Dataset) being referenced.
:param project_id: The id of the project this form belongs to.
:param skip: The first n rows will be omitted from the results.
:param top: Only up to n rows will be returned in the results.
:param count: If True, an @odata.count property will be added to the result to
indicate the total number of rows, ignoring the above paging parameters.
:param filter: Filter responses to those matching the query. Only certain fields
are available to reference. The operators lt, le, eq, neq, ge, gt, not, and,
and or are supported, and the built-in functions now, year, month, day, hour,
minute, second.
:param select: If provided, will return only the selected fields.
:return: A dictionary representation of the OData JSON document.
"""
try:
pid = pv.validate_project_id(project_id, self.default_project_id)
eln = pv.validate_entity_list_name(
entity_list_name, self.default_entity_list_name
)
params = {
k: v
for k, v in {
"$skip": skip,
"$top": top,
"$count": count,
"$filter": filter,
"$select": select,
}.items()
if v is not None
}
except PyODKError as err:
log.error(err, exc_info=True)
raise

response = self.session.response_or_error(
method="GET",
url=self.session.urlformat(
self.urls.get_table, project_id=pid, el_name=eln, table_name="Entities"
),
logger=log,
params=params,
)
return response.json()
79 changes: 79 additions & 0 deletions pyodk/_endpoints/entity_lists.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import logging
from datetime import datetime

from pyodk._endpoints import bases
from pyodk._utils import validators as pv
from pyodk._utils.session import Session
from pyodk.errors import PyODKError

log = logging.getLogger(__name__)


class EntityList(bases.Model):
name: str
projectId: int
createdAt: datetime
approvalRequired: bool


class URLs(bases.Model):
class Config:
frozen = True

list: str = "projects/{project_id}/datasets"


class EntityListService(bases.Service):
"""
Entity List-related functionality is accessed through `client.entity_lists`.
For example:
```python
from pyodk.client import Client
client = Client()
data = client.entity_lists.list()
```
The structure this class works with is conceptually a list of lists, e.g.
```
EntityList = list[Entity]
self.list() = list[EntityList]
```
"""

__slots__ = ("urls", "session", "default_project_id")

def __init__(
self,
session: Session,
default_project_id: int | None = None,
urls: URLs = None,
):
self.urls: URLs = urls if urls is not None else URLs()
self.session: Session = session
self.default_project_id: int | None = default_project_id

def list(self, project_id: int | None = None) -> list[EntityList]:
"""
Read Entity List details.
:param project_id: The id of the project the Entity List belongs to.
:return: A list of the object representation of all Entity Lists' details.
"""
try:
pid = pv.validate_project_id(project_id, self.default_project_id)
except PyODKError as err:
log.error(err, exc_info=True)
raise

response = self.session.response_or_error(
method="GET",
url=self.session.urlformat(self.urls.list, project_id=pid),
logger=log,
)
data = response.json()
return [EntityList(**r) for r in data]
16 changes: 16 additions & 0 deletions pyodk/_utils/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ def validate_instance_id(*args: str) -> str:
)


def validate_entity_list_name(*args: str) -> str:
return wrap_error(
validator=v.str_validator,
key="entity_list_name",
value=coalesce(*args),
)


def validate_str(*args: str, key: str) -> str:
return wrap_error(
validator=v.str_validator,
Expand All @@ -81,6 +89,14 @@ def validate_int(*args: int, key: str) -> int:
)


def validate_dict(*args: dict, key: str) -> int:
return wrap_error(
validator=v.dict_validator,
key=key,
value=coalesce(*args),
)


def validate_file_path(*args: str) -> Path:
def validate_fp(f):
p = v.path_validator(f)
Expand Down
8 changes: 8 additions & 0 deletions pyodk/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from collections.abc import Callable

from pyodk._endpoints.comments import CommentService
from pyodk._endpoints.entities import EntityService
from pyodk._endpoints.entity_lists import EntityListService
from pyodk._endpoints.forms import FormService
from pyodk._endpoints.projects import ProjectService
from pyodk._endpoints.submissions import SubmissionService
Expand Down Expand Up @@ -67,6 +69,12 @@ def __init__(
self._comments: CommentService = CommentService(
session=self.session, default_project_id=self.project_id
)
self.entities: EntityService = EntityService(
session=self.session, default_project_id=self.project_id
)
self.entity_lists: EntityListService = EntityListService(
session=self.session, default_project_id=self.project_id
)

@property
def project_id(self) -> int | None:
Expand Down
48 changes: 48 additions & 0 deletions tests/endpoints/test_entities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from unittest import TestCase
from unittest.mock import MagicMock, patch

from pyodk._endpoints.entities import Entity
from pyodk._utils.session import Session
from pyodk.client import Client

from tests.resources import CONFIG_DATA, entities_data


@patch("pyodk._utils.session.Auth.login", MagicMock())
@patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA))
class TestEntities(TestCase):
def test_list__ok(self):
"""Should return a list of Entity objects."""
fixture = entities_data.test_entities
with patch.object(Session, "request") as mock_session:
mock_session.return_value.status_code = 200
mock_session.return_value.json.return_value = fixture
with Client() as client:
observed = client.entities.list(entity_list_name="test")
self.assertEqual(2, len(observed))
for i, o in enumerate(observed):
with self.subTest(i):
self.assertIsInstance(o, Entity)

def test_create__ok(self):
"""Should return an Entity object."""
fixture = entities_data.test_entities
with patch.object(Session, "request") as mock_session:
mock_session.return_value.status_code = 200
mock_session.return_value.json.return_value = fixture[0]
with Client() as client:
# Specify project
observed = client.entities.create(
project_id=2,
entity_list_name="test",
label="John (88)",
data=entities_data.test_entities_data,
)
self.assertIsInstance(observed, Entity)
# Use default
observed = client.entities.create(
entity_list_name="test",
label="John (88)",
data=entities_data.test_entities_data,
)
self.assertIsInstance(observed, Entity)
Loading

0 comments on commit 43df2d5

Please sign in to comment.