diff --git a/docs/examples/app_user_provisioner/requirements.txt b/docs/examples/app_user_provisioner/requirements.txt index 04015f4..b9f7537 100644 --- a/docs/examples/app_user_provisioner/requirements.txt +++ b/docs/examples/app_user_provisioner/requirements.txt @@ -1,3 +1,2 @@ -pyodk==0.3.0 segno==1.6.1 Pillow==10.3.0 diff --git a/docs/examples/create_entities_from_submissions/create_entities_from_submissions.py b/docs/examples/create_entities_from_submissions/create_entities_from_submissions.py new file mode 100644 index 0000000..6421576 --- /dev/null +++ b/docs/examples/create_entities_from_submissions/create_entities_from_submissions.py @@ -0,0 +1,30 @@ +""" +A script that uses CSV data to create an entity list and populate it with entities. +""" + +import csv +from pathlib import Path +from uuid import uuid4 + +from pyodk import Client + +if __name__ == "__main__": + project_id = 1 + entity_list_name = f"previous_survey_{uuid4()}" + entity_label_field = "first_name" + entity_properties = ("age", "location") + csv_path = Path("./imported_answers.csv") + + with Client(project_id=project_id) as client, open(csv_path) as csv_file: + # Create the entity list. + client.entity_lists.create(entity_list_name=entity_list_name) + for prop in entity_properties: + client.entity_lists.add_property(name=prop, entity_list_name=entity_list_name) + + # Create the entities from the CSV data. + for row in csv.DictReader(csv_file): + client.entities.create( + label=row[entity_label_field], + data={k: str(v) for k, v in row.items() if k in entity_properties}, + entity_list_name=entity_list_name, + ) diff --git a/docs/examples/create_entities_from_submissions/imported_answers.csv b/docs/examples/create_entities_from_submissions/imported_answers.csv new file mode 100644 index 0000000..935ddcd --- /dev/null +++ b/docs/examples/create_entities_from_submissions/imported_answers.csv @@ -0,0 +1,4 @@ +first_name,age,favorite_color,favorite_color_other,location +John,30,r,,37.7749 -122.4194 0 10 +Alice,25,y,,-33.8651 151.2099 0 5 +Bob,35,o,orange,51.5074 -0.1278 0 15 \ No newline at end of file diff --git a/docs/examples/create_or_update_form/create_or_update_form.py b/docs/examples/create_or_update_form/create_or_update_form.py index dbda43a..1b8e0c5 100644 --- a/docs/examples/create_or_update_form/create_or_update_form.py +++ b/docs/examples/create_or_update_form/create_or_update_form.py @@ -1,11 +1,3 @@ -import sys -from os import PathLike -from pathlib import Path - -from pyodk.client import Client -from pyodk.errors import PyODKError -from requests import Response - """ A script to create or update a form, optionally with attachments. @@ -14,18 +6,21 @@ If provided, all files in the [attachments_dir] path will be uploaded with the form. """ +import sys +from os import PathLike +from pathlib import Path + +from pyodk.client import Client +from pyodk.errors import PyODKError + def create_ignore_duplicate_error(client: Client, definition: PathLike | str | bytes): """Create the form; ignore the error raised if it exists (409.3).""" try: client.forms.create(definition=definition) except PyODKError as err: - if len(err.args) >= 2 and isinstance(err.args[1], Response): - err_detail = err.args[1].json() - err_code = err_detail.get("code") - if err_code is not None and str(err_code) == "409.3": - return - raise + if not err.is_central_error(code=409.3): + raise def create_or_update(form_id: str, definition: str, attachments: str | None): diff --git a/docs/examples/create_or_update_form/requirements.txt b/docs/examples/create_or_update_form/requirements.txt deleted file mode 100644 index 9485a46..0000000 --- a/docs/examples/create_or_update_form/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pyodk==1.0.0 diff --git a/docs/examples/mail_merge/requirements.txt b/docs/examples/mail_merge/requirements.txt index c677b85..628d29f 100644 --- a/docs/examples/mail_merge/requirements.txt +++ b/docs/examples/mail_merge/requirements.txt @@ -1,2 +1 @@ -pyodk==0.3.0 -docx-mailmerge2=0.8.0 \ No newline at end of file +docx-mailmerge2==0.8.0 diff --git a/pyodk/_endpoints/comments.py b/pyodk/_endpoints/comments.py index c27b65c..adada4a 100644 --- a/pyodk/_endpoints/comments.py +++ b/pyodk/_endpoints/comments.py @@ -96,9 +96,7 @@ def post( pid = pv.validate_project_id(project_id, self.default_project_id) fid = pv.validate_form_id(form_id, self.default_form_id) iid = pv.validate_instance_id(instance_id, self.default_instance_id) - comment = pv.wrap_error( - validator=pv.v.str_validator, key="comment", value=comment - ) + comment = pv.validate_str(comment, key="comment") json = {"body": comment} except PyODKError as err: log.error(err, exc_info=True) diff --git a/pyodk/_endpoints/entity_list_properties.py b/pyodk/_endpoints/entity_list_properties.py new file mode 100644 index 0000000..09032fc --- /dev/null +++ b/pyodk/_endpoints/entity_list_properties.py @@ -0,0 +1,82 @@ +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 EntityListProperty(bases.Model): + name: str + odataName: str + publishedAt: datetime + forms: list[str] + + +class URLs(bases.Model): + class Config: + frozen = True + + post: str = "projects/{project_id}/datasets/{entity_list_name}/properties" + + +class EntityListPropertyService(bases.Service): + __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 create( + self, + name: str, + entity_list_name: str | None = None, + project_id: int | None = None, + ) -> bool: + """ + Create an Entity List Property. + + :param name: The name of the Property. Property names follow the same rules as + form field names (valid XML identifiers) and cannot use the reserved names of + name or label, or begin with the reserved prefix __. + :param entity_list_name: The name of the Entity List (Dataset) being referenced. + :param project_id: The id of the project this Entity List 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 + ) + req_data = {"name": pv.validate_str(name, key="name")} + 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, + entity_list_name=eln, + ), + logger=log, + json=req_data, + ) + data = response.json() + return data["success"] diff --git a/pyodk/_endpoints/entity_lists.py b/pyodk/_endpoints/entity_lists.py index bf0f9bb..cb94f36 100644 --- a/pyodk/_endpoints/entity_lists.py +++ b/pyodk/_endpoints/entity_lists.py @@ -1,7 +1,12 @@ import logging from datetime import datetime +from typing import Any from pyodk._endpoints import bases +from pyodk._endpoints.entity_list_properties import ( + EntityListProperty, + EntityListPropertyService, +) from pyodk._utils import validators as pv from pyodk._utils.session import Session from pyodk.errors import PyODKError @@ -14,13 +19,17 @@ class EntityList(bases.Model): projectId: int createdAt: datetime approvalRequired: bool + properties: list[EntityListProperty] | None = None class URLs(bases.Model): class Config: frozen = True - list: str = "projects/{project_id}/datasets" + _entity_list = "projects/{project_id}/datasets" + list: str = _entity_list + post: str = _entity_list + get: str = f"{_entity_list}/{{entity_list_name}}" class EntityListService(bases.Service): @@ -40,21 +49,59 @@ class EntityListService(bases.Service): multiple EntityLists. """ - __slots__ = ("urls", "session", "default_project_id") + __slots__ = ( + "urls", + "session", + "_default_project_id", + "_default_entity_list_name", + "_property_service", + "add_property", + ) 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._property_service = EntityListPropertyService(session=self.session) + self.add_property = self._property_service.create + + self._default_project_id: int | None = None + self.default_project_id = default_project_id + self._default_entity_list_name: str | None = None + self.default_entity_list_name = default_entity_list_name + + def _default_kw(self) -> dict[str, Any]: + return { + "default_project_id": self.default_project_id, + "default_entity_list_name": self.default_entity_list_name, + } + + @property + def default_project_id(self) -> int | None: + return self._default_project_id + + @default_project_id.setter + def default_project_id(self, v) -> None: + self._default_project_id = v + self._property_service.default_project_id = v + + @property + def default_entity_list_name(self) -> str | None: + return self._default_entity_list_name + + @default_entity_list_name.setter + def default_entity_list_name(self, v) -> None: + self._default_entity_list_name = v + self._property_service.default_entity_list_name = v def list(self, project_id: int | None = None) -> list[EntityList]: """ - Read Entity List details. + Read all Entity List details. :param project_id: The id of the project the Entity List belongs to. @@ -73,3 +120,73 @@ def list(self, project_id: int | None = None) -> list[EntityList]: ) data = response.json() return [EntityList(**r) for r in data] + + def get( + self, + entity_list_name: str | None = None, + project_id: int | None = None, + ) -> EntityList: + """ + Read Entity List details. + + :param project_id: The id of the project the Entity List belongs to. + :param entity_list_name: The name of the Entity List (Dataset) being referenced. + + :return: An object representation of all Entity Lists' details. + """ + 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.get, project_id=pid, entity_list_name=eln + ), + logger=log, + ) + data = response.json() + return EntityList(**data) + + def create( + self, + approval_required: bool | None = False, + entity_list_name: str | None = None, + project_id: int | None = None, + ) -> EntityList: + """ + Create an Entity List. + + :param approval_required: If False, create Entities as soon as Submissions are + received by Central. If True, create Entities when Submissions are marked as + Approved in Central. + :param entity_list_name: The name of the Entity List (Dataset) being referenced. + :param project_id: The id of the project this Entity List belongs to. + """ + try: + pid = pv.validate_project_id(project_id, self.default_project_id) + req_data = { + "name": pv.validate_entity_list_name( + entity_list_name, self.default_entity_list_name + ), + "approvalRequired": pv.validate_bool( + approval_required, key="approval_required" + ), + } + 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), + logger=log, + json=req_data, + ) + data = response.json() + return EntityList(**data) diff --git a/pyodk/_endpoints/project_app_users.py b/pyodk/_endpoints/project_app_users.py index 8429423..025bbed 100644 --- a/pyodk/_endpoints/project_app_users.py +++ b/pyodk/_endpoints/project_app_users.py @@ -81,9 +81,7 @@ def create( """ try: pid = pv.validate_project_id(project_id, self.default_project_id) - display_name = pv.wrap_error( - validator=pv.v.str_validator, key="display_name", value=display_name - ) + display_name = pv.validate_str(display_name, key="display_name") json = {"displayName": display_name} except PyODKError as err: log.error(err, exc_info=True) diff --git a/pyodk/_utils/validators.py b/pyodk/_utils/validators.py index cf1b9e6..77ad52c 100644 --- a/pyodk/_utils/validators.py +++ b/pyodk/_utils/validators.py @@ -4,8 +4,7 @@ from typing import Any from pydantic.v1 import validators as v -from pydantic.v1.errors import PydanticValueError -from pydantic_core._pydantic_core import ValidationError +from pydantic.v1.errors import PydanticTypeError, PydanticValueError from pyodk._utils.utils import coalesce from pyodk.errors import PyODKError @@ -22,7 +21,7 @@ def wrap_error(validator: Callable, key: str, value: Any) -> Any: """ try: return validator(value) - except (ValidationError, PydanticValueError) as err: + except (PydanticTypeError, PydanticValueError) as err: msg = f"{key}: {err!s}" raise PyODKError(msg) from err @@ -99,9 +98,9 @@ def validate_dict(*args: dict, key: str) -> int: ) -def validate_file_path(*args: PathLike | str) -> Path: +def validate_file_path(*args: PathLike | str, key: str = "file_path") -> Path: def validate_fp(f): p = v.path_validator(f) return v.path_exists_validator(p) - return wrap_error(validator=validate_fp, key="file_path", value=coalesce(*args)) + return wrap_error(validator=validate_fp, key=key, value=coalesce(*args)) diff --git a/pyodk/errors.py b/pyodk/errors.py index 7b43487..f40fb37 100644 --- a/pyodk/errors.py +++ b/pyodk/errors.py @@ -1,2 +1,18 @@ +from requests import Response + + class PyODKError(Exception): """An error raised by pyodk.""" + + def is_central_error(self, code: float | str) -> bool: + """ + Does the PyODK error represent a Central error with the specified code? + + Per central-backend/lib/util/problem.js. + """ + if len(self.args) >= 2 and isinstance(self.args[1], Response): + err_detail = self.args[1].json() + err_code = str(err_detail.get("code", "")) + if err_code is not None and err_code == str(code): + return True + return False diff --git a/tests/endpoints/test_entity_lists.py b/tests/endpoints/test_entity_lists.py index 517c121..96ef818 100644 --- a/tests/endpoints/test_entity_lists.py +++ b/tests/endpoints/test_entity_lists.py @@ -19,7 +19,37 @@ def test_list__ok(self): mock_session.return_value.json.return_value = fixture with Client() as client: observed = client.entity_lists.list() - self.assertEqual(2, len(observed)) + self.assertEqual(3, len(observed)) for i, o in enumerate(observed): with self.subTest(i): self.assertIsInstance(o, EntityList) + + def test_get__ok(self): + """Should return an EntityList object.""" + fixture = entity_lists_data.test_entity_lists[2] + 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.entity_lists.get(entity_list_name="pyodk_test_eln") + self.assertIsInstance(observed, EntityList) + + def test_create__ok(self): + """Should return an EntityList object.""" + fixture = entity_lists_data.test_entity_lists + 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.entity_lists.create( + project_id=2, + entity_list_name="test", + approval_required=False, + ) + self.assertIsInstance(observed, EntityList) + # Use default + client.entity_lists.default_entity_list_name = "test" + client.entity_lists.default_project_id = 2 + observed = client.entity_lists.create() + self.assertIsInstance(observed, EntityList) diff --git a/tests/resources/entity_lists_data.py b/tests/resources/entity_lists_data.py index 9f2a227..2a5703a 100644 --- a/tests/resources/entity_lists_data.py +++ b/tests/resources/entity_lists_data.py @@ -11,4 +11,35 @@ "projectId": 1, "approvalRequired": False, }, + { + "name": "pyodk_test_eln", + "createdAt": "2024-04-17T13:16:32.960Z", + "projectId": 1, + "approvalRequired": False, + "entities": 5, + "lastEntity": "2024-05-22T05:52:02.868Z", + "conflicts": 0, + "linkedForms": [], + "sourceForms": [], + "properties": [ + { + "name": "test_label", + "publishedAt": "2024-04-17T13:16:33.172Z", + "odataName": "test_label", + "forms": [], + }, + { + "name": "another_prop", + "publishedAt": "2024-04-17T13:16:33.383Z", + "odataName": "another_prop", + "forms": [], + }, + { + "name": "third_property", + "publishedAt": "2024-05-22T05:46:29.578Z", + "odataName": "third_property", + "forms": [], + }, + ], + }, ] diff --git a/tests/test_client.py b/tests/test_client.py index 0876fd5..45e7629 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -286,6 +286,28 @@ def test_entities__update(self): self.assertEqual("test_value3", forced.currentVersion.data["test_label"]) def test_entity_lists__list(self): - """Should return a list of entities""" + """Should return a list of Entity Lists.""" observed = self.client.entity_lists.list() self.assertGreater(len(observed), 0) + + def test_entity_lists__create_and_query(self): + """Should create a new Entity List, and query it afterwards via list().""" + self.client.entity_lists.default_entity_list_name = ( + self.client.session.get_xform_uuid() + ) + entity_list = self.client.entity_lists.create() + entity_lists = self.client.entity_lists.list() + self.assertIn( + (entity_list.name, entity_list.projectId), + [(e.name, e.projectId) for e in entity_lists], + ) + + def test_entity_lists__add_property(self): + """Should create a new property on the Entity List.""" + self.client.entity_lists.default_entity_list_name = ( + self.client.session.get_xform_uuid() + ) + self.client.entity_lists.create() + self.client.entity_lists.add_property(name="test") + entity_list = self.client.entity_lists.get() + self.assertEqual(["test"], [p.name for p in entity_list.properties]) diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..45ff5b1 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,41 @@ +from unittest import TestCase + +from pyodk._utils import validators as v +from pyodk.errors import PyODKError + + +class TestValidators(TestCase): + def test_wrap_error__raises_pyodk_error(self): + """Should raise a PyODK error (from Pydantic) if a validator check fails.""" + + def a_func(): + pass + + cases = ( + (v.validate_project_id, False, (None, "a")), + (v.validate_form_id, False, (None, a_func)), + (v.validate_table_name, False, (None, a_func)), + (v.validate_instance_id, False, (None, a_func)), + (v.validate_entity_list_name, False, (None, a_func)), + (v.validate_str, True, (None, a_func)), + (v.validate_bool, True, (None, a_func)), + (v.validate_int, True, (None, a_func)), + (v.validate_dict, True, (None, ((("a",),),))), + ( + v.validate_file_path, + True, + ( + None, + "No such file", + ), + ), + ) + + for i, (func, has_key, values) in enumerate(cases): + for j, value in enumerate(values): + msg = f"Case {i}, Value {j}" + with self.subTest(msg=msg), self.assertRaises(PyODKError): + if has_key: + func(value, key=msg) + else: + func(value) diff --git a/tests/utils/entity_lists.py b/tests/utils/entity_lists.py index d6039b1..b50e507 100644 --- a/tests/utils/entity_lists.py +++ b/tests/utils/entity_lists.py @@ -1,4 +1,4 @@ -from pyodk._endpoints.entity_lists import EntityList, log +from pyodk._endpoints.entity_lists import EntityList from pyodk.client import Client from pyodk.errors import PyODKError @@ -14,16 +14,10 @@ def create_new_or_get_entity_list( :param entity_props: Properties to add to the entity list. """ try: - entity_list = client.session.response_or_error( - method="POST", - url=client.session.urlformat( - "projects/{pid}/datasets", - pid=client.project_id, - ), - logger=log, - json={"name": entity_list_name}, - ) - except PyODKError: + entity_list = client.entity_lists.create(entity_list_name=entity_list_name) + except PyODKError as err: + if not err.is_central_error(code=409.3): + raise entity_list = client.session.get( url=client.session.urlformat( "projects/{pid}/datasets/{eln}", @@ -31,18 +25,10 @@ def create_new_or_get_entity_list( eln=entity_list_name, ), ) - try: - for prop in entity_props: - client.session.response_or_error( - method="GET", - url=client.session.urlformat( - "projects/{pid}/datasets/{eln}/properties", - pid=client.project_id, - eln=entity_list_name, - ), - logger=log, - json={"name": prop}, - ) - except PyODKError: - pass + for prop in entity_props: + try: + client.entity_lists.add_property(name=prop, entity_list_name=entity_list_name) + except PyODKError as err: + if not err.is_central_error(code=409.3): + raise return EntityList(**entity_list.json()) diff --git a/tests/utils/forms.py b/tests/utils/forms.py index 9df3e1a..941c3ac 100644 --- a/tests/utils/forms.py +++ b/tests/utils/forms.py @@ -2,7 +2,6 @@ from pyodk.client import Client from pyodk.errors import PyODKError -from requests import Response from tests.utils import utils from tests.utils.md_table import md_table_to_temp_dir @@ -17,12 +16,8 @@ def create_ignore_duplicate_error( try: client.forms.create(definition=definition, form_id=form_id) except PyODKError as err: - if len(err.args) >= 2 and isinstance(err.args[1], Response): - err_detail = err.args[1].json() - err_code = err_detail.get("code") - if err_code is not None and str(err_code) == "409.3": - return - raise + if not err.is_central_error(code=409.3): + raise def create_new_form__md(client: Client, form_id: str, form_def: str): diff --git a/tests/utils/submissions.py b/tests/utils/submissions.py index 371505f..1faeaf3 100644 --- a/tests/utils/submissions.py +++ b/tests/utils/submissions.py @@ -27,7 +27,9 @@ def create_new_or_get_last_submission( ), form_id=form_id, ).instanceId - except PyODKError: + except PyODKError as err: + if not err.is_central_error(code=409.3): + raise subvs = client.session.get( client.session.urlformat( "projects/{pid}/forms/{fid}/submissions/{iid}/versions",