Skip to content

Commit

Permalink
Merge pull request #84 from lindsay-stevens/pyodk-73
Browse files Browse the repository at this point in the history
73: add client.entities.update
  • Loading branch information
lognaturel authored May 13, 2024
2 parents d7a4f7f + c1bac20 commit 47cf2cf
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 6 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ The `Client` is not specific to a project, but a default `project_id` can be set
- An init argument: `Client(project_id=1)`.
- A property on the client: `client.project_id = 1`.

*Default Identifiers*

For each endpoint, a default can be set for key identifiers, so these identifiers are optional in most methods. When the identifier is required, validation ensures that either a default value is set, or a value is specified. E.g.

```python
client.projects.default_project_id = 1
client.forms.default_form_id = "my_form"
client.submissions.default_form_id = "my_form"
client.entities.default_entity_list_name = "my_list"
client.entities.default_project_id = 1
```

### Session cache file

The session cache file uses the TOML format. The default file name is `.pyodk_cache.toml`, and the default location is the user home directory. The file name and location can be customised by setting the environment variable `PYODK_CACHE_FILE` to some other file path, or by passing the path at init with `Client(config_path="my_cache.toml")`. This file should not be pre-created as it is used to store a session token after login.
Expand Down
71 changes: 68 additions & 3 deletions pyodk/_endpoints/entities.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from datetime import datetime
from uuid import uuid4

from pyodk._endpoints import bases
from pyodk._utils import validators as pv
Expand All @@ -12,9 +13,11 @@
class CurrentVersion(bases.Model):
label: str
current: bool
createdAt: datetime
creatorId: int
userAgent: str
version: int
data: dict | None = None
baseVersion: int | None = None
conflictingProperties: list[str] | None = None

Expand All @@ -24,6 +27,7 @@ class Entity(bases.Model):
creatorId: int
createdAt: datetime
currentVersion: CurrentVersion
conflict: str | None = None # null, soft, hard
updatedAt: datetime | None = None
deletedAt: datetime | None = None

Expand All @@ -33,8 +37,10 @@ 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"
_entities: str = f"{_entity_name}/entities"
list: str = _entities
post: str = _entities
patch: str = f"{_entities}/{{entity_id}}"
get_table: str = f"{_entity_name}.svc/Entities"


Expand Down Expand Up @@ -120,7 +126,8 @@ def create(
entity_list_name, self.default_entity_list_name
)
req_data = {
"uuid": pv.validate_str(uuid, self.session.get_xform_uuid(), key="uuid"),
# For entities, Central creates a literal uuid, not an XForm uuid:uuid4()
"uuid": pv.validate_str(uuid, str(uuid4()), key="uuid"),
"label": pv.validate_str(label, key="label"),
"data": pv.validate_dict(data, key="data"),
}
Expand All @@ -137,6 +144,64 @@ def create(
data = response.json()
return Entity(**data)

def update(
self,
uuid: str,
entity_list_name: str | None = None,
project_id: int | None = None,
label: str | None = None,
data: dict | None = None,
force: bool | None = None,
base_version: int | None = None,
) -> Entity:
"""
Update an Entity.
:param uuid: The unique identifier for the Entity.
:param label: Label of the Entity.
:param data: Data to store for the Entity.
:param force: If True, update an Entity regardless of its current state. If
`base_version` is not specified, then `force` must be True.
:param base_version: The expected current version of the Entity on the server. If
`force` is not True, then `base_version` must be specified.
: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.
"""
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 = {
"uuid": pv.validate_str(uuid, key="uuid"),
}
if force is not None:
params["force"] = pv.validate_bool(force, key="force")
if base_version is not None:
params["baseVersion"] = pv.validate_int(base_version, key="base_version")
if len([i for i in (force, base_version) if i is not None]) != 1:
raise PyODKError("Must specify one of 'force' or 'base_version'.") # noqa: TRY301
req_data = {}
if label is not None:
req_data["label"] = pv.validate_str(label, key="label")
if data is not None:
req_data["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="PATCH",
url=self.session.urlformat(
self.urls.patch, project_id=pid, el_name=eln, entity_id=uuid
),
logger=log,
params=params,
json=req_data,
)
data = response.json()
return Entity(**data)

def get_table(
self,
entity_list_name: str | None = None,
Expand Down
70 changes: 70 additions & 0 deletions tests/endpoints/test_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pyodk._endpoints.entities import Entity
from pyodk._utils.session import Session
from pyodk.client import Client
from pyodk.errors import PyODKError

from tests.resources import CONFIG_DATA, entities_data

Expand Down Expand Up @@ -46,3 +47,72 @@ def test_create__ok(self):
data=entities_data.test_entities_data,
)
self.assertIsInstance(observed, Entity)

def test_update__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
for i, case in enumerate(fixture):
with self.subTest(msg=f"Case: {i}"):
mock_session.return_value.json.return_value = case
with Client() as client:
force = None
base_version = case["currentVersion"]["baseVersion"]
if base_version is None:
force = True
# Specify project
observed = client.entities.update(
project_id=2,
entity_list_name="test",
label=case["currentVersion"]["label"],
data=entities_data.test_entities_data,
uuid=case["uuid"],
base_version=base_version,
force=force,
)
self.assertIsInstance(observed, Entity)
# Use default
client.entities.default_entity_list_name = "test"
observed = client.entities.update(
label=case["currentVersion"]["label"],
data=entities_data.test_entities_data,
uuid=case["uuid"],
base_version=base_version,
force=force,
)
self.assertIsInstance(observed, Entity)

def test_update__raise_if_invalid_force_or_base_version(self):
"""Should raise an error for invalid `force` or `base_version` specification."""
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[1]
with Client() as client:
with self.assertRaises(PyODKError) as err:
client.entities.update(
project_id=2,
entity_list_name="test",
uuid=fixture[1]["uuid"],
label=fixture[1]["currentVersion"]["label"],
data=entities_data.test_entities_data,
)
self.assertIn(
"Must specify one of 'force' or 'base_version'.",
err.exception.args[0],
)
with self.assertRaises(PyODKError) as err:
client.entities.update(
project_id=2,
entity_list_name="test",
uuid=fixture[1]["uuid"],
label=fixture[1]["currentVersion"]["label"],
data=entities_data.test_entities_data,
force=True,
base_version=fixture[1]["currentVersion"]["baseVersion"],
)
self.assertIn(
"Must specify one of 'force' or 'base_version'.",
err.exception.args[0],
)
6 changes: 4 additions & 2 deletions tests/resources/entities_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"version": 1,
"baseVersion": None,
"conflictingProperties": None,
"data": {"firstName": "John", "age": "88"},
},
},
{
Expand All @@ -29,9 +30,10 @@
"createdAt": "2018-03-21T12:45:02.312Z",
"creatorId": 1,
"userAgent": "Enketo/3.0.4",
"version": 1,
"baseVersion": None,
"version": 2,
"baseVersion": 1,
"conflictingProperties": None,
"data": {"firstName": "John", "age": "88"},
},
},
]
Expand Down
25 changes: 24 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,33 @@ def test_entities__create_and_query(self):
data={"test_label": "test_value", "another_prop": "another_value"},
)
entity_list = self.client.entities.list()
self.assertIn(entity, entity_list)
# entities.create() has entities.currentVersion.data, entities.list() doesn't.
self.assertIn(entity.uuid, [e.uuid for e in entity_list])
entity_data = self.client.entities.get_table(select="__id")
self.assertIn(entity.uuid, [d["__id"] for d in entity_data["value"]])

def test_entities__update(self):
"""Should update the entity, via either base_version or force."""
self.client.entities.default_entity_list_name = "pyodk_test_eln"
entity = self.client.entities.create(
label="test_label",
data={"test_label": "test_value", "another_prop": "another_value"},
)
updated = self.client.entities.update(
label="test_label",
data={"test_label": "test_value2", "another_prop": "another_value2"},
uuid=entity.uuid,
base_version=entity.currentVersion.version,
)
self.assertEqual("test_value2", updated.currentVersion.data["test_label"])
forced = self.client.entities.update(
label="test_label",
data={"test_label": "test_value3", "another_prop": "another_value3"},
uuid=entity.uuid,
force=True,
)
self.assertEqual("test_value3", forced.currentVersion.data["test_label"])

def test_entity_lists__list(self):
"""Should return a list of entities"""
observed = self.client.entity_lists.list()
Expand Down

0 comments on commit 47cf2cf

Please sign in to comment.