├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ └── verify.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── SECURITY.md ├── docs ├── assets │ ├── extra_css.css │ ├── favicon.ico │ └── logo.svg ├── client.md ├── entities.md ├── entity_lists.md ├── examples │ ├── 2022-10-pyodk-webinar.ipynb │ ├── README.md │ ├── app_user_provisioner │ │ ├── Roboto-Regular.license │ │ ├── Roboto-Regular.ttf │ │ ├── app_user_provisioner.py │ │ └── requirements.txt │ ├── basic-analysis-pandas.ipynb │ ├── beyond-library-methods.ipynb │ ├── create_entities_from_submissions │ │ ├── create_entities_from_submissions.py │ │ └── imported_answers.csv │ ├── create_or_update_form │ │ └── create_or_update_form.py │ ├── docs-form-version-xlsx.png │ ├── fav_color.xlsx │ ├── mail_merge │ │ ├── mail_merge.py │ │ ├── merged │ │ │ ├── Pedro.docx │ │ │ └── Sally.docx │ │ ├── requirements.txt │ │ └── template.docx │ ├── requirements.txt │ ├── simple_repeat.xlsx │ └── working-with-repeats.ipynb ├── forms.md ├── http-methods.md ├── index.md ├── overrides │ └── main.html ├── projects.md └── submissions.md ├── mkdocs.yml ├── pyodk ├── __init__.py ├── __version__.py ├── _endpoints │ ├── __init__.py │ ├── auth.py │ ├── bases.py │ ├── comments.py │ ├── entities.py │ ├── entity_list_properties.py │ ├── entity_lists.py │ ├── form_assignments.py │ ├── form_draft_attachments.py │ ├── form_drafts.py │ ├── forms.py │ ├── project_app_users.py │ ├── projects.py │ ├── submission_attachments.py │ └── submissions.py ├── _utils │ ├── __init__.py │ ├── config.py │ ├── session.py │ ├── utils.py │ └── validators.py ├── client.py └── errors.py ├── pyproject.toml ├── runtime.txt └── tests ├── __init__.py ├── endpoints ├── __init__.py ├── test_auth.py ├── test_comments.py ├── test_entities.py ├── test_entity_lists.py ├── test_forms.py ├── test_projects.py └── test_submissions.py ├── resources ├── .pyodk_cache.toml ├── .pyodk_config.toml ├── __init__.py ├── comments_data.py ├── entities_data.py ├── entity_lists_data.py ├── forms │ ├── fruits.csv │ └── range_draft.xml ├── forms_data.py ├── projects_data.py └── submissions_data.py ├── test_client.py ├── test_config.py ├── test_session.py ├── test_validators.py └── utils ├── __init__.py ├── entity_lists.py ├── forms.py ├── md_table.py ├── submissions.py └── utils.py /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Please refer to the project-wide [ODK Code of Conduct](https://github.com/getodk/governance/blob/master/CODE-OF-CONDUCT.md). -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | #### Software and hardware versions 14 | pyodk v1.x.x, Python v 15 | 16 | #### Problem description 17 | 18 | #### Steps to reproduce the problem 19 | 20 | #### Expected behavior 21 | 22 | #### Other information 23 | Things you tried, stack traces, related issues, suggestions on how to fix it... -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: "Report an issue" 4 | about: "For when pyodk is behaving in an unexpected way" 5 | url: "https://forum.getodk.org/c/support/6" 6 | - name: "Request a feature" 7 | about: "For when pyodk is missing functionality" 8 | url: "https://forum.getodk.org/c/features/9" 9 | - name: "Everything else" 10 | about: "For everything else" 11 | url: "https://forum.getodk.org/c/support/6" 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Closes # 2 | 3 | 9 | 10 | #### What has been done to verify that this works as intended? 11 | 12 | #### Why is this the best possible solution? Were any other approaches considered? 13 | 14 | #### How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks? 15 | 16 | #### Do we need any specific form for testing your changes? If so, please attach one. 17 | 18 | #### Does this change require updates to documentation? If so, please file an issue [here]( https://github.com/getodk/docs/issues/new) and include the link below. 19 | 20 | #### Before submitting this PR, please make sure you have: 21 | - [ ] included test cases for core behavior and edge cases in `tests` 22 | - [ ] run `python -m unittest` and verified all tests pass 23 | - [ ] run `ruff format pyodk tests` and `ruff check pyodk tests` to lint code 24 | - [ ] verified that any code or assets from external sources are properly credited in comments 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | python: ['3.12'] 13 | os: [ubuntu-latest] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python }} 20 | 21 | # Install dependencies. 22 | - uses: actions/cache@v4 23 | name: Python cache with dependencies. 24 | id: python-cache 25 | with: 26 | path: ${{ env.pythonLocation }} 27 | key: ${{ env.pythonLocation }}-${{ matrix.os }}-${{ matrix.python }}-${{ hashFiles('pyproject.toml') }} 28 | - name: Install dependencies. 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -e .[docs] 32 | pip list 33 | 34 | # Build and publish. 35 | - name: Publish release to PyPI 36 | if: success() 37 | run: | 38 | pip install flit==3.9.0 39 | flit --debug publish --no-use-vcs 40 | env: 41 | FLIT_USERNAME: __token__ 42 | FLIT_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 43 | 44 | # Publish docs. 45 | - name: Publish docs to gh-pages 46 | run: mkdocs gh-deploy --force 47 | env: 48 | # Silences platformdirs DeprecationWarning for jupyter v6 (not released yet). 49 | JUPYTER_PLATFORM_DIRS: 1 50 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | python: ['3.12'] 11 | os: [ubuntu-latest] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python }} 18 | 19 | # Install dependencies. 20 | - uses: actions/cache@v4 21 | name: Python cache with dependencies. 22 | id: python-cache 23 | with: 24 | path: ${{ env.pythonLocation }} 25 | key: ${{ env.pythonLocation }}-${{ matrix.os }}-${{ matrix.python }}-${{ hashFiles('pyproject.toml') }} 26 | - name: Install dependencies. 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -e .[dev] 30 | pip list 31 | 32 | # Linter. 33 | - run: ruff check pyodk tests docs --no-fix 34 | - run: ruff format pyodk tests docs --diff 35 | 36 | test: 37 | runs-on: ${{ matrix.os }} 38 | strategy: 39 | # Run all matrix jobs even if one of them fails. 40 | fail-fast: false 41 | matrix: 42 | python: ['3.10', '3.11', '3.12', '3.13'] 43 | os: [ubuntu-latest, macos-latest, windows-latest] 44 | pydantic: ['2.11.7'] 45 | # Test pydantic at lower boundary of requirement compatibility spec. 46 | include: 47 | - python: '3.12' 48 | os: ubuntu-latest 49 | pydantic: '2.6.4' 50 | steps: 51 | - uses: actions/checkout@v4 52 | - name: Set up Python 53 | uses: actions/setup-python@v5 54 | with: 55 | python-version: ${{ matrix.python }} 56 | 57 | # Install dependencies. 58 | - uses: actions/cache@v4 59 | name: Python cache with dependencies. 60 | id: python-cache 61 | with: 62 | path: ${{ env.pythonLocation }} 63 | key: ${{ env.pythonLocation }}-${{ matrix.os }}-${{ matrix.python }}-${{ hashFiles('pyproject.toml') }} 64 | - name: Install dependencies. 65 | run: | 66 | python -m pip install --upgrade pip 67 | pip install pydantic==${{ matrix.pydantic }} 68 | pip install -e .[dev,docs] 69 | pip list 70 | 71 | # Tests. 72 | - name: Run tests 73 | run: python -m unittest --verbose 74 | 75 | # Build and Upload. 76 | - name: Build sdist and wheel. 77 | if: success() 78 | run: | 79 | pip install flit==3.9.0 80 | flit --debug build --no-use-vcs 81 | - name: Upload sdist and wheel. 82 | if: success() 83 | uses: actions/upload-artifact@v4 84 | with: 85 | name: pyodk--on-${{ matrix.os }}--py${{ matrix.python }}--pydantic${{ matrix.pydantic }} 86 | path: ${{ github.workspace }}/dist/pyodk* 87 | 88 | # Check docs. 89 | - name: Check docs 90 | run: mkdocs build --strict 91 | env: 92 | # Silences platformdirs DeprecationWarning for jupyter v6 (not released yet). 93 | JUPYTER_PLATFORM_DIRS: 1 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Common editing files. 2 | *.pyc 3 | *.swp 4 | .DS_Store 5 | .idea 6 | *.iml 7 | .vscode 8 | venv/* 9 | 10 | # Folders created by setuptools. 11 | build 12 | dist 13 | pyodk.egg-info 14 | pip-wheel-metadata/* 15 | 16 | # Pypi manifest. 17 | MANIFEST 18 | 19 | # Jupyter 20 | .ipynb_checkpoints 21 | */.ipynb_checkpoints/* 22 | 23 | # Mkdocs 24 | site/* 25 | 26 | # MS Office temp files 27 | ~$* -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | ============== 3 | 4 | _Version 2.0, January 2004_ 5 | _<>_ 6 | 7 | ### Terms and Conditions for use, reproduction, and distribution 8 | 9 | #### 1. Definitions 10 | 11 | “License” shall mean the terms and conditions for use, reproduction, and 12 | distribution as defined by Sections 1 through 9 of this document. 13 | 14 | “Licensor” shall mean the copyright owner or entity authorized by the copyright 15 | owner that is granting the License. 16 | 17 | “Legal Entity” shall mean the union of the acting entity and all other entities 18 | that control, are controlled by, or are under common control with that entity. 19 | For the purposes of this definition, “control” means **(i)** the power, direct or 20 | indirect, to cause the direction or management of such entity, whether by 21 | contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the 22 | outstanding shares, or **(iii)** beneficial ownership of such entity. 23 | 24 | “You” (or “Your”) shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | “Source” form shall mean the preferred form for making modifications, including 28 | but not limited to software source code, documentation source, and configuration 29 | files. 30 | 31 | “Object” form shall mean any form resulting from mechanical transformation or 32 | translation of a Source form, including but not limited to compiled object code, 33 | generated documentation, and conversions to other media types. 34 | 35 | “Work” shall mean the work of authorship, whether in Source or Object form, made 36 | available under the License, as indicated by a copyright notice that is included 37 | in or attached to the work (an example is provided in the Appendix below). 38 | 39 | “Derivative Works” shall mean any work, whether in Source or Object form, that 40 | is based on (or derived from) the Work and for which the editorial revisions, 41 | annotations, elaborations, or other modifications represent, as a whole, an 42 | original work of authorship. For the purposes of this License, Derivative Works 43 | shall not include works that remain separable from, or merely link (or bind by 44 | name) to the interfaces of, the Work and Derivative Works thereof. 45 | 46 | “Contribution” shall mean any work of authorship, including the original version 47 | of the Work and any modifications or additions to that Work or Derivative Works 48 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 49 | by the copyright owner or by an individual or Legal Entity authorized to submit 50 | on behalf of the copyright owner. For the purposes of this definition, 51 | “submitted” means any form of electronic, verbal, or written communication sent 52 | to the Licensor or its representatives, including but not limited to 53 | communication on electronic mailing lists, source code control systems, and 54 | issue tracking systems that are managed by, or on behalf of, the Licensor for 55 | the purpose of discussing and improving the Work, but excluding communication 56 | that is conspicuously marked or otherwise designated in writing by the copyright 57 | owner as “Not a Contribution.” 58 | 59 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf 60 | of whom a Contribution has been received by Licensor and subsequently 61 | incorporated within the Work. 62 | 63 | #### 2. Grant of Copyright License 64 | 65 | Subject to the terms and conditions of this License, each Contributor hereby 66 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 67 | irrevocable copyright license to reproduce, prepare Derivative Works of, 68 | publicly display, publicly perform, sublicense, and distribute the Work and such 69 | Derivative Works in Source or Object form. 70 | 71 | #### 3. Grant of Patent License 72 | 73 | Subject to the terms and conditions of this License, each Contributor hereby 74 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 75 | irrevocable (except as stated in this section) patent license to make, have 76 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 77 | such license applies only to those patent claims licensable by such Contributor 78 | that are necessarily infringed by their Contribution(s) alone or by combination 79 | of their Contribution(s) with the Work to which such Contribution(s) was 80 | submitted. If You institute patent litigation against any entity (including a 81 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 82 | Contribution incorporated within the Work constitutes direct or contributory 83 | patent infringement, then any patent licenses granted to You under this License 84 | for that Work shall terminate as of the date such litigation is filed. 85 | 86 | #### 4. Redistribution 87 | 88 | You may reproduce and distribute copies of the Work or Derivative Works thereof 89 | in any medium, with or without modifications, and in Source or Object form, 90 | provided that You meet the following conditions: 91 | 92 | * **(a)** You must give any other recipients of the Work or Derivative Works a copy of 93 | this License; and 94 | * **(b)** You must cause any modified files to carry prominent notices stating that You 95 | changed the files; and 96 | * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, 97 | all copyright, patent, trademark, and attribution notices from the Source form 98 | of the Work, excluding those notices that do not pertain to any part of the 99 | Derivative Works; and 100 | * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any 101 | Derivative Works that You distribute must include a readable copy of the 102 | attribution notices contained within such NOTICE file, excluding those notices 103 | that do not pertain to any part of the Derivative Works, in at least one of the 104 | following places: within a NOTICE text file distributed as part of the 105 | Derivative Works; within the Source form or documentation, if provided along 106 | with the Derivative Works; or, within a display generated by the Derivative 107 | Works, if and wherever such third-party notices normally appear. The contents of 108 | the NOTICE file are for informational purposes only and do not modify the 109 | License. You may add Your own attribution notices within Derivative Works that 110 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 111 | provided that such additional attribution notices cannot be construed as 112 | modifying the License. 113 | 114 | You may add Your own copyright statement to Your modifications and may provide 115 | additional or different license terms and conditions for use, reproduction, or 116 | distribution of Your modifications, or for any such Derivative Works as a whole, 117 | provided Your use, reproduction, and distribution of the Work otherwise complies 118 | with the conditions stated in this License. 119 | 120 | #### 5. Submission of Contributions 121 | 122 | Unless You explicitly state otherwise, any Contribution intentionally submitted 123 | for inclusion in the Work by You to the Licensor shall be under the terms and 124 | conditions of this License, without any additional terms or conditions. 125 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 126 | any separate license agreement you may have executed with Licensor regarding 127 | such Contributions. 128 | 129 | #### 6. Trademarks 130 | 131 | This License does not grant permission to use the trade names, trademarks, 132 | service marks, or product names of the Licensor, except as required for 133 | reasonable and customary use in describing the origin of the Work and 134 | reproducing the content of the NOTICE file. 135 | 136 | #### 7. Disclaimer of Warranty 137 | 138 | Unless required by applicable law or agreed to in writing, Licensor provides the 139 | Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, 140 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 141 | including, without limitation, any warranties or conditions of TITLE, 142 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 143 | solely responsible for determining the appropriateness of using or 144 | redistributing the Work and assume any risks associated with Your exercise of 145 | permissions under this License. 146 | 147 | #### 8. Limitation of Liability 148 | 149 | In no event and under no legal theory, whether in tort (including negligence), 150 | contract, or otherwise, unless required by applicable law (such as deliberate 151 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 152 | liable to You for damages, including any direct, indirect, special, incidental, 153 | or consequential damages of any character arising as a result of this License or 154 | out of the use or inability to use the Work (including but not limited to 155 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 156 | any and all other commercial damages or losses), even if such Contributor has 157 | been advised of the possibility of such damages. 158 | 159 | #### 9. Accepting Warranty or Additional Liability 160 | 161 | While redistributing the Work or Derivative Works thereof, You may choose to 162 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 163 | other liability obligations and/or rights consistent with this License. However, 164 | in accepting such obligations, You may act only on Your own behalf and on Your 165 | sole responsibility, not on behalf of any other Contributor, and only if You 166 | agree to indemnify, defend, and hold each Contributor harmless for any liability 167 | incurred by, or claims asserted against, such Contributor by reason of your 168 | accepting any such warranty or additional liability. 169 | 170 | _END OF TERMS AND CONDITIONS_ 171 | 172 | ### APPENDIX: How to apply the Apache License to your work 173 | 174 | To apply the Apache License to your work, attach the following boilerplate 175 | notice, with the fields enclosed by brackets `[]` replaced with your own 176 | identifying information. (Don't include the brackets!) The text should be 177 | enclosed in the appropriate comment syntax for the file format. We also 178 | recommend that a file or class name and description of purpose be included on 179 | the same “printed page” as the copyright notice for easier identification within 180 | third-party archives. 181 | 182 | Copyright [yyyy] [name of copyright owner] 183 | 184 | Licensed under the Apache License, Version 2.0 (the "License"); 185 | you may not use this file except in compliance with the License. 186 | You may obtain a copy of the License at 187 | 188 | http://www.apache.org/licenses/LICENSE-2.0 189 | 190 | Unless required by applicable law or agreed to in writing, software 191 | distributed under the License is distributed on an "AS IS" BASIS, 192 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 193 | See the License for the specific language governing permissions and 194 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyODK 2 | [![pypi](https://img.shields.io/pypi/v/pyodk.svg)](https://pypi.python.org/pypi/pyodk) 3 | 4 | An API client for the [ODK Central API](https://docs.getodk.org/central-api). Use it to interact with your data and automate common tasks from Python. 5 | 6 | This library aims to make common data analysis and workflow automation tasks as simple as possible by providing clear method names, types, and examples. It also provides convenient access to the full API using [HTTP verb methods](https://getodk.github.io/pyodk/http-methods/). 7 | 8 | ## Install 9 | 10 | The currently supported Python versions for `pyodk` are 3.10 to 3.13 (the primary development version is 3.12). If this is different from the version you use for other projects, consider using [`pyenv`](https://github.com/pyenv/pyenv) to manage multiple versions of Python. 11 | 12 | The currently supported Central version is v2025.1.4. Newer or older Central versions will likely work too, but convenience (non-HTTP) methods assume this version. If you see a 404 error or another server error, please verify the version of your Central server. 13 | 14 | ### From pip 15 | 16 | ```bash 17 | pip install pyodk 18 | ``` 19 | 20 | ### From source 21 | 22 | ```bash 23 | # Get a copy of the repository. 24 | mkdir -P ~/repos/pyodk 25 | cd ~/repos/pyodk 26 | git clone https://github.com/getodk/pyodk.git repo 27 | 28 | # Create and activate a virtual environment for the install. 29 | python -m venv venv 30 | source venv/bin/activate 31 | 32 | # Install pyodk and its production dependencies. 33 | cd ~/repos/pyodk/repo 34 | pip install -e . 35 | 36 | # Leave the virtualenv. 37 | deactivate 38 | ``` 39 | 40 | ## Configure 41 | 42 | The configuration file uses the TOML format. The default file name is `.pyodk_config.toml`, and the default location is the user home directory. The file name and location can be customised by setting the environment variable `PYODK_CONFIG_FILE` to some other file path, or by passing the path at init with `Client(config_path="my_config.toml")`. The expected file structure is as follows: 43 | 44 | ```toml 45 | [central] 46 | base_url = "https://www.example.com" 47 | username = "my_user" 48 | password = "my_password" 49 | default_project_id = 123 50 | ``` 51 | 52 | ### Custom configuration file paths 53 | 54 | The `Client` is specific to a configuration and cache file. These approximately correspond to the session which the `Client` represents; it also encourages segregating credentials. These paths can be set by: 55 | 56 | - Setting environment variables `PYODK_CONFIG_FILE` and `PYODK_CACHE_FILE` 57 | - Init arguments: `Client(config_path="my_config.toml", cache_path="my_cache.toml")`. 58 | 59 | ### Default project 60 | 61 | The `Client` is not specific to a project, but a default `project_id` can be set by: 62 | 63 | - A `default_project_id` in the configuration file. 64 | - An init argument: `Client(project_id=1)`. 65 | - A property on the client: `client.project_id = 1`. 66 | 67 | *Default Identifiers* 68 | 69 | For each endpoint, a default can be set for key identifiers, so these identifiers are optional in most methods. When the identifier is required, validation ensures that either a default value is set, or a value is specified. E.g. 70 | 71 | ```python 72 | client.projects.default_project_id = 1 73 | client.forms.default_form_id = "my_form" 74 | client.submissions.default_form_id = "my_form" 75 | client.entities.default_entity_list_name = "my_list" 76 | client.entities.default_project_id = 1 77 | ``` 78 | 79 | ### Session cache file 80 | 81 | The session cache file uses the TOML format. The default file name is `.pyodk_cache.toml`, and the default location is the user home directory. The file name and location can be customised by setting the environment variable `PYODK_CACHE_FILE` to some other file path, or by passing the path at init with `Client(config_path="my_cache.toml")`. This file should not be pre-created as it is used to store a session token after login. 82 | 83 | ## Use 84 | 85 | To get started with `pyODK`, build a `Client`: 86 | 87 | ```python 88 | from pyodk.client import Client 89 | 90 | client = Client() 91 | ``` 92 | 93 | Authentication is triggered by the first API call on the `Client`, or by explicitly using `Client.open()`. 94 | 95 | Use `Client.close()` to clean up a client session. Clean up is recommended for long-running scripts, e.g. web apps, etc. 96 | 97 | You can also use the Client as a context manager to manage authentication and clean up: 98 | 99 | ```python 100 | with Client() as client: 101 | print(client.projects.list()) 102 | ``` 103 | 104 | Learn more [in the documentation](https://getodk.github.io/pyodk/). 105 | 106 | ### Examples 107 | 108 | **👉 See detailed tutorials in [the documentation](https://getodk.github.io/pyodk/examples/).** 109 | 110 | ```python 111 | from pyodk.client import Client 112 | 113 | client = Client() 114 | projects = client.projects.list() 115 | forms = client.forms.list() 116 | submissions = client.submissions.list(form_id=next(forms).xmlFormId) 117 | form_data = client.submissions.get_table(form_id="birds", project_id=8) 118 | comments = client.submissions.list_comments(form_id=next(forms).xmlFormId, instance_id="uuid:...") 119 | client.forms.update( 120 | form_id="my_xlsform", 121 | definition="my_xlsform.xlsx", 122 | attachments=["fruits.csv", "vegetables.png"], 123 | ) 124 | client.close() 125 | ``` 126 | 127 | ### Session customization 128 | If Session behaviour needs to be customised, for example to set alternative timeouts or retry strategies, etc., then subclass the `pyodk.session.Session` and provide an instance to the `Client` constructor, e.g. `Client(session=my_session)`. 129 | 130 | 131 | ### Logging 132 | Errors raised by pyODK and other messages are logged using the `logging` standard library. The logger is in the `pyodk` namespace / hierarchy (e.g `pyodk.config`, `pyodk.endpoints.auth`, etc.). The logs can be manipulated from your script / app as follows. 133 | 134 | ```python 135 | import logging 136 | 137 | # Initialise an example basic logging config (writes to stdout/stderr). 138 | logging.basicConfig() 139 | logging.getLogger().setLevel(logging.DEBUG) 140 | 141 | # Get a reference to the pyodk logger. 142 | pyodk_log = logging.getLogger("pyodk") 143 | 144 | # Receive everything DEBUG level and higher. 145 | pyodk_log.setLevel(logging.DEBUG) 146 | pyodk_log.propagate = True 147 | 148 | # Ignore everything below FATAL level. 149 | pyodk_log.setLevel(logging.FATAL) 150 | pyodk_log.propagate = False 151 | ``` 152 | 153 | ### Errors raised by pyODK 154 | Error types raised by pyODK are found in `errors.py`, which currently is only the `PyODKError`. In general this error is raised when: 155 | 156 | - The pyODK configuration is invalid (missing file, missing fields, etc). 157 | - The client method arguments are invalid (missing, wrong type, etc.). 158 | - The response from ODK Central indicated and error (HTTP >=400, <600). 159 | - The data returned from ODK Central does not have the expected fields or types. 160 | 161 | Note that pyODK does not attempt to wrap every possible error condition, so if needed, broader exception handling should be included in your script / app. 162 | 163 | ## Design considerations 164 | Our goal with pyODK is to support the most common Central API functionality in an easy-to-use, high-level way. Because we expose [HTTP verb methods](https://getodk.github.io/pyodk/http-methods/), we don't feel the need to add explicit support for the whole Central API. 165 | 166 | Here are some points to think about when considering adding new methods to pyODK: 167 | 168 | * Is it common enough to warrant a designed method or can we show examples using the HTTP methods if needed? 169 | * For example, we currently consider manipulating form drafts directly out of scope but `client.forms.update` implicitly creates and publishes a draft 170 | * Do people take this action independently or is it always part of reaching some broader goal? 171 | * What pyODK class does it best fit in? 172 | * For example, form versions are subresources on Central backend but in this library, methods that deal with form versions can be in the `forms` class directly since we’re not going to expose many of them 173 | * How do people talk about the action that’s being performed? How do ODK docs and Central frontend talk about it? 174 | * For example, documentation has the concept of "submission edits" so use `client.submissions.edit` rather than update 175 | * Value expressiveness and consistency with ODK concepts over pyODK internal consistency 176 | * What actually needs to be commonly configured? Starting by exposing a subset of parameters and naming/typing them carefully is ideal. 177 | 178 | ## Contribute 179 | 180 | See issues for additions to `pyodk` that are under consideration. Please file new issues for any functionality you are missing. 181 | 182 | ## Develop 183 | 184 | Install the source files as described above, then: 185 | 186 | ```bash 187 | pip install -e .[dev] 188 | ``` 189 | 190 | You can run tests with: 191 | 192 | ```bash 193 | python -m unittest 194 | ``` 195 | 196 | ### Testing 197 | 198 | When adding or updating pyODK functionality, at a minimum add or update corresponding unit tests. The unit tests are filed in `tests/endpoints` or `tests`. These tests focus on pyODK functionality, such as ensuring that data de/serialisation works as expected, and that method logic results in the expected call patterns. The unit tests use mocks and static data, which are stored in `tests/resources`. These data are obtained by making an API call and saving the Python dict returned by `response.json()` as text. 199 | 200 | For interactive testing, debugging, or sanity checking workflows, end-to-end tests are stored in `tests/test_client.py`. These tests are not run by default because they require access to a live Central server. The ODK team use the Central staging instance https://staging.getodk.cloud/ which is already configured for testing. Below are the steps to set up a new project in Central to be able to run these tests. 201 | 202 | 1. Create a test project in Central. 203 | 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. 204 | 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". 205 | 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. 206 | 207 | 208 | ## Release 209 | 210 | 1. Run all linting and tests. 211 | 1. Draft a new GitHub release with the list of merged PRs. 212 | 1. Check out a release branch from latest upstream master. 213 | 1. Update `pyproject.toml` and `pyodk/__version__.py` with the new release version number. 214 | 1. Update the Central version in the README to reflect the version we test against. 215 | 1. Commit, push the branch, and initiate a pull request. Wait for tests to pass, then merge the PR. 216 | 1. Tag the release and it will automatically be published (see `release.yml` actions file). 217 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | See our [Vulnerability Disclosure Policy](https://getodk.org/legal/vulnerability.html). 6 | -------------------------------------------------------------------------------- /docs/assets/extra_css.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 3 | } 4 | 5 | :root { 6 | --md-text-font: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'"; 7 | --md-accent-fg-color:#009ecc; 8 | } 9 | 10 | .md-content,.md-sidebar,.md-header { 11 | --md-typeset-a-color:#009ecc; 12 | } -------------------------------------------------------------------------------- /docs/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/assets/favicon.ico -------------------------------------------------------------------------------- /docs/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/client.md: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | ::: pyodk.client.Client -------------------------------------------------------------------------------- /docs/entities.md: -------------------------------------------------------------------------------- 1 | # Entities 2 | 3 | ::: pyodk._endpoints.entities.EntityService -------------------------------------------------------------------------------- /docs/entity_lists.md: -------------------------------------------------------------------------------- 1 | # Entity Lists 2 | 3 | ::: pyodk._endpoints.entity_lists.EntityListService -------------------------------------------------------------------------------- /docs/examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Examples of solutions to different types of problems. 4 | 5 | Please help us expand this resource! Contribute an example by [opening a pull request](https://github.com/getodk/pyodk/pulls). You can also share what you've done or what you'd like to see in [an issue](https://github.com/getodk/pyodk/issues). 6 | 7 | ## [Using `pyodk` and `pandas` for basic analysis](basic-analysis-pandas.ipynb) 8 | 9 | A standalone Jupyter notebook intended to introduce `pyodk` and show how it can be used with `pandas` and `geopandas` to perform basic analysis. Shows building a client and fetching data for a form. You can try this on your server using [this form definition](fav_color.xlsx). 10 | 11 | ## [Going beyond `pyodk`'s library methods](beyond-library-methods.ipynb) 12 | 13 | A standalone Jupyter notebook that shows how the raw HTTP method access and the [API docs](https://docs.getodk.org/central-api) can be used together to make use of the full ODK Central API. 14 | 15 | ## [Working with repeats](working-with-repeats.ipynb) 16 | 17 | A Jupyter notebook demonstrating some options for working with repeats. 18 | 19 | ## [App User provisioning script](app_user_provisioner/app_user_provisioner.py) 20 | 21 | A script that reads names from a CSV and creates an App User for each one that isn't currently used by an active App User on the server. Also creates customized QR codes for each new App User. 22 | 23 | ## [Create Entities from CSV data](create_entities_from_submissions/create_entities_from_submissions.py) 24 | 25 | A script that reads data from a CSV and creates Entities. 26 | 27 | ## [Mail merge script](mail_merge/mail_merge.py) 28 | 29 | A script that uses mail merge to create personalized Word documents with data from Central. 30 | 31 | ## [October 2022 webinar materials](2022-10-pyodk-webinar.ipynb) 32 | 33 | A Jupyter notebook companion to an October 2022 webinar by Hélène Martin introducing `pyodk`. Includes link to the session recording. 34 | 35 | ## [Create or Update Form script](create_or_update_form/create_or_update_form.py) 36 | 37 | A script to create or update a form, optionally with attachments. 38 | -------------------------------------------------------------------------------- /docs/examples/app_user_provisioner/Roboto-Regular.license: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /docs/examples/app_user_provisioner/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/app_user_provisioner/Roboto-Regular.ttf -------------------------------------------------------------------------------- /docs/examples/app_user_provisioner/app_user_provisioner.py: -------------------------------------------------------------------------------- 1 | """ 2 | App User Provisioner 3 | 4 | Put a series of user names (one on each line) in a file named `users.csv` in the same 5 | directory as this script. The script will create App Users for each user, using the 6 | project, forms, and other configurations set below. The outputs are one PNG for each 7 | provisioned App User, and a `users.pdf` file with all the App User PNGs in the folder. 8 | 9 | Install requirements for this script in `requirements.txt`. The specified versions are 10 | those that were current when the script was last updated, though it should work with 11 | more recent versions. Install these with `pip install -r requirements.txt`. 12 | 13 | To run the script, use `python app_user_provisioner.py`. 14 | """ 15 | 16 | import base64 17 | import glob 18 | import json 19 | import zlib 20 | from typing import Any 21 | 22 | import segno 23 | from PIL import Image, ImageDraw, ImageFont, ImageOps 24 | from pyodk.client import Client 25 | 26 | # Customise these settings to your environment. 27 | PROJECT_ID = 149 28 | PROJECT_NAME = "My Cool Project" 29 | FORMS_TO_ACCESS = ["all-widgets", "afp-knowledge"] 30 | ADMIN_PASSWORD = "s00p3rs3cr3t" # noqa: S105 31 | 32 | 33 | def get_settings(server_url: str, project_name: str, username: str) -> dict[str, Any]: 34 | """Template for the settings to encode in the QR image. Customise as needed.""" 35 | return { 36 | "general": { 37 | "form_update_mode": "match_exactly", 38 | "autosend": "wifi_and_cellular", 39 | "delete_send": True, 40 | "server_url": server_url, 41 | "username": username, 42 | }, 43 | "admin": { 44 | "admin_pw": ADMIN_PASSWORD, 45 | "change_server": False, 46 | "automatic_update": False, 47 | "change_autosend": False, 48 | }, 49 | "project": {"name": project_name, "color": "#ffeb3b", "icon": "💥"}, 50 | } 51 | 52 | 53 | # Check that the Roboto font used for the QR images is available (e.g. on Linux / Win). 54 | try: 55 | ImageFont.truetype("Roboto-Regular.ttf", 24) 56 | except OSError: 57 | print( 58 | "Font file 'Roboto-Regular.ttf' not found. This can be downloaded " 59 | "from Google, or copied from the Examples directory. " 60 | "Source: https://fonts.google.com/specimen/Roboto/about" 61 | ) 62 | 63 | 64 | # Provision the App Users. 65 | with open("users.csv", newline="") as f: 66 | desired_users = f.readlines() 67 | desired_users = [user.rstrip() for user in desired_users] 68 | 69 | client = Client() 70 | provisioned_users = client.projects.create_app_users( 71 | display_names=desired_users, forms=FORMS_TO_ACCESS, project_id=PROJECT_ID 72 | ) 73 | 74 | # Generate the QR codes. 75 | for user in provisioned_users: 76 | collect_settings = get_settings( 77 | server_url=f"{client.session.base_url}key/{user.token}/projects/{PROJECT_ID}", 78 | project_name=f"{PROJECT_NAME}: {user.displayName}", 79 | username=user.displayName, 80 | ) 81 | qr_data = base64.b64encode( 82 | zlib.compress(json.dumps(collect_settings).encode("utf-8")) 83 | ) 84 | 85 | code = segno.make(qr_data, micro=False) 86 | code.save("settings.png", scale=4) 87 | 88 | png = Image.open("settings.png") 89 | png = png.convert("RGB") 90 | text_anchor = png.height 91 | png = ImageOps.expand(png, border=(0, 0, 0, 30), fill=(255, 255, 255)) 92 | draw = ImageDraw.Draw(png) 93 | font = ImageFont.truetype("Roboto-Regular.ttf", 24) 94 | draw.text((20, text_anchor - 10), user.displayName, font=font, fill=(0, 0, 0)) 95 | png.save(f"settings-{user.displayName}.png", format="PNG") 96 | 97 | # Concatenate the user images into a PDF. 98 | images = [Image.open(f) for f in sorted(glob.glob("./settings-*.png"))] 99 | if 0 < len(images): 100 | img = iter(images) 101 | next(img).save( 102 | "users.pdf", format="PDF", resolution=100, save_all=True, append_images=img 103 | ) 104 | -------------------------------------------------------------------------------- /docs/examples/app_user_provisioner/requirements.txt: -------------------------------------------------------------------------------- 1 | segno==1.6.1 2 | Pillow==10.3.0 3 | -------------------------------------------------------------------------------- /docs/examples/create_entities_from_submissions/create_entities_from_submissions.py: -------------------------------------------------------------------------------- 1 | """ 2 | A script that uses CSV data to create an entity list and populate it with entities. 3 | """ 4 | 5 | from csv import DictReader 6 | from pathlib import Path 7 | from uuid import uuid4 8 | 9 | from pyodk import Client 10 | 11 | project_id = 1 12 | entity_label_field = "first_name" 13 | entity_properties = ("age", "location") 14 | csv_path = Path("./imported_answers.csv") 15 | 16 | 17 | def create_one_at_a_time(): 18 | with Client(project_id=project_id) as client, open(csv_path) as csv_file: 19 | # Create the entity list. 20 | entity_list = client.entity_lists.create( 21 | entity_list_name=f"previous_survey_{uuid4()}" 22 | ) 23 | for prop in entity_properties: 24 | client.entity_lists.add_property(name=prop, entity_list_name=entity_list.name) 25 | 26 | # Create the entities from the CSV data. 27 | for row in DictReader(csv_file): 28 | client.entities.create( 29 | label=row[entity_label_field], 30 | data={k: str(v) for k, v in row.items() if k in entity_properties}, 31 | entity_list_name=entity_list.name, 32 | ) 33 | 34 | 35 | def create_with_merge(): 36 | with Client(project_id=project_id) as client, open(csv_path) as csv_file: 37 | client.entity_lists.default_entity_list_name = f"previous_survey_{uuid4()}" 38 | entity_list = client.entity_lists.create() 39 | client.entities.merge( 40 | data=DictReader(csv_file), 41 | entity_list_name=entity_list.name, 42 | source_label_key=entity_label_field, 43 | source_keys=(entity_label_field, *entity_properties), 44 | ) 45 | 46 | 47 | if __name__ == "__main__": 48 | # create_one_at_a_time() 49 | create_with_merge() 50 | -------------------------------------------------------------------------------- /docs/examples/create_entities_from_submissions/imported_answers.csv: -------------------------------------------------------------------------------- 1 | first_name,age,favorite_color,favorite_color_other,location 2 | John,30,r,,37.7749 -122.4194 0 10 3 | Alice,25,y,,-33.8651 151.2099 0 5 4 | Bob,35,o,orange,51.5074 -0.1278 0 15 -------------------------------------------------------------------------------- /docs/examples/create_or_update_form/create_or_update_form.py: -------------------------------------------------------------------------------- 1 | """ 2 | A script to create or update a form, optionally with attachments. 3 | 4 | Either use as a CLI script, or import create_or_update into another module. 5 | 6 | If provided, all files in the [attachments_dir] path will be uploaded with the form. 7 | """ 8 | 9 | import sys 10 | from os import PathLike 11 | from pathlib import Path 12 | 13 | from pyodk.client import Client 14 | from pyodk.errors import PyODKError 15 | 16 | 17 | def create_ignore_duplicate_error(client: Client, definition: PathLike | str | bytes): 18 | """Create the form; ignore the error raised if it exists (409.3).""" 19 | try: 20 | client.forms.create(definition=definition) 21 | except PyODKError as err: 22 | if not err.is_central_error(code=409.3): 23 | raise 24 | 25 | 26 | def create_or_update(form_id: str, definition: str, attachments: str | None): 27 | """Create (and publish) the form, optionally with attachments.""" 28 | with Client() as client: 29 | create_ignore_duplicate_error(client=client, definition=definition) 30 | attach = None 31 | if attachments is not None: 32 | attach = Path(attachments).iterdir() 33 | client.forms.update( 34 | definition=definition, 35 | form_id=form_id, 36 | attachments=attach, 37 | ) 38 | 39 | 40 | if __name__ == "__main__": 41 | usage = """ 42 | Usage: 43 | 44 | python create_or_update_form.py form_id definition.xlsx 45 | python create_or_update_form.py form_id definition.xlsx attachments_dir 46 | """ 47 | if len(sys.argv) < 3: 48 | print(usage) 49 | sys.exit(1) 50 | fid = sys.argv[1] 51 | def_path = sys.argv[2] 52 | attach_path = None 53 | if len(sys.argv) == 4: 54 | attach_path = sys.argv[3] 55 | create_or_update(form_id=fid, definition=def_path, attachments=attach_path) 56 | -------------------------------------------------------------------------------- /docs/examples/docs-form-version-xlsx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/docs-form-version-xlsx.png -------------------------------------------------------------------------------- /docs/examples/fav_color.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/fav_color.xlsx -------------------------------------------------------------------------------- /docs/examples/mail_merge/mail_merge.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mail Merge 3 | 4 | This script will use mail merge to create personalized Word documents with 5 | data from Central. In this example, only data from approved submissions 6 | are included. 7 | 8 | For a tutorial on how to populate Word templates with Python, see: 9 | https://pbpython.com/python-word-template.html 10 | 11 | For more examples, see: 12 | https://github.com/iulica/docx-mailmerge/tree/master/tests 13 | 14 | Install requirements for this script in `requirements.txt`. The specified 15 | versions are those that were current when the script was last updated, 16 | though it should work with more recent versions. 17 | Install with `pip install -r requirements.txt`. 18 | 19 | To run the script, use `python mail_merge.py`. 20 | """ 21 | 22 | import os 23 | from datetime import datetime 24 | 25 | from mailmerge import MailMerge 26 | from pyodk.client import Client 27 | 28 | # customize these settings to your environment 29 | PROJECT_ID = 1 30 | FORM_ID = "my_form" 31 | TEMPLATE_DOCUMENT = "template.docx" 32 | OUTPUT_FOLDER = "merged" 33 | 34 | with Client(project_id=PROJECT_ID) as client: 35 | submissions = client.submissions.get_table(form_id=FORM_ID) 36 | for submission in submissions["value"]: 37 | # only include approved submissions 38 | if submission["__system"]["reviewState"] == "approved": 39 | with MailMerge(TEMPLATE_DOCUMENT) as document: 40 | coordinates = submission["age_location"]["location"]["coordinates"] 41 | location = f"{coordinates[1]}, {coordinates[0]}" 42 | generation_date = datetime.now().strftime("%m-%d-%Y %H:%M:%S.%f") 43 | document.merge( 44 | first_name=submission["first_name"], 45 | age=submission["age_location"]["age"], 46 | location=location, 47 | instance_id=submission["meta"]["instanceID"], 48 | submission_date=submission["__system"]["submissionDate"], 49 | generation_date=generation_date, 50 | ) 51 | # warning: choose variables with unique values to prevent overwritten files 52 | output_path = os.path.join( 53 | OUTPUT_FOLDER, f"{submission['first_name']}.docx" 54 | ) 55 | document.write(output_path) 56 | -------------------------------------------------------------------------------- /docs/examples/mail_merge/merged/Pedro.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/mail_merge/merged/Pedro.docx -------------------------------------------------------------------------------- /docs/examples/mail_merge/merged/Sally.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/mail_merge/merged/Sally.docx -------------------------------------------------------------------------------- /docs/examples/mail_merge/requirements.txt: -------------------------------------------------------------------------------- 1 | docx-mailmerge2==0.8.0 2 | -------------------------------------------------------------------------------- /docs/examples/mail_merge/template.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/mail_merge/template.docx -------------------------------------------------------------------------------- /docs/examples/requirements.txt: -------------------------------------------------------------------------------- 1 | geopandas 2 | pandas 3 | flatten_json -------------------------------------------------------------------------------- /docs/examples/simple_repeat.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/simple_repeat.xlsx -------------------------------------------------------------------------------- /docs/forms.md: -------------------------------------------------------------------------------- 1 | # Forms 2 | 3 | ::: pyodk._endpoints.forms.FormService -------------------------------------------------------------------------------- /docs/http-methods.md: -------------------------------------------------------------------------------- 1 | # HTTP verb methods 2 | 3 | For interacting with parts of the ODK Central API ([docs](https://docs.getodk.org/central-api)) that have not been implemented in `pyodk`, use HTTP verb methods exposed on the `Client`: 4 | 5 | ```python 6 | client.get("projects/8") 7 | client.post("projects/7/app-users", json={"displayName": "Lab Tech"}) 8 | ``` 9 | 10 | These methods provide convenient access to `Client.session`, which is a [`requests.Session`](https://requests.readthedocs.io/en/latest/user/advanced/#session-objects) object subclass. The `Session` has customised to prefix request URLs with the `base_url` from the pyodk config. For example with a base_url `https://www.example.com`, a call to `client.session.get("projects/8")` gets the details of `project_id=8`, using the full url `https://www.example.com/v1/projects/8`. 11 | 12 | Learn more in [this example](https://getodk.github.io/pyodk/examples/beyond-library-methods/). 13 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" -------------------------------------------------------------------------------- /docs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block scripts %} 4 | {{ super() }} 5 | 6 | {% endblock %} -------------------------------------------------------------------------------- /docs/projects.md: -------------------------------------------------------------------------------- 1 | # Projects 2 | 3 | ::: pyodk._endpoints.projects.ProjectService -------------------------------------------------------------------------------- /docs/submissions.md: -------------------------------------------------------------------------------- 1 | # Submissions 2 | 3 | ::: pyodk._endpoints.submissions.SubmissionService -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pyODK 2 | edit_uri: https://github.com/getodk/pyodk/tree/master/docs 3 | repo_url: https://github.com/getodk/pyodk 4 | repo_name: getodk/pyodk 5 | docs_dir: docs 6 | watch: 7 | - ./README.md 8 | - ./docs 9 | - ./pyodk 10 | 11 | nav: 12 | - Overview: index.md 13 | - Client: client.md 14 | - .entities: entities.md 15 | - .entity_lists: entity_lists.md 16 | - .forms: forms.md 17 | - .projects: projects.md 18 | - .submissions: submissions.md 19 | - HTTP methods: http-methods.md 20 | - Examples: examples/README.md 21 | 22 | theme: 23 | name: material 24 | logo: assets/logo.svg 25 | favicon: assets/favicon.ico 26 | custom_dir: docs/overrides 27 | features: 28 | - content.action.edit 29 | - content.action.view 30 | - content.code.copy 31 | - navigation.expand 32 | - navigation.instant 33 | - navigation.top 34 | - navigation.tracking 35 | - toc.follow 36 | icon: 37 | view: material/file-eye-outline 38 | edit: material/file-edit-outline 39 | palette: 40 | - media: "(prefers-color-scheme: light)" 41 | scheme: default 42 | primary: white 43 | toggle: 44 | icon: material/weather-sunny 45 | name: Switch to dark mode 46 | - media: "(prefers-color-scheme: dark)" 47 | scheme: slate 48 | primary: black 49 | toggle: 50 | icon: material/weather-night 51 | name: Switch to light mode 52 | 53 | extra_css: 54 | - assets/extra_css.css 55 | 56 | plugins: 57 | - search 58 | - mkdocstrings: 59 | enabled: !ENV [ENABLE_MKDOCSTRINGS, true] 60 | default_handler: python 61 | handlers: 62 | python: 63 | options: 64 | docstring_style: sphinx 65 | members_order: alphabetical 66 | show_bases: false 67 | show_root_toc_entry: false 68 | show_source: false 69 | - mkdocs-jupyter 70 | 71 | markdown_extensions: 72 | - pymdownx.highlight: 73 | anchor_linenums: true 74 | - pymdownx.inlinehilite 75 | - pymdownx.snippets 76 | - pymdownx.superfences 77 | - toc: 78 | permalink: true 79 | -------------------------------------------------------------------------------- /pyodk/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pyodk import errors 4 | from pyodk.client import Client 5 | 6 | __all__ = ( 7 | "Client", 8 | "errors", 9 | ) 10 | 11 | 12 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 13 | -------------------------------------------------------------------------------- /pyodk/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.2.1" 2 | -------------------------------------------------------------------------------- /pyodk/_endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = () 2 | -------------------------------------------------------------------------------- /pyodk/_endpoints/auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING 3 | 4 | from pyodk._utils import config 5 | from pyodk.errors import PyODKError 6 | 7 | if TYPE_CHECKING: 8 | from pyodk._utils.session import Session 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class AuthService: 14 | def __init__(self, session: "Session", cache_path: str | None = None) -> None: 15 | self.session: Session = session 16 | self.cache_path: str = cache_path 17 | 18 | def verify_token(self, token: str) -> str: 19 | """ 20 | Check with Central that a token is valid. 21 | 22 | :param token: The token to check. 23 | :return: 24 | """ 25 | response = self.session.get( 26 | url="users/current", 27 | headers={ 28 | "Content-Type": "application/json", 29 | "Authorization": f"Bearer {token}", 30 | }, 31 | ) 32 | if response.status_code == 200: 33 | return token 34 | else: 35 | msg = ( 36 | f"The token verification request failed." 37 | f" Status: {response.status_code}, content: {response.content}" 38 | ) 39 | err = PyODKError(msg) 40 | log.error(err, exc_info=True) 41 | raise err 42 | 43 | def get_new_token(self, username: str, password: str) -> str: 44 | """ 45 | Get a new token from Central by creating a new session. 46 | 47 | https://docs.getodk.org/central-api-authentication/#logging-in 48 | 49 | :param username: The username of the Web User to auth with. 50 | :param password: The Web User's password. 51 | :return: The session token. 52 | """ 53 | response = self.session.post( 54 | url="sessions", 55 | json={"email": username, "password": password}, 56 | headers={"Content-Type": "application/json"}, 57 | ) 58 | if response.status_code == 200: 59 | data = response.json() 60 | if "token" not in data: 61 | msg = "The login request was OK but there was no token in the response." 62 | err = PyODKError(msg) 63 | log.error(err, exc_info=True) 64 | raise err 65 | else: 66 | return data["token"] 67 | else: 68 | msg = ( 69 | f"The login request failed." 70 | f" Status: {response.status_code}, content: {response.content}" 71 | ) 72 | err = PyODKError(msg, response) 73 | log.error(err, exc_info=True) 74 | raise err 75 | 76 | def get_token(self, username: str, password: str) -> str: 77 | """ 78 | Get a verified session token with the provided credential. 79 | 80 | Tries to verify token in cache_file, or requests a new session. 81 | 82 | :param username: The username of the Web User to auth with. 83 | :param password: The Web User's password. 84 | :return: The session token or None if anything has gone wrong 85 | """ 86 | try: 87 | token = config.read_cache_token(cache_path=self.cache_path) 88 | return self.verify_token(token=token) 89 | except PyODKError: 90 | # Couldn't read the token, or it wasn't valid. 91 | pass 92 | 93 | token = self.get_new_token(username=username, password=password) 94 | config.write_cache(key="token", value=token, cache_path=self.cache_path) 95 | return token 96 | -------------------------------------------------------------------------------- /pyodk/_endpoints/bases.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict 2 | 3 | from pyodk._utils.session import Session 4 | 5 | 6 | class Model(BaseModel): 7 | """Base configuration for data model classes.""" 8 | 9 | model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) 10 | 11 | 12 | class Manager: 13 | """Base for managers of data model classes.""" 14 | 15 | __slots__ = ("__weakref__",) 16 | 17 | @classmethod 18 | def from_dict(cls, session: Session, project_id: int, data: dict) -> Model: 19 | raise NotImplementedError() 20 | 21 | 22 | class Service: 23 | """Base for services interacting with the ODK Central API over HTTP.""" 24 | 25 | __slots__ = ("__weakref__",) 26 | -------------------------------------------------------------------------------- /pyodk/_endpoints/comments.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | 5 | from pyodk._endpoints.bases import Model, Service 6 | from pyodk._utils import validators as pv 7 | from pyodk._utils.session import Session 8 | from pyodk.errors import PyODKError 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class Comment(Model): 14 | body: str 15 | actorId: int 16 | createdAt: datetime 17 | 18 | 19 | @dataclass(frozen=True, slots=True) 20 | class URLs: 21 | list: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}/comments" 22 | post: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}/comments" 23 | 24 | 25 | class CommentService(Service): 26 | __slots__ = ( 27 | "default_form_id", 28 | "default_instance_id", 29 | "default_project_id", 30 | "session", 31 | "urls", 32 | ) 33 | 34 | def __init__( 35 | self, 36 | session: Session, 37 | default_project_id: int | None = None, 38 | default_form_id: str | None = None, 39 | default_instance_id: str | None = None, 40 | urls: URLs = None, 41 | ): 42 | self.urls: URLs = urls if urls is not None else URLs() 43 | self.session: Session = session 44 | self.default_project_id: int | None = default_project_id 45 | self.default_form_id: str | None = default_form_id 46 | self.default_instance_id: str | None = default_instance_id 47 | 48 | def list( 49 | self, 50 | form_id: str | None = None, 51 | project_id: int | None = None, 52 | instance_id: str | None = None, 53 | ) -> list[Comment]: 54 | """ 55 | Read all Comment details. 56 | 57 | :param form_id: The xmlFormId of the Form being referenced. 58 | :param project_id: The id of the project the Submissions belong to. 59 | :param instance_id: The instanceId of the Submission being referenced. 60 | """ 61 | try: 62 | pid = pv.validate_project_id(project_id, self.default_project_id) 63 | fid = pv.validate_form_id(form_id, self.default_form_id) 64 | iid = pv.validate_instance_id(instance_id, self.default_instance_id) 65 | except PyODKError as err: 66 | log.error(err, exc_info=True) 67 | raise 68 | 69 | response = self.session.response_or_error( 70 | method="GET", 71 | url=self.session.urlformat( 72 | self.urls.list, project_id=pid, form_id=fid, instance_id=iid 73 | ), 74 | logger=log, 75 | ) 76 | data = response.json() 77 | return [Comment(**r) for r in data] 78 | 79 | def post( 80 | self, 81 | comment: str, 82 | project_id: int | None = None, 83 | form_id: str | None = None, 84 | instance_id: str | None = None, 85 | ) -> Comment: 86 | """ 87 | Create a Comment. 88 | 89 | :param comment: The text of the comment. 90 | :param project_id: The id of the project this form belongs to. 91 | :param form_id: The xmlFormId of the Form being referenced. 92 | :param instance_id: The instanceId of the Submission being referenced. 93 | """ 94 | try: 95 | pid = pv.validate_project_id(project_id, self.default_project_id) 96 | fid = pv.validate_form_id(form_id, self.default_form_id) 97 | iid = pv.validate_instance_id(instance_id, self.default_instance_id) 98 | comment = pv.validate_str(comment, key="comment") 99 | json = {"body": comment} 100 | except PyODKError as err: 101 | log.error(err, exc_info=True) 102 | raise 103 | 104 | response = self.session.response_or_error( 105 | method="POST", 106 | url=self.session.urlformat( 107 | self.urls.post, project_id=pid, form_id=fid, instance_id=iid 108 | ), 109 | logger=log, 110 | json=json, 111 | ) 112 | data = response.json() 113 | return Comment(**data) 114 | -------------------------------------------------------------------------------- /pyodk/_endpoints/entity_list_properties.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | 5 | from pyodk._endpoints.bases import Model, Service 6 | from pyodk._utils import validators as pv 7 | from pyodk._utils.session import Session 8 | from pyodk.errors import PyODKError 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class EntityListProperty(Model): 14 | name: str 15 | odataName: str 16 | publishedAt: datetime 17 | forms: list[str] 18 | 19 | 20 | @dataclass(frozen=True, slots=True) 21 | class URLs: 22 | post: str = "projects/{project_id}/datasets/{entity_list_name}/properties" 23 | 24 | 25 | class EntityListPropertyService(Service): 26 | __slots__ = ( 27 | "default_entity_list_name", 28 | "default_project_id", 29 | "session", 30 | "urls", 31 | ) 32 | 33 | def __init__( 34 | self, 35 | session: Session, 36 | default_project_id: int | None = None, 37 | default_entity_list_name: str | None = None, 38 | urls: URLs = None, 39 | ): 40 | self.urls: URLs = urls if urls is not None else URLs() 41 | self.session: Session = session 42 | self.default_project_id: int | None = default_project_id 43 | self.default_entity_list_name: str | None = default_entity_list_name 44 | 45 | def create( 46 | self, 47 | name: str, 48 | entity_list_name: str | None = None, 49 | project_id: int | None = None, 50 | ) -> bool: 51 | """ 52 | Create an Entity List Property. 53 | 54 | :param name: The name of the Property. Property names follow the same rules as 55 | form field names (valid XML identifiers) and cannot use the reserved names of 56 | name or label, or begin with the reserved prefix __. 57 | :param entity_list_name: The name of the Entity List (Dataset) being referenced. 58 | :param project_id: The id of the project this Entity List belongs to. 59 | """ 60 | try: 61 | pid = pv.validate_project_id(project_id, self.default_project_id) 62 | eln = pv.validate_entity_list_name( 63 | entity_list_name, self.default_entity_list_name 64 | ) 65 | req_data = {"name": pv.validate_str(name, key="name")} 66 | except PyODKError as err: 67 | log.error(err, exc_info=True) 68 | raise 69 | 70 | response = self.session.response_or_error( 71 | method="POST", 72 | url=self.session.urlformat( 73 | self.urls.post, 74 | project_id=pid, 75 | entity_list_name=eln, 76 | ), 77 | logger=log, 78 | json=req_data, 79 | ) 80 | data = response.json() 81 | return data["success"] 82 | -------------------------------------------------------------------------------- /pyodk/_endpoints/entity_lists.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | from pyodk._endpoints.bases import Model, Service 7 | from pyodk._endpoints.entity_list_properties import ( 8 | EntityListProperty, 9 | EntityListPropertyService, 10 | ) 11 | from pyodk._utils import validators as pv 12 | from pyodk._utils.session import Session 13 | from pyodk.errors import PyODKError 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | class EntityList(Model): 19 | name: str 20 | projectId: int 21 | createdAt: datetime 22 | approvalRequired: bool 23 | properties: list[EntityListProperty] | None = None 24 | 25 | 26 | @dataclass(frozen=True, slots=True) 27 | class URLs: 28 | _entity_list = "projects/{project_id}/datasets" 29 | list: str = _entity_list 30 | post: str = _entity_list 31 | get: str = f"{_entity_list}/{{entity_list_name}}" 32 | 33 | 34 | class EntityListService(Service): 35 | """ 36 | Entity List-related functionality is accessed through `client.entity_lists`. 37 | 38 | For example: 39 | 40 | ```python 41 | from pyodk.client import Client 42 | 43 | client = Client() 44 | data = client.entity_lists.list() 45 | ``` 46 | 47 | Conceptually, an EntityList's parent object is a Project. Each Project may have 48 | multiple EntityLists. 49 | """ 50 | 51 | __slots__ = ( 52 | "_default_entity_list_name", 53 | "_default_project_id", 54 | "_property_service", 55 | "add_property", 56 | "session", 57 | "urls", 58 | ) 59 | 60 | def __init__( 61 | self, 62 | session: Session, 63 | default_project_id: int | None = None, 64 | default_entity_list_name: str | None = None, 65 | urls: URLs = None, 66 | ): 67 | self.urls: URLs = urls if urls is not None else URLs() 68 | self.session: Session = session 69 | self._property_service = EntityListPropertyService( 70 | session=self.session, 71 | default_project_id=default_project_id, 72 | default_entity_list_name=default_entity_list_name, 73 | ) 74 | self.add_property = self._property_service.create 75 | 76 | self._default_project_id: int | None = None 77 | self.default_project_id = default_project_id 78 | self._default_entity_list_name: str | None = None 79 | self.default_entity_list_name = default_entity_list_name 80 | 81 | def _default_kw(self) -> dict[str, Any]: 82 | return { 83 | "default_project_id": self.default_project_id, 84 | "default_entity_list_name": self.default_entity_list_name, 85 | } 86 | 87 | @property 88 | def default_project_id(self) -> int | None: 89 | return self._default_project_id 90 | 91 | @default_project_id.setter 92 | def default_project_id(self, v) -> None: 93 | self._default_project_id = v 94 | self._property_service.default_project_id = v 95 | 96 | @property 97 | def default_entity_list_name(self) -> str | None: 98 | return self._default_entity_list_name 99 | 100 | @default_entity_list_name.setter 101 | def default_entity_list_name(self, v) -> None: 102 | self._default_entity_list_name = v 103 | self._property_service.default_entity_list_name = v 104 | 105 | def list(self, project_id: int | None = None) -> list[EntityList]: 106 | """ 107 | Read all Entity List details. 108 | 109 | :param project_id: The id of the project the Entity List belongs to. 110 | 111 | :return: A list of the object representation of all Entity Lists' details. 112 | """ 113 | try: 114 | pid = pv.validate_project_id(project_id, self.default_project_id) 115 | except PyODKError as err: 116 | log.error(err, exc_info=True) 117 | raise 118 | 119 | response = self.session.response_or_error( 120 | method="GET", 121 | url=self.session.urlformat(self.urls.list, project_id=pid), 122 | logger=log, 123 | ) 124 | data = response.json() 125 | return [EntityList(**r) for r in data] 126 | 127 | def get( 128 | self, 129 | entity_list_name: str | None = None, 130 | project_id: int | None = None, 131 | ) -> EntityList: 132 | """ 133 | Read Entity List details. 134 | 135 | :param project_id: The id of the project the Entity List belongs to. 136 | :param entity_list_name: The name of the Entity List (Dataset) being referenced. 137 | 138 | :return: An object representation of all Entity Lists' details. 139 | """ 140 | try: 141 | pid = pv.validate_project_id(project_id, self.default_project_id) 142 | eln = pv.validate_entity_list_name( 143 | entity_list_name, self.default_entity_list_name 144 | ) 145 | except PyODKError as err: 146 | log.error(err, exc_info=True) 147 | raise 148 | 149 | response = self.session.response_or_error( 150 | method="GET", 151 | url=self.session.urlformat( 152 | self.urls.get, project_id=pid, entity_list_name=eln 153 | ), 154 | logger=log, 155 | ) 156 | data = response.json() 157 | return EntityList(**data) 158 | 159 | def create( 160 | self, 161 | approval_required: bool | None = False, 162 | entity_list_name: str | None = None, 163 | project_id: int | None = None, 164 | ) -> EntityList: 165 | """ 166 | Create an Entity List. 167 | 168 | :param approval_required: If False, create Entities as soon as Submissions are 169 | received by Central. If True, create Entities when Submissions are marked as 170 | Approved in Central. 171 | :param entity_list_name: The name of the Entity List (Dataset) being referenced. 172 | :param project_id: The id of the project this Entity List belongs to. 173 | """ 174 | try: 175 | pid = pv.validate_project_id(project_id, self.default_project_id) 176 | req_data = { 177 | "name": pv.validate_entity_list_name( 178 | entity_list_name, self.default_entity_list_name 179 | ), 180 | "approvalRequired": pv.validate_bool( 181 | approval_required, key="approval_required" 182 | ), 183 | } 184 | except PyODKError as err: 185 | log.error(err, exc_info=True) 186 | raise 187 | 188 | response = self.session.response_or_error( 189 | method="POST", 190 | url=self.session.urlformat(self.urls.post, project_id=pid), 191 | logger=log, 192 | json=req_data, 193 | ) 194 | data = response.json() 195 | return EntityList(**data) 196 | -------------------------------------------------------------------------------- /pyodk/_endpoints/form_assignments.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from pyodk._endpoints.bases import Service 5 | from pyodk._utils import validators as pv 6 | from pyodk._utils.session import Session 7 | from pyodk.errors import PyODKError 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | @dataclass(frozen=True, slots=True) 13 | class URLs: 14 | _form: str = "projects/{project_id}/forms/{form_id}" 15 | post: str = f"{_form}/assignments/{{role_id}}/{{user_id}}" 16 | 17 | 18 | class FormAssignmentService(Service): 19 | __slots__ = ("default_form_id", "default_project_id", "session", "urls") 20 | 21 | def __init__( 22 | self, 23 | session: Session, 24 | default_project_id: int | None = None, 25 | default_form_id: str | None = None, 26 | urls: URLs = None, 27 | ): 28 | self.urls: URLs = urls if urls is not None else URLs() 29 | self.session: Session = session 30 | self.default_project_id: int | None = default_project_id 31 | self.default_form_id: str | None = default_form_id 32 | 33 | def assign( 34 | self, 35 | role_id: int, 36 | user_id: int, 37 | form_id: str | None = None, 38 | project_id: int | None = None, 39 | ) -> bool: 40 | """ 41 | Assign a user to a role for a form. 42 | 43 | :param role_id: The id of the role to assign the user to. 44 | :param user_id: The id of the user to assign to the role. 45 | :param form_id: The xmlFormId of the Form being referenced. 46 | :param project_id: The id of the project this form belongs to. 47 | """ 48 | try: 49 | pid = pv.validate_project_id(project_id, self.default_project_id) 50 | fid = pv.validate_form_id(form_id, self.default_form_id) 51 | rid = pv.validate_int(role_id, key="role_id") 52 | uid = pv.validate_int(user_id, key="user_id") 53 | except PyODKError as err: 54 | log.error(err, exc_info=True) 55 | raise 56 | 57 | response = self.session.response_or_error( 58 | method="POST", 59 | url=self.session.urlformat( 60 | self.urls.post, project_id=pid, form_id=fid, role_id=rid, user_id=uid 61 | ), 62 | logger=log, 63 | ) 64 | 65 | data = response.json() 66 | return data["success"] 67 | -------------------------------------------------------------------------------- /pyodk/_endpoints/form_draft_attachments.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mimetypes 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | from os import PathLike 6 | 7 | from pyodk._endpoints.bases import Model, Service 8 | from pyodk._utils import validators as pv 9 | from pyodk._utils.session import Session 10 | from pyodk.errors import PyODKError 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | class FormAttachment(Model): 16 | name: str 17 | type: str # image | audio | video | file 18 | hash: str 19 | exists: bool # Either blobExists or dataExists is True 20 | blobExists: bool # Server has the file 21 | datasetExists: bool # File is linked to a Dataset 22 | updatedAt: datetime # When the file was created or deleted 23 | 24 | 25 | @dataclass(frozen=True, slots=True) 26 | class URLs: 27 | _form: str = "projects/{project_id}/forms/{form_id}" 28 | post: str = f"{_form}/draft/attachments/{{fname}}" 29 | 30 | 31 | class FormDraftAttachmentService(Service): 32 | __slots__ = ("default_form_id", "default_project_id", "session", "urls") 33 | 34 | def __init__( 35 | self, 36 | session: Session, 37 | default_project_id: int | None = None, 38 | default_form_id: str | None = None, 39 | urls: URLs = None, 40 | ): 41 | self.urls: URLs = urls if urls is not None else URLs() 42 | self.session: Session = session 43 | self.default_project_id: int | None = default_project_id 44 | self.default_form_id: str | None = default_form_id 45 | 46 | def upload( 47 | self, 48 | file_path: PathLike | str, 49 | file_name: str | None = None, 50 | form_id: str | None = None, 51 | project_id: int | None = None, 52 | ) -> bool: 53 | """ 54 | Upload a Form Draft Attachment. 55 | 56 | :param file_path: The path to the file to upload. 57 | :param file_name: A name for the file, otherwise the name in file_path is used. 58 | :param form_id: The xmlFormId of the Form being referenced. 59 | :param project_id: The id of the project this form belongs to. 60 | """ 61 | try: 62 | pid = pv.validate_project_id(project_id, self.default_project_id) 63 | fid = pv.validate_form_id(form_id, self.default_form_id) 64 | file_path = pv.validate_file_path(file_path) 65 | if file_name is None: 66 | file_name = pv.validate_str(file_path.name, key="file_name") 67 | guess_type, guess_encoding = mimetypes.guess_type(file_name) 68 | headers = { 69 | "Transfer-Encoding": "chunked", 70 | "Content-Type": guess_type or "application/octet-stream", 71 | } 72 | if guess_encoding: # associated compression type, if any. 73 | headers["Content-Encoding"] = guess_encoding 74 | except PyODKError as err: 75 | log.error(err, exc_info=True) 76 | raise 77 | 78 | def file_stream(): 79 | # Generator forces requests to read/send in chunks instead of all at once. 80 | with open(file_path, "rb") as f: 81 | while chunk := f.read(self.session.blocksize): 82 | yield chunk 83 | 84 | response = self.session.response_or_error( 85 | method="POST", 86 | url=self.session.urlformat( 87 | self.urls.post, 88 | project_id=pid, 89 | form_id=fid, 90 | fname=file_name, 91 | ), 92 | logger=log, 93 | headers=headers, 94 | data=file_stream(), 95 | ) 96 | data = response.json() 97 | try: 98 | # Response format prior to Central v2025.1 is constant `{"success": True}`. 99 | return data["success"] 100 | except KeyError: 101 | # Response introduced in Central v2025.1. Model details currently not used. 102 | return FormAttachment(**data).exists 103 | -------------------------------------------------------------------------------- /pyodk/_endpoints/form_drafts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from io import BytesIO 4 | from os import PathLike 5 | from zipfile import is_zipfile 6 | 7 | from pyodk._endpoints.bases import Service 8 | from pyodk._utils import validators as pv 9 | from pyodk._utils.session import Session 10 | from pyodk.errors import PyODKError 11 | 12 | log = logging.getLogger(__name__) 13 | CONTENT_TYPES = { 14 | ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 15 | ".xls": "application/vnd.ms-excel", 16 | ".xml": "application/xml", 17 | } 18 | 19 | 20 | def is_xls_file(buf: bytes) -> bool: 21 | """ 22 | Implements the Microsoft Excel (Office 97-2003) document type matcher. 23 | 24 | From h2non/filetype v1.2.0, MIT License, Copyright (c) 2016 Tomás Aparicio 25 | 26 | :param buf: buffer to match against. 27 | """ 28 | if len(buf) > 520 and buf[0:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1": 29 | if buf[512:516] == b"\xfd\xff\xff\xff" and (buf[518] == 0x00 or buf[518] == 0x02): 30 | return True 31 | if buf[512:520] == b"\x09\x08\x10\x00\x00\x06\x05\x00": 32 | return True 33 | if ( 34 | len(buf) > 2095 35 | and b"\xe2\x00\x00\x00\x5c\x00\x70\x00\x04\x00\x00Calc" in buf[1568:2095] 36 | ): 37 | return True 38 | 39 | return False 40 | 41 | 42 | def get_definition_data( 43 | definition: PathLike | str | bytes | None, 44 | ) -> (bytes, str, str | None): 45 | """ 46 | Get the form definition data from a path or bytes. 47 | 48 | :param definition: The path to the file to upload (string or PathLike), or the 49 | form definition in memory (string (XML) or bytes (XLS/XLSX)). 50 | :return: definition_data, content_type, file_path_stem (if any). 51 | """ 52 | definition_data = None 53 | content_type = None 54 | file_path_stem = None 55 | if ( 56 | isinstance(definition, str) 57 | and """http://www.w3.org/2002/xforms""" in definition[:1000] 58 | ): 59 | content_type = CONTENT_TYPES[".xml"] 60 | definition_data = definition.encode("utf-8") 61 | elif isinstance(definition, str | PathLike): 62 | file_path = pv.validate_file_path(definition) 63 | file_path_stem = file_path.stem 64 | definition_data = file_path.read_bytes() 65 | if file_path.suffix not in CONTENT_TYPES: 66 | raise PyODKError( 67 | "Parameter 'definition' file name has an unexpected file extension, " 68 | "expected one of '.xlsx', '.xls', '.xml'." 69 | ) 70 | content_type = CONTENT_TYPES[file_path.suffix] 71 | elif isinstance(definition, bytes): 72 | definition_data = definition 73 | if is_zipfile(BytesIO(definition)): 74 | content_type = CONTENT_TYPES[".xlsx"] 75 | elif is_xls_file(definition): 76 | content_type = CONTENT_TYPES[".xls"] 77 | if definition_data is None or content_type is None: 78 | raise PyODKError( 79 | "Parameter 'definition' has an unexpected file type, " 80 | "expected one of '.xlsx', '.xls', '.xml'." 81 | ) 82 | return definition_data, content_type, file_path_stem 83 | 84 | 85 | @dataclass(frozen=True, slots=True) 86 | class URLs: 87 | _form: str = "projects/{project_id}/forms/{form_id}" 88 | post: str = f"{_form}/draft" 89 | post_publish: str = f"{_form}/draft/publish" 90 | 91 | 92 | class FormDraftService(Service): 93 | __slots__ = ("default_form_id", "default_project_id", "session", "urls") 94 | 95 | def __init__( 96 | self, 97 | session: Session, 98 | default_project_id: int | None = None, 99 | default_form_id: str | None = None, 100 | urls: URLs = None, 101 | ): 102 | self.urls: URLs = urls if urls is not None else URLs() 103 | self.session: Session = session 104 | self.default_project_id: int | None = default_project_id 105 | self.default_form_id: str | None = default_form_id 106 | 107 | def _prep_form_post( 108 | self, 109 | definition: PathLike | str | bytes | None = None, 110 | ignore_warnings: bool | None = True, 111 | form_id: str | None = None, 112 | project_id: int | None = None, 113 | ) -> (str, str, dict, dict, bytes | None): 114 | """ 115 | Prepare / validate input arguments for POSTing a new form definition or version. 116 | 117 | :param definition: The path to the file to upload (string or PathLike), or the 118 | form definition in memory (string (XML) or bytes (XLS/XLSX)). 119 | :param form_id: The xmlFormId of the Form being referenced. 120 | :param project_id: The id of the project this form belongs to. 121 | :param ignore_warnings: If True, create the form if there are XLSForm warnings. 122 | :return: project_id, form_id, headers, params 123 | """ 124 | try: 125 | pid = pv.validate_project_id(project_id, self.default_project_id) 126 | headers = {} 127 | params = {} 128 | definition_data = None 129 | file_path_stem = None 130 | if definition is not None: 131 | definition_data, content_type, file_path_stem = get_definition_data( 132 | definition=definition 133 | ) 134 | headers["Content-Type"] = content_type 135 | fid = pv.validate_form_id( 136 | form_id, 137 | self.default_form_id, 138 | file_path_stem, 139 | self.session.get_xform_uuid(), 140 | ) 141 | if definition is not None: 142 | if ignore_warnings is not None: 143 | key = "ignore_warnings" 144 | params["ignoreWarnings"] = pv.validate_bool(ignore_warnings, key=key) 145 | headers["X-XlsForm-FormId-Fallback"] = self.session.urlquote(fid) 146 | except PyODKError as err: 147 | log.error(err, exc_info=True) 148 | raise 149 | 150 | return pid, fid, headers, params, definition_data 151 | 152 | def create( 153 | self, 154 | definition: PathLike | str | bytes | None = None, 155 | ignore_warnings: bool | None = True, 156 | form_id: str | None = None, 157 | project_id: int | None = None, 158 | ) -> bool: 159 | """ 160 | Create a Form Draft. 161 | 162 | :param definition: The path to the file to upload (string or PathLike), or the 163 | form definition in memory (string (XML) or bytes (XLS/XLSX)). 164 | :param form_id: The xmlFormId of the Form being referenced. 165 | :param project_id: The id of the project this form belongs to. 166 | :param ignore_warnings: If True, create the form if there are XLSForm warnings. 167 | """ 168 | pid, fid, headers, params, form_def = self._prep_form_post( 169 | definition=definition, 170 | ignore_warnings=ignore_warnings, 171 | form_id=form_id, 172 | project_id=project_id, 173 | ) 174 | response = self.session.response_or_error( 175 | method="POST", 176 | url=self.session.urlformat(self.urls.post, project_id=pid, form_id=fid), 177 | logger=log, 178 | headers=headers, 179 | params=params, 180 | data=form_def, 181 | ) 182 | data = response.json() 183 | return data["success"] 184 | 185 | def publish( 186 | self, 187 | form_id: str | None = None, 188 | project_id: int | None = None, 189 | version: str | None = None, 190 | ) -> bool: 191 | """ 192 | Publish a Form Draft. 193 | 194 | :param form_id: The xmlFormId of the Form being referenced. 195 | :param project_id: The id of the project this form belongs to. 196 | :param version: The version to be associated with the Draft once it's published. 197 | """ 198 | try: 199 | pid = pv.validate_project_id(project_id, self.default_project_id) 200 | fid = pv.validate_form_id(form_id, self.default_form_id) 201 | params = {} 202 | if version is not None: 203 | key = "version" 204 | params[key] = pv.validate_str(version, key=key) 205 | except PyODKError as err: 206 | log.error(err, exc_info=True) 207 | raise 208 | 209 | response = self.session.response_or_error( 210 | method="POST", 211 | url=self.session.urlformat( 212 | self.urls.post_publish, project_id=pid, form_id=fid 213 | ), 214 | logger=log, 215 | params=params, 216 | ) 217 | data = response.json() 218 | return data["success"] 219 | -------------------------------------------------------------------------------- /pyodk/_endpoints/forms.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Callable, Iterable 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | from os import PathLike 6 | from typing import Any 7 | 8 | from pyodk._endpoints.bases import Model, Service 9 | from pyodk._endpoints.form_draft_attachments import FormDraftAttachmentService 10 | from pyodk._endpoints.form_drafts import FormDraftService 11 | from pyodk._utils import validators as pv 12 | from pyodk._utils.session import Session 13 | from pyodk.errors import PyODKError 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | # TODO: actual response has undocumented fields: enketoOnceId, sha, sha256, draftToken 19 | 20 | 21 | class Form(Model): 22 | projectId: int 23 | xmlFormId: str 24 | version: str 25 | hash: str 26 | state: str # open, closing, closed 27 | createdAt: datetime 28 | name: str | None # Null if Central couldn't parse the XForm title, or it was blank. 29 | enketoId: str | None # Null if Enketo not being used with Central. 30 | keyId: int | None 31 | updatedAt: datetime | None 32 | publishedAt: datetime | None 33 | 34 | 35 | @dataclass(frozen=True, slots=True) 36 | class URLs: 37 | forms: str = "projects/{project_id}/forms" 38 | get: str = f"{forms}/{{form_id}}" 39 | 40 | 41 | class FormService(Service): 42 | """ 43 | Form-related functionality is accessed through `client.forms`. For example: 44 | 45 | ```python 46 | from pyodk.client import Client 47 | 48 | client = Client() 49 | forms = client.forms.list() 50 | ``` 51 | """ 52 | 53 | __slots__ = ("default_form_id", "default_project_id", "session", "urls") 54 | 55 | def __init__( 56 | self, 57 | session: Session, 58 | default_project_id: int | None = None, 59 | default_form_id: str | None = None, 60 | urls: URLs = None, 61 | ): 62 | self.urls: URLs = urls if urls is not None else URLs() 63 | self.session: Session = session 64 | self.default_project_id: int | None = default_project_id 65 | self.default_form_id: str | None = default_form_id 66 | 67 | def _default_kw(self) -> dict[str, Any]: 68 | return { 69 | "default_project_id": self.default_project_id, 70 | "default_form_id": self.default_form_id, 71 | } 72 | 73 | def list(self, project_id: int | None = None) -> list[Form]: 74 | """ 75 | Read all Form details. 76 | 77 | :param project_id: The id of the project the forms belong to. 78 | 79 | :return: A list of object representations of all Forms' metadata. 80 | """ 81 | try: 82 | pid = pv.validate_project_id(project_id, self.default_project_id) 83 | except PyODKError as err: 84 | log.error(err, exc_info=True) 85 | raise 86 | else: 87 | response = self.session.response_or_error( 88 | method="GET", 89 | url=self.session.urlformat(self.urls.forms, project_id=pid), 90 | logger=log, 91 | ) 92 | data = response.json() 93 | return [Form(**r) for r in data] 94 | 95 | def get( 96 | self, 97 | form_id: str, 98 | project_id: int | None = None, 99 | ) -> Form: 100 | """ 101 | Read Form details. 102 | 103 | :param form_id: The id of this form as given in its XForms XML definition. 104 | :param project_id: The id of the project this form belongs to. 105 | 106 | :return: An object representation of the Form's metadata. 107 | """ 108 | try: 109 | pid = pv.validate_project_id(project_id, self.default_project_id) 110 | fid = pv.validate_form_id(form_id, self.default_form_id) 111 | except PyODKError as err: 112 | log.error(err, exc_info=True) 113 | raise 114 | else: 115 | response = self.session.response_or_error( 116 | method="GET", 117 | url=self.session.urlformat(self.urls.get, project_id=pid, form_id=fid), 118 | logger=log, 119 | ) 120 | data = response.json() 121 | return Form(**data) 122 | 123 | def create( 124 | self, 125 | definition: PathLike | str | bytes, 126 | attachments: Iterable[PathLike | str] | None = None, 127 | ignore_warnings: bool | None = True, 128 | form_id: str | None = None, 129 | project_id: int | None = None, 130 | ) -> Form: 131 | """ 132 | Create a form. 133 | 134 | :param definition: The path to the file to upload (string or PathLike), or the 135 | form definition in memory (string (XML) or bytes (XLS/XLSX)). 136 | :param attachments: The paths of the form attachment file(s) to upload. 137 | :param ignore_warnings: If True, create the form if there are XLSForm warnings. 138 | :param form_id: The xmlFormId of the Form being referenced. 139 | :param project_id: The id of the project this form belongs to. 140 | :return: An object representation of the Form's metadata. 141 | """ 142 | fd = FormDraftService(session=self.session, **self._default_kw()) 143 | pid, fid, headers, params, form_def = fd._prep_form_post( 144 | definition=definition, 145 | ignore_warnings=ignore_warnings, 146 | form_id=form_id, 147 | project_id=project_id, 148 | ) 149 | 150 | # Create the new Form definition, in draft state. 151 | params["publish"] = False 152 | response = self.session.response_or_error( 153 | method="POST", 154 | url=self.session.urlformat(self.urls.forms, project_id=pid), 155 | logger=log, 156 | headers=headers, 157 | params=params, 158 | data=form_def, 159 | ) 160 | data = response.json() 161 | 162 | # In case the form_id parameter was None, use the (maybe generated) response value. 163 | form = Form(**data) 164 | fp_ids = {"form_id": form.xmlFormId, "project_id": project_id} 165 | 166 | # Upload the attachments, if any. 167 | if attachments is not None: 168 | fda = FormDraftAttachmentService(session=self.session, **self._default_kw()) 169 | for attach in attachments: 170 | if not fda.upload(file_path=attach, **fp_ids): 171 | raise PyODKError("Form create (attachment upload) failed.") 172 | 173 | # Publish the draft. 174 | if not fd.publish(**fp_ids): 175 | raise PyODKError("Form create (draft publish) failed.") 176 | 177 | return form 178 | 179 | def update( 180 | self, 181 | form_id: str, 182 | project_id: int | None = None, 183 | definition: PathLike | str | bytes | None = None, 184 | attachments: Iterable[PathLike | str] | None = None, 185 | version_updater: Callable[[str], str] | None = None, 186 | ) -> None: 187 | """ 188 | Update an existing Form. Must specify definition, attachments or both. 189 | 190 | Accepted call patterns: 191 | 192 | * form definition only 193 | * form definition with attachments 194 | * form attachments only 195 | * form attachments with `version_updater` 196 | 197 | If a definition is provided, the new version name must be specified in the 198 | definition. If no definition is provided, a default version will be set using 199 | the current datetime is ISO format. 200 | 201 | The default datetime version can be overridden by providing a `version_updater` 202 | function. The function will be passed the current version name as a string, and 203 | must return a string with the new version name. For example: 204 | 205 | * Parse then increment a version number: `version_updater=lambda v: int(v) + 1` 206 | * Disregard the input and return a string: `version_updater=lambda v: "v2.0"`. 207 | 208 | :param form_id: The xmlFormId of the Form being referenced. 209 | :param project_id: The id of the project this form belongs to. 210 | :param definition: The path to the file to upload (string or PathLike), or the 211 | form definition in memory (string (XML) or bytes (XLS/XLSX)). The form 212 | definition must include an updated version string. 213 | :param attachments: The paths of the form attachment file(s) to upload. 214 | :param version_updater: A function that accepts a version name string and returns 215 | a version name string, which is used for the new form version. Not allowed if a 216 | form definition is specified. 217 | """ 218 | if definition is None and attachments is None: 219 | raise PyODKError("Must specify a form definition and/or attachments.") 220 | 221 | if definition is not None and version_updater is not None: 222 | raise PyODKError("Must not specify both a definition and version_updater.") 223 | 224 | # Start a new draft - with a new definition, if provided. 225 | fp_ids = {"form_id": form_id, "project_id": project_id} 226 | fd = FormDraftService(session=self.session, **self._default_kw()) 227 | if not fd.create(definition=definition, **fp_ids): 228 | raise PyODKError("Form update (form draft create) failed.") 229 | 230 | # Upload the attachments, if any. 231 | if attachments is not None: 232 | fda = FormDraftAttachmentService(session=self.session, **self._default_kw()) 233 | for attach in attachments: 234 | if not fda.upload(file_path=attach, **fp_ids): 235 | raise PyODKError("Form update (attachment upload) failed.") 236 | 237 | new_version = None 238 | if definition is None: 239 | # Get a new version - using either a timestamp or the callback. 240 | if version_updater is None: 241 | new_version = datetime.now().isoformat() 242 | else: 243 | new_version = version_updater(self.get(**fp_ids).version) 244 | 245 | # Publish the draft. 246 | if not fd.publish(version=new_version, **fp_ids): 247 | raise PyODKError("Form update (draft publish) failed.") 248 | -------------------------------------------------------------------------------- /pyodk/_endpoints/project_app_users.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | 5 | from pyodk._endpoints.bases import Model, Service 6 | from pyodk._utils import validators as pv 7 | from pyodk._utils.session import Session 8 | from pyodk.errors import PyODKError 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class ProjectAppUser(Model): 14 | projectId: int 15 | id: int 16 | displayName: str 17 | createdAt: datetime 18 | type: str | None # user, field_key, public_link, singleUse 19 | token: str | None 20 | updatedAt: datetime | None 21 | deletedAt: datetime | None 22 | 23 | 24 | @dataclass(frozen=True, slots=True) 25 | class URLs: 26 | list: str = "projects/{project_id}/app-users" 27 | post: str = "projects/{project_id}/app-users" 28 | 29 | 30 | class ProjectAppUserService(Service): 31 | __slots__ = ( 32 | "default_project_id", 33 | "session", 34 | "urls", 35 | ) 36 | 37 | def __init__( 38 | self, 39 | session: Session, 40 | default_project_id: int | None = None, 41 | urls: URLs = None, 42 | ): 43 | self.urls: URLs = urls if urls is not None else URLs() 44 | self.session: Session = session 45 | self.default_project_id: int | None = default_project_id 46 | 47 | def list( 48 | self, 49 | project_id: int | None = None, 50 | ) -> list[ProjectAppUser]: 51 | """ 52 | Read all ProjectAppUser details. 53 | 54 | :param project_id: The project_id the ProjectAppUsers are assigned to. 55 | """ 56 | try: 57 | pid = pv.validate_project_id(project_id, self.default_project_id) 58 | except PyODKError as err: 59 | log.error(err, exc_info=True) 60 | raise 61 | 62 | response = self.session.response_or_error( 63 | method="GET", 64 | url=self.session.urlformat(self.urls.list, project_id=pid), 65 | logger=log, 66 | ) 67 | data = response.json() 68 | return [ProjectAppUser(**r) for r in data] 69 | 70 | def create( 71 | self, 72 | display_name: str, 73 | project_id: int | None = None, 74 | ) -> ProjectAppUser: 75 | """ 76 | Create a ProjectAppUser. 77 | 78 | :param display_name: The friendly nickname of the App User to be created. 79 | :param project_id: The project_id the ProjectAppUser should be assigned to. 80 | """ 81 | try: 82 | pid = pv.validate_project_id(project_id, self.default_project_id) 83 | display_name = pv.validate_str(display_name, key="display_name") 84 | json = {"displayName": display_name} 85 | except PyODKError as err: 86 | log.error(err, exc_info=True) 87 | raise 88 | 89 | response = self.session.response_or_error( 90 | method="POST", 91 | url=self.session.urlformat(self.urls.post, project_id=pid), 92 | logger=log, 93 | json=json, 94 | ) 95 | data = response.json() 96 | return ProjectAppUser(**data) 97 | -------------------------------------------------------------------------------- /pyodk/_endpoints/projects.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Iterable 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | from typing import Any 6 | 7 | from pyodk._endpoints.bases import Model, Service 8 | from pyodk._endpoints.form_assignments import FormAssignmentService 9 | from pyodk._endpoints.project_app_users import ProjectAppUser, ProjectAppUserService 10 | from pyodk._utils import validators as pv 11 | from pyodk._utils.session import Session 12 | from pyodk.errors import PyODKError 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | class Project(Model): 18 | id: int 19 | name: str 20 | createdAt: datetime 21 | description: str | None = None 22 | archived: bool | None = None 23 | keyId: int | None = None 24 | appUsers: int | None = None 25 | forms: int | None = None 26 | lastSubmission: str | None = None 27 | updatedAt: datetime | None = None 28 | deletedAt: datetime | None = None 29 | 30 | 31 | @dataclass(frozen=True, slots=True) 32 | class URLs: 33 | list: str = "projects" 34 | get: str = "projects/{project_id}" 35 | get_data: str = "projects/{project_id}/forms/{form_id}.svc/{table_name}" 36 | post_app_users: str = "projects/{project_id}/app-users" 37 | 38 | 39 | class ProjectService(Service): 40 | """ 41 | Project-related functionality is accessed through `client.projects`. For example: 42 | 43 | ```python 44 | from pyodk.client import Client 45 | 46 | client = Client() 47 | projects = client.projects.list() 48 | ``` 49 | """ 50 | 51 | __slots__ = ("default_project_id", "session", "urls") 52 | 53 | def __init__( 54 | self, 55 | session: Session, 56 | default_project_id: int | None = None, 57 | urls: URLs = None, 58 | ): 59 | self.urls: URLs = urls if urls is not None else URLs() 60 | self.session: Session = session 61 | self.default_project_id: int | None = default_project_id 62 | 63 | def _default_kw(self) -> dict[str, Any]: 64 | return { 65 | "default_project_id": self.default_project_id, 66 | } 67 | 68 | def list(self) -> list[Project]: 69 | """ 70 | Read Project details. 71 | 72 | :return: An list of object representations of the Projects' metadata. 73 | """ 74 | response = self.session.response_or_error( 75 | method="GET", 76 | url=self.urls.list, 77 | logger=log, 78 | ) 79 | data = response.json() 80 | return [Project(**r) for r in data] 81 | 82 | def get(self, project_id: int | None = None) -> Project: 83 | """ 84 | Read all Project details. 85 | 86 | :param project_id: The id of the project to read. 87 | 88 | :return: An object representation of the Project's metadata. 89 | """ 90 | try: 91 | pid = pv.validate_project_id(project_id, self.default_project_id) 92 | except PyODKError as err: 93 | log.error(err, exc_info=True) 94 | raise 95 | else: 96 | response = self.session.response_or_error( 97 | method="GET", 98 | url=self.session.urlformat(self.urls.get, project_id=pid), 99 | logger=log, 100 | ) 101 | data = response.json() 102 | return Project(**data) 103 | 104 | def create_app_users( 105 | self, 106 | display_names: Iterable[str], 107 | forms: Iterable[str] | None = None, 108 | project_id: int | None = None, 109 | ) -> Iterable[ProjectAppUser]: 110 | """ 111 | Create new project app users and optionally assign forms to them. 112 | 113 | :param display_names: The friendly nicknames of the app users to be created. 114 | :param forms: The xmlFormIds of the forms to assign the app users to. 115 | :param project_id: The id of the project this form belongs to. 116 | """ 117 | if display_names is None: 118 | raise PyODKError("Must specify display_names.") 119 | 120 | pid = {"project_id": project_id} 121 | pau = ProjectAppUserService(session=self.session, **self._default_kw()) 122 | fa = FormAssignmentService(session=self.session, **self._default_kw()) 123 | 124 | current = {u.displayName for u in pau.list(**pid) if u.token is not None} 125 | to_create = (user for user in display_names if user not in current) 126 | users = [pau.create(display_name=n, **pid) for n in to_create] 127 | # The "App User" role_id should always be "2", so no need to look it up by name. 128 | # Ref: "https://github.com/getodk/central-backend/blob/9db0d792cf4640ec7329722984 129 | # cebdee3687e479/lib/model/migrations/20181212-01-add-roles.js" 130 | # See also roles data in `tests/resources/projects_data.py`. 131 | if forms is not None: 132 | for user in users: 133 | for form_id in forms: 134 | if not fa.assign(role_id=2, user_id=user.id, form_id=form_id, **pid): 135 | raise PyODKError("Role assignment failed.") 136 | 137 | return users 138 | -------------------------------------------------------------------------------- /pyodk/_endpoints/submission_attachments.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mimetypes 3 | from dataclasses import dataclass 4 | from os import PathLike 5 | 6 | from pyodk._endpoints.bases import Model, Service 7 | from pyodk._utils import validators as pv 8 | from pyodk._utils.session import Session 9 | from pyodk.errors import PyODKError 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class SubmissionAttachment(Model): 15 | name: str 16 | exists: bool 17 | 18 | 19 | @dataclass(frozen=True, slots=True) 20 | class URLs: 21 | _submission: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}" 22 | list: str = f"{_submission}/attachments" 23 | post: str = f"{_submission}/attachments/{{fname}}" 24 | 25 | 26 | class SubmissionAttachmentService(Service): 27 | __slots__ = ( 28 | "default_form_id", 29 | "default_instance_id", 30 | "default_project_id", 31 | "session", 32 | "urls", 33 | ) 34 | 35 | def __init__( 36 | self, 37 | session: Session, 38 | default_project_id: int | None = None, 39 | default_form_id: str | None = None, 40 | default_instance_id: str | None = None, 41 | urls: URLs = None, 42 | ): 43 | self.urls: URLs = urls if urls is not None else URLs() 44 | self.session: Session = session 45 | self.default_project_id: int | None = default_project_id 46 | self.default_form_id: str | None = default_form_id 47 | self.default_instance_id: str | None = default_instance_id 48 | 49 | def list( 50 | self, 51 | form_id: str | None = None, 52 | project_id: int | None = None, 53 | instance_id: str | None = None, 54 | ) -> list[SubmissionAttachment]: 55 | """ 56 | Read all Submission Attachment details. 57 | 58 | :param form_id: The xmlFormId of the Form being referenced. 59 | :param project_id: The id of the project the Submissions belong to. 60 | :param instance_id: The instanceId of the Submission being referenced. 61 | """ 62 | try: 63 | pid = pv.validate_project_id(project_id, self.default_project_id) 64 | fid = pv.validate_form_id(form_id, self.default_form_id) 65 | iid = pv.validate_instance_id(instance_id, self.default_instance_id) 66 | except PyODKError as err: 67 | log.error(err, exc_info=True) 68 | raise 69 | 70 | response = self.session.response_or_error( 71 | method="GET", 72 | url=self.session.urlformat( 73 | self.urls.list, project_id=pid, form_id=fid, instance_id=iid 74 | ), 75 | logger=log, 76 | ) 77 | data = response.json() 78 | return [SubmissionAttachment(**r) for r in data] 79 | 80 | def upload( 81 | self, 82 | file_path: PathLike | str, 83 | file_name: str | None = None, 84 | project_id: int | None = None, 85 | form_id: str | None = None, 86 | instance_id: str | None = None, 87 | ) -> bool: 88 | """ 89 | Upload a Submission Attachment. 90 | 91 | :param file_path: The path to the file to upload. 92 | :param file_name: A name for the file, otherwise the name in file_path is used. 93 | :param project_id: The id of the project this form belongs to. 94 | :param form_id: The xmlFormId of the Form being referenced. 95 | :param instance_id: The instanceId of the Submission being referenced. 96 | """ 97 | try: 98 | pid = pv.validate_project_id(project_id, self.default_project_id) 99 | fid = pv.validate_form_id(form_id, self.default_form_id) 100 | iid = pv.validate_instance_id(instance_id, self.default_instance_id) 101 | file_path = pv.validate_file_path(file_path) 102 | if file_name is None: 103 | file_name = pv.validate_str(file_path.name, key="file_name") 104 | guess_type, guess_encoding = mimetypes.guess_type(file_name) 105 | headers = { 106 | "Transfer-Encoding": "chunked", 107 | "Content-Type": guess_type or "application/octet-stream", 108 | } 109 | if guess_encoding: # associated compression type, if any. 110 | headers["Content-Encoding"] = guess_encoding 111 | except PyODKError as err: 112 | log.error(err, exc_info=True) 113 | raise 114 | 115 | def file_stream(): 116 | # Generator forces requests to read/send in chunks instead of all at once. 117 | with open(file_path, "rb") as f: 118 | while chunk := f.read(self.session.blocksize): 119 | yield chunk 120 | 121 | response = self.session.response_or_error( 122 | method="POST", 123 | url=self.session.urlformat( 124 | self.urls.post, 125 | project_id=pid, 126 | form_id=fid, 127 | instance_id=iid, 128 | fname=file_name, 129 | ), 130 | logger=log, 131 | headers=headers, 132 | data=file_stream(), 133 | ) 134 | data = response.json() 135 | return data["success"] 136 | -------------------------------------------------------------------------------- /pyodk/_utils/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = () 2 | -------------------------------------------------------------------------------- /pyodk/_utils/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from dataclasses import dataclass, field 4 | from pathlib import Path 5 | 6 | import toml 7 | 8 | from pyodk.errors import PyODKError 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | defaults = { 13 | "PYODK_CONFIG_FILE": Path.home() / ".pyodk_config.toml", 14 | "PYODK_CACHE_FILE": Path.home() / ".pyodk_cache.toml", 15 | } 16 | 17 | 18 | @dataclass 19 | class CentralConfig: 20 | base_url: str 21 | username: str 22 | password: str = field(repr=False) 23 | default_project_id: int | None = None 24 | 25 | def validate(self): 26 | for key in ["base_url", "username", "password"]: # Mandatory keys. 27 | if getattr(self, key) is None or getattr(self, key) == "": 28 | err = PyODKError(f"Config value '{key}' must not be empty.") 29 | log.error(err, exc_info=True) 30 | raise err 31 | 32 | def __post_init__(self): 33 | self.validate() 34 | 35 | 36 | @dataclass 37 | class Config: 38 | central: CentralConfig 39 | 40 | 41 | def objectify_config(config_data: dict) -> Config: 42 | """ 43 | Convert a config dict into objects to validate the data. 44 | """ 45 | central = CentralConfig(**config_data["central"]) 46 | config = Config(central=central) 47 | return config 48 | 49 | 50 | def get_path(path: str, env_key: str) -> Path: 51 | """ 52 | Get a path from the path argument, the environment key, or the default. 53 | """ 54 | if path is not None: 55 | return Path(path) 56 | env_file_path = os.environ.get(env_key) 57 | if env_file_path is not None: 58 | return Path(env_file_path) 59 | return defaults[env_key] 60 | 61 | 62 | def get_config_path(config_path: str | None = None) -> Path: 63 | return get_path(path=config_path, env_key="PYODK_CONFIG_FILE") 64 | 65 | 66 | def get_cache_path(cache_path: str | None = None) -> Path: 67 | return get_path(path=cache_path, env_key="PYODK_CACHE_FILE") 68 | 69 | 70 | def read_toml(path: Path) -> dict: 71 | """ 72 | Read a toml file. 73 | """ 74 | try: 75 | with open(path) as f: 76 | return toml.load(f) 77 | except (FileNotFoundError, PermissionError) as err: 78 | pyodk_err = PyODKError(f"Could not read file at: {path}. {err!r}.") 79 | log.error(pyodk_err, exc_info=True) 80 | raise pyodk_err from err 81 | 82 | 83 | def read_config(config_path: str | None = None) -> Config: 84 | """ 85 | Read the config file. 86 | """ 87 | file_path = get_path(path=config_path, env_key="PYODK_CONFIG_FILE") 88 | file_data = read_toml(path=file_path) 89 | return objectify_config(config_data=file_data) 90 | 91 | 92 | def read_cache_token(cache_path: str | None = None) -> str: 93 | """ 94 | Read the "token" key from the cache file. 95 | """ 96 | file_path = get_cache_path(cache_path=cache_path) 97 | file_data = read_toml(path=file_path) 98 | if "token" not in file_data: 99 | err = PyODKError(f"Cached token not found in file: {file_path}") 100 | log.error(err, exc_info=True) 101 | raise err 102 | return file_data["token"] 103 | 104 | 105 | def write_cache(key: str, value: str, cache_path: str | None = None) -> None: 106 | """ 107 | Append or overwrite the given key/value pair to the cache file. 108 | """ 109 | file_path = get_cache_path(cache_path=cache_path) 110 | if file_path.exists() and file_path.is_file(): 111 | file_data = read_toml(path=file_path) 112 | file_data[key] = value 113 | else: 114 | file_data = {key: value} 115 | with open(file_path, "w") as outfile: 116 | toml.dump(file_data, outfile) 117 | 118 | 119 | def delete_cache(cache_path: str | None = None) -> None: 120 | """ 121 | Delete the cache file, if it exists. 122 | """ 123 | file_path = get_cache_path(cache_path=cache_path) 124 | file_path.unlink(missing_ok=True) 125 | -------------------------------------------------------------------------------- /pyodk/_utils/session.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | from string import Formatter 3 | from typing import Any 4 | from urllib.parse import quote, urljoin 5 | from uuid import uuid4 6 | 7 | from requests import PreparedRequest, Response 8 | from requests import Session as RequestsSession 9 | from requests.adapters import HTTPAdapter, Retry 10 | from requests.auth import AuthBase 11 | from requests.exceptions import HTTPError 12 | 13 | from pyodk.__version__ import __version__ 14 | from pyodk._endpoints.auth import AuthService 15 | from pyodk.errors import PyODKError 16 | 17 | 18 | class URLFormatter(Formatter): 19 | """ 20 | Makes a valid URL by sending each format input field through urllib.parse.quote. 21 | 22 | To parse/un-parse URLs, currently (v2023.5) Central uses JS default functions 23 | encodeURIComponent and decodeURIComponent, which comply with RFC2396. The more recent 24 | RFC3986 reserves hex characters 2A (asterisk), 27 (single quote), 28 (left 25 | parenthesis), and 29 (right parenthesis). Python 3.7+ urllib.parse complies with 26 | RFC3986 so in order for pyODK to behave as Central expects, these additional 4 27 | characters are specified as "safe" in `format_field()` to not percent-encode them. 28 | 29 | Currently (v2023.5) Central primarily supports the default submission instanceID 30 | format per the XForm spec, namely "uuid:" followed by the 36 character UUID string. 31 | In many endpoints, custom UUIDs (including non-ASCII/UTF-8 chars) will work, but in 32 | some places they won't. For example the Central page for viewing submission details 33 | fails on the Submissions OData call, because the OData function to filter by ID 34 | (`Submission('instanceId')`) only works for the default instanceID format. 35 | """ 36 | 37 | def format_field(self, value: Any, format_spec: str) -> Any: 38 | return format(quote(str(value), safe="*'()"), format_spec) 39 | 40 | 41 | _URL_FORMATTER = URLFormatter() 42 | 43 | 44 | class Adapter(HTTPAdapter): 45 | def __init__(self, *args, **kwargs): 46 | if "timeout" in kwargs: 47 | self.timeout = kwargs["timeout"] 48 | del kwargs["timeout"] 49 | if "max_retries" not in kwargs: 50 | kwargs["max_retries"] = Retry( 51 | total=3, 52 | backoff_factor=2, 53 | status_forcelist=(429, 500, 502, 503, 504), 54 | allowed_methods=("GET", "PUT", "POST", "DELETE"), 55 | ) 56 | if (blocksize := kwargs.get("blocksize")) is not None: 57 | self.blocksize = blocksize 58 | del kwargs["blocksize"] 59 | super().__init__(*args, **kwargs) 60 | 61 | def init_poolmanager(self, *args, **kwargs): 62 | if kwargs.get("blocksize") is None and hasattr(self, "blocksize"): 63 | kwargs["blocksize"] = self.blocksize 64 | super().init_poolmanager(*args, **kwargs) 65 | 66 | def send(self, request, **kwargs): 67 | timeout = kwargs.get("timeout") 68 | if timeout is None and hasattr(self, "timeout"): 69 | kwargs["timeout"] = self.timeout 70 | return super().send(request, **kwargs) 71 | 72 | 73 | class Auth(AuthBase): 74 | def __init__(self, session: "Session", username: str, password: str, cache_path: str): 75 | self.session: Session = session 76 | self.username: str = username 77 | self.password: str = password 78 | self.service: AuthService = AuthService(session=session, cache_path=cache_path) 79 | self._skip_auth_check: bool = False 80 | 81 | def login(self) -> str: 82 | """ 83 | Log in to Central (create new session or verify existing). 84 | 85 | :return: Bearer 86 | """ 87 | if "Authorization" not in self.session.headers: 88 | try: 89 | self._skip_auth_check = True # Avoid loop of death due to the below call. 90 | t = self.service.get_token(username=self.username, password=self.password) 91 | self.session.headers["Authorization"] = "Bearer " + t 92 | finally: 93 | self._skip_auth_check = False 94 | return self.session.headers["Authorization"] 95 | 96 | def __call__(self, r: PreparedRequest, *args, **kwargs): 97 | if "Authorization" not in r.headers and not self._skip_auth_check: 98 | r.headers["Authorization"] = self.login() 99 | return r 100 | 101 | 102 | class Session(RequestsSession): 103 | def __init__( 104 | self, 105 | base_url: str, 106 | api_version: str, 107 | username: str, 108 | password: str, 109 | cache_path: str, 110 | chunk_size: int = 16384, 111 | ) -> None: 112 | """ 113 | :param base_url: Scheme/domain/port parts of the URL e.g. https://www.example.com 114 | :param api_version: The Central API version (first part of the URL path). 115 | :param username: The Central user name to log in with. 116 | :param password: The Central user's password to log in with. 117 | :param cache_path: Where to read/write pyodk_cache.toml. 118 | :param chunk_size: In bytes. For transferring large files (e.g. >1MB), it may be 119 | noticeably faster to use larger chunks than the default 16384 bytes (16KB). 120 | """ 121 | super().__init__() 122 | self.base_url: str = self.base_url_validate( 123 | base_url=base_url, api_version=api_version 124 | ) 125 | self.blocksize: int = chunk_size 126 | self.mount("https://", Adapter(timeout=30, blocksize=self.blocksize)) 127 | self.headers.update({"User-Agent": f"pyodk v{__version__}"}) 128 | self.auth: Auth = Auth( 129 | session=self, username=username, password=password, cache_path=cache_path 130 | ) 131 | 132 | @staticmethod 133 | def base_url_validate(base_url: str, api_version: str): 134 | if not base_url.endswith(f"{api_version}/"): 135 | if base_url.endswith(api_version): 136 | base_url = base_url + "/" 137 | elif not base_url.endswith(api_version): 138 | base_url = base_url.rstrip("/") + f"/{api_version}/" 139 | return base_url 140 | 141 | def urljoin(self, url: str) -> str: 142 | return urljoin(self.base_url, url.lstrip("/")) 143 | 144 | @staticmethod 145 | def urlformat(url: str, *args, **kwargs) -> str: 146 | return _URL_FORMATTER.format(url, *args, **kwargs) 147 | 148 | @staticmethod 149 | def urlquote(url: str) -> str: 150 | return _URL_FORMATTER.format_field(url, format_spec="") 151 | 152 | def request(self, method, url, *args, **kwargs): 153 | return super().request(method, self.urljoin(url), *args, **kwargs) 154 | 155 | def prepare_request(self, request): 156 | request.url = self.urljoin(request.url) 157 | return super().prepare_request(request) 158 | 159 | def response_or_error( 160 | self, method: str, url: str, logger: Logger, *args, **kwargs 161 | ) -> Response: 162 | response = self.request(*args, method=method, url=url, **kwargs) 163 | try: 164 | response.raise_for_status() 165 | except HTTPError as e: 166 | msg = ( 167 | f"The request to {self.urljoin(url)} failed." 168 | f" Status: {response.status_code}, content: {response.text}" 169 | ) 170 | err = PyODKError(msg, response) 171 | logger.error(err, exc_info=True) 172 | raise err from e 173 | else: 174 | return response 175 | 176 | @staticmethod 177 | def get_xform_uuid() -> str: 178 | """ 179 | Get XForm UUID, which is "uuid:" followed by a random uuid v4. 180 | """ 181 | return f"uuid:{uuid4()}" 182 | -------------------------------------------------------------------------------- /pyodk/_utils/utils.py: -------------------------------------------------------------------------------- 1 | def coalesce(*args): 2 | return next((a for a in args if a is not None), None) 3 | -------------------------------------------------------------------------------- /pyodk/_utils/validators.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from os import PathLike 3 | from pathlib import Path 4 | from typing import Any 5 | 6 | from pydantic.v1 import validators as v 7 | from pydantic.v1.errors import PydanticTypeError, PydanticValueError 8 | 9 | from pyodk._utils.utils import coalesce 10 | from pyodk.errors import PyODKError 11 | 12 | 13 | def wrap_error(validator: Callable, key: str, value: Any) -> Any: 14 | """ 15 | Wrap the error in a PyODKError, with a nicer message. 16 | 17 | :param validator: A pydantic validator function. 18 | :param key: The variable name to use in the error message. 19 | :param value: The variable value. 20 | :return: 21 | """ 22 | try: 23 | return validator(value) 24 | except (PydanticTypeError, PydanticValueError) as err: 25 | msg = f"{key}: {err!s}" 26 | raise PyODKError(msg) from err 27 | 28 | 29 | def validate_project_id(*args: int) -> int: 30 | return wrap_error( 31 | validator=v.int_validator, 32 | key="project_id", 33 | value=coalesce(*args), 34 | ) 35 | 36 | 37 | def validate_form_id(*args: str) -> str: 38 | return wrap_error( 39 | validator=v.str_validator, 40 | key="form_id", 41 | value=coalesce(*args), 42 | ) 43 | 44 | 45 | def validate_table_name(*args: str) -> str: 46 | return wrap_error( 47 | validator=v.str_validator, 48 | key="table_name", 49 | value=coalesce(*args), 50 | ) 51 | 52 | 53 | def validate_instance_id(*args: str) -> str: 54 | return wrap_error( 55 | validator=v.str_validator, 56 | key="instance_id", 57 | value=coalesce(*args), 58 | ) 59 | 60 | 61 | def validate_entity_list_name(*args: str) -> str: 62 | return wrap_error( 63 | validator=v.str_validator, 64 | key="entity_list_name", 65 | value=coalesce(*args), 66 | ) 67 | 68 | 69 | def validate_str(*args: str, key: str) -> str: 70 | return wrap_error( 71 | validator=v.str_validator, 72 | key=key, 73 | value=coalesce(*args), 74 | ) 75 | 76 | 77 | def validate_bool(*args: bool, key: str) -> str: 78 | return wrap_error( 79 | validator=v.bool_validator, 80 | key=key, 81 | value=coalesce(*args), 82 | ) 83 | 84 | 85 | def validate_int(*args: int, key: str) -> int: 86 | return wrap_error( 87 | validator=v.int_validator, 88 | key=key, 89 | value=coalesce(*args), 90 | ) 91 | 92 | 93 | def validate_dict(*args: dict, key: str) -> int: 94 | return wrap_error( 95 | validator=v.dict_validator, 96 | key=key, 97 | value=coalesce(*args), 98 | ) 99 | 100 | 101 | def validate_file_path(*args: PathLike | str, key: str = "file_path") -> Path: 102 | def validate_fp(f): 103 | p = v.path_validator(f) 104 | return v.path_exists_validator(p) 105 | 106 | return wrap_error(validator=validate_fp, key=key, value=coalesce(*args)) 107 | 108 | 109 | def validate_is_instance(*args: Any, typ: Any, key: str): 110 | val = coalesce(*args) 111 | if not isinstance(val, typ): 112 | raise PyODKError(f"{key}: Unexpected type. Expected '{typ}'.") 113 | return val 114 | -------------------------------------------------------------------------------- /pyodk/client.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from pyodk._endpoints.comments import CommentService 4 | from pyodk._endpoints.entities import EntityService 5 | from pyodk._endpoints.entity_lists import EntityListService 6 | from pyodk._endpoints.forms import FormService 7 | from pyodk._endpoints.projects import ProjectService 8 | from pyodk._endpoints.submissions import SubmissionService 9 | from pyodk._utils import config 10 | from pyodk._utils.session import Session 11 | 12 | 13 | class Client: 14 | """ 15 | A connection to a specific ODK Central server. Manages authentication and provides 16 | access to Central functionality through methods organized by the Central resource 17 | they are most related to. 18 | 19 | :param config_path: Where to read the pyodk_config.toml. Defaults to the 20 | path in PYODK_CONFIG_FILE, then the user home directory. 21 | :param cache_path: Where to read/write pyodk_cache.toml. Defaults to the 22 | path in PYODK_CACHE_FILE, then the user home directory. 23 | :param project_id: The project ID to use for all client calls. Defaults to the 24 | "default_project_id" in pyodk_config.toml, or can be specified per call. 25 | :param session: A prepared pyodk.session.Session class instance, or an instance 26 | of a customised subclass. 27 | :param api_version: The ODK Central API version, which is used in the URL path 28 | e.g. 'v1' in 'https://www.example.com/v1/projects'. 29 | """ 30 | 31 | def __init__( 32 | self, 33 | config_path: str | None = None, 34 | cache_path: str | None = None, 35 | project_id: int | None = None, 36 | session: Session | None = None, 37 | api_version: str | None = "v1", 38 | ) -> None: 39 | self.config: config.Config = config.read_config(config_path=config_path) 40 | self._project_id: int | None = project_id 41 | if session is None: 42 | session = Session( 43 | base_url=self.config.central.base_url, 44 | api_version=api_version, 45 | username=self.config.central.username, 46 | password=self.config.central.password, 47 | cache_path=cache_path, 48 | ) 49 | self.session: Session = session 50 | 51 | # Delegate http verbs for ease of use. 52 | self.get: Callable = self.session.get 53 | self.post: Callable = self.session.post 54 | self.put: Callable = self.session.put 55 | self.patch: Callable = self.session.patch 56 | self.delete: Callable = self.session.delete 57 | 58 | # Endpoints 59 | self.projects: ProjectService = ProjectService( 60 | session=self.session, 61 | default_project_id=self.project_id, 62 | ) 63 | self.forms: FormService = FormService( 64 | session=self.session, default_project_id=self.project_id 65 | ) 66 | self.submissions: SubmissionService = SubmissionService( 67 | session=self.session, default_project_id=self.project_id 68 | ) 69 | self._comments: CommentService = CommentService( 70 | session=self.session, default_project_id=self.project_id 71 | ) 72 | self.entities: EntityService = EntityService( 73 | session=self.session, default_project_id=self.project_id 74 | ) 75 | self.entity_lists: EntityListService = EntityListService( 76 | session=self.session, default_project_id=self.project_id 77 | ) 78 | 79 | @property 80 | def project_id(self) -> int | None: 81 | if self._project_id is None: 82 | return self.config.central.default_project_id 83 | else: 84 | return self._project_id 85 | 86 | @project_id.setter 87 | def project_id(self, v: str): 88 | self._project_id = v 89 | 90 | def open(self) -> "Client": 91 | """Enter the session, and authenticate.""" 92 | self.session.__enter__() 93 | self.session.auth.login() 94 | return self 95 | 96 | def close(self, *args): 97 | """Close the session.""" 98 | self.session.__exit__(*args) 99 | 100 | def __enter__(self) -> "Client": 101 | return self.open() 102 | 103 | def __exit__(self, *args): 104 | self.close(*args) 105 | -------------------------------------------------------------------------------- /pyodk/errors.py: -------------------------------------------------------------------------------- 1 | from requests import Response 2 | 3 | 4 | class PyODKError(Exception): 5 | """An error raised by pyodk.""" 6 | 7 | def is_central_error(self, code: float | str) -> bool: 8 | """ 9 | Does the PyODK error represent a Central error with the specified code? 10 | 11 | Per central-backend/lib/util/problem.js. 12 | """ 13 | if len(self.args) >= 2 and isinstance(self.args[1], Response): 14 | err_detail = self.args[1].json() 15 | err_code = str(err_detail.get("code", "")) 16 | if err_code is not None and err_code == str(code): 17 | return True 18 | return False 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pyodk" 3 | version = "1.2.1" 4 | authors = [ 5 | {name = "github.com/getodk", email = "support@getodk.org"}, 6 | ] 7 | description = "The official Python library for ODK 🐍" 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | dependencies = [ 11 | "requests>=2.32.4,<2.33", # HTTP with Central. 12 | "toml==0.10.2", # Configuration files 13 | "pydantic>=2.6.4,<=2.11.7", # Data validation. Ensure actions verify.yml matches range. 14 | ] 15 | 16 | [project.optional-dependencies] 17 | # Install with `pip install pyodk[dev]`. 18 | dev = [ 19 | "ruff==0.12.4", # Format and lint 20 | "openpyxl==3.1.5", # Create test XLSX files 21 | "xlwt==1.3.0", # Create test XLS files 22 | ] 23 | docs = [ 24 | "mkdocs==1.6.1", 25 | "mkdocstrings==0.28.3", 26 | "mkdocstrings-python==1.16.3", 27 | "mkdocs-jupyter==0.25.1", 28 | ] 29 | 30 | [project.urls] 31 | Homepage = "https://pypi.python.org/pypi/pyodk/" 32 | Repository = "https://github.com/getodk/pyodk/" 33 | 34 | [build-system] 35 | requires = ["flit_core >=3.2,<4"] 36 | build-backend = "flit_core.buildapi" 37 | 38 | [tool.flit.module] 39 | name = "pyodk" 40 | 41 | [tool.flit.sdist] 42 | exclude = ["docs", "tests"] 43 | 44 | [tool.ruff] 45 | line-length = 90 46 | target-version = "py310" 47 | fix = true 48 | show-fixes = true 49 | output-format = "full" 50 | src = ["pyodk", "tests"] 51 | 52 | [tool.ruff.lint] 53 | # By default, ruff enables flake8's F rules, along with a subset of the E rules. 54 | select = [ 55 | "B", # flake8-bugbear 56 | "C4", # flake8-comprehensions 57 | "E", # pycodestyle error 58 | # "ERA", # eradicate (commented out code) 59 | "F", # pyflakes 60 | "I", # isort 61 | "PERF", # perflint 62 | "PIE", # flake8-pie 63 | "PL", # pylint 64 | # "PTH", # flake8-use-pathlib 65 | "PYI", # flake8-pyi 66 | # "RET", # flake8-return 67 | "RUF", # ruff-specific rules 68 | "S", # flake8-bandit 69 | # "SIM", # flake8-simplify 70 | "TRY", # tryceratops 71 | "UP", # pyupgrade 72 | "W", # pycodestyle warning 73 | ] 74 | ignore = [ 75 | "E501", # line-too-long (we have a lot of long strings) 76 | "F821", # undefined-name (doesn't work well with type hints, ruff 0.1.11). 77 | "PERF401", # manual-list-comprehension (false positives on selective transforms) 78 | "PERF402", # manual-list-copy (false positives on selective transforms) 79 | "PLR0911", # too-many-return-statements (complexity not useful to warn every time) 80 | "PLR0912", # too-many-branches (complexity not useful to warn every time) 81 | "PLR0913", # too-many-arguments (complexity not useful to warn every time) 82 | "PLR0915", # too-many-statements (complexity not useful to warn every time) 83 | "PLR2004", # magic-value-comparison (many tests expect certain numbers of things) 84 | "PLW2901", # redefined-loop-name (usually not a bug) 85 | "RUF001", # ambiguous-unicode-character-string (false positives on unicode tests) 86 | "S310", # suspicious-url-open-usage (prone to false positives, ruff 0.1.11) 87 | "S603", # subprocess-without-shell-equals-true (prone to false positives, ruff 0.1.11) 88 | "TRY003", # raise-vanilla-args (reasonable lint but would require large refactor) 89 | ] 90 | # per-file-ignores = {"tests/*" = ["E501"]} 91 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.12 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/tests/__init__.py -------------------------------------------------------------------------------- /tests/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/tests/endpoints/__init__.py -------------------------------------------------------------------------------- /tests/endpoints/test_auth.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch 3 | 4 | from pyodk._endpoints.auth import AuthService 5 | from pyodk._utils import config 6 | from pyodk.client import Client 7 | from pyodk.errors import PyODKError 8 | from requests import Session 9 | 10 | from tests.resources import CONFIG_DATA 11 | from tests.utils.utils import get_temp_dir 12 | 13 | 14 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) 15 | class TestAuth(TestCase): 16 | """Test login.""" 17 | 18 | def test_get_new_token__ok(self): 19 | """Should return the token from the response data.""" 20 | with patch.object(Session, "post") as mock_session: 21 | mock_session.return_value.status_code = 200 22 | mock_session.return_value.json.return_value = {"token": "here"} 23 | conf = config.read_config().central 24 | client = Client() 25 | with client.session as s: 26 | token = s.auth.service.get_new_token(conf.username, conf.password) 27 | self.assertEqual("here", token) 28 | 29 | def test_get_new_token__error__response_status(self): 30 | """Should raise an error if login request is not OK (HTTP 200).""" 31 | with patch.object(Session, "post") as mock_session: 32 | mock_session.return_value.status_code = 404 33 | conf = config.read_config().central 34 | client = Client() 35 | with client.session as s, self.assertRaises(PyODKError) as err: 36 | s.auth.service.get_new_token(conf.username, conf.password) 37 | msg = "The login request failed. Status:" 38 | self.assertTrue(err.exception.args[0].startswith(msg)) 39 | 40 | def test_get_new_token__error__response_data(self): 41 | """Should raise an error if login token not found in response data.""" 42 | with patch.object(Session, "post") as mock_session: 43 | mock_session.return_value.status_code = 200 44 | mock_session.return_value.json.return_value = {"not": "here"} 45 | conf = config.read_config().central 46 | client = Client() 47 | with client.session as s, self.assertRaises(PyODKError) as err: 48 | s.auth.service.get_new_token(conf.username, conf.password) 49 | msg = "The login request was OK but there was no token in the response." 50 | self.assertTrue(err.exception.args[0].startswith(msg)) 51 | 52 | def test_verify_token__ok(self): 53 | """Should return the token.""" 54 | with patch.object(Session, "get") as mock_session: 55 | mock_session.return_value.status_code = 200 56 | client = Client() 57 | with client.session as s: 58 | token = s.auth.service.verify_token(token="123") # noqa: S106 59 | self.assertEqual("123", token) 60 | 61 | def test_verify_token__error__response_status(self): 62 | """Should raise an error if the request is not OK (HTTP 200).""" 63 | with patch.object(Session, "get") as mock_session: 64 | mock_session.return_value.status_code = 401 65 | client = Client() 66 | with client.session as s, self.assertRaises(PyODKError) as err: 67 | s.auth.service.verify_token(token="123") # noqa: S106 68 | msg = "The token verification request failed. Status:" 69 | self.assertTrue(err.exception.args[0].startswith(msg)) 70 | 71 | def test_get_token__ok__new_cache(self): 72 | """Should return the token, and write it to the cache file.""" 73 | with ( 74 | patch.multiple( 75 | AuthService, 76 | get_new_token=MagicMock(return_value="123"), 77 | ), 78 | get_temp_dir() as tmp, 79 | ): 80 | cache_path = (tmp / "test_cache.toml").as_posix() 81 | client = Client(cache_path=cache_path) 82 | token = client.session.auth.service.get_token( 83 | username="user", 84 | password="pass", # noqa: S106 85 | ) 86 | self.assertEqual("123", token) 87 | cache = config.read_cache_token(cache_path=cache_path) 88 | self.assertEqual("123", cache) 89 | 90 | def test_get_token__error__new_cache_bad_response(self): 91 | """Should raise an error, when no existing token and new token request fails.""" 92 | verify_mock = MagicMock() 93 | verify_mock.side_effect = PyODKError("The token verification request failed.") 94 | get_new_mock = MagicMock() 95 | get_new_mock.side_effect = PyODKError("The login request failed.") 96 | with ( 97 | patch.multiple( 98 | AuthService, 99 | verify_token=verify_mock, 100 | get_new_token=get_new_mock, 101 | ), 102 | get_temp_dir() as tmp, 103 | self.assertRaises(PyODKError) as err, 104 | ): 105 | cache_path = tmp / "test_cache.toml" 106 | client = Client(cache_path=cache_path.as_posix()) 107 | client.session.auth.service.get_token(username="user", password="pass") # noqa: S106 108 | self.assertFalse(cache_path.exists()) 109 | self.assertTrue(err.exception.args[0].startswith("The login request failed.")) 110 | 111 | def test_get_token__ok__existing_cache(self): 112 | """Should return the token from the cache file.""" 113 | with ( 114 | patch.multiple( 115 | AuthService, 116 | verify_token=MagicMock(return_value="123"), 117 | ), 118 | get_temp_dir() as tmp, 119 | ): 120 | cache_path = (tmp / "test_cache.toml").as_posix() 121 | client = Client(cache_path=cache_path) 122 | config.write_cache("token", "123", cache_path=cache_path) 123 | token = client.session.auth.service.get_token( 124 | username="user", 125 | password="pass", # noqa: S106 126 | ) 127 | self.assertEqual("123", token) 128 | cache = config.read_cache_token(cache_path=cache_path) 129 | self.assertEqual("123", cache) 130 | 131 | def test_get_token__error__existing_cache_bad_response(self): 132 | """Should get a new token, when verification of an existing token fails.""" 133 | verify_mock = MagicMock() 134 | verify_mock.side_effect = PyODKError("The token verification request failed.") 135 | with ( 136 | patch.multiple( 137 | AuthService, 138 | verify_token=verify_mock, 139 | get_new_token=MagicMock(return_value="123"), 140 | ), 141 | get_temp_dir() as tmp, 142 | ): 143 | cache_path = (tmp / "test_cache.toml").as_posix() 144 | client = Client(cache_path=cache_path) 145 | config.write_cache("token", "123", cache_path=cache_path) 146 | token = client.session.auth.service.get_token( 147 | username="user", 148 | password="pass", # noqa: S106 149 | ) 150 | self.assertEqual("123", token) 151 | cache = config.read_cache_token(cache_path=cache_path) 152 | self.assertEqual("123", cache) 153 | self.assertEqual(1, verify_mock.call_count) 154 | -------------------------------------------------------------------------------- /tests/endpoints/test_comments.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch 3 | 4 | from pyodk._endpoints.comments import Comment 5 | from pyodk._utils.session import Session 6 | from pyodk.client import Client 7 | 8 | from tests.resources import CONFIG_DATA, comments_data 9 | 10 | 11 | @patch("pyodk._utils.session.Auth.login", MagicMock()) 12 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) 13 | class TestComments(TestCase): 14 | def test_list__ok(self): 15 | """Should return a list of Comment objects.""" 16 | fixture = comments_data.test_comments 17 | with patch.object(Session, "request") as mock_session: 18 | mock_session.return_value.status_code = 200 19 | mock_session.return_value.json.return_value = fixture["response_data"] 20 | with Client() as client: 21 | observed = client._comments.list( 22 | form_id=fixture["form_id"], 23 | instance_id=fixture["instance_id"], 24 | ) 25 | self.assertEqual(4, len(observed)) 26 | for i, o in enumerate(observed): 27 | with self.subTest(i): 28 | self.assertIsInstance(o, Comment) 29 | 30 | def test_post__ok(self): 31 | """Should return a Comment object.""" 32 | fixture = comments_data.test_comments 33 | with patch.object(Session, "request") as mock_session: 34 | mock_session.return_value.status_code = 200 35 | mock_session.return_value.json.return_value = fixture["response_data"][0] 36 | with Client() as client: 37 | # Specify project 38 | observed = client._comments.post( 39 | project_id=fixture["project_id"], 40 | form_id=fixture["form_id"], 41 | instance_id=fixture["instance_id"], 42 | comment="Looks good", 43 | ) 44 | self.assertIsInstance(observed, Comment) 45 | # Use default 46 | observed = client._comments.post( 47 | form_id=fixture["form_id"], 48 | instance_id=fixture["instance_id"], 49 | comment="Looks good", 50 | ) 51 | self.assertIsInstance(observed, Comment) 52 | -------------------------------------------------------------------------------- /tests/endpoints/test_entity_lists.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch 3 | 4 | from pyodk._endpoints.entity_lists import EntityList 5 | from pyodk._utils.session import Session 6 | from pyodk.client import Client 7 | 8 | from tests.resources import CONFIG_DATA, entity_lists_data 9 | 10 | 11 | @patch("pyodk._utils.session.Auth.login", MagicMock()) 12 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) 13 | class TestEntityLists(TestCase): 14 | def test_list__ok(self): 15 | """Should return a list of EntityList objects.""" 16 | fixture = entity_lists_data.test_entity_lists 17 | with patch.object(Session, "request") as mock_session: 18 | mock_session.return_value.status_code = 200 19 | mock_session.return_value.json.return_value = fixture 20 | with Client() as client: 21 | observed = client.entity_lists.list() 22 | self.assertEqual(3, len(observed)) 23 | for i, o in enumerate(observed): 24 | with self.subTest(i): 25 | self.assertIsInstance(o, EntityList) 26 | 27 | def test_get__ok(self): 28 | """Should return an EntityList object.""" 29 | fixture = entity_lists_data.test_entity_lists[2] 30 | with patch.object(Session, "request") as mock_session: 31 | mock_session.return_value.status_code = 200 32 | mock_session.return_value.json.return_value = fixture 33 | with Client() as client: 34 | observed = client.entity_lists.get(entity_list_name="pyodk_test_eln") 35 | self.assertIsInstance(observed, EntityList) 36 | 37 | def test_create__ok(self): 38 | """Should return an EntityList object.""" 39 | fixture = entity_lists_data.test_entity_lists 40 | with patch.object(Session, "request") as mock_session: 41 | mock_session.return_value.status_code = 200 42 | mock_session.return_value.json.return_value = fixture[0] 43 | with Client() as client: 44 | # Specify project 45 | observed = client.entity_lists.create( 46 | project_id=2, 47 | entity_list_name="test", 48 | approval_required=False, 49 | ) 50 | self.assertIsInstance(observed, EntityList) 51 | # Use default 52 | client.entity_lists.default_entity_list_name = "test" 53 | client.entity_lists.default_project_id = 2 54 | observed = client.entity_lists.create() 55 | self.assertIsInstance(observed, EntityList) 56 | -------------------------------------------------------------------------------- /tests/endpoints/test_projects.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass 3 | from functools import wraps 4 | from unittest import TestCase 5 | from unittest.mock import MagicMock, patch 6 | 7 | from pyodk._endpoints.form_assignments import FormAssignmentService 8 | from pyodk._endpoints.project_app_users import ProjectAppUser, ProjectAppUserService 9 | from pyodk._endpoints.projects import Project 10 | from pyodk.client import Client 11 | from requests import Session 12 | 13 | from tests.resources import CONFIG_DATA, projects_data 14 | 15 | PROJECT_APP_USERS = [ 16 | ProjectAppUser(**d) for d in projects_data.project_app_users["response_data"] 17 | ] 18 | 19 | 20 | @dataclass 21 | class MockContext: 22 | fa_assign: MagicMock 23 | pau_list: MagicMock 24 | pau_create: MagicMock 25 | 26 | 27 | def get_mock_context(func) -> Callable: 28 | """ 29 | Inject a context object with mocks for testing projects. 30 | 31 | To use, add a keyword argument "ctx" to the decorated function. 32 | """ 33 | 34 | @wraps(func) 35 | def patched(*args, **kwargs): 36 | with ( 37 | patch.object(FormAssignmentService, "assign", return_value=True) as fa_assign, 38 | patch.object( 39 | ProjectAppUserService, "list", return_value=PROJECT_APP_USERS 40 | ) as pau_list, 41 | patch.object( 42 | ProjectAppUserService, "create", return_value=True 43 | ) as pau_create, 44 | ): 45 | ctx = MockContext( 46 | fa_assign=fa_assign, 47 | pau_list=pau_list, 48 | pau_create=pau_create, 49 | ) 50 | kwargs.update({"ctx": ctx}) 51 | return func(*args, **kwargs) 52 | 53 | return patched 54 | 55 | 56 | @patch("pyodk._utils.session.Auth.login", MagicMock()) 57 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) 58 | class TestProjects(TestCase): 59 | """Tests for `client.project`.""" 60 | 61 | def test_list__ok(self): 62 | """Should return a list of ProjectType objects.""" 63 | fixture = projects_data.test_projects 64 | with patch.object(Session, "request") as mock_session: 65 | mock_session.return_value.status_code = 200 66 | mock_session.return_value.json.return_value = fixture["response_data"] 67 | client = Client() 68 | observed = client.projects.list() 69 | self.assertEqual(2, len(observed)) 70 | for i, o in enumerate(observed): 71 | with self.subTest(i): 72 | self.assertIsInstance(o, Project) 73 | 74 | def test_get__ok(self): 75 | """Should return a ProjectType object.""" 76 | fixture = projects_data.test_projects 77 | with patch.object(Session, "request") as mock_session: 78 | mock_session.return_value.status_code = 200 79 | mock_session.return_value.json.return_value = fixture["response_data"][0] 80 | client = Client() 81 | # Specify project 82 | observed = client.projects.get(project_id=fixture["response_data"][0]["id"]) 83 | self.assertIsInstance(observed, Project) 84 | # Use default 85 | observed = client.projects.get() 86 | self.assertIsInstance(observed, Project) 87 | 88 | 89 | @patch("pyodk._utils.session.Auth.login", MagicMock()) 90 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) 91 | class TestProjectCreateAppUsers(TestCase): 92 | """Test for `client.project.create_app_users`.""" 93 | 94 | @get_mock_context 95 | def test_names_only__list_create__no_existing_users(self, ctx: MockContext): 96 | """Should call pau.list, pau.create, not fa.assign (no forms specified).""" 97 | client = Client() 98 | unames = [u.displayName for u in PROJECT_APP_USERS] 99 | ctx.pau_list.return_value = [] 100 | ctx.pau_create.return_value = PROJECT_APP_USERS[1] 101 | client.projects.create_app_users(display_names=unames) 102 | ctx.pau_list.assert_called_once_with(project_id=None) 103 | self.assertEqual(2, ctx.pau_create.call_count) 104 | ctx.pau_create.assert_any_call(display_name=unames[0], project_id=None) 105 | ctx.pau_create.assert_any_call(display_name=unames[1], project_id=None) 106 | ctx.fa_assign.assert_not_called() 107 | 108 | @get_mock_context 109 | def test_names_only__list_create__existing_user(self, ctx: MockContext): 110 | """Should call pau.create only for the user that doesn't exist.""" 111 | client = Client() 112 | unames = [u.displayName for u in PROJECT_APP_USERS] 113 | client.projects.create_app_users(display_names=unames) 114 | ctx.pau_create.assert_called_once_with(display_name=unames[1], project_id=None) 115 | 116 | @get_mock_context 117 | def test_names_forms__list_create_assign(self, ctx: MockContext): 118 | """Should call pau.list, pau.create, fa.assign.""" 119 | client = Client() 120 | unames = [u.displayName for u in PROJECT_APP_USERS] 121 | new_user = PROJECT_APP_USERS[1] 122 | forms = ["form1", "form2"] 123 | ctx.pau_create.return_value = new_user 124 | client.projects.create_app_users(display_names=unames, forms=forms) 125 | ctx.pau_list.assert_called_once_with(project_id=None) 126 | ctx.pau_create.assert_called_once_with(display_name=unames[1], project_id=None) 127 | self.assertEqual(2, ctx.fa_assign.call_count) 128 | ctx.fa_assign.assert_any_call( 129 | role_id=2, 130 | user_id=new_user.id, 131 | form_id=forms[0], 132 | project_id=None, 133 | ) 134 | ctx.fa_assign.assert_any_call( 135 | role_id=2, 136 | user_id=new_user.id, 137 | form_id=forms[1], 138 | project_id=None, 139 | ) 140 | 141 | 142 | @patch("pyodk._utils.session.Auth.login", MagicMock()) 143 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) 144 | class TestProjectAppUsers(TestCase): 145 | def test_list__ok(self): 146 | """Should return a list of ProjectAppUser objects.""" 147 | fixture = projects_data.project_app_users 148 | with patch.object(Session, "request") as mock_session: 149 | mock_session.return_value.status_code = 200 150 | mock_session.return_value.json.return_value = fixture["response_data"] 151 | client = Client() 152 | observed = ProjectAppUserService(session=client.session).list(project_id=1) 153 | self.assertEqual(2, len(observed)) 154 | for i, o in enumerate(observed): 155 | with self.subTest(i): 156 | self.assertIsInstance(o, ProjectAppUser) 157 | 158 | def test_create__ok(self): 159 | """Should return a ProjectAppUser object.""" 160 | fixture = projects_data.project_app_users 161 | with patch.object(Session, "request") as mock_session: 162 | mock_session.return_value.status_code = 200 163 | mock_session.return_value.json.return_value = fixture["response_data"][0] 164 | client = Client() 165 | pau = ProjectAppUserService(session=client.session) 166 | observed = pau.create( 167 | display_name=fixture["response_data"][0]["displayName"], 168 | project_id=fixture["project_id"], 169 | ) 170 | self.assertIsInstance(observed, ProjectAppUser) 171 | -------------------------------------------------------------------------------- /tests/endpoints/test_submissions.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass 3 | from functools import wraps 4 | from unittest import TestCase 5 | from unittest.mock import MagicMock, patch 6 | 7 | from pyodk._endpoints.submission_attachments import SubmissionAttachmentService 8 | from pyodk._endpoints.submissions import Submission 9 | from pyodk._utils.session import Session 10 | from pyodk.client import Client 11 | 12 | from tests.resources import CONFIG_DATA, submissions_data 13 | 14 | 15 | @dataclass 16 | class MockContext: 17 | sa_list: MagicMock 18 | sa_upload: MagicMock 19 | 20 | 21 | def get_mock_context(func) -> Callable: 22 | """ 23 | Inject a context object with mocks for testing related services. 24 | 25 | To use, add a keyword argument "ctx" to the decorated function. 26 | """ 27 | 28 | @wraps(func) 29 | def patched(*args, **kwargs): 30 | with ( 31 | patch.object(SubmissionAttachmentService, "list", return_value=[]) as sa_list, 32 | patch.object( 33 | SubmissionAttachmentService, "upload", return_value=True 34 | ) as sa_upload, 35 | ): 36 | ctx = MockContext(sa_list=sa_list, sa_upload=sa_upload) 37 | kwargs.update({"ctx": ctx}) 38 | return func(*args, **kwargs) 39 | 40 | return patched 41 | 42 | 43 | @patch("pyodk._utils.session.Auth.login", MagicMock()) 44 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) 45 | class TestSubmissions(TestCase): 46 | def test_list__ok(self): 47 | """Should return a list of Submission objects.""" 48 | fixture = submissions_data.test_submissions 49 | with patch.object(Session, "request") as mock_session: 50 | mock_session.return_value.status_code = 200 51 | mock_session.return_value.json.return_value = fixture["response_data"] 52 | with Client() as client: 53 | observed = client.submissions.list(form_id="range") 54 | self.assertEqual(4, len(observed)) 55 | for i, o in enumerate(observed): 56 | with self.subTest(i): 57 | self.assertIsInstance(o, Submission) 58 | 59 | def test_get__ok(self): 60 | """Should return a Submission object.""" 61 | fixture = submissions_data.test_submissions 62 | with patch.object(Session, "request") as mock_session: 63 | mock_session.return_value.status_code = 200 64 | mock_session.return_value.json.return_value = fixture["response_data"][0] 65 | with Client() as client: 66 | # Specify project 67 | observed = client.submissions.get( 68 | project_id=fixture["project_id"], 69 | form_id=fixture["form_id"], 70 | instance_id=fixture["response_data"][0]["instanceId"], 71 | ) 72 | self.assertIsInstance(observed, Submission) 73 | # Use default 74 | observed = client.submissions.get( 75 | form_id=fixture["form_id"], 76 | instance_id=fixture["response_data"][0]["instanceId"], 77 | ) 78 | self.assertIsInstance(observed, Submission) 79 | 80 | @get_mock_context 81 | def test_create__ok(self, ctx: MockContext): 82 | """Should return a Submission object.""" 83 | fixture = submissions_data.test_submissions 84 | with patch.object(Session, "request") as mock_session: 85 | mock_session.return_value.status_code = 200 86 | mock_session.return_value.json.return_value = fixture["response_data"][0] 87 | with Client() as client: 88 | # Specify project 89 | observed = client.submissions.create( 90 | project_id=fixture["project_id"], 91 | form_id=fixture["form_id"], 92 | xml=submissions_data.test_xml, 93 | ) 94 | self.assertIsInstance(observed, Submission) 95 | # Use default 96 | observed = client.submissions.create( 97 | form_id=fixture["form_id"], 98 | xml=submissions_data.test_xml, 99 | ) 100 | self.assertIsInstance(observed, Submission) 101 | 102 | @get_mock_context 103 | def test_create__with_attachments__ok(self, ctx: MockContext): 104 | """Should return a Submission object, and call the attachments service.""" 105 | fixture = submissions_data.test_submissions 106 | with patch.object(Session, "request") as mock_session: 107 | mock_session.return_value.status_code = 200 108 | mock_session.return_value.json.return_value = fixture["response_data"][1] 109 | with Client() as client: 110 | observed = client.submissions.create( 111 | project_id=fixture["project_id"], 112 | form_id=fixture["form_id"], 113 | xml=submissions_data.upload_file_xml.format( 114 | iid=fixture["response_data"][1]["instanceId"], 115 | file_name="a.jpg", 116 | ), 117 | attachments=["/some/path/a.jpg"], 118 | ) 119 | self.assertIsInstance(observed, Submission) 120 | self.assertEqual(1, ctx.sa_upload.call_count) 121 | self.assertEqual(1, ctx.sa_list.call_count) 122 | 123 | def test__put__ok(self): 124 | """Should return a Submission object.""" 125 | fixture = submissions_data.test_submissions 126 | with patch.object(Session, "request") as mock_session: 127 | mock_session.return_value.status_code = 200 128 | mock_session.return_value.json.return_value = fixture["response_data"][0] 129 | with Client() as client: 130 | # Specify project 131 | observed = client.submissions._put( 132 | project_id=fixture["project_id"], 133 | form_id=fixture["form_id"], 134 | instance_id=fixture["response_data"][0]["instanceId"], 135 | xml=submissions_data.test_xml, 136 | ) 137 | self.assertIsInstance(observed, Submission) 138 | # Use default 139 | observed = client.submissions._put( 140 | form_id=fixture["form_id"], 141 | instance_id=fixture["response_data"][0]["instanceId"], 142 | xml=submissions_data.test_xml, 143 | ) 144 | self.assertIsInstance(observed, Submission) 145 | 146 | def test_review__ok(self): 147 | """Should return a Submission object.""" 148 | fixture = submissions_data.test_submissions 149 | with patch.object(Session, "request") as mock_session: 150 | mock_session.return_value.status_code = 200 151 | mock_session.return_value.json.return_value = fixture["response_data"][0] 152 | with Client() as client: 153 | # Specify project 154 | observed = client.submissions._patch( 155 | project_id=fixture["project_id"], 156 | form_id=fixture["form_id"], 157 | instance_id=fixture["response_data"][0]["instanceId"], 158 | review_state="edited", 159 | ) 160 | self.assertIsInstance(observed, Submission) 161 | # Use default 162 | observed = client.submissions._patch( 163 | form_id=fixture["form_id"], 164 | instance_id=fixture["response_data"][0]["instanceId"], 165 | review_state="edited", 166 | ) 167 | self.assertIsInstance(observed, Submission) 168 | -------------------------------------------------------------------------------- /tests/resources/.pyodk_cache.toml: -------------------------------------------------------------------------------- 1 | token = "1234abcd" 2 | -------------------------------------------------------------------------------- /tests/resources/.pyodk_config.toml: -------------------------------------------------------------------------------- 1 | [central] 2 | base_url = "https://example.com" 3 | username = "user@example.com" 4 | password = "SomePassword" 5 | default_project_id = 1 6 | -------------------------------------------------------------------------------- /tests/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pyodk._utils import config 4 | 5 | from tests.resources import ( 6 | forms_data, # noqa: F401 7 | projects_data, # noqa: F401 8 | ) 9 | 10 | RESOURCES = Path.absolute(Path(__file__)).parent 11 | 12 | CONFIG_FILE = RESOURCES / ".pyodk_config.toml" 13 | CONFIG_DATA = config.read_config(config_path=CONFIG_FILE.as_posix()) 14 | 15 | CACHE_FILE = RESOURCES / ".pyodk_cache.toml" 16 | CACHE_DATA = config.read_cache_token(cache_path=CACHE_FILE.as_posix()) 17 | -------------------------------------------------------------------------------- /tests/resources/comments_data.py: -------------------------------------------------------------------------------- 1 | test_comments = { 2 | "project_id": 51, 3 | "form_id": "range", 4 | "instance_id": "uuid:85cb9aff-005e-4edd-9739-dc9c1a829c47", 5 | "response_data": [ 6 | {"body": "Test", "actorId": 650, "createdAt": "2022-10-06T09:08:07.722Z"}, 7 | {"body": "Looks good", "actorId": 650, "createdAt": "2022-10-06T09:01:43.868Z"}, 8 | {"body": "Looks good", "actorId": 650, "createdAt": "2022-10-06T09:00:41.433Z"}, 9 | {"body": "Looks good", "actorId": 650, "createdAt": "2022-10-06T09:00:10.638Z"}, 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /tests/resources/entities_data.py: -------------------------------------------------------------------------------- 1 | test_entities = [ 2 | { 3 | "uuid": "uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44", 4 | "createdAt": "2018-01-19T23:58:03.395Z", 5 | "updatedAt": "2018-03-21T12:45:02.312Z", 6 | "deletedAt": "2018-03-21T12:45:02.312Z", 7 | "creatorId": 1, 8 | "currentVersion": { 9 | "label": "John (88)", 10 | "current": True, 11 | "createdAt": "2018-03-21T12:45:02.312Z", 12 | "creatorId": 1, 13 | "userAgent": "Enketo/3.0.4", 14 | "version": 1, 15 | "baseVersion": None, 16 | "conflictingProperties": None, 17 | "data": {"firstName": "John", "age": "88"}, 18 | }, 19 | }, 20 | { 21 | "uuid": "uuid:85cb9aff-005e-4edd-9739-dc9c1a829c45", 22 | "createdAt": "2018-01-19T23:58:03.395Z", 23 | "updatedAt": "2018-03-21T12:45:02.312Z", 24 | "deletedAt": "2018-03-21T12:45:02.312Z", 25 | "creatorId": 1, 26 | "conflict": "soft", 27 | "currentVersion": { 28 | "label": "John (89)", 29 | "current": True, 30 | "createdAt": "2018-03-21T12:45:02.312Z", 31 | "creatorId": 1, 32 | "userAgent": "Enketo/3.0.4", 33 | "version": 2, 34 | "baseVersion": 1, 35 | "conflictingProperties": None, 36 | "data": {"firstName": "John", "age": "88"}, 37 | }, 38 | }, 39 | ] 40 | test_entities_data = { 41 | "firstName": "John", 42 | "age": "88", 43 | } 44 | -------------------------------------------------------------------------------- /tests/resources/entity_lists_data.py: -------------------------------------------------------------------------------- 1 | test_entity_lists = [ 2 | { 3 | "name": "people", 4 | "createdAt": "2018-01-19T23:58:03.395Z", 5 | "projectId": 1, 6 | "approvalRequired": True, 7 | }, 8 | { 9 | "name": "places", 10 | "createdAt": "2018-01-19T23:58:03.396Z", 11 | "projectId": 1, 12 | "approvalRequired": False, 13 | }, 14 | { 15 | "name": "pyodk_test_eln", 16 | "createdAt": "2024-04-17T13:16:32.960Z", 17 | "projectId": 1, 18 | "approvalRequired": False, 19 | "entities": 5, 20 | "lastEntity": "2024-05-22T05:52:02.868Z", 21 | "conflicts": 0, 22 | "linkedForms": [], 23 | "sourceForms": [], 24 | "properties": [ 25 | { 26 | "name": "test_label", 27 | "publishedAt": "2024-04-17T13:16:33.172Z", 28 | "odataName": "test_label", 29 | "forms": [], 30 | }, 31 | { 32 | "name": "another_prop", 33 | "publishedAt": "2024-04-17T13:16:33.383Z", 34 | "odataName": "another_prop", 35 | "forms": [], 36 | }, 37 | { 38 | "name": "third_property", 39 | "publishedAt": "2024-05-22T05:46:29.578Z", 40 | "odataName": "third_property", 41 | "forms": [], 42 | }, 43 | ], 44 | }, 45 | ] 46 | -------------------------------------------------------------------------------- /tests/resources/forms/fruits.csv: -------------------------------------------------------------------------------- 1 | name_key,name 2 | mango,Mangoe 3 | orange,Orange 4 | banana,Banana 5 | papaya,Papaya 6 | -------------------------------------------------------------------------------- /tests/resources/forms/range_draft.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | range_draft 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 24 | 26 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /tests/resources/forms_data.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from pathlib import Path 3 | 4 | test_forms = { 5 | "project_id": 8, 6 | "response_data": [ 7 | { 8 | "projectId": 8, 9 | "xmlFormId": "dash-in-last-saved", 10 | "state": "open", 11 | "enketoId": "48Yd04BkdsshBUeux3m2y4Eea0KbYqB", 12 | "enketoOnceId": None, 13 | "createdAt": "2021-04-23T03:03:27.444Z", 14 | "updatedAt": None, 15 | "keyId": None, 16 | "version": "", 17 | "hash": "4401f8a5781f3c832b70023fb79442a1", 18 | "sha": "f116369d91b7f356e9d8b75dfd496ecaafa23c92", 19 | "sha256": "ae91ed9896bcc3a4ea13b52c9727d106634b6b17423b762d3e033f0989e7c1a3", 20 | "draftToken": "z2umc57Qpt0eZR8wngV9CjzhuEWtLvUttHng$qq6WZt0eptQqOKVMkYPPDStElzF", 21 | "publishedAt": None, 22 | "name": "dash-in-last-saved", 23 | }, 24 | { 25 | "projectId": 8, 26 | "xmlFormId": "external_52k", 27 | "state": "open", 28 | "enketoId": None, 29 | "enketoOnceId": "0510ccec266c8e0c88e939c2597341e523535b0e18460fca7c8b4585826a157d", 30 | "createdAt": "2021-10-28T19:11:37.064Z", 31 | "updatedAt": "2021-10-28T19:11:59.047Z", 32 | "keyId": None, 33 | "version": "1", 34 | "hash": "31a52959d89621f995fd95b3822e54fd", 35 | "sha": "e8a128bbfaceb7265f12b903648f6f63700630aa", 36 | "sha256": "1c5ffdf837c153672fbd7858753c6fa41a8e5813423932e53162016139f11ca1", 37 | "draftToken": None, 38 | "publishedAt": "2021-10-28T19:11:57.082Z", 39 | "name": None, 40 | }, 41 | { 42 | "projectId": 8, 43 | "xmlFormId": "Infos_registre_CTC_20210226", 44 | "state": "open", 45 | "enketoId": "fcH3ZtJxHEA5bHBRfp5eq3jBGjeEgbv", 46 | "enketoOnceId": "293dfdb6fcc501e0e323a8f5d27eb158f6f09b0885b32075a02d75bfdc8703e7", 47 | "createdAt": "2021-05-18T03:26:25.679Z", 48 | "updatedAt": "2021-05-18T03:29:23.487Z", 49 | "keyId": None, 50 | "version": "04052021", 51 | "hash": "ca51a72cc206f6557198ae92cb11f7a8", 52 | "sha": "b98783f9de7a20ff683842701aa897bbe108f933", 53 | "sha256": "eb8c7e52b4d685f0f8f1bb61fd5957f09ba0fdd26f6b87643cfcdd60389f842f", 54 | "draftToken": None, 55 | "publishedAt": "2021-05-18T03:29:23.487Z", 56 | "name": "Infos registre CTC/CTU", 57 | }, 58 | { 59 | "projectId": 8, 60 | "xmlFormId": "range", 61 | "state": "open", 62 | "enketoId": "sRgCcrzoEYKc66geY9fC5vL28bmqFBJ", 63 | "enketoOnceId": "4e1f9feaa813149c67a8a4c709117f128e4d26153d9061fe70d9b1c5ca7215a6", 64 | "createdAt": "2021-04-20T21:11:50.794Z", 65 | "updatedAt": "2021-05-10T20:51:34.202Z", 66 | "keyId": None, 67 | "version": "2021042001", 68 | "hash": "50714e468ceb8a53e4294becc1bfc92a", 69 | "sha": "0c934f081e3236c2d2e21100ca05bb770885cdf3", 70 | "sha256": "cc1f15261da182655a77e2005798110ab95897f5d336ef77b60906320317bb30", 71 | "draftToken": None, 72 | "publishedAt": "2021-05-10T20:51:22.100Z", 73 | "name": "range", 74 | }, 75 | ], 76 | } 77 | test_form_attachments = [ 78 | { 79 | "name": "fruits.csv", 80 | "type": "file", 81 | "hash": "b61381f802d5ca6bc054e49e32471500", 82 | "exists": True, 83 | "blobExists": True, 84 | "datasetExists": False, 85 | "updatedAt": "2025-05-10T20:51:22.100Z", 86 | } 87 | ] 88 | 89 | 90 | def get_xml__range_draft( 91 | form_id: str | None = "range_draft", version: str | None = None 92 | ) -> str: 93 | if version is None: 94 | version = datetime.now(timezone.utc).isoformat() 95 | with open(Path(__file__).parent / "forms" / "range_draft.xml") as fd: 96 | return fd.read().format(form_id=form_id, version=version) 97 | 98 | 99 | def get_md__pull_data(version: str | None = None) -> str: 100 | if version is None: 101 | version = datetime.now(timezone.utc).isoformat() 102 | return f""" 103 | | settings | 104 | | | version | 105 | | | {version} | 106 | | survey | | | | | 107 | | | type | name | label | calculation | 108 | | | calculate | fruit | | pulldata('fruits', 'name', 'name_key', 'mango') | 109 | | | note | note_fruit | The fruit ${{fruit}} pulled from csv | | 110 | """ 111 | 112 | 113 | md__symbols = """ 114 | | settings | 115 | | | form_title | form_id | version | 116 | | | a non_ascii_form_id | ''=+/*-451%/% | 1 | 117 | | survey | | | | | 118 | | | type | name | label | calculation | 119 | | | calculate | fruit | | pulldata('fruits', 'name', 'name_key', 'mango') | 120 | | | note | note_fruit | The fruit ${fruit} pulled from csv | | 121 | """ 122 | md__dingbat = """ 123 | | settings | 124 | | | form_title | form_id | version | 125 | | | ✅ | ✅ | 1 | 126 | | survey | | | | | 127 | | | type | name | label | calculation | 128 | | | calculate | fruit | | pulldata('fruits', 'name', 'name_key', 'mango') | 129 | | | note | note_fruit | The fruit ${fruit} pulled from csv | | 130 | """ 131 | md__upload_file = """ 132 | | settings | 133 | | | form_title | form_id | version | 134 | | | upload_file | upload_file | 1 | 135 | | survey | 136 | | | type | name | label | 137 | | | text | name | Name | 138 | | | file | file | File | 139 | """ 140 | -------------------------------------------------------------------------------- /tests/resources/projects_data.py: -------------------------------------------------------------------------------- 1 | test_projects = { 2 | "response_data": [ 3 | { 4 | "id": 8, 5 | "name": "Hélène", 6 | "description": "Some sample forms for me and others! Yay!", 7 | "archived": None, 8 | "keyId": None, 9 | "createdAt": "2021-02-15T01:19:41.923Z", 10 | "updatedAt": "2022-07-07T23:39:39.198Z", 11 | "deletedAt": None, 12 | "verbs": [ 13 | "project.read", 14 | "form.list", 15 | "form.read", 16 | "submission.read", 17 | "submission.list", 18 | ], 19 | }, 20 | { 21 | "id": 51, 22 | "name": "Lindsay", 23 | "description": None, 24 | "archived": None, 25 | "keyId": None, 26 | "createdAt": "2022-07-07T23:40:16.722Z", 27 | "updatedAt": None, 28 | "deletedAt": None, 29 | "verbs": [ 30 | "project.read", 31 | "project.update", 32 | "project.delete", 33 | "form.create", 34 | "form.delete", 35 | "form.list", 36 | "form.read", 37 | "form.update", 38 | "submission.create", 39 | "submission.read", 40 | "submission.list", 41 | "submission.update", 42 | "field_key.create", 43 | "field_key.delete", 44 | "field_key.list", 45 | "assignment.list", 46 | "assignment.create", 47 | "assignment.delete", 48 | "public_link.create", 49 | "public_link.list", 50 | "public_link.read", 51 | "public_link.update", 52 | "public_link.delete", 53 | "session.end", 54 | "submission.update", 55 | "form.restore", 56 | ], 57 | }, 58 | ] 59 | } 60 | project_app_users = { 61 | "project_id": 1, 62 | "response_data": [ 63 | { 64 | "projectId": 1, 65 | "id": 115, 66 | "type": "field_key", 67 | "displayName": "test_user_1", 68 | "createdAt": "2018-04-18T23:19:14.802Z", 69 | "updatedAt": None, 70 | "deletedAt": None, 71 | "token": "d1!E2GVHgpr4h9bpxxtqUJ7EVJ1Q$Dusm2RBXg8XyVJMCBCbvyE8cGacxUx3bcUT", 72 | }, 73 | { 74 | "projectId": 1, 75 | "id": 116, 76 | "type": "field_key", 77 | "displayName": "test_user_2", 78 | "createdAt": "2018-04-18T23:23:14.802Z", 79 | "updatedAt": None, 80 | "deletedAt": None, 81 | "token": None, 82 | }, 83 | ], 84 | } 85 | roles = { 86 | "response_data": [ 87 | { 88 | "id": 6, 89 | "name": "Project Viewer", 90 | "system": "viewer", 91 | "createdAt": None, 92 | "updatedAt": None, 93 | "verbs": [ 94 | "project.read", 95 | "form.list", 96 | "form.read", 97 | "submission.read", 98 | "submission.list", 99 | "dataset.list", 100 | "entity.list", 101 | ], 102 | }, 103 | { 104 | "id": 3, 105 | "name": "Password Reset Token", 106 | "system": "pwreset", 107 | "createdAt": "2022-10-06T16:39:20.979Z", 108 | "updatedAt": None, 109 | "verbs": ["user.password.reset"], 110 | }, 111 | { 112 | "id": 2, 113 | "name": "App User", 114 | "system": "app-user", 115 | "createdAt": "2022-10-06T16:39:20.939Z", 116 | "updatedAt": None, 117 | "verbs": ["form.read", "submission.create"], 118 | }, 119 | { 120 | "id": 7, 121 | "name": "Form Viewer (system internal)", 122 | "system": "formview", 123 | "createdAt": None, 124 | "updatedAt": None, 125 | "verbs": ["form.read"], 126 | }, 127 | { 128 | "id": 8, 129 | "name": "Data Collector", 130 | "system": "formfill", 131 | "createdAt": None, 132 | "updatedAt": None, 133 | "verbs": ["project.read", "form.list", "form.read", "submission.create"], 134 | }, 135 | { 136 | "id": 9, 137 | "name": "Public Link", 138 | "system": "pub-link", 139 | "createdAt": "2022-10-06T16:39:21.445Z", 140 | "updatedAt": None, 141 | "verbs": ["form.read", "submission.create"], 142 | }, 143 | { 144 | "id": 1, 145 | "name": "Administrator", 146 | "system": "admin", 147 | "createdAt": "2022-10-06T16:39:20.939Z", 148 | "updatedAt": None, 149 | "verbs": [ 150 | "config.read", 151 | "field_key.create", 152 | "field_key.delete", 153 | "field_key.list", 154 | "form.create", 155 | "form.delete", 156 | "form.list", 157 | "form.read", 158 | "form.update", 159 | "project.create", 160 | "project.delete", 161 | "project.read", 162 | "project.update", 163 | "session.end", 164 | "submission.create", 165 | "submission.read", 166 | "submission.list", 167 | "user.create", 168 | "user.list", 169 | "user.password.invalidate", 170 | "user.read", 171 | "user.update", 172 | "submission.update", 173 | "role.create", 174 | "role.update", 175 | "role.delete", 176 | "assignment.list", 177 | "assignment.create", 178 | "assignment.delete", 179 | "user.delete", 180 | "audit.read", 181 | "public_link.create", 182 | "public_link.list", 183 | "public_link.read", 184 | "public_link.update", 185 | "public_link.delete", 186 | "backup.run", 187 | "submission.update", 188 | "config.set", 189 | "analytics.read", 190 | "form.restore", 191 | "dataset.list", 192 | "entity.list", 193 | ], 194 | }, 195 | { 196 | "id": 5, 197 | "name": "Project Manager", 198 | "system": "manager", 199 | "createdAt": None, 200 | "updatedAt": None, 201 | "verbs": [ 202 | "project.read", 203 | "project.update", 204 | "project.delete", 205 | "form.create", 206 | "form.delete", 207 | "form.list", 208 | "form.read", 209 | "form.update", 210 | "submission.create", 211 | "submission.read", 212 | "submission.list", 213 | "submission.update", 214 | "field_key.create", 215 | "field_key.delete", 216 | "field_key.list", 217 | "assignment.list", 218 | "assignment.create", 219 | "assignment.delete", 220 | "public_link.create", 221 | "public_link.list", 222 | "public_link.read", 223 | "public_link.update", 224 | "public_link.delete", 225 | "session.end", 226 | "submission.update", 227 | "form.restore", 228 | "dataset.list", 229 | "entity.list", 230 | ], 231 | }, 232 | ] 233 | } 234 | -------------------------------------------------------------------------------- /tests/resources/submissions_data.py: -------------------------------------------------------------------------------- 1 | test_submissions = { 2 | "project_id": 8, 3 | "form_id": "range", 4 | "response_data": [ 5 | { 6 | "instanceId": "uuid:96f2a014-eaa1-466a-abe2-3ccacc756d5a", 7 | "submitterId": 28, 8 | "deviceId": None, 9 | "createdAt": "2021-05-10T20:51:51.404Z", 10 | "updatedAt": None, 11 | "reviewState": None, 12 | }, 13 | { 14 | "instanceId": "uuid:0ef76597-5a6d-4788-b924-d875f615025c", 15 | "submitterId": 28, 16 | "deviceId": None, 17 | "createdAt": "2021-05-10T20:51:48.198Z", 18 | "updatedAt": None, 19 | "reviewState": None, 20 | }, 21 | { 22 | "instanceId": "uuid:2d434c15-096a-41aa-a9f5-714badf72d0d", 23 | "submitterId": 28, 24 | "deviceId": None, 25 | "createdAt": "2021-05-10T20:51:45.344Z", 26 | "updatedAt": None, 27 | "reviewState": None, 28 | }, 29 | { 30 | "instanceId": "uuid:4ef8a694-2848-4c77-a0db-31a3b53c14cd", 31 | "submitterId": 28, 32 | "deviceId": None, 33 | "createdAt": "2021-05-10T20:51:40.330Z", 34 | "updatedAt": None, 35 | "reviewState": None, 36 | }, 37 | ], 38 | } 39 | test_xml = """ 40 | 41 | 42 | uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44 43 | 44 | Alice 45 | 36 46 | 47 | """ 48 | upload_file_xml = """ 49 | 50 | {iid} 51 | file 52 | {file_name} 53 | 54 | """ 55 | upload_file_submissions = { 56 | "project_id": 1, 57 | "form_id": "upload_file", 58 | "response_data": [ 59 | { 60 | "instanceId": "uuid:85cb9aff-005e-4edd-9739-dc9c1a829c45", 61 | "submitterId": 10, 62 | "deviceId": None, 63 | "createdAt": "2025-03-14T06:21:29.581Z", 64 | "updatedAt": None, 65 | "reviewState": None, 66 | }, 67 | ], 68 | } 69 | 70 | 71 | def get_xml__fruits( 72 | form_id: str, 73 | version: str, 74 | instance_id: str, 75 | deprecated_instance_id: str | None = None, 76 | selected_fruit: str = "Papaya", 77 | ) -> str: 78 | """ 79 | Get Submission XML for the "fruits" form that uses an external data list. 80 | 81 | :param form_id: The xmlFormId of the Form being referenced. 82 | :param version: The version of the form that the submission is for. 83 | :param instance_id: The instanceId of the Submission being referenced. 84 | :param deprecated_instance_id: If the submission is an edit, then the instance_id of 85 | the submission being replaced must be provided. 86 | :param selected_fruit: Which delicious tropical fruit do you like? 87 | """ 88 | iidd = "" 89 | if deprecated_instance_id is not None: 90 | iidd = f"{deprecated_instance_id}" 91 | return f""" 92 | 93 | {iidd} 94 | {instance_id} 95 | 96 | {selected_fruit} 97 | 98 | 99 | """ 100 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase 3 | from unittest.mock import patch 4 | 5 | from pyodk._utils import config 6 | from pyodk.errors import PyODKError 7 | 8 | from tests import resources 9 | from tests.utils.utils import get_temp_dir 10 | 11 | 12 | class TestConfig(TestCase): 13 | def setUp(self) -> None: 14 | self.section_data = { 15 | "base_url": "https://www.example.com", 16 | "username": "user", 17 | "password": "pass", 18 | } 19 | 20 | def test_read_config__ok(self): 21 | """Should return the configuration data when no path is specified.""" 22 | cf = {"PYODK_CONFIG_FILE": resources.CONFIG_FILE.as_posix()} 23 | with patch.dict(os.environ, cf, clear=True): 24 | self.assertIsInstance(config.read_config(), config.Config) 25 | 26 | def test_read_config__ok__with_path(self): 27 | """Should return the configuration data when a path is specified.""" 28 | self.assertIsInstance( 29 | config.read_config(config_path=resources.CONFIG_FILE), config.Config 30 | ) 31 | 32 | def test_read_cache__ok(self): 33 | """Should return the cache data when no path is specified.""" 34 | cf = {"PYODK_CACHE_FILE": resources.CACHE_FILE.as_posix()} 35 | with patch.dict(os.environ, cf, clear=True): 36 | self.assertIsInstance(config.read_cache_token(), str) 37 | 38 | def test_read_cache__ok__with_path(self): 39 | """Should return the cache data when a path is specified.""" 40 | self.assertIsInstance( 41 | config.read_cache_token(cache_path=resources.CACHE_FILE), str 42 | ) 43 | 44 | def test_read_toml__error__non_existent(self): 45 | """Should raise an error if the path is not valid.""" 46 | bad_path = resources.RESOURCES / "nothing_here" 47 | with self.assertRaises(PyODKError) as err: 48 | config.read_toml(path=bad_path) 49 | self.assertTrue( 50 | str(err.exception.args[0]).startswith(f"Could not read file at: {bad_path}") 51 | ) 52 | 53 | def test_write_cache__ok(self): 54 | """Should write the cache data when no path is specified.""" 55 | with get_temp_dir() as tmp: 56 | path = tmp / "my_cache.toml" 57 | with patch.dict(os.environ, {"PYODK_CACHE_FILE": path.as_posix()}): 58 | self.assertFalse(path.exists()) 59 | config.write_cache(key="token", value="1234abcd") 60 | self.assertTrue(path.exists()) 61 | 62 | def test_write_cache__with_path(self): 63 | """Should write the cache data when a path is specified.""" 64 | with get_temp_dir() as tmp: 65 | path = tmp / "my_cache.toml" 66 | self.assertFalse(path.exists()) 67 | config.write_cache(key="token", value="1234abcd", cache_path=path.as_posix()) 68 | self.assertTrue(path.exists()) 69 | 70 | def test_objectify_config__error__missing_section(self): 71 | cfg = {"centrall": {}} 72 | with self.assertRaises(KeyError) as err: 73 | config.objectify_config(config_data=cfg) 74 | self.assertEqual("central", err.exception.args[0]) 75 | 76 | def test_objectify_config__error__missing_key(self): 77 | del self.section_data["password"] 78 | cfg = {"central": self.section_data} 79 | with self.assertRaises(TypeError) as err: 80 | config.objectify_config(config_data=cfg) 81 | # Py3.8 doesn't prefix the class name to __init__(), but Py3.10 does. 82 | self.assertIn( 83 | "__init__() missing 1 required positional argument: 'password'", 84 | err.exception.args[0], 85 | ) 86 | 87 | def test_objectify_config__error__empty_key(self): 88 | self.section_data["password"] = "" 89 | cfg = {"central": self.section_data} 90 | with self.assertRaises(PyODKError) as err: 91 | config.objectify_config(config_data=cfg) 92 | self.assertEqual( 93 | "Config value 'password' must not be empty.", err.exception.args[0] 94 | ) 95 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest import TestCase 3 | 4 | from pyodk._utils.session import Session 5 | 6 | 7 | class TestSession(TestCase): 8 | def test_base_url_validate(self): 9 | """Should return base_url suffixed with '/{version}/', if not already added.""" 10 | cases = ( 11 | ("https://example.com", "https://example.com/v1/"), 12 | ("https://example.com/", "https://example.com/v1/"), 13 | ("https://example.com/v1", "https://example.com/v1/"), 14 | ("https://example.com/v1/", "https://example.com/v1/"), 15 | ("https://example.com/subpath/v1", "https://example.com/subpath/v1/"), 16 | ("https://example.com/subpath/v1/", "https://example.com/subpath/v1/"), 17 | ) 18 | for base_url, expected in cases: 19 | with self.subTest(msg=f"{base_url}"): 20 | observed = Session.base_url_validate(base_url=base_url, api_version="v1") 21 | self.assertEqual(expected, observed) 22 | 23 | def test_urlformat(self): 24 | """Should replace input fields with url-encoded values.""" 25 | url = "projects/{project_id}/forms/{form_id}" 26 | test_cases = ( 27 | # Basic latin string 28 | ({"project_id": 1, "form_id": "a"}, "projects/1/forms/a"), 29 | # integer 30 | ({"project_id": 1, "form_id": 1}, "projects/1/forms/1"), 31 | # latin symbols 32 | ({"project_id": 1, "form_id": "+-_*%*"}, "projects/1/forms/%2B-_*%25*"), 33 | # lower case e, with combining acute accent (2 symbols) 34 | ({"project_id": 1, "form_id": "tést"}, "projects/1/forms/te%CC%81st"), 35 | # lower case e with acute (1 symbol) 36 | ({"project_id": 1, "form_id": "tést"}, "projects/1/forms/t%C3%A9st"), 37 | # white heavy check mark 38 | ({"project_id": 1, "form_id": "✅"}, "projects/1/forms/%E2%9C%85"), 39 | ) 40 | for params, expected in test_cases: 41 | with self.subTest(msg=str(params)): 42 | self.assertEqual(expected, Session.urlformat(url, **params)) 43 | 44 | def test_urlquote(self): 45 | """Should url-encode input values.""" 46 | test_cases = ( 47 | # Basic latin string 48 | ("test.xlsx", "test"), 49 | # integer 50 | ("1.xls", "1"), 51 | # latin symbols 52 | ("+-_*%*.xls", "%2B-_*%25*"), 53 | # spaces 54 | ("my form.xlsx", "my%20form"), 55 | # lower case e, with combining acute accent (2 symbols) 56 | ("tést.xlsx", "te%CC%81st"), 57 | # lower case e with acute (1 symbol) 58 | ("tést", "t%C3%A9st"), 59 | # white heavy check mark 60 | ("✅.xlsx", "%E2%9C%85"), 61 | ) 62 | for params, expected in test_cases: 63 | with self.subTest(msg=str(params)): 64 | self.assertEqual(expected, Session.urlquote(Path(params).stem)) 65 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pyodk._utils import validators as v 4 | from pyodk.errors import PyODKError 5 | 6 | 7 | class TestValidators(TestCase): 8 | def test_wrap_error__raises_pyodk_error(self): 9 | """Should raise a PyODK error (from Pydantic) if a validator check fails.""" 10 | 11 | def a_func(): 12 | pass 13 | 14 | cases = ( 15 | (v.validate_project_id, False, (None, "a")), 16 | (v.validate_form_id, False, (None, a_func)), 17 | (v.validate_table_name, False, (None, a_func)), 18 | (v.validate_instance_id, False, (None, a_func)), 19 | (v.validate_entity_list_name, False, (None, a_func)), 20 | (v.validate_str, True, (None, a_func)), 21 | (v.validate_bool, True, (None, a_func)), 22 | (v.validate_int, True, (None, a_func)), 23 | (v.validate_dict, True, (None, ((("a",),),))), 24 | ( 25 | v.validate_file_path, 26 | True, 27 | ( 28 | None, 29 | "No such file", 30 | ), 31 | ), 32 | ) 33 | 34 | for i, (func, has_key, values) in enumerate(cases): 35 | for j, value in enumerate(values): 36 | msg = f"Case {i}, Value {j}" 37 | with self.subTest(msg=msg), self.assertRaises(PyODKError): 38 | if has_key: 39 | func(value, key=msg) 40 | else: 41 | func(value) 42 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/entity_lists.py: -------------------------------------------------------------------------------- 1 | from pyodk._endpoints.entity_lists import EntityList 2 | from pyodk.client import Client 3 | from pyodk.errors import PyODKError 4 | 5 | 6 | def create_new_or_get_entity_list( 7 | client: Client, entity_list_name: str, entity_props: list[str] 8 | ) -> EntityList: 9 | """ 10 | Create a new entity list, or get the entity list metadata. 11 | 12 | :param client: Client instance to use for API calls. 13 | :param entity_list_name: Name of the entity list. 14 | :param entity_props: Properties to add to the entity list. 15 | """ 16 | try: 17 | entity_list = client.entity_lists.create(entity_list_name=entity_list_name) 18 | except PyODKError as err: 19 | if not err.is_central_error(code=409.3): 20 | raise 21 | entity_list = EntityList( 22 | **client.session.get( 23 | url=client.session.urlformat( 24 | "projects/{pid}/datasets/{eln}", 25 | pid=client.project_id, 26 | eln=entity_list_name, 27 | ), 28 | ).json() 29 | ) 30 | try: 31 | for prop in entity_props: 32 | client.entity_lists.add_property(name=prop, entity_list_name=entity_list_name) 33 | except PyODKError as err: 34 | if not err.is_central_error(code=409.3): 35 | raise 36 | return entity_list 37 | -------------------------------------------------------------------------------- /tests/utils/forms.py: -------------------------------------------------------------------------------- 1 | from os import PathLike 2 | 3 | from pyodk.client import Client 4 | from pyodk.errors import PyODKError 5 | 6 | from tests.utils import utils 7 | from tests.utils.md_table import md_table_to_temp_dir 8 | 9 | 10 | def create_ignore_duplicate_error( 11 | client: Client, 12 | definition: PathLike | str | bytes, 13 | form_id: str, 14 | ): 15 | """Create the form; ignore the error raised if it exists (409.3).""" 16 | try: 17 | client.forms.create(definition=definition, form_id=form_id) 18 | except PyODKError as err: 19 | if not err.is_central_error(code=409.3): 20 | raise 21 | 22 | 23 | def create_new_form__md(client: Client, form_id: str, form_def: str): 24 | """ 25 | Create a new form from a MarkDown string. 26 | 27 | :param client: Client instance to use for API calls. 28 | :param form_id: The xmlFormId of the Form being referenced. 29 | :param form_def: The form definition MarkDown. 30 | """ 31 | with ( 32 | md_table_to_temp_dir(form_id=form_id, mdstr=form_def) as fp, 33 | ): 34 | create_ignore_duplicate_error(client=client, definition=fp, form_id=form_id) 35 | 36 | 37 | def create_new_form__xml(client: Client, form_id: str, form_def: str): 38 | """ 39 | Create a new form from a XML string. 40 | 41 | :param client: Client instance to use for API calls. 42 | :param form_id: The xmlFormId of the Form being referenced. 43 | :param form_def: The form definition XML. 44 | """ 45 | with utils.get_temp_file(suffix=".xml") as fp: 46 | fp.write_text(form_def) 47 | create_ignore_duplicate_error(client=client, definition=fp, form_id=form_id) 48 | 49 | 50 | def get_latest_form_version(client: Client, form_id: str) -> str: 51 | """ 52 | Get the version name of the most recently published version of the form. 53 | 54 | :param client: Client instance to use for API calls. 55 | :param form_id: The xmlFormId of the Form being referenced. 56 | """ 57 | versions = client.session.get( 58 | client.session.urlformat( 59 | "projects/{pid}/forms/{fid}/versions", 60 | pid=client.project_id, 61 | fid=form_id, 62 | ) 63 | ) 64 | return sorted( 65 | (s for s in versions.json()), key=lambda s: s["publishedAt"], reverse=True 66 | )[0]["version"] 67 | -------------------------------------------------------------------------------- /tests/utils/md_table.py: -------------------------------------------------------------------------------- 1 | """ 2 | Markdown table utility functions. 3 | """ 4 | 5 | import re 6 | from contextlib import contextmanager 7 | from io import BytesIO 8 | from pathlib import Path 9 | 10 | from openpyxl import Workbook 11 | from xlwt import Workbook as XLSWorkbook 12 | 13 | from tests.utils.utils import get_temp_dir 14 | 15 | 16 | def _strp_cell(cell): 17 | val = cell.strip() 18 | if val == "": 19 | return None 20 | val = val.replace(r"\|", "|") 21 | return val 22 | 23 | 24 | def _extract_array(mdtablerow): 25 | match = re.match(r"\s*\|(.*)\|\s*", mdtablerow) 26 | if match: 27 | mtchstr = match.groups()[0] 28 | if re.match(r"^[\|-]+$", mtchstr): 29 | return False 30 | else: 31 | return [_strp_cell(c) for c in re.split(r"(? list[tuple[str, list[list[str]]]]: 45 | ss_arr = [] 46 | for item in mdstr.split("\n"): 47 | arr = _extract_array(item) 48 | if arr: 49 | ss_arr.append(arr) 50 | sheet_name = False 51 | sheet_arr = False 52 | sheets = [] 53 | for row in ss_arr: 54 | if row[0] is not None: 55 | if sheet_arr: 56 | sheets.append((sheet_name, sheet_arr)) 57 | sheet_arr = [] 58 | sheet_name = row[0] 59 | excluding_first_col = row[1:] 60 | if sheet_name and not _is_null_row(excluding_first_col): 61 | sheet_arr.append(excluding_first_col) 62 | sheets.append((sheet_name, sheet_arr)) 63 | 64 | return sheets 65 | 66 | 67 | def md_table_to_workbook(mdstr: str) -> Workbook: 68 | """ 69 | Convert Markdown table string to an openpyxl.Workbook. Call wb.save() to persist. 70 | """ 71 | md_data = md_table_to_ss_structure(mdstr=mdstr) 72 | wb = Workbook(write_only=True) 73 | for key, rows in md_data: 74 | sheet = wb.create_sheet(title=key) 75 | for r in rows: 76 | sheet.append(r) 77 | return wb 78 | 79 | 80 | @contextmanager 81 | def md_table_to_temp_dir(form_id: str, mdstr: str) -> Path: 82 | """ 83 | Convert MarkDown table string to a XLSX file saved in a temp directory. 84 | 85 | :param form_id: The xmlFormId of the Form being referenced. 86 | :param mdstr: The MarkDown table string. 87 | :return: The path of the XLSX file. 88 | """ 89 | with get_temp_dir() as td: 90 | fp = Path(td) / f"{form_id}.xlsx" 91 | md_table_to_workbook(mdstr).save(fp.as_posix()) 92 | yield fp 93 | 94 | 95 | def md_table_to_bytes(mdstr: str) -> bytes: 96 | """ 97 | Convert MarkDown table string to XLSX Workbook bytes. 98 | 99 | :param mdstr: The MarkDown table string. 100 | """ 101 | wb = md_table_to_workbook(mdstr=mdstr) 102 | fd = BytesIO() 103 | wb.save(fd) 104 | fd.seek(0) 105 | return fd.getvalue() 106 | 107 | 108 | def md_table_to_bytes_xls(mdstr: str) -> bytes: 109 | """ 110 | Convert MarkDown table string to XLS Workbook bytes. 111 | 112 | :param mdstr: The MarkDown table string. 113 | """ 114 | md_data = md_table_to_ss_structure(mdstr=mdstr) 115 | wb = XLSWorkbook() 116 | for key, rows in md_data: 117 | sheet = wb.add_sheet(sheetname=key) 118 | for ir, row in enumerate(rows): 119 | for ic, cell in enumerate(row): 120 | sheet.write(ir, ic, cell) 121 | fd = BytesIO() 122 | wb.save(fd) 123 | fd.seek(0) 124 | return fd.getvalue() 125 | -------------------------------------------------------------------------------- /tests/utils/submissions.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from pyodk.client import Client 4 | from pyodk.errors import PyODKError 5 | 6 | from tests.resources import submissions_data 7 | from tests.utils.forms import get_latest_form_version 8 | 9 | 10 | def create_new_or_get_last_submission( 11 | client: Client, form_id: str, instance_id: str 12 | ) -> str: 13 | """ 14 | Create a new submission, or get the most recent version, and return it's instance_id. 15 | 16 | :param client: Client instance to use for API calls. 17 | :param form_id: The xmlFormId of the Form being referenced. 18 | :param instance_id: The instanceId of the Submission being referenced. 19 | :return: The created instance_id or the instance_id of the most recent version. 20 | """ 21 | try: 22 | old_iid = client.submissions.create( 23 | xml=submissions_data.get_xml__fruits( 24 | form_id=form_id, 25 | version=get_latest_form_version(client=client, form_id=form_id), 26 | instance_id=instance_id, 27 | ), 28 | form_id=form_id, 29 | ).instanceId 30 | except PyODKError as err: 31 | if not err.is_central_error(code=409.3): 32 | raise 33 | subvs = client.session.get( 34 | client.session.urlformat( 35 | "projects/{pid}/forms/{fid}/submissions/{iid}/versions", 36 | pid=client.project_id, 37 | fid=form_id, 38 | iid=instance_id, 39 | ), 40 | ) 41 | old_iid = sorted( 42 | (s for s in subvs.json()), key=lambda s: s["createdAt"], reverse=True 43 | )[0]["instanceId"] 44 | return old_iid 45 | 46 | 47 | def create_or_update_submission_with_comment( 48 | client: Client, form_id: str, instance_id: str 49 | ): 50 | """ 51 | Create and/or update a submission, adding a comment with the edit. 52 | 53 | :param client: Client instance to use for API calls. 54 | :param form_id: The xmlFormId of the Form being referenced. 55 | :param instance_id: The instanceId of the Submission being referenced. 56 | """ 57 | pd_iid = create_new_or_get_last_submission( 58 | client=client, 59 | form_id=form_id, 60 | instance_id=instance_id, 61 | ) 62 | client.submissions.edit( 63 | xml=submissions_data.get_xml__fruits( 64 | form_id=form_id, 65 | version=get_latest_form_version(client=client, form_id=form_id), 66 | instance_id=uuid4().hex, 67 | deprecated_instance_id=pd_iid, 68 | ), 69 | form_id=form_id, 70 | instance_id=instance_id, 71 | comment="pyODK edit", 72 | ) 73 | -------------------------------------------------------------------------------- /tests/utils/utils.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import tempfile 3 | from contextlib import contextmanager 4 | from pathlib import Path 5 | 6 | 7 | @contextmanager 8 | def get_temp_file(**kwargs) -> Path: 9 | """ 10 | Create a temporary file. 11 | 12 | :param kwargs: File handling options passed through to NamedTemporaryFile. 13 | :return: The path of the temporary file. 14 | """ 15 | temp_file = tempfile.NamedTemporaryFile(delete=False, **kwargs) 16 | temp_file.close() 17 | temp_path = Path(temp_file.name) 18 | try: 19 | yield temp_path 20 | finally: 21 | temp_file.close() 22 | temp_path.unlink(missing_ok=True) 23 | 24 | 25 | @contextmanager 26 | def get_temp_dir() -> Path: 27 | temp_dir = tempfile.mkdtemp(prefix="pyodk_tmp_") 28 | temp_path = Path(temp_dir) 29 | try: 30 | yield temp_path 31 | finally: 32 | if temp_path.exists(): 33 | shutil.rmtree(temp_dir) 34 | --------------------------------------------------------------------------------