-
Notifications
You must be signed in to change notification settings - Fork 20
New issue
Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? # to your account
Allow uploading attachment files alongside a submission #105
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,13 @@ | ||
import builtins | ||
import logging | ||
from collections.abc import Iterable | ||
from datetime import datetime | ||
from pathlib import Path | ||
from typing import Any | ||
|
||
from pyodk._endpoints import bases | ||
from pyodk._endpoints.comments import Comment, CommentService | ||
from pyodk._endpoints.submission_attachments import SubmissionAttachmentService | ||
from pyodk._utils import validators as pv | ||
from pyodk._utils.session import Session | ||
from pyodk.errors import PyODKError | ||
|
@@ -24,10 +27,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}}" | ||
|
@@ -201,6 +201,8 @@ def create( | |
project_id: int | None = None, | ||
device_id: str | None = None, | ||
encoding: str = "utf-8", | ||
# Here we must use imported typing.List to avoid conflict with .list method | ||
attachments: builtins.list[str] | None = None, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you using an old python version? I think the |
||
) -> Submission: | ||
""" | ||
Create a Submission. | ||
|
@@ -222,6 +224,7 @@ def create( | |
:param project_id: The id of the project this form belongs to. | ||
:param device_id: An optional deviceID associated with the submission. | ||
:param encoding: The encoding of the submission XML, default "utf-8". | ||
:param attachments: A list of file paths to upload as attachments. | ||
""" | ||
try: | ||
pid = pv.validate_project_id(project_id, self.default_project_id) | ||
|
@@ -242,7 +245,26 @@ def create( | |
data=xml.encode(encoding=encoding), | ||
) | ||
data = response.json() | ||
return Submission(**data) | ||
submission = Submission(**data) | ||
instance_id = submission.instanceId | ||
|
||
# If there are attachments, upload each one | ||
if attachments: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please follow the pattern in |
||
attachment_svc = SubmissionAttachmentService(session=self.session) | ||
for attachment in attachments: | ||
attachment_path = Path(attachment) | ||
file_name = attachment_path.name | ||
upload_success = attachment_svc.upload( | ||
file_path_or_bytes=attachment, | ||
instance_id=instance_id, | ||
file_name=file_name, | ||
form_id=fid, | ||
project_id=pid, | ||
) | ||
if not upload_success: | ||
log.error(f"Failed to upload attachment: {attachment}") | ||
|
||
return submission | ||
|
||
def _put( | ||
self, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please follow the
forms.create
process where it's assumed file paths are passed in. If using bytes is needed please open a ticket for that so we can think about doing that consistently across endpoints that currently accept file paths.