From 49ad17ca28b23946a4c34560f8325e55c0ee0ab8 Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Tue, 11 Feb 2025 19:49:37 +0000 Subject: [PATCH 1/2] fix: update deprecated pydantic model Config class to model_config attribute --- pyodk/_endpoints/bases.py | 15 +++++++++++---- pyodk/_endpoints/comments.py | 5 +---- pyodk/_endpoints/entities.py | 5 +---- pyodk/_endpoints/entity_list_properties.py | 5 +---- pyodk/_endpoints/entity_lists.py | 5 +---- pyodk/_endpoints/form_assignments.py | 5 +---- pyodk/_endpoints/form_draft_attachments.py | 5 +---- pyodk/_endpoints/form_drafts.py | 5 +---- pyodk/_endpoints/forms.py | 5 +---- pyodk/_endpoints/project_app_users.py | 5 +---- pyodk/_endpoints/projects.py | 5 +---- pyodk/_endpoints/submissions.py | 5 +---- 12 files changed, 22 insertions(+), 48 deletions(-) diff --git a/pyodk/_endpoints/bases.py b/pyodk/_endpoints/bases.py index 126debc..cbefc84 100644 --- a/pyodk/_endpoints/bases.py +++ b/pyodk/_endpoints/bases.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from pyodk._utils.session import Session @@ -6,9 +6,16 @@ class Model(BaseModel): """Base configuration for data model classes.""" - class Config: - arbitrary_types_allowed = True - validate_assignment = True + model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) + + +class FrozenModel(Model): + """Make the base configuration model faux-immutable. + + NOTE in pydantic v2 inherited model_config are *merged*. + """ + + model_config = ConfigDict(frozen=True) class Manager: diff --git a/pyodk/_endpoints/comments.py b/pyodk/_endpoints/comments.py index adada4a..4224a57 100644 --- a/pyodk/_endpoints/comments.py +++ b/pyodk/_endpoints/comments.py @@ -15,10 +15,7 @@ class Comment(bases.Model): createdAt: datetime -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): list: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}/comments" post: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}/comments" diff --git a/pyodk/_endpoints/entities.py b/pyodk/_endpoints/entities.py index 35b7640..cbe67cf 100644 --- a/pyodk/_endpoints/entities.py +++ b/pyodk/_endpoints/entities.py @@ -65,10 +65,7 @@ class Entity(bases.Model): deletedAt: datetime | None = None -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): _entity_name: str = "projects/{project_id}/datasets/{el_name}" _entities: str = f"{_entity_name}/entities" list: str = _entities diff --git a/pyodk/_endpoints/entity_list_properties.py b/pyodk/_endpoints/entity_list_properties.py index 09032fc..bfe65aa 100644 --- a/pyodk/_endpoints/entity_list_properties.py +++ b/pyodk/_endpoints/entity_list_properties.py @@ -16,10 +16,7 @@ class EntityListProperty(bases.Model): forms: list[str] -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): post: str = "projects/{project_id}/datasets/{entity_list_name}/properties" diff --git a/pyodk/_endpoints/entity_lists.py b/pyodk/_endpoints/entity_lists.py index ee478c9..9c58f7d 100644 --- a/pyodk/_endpoints/entity_lists.py +++ b/pyodk/_endpoints/entity_lists.py @@ -22,10 +22,7 @@ class EntityList(bases.Model): properties: list[EntityListProperty] | None = None -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): _entity_list = "projects/{project_id}/datasets" list: str = _entity_list post: str = _entity_list diff --git a/pyodk/_endpoints/form_assignments.py b/pyodk/_endpoints/form_assignments.py index 7bb802f..0ef0c59 100644 --- a/pyodk/_endpoints/form_assignments.py +++ b/pyodk/_endpoints/form_assignments.py @@ -8,10 +8,7 @@ log = logging.getLogger(__name__) -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): _form: str = "projects/{project_id}/forms/{form_id}" post: str = f"{_form}/assignments/{{role_id}}/{{user_id}}" diff --git a/pyodk/_endpoints/form_draft_attachments.py b/pyodk/_endpoints/form_draft_attachments.py index cc6ae9a..f6e650d 100644 --- a/pyodk/_endpoints/form_draft_attachments.py +++ b/pyodk/_endpoints/form_draft_attachments.py @@ -9,10 +9,7 @@ log = logging.getLogger(__name__) -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): _form: str = "projects/{project_id}/forms/{form_id}" post: str = f"{_form}/draft/attachments/{{fname}}" diff --git a/pyodk/_endpoints/form_drafts.py b/pyodk/_endpoints/form_drafts.py index 2d918dd..b1ddf46 100644 --- a/pyodk/_endpoints/form_drafts.py +++ b/pyodk/_endpoints/form_drafts.py @@ -81,10 +81,7 @@ def get_definition_data( return definition_data, content_type, file_path_stem -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): _form: str = "projects/{project_id}/forms/{form_id}" post: str = f"{_form}/draft" post_publish: str = f"{_form}/draft/publish" diff --git a/pyodk/_endpoints/forms.py b/pyodk/_endpoints/forms.py index 98eedb0..e351725 100644 --- a/pyodk/_endpoints/forms.py +++ b/pyodk/_endpoints/forms.py @@ -31,10 +31,7 @@ class Form(bases.Model): publishedAt: datetime | None -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): forms: str = "projects/{project_id}/forms" get: str = f"{forms}/{{form_id}}" diff --git a/pyodk/_endpoints/project_app_users.py b/pyodk/_endpoints/project_app_users.py index 025bbed..1e20ae4 100644 --- a/pyodk/_endpoints/project_app_users.py +++ b/pyodk/_endpoints/project_app_users.py @@ -20,10 +20,7 @@ class ProjectAppUser(bases.Model): deletedAt: datetime | None -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): list: str = "projects/{project_id}/app-users" post: str = "projects/{project_id}/app-users" diff --git a/pyodk/_endpoints/projects.py b/pyodk/_endpoints/projects.py index f390d2d..50f3a74 100644 --- a/pyodk/_endpoints/projects.py +++ b/pyodk/_endpoints/projects.py @@ -27,10 +27,7 @@ class Project(bases.Model): deletedAt: datetime | None = None -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): list: str = "projects" get: str = "projects/{project_id}" get_data: str = "projects/{project_id}/forms/{form_id}.svc/{table_name}" diff --git a/pyodk/_endpoints/submissions.py b/pyodk/_endpoints/submissions.py index c59e74b..3ec4609 100644 --- a/pyodk/_endpoints/submissions.py +++ b/pyodk/_endpoints/submissions.py @@ -24,10 +24,7 @@ class Submission(bases.Model): updatedAt: datetime | None = None -class URLs(bases.Model): - class Config: - frozen = True - +class URLs(bases.FrozenModel): _form: str = "projects/{project_id}/forms/{form_id}" list: str = f"{_form}/submissions" get: str = f"{_form}/submissions/{{instance_id}}" From 755c32e1a8eaec8db80da2c8d21a9b4bf3558d3b Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Tue, 11 Feb 2025 20:13:46 +0000 Subject: [PATCH 2/2] feat: add submission attachment api --- pyodk/_endpoints/submission_attachments.py | 168 ++++++++++++++++++ pyodk/_utils/validators.py | 2 +- pyodk/client.py | 6 + tests/endpoints/test_submissions.py | 120 ++++++++++++- .../attachments/submission_image.png | Bin 0 -> 375 bytes .../resources/submission_attachments_data.py | 30 ++++ 6 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 pyodk/_endpoints/submission_attachments.py create mode 100644 tests/resources/attachments/submission_image.png create mode 100644 tests/resources/submission_attachments_data.py diff --git a/pyodk/_endpoints/submission_attachments.py b/pyodk/_endpoints/submission_attachments.py new file mode 100644 index 0000000..fe56679 --- /dev/null +++ b/pyodk/_endpoints/submission_attachments.py @@ -0,0 +1,168 @@ +import logging +from os import PathLike + +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 SubmissionAttachment(bases.Model): + name: str + exists: bool + + +class URLs(bases.FrozenModel): + _submission: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}" + list: str = f"{_submission}/attachments" + get: str = f"{_submission}/attachments/{{fname}}" + post: str = f"{_submission}/attachments/{{fname}}" + delete: str = f"{_submission}/attachments/{{fname}}" + + +class SubmissionAttachmentService(bases.Service): + __slots__ = ( + "urls", + "session", + "default_project_id", + "default_form_id", + "default_instance_id", + ) + + def __init__( + self, + session: Session, + default_project_id: int | None = None, + default_form_id: str | None = None, + default_instance_id: 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_form_id: str | None = default_form_id + self.default_instance_id: str | None = default_instance_id + + def list( + self, + instance_id: str | None = None, + form_id: str | None = None, + project_id: int | None = None, + ) -> list[SubmissionAttachment]: + """ + Show all required submission attachments and their upload status. + + :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. + + :return: A list of the object representation of all Submission + attachment metadata. + """ + 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_form_id(instance_id, self.default_instance_id) + 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.list, project_id=pid, form_id=fid, instance_id=iid + ), + logger=log, + ) + data = response.json() + return [SubmissionAttachment(**r) for r in data] + + def get( + self, + file_name: str, + instance_id: str, + form_id: str | None = None, + project_id: int | None = None, + ) -> bytes: + """ + Read Submission metadata. + + :param file_name: The file name of the Submission attachment being referenced. + :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. + + :return: The attachment bytes for download. + """ + 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 + + response = self.session.response_or_error( + method="GET", + url=self.session.urlformat( + self.urls.get, + project_id=pid, + form_id=fid, + instance_id=iid, + fname=file_name, + ), + logger=log, + ) + return response.content + + def upload( + self, + file_path_or_bytes: PathLike | str | bytes, + instance_id: str, + file_name: str | None = None, + form_id: str | None = None, + project_id: int | None = None, + ) -> bool: + """ + Upload a Form Draft Attachment. + + :param file_path_or_bytes: The path to the file or file bytes to upload. + :param instance_id: The instanceId of the Submission being referenced. + :param file_name: A name for the file, otherwise the name in file_path is used. + :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, self.default_instance_id) + if isinstance(file_path_or_bytes, bytes): + file_bytes = file_path_or_bytes + # file_name cannot be empty when passing a bytes object + pv.validate_str(file_name, key="file_name") + else: + file_path = pv.validate_file_path(file_path_or_bytes) + with open(file_path_or_bytes, "rb") as fd: + file_bytes = fd.read() + if file_name is None: + file_name = pv.validate_str(file_path.name, key="file_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, + form_id=fid, + instance_id=iid, + fname=file_name, + ), + logger=log, + data=file_bytes, + ) + data = response.json() + return data["success"] diff --git a/pyodk/_utils/validators.py b/pyodk/_utils/validators.py index 666999b..1d64834 100644 --- a/pyodk/_utils/validators.py +++ b/pyodk/_utils/validators.py @@ -66,7 +66,7 @@ def validate_entity_list_name(*args: str) -> str: ) -def validate_str(*args: str, key: str) -> str: +def validate_str(*args: str | None, key: str) -> str: return wrap_error( validator=v.str_validator, key=key, diff --git a/pyodk/client.py b/pyodk/client.py index 06c2104..f4b64bc 100644 --- a/pyodk/client.py +++ b/pyodk/client.py @@ -5,6 +5,7 @@ from pyodk._endpoints.entity_lists import EntityListService from pyodk._endpoints.forms import FormService from pyodk._endpoints.projects import ProjectService +from pyodk._endpoints.submission_attachments import SubmissionAttachmentService from pyodk._endpoints.submissions import SubmissionService from pyodk._utils import config from pyodk._utils.session import Session @@ -66,6 +67,11 @@ def __init__( self.submissions: SubmissionService = SubmissionService( session=self.session, default_project_id=self.project_id ) + self.submission_attachments: SubmissionAttachmentService = ( + SubmissionAttachmentService( + session=self.session, default_project_id=self.project_id + ) + ) self._comments: CommentService = CommentService( session=self.session, default_project_id=self.project_id ) diff --git a/tests/endpoints/test_submissions.py b/tests/endpoints/test_submissions.py index 8f96901..c0543cf 100644 --- a/tests/endpoints/test_submissions.py +++ b/tests/endpoints/test_submissions.py @@ -1,11 +1,18 @@ from unittest import TestCase from unittest.mock import MagicMock, patch +from pyodk._endpoints.submission_attachments import SubmissionAttachment from pyodk._endpoints.submissions import Submission from pyodk._utils.session import Session from pyodk.client import Client +from pyodk.errors import PyODKError -from tests.resources import CONFIG_DATA, submissions_data +from tests.resources import ( + CONFIG_DATA, + RESOURCES, + submission_attachments_data, + submissions_data, +) @patch("pyodk._utils.session.Auth.login", MagicMock()) @@ -111,3 +118,114 @@ def test_review__ok(self): review_state="edited", ) self.assertIsInstance(observed, Submission) + + +@patch("pyodk._utils.session.Auth.login", MagicMock()) +@patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) +class TestSubmissionAttachments(TestCase): + def test_list__ok(self): + """Should return a list of SubmissionAttachment objects.""" + fixture = submission_attachments_data.test_submission_attachments + 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.submission_attachments.list( + instance_id="test_submission", + form_id="test_form", + project_id=1, + ) + self.assertEqual(len(fixture), len(observed)) + for i, o in enumerate(observed): + with self.subTest(i): + self.assertIsInstance(o, SubmissionAttachment) + self.assertEqual(fixture[i]["name"], o.name) + self.assertEqual(fixture[i]["exists"], o.exists) + + def test_get__ok(self): + """Should return the binary content of a submission attachment.""" + fixture = submission_attachments_data.test_submission_attachment_get + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.content = fixture["content"] + with Client() as client: + observed = client.submission_attachments.get( + file_name=fixture["file_name"], + instance_id=fixture["instance_id"], + form_id=fixture["form_id"], + project_id=fixture["project_id"], + ) + self.assertEqual(fixture["content"], observed) + self.assertIsInstance(observed, bytes) + + def test_upload_bytes__ok(self): + """Should return True when the bytes attachment is successfully uploaded.""" + fixture = submission_attachments_data.test_submission_attachment_upload + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.json.return_value = {"success": True} + with Client() as client: + observed = client.submission_attachments.upload( + file_path_or_bytes=fixture["file_path_or_bytes"], + instance_id=fixture["instance_id"], + file_name=fixture["file_name"], + form_id=fixture["form_id"], + project_id=fixture["project_id"], + ) + self.assertTrue(observed) + + def test_upload_bytes__no_filename(self): + """Should return False when no filename is passed uploading bytes.""" + fixture = submission_attachments_data.test_submission_attachment_upload + with self.assertRaises(PyODKError) as context: + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.json.return_value = {"success": False} + with Client() as client: + client.submission_attachments.upload( + file_path_or_bytes=fixture["file_path_or_bytes"], + instance_id=fixture["instance_id"], + file_name=None, + form_id=fixture["form_id"], + project_id=fixture["project_id"], + ) + self.assertIn("file_name: str type expected", str(context.exception)) + + def test_upload_file__ok(self): + """Should return True when the file attachment is successfully uploaded.""" + fixture = submission_attachments_data.test_submission_attachment_upload + submission_file_path = ( + RESOURCES / "attachments" / "submission_image.png" + ).as_posix() + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.json.return_value = {"success": True} + with Client() as client: + # Perform the upload action, passing the file path directly + observed = client.submission_attachments.upload( + file_path_or_bytes=submission_file_path, + instance_id=fixture["instance_id"], + file_name=fixture["file_name"], + form_id=fixture["form_id"], + project_id=fixture["project_id"], + ) + self.assertTrue(observed) + + def test_upload_file__not_exist(self): + """Should return False when attempting upload of non-existent file.""" + fixture = submission_attachments_data.test_submission_attachment_upload + with self.assertRaises(PyODKError) as context: + with patch.object(Session, "request") as mock_session: + mock_session.return_value.status_code = 200 + mock_session.return_value.json.return_value = {"success": False} + with Client() as client: + client.submission_attachments.upload( + file_path_or_bytes="/file/path/does/not/exist.jpg", + instance_id=fixture["instance_id"], + file_name=None, + form_id=fixture["form_id"], + project_id=fixture["project_id"], + ) + self.assertIn("file_path: file or directory at path", str(context.exception)) + # NOTE we avoid checking the path in the exception, due to differences on Windows/Linux + self.assertIn("does not exist", str(context.exception)) diff --git a/tests/resources/attachments/submission_image.png b/tests/resources/attachments/submission_image.png new file mode 100644 index 0000000000000000000000000000000000000000..f991ec87e958fbe621722e7eae3165fb46b3054c GIT binary patch literal 375 zcmV--0f_#IP)Px#Gf+%aMMrQ<2?hqDr>cRZ$Z(^1ZD(w$hizqJW|EkZ zMv+vPr(R5;6>)7uin5DW%jQpBlv|EJvuZOyKo*{q8S^O^bUpwxd&TdUW8U(GS( z3^b#CN-$P=WBjP#sxEt+=RwM*S{+!LvZfTnMGUiAENkm(tu5h$vD*bt+ckDf+UtQS zOzHNHG3>#g$hb$rjuG}2Mn~ASu4Bg>Aw*PfR?ni5YksY_}BFV0(?DFrPmHPUuWDoX-_5%I* V6=F2LSM2}*002ovPDHLkV1ghbr$qn& literal 0 HcmV?d00001 diff --git a/tests/resources/submission_attachments_data.py b/tests/resources/submission_attachments_data.py new file mode 100644 index 0000000..28b2a90 --- /dev/null +++ b/tests/resources/submission_attachments_data.py @@ -0,0 +1,30 @@ +test_submission_attachments = [ + { + "name": "file1.jpg", + "exists": True, + }, + { + "name": "file2.jpg", + "exists": False, + }, + { + "name": "file3.jpg", + "exists": True, + }, +] + +test_submission_attachment_get = { + "content": b"Mock binary data for attachment download", + "file_name": "file1.jpg", + "instance_id": "uuid:96f2a014-eaa1-466a-abe2-3ccacc756d5a", + "form_id": "sub_attachments", + "project_id": 8, +} + +test_submission_attachment_upload = { + "project_id": 8, + "form_id": "sub_attachments", + "instance_id": "uuid:96f2a014-eaa1-466a-abe2-3ccacc756d5a", + "file_path_or_bytes": b"Mock binary data for attachment download", + "file_name": "file1.jpg", +}