From c17d91cb59634bddafa6a6098a40044135ccbd17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=86=A0=EB=B9=84?= Date: Fri, 9 Feb 2024 16:34:28 +0100 Subject: [PATCH] Major Rewrite to v3.0.0 (#87) Co-authored-by: tb1337 --- .devcontainer/devcontainer.json | 3 +- .github/labels.yml | 78 ++ .github/workflows/labels.yml | 22 + .github/workflows/pr-labels.yml | 9 +- .github/workflows/pre-commit-updater.yml | 26 +- .github/workflows/release-package.yml | 49 - .github/workflows/release.yml | 56 + .github/workflows/test.yml | 36 - .github/workflows/tests.yml | 62 + .github/workflows/typing-linting.yml | 28 + .pre-commit-config.yaml | 26 +- Dockerfile | 2 +- README.md | 555 ++++++++- docs/CRUD.md | 205 ---- docs/REQUEST.md | 117 -- docs/SESSION.md | 119 -- example/usage.py | 41 - pypaperless/__init__.py | 363 +----- pypaperless/api.py | 227 ++++ pypaperless/const.py | 150 ++- pypaperless/controllers/__init__.py | 41 - pypaperless/controllers/base.py | 185 --- pypaperless/controllers/default.py | 212 ---- pypaperless/controllers/documents.py | 158 --- pypaperless/controllers/tasks.py | 41 - pypaperless/errors.py | 17 - pypaperless/exceptions.py | 83 ++ pypaperless/helpers.py | 16 + pypaperless/models/__init__.py | 63 +- pypaperless/models/base.py | 129 +- pypaperless/models/classifiers.py | 273 +++++ pypaperless/models/common.py | 183 +++ pypaperless/models/config.py | 56 + pypaperless/models/custom_fields.py | 79 +- pypaperless/models/documents.py | 640 +++++++++- pypaperless/models/generators/__init__.py | 5 + pypaperless/models/generators/page.py | 66 + pypaperless/models/groups.py | 14 - pypaperless/models/mails.py | 80 +- pypaperless/models/matching.py | 137 --- pypaperless/models/mixins/__init__.py | 1 + pypaperless/models/mixins/helpers/__init__.py | 13 + pypaperless/models/mixins/helpers/callable.py | 38 + .../models/mixins/helpers/draftable.py | 29 + pypaperless/models/mixins/helpers/iterable.py | 91 ++ .../models/mixins/helpers/securable.py | 23 + pypaperless/models/mixins/models/__init__.py | 17 + pypaperless/models/mixins/models/creatable.py | 64 + .../models/mixins/models/data_fields.py | 14 + pypaperless/models/mixins/models/deletable.py | 26 + pypaperless/models/mixins/models/securable.py | 27 + pypaperless/models/mixins/models/updatable.py | 71 ++ pypaperless/models/pages.py | 93 ++ pypaperless/models/permissions.py | 84 ++ pypaperless/models/saved_views.py | 49 +- pypaperless/models/share_links.py | 82 +- pypaperless/models/tasks.py | 94 +- pypaperless/models/users.py | 25 - .../{util.py => models/utils/__init__.py} | 198 ++- pypaperless/models/workflows.py | 199 +-- pypaperless/sessions.py | 156 +++ pyproject.toml | 54 +- tests/__init__.py | 316 ++++- tests/conftest.py | 56 +- tests/const.py | 4 +- tests/data/v0_0_0/__init__.py | 127 +- tests/data/v1_8_0/__init__.py | 26 +- tests/data/v2_0_0/__init__.py | 48 +- tests/test_paperless_0_0_0.py | 1092 ++++++----------- tests/test_paperless_1_17_0.py | 151 ++- tests/test_paperless_1_8_0.py | 213 ++-- tests/test_paperless_2_0_0.py | 413 +++---- tests/test_paperless_2_3_0.py | 299 ++--- tests/test_paperless_common.py | 330 +++-- tests/util/router.py | 139 ++- 75 files changed, 5496 insertions(+), 3818 deletions(-) create mode 100644 .github/labels.yml create mode 100644 .github/workflows/labels.yml delete mode 100644 .github/workflows/release-package.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .github/workflows/typing-linting.yml delete mode 100644 docs/CRUD.md delete mode 100644 docs/REQUEST.md delete mode 100644 docs/SESSION.md delete mode 100644 example/usage.py create mode 100644 pypaperless/api.py delete mode 100644 pypaperless/controllers/__init__.py delete mode 100644 pypaperless/controllers/base.py delete mode 100644 pypaperless/controllers/default.py delete mode 100644 pypaperless/controllers/documents.py delete mode 100644 pypaperless/controllers/tasks.py delete mode 100644 pypaperless/errors.py create mode 100644 pypaperless/exceptions.py create mode 100644 pypaperless/helpers.py create mode 100644 pypaperless/models/classifiers.py create mode 100644 pypaperless/models/common.py create mode 100644 pypaperless/models/config.py create mode 100644 pypaperless/models/generators/__init__.py create mode 100644 pypaperless/models/generators/page.py delete mode 100644 pypaperless/models/groups.py delete mode 100644 pypaperless/models/matching.py create mode 100644 pypaperless/models/mixins/__init__.py create mode 100644 pypaperless/models/mixins/helpers/__init__.py create mode 100644 pypaperless/models/mixins/helpers/callable.py create mode 100644 pypaperless/models/mixins/helpers/draftable.py create mode 100644 pypaperless/models/mixins/helpers/iterable.py create mode 100644 pypaperless/models/mixins/helpers/securable.py create mode 100644 pypaperless/models/mixins/models/__init__.py create mode 100644 pypaperless/models/mixins/models/creatable.py create mode 100644 pypaperless/models/mixins/models/data_fields.py create mode 100644 pypaperless/models/mixins/models/deletable.py create mode 100644 pypaperless/models/mixins/models/securable.py create mode 100644 pypaperless/models/mixins/models/updatable.py create mode 100644 pypaperless/models/pages.py create mode 100644 pypaperless/models/permissions.py delete mode 100644 pypaperless/models/users.py rename pypaperless/{util.py => models/utils/__init__.py} (50%) create mode 100644 pypaperless/sessions.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f4d84df..044c6ae 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,7 +20,8 @@ "esbenp.prettier-vscode", "GitHub.vscode-pull-request-github", "ms-azuretools.vscode-docker", - "ms-python.isort" + "ms-python.isort", + "ms-python.mypy-type-checker" ], "settings": { "python.pythonPath": "/usr/local/bin/python", diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..e7edee5 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,78 @@ +--- +- name: "breaking-change" + color: ee0701 + description: "A breaking change for existing users." +- name: "bugfix" + color: ee0701 + description: "Inconsistencies or issues which will cause a problem for users or implementers." +- name: "documentation" + color: 0052cc + description: "Solely about the documentation of the project." +- name: "enhancement" + color: 1d76db + description: "Enhancement of the code, not introducing new features." +- name: "refactor" + color: 1d76db + description: "Improvement of existing code, not introducing new features." +- name: "performance" + color: 1d76db + description: "Improving performance, not introducing new features." +- name: "new-feature" + color: 0e8a16 + description: "New features or options." +- name: "maintenance" + color: 2af79e + description: "Generic maintenance tasks." +- name: "ci" + color: 1d76db + description: "Work that improves the continue integration." +- name: "dependencies" + color: 1d76db + description: "Upgrade or downgrade of project dependencies." + +- name: "in-progress" + color: fbca04 + description: "Issue is currently being resolved by a developer." +- name: "stale" + color: fef2c0 + description: "There has not been activity on this issue or PR for quite some time." +- name: "no-stale" + color: fef2c0 + description: "This issue or PR is exempted from the stable bot." + +- name: "security" + color: ee0701 + description: "Marks a security issue that needs to be resolved asap." +- name: "incomplete" + color: fef2c0 + description: "Marks a PR or issue that is missing information." +- name: "invalid" + color: fef2c0 + description: "Marks a PR or issue that is missing information." + +- name: "beginner-friendly" + color: 0e8a16 + description: "Good first issue for people wanting to contribute to the project." +- name: "help-wanted" + color: 0e8a16 + description: "We need some extra helping hands or expertise in order to resolve this." + +- name: "priority-critical" + color: ee0701 + description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." +- name: "priority-high" + color: b60205 + description: "After critical issues are fixed, these should be dealt with before any further issues." +- name: "priority-medium" + color: 0e8a16 + description: "This issue may be useful, and needs some attention." +- name: "priority-low" + color: e4ea8a + description: "Nice addition, maybe... someday..." + +- name: "major" + color: b60205 + description: "This PR causes a major version bump in the version number." +- name: "minor" + color: 0e8a16 + description: "This PR causes a minor version bump in the version number." diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 0000000..01c5b73 --- /dev/null +++ b/.github/workflows/labels.yml @@ -0,0 +1,22 @@ +--- +name: Sync labels + +on: + push: + branches: + - master + paths: + - .github/labels.yml + workflow_dispatch: + +jobs: + labels: + name: ๐Ÿท Sync labels + runs-on: ubuntu-latest + steps: + - name: โคต๏ธ Checkout code + uses: actions/checkout@v4.1.1 + - name: ๐Ÿš€ Run label sync + uses: micnncim/action-label-syncer@v1.3.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml index 9c7f3c8..019e0e7 100644 --- a/.github/workflows/pr-labels.yml +++ b/.github/workflows/pr-labels.yml @@ -1,5 +1,4 @@ -# Verify valid PR labels - +--- name: PR Labels on: @@ -11,13 +10,15 @@ on: branches: - master +permissions: {} + jobs: pr_labels: name: Verify runs-on: ubuntu-latest steps: - - name: Verify PR has a valid label + - name: ๐Ÿท Verify valid PR label uses: ludeeus/action-require-labels@1.1.0 with: labels: >- - breaking change, bug, chore, enhancement, refactor, documentation + breaking-change, bugfix, chore, dependencies, documentation, enhancement, new-feature, refactor diff --git a/.github/workflows/pre-commit-updater.yml b/.github/workflows/pre-commit-updater.yml index e654f9a..51250d3 100644 --- a/.github/workflows/pre-commit-updater.yml +++ b/.github/workflows/pre-commit-updater.yml @@ -1,31 +1,35 @@ -# Update pre-commit on a daily interval - +--- name: Pre-commit auto-update on: schedule: - - cron: '0 0 * * *' + - cron: '0 0 * * 1' + +env: + DEFAULT_PYTHON: "3.11" + jobs: auto-update: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Python + - name: โคต๏ธ Checkout code + uses: actions/checkout@v4.0.0 + - name: ๐Ÿ Setup Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.0.0 with: - python-version: '3.10' - - name: Install pre-commit + python-version: ${{ env.DEFAULT_PYTHON }} + - name: ๐Ÿšง Install pre-commit run: pip install pre-commit - - name: Run pre-commit autoupdate + - name: ๐Ÿšง Run pre-commit autoupdate run: pre-commit autoupdate - - name: Create Pull Request + - name: ๐Ÿ…ฟ๏ธ Create Pull Request uses: peter-evans/create-pull-request@v6.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} - branch: update/pre-commit-autoupdate + branch: chore/pre-commit-autoupdate title: Auto-update pre-commit hooks commit-message: Auto-update pre-commit hooks body: | Update versions of tools in pre-commit configs to latest version - labels: chore + labels: dependencies diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml deleted file mode 100644 index f8f306d..0000000 --- a/.github/workflows/release-package.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Upload package to PyPI - -name: Publish PyPI - -on: - release: - types: [published] - -jobs: - build-and-publish: - name: Build and publish package to PyPI - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/project/pypaperless - permissions: - id-token: write # important for trusted publishing (pypi) - outputs: - version: ${{ steps.vars.outputs.tag }} - steps: - - uses: actions/checkout@v4 - - name: Get tag - id: vars - run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT - - name: Set up Python - uses: actions/setup-python@v5.0.0 - with: - python-version: '3.10' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build tomli tomli-w - - name: Set Python project version from tag - shell: python - run: |- - import tomli - import tomli_w - - with open("pyproject.toml", "rb") as f: - pyproject = tomli.load(f) - - pyproject["project"]["version"] = "${{ steps.vars.outputs.tag }}" - - with open("pyproject.toml", "wb") as f: - tomli_w.dump(pyproject, f) - - name: Build package - run: python -m build - - name: Upload package to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.11 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fab19e6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +--- +name: Release + +on: + release: + types: + - published + +env: + DEFAULT_PYTHON: "3.11" + +jobs: + release: + name: Releasing to PyPi + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pypaperless + permissions: + contents: write + id-token: write # important for trusted publishing (pypi) + outputs: + version: ${{ steps.vars.outputs.tag }} + steps: + - name: โคต๏ธ Checkout code + uses: actions/checkout@v4.0.0 + - name: ๐Ÿท Get repository tag + id: vars + run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT + - name: ๐Ÿ Setup Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v5.0.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: ๐Ÿšง Install dependencies + run: | + python -m pip install --upgrade pip + pip install build tomli tomli-w + - name: ๐Ÿšง Set project version from tag + shell: python + run: |- + import tomli + import tomli_w + with open("pyproject.toml", "rb") as f: + pyproject = tomli.load(f) + pyproject["project"]["version"] = "${{ steps.vars.outputs.tag }}" + with open("pyproject.toml", "wb") as f: + tomli_w.dump(pyproject, f) + - name: ๐Ÿš€ Build package + run: python -m build + - name: โฌ†๏ธ Publish to PyPi + uses: pypa/gh-action-pypi-publish@v1.8.11 + - name: ๐Ÿ” Sign published artifacts + uses: sigstore/gh-action-sigstore-python@v2.1.1 + with: + inputs: ./dist/*.tar.gz ./dist/*.whl + release-signing-artifacts: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 02fc21a..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Run tests on each push and pull request - -name: Run tests - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12"] - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: pip install . .[test] - - name: Lint and test with pre-commit - run: pre-commit run --all-files - - name: Pytest and coverage - run: pytest tests --cov - - name: Turn coverage into xml - run: python -m coverage xml - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - with: - file: ./coverage.xml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a912c83 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,62 @@ +--- +name: Tests + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +env: + DEFAULT_PYTHON: "3.11" + +jobs: + pytest: + name: Testing on Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + steps: + - name: โคต๏ธ Checkout code + uses: actions/checkout@v4.0.0 + - name: ๐Ÿ Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5.0.0 + with: + python-version: ${{ matrix.python-version }} + - name: ๐Ÿšง Install dependencies + run: pip install . .[test] + - name: ๐Ÿš€ Run Pytest + run: pytest --cov pypaperless tests + - name: โฌ†๏ธ Upload coverage + uses: actions/upload-artifact@v4.3.1 + with: + name: coverage-${{ matrix.python-version }} + path: .coverage + + coverage: + name: Coverage report + runs-on: ubuntu-latest + needs: pytest + steps: + - name: โคต๏ธ Checkout code + uses: actions/checkout@v4.0.0 + - name: โฌ‡๏ธ Download coverage + uses: actions/download-artifact@v4.1.2 + - name: ๐Ÿ Setup Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v5.0.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: ๐Ÿšง Install dependencies + run: pip install . .[test] + - name: ๐Ÿš€ Process coverage results + run: | + coverage combine coverage*/.coverage* + coverage xml -i + - name: โฌ†๏ธ Upload coverage to Codecov + uses: codecov/codecov-action@v4.0.1 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + file: ./coverage.xml diff --git a/.github/workflows/typing-linting.yml b/.github/workflows/typing-linting.yml new file mode 100644 index 0000000..446d0eb --- /dev/null +++ b/.github/workflows/typing-linting.yml @@ -0,0 +1,28 @@ +--- +name: Typing and Linting + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +env: + DEFAULT_PYTHON: "3.11" + +jobs: + typing-and-linting: + name: Run pre-commit hooks + runs-on: ubuntu-latest + steps: + - name: โคต๏ธ Checkout code + uses: actions/checkout@v4.0.0 + - name: ๐Ÿ Setup Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v5.0.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: ๐Ÿšง Install dependencies + run: pip install . .[test] + - name: ๐Ÿš€ Run pre-commit hooks + run: pre-commit run --all --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd8fe5b..ff3fdb6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,22 +1,36 @@ +--- repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: check-yaml + name: โœ… Check yaml files + - id: check-toml + name: โœ… Check toml files + - id: check-ast + name: โœ… Check files are valid python + - id: check-docstring-first + name: โœ… Check docstrings first + - id: debug-statements + name: โœ… Check debug statements - id: end-of-file-fixer + name: โฎ Fix end of files - id: trailing-whitespace + name: โฎ Trailing whitespace - id: no-commit-to-branch - args: - - --branch=main - - id: debug-statements + name: ๐Ÿ›‘ No commit to master branch - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.2.1 hooks: - id: ruff + name: ๐Ÿถ Ruff Check + - id: ruff-format + name: ๐Ÿถ Ruff Format - repo: https://github.com/psf/black rev: 24.1.1 hooks: - id: black + name: โœ… Black Format args: - --safe - --quiet @@ -24,22 +38,22 @@ repos: rev: v2.2.6 hooks: - id: codespell + name: โœ… Check code for proper spelling args: [] exclude_types: [csv, json] - exclude: ^tests/fixtures/ additional_dependencies: - tomli - repo: local hooks: - id: mypy - name: mypy + name: ๐Ÿ†Ž Type checking with mypy entry: script/run-in-env.sh mypy language: script types: [python] require_serial: true files: ^pypaperless/.+\.py$ - id: pylint - name: pylint + name: ๐ŸŒŸ Rating code with pylint entry: script/run-in-env.sh pylint -j 0 language: script types: [python] diff --git a/Dockerfile b/Dockerfile index e47bbaa..c37a134 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.11 +FROM mcr.microsoft.com/vscode/devcontainers/python:3.12 ENV PYTHONUNBUFFERED 1 diff --git a/README.md b/README.md index 5dad101..835af20 100644 --- a/README.md +++ b/README.md @@ -6,27 +6,556 @@ Little api client for [Paperless-ngx](https://github.com/paperless-ngx/paperless Find out more here: -* Project: https://docs.paperless-ngx.com -* REST API: https://docs.paperless-ngx.com/api/ +- Project: https://docs.paperless-ngx.com +- REST API: https://docs.paperless-ngx.com/api/ ## Features - Depends on aiohttp, works in async environments. - Token authentication only. **No credentials anymore.** -- `list()` requests all object ids of resources. -- `get()` for each resources. Accepts Django filters. -- `iterate()` for each endpoint. Accepts Django filters. -- `create()`, `update()`, `delete()` methods for many resources. -- Paperless makes use of pagination. We use that too. You have full control. -- *PyPaperless* only transports data. Your code organizes it. +- Request single resource items. +- Iterate over all resource items or request them page by page. +- Create, update and delete resource items. +- Almost feature complete. +- _PyPaperless_ is designed to transport data only. Your code must organize it. ## Documentation -* [Handling a session](docs/SESSION.md) -* [Request data](docs/REQUEST.md) -* [Create, update, delete data](docs/CRUD.md) +- [Starting a session](#starting-a-session) + - [Quickstart](#quickstart) + - [URL rules](#url-rules) + - [Custom session](#custom-session) + - [Creating a token](#creating-a-token) +- [Resource features](#resource-features) +- [Requesting data](#request-data) + - [Getting one item by primary key](#getting-one-item-by-primary-key) + - [Retrieving a list of primary keys](#retrieving-a-list-of-primary-keys) + - [Iterating over resource items](#iterating-over-resource-items) + - [Iterating over pages](#iterating-over-pages) + - [Reducing http requests](#reducing-http-requests) +- [Manipulating data](#manipulating-data) + - [Creating new items](#creating-new-items) + - [Updating existing items](#updating-existing-items) + - [Deleting items](#deleting-items) +- [Special cases](#special-cases) + - [Document binary data](#document-binary-data) + - [Document metadata](#document-metadata) + - [Document notes](#document-notes) + - [Document searching](#document-searching) + - [Document suggestions](#document-suggestions) + - [Next available ASN](#next-available-asn) + +### Starting a session + +#### Quickstart + +Just import the module and go on. Note that we must be async. + +```python +import asyncio + +from pypaperless import Paperless + +paperless = Paperless("localhost:8000", "your-secret-token") + +# see main() examples + +asyncio.run(main()) +``` + +**main() Example 1** + +```python +async def main(): + await paperless.initialize() + # do something + await paperless.close() +``` + +**main() Example 2** + +```python +async def main(): + async with paperless: + # do something +``` + +#### URL rules + +There are some rules for the Paperless-ngx url. + +1. Isn't a scheme applied to it? `https` is automatically used. +2. Does the url explicitly start with `http`? Okay, be unsafe :dizzy_face:. +3. Only use the **base url** of your Paperless-ngx. Don't add `/api` to it. + +#### Custom session + +You may want to use a customized session in some cases. The `PaperlessSession` object will pass optional kwargs to each request method call, it is utilizing an `aiohttp.ClientSession` under the hood. + +```python +from pypaperless import Paperless, PaperlessSession + +my_session = PaperlessSession("localhost:8000", "your-secret-token", ssl=False, ...) + +paperless = Paperless(session=my_session) +``` + +You also can implement your own session class. The code of `PaperlessSession` isn't too big and easy to understand. Your custom class must at least implement `__init__` and `request` methods, or simply derive it from `PaperlessSession`. + +```python +class MyCustomSession(PaperlessSession): + # start overriding methods +``` + +#### Creating a token + +_PyPaperless_ needs an API token to request and send data from and to Paperless-ngx for authentication purposes. I recommend you to create a technical user and assign a token to it via Django Admin, when you bootstrap any project with _PyPaperless_. If you need to create that token by providing credentials, _PyPaperless_ ships with a little helper for that task. + +```python +token = Paperless.generate_api_token( + "localhost:8000", + "test_user", + "not-so-secret-password-anymore", +) +``` + +This method utilizes `PaperlessSession`, so the same rules apply to it as when initiating a regular `Paperless` session. It also accepts a custom `PaperlessSession`: + +```python +url = "localhost:8000" +session = PaperlessSession(url, "") # empty token string + +token = Paperless.generate_api_token( + "localhost:8000", + "test_user", + "not-so-secret-password-anymore", + session=session, +) +``` + +> [!CAUTION] +> Hardcoding credentials or tokens is never good practise. Use that with caution. + +The code above executes one http request: + +`POST` `https://localhost:8000/api/token/` + +### Resource features + +| Resource | Request | Iterate | Create | ย Update | Delete | +| -------------- | -------- | ------- | ------ | ------- | ------ | +| config | x | | | | +| correspondents | x | x | x | x | x | +| custom_fieldsย  | x | x | x | x | x | +| document_types | x | x | x | x | x | +| documents | x | x | x | xย  | x | +| groups | x | x | | | +| logs | **n.a.** | +| mail_accounts | x | x | | | +| mail_rules | x | x | | | +| saved_views | x | x | | | | +| share_links | x | x | x | x | x | +| storage_paths | x | x | x | x | x | +| tags | x | x | x | x | x | +| tasks | x | x\* | | | +| users | x | x | | | +| workflows | x | x | | | + +\*: Only `__aiter__` is supported. + +`logs` are not implemented, as they return plain text. I cannot imagine any case where that could be needed by someone. + +### Requesting data + +Retrieving data from Paperless-ngx is really easy, there are different possibilities to achieve that. + +#### Getting one item by primary key + +You'll need to use that in the most cases, as _PyPaperless_ always returns references to other resource items by their primary keys. You must resolve these references on your own. The returned objects are always `PaperlessModel`s. + +```python +document = await paperless.documents(1337) +doc_type = await paperless.document_types(document.document_type) # 23 + +print(f"Document '{document.title}' is an {doc_type.name}.") +#-> Document 'Order #23: Desktop Table' is an Invoice. +``` + +The code above executes two http requests: + +`GET` `https://localhost:8000/api/documents/1337/`
+`GET` `https://localhost:8000/api/document_types/23/` + +#### Retrieving a list of primary keys + +Since resource items are requested by their primary key, it could be useful to request a list of all available primary keys. + +```python +item_keys = await paperless.documents.all() +#-> [1, 2, 3, ...] +``` + +The code above executes one http request: + +`GET` `https://localhost:8000/api/documents/?page=1` + +#### Iterating over resource items + +Iteration enables you to execute mass operations of any kind. Like requesting single items, the iterator always returns `PaperlessModel`s. + +```python +count = 0 +async for item in paperless.documents: + if item.correspondent == 1: + count += 1 +print(f"{count} documents are currently stored for correspondent 1.") +#-> 5 documents are currently stored for correspondent 1. +``` + +The code above executes many http requests, depending on the count of your stored documents: + +`GET` `https://localhost:8000/api/documents/?page=1`
+`GET` `https://localhost:8000/api/documents/?page=2`
+`...`
+`GET` `https://localhost:8000/api/documents/?page=19` + +#### Iterating over pages + +Instead of iterating over resource items, you may want to iterate over pagination results in some cases. The `Page` model itself delivers the possibility to check for the existence of previous and next pages, item counts, accessing the raw (`.results`) or processed data (`.items`), and so on. + +```python +page_iter = aiter(paperless.documents.pages()) +page = await anext(page_iter) +#-> page.current_page == 1 +page = await anext(page_iter) +#-> page.current_page == 2 +``` + +The code above executes two http requests: + +`GET` `https://localhost:8000/api/documents/?page=1`
+`GET` `https://localhost:8000/api/documents/?page=2` + +#### Reducing http requests + +Requesting many pages can be time-consuming, so a better way to apply the filter (mentioned [here](#iterating-over-resource-items)) is using the `reduce` context. Technically, it applies query parameters to the http request, which are interpreted as filters by Paperless-ngx. + +```python +filters = { + "correspondent__id": 1, +} +async with paperless.documents.reduce(**filters) as filtered: + async for item in filtered: + count += 1 +# ... +#-> 5 documents are currently stored for correspondent 1. +``` + +The code above executes just one http request, and achieves the same: + +`GET` `https://localhost:8000/api/documents/?page=1&correspondent__id=1` + +> [!TIP] +> The `reduce` context works with all previously mentioned methods: `__aiter__`, `all` and `pages`. + +> [!NOTE] +> There are many filters available, _PyPaperless_ doesn't provide a complete list. I am working on that. At the moment, you must use the Django Rest framework http endpoint of Paperless-ngx in your browser and play around with the **Filter** button on each resource. +> +> Paperless-ngx simply ignores filters which don't exist and treats them as no filter instead of raising errors, be careful. + +### Manipulating data + +_PyPaperless_ offers creation, update and deletion of resource items. These features are enabled where it makes (at least for me) sense, Paperless-ngx itself offers full CRUD functionality. Please check the [resource features](#resource-features) table at the top of this README. If you need CRUD for another resource, please let me know and open an [issue](https://github.com/tb1337/paperless-api/issues) with your specific use-case. + +#### Creating new items + +The process of creating items consists of three parts: retrieving a new draft instance from _PyPaperless_, apply data to it and call `save`. You can choose whether applying data to the draft via `kwargs` or by assigning it to the draft instance, or both. Maybe you want to request the newly created item by the returned primary key and compare it against the data from the draft. If not, you can safely trash the draft instance after saving, as it cannot be saved twice (database constraint violation). + +```python +from pypaperless.models.common import MatchingAlgorithmType + +draft = paperless.correspondents.draft( + name="New correspondent", + is_insensitive=True, # this works +) +draft.matching_algorithm = MatchingAlgorithmType.ANY +draft.match = 'any word "or small strings" match' +draft.is_insensitive = False # and this, too! + +new_pk = await draft.save() +#-> 42 +``` + +The code above executes one http request: + +`POST` `https://localhost:8000/api/correspondents/` + +#### Updating existing items + +When it comes to updating data, you can choose between http `PATCH` (only changed fields) or `PUT` (all fields) methods. Usually updating only changed fields will do the trick. You can continue working with the class instance after updating, as the `update` method applies new data from Paperless-ngx to it. + +```python +item = await paperless.documents(23) +item.title = "New document title" +success = await item.update() +success = await item.update(only_changed=False) # put all fields +#-> True +``` + +The code above executes two http requests: + +`PATCH` `http://localhost:8000/api/documents/23/`
+`PUT` `http://localhost:8000/api/documents/23/` + +> [!NOTE] +> The actual payload of the request is completely different here, and I recommend you to use `PATCH` whenever possible. It is cleaner and much safer, as it only updates fields which have _actually_ changed. + +**PATCH** + +```json +{ + "title": "New document title" +} +``` + +**PUT** + +```json +{ + "title": "New document title", + "content": "...", + "correspondents": ["..."], + "document_types": ["..."], + "storage_paths": ["..."], + "...": "..." + // and every other field +} +``` + +#### Deleting items + +Lust but not least, it is also possible to remove data from Paperless-ngx. + +> [!CAUTION] +> This will permanently delete data from your database. There is no point of return. Be careful. + +```python +item = await paperless.documents(23) +success = await item.delete() +#-> True +``` + +The code above executes one http request: + +`DELETE` `http://localhost:8000/api/documents/23/` + +### Special cases + +Some Paperless-ngx resources provide more features as others, especially when it comes to `Documents`. + +#### Document binary data + +You can access the binary data by using the following methods. They all return a `DownloadedDocument` class instance, which holds the binary data and provides some more useful attributes, like content type, disposition type and filename. + +**Example 1: Provide a primary key** + +```python +download = await paperless.documents.download(23) +preview = await paperless.documents.preview(23) +thumbnail = await paperless.documents.thumbnail(23) +``` + +**Example 2: Already fetched item** + +```python +document = await paperless.documents(23) + +download = await document.get_download() +preview = await document.get_preview() +thumbnail = await document.get_thumbnail() +``` + +Both codes above execute all of these http requests: + +`GET` `https://localhost:8000/api/documents/23/download/`
+`GET` `https://localhost:8000/api/documents/23/preview/`
+`GET` `https://localhost:8000/api/documents/23/thumb/` + +#### Document metadata + +Paperless-ngx stores some metadata about your documents. If you wish to access that, there are again two possibilities. + +**Example 1: Provide a primary key** + +```python +metadata = await paperless.documents.metadata(23) +``` + +**Example 2: Already fetched item** + +```python +document = await paperless.documents(23) +metadata = await document.get_metadata() +``` + +Both codes above execute one http request: + +`GET` `https://localhost:8000/api/documents/23/metadata/` + +#### Document notes + +Documents can be commented with so called notes. Paperless-ngx supports requesting, creating and deleting those notes. _PyPaperless_ ships with support for it, too. + +**Getting notes** + +Document notes are always available as `list[DocumentNote]` after requesting them. + +```python +# by primary key +list_of_notes = await paperless.documents.notes(23) + +# by already fetched item +document = await paperless.documents(23) +list_of_notes = await document.notes() +``` + +The code above executes one http request: + +`GET` `https://localhost:8000/api/documents/23/notes/` + +**Creating notes** + +You can add new notes. Updating existing notes isn't possible due to Paperless-ngx API limitations. + +```python +# by primary key +draft = paperless.documents.notes.draft(23) + +# by already fetched item +document = await paperless.documents(23) + +draft = document.notes.draft() +draft.note = "Lorem ipsum" + +new_note_pk, document_pk = await draft.save() +#-> 42, 23 +``` + +The code above executes one http request: + +`POST` `https://localhost:8000/api/documents/23/notes/` + +**Deleting notes** + +Sometimes it may be necessary to delete document notes. + +> [!CAUTION] +> This will permanently delete data from your database. There is no point of return. Be careful. + +```python +a_note = list_of_notes.pop() # document note with example pk 42 +success = await a_note.delete() +#-> True +``` + +The code above executes one http request: + +`DELETE` `https://localhost:8000/api/documents/23/notes/?id=42` + +#### Document searching + +If you want to seek after documents, Paperless-ngx offers two possibilities to achieve that. _PyPaperless_ implements two iterable shortcuts for that. + +**Search query** + +Search query documentation: https://docs.paperless-ngx.com/usage/#basic-usage_searching + +```python +async for document in paperless.documents.search("type:invoice"): + # do something +``` + +The code above executes many http requests, depending on the count of your matched documents: + +`GET` `https://localhost:8000/api/documents/?page=1&query=type%3Ainvoice`
+`GET` `https://localhost:8000/api/documents/?page=2&query=type%3Ainvoice`
+`...`
+`GET` `https://localhost:8000/api/documents/?page=19&query=type%3Ainvoice` + +**More like** + +Search for similar documents like the permitted document primary key. + +```python +async for document in paperless.documents.more_like(23): + # do something +``` + +The code above executes many http requests, depending on the count of your matched documents: + +`GET` `https://localhost:8000/api/documents/?page=1&more_like_id=23`
+`GET` `https://localhost:8000/api/documents/?page=2&more_like_id=23`
+`...`
+`GET` `https://localhost:8000/api/documents/?page=19&more_like_id=23` + +**Search results** + +While iterating over search results, `Document` models are extended with another field: `search_hit`. Lets take a closer look at it. + +```python +async for document in paperless.documents.more_like(23): + print(f"{document.id} matched query by {document.search_hit.score}.") +#-> 42 matched query by 13.37. +``` + +To make life easier, you have the possibility to check whether a `Document` model has been initialized from a search or not: + +```python +document = await paperless.documents(23) # no search +if document.has_search_hit: + print("result of a search query") +else: + print("not a result from a query") +#-> not a result from a query +``` + +#### Document suggestions + +One of the biggest tasks of Paperless-ngx is _classification_: it is the workflow of assigning classifiers to your documents, like correspondents or tags. Paperless does that by auto-assigning or suggesting them to you. These suggestions can be accessed by _PyPaperless_, as well. + +**Example 1: Provide a primary key** + +```python +suggestions = await paperless.documents.suggestions(23) +``` + +**Example 2: Already fetched item** + +```python +document = await paperless.documents(23) +suggestions = await document.get_suggestions() +``` + +Both codes above execute one http request: + +`GET` `https://localhost:8000/api/documents/23/suggestions/` + +The returned `DocumentSuggestions` instance stores a list of suggested resource items for each classifier: correspondents, tags, document_types, storage_paths and dates. + +#### Next available ASN + +Simply returns the next available archive serial number as `int`. + +```python +next_asn = await paperless.documents.get_next_asn() +#-> 1337 +``` + +The code above executes one http request: + +`GET` `https://localhost:8000/api/documents/next_asn/` ## Thanks to -* The Paperless-ngx Team -* The Home Assistant Community +- The Paperless-ngx Team +- The Home Assistant Community diff --git a/docs/CRUD.md b/docs/CRUD.md deleted file mode 100644 index 479fd93..0000000 --- a/docs/CRUD.md +++ /dev/null @@ -1,205 +0,0 @@ -# Create, Update, Delete - -If you plan to manipulate your Paperless data, continue reading. Manipulation is the process of inserting, updating and deleting data from and to your Paperless database. - -In the following examples, we assume you already have initialized the `Paperless` object in your code. - -- [Supported resources](#supported-resources) -- [Default operations](#default-operations) - - [Update items](#update-items) - - [Create items](#create-items) - - [Delete items](#delete-items) -- [Special cases](#special-cases) - - [Document Notes](#document-notes) - - [Document Custom Fields](#document-custom-fields) - -## Supported resources - -_PyPaperless_ enables create/update/delete wherever it makes sense: - -- correspondents -- custom_fields -- document_types -- documents - - custom_fields - - notes - - _metadata_ is not supported -- share_links -- storage_paths -- tags - -## Default operations - -### Update items - -The Paperless api enables us to change almost everything via REST. Personally, I use that to validate document titles, as I have declared naming conventions to document types. I try to apply the correct title to the document, and if that fails for some reason, it gets a _TODO_ tag applied. So I can edit manually later on. - -> [!TIP] -> You may have other use-cases. Feel free to share them with me by opening an [issue](https://github.com/tb1337/paperless-api/issues). - -Updating is as easy as requesting items. Gather any resource item object, update its attributes and call the `update()` method of the endpoint. - -**Example 1** - -```python -document = await paperless.documents.one(42) -document.title = "42 - The Answer" -document.content = """ -The Answer to the Ultimate Question of Life, -the Universe, and Everything. -""" -document = await paperless.documents.update(document) -#>>> Document(id=42, title="42 - The Answer", content="...", ...) -``` - -**Example 2** - -```python -filters = { - "title__istartswith": "invoice", -} -async for item in paperless.documents.iterate(**filters): - item.title = item.title.replace("invoice", "bill") - await paperless.documents.update(item) -``` - -Every `update()` call will send a `PUT` http request to Paperless, containing the full serialized item. That behaviour will be refactored in the future. Only changed attributes will be sent via `PATCH` http requests. ([#24](https://github.com/tb1337/paperless-api/issues/24)) - -### Create items - -It absolutely makes sense to create new data in the Paperless database, especially documents. Therefore, item creation is implemented for many resources. It differs slightly from `update()` and `delete()`. _PyPaperless_ doesn't validate data, its meant to be the transportation layer between your code and Paperless only. To reduce common mistakes, it provides special classes for creating new items. Use them. - -For every creatable resource exists a *Resource*Post class. Instantiate that class with some data and call the `create()` method of your endpoint. There you go. - -**Example for documents** - -```python -from pypaperless.models import DocumentPost - -# or read the contents of a file, whatver you want -content = b"..." - -# there are more attributes available, check type hints -new_document = DocumentPost(document=content) -task_id = await paperless.documents.create(new_document) -#>>> abcdefabcd-efab-cdef-abcd-efabcdefabcd -``` - -> [!TIP] -> You can access the current OCR status of your new document when requesting the `tasks` endpoint with that id. - -**Example for other resources** - -```python -from pypaperless.models import CorrespondentPost -from pypaperless.models.shared import MatchingAlgorithm - -new_correspondent = CorrespondentPost( - name="Salty correspondent", - match="Give me all your money", - matching_algorithm=MatchingAlgorithm.ALL, -) -# watch out, the result is a Correspondent object... -created_correspondent = paperless.correspondents.create(new_correspondent) -#>>> Correspondent(id=1337, name="Salty correspondent", ...) -``` - -Every `create()` call will send a `POST` http request to Paperless, containing the full serialized item. - -### Delete items - -In some cases, you want to delete items. Its almost the same as updating, just call the `delete()` method. Lets delete that salty guy again, including all of his documents! - -> [!CAUTION] -> This will permanently delete data from your Paperless database. There is no point of return. - -```python -# ... -filters = { - "correspondent__id": new_correspondent.id -} -async for item in paperless.documents.iterate(**filters): - await paperless.documents.delete(item) - -await paperless.correspondents.delete(created_correspondent) -``` - -Every `delete()` call will send a `DELETE` http request to Paperless without any payload. - -## Special cases - -There are some resources that differ from default operations. Luckily you wouldn't need that very often. - -### Document Notes - -Document notes are treated as a sub-resource by Paperless and got some special handling on requesting, creating and deleting. - -> [!NOTE] -> Updating existing document notes is currently impossible due to Paperless api limitations. - -**There are two ways of requesting document notes:** - -```python -# request a document and access its notes property -document = await paperless.documents.one(23) -#>>> Document(..., notes=[ -#>>> DocumentNote(id=1, note="Sample note.", document=23, user=1, created=datetime.datetime()), -#>>> ... -#>>> ], ...) - -# request document notes by applying a document id or object -notes = await p.documents.notes.get(23) -notes = await p.documents.notes.get(document) -#>>> [ -#>>> DocumentNote(id=1, note="Sample note.", document=23, user=1, created=datetime.datetime()), -#>>> ... -#>>> ] -``` - -**Lets create and delete document notes:** - -```python -# add new note to a document -from pypaperless.models import DocumentNotePost - -note = DocumentNotePost(note="New sample note.", document=23) -await p.documents.notes.create(note) # we defined the document id in the Post model - -# deleting document notes can be tricky, you need the -# DocumentNote object for it, which will be a very rare case -document_note = (await p.documents.notes.get(23)).pop() -await p.documents.notes.delete(document_note) -``` - -### Document Custom Fields - -Custom Fields are managed in the Paperless configuration. _PyPaperless_ enables you to attach values for them to your documents. Currently, they are of _Any_ type, so you must pay attention to their actual values. - -On calling the persistence method, the complete list of CustomFieldValues will replace the current one. That could lead to unintended changes: if you persist an empty list, all fields are removed from the document. - -> [!CAUTION] -> You could overwrite or delete all of your Custom Field attachments to a document, so be careful. I want to overhaul the Custom Field feature somewhere in the future. - -```python -# request a document and access its custom fields -document = await paperless.documents.one(42) -#>>> Document(..., custom_fields=[ -#>>> CustomFieldValue(field=1, value="I am a field value"), -#>>> CustomFieldValue(field=2, value=True), -#>>> ... -#>>> ], ...) - -# you can do everything with that list, append, pop, etc. -# as long as list values are of type CustomFieldValue -fields = document.custom_fields -fields.pop() -fields.append(CustomFieldValue(field=2, value=False)) - -# persist changes. the list is taken as-is and replaces the current one -document = paperless.documents.custom_fields(document, fields) -#>>> Document(..., custom_fields=[ ... -#>>> CustomFieldValue(field=2, value=False), -#>>> ... -#>>> ], ...) - -``` diff --git a/docs/REQUEST.md b/docs/REQUEST.md deleted file mode 100644 index 8273c99..0000000 --- a/docs/REQUEST.md +++ /dev/null @@ -1,117 +0,0 @@ -# Requesting data - -It's all about accessing data, that's obviously the reason you downloaded *PyPaperless*. - -In the following examples, we assume you already have initialized the `Paperless` object in your code. - -- [Basic requests](#basic-requests) - - [Requesting a list of pk's](#requesting-a-list-of-pks) - - [Requesting an item](#requesting-an-item) - - [Requesting paginated items of a resource](#requesting-paginated-items-of-a-resource) - - [Iterating over all resource items](#iterating-over-all-resource-items) -- [Filtered requests](#filtered-requests) - - [Requesting filtered pages of a resource](#requesting-filtered-pages-of-a-resource) - - [Iterating over filtered resource items](#iterating-over-filtered-resource-items) -- [Further information](#further-information) - -## Basic requests - -The following examples should solve the most use-cases. - -### Requesting a list of pk's - -Paperless returns a JSON key *all* on every paginated request, which represents a list of all pk's matching our filtered request. The `list()` method requests page one unfiltered, resulting in getting a complete list. - -```python -correspondent_pks = await paperless.correspondents.list() -#>>> [1, 2, 3, ...] -``` - -It's the same for each resource. Let's try with documents: - -```python -document_pks = await paperless.documents.list() -#>>> [5, 23, 42, 1337, ...] -``` - -### Requesting an item - -You may want to actually access the data of Paperless resources. Lets do it! - -```python -# request document with pk 23 -document = await paperless.documents.one(23) -#>>> Document(id=23, ...) -``` - -### Requesting paginated items of a resource - -Accessing single resources by pk would result in too many requests, so you can access the paginated results, too. - -```python -# request page 1 -documents = await paperless.documents.get() -#>>> PaginatedResult(current_page=1, next_page=2, items=[Document(...), ...]) - -# request page 2 -documents = await paperless.documents.get(page=2) -#>>> PaginatedResult(current_page=2, next_page=3, items=[Document(...), ...]) -``` - -If you are requesting the last page, the `next_page` property would be `None`. - -### Iterating over all resource items - -Sometimes, dealing with pages makes no sense for you, so you may want to iterate over all items at once. - -> [!NOTE] -> Iterating over all documents could take some time, depending on how many items you have stored in your database. - -```python -async for item in paperless.documents.iterate(): - print(item.title) - #>>> 'New Kitchen Invoice' -``` - -## Filtered requests - -Sometimes, you want to filter results in order to access them faster, or to apply context to your requests, or both. In case of documents, iterating over them can be very time-consuming. The Paperless api provides filter query attributes for each resource. There are **many** filters, so I cannot list them all. The easiest way to find them out is accessing the Api Root of your local Paperless installation, by adding `/api/` to the url. - -For example: `http://localhost:8000/api/` - -Once the list of all api endpoints is available, choose your resource by clicking the link next to the name. If a **Filter** button is displayed on the top of the next page, filtering is supported by the resource. Click the button to access all available filters, apply a dummy filter and click on the **Apply** button. -The website now displays something like that under the heading resource name: -`GET /api/documents/?id__in=&id=&title__istartswith=&title__iendswith=&title__icontains=...` - -The names of the query parameters are available as keywords in the `get()`and `รฌterate()` methods. - -### Requesting filtered pages of a resource - -Filters are passed as keywords to the `get()` method. - -```python -filters = { - "title__istartswith": "invoice", - "content__icontains": "cheeseburger", -} -filtered_documents = await paperless.documents.get(**filters) -#>>> PaginatedResult(current_page=1, next_page=None, items=[Document(...), ...]) -``` - -### Iterating over filtered resource items - -Iterating is also possible with filters and works the same way as requesting filtered pages. - -```python -# we assume you have declared the same filter dict as above -async for item in paperless.documents.iterate(**filters): - print(item.title) - #>>> 'Invoice for yummy cheeseburgers' -``` - -> [!NOTE] -> Paperless simply ignores filters which don't exist. You could end up iterating over all of your documents, which will take time in the worst case. Use filters carefully and check twice. - -## Further information - -Each `list()` and `get()` call results in a single `GET` http request. When using `iterate()`, 1-n `GET` http requests will be sent until all pages are requested. diff --git a/docs/SESSION.md b/docs/SESSION.md deleted file mode 100644 index 34d44ad..0000000 --- a/docs/SESSION.md +++ /dev/null @@ -1,119 +0,0 @@ -# Handling a session - -- [Connect to Paperless-ngx](#connect-to-paperless-ngx) - - [Some rules](#some-rules) - - [By url object](#by-url-object) - - [By url string](#by-url-string) - - [Force usage of http](#force-usage-of-http) - - [More](#more) - - [Customize aiohttp.ClientSession](#customize-aiohttpclientsession) - - [Customize aiohttp.ClientSession.request](#customize-aiohttpclientsessionrequest-kwargs) -- [Start working with your data](#start-working-with-your-data) - -Just import the module and go on. - -```python -import asyncio - -from pypaperless import Paperless - -paperless = Paperless("localhost:8000", "your-secret-token") - -# your main function here - -asyncio.run(main()) -``` - -## Connect to Paperless-ngx - -*PyPaperless* makes use of *YARL* and applies some logic to the path when instantiating the Paperless object. - -### Some rules - -*PyPaperless* checks the passed urls and magically makes things work for you. Or not, in some cases. So be aware of the following rules: - -1. Isn't a scheme applied to it? Apply `https`. -2. Is `http` explicitly used in it? Okay, continue with `http` :dizzy_face:. -3. Doesn't it end with `/api`? Append `/api`. - -### By url object - -```python -from yarl import URL - -# your desired URL object -url = URL("homelab.lan").with_path("/path/to/paperless") -paperless = Paperless(url, "your-secret-token") -``` - -Connects to `https://homelab.lan/path/to/paperless/api`. - -### By url string - -If you don't want to create a YARL url object, simply pass a string to the Paperless object. - -```python -paperless = Paperless("paperless.lan", "your-secret-token") -``` - -Connects to `https://paperless.lan/api`. - -### Force usage of http - -As mentioned above, `http` is also possible. Just call is explicitly. - -```python -paperless = Paperless("http://paperless.lan", "your-secret-token") -``` - -Connects to `http://paperless.lan/api`. - -It is not possible to force `http`-usage by just applying a port number. `paperless.lan:80` will result in connecting to it via `https`, even if it seems odd. Well, use `scheme://` which is a far more clear intention, and everything is fine. - -### More - -#### Customize `aiohttp.ClientSession` - -If you want to use an already existing `aiohttp.ClientSession`, pass it to the Paperless object. - -```python -import aiohttp - -your_session = aiohttp.ClientSession() - -paperless = Paperless("your-url", "your-token", session=your_session) -``` - -#### Customize `aiohttp.ClientSession.request` kwargs - -You may want to pass custom data (f.e. ssl context) to the `request()` method, which is internally called by *PyPaperless* on each http-request. Pass it to the Paperless object. - -```python -paperless = Paperless("your-url", "your-token", request_opts={...}) -``` - -## Start working with your data - -Now you can utilize the Paperless object. - -**Example 1** - -```python -async def main(): - paperless.initialize() - # do something - paperless.close() -``` - -**Example 2** - -```python -async def main(): - async with paperless: - # do something -``` - -You may want to request or manipulate data. Read more about that here: - -* [Request data](REQUEST.md) -* [Create, update, delete data](CRUD.md) diff --git a/example/usage.py b/example/usage.py deleted file mode 100644 index f6aaf2b..0000000 --- a/example/usage.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Example usage.""" - -import asyncio -import logging -import os.path -import sys - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir))) - -logging.basicConfig( - level=logging.DEBUG, - format="%(levelname)-8s %(name)s - %(message)s", -) - -from pypaperless import Paperless # noqa - -paperless = Paperless( - "localhost:8000", - # replace with your own token - "17d85e03b83c4bfd9aa0e9a4e71dc3b79265d51e", - request_opts={"ssl": False}, -) - - -async def main(): - """Execute main function.""" - async with paperless as p: - correspondents = {} - async for item in p.correspondents.iterate(): - correspondents[item.id] = item - - documents = await p.documents.get(page=1) - for item in documents.items: - print( - f"Correspondent of document {item.id} is: {correspondents[item.correspondent].name}!" # noqa - ) - await asyncio.sleep(0.25) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/pypaperless/__init__.py b/pypaperless/__init__.py index 51d084f..a488837 100644 --- a/pypaperless/__init__.py +++ b/pypaperless/__init__.py @@ -1,362 +1,9 @@ """PyPaperless.""" -import logging -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import Any +from .api import Paperless +from .sessions import PaperlessSession -import aiohttp -from awesomeversion import AwesomeVersion -from yarl import URL - -from .const import ( - PAPERLESS_V1_8_0, - PAPERLESS_V1_17_0, - PAPERLESS_V2_0_0, - PAPERLESS_V2_3_0, - ControllerPath, - PaperlessFeature, -) -from .controllers import ( - ConsumptionTemplatesController, - CorrespondentsController, - CustomFieldsController, - DocumentsController, - DocumentTypesController, - GroupsController, - MailAccountsController, - MailRulesController, - SavedViewsController, - ShareLinksController, - StoragePathsController, - TagsController, - TasksController, - UsersController, - WorkflowActionsController, - WorkflowsController, - WorkflowTriggersController, +__all__ = ( + "Paperless", + "PaperlessSession", ) -from .errors import BadRequestException, ControllerConfusion, DataNotExpectedException -from .util import create_url_from_input - - -class Paperless: # pylint: disable=too-many-instance-attributes,too-many-public-methods - """Retrieves and manipulates data from and to paperless via REST.""" - - def __init__( - self, - url: str | URL, - token: str, - request_opts: dict[str, Any] | None = None, - session: aiohttp.ClientSession | None = None, - ): - """ - Initialize the Paperless api instance. - - Parameters: - * host: the hostname or IP-address of Paperless as string, or yarl.URL object. - * token: provide an api token created in Paperless Django settings. - * session: provide an existing aiohttp ClientSession. - """ - self._url = create_url_from_input(url) - self._token = token - self._request_opts = request_opts - self._session = session - self._initialized = False - self.logger = logging.getLogger(f"{__package__}[{self._url.host}]") - - self.features: PaperlessFeature = PaperlessFeature(0) - # api controllers - self._consumption_templates: ConsumptionTemplatesController | None = None - self._correspondents: CorrespondentsController | None = None - self._custom_fields: CustomFieldsController | None = None - self._documents: DocumentsController | None = None - self._document_types: DocumentTypesController | None = None - self._groups: GroupsController | None = None - self._mail_accounts: MailAccountsController | None = None - self._mail_rules: MailRulesController | None = None - self._saved_views: SavedViewsController | None = None - self._share_links: ShareLinksController | None = None - self._storage_paths: StoragePathsController | None = None - self._tags: TagsController | None = None - self._tasks: TasksController | None = None - self._users: UsersController | None = None - self._workflows: WorkflowsController | None = None - self._workflow_actions: WorkflowActionsController | None = None - self._workflow_triggers: WorkflowTriggersController | None = None - - @property - def url(self) -> URL: - """Return the url of Paperless.""" - return self._url - - @property - def is_initialized(self) -> bool: - """Return if connection is initialized.""" - return self._initialized - - @property - def consumption_templates(self) -> ConsumptionTemplatesController | None: - """Gateway to consumption templates.""" - return self._consumption_templates - - @property - def correspondents(self) -> CorrespondentsController | None: - """Gateway to correspondents.""" - return self._correspondents - - @property - def custom_fields(self) -> CustomFieldsController | None: - """Gateway to custom fields.""" - return self._custom_fields - - @property - def documents(self) -> DocumentsController | None: - """Gateway to document types.""" - return self._documents - - @property - def document_types(self) -> DocumentTypesController | None: - """Gateway to document types.""" - return self._document_types - - @property - def groups(self) -> GroupsController | None: - """Gateway to groups.""" - return self._groups - - @property - def mail_accounts(self) -> MailAccountsController | None: - """Gateway to mail accounts.""" - return self._mail_accounts - - @property - def mail_rules(self) -> MailRulesController | None: - """Gateway to mail rules.""" - return self._mail_rules - - @property - def saved_views(self) -> SavedViewsController | None: - """Gateway to saved views.""" - return self._saved_views - - @property - def share_links(self) -> ShareLinksController | None: - """Gateway to share links.""" - return self._share_links - - @property - def storage_paths(self) -> StoragePathsController | None: - """Gateway to storage paths.""" - return self._storage_paths - - @property - def tags(self) -> TagsController | None: - """Gateway to tags.""" - return self._tags - - @property - def tasks(self) -> TasksController | None: - """Gateway to tasks.""" - return self._tasks - - @property - def users(self) -> UsersController | None: - """Gateway to users.""" - return self._users - - @property - def workflows(self) -> WorkflowsController | None: - """Gateway to workflows.""" - return self._workflows - - @property - def workflow_actions(self) -> WorkflowActionsController | None: - """Gateway to workflow actions.""" - return self._workflow_actions - - @property - def workflow_triggers(self) -> WorkflowTriggersController | None: - """Gateway to workflow triggers.""" - return self._workflow_triggers - - async def initialize(self) -> None: - """Initialize the connection to the api and fetch the endpoints.""" - self.logger.info("Fetching api endpoints.") - - async with self.generate_request("get", f"{self._url}") as res: - version = AwesomeVersion( - res.headers.get("x-version") if "x-version" in res.headers else "0.0.0" - ) - - if version >= PAPERLESS_V1_8_0: - self.features |= PaperlessFeature.CONTROLLER_STORAGE_PATHS - if version >= PAPERLESS_V1_17_0: - self.features |= PaperlessFeature.FEATURE_DOCUMENT_NOTES - if version >= PAPERLESS_V2_0_0: - self.features |= ( - PaperlessFeature.CONTROLLER_SHARE_LINKS - | PaperlessFeature.CONTROLLER_CONSUMPTION_TEMPLATES - | PaperlessFeature.CONTROLLER_CUSTOM_FIELDS - ) - if version >= PAPERLESS_V2_3_0: - self.features |= ( - PaperlessFeature.CONTROLLER_CONFIGS | PaperlessFeature.CONTROLLER_WORKFLOWS - ) - self.features ^= PaperlessFeature.CONTROLLER_CONSUMPTION_TEMPLATES - - paths = await res.json() - - self._correspondents = CorrespondentsController( - self, paths.pop(ControllerPath.CORRESPONDENTS) - ) - self._documents = DocumentsController(self, paths.pop(ControllerPath.DOCUMENTS)) - self._document_types = DocumentTypesController( - self, paths.pop(ControllerPath.DOCUMENT_TYPES) - ) - self._groups = GroupsController(self, paths.pop(ControllerPath.GROUPS)) - self._mail_accounts = MailAccountsController(self, paths.pop(ControllerPath.MAIL_ACCOUNTS)) - self._mail_rules = MailRulesController(self, paths.pop(ControllerPath.MAIL_RULES)) - self._saved_views = SavedViewsController(self, paths.pop(ControllerPath.SAVED_VIEWS)) - self._tags = TagsController(self, paths.pop(ControllerPath.TAGS)) - self._tasks = TasksController(self, paths.pop(ControllerPath.TASKS)) - self._users = UsersController(self, paths.pop(ControllerPath.USERS)) - - try: - if PaperlessFeature.CONTROLLER_STORAGE_PATHS in self.features: - self._storage_paths = StoragePathsController( - self, paths.pop(ControllerPath.STORAGE_PATHS) - ) - if PaperlessFeature.CONTROLLER_CONSUMPTION_TEMPLATES in self.features: - self._consumption_templates = ConsumptionTemplatesController( - self, paths.pop(ControllerPath.CONSUMPTION_TEMPLATES) - ) - if PaperlessFeature.CONTROLLER_CUSTOM_FIELDS in self.features: - self._custom_fields = CustomFieldsController( - self, paths.pop(ControllerPath.CUSTOM_FIELDS) - ) - if PaperlessFeature.CONTROLLER_SHARE_LINKS in self.features: - self._share_links = ShareLinksController( - self, paths.pop(ControllerPath.SHARE_LINKS) - ) - if PaperlessFeature.CONTROLLER_WORKFLOWS in self.features: - self._workflows = WorkflowsController(self, paths.pop(ControllerPath.WORKFLOWS)) - self._workflow_actions = WorkflowActionsController( - self, paths.pop(ControllerPath.WORKFLOW_ACTIONS) - ) - self._workflow_triggers = WorkflowTriggersController( - self, paths.pop(ControllerPath.WORKFLOW_TRIGGERS) - ) - except KeyError as exc: - raise ControllerConfusion(exc) from exc - - self._initialized = True - - if len(paths) > 0: - self.logger.debug("Unused paths: %s", ", ".join(paths)) - self.logger.info("Initialized.") - - async def close(self) -> None: - """Clean up connection.""" - if self._session: - await self._session.close() - self.logger.info("Closed.") - - @asynccontextmanager - async def generate_request( - self, - method: str, - path: str, - **kwargs: Any, - ) -> AsyncGenerator[aiohttp.ClientResponse, None]: - """Create a client response object for further use.""" - if not isinstance(self._session, aiohttp.ClientSession): - self._session = aiohttp.ClientSession() - - path = path.rstrip("/") + "/" # check and add trailing slash - - if isinstance(self._request_opts, dict): - kwargs.update(self._request_opts) - - kwargs.setdefault("headers", {}) - kwargs["headers"].update( - { - "accept": "application/json; version=2", - "authorization": f"Token {self._token}", - } - ) - - # convert form to FormData, if dict - if "form" in kwargs: - payload = kwargs.pop("form") - if not isinstance(payload, dict): - raise TypeError() - form = aiohttp.FormData() - - # we just convert data, no nesting dicts - for key, value in payload.items(): - if isinstance(value, str | bytes): - form.add_field(key, value) - elif isinstance(value, list): - for list_value in value: - form.add_field(key, f"{list_value}") - else: - form.add_field(key, f"{value}") - - kwargs["data"] = form - - # request data - async with self._session.request(method, path, **kwargs) as res: - yield res - - async def request_json( - self, - method: str, - endpoint: str, - **kwargs: Any, - ) -> Any: - """Make a request to the api and parse response json to dict.""" - async with self.generate_request(method, endpoint, **kwargs) as res: - self.logger.debug("Json-Request %s (%d): %s", method.upper(), res.status, res.url) - - # bad request - if res.status == 400: - raise BadRequestException(f"{await res.text()}") - # no content, in most cases on DELETE method - if res.status == 204: - return {} - res.raise_for_status() - - if res.content_type != "application/json": - raise DataNotExpectedException(f"Content-type is not json! {res.content_type}") - - return await res.json() - - async def request_file( - self, - method: str, - endpoint: str, - **kwargs: Any, - ) -> bytes: - """Make a request to the api and return response as bytes.""" - async with self.generate_request(method, endpoint, **kwargs) as res: - self.logger.debug("File-Request %s (%d): %s", method.upper(), res.status, res.url) - - # bad request - if res.status == 400: - raise BadRequestException(f"{await res.text()}") - res.raise_for_status() - - return await res.read() - - async def __aenter__(self) -> "Paperless": - """Return context manager.""" - await self.initialize() - return self - - async def __aexit__(self, exc_type: Any, exc: Any, exc_tb: Any) -> Any | None: - """Exit context manager.""" - await self.close() - if exc: - raise exc - return exc_type diff --git a/pypaperless/api.py b/pypaperless/api.py new file mode 100644 index 0000000..57ee7ba --- /dev/null +++ b/pypaperless/api.py @@ -0,0 +1,227 @@ +"""PyPaperless.""" + +import logging +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Any + +import aiohttp +from yarl import URL + +from . import helpers +from .const import API_PATH, PaperlessResource +from .exceptions import AuthentificationRequired, BadJsonResponse, JsonResponseWithError +from .sessions import PaperlessSession + + +class Paperless: # pylint: disable=too-many-instance-attributes + """Retrieves and manipulates data from and to Paperless via REST.""" + + _class_map: set[tuple[str, type]] = { + (PaperlessResource.CONFIG, helpers.ConfigHelper), + (PaperlessResource.CORRESPONDENTS, helpers.CorrespondentHelper), + (PaperlessResource.CUSTOM_FIELDS, helpers.CustomFieldHelper), + (PaperlessResource.DOCUMENTS, helpers.DocumentHelper), + (PaperlessResource.DOCUMENT_TYPES, helpers.DocumentTypeHelper), + (PaperlessResource.GROUPS, helpers.GroupHelper), + (PaperlessResource.MAIL_ACCOUNTS, helpers.MailAccountHelper), + (PaperlessResource.MAIL_RULES, helpers.MailRuleHelper), + (PaperlessResource.SAVED_VIEWS, helpers.SavedViewHelper), + (PaperlessResource.SHARE_LINKS, helpers.ShareLinkHelper), + (PaperlessResource.STORAGE_PATHS, helpers.StoragePathHelper), + (PaperlessResource.TAGS, helpers.TagHelper), + (PaperlessResource.TASKS, helpers.TaskHelper), + (PaperlessResource.USERS, helpers.UserHelper), + (PaperlessResource.WORKFLOWS, helpers.WorkflowHelper), + } + + config: helpers.ConfigHelper + correspondents: helpers.CorrespondentHelper + custom_fields: helpers.CustomFieldHelper + documents: helpers.DocumentHelper + document_types: helpers.DocumentTypeHelper + groups: helpers.GroupHelper + mail_accounts: helpers.MailAccountHelper + mail_rules: helpers.MailRuleHelper + saved_views: helpers.SavedViewHelper + share_links: helpers.ShareLinkHelper + storage_paths: helpers.StoragePathHelper + tags: helpers.TagHelper + tasks: helpers.TaskHelper + users: helpers.UserHelper + workflows: helpers.WorkflowHelper + + async def __aenter__(self) -> "Paperless": + """Return context manager.""" + await self.initialize() + return self + + async def __aexit__(self, *_: object) -> None: + """Exit context manager.""" + await self.close() + + def __init__( + self, + url: str | URL | None = None, + token: str | None = None, + session: PaperlessSession | None = None, + ): + """Initialize a `Paperless` instance. + + You have to permit either a session, or an url / token pair. + + `url`: A hostname or IP-address as string, or yarl.URL object. + `token`: An api token created in Paperless Django settings, or via the helper function. + `session`: A custom `PaperlessSession` object, if existing. + """ + if session is not None: + self._session = session + elif url is not None and token is not None: + self._session = PaperlessSession(url, token) + else: + raise AuthentificationRequired + + self._initialized = False + self._local_resources: set[PaperlessResource] = set() + self._remote_resources: set[PaperlessResource] = set() + self._version: str | None = None + + self.logger = logging.getLogger(f"{__package__}[{self._session}]") + + @property + def is_initialized(self) -> bool: + """Return `True` if connection is initialized.""" + return self._initialized + + @property + def host_version(self) -> str | None: + """Return the version object of the Paperless host.""" + return self._version + + @property + def local_resources(self) -> set[PaperlessResource]: + """Return a set of locally available resources.""" + return self._local_resources + + @property + def remote_resources(self) -> set[PaperlessResource]: + """Return a set of available resources of the Paperless host.""" + return self._remote_resources + + @staticmethod + async def generate_api_token( + url: str, + username: str, + password: str, + session: PaperlessSession | None = None, + ) -> str: + """Request Paperless to generate an api token for the given credentials. + + Warning: the request is plain and insecure. Don't use this in production + environments or businesses. + + Warning: error handling is low for this method, as it is just a helper. + + Example: + ```python + token = Paperless.generate_api_token("example.com:8000", "api_user", "secret_password") + + paperless = Paperless("example.com:8000", token) + # do something + ``` + """ + session = session or PaperlessSession(url, "") + try: + url = url.rstrip("/") + json = { + "username": username, + "password": password, + } + res = await session.request("post", f"{API_PATH['token']}", json=json) + data = await res.json() + res.raise_for_status() + return str(data["token"]) + except KeyError as exc: + raise BadJsonResponse("Token is missing in response.") from exc + except aiohttp.ClientResponseError as exc: + raise JsonResponseWithError(payload={"error": data}) from exc + except Exception as exc: + raise exc + finally: + await session.close() + + async def close(self) -> None: + """Clean up connection.""" + if self._session: + await self._session.close() + self.logger.info("Closed.") + + async def initialize(self) -> None: + """Initialize the connection to DRF and fetch the endpoints.""" + async with self.request("get", API_PATH["index"]) as res: + self._version = res.headers.get("x-version", None) + self._remote_resources = set(map(PaperlessResource, await res.json())) + + for endpoint, cls in self._class_map: + setattr(self, f"{endpoint}", cls(self)) + + unused = self._remote_resources.difference(self._local_resources) + missing = self._local_resources.difference(self._remote_resources) + + if len(unused) > 0: + self.logger.debug("Unused features: %s", ", ".join(unused)) + + if len(missing) > 0: + self.logger.warning("Outdated version detected: v%s", self._version) + self.logger.warning("Missing features: %s", ", ".join(missing)) + self.logger.warning("Consider pulling the latest version of Paperless-ngx.") + + self.logger.info("Initialized.") + + self._initialized = True + + @asynccontextmanager + async def request( ## pylint: disable=too-many-arguments + self, + method: str, + path: str, + json: dict[str, Any] | None = None, + data: dict[str, Any] | aiohttp.FormData | None = None, + form: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + **kwargs: Any, + ) -> AsyncGenerator[aiohttp.ClientResponse, None]: + """Perform a request.""" + if method == "post": + pass + res = await self._session.request( + method, + path, + json=json, + data=data, + form=form, + params=params, + **kwargs, + ) + self.logger.debug("%s (%d): %s", method.upper(), res.status, res.url) + yield res + + async def request_json( + self, + method: str, + endpoint: str, + **kwargs: Any, + ) -> Any: + """Make a request to the api and parse response json to dict.""" + async with self.request(method, endpoint, **kwargs) as res: + try: + assert res.content_type == "application/json" + payload = await res.json() + except (AssertionError, ValueError) as exc: + raise BadJsonResponse(res) from exc + + if res.status == 400: + raise JsonResponseWithError(payload) + res.raise_for_status() + + return payload diff --git a/pypaperless/const.py b/pypaperless/const.py index 45bc2e7..52a618f 100644 --- a/pypaperless/const.py +++ b/pypaperless/const.py @@ -1,65 +1,103 @@ """PyPaperless constants.""" -from enum import IntFlag, StrEnum +from __future__ import annotations -from awesomeversion import AwesomeVersion +from enum import StrEnum -PAPERLESS_V1_8_0 = AwesomeVersion("1.8.0") -PAPERLESS_V1_17_0 = AwesomeVersion("1.17.0") -PAPERLESS_V2_0_0 = AwesomeVersion("2.0.0") -PAPERLESS_V2_3_0 = AwesomeVersion("2.3.0") +CONFIG = "config" +CONSUMPTION_TEMPLATES = "consumption_templates" +CORRESPONDENTS = "correspondents" +CUSTOM_FIELDS = "custom_fields" +DOCUMENTS = "documents" +DOCUMENT_TYPES = "document_types" +GROUPS = "groups" +LOGS = "logs" +MAIL_ACCOUNTS = "mail_accounts" +MAIL_RULES = "mail_rules" +SAVED_VIEWS = "saved_views" +SHARE_LINKS = "share_links" +STORAGE_PATHS = "storage_paths" +TAGS = "tags" +TASKS = "tasks" +USERS = "users" +WORKFLOW_ACTIONS = "workflow_actions" +WORKFLOWS = "workflows" +WORKFLOW_TRIGGERS = "workflow_triggers" +UNKNOWN = "unknown" +API_PATH = { + "index": "/api/", + "token": "/api/token/", + f"{CONFIG}": f"/api/{CONFIG}/", + f"{CONFIG}_single": f"/api/{CONFIG}/{{pk}}/", + f"{CORRESPONDENTS}": f"/api/{CORRESPONDENTS}/", + f"{CORRESPONDENTS}_single": f"/api/{CORRESPONDENTS}/{{pk}}/", + f"{CUSTOM_FIELDS}": f"/api/{CUSTOM_FIELDS}/", + f"{CUSTOM_FIELDS}_single": f"/api/{CUSTOM_FIELDS}/{{pk}}/", + f"{DOCUMENTS}": f"/api/{DOCUMENTS}/", + f"{DOCUMENTS}_download": f"/api/{DOCUMENTS}/{{pk}}/download/", + f"{DOCUMENTS}_meta": f"/api/{DOCUMENTS}/{{pk}}/metadata/", + f"{DOCUMENTS}_next_asn": f"/api/{DOCUMENTS}/next_asn/", + f"{DOCUMENTS}_notes": f"/api/{DOCUMENTS}/{{pk}}/notes/", + f"{DOCUMENTS}_preview": f"/api/{DOCUMENTS}/{{pk}}/preview/", + f"{DOCUMENTS}_thumbnail": f"/api/{DOCUMENTS}/{{pk}}/thumb/", + f"{DOCUMENTS}_post": f"/api/{DOCUMENTS}/post_document/", + f"{DOCUMENTS}_single": f"/api/{DOCUMENTS}/{{pk}}/", + f"{DOCUMENTS}_suggestions": f"/api/{DOCUMENTS}/{{pk}}/suggestions/", + f"{DOCUMENT_TYPES}": f"/api/{DOCUMENT_TYPES}/", + f"{DOCUMENT_TYPES}_single": f"/api/{DOCUMENT_TYPES}/{{pk}}/", + f"{GROUPS}": f"/api/{GROUPS}/", + f"{GROUPS}_single": f"/api/{GROUPS}/{{pk}}/", + f"{MAIL_ACCOUNTS}": f"/api/{MAIL_ACCOUNTS}/", + f"{MAIL_ACCOUNTS}_single": f"/api/{MAIL_ACCOUNTS}/{{pk}}/", + f"{MAIL_RULES}": f"/api/{MAIL_RULES}/", + f"{MAIL_RULES}_single": f"/api/{MAIL_RULES}/{{pk}}/", + f"{SAVED_VIEWS}": f"/api/{SAVED_VIEWS}/", + f"{SAVED_VIEWS}_single": f"/api/{SAVED_VIEWS}/{{pk}}/", + f"{SHARE_LINKS}": f"/api/{SHARE_LINKS}/", + f"{SHARE_LINKS}_single": f"/api/{SHARE_LINKS}/{{pk}}/", + f"{STORAGE_PATHS}": f"/api/{STORAGE_PATHS}/", + f"{STORAGE_PATHS}_single": f"/api/{STORAGE_PATHS}/{{pk}}/", + f"{TAGS}": f"/api/{TAGS}/", + f"{TAGS}_single": f"/api/{TAGS}/{{pk}}/", + f"{TASKS}": f"/api/{TASKS}/", + f"{TASKS}_single": f"/api/{TASKS}/{{pk}}/", + f"{USERS}": f"/api/{USERS}/", + f"{USERS}_single": f"/api/{USERS}/{{pk}}/", + f"{WORKFLOWS}": f"/api/{WORKFLOWS}/", + f"{WORKFLOWS}_single": f"/api/{WORKFLOWS}/{{pk}}/", + f"{WORKFLOW_ACTIONS}": f"/api/{WORKFLOW_ACTIONS}/", + f"{WORKFLOW_ACTIONS}_single": f"/api/{WORKFLOW_ACTIONS}/{{pk}}/", + f"{WORKFLOW_TRIGGERS}": f"/api/{WORKFLOW_TRIGGERS}/", + f"{WORKFLOW_TRIGGERS}_single": f"/api/{WORKFLOW_TRIGGERS}/{{pk}}/", +} -CTRL_CONSUMPTION_TEMPLATES = "consumption_templates" -CTRL_CORRESPONDENTS = "correspondents" -CTRL_CUSTOM_FIELDS = "custom_fields" -CTRL_DOCUMENTS = "documents" -CTRL_DOCUMENT_TYPES = "document_types" -CTRL_GROUPS = "groups" -CTRL_MAIL_ACCOUNTS = "mail_accounts" -CTRL_MAIL_RULES = "mail_rules" -CTRL_SAVED_VIEWS = "saved_views" -CTRL_SHARE_LINKS = "share_links" -CTRL_STORAGE_PATHS = "storage_paths" -CTRL_TAGS = "tags" -CTRL_TASKS = "tasks" -CTRL_USERS = "users" -CTRL_WORKFLOW_ACTIONS = "workflow_actions" -CTRL_WORKFLOWS = "workflows" -CTRL_WORKFLOW_TRIGGERS = "workflow_triggers" -CTRL_UNKNOWN = "unknown" - -class PaperlessFeature(IntFlag): - """Supported features.""" - - CONTROLLER_STORAGE_PATHS = 1 - FEATURE_DOCUMENT_NOTES = 2 - CONTROLLER_SHARE_LINKS = 4 - CONTROLLER_CUSTOM_FIELDS = 8 - CONTROLLER_CONSUMPTION_TEMPLATES = 16 - CONTROLLER_WORKFLOWS = 32 - CONTROLLER_CONFIGS = 64 - - -class ControllerPath(StrEnum): +class PaperlessResource(StrEnum): """Represent paths of api endpoints.""" - CONSUMPTION_TEMPLATES = CTRL_CONSUMPTION_TEMPLATES - CORRESPONDENTS = CTRL_CORRESPONDENTS - CUSTOM_FIELDS = CTRL_CUSTOM_FIELDS - DOCUMENTS = CTRL_DOCUMENTS - DOCUMENT_TYPES = CTRL_DOCUMENT_TYPES - GROUPS = CTRL_GROUPS - MAIL_ACCOUNTS = CTRL_MAIL_ACCOUNTS - MAIL_RULES = CTRL_MAIL_RULES - SAVED_VIEWS = CTRL_SAVED_VIEWS - SHARE_LINKS = CTRL_SHARE_LINKS - STORAGE_PATHS = CTRL_STORAGE_PATHS - TAGS = CTRL_TAGS - TASKS = CTRL_TASKS - USERS = CTRL_USERS - WORKFLOWS = CTRL_WORKFLOWS - WORKFLOW_ACTIONS = CTRL_WORKFLOW_ACTIONS - WORKFLOW_TRIGGERS = CTRL_WORKFLOW_TRIGGERS - UNKNOWN = CTRL_UNKNOWN + CONFIG = CONFIG + CONSUMPTION_TEMPLATES = CONSUMPTION_TEMPLATES + CORRESPONDENTS = CORRESPONDENTS + CUSTOM_FIELDS = CUSTOM_FIELDS + DOCUMENTS = DOCUMENTS + DOCUMENT_TYPES = DOCUMENT_TYPES + GROUPS = GROUPS + LOGS = LOGS + MAIL_ACCOUNTS = MAIL_ACCOUNTS + MAIL_RULES = MAIL_RULES + SAVED_VIEWS = SAVED_VIEWS + SHARE_LINKS = SHARE_LINKS + STORAGE_PATHS = STORAGE_PATHS + TAGS = TAGS + TASKS = TASKS + USERS = USERS + WORKFLOWS = WORKFLOWS + WORKFLOW_ACTIONS = WORKFLOW_ACTIONS + WORKFLOW_TRIGGERS = WORKFLOW_TRIGGERS + UNKNOWN = UNKNOWN + + @classmethod + def _missing_(cls: type[PaperlessResource], value: object) -> PaperlessResource: # noqa ARG003 + """Set default member on unknown value.""" + return cls.UNKNOWN diff --git a/pypaperless/controllers/__init__.py b/pypaperless/controllers/__init__.py deleted file mode 100644 index 8e5155f..0000000 --- a/pypaperless/controllers/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Controllers for Paperless api endpoints.""" - -from .default import ( - ConsumptionTemplatesController, - CorrespondentsController, - CustomFieldsController, - DocumentTypesController, - GroupsController, - MailAccountsController, - MailRulesController, - SavedViewsController, - ShareLinksController, - StoragePathsController, - TagsController, - UsersController, - WorkflowActionsController, - WorkflowsController, - WorkflowTriggersController, -) -from .documents import DocumentsController -from .tasks import TasksController - -__all__ = [ - "ConsumptionTemplatesController", - "CorrespondentsController", - "CustomFieldsController", - "DocumentsController", - "DocumentTypesController", - "GroupsController", - "MailAccountsController", - "MailRulesController", - "SavedViewsController", - "ShareLinksController", - "StoragePathsController", - "TagsController", - "TasksController", - "UsersController", - "WorkflowActionsController", - "WorkflowsController", - "WorkflowTriggersController", -] diff --git a/pypaperless/controllers/base.py b/pypaperless/controllers/base.py deleted file mode 100644 index 84adc01..0000000 --- a/pypaperless/controllers/base.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -Base controller for Paperless resources. - -A controller is meant to handle requests to the Paperless api and their responses. -It is responsible for transforming data into its expected format, f.e. from json to dataclass. - -The base controller implements basic get/iterate methods. -A derived controller can inherit from the base controller or specific features or both. -""" - -import math -from collections.abc import AsyncGenerator -from logging import Logger -from typing import TYPE_CHECKING, Any, Generic, NamedTuple, Protocol, TypeVar - -from pypaperless.models.base import PaperlessPost -from pypaperless.util import dataclass_from_dict, dataclass_to_dict - -if TYPE_CHECKING: - from pypaperless import Paperless - - -ResourceT = TypeVar("ResourceT") - - -class BaseControllerProtocol(Protocol, Generic[ResourceT]): - """Protocol for BaseController.""" - - _logger: Logger - _page_size: int - _paperless: "Paperless" - _resource: type[ResourceT] - - # fmt: off - @property - def path(self) -> str: ... # pylint: disable=missing-function-docstring # noqa: D102 - - @property - def resource(self) -> type[ResourceT]: ... # pylint: disable=missing-function-docstring # noqa: D102 - # fmt: on - - -class ResultPage(NamedTuple, Generic[ResourceT]): - """Represent a result page from an api call.""" - - items: list[ResourceT] - current_page: int - next_page: int | None - last_page: int - - -class BaseController(Generic[ResourceT]): - """Represent the base controller.""" - - _logger: Logger - _page_size: int = 50 - _resource: type[ResourceT] - - def __init__(self, paperless: "Paperless", path: str) -> None: - """Initialize controller.""" - self._paperless = paperless - self._logger = paperless.logger.getChild(self.__class__.__name__) - self._path = path.rstrip("/") - - @property - def resource(self) -> type[ResourceT]: - """Get the resource type.""" - return self._resource - - @property - def path(self) -> str: - """Get the url path.""" - return self._path - - -class PaginationMixin(BaseControllerProtocol[ResourceT]): - """Extend a controller with default `get` and `iterate` methods.""" - - async def get( - self, - page: int = 1, - **kwargs: Any, - ) -> ResultPage[ResourceT]: - """Retrieve a specific page from resource api.""" - kwargs["page"] = page - if "page_size" not in kwargs: - kwargs["page_size"] = self._page_size - - res = await self._paperless.request_json("get", self.path, params=kwargs) - return ResultPage( - [dataclass_from_dict(self.resource, item) for item in res["results"]], - kwargs["page"], - kwargs["page"] + 1 if res["next"] else None, - math.ceil(res["count"] / kwargs["page_size"]), - ) - - async def iterate( - self, - **kwargs: Any, - ) -> AsyncGenerator[ResourceT, None]: - """Iterate pages and yield every item.""" - next_page: int | None = 1 - while next_page: - res: ResultPage = await self.get(next_page, **kwargs) - next_page = res.next_page - for item in res.items: - yield item - - -class OneMixin(BaseControllerProtocol[ResourceT]): - """Extend a controller with a default `one` method.""" - - async def one(self, pk: int) -> ResourceT: - """Return exactly one item by its pk.""" - url = f"{self.path}/{pk}" - res = await self._paperless.request_json("get", url) - data: ResourceT = dataclass_from_dict(self.resource, res) - return data - - -class ListMixin(BaseControllerProtocol[ResourceT]): - """Extend a controller with a default `list` method.""" - - async def list(self) -> list[int]: - """Return a list of all item pks.""" - res = await self._paperless.request_json("get", self.path) - if "all" in res: - data: list[int] = res["all"] - return data - - self._logger.debug("List result is empty.") - return [] - - -class CreateMixin(BaseControllerProtocol[ResourceT]): - """Extend a controller with a default `create` method.""" - - async def create(self, obj: PaperlessPost) -> ResourceT: - """Create a new item on the Paperless api. Raise on failure.""" - res = await self._paperless.request_json( - "post", - self.path, - json=dataclass_to_dict(obj), - ) - data: ResourceT = dataclass_from_dict(self.resource, res) - return data - - -class UpdateMixin(BaseControllerProtocol[ResourceT]): - """Extend a controller with a default `update' method.""" - - async def update(self, obj: ResourceT) -> ResourceT: - """Update an existing item on the Paperless api. Raise on failure.""" - url = f"{self.path}/{obj.id}" # type: ignore[attr-defined] # TODO: have to fix that # pylint: disable=fixme - res = await self._paperless.request_json( - "put", - url, - json=dataclass_to_dict(obj, skip_none=False), - ) - data: ResourceT = dataclass_from_dict(self.resource, res) - return data - - -class DeleteMixin(BaseControllerProtocol[ResourceT]): - """Extend a controller with a default `delete` method.""" - - async def delete(self, obj: ResourceT) -> bool: - """Delete an existing item from the Paperless api. Raise on failure.""" - url = f"{self.path}/{obj.id}" # type: ignore[attr-defined] # TODO: have to fix that # pylint: disable=fixme - try: - await self._paperless.request_json("delete", url) - return True - except Exception as exc: # pylint: disable=broad-exception-caught - raise exc - - -class BaseService: # pylint: disable=too-few-public-methods - """Handle requests to sub-endpoints or special tasks.""" - - def __init__(self, controller: BaseController) -> None: - """Initialize service.""" - self._paperless = controller._paperless - self._controller = controller - self._path = controller.path - self._logger = controller._logger diff --git a/pypaperless/controllers/default.py b/pypaperless/controllers/default.py deleted file mode 100644 index 671d150..0000000 --- a/pypaperless/controllers/default.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Default controllers for Paperless resources.""" - -from pypaperless.models import ( - ConsumptionTemplate, - Correspondent, - CustomField, - DocumentType, - Group, - MailAccount, - MailRule, - SavedView, - ShareLink, - StoragePath, - Tag, - User, - Workflow, - WorkflowAction, - WorkflowTrigger, -) - -from .base import ( - BaseController, - CreateMixin, - DeleteMixin, - ListMixin, - OneMixin, - PaginationMixin, - UpdateMixin, -) - - -class ConsumptionTemplatesController( - BaseController[ConsumptionTemplate], - PaginationMixin[ConsumptionTemplate], - ListMixin[ConsumptionTemplate], - OneMixin[ConsumptionTemplate], -): - """Represent Paperless consumption templates resource.""" - - _resource = ConsumptionTemplate - - -class CorrespondentsController( # pylint: disable=too-many-ancestors - BaseController[Correspondent], - PaginationMixin[Correspondent], - ListMixin[Correspondent], - OneMixin[Correspondent], - CreateMixin[Correspondent], - UpdateMixin[Correspondent], - DeleteMixin[Correspondent], -): - """Represent Paperless correspondents resource.""" - - _resource = Correspondent - - -class CustomFieldsController( # pylint: disable=too-many-ancestors - BaseController[CustomField], - PaginationMixin[CustomField], - ListMixin[CustomField], - OneMixin[CustomField], - CreateMixin[CustomField], - UpdateMixin[CustomField], - DeleteMixin[CustomField], -): - """Represent Paperless custom fields resource.""" - - _resource = CustomField - - -class DocumentTypesController( # pylint: disable=too-many-ancestors - BaseController[DocumentType], - PaginationMixin[DocumentType], - ListMixin[DocumentType], - OneMixin[DocumentType], - CreateMixin[DocumentType], - UpdateMixin[DocumentType], - DeleteMixin[DocumentType], -): - """Represent Paperless document types resource.""" - - _resource = DocumentType - - -class GroupsController( - BaseController[Group], - PaginationMixin[Group], - ListMixin[Group], - OneMixin[Group], -): - """Represent Paperless groups resource.""" - - _resource = Group - - -class MailAccountsController( - BaseController[MailAccount], - PaginationMixin[MailAccount], - ListMixin[MailAccount], - OneMixin[MailAccount], -): - """Represent Paperless mail accounts resource.""" - - _resource = MailAccount - - -class MailRulesController( - BaseController[MailRule], - PaginationMixin[MailRule], - ListMixin[MailRule], - OneMixin[MailRule], -): - """Represent Paperless mail rules resource.""" - - _resource = MailRule - - -class SavedViewsController( - BaseController[SavedView], - PaginationMixin[SavedView], - ListMixin[SavedView], - OneMixin[SavedView], -): - """Represent Paperless mail rules resource.""" - - _resource = SavedView - - -class ShareLinksController( # pylint: disable=too-many-ancestors - BaseController[ShareLink], - PaginationMixin[ShareLink], - ListMixin[ShareLink], - OneMixin[ShareLink], - CreateMixin[ShareLink], - UpdateMixin[ShareLink], - DeleteMixin[ShareLink], -): - """Represent Paperless share links resource.""" - - _resource = ShareLink - - -class StoragePathsController( # pylint: disable=too-many-ancestors - BaseController[StoragePath], - PaginationMixin[StoragePath], - ListMixin[StoragePath], - OneMixin[StoragePath], - CreateMixin[StoragePath], - UpdateMixin[StoragePath], - DeleteMixin[StoragePath], -): - """Represent Paperless storage paths resource.""" - - _resource = StoragePath - - -class TagsController( # pylint: disable=too-many-ancestors - BaseController[Tag], - PaginationMixin[Tag], - ListMixin[Tag], - OneMixin[Tag], - CreateMixin[Tag], - UpdateMixin[Tag], - DeleteMixin[Tag], -): - """Represent Paperless tags resource.""" - - _resource = Tag - - -class UsersController( - BaseController[User], - PaginationMixin[User], - ListMixin[User], - OneMixin[User], -): - """Represent Paperless users resource.""" - - _resource = User - - -class WorkflowsController( - BaseController[Workflow], - PaginationMixin[Workflow], - ListMixin[Workflow], - OneMixin[Workflow], -): - """Represent Paperless workflows resource.""" - - _resource = Workflow - - -class WorkflowActionsController( - BaseController[WorkflowAction], - PaginationMixin[WorkflowAction], - ListMixin[WorkflowAction], - OneMixin[WorkflowAction], -): - """Represent Paperless workflow actions resource.""" - - _resource = WorkflowAction - - -class WorkflowTriggersController( - BaseController[WorkflowTrigger], - PaginationMixin[WorkflowTrigger], - ListMixin[WorkflowTrigger], - OneMixin[WorkflowTrigger], -): - """Represent Paperless workflow triggers resource.""" - - _resource = WorkflowTrigger diff --git a/pypaperless/controllers/documents.py b/pypaperless/controllers/documents.py deleted file mode 100644 index 1ee6727..0000000 --- a/pypaperless/controllers/documents.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Controller for Paperless documents resource.""" - -from typing import TYPE_CHECKING - -from pypaperless.const import PaperlessFeature -from pypaperless.models import ( - Document, - DocumentMetaInformation, - DocumentNote, - DocumentNotePost, - DocumentPost, -) -from pypaperless.models.custom_fields import CustomFieldValue -from pypaperless.util import dataclass_from_dict, dataclass_to_dict - -from .base import ( - BaseController, - BaseService, - DeleteMixin, - ListMixin, - OneMixin, - PaginationMixin, - UpdateMixin, -) - -if TYPE_CHECKING: - from pypaperless import Paperless - -_DocumentOrIdType = Document | int - - -def _get_document_id_helper(item: _DocumentOrIdType) -> int: - """Return a document id from object or int.""" - if isinstance(item, Document): - return item.id - return int(item) - - -class DocumentCustomFieldsService(BaseService): # pylint: disable=too-few-public-methods - """Handle manipulation of custom field value instances.""" - - async def __call__( - self, - obj: _DocumentOrIdType, - cf: list[CustomFieldValue], - ) -> Document: - """Add or change custom field value.""" - idx = _get_document_id_helper(obj) - url = f"{self._path}/{idx}/" - payload = { - "custom_fields": [dataclass_to_dict(item) for item in cf], - } - res = await self._paperless.request_json("patch", url, json=payload) - data: Document = dataclass_from_dict(Document, res) - return data - - -class DocumentNotesService(BaseService): - """Handle http requests for document notes sub-endpoint.""" - - async def get(self, obj: _DocumentOrIdType) -> list[DocumentNote]: - """Request document notes of given document.""" - idx = _get_document_id_helper(obj) - url = f"{self._path}/{idx}/notes" - res = await self._paperless.request_json("get", url) - - # We have to transform data here slightly. - # There are two major differences in the data depending on which endpoint is requested. - # url: documents/{:pk}/ -> - # .document -> int - # .user -> int - # url: documents/{:pk}/notes/ -> - # .document -> does not exist (so we add it here) - # .user -> dict(id=int, username=str, first_name=str, last_name=str) - return [ - dataclass_from_dict(DocumentNote, {**item, "document": idx, "user": item["user"]["id"]}) - for item in res - ] - - async def create(self, obj: DocumentNotePost) -> None: - """Create a new document note. Raise on failure.""" - url = f"{self._path}/{obj.document}/notes" - await self._paperless.request_json("post", url, json=dataclass_to_dict(obj)) - - async def delete(self, obj: DocumentNote) -> None: - """Delete an existing document note. Raise on failure.""" - url = f"{self._path}/{obj.document}/notes" - params = { - "id": obj.id, - } - await self._paperless.request_json("delete", url, params=params) - - -class DocumentFilesService(BaseService): - """Handle http requests for document files downloading.""" - - async def _get_data( - self, - path: str, - idx: int, - ) -> bytes: - """Request a child endpoint.""" - url = f"{self._path}/{idx}/{path}" - return await self._paperless.request_file("get", url) - - async def download(self, obj: _DocumentOrIdType) -> bytes: - """Request document endpoint for downloading the actual file.""" - return await self._get_data("download", _get_document_id_helper(obj)) - - async def preview(self, obj: _DocumentOrIdType) -> bytes: - """Request document endpoint for previewing the actual file.""" - return await self._get_data("preview", _get_document_id_helper(obj)) - - async def thumb(self, obj: _DocumentOrIdType) -> bytes: - """Request document endpoint for the thumbnail file.""" - return await self._get_data("thumb", _get_document_id_helper(obj)) - - -class DocumentsController( # pylint: disable=too-many-ancestors - BaseController[Document], - PaginationMixin[Document], - ListMixin[Document], - OneMixin[Document], - UpdateMixin[Document], - DeleteMixin[Document], -): - """Represent Paperless documents resource.""" - - _resource = Document - _page_size = 100 - - def __init__(self, paperless: "Paperless", path: str) -> None: - """Override initialize controller. Also initialize service controllers.""" - super().__init__(paperless, path) - - self.files = DocumentFilesService(self) - self.notes: DocumentNotesService | None = None - self.custom_fields: DocumentCustomFieldsService | None = None - - if PaperlessFeature.FEATURE_DOCUMENT_NOTES in self._paperless.features: - self.notes = DocumentNotesService(self) - if PaperlessFeature.CONTROLLER_CUSTOM_FIELDS in self._paperless.features: - self.custom_fields = DocumentCustomFieldsService(self) - - async def create(self, obj: DocumentPost) -> str: - """Create a new document. Raise on failure.""" - url = f"{self.path}/post_document/" - # result is a string in this case - res: str = await self._paperless.request_json("post", url, form=dataclass_to_dict(obj)) - return res - - async def meta(self, obj: _DocumentOrIdType) -> DocumentMetaInformation: - """Request document metadata of given document.""" - idx = _get_document_id_helper(obj) - url = f"{self.path}/{idx}/metadata" - res = await self._paperless.request_json("get", url) - data: DocumentMetaInformation = dataclass_from_dict(DocumentMetaInformation, res) - return data diff --git a/pypaperless/controllers/tasks.py b/pypaperless/controllers/tasks.py deleted file mode 100644 index f3ad5d4..0000000 --- a/pypaperless/controllers/tasks.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Controller for Paperless tasks resource.""" - -from collections.abc import AsyncGenerator -from typing import Any - -from pypaperless.models import Task -from pypaperless.util import dataclass_from_dict - -from .base import BaseController - - -class TasksController(BaseController[Task]): - """Represent Paperless tasks resource.""" - - _resource = Task - - async def get(self, **kwargs: Any) -> list[Task]: - """Request list of task items.""" - res = await self._paperless.request_json("get", self.path, params=kwargs) - data: list[Task] = [dataclass_from_dict(self.resource, item) for item in res] - return data - - async def iterate( - self, - **kwargs: Any, - ) -> AsyncGenerator[Task, None]: - """Iterate pages and yield every task item.""" - res: list[Task] = await self.get(**kwargs) - for item in res: - yield item - - async def one(self, idx: str) -> Task | None: - """Request exactly one task item by pk.""" - params = { - "task_id": idx, - } - res = await self._paperless.request_json("get", self.path, params=params) - if len(res) > 0: - data: Task = dataclass_from_dict(self.resource, res.pop()) - return data - return None diff --git a/pypaperless/errors.py b/pypaperless/errors.py deleted file mode 100644 index f2b649a..0000000 --- a/pypaperless/errors.py +++ /dev/null @@ -1,17 +0,0 @@ -"""PyPaperless errors.""" - - -class PaperlessException(Exception): - """Base exception for PyPaperless.""" - - -class BadRequestException(PaperlessException): - """Raise when requesting wrong data.""" - - -class DataNotExpectedException(PaperlessException): - """Raise when expecting a type and receiving something else.""" - - -class ControllerConfusion(PaperlessException): - """Raise when Paperless version does not match to controllers.""" diff --git a/pypaperless/exceptions.py b/pypaperless/exceptions.py new file mode 100644 index 0000000..ff5ed49 --- /dev/null +++ b/pypaperless/exceptions.py @@ -0,0 +1,83 @@ +"""PyPaperless exceptions.""" + +from typing import Any + + +class PaperlessException(Exception): + """Base exception for PyPaperless.""" + + +# Sessions and requests + + +class AuthentificationRequired(PaperlessException): + """Raise when initializing a `Paperless` instance without url/token or session.""" + + +class RequestException(PaperlessException): + """Raise when issuing a request fails.""" + + def __init__( + self, + exc: Exception, + req_args: tuple[str, str, dict[str, str | int] | None], + req_kwargs: dict[str, Any] | None, + ) -> None: + """Initialize a `RequestException` instance.""" + message = f"Request error: {type(exc).__name__}\n" + message += f"URL: {req_args[1]}\n" + message += f"Method: {req_args[0].upper()}\n" + message += f"params={req_args[2]}\n" + message += f"kwargs={req_kwargs}" + + super().__init__(message) + + +class BadJsonResponse(PaperlessException): + """Raise when response is no valid json.""" + + +class JsonResponseWithError(PaperlessException): + """Raise when Paperless accepted the request, but responded with an error payload.""" + + def __init__(self, payload: Any) -> None: + """Initialize a `JsonResponseWithError` instance.""" + message: Any = "Unknown error" + + if isinstance(payload, dict): + key = "error" if "error" in payload else set(payload.keys()).pop() + message = payload[key] + if isinstance(message, list): + message = message.pop() + + super().__init__(f"Paperless: {key} - {message}") + + +# Models + + +class AsnRequestError(PaperlessException): + """Raise when getting an error during requesting the next asn.""" + + +class DraftFieldRequired(PaperlessException): + """Raise when trying to save models with missing required fields.""" + + +class DraftNotSupported(PaperlessException): + """Raise when trying to draft unsupported models.""" + + +class PrimaryKeyRequired(PaperlessException): + """Raise when trying to access model data without supplying a pk.""" + + +# Tasks + + +class TaskNotFound(PaperlessException): + """Raise when trying to access a task by non-existing uuid.""" + + def __init__(self, task_id: str) -> None: + """Initialize a `TaskNotFound` instance.""" + super().__init__(f"Task with UUID {task_id} not found.") diff --git a/pypaperless/helpers.py b/pypaperless/helpers.py new file mode 100644 index 0000000..843962c --- /dev/null +++ b/pypaperless/helpers.py @@ -0,0 +1,16 @@ +"""PyPaperless helpers.""" + +# pylint: disable=unused-import + +from .models.base import HelperBase # noqa F401 +from .models.classifiers import CorrespondentHelper # noqa F401 +from .models.classifiers import DocumentTypeHelper, StoragePathHelper, TagHelper # noqa F401 +from .models.config import ConfigHelper # noqa F401 +from .models.custom_fields import CustomFieldHelper # noqa F401 +from .models.documents import DocumentHelper, DocumentMetaHelper, DocumentNoteHelper # noqa F401 +from .models.mails import MailAccountHelper, MailRuleHelper # noqa F401 +from .models.permissions import GroupHelper, UserHelper # noqa F401 +from .models.saved_views import SavedViewHelper # noqa F401 +from .models.share_links import ShareLinkHelper # noqa F401 +from .models.tasks import TaskHelper # noqa F401 +from .models.workflows import WorkflowHelper # noqa F401 diff --git a/pypaperless/models/__init__.py b/pypaperless/models/__init__.py index 3e1b67d..085d38c 100644 --- a/pypaperless/models/__init__.py +++ b/pypaperless/models/__init__.py @@ -1,60 +1,53 @@ -"""Models for Paperless resources.""" +"""PyPaperless models.""" -from .custom_fields import CustomField, CustomFieldPost -from .documents import ( - Document, - DocumentMetadata, - DocumentMetaInformation, - DocumentNote, - DocumentNotePost, - DocumentPost, -) -from .groups import Group -from .mails import MailAccount, MailRule -from .matching import ( +from .classifiers import ( Correspondent, - CorrespondentPost, + CorrespondentDraft, DocumentType, - DocumentTypePost, + DocumentTypeDraft, StoragePath, - StoragePathPost, + StoragePathDraft, Tag, - TagPost, + TagDraft, ) -from .saved_views import SavedView, SavedViewFilterRule -from .share_links import ShareLink, ShareLinkPost +from .config import Config +from .custom_fields import CustomField, CustomFieldDraft +from .documents import Document, DocumentDraft, DocumentMeta, DocumentNote, DocumentNoteDraft +from .mails import MailAccount, MailRule +from .pages import Page +from .permissions import Group, User +from .saved_views import SavedView +from .share_links import ShareLink, ShareLinkDraft from .tasks import Task -from .users import User -from .workflows import ConsumptionTemplate, Workflow, WorkflowAction, WorkflowTrigger +from .workflows import Workflow, WorkflowAction, WorkflowTrigger -__all__ = [ - "ConsumptionTemplate", +__all__ = ( + "Config", "Correspondent", - "CorrespondentPost", + "CorrespondentDraft", "CustomField", - "CustomFieldPost", + "CustomFieldDraft", "Document", - "DocumentPost", - "DocumentMetadata", - "DocumentMetaInformation", + "DocumentDraft", + "DocumentMeta", "DocumentNote", - "DocumentNotePost", + "DocumentNoteDraft", "DocumentType", - "DocumentTypePost", + "DocumentTypeDraft", "Group", "MailAccount", "MailRule", + "Page", "SavedView", - "SavedViewFilterRule", "ShareLink", - "ShareLinkPost", + "ShareLinkDraft", "StoragePath", - "StoragePathPost", + "StoragePathDraft", "Tag", - "TagPost", + "TagDraft", "Task", "User", "Workflow", "WorkflowAction", "WorkflowTrigger", -] +) diff --git a/pypaperless/models/base.py b/pypaperless/models/base.py index 3c31144..1f087f1 100644 --- a/pypaperless/models/base.py +++ b/pypaperless/models/base.py @@ -1,9 +1,128 @@ -"""Base models.""" +"""Provide base classes.""" +from dataclasses import Field, dataclass, fields +from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, final -class PaperlessModel: # pylint: disable=too-few-public-methods - """Base class that represents a Paperless model.""" +from pypaperless.const import API_PATH, PaperlessResource +from pypaperless.models.utils import dict_value_to_object +if TYPE_CHECKING: + from pypaperless import Paperless -class PaperlessPost(PaperlessModel): # pylint: disable=too-few-public-methods - """Base class that represents a Paperless POST model.""" + +ResourceT = TypeVar("ResourceT", bound="PaperlessModel") + + +class PaperlessBase: # pylint: disable=too-few-public-methods + """Superclass for all classes in PyPaperless.""" + + _api_path = API_PATH["index"] + + def __init__(self, api: "Paperless"): + """Initialize a `PaperlessBase` instance.""" + self._api = api + + +class HelperProtocol(Protocol, Generic[ResourceT]): # pylint: disable=too-few-public-methods + """Protocol for any `HelperBase` instances and its ancestors.""" + + _api: "Paperless" + _api_path: str + _resource: PaperlessResource + _resource_cls: type[ResourceT] + + +class HelperBase(PaperlessBase, Generic[ResourceT]): # pylint: disable=too-few-public-methods + """Base class for all helpers in PyPaperless.""" + + _resource: PaperlessResource + + def __init__(self, api: "Paperless"): + """Initialize a `HelperBase` instance.""" + super().__init__(api) + + self._api.local_resources.add(self._resource) + + @property + def is_available(self) -> bool: + """Return if the attached endpoint is available, or not.""" + return self._resource in self._api.remote_resources + + +@dataclass(init=False) +class PaperlessModelProtocol(Protocol): + """Protocol for any `PaperlessBase` instances and its ancestors.""" + + _api: "Paperless" + _api_path: str + _data: dict[str, Any] + _fetched: bool + _params: dict[str, Any] + + # fmt: off + def _get_dataclass_fields(self) -> list[Field]: ... + def _set_dataclass_fields(self) -> None: ... + # fmt: on + + +@dataclass(init=False) +class PaperlessModel(PaperlessBase): + """Base class for all models in PyPaperless.""" + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `PaperlessModel` instance.""" + super().__init__(api) + self._data = {} + self._data.update(data) + self._fetched = False + self._params: dict[str, Any] = {} + + @final + @classmethod + def create_with_data( + cls: type[ResourceT], + api: "Paperless", + data: dict[str, Any], + fetched: bool = False, + ) -> ResourceT: + """Return a new instance of `cls` from `data`. + + Primarily used by class factories to create new model instances. + + Example: `document = Document.create_with_data(...)` + """ + item = cls(api, data=data) + item._fetched = fetched + if fetched: + item._set_dataclass_fields() + return item + + @final + def _get_dataclass_fields(self) -> list[Field]: + """Get the dataclass fields.""" + return [ + field + for field in fields(self) + if (not field.name.startswith("_") or field.name == "__search_hit__") + ] + + @final + def _set_dataclass_fields(self) -> None: + """Set the dataclass fields from `self._data`.""" + for field in self._get_dataclass_fields(): + value = dict_value_to_object( + f"{self.__class__.__name__}.{field.name}", + self._data.get(field.name), + field.type, + field.default, + self._api, + ) + setattr(self, field.name, value) + + async def load(self) -> None: + """Get `model data` from DRF.""" + data = await self._api.request_json("get", self._api_path, params=self._params) + + self._data.update(data) + self._set_dataclass_fields() + self._fetched = True diff --git a/pypaperless/models/classifiers.py b/pypaperless/models/classifiers.py new file mode 100644 index 0000000..d8a1615 --- /dev/null +++ b/pypaperless/models/classifiers.py @@ -0,0 +1,273 @@ +"""Provide `Correspondent`, `DocumentType`, `StoragePath` and `Tag` related models and helpers.""" + +import datetime +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from pypaperless.const import API_PATH, PaperlessResource + +from .base import HelperBase, PaperlessModel +from .mixins import helpers, models + +if TYPE_CHECKING: + from pypaperless import Paperless + + +@dataclass(init=False) +class Correspondent( # pylint: disable=too-many-ancestors + PaperlessModel, + models.MatchingFieldsMixin, + models.SecurableMixin, + models.UpdatableMixin, + models.DeletableMixin, +): + """Represent a Paperless `Correspondent`.""" + + _api_path = API_PATH["correspondents_single"] + + id: int | None = None + slug: str | None = None + name: str | None = None + document_count: int | None = None + last_correspondence: datetime.datetime | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Correspondent` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +@dataclass(init=False) +class CorrespondentDraft( # pylint: disable=too-many-ancestors + PaperlessModel, + models.MatchingFieldsMixin, + models.SecurableDraftMixin, + models.CreatableMixin, +): + """Represent a new `Correspondent`, which is not yet stored in Paperless.""" + + _api_path = API_PATH["correspondents"] + + _create_required_fields = { + "name", + "match", + "matching_algorithm", + "is_insensitive", + } + + name: str | None = None + + +@dataclass(init=False) +class DocumentType( # pylint: disable=too-many-ancestors + PaperlessModel, + models.MatchingFieldsMixin, + models.SecurableMixin, + models.UpdatableMixin, + models.DeletableMixin, +): + """Represent a Paperless `DocumentType`.""" + + _api_path = API_PATH["document_types_single"] + + id: int | None = None + slug: str | None = None + name: str | None = None + document_count: int | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `DocumentType` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +@dataclass(init=False) +class DocumentTypeDraft( # pylint: disable=too-many-ancestors + PaperlessModel, + models.MatchingFieldsMixin, + models.SecurableDraftMixin, + models.CreatableMixin, +): + """Represent a new `DocumentType`, which is not yet stored in Paperless.""" + + _api_path = API_PATH["document_types"] + + _create_required_fields = { + "name", + "match", + "matching_algorithm", + "is_insensitive", + } + + name: str | None = None + owner: int | None = None + + +@dataclass(init=False) +class StoragePath( # pylint: disable=too-many-ancestors + PaperlessModel, + models.MatchingFieldsMixin, + models.SecurableMixin, + models.UpdatableMixin, + models.DeletableMixin, +): + """Represent a Paperless `StoragePath`.""" + + _api_path = API_PATH["storage_paths_single"] + + id: int | None = None + slug: str | None = None + name: str | None = None + path: str | None = None + document_count: int | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `StoragePath` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +@dataclass(init=False) +class StoragePathDraft( # pylint: disable=too-many-ancestors + PaperlessModel, + models.MatchingFieldsMixin, + models.SecurableDraftMixin, + models.CreatableMixin, +): + """Represent a new `StoragePath`, which is not yet stored in Paperless.""" + + _api_path = API_PATH["storage_paths"] + + _create_required_fields = { + "name", + "path", + "match", + "matching_algorithm", + "is_insensitive", + } + + name: str | None = None + path: str | None = None + owner: int | None = None + + +@dataclass(init=False) +class Tag( # pylint: disable=too-many-ancestors,too-many-instance-attributes + PaperlessModel, + models.MatchingFieldsMixin, + models.SecurableMixin, + models.UpdatableMixin, + models.DeletableMixin, +): + """Represent a Paperless `Tag`.""" + + _api_path = API_PATH["tags_single"] + + id: int | None = None + slug: str | None = None + name: str | None = None + color: str | None = None + text_color: str | None = None + is_inbox_tag: bool | None = None + document_count: int | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Tag` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +@dataclass(init=False) +class TagDraft( # pylint: disable=too-many-ancestors + PaperlessModel, + models.MatchingFieldsMixin, + models.SecurableDraftMixin, + models.CreatableMixin, +): + """Represent a new `Tag`, which is not yet stored in Paperless.""" + + _api_path = API_PATH["tags"] + + _create_required_fields = { + "name", + "color", + "text_color", + "is_inbox_tag", + "match", + "matching_algorithm", + "is_insensitive", + } + + name: str | None = None + color: str | None = None + text_color: str | None = None + is_inbox_tag: bool | None = None + owner: int | None = None + + +class CorrespondentHelper( # pylint: disable=too-many-ancestors + HelperBase[Correspondent], + helpers.SecurableMixin, + helpers.CallableMixin[Correspondent], + helpers.DraftableMixin[CorrespondentDraft], + helpers.IterableMixin[Correspondent], +): + """Represent a factory for Paperless `Correspondent` models.""" + + _api_path = API_PATH["correspondents"] + _resource = PaperlessResource.CORRESPONDENTS + + _draft_cls = CorrespondentDraft + _resource_cls = Correspondent + + +class DocumentTypeHelper( # pylint: disable=too-many-ancestors + HelperBase[DocumentType], + helpers.SecurableMixin, + helpers.CallableMixin[DocumentType], + helpers.DraftableMixin[DocumentTypeDraft], + helpers.IterableMixin[DocumentType], +): + """Represent a factory for Paperless `DocumentType` models.""" + + _api_path = API_PATH["document_types"] + _resource = PaperlessResource.DOCUMENT_TYPES + + _draft_cls = DocumentTypeDraft + _resource_cls = DocumentType + + +class StoragePathHelper( # pylint: disable=too-many-ancestors + HelperBase[StoragePath], + helpers.SecurableMixin, + helpers.CallableMixin[StoragePath], + helpers.DraftableMixin[StoragePathDraft], + helpers.IterableMixin[StoragePath], +): + """Represent a factory for Paperless `StoragePath` models.""" + + _api_path = API_PATH["storage_paths"] + _resource = PaperlessResource.STORAGE_PATHS + + _draft_cls = StoragePathDraft + _resource_cls = StoragePath + + +class TagHelper( # pylint: disable=too-many-ancestors + HelperBase[Tag], + helpers.SecurableMixin, + helpers.CallableMixin[Tag], + helpers.DraftableMixin[TagDraft], + helpers.IterableMixin[Tag], +): + """Represent a factory for Paperless `Tag` models.""" + + _api_path = API_PATH["tags"] + _resource = PaperlessResource.TAGS + + _draft_cls = TagDraft + _resource_cls = Tag diff --git a/pypaperless/models/common.py b/pypaperless/models/common.py new file mode 100644 index 0000000..70bd986 --- /dev/null +++ b/pypaperless/models/common.py @@ -0,0 +1,183 @@ +"""PyPaperless common types.""" + +from dataclasses import dataclass, field +from enum import Enum, StrEnum +from typing import Any + + +# custom_fields +class CustomFieldType(Enum): + """Represent a subtype of `CustomField`.""" + + STRING = "string" + BOOLEAN = "boolean" + INTEGER = "integer" + FLOAT = "float" + MONETARY = "monetary" + DATE = "date" + URL = "url" + DOCUMENT_LINK = "documentlink" + UNKNOWN = "unknown" + + @classmethod + def _missing_(cls: type, value: object) -> "CustomFieldType": # noqa ARG003 + """Set default member on unknown value.""" + return CustomFieldType.UNKNOWN + + +# documents +@dataclass(kw_only=True) +class CustomFieldValueType: + """Represent a subtype of `Document`.""" + + field: int | None = None + value: Any | None = None + + +# documents +@dataclass(kw_only=True) +class DocumentMetadataType: + """Represent a subtype of `DocumentMeta`.""" + + namespace: str | None = None + prefix: str | None = None + key: str | None = None + value: str | None = None + + +# documents +@dataclass(kw_only=True) +class DocumentSearchHitType: + """Represent a subtype of `Document`.""" + + score: float | None = None + highlights: str | None = None + note_highlights: str | None = None + rank: int | None = None + + +# mixins/models/data_fields, used for classifiers +class MatchingAlgorithmType(Enum): + """Represent a subtype of `Correspondent`, `DocumentType`, `StoragePath` and `Tag`.""" + + NONE = 0 + ANY = 1 + ALL = 2 + LITERAL = 3 + REGEX = 4 + FUZZY = 5 + AUTO = 6 + UNKNOWN = -1 + + @classmethod + def _missing_(cls: type, value: object) -> "MatchingAlgorithmType": # noqa ARG003 + """Set default member on unknown value.""" + return MatchingAlgorithmType.UNKNOWN + + +# mixins/models/securable +@dataclass(kw_only=True) +class PermissionSetType: + """Represent a Paperless permission set.""" + + users: list[int] = field(default_factory=list) + groups: list[int] = field(default_factory=list) + + +# mixins/models/securable +@dataclass(kw_only=True) +class PermissionTableType: + """Represent a Paperless permissions type.""" + + view: PermissionSetType = field(default_factory=PermissionSetType) + change: PermissionSetType = field(default_factory=PermissionSetType) + + +# documents +class RetrieveFileMode(StrEnum): + """Represent a subtype of `DownloadedDocument`.""" + + DOWNLOAD = "download" + PREVIEW = "preview" + THUMBNAIL = "thumb" + + +# saved_views +@dataclass(kw_only=True) +class SavedViewFilterRuleType: + """Represent a subtype of `SavedView`.""" + + rule_type: int | None = None + value: str | None = None + + +# share_links +class ShareLinkFileVersionType(Enum): + """Represent a subtype of `ShareLink`.""" + + ARCHIVE = "archive" + ORIGINAL = "original" + UNKNOWN = "unknown" + + @classmethod + def _missing_(cls: type, value: object) -> "ShareLinkFileVersionType": # noqa ARG003 + """Set default member on unknown value.""" + return ShareLinkFileVersionType.UNKNOWN + + +# tasks +class TaskStatusType(Enum): + """Represent a subtype of `Task`.""" + + PENDING = "PENDING" + SUCCESS = "SUCCESS" + FAILURE = "FAILURE" + UNKNOWN = "UNKNOWN" + + @classmethod + def _missing_(cls: type, value: object) -> "TaskStatusType": # noqa ARG003 + """Set default member on unknown value.""" + return TaskStatusType.UNKNOWN + + +# workflows +class WorkflowActionType(Enum): + """Represent a subtype of `Workflow`.""" + + ASSIGNMENT = 1 + UNKNOWN = -1 + + @classmethod + def _missing_(cls: type, value: object) -> "WorkflowActionType": # noqa ARG003 + """Set default member on unknown value.""" + return WorkflowActionType.UNKNOWN + + +# workflows +class WorkflowTriggerType(Enum): + """Represent a subtype of `Workflow`.""" + + CONSUMPTION = 1 + DOCUMENT_ADDED = 2 + DOCUMENT_UPDATED = 3 + UNKNOWN = -1 + + @classmethod + def _missing_(cls: type, value: object) -> "WorkflowTriggerType": # noqa ARG003 + """Set default member on unknown value.""" + return WorkflowTriggerType.UNKNOWN + + +# workflows +class WorkflowTriggerSourceType(Enum): + """Represent a subtype of `Workflow`.""" + + CONSUME_FOLDER = 1 + API_UPLOAD = 2 + MAIL_FETCH = 3 + UNKNOWN = -1 + + @classmethod + def _missing_(cls: type, value: object) -> "WorkflowTriggerSourceType": # noqa ARG003 + """Set default member on unknown value.""" + return WorkflowTriggerSourceType.UNKNOWN diff --git a/pypaperless/models/config.py b/pypaperless/models/config.py new file mode 100644 index 0000000..5d54b23 --- /dev/null +++ b/pypaperless/models/config.py @@ -0,0 +1,56 @@ +"""Provide `Config` related models and helpers.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from pypaperless.const import API_PATH, PaperlessResource + +from .base import HelperBase, PaperlessModel +from .mixins import helpers + +if TYPE_CHECKING: + from pypaperless import Paperless + + +@dataclass(init=False) +class Config( # pylint: disable=too-many-instance-attributes + PaperlessModel, +): + """Represent a Paperless `Config`.""" + + _api_path = API_PATH["config_single"] + + id: int | None = None + user_args: str | None = None + output_type: str | None = None + pages: int | None = None + language: str | None = None + mode: str | None = None + skip_archive_file: str | None = None + image_dpi: int | None = None + unpaper_clean: str | None = None + deskew: bool | None = None + rotate_pages: bool | None = None + rotate_pages_threshold: float | None = None + max_image_pixels: float | None = None + color_conversion_strategy: str | None = None + app_title: str | None = None + app_logo: str | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Config` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +class ConfigHelper( # pylint: disable=too-few-public-methods + HelperBase[Config], + helpers.CallableMixin[Config], +): + """Represent a factory for Paperless `Config` models.""" + + _api_path = API_PATH["config"] + _resource = PaperlessResource.CONFIG + + _resource_cls = Config diff --git a/pypaperless/models/custom_fields.py b/pypaperless/models/custom_fields.py index 85b0f85..fb22317 100644 --- a/pypaperless/models/custom_fields.py +++ b/pypaperless/models/custom_fields.py @@ -1,51 +1,64 @@ -"""Model for custom field resource.""" +"""Provide `CustomField` related models and helpers.""" from dataclasses import dataclass -from enum import Enum -from typing import Any +from typing import TYPE_CHECKING, Any -from .base import PaperlessModel, PaperlessPost +from pypaperless.const import API_PATH, PaperlessResource +from .base import HelperBase, PaperlessModel +from .common import CustomFieldType +from .mixins import helpers, models -class CustomFieldType(Enum): - """Enum with custom field types.""" +if TYPE_CHECKING: + from pypaperless import Paperless - STRING = "string" - BOOLEAN = "boolean" - INTEGER = "integer" - FLOAT = "float" - MONETARY = "monetary" - DATE = "date" - URL = "url" - DOCUMENT_LINK = "documentlink" - UNKNOWN = "unknown" - @classmethod - def _missing_(cls: type, value: object) -> "CustomFieldType": # noqa ARG003 - """Set default member on unknown value.""" - return CustomFieldType.UNKNOWN +@dataclass(init=False) +class CustomField( + PaperlessModel, + models.UpdatableMixin, + models.DeletableMixin, +): + """Represent a Paperless `CustomField`.""" + _api_path = API_PATH["custom_fields_single"] -@dataclass(kw_only=True) -class CustomFieldValue(PaperlessModel): - """Represent a custom field value mapping on the Paperless api.""" + id: int + name: str | None = None + data_type: CustomFieldType | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Document` instance.""" + super().__init__(api, data) - field: int | None = None - value: Any | None = None + self._api_path = self._api_path.format(pk=data.get("id")) -@dataclass(kw_only=True) -class CustomField(PaperlessModel): - """Represent a custom field resource on the Paperless api.""" +@dataclass(init=False) +class CustomFieldDraft( + PaperlessModel, + models.CreatableMixin, +): + """Represent a new Paperless `CustomField`, which is not stored in Paperless.""" + + _api_path = API_PATH["custom_fields"] + + _create_required_fields = {"name", "data_type"} - id: int | None = None name: str | None = None data_type: CustomFieldType | None = None -@dataclass(kw_only=True) -class CustomFieldPost(PaperlessPost): - """Attributes to send when creating a custom field on the Paperless api.""" +class CustomFieldHelper( # pylint: disable=too-many-ancestors + HelperBase[CustomField], + helpers.CallableMixin[CustomField], + helpers.DraftableMixin[CustomFieldDraft], + helpers.IterableMixin[CustomField], +): + """Represent a factory for Paperless `CustomField` models.""" + + _api_path = API_PATH["custom_fields"] + _resource = PaperlessResource.CUSTOM_FIELDS - name: str - data_type: CustomFieldType + _draft_cls = CustomFieldDraft + _resource_cls = CustomField diff --git a/pypaperless/models/documents.py b/pypaperless/models/documents.py index e79aebc..0d9185d 100644 --- a/pypaperless/models/documents.py +++ b/pypaperless/models/documents.py @@ -1,25 +1,207 @@ -"""Model for document resource.""" +"""Provide `Document` related models and helpers.""" +import datetime +from collections.abc import AsyncGenerator from dataclasses import dataclass -from datetime import date, datetime +from typing import TYPE_CHECKING, Any, cast -from .base import PaperlessModel, PaperlessPost -from .custom_fields import CustomFieldValue +from pypaperless.const import API_PATH, PaperlessResource +from pypaperless.exceptions import AsnRequestError, PrimaryKeyRequired +from pypaperless.models.utils import object_to_dict_value +from .base import HelperBase, PaperlessModel +from .common import ( + CustomFieldValueType, + DocumentMetadataType, + DocumentSearchHitType, + RetrieveFileMode, +) +from .mixins import helpers, models -@dataclass(kw_only=True) -class DocumentMetadata(PaperlessModel): - """Represent a document metadata resource on the Paperless api.""" +if TYPE_CHECKING: + from pypaperless import Paperless + + +@dataclass(init=False) +class Document( # pylint: disable=too-many-instance-attributes, too-many-ancestors + PaperlessModel, + models.SecurableMixin, + models.UpdatableMixin, + models.DeletableMixin, +): + """Represent a Paperless `Document`.""" + + _api_path = API_PATH["documents_single"] + + id: int | None = None + correspondent: int | None = None + document_type: int | None = None + storage_path: int | None = None + title: str | None = None + content: str | None = None + tags: list[int] | None = None + created: datetime.datetime | None = None + created_date: datetime.date | None = None + modified: datetime.datetime | None = None + added: datetime.datetime | None = None + archive_serial_number: int | None = None + original_file_name: str | None = None + archived_file_name: str | None = None + is_shared_by_requester: bool | None = None + custom_fields: list[CustomFieldValueType] | None = None + __search_hit__: DocumentSearchHitType | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Document` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + self.notes = DocumentNoteHelper(api, data.get("id")) + + @property + def has_search_hit(self) -> bool: + """Return if the document has a search hit attached.""" + return self.__search_hit__ is not None + + @property + def search_hit(self) -> DocumentSearchHitType | None: + """Return the document search hit.""" + return self.__search_hit__ + + async def get_download(self, original: bool = False) -> "DownloadedDocument": + """Request and return the `DownloadedDocument` class.""" + item = await self._api.documents.download(cast(int, self.id), original) + return item + + async def get_metadata(self) -> "DocumentMeta": + """Request and return the documents `DocumentMeta` class.""" + item = await self._api.documents.metadata(cast(int, self.id)) + return item + + async def get_preview(self, original: bool = False) -> "DownloadedDocument": + """Request and return the `DownloadedDocument` class.""" + item = await self._api.documents.preview(cast(int, self.id), original) + return item + + async def get_suggestions(self) -> "DocumentSuggestions": + """Request and return the `DocumentSuggestions` class.""" + item = await self._api.documents.suggestions(cast(int, self.id)) + return item + + async def get_thumbnail(self, original: bool = False) -> "DownloadedDocument": + """Request and return the `DownloadedDocument` class.""" + item = await self._api.documents.thumbnail(cast(int, self.id), original) + return item + + +@dataclass(init=False) +class DocumentDraft( + PaperlessModel, + models.CreatableMixin, +): # pylint: disable=too-many-instance-attributes + """Represent a new Paperless `Document`, which is not stored in Paperless.""" + + _api_path = API_PATH["documents_post"] + + _create_required_fields = {"document"} + + document: bytes | None = None + filename: str | None = None + title: str | None = None + created: datetime.datetime | None = None + correspondent: int | None = None + document_type: int | None = None + storage_path: int | None = None + tags: int | list[int] | None = None + archive_serial_number: int | None = None + + def _serialize(self) -> dict[str, Any]: + """Serialize.""" + data = { + "form": { + field.name: object_to_dict_value(getattr(self, field.name)) + for field in self._get_dataclass_fields() + if field.name not in {"document", "filename"} + } + } + data["form"].update( + { + "document": ( + (self.document, self.filename) if self.filename is not None else self.document + ) + } + ) + return data + + +@dataclass(init=False) +class DocumentNote(PaperlessModel): + """Represent a Paperless `DocumentNote`.""" + + _api_path = API_PATH["documents_notes"] + + id: int | None = None + note: str | None = None + created: datetime.datetime | None = None + document: int | None = None + user: int | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `DocumentNote` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("document")) + + async def delete(self) -> bool: + """Delete a `resource item` from DRF. There is no point of return. - namespace: str | None = None - prefix: str | None = None - key: str | None = None - value: str | None = None + Return `True` when deletion was successful, `False` otherwise. + + Example: + ```python + # request document notes + notes = await paperless.documents.notes(42) + + for note in notes: + if await note.delete(): + print("Successfully deleted the note!") + ``` + """ + params = { + "id": self.id, + } + async with self._api.request("delete", self._api_path, params=params) as res: + success = res.status == 204 + + return success @dataclass(kw_only=True) -class DocumentMetaInformation(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a document metadata information resource on the Paperless api.""" +class DocumentNoteDraft( + PaperlessModel, + models.CreatableMixin, +): + """Represent a new Paperless `DocumentNote`, which is not stored in Paperless.""" + + _api_path = API_PATH["documents_notes"] + + _create_required_fields = {"note", "document"} + + note: str | None = None + document: int | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `DocumentNote` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("document")) + + +@dataclass(init=False) +class DocumentMeta(PaperlessModel): # pylint: disable=too-many-instance-attributes + """Represent a Paperless `Document`s metadata.""" + + _api_path = API_PATH["documents_meta"] id: int | None = None original_checksum: str | None = None @@ -27,67 +209,409 @@ class DocumentMetaInformation(PaperlessModel): # pylint: disable=too-many-insta original_mime_type: str | None = None media_filename: str | None = None has_archive_version: bool | None = None - original_metadata: list[DocumentMetadata] | None = None + original_metadata: list[DocumentMetadataType] | None = None archive_checksum: str | None = None archive_media_filename: str | None = None original_filename: str | None = None lang: str | None = None archive_size: int | None = None - archive_metadata: list[DocumentMetadata] | None = None + archive_metadata: list[DocumentMetadataType] | None = None + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `DocumentMeta` instance.""" + super().__init__(api, data) -@dataclass(kw_only=True) -class DocumentNote(PaperlessModel): - """Represent a document note resource on the Paperless api.""" + self._api_path = self._api_path.format(pk=data.get("id")) + + +@dataclass(init=False) +class DownloadedDocument(PaperlessModel): # pylint: disable=too-many-instance-attributes + """Represent a Paperless `Document`s downloaded file.""" + + _api_path = API_PATH["documents"] id: int | None = None - note: str | None = None - created: datetime | None = None - document: int | None = None - user: int | None = None + mode: RetrieveFileMode | None = None + original: bool | None = None + content: bytes | None = None + content_type: str | None = None + disposition_filename: str | None = None + disposition_type: str | None = None + async def load(self) -> None: + """Get `raw data` from DRF.""" + self._api_path = self._api_path.format(pk=self._data.get("id")) -@dataclass(kw_only=True) -class DocumentNotePost(PaperlessPost): - """Attributes to send when creating a document note on the Paperless api.""" + params = { + "original": "true" if self._data.get("original", False) else "false", + } - note: str - document: int + async with self._api.request("get", self._api_path, params=params) as res: + self._data.update( + { + "content": await res.read(), + "content_type": res.content_type, + } + ) + if res.content_disposition is not None: + self._data.update( + { + "disposition_filename": res.content_disposition.filename, + "disposition_type": res.content_disposition.type, + } + ) -@dataclass(kw_only=True) -class Document(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a document resource on the Paperless api.""" + self._set_dataclass_fields() + self._fetched = True - id: int - correspondent: int | None = None - document_type: int | None = None - storage_path: int | None = None - title: str | None = None - content: str | None = None - tags: list[int] | None = None - created: datetime | None = None - created_date: date | None = None - modified: datetime | None = None - added: datetime | None = None - archive_serial_number: int | None = None - original_file_name: str | None = None - archived_file_name: str | None = None - owner: int | None = None - user_can_change: bool | None = None - notes: list[DocumentNote] | None = None - custom_fields: list[CustomFieldValue] | None = None +@dataclass(init=False) +class DocumentSuggestions(PaperlessModel): + """Represent a Paperless `Document` suggestions.""" -@dataclass(kw_only=True) -class DocumentPost(PaperlessPost): # pylint: disable=too-many-instance-attributes - """Attributes to send when creating a document on the Paperless api.""" + _api_path = API_PATH["documents_suggestions"] - document: bytes - title: str | None = None - created: datetime | None = None - correspondent: int | None = None - document_type: int | None = None - storage_path: int | None = None + id: int | None = None + correspondents: list[int] | None = None tags: list[int] | None = None - archive_serial_number: int | None = None + document_types: list[int] | None = None + storage_paths: list[int] | None = None + dates: list[datetime.date] | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `DocumentSuggestions` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +class DocumentSuggestionsHelper(HelperBase[DocumentSuggestions]): + """Represent a factory for Paperless `DocumentSuggestions` models.""" + + _api_path = API_PATH["documents_suggestions"] + _resource = PaperlessResource.DOCUMENTS + + _resource_cls = DocumentSuggestions + + async def __call__(self, pk: int) -> DocumentSuggestions: + """Request exactly one resource item.""" + data = { + "id": pk, + } + item = self._resource_cls.create_with_data(self._api, data) + await item.load() + + return item + + +class DocumentSubHelperBase( # pylint: disable=too-few-public-methods + HelperBase[DownloadedDocument], +): + """Represent a factory for Paperless `DownloadedDocument` models.""" + + _api_path = API_PATH["documents_suggestions"] + _resource = PaperlessResource.DOCUMENTS + + _resource_cls = DownloadedDocument + + async def __call__( + self, + pk: int, + original: bool, + mode: RetrieveFileMode, + api_path: str, + ) -> DownloadedDocument: + """Request exactly one resource item.""" + data = { + "id": pk, + "mode": mode, + "original": original, + } + item = self._resource_cls.create_with_data(self._api, data) + item._api_path = api_path + await item.load() + + return item + + +class DocumentFileDownloadHelper(DocumentSubHelperBase): # pylint: disable=too-few-public-methods + """Represent a factory for Paperless `DownloadedDocument` models.""" + + _api_path = API_PATH["documents_download"] + + async def __call__( # type: ignore[override] + self, + pk: int, + original: bool = False, + ) -> DownloadedDocument: + """Request exactly one resource item.""" + return await super().__call__(pk, original, RetrieveFileMode.DOWNLOAD, self._api_path) + + +class DocumentFilePreviewHelper(DocumentSubHelperBase): # pylint: disable=too-few-public-methods + """Represent a factory for Paperless `DownloadedDocument` models.""" + + _api_path = API_PATH["documents_preview"] + + async def __call__( # type: ignore[override] + self, + pk: int, + original: bool = False, + ) -> DownloadedDocument: + """Request exactly one resource item.""" + return await super().__call__(pk, original, RetrieveFileMode.PREVIEW, self._api_path) + + +class DocumentFileThumbnailHelper(DocumentSubHelperBase): # pylint: disable=too-few-public-methods + """Represent a factory for Paperless `DownloadedDocument` models.""" + + _api_path = API_PATH["documents_thumbnail"] + + async def __call__( # type: ignore[override] + self, + pk: int, + original: bool = False, + ) -> DownloadedDocument: + """Request exactly one resource item.""" + return await super().__call__(pk, original, RetrieveFileMode.THUMBNAIL, self._api_path) + + +class DocumentMetaHelper( # pylint: disable=too-few-public-methods + HelperBase[DocumentMeta], + helpers.CallableMixin[DocumentMeta], +): + """Represent a factory for Paperless `DocumentMeta` models.""" + + _api_path = API_PATH["documents_meta"] + _resource = PaperlessResource.DOCUMENTS + + _resource_cls = DocumentMeta + + +class DocumentNoteHelper(HelperBase[DocumentNote]): # pylint: disable=too-few-public-methods + """Represent a factory for Paperless `DocumentNote` models.""" + + _api_path = API_PATH["documents_notes"] + _resource = PaperlessResource.DOCUMENTS + + _resource_cls = DocumentNote + + def __init__(self, api: "Paperless", attached_to: int | None = None) -> None: + """Initialize a `DocumentHelper` instance.""" + super().__init__(api) + + self._attached_to = attached_to + + async def __call__( + self, + pk: int | None = None, + ) -> list[DocumentNote]: + """Request and return the documents `DocumentNote` list.""" + doc_pk = self._get_document_pk(pk) + res = await self._api.request_json("get", self._get_api_path(doc_pk)) + + # We have to transform data here slightly. + # There are two major differences in the data depending on which endpoint is requested. + # url: documents/{:pk}/ -> + # .document -> int + # .user -> int + # url: documents/{:pk}/notes/ -> + # .document -> does not exist (so we add it here) + # .user -> dict(id=int, username=str, first_name=str, last_name=str) + return [ + self._resource_cls.create_with_data( + self._api, + { + **item, + "document": doc_pk, + "user": item["user"]["id"], + }, + fetched=True, + ) + for item in res + ] + + def _get_document_pk(self, pk: int | None = None) -> int: + """Return the attached document pk, or the parameter.""" + if not any((self._attached_to, pk)): + raise PrimaryKeyRequired(f"Accessing {type(self).__name__} data without a primary key.") + return cast(int, self._attached_to or pk) + + def _get_api_path(self, pk: int) -> str: + """Return the formatted api path.""" + return self._api_path.format(pk=pk) + + def draft(self, pk: int | None = None, **kwargs: Any) -> DocumentNoteDraft: + """Return a fresh and empty `DocumentNoteDraft` instance. + + Example: + ```python + draft = paperless.documents.notes.draft(...) + # do something + ``` + """ + kwargs.update({"document": self._get_document_pk(pk)}) + return DocumentNoteDraft.create_with_data( + self._api, + data=kwargs, + fetched=True, + ) + + +class DocumentHelper( # pylint: disable=too-many-ancestors + HelperBase[Document], + helpers.SecurableMixin, + helpers.CallableMixin[Document], + helpers.DraftableMixin[DocumentDraft], + helpers.IterableMixin[Document], +): + """Represent a factory for Paperless `Document` models.""" + + _api_path = API_PATH["documents"] + _resource = PaperlessResource.DOCUMENTS + + _draft_cls = DocumentDraft + _resource_cls = Document + + def __init__(self, api: "Paperless") -> None: + """Initialize a `DocumentHelper` instance.""" + super().__init__(api) + + self._download = DocumentFileDownloadHelper(api) + self._meta = DocumentMetaHelper(api) + self._notes = DocumentNoteHelper(api) + self._preview = DocumentFilePreviewHelper(api) + self._suggestions = DocumentSuggestionsHelper(api) + self._thumbnail = DocumentFileThumbnailHelper(api) + + @property + def download(self) -> DocumentFileDownloadHelper: + """Download the contents of an archived file. + + Example: + ```python + # request document contents directly... + download = await paperless.documents.download(42) + + # ... or by using an already fetched document + doc = await paperless.documents(42) + + download = await doc.get_download() + ``` + """ + return self._download + + @property + def metadata(self) -> DocumentMetaHelper: + """Return the attached `DocumentMetaHelper` instance. + + Example: + ```python + # request metadata of a document directly... + metadata = await paperless.documents.metadata(42) + + # ... or by using an already fetched document + doc = await paperless.documents(42) + metadata = await doc.get_metadata() + ``` + """ + return self._meta + + @property + def notes(self) -> DocumentNoteHelper: + """Return the attached `DocumentNoteHelper` instance. + + Example: + ```python + # request document notes directly... + notes = await paperless.documents.notes(42) + + # ... or by using an already fetched document + doc = await paperless.documents(42) + notes = await doc.notes() + ``` + """ + return self._notes + + @property + def preview(self) -> DocumentFilePreviewHelper: + """Preview the contents of an archived file. + + Example: + ```python + # request document contents directly... + download = await paperless.documents.preview(42) + + # ... or by using an already fetched document + doc = await paperless.documents(42) + + download = await doc.get_preview() + ``` + """ + return self._preview + + @property + def suggestions(self) -> DocumentSuggestionsHelper: + """Return the attached `DocumentSuggestionsHelper` instance. + + Example: + ```python + # request document suggestions directly... + suggestions = await paperless.documents.suggestions(42) + + # ... or by using an already fetched document + doc = await paperless.suggestions(42) + + suggestions = await doc.get_suggestions() + ``` + """ + return self._suggestions + + @property + def thumbnail(self) -> DocumentFileThumbnailHelper: + """Download the contents of a thumbnail file. + + Example: + ```python + # request document contents directly... + download = await paperless.documents.thumbnail(42) + + # ... or by using an already fetched document + doc = await paperless.documents(42) + + download = await doc.get_thumbnail() + ``` + """ + return self._thumbnail + + async def get_next_asn(self) -> int: + """Request the next archive serial number from DRF.""" + async with self._api.request("get", API_PATH["documents_next_asn"]) as res: + try: + res.raise_for_status() + return int(await res.text()) + except Exception as exc: + raise AsnRequestError from exc + + async def more_like(self, pk: int) -> AsyncGenerator[Document, None]: + """Lookup more documents similar to the given document pk. + + Shortcut function. Same behaviour is possible using `reduce()`. + + Documentation: https://docs.paperless-ngx.com/api/#searching-for-documents + """ + async with self.reduce(more_like_id=pk): + async for item in self: + yield item + + async def search(self, query: str) -> AsyncGenerator[Document, None]: + """Lookup documents by a search query. + + Shortcut function. Same behaviour is possible using `reduce()`. + + Documentation: https://docs.paperless-ngx.com/usage/#basic-usage_searching + """ + async with self.reduce(query=query): + async for item in self: + yield item diff --git a/pypaperless/models/generators/__init__.py b/pypaperless/models/generators/__init__.py new file mode 100644 index 0000000..04817a1 --- /dev/null +++ b/pypaperless/models/generators/__init__.py @@ -0,0 +1,5 @@ +"""PyPaperless generators.""" + +from .page import PageGenerator + +__all__ = ("PageGenerator",) diff --git a/pypaperless/models/generators/page.py b/pypaperless/models/generators/page.py new file mode 100644 index 0000000..2f9b7ea --- /dev/null +++ b/pypaperless/models/generators/page.py @@ -0,0 +1,66 @@ +"""Provide the PageGenerator class.""" + +from collections.abc import AsyncIterator +from copy import deepcopy +from typing import TYPE_CHECKING, Any + +from pypaperless.models.base import PaperlessBase +from pypaperless.models.pages import Page + +if TYPE_CHECKING: + from pypaperless import Paperless + + +class PageGenerator(PaperlessBase, AsyncIterator): + """Iterator for DRF paginated endpoints. + + `api`: An instance of :class:`Paperless`. + `url`: A url returning DRF page contents. + `resource`: A target resource model type for mapping results with. + `params`: Optional dict of query string parameters. + """ + + _page: Page | None + + def __aiter__(self) -> AsyncIterator: + """Return self as iterator.""" + return self + + async def __anext__(self) -> Page: + """Return next item from the current batch.""" + if self._page is not None and self._page.is_last_page: + raise StopAsyncIteration + + res = await self._api.request_json("get", self._url, params=self.params) + data = { + **res, + "_api_path": self._url, + "current_page": self.params["page"], + "page_size": self.params["page_size"], + } + self._page = Page.create_with_data(self._api, data, fetched=True) + self._page._resource_cls = self._resource_cls # attach the resource to the data class + + # rise page by one to request next page on next iteration + self.params["page"] += 1 + + # we do not reach this point without a self._page object, so: ignore type error + return self._page + + def __init__( + self, + api: "Paperless", + url: str, + resource_cls: type, + params: dict[str, Any] | None = None, + ): + """Initialize `PageGenerator` class instance.""" + super().__init__(api) + + self._page = None + self._resource_cls = resource_cls + self._url = url + + self.params = deepcopy(params) if params else {} + self.params.setdefault("page", 1) + self.params.setdefault("page_size", 150) diff --git a/pypaperless/models/groups.py b/pypaperless/models/groups.py deleted file mode 100644 index 1be4d22..0000000 --- a/pypaperless/models/groups.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Model for group resource.""" - -from dataclasses import dataclass - -from .base import PaperlessModel - - -@dataclass(kw_only=True) -class Group(PaperlessModel): - """Represent a group resource on the Paperless api.""" - - id: int | None = None - name: str | None = None - permissions: list[str] | None = None diff --git a/pypaperless/models/mails.py b/pypaperless/models/mails.py index 6c5899b..1a6a7cc 100644 --- a/pypaperless/models/mails.py +++ b/pypaperless/models/mails.py @@ -1,13 +1,25 @@ -"""Model for mail resources.""" +"""Provide `MailRule` related models and helpers.""" from dataclasses import dataclass +from typing import TYPE_CHECKING, Any -from .base import PaperlessModel +from pypaperless.const import API_PATH, PaperlessResource +from .base import HelperBase, PaperlessModel +from .mixins import helpers, models -@dataclass(kw_only=True) -class MailAccount(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a mail account resource on the Paperless api.""" +if TYPE_CHECKING: + from pypaperless import Paperless + + +@dataclass(init=False) +class MailAccount( + PaperlessModel, + models.SecurableMixin, +): # pylint: disable=too-many-instance-attributes + """Represent a Paperless `MailAccount`.""" + + _api_path = API_PATH["mail_accounts_single"] id: int | None = None name: str | None = None @@ -15,16 +27,26 @@ class MailAccount(PaperlessModel): # pylint: disable=too-many-instance-attribut imap_port: int | None = None imap_security: int | None = None username: str | None = None - password: str | None = None + # exclude that from the dataclass + # password: str | None = None character_set: str | None = None is_token: bool | None = None - owner: int | None = None - user_can_change: bool | None = None + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `MailAccount` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) -@dataclass(kw_only=True) -class MailRule(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a mail rule resource on the Paperless api.""" + +@dataclass(init=False) +class MailRule( + PaperlessModel, + models.SecurableMixin, +): # pylint: disable=too-many-instance-attributes + """Represent a Paperless `MailRule`.""" + + _api_path = API_PATH["mail_rules_single"] id: int | None = None name: str | None = None @@ -48,5 +70,37 @@ class MailRule(PaperlessModel): # pylint: disable=too-many-instance-attributes order: int | None = None attachment_type: int | None = None consumption_scope: int | None = None - owner: int | None = None - user_can_change: bool | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `MailRule` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +class MailAccountHelper( # pylint: disable=too-many-ancestors + HelperBase[MailAccount], + helpers.CallableMixin[MailAccount], + helpers.IterableMixin[MailAccount], + helpers.SecurableMixin, +): + """Represent a factory for Paperless `MailAccount` models.""" + + _api_path = API_PATH["mail_accounts"] + _resource = PaperlessResource.MAIL_ACCOUNTS + + _resource_cls = MailAccount + + +class MailRuleHelper( # pylint: disable=too-many-ancestors + HelperBase[MailRule], + helpers.CallableMixin[MailRule], + helpers.IterableMixin[MailRule], + helpers.SecurableMixin, +): + """Represent a factory for Paperless `MailRule` models.""" + + _api_path = API_PATH["mail_rules"] + _resource = PaperlessResource.MAIL_RULES + + _resource_cls = MailRule diff --git a/pypaperless/models/matching.py b/pypaperless/models/matching.py deleted file mode 100644 index c1a02c4..0000000 --- a/pypaperless/models/matching.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Model for matching resources.""" - -from dataclasses import dataclass -from datetime import datetime -from enum import Enum - -from .base import PaperlessModel, PaperlessPost - - -class MatchingAlgorithm(Enum): - """Enum with matching algorithms.""" - - NONE = 0 - ANY = 1 - ALL = 2 - LITERAL = 3 - REGEX = 4 - FUZZY = 5 - AUTO = 6 - UNKNOWN = -1 - - @classmethod - def _missing_(cls: type, value: object) -> "MatchingAlgorithm": # noqa ARG003 - """Set default member on unknown value.""" - return MatchingAlgorithm.UNKNOWN - - -@dataclass(kw_only=True) -class PaperlessModelMatchingMixin: - """Mixin that adds matching attributes to a model.""" - - match: str | None = None - matching_algorithm: MatchingAlgorithm | None = None - is_insensitive: bool | None = None - - def __post_init__(self) -> None: - """Set default values when missing. Only on `POST`.""" - if not isinstance(self, PaperlessPost): - return - - if not self.match: - self.match = "" - if not self.matching_algorithm: - self.matching_algorithm = MatchingAlgorithm.NONE - if not self.is_insensitive: - self.is_insensitive = True - - -@dataclass(kw_only=True) -class Correspondent(PaperlessModel, PaperlessModelMatchingMixin): - """Represent a correspondent resource on the Paperless api.""" - - id: int | None = None - slug: str | None = None - name: str | None = None - document_count: int | None = None - last_correspondence: datetime | None = None - owner: int | None = None - user_can_change: bool | None = None - - -@dataclass(kw_only=True) -class DocumentType(PaperlessModel, PaperlessModelMatchingMixin): - """Represent a document type resource on the Paperless api.""" - - id: int | None = None - slug: str | None = None - name: str | None = None - document_count: int | None = None - owner: int | None = None - user_can_change: bool | None = None - - -@dataclass(kw_only=True) -class StoragePath(PaperlessModel, PaperlessModelMatchingMixin): - """Represent a storage path resource on the Paperless api.""" - - id: int | None = None - slug: str | None = None - name: str | None = None - path: str | None = None - document_count: int | None = None - owner: int | None = None - user_can_change: bool | None = None - - -@dataclass(kw_only=True) -class Tag( - PaperlessModel, PaperlessModelMatchingMixin -): # pylint: disable=too-many-instance-attributes - """Represent a tag resource on the Paperless api.""" - - id: int | None = None - slug: str | None = None - name: str | None = None - color: str | None = None - text_color: str | None = None - is_inbox_tag: bool | None = None - document_count: int | None = None - owner: int | None = None - user_can_change: bool | None = None - - -@dataclass(kw_only=True) -class CorrespondentPost(PaperlessPost, PaperlessModelMatchingMixin): - """Attributes to send when creating a correspondent on the Paperless api.""" - - name: str - owner: int | None = None - - -@dataclass(kw_only=True) -class DocumentTypePost(PaperlessPost, PaperlessModelMatchingMixin): - """Attributes to send when creating a document type on the api.""" - - name: str - owner: int | None = None - - -@dataclass(kw_only=True) -class StoragePathPost(PaperlessPost, PaperlessModelMatchingMixin): - """Attributes to send when creating a storage path on the Paperless api.""" - - name: str - path: str - owner: int | None = None - - -@dataclass(kw_only=True) -class TagPost(PaperlessPost, PaperlessModelMatchingMixin): - """Attributes to send when creating a tag on the Paperless api.""" - - name: str - color: str = "#ffffff" - text_color: str = "#000000" - is_inbox_tag: bool = False - owner: int | None = None diff --git a/pypaperless/models/mixins/__init__.py b/pypaperless/models/mixins/__init__.py new file mode 100644 index 0000000..3001c00 --- /dev/null +++ b/pypaperless/models/mixins/__init__.py @@ -0,0 +1 @@ +"""Mixins for PyPaperless models.""" diff --git a/pypaperless/models/mixins/helpers/__init__.py b/pypaperless/models/mixins/helpers/__init__.py new file mode 100644 index 0000000..0608cad --- /dev/null +++ b/pypaperless/models/mixins/helpers/__init__.py @@ -0,0 +1,13 @@ +"""Mixins for PyPaperless helpers.""" + +from .callable import CallableMixin +from .draftable import DraftableMixin +from .iterable import IterableMixin +from .securable import SecurableMixin + +__all__ = ( + "CallableMixin", + "DraftableMixin", + "IterableMixin", + "SecurableMixin", +) diff --git a/pypaperless/models/mixins/helpers/callable.py b/pypaperless/models/mixins/helpers/callable.py new file mode 100644 index 0000000..f8bf6d3 --- /dev/null +++ b/pypaperless/models/mixins/helpers/callable.py @@ -0,0 +1,38 @@ +"""CallableMixin for PyPaperless helpers.""" + +from pypaperless.models.base import HelperProtocol, ResourceT + +from .securable import SecurableMixin + + +class CallableMixin(HelperProtocol[ResourceT]): # pylint: disable=too-few-public-methods + """Provide methods for calling a specific resource item.""" + + async def __call__( + self, + pk: int, + lazy: bool = False, + ) -> ResourceT: + """Request exactly one resource item. + + Example: + ```python + # request a document + document = await paperless.documents(42) + + # initialize a model and request it later + document = await paperless.documents(42, lazy=True) + ``` + """ + data = { + "id": pk, + } + item = self._resource_cls.create_with_data(self._api, data) + + # set requesting full permissions + if SecurableMixin in type(self).__bases__ and getattr(self, "_request_full_perms", False): + item._params.update({"full_perms": "true"}) # pylint: disable=protected-access + + if not lazy: + await item.load() + return item diff --git a/pypaperless/models/mixins/helpers/draftable.py b/pypaperless/models/mixins/helpers/draftable.py new file mode 100644 index 0000000..7345243 --- /dev/null +++ b/pypaperless/models/mixins/helpers/draftable.py @@ -0,0 +1,29 @@ +"""DraftableMixin for PyPaperless helpers.""" + +from typing import Any + +from pypaperless.exceptions import DraftNotSupported +from pypaperless.models.base import HelperProtocol, ResourceT + + +class DraftableMixin(HelperProtocol[ResourceT]): # pylint: disable=too-few-public-methods + """Provide the `draft` method for PyPaperless helpers.""" + + _draft_cls: type[ResourceT] + + def draft(self, **kwargs: Any) -> ResourceT: + """Return a fresh and empty `PaperlessModel` instance. + + Example: + ```python + draft = paperless.documents.draft(document=bytes(...), title="New Document") + # do something + ``` + """ + if not hasattr(self, "_draft_cls"): + raise DraftNotSupported("Helper class has no _draft_cls attribute.") + kwargs.update({"id": -1}) + + item = self._draft_cls.create_with_data(self._api, data=kwargs, fetched=True) + + return item diff --git a/pypaperless/models/mixins/helpers/iterable.py b/pypaperless/models/mixins/helpers/iterable.py new file mode 100644 index 0000000..d1a5a0d --- /dev/null +++ b/pypaperless/models/mixins/helpers/iterable.py @@ -0,0 +1,91 @@ +"""IterableMixin for PyPaperless helpers.""" + +from collections.abc import AsyncGenerator, AsyncIterator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Self + +from pypaperless.models.base import HelperProtocol, ResourceT +from pypaperless.models.generators import PageGenerator + +if TYPE_CHECKING: + from pypaperless.models import Page + + +class IterableMixin(HelperProtocol[ResourceT]): + """Provide methods for iterating over resource items.""" + + _aiter_filters: dict[str, str | int] | None + + async def __aiter__(self) -> AsyncIterator[ResourceT]: + """Iterate over resource items. + + Example: + ```python + async for item in paperless.documents: + # do something + ``` + """ + async for page in self.pages(): + for item in page: + yield item + + @asynccontextmanager + async def reduce( + self: Self, + **kwargs: str | int, + ) -> AsyncGenerator[Self, None]: + """Provide context for iterating over resource items with query parameters. + + `kwargs`: Insert any Paperless api supported filter keywords here. + You can provide `page` and `page_size` parameters, as well. + + Example: + ```python + filters = { + "page_size": 1337, + "title__icontains": "2023", + } + + async with paperless.documents.reduce(**filters): + # iterate over resource items ... + async for item in paperless.documents: + ... + + # ... or iterate pages as-is + async for page in paperless.documents.pages(): + ... + ``` + """ + self._aiter_filters = kwargs + yield self + self._aiter_filters = None + + async def all(self) -> list[int]: + """Return a list of all resource item primary keys. + + When used within a `reduce` context, returns a list of filtered primary keys. + """ + page = await anext(self.pages(page=1)) + return page.all + + def pages( + self, + page: int = 1, + page_size: int = 150, + ) -> "AsyncIterator[Page[ResourceT]]": + """Iterate over resource pages. + + `page`: A page number to start with. + `page_size`: The page size for each requested batch. + + Example: + ```python + async for item in paperless.documents.pages(): + # do something + ``` + """ + params = getattr(self, "_aiter_filters", None) or {} + params.setdefault("page", page) + params.setdefault("page_size", page_size) + + return PageGenerator(self._api, self._api_path, self._resource_cls, params=params) diff --git a/pypaperless/models/mixins/helpers/securable.py b/pypaperless/models/mixins/helpers/securable.py new file mode 100644 index 0000000..5d7ffdf --- /dev/null +++ b/pypaperless/models/mixins/helpers/securable.py @@ -0,0 +1,23 @@ +"""SecurableMixin for PyPaperless helpers.""" + + +class SecurableMixin: # pylint: disable=too-few-public-methods + """Provide the `request_full_permissions` property for PyPaperless helpers.""" + + _request_full_perms: bool = False + + @property + def request_permissions(self) -> bool: + """Return whether the helper requests items with the `permissions` table, or not. + + Documentation: https://docs.paperless-ngx.com/api/#permissions + """ + return self._request_full_perms + + @request_permissions.setter + def request_permissions(self, value: bool) -> None: + """Set whether the helper requests items with the `permissions` table, or not. + + Documentation: https://docs.paperless-ngx.com/api/#permissions + """ + self._request_full_perms = value diff --git a/pypaperless/models/mixins/models/__init__.py b/pypaperless/models/mixins/models/__init__.py new file mode 100644 index 0000000..2b5b157 --- /dev/null +++ b/pypaperless/models/mixins/models/__init__.py @@ -0,0 +1,17 @@ +"""Mixins for PyPaperless models.""" + +from .creatable import CreatableMixin +from .data_fields import MatchingFieldsMixin # , PermissionFieldsMixin +from .deletable import DeletableMixin +from .securable import SecurableDraftMixin, SecurableMixin +from .updatable import UpdatableMixin + +__all__ = ( + "CreatableMixin", + "DeletableMixin", + "MatchingFieldsMixin", + # "PermissionFieldsMixin", + "UpdatableMixin", + "SecurableMixin", + "SecurableDraftMixin", +) diff --git a/pypaperless/models/mixins/models/creatable.py b/pypaperless/models/mixins/models/creatable.py new file mode 100644 index 0000000..969243b --- /dev/null +++ b/pypaperless/models/mixins/models/creatable.py @@ -0,0 +1,64 @@ +"""CreatableMixin for PyPaperless models.""" + +from typing import Any, cast + +from pypaperless.exceptions import DraftFieldRequired +from pypaperless.models.base import PaperlessModelProtocol +from pypaperless.models.utils import object_to_dict_value + + +class CreatableMixin(PaperlessModelProtocol): # pylint: disable=too-few-public-methods + """Provide the `save` method for PyPaperless models.""" + + _create_required_fields: set[str] + + async def save(self) -> int | str | tuple[int, int]: + """Create a new `resource item` in Paperless. + + Return the created item `id`, or a `task_id` in case of documents. + + Example: + ```python + draft = paperless.documents.draft(document=bytes(...)) + draft.title = "Add a title" + + # request Paperless to store the new item + draft.save() + ``` + """ + self.validate() + kwdict = self._serialize() + res = await self._api.request_json("post", self._api_path, **kwdict) + + if type(self).__name__ == "DocumentNoteDraft": + return ( + cast(int, max(item.get("id") for item in res)), + cast(int, kwdict["json"]["document"]), + ) + if isinstance(res, dict): + return int(res["id"]) + return str(res) + + def _serialize(self) -> dict[str, Any]: + """Serialize.""" + data = { + "json": { + field.name: object_to_dict_value(getattr(self, field.name)) + for field in self._get_dataclass_fields() + }, + } + # check for empty permissions as they will raise if None + if "set_permissions" in data["json"] and data["json"]["set_permissions"] is None: + del data["json"]["set_permissions"] + + return data + + def validate(self) -> None: + """Check required fields before persisting the item to Paperless.""" + missing = [field for field in self._create_required_fields if getattr(self, field) is None] + + if len(missing) == 0: + return + raise DraftFieldRequired( + f"Missing fields for saving a `{type(self).__name__}`: {', '.join(missing)}." + ) diff --git a/pypaperless/models/mixins/models/data_fields.py b/pypaperless/models/mixins/models/data_fields.py new file mode 100644 index 0000000..b55e4fa --- /dev/null +++ b/pypaperless/models/mixins/models/data_fields.py @@ -0,0 +1,14 @@ +"""PermissionFieldsMixin for PyPaperless models.""" + +from dataclasses import dataclass + +from pypaperless.models.common import MatchingAlgorithmType + + +@dataclass +class MatchingFieldsMixin: + """Provide shared matching fields for PyPaperless models.""" + + match: str | None = None + matching_algorithm: MatchingAlgorithmType | None = None + is_insensitive: bool | None = None diff --git a/pypaperless/models/mixins/models/deletable.py b/pypaperless/models/mixins/models/deletable.py new file mode 100644 index 0000000..8bed835 --- /dev/null +++ b/pypaperless/models/mixins/models/deletable.py @@ -0,0 +1,26 @@ +"""DeletableMixin for PyPaperless models.""" + +from pypaperless.models.base import PaperlessModelProtocol + + +class DeletableMixin(PaperlessModelProtocol): # pylint: disable=too-few-public-methods + """Provide the `delete` method for PyPaperless models.""" + + async def delete(self) -> bool: + """Delete a `resource item` from DRF. There is no point of return. + + Return `True` when deletion was successful, `False` otherwise. + + Example: + ```python + # request a document + document = await paperless.documents(42) + + if await document.delete(): + print("Successfully deleted the document!") + ``` + """ + async with self._api.request("delete", self._api_path) as res: + success = res.status == 204 + + return success diff --git a/pypaperless/models/mixins/models/securable.py b/pypaperless/models/mixins/models/securable.py new file mode 100644 index 0000000..ae9a6bd --- /dev/null +++ b/pypaperless/models/mixins/models/securable.py @@ -0,0 +1,27 @@ +"""SecurableMixin for PyPaperless models.""" + +from dataclasses import dataclass + +from pypaperless.models.common import PermissionTableType + + +@dataclass(kw_only=True) +class SecurableMixin: + """Provide permission fields for PyPaperless models.""" + + owner: int | None = None + user_can_change: bool | None = None + permissions: PermissionTableType | None = None + + @property + def has_permissions(self) -> bool: + """Return if the model data includes the permission field.""" + return self.permissions is not None + + +@dataclass(kw_only=True) +class SecurableDraftMixin: + """Provide permission fields for PyPaperless draft models.""" + + owner: int | None = None + set_permissions: PermissionTableType | None = None diff --git a/pypaperless/models/mixins/models/updatable.py b/pypaperless/models/mixins/models/updatable.py new file mode 100644 index 0000000..9b43e66 --- /dev/null +++ b/pypaperless/models/mixins/models/updatable.py @@ -0,0 +1,71 @@ +"""UpdatableMixin for PyPaperless models.""" + +from typing import Any + +from pypaperless.models.base import PaperlessModelProtocol +from pypaperless.models.utils import object_to_dict_value + + +class UpdatableMixin(PaperlessModelProtocol): # pylint: disable=too-few-public-methods + """Provide the `update` method for PyPaperless models.""" + + _data: dict[str, Any] + + async def update(self, only_changed: bool = True) -> bool: + """Send actually changed `model data` to DRF. + + Return `True` when any attribute was updated, `False` otherwise. + + Example: + ```python + # request a document + document = await paperless.documents(42) + + document.title = "New Title" + if await document.update(): + print("Successfully updated a field!") + ``` + """ + updated = False + + if only_changed: + updated = await self._patch_fields() + else: + updated = await self._put_fields() + + self._set_dataclass_fields() + return updated + + async def _patch_fields(self) -> bool: + """Use the http `PATCH` method for updating only changed fields.""" + changed = {} + for field in self._get_dataclass_fields(): + new_value = object_to_dict_value(getattr(self, field.name)) + + if field.name in self._data and new_value != self._data[field.name]: + changed[field.name] = new_value + + if len(changed) == 0: + return False + + self._data = await self._api.request_json( + "patch", + self._api_path, + json=changed, + params=self._params, + ) + return True + + async def _put_fields(self) -> bool: + """Use the http `PUT` method to replace all fields.""" + data = { + field.name: object_to_dict_value(getattr(self, field.name)) + for field in self._get_dataclass_fields() + } + self._data = await self._api.request_json( + "put", + self._api_path, + json=data, + params=self._params, + ) + return True diff --git a/pypaperless/models/pages.py b/pypaperless/models/pages.py new file mode 100644 index 0000000..4f2ec43 --- /dev/null +++ b/pypaperless/models/pages.py @@ -0,0 +1,93 @@ +"""Provide the `Paginated` class.""" + +import math +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any, Generic + +from pypaperless.const import API_PATH +from pypaperless.models.base import ResourceT + +from .base import PaperlessModel + + +@dataclass(init=False) +class Page( + PaperlessModel, + Generic[ResourceT], +): # pylint: disable=too-many-instance-attributes + """Represent a Paperless DRF `Paginated`.""" + + _api_path = API_PATH["index"] + _resource_cls: type[ResourceT] + + # our fields + current_page: int + page_size: int + + # DRF fields + count: int + next: str | None = None + previous: str | None = None + all: list[int] = field(default_factory=list) + results: list[dict[str, Any]] = field(default_factory=list) + + def __iter__(self) -> Iterator[ResourceT]: + """Return iter of `.items`.""" + return iter(self.items) + + @property + def current_count(self) -> int: + """Return the item count of the current page.""" + return len(self.results) + + @property + def has_next_page(self) -> bool: + """Return whether there is a next page or not.""" + return self.next_page is not None + + @property + def has_previous_page(self) -> bool: + """Return whether there is a previous page or not.""" + return self.previous_page is not None + + @property + def items(self) -> list[ResourceT]: + """Return the results list field with mapped PyPaperless `models`. + + Example: + ```python + async for page in paperless.documents.pages(): + assert isinstance(page.results.pop(), Document) # fails, it is a dict + assert isinstance(page.items.pop(), Document) # ok + ``` + """ + + def mapper(data: dict[str, Any]) -> ResourceT: + return self._resource_cls.create_with_data(self._api, data, fetched=True) + + return list(map(mapper, self._data["results"])) + + @property + def is_last_page(self) -> bool: + """Return whether we are on the last page or not.""" + return not self.has_next_page + + @property + def last_page(self) -> int: + """Return the last page number.""" + return math.ceil(self.count / self.page_size) + + @property + def next_page(self) -> int | None: + """Return the next page number if a next page exists.""" + if self.next is None: + return None + return self.current_page + 1 + + @property + def previous_page(self) -> int | None: + """Return the previous page number if a previous page exists.""" + if self.previous is None: + return None + return self.current_page - 1 diff --git a/pypaperless/models/permissions.py b/pypaperless/models/permissions.py new file mode 100644 index 0000000..998efd4 --- /dev/null +++ b/pypaperless/models/permissions.py @@ -0,0 +1,84 @@ +"""Provide `User` and 'Group' related models and helpers.""" + +import datetime +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from pypaperless.const import API_PATH, PaperlessResource + +from .base import HelperBase, PaperlessModel +from .mixins import helpers + +if TYPE_CHECKING: + from pypaperless import Paperless + + +@dataclass(init=False) +class Group(PaperlessModel): + """Represent a Paperless `Group`.""" + + _api_path = API_PATH["groups_single"] + + id: int + name: str | None = None + permissions: list[str] | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Group` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +@dataclass(init=False) +class User(PaperlessModel): # pylint: disable=too-many-instance-attributes + """Represent a Paperless `User`.""" + + _api_path = API_PATH["users_single"] + + id: int + username: str | None = None + # exclude that from the dataclass + # password: str | None = None + email: str | None = None + first_name: str | None = None + last_name: str | None = None + date_joined: datetime.datetime | None = None + is_staff: bool | None = None + is_active: bool | None = None + is_superuser: bool | None = None + groups: list[int] | None = None + user_permissions: list[str] | None = None + inherited_permissions: list[str] | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `User` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +class GroupHelper( + HelperBase[Group], + helpers.CallableMixin[Group], + helpers.IterableMixin[Group], +): + """Represent a factory for Paperless `Group` models.""" + + _api_path = API_PATH["groups"] + _resource = PaperlessResource.GROUPS + + _resource_cls = Group + + +class UserHelper( + HelperBase[User], + helpers.CallableMixin[User], + helpers.IterableMixin[User], +): + """Represent a factory for Paperless `User` models.""" + + _api_path = API_PATH["users"] + _resource = PaperlessResource.USERS + + _resource_cls = User diff --git a/pypaperless/models/saved_views.py b/pypaperless/models/saved_views.py index 9b1ef7a..5f432fa 100644 --- a/pypaperless/models/saved_views.py +++ b/pypaperless/models/saved_views.py @@ -1,21 +1,26 @@ -"""Model for saved view resource.""" +"""Provide `SavedView` related models and helpers.""" from dataclasses import dataclass +from typing import TYPE_CHECKING, Any -from .base import PaperlessModel +from pypaperless.const import API_PATH, PaperlessResource +from .base import HelperBase, PaperlessModel +from .common import SavedViewFilterRuleType +from .mixins import helpers, models -@dataclass(kw_only=True) -class SavedViewFilterRule(PaperlessModel): - """Represent a saved view filter rule resource on the Paperless api.""" +if TYPE_CHECKING: + from pypaperless import Paperless - rule_type: int | None = None - value: str | None = None +@dataclass(init=False) +class SavedView( + PaperlessModel, + models.SecurableMixin, +): # pylint: disable=too-many-instance-attributes + """Represent a Paperless `SavedView`.""" -@dataclass(kw_only=True) -class SavedView(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a saved view resource on the Paperless api.""" + _api_path = API_PATH["saved_views_single"] id: int | None = None name: str | None = None @@ -23,6 +28,24 @@ class SavedView(PaperlessModel): # pylint: disable=too-many-instance-attributes show_in_sidebar: bool | None = None sort_field: str | None = None sort_reverse: bool | None = None - filter_rules: list[SavedViewFilterRule] | None = None - owner: int | None = None - user_can_change: bool | None = None + filter_rules: list[SavedViewFilterRuleType] | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `SavedView` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +class SavedViewHelper( # pylint: disable=too-many-ancestors + HelperBase[SavedView], + helpers.CallableMixin[SavedView], + helpers.IterableMixin[SavedView], + helpers.SecurableMixin, +): + """Represent a factory for Paperless `SavedView` models.""" + + _api_path = API_PATH["saved_views"] + _resource = PaperlessResource.SAVED_VIEWS + + _resource_cls = SavedView diff --git a/pypaperless/models/share_links.py b/pypaperless/models/share_links.py index 02a1782..398f656 100644 --- a/pypaperless/models/share_links.py +++ b/pypaperless/models/share_links.py @@ -1,41 +1,69 @@ -"""Model for share link resource.""" +"""Provide `ShareLink` related models and helpers.""" +import datetime from dataclasses import dataclass -from datetime import datetime -from enum import Enum +from typing import TYPE_CHECKING, Any -from .base import PaperlessModel, PaperlessPost +from pypaperless.const import API_PATH, PaperlessResource +from .base import HelperBase, PaperlessModel +from .common import ShareLinkFileVersionType +from .mixins import helpers, models -class ShareLinkFileVersion(Enum): - """Enum with file version.""" +if TYPE_CHECKING: + from pypaperless import Paperless - ARCHIVE = "archive" - ORIGINAL = "original" - UNKNOWN = "unknown" - @classmethod - def _missing_(cls: type, value: object) -> "ShareLinkFileVersion": # noqa ARG003 - """Set default member on unknown value.""" - return ShareLinkFileVersion.UNKNOWN +@dataclass(init=False) +class ShareLink( + PaperlessModel, + models.DeletableMixin, + models.UpdatableMixin, +): + """Represent a Paperless `ShareLink`.""" + _api_path = API_PATH["share_links_single"] -@dataclass(kw_only=True) -class ShareLink(PaperlessModel): - """Represent a share link resource on the Paperless api.""" - - id: int | None = None - created: datetime | None = None - expiration: datetime | None = None + id: int + created: datetime.datetime | None = None + expiration: datetime.datetime | None = None slug: str | None = None document: int | None = None - file_version: ShareLinkFileVersion | None = None + file_version: ShareLinkFileVersionType | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `MailAccount` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +@dataclass(init=False) +class ShareLinkDraft( + PaperlessModel, + models.CreatableMixin, +): + """Represent a new Paperless `ShareLink`, which is not stored in Paperless.""" + + _api_path = API_PATH["share_links"] + + _create_required_fields = {"document", "file_version"} + + expiration: datetime.datetime | None = None + document: int | None = None + file_version: ShareLinkFileVersionType | None = None + +class ShareLinkHelper( # pylint: disable=too-many-ancestors + HelperBase[ShareLink], + helpers.CallableMixin[ShareLink], + helpers.DraftableMixin[ShareLinkDraft], + helpers.IterableMixin[ShareLink], +): + """Represent a factory for Paperless `ShareLink` models.""" -@dataclass(kw_only=True) -class ShareLinkPost(PaperlessPost): - """Attributes to send when creating a share link on the Paperless api.""" + _api_path = API_PATH["share_links"] + _resource = PaperlessResource.SHARE_LINKS - expiration: datetime - document: int - file_version: ShareLinkFileVersion + _draft_cls = ShareLinkDraft + _resource_cls = ShareLink diff --git a/pypaperless/models/tasks.py b/pypaperless/models/tasks.py index 8e6080a..ea7fccc 100644 --- a/pypaperless/models/tasks.py +++ b/pypaperless/models/tasks.py @@ -1,28 +1,26 @@ -"""Model for task resource.""" +"""Provide `Task` related models and helpers.""" +from collections.abc import AsyncIterator from dataclasses import dataclass -from enum import Enum +from typing import TYPE_CHECKING, Any -from .base import PaperlessModel +from pypaperless.const import API_PATH, PaperlessResource +from pypaperless.exceptions import TaskNotFound +from .base import HelperBase, PaperlessModel +from .common import TaskStatusType -class TaskStatus(Enum): - """Enum with task states.""" +if TYPE_CHECKING: + from pypaperless import Paperless - PENDING = "PENDING" - SUCCESS = "SUCCESS" - FAILURE = "FAILURE" - UNKNOWN = "UNKNOWN" - @classmethod - def _missing_(cls: type, value: object) -> "TaskStatus": # noqa ARG003 - """Set default member on unknown value.""" - return TaskStatus.UNKNOWN +@dataclass(init=False) +class Task( # pylint: disable=too-many-instance-attributes + PaperlessModel, +): + """Represent a Paperless `Task`.""" - -@dataclass(kw_only=True) -class Task(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a task resource in the Paperless api.""" + _api_path = API_PATH["tasks_single"] id: int | None = None task_id: str | None = None @@ -30,7 +28,67 @@ class Task(PaperlessModel): # pylint: disable=too-many-instance-attributes date_created: str | None = None date_done: str | None = None type: str | None = None - status: TaskStatus | None = None + status: TaskStatusType | None = None result: str | None = None acknowledged: bool | None = None related_document: int | None = None + + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Task` instance.""" + super().__init__(api, data) + + self._api_path = self._api_path.format(pk=data.get("id")) + + +class TaskHelper( + HelperBase[Task], +): + """Represent a factory for Paperless `Task` models.""" + + _api_path = API_PATH["tasks"] + _resource = PaperlessResource.TASKS + + _resource_cls = Task + + async def __aiter__(self) -> AsyncIterator[Task]: + """Iterate over task items. + + Example: + ```python + async for task in paperless.tasks: + # do something + ``` + """ + res = await self._api.request_json("get", self._api_path) + for data in res: + yield self._resource_cls.create_with_data(self._api, data, fetched=True) + + async def __call__(self, task_id: int | str) -> Task: + """Request exactly one task by id. + + If task_id is `str`: interpret it as a task uuid. + If task_id is `int`: interpret it as a primary key. + + Example: + ```python + task = await paperless.tasks("uuid-string") + task = await paperless.tasks(1337) + ``` + """ + if isinstance(task_id, str): + params = { + "task_id": task_id, + } + res = await self._api.request_json("get", self._api_path, params=params) + try: + item = self._resource_cls.create_with_data(self._api, res.pop(), fetched=True) + except IndexError as exc: + raise TaskNotFound(task_id) from exc + else: + data = { + "id": task_id, + } + item = self._resource_cls.create_with_data(self._api, data) + await item.load() + + return item diff --git a/pypaperless/models/users.py b/pypaperless/models/users.py deleted file mode 100644 index 3329932..0000000 --- a/pypaperless/models/users.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Model for user resource.""" - -from dataclasses import dataclass -from datetime import datetime - -from .base import PaperlessModel - - -@dataclass(kw_only=True) -class User(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a user resource on the Paperless api.""" - - id: int | None = None - username: str | None = None - password: str | None = None - email: str | None = None - first_name: str | None = None - last_name: str | None = None - date_joined: datetime | None = None - is_staff: bool | None = None - is_active: bool | None = None - is_superuser: bool | None = None - groups: list[int] | None = None - user_permissions: list[str] | None = None - inherited_permissions: list[str] | None = None diff --git a/pypaperless/util.py b/pypaperless/models/utils/__init__.py similarity index 50% rename from pypaperless/util.py rename to pypaperless/models/utils/__init__.py index 9bb7eef..13ab4e1 100644 --- a/pypaperless/util.py +++ b/pypaperless/models/utils/__init__.py @@ -1,5 +1,5 @@ """ -Utils for pypaperless. +Utils for pypaperless models. Since there are common use-cases in transforming dicts to dataclass et vice-versa, we borrowed some snippets from aiohue instead of re-inventing the wheel. @@ -16,109 +16,84 @@ # pylint: disable=all import logging -from dataclasses import MISSING, asdict, dataclass, fields, is_dataclass +from dataclasses import MISSING, asdict, fields, is_dataclass from datetime import date, datetime from enum import Enum from types import NoneType, UnionType -from typing import Any, Union, get_args, get_origin, get_type_hints +from typing import TYPE_CHECKING, Any, Union, get_args, get_origin, get_type_hints -from yarl import URL +import pypaperless.models.base as paperless_base +if TYPE_CHECKING: + from pypaperless import Paperless -def create_url_from_input(url: str | URL) -> URL: - """Create URL from string or URL and prepare for further usage.""" - # reverse compatibility, fall back to https - if isinstance(url, str) and "://" not in url: - url = f"https://{url}".rstrip("/") - url = URL(url) - # scheme check. fall back to https - if url.scheme not in ("https", "http"): - url = URL(url).with_scheme("https") +def _str_to_datetime(datetimestr: str): + """Parse datetime from string.""" + return datetime.fromisoformat(datetimestr.replace("Z", "+00:00")) - # check if /api is included - if url.name != "api": - url = url.with_path(url.path.rstrip("/") + "/api") - return url +def _str_to_date(datestr: str): + """Parse date from string.""" + return date.fromisoformat(datestr) -def update_dataclass(cur_obj: dataclass, new_vals: dict) -> set[str]: - """ - Update instance of dataclass from (partial) dict. +def _dateobj_to_str(value: date | datetime): + """Parse string from date objects.""" + result = value.isoformat().replace("+00:00", "Z") + if isinstance(value, datetime): + result = result.rstrip("Z") + "Z" + return result + + +def object_to_dict_value(value: Any) -> Any: + """Convert object values to their correspondending json values.""" + + def _clean_value(_value_obj: Any) -> Any: + if isinstance(_value_obj, dict): + _value_obj = _clean_dict(_value_obj) + if isinstance(_value_obj, list): + _value_obj = _clean_list(_value_obj) + if isinstance(_value_obj, Enum): + _value_obj = _value_obj.value + if isinstance(_value_obj, date | datetime): + _value_obj = _dateobj_to_str(_value_obj) + if is_dataclass(_value_obj): + _value_obj = _clean_dict(asdict(_value_obj)) + return _value_obj + + def _clean_list(_list_obj: list) -> list[Any]: + final = [] + for list_value in _list_obj: + final.append(_clean_value(list_value)) + return final - Returns: Set with changed keys. - """ - changed_keys = set() - for f in fields(cur_obj): - cur_val = getattr(cur_obj, f.name, None) - new_val = new_vals.get(f.name) - - # handle case where value is sub dataclass/model - if is_dataclass(cur_val) and isinstance(new_val, dict): - for subkey in update_dataclass(cur_val, new_val): - changed_keys.add(f"{f.name}.{subkey}") - continue - # parse value from type annotations - new_val = _parse_value(f.name, new_val, f.type, cur_val) - if cur_val == new_val: - continue - setattr(cur_obj, f.name, new_val) - changed_keys.add(f.name) - return changed_keys - - -def dataclass_to_dict(obj_in: dataclass, skip_none: bool = True) -> dict: - """Convert dataclass instance to dict, optionally skip None values.""" - if skip_none: - dict_obj = asdict(obj_in, dict_factory=lambda x: {k: v for (k, v) in x if v is not None}) - else: - dict_obj = asdict(obj_in) - - # added for pypaperless - def _clean_list(_list_obj: list): - for i, value in enumerate(_list_obj): - if isinstance(value, dict): - _list_obj[i] = _clean_dict(value) - elif isinstance(value, list): - _list_obj[i] = _clean_list(value) - else: - _list_obj[i] = value - return _list_obj - - def _clean_dict(_dict_obj: dict): + def _clean_dict(_dict_obj: dict) -> dict[str, Any]: final = {} - for key, value in _dict_obj.items(): - if value is None and skip_none: - continue - # added for pypaperless - if isinstance(value, list): - value = _clean_list(value) # noqa: PLW2901 - if isinstance(value, dict): - value = _clean_dict(value) # noqa: PLW2901 - if isinstance(value, Enum): - value = value.value # noqa: PLW2901 - # added for pypaperless - if isinstance(value, date | datetime): - value = value.isoformat() # noqa: PLW2901 - final[key] = value + for dict_key, dict_value in _dict_obj.items(): + final[dict_key] = _clean_value(dict_value) return final - return _clean_dict(dict_obj) - + return _clean_value(value) -def parse_utc_timestamp(datetimestr: str): - """Parse datetime from string.""" - return datetime.fromisoformat(datetimestr.replace("Z", "+00:00")) +def dict_value_to_object( + name: str, + value: Any, + value_type: Any, + default: Any = MISSING, + _api: "Paperless | None" = None, +) -> Any: + """Try to parse a value from raw (json) data and type annotations. -def parse_date(datestr: str): - """Parse date from string.""" - return date.fromisoformat(datestr) + Since there are common use-cases in transforming dicts to dataclass et vice-versa, + we borrowed some snippets from aiohue instead of re-inventing the wheel. + pypaperless is meant to be the api library for a Home Assistant integration, + so it should be okay I think. -def _parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) -> Any: - """Try to parse a value from raw (json) data and type annotations.""" + https://github.com/home-assistant-libs/aiohue/ + """ # ruff: noqa: PLR0911, PLR0912 if isinstance(value_type, str): # this shouldn't happen, but just in case @@ -133,22 +108,36 @@ def _parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) if value is None and value_type is NoneType: return None if is_dataclass(value_type) and isinstance(value, dict): - return dataclass_from_dict(value_type, value) + if _api is not None and paperless_base.PaperlessModel in value_type.__mro__: + return value_type.create_with_data(api=_api, data=value, fetched=True) + return value_type( + **{ + field.name: dict_value_to_object( + f"{value_type.__name__}.{field.name}", + value.get(field.name), + field.type, + field.default, + _api, + ) + for field in fields(value_type) + } + ) # get origin value type and inspect one-by-one origin: Any = get_origin(value_type) if origin in (list, tuple, set) and isinstance(value, list | tuple | set): return origin( - _parse_value(name, subvalue, get_args(value_type)[0]) + dict_value_to_object(name, subvalue, get_args(value_type)[0], _api=_api) for subvalue in value if subvalue is not None ) + # handle dictionary where we should inspect all values if origin is dict: subkey_type = get_args(value_type)[0] subvalue_type = get_args(value_type)[1] return { - _parse_value(subkey, subkey, subkey_type): _parse_value( - f"{subkey}.value", subvalue, subvalue_type + dict_value_to_object(subkey, subkey, subkey_type, _api=_api): dict_value_to_object( + f"{subkey}.value", subvalue, subvalue_type, _api=_api ) for subkey, subvalue in value.items() } @@ -164,7 +153,7 @@ def _parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) return None # try them all until one succeeds try: - return _parse_value(name, value, sub_arg_type) + return dict_value_to_object(name, value, sub_arg_type, _api=_api) except (KeyError, TypeError, ValueError): pass # if we get to this point, all possibilities failed @@ -192,9 +181,9 @@ def _parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) if issubclass(value_type, Enum): return value_type(value) if issubclass(value_type, datetime): - return parse_utc_timestamp(value) + return _str_to_datetime(value) if issubclass(value_type, date): - return parse_date(value) + return _str_to_date(value) except TypeError: # happens if value_type is not a class pass @@ -212,30 +201,3 @@ def _parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) f"expected value of type {value_type}" ) return value - - -def dataclass_from_dict(cls: dataclass, dict_obj: dict, strict=False): - """ - Create (instance of) a dataclass by providing a dict with values. - - Including support for nested structures and common type conversions. - If strict mode enabled, any additional keys in the provided dict will result in a KeyError. - """ - if strict: - extra_keys = dict_obj.keys() - set([f.name for f in fields(cls)]) - if extra_keys: - raise KeyError( - "Extra key(s) {} not allowed for {}".format(",".join(extra_keys), (str(cls))) - ) - - return cls( - **{ - field.name: _parse_value( - f"{cls.__name__}.{field.name}", - dict_obj.get(field.name), - field.type, - field.default, - ) - for field in fields(cls) - } - ) diff --git a/pypaperless/models/workflows.py b/pypaperless/models/workflows.py index 319d8fd..448147c 100644 --- a/pypaperless/models/workflows.py +++ b/pypaperless/models/workflows.py @@ -1,61 +1,55 @@ -"""Model for workflow resource.""" +"""Provide `Workflow` related models and helpers.""" from dataclasses import dataclass -from enum import Enum +from typing import TYPE_CHECKING, Any -from .base import PaperlessModel -from .matching import PaperlessModelMatchingMixin +from pypaperless.const import API_PATH, PaperlessResource +from .base import HelperBase, PaperlessModel +from .common import WorkflowActionType, WorkflowTriggerSourceType, WorkflowTriggerType +from .mixins import helpers, models -class WorkflowTriggerType(Enum): - """Enum with workflow trigger types.""" +if TYPE_CHECKING: + from pypaperless import Paperless - CONSUMPTION = 1 - DOCUMENT_ADDED = 2 - DOCUMENT_UPDATED = 3 - UNKNOWN = -1 - @classmethod - def _missing_(cls: type, value: object) -> "WorkflowTriggerType": # noqa ARG003 - """Set default member on unknown value.""" - return WorkflowTriggerType.UNKNOWN - - -class WorkflowActionType(Enum): - """Enum with workflow action types.""" - - ASSIGNMENT = 1 - UNKNOWN = -1 - - @classmethod - def _missing_(cls: type, value: object) -> "WorkflowActionType": # noqa ARG003 - """Set default member on unknown value.""" - return WorkflowActionType.UNKNOWN +@dataclass(init=False) +class WorkflowAction(PaperlessModel): # pylint: disable=too-many-instance-attributes + """Represent a Paperless `WorkflowAction`.""" + _api_path = API_PATH["workflow_actions_single"] -class WorkflowTriggerSource(Enum): - """Enum with workflow trigger sources.""" + id: int | None = None + type: WorkflowActionType | None = None + assign_title: str | None = None + assign_tags: list[int] | None = None + assign_correspondent: int | None = None + assign_document_type: int | None = None + assign_storage_path: int | None = None + assign_view_users: list[int] | None = None + assign_view_groups: list[int] | None = None + assign_change_users: list[int] | None = None + assign_change_groups: list[int] | None = None + assign_custom_fields: list[int] | None = None - CONSUME_FOLDER = 1 - API_UPLOAD = 2 - MAIL_FETCH = 3 - UNKNOWN = -1 + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Workflow` instance.""" + super().__init__(api, data) - @classmethod - def _missing_(cls: type, value: object) -> "WorkflowTriggerSource": # noqa ARG003 - """Set default member on unknown value.""" - return WorkflowTriggerSource.UNKNOWN + self._api_path = self._api_path.format(pk=data.get("id")) -@dataclass(kw_only=True) -class WorkflowTrigger( +@dataclass(init=False) +class WorkflowTrigger( # pylint: disable=too-many-instance-attributes PaperlessModel, - PaperlessModelMatchingMixin, -): # pylint: disable=too-many-instance-attributes - """Represent a workflow trigger on the Paperless api.""" + models.MatchingFieldsMixin, +): + """Represent a Paperless `WorkflowTrigger`.""" + + _api_path = API_PATH["workflow_triggers_single"] id: int | None = None - sources: list[WorkflowTriggerSource] | None = None + sources: list[WorkflowTriggerSourceType] | None = None type: WorkflowTriggerType | None = None filter_path: str | None = None filter_filename: str | None = None @@ -64,67 +58,96 @@ class WorkflowTrigger( filter_has_correspondent: int | None = None filter_has_document_type: int | None = None + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Workflow` instance.""" + super().__init__(api, data) -@dataclass(kw_only=True) -class WorkflowAction(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a workflow action on the Paperless api.""" + self._api_path = self._api_path.format(pk=data.get("id")) - id: int | None = None - type: WorkflowActionType | None = None - assign_title: str | None = None - assign_tags: list[int] | None = None - assign_correspondent: int | None = None - assign_document_type: int | None = None - assign_storage_path: int | None = None - assign_view_users: list[int] | None = None - assign_view_groups: list[int] | None = None - assign_change_users: list[int] | None = None - assign_change_groups: list[int] | None = None - assign_custom_fields: list[int] | None = None +@dataclass(init=False) +class Workflow(PaperlessModel): + """Represent a Paperless `Workflow`.""" -@dataclass(kw_only=True) -class Workflow(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a workflow resource on the Paperless api.""" + _api_path = API_PATH["workflows_single"] id: int | None = None name: str | None = None order: int | None = None enabled: bool | None = None - triggers: list[WorkflowTrigger] | None = None actions: list[WorkflowAction] | None = None + triggers: list[WorkflowTrigger] | None = None + def __init__(self, api: "Paperless", data: dict[str, Any]): + """Initialize a `Workflow` instance.""" + super().__init__(api, data) -# old consumption templates model + self._api_path = self._api_path.format(pk=data.get("id")) -class ConsumptionTemplateSource(Enum): - """Enum with consumption template sources.""" +class WorkflowActionHelper( + HelperBase[WorkflowAction], + helpers.CallableMixin[WorkflowAction], + helpers.IterableMixin[WorkflowAction], +): + """Represent a factory for Paperless `WorkflowAction` models.""" - FOLDER = 1 - API = 2 - EMAIL = 3 + _api_path = API_PATH["workflow_actions"] + _resource = PaperlessResource.WORKFLOW_ACTIONS + _resource_cls = WorkflowAction -@dataclass(kw_only=True) -class ConsumptionTemplate(PaperlessModel): # pylint: disable=too-many-instance-attributes - """Represent a consumption template resource on the Paperless api.""" - id: int | None = None - name: str | None = None - order: int | None = None - sources: list[ConsumptionTemplateSource] | None = None - filter_path: str | None = None - filter_filename: str | None = None - filter_mailrule: int | None = None - assign_title: str | None = None - assign_tags: list[int] | None = None - assign_correspondent: int | None = None - assign_document_type: int | None = None - assign_storage_path: int | None = None - assign_owner: int | None = None - assign_view_users: list[int] | None = None - assign_view_groups: list[int] | None = None - assign_change_users: list[int] | None = None - assign_change_groups: list[int] | None = None - assign_custom_fields: list[int] | None = None +class WorkflowTriggerHelper( + HelperBase[WorkflowTrigger], + helpers.CallableMixin[WorkflowTrigger], + helpers.IterableMixin[WorkflowTrigger], +): + """Represent a factory for Paperless `WorkflowTrigger` models.""" + + _api_path = API_PATH["workflow_triggers"] + _resource = PaperlessResource.WORKFLOW_TRIGGERS + + _resource_cls = WorkflowTrigger + + +class WorkflowHelper( + HelperBase[Workflow], + helpers.CallableMixin[Workflow], + helpers.IterableMixin[Workflow], +): + """Represent a factory for Paperless `Workflow` models.""" + + _api_path = API_PATH["workflows"] + _resource = PaperlessResource.WORKFLOWS + + _resource_cls = Workflow + + def __init__(self, api: "Paperless") -> None: + """Initialize a `WorkflowHelper` instance.""" + super().__init__(api) + + self._actions = WorkflowActionHelper(api) + self._triggers = WorkflowTriggerHelper(api) + + @property + def actions(self) -> WorkflowActionHelper: + """Return the attached `WorkflowActionHelper` instance. + + Example: + ```python + wf_action = await paperless.workflows.actions(5) + ``` + """ + return self._actions + + @property + def triggers(self) -> WorkflowTriggerHelper: + """Return the attached `WorkflowTriggerHelper` instance. + + Example: + ```python + wf_trigger = await paperless.workflows.triggers(23) + ``` + """ + return self._triggers diff --git a/pypaperless/sessions.py b/pypaperless/sessions.py new file mode 100644 index 0000000..ce08937 --- /dev/null +++ b/pypaperless/sessions.py @@ -0,0 +1,156 @@ +"""PyPaperless sessions.""" + +from typing import Any + +import aiohttp +from yarl import URL + +from .exceptions import RequestException + + +class PaperlessSession: + """Provide an interface to http requests.""" + + _session: aiohttp.ClientSession + + def __init__( + self, + base_url: str | URL, + token: str, + **kwargs: Any, + ) -> None: + """Initialize a `PaperlessSession` instance. + + Setting `token` to an empty string omits the Authorization header on requests. + + `kwargs` are passed to each request method call as additional kwargs, ssl stuff for example. + You should read the aiohttp docs to learn more about it. + """ + self._initialized = False + self._request_args = kwargs + self._token = token + self._base_url = self._create_base_url(base_url) + + def __str__(self) -> str: + """Return `base_url` host as string.""" + return f"{self._base_url.host}" + + @property + def is_initialized(self) -> bool: + """Return if the session is initialized.""" + return self._initialized + + @staticmethod + def _create_base_url(url: str | URL) -> URL: + """Create URL from string or URL and prepare for further use.""" + # reverse compatibility, fall back to https + if isinstance(url, str) and "://" not in url: + url = f"https://{url}".rstrip("/") + url = URL(url) + + # scheme check. fall back to https + if url.scheme not in ("https", "http"): + url = URL(url).with_scheme("https") + + return url + + def _process_form( + self, + data: dict[str, Any], + ) -> aiohttp.FormData: + """Process form data and create a `aiohttp.FormData` object. + + Every field item gets converted to a string-like object. + """ + form = aiohttp.FormData() + + def _add_form_value(name: str | None, value: Any) -> Any: + if value is None: + return + params = {} + if isinstance(value, dict): + for dict_key, dict_value in value.items(): + _add_form_value(dict_key, dict_value) + return + if isinstance(value, list | set): + for list_value in value: + _add_form_value(name, list_value) + return + if isinstance(value, tuple): + if len(value) == 2: + params["filename"] = f"{value[1]}" + value = value[0] + if name is not None: + form.add_field(name, value if isinstance(value, bytes) else f"{value}", **params) + + _add_form_value(None, data) + return form + + async def close(self) -> None: + """Clean up connection.""" + if self.is_initialized: + await self._session.close() + + async def initialize(self) -> None: + """Initialize session.""" + self._session = aiohttp.ClientSession() + + headers = { + "Accept": "application/json; version=2", + } + headers.update( + { + "Authorization": f"Token {self._token}", + } + if self._token + else {} + ) + self._session.headers.update(headers) + self._initialized = True + + async def request( # pylint: disable=too-many-arguments + self, + method: str, + path: str, + json: dict[str, Any] | None = None, + data: dict[str, Any] | aiohttp.FormData | None = None, + form: dict[str, Any] | None = None, + params: dict[str, str | int] | None = None, + **kwargs: Any, + ) -> aiohttp.ClientResponse: + """Send a request to the Paperless api and return the `aiohttp.ClientResponse`. + + This method provides a little interface for utilizing `aiohttp.FormData`. + + `method`: A http method: get, post, patch, put, delete, head, options + `path`: A path to the endpoint or a string url. + `json`: A dict containing the json data. + `data`: A dict containing the data to send in the request body. + `form`: A dict with form data, which gets converted to `aiohttp.FormData` + and replaces `data`. + `params`: A dict with query parameters. + `kwargs`: Optional attributes for the `aiohttp.ClientSession.request` method. + """ + if not self.is_initialized: + await self.initialize() + + kwargs.update(self._request_args) + + # overwrite data with a form, when there is a form payload + if isinstance(form, dict): + data = self._process_form(form) + + # add base path + url = f"{self._base_url}{path}" if not path.startswith("http") else path + + try: + return await self._session.request( + method=method, + url=url, + data=data, + json=json, + params=params, + **kwargs, + ) + except Exception as exc: + raise RequestException(exc, (method, url, params), kwargs) from None diff --git a/pyproject.toml b/pyproject.toml index 6dbc5ee..942fea1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pypaperless" -# The version is set by GH action on release -version = "0.0.0" +version = "0.0.0" # set on release license = {text = "MIT License"} description = "Little api wrapper for the paperless(-ngx) dms." readme = "README.md" @@ -26,7 +25,6 @@ classifiers = [ ] dependencies = [ "aiohttp==3.9.3", - "awesomeversion==23.11.0", "yarl==1.9.4" ] @@ -40,6 +38,7 @@ dependencies = [ test = [ "black==24.1.1", "codespell==2.2.6", + "coverage==7.4.1", "mypy==1.8.0", "ruff==0.2.1", "pytest<8.0.0", @@ -52,24 +51,39 @@ test = [ "httpx==0.26.0" ] -[tool.codespell] -ignore-words-list = "dependees," +[tool.setuptools] +platforms = ["any"] +zip-safe = false +packages = ["pypaperless"] +include-package-data = true + +[tool.setuptools.package-data] +pypaperless = ["py.typed"] [tool.black] target-version = ['py311'] line-length = 100 +[tool.codespell] +ignore-words-list = "dependees," + [tool.mypy] +platform = "linux" python_version = "3.11" + +follow_imports = "normal" + check_untyped_defs = true disallow_any_generics = false disallow_incomplete_defs = true -disallow_untyped_calls = false +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true disallow_untyped_defs = true -mypy_path = "pypaperless/" no_implicit_optional = true show_error_codes = true warn_incomplete_stub = true +warn_no_return = true warn_redundant_casts = true warn_return_any = true warn_unreachable = true @@ -86,44 +100,32 @@ testpaths = [ ] norecursedirs = [ ".git", - "testing_config", ] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" -[tool.setuptools] -platforms = ["any"] -zip-safe = false -packages = ["pypaperless"] -include-package-data = true - -[tool.setuptools.package-data] -pypaperless = ["py.typed"] - [tool.ruff] fix = true show-fixes = true - -select = ["E", "F", "W", "I", "N", "D", "UP", "PL", "Q", "SIM", "TID", "ARG"] -ignore = ["PLR2004", "N818"] -extend-exclude = ["app_vars.py"] -unfixable = ["F841"] line-length = 100 target-version = "py311" +lint.select = ["E", "F", "W", "I", "N", "D", "UP", "PL", "Q", "SIM", "TID", "ARG"] +lint.ignore = ["PLR2004", "N818"] +lint.unfixable = ["F841"] -[tool.ruff.flake8-annotations] +[tool.ruff.lint.flake8-annotations] allow-star-arg-any = true suppress-dummy-args = true -[tool.ruff.flake8-builtins] +[tool.ruff.lint.flake8-builtins] builtins-ignorelist = ["id"] -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] # Use Google-style docstrings. convention = "pep257" -[tool.ruff.pylint] +[tool.ruff.lint.pylint] max-branches=25 max-returns=15 max-args=10 diff --git a/tests/__init__.py b/tests/__init__.py index 46a9166..0d350c2 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,91 +1,306 @@ """Tests for pypaperless.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from dataclasses import dataclass from typing import Any -from aiohttp.web_exceptions import HTTPNotFound +import aiohttp +from aiohttp.client_exceptions import ClientResponseError +from aiohttp.client_reqrep import RequestInfo from fastapi.testclient import TestClient from httpx import AsyncClient, Response +from yarl import URL -from pypaperless import Paperless +from pypaperless import PaperlessSession, helpers +from pypaperless.const import PaperlessResource +from pypaperless.exceptions import RequestException +from pypaperless.models import ( + Config, + Correspondent, + CorrespondentDraft, + CustomField, + CustomFieldDraft, + Document, + DocumentDraft, + DocumentType, + DocumentTypeDraft, + Group, + MailAccount, + MailRule, + SavedView, + ShareLink, + ShareLinkDraft, + StoragePath, + StoragePathDraft, + Tag, + TagDraft, + Task, + User, + Workflow, +) +from pypaperless.models.common import ( + CustomFieldType, + MatchingAlgorithmType, + ShareLinkFileVersionType, +) from .const import PAPERLESS_TEST_URL from .util.router import FakePaperlessAPI +# mypy: ignore-errors -class PaperlessMock(Paperless): - """Mock Paperless.""" + +@dataclass +class ResourceTestMapping: + """Mapping for test cases.""" + + resource: str + helper_cls: type + model_cls: type + draft_cls: type | None = None + draft_defaults: dict[str, Any] | None = None + + +CONFIG_MAP = ResourceTestMapping( + PaperlessResource.CONFIG, + helpers.ConfigHelper, + Config, +) +CORRESPONDENT_MAP = ResourceTestMapping( + PaperlessResource.CORRESPONDENTS, + helpers.CorrespondentHelper, + Correspondent, + CorrespondentDraft, + { + "name": "New Correspondent", + "match": "", + "matching_algorithm": MatchingAlgorithmType.ANY, + "is_insensitive": True, + }, +) +CUSTOM_FIELD_MAP = ResourceTestMapping( + PaperlessResource.CUSTOM_FIELDS, + helpers.CustomFieldHelper, + CustomField, + CustomFieldDraft, + { + "name": "New Custom Field", + "data_type": CustomFieldType.BOOLEAN, + }, +) +DOCUMENT_MAP = ResourceTestMapping( + PaperlessResource.DOCUMENTS, + helpers.DocumentHelper, + Document, + DocumentDraft, + { + "document": b"...example...content...", + "tags": [1, 2, 3], + "correspondent": 1, + "document_type": 1, + "storage_path": 1, + "title": "New Document", + "created": None, + "archive_serial_number": 1, + }, +) +DOCUMENT_TYPE_MAP = ResourceTestMapping( + PaperlessResource.DOCUMENT_TYPES, + helpers.DocumentTypeHelper, + DocumentType, + DocumentTypeDraft, + { + "name": "New Document Type", + "match": "", + "matching_algorithm": MatchingAlgorithmType.ANY, + "is_insensitive": True, + }, +) +GROUP_MAP = ResourceTestMapping( + PaperlessResource.GROUPS, + helpers.GroupHelper, + Group, +) +MAIL_ACCOUNT_MAP = ResourceTestMapping( + PaperlessResource.MAIL_ACCOUNTS, + helpers.MailAccountHelper, + MailAccount, +) +MAIL_RULE_MAP = ResourceTestMapping( + PaperlessResource.MAIL_RULES, + helpers.MailRuleHelper, + MailRule, +) +SAVED_VIEW_MAP = ResourceTestMapping( + PaperlessResource.SAVED_VIEWS, + helpers.SavedViewHelper, + SavedView, +) +SHARE_LINK_MAP = ResourceTestMapping( + PaperlessResource.SHARE_LINKS, + helpers.ShareLinkHelper, + ShareLink, + ShareLinkDraft, + { + "expiration": None, + "document": 1, + "file_version": ShareLinkFileVersionType.ORIGINAL, + }, +) +STORAGE_PATH_MAP = ResourceTestMapping( + PaperlessResource.STORAGE_PATHS, + helpers.StoragePathHelper, + StoragePath, + StoragePathDraft, + { + "name": "New Storage Path", + "path": "path/to/test", + "match": "", + "matching_algorithm": MatchingAlgorithmType.ANY, + "is_insensitive": True, + }, +) +TAG_MAP = ResourceTestMapping( + PaperlessResource.TAGS, + helpers.TagHelper, + Tag, + TagDraft, + { + "name": "New Tag", + "color": "#012345", + "text_color": "#987654", + "is_inbox_tag": False, + "match": "", + "matching_algorithm": MatchingAlgorithmType.ANY, + "is_insensitive": True, + }, +) +TASK_MAP = ResourceTestMapping( + PaperlessResource.TASKS, + helpers.TaskHelper, + Task, +) +USER_MAP = ResourceTestMapping( + PaperlessResource.USERS, + helpers.UserHelper, + User, +) + +WORKFLOW_MAP = ResourceTestMapping( + PaperlessResource.WORKFLOWS, + helpers.WorkflowHelper, + Workflow, +) + + +class PaperlessSessionMock(PaperlessSession): + """Mock PaperlessSession.""" def __init__( self, - url, - token, - request_opts=None, - session=None, - ): - """Construct MockPaperless.""" - Paperless.__init__( + base_url: str | URL, + token: str, + params: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Initialize PaperlessSessionMock.""" + PaperlessSession.__init__( self, - url, + base_url, token, - request_opts, - session, + **kwargs, ) - - self.version = "0.0.0" self.client = TestClient(FakePaperlessAPI) + self.params = params or {} + self.version = "0.0.0" - @asynccontextmanager - async def generate_request( + async def request( # pylint: disable=too-many-arguments self, method: str, path: str, + json: dict[str, Any] | None = None, + data: dict[str, Any] | aiohttp.FormData | None = None, + form: dict[str, Any] | None = None, + params: dict[str, str | int] | None = None, **kwargs: Any, - ) -> AsyncGenerator["FakeClientResponse", None]: - """Create a client response object for further use.""" - path = path.rstrip("/") + "/" # check and add trailing slash + ) -> "FakeClientResponse": + """Mock PaperlessSession.request.""" + if not self.is_initialized: + await self.initialize() kwargs.setdefault("headers", {}) - kwargs["headers"].update( - { - "accept": "application/json; version=2", - "authorization": f"Token {self._token}", - "x-test-ver": self.version, - } - ) + kwargs["headers"].update({"x-test-ver": self.version}) # we fake form data to json payload as we don't want to mess with FastAPI forms - if "form" in kwargs: + if isinstance(form, dict): payload = {} - for key, value in kwargs.pop("form").items(): + for key, value in form.items(): + if value is None: + continue if isinstance(value, bytes): - payload[key] = value.decode() - else: - payload[key] = value - kwargs["json"] = payload + value = value.decode() # noqa PLW2901 + payload[key] = value + json = payload + + # add base path + url = f"{self._base_url}{path}" if not path.startswith("http") else path + # check for trailing slash + if URL(url).query_string == "": + url = url.rstrip("/") + "/" + + # add PaperlessSessionMock params to params + if len(self.params) > 0: + if params is None: + params = self.params + else: + params.update(self.params) + + try: + async with AsyncClient( + app=FakePaperlessAPI, + base_url=PAPERLESS_TEST_URL, + ) as client: + res = await client.request( + method, url, json=json, data=data, params=params, **kwargs + ) + return FakeClientResponse(res, self.version, method) + except Exception as exc: + raise RequestException(exc, (method, url, params), kwargs) from None - # finally, request - async with AsyncClient( - app=FakePaperlessAPI, - base_url=PAPERLESS_TEST_URL, - ) as ac: - res = await ac.request(method, path, **kwargs) - yield FakeClientResponse(res, self.version) # wrap httpx response + +@dataclass(kw_only=True) +class FakeContentDisposition: + """A fake content disposition object.""" + + filename: str | None = None + type: str | None = None class FakeClientResponse: """A fake response object.""" - def __init__(self, res: Response, version): + def __init__(self, res: Response, version: str, method: str): """Construct fake response.""" + self.method = method self.res = res self.version = version + @property + def content_disposition(self): + """Content disposition.""" + if "content-disposition" in self.res.headers: + dispo, filename = tuple(self.res.headers["content-disposition"].split(";")) + return FakeContentDisposition(type=dispo, filename=filename) + return None + + @property + def content_type(self): + """Content type.""" + return self.res.headers.setdefault("content-type", "application/json") + @property def headers(self): """Headers.""" - return {**self.res.headers, **{"x-version": self.version}} + return {**self.res.headers, "x-version": self.version} @property def status(self): @@ -97,15 +312,12 @@ def url(self): """Url.""" return f"{self.res.url}" - @property - def content_type(self): - """Content type.""" - return self.res.headers.setdefault("content-type", "application/json") - def raise_for_status(self): """Raise for status.""" - if self.status != 200: - raise HTTPNotFound() + if self.status == 200: + return + info = RequestInfo(self.url, self.method, self.res.headers, self.url) + raise ClientResponseError(info, (self.res,), status=self.status) async def json(self): """Json.""" diff --git a/tests/conftest.py b/tests/conftest.py index 2bf7efe..a996246 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,54 +4,62 @@ from pypaperless import Paperless -from . import PaperlessMock -from .const import PAPERLESS_TEST_REQ_OPTS, PAPERLESS_TEST_TOKEN, PAPERLESS_TEST_URL +from . import PaperlessSessionMock +from .const import PAPERLESS_TEST_REQ_ARGS, PAPERLESS_TEST_TOKEN, PAPERLESS_TEST_URL + +# mypy: ignore-errors +# pylint: disable=protected-access,redefined-outer-name @pytest.fixture -def api() -> Paperless: +def api_obj() -> Paperless: """Return a mock Paperless.""" - return PaperlessMock( + session = PaperlessSessionMock( PAPERLESS_TEST_URL, PAPERLESS_TEST_TOKEN, - request_opts=PAPERLESS_TEST_REQ_OPTS, + **PAPERLESS_TEST_REQ_ARGS, + ) + return Paperless( + url=PAPERLESS_TEST_URL, + token=PAPERLESS_TEST_TOKEN, + session=session, ) @pytest.fixture -async def api_00(api) -> Paperless: +async def api_00(api_obj) -> Paperless: """Return a basic Paperless object.""" - async with api: - yield api + async with api_obj: + yield api_obj @pytest.fixture -async def api_18(api) -> Paperless: +async def api_18(api_obj) -> Paperless: """Return a Paperless object with given version.""" - api.version = "1.8.0" - async with api: - yield api + api_obj._session.version = "1.8.0" + async with api_obj: + yield api_obj @pytest.fixture -async def api_117(api) -> Paperless: +async def api_117(api_obj) -> Paperless: """Return a Paperless object with given version.""" - api.version = "1.17.0" - async with api: - yield api + api_obj._session.version = "1.17.0" + async with api_obj: + yield api_obj @pytest.fixture -async def api_20(api) -> Paperless: +async def api_20(api_obj) -> Paperless: """Return a Paperless object with given version.""" - api.version = "2.0.0" - async with api: - yield api + api_obj._session.version = "2.0.0" + async with api_obj: + yield api_obj @pytest.fixture -async def api_23(api) -> Paperless: +async def api_23(api_obj) -> Paperless: """Return a Paperless object with given version.""" - api.version = "2.3.0" - async with api: - yield api + api_obj._session.version = "2.3.0" + async with api_obj: + yield api_obj diff --git a/tests/const.py b/tests/const.py index 99c1a51..fa6d867 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,5 +1,5 @@ """Test constants.""" PAPERLESS_TEST_URL = "https://local.test" -PAPERLESS_TEST_TOKEN = "abc" -PAPERLESS_TEST_REQ_OPTS = {"ssl": False} +PAPERLESS_TEST_TOKEN = "abcdef0123456789" +PAPERLESS_TEST_REQ_ARGS = {"ssl": False} diff --git a/tests/data/v0_0_0/__init__.py b/tests/data/v0_0_0/__init__.py index 83276bd..6fe4b12 100644 --- a/tests/data/v0_0_0/__init__.py +++ b/tests/data/v0_0_0/__init__.py @@ -1,7 +1,11 @@ """Raw data constants for all Paperless versions.""" +# mypy: ignore-errors + from tests.const import PAPERLESS_TEST_URL +V0_0_0_TOKEN = {"token": "abcdef1234567890987654321fedcba"} + V0_0_0_PATHS = { "correspondents": f"{PAPERLESS_TEST_URL}/api/correspondents/", "document_types": f"{PAPERLESS_TEST_URL}/api/document_types/", @@ -16,6 +20,17 @@ "mail_rules": f"{PAPERLESS_TEST_URL}/api/mail_rules/", } +V0_0_0_OBJECT_PERMISSIONS = { + "view": { + "users": [1, 2], + "groups": [], + }, + "change": { + "users": [], + "groups": [1], + }, +} + V0_0_0_CORRESPONDENTS = { "count": 5, "next": None, @@ -165,6 +180,41 @@ ], } +V0_0_0_DOCUMENTS_SEARCH = { + "count": 1, + "next": None, + "previous": None, + "all": [1], + "results": [ + { + "id": 1, + "correspondent": 1, + "document_type": 2, + "storage_path": None, + "title": "Crazy Document", + "content": "some OCRd text", + "tags": [], + "created": "2011-06-22T00:00:00Z", + "created_date": "2011-06-22", + "modified": "2023-08-08T06:06:35.495972Z", + "added": "2023-06-30T05:44:14.317925Z", + "archive_serial_number": None, + "original_file_name": "Scan_2023-06-29_113857.pdf", + "archived_file_name": "2011-06-22 filename.pdf", + "owner": 2, + "user_can_change": True, + "notes": [], + "custom_fields": [], + "__search_hit__": { + "score": 1.0, + "highlights": "some neat hint", + "note_highlights": "", + "rank": 0, + }, + }, + ], +} + V0_0_0_DOCUMENTS_METADATA = { "original_checksum": "18e2352cc13379d19bd9ce329428bb99", "original_size": 190348, @@ -247,6 +297,24 @@ ], } +V0_0_0_DOCUMENT_SUGGESTIONS = { + "correspondents": [26], + "tags": [ + 1, + 2, + 3, + ], + "document_types": [4], + "storage_paths": [ + 3, + 5, + ], + "dates": [ + "2022-01-07", + "2023-01-07", + ], +} + V0_0_0_DOCUMENT_TYPES = { "count": 5, "next": None, @@ -489,6 +557,9 @@ "all": [ 1, 2, + 3, + 4, + 5, ], "results": [ { @@ -512,20 +583,62 @@ "color": "#ff0000", "text_color": "#00ff00", "match": "", - "matching_algorithm": 0, + "matching_algorithm": 1, "is_insensitive": True, "is_inbox_tag": True, "document_count": 20, "owner": None, "user_can_change": True, }, + { + "id": 3, + "slug": "test-3", + "name": "Test 3", + "color": "#ff0000", + "text_color": "#00ff00", + "match": "", + "matching_algorithm": 2, + "is_insensitive": True, + "is_inbox_tag": False, + "document_count": 20, + "owner": None, + "user_can_change": True, + }, + { + "id": 4, + "slug": "test-4", + "name": "Test 4", + "color": "#ff0000", + "text_color": "#00ff00", + "match": "", + "matching_algorithm": 3, + "is_insensitive": True, + "is_inbox_tag": False, + "document_count": 20, + "owner": None, + "user_can_change": True, + }, + { + "id": 5, + "slug": "test-5", + "name": "Test 5", + "color": "#ff0000", + "text_color": "#00ff00", + "match": "", + "matching_algorithm": 4, + "is_insensitive": True, + "is_inbox_tag": False, + "document_count": 20, + "owner": None, + "user_can_change": True, + }, ], } V0_0_0_TASKS = [ { - "id": 2134, - "task_id": "bd2de639-5ecd-4bc1-ab3d-106908ef00e1", + "id": 1, + "task_id": "11112222-aaaa-bbbb-cccc-333344445555", "task_file_name": "a.png", "date_created": "2023-12-16T13:06:29.107815Z", "date_done": None, @@ -536,8 +649,8 @@ "related_document": None, }, { - "id": 2133, - "task_id": "eb327ed7-b3c8-4a8c-9aa2-5385e499c74a", + "id": 2, + "task_id": "ffffeeee-9999-8888-7777-ddddccccbbbb", "task_file_name": "b.png", "date_created": "2023-12-16T13:06:26.117158Z", "date_done": "2023-12-16T13:06:29.859669Z", @@ -548,8 +661,8 @@ "related_document": "1780", }, { - "id": 2132, - "task_id": "071674a5-274a-4592-8331-b3a1055d1928", + "id": 3, + "task_id": "abcdef12-3456-7890-abcd-ef1234567890", "task_file_name": "c.png", "date_created": "2023-12-16T13:04:28.175624Z", "date_done": "2023-12-16T13:04:32.318797Z", diff --git a/tests/data/v1_8_0/__init__.py b/tests/data/v1_8_0/__init__.py index 02df7b2..3a0a80a 100644 --- a/tests/data/v1_8_0/__init__.py +++ b/tests/data/v1_8_0/__init__.py @@ -10,7 +10,7 @@ "count": 3, "next": None, "previous": None, - "all": [1, 2, 3], + "all": [1, 2, 3, 4, 5], "results": [ { "id": 1, @@ -48,5 +48,29 @@ "owner": None, "user_can_change": True, }, + { + "id": 4, + "slug": "another-test-2", + "name": "Another Test 2", + "path": "Test/Path/{doc_pk}", + "match": "", + "matching_algorithm": 0, + "is_insensitive": True, + "document_count": 0, + "owner": None, + "user_can_change": True, + }, + { + "id": 5, + "slug": "another-test-3", + "name": "Another Test 3", + "path": "Test/Path/{doc_pk}", + "match": "", + "matching_algorithm": 0, + "is_insensitive": True, + "document_count": 0, + "owner": None, + "user_can_change": True, + }, ], } diff --git a/tests/data/v2_0_0/__init__.py b/tests/data/v2_0_0/__init__.py index e89ce22..af96e47 100644 --- a/tests/data/v2_0_0/__init__.py +++ b/tests/data/v2_0_0/__init__.py @@ -3,11 +3,33 @@ from tests.const import PAPERLESS_TEST_URL V2_0_0_PATHS = { + "config": f"{PAPERLESS_TEST_URL}/api/config/", "consumption_templates": f"{PAPERLESS_TEST_URL}/api/consumption_templates/", "custom_fields": f"{PAPERLESS_TEST_URL}/api/custom_fields/", "share_links": f"{PAPERLESS_TEST_URL}/api/share_links/", } +V2_0_0_CONFIG = [ + { + "id": 1, + "user_args": None, + "output_type": "pdf", + "pages": None, + "language": "eng", + "mode": None, + "skip_archive_file": None, + "image_dpi": None, + "unpaper_clean": None, + "deskew": None, + "rotate_pages": None, + "rotate_pages_threshold": None, + "max_image_pixels": None, + "color_conversion_strategy": None, + "app_title": None, + "app_logo": None, + } +] + V2_0_0_CONSUMPTION_TEMPLATES = { "count": 3, "next": None, @@ -98,7 +120,7 @@ "count": 5, "next": None, "previous": None, - "all": [1, 2, 3, 4, 5], + "all": [1, 2, 3, 4, 5, 6, 7, 8], "results": [ { "id": 1, @@ -140,5 +162,29 @@ "document": 1, "file_version": "archive", }, + { + "id": 6, + "created": "2023-12-11T14:11:50.710369Z", + "expiration": None, + "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", + "document": 1, + "file_version": "archive", + }, + { + "id": 7, + "created": "2023-12-11T14:11:50.710369Z", + "expiration": None, + "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", + "document": 1, + "file_version": "archive", + }, + { + "id": 8, + "created": "2023-12-11T14:11:50.710369Z", + "expiration": None, + "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", + "document": 1, + "file_version": "archive", + }, ], } diff --git a/tests/test_paperless_0_0_0.py b/tests/test_paperless_0_0_0.py index 53ba389..ec746aa 100644 --- a/tests/test_paperless_0_0_0.py +++ b/tests/test_paperless_0_0_0.py @@ -1,752 +1,434 @@ """Paperless basic tests.""" -import datetime -from unittest.mock import patch - import pytest -from aiohttp.web_exceptions import HTTPNotFound - -from pypaperless import Paperless -from pypaperless.controllers import ( - CorrespondentsController, - DocumentsController, - DocumentTypesController, - GroupsController, - MailAccountsController, - MailRulesController, - SavedViewsController, - TagsController, - TasksController, - UsersController, + +from pypaperless import Paperless, PaperlessSession +from pypaperless.const import PaperlessResource +from pypaperless.exceptions import ( + AsnRequestError, + DraftFieldRequired, + RequestException, + TaskNotFound, ) -from pypaperless.controllers.base import ResultPage -from pypaperless.models import ( - Correspondent, - CorrespondentPost, - Document, - DocumentMetadata, - DocumentMetaInformation, - DocumentPost, - DocumentType, - DocumentTypePost, - Group, - MailAccount, - MailRule, - SavedView, - Tag, - TagPost, - Task, - User, +from pypaperless.models import DocumentMeta, Page +from pypaperless.models import documents as doc_helpers +from pypaperless.models.common import DocumentMetadataType, PermissionTableType, RetrieveFileMode +from pypaperless.models.documents import DocumentSuggestions, DownloadedDocument +from pypaperless.models.mixins import helpers as helper_mixins +from pypaperless.models.mixins import models as model_mixins + +from . import ( + CORRESPONDENT_MAP, + DOCUMENT_MAP, + DOCUMENT_TYPE_MAP, + GROUP_MAP, + MAIL_ACCOUNT_MAP, + MAIL_RULE_MAP, + SAVED_VIEW_MAP, + TAG_MAP, + TASK_MAP, + USER_MAP, + PaperlessSessionMock, + ResourceTestMapping, ) -from pypaperless.models.matching import MatchingAlgorithm +from .const import PAPERLESS_TEST_URL + +# mypy: ignore-errors +# pylint: disable=protected-access,redefined-outer-name + +@pytest.fixture(scope="function") +async def p(api_00) -> Paperless: + """Yield version for this test case.""" + yield api_00 + +# test api.py with legacy endpoint class TestBeginPaperless: """Common Paperless test cases.""" - async def test_init(self, api_00: Paperless): + async def test_init(self, p: Paperless): """Test init.""" - assert api_00._token - assert api_00._request_opts - assert not api_00._session - # test properties - assert api_00.url - assert api_00.is_initialized - - async def test_features(self, api_00: Paperless): - """Test features.""" - # basic class has no features - assert api_00.features == 0 - assert not api_00.storage_paths - assert not api_00.consumption_templates - assert not api_00.custom_fields - assert not api_00.share_links - assert not api_00.workflows - assert not api_00.workflow_actions - assert not api_00.workflow_triggers - - async def test_enums(self): - """Test enums.""" - assert MatchingAlgorithm(999) == MatchingAlgorithm.UNKNOWN - - -class TestCorrespondents: - """Correspondents test cases.""" - - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.correspondents, CorrespondentsController) - # test mixins - assert hasattr(api_00.correspondents, "list") - assert hasattr(api_00.correspondents, "get") - assert hasattr(api_00.correspondents, "iterate") - assert hasattr(api_00.correspondents, "one") - assert hasattr(api_00.correspondents, "create") - assert hasattr(api_00.correspondents, "update") - assert hasattr(api_00.correspondents, "delete") - - async def test_list(self, api_00: Paperless): - """Test list.""" - items = await api_00.correspondents.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_00: Paperless): - """Test get.""" - results = await api_00.correspondents.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, Correspondent) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.correspondents.iterate(): - assert isinstance(item, Correspondent) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.correspondents.one(1) + assert isinstance(p._session, PaperlessSession) + assert p.host_version == "0.0.0" + assert p.is_initialized + assert isinstance(p.local_resources, set) + assert isinstance(p.remote_resources, set) + + async def test_resources(self, p: Paperless): + """Test resources.""" + assert not p.config.is_available + assert p.correspondents.is_available + assert not p.custom_fields.is_available + assert p.document_types.is_available + assert p.documents.is_available + assert p.groups.is_available + assert p.mail_accounts.is_available + assert p.mail_rules.is_available + assert p.saved_views.is_available + assert not p.share_links.is_available + assert not p.storage_paths.is_available + assert p.tags.is_available + assert p.users.is_available + assert not p.workflows.is_available + + +@pytest.mark.parametrize( + "mapping", + [CORRESPONDENT_MAP, DOCUMENT_TYPE_MAP, TAG_MAP], + scope="class", +) +# test models/classifiers.py +class TestClassifiers: + """Classifiers test cases.""" + + async def test_helper(self, p: Paperless, mapping: ResourceTestMapping): + """Test helper.""" + assert hasattr(p, mapping.resource) + assert isinstance(getattr(p, mapping.resource), mapping.helper_cls) + assert helper_mixins.CallableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.DraftableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.IterableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.SecurableMixin in mapping.helper_cls.__bases__ + + async def test_model(self, mapping: ResourceTestMapping): + """Test model.""" + assert model_mixins.DeletableMixin in mapping.model_cls.__bases__ + assert model_mixins.MatchingFieldsMixin in mapping.model_cls.__bases__ + assert model_mixins.SecurableMixin in mapping.model_cls.__bases__ + assert model_mixins.UpdatableMixin in mapping.model_cls.__bases__ + # draft + assert model_mixins.CreatableMixin in mapping.draft_cls.__bases__ + assert model_mixins.SecurableDraftMixin in mapping.draft_cls.__bases__ + + async def test_pages(self, p: Paperless, mapping: ResourceTestMapping): + """Test pages.""" + page = await anext(aiter(getattr(p, mapping.resource).pages(1))) + assert isinstance(page, Page) + assert isinstance(page.items, list) + for item in page.items: + assert isinstance(item, mapping.model_cls) + + async def test_iter(self, p: Paperless, mapping: ResourceTestMapping): + """Test iter.""" + async for item in getattr(p, mapping.resource): + assert isinstance(item, mapping.model_cls) + + async def test_reduce(self, p: Paperless, mapping: ResourceTestMapping): + """Test iter with reduce.""" + async with getattr(p, mapping.resource).reduce(any_filter_param="1") as q: + async for item in q: + assert isinstance(item, mapping.model_cls) + + async def test_call(self, p: Paperless, mapping: ResourceTestMapping): + """Test call.""" + item = await getattr(p, mapping.resource)(1) assert item - assert isinstance(item, Correspondent) + assert isinstance(item, mapping.model_cls) # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_00.correspondents.one(1337) + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(1337) - async def test_create(self, api_00: Paperless): + async def test_create(self, p: Paperless, mapping: ResourceTestMapping): """Test create.""" - new_name = "Created Correspondent" - to_create = CorrespondentPost(name=new_name) - # test mixins, and their defaults - assert to_create.is_insensitive is True - assert to_create.match == "" - assert to_create.matching_algorithm == MatchingAlgorithm.NONE - # test default override - to_create = CorrespondentPost( - name=new_name, - matching_algorithm=MatchingAlgorithm.FUZZY, - ) - assert to_create.matching_algorithm == MatchingAlgorithm.FUZZY + draft = getattr(p, mapping.resource).draft(**mapping.draft_defaults) + assert isinstance(draft, mapping.draft_cls) + backup = draft.name + draft.name = None + with pytest.raises(DraftFieldRequired): + await draft.save() + draft.name = backup # actually call the create endpoint - created = await api_00.correspondents.create(to_create) - assert isinstance(created, Correspondent) - assert created.id == 6 - assert created.matching_algorithm == MatchingAlgorithm.FUZZY + assert await draft.save() >= 1 - async def test_udpate(self, api_00: Paperless): + async def test_udpate(self, p: Paperless, mapping: ResourceTestMapping): """Test update.""" - new_name = "Created Correspondent Update" - to_update = await api_00.correspondents.one(6) + to_update = await getattr(p, mapping.resource)(5) + new_name = f"{to_update.name} Updated" + to_update.name = new_name + await to_update.update() + assert to_update.name == new_name + # force update + new_name = f"{to_update.name} again" to_update.name = new_name - updated = await api_00.correspondents.update(to_update) - assert isinstance(updated, Correspondent) - assert updated.name == new_name + await to_update.update(only_changed=False) + assert to_update.name == new_name - async def test_delete(self, api_00: Paperless): + async def test_delete(self, p: Paperless, mapping: ResourceTestMapping): """Test delete.""" - to_delete = await api_00.correspondents.one(6) - deleted = await api_00.correspondents.delete(to_delete) - assert deleted - # must raise as we deleted 6 - with pytest.raises(HTTPNotFound): - await api_00.correspondents.one(6) + to_delete = await getattr(p, mapping.resource)(5) + assert await to_delete.delete() + # must raise as we deleted 5 + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(5) + + async def test_permissions(self, p: Paperless, mapping: ResourceTestMapping): + """Test permissions.""" + getattr(p, mapping.resource).request_permissions = True + item = await getattr(p, mapping.resource)(1) + assert item.has_permissions + assert isinstance(item.permissions, PermissionTableType) + # check disabling again + getattr(p, mapping.resource).request_permissions = False + item = await getattr(p, mapping.resource)(1) + assert not item.has_permissions + + +@pytest.mark.parametrize( + "mapping", + [GROUP_MAP, MAIL_ACCOUNT_MAP, MAIL_RULE_MAP, SAVED_VIEW_MAP, USER_MAP], + scope="class", +) +# test models/mails.py +# test models/permissions.py +# test models/saved_views.py +class TestReadOnly: + """Read only resources test cases.""" + + async def test_helper(self, p: Paperless, mapping: ResourceTestMapping): + """Test helper.""" + assert hasattr(p, mapping.resource) + assert isinstance(getattr(p, mapping.resource), mapping.helper_cls) + assert helper_mixins.CallableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.DraftableMixin not in mapping.helper_cls.__bases__ + assert helper_mixins.IterableMixin in mapping.helper_cls.__bases__ + + perms = helper_mixins.SecurableMixin in mapping.helper_cls.__bases__ + if mapping.resource in (PaperlessResource.GROUPS, PaperlessResource.USERS): + assert not perms + else: + assert perms + + async def test_model(self, mapping: ResourceTestMapping): + """Test model.""" + assert model_mixins.DeletableMixin not in mapping.model_cls.__bases__ + assert model_mixins.MatchingFieldsMixin not in mapping.model_cls.__bases__ + assert model_mixins.UpdatableMixin not in mapping.model_cls.__bases__ + + perms = model_mixins.SecurableMixin in mapping.model_cls.__bases__ + if mapping.resource in (PaperlessResource.GROUPS, PaperlessResource.USERS): + assert not perms + else: + assert perms + + async def test_pages(self, p: Paperless, mapping: ResourceTestMapping): + """Test pages.""" + page = await anext(aiter(getattr(p, mapping.resource).pages(1))) + assert isinstance(page, Page) + assert isinstance(page.items, list) + for item in page.items: + assert isinstance(item, mapping.model_cls) + + async def test_iter(self, p: Paperless, mapping: ResourceTestMapping): + """Test iter.""" + async for item in getattr(p, mapping.resource): + assert isinstance(item, mapping.model_cls) + + async def test_call(self, p: Paperless, mapping: ResourceTestMapping): + """Test call.""" + item = await getattr(p, mapping.resource)(1) + assert item + assert isinstance(item, mapping.model_cls) + # must raise as 1337 doesn't exist + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(1337) + + +@pytest.mark.parametrize( + "mapping", + [TASK_MAP], + scope="class", +) +# test models/tasks.py +class TestTasks: + """Tasks test cases.""" + + async def test_helper(self, p: Paperless, mapping: ResourceTestMapping): + """Test helper.""" + assert hasattr(p, mapping.resource) + assert isinstance(getattr(p, mapping.resource), mapping.helper_cls) + assert helper_mixins.CallableMixin not in mapping.helper_cls.__bases__ + assert helper_mixins.DraftableMixin not in mapping.helper_cls.__bases__ + assert helper_mixins.IterableMixin not in mapping.helper_cls.__bases__ + assert helper_mixins.SecurableMixin not in mapping.helper_cls.__bases__ + + async def test_model(self, mapping: ResourceTestMapping): + """Test model.""" + assert model_mixins.DeletableMixin not in mapping.model_cls.__bases__ + assert model_mixins.MatchingFieldsMixin not in mapping.model_cls.__bases__ + assert model_mixins.SecurableMixin not in mapping.model_cls.__bases__ + assert model_mixins.UpdatableMixin not in mapping.model_cls.__bases__ + + async def test_iter(self, p: Paperless, mapping: ResourceTestMapping): + """Test iter.""" + async for item in getattr(p, mapping.resource): + assert isinstance(item, mapping.model_cls) + + async def test_call(self, p: Paperless, mapping: ResourceTestMapping): + """Test call.""" + # by pk + item = await getattr(p, mapping.resource)(1) + assert item + assert isinstance(item, mapping.model_cls) + # by uuid + item = await getattr(p, mapping.resource)("abcdef12-3456-7890-abcd-ef1234567890") + assert item + assert isinstance(item, mapping.model_cls) + # must raise as 1337 doesn't exist + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(1337) + # must raise as abcdef doesn't exist + with pytest.raises(TaskNotFound): + await getattr(p, mapping.resource)("abcdef") +@pytest.mark.parametrize( + "mapping", + [DOCUMENT_MAP], + scope="class", +) +# test models/documents.py class TestDocuments: """Documents test cases.""" - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.documents, DocumentsController) - # test mixins - assert hasattr(api_00.documents, "list") - assert hasattr(api_00.documents, "get") - assert hasattr(api_00.documents, "iterate") - assert hasattr(api_00.documents, "one") - assert hasattr(api_00.documents, "create") - assert hasattr(api_00.documents, "update") - assert hasattr(api_00.documents, "delete") - # test services - assert api_00.documents.files - assert not api_00.documents.notes - - async def test_list(self, api_00: Paperless): - """Test list.""" - items = await api_00.documents.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_empty_list(self, api_00: Paperless): - """Test empty controller list.""" - with patch.object(api_00, "request_json", return_value={}): - # list must be empty because "all" key is omitted in return value - items = await api_00.documents.list() - assert items == [] - - async def test_get(self, api_00: Paperless): - """Test get.""" - results = await api_00.documents.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, Document) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.documents.iterate(): - assert isinstance(item, Document) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.documents.one(1) + async def test_helper(self, p: Paperless, mapping: ResourceTestMapping): + """Test helper.""" + assert hasattr(p, mapping.resource) + assert isinstance(getattr(p, mapping.resource), mapping.helper_cls) + assert helper_mixins.CallableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.DraftableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.IterableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.SecurableMixin in mapping.helper_cls.__bases__ + # test sub helpers + assert isinstance(p.documents.download, doc_helpers.DocumentFileDownloadHelper) + assert isinstance(p.documents.metadata, doc_helpers.DocumentMetaHelper) + assert isinstance(p.documents.preview, doc_helpers.DocumentFilePreviewHelper) + assert isinstance(p.documents.thumbnail, doc_helpers.DocumentFileThumbnailHelper) + + async def test_model(self, mapping: ResourceTestMapping): + """Test model.""" + assert model_mixins.DeletableMixin in mapping.model_cls.__bases__ + assert model_mixins.MatchingFieldsMixin not in mapping.model_cls.__bases__ + assert model_mixins.SecurableMixin in mapping.model_cls.__bases__ + assert model_mixins.UpdatableMixin in mapping.model_cls.__bases__ + # draft + assert model_mixins.CreatableMixin in mapping.draft_cls.__bases__ + assert model_mixins.SecurableDraftMixin not in mapping.draft_cls.__bases__ + + async def test_pages(self, p: Paperless, mapping: ResourceTestMapping): + """Test pages.""" + page = await anext(aiter(getattr(p, mapping.resource).pages(1))) + assert isinstance(page, Page) + assert isinstance(page.items, list) + for item in page.items: + assert isinstance(item, mapping.model_cls) + + async def test_iter(self, p: Paperless, mapping: ResourceTestMapping): + """Test iter.""" + async for item in getattr(p, mapping.resource): + assert isinstance(item, mapping.model_cls) + + async def test_call(self, p: Paperless, mapping: ResourceTestMapping): + """Test call.""" + item = await getattr(p, mapping.resource)(1) assert item - assert isinstance(item, Document) + assert isinstance(item, mapping.model_cls) # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_00.documents.one(1337) + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(1337) - async def test_create(self, api_00: Paperless): + async def test_create(self, p: Paperless, mapping: ResourceTestMapping): """Test create.""" - new_document = b"example content" - new_tags = [1, 2, 3] - new_correspondent = 1 - new_document_type = 1 - new_storage_path = 1 - title = "New Document" - created = datetime.datetime.now() - new_asn = 1 - to_create = DocumentPost( - document=new_document, - tags=new_tags, - title=title, - correspondent=new_correspondent, - document_type=new_document_type, - storage_path=new_storage_path, - created=created, - archive_serial_number=new_asn, - ) + draft = getattr(p, mapping.resource).draft(**mapping.draft_defaults) + assert isinstance(draft, mapping.draft_cls) + backup = draft.document + draft.document = None + with pytest.raises(DraftFieldRequired): + await draft.save() + draft.document = backup # actually call the create endpoint - task_id = await api_00.documents.create(to_create) + task_id = await draft.save() + # get the task assert isinstance(task_id, str) - task = await api_00.tasks.one(task_id) + task = await p.tasks(task_id) assert task.related_document - created = await api_00.documents.one(task.related_document) - assert isinstance(created, Document) + # get the document + created = await getattr(p, mapping.resource)(task.related_document) + assert isinstance(created, mapping.model_cls) assert created.tags.count(1) == 1 assert created.tags.count(2) == 1 assert created.tags.count(3) == 1 - async def test_udpate(self, api_00: Paperless): + async def test_udpate(self, p: Paperless, mapping: ResourceTestMapping): """Test update.""" - new_name = "Created Document Update" - to_update = await api_00.documents.one(3) - to_update.title = new_name - updated = await api_00.documents.update(to_update) - assert isinstance(updated, Document) - assert updated.title == new_name - - async def test_delete(self, api_00: Paperless): + to_update = await getattr(p, mapping.resource)(2) + new_title = f"{to_update.title} Updated" + to_update.title = new_title + await to_update.update() + assert to_update.title == new_title + + async def test_delete(self, p: Paperless, mapping: ResourceTestMapping): """Test delete.""" - to_delete = await api_00.documents.one(3) - deleted = await api_00.documents.delete(to_delete) - assert deleted - # must raise as we deleted 6 - with pytest.raises(HTTPNotFound): - await api_00.documents.one(6) - - async def test_meta(self, api_00: Paperless): + to_delete = await getattr(p, mapping.resource)(2) + assert await to_delete.delete() + # must raise as we deleted 2 + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(2) + + async def test_meta(self, p: Paperless, mapping: ResourceTestMapping): """Test meta.""" - # test with pk - meta = await api_00.documents.meta(1) - assert isinstance(meta, DocumentMetaInformation) - # test with item - item = await api_00.documents.one(1) - meta = await api_00.documents.meta(item) + document = await getattr(p, mapping.resource)(1) + meta = await document.get_metadata() + assert isinstance(meta, DocumentMeta) assert isinstance(meta.original_metadata, list) for item in meta.original_metadata: - assert isinstance(item, DocumentMetadata) + assert isinstance(item, DocumentMetadataType) assert isinstance(meta.archive_metadata, list) for item in meta.archive_metadata: - assert isinstance(item, DocumentMetadata) + assert isinstance(item, DocumentMetadataType) - async def test_files(self, api_00: Paperless): + async def test_files(self, p: Paperless, mapping: ResourceTestMapping): """Test files.""" - # test with pk - files = await api_00.documents.files.thumb(1) - assert isinstance(files, bytes) - files = await api_00.documents.files.preview(1) - assert isinstance(files, bytes) - files = await api_00.documents.files.download(1) - assert isinstance(files, bytes) - # test with item - item = await api_00.documents.one(1) - files = await api_00.documents.files.thumb(item) - assert isinstance(files, bytes) - files = await api_00.documents.files.preview(item) - assert isinstance(files, bytes) - files = await api_00.documents.files.download(item) - assert isinstance(files, bytes) - - -class TestDocumentTypes: - """Document Types test cases.""" - - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.document_types, DocumentTypesController) - # test mixins - assert hasattr(api_00.document_types, "list") - assert hasattr(api_00.document_types, "get") - assert hasattr(api_00.document_types, "iterate") - assert hasattr(api_00.document_types, "one") - assert hasattr(api_00.document_types, "create") - assert hasattr(api_00.document_types, "update") - assert hasattr(api_00.document_types, "delete") - - async def test_list(self, api_00: Paperless): - """Test list.""" - items = await api_00.document_types.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_00: Paperless): - """Test get.""" - results = await api_00.document_types.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, DocumentType) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.document_types.iterate(): - assert isinstance(item, DocumentType) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.document_types.one(1) - assert item - assert isinstance(item, DocumentType) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_00.document_types.one(1337) - - async def test_create(self, api_00: Paperless): - """Test create.""" - new_name = "Created Document Type" - to_create = DocumentTypePost(name=new_name) - # test mixins, and their defaults - assert to_create.is_insensitive is True - assert to_create.match == "" - assert to_create.matching_algorithm == MatchingAlgorithm.NONE - # test default override - to_create = DocumentTypePost( - name=new_name, - matching_algorithm=MatchingAlgorithm.FUZZY, - ) - assert to_create.matching_algorithm == MatchingAlgorithm.FUZZY - # actually call the create endpoint - created = await api_00.document_types.create(to_create) - assert isinstance(created, DocumentType) - assert created.id == 6 - assert created.matching_algorithm == MatchingAlgorithm.FUZZY - - async def test_udpate(self, api_00: Paperless): - """Test update.""" - new_name = "Created Document Type Update" - to_update = await api_00.document_types.one(6) - to_update.name = new_name - updated = await api_00.document_types.update(to_update) - assert isinstance(updated, DocumentType) - assert updated.name == new_name - - async def test_delete(self, api_00: Paperless): - """Test delete.""" - to_delete = await api_00.document_types.one(6) - deleted = await api_00.document_types.delete(to_delete) - assert deleted - # must raise as we deleted 6 - with pytest.raises(HTTPNotFound): - await api_00.document_types.one(6) - - -class TestGroups: - """Groups test cases.""" - - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.groups, GroupsController) - # test mixins - assert hasattr(api_00.groups, "list") - assert hasattr(api_00.groups, "get") - assert hasattr(api_00.groups, "iterate") - assert hasattr(api_00.groups, "one") - assert not hasattr(api_00.groups, "create") - assert not hasattr(api_00.groups, "update") - assert not hasattr(api_00.groups, "delete") - - async def test_list(self, api_00: Paperless): - """Test list.""" - items = await api_00.groups.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_00: Paperless): - """Test get.""" - results = await api_00.groups.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, Group) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.groups.iterate(): - assert isinstance(item, Group) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.groups.one(1) - assert item - assert isinstance(item, Group) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_00.groups.one(1337) - - -class TestMailAccounts: - """Mail Accounts test cases.""" - - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.mail_accounts, MailAccountsController) - # test mixins - assert hasattr(api_00.mail_accounts, "list") - assert hasattr(api_00.mail_accounts, "get") - assert hasattr(api_00.mail_accounts, "iterate") - assert hasattr(api_00.mail_accounts, "one") - assert not hasattr(api_00.mail_accounts, "create") - assert not hasattr(api_00.mail_accounts, "update") - assert not hasattr(api_00.mail_accounts, "delete") - - async def test_list(self, api_00: Paperless): - """Test list.""" - items = await api_00.mail_accounts.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_00: Paperless): - """Test get.""" - results = await api_00.mail_accounts.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, MailAccount) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.mail_accounts.iterate(): - assert isinstance(item, MailAccount) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.mail_accounts.one(1) - assert item - assert isinstance(item, MailAccount) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_00.mail_accounts.one(1337) - - -class TestMailRules: - """Mail Rules test cases.""" - - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.mail_rules, MailRulesController) - # test mixins - assert hasattr(api_00.mail_rules, "list") - assert hasattr(api_00.mail_rules, "get") - assert hasattr(api_00.mail_rules, "iterate") - assert hasattr(api_00.mail_rules, "one") - assert not hasattr(api_00.mail_rules, "create") - assert not hasattr(api_00.mail_rules, "update") - assert not hasattr(api_00.mail_rules, "delete") - - async def test_list(self, api_00: Paperless): - """Test list.""" - items = await api_00.mail_rules.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_00: Paperless): - """Test get.""" - results = await api_00.mail_rules.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, MailRule) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.mail_rules.iterate(): - assert isinstance(item, MailRule) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.mail_rules.one(1) - assert item - assert isinstance(item, MailRule) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_00.mail_rules.one(1337) - - -class TestSavedViews: - """Saved Views test cases.""" - - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.saved_views, SavedViewsController) - # test mixins - assert hasattr(api_00.saved_views, "list") - assert hasattr(api_00.saved_views, "get") - assert hasattr(api_00.saved_views, "iterate") - assert hasattr(api_00.saved_views, "one") - assert not hasattr(api_00.saved_views, "create") - assert not hasattr(api_00.saved_views, "update") - assert not hasattr(api_00.saved_views, "delete") - - async def test_list(self, api_00: Paperless): - """Test list.""" - items = await api_00.saved_views.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test__get(self, api_00: Paperless): - """Test get.""" - results = await api_00.saved_views.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, SavedView) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.saved_views.iterate(): - assert isinstance(item, SavedView) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.saved_views.one(1) - assert item - assert isinstance(item, SavedView) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_00.saved_views.one(1337) - - -class TestTags: - """Tags test cases.""" - - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.tags, TagsController) - # test mixins - assert hasattr(api_00.tags, "list") - assert hasattr(api_00.tags, "get") - assert hasattr(api_00.tags, "iterate") - assert hasattr(api_00.tags, "one") - assert hasattr(api_00.tags, "create") - assert hasattr(api_00.tags, "update") - assert hasattr(api_00.tags, "delete") - - async def test_list(self, api_00: Paperless): - """Test list.""" - items = await api_00.tags.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_00: Paperless): - """Test get.""" - results = await api_00.tags.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, Tag) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.tags.iterate(): - assert isinstance(item, Tag) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.tags.one(1) - assert item - assert isinstance(item, Tag) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_00.tags.one(1337) - - async def test_create(self, api_00: Paperless): - """Test create.""" - new_name = "Created Tag" - to_create = TagPost(name=new_name) - # test mixins, and their defaults - assert to_create.is_insensitive is True - assert to_create.match == "" - assert to_create.matching_algorithm == MatchingAlgorithm.NONE - # test default override - to_create = TagPost( - name=new_name, - matching_algorithm=MatchingAlgorithm.FUZZY, + document = await getattr(p, mapping.resource)(1) + download = await document.get_download() + assert isinstance(download, DownloadedDocument) + assert download.mode == RetrieveFileMode.DOWNLOAD + preview = await document.get_preview() + assert isinstance(preview, DownloadedDocument) + assert preview.mode == RetrieveFileMode.PREVIEW + thumbnail = await document.get_thumbnail() + assert isinstance(thumbnail, DownloadedDocument) + assert thumbnail.mode == RetrieveFileMode.THUMBNAIL + + async def test_suggestions(self, p: Paperless, mapping: ResourceTestMapping): + """Test suggestions.""" + document = await getattr(p, mapping.resource)(1) + suggestions = await document.get_suggestions() + assert isinstance(suggestions, DocumentSuggestions) + + async def test_get_next_an(self, p: Paperless, mapping: ResourceTestMapping): + """Test get next asn.""" + asn = await getattr(p, mapping.resource).get_next_asn() + assert isinstance(asn, int) + # test exception + session = PaperlessSessionMock( + PAPERLESS_TEST_URL, + "", + params={ + "status": 400, + }, ) - assert to_create.matching_algorithm == MatchingAlgorithm.FUZZY - # actually call the create endpoint - created = await api_00.tags.create(to_create) - assert isinstance(created, Tag) - assert created.id == 3 - assert created.matching_algorithm == MatchingAlgorithm.FUZZY - - async def test_udpate(self, api_00: Paperless): - """Test update.""" - new_name = "Created Tag Update" - to_update = await api_00.tags.one(3) - to_update.name = new_name - updated = await api_00.tags.update(to_update) - assert isinstance(updated, Tag) - assert updated.name == new_name - - async def test_delete(self, api_00: Paperless): - """Test delete.""" - to_delete = await api_00.tags.one(3) - deleted = await api_00.tags.delete(to_delete) - assert deleted - # must raise as we deleted 3 - with pytest.raises(HTTPNotFound): - await api_00.tags.one(3) - - -class TestTasks: - """Tasks test cases.""" - - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.tasks, TasksController) - # test mixins - assert not hasattr(api_00.tasks, "list") - assert hasattr(api_00.tasks, "get") - assert hasattr(api_00.tasks, "iterate") - assert hasattr(api_00.tasks, "one") - assert not hasattr(api_00.tasks, "create") - assert not hasattr(api_00.tasks, "update") - assert not hasattr(api_00.tasks, "delete") - - async def test_get(self, api_00: Paperless): - """Test get.""" - results = await api_00.tasks.get() - assert isinstance(results, list) - for item in results: - assert isinstance(item, Task) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.tasks.iterate(): - assert isinstance(item, Task) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.tasks.one("eb327ed7-b3c8-4a8c-9aa2-5385e499c74a") - assert isinstance(item, Task) - item = await api_00.tasks.one("non-existing-uuid") - assert not item - - -class TestUsers: - """Users test cases.""" - - async def test_controller(self, api_00: Paperless): - """Test controller.""" - assert isinstance(api_00.users, UsersController) - # test mixins - assert hasattr(api_00.users, "list") - assert hasattr(api_00.users, "get") - assert hasattr(api_00.users, "iterate") - assert hasattr(api_00.users, "one") - assert not hasattr(api_00.users, "create") - assert not hasattr(api_00.users, "update") - assert not hasattr(api_00.users, "delete") - - async def test_list(self, api_00: Paperless): - """Test list.""" - items = await api_00.users.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_00: Paperless): - """Test get.""" - results = await api_00.users.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, User) - - async def test_iterate(self, api_00: Paperless): - """Test iterate.""" - async for item in api_00.users.iterate(): - assert isinstance(item, User) - - async def test_one(self, api_00: Paperless): - """Test one.""" - item = await api_00.users.one(1) - assert item - assert isinstance(item, User) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_00.users.one(1337) + p._session = session + with pytest.raises(AsnRequestError): + await getattr(p, mapping.resource).get_next_asn() + + async def test_searching(self, p: Paperless, mapping: ResourceTestMapping): + """Test searching.""" + # search + async for item in getattr(p, mapping.resource).search("leet"): + assert isinstance(item, mapping.model_cls) + assert item.has_search_hit + # more_like + async for item in getattr(p, mapping.resource).more_like(1337): + assert isinstance(item, mapping.model_cls) + assert item.has_search_hit diff --git a/tests/test_paperless_1_17_0.py b/tests/test_paperless_1_17_0.py index d514ca6..10222a0 100644 --- a/tests/test_paperless_1_17_0.py +++ b/tests/test_paperless_1_17_0.py @@ -1,86 +1,115 @@ """Paperless v1.17.0 tests.""" -from datetime import datetime +import datetime -from pypaperless import Paperless -from pypaperless.const import PaperlessFeature -from pypaperless.controllers import DocumentsController -from pypaperless.models import Document, DocumentNote, DocumentNotePost +import pytest +from pypaperless import Paperless, PaperlessSession +from pypaperless.exceptions import DraftFieldRequired, PrimaryKeyRequired +from pypaperless.models import documents as doc_helpers +from pypaperless.models.documents import DocumentNote, DocumentNoteDraft +from pypaperless.models.mixins import models as model_mixins +from . import DOCUMENT_MAP, ResourceTestMapping + +# mypy: ignore-errors +# pylint: disable=protected-access,redefined-outer-name + + +@pytest.fixture(scope="function") +async def p(api_117) -> Paperless: + """Yield version for this test case.""" + yield api_117 + + +# test api.py with extra document notes endpoint class TestBeginPaperless: """Paperless v1.17.0 test cases.""" - async def test_init(self, api_117: Paperless): + async def test_init(self, p: Paperless): """Test init.""" - assert api_117._token - assert api_117._request_opts - assert not api_117._session - # test properties - assert api_117.url - assert api_117.is_initialized - - async def test_features(self, api_117: Paperless): - """Test features.""" - # basic class has no features - assert PaperlessFeature.CONTROLLER_STORAGE_PATHS in api_117.features - assert PaperlessFeature.FEATURE_DOCUMENT_NOTES in api_117.features - assert api_117.storage_paths - assert not api_117.consumption_templates - assert not api_117.custom_fields - assert not api_117.share_links - assert not api_117.workflows - assert not api_117.workflow_actions - assert not api_117.workflow_triggers + assert isinstance(p._session, PaperlessSession) + assert p.host_version == "1.17.0" + assert p.is_initialized + assert isinstance(p.local_resources, set) + assert isinstance(p.remote_resources, set) + + async def test_resources(self, p: Paperless): + """Test resources.""" + assert not p.config.is_available + assert p.correspondents.is_available + assert not p.custom_fields.is_available + assert p.document_types.is_available + assert p.documents.is_available + assert p.groups.is_available + assert p.mail_accounts.is_available + assert p.mail_rules.is_available + assert p.saved_views.is_available + assert not p.share_links.is_available + assert p.storage_paths.is_available + assert p.tags.is_available + assert p.users.is_available + assert not p.workflows.is_available +@pytest.mark.parametrize( + "mapping", + [DOCUMENT_MAP], + scope="class", +) +# test models/documents.py class TestDocumentNotes: """Document Notes test cases.""" - async def test_controller(self, api_117: Paperless): - """Test controller.""" - assert isinstance(api_117.documents, DocumentsController) - # test services - assert api_117.documents.notes - - async def test_document_one(self, api_117: Paperless): - """Test document one (notes edition).""" - item = await api_117.documents.one(2) - assert isinstance(item, Document) - assert len(item.notes) > 0 - for note in item.notes: - assert isinstance(note, DocumentNote) - assert isinstance(note.created, datetime) + async def test_helper(self, p: Paperless, mapping: ResourceTestMapping): + """Test helper.""" + assert isinstance(getattr(p, mapping.resource).notes, doc_helpers.DocumentNoteHelper) + + async def test_model( + self, + mapping: ResourceTestMapping, # pylint: disable=unused-argument # noqa ARG002 + ): + """Test model.""" + assert model_mixins.DeletableMixin not in DocumentNote.__bases__ + assert model_mixins.MatchingFieldsMixin not in DocumentNote.__bases__ + assert model_mixins.SecurableMixin not in DocumentNote.__bases__ + assert model_mixins.UpdatableMixin not in DocumentNote.__bases__ + assert model_mixins.CreatableMixin in DocumentNoteDraft.__bases__ - async def test_get(self, api_117: Paperless): - """Test get.""" - pk = 2 - results = await api_117.documents.notes.get(pk) + async def test_call(self, p: Paperless, mapping: ResourceTestMapping): + """Test call.""" + item = await getattr(p, mapping.resource)(1) + assert isinstance(item, mapping.model_cls) + results = await item.notes() assert isinstance(results, list) assert len(results) > 0 - for item in results: - assert isinstance(item, DocumentNote) - # Paperless fakes the pk on this endpoint, lets test - # Read more about that in pypaperless/controllers/documents.py - DocumentNotesService - assert item.document == pk + for note in results: + assert isinstance(note, DocumentNote) + assert isinstance(note.created, datetime.datetime) + with pytest.raises(PrimaryKeyRequired): + item = await getattr(p, mapping.resource).notes() - async def test_create(self, api_117: Paperless): + async def test_create(self, p: Paperless, mapping: ResourceTestMapping): """Test create.""" - pk = 2 - to_create = DocumentNotePost( - note="Sample text.", - document=pk, - ) - # there are no returns, just test if something fails - # the fake PaperlessAPI won't even setup a new object - await api_117.documents.notes.create(to_create) + item = await getattr(p, mapping.resource)(1) + draft = item.notes.draft(note="Test note.") + assert isinstance(draft, DocumentNoteDraft) + backup = draft.note + draft.note = None + with pytest.raises(DraftFieldRequired): + await draft.save() + draft.note = backup + # actually call the create endpoint + result = await draft.save() + assert isinstance(result, tuple) - async def test_delete(self, api_117: Paperless): + async def test_delete(self, p: Paperless, mapping: ResourceTestMapping): """Test delete.""" - pk = 2 - results = await api_117.documents.notes.get(pk) + item = await getattr(p, mapping.resource)(1) + results = await item.notes() assert isinstance(results, list) assert len(results) > 0 # there are no returns, just test if something fails # the fake PaperlessAPI won't even setup a new object - await api_117.documents.notes.delete(results.pop()) + deletion = await results.pop().delete() + assert deletion diff --git a/tests/test_paperless_1_8_0.py b/tests/test_paperless_1_8_0.py index 4a78b2c..33a9ee9 100644 --- a/tests/test_paperless_1_8_0.py +++ b/tests/test_paperless_1_8_0.py @@ -1,125 +1,128 @@ """Paperless v1.8.0 tests.""" import pytest -from aiohttp.web_exceptions import HTTPNotFound -from pypaperless import Paperless -from pypaperless.const import PaperlessFeature -from pypaperless.controllers import StoragePathsController -from pypaperless.controllers.base import ResultPage -from pypaperless.models import StoragePath, StoragePathPost -from pypaperless.models.matching import MatchingAlgorithm +from pypaperless import Paperless, PaperlessSession +from pypaperless.exceptions import DraftFieldRequired, RequestException +from pypaperless.models import Page +from pypaperless.models.mixins import helpers as helper_mixins +from pypaperless.models.mixins import models as model_mixins +from . import STORAGE_PATH_MAP, ResourceTestMapping +# mypy: ignore-errors +# pylint: disable=protected-access,redefined-outer-name + + +@pytest.fixture(scope="function") +async def p(api_18) -> Paperless: + """Yield version for this test case.""" + yield api_18 + + +# test api.py with extra storage paths endpoint class TestBeginPaperless: """Paperless v1.8.0 test cases.""" - async def test_init(self, api_18: Paperless): + async def test_init(self, p: Paperless): """Test init.""" - assert api_18._token - assert api_18._request_opts - assert not api_18._session - # test properties - assert api_18.url - assert api_18.is_initialized - - async def test_features(self, api_18: Paperless): - """Test features.""" - # basic class has no features - assert PaperlessFeature.CONTROLLER_STORAGE_PATHS in api_18.features - assert api_18.storage_paths - assert not api_18.consumption_templates - assert not api_18.custom_fields - assert not api_18.share_links - assert not api_18.workflows - assert not api_18.workflow_actions - assert not api_18.workflow_triggers - - -class TestStoragePaths: - """Storage Paths test cases.""" - - async def test_controller(self, api_18: Paperless): - """Test controller.""" - assert isinstance(api_18.storage_paths, StoragePathsController) - # test mixins - assert hasattr(api_18.storage_paths, "list") - assert hasattr(api_18.storage_paths, "get") - assert hasattr(api_18.storage_paths, "iterate") - assert hasattr(api_18.storage_paths, "one") - assert hasattr(api_18.storage_paths, "create") - assert hasattr(api_18.storage_paths, "update") - assert hasattr(api_18.storage_paths, "delete") - - async def test_list(self, api_18: Paperless): - """Test list.""" - items = await api_18.storage_paths.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_18: Paperless): - """Test get.""" - results = await api_18.storage_paths.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, StoragePath) - - async def test_iterate(self, api_18: Paperless): - """Test iterate.""" - async for item in api_18.storage_paths.iterate(): - assert isinstance(item, StoragePath) - - async def test_one(self, api_18: Paperless): - """Test one.""" - item = await api_18.storage_paths.one(1) + assert isinstance(p._session, PaperlessSession) + assert p.host_version == "1.8.0" + assert p.is_initialized + assert isinstance(p.local_resources, set) + assert isinstance(p.remote_resources, set) + + async def test_resources(self, p: Paperless): + """Test resources.""" + assert not p.config.is_available + assert p.correspondents.is_available + assert not p.custom_fields.is_available + assert p.document_types.is_available + assert p.documents.is_available + assert p.groups.is_available + assert p.mail_accounts.is_available + assert p.mail_rules.is_available + assert p.saved_views.is_available + assert not p.share_links.is_available + assert p.storage_paths.is_available + assert p.tags.is_available + assert p.users.is_available + assert not p.workflows.is_available + + +@pytest.mark.parametrize( + "mapping", + [STORAGE_PATH_MAP], + scope="class", +) +# test models/classifiers.py +class TestClassifiers: + """Classifiers test cases.""" + + async def test_helper(self, p: Paperless, mapping: ResourceTestMapping): + """Test helper.""" + assert hasattr(p, mapping.resource) + assert isinstance(getattr(p, mapping.resource), mapping.helper_cls) + assert helper_mixins.CallableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.DraftableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.IterableMixin in mapping.helper_cls.__bases__ + + async def test_model(self, mapping: ResourceTestMapping): + """Test model.""" + assert model_mixins.DeletableMixin in mapping.model_cls.__bases__ + assert model_mixins.MatchingFieldsMixin in mapping.model_cls.__bases__ + assert model_mixins.SecurableMixin in mapping.model_cls.__bases__ + assert model_mixins.UpdatableMixin in mapping.model_cls.__bases__ + # draft + assert model_mixins.CreatableMixin in mapping.draft_cls.__bases__ + assert model_mixins.SecurableDraftMixin in mapping.draft_cls.__bases__ + + async def test_pages(self, p: Paperless, mapping: ResourceTestMapping): + """Test pages.""" + page = await anext(aiter(getattr(p, mapping.resource).pages(1))) + assert isinstance(page, Page) + assert isinstance(page.items, list) + for item in page.items: + assert isinstance(item, mapping.model_cls) + + async def test_iter(self, p: Paperless, mapping: ResourceTestMapping): + """Test iter.""" + async for item in getattr(p, mapping.resource): + assert isinstance(item, mapping.model_cls) + + async def test_call(self, p: Paperless, mapping: ResourceTestMapping): + """Test call.""" + item = await getattr(p, mapping.resource)(1) assert item - assert isinstance(item, StoragePath) + assert isinstance(item, mapping.model_cls) # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_18.storage_paths.one(1337) + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(1337) - async def test_create(self, api_18: Paperless): + async def test_create(self, p: Paperless, mapping: ResourceTestMapping): """Test create.""" - new_name = "Created Storage Path" - new_path = "test/test/{doc_pk}" - to_create = StoragePathPost(name=new_name, path=new_path) - # test mixins, and their defaults - assert to_create.is_insensitive is True - assert to_create.match == "" - assert to_create.matching_algorithm == MatchingAlgorithm.NONE - # test default override - to_create = StoragePathPost( - name=new_name, - path=new_path, - matching_algorithm=MatchingAlgorithm.FUZZY, - ) - assert to_create.matching_algorithm == MatchingAlgorithm.FUZZY + draft = getattr(p, mapping.resource).draft(**mapping.draft_defaults) + assert isinstance(draft, mapping.draft_cls) + backup = draft.name + draft.name = None + with pytest.raises(DraftFieldRequired): + await draft.save() + draft.name = backup # actually call the create endpoint - created = await api_18.storage_paths.create(to_create) - assert isinstance(created, StoragePath) - assert created.id == 4 - assert created.matching_algorithm == MatchingAlgorithm.FUZZY + assert await draft.save() >= 1 - async def test_udpate(self, api_18: Paperless): + async def test_udpate(self, p: Paperless, mapping: ResourceTestMapping): """Test update.""" - new_name = "Created Storage Path Update" - to_update = await api_18.storage_paths.one(4) + to_update = await getattr(p, mapping.resource)(5) + new_name = f"{to_update.name} Updated" to_update.name = new_name - updated = await api_18.storage_paths.update(to_update) - assert isinstance(updated, StoragePath) - assert updated.name == new_name + await to_update.update() + assert to_update.name == new_name - async def test_delete(self, api_18: Paperless): + async def test_delete(self, p: Paperless, mapping: ResourceTestMapping): """Test delete.""" - to_delete = await api_18.storage_paths.one(4) - deleted = await api_18.storage_paths.delete(to_delete) - assert deleted - # must raise as we deleted 6 - with pytest.raises(HTTPNotFound): - await api_18.storage_paths.one(4) + to_delete = await getattr(p, mapping.resource)(5) + assert await to_delete.delete() + # must raise as we deleted 5 + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(5) diff --git a/tests/test_paperless_2_0_0.py b/tests/test_paperless_2_0_0.py index c6e0f54..fed2179 100644 --- a/tests/test_paperless_2_0_0.py +++ b/tests/test_paperless_2_0_0.py @@ -1,291 +1,166 @@ """Paperless v2.0.0 tests.""" -import datetime - import pytest -from aiohttp.web_exceptions import HTTPNotFound - -from pypaperless import Paperless -from pypaperless.const import PaperlessFeature -from pypaperless.controllers import ( - ConsumptionTemplatesController, - CustomFieldsController, - ShareLinksController, -) -from pypaperless.controllers.base import ResultPage -from pypaperless.models import ( - ConsumptionTemplate, - CustomField, - CustomFieldPost, - Document, - ShareLink, - ShareLinkPost, -) -from pypaperless.models.custom_fields import CustomFieldType, CustomFieldValue -from pypaperless.models.share_links import ShareLinkFileVersion - - -class TestBeginPaperless: - """Paperless v2.0.0 test cases.""" - - async def test_init(self, api_20: Paperless): - """Test init.""" - assert api_20._token - assert api_20._request_opts - assert not api_20._session - # test properties - assert api_20.url - assert api_20.is_initialized - - async def test_features(self, api_20: Paperless): - """Test features.""" - # basic class has no features - assert PaperlessFeature.CONTROLLER_STORAGE_PATHS in api_20.features - assert PaperlessFeature.FEATURE_DOCUMENT_NOTES in api_20.features - assert PaperlessFeature.CONTROLLER_SHARE_LINKS in api_20.features - assert PaperlessFeature.CONTROLLER_CONSUMPTION_TEMPLATES in api_20.features - assert PaperlessFeature.CONTROLLER_CUSTOM_FIELDS in api_20.features - assert api_20.storage_paths - assert api_20.consumption_templates - assert api_20.custom_fields - assert api_20.share_links - assert not api_20.workflows - assert not api_20.workflow_actions - assert not api_20.workflow_triggers - async def test_enums(self): - """Test enums.""" - assert CustomFieldType(999) == CustomFieldType.UNKNOWN - assert ShareLinkFileVersion("nope") == ShareLinkFileVersion.UNKNOWN +from pypaperless import Paperless, PaperlessSession +from pypaperless.exceptions import RequestException +from pypaperless.models import Page +from pypaperless.models.mixins import helpers as helper_mixins +from pypaperless.models.mixins import models as model_mixins +from . import CONFIG_MAP, CUSTOM_FIELD_MAP, SHARE_LINK_MAP, ResourceTestMapping -class TestConsumptionTemplates: - """Consumption Templates test cases.""" +# mypy: ignore-errors +# pylint: disable=protected-access,redefined-outer-name - async def test_controller(self, api_20: Paperless): - """Test controller.""" - assert isinstance(api_20.consumption_templates, ConsumptionTemplatesController) - # test mixins - assert hasattr(api_20.consumption_templates, "list") - assert hasattr(api_20.consumption_templates, "get") - assert hasattr(api_20.consumption_templates, "iterate") - assert hasattr(api_20.consumption_templates, "one") - assert not hasattr(api_20.consumption_templates, "create") - assert not hasattr(api_20.consumption_templates, "update") - assert not hasattr(api_20.consumption_templates, "delete") - async def test_list(self, api_20: Paperless): - """Test list.""" - items = await api_20.consumption_templates.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) +@pytest.fixture(scope="function") +async def p(api_20) -> Paperless: + """Yield version for this test case.""" + yield api_20 - async def test_get(self, api_20: Paperless): - """Test get.""" - results = await api_20.consumption_templates.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, ConsumptionTemplate) - async def test_iterate(self, api_20: Paperless): - """Test iterate.""" - async for item in api_20.consumption_templates.iterate(): - assert isinstance(item, ConsumptionTemplate) - - async def test_one(self, api_20: Paperless): - """Test one.""" - item = await api_20.consumption_templates.one(1) - assert item - assert isinstance(item, ConsumptionTemplate) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_20.consumption_templates.one(1337) - - -class TestCustomFields: - """Custom Fields test cases.""" - - async def test_controller(self, api_20: Paperless): - """Test controller.""" - assert isinstance(api_20.custom_fields, CustomFieldsController) - # test mixins - assert hasattr(api_20.custom_fields, "list") - assert hasattr(api_20.custom_fields, "get") - assert hasattr(api_20.custom_fields, "iterate") - assert hasattr(api_20.custom_fields, "one") - assert hasattr(api_20.custom_fields, "create") - assert hasattr(api_20.custom_fields, "update") - assert hasattr(api_20.custom_fields, "delete") - - async def test_list(self, api_20: Paperless): - """Test list.""" - items = await api_20.custom_fields.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_20: Paperless): - """Test get.""" - results = await api_20.custom_fields.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, CustomField) - - async def test_iterate(self, api_20: Paperless): - """Test iterate.""" - async for item in api_20.custom_fields.iterate(): - assert isinstance(item, CustomField) +# test api.py with extra custom fields and sharelinks endpoints +class TestBeginPaperless: + """Paperless v2.0.0 test cases.""" - async def test_one(self, api_20: Paperless): - """Test one.""" - item = await api_20.custom_fields.one(1) + async def test_init(self, p: Paperless): + """Test init.""" + assert isinstance(p._session, PaperlessSession) + assert p.host_version == "2.0.0" + assert p.is_initialized + assert isinstance(p.local_resources, set) + assert isinstance(p.remote_resources, set) + + async def test_resources(self, p: Paperless): + """Test resources.""" + assert p.config.is_available + assert p.correspondents.is_available + assert p.custom_fields.is_available + assert p.document_types.is_available + assert p.documents.is_available + assert p.groups.is_available + assert p.mail_accounts.is_available + assert p.mail_rules.is_available + assert p.saved_views.is_available + assert p.share_links.is_available + assert p.storage_paths.is_available + assert p.tags.is_available + assert p.users.is_available + assert not p.workflows.is_available + + +@pytest.mark.parametrize( + "mapping", + [CUSTOM_FIELD_MAP, SHARE_LINK_MAP], + scope="class", +) +# test models/custom_fields.py +# test models/share_links.py +class TestCustomFieldShareLinks: + """Custom fields and share links test cases.""" + + async def test_helper(self, p: Paperless, mapping: ResourceTestMapping): + """Test helper.""" + assert hasattr(p, mapping.resource) + assert isinstance(getattr(p, mapping.resource), mapping.helper_cls) + assert helper_mixins.CallableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.DraftableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.IterableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.SecurableMixin not in mapping.helper_cls.__bases__ + + async def test_model(self, mapping: ResourceTestMapping): + """Test model.""" + assert model_mixins.DeletableMixin in mapping.model_cls.__bases__ + assert model_mixins.MatchingFieldsMixin not in mapping.model_cls.__bases__ + assert model_mixins.SecurableMixin not in mapping.model_cls.__bases__ + assert model_mixins.UpdatableMixin in mapping.model_cls.__bases__ + # draft + assert model_mixins.CreatableMixin in mapping.draft_cls.__bases__ + assert model_mixins.SecurableDraftMixin not in mapping.draft_cls.__bases__ + + async def test_pages(self, p: Paperless, mapping: ResourceTestMapping): + """Test pages.""" + page = await anext(aiter(getattr(p, mapping.resource).pages(1))) + assert isinstance(page, Page) + assert isinstance(page.items, list) + for item in page.items: + assert isinstance(item, mapping.model_cls) + + async def test_iter(self, p: Paperless, mapping: ResourceTestMapping): + """Test iter.""" + async for item in getattr(p, mapping.resource): + assert isinstance(item, mapping.model_cls) + + async def test_call(self, p: Paperless, mapping: ResourceTestMapping): + """Test call.""" + item = await getattr(p, mapping.resource)(1) assert item - assert isinstance(item, CustomField) + assert isinstance(item, mapping.model_cls) # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_20.custom_fields.one(1337) + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(1337) - async def test_create(self, api_20: Paperless): + async def test_create(self, p: Paperless, mapping: ResourceTestMapping): """Test create.""" - new_name = "Created Custom Field" - to_create = CustomFieldPost(name=new_name, data_type=CustomFieldType.BOOLEAN) - created = await api_20.custom_fields.create(to_create) - assert isinstance(created, CustomField) - assert created.id == 9 - assert created.data_type == CustomFieldType.BOOLEAN + draft = getattr(p, mapping.resource).draft(**mapping.draft_defaults) + assert isinstance(draft, mapping.draft_cls) + # actually call the create endpoint + assert await draft.save() >= 1 - async def test_udpate(self, api_20: Paperless): + async def test_udpate(self, p: Paperless, mapping: ResourceTestMapping): """Test update.""" - new_name = "Created Custom Field Update" - to_update = await api_20.custom_fields.one(9) - to_update.name = new_name - updated = await api_20.custom_fields.update(to_update) - assert isinstance(updated, CustomField) - assert updated.name == new_name - - async def test_delete(self, api_20: Paperless): + to_update = await getattr(p, mapping.resource)(5) + if mapping.model_cls is SHARE_LINK_MAP.model_cls: + new_document = 3 + to_update.document = new_document + await to_update.update() + assert to_update.document == new_document + else: + new_name = f"{to_update.name} Updated" + to_update.name = new_name + await to_update.update() + assert to_update.name == new_name + + async def test_delete(self, p: Paperless, mapping: ResourceTestMapping): """Test delete.""" - to_delete = await api_20.custom_fields.one(9) - deleted = await api_20.custom_fields.delete(to_delete) - assert deleted - # must raise as we deleted 9 - with pytest.raises(HTTPNotFound): - await api_20.custom_fields.one(9) - - -class TestCustomFieldValues: - """Custom Field Values test cases.""" - - async def test_set(self, api_20: Paperless): - """Test set.""" - document = await api_20.documents.one(2) - assert isinstance(document, Document) - assert isinstance(document.custom_fields, list) - assert len(document.custom_fields) > 0 - for item in document.custom_fields: - assert isinstance(item, CustomFieldValue) - # manipulate custom fields - fields = document.custom_fields - new_field = CustomFieldValue(field=1, value=False) - fields.append(new_field) - document = await api_20.documents.custom_fields(document, fields) - assert isinstance(document.custom_fields, list) - for item in document.custom_fields: - assert isinstance(item, CustomFieldValue) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_20.documents.custom_fields(1337, fields) - - -class TestShareLinks: - """Share Links test cases.""" + to_delete = await getattr(p, mapping.resource)(5) + assert await to_delete.delete() + # must raise as we deleted 5 + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(5) - async def test_controller(self, api_20: Paperless): - """Test controller.""" - assert isinstance(api_20.share_links, ShareLinksController) - # test mixins - assert hasattr(api_20.share_links, "list") - assert hasattr(api_20.share_links, "get") - assert hasattr(api_20.share_links, "iterate") - assert hasattr(api_20.share_links, "one") - assert hasattr(api_20.share_links, "create") - assert hasattr(api_20.share_links, "update") - assert hasattr(api_20.share_links, "delete") - async def test_list(self, api_20: Paperless): - """Test list.""" - items = await api_20.share_links.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_20: Paperless): - """Test get.""" - results = await api_20.share_links.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, ShareLink) - - async def test_iterate(self, api_20: Paperless): - """Test iterate.""" - async for item in api_20.share_links.iterate(): - assert isinstance(item, ShareLink) - - async def test_one(self, api_20: Paperless): - """Test one.""" - item = await api_20.share_links.one(1) +@pytest.mark.parametrize( + "mapping", + [CONFIG_MAP], + scope="class", +) +# test models/custom_fields.py +# test models/share_links.py +class TestConfig: + """Config test cases.""" + + async def test_helper(self, p: Paperless, mapping: ResourceTestMapping): + """Test helper.""" + assert hasattr(p, mapping.resource) + assert isinstance(getattr(p, mapping.resource), mapping.helper_cls) + assert helper_mixins.CallableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.DraftableMixin not in mapping.helper_cls.__bases__ + assert helper_mixins.IterableMixin not in mapping.helper_cls.__bases__ + + async def test_model(self, mapping: ResourceTestMapping): + """Test model.""" + assert model_mixins.DeletableMixin not in mapping.model_cls.__bases__ + assert model_mixins.MatchingFieldsMixin not in mapping.model_cls.__bases__ + assert model_mixins.SecurableMixin not in mapping.model_cls.__bases__ + assert model_mixins.UpdatableMixin not in mapping.model_cls.__bases__ + + async def test_call(self, p: Paperless, mapping: ResourceTestMapping): + """Test call.""" + item = await getattr(p, mapping.resource)(1) assert item - assert isinstance(item, ShareLink) + assert isinstance(item, mapping.model_cls) # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_20.share_links.one(1337) - - async def test_create(self, api_20: Paperless): - """Test create.""" - new_document = 1 - new_expiration = datetime.datetime.now() + datetime.timedelta(30) - new_file_version = ShareLinkFileVersion.ORIGINAL - to_create = ShareLinkPost( - document=new_document, - expiration=new_expiration, - file_version=new_file_version, - ) - created = await api_20.share_links.create(to_create) - assert isinstance(created, ShareLink) - assert created.id == 6 - assert isinstance(created.expiration, datetime.datetime) - assert created.file_version == ShareLinkFileVersion.ORIGINAL - - async def test_udpate(self, api_20: Paperless): - """Test update.""" - new_expiration = None - to_update = await api_20.share_links.one(6) - to_update.expiration = new_expiration - updated = await api_20.share_links.update(to_update) - assert isinstance(updated, ShareLink) - assert not updated.expiration - - async def test_delete(self, api_20: Paperless): - """Test delete.""" - to_delete = await api_20.share_links.one(6) - deleted = await api_20.share_links.delete(to_delete) - assert deleted - # must raise as we deleted 6 - with pytest.raises(HTTPNotFound): - await api_20.share_links.one(6) + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(1337) diff --git a/tests/test_paperless_2_3_0.py b/tests/test_paperless_2_3_0.py index df12b82..a3057a5 100644 --- a/tests/test_paperless_2_3_0.py +++ b/tests/test_paperless_2_3_0.py @@ -1,204 +1,119 @@ """Paperless v2.3.0 tests.""" import pytest -from aiohttp.web_exceptions import HTTPNotFound - -from pypaperless import Paperless -from pypaperless.const import PaperlessFeature -from pypaperless.controllers import ( - WorkflowActionsController, - WorkflowsController, - WorkflowTriggersController, -) -from pypaperless.controllers.base import ResultPage -from pypaperless.models import Workflow, WorkflowAction, WorkflowTrigger -from pypaperless.models.workflows import ( - WorkflowActionType, - WorkflowTriggerSource, - WorkflowTriggerType, -) + +import pypaperless.models.workflows as wf_helpers +from pypaperless import Paperless, PaperlessSession +from pypaperless.exceptions import RequestException +from pypaperless.models import Page, WorkflowAction, WorkflowTrigger +from pypaperless.models.mixins import helpers as helper_mixins +from pypaperless.models.mixins import models as model_mixins + +from . import WORKFLOW_MAP, ResourceTestMapping + +# mypy: ignore-errors +# pylint: disable=protected-access,redefined-outer-name + + +@pytest.fixture(scope="function") +async def p(api_23) -> Paperless: + """Yield version for this test case.""" + yield api_23 +# test api.py with extra workflows endpoint class TestBeginPaperless: """Paperless v2.3.0 test cases.""" - async def test_init(self, api_23: Paperless): + async def test_init(self, p: Paperless): """Test init.""" - assert api_23._token - assert api_23._request_opts - assert not api_23._session - # test properties - assert api_23.url - assert api_23.is_initialized - - async def test_features(self, api_23: Paperless): - """Test features.""" - # basic class has no features - assert PaperlessFeature.CONTROLLER_STORAGE_PATHS in api_23.features - assert PaperlessFeature.FEATURE_DOCUMENT_NOTES in api_23.features - assert PaperlessFeature.CONTROLLER_SHARE_LINKS in api_23.features - assert PaperlessFeature.CONTROLLER_CUSTOM_FIELDS in api_23.features - assert PaperlessFeature.CONTROLLER_WORKFLOWS in api_23.features - assert ( - PaperlessFeature.CONTROLLER_CONSUMPTION_TEMPLATES not in api_23.features - ) # got removed again - assert api_23.storage_paths - assert api_23.custom_fields - assert api_23.share_links - assert api_23.workflows - assert api_23.workflow_actions - assert api_23.workflow_triggers - assert not api_23.consumption_templates # got removed again - - async def test_enums(self): - """Test enums.""" - assert WorkflowActionType(999) == WorkflowActionType.UNKNOWN - assert WorkflowTriggerSource(999) == WorkflowTriggerSource.UNKNOWN - assert WorkflowTriggerType(999) == WorkflowTriggerType.UNKNOWN - - + assert isinstance(p._session, PaperlessSession) + assert p.host_version == "2.3.0" + assert p.is_initialized + assert isinstance(p.local_resources, set) + assert isinstance(p.remote_resources, set) + + async def test_resources(self, p: Paperless): + """Test resources.""" + assert p.config.is_available + assert p.correspondents.is_available + assert p.custom_fields.is_available + assert p.document_types.is_available + assert p.documents.is_available + assert p.groups.is_available + assert p.mail_accounts.is_available + assert p.mail_rules.is_available + assert p.saved_views.is_available + assert p.share_links.is_available + assert p.storage_paths.is_available + assert p.tags.is_available + assert p.users.is_available + assert p.workflows.is_available + + +@pytest.mark.parametrize( + "mapping", + [WORKFLOW_MAP], + scope="class", +) +# test models/workflows.py class TestWorkflows: - """Workflows test cases.""" - - async def test_controller(self, api_23: Paperless): - """Test controller.""" - assert isinstance(api_23.workflows, WorkflowsController) - # test mixins - assert hasattr(api_23.workflows, "list") - assert hasattr(api_23.workflows, "get") - assert hasattr(api_23.workflows, "iterate") - assert hasattr(api_23.workflows, "one") - assert not hasattr(api_23.workflows, "create") - assert not hasattr(api_23.workflows, "update") - assert not hasattr(api_23.workflows, "delete") - - async def test_list(self, api_23: Paperless): - """Test list.""" - items = await api_23.workflows.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_23: Paperless): - """Test get.""" - results = await api_23.workflows.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, Workflow) - - async def test_iterate(self, api_23: Paperless): - """Test iterate.""" - async for item in api_23.workflows.iterate(): - assert isinstance(item, Workflow) - - async def test_one(self, api_23: Paperless): - """Test one.""" - item = await api_23.workflows.one(1) - assert item - assert isinstance(item, Workflow) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_23.workflows.one(1337) - - -class TestWorkflowsAction: - """Workflow Actions test cases.""" - - async def test_controller(self, api_23: Paperless): - """Test controller.""" - assert isinstance(api_23.workflow_actions, WorkflowActionsController) - # test mixins - assert hasattr(api_23.workflow_actions, "list") - assert hasattr(api_23.workflow_actions, "get") - assert hasattr(api_23.workflow_actions, "iterate") - assert hasattr(api_23.workflow_actions, "one") - assert not hasattr(api_23.workflow_actions, "create") - assert not hasattr(api_23.workflow_actions, "update") - assert not hasattr(api_23.workflow_actions, "delete") - - async def test_list(self, api_23: Paperless): - """Test list.""" - items = await api_23.workflow_actions.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_23: Paperless): - """Test get.""" - results = await api_23.workflow_actions.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, WorkflowAction) - - async def test_iterate(self, api_23: Paperless): - """Test iterate.""" - async for item in api_23.workflow_actions.iterate(): - assert isinstance(item, WorkflowAction) - - async def test_one(self, api_23: Paperless): - """Test one.""" - item = await api_23.workflow_actions.one(1) - assert item - assert isinstance(item, WorkflowAction) - # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_23.workflow_actions.one(1337) - - -class TestWorkflowTriggers: - """Workflow Triggers test cases.""" - - async def test_controller(self, api_23: Paperless): - """Test controller.""" - assert isinstance(api_23.workflow_triggers, WorkflowTriggersController) - # test mixins - assert hasattr(api_23.workflow_triggers, "list") - assert hasattr(api_23.workflow_triggers, "get") - assert hasattr(api_23.workflow_triggers, "iterate") - assert hasattr(api_23.workflow_triggers, "one") - assert not hasattr(api_23.workflow_triggers, "create") - assert not hasattr(api_23.workflow_triggers, "update") - assert not hasattr(api_23.workflow_triggers, "delete") - - async def test_list(self, api_23: Paperless): - """Test list.""" - items = await api_23.workflow_triggers.list() - assert isinstance(items, list) - assert len(items) > 0 - for item in items: - assert isinstance(item, int) - - async def test_get(self, api_23: Paperless): - """Test get.""" - results = await api_23.workflow_triggers.get() - assert isinstance(results, ResultPage) - assert results.current_page == 1 - assert not results.next_page # there is 1 page in sample data - assert results.last_page == 1 # there is 1 page in sample data - assert isinstance(results.items, list) - for item in results.items: - assert isinstance(item, WorkflowTrigger) - - async def test_iterate(self, api_23: Paperless): - """Test iterate.""" - async for item in api_23.workflow_triggers.iterate(): - assert isinstance(item, WorkflowTrigger) - - async def test_one(self, api_23: Paperless): - """Test one.""" - item = await api_23.workflow_triggers.one(1) + """Read only resources test cases.""" + + async def test_helper(self, p: Paperless, mapping: ResourceTestMapping): + """Test helper.""" + assert hasattr(p, mapping.resource) + assert isinstance(getattr(p, mapping.resource), mapping.helper_cls) + assert helper_mixins.CallableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.DraftableMixin not in mapping.helper_cls.__bases__ + assert helper_mixins.IterableMixin in mapping.helper_cls.__bases__ + assert helper_mixins.SecurableMixin not in mapping.helper_cls.__bases__ + # test sub helpers + assert isinstance(p.workflows.actions, wf_helpers.WorkflowActionHelper) + assert isinstance(p.workflows.triggers, wf_helpers.WorkflowTriggerHelper) + + async def test_model(self, mapping: ResourceTestMapping): + """Test model.""" + for model_cls in ( + mapping.model_cls, + WorkflowAction, + WorkflowTrigger, + ): + assert model_mixins.DeletableMixin not in model_cls.__bases__ + assert model_mixins.SecurableMixin not in model_cls.__bases__ + assert model_mixins.UpdatableMixin not in model_cls.__bases__ + + matching = model_mixins.MatchingFieldsMixin in model_cls.__bases__ + if model_cls is WorkflowTrigger: + assert matching + else: + assert not matching + + async def test_pages(self, p: Paperless, mapping: ResourceTestMapping): + """Test pages.""" + page = await anext(aiter(getattr(p, mapping.resource).pages(1))) + assert isinstance(page, Page) + assert isinstance(page.items, list) + for item in page.items: + assert isinstance(item, mapping.model_cls) + + async def test_iter(self, p: Paperless, mapping: ResourceTestMapping): + """Test iter.""" + async for item in getattr(p, mapping.resource): + assert isinstance(item, mapping.model_cls) + + async def test_call(self, p: Paperless, mapping: ResourceTestMapping): + """Test call.""" + item = await getattr(p, mapping.resource)(1) assert item - assert isinstance(item, WorkflowTrigger) + assert isinstance(item, mapping.model_cls) # must raise as 1337 doesn't exist - with pytest.raises(HTTPNotFound): - await api_23.workflow_triggers.one(1337) + with pytest.raises(RequestException): + await getattr(p, mapping.resource)(1337) + # check underlying model lists + assert isinstance(item.actions, list) + for a in item.actions: + assert isinstance(a, WorkflowAction) + assert isinstance(item.triggers, list) + for t in item.triggers: + assert isinstance(t, WorkflowTrigger) diff --git a/tests/test_paperless_common.py b/tests/test_paperless_common.py index bfeef2a..2babd64 100644 --- a/tests/test_paperless_common.py +++ b/tests/test_paperless_common.py @@ -1,117 +1,210 @@ """Paperless common tests.""" -from dataclasses import dataclass +from dataclasses import dataclass, fields from datetime import date, datetime from enum import Enum import pytest -from pypaperless import Paperless -from pypaperless.errors import BadRequestException, ControllerConfusion, DataNotExpectedException -from pypaperless.util import ( - create_url_from_input, - dataclass_from_dict, - dataclass_to_dict, - update_dataclass, +from pypaperless import Paperless, PaperlessSession +from pypaperless.const import PaperlessResource +from pypaperless.exceptions import ( + AuthentificationRequired, + BadJsonResponse, + DraftNotSupported, + JsonResponseWithError, + RequestException, ) -from tests.const import PAPERLESS_TEST_REQ_OPTS, PAPERLESS_TEST_TOKEN, PAPERLESS_TEST_URL +from pypaperless.models import Page +from pypaperless.models.base import HelperBase, PaperlessModel +from pypaperless.models.common import ( + CustomFieldType, + MatchingAlgorithmType, + ShareLinkFileVersionType, + TaskStatusType, + WorkflowActionType, + WorkflowTriggerSourceType, + WorkflowTriggerType, +) +from pypaperless.models.mixins import helpers +from pypaperless.models.utils import dict_value_to_object, object_to_dict_value +from tests.const import PAPERLESS_TEST_TOKEN, PAPERLESS_TEST_URL + +from . import PaperlessSessionMock + +# mypy: ignore-errors +# pylint: disable=protected-access class TestPaperless: """Paperless common test cases.""" - async def test_init(self, api: Paperless): + async def test_init(self, api_obj: Paperless): """Test init.""" - await api.initialize() - assert api.is_initialized - await api.close() + await api_obj.initialize() + assert api_obj.is_initialized + await api_obj.close() - async def test_context(self, api: Paperless): + async def test_context(self, api_obj: Paperless): """Test context.""" - async with api: - assert api.is_initialized - - async def test_confusion(self, api: Paperless): - """Test confusion.""" - # it is a very specific case and shouldn't ever happen - # set a version which doesn't exist in the router patchwork - api.version = "999.99.99" - # on that version PyPaperless expects a WorkflowsController (999 > 2.3.0) - # but the router will fall back to version 0.0.0 data - # this will result in an exception, or ControllerConfusion - with pytest.raises(ControllerConfusion): - async with api: - # boom - pass + async with api_obj: + assert api_obj.is_initialized - async def test_requests(self, api: Paperless): - """Test requests.""" - # request_json - with pytest.raises(BadRequestException): - await api.request_json("get", "/_bad_request/") - with pytest.raises(DataNotExpectedException): - await api.request_json("get", "/_no_json/") - # request_file - with pytest.raises(BadRequestException): - await api.request_file("get", "/_bad_request/") - - async def test_generate_request(self): + async def test_auth_missing(self): + """Test auth missing.""" + with pytest.raises(AuthentificationRequired): + api = Paperless() # noqa + + async def test_request(self): """Test generate request.""" - # we need to use an unmocked generate_request() method + # we need to use an unmocked PaperlessSession.request() method # simply don't initialize Paperless and everything will be fine api = Paperless( PAPERLESS_TEST_URL, PAPERLESS_TEST_TOKEN, - request_opts=PAPERLESS_TEST_REQ_OPTS, ) - async with api.generate_request("get", "https://example.org") as res: + async with api.request("get", "https://example.org") as res: assert res.status # last but not least, we test sending a form to test the converter form_data = { - "string": "Hello Bytes!", - "bytes": b"Hello String!", - "int": 23, - "float": 13.37, - "list": [1, 1, 2, 3, 5, 8, 13], - "dict": {"str": "str", "int": 2}, + "none_field": None, + "str_field": "Hello Bytes!", + "bytes_field": b"Hello String!", + "tuple_field": (b"Document Content", "filename.pdf"), + "int_field": 23, + "float_field": 13.37, + "int_list": [1, 1, 2, 3, 5, 8, 13], + "dict_field": {"dict_str_field": "str", "dict_int_field": 2}, } - async with api.generate_request("post", "https://example.org", form=form_data) as res: + async with api.request("post", "https://example.org", form=form_data) as res: assert res.status + # test non-existing request + with pytest.raises(RequestException): + async with api.request("get", "does-not-exist.example") as res: + pass + + # session is still open + await api.close() + + async def test_request_json(self, api_obj: Paperless): + """Test requests.""" + # test 400 bad request with error payload + with pytest.raises(JsonResponseWithError): + await api_obj.request_json( + "get", + "/test/http/400/", + params={ + "response_content": '{"error":"sample message"}', + "content_type": "application/json", + }, + ) + + # test 200 ok with wrong content type + with pytest.raises(BadJsonResponse): + await api_obj.request_json( + "get", + "/test/http/200/", + params={ + "response_content": '{"error":"sample message"}', + "content_type": "text/plain", + }, + ) + + # test 200 ok with correct content type, but no json payload + with pytest.raises(BadJsonResponse): + await api_obj.request_json( + "get", + "/test/http/200/", + params={ + "response_content": "test 1337 5 23 42 1337", + "content_type": "application/json", + }, + ) + async def test_create_url(self): """Test create url util.""" + create_url = PaperlessSession._create_base_url + # test default ssl - url = create_url_from_input("hostname") + url = create_url("hostname") assert url.host == "hostname" assert url.port == 443 - # test if api-path is added - assert url.name == "api" - - # test full url string - assert f"{url}" == "https://hostname/api" - # test enforce http - url = create_url_from_input("http://hostname") + url = create_url("http://hostname") assert url.port == 80 # test non-http scheme - url = create_url_from_input("ftp://hostname") + url = create_url("ftp://hostname") assert url.scheme == "https" # should be https even on just setting a port number - url = create_url_from_input("hostname:80") + url = create_url("hostname:80") assert url.scheme == "https" # test api/api url - url = create_url_from_input("hostname/api/api/") + url = create_url("hostname/api/api/") assert f"{url}" == "https://hostname/api/api" - # test with path and check if "api" is added - url = create_url_from_input("hostname/path/to/paperless") - assert f"{url}" == "https://hostname/path/to/paperless/api" + async def test_generate_api_token(self, api_obj: Paperless): + """Test generate api token.""" + test_token = "abcdef1234567890" + + session = PaperlessSessionMock(PAPERLESS_TEST_URL, "") + + # test successful token creation + session.params = { + "response_content": f'{{"token":"{test_token}"}}', + } + token = await api_obj.generate_api_token( + PAPERLESS_TEST_URL, "test-user", "not-so-secret-password", session + ) + assert token == test_token + + # test token creation with wrong json answer + with pytest.raises(BadJsonResponse): + session.params = { + "response_content": '{"bla":"any string"}', + } + token = await api_obj.generate_api_token( + PAPERLESS_TEST_URL, "test-user", "not-so-secret-password", session + ) + + # test error 400 + with pytest.raises(JsonResponseWithError): + session.params = { + "response_content": '{"non_field_errors":["Unable to log in."]}', + "status": "400", + } + token = await api_obj.generate_api_token( + PAPERLESS_TEST_URL, "test-user", "not-so-secret-password", session + ) + + # general exception + with pytest.raises(Exception): + session.params = { + "response_content": "no json", + "status": "500", + } + token = await api_obj.generate_api_token( + PAPERLESS_TEST_URL, "test-user", "not-so-secret-password", session + ) + + async def test_types(self): + """Test types.""" + never_str = "!never_existing_type!" + never_int = 99952342 + assert PaperlessResource(never_str) == PaperlessResource.UNKNOWN + assert CustomFieldType(never_str) == CustomFieldType.UNKNOWN + assert MatchingAlgorithmType(never_int) == MatchingAlgorithmType.UNKNOWN + assert ShareLinkFileVersionType(never_str) == ShareLinkFileVersionType.UNKNOWN + assert TaskStatusType(never_str) == TaskStatusType.UNKNOWN + assert WorkflowActionType(never_int) == WorkflowActionType.UNKNOWN + assert WorkflowTriggerType(never_int) == WorkflowTriggerType.UNKNOWN + assert WorkflowTriggerSourceType(never_int) == WorkflowTriggerSourceType.UNKNOWN async def test_dataclass_conversion(self): """Test dataclass utils.""" @@ -124,7 +217,7 @@ class _Status(Enum): UNKNOWN = -1 @classmethod - def _missing_(cls: type, value: object): # noqa ARG003 + def _missing_(cls: type, *_: object): """Set default.""" return cls.UNKNOWN @@ -149,6 +242,7 @@ class _Person: is_deleted: bool status: _Status file: bytes + meta: dict[str, str] raw_data = { "name": "Lee Tobi, Sajangnim", @@ -169,9 +263,19 @@ class _Person: ], "status": 1, "file": b"5-23-42-666-0815-1337", + "meta": {"hairs": "blonde", "eyes": "blue", "loves": "Python"}, } - res = dataclass_from_dict(_Person, raw_data) + data = { + field.name: dict_value_to_object( + f"_Person.{__name__}.{field.name}", + raw_data.get(field.name), + field.type, + field.default, + ) + for field in fields(_Person) + } + res = _Person(**data) assert isinstance(res.name, str) assert isinstance(res.age, int) @@ -187,23 +291,83 @@ class _Person: assert isinstance(res.status, _Status) assert isinstance(res.file, bytes) - update_dataclass( - res, - { - "deleted": datetime.now(), - "is_deleted": True, - }, - ) + # back conversion + back = {field.name: object_to_dict_value(getattr(res, field.name)) for field in fields(res)} - assert isinstance(res.deleted, datetime) - assert res.is_deleted + assert isinstance(back["friends"][0]["age"], int) # was str in the source dict + assert isinstance(back["meta"], dict) - assert res.status == _Status.ACTIVE - update_dataclass(res, {"status": 100}) - assert isinstance(res.status, _Status) - assert res.status == _Status.UNKNOWN + async def test_pages_object(self, api_obj): + """Test pages.""" - # back conversion - back = dataclass_to_dict(res) + @dataclass(init=False) + class TestResource(PaperlessModel): + """Test Resource.""" - assert isinstance(back["friends"][0]["age"], int) # was str in the source dict + id: int | None = None + + data = { + "count": 0, + "current_page": 1, + "page_size": 25, + "next": "any.url", + "previous": None, + "all": [], + "results": [], + } + + for i in range(1, 101): + data["count"] += 1 + data["all"].append(i) + data["results"].append({"id": i}) + + page = Page.create_with_data(api_obj, data=data, fetched=True) + page._resource_cls = TestResource + + assert isinstance(page, Page) + assert page.current_count == 100 + for item in page: + assert isinstance(item, TestResource) + + # check first page + assert not page.has_previous_page + assert page.has_next_page + assert not page.is_last_page + assert page.last_page == 4 + assert page.next_page == 2 + assert page.previous_page is None + + # check inner page + page.previous = "any.url" + page.current_page = 3 + + assert page.previous_page is not None + assert page.next_page is not None + assert not page.is_last_page + + # check last page + page.next = None + page.current_page = 4 + + assert page.next_page is None + assert page.is_last_page + + async def test_draft_exc(self, api_00: Paperless): + """Test draft not supported.""" + + @dataclass(init=False) + class TestResource(PaperlessModel): + """Test Resource.""" + + class TestHelper(HelperBase, helpers.DraftableMixin): + """Test Helper.""" + + _api_path = "any.url" + _resource = "test" + # draft_cls - we "forgot" to set a draft class, which will raise an exception ... + _resource_cls = TestResource + + helper = TestHelper(api_00) + with pytest.raises(DraftNotSupported): + # ... there it is + draft = helper.draft() # noqa diff --git a/tests/util/router.py b/tests/util/router.py index 2f5b147..191600f 100644 --- a/tests/util/router.py +++ b/tests/util/router.py @@ -1,28 +1,35 @@ """Simple router for faking Paperless routes.""" import datetime +import random import uuid +from copy import deepcopy from aiohttp.web_exceptions import HTTPBadRequest, HTTPNotFound from fastapi import FastAPI, Request, Response from tests.data.v0_0_0 import ( V0_0_0_CORRESPONDENTS, + V0_0_0_DOCUMENT_SUGGESTIONS, V0_0_0_DOCUMENT_TYPES, V0_0_0_DOCUMENTS, V0_0_0_DOCUMENTS_METADATA, + V0_0_0_DOCUMENTS_SEARCH, V0_0_0_GROUPS, V0_0_0_MAIL_ACCOUNTS, V0_0_0_MAIL_RULES, + V0_0_0_OBJECT_PERMISSIONS, V0_0_0_PATHS, V0_0_0_SAVED_VIEWS, V0_0_0_TAGS, V0_0_0_TASKS, + V0_0_0_TOKEN, V0_0_0_USERS, ) from tests.data.v1_8_0 import V1_8_0_PATHS, V1_8_0_STORAGE_PATHS from tests.data.v1_17_0 import V1_17_0_DOCUMENT_NOTES from tests.data.v2_0_0 import ( + V2_0_0_CONFIG, V2_0_0_CONSUMPTION_TEMPLATES, V2_0_0_CUSTOM_FIELDS, V2_0_0_PATHS, @@ -35,19 +42,25 @@ V2_3_0_WORKFLOWS, ) +# mypy: ignore-errors + PATCHWORK = { "0.0.0": { "PATHS": V0_0_0_PATHS, "CORRESPONDENTS": V0_0_0_CORRESPONDENTS, "DOCUMENTS": V0_0_0_DOCUMENTS, "DOCUMENTS_METADATA": V0_0_0_DOCUMENTS_METADATA, + "DOCUMENTS_SEARCH": V0_0_0_DOCUMENTS_SEARCH, + "DOCUMENTS_SUGGESTIONS": V0_0_0_DOCUMENT_SUGGESTIONS, "DOCUMENT_TYPES": V0_0_0_DOCUMENT_TYPES, "GROUPS": V0_0_0_GROUPS, "MAIL_ACCOUNTS": V0_0_0_MAIL_ACCOUNTS, "MAIL_RULES": V0_0_0_MAIL_RULES, + "OBJECT_PERMISSIONS": V0_0_0_OBJECT_PERMISSIONS, "SAVED_VIEWS": V0_0_0_SAVED_VIEWS, "TAGS": V0_0_0_TAGS, "TASKS": V0_0_0_TASKS, + "TOKEN": V0_0_0_TOKEN, "USERS": V0_0_0_USERS, }, "1.8.0": { @@ -61,6 +74,7 @@ }, "2.0.0": { "PATHS": V0_0_0_PATHS | V1_8_0_PATHS | V2_0_0_PATHS, + "CONFIG": V2_0_0_CONFIG, "CONSUMPTION_TEMPLATES": V2_0_0_CONSUMPTION_TEMPLATES, "CUSTOM_FIELDS": V2_0_0_CUSTOM_FIELDS, "SHARE_LINKS": V2_0_0_SHARE_LINKS, @@ -90,6 +104,19 @@ def _api_switcher(req: Request, key: str): FakePaperlessAPI = FastAPI() +# test http route with parameterable content, content type and status codes +@FakePaperlessAPI.get("/test/http/{status:int}/") +async def get_http_error(req: Request, status: int): + """Get bad request.""" + content = req.query_params.get("response_content", f"http error {status}") + content_type = req.query_params.get("content_type", "application/json") + return Response( + status_code=status, + content=content, + headers={"Content-Type": content_type}, + ) + + # index route @FakePaperlessAPI.get("/api/") async def get_index(req: Request): @@ -97,17 +124,17 @@ async def get_index(req: Request): return _api_switcher(req, "PATHS") -# bad request route -@FakePaperlessAPI.get("/_bad_request/") -async def get_bad_request(): - """Get bad request.""" - return Response(status_code=400) - - -@FakePaperlessAPI.get("/_no_json/") -async def get_no_json(): - """Get no JSON.""" - return Response(headers={"content-type": "text/text"}) +# token route +@FakePaperlessAPI.post("/api/token/") +async def post_token(req: Request): + """Post credentials to get new api token.""" + content = req.query_params.get("response_content", "empty") + status = req.query_params.get("status", 200) + return Response( + status_code=int(status), + content=content, + headers={"Content-Type": "application/json"}, + ) # static routes for special cases @@ -121,7 +148,7 @@ async def post_document(req: Request): data = _api_switcher(req, "DOCUMENTS") task_id = uuid.uuid4() payload.setdefault("title", f"New Document {task_id}") - payload.setdefault("created", datetime.datetime.now()) + payload.setdefault("created", datetime.datetime.now().isoformat()) payload.setdefault("correspondent", None) payload.setdefault("document_type", None) payload.setdefault("archive_serial_number", None) @@ -167,6 +194,16 @@ async def post_document(req: Request): return f"{task_id}" +@FakePaperlessAPI.get("/api/documents/next_asn/") +async def get_documents_next_asn(req: Request): + """Get documents next asn.""" + status = req.query_params.get("status", 200) + return Response( + status_code=int(status), + content=str(random.randint(1, 1337)), + ) + + @FakePaperlessAPI.get("/api/documents/{pk:int}/metadata/") async def get_documents_meta(req: Request, pk: int): """Get documents meta.""" @@ -178,6 +215,16 @@ async def get_documents_meta(req: Request, pk: int): return data +@FakePaperlessAPI.get("/api/documents/{pk:int}/suggestions/") +async def get_documents_suggestions(req: Request, pk: int): + """Get documents suggestions.""" + data = _api_switcher(req, "DOCUMENTS") + if not data["all"].count(pk): + raise HTTPNotFound() + data = _api_switcher(req, "DOCUMENTS_SUGGESTIONS") + return data + + @FakePaperlessAPI.get("/api/documents/{pk:int}/thumb/") @FakePaperlessAPI.get("/api/documents/{pk:int}/preview/") @FakePaperlessAPI.get("/api/documents/{pk:int}/download/") @@ -186,25 +233,33 @@ async def get_documents_files(req: Request, pk: int): data = _api_switcher(req, "DOCUMENTS") if not data["all"].count(pk): raise HTTPNotFound() - return b"This is a file." + return Response( + status_code=200, + content=b"This is a file.", + headers={ + "Content-Type": "application/pdf", + "Content-Disposition": "attachment;any_filename.pdf", + }, + ) @FakePaperlessAPI.get("/api/documents/{pk:int}/notes/") +@FakePaperlessAPI.post("/api/documents/{pk:int}/notes/") async def get_documents_notes( - req: Request, pk: int # pylint: disable=unused-argument # noqa: ARG001 + req: Request, + pk: int, # pylint: disable=unused-argument # noqa: ARG001 ): """Get documents notes.""" data = _api_switcher(req, "DOCUMENT_NOTES") return data -@FakePaperlessAPI.post("/api/documents/{pk:int}/notes/") @FakePaperlessAPI.delete("/api/documents/{pk:int}/notes/") async def post_delete_documents_notes( - req: Request, pk: int # pylint: disable=unused-argument # noqa: ARG001 + pk: int, # pylint: disable=unused-argument # noqa: ARG001 ): """Get documents notes.""" - return True + return Response(status_code=204) @FakePaperlessAPI.get("/api/tasks/") @@ -224,9 +279,18 @@ async def get_tasks(req: Request): @FakePaperlessAPI.get("/api/{resource}/") async def get_resource(req: Request, resource: str): """Get resource.""" - data = _api_switcher(req, resource.upper()) - if resource == "tasks": - return {} + if resource == "documents": + query = req.query_params.get("query", None) + more_like = req.query_params.get("more_like_id", None) + if query or more_like: + data = _api_switcher(req, f"{resource}_search".upper()) + return data + + data = deepcopy(_api_switcher(req, resource.upper())) + + if req.query_params.get("full_perms", "false") == "true": + data["permissions"] = _api_switcher(req, "OBJECT_PERMISSIONS") + return data @@ -245,21 +309,25 @@ async def post_resource(req: Request, resource: str): async def get_resource_item(req: Request, resource: str, item: int): """Get resource item.""" data = _api_switcher(req, resource.upper()) - for result in data["results"]: + # tasks is list, else dict + iterable = data["results"] if isinstance(data, dict) else data + for result in iterable: if result["id"] == item: - return result + found = deepcopy(result) + if req.query_params.get("full_perms", "false") == "true": + found["permissions"] = _api_switcher(req, "OBJECT_PERMISSIONS") + return found raise HTTPNotFound() -@FakePaperlessAPI.put("/api/{resource}/{item:int}/") -async def put_resource_item(req: Request, resource: str, item: int): - """Put resource item.""" - payload = await req.json() +@FakePaperlessAPI.delete("/api/{resource}/{item:int}/") +async def delete_resource_item(req: Request, resource: str, item: int): + """Delete resource item.""" data = _api_switcher(req, resource.upper()) - for result in data["results"]: + for idx, result in enumerate(data["results"]): if result["id"] == item: - result = payload # noqa: PLW2901 - return result + data["results"].pop(idx) + return Response(status_code=204) raise HTTPNotFound() @@ -276,12 +344,13 @@ async def patch_resource_item(req: Request, resource: str, item: int): raise HTTPNotFound() -@FakePaperlessAPI.delete("/api/{resource}/{item:int}/") -async def delete_resource_item(req: Request, resource: str, item: int): - """Delete resource item.""" +@FakePaperlessAPI.put("/api/{resource}/{item:int}/") +async def put_resource_item(req: Request, resource: str, item: int): + """Put resource item.""" + payload = await req.json() data = _api_switcher(req, resource.upper()) - for idx, result in enumerate(data["results"]): + for result in data["results"]: if result["id"] == item: - data["results"].pop(idx) - return Response(status_code=204) + result = payload # noqa: PLW2901 + return result raise HTTPNotFound()