From 3c6b65fb1ee2ca7e6b7afc3b7b5ec8dd8239175f Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 6 Oct 2022 21:09:15 +1100 Subject: [PATCH 1/6] add: client.submissions.edit, validation improvements - add client.submissions.edit to update submission data and optionally add a comment at the same time. - validation improvements: - use pydantic validators instead of writing the same. Also benefits by returning a type-cast value if it was possible to do so, e.g. "1" is a castable number so return 1. - instead of checking 200 response status only, use the built-in "raise_for_status" for use with all verbs, which checks for status codes >=400 and <600. --- README.md | 22 ++- pyodk/client.py | 4 + pyodk/endpoints/comment.py | 111 +++++++++++++ pyodk/endpoints/forms.py | 23 ++- pyodk/endpoints/projects.py | 17 +- pyodk/endpoints/submissions.py | 237 ++++++++++++++++++++++------ pyodk/session.py | 20 ++- pyodk/validators.py | 83 ++++++---- tests/endpoints/test_comments.py | 50 ++++++ tests/endpoints/test_submissions.py | 71 ++++++++- tests/resources/comments_data.py | 11 ++ tests/resources/submissions_data.py | 9 ++ tests/test_client.py | 13 +- 13 files changed, 549 insertions(+), 122 deletions(-) create mode 100644 pyodk/endpoints/comment.py create mode 100644 tests/endpoints/test_comments.py create mode 100644 tests/resources/comments_data.py diff --git a/README.md b/README.md index abf9ff8..25e8047 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ with Client() as client: forms = client.forms.list() submissions = client.submissions.list(form_id=next(forms).xmlFormId) form_data = client.submissions.get_table(form_id="birds", project_id=8) + comments = client.comments.list(form_id=next(forms).xmlFormId, instance_id="uuid:...") ``` **👉 Looking for more advanced examples? You can find detailed Jupyter notebooks, scripts, and webinars [here](examples).** @@ -96,15 +97,22 @@ The `Client` is specific to a configuration and cache file. These approximately Available methods on `Client`: - Projects - - get - - list + - list: Read all Project details. + - get: Read Project details. - Forms - - get - - list + - list: Read all Form details. + - get: Read Form details. - Submissions - - get - - list - - get_table + - list: Read all Submission metadata. + - get: Read Submission metadata. + - get_table: Read Submission data. + - post: Create a Submission. + - put: Update Submission data. + - patch: Update Submission metadata. + - edit: Submission.post then Comment.post. +- Comment + - list: Read all Comment details. + - post: Create a Comment. - *for additional requests* - get - post diff --git a/pyodk/client.py b/pyodk/client.py index 255a618..a4bd807 100644 --- a/pyodk/client.py +++ b/pyodk/client.py @@ -2,6 +2,7 @@ from pyodk import config from pyodk.endpoints.auth import AuthService +from pyodk.endpoints.comment import CommentService from pyodk.endpoints.forms import FormService from pyodk.endpoints.projects import ProjectService from pyodk.endpoints.submissions import SubmissionService @@ -56,6 +57,9 @@ def __init__( self.submissions: SubmissionService = SubmissionService( session=self.session, default_project_id=self.project_id ) + self.comments: CommentService = CommentService( + session=self.session, default_project_id=self.project_id + ) @property def project_id(self) -> Optional[int]: diff --git a/pyodk/endpoints/comment.py b/pyodk/endpoints/comment.py new file mode 100644 index 0000000..69a96fb --- /dev/null +++ b/pyodk/endpoints/comment.py @@ -0,0 +1,111 @@ +import logging +from typing import List, Optional + +from pyodk import validators as pv +from pyodk.endpoints import bases +from pyodk.errors import PyODKError +from pyodk.session import Session + +log = logging.getLogger(__name__) + + +class Comment(bases.Model): + body: str + actorId: int + + +class URLs(bases.Model): + class Config: + frozen = True + + list: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}/comments" + post: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}/comments" + + +class CommentService(bases.Service): + __slots__ = ( + "urls", + "session", + "default_project_id", + "default_form_id", + "default_instance_id", + ) + + def __init__( + self, + session: Session, + default_project_id: Optional[int] = None, + default_form_id: Optional[str] = None, + default_instance_id: Optional[str] = None, + urls: URLs = None, + ): + self.urls: URLs = urls if urls is not None else URLs() + self.session: Session = session + self.default_project_id: Optional[int] = default_project_id + self.default_form_id: Optional[str] = default_form_id + self.default_instance_id: Optional[str] = default_instance_id + + def list( + self, + form_id: Optional[str] = None, + project_id: Optional[int] = None, + instance_id: Optional[str] = None, + ) -> List[Comment]: + """ + Read all Comment details. + + :param form_id: The xmlFormId of the Form being referenced. + :param project_id: The id of the project the Submissions belong to. + :param instance_id: The instanceId of the Submission being referenced. + """ + try: + 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) + except PyODKError as err: + log.error(err, exc_info=True) + raise err + + response = self.session.response_or_error( + method="GET", + url=self.urls.list.format(project_id=pid, form_id=fid, instance_id=iid), + logger=log, + ) + data = response.json() + return [Comment(**r) for r in data] + + def post( + self, + comment: str, + project_id: Optional[int] = None, + form_id: Optional[str] = None, + instance_id: Optional[str] = None, + ) -> Comment: + """ + Create a Comment. + + :param comment: The text of the comment. + :param project_id: The id of the project this form belongs to. + :param form_id: The xmlFormId of the Form being referenced. + :param instance_id: The instanceId of the Submission being referenced. + """ + try: + 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 + ) + json = {"body": comment} + except PyODKError as err: + log.error(err, exc_info=True) + raise err + + response = self.session.response_or_error( + method="POST", + url=self.urls.post.format(project_id=pid, form_id=fid, instance_id=iid), + logger=log, + json=json, + ) + data = response.json() + return Comment(**data) diff --git a/pyodk/endpoints/forms.py b/pyodk/endpoints/forms.py index 011702a..48cb8f6 100644 --- a/pyodk/endpoints/forms.py +++ b/pyodk/endpoints/forms.py @@ -34,6 +34,7 @@ class Config: list: str = "projects/{project_id}/forms" get: str = "projects/{project_id}/forms/{form_id}" + class FormService(bases.Service): __slots__ = ("urls", "session", "default_project_id", "default_form_id") @@ -51,19 +52,18 @@ def __init__( def list(self, project_id: Optional[int] = None) -> List[Form]: """ - Read the details of all Forms. + Read all Form details. :param project_id: The id of the project the forms belong to. """ try: - pid = pv.validate_project_id( - project_id=project_id, default_project_id=self.default_project_id - ) + pid = pv.validate_project_id(project_id, self.default_project_id) except PyODKError as err: log.error(err, exc_info=True) raise err else: - response = self.session.get_200_or_error( + response = self.session.response_or_error( + method="GET", url=self.urls.list.format(project_id=pid), logger=log, ) @@ -76,23 +76,20 @@ def get( project_id: Optional[int] = None, ) -> Form: """ - Read the details of a Form. + Read Form details. :param form_id: The id of this form as given in its XForms XML definition. :param project_id: The id of the project this form belongs to. """ try: - pid = pv.validate_project_id( - project_id=project_id, default_project_id=self.default_project_id - ) - fid = pv.validate_form_id( - form_id=form_id, default_form_id=self.default_form_id - ) + pid = pv.validate_project_id(project_id, self.default_project_id) + fid = pv.validate_form_id(form_id, self.default_form_id) except PyODKError as err: log.error(err, exc_info=True) raise err else: - response = self.session.get_200_or_error( + response = self.session.response_or_error( + method="GET", url=self.urls.get.format(project_id=pid, form_id=fid), logger=log, ) diff --git a/pyodk/endpoints/projects.py b/pyodk/endpoints/projects.py index e1c7272..5ae4e14 100644 --- a/pyodk/endpoints/projects.py +++ b/pyodk/endpoints/projects.py @@ -48,27 +48,30 @@ def __init__( def list(self) -> List[Project]: """ - Read the details of all projects. + Read Project details. """ - response = self.session.get_200_or_error(url=self.urls.list, logger=log) + response = self.session.response_or_error( + method="GET", + url=self.urls.list, + logger=log, + ) data = response.json() return [Project(**r) for r in data] def get(self, project_id: Optional[int] = None) -> Project: """ - Read the details of a Project. + Read all Project details. :param project_id: The id of the project to read. """ try: - pid = pv.validate_project_id( - project_id=project_id, default_project_id=self.default_project_id - ) + pid = pv.validate_project_id(project_id, self.default_project_id) except PyODKError as err: log.error(err, exc_info=True) raise err else: - response = self.session.get_200_or_error( + response = self.session.response_or_error( + method="GET", url=self.urls.get.format(project_id=pid), logger=log, ) diff --git a/pyodk/endpoints/submissions.py b/pyodk/endpoints/submissions.py index 23cab3a..c552657 100644 --- a/pyodk/endpoints/submissions.py +++ b/pyodk/endpoints/submissions.py @@ -4,6 +4,7 @@ from pyodk import validators as pv from pyodk.endpoints import bases +from pyodk.endpoints.comment import CommentService from pyodk.errors import PyODKError from pyodk.session import Session @@ -29,6 +30,9 @@ class Config: list: str = "projects/{project_id}/forms/{form_id}/submissions" get: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}" get_table: str = "projects/{project_id}/forms/{form_id}.svc/{table_name}" + post: str = "projects/{project_id}/forms/{form_id}/submissions" + patch: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}" + put: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}" class SubmissionService(bases.Service): @@ -50,28 +54,25 @@ def list( self, form_id: Optional[str] = None, project_id: Optional[int] = None ) -> List[Submission]: """ - Read the details of all Submissions. + Read all Submission metadata. :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project the Submissions belong to. """ try: - pid = pv.validate_project_id( - project_id=project_id, default_project_id=self.default_project_id - ) - fid = pv.validate_form_id( - form_id=form_id, default_form_id=self.default_form_id - ) + pid = pv.validate_project_id(project_id, self.default_project_id) + fid = pv.validate_form_id(form_id, self.default_form_id) except PyODKError as err: log.error(err, exc_info=True) raise err - else: - response = self.session.get_200_or_error( - url=self.urls.list.format(project_id=pid, form_id=fid), - logger=log, - ) - data = response.json() - return [Submission(**r) for r in data] + + response = self.session.response_or_error( + method="GET", + url=self.urls.list.format(project_id=pid, form_id=fid), + logger=log, + ) + data = response.json() + return [Submission(**r) for r in data] def get( self, @@ -80,30 +81,27 @@ def get( project_id: Optional[int] = None, ) -> Submission: """ - Read the details of a Submission. + Read Submission metadata. :param instance_id: The instanceId of the Submission being referenced. :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project this form belongs to. """ try: - pid = pv.validate_project_id( - project_id=project_id, default_project_id=self.default_project_id - ) - fid = pv.validate_form_id( - form_id=form_id, default_form_id=self.default_form_id - ) - iid = pv.validate_instance_id(instance_id=instance_id) + 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) except PyODKError as err: log.error(err, exc_info=True) raise err - else: - response = self.session.get_200_or_error( - url=self.urls.get.format(project_id=pid, form_id=fid, instance_id=iid), - logger=log, - ) - data = response.json() - return Submission(**data) + + response = self.session.response_or_error( + method="GET", + url=self.urls.get.format(project_id=pid, form_id=fid, instance_id=iid), + logger=log, + ) + data = response.json() + return Submission(**data) def get_table( self, @@ -118,7 +116,7 @@ def get_table( expand: Optional[str] = None, ) -> Dict: """ - Read Submissions as an OData table. + Read Submission data. :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project this form belongs to. @@ -137,13 +135,9 @@ def get_table( is implemented, which expands all repetitions. """ try: - pid = pv.validate_project_id( - project_id=project_id, default_project_id=self.default_project_id - ) - fid = pv.validate_form_id( - form_id=form_id, default_form_id=self.default_form_id - ) - table = pv.validate_table_name(table_name=table_name) + pid = pv.validate_project_id(project_id, self.default_project_id) + fid = pv.validate_form_id(form_id, self.default_form_id) + table = pv.validate_table_name(table_name) params = { k: v for k, v in { @@ -159,12 +153,165 @@ def get_table( except PyODKError as err: log.error(err, exc_info=True) raise err - else: - response = self.session.get_200_or_error( - url=self.urls.get_table.format( - project_id=pid, form_id=fid, table_name=table - ), - logger=log, - params=params, + + response = self.session.response_or_error( + method="GET", + url=self.urls.get_table.format(project_id=pid, form_id=fid, table_name=table), + logger=log, + params=params, + ) + return response.json() + + def post( + self, + xml: str, + form_id: Optional[str] = None, + project_id: Optional[int] = None, + device_id: Optional[str] = None, + ) -> Submission: + """ + Create a Submission. + + Example submission XML structure: + + + + uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44 + + Alice + 36 + + + :param xml: The submission XML. + :param form_id: The xmlFormId of the Form being referenced. + :param project_id: The id of the project this form belongs to. + :param device_id: An optional deviceID associated with the submission. + """ + try: + pid = pv.validate_project_id(project_id, self.default_project_id) + fid = pv.validate_form_id(form_id, self.default_form_id) + params = {} + if device_id is not None: + params["deviceID"] = device_id + except PyODKError as err: + log.error(err, exc_info=True) + raise err + + response = self.session.response_or_error( + method="POST", + url=self.urls.post.format(project_id=pid, form_id=fid), + logger=log, + headers={"Content-Type": "application/xml"}, + params=params, + data=xml, + ) + data = response.json() + return Submission(**data) + + def put( + self, + instance_id: str, + xml: str, + form_id: Optional[str] = None, + project_id: Optional[int] = None, + ) -> Submission: + """ + Update Submission data. + + Example submission XML structure: + + + + uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44 + uuid:315c2f74-c8fc-4606-ae3f-22f8983e441e + + Alice + 36 + + + :param instance_id: The instanceId of the Submission being referenced. + :param xml: The submission XML. + :param form_id: The xmlFormId of the Form 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) + fid = pv.validate_form_id(form_id, self.default_form_id) + iid = pv.validate_instance_id(instance_id) + except PyODKError as err: + log.error(err, exc_info=True) + raise err + + response = self.session.response_or_error( + method="PUT", + url=self.urls.put.format(project_id=pid, form_id=fid, instance_id=iid), + logger=log, + headers={"Content-Type": "application/xml"}, + data=xml, + ) + data = response.json() + return Submission(**data) + + def patch( + self, + instance_id: str, + form_id: Optional[str] = None, + project_id: Optional[int] = None, + review_state: Optional[str] = None, + ) -> Submission: + """ + Update Submission metadata. + + :param instance_id: The instanceId of the Submission being referenced. + :param form_id: The xmlFormId of the Form being referenced. + :param project_id: The id of the project this form belongs to. + :param review_state: The current review state of the submission. + """ + try: + 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) + json = {} + if review_state is not None: + json["reviewState"] = review_state + except PyODKError as err: + log.error(err, exc_info=True) + raise err + + response = self.session.response_or_error( + method="PATCH", + url=self.urls.patch.format(project_id=pid, form_id=fid, instance_id=iid), + logger=log, + json=json, + ) + data = response.json() + return Submission(**data) + + def edit( + self, + instance_id: str, + xml: str, + form_id: Optional[str] = None, + project_id: Optional[int] = None, + comment: Optional[str] = None, + ) -> None: + """ + Submission.post then Comment.post. + + :param instance_id: The instanceId of the Submission being referenced. + :param xml: The submission XML. + :param form_id: The xmlFormId of the Form being referenced. + :param project_id: The id of the project this form belongs to. + :param comment: The text of the comment. + """ + self.put(instance_id=instance_id, xml=xml, form_id=form_id, project_id=project_id) + if comment is not None: + comment_svc = CommentService( + session=self.session, default_project_id=self.default_project_id + ) + comment_svc.post( + comment=comment, + project_id=project_id, + form_id=form_id, + instance_id=instance_id, ) - return response.json() diff --git a/pyodk/session.py b/pyodk/session.py index 5ec090d..2f57e68 100644 --- a/pyodk/session.py +++ b/pyodk/session.py @@ -1,8 +1,10 @@ +from logging import Logger from urllib.parse import urljoin from requests import Response from requests import Session as RequestsSession from requests.adapters import HTTPAdapter, Retry +from requests.exceptions import HTTPError from pyodk import __version__ from pyodk.errors import PyODKError @@ -61,15 +63,19 @@ def prepare_request(self, request): request.url = self.urljoin(request.url) return super().prepare_request(request) - def get_200_or_error(self, url, logger, *args, **kwargs) -> Response: - response = self.request("GET", url, *args, **kwargs) - if response.status_code == 200: - return response - else: + def response_or_error( + self, method: str, url: str, logger: Logger, *args, **kwargs + ) -> Response: + response = self.request(method=method, url=url, *args, **kwargs) + try: + response.raise_for_status() + except HTTPError as e: msg = ( f"The request to {url} failed." - f" Status: {response.status_code}, content: {response.content}" + f" Status: {response.status_code}, content: {response.text}" ) err = PyODKError(msg, response) logger.error(err, exc_info=True) - raise err + raise err from e + else: + return response diff --git a/pyodk/validators.py b/pyodk/validators.py index 04a4618..e5df44c 100644 --- a/pyodk/validators.py +++ b/pyodk/validators.py @@ -1,38 +1,55 @@ -from typing import Optional +from typing import Any, Callable + +from pydantic import validators as v +from pydantic.errors import StrError from pyodk.errors import PyODKError from pyodk.utils import coalesce -def validate_project_id( - project_id: Optional[int] = None, default_project_id: Optional[int] = None -) -> int: - pid = coalesce(project_id, default_project_id) - if pid is None: - msg = "No project ID was provided." - raise PyODKError(msg) - return pid - - -def validate_form_id( - form_id: Optional[str] = None, default_form_id: Optional[str] = None -) -> str: - fid = coalesce(form_id, default_form_id) - if fid is None: - msg = "No form ID was provided." - raise PyODKError(msg) - return fid - - -def validate_table_name(table_name: Optional[str] = None) -> str: - if table_name is None: - msg = "No table name was provided." - raise PyODKError(msg) - return table_name - - -def validate_instance_id(instance_id: Optional[str] = None) -> str: - if instance_id is None: - msg = "No instance ID was provided." - raise PyODKError(msg) - return instance_id +def wrap_error(validator: Callable, key: str, value: Any) -> Any: + """ + Wrap the error in a PyODKError, with a nicer message. + + :param validator: A pydantic validator function. + :param key: The variable name to use in the error message. + :param value: The variable value. + :return: + """ + try: + return validator(value) + except StrError as err: + msg = f"{key}: {str(err)}" + raise PyODKError(msg) from err + + +def validate_project_id(*args: int) -> int: + return wrap_error( + validator=v.int_validator, + key="project_id", + value=coalesce(*args), + ) + + +def validate_form_id(*args: str) -> str: + return wrap_error( + validator=v.str_validator, + key="form_id", + value=coalesce(*args), + ) + + +def validate_table_name(*args: str) -> str: + return wrap_error( + validator=v.str_validator, + key="table_name", + value=coalesce(*args), + ) + + +def validate_instance_id(*args: str) -> str: + return wrap_error( + validator=v.str_validator, + key="instance_id", + value=coalesce(*args), + ) diff --git a/tests/endpoints/test_comments.py b/tests/endpoints/test_comments.py new file mode 100644 index 0000000..a79658e --- /dev/null +++ b/tests/endpoints/test_comments.py @@ -0,0 +1,50 @@ +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from pyodk.client import Client +from pyodk.endpoints.comment import Comment +from pyodk.session import Session +from tests.resources import CONFIG_DATA, comments_data + + +@patch("pyodk.client.Client._login", MagicMock()) +@patch("pyodk.config.read_config", MagicMock(return_value=CONFIG_DATA)) +class TestComments(TestCase): + def test_list__ok(self): + """Should return a list of Comment objects.""" + fixture = comments_data.test_comments + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.json.return_value = fixture["response_data"] + with Client() as client: + observed = client.comments.list( + form_id=fixture["form_id"], + instance_id=fixture["instance_id"], + ) + self.assertEqual(4, len(observed)) + for i, o in enumerate(observed): + with self.subTest(i): + self.assertIsInstance(o, Comment) + + def test_post__ok(self): + """Should return a Comment object.""" + fixture = comments_data.test_comments + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.json.return_value = fixture["response_data"][0] + with Client() as client: + # Specify project + observed = client.comments.post( + project_id=fixture["project_id"], + form_id=fixture["form_id"], + instance_id=fixture["instance_id"], + comment="Looks good", + ) + self.assertIsInstance(observed, Comment) + # Use default + observed = client.comments.post( + form_id=fixture["form_id"], + instance_id=fixture["instance_id"], + comment="Looks good", + ) + self.assertIsInstance(observed, Comment) diff --git a/tests/endpoints/test_submissions.py b/tests/endpoints/test_submissions.py index 25eef62..bf61f0a 100644 --- a/tests/endpoints/test_submissions.py +++ b/tests/endpoints/test_submissions.py @@ -11,7 +11,7 @@ @patch("pyodk.config.read_config", MagicMock(return_value=CONFIG_DATA)) class TestSubmissions(TestCase): def test_list__ok(self): - """Should return a list of SubmissionType objects.""" + """Should return a list of Submission objects.""" fixture = submissions_data.test_submissions with patch.object(Session, "request") as mock_session: mock_session.return_value.status_code = 200 @@ -24,7 +24,7 @@ def test_list__ok(self): self.assertIsInstance(o, Submission) def test_get__ok(self): - """Should return a SubmissionType object.""" + """Should return a Submission object.""" fixture = submissions_data.test_submissions with patch.object(Session, "request") as mock_session: mock_session.return_value.status_code = 200 @@ -43,3 +43,70 @@ def test_get__ok(self): instance_id=fixture["response_data"][0]["instanceId"], ) self.assertIsInstance(observed, Submission) + + def test_post__ok(self): + """Should return a Submission object.""" + fixture = submissions_data.test_submissions + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.json.return_value = fixture["response_data"][0] + with Client() as client: + # Specify project + observed = client.submissions.post( + project_id=fixture["project_id"], + form_id=fixture["form_id"], + xml=submissions_data.test_xml, + ) + self.assertIsInstance(observed, Submission) + # Use default + observed = client.submissions.post( + form_id=fixture["form_id"], + xml=submissions_data.test_xml, + ) + self.assertIsInstance(observed, Submission) + + def test_put__ok(self): + """Should return a Submission object.""" + fixture = submissions_data.test_submissions + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.json.return_value = fixture["response_data"][0] + with Client() as client: + # Specify project + observed = client.submissions.put( + project_id=fixture["project_id"], + form_id=fixture["form_id"], + instance_id=fixture["response_data"][0]["instanceId"], + xml=submissions_data.test_xml, + ) + self.assertIsInstance(observed, Submission) + # Use default + observed = client.submissions.put( + form_id=fixture["form_id"], + instance_id=fixture["response_data"][0]["instanceId"], + xml=submissions_data.test_xml, + ) + self.assertIsInstance(observed, Submission) + + def test_patch__ok(self): + """Should return a Submission object.""" + fixture = submissions_data.test_submissions + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.json.return_value = fixture["response_data"][0] + with Client() as client: + # Specify project + observed = client.submissions.patch( + project_id=fixture["project_id"], + form_id=fixture["form_id"], + instance_id=fixture["response_data"][0]["instanceId"], + review_state="edited", + ) + self.assertIsInstance(observed, Submission) + # Use default + observed = client.submissions.patch( + form_id=fixture["form_id"], + instance_id=fixture["response_data"][0]["instanceId"], + review_state="edited", + ) + self.assertIsInstance(observed, Submission) diff --git a/tests/resources/comments_data.py b/tests/resources/comments_data.py new file mode 100644 index 0000000..f8b1bd7 --- /dev/null +++ b/tests/resources/comments_data.py @@ -0,0 +1,11 @@ +test_comments = { + "project_id": 51, + "form_id": "range", + "instance_id": "uuid:85cb9aff-005e-4edd-9739-dc9c1a829c47", + "response_data": [ + {"body": "Test", "actorId": 650, "createdAt": "2022-10-06T09:08:07.722Z"}, + {"body": "Looks good", "actorId": 650, "createdAt": "2022-10-06T09:01:43.868Z"}, + {"body": "Looks good", "actorId": 650, "createdAt": "2022-10-06T09:00:41.433Z"}, + {"body": "Looks good", "actorId": 650, "createdAt": "2022-10-06T09:00:10.638Z"}, + ], +} diff --git a/tests/resources/submissions_data.py b/tests/resources/submissions_data.py index 34fa868..e377c68 100644 --- a/tests/resources/submissions_data.py +++ b/tests/resources/submissions_data.py @@ -36,3 +36,12 @@ }, ], } +test_xml = """ + + + uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44 + + Alice + 36 + +""" diff --git a/tests/test_client.py b/tests/test_client.py index a5b0cf0..e99fb85 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,12 +18,9 @@ def test_direct(self): table_name="Submissions", count=True, ) - print( - [ - projects, - forms, - submissions, - form_data, - form_data_params - ] + comments = client.comments.list( + project_id=51, + form_id="range", + instance_id="uuid:85cb9aff-005e-4edd-9739-dc9c1a829c47", ) + print([projects, forms, submissions, form_data, form_data_params, comments]) From 675c8eda7efeda4c4bfa77b6cc29fd46d2782305 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Fri, 7 Oct 2022 19:48:38 +1100 Subject: [PATCH 2/6] add: createdAt to Comment object since this is returned now too - switched from sandbox to staging for fix for the Comments.post response (no actorID, comment as array). It also now returns a createdAt like Comments.list does, so add that to the expected data model. - Updated integration test UUID for new test data in staging. --- pyodk/endpoints/comment.py | 2 ++ tests/test_client.py | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyodk/endpoints/comment.py b/pyodk/endpoints/comment.py index 69a96fb..9751df0 100644 --- a/pyodk/endpoints/comment.py +++ b/pyodk/endpoints/comment.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from typing import List, Optional from pyodk import validators as pv @@ -12,6 +13,7 @@ class Comment(bases.Model): body: str actorId: int + createdAt: datetime class URLs(bases.Model): diff --git a/tests/test_client.py b/tests/test_client.py index e99fb85..ba4acfc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,8 +19,7 @@ def test_direct(self): count=True, ) comments = client.comments.list( - project_id=51, form_id="range", - instance_id="uuid:85cb9aff-005e-4edd-9739-dc9c1a829c47", + instance_id="uuid:2c296eae-2708-4a89-bfe7-0f2d440b7fe8", ) print([projects, forms, submissions, form_data, form_data_params, comments]) From 89a93af20e76d2b7d4e86b467c2eafb76f6fed6a Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Fri, 7 Oct 2022 20:40:58 +1100 Subject: [PATCH 3/6] fix: add plural on comment -> comments for consistency with others --- pyodk/client.py | 2 +- pyodk/endpoints/{comment.py => comments.py} | 0 pyodk/endpoints/submissions.py | 2 +- tests/endpoints/test_comments.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename pyodk/endpoints/{comment.py => comments.py} (100%) diff --git a/pyodk/client.py b/pyodk/client.py index a4bd807..03a4439 100644 --- a/pyodk/client.py +++ b/pyodk/client.py @@ -2,7 +2,7 @@ from pyodk import config from pyodk.endpoints.auth import AuthService -from pyodk.endpoints.comment import CommentService +from pyodk.endpoints.comments import CommentService from pyodk.endpoints.forms import FormService from pyodk.endpoints.projects import ProjectService from pyodk.endpoints.submissions import SubmissionService diff --git a/pyodk/endpoints/comment.py b/pyodk/endpoints/comments.py similarity index 100% rename from pyodk/endpoints/comment.py rename to pyodk/endpoints/comments.py diff --git a/pyodk/endpoints/submissions.py b/pyodk/endpoints/submissions.py index c552657..05c2e09 100644 --- a/pyodk/endpoints/submissions.py +++ b/pyodk/endpoints/submissions.py @@ -4,7 +4,7 @@ from pyodk import validators as pv from pyodk.endpoints import bases -from pyodk.endpoints.comment import CommentService +from pyodk.endpoints.comments import CommentService from pyodk.errors import PyODKError from pyodk.session import Session diff --git a/tests/endpoints/test_comments.py b/tests/endpoints/test_comments.py index a79658e..522ed21 100644 --- a/tests/endpoints/test_comments.py +++ b/tests/endpoints/test_comments.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch from pyodk.client import Client -from pyodk.endpoints.comment import Comment +from pyodk.endpoints.comments import Comment from pyodk.session import Session from tests.resources import CONFIG_DATA, comments_data From 7c8544241173857506ba54a592db25e5bdb46ca2 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 17 Jan 2023 04:09:44 +1100 Subject: [PATCH 4/6] chg: expose only Client and errors, rename Submission methods - mark most modules as internal (underscore prefix), and specify public code in __all__ - users can still import but these are the PEP8 conventions - addresses feedback about what the public API of pyodk is / should be. - rename Submissions HTTP method names to edit/review - make client.comments private and instead put Comment methods on Submissions - addresses feedback around naming / code organisation --- README.md | 15 ++- pyodk/__init__.py | 8 ++ pyodk/_endpoints/__init__.py | 1 + pyodk/{endpoints => _endpoints}/auth.py | 4 +- pyodk/{endpoints => _endpoints}/bases.py | 2 +- pyodk/{endpoints => _endpoints}/comments.py | 6 +- pyodk/{endpoints => _endpoints}/forms.py | 6 +- pyodk/{endpoints => _endpoints}/projects.py | 6 +- .../{endpoints => _endpoints}/submissions.py | 107 +++++++++++++++--- pyodk/_utils/__init__.py | 1 + pyodk/{ => _utils}/config.py | 0 pyodk/{ => _utils}/session.py | 3 +- pyodk/{ => _utils}/utils.py | 0 pyodk/{ => _utils}/validators.py | 2 +- pyodk/client.py | 16 +-- pyodk/endpoints/__init__.py | 0 tests/endpoints/test_auth.py | 6 +- tests/endpoints/test_comments.py | 12 +- tests/endpoints/test_forms.py | 6 +- tests/endpoints/test_projects.py | 4 +- tests/endpoints/test_submissions.py | 24 ++-- tests/resources/__init__.py | 2 +- tests/test_client.py | 2 +- tests/test_config.py | 2 +- tests/test_session.py | 2 +- 25 files changed, 162 insertions(+), 75 deletions(-) create mode 100644 pyodk/_endpoints/__init__.py rename pyodk/{endpoints => _endpoints}/auth.py (97%) rename pyodk/{endpoints => _endpoints}/bases.py (93%) rename pyodk/{endpoints => _endpoints}/comments.py (96%) rename pyodk/{endpoints => _endpoints}/forms.py (95%) rename pyodk/{endpoints => _endpoints}/projects.py (94%) rename pyodk/{endpoints => _endpoints}/submissions.py (78%) create mode 100644 pyodk/_utils/__init__.py rename pyodk/{ => _utils}/config.py (100%) rename pyodk/{ => _utils}/session.py (98%) rename pyodk/{ => _utils}/utils.py (100%) rename pyodk/{ => _utils}/validators.py (96%) delete mode 100644 pyodk/endpoints/__init__.py diff --git a/README.md b/README.md index 25e8047..601bc97 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ with Client() as client: forms = client.forms.list() submissions = client.submissions.list(form_id=next(forms).xmlFormId) form_data = client.submissions.get_table(form_id="birds", project_id=8) - comments = client.comments.list(form_id=next(forms).xmlFormId, instance_id="uuid:...") + comments = client.submissions.list_comments(form_id=next(forms).xmlFormId, instance_id="uuid:...") ``` **👉 Looking for more advanced examples? You can find detailed Jupyter notebooks, scripts, and webinars [here](examples).** @@ -106,13 +106,12 @@ Available methods on `Client`: - list: Read all Submission metadata. - get: Read Submission metadata. - get_table: Read Submission data. - - post: Create a Submission. - - put: Update Submission data. - - patch: Update Submission metadata. - - edit: Submission.post then Comment.post. -- Comment - - list: Read all Comment details. - - post: Create a Comment. + - create: Create a Submission. + - edit: Edit a submission, and optionally comment on it. + - review: Update Submission metadata (review state), and optionally comment on it. + - list_comments: Read Comment data for a Submission. + - add_comment: Create a Comment for a Submission. + - *for additional requests* - get - post diff --git a/pyodk/__init__.py b/pyodk/__init__.py index 4122cf0..0f24ce2 100644 --- a/pyodk/__init__.py +++ b/pyodk/__init__.py @@ -1,5 +1,13 @@ import logging +from pyodk import errors +from pyodk.client import Client + +__all__ = ( + "Client", + "errors", +) + __version__ = "0.1.0" diff --git a/pyodk/_endpoints/__init__.py b/pyodk/_endpoints/__init__.py new file mode 100644 index 0000000..bcccb4b --- /dev/null +++ b/pyodk/_endpoints/__init__.py @@ -0,0 +1 @@ +__all__ = tuple() diff --git a/pyodk/endpoints/auth.py b/pyodk/_endpoints/auth.py similarity index 97% rename from pyodk/endpoints/auth.py rename to pyodk/_endpoints/auth.py index 3eb40bb..515e6f6 100644 --- a/pyodk/endpoints/auth.py +++ b/pyodk/_endpoints/auth.py @@ -1,9 +1,9 @@ import logging from typing import Optional -from pyodk import config +from pyodk._utils import config +from pyodk._utils.session import Session from pyodk.errors import PyODKError -from pyodk.session import Session log = logging.getLogger(__name__) diff --git a/pyodk/endpoints/bases.py b/pyodk/_endpoints/bases.py similarity index 93% rename from pyodk/endpoints/bases.py rename to pyodk/_endpoints/bases.py index 4a00fb7..af59e16 100644 --- a/pyodk/endpoints/bases.py +++ b/pyodk/_endpoints/bases.py @@ -2,7 +2,7 @@ from pydantic import BaseModel -from pyodk.session import Session +from pyodk._utils.session import Session class Model(BaseModel): diff --git a/pyodk/endpoints/comments.py b/pyodk/_endpoints/comments.py similarity index 96% rename from pyodk/endpoints/comments.py rename to pyodk/_endpoints/comments.py index 9751df0..a5cd02d 100644 --- a/pyodk/endpoints/comments.py +++ b/pyodk/_endpoints/comments.py @@ -2,10 +2,10 @@ from datetime import datetime from typing import List, Optional -from pyodk import validators as pv -from pyodk.endpoints import bases +from pyodk._endpoints import bases +from pyodk._utils import validators as pv +from pyodk._utils.session import Session from pyodk.errors import PyODKError -from pyodk.session import Session log = logging.getLogger(__name__) diff --git a/pyodk/endpoints/forms.py b/pyodk/_endpoints/forms.py similarity index 95% rename from pyodk/endpoints/forms.py rename to pyodk/_endpoints/forms.py index 48cb8f6..f5b2e57 100644 --- a/pyodk/endpoints/forms.py +++ b/pyodk/_endpoints/forms.py @@ -2,10 +2,10 @@ from datetime import datetime from typing import List, Optional -from pyodk import validators as pv -from pyodk.endpoints import bases +from pyodk._endpoints import bases +from pyodk._utils import validators as pv +from pyodk._utils.session import Session from pyodk.errors import PyODKError -from pyodk.session import Session log = logging.getLogger(__name__) diff --git a/pyodk/endpoints/projects.py b/pyodk/_endpoints/projects.py similarity index 94% rename from pyodk/endpoints/projects.py rename to pyodk/_endpoints/projects.py index 5ae4e14..d89634b 100644 --- a/pyodk/endpoints/projects.py +++ b/pyodk/_endpoints/projects.py @@ -2,10 +2,10 @@ from datetime import datetime from typing import List, Optional -from pyodk import validators as pv -from pyodk.endpoints import bases +from pyodk._endpoints import bases +from pyodk._utils import validators as pv +from pyodk._utils.session import Session from pyodk.errors import PyODKError -from pyodk.session import Session log = logging.getLogger(__name__) diff --git a/pyodk/endpoints/submissions.py b/pyodk/_endpoints/submissions.py similarity index 78% rename from pyodk/endpoints/submissions.py rename to pyodk/_endpoints/submissions.py index 05c2e09..a28a3f2 100644 --- a/pyodk/endpoints/submissions.py +++ b/pyodk/_endpoints/submissions.py @@ -2,11 +2,11 @@ from datetime import datetime from typing import Dict, List, Optional -from pyodk import validators as pv -from pyodk.endpoints import bases -from pyodk.endpoints.comments import CommentService +from pyodk._endpoints import bases +from pyodk._endpoints.comments import Comment, CommentService +from pyodk._utils import validators as pv +from pyodk._utils.session import Session from pyodk.errors import PyODKError -from pyodk.session import Session log = logging.getLogger(__name__) @@ -162,7 +162,7 @@ def get_table( ) return response.json() - def post( + def create( self, xml: str, form_id: Optional[str] = None, @@ -208,7 +208,7 @@ def post( data = response.json() return Submission(**data) - def put( + def _put( self, instance_id: str, xml: str, @@ -252,20 +252,20 @@ def put( data = response.json() return Submission(**data) - def patch( + def _patch( self, instance_id: str, + review_state: str, form_id: Optional[str] = None, project_id: Optional[int] = None, - review_state: Optional[str] = None, ) -> Submission: """ Update Submission metadata. :param instance_id: The instanceId of the Submission being referenced. + :param review_state: The current review state of the submission. :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project this form belongs to. - :param review_state: The current review state of the submission. """ try: pid = pv.validate_project_id(project_id, self.default_project_id) @@ -296,7 +296,7 @@ def edit( comment: Optional[str] = None, ) -> None: """ - Submission.post then Comment.post. + Edit a submission and optionally comment on it. :param instance_id: The instanceId of the Submission being referenced. :param xml: The submission XML. @@ -304,14 +304,91 @@ def edit( :param project_id: The id of the project this form belongs to. :param comment: The text of the comment. """ - self.put(instance_id=instance_id, xml=xml, form_id=form_id, project_id=project_id) + self._put( + instance_id=instance_id, xml=xml, form_id=form_id, project_id=project_id + ) if comment is not None: - comment_svc = CommentService( - session=self.session, default_project_id=self.default_project_id - ) - comment_svc.post( + self.add_comment( + instance_id=instance_id, comment=comment, project_id=project_id, form_id=form_id, + ) + + def review( + self, + instance_id: str, + review_state: str, + form_id: Optional[str] = None, + project_id: Optional[int] = None, + comment: Optional[str] = None, + ) -> None: + """ + Update Submission metadata and optionally comment on it. + + :param instance_id: The instanceId of the Submission being referenced. + :param review_state: The current review state of the submission. + :param form_id: The xmlFormId of the Form being referenced. + :param project_id: The id of the project this form belongs to. + :param comment: The text of the comment. + """ + self._patch( + instance_id=instance_id, + review_state=review_state, + form_id=form_id, + project_id=project_id, + ) + if comment is not None: + self.add_comment( instance_id=instance_id, + comment=comment, + project_id=project_id, + form_id=form_id, ) + + def list_comments( + self, + instance_id: str, + form_id: Optional[str] = None, + project_id: Optional[int] = None, + ) -> List[Comment]: + """ + Read all Comment details. + + :param instance_id: The instanceId of the Submission being referenced. + :param form_id: The xmlFormId of the Form being referenced. + :param project_id: The id of the project the Submissions belong to. + """ + comment_svc = CommentService( + session=self.session, default_project_id=self.default_project_id + ) + return comment_svc.list( + project_id=project_id, + form_id=form_id, + instance_id=instance_id, + ) + + def add_comment( + self, + instance_id: str, + comment: str, + project_id: Optional[int] = None, + form_id: Optional[str] = None, + ) -> Comment: + """ + Create a Comment. + + :param instance_id: The instanceId of the Submission being referenced. + :param comment: The text of the comment. + :param project_id: The id of the project this form belongs to. + :param form_id: The xmlFormId of the Form being referenced. + """ + comment_svc = CommentService( + session=self.session, default_project_id=self.default_project_id + ) + return comment_svc.post( + comment=comment, + project_id=project_id, + form_id=form_id, + instance_id=instance_id, + ) diff --git a/pyodk/_utils/__init__.py b/pyodk/_utils/__init__.py new file mode 100644 index 0000000..bcccb4b --- /dev/null +++ b/pyodk/_utils/__init__.py @@ -0,0 +1 @@ +__all__ = tuple() diff --git a/pyodk/config.py b/pyodk/_utils/config.py similarity index 100% rename from pyodk/config.py rename to pyodk/_utils/config.py diff --git a/pyodk/session.py b/pyodk/_utils/session.py similarity index 98% rename from pyodk/session.py rename to pyodk/_utils/session.py index 2f57e68..09b36ab 100644 --- a/pyodk/session.py +++ b/pyodk/_utils/session.py @@ -6,7 +6,6 @@ from requests.adapters import HTTPAdapter, Retry from requests.exceptions import HTTPError -from pyodk import __version__ from pyodk.errors import PyODKError @@ -51,6 +50,8 @@ def base_url_validate(base_url: str, api_version: str): def _post_init(self): """Extra steps to customise the Session after core init.""" self.mount("https://", PyODKAdapter(timeout=30)) + from pyodk import __version__ + self.headers.update({"User-Agent": f"pyodk v{__version__}"}) def urljoin(self, url: str) -> str: diff --git a/pyodk/utils.py b/pyodk/_utils/utils.py similarity index 100% rename from pyodk/utils.py rename to pyodk/_utils/utils.py diff --git a/pyodk/validators.py b/pyodk/_utils/validators.py similarity index 96% rename from pyodk/validators.py rename to pyodk/_utils/validators.py index e5df44c..d1e3fc1 100644 --- a/pyodk/validators.py +++ b/pyodk/_utils/validators.py @@ -3,8 +3,8 @@ from pydantic import validators as v from pydantic.errors import StrError +from pyodk._utils.utils import coalesce from pyodk.errors import PyODKError -from pyodk.utils import coalesce def wrap_error(validator: Callable, key: str, value: Any) -> Any: diff --git a/pyodk/client.py b/pyodk/client.py index 03a4439..0e3aa62 100644 --- a/pyodk/client.py +++ b/pyodk/client.py @@ -1,12 +1,12 @@ from typing import Callable, Optional -from pyodk import config -from pyodk.endpoints.auth import AuthService -from pyodk.endpoints.comments import CommentService -from pyodk.endpoints.forms import FormService -from pyodk.endpoints.projects import ProjectService -from pyodk.endpoints.submissions import SubmissionService -from pyodk.session import Session +from pyodk._endpoints.auth import AuthService +from pyodk._endpoints.comments import CommentService +from pyodk._endpoints.forms import FormService +from pyodk._endpoints.projects import ProjectService +from pyodk._endpoints.submissions import SubmissionService +from pyodk._utils import config +from pyodk._utils.session import Session class Client: @@ -57,7 +57,7 @@ def __init__( self.submissions: SubmissionService = SubmissionService( session=self.session, default_project_id=self.project_id ) - self.comments: CommentService = CommentService( + self._comments: CommentService = CommentService( session=self.session, default_project_id=self.project_id ) diff --git a/pyodk/endpoints/__init__.py b/pyodk/endpoints/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/endpoints/test_auth.py b/tests/endpoints/test_auth.py index e36710b..5f8ea48 100644 --- a/tests/endpoints/test_auth.py +++ b/tests/endpoints/test_auth.py @@ -3,15 +3,15 @@ from requests import Session -from pyodk import config +from pyodk._endpoints.auth import AuthService +from pyodk._utils import config from pyodk.client import Client -from pyodk.endpoints.auth import AuthService from pyodk.errors import PyODKError from tests import utils from tests.resources import CONFIG_DATA -@patch("pyodk.config.read_config", MagicMock(return_value=CONFIG_DATA)) +@patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) class TestAuth(TestCase): """Test login.""" diff --git a/tests/endpoints/test_comments.py b/tests/endpoints/test_comments.py index 522ed21..55a2217 100644 --- a/tests/endpoints/test_comments.py +++ b/tests/endpoints/test_comments.py @@ -1,14 +1,14 @@ from unittest import TestCase from unittest.mock import MagicMock, patch +from pyodk._endpoints.comments import Comment +from pyodk._utils.session import Session from pyodk.client import Client -from pyodk.endpoints.comments import Comment -from pyodk.session import Session from tests.resources import CONFIG_DATA, comments_data @patch("pyodk.client.Client._login", MagicMock()) -@patch("pyodk.config.read_config", MagicMock(return_value=CONFIG_DATA)) +@patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) class TestComments(TestCase): def test_list__ok(self): """Should return a list of Comment objects.""" @@ -17,7 +17,7 @@ def test_list__ok(self): mock_session.return_value.status_code = 200 mock_session.return_value.json.return_value = fixture["response_data"] with Client() as client: - observed = client.comments.list( + observed = client._comments.list( form_id=fixture["form_id"], instance_id=fixture["instance_id"], ) @@ -34,7 +34,7 @@ def test_post__ok(self): mock_session.return_value.json.return_value = fixture["response_data"][0] with Client() as client: # Specify project - observed = client.comments.post( + observed = client._comments.post( project_id=fixture["project_id"], form_id=fixture["form_id"], instance_id=fixture["instance_id"], @@ -42,7 +42,7 @@ def test_post__ok(self): ) self.assertIsInstance(observed, Comment) # Use default - observed = client.comments.post( + observed = client._comments.post( form_id=fixture["form_id"], instance_id=fixture["instance_id"], comment="Looks good", diff --git a/tests/endpoints/test_forms.py b/tests/endpoints/test_forms.py index 7f60537..4fbba35 100644 --- a/tests/endpoints/test_forms.py +++ b/tests/endpoints/test_forms.py @@ -1,14 +1,14 @@ from unittest import TestCase from unittest.mock import MagicMock, patch +from pyodk._endpoints.forms import Form +from pyodk._utils.session import Session from pyodk.client import Client -from pyodk.endpoints.forms import Form -from pyodk.session import Session from tests.resources import CONFIG_DATA, forms_data @patch("pyodk.client.Client._login", MagicMock()) -@patch("pyodk.config.read_config", MagicMock(return_value=CONFIG_DATA)) +@patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) class TestForms(TestCase): def test_list__ok(self): """Should return a list of FormType objects.""" diff --git a/tests/endpoints/test_projects.py b/tests/endpoints/test_projects.py index d1790f5..2d13aad 100644 --- a/tests/endpoints/test_projects.py +++ b/tests/endpoints/test_projects.py @@ -3,13 +3,13 @@ from requests import Session +from pyodk._endpoints.projects import Project from pyodk.client import Client -from pyodk.endpoints.projects import Project from tests.resources import CONFIG_DATA, projects_data @patch("pyodk.client.Client._login", MagicMock()) -@patch("pyodk.config.read_config", MagicMock(return_value=CONFIG_DATA)) +@patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) class TestProjects(TestCase): def test_list__ok(self): """Should return a list of ProjectType objects.""" diff --git a/tests/endpoints/test_submissions.py b/tests/endpoints/test_submissions.py index bf61f0a..fcdbbfe 100644 --- a/tests/endpoints/test_submissions.py +++ b/tests/endpoints/test_submissions.py @@ -1,14 +1,14 @@ from unittest import TestCase from unittest.mock import MagicMock, patch +from pyodk._endpoints.submissions import Submission +from pyodk._utils.session import Session from pyodk.client import Client -from pyodk.endpoints.submissions import Submission -from pyodk.session import Session from tests.resources import CONFIG_DATA, submissions_data @patch("pyodk.client.Client._login", MagicMock()) -@patch("pyodk.config.read_config", MagicMock(return_value=CONFIG_DATA)) +@patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) class TestSubmissions(TestCase): def test_list__ok(self): """Should return a list of Submission objects.""" @@ -44,7 +44,7 @@ def test_get__ok(self): ) self.assertIsInstance(observed, Submission) - def test_post__ok(self): + def test_create__ok(self): """Should return a Submission object.""" fixture = submissions_data.test_submissions with patch.object(Session, "request") as mock_session: @@ -52,20 +52,20 @@ def test_post__ok(self): mock_session.return_value.json.return_value = fixture["response_data"][0] with Client() as client: # Specify project - observed = client.submissions.post( + observed = client.submissions.create( project_id=fixture["project_id"], form_id=fixture["form_id"], xml=submissions_data.test_xml, ) self.assertIsInstance(observed, Submission) # Use default - observed = client.submissions.post( + observed = client.submissions.create( form_id=fixture["form_id"], xml=submissions_data.test_xml, ) self.assertIsInstance(observed, Submission) - def test_put__ok(self): + def test__put__ok(self): """Should return a Submission object.""" fixture = submissions_data.test_submissions with patch.object(Session, "request") as mock_session: @@ -73,7 +73,7 @@ def test_put__ok(self): mock_session.return_value.json.return_value = fixture["response_data"][0] with Client() as client: # Specify project - observed = client.submissions.put( + observed = client.submissions._put( project_id=fixture["project_id"], form_id=fixture["form_id"], instance_id=fixture["response_data"][0]["instanceId"], @@ -81,14 +81,14 @@ def test_put__ok(self): ) self.assertIsInstance(observed, Submission) # Use default - observed = client.submissions.put( + observed = client.submissions._put( form_id=fixture["form_id"], instance_id=fixture["response_data"][0]["instanceId"], xml=submissions_data.test_xml, ) self.assertIsInstance(observed, Submission) - def test_patch__ok(self): + def test_review__ok(self): """Should return a Submission object.""" fixture = submissions_data.test_submissions with patch.object(Session, "request") as mock_session: @@ -96,7 +96,7 @@ def test_patch__ok(self): mock_session.return_value.json.return_value = fixture["response_data"][0] with Client() as client: # Specify project - observed = client.submissions.patch( + observed = client.submissions._patch( project_id=fixture["project_id"], form_id=fixture["form_id"], instance_id=fixture["response_data"][0]["instanceId"], @@ -104,7 +104,7 @@ def test_patch__ok(self): ) self.assertIsInstance(observed, Submission) # Use default - observed = client.submissions.patch( + observed = client.submissions._patch( form_id=fixture["form_id"], instance_id=fixture["response_data"][0]["instanceId"], review_state="edited", diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py index a9b14c0..ae06ddb 100644 --- a/tests/resources/__init__.py +++ b/tests/resources/__init__.py @@ -1,6 +1,6 @@ from pathlib import Path -from pyodk import config +from pyodk._utils import config from tests.resources import forms_data # noqa: F401 from tests.resources import projects_data # noqa: F401 diff --git a/tests/test_client.py b/tests/test_client.py index ba4acfc..28cd7f3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,7 +18,7 @@ def test_direct(self): table_name="Submissions", count=True, ) - comments = client.comments.list( + comments = client.submissions.list_comments( form_id="range", instance_id="uuid:2c296eae-2708-4a89-bfe7-0f2d440b7fe8", ) diff --git a/tests/test_config.py b/tests/test_config.py index 3ecc749..4ece515 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,7 @@ from unittest import TestCase from unittest.mock import patch -from pyodk import config +from pyodk._utils import config from pyodk.errors import PyODKError from tests import resources, utils diff --git a/tests/test_session.py b/tests/test_session.py index 4189837..ff56555 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,6 +1,6 @@ from unittest import TestCase -from pyodk.session import Session +from pyodk._utils.session import Session class TestSession(TestCase): From bd6efc24a017a4cef1ce08650163cc294d0ca2fe Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 17 Jan 2023 05:08:57 +1100 Subject: [PATCH 5/6] fix: import from setup.py causing `pip install -e .` to fail - move __version__ to a separate file and read/exec the file at install - also fixes import chain requiring inline import in session.py - problem was ultimately the __init__ import of Client > session > toml breaks install because when executing setup.py, toml potentially isn't installed yet. --- pyodk/__init__.py | 2 -- pyodk/__version__.py | 1 + pyodk/_utils/session.py | 3 +-- setup.py | 8 ++++++-- 4 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 pyodk/__version__.py diff --git a/pyodk/__init__.py b/pyodk/__init__.py index 0f24ce2..6bd19a8 100644 --- a/pyodk/__init__.py +++ b/pyodk/__init__.py @@ -8,7 +8,5 @@ "errors", ) -__version__ = "0.1.0" - logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/pyodk/__version__.py b/pyodk/__version__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/pyodk/__version__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/pyodk/_utils/session.py b/pyodk/_utils/session.py index 09b36ab..9464d9f 100644 --- a/pyodk/_utils/session.py +++ b/pyodk/_utils/session.py @@ -6,6 +6,7 @@ from requests.adapters import HTTPAdapter, Retry from requests.exceptions import HTTPError +from pyodk.__version__ import __version__ from pyodk.errors import PyODKError @@ -50,8 +51,6 @@ def base_url_validate(base_url: str, api_version: str): def _post_init(self): """Extra steps to customise the Session after core init.""" self.mount("https://", PyODKAdapter(timeout=30)) - from pyodk import __version__ - self.headers.update({"User-Agent": f"pyodk v{__version__}"}) def urljoin(self, url: str) -> str: diff --git a/setup.py b/setup.py index 5659efe..e5eedd3 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,14 @@ from setuptools import find_packages, setup +from pathlib import Path + + +about = {} +exec((Path(__file__).parent / "pyodk" / "__version__.py").read_text(), about) -from pyodk import __version__ setup( name="pyodk", - version=__version__, + version=about["__version__"], author="github.com/getodk", author_email="support@getodk.org", packages=find_packages(exclude=["tests", "tests.*"]), From c85d0f15869cdfa90393d3b538355a6984237ca8 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Fri, 20 Jan 2023 04:11:19 +1100 Subject: [PATCH 6/6] chg: validate device_id, simplify urls and kwarg handling - submissions.py: - put submission.create's device_id kwarg through string validator - use prefix variable to simplify URLs list - use _default_kw() and fp_ids dicts to simplify handling form_id/project_id - validators.py: - in wrapper func, raise for any type error (e.g. int, bool) --- pyodk/_endpoints/submissions.py | 73 ++++++++++++--------------------- pyodk/_utils/validators.py | 12 +++++- 2 files changed, 37 insertions(+), 48 deletions(-) diff --git a/pyodk/_endpoints/submissions.py b/pyodk/_endpoints/submissions.py index a28a3f2..d1c760a 100644 --- a/pyodk/_endpoints/submissions.py +++ b/pyodk/_endpoints/submissions.py @@ -1,6 +1,6 @@ import logging from datetime import datetime -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from pyodk._endpoints import bases from pyodk._endpoints.comments import Comment, CommentService @@ -27,12 +27,13 @@ class URLs(bases.Model): class Config: frozen = True - list: str = "projects/{project_id}/forms/{form_id}/submissions" - get: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}" - get_table: str = "projects/{project_id}/forms/{form_id}.svc/{table_name}" - post: str = "projects/{project_id}/forms/{form_id}/submissions" - patch: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}" - put: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}" + _form: str = "projects/{project_id}/forms/{form_id}" + list: str = f"{_form}/submissions" + get: str = f"{_form}/submissions/{{instance_id}}" + get_table: str = f"{_form}.svc/{{table_name}}" + post: str = f"{_form}/submissions" + patch: str = f"{_form}/submissions/{{instance_id}}" + put: str = f"{_form}/submissions/{{instance_id}}" class SubmissionService(bases.Service): @@ -50,6 +51,12 @@ def __init__( self.default_project_id: Optional[int] = default_project_id self.default_form_id: Optional[str] = default_form_id + def _default_kw(self) -> Dict[str, Any]: + return { + "default_project_id": self.default_project_id, + "default_form_id": self.default_form_id, + } + def list( self, form_id: Optional[str] = None, project_id: Optional[int] = None ) -> List[Submission]: @@ -192,7 +199,7 @@ def create( fid = pv.validate_form_id(form_id, self.default_form_id) params = {} if device_id is not None: - params["deviceID"] = device_id + params["deviceID"] = pv.validate_str(device_id, key="device_id") except PyODKError as err: log.error(err, exc_info=True) raise err @@ -304,16 +311,10 @@ def edit( :param project_id: The id of the project this form belongs to. :param comment: The text of the comment. """ - self._put( - instance_id=instance_id, xml=xml, form_id=form_id, project_id=project_id - ) + fp_ids = {"form_id": form_id, "project_id": project_id} + self._put(instance_id=instance_id, xml=xml, **fp_ids) if comment is not None: - self.add_comment( - instance_id=instance_id, - comment=comment, - project_id=project_id, - form_id=form_id, - ) + self.add_comment(instance_id=instance_id, comment=comment, **fp_ids) def review( self, @@ -332,19 +333,10 @@ def review( :param project_id: The id of the project this form belongs to. :param comment: The text of the comment. """ - self._patch( - instance_id=instance_id, - review_state=review_state, - form_id=form_id, - project_id=project_id, - ) + fp_ids = {"form_id": form_id, "project_id": project_id} + self._patch(instance_id=instance_id, review_state=review_state, **fp_ids) if comment is not None: - self.add_comment( - instance_id=instance_id, - comment=comment, - project_id=project_id, - form_id=form_id, - ) + self.add_comment(instance_id=instance_id, comment=comment, **fp_ids) def list_comments( self, @@ -359,14 +351,9 @@ def list_comments( :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project the Submissions belong to. """ - comment_svc = CommentService( - session=self.session, default_project_id=self.default_project_id - ) - return comment_svc.list( - project_id=project_id, - form_id=form_id, - instance_id=instance_id, - ) + fp_ids = {"form_id": form_id, "project_id": project_id} + comment_svc = CommentService(session=self.session, **self._default_kw()) + return comment_svc.list(instance_id=instance_id, **fp_ids) def add_comment( self, @@ -383,12 +370,6 @@ def add_comment( :param project_id: The id of the project this form belongs to. :param form_id: The xmlFormId of the Form being referenced. """ - comment_svc = CommentService( - session=self.session, default_project_id=self.default_project_id - ) - return comment_svc.post( - comment=comment, - project_id=project_id, - form_id=form_id, - instance_id=instance_id, - ) + fp_ids = {"form_id": form_id, "project_id": project_id} + comment_svc = CommentService(session=self.session, **self._default_kw()) + return comment_svc.post(comment=comment, instance_id=instance_id, **fp_ids) diff --git a/pyodk/_utils/validators.py b/pyodk/_utils/validators.py index d1e3fc1..946730a 100644 --- a/pyodk/_utils/validators.py +++ b/pyodk/_utils/validators.py @@ -1,7 +1,7 @@ from typing import Any, Callable from pydantic import validators as v -from pydantic.errors import StrError +from pydantic.errors import PydanticTypeError from pyodk._utils.utils import coalesce from pyodk.errors import PyODKError @@ -18,7 +18,7 @@ def wrap_error(validator: Callable, key: str, value: Any) -> Any: """ try: return validator(value) - except StrError as err: + except PydanticTypeError as err: msg = f"{key}: {str(err)}" raise PyODKError(msg) from err @@ -53,3 +53,11 @@ def validate_instance_id(*args: str) -> str: key="instance_id", value=coalesce(*args), ) + + +def validate_str(*args: str, key: str) -> str: + return wrap_error( + validator=v.str_validator, + key=key, + value=coalesce(*args), + )