From 321d0f44914d8696e9c42ad76f06c0d3896baf80 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Fri, 3 May 2024 23:06:31 +1000 Subject: [PATCH 1/2] add: client.entities.update - add: entity fields that were not included earlier: - entity.conflict - entity.currentVersion.createdAt - entity.currentVersion.data (None for entities.list()) - chg: use plain uuid4() instead of uuid:uuid4() to match Central --- pyodk/_endpoints/entities.py | 70 ++++++++++++++++++++++++++++++-- tests/endpoints/test_entities.py | 70 ++++++++++++++++++++++++++++++++ tests/resources/entities_data.py | 6 ++- tests/test_client.py | 25 +++++++++++- 4 files changed, 165 insertions(+), 6 deletions(-) diff --git a/pyodk/_endpoints/entities.py b/pyodk/_endpoints/entities.py index 13b5da9..5db722d 100644 --- a/pyodk/_endpoints/entities.py +++ b/pyodk/_endpoints/entities.py @@ -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 @@ -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 @@ -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 @@ -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" @@ -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"), } @@ -137,6 +144,63 @@ def create( data = response.json() return Entity(**data) + def update( + self, + label: str, + data: dict, + uuid: str, + force: bool | None = None, + base_version: int | None = None, + entity_list_name: str | None = None, + project_id: int | None = None, + ) -> Entity: + """ + Update an Entity. + + :param label: Label of the Entity. + :param data: Data to store for the Entity. + :param uuid: The unique identifier 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 = { + "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="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, diff --git a/tests/endpoints/test_entities.py b/tests/endpoints/test_entities.py index 55f4ba8..f486ea9 100644 --- a/tests/endpoints/test_entities.py +++ b/tests/endpoints/test_entities.py @@ -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 @@ -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], + ) diff --git a/tests/resources/entities_data.py b/tests/resources/entities_data.py index 512d937..4a020fb 100644 --- a/tests/resources/entities_data.py +++ b/tests/resources/entities_data.py @@ -14,6 +14,7 @@ "version": 1, "baseVersion": None, "conflictingProperties": None, + "data": {"firstName": "John", "age": "88"}, }, }, { @@ -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"}, }, }, ] diff --git a/tests/test_client.py b/tests/test_client.py index ac62fca..0876fd5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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() From c1bac20023e026cd1ed01f6811cc8e70b8159f98 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Sat, 4 May 2024 16:02:43 +1000 Subject: [PATCH 2/2] chg: entities.update optional label and data params, update readme - chg: label or data (or both/neither) are allowed for entities.update - add: readme section on default identifiers --- README.md | 12 ++++++++++++ pyodk/_endpoints/entities.py | 19 ++++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7eb249d..1402c0c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pyodk/_endpoints/entities.py b/pyodk/_endpoints/entities.py index 5db722d..31074ed 100644 --- a/pyodk/_endpoints/entities.py +++ b/pyodk/_endpoints/entities.py @@ -146,20 +146,20 @@ def create( def update( self, - label: str, - data: dict, uuid: str, - force: bool | None = None, - base_version: int | None = None, 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 uuid: The unique identifier 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 @@ -181,10 +181,11 @@ def update( 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 = { - "label": pv.validate_str(label, key="label"), - "data": pv.validate_dict(data, key="data"), - } + 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