Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

73: add client.entities.update #84

Merged
merged 2 commits into from
May 13, 2024
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
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
Loading