Skip to content

Commit

Permalink
Merge pull request #65 from lindsay-stevens/pyodk-62
Browse files Browse the repository at this point in the history
62: add support for entities and entity_lists
  • Loading branch information
lindsay-stevens authored Apr 18, 2024
2 parents fab3727 + dc36fbb commit 7040a26
Show file tree
Hide file tree
Showing 15 changed files with 517 additions and 3 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ For interactive testing, debugging, or sanity checking workflows, end-to-end tes
1. Create a test project in Central.
2. Create a test user in Central. It can be a site-wide Administrator. If it is not an Administrator, assign the user to the project with "Project Manager" privileges, so that forms and submissions in the test project can be uploaded and modified.
3. Save the user's credentials and the project ID in a `.pyodk_config.toml` (or equivalent) as described in the above section titled "Configure".
4. When the tests in `test_client.py` are run, the test setup method should automatically create a few forms and submissions for testing with. At a minimum these allow the tests to pass, but can also be used to interactively test or debug.
4. When the tests in `test_client.py` are run, the test setup method should automatically create a few fixtures for testing with. At a minimum these allow the tests to pass, but can also be used to interactively test or debug.


## Release
Expand Down
3 changes: 3 additions & 0 deletions docs/entities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Entities

::: pyodk._endpoints.entities.EntityService
3 changes: 3 additions & 0 deletions docs/entity_lists.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Entity Lists

::: pyodk._endpoints.entity_lists.EntityListService
4 changes: 3 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ watch:
nav:
- Overview: index.md
- Client: client.md
- .entities: entities.md
- .entity_lists: entity_lists.md
- .forms: forms.md
- .submissions: submissions.md
- .projects: projects.md
- .submissions: submissions.md
- HTTP methods: http-methods.md
- Examples: examples/README.md

Expand Down
196 changes: 196 additions & 0 deletions pyodk/_endpoints/entities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import logging
from datetime import datetime
from uuid import uuid4

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 CurrentVersion(bases.Model):
label: str
current: bool
creatorId: int
userAgent: str
version: int
baseVersion: int | None = None
conflictingProperties: list[str] | None = None


class Entity(bases.Model):
uuid: str
creatorId: int
createdAt: datetime
currentVersion: CurrentVersion
updatedAt: datetime | None = None
deletedAt: datetime | None = None


class URLs(bases.Model):
class Config:
frozen = True

_entity_name: str = "projects/{project_id}/datasets/{el_name}"
list: str = f"{_entity_name}/entities"
post: str = f"{_entity_name}/entities"
get_table: str = f"{_entity_name}.svc/Entities"


class EntityService(bases.Service):
"""
Entity-related functionality is accessed through `client.entities`. For example:
```python
from pyodk.client import Client
client = Client()
data = client.entities.list()
```
Conceptually, an Entity's parent object is an EntityList. Each EntityList may
have multiple Entities. In Python parlance, EntityLists are like classes, while
Entities are like instances.
"""

__slots__ = ("urls", "session", "default_project_id", "default_entity_list_name")

def __init__(
self,
session: Session,
default_project_id: int | None = None,
default_entity_list_name: str | None = None,
urls: URLs = None,
):
self.urls: URLs = urls if urls is not None else URLs()
self.session: Session = session
self.default_project_id: int | None = default_project_id
self.default_entity_list_name: str | None = default_entity_list_name

def list(
self, entity_list_name: str | None = None, project_id: int | None = None
) -> list[Entity]:
"""
Read all Entity metadata.
:param entity_list_name: The name of the Entity List (Dataset) being referenced.
:param project_id: The id of the project the Entity belongs to.
:return: A list of the object representation of all Entity metadata.
"""
try:
pid = pv.validate_project_id(project_id, self.default_project_id)
eln = pv.validate_entity_list_name(
entity_list_name, self.default_entity_list_name
)
except PyODKError as err:
log.error(err, exc_info=True)
raise

response = self.session.response_or_error(
method="GET",
url=self.session.urlformat(self.urls.list, project_id=pid, el_name=eln),
logger=log,
)
data = response.json()
return [Entity(**r) for r in data]

def create(
self,
label: str,
data: dict,
entity_list_name: str | None = None,
project_id: int | None = None,
uuid: str | None = None,
) -> Entity:
"""
Create an Entity.
:param label: Label of the Entity.
:param data: Data to store for the Entity.
:param entity_list_name: The name of the Entity List (Dataset) being referenced.
:param project_id: The id of the project this form belongs to.
:param uuid: An optional unique identifier for the Entity. If not provided then
a uuid will be generated and sent by the client.
"""
try:
pid = pv.validate_project_id(project_id, self.default_project_id)
eln = pv.validate_entity_list_name(
entity_list_name, self.default_entity_list_name
)
req_data = {
"uuid": pv.validate_str(uuid, str(uuid4()), key="uuid"),
"label": pv.validate_str(label, key="label"),
"data": pv.validate_dict(data, key="data"),
}
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, el_name=eln),
logger=log,
json=req_data,
)
data = response.json()
return Entity(**data)

def get_table(
self,
entity_list_name: str | None = None,
project_id: int | None = None,
skip: int | None = None,
top: int | None = None,
count: bool | None = None,
filter: str | None = None,
select: str | None = None,
) -> dict:
"""
Read Entity List data.
:param entity_list_name: The name of the Entity List (Dataset) being referenced.
:param project_id: The id of the project this form belongs to.
:param skip: The first n rows will be omitted from the results.
:param top: Only up to n rows will be returned in the results.
:param count: If True, an @odata.count property will be added to the result to
indicate the total number of rows, ignoring the above paging parameters.
:param filter: Filter responses to those matching the query. Only certain fields
are available to reference. The operators lt, le, eq, neq, ge, gt, not, and,
and or are supported, and the built-in functions now, year, month, day, hour,
minute, second.
:param select: If provided, will return only the selected fields.
:return: A dictionary representation of the OData JSON document.
"""
try:
pid = pv.validate_project_id(project_id, self.default_project_id)
eln = pv.validate_entity_list_name(
entity_list_name, self.default_entity_list_name
)
params = {
k: v
for k, v in {
"$skip": skip,
"$top": top,
"$count": count,
"$filter": filter,
"$select": select,
}.items()
if v is not None
}
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_table, project_id=pid, el_name=eln, table_name="Entities"
),
logger=log,
params=params,
)
return response.json()
75 changes: 75 additions & 0 deletions pyodk/_endpoints/entity_lists.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import logging
from datetime import datetime

from pyodk._endpoints import bases
from pyodk._utils import validators as pv
from pyodk._utils.session import Session
from pyodk.errors import PyODKError

log = logging.getLogger(__name__)


class EntityList(bases.Model):
name: str
projectId: int
createdAt: datetime
approvalRequired: bool


class URLs(bases.Model):
class Config:
frozen = True

list: str = "projects/{project_id}/datasets"


class EntityListService(bases.Service):
"""
Entity List-related functionality is accessed through `client.entity_lists`.
For example:
```python
from pyodk.client import Client
client = Client()
data = client.entity_lists.list()
```
Conceptually, an EntityList's parent object is a Project. Each Project may have
multiple EntityLists.
"""

__slots__ = ("urls", "session", "default_project_id")

def __init__(
self,
session: Session,
default_project_id: int | 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

def list(self, project_id: int | None = None) -> list[EntityList]:
"""
Read Entity List details.
:param project_id: The id of the project the Entity List belongs to.
:return: A list of the object representation of all Entity Lists' details.
"""
try:
pid = pv.validate_project_id(project_id, self.default_project_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),
logger=log,
)
data = response.json()
return [EntityList(**r) for r in data]
2 changes: 1 addition & 1 deletion pyodk/_endpoints/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def create_app_users(
# The "App User" role_id should always be "2", so no need to look it up by name.
# Ref: "https://github.com/getodk/central-backend/blob/9db0d792cf4640ec7329722984
# cebdee3687e479/lib/model/migrations/20181212-01-add-roles.js"
# See also roles data in `tests/resorces/projects_data.py`.
# See also roles data in `tests/resources/projects_data.py`.
if forms is not None:
for user in users:
for form_id in forms:
Expand Down
16 changes: 16 additions & 0 deletions pyodk/_utils/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ def validate_instance_id(*args: str) -> str:
)


def validate_entity_list_name(*args: str) -> str:
return wrap_error(
validator=v.str_validator,
key="entity_list_name",
value=coalesce(*args),
)


def validate_str(*args: str, key: str) -> str:
return wrap_error(
validator=v.str_validator,
Expand All @@ -81,6 +89,14 @@ def validate_int(*args: int, key: str) -> int:
)


def validate_dict(*args: dict, key: str) -> int:
return wrap_error(
validator=v.dict_validator,
key=key,
value=coalesce(*args),
)


def validate_file_path(*args: str) -> Path:
def validate_fp(f):
p = v.path_validator(f)
Expand Down
8 changes: 8 additions & 0 deletions pyodk/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from collections.abc import Callable

from pyodk._endpoints.comments import CommentService
from pyodk._endpoints.entities import EntityService
from pyodk._endpoints.entity_lists import EntityListService
from pyodk._endpoints.forms import FormService
from pyodk._endpoints.projects import ProjectService
from pyodk._endpoints.submissions import SubmissionService
Expand Down Expand Up @@ -67,6 +69,12 @@ def __init__(
self._comments: CommentService = CommentService(
session=self.session, default_project_id=self.project_id
)
self.entities: EntityService = EntityService(
session=self.session, default_project_id=self.project_id
)
self.entity_lists: EntityListService = EntityListService(
session=self.session, default_project_id=self.project_id
)

@property
def project_id(self) -> int | None:
Expand Down
Loading

0 comments on commit 7040a26

Please sign in to comment.