Skip to content

Commit 47cf2cf

Browse files
authored
Merge pull request #84 from lindsay-stevens/pyodk-73
73: add client.entities.update
2 parents d7a4f7f + c1bac20 commit 47cf2cf

File tree

5 files changed

+178
-6
lines changed

5 files changed

+178
-6
lines changed

README.md

+12
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ The `Client` is not specific to a project, but a default `project_id` can be set
6464
- An init argument: `Client(project_id=1)`.
6565
- A property on the client: `client.project_id = 1`.
6666

67+
*Default Identifiers*
68+
69+
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.
70+
71+
```python
72+
client.projects.default_project_id = 1
73+
client.forms.default_form_id = "my_form"
74+
client.submissions.default_form_id = "my_form"
75+
client.entities.default_entity_list_name = "my_list"
76+
client.entities.default_project_id = 1
77+
```
78+
6779
### Session cache file
6880

6981
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.

pyodk/_endpoints/entities.py

+68-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from datetime import datetime
3+
from uuid import uuid4
34

45
from pyodk._endpoints import bases
56
from pyodk._utils import validators as pv
@@ -12,9 +13,11 @@
1213
class CurrentVersion(bases.Model):
1314
label: str
1415
current: bool
16+
createdAt: datetime
1517
creatorId: int
1618
userAgent: str
1719
version: int
20+
data: dict | None = None
1821
baseVersion: int | None = None
1922
conflictingProperties: list[str] | None = None
2023

@@ -24,6 +27,7 @@ class Entity(bases.Model):
2427
creatorId: int
2528
createdAt: datetime
2629
currentVersion: CurrentVersion
30+
conflict: str | None = None # null, soft, hard
2731
updatedAt: datetime | None = None
2832
deletedAt: datetime | None = None
2933

@@ -33,8 +37,10 @@ class Config:
3337
frozen = True
3438

3539
_entity_name: str = "projects/{project_id}/datasets/{el_name}"
36-
list: str = f"{_entity_name}/entities"
37-
post: str = f"{_entity_name}/entities"
40+
_entities: str = f"{_entity_name}/entities"
41+
list: str = _entities
42+
post: str = _entities
43+
patch: str = f"{_entities}/{{entity_id}}"
3844
get_table: str = f"{_entity_name}.svc/Entities"
3945

4046

@@ -120,7 +126,8 @@ def create(
120126
entity_list_name, self.default_entity_list_name
121127
)
122128
req_data = {
123-
"uuid": pv.validate_str(uuid, self.session.get_xform_uuid(), key="uuid"),
129+
# For entities, Central creates a literal uuid, not an XForm uuid:uuid4()
130+
"uuid": pv.validate_str(uuid, str(uuid4()), key="uuid"),
124131
"label": pv.validate_str(label, key="label"),
125132
"data": pv.validate_dict(data, key="data"),
126133
}
@@ -137,6 +144,64 @@ def create(
137144
data = response.json()
138145
return Entity(**data)
139146

147+
def update(
148+
self,
149+
uuid: str,
150+
entity_list_name: str | None = None,
151+
project_id: int | None = None,
152+
label: str | None = None,
153+
data: dict | None = None,
154+
force: bool | None = None,
155+
base_version: int | None = None,
156+
) -> Entity:
157+
"""
158+
Update an Entity.
159+
160+
:param uuid: The unique identifier for the Entity.
161+
:param label: Label of the Entity.
162+
:param data: Data to store for the Entity.
163+
:param force: If True, update an Entity regardless of its current state. If
164+
`base_version` is not specified, then `force` must be True.
165+
:param base_version: The expected current version of the Entity on the server. If
166+
`force` is not True, then `base_version` must be specified.
167+
:param entity_list_name: The name of the Entity List (Dataset) being referenced.
168+
:param project_id: The id of the project this form belongs to.
169+
"""
170+
try:
171+
pid = pv.validate_project_id(project_id, self.default_project_id)
172+
eln = pv.validate_entity_list_name(
173+
entity_list_name, self.default_entity_list_name
174+
)
175+
params = {
176+
"uuid": pv.validate_str(uuid, key="uuid"),
177+
}
178+
if force is not None:
179+
params["force"] = pv.validate_bool(force, key="force")
180+
if base_version is not None:
181+
params["baseVersion"] = pv.validate_int(base_version, key="base_version")
182+
if len([i for i in (force, base_version) if i is not None]) != 1:
183+
raise PyODKError("Must specify one of 'force' or 'base_version'.") # noqa: TRY301
184+
req_data = {}
185+
if label is not None:
186+
req_data["label"] = pv.validate_str(label, key="label")
187+
if data is not None:
188+
req_data["data"] = pv.validate_dict(data, key="data")
189+
except PyODKError as err:
190+
log.error(err, exc_info=True)
191+
raise
192+
193+
response = self.session.response_or_error(
194+
method="PATCH",
195+
url=self.session.urlformat(
196+
self.urls.patch, project_id=pid, el_name=eln, entity_id=uuid
197+
),
198+
logger=log,
199+
params=params,
200+
json=req_data,
201+
)
202+
data = response.json()
203+
return Entity(**data)
204+
140205
def get_table(
141206
self,
142207
entity_list_name: str | None = None,

tests/endpoints/test_entities.py

+70
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pyodk._endpoints.entities import Entity
55
from pyodk._utils.session import Session
66
from pyodk.client import Client
7+
from pyodk.errors import PyODKError
78

89
from tests.resources import CONFIG_DATA, entities_data
910

@@ -46,3 +47,72 @@ def test_create__ok(self):
4647
data=entities_data.test_entities_data,
4748
)
4849
self.assertIsInstance(observed, Entity)
50+
51+
def test_update__ok(self):
52+
"""Should return an Entity object."""
53+
fixture = entities_data.test_entities
54+
with patch.object(Session, "request") as mock_session:
55+
mock_session.return_value.status_code = 200
56+
for i, case in enumerate(fixture):
57+
with self.subTest(msg=f"Case: {i}"):
58+
mock_session.return_value.json.return_value = case
59+
with Client() as client:
60+
force = None
61+
base_version = case["currentVersion"]["baseVersion"]
62+
if base_version is None:
63+
force = True
64+
# Specify project
65+
observed = client.entities.update(
66+
project_id=2,
67+
entity_list_name="test",
68+
label=case["currentVersion"]["label"],
69+
data=entities_data.test_entities_data,
70+
uuid=case["uuid"],
71+
base_version=base_version,
72+
force=force,
73+
)
74+
self.assertIsInstance(observed, Entity)
75+
# Use default
76+
client.entities.default_entity_list_name = "test"
77+
observed = client.entities.update(
78+
label=case["currentVersion"]["label"],
79+
data=entities_data.test_entities_data,
80+
uuid=case["uuid"],
81+
base_version=base_version,
82+
force=force,
83+
)
84+
self.assertIsInstance(observed, Entity)
85+
86+
def test_update__raise_if_invalid_force_or_base_version(self):
87+
"""Should raise an error for invalid `force` or `base_version` specification."""
88+
fixture = entities_data.test_entities
89+
with patch.object(Session, "request") as mock_session:
90+
mock_session.return_value.status_code = 200
91+
mock_session.return_value.json.return_value = fixture[1]
92+
with Client() as client:
93+
with self.assertRaises(PyODKError) as err:
94+
client.entities.update(
95+
project_id=2,
96+
entity_list_name="test",
97+
uuid=fixture[1]["uuid"],
98+
label=fixture[1]["currentVersion"]["label"],
99+
data=entities_data.test_entities_data,
100+
)
101+
self.assertIn(
102+
"Must specify one of 'force' or 'base_version'.",
103+
err.exception.args[0],
104+
)
105+
with self.assertRaises(PyODKError) as err:
106+
client.entities.update(
107+
project_id=2,
108+
entity_list_name="test",
109+
uuid=fixture[1]["uuid"],
110+
label=fixture[1]["currentVersion"]["label"],
111+
data=entities_data.test_entities_data,
112+
force=True,
113+
base_version=fixture[1]["currentVersion"]["baseVersion"],
114+
)
115+
self.assertIn(
116+
"Must specify one of 'force' or 'base_version'.",
117+
err.exception.args[0],
118+
)

tests/resources/entities_data.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"version": 1,
1515
"baseVersion": None,
1616
"conflictingProperties": None,
17+
"data": {"firstName": "John", "age": "88"},
1718
},
1819
},
1920
{
@@ -29,9 +30,10 @@
2930
"createdAt": "2018-03-21T12:45:02.312Z",
3031
"creatorId": 1,
3132
"userAgent": "Enketo/3.0.4",
32-
"version": 1,
33-
"baseVersion": None,
33+
"version": 2,
34+
"baseVersion": 1,
3435
"conflictingProperties": None,
36+
"data": {"firstName": "John", "age": "88"},
3537
},
3638
},
3739
]

tests/test_client.py

+24-1
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,33 @@ def test_entities__create_and_query(self):
258258
data={"test_label": "test_value", "another_prop": "another_value"},
259259
)
260260
entity_list = self.client.entities.list()
261-
self.assertIn(entity, entity_list)
261+
# entities.create() has entities.currentVersion.data, entities.list() doesn't.
262+
self.assertIn(entity.uuid, [e.uuid for e in entity_list])
262263
entity_data = self.client.entities.get_table(select="__id")
263264
self.assertIn(entity.uuid, [d["__id"] for d in entity_data["value"]])
264265

266+
def test_entities__update(self):
267+
"""Should update the entity, via either base_version or force."""
268+
self.client.entities.default_entity_list_name = "pyodk_test_eln"
269+
entity = self.client.entities.create(
270+
label="test_label",
271+
data={"test_label": "test_value", "another_prop": "another_value"},
272+
)
273+
updated = self.client.entities.update(
274+
label="test_label",
275+
data={"test_label": "test_value2", "another_prop": "another_value2"},
276+
uuid=entity.uuid,
277+
base_version=entity.currentVersion.version,
278+
)
279+
self.assertEqual("test_value2", updated.currentVersion.data["test_label"])
280+
forced = self.client.entities.update(
281+
label="test_label",
282+
data={"test_label": "test_value3", "another_prop": "another_value3"},
283+
uuid=entity.uuid,
284+
force=True,
285+
)
286+
self.assertEqual("test_value3", forced.currentVersion.data["test_label"])
287+
265288
def test_entity_lists__list(self):
266289
"""Should return a list of entities"""
267290
observed = self.client.entity_lists.list()

0 commit comments

Comments
 (0)