Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow uploading attachment files alongside a submission #105

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
15 changes: 11 additions & 4 deletions pyodk/_endpoints/bases.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict

from pyodk._utils.session import Session


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:
Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/entity_list_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/entity_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/form_assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"

Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/form_draft_attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"

Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/form_drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"

Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/project_app_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
5 changes: 1 addition & 4 deletions pyodk/_endpoints/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
161 changes: 161 additions & 0 deletions pyodk/_endpoints/submission_attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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_submission_id",
)

def __init__(
self,
session: Session,
default_project_id: int | None = None,
default_form_id: str | None = None,
default_submission_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_submission_id: str | None = default_submission_id

def list(
self, form_id: str | None = None, project_id: int | None = None
) -> list[SubmissionAttachment]:
"""
Show all required submission attachments and their upload status.

: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)
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),
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)
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)
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"]
32 changes: 27 additions & 5 deletions pyodk/_endpoints/submissions.py
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
Expand All @@ -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}}"
Expand Down Expand Up @@ -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,
) -> Submission:
"""
Create a Submission.
Expand All @@ -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)
Expand All @@ -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:
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,
Expand Down
2 changes: 1 addition & 1 deletion pyodk/_utils/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading