Compare commits

..
Author SHA1 Message Date
stumpylog 08ea8b90a4 Refactor: split views.py into documents/views/ module
documents/views.py suffers the same problem as serialisers.py: a
single 5,395-line file covering every REST resource in the app.
Restructures it into a package, one module per domain area, matching
the same layout used for documents/serialisers/.

paperless/urls.py and paperless_mail/views.py are updated to import
from the new submodules. Test files that imported or mock.patch'd
documents.views or documents.serialisers symbols directly are updated
to point at the correct submodule for both splits. The mypy and
pyrefly baselines are updated to reference the new file paths, and a
stale comment in chat_qa.j2 pointing at the old location of
_get_llm_output_language is corrected.
2026-10-03 14:34:19 -07:00
stumpylog 12fa696c73 Refactor: extract serialisers.py into a structured module
documents/serialisers.py had grown into a single file with every
serialiser for the app, making it hard to navigate and putting
unrelated domains behind one import path.

Moves serialisers into documents/serialisers/ as a package, grouping
them into logical per-domain files instead. paperless_mail/serialisers.py
is updated to import from the new submodules.
2026-10-03 14:03:33 -07:00
207 changed files with 44731 additions and 53276 deletions

No files matched your search

+3 -3
View File
@@ -80,7 +80,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.backend_changed == 'true' if: needs.changes.outputs.backend_changed == 'true'
name: "Python ${{ matrix.python-version }}" name: "Python ${{ matrix.python-version }}"
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
strategy: strategy:
@@ -114,7 +114,7 @@ jobs:
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
- name: Configure ImageMagick - name: Configure ImageMagick
run: | run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-7/policy.xml sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
- name: Install Python dependencies - name: Install Python dependencies
env: env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }} PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
@@ -158,7 +158,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.backend_changed == 'true' if: needs.changes.outputs.backend_changed == 'true'
name: Check project typing name: Check project typing
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
env: env:
+3 -3
View File
@@ -24,10 +24,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- runner: ubuntu-26.04 - runner: ubuntu-24.04
arch: amd64 arch: amd64
platform: linux/amd64 platform: linux/amd64
- runner: ubuntu-26.04-arm - runner: ubuntu-24.04-arm
arch: arm64 arch: arm64
platform: linux/arm64 platform: linux/arm64
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
@@ -163,7 +163,7 @@ jobs:
archive: false archive: false
merge-and-push: merge-and-push:
name: Merge and Push Manifest name: Merge and Push Manifest
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
needs: build-arch needs: build-arch
if: needs.build-arch.outputs.should-push == 'true' if: needs.build-arch.outputs.should-push == 'true'
environment: image-publishing environment: image-publishing
+2 -2
View File
@@ -65,7 +65,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.docs_changed == 'true' if: needs.changes.outputs.docs_changed == 'true'
name: Build Documentation name: Build Documentation
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Checkout - name: Checkout
@@ -102,7 +102,7 @@ jobs:
name: Deploy Documentation name: Deploy Documentation
needs: [changes, build] needs: [changes, build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_changed == 'true' if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_changed == 'true'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
pages: write pages: write
id-token: write id-token: write
+6 -6
View File
@@ -72,7 +72,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
name: Install Dependencies name: Install Dependencies
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -104,7 +104,7 @@ jobs:
name: Lint name: Lint
needs: [changes, install-dependencies] needs: [changes, install-dependencies]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -137,7 +137,7 @@ jobs:
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: [changes, install-dependencies] needs: [changes, install-dependencies]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
strategy: strategy:
@@ -188,10 +188,10 @@ jobs:
name: E2E Tests name: E2E Tests
needs: [changes, install-dependencies] needs: [changes, install-dependencies]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
container: mcr.microsoft.com/playwright:v1.62.1-resolute container: mcr.microsoft.com/playwright:v1.62.1-noble
env: env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
@@ -246,7 +246,7 @@ jobs:
name: Frontend Build name: Frontend Build
needs: [changes, unit-tests, e2e-tests] needs: [changes, unit-tests, e2e-tests]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
steps: steps:
+5 -5
View File
@@ -14,7 +14,7 @@ permissions: {}
jobs: jobs:
wait-for-docker: wait-for-docker:
name: Wait for Docker Build name: Wait for Docker Build
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
checks: read checks: read
statuses: read statuses: read
@@ -30,7 +30,7 @@ jobs:
build-release: build-release:
name: Build Release name: Build Release
needs: wait-for-docker needs: wait-for-docker
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -73,7 +73,7 @@ jobs:
timeout-minutes: 12 timeout-minutes: 12
uses: $/.github/actions/apt-install uses: $/.github/actions/apt-install
with: with:
packages: gettext libleptonica6 packages: gettext liblept5
# ---- Build Documentation ---- # ---- Build Documentation ----
- name: Build documentation - name: Build documentation
env: env:
@@ -145,7 +145,7 @@ jobs:
publish-release: publish-release:
name: Publish Release name: Publish Release
needs: build-release needs: build-release
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
@@ -197,7 +197,7 @@ jobs:
name: Append Changelog name: Append Changelog
needs: publish-release needs: publish-release
if: needs.publish-release.outputs.prerelease == 'false' if: needs.publish-release.outputs.prerelease == 'false'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
+2 -2
View File
@@ -15,7 +15,7 @@ permissions:
jobs: jobs:
zizmor: zizmor:
name: Run zizmor name: Run zizmor
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
actions: read actions: read
@@ -29,7 +29,7 @@ jobs:
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
semgrep: semgrep:
name: Semgrep CE name: Semgrep CE
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
container: container:
image: semgrep/semgrep:1.155.0@sha256:cc869c685dcc0fe497c86258da9f205397d8108e56d21a86082ea4886e52784d image: semgrep/semgrep:1.155.0@sha256:cc869c685dcc0fe497c86258da9f205397d8108e56d21a86082ea4886e52784d
if: github.actor != 'dependabot[bot]' if: github.actor != 'dependabot[bot]'
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
cleanup-images: cleanup-images:
name: Cleanup Image Tags for ${{ matrix.primary-name }} name: Cleanup Image Tags for ${{ matrix.primary-name }}
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
environment: registry-maintenance environment: registry-maintenance
strategy: strategy:
fail-fast: false fail-fast: false
@@ -42,7 +42,7 @@ jobs:
cleanup-untagged-images: cleanup-untagged-images:
name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }} name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }}
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
needs: needs:
- cleanup-images - cleanup-images
environment: registry-maintenance environment: registry-maintenance
+1 -1
View File
@@ -21,7 +21,7 @@ on:
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
actions: read actions: read
contents: read contents: read
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
synchronize-with-crowdin: synchronize-with-crowdin:
name: Crowdin Sync name: Crowdin Sync
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
environment: translation-sync environment: translation-sync
steps: steps:
- name: Checkout - name: Checkout
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
# Note: peakoss/anti-slop does not support the `issues` event yet (all of its # Note: peakoss/anti-slop does not support the `issues` event yet (all of its
# issue inputs are still commented out upstream), so the checks that the PR Bot # issue inputs are still commented out upstream), so the checks that the PR Bot
# workflow gets from the action are implemented manually here. # workflow gets from the action are implemented manually here.
runs-on: ubuntu-slim runs-on: ubuntu-latest
permissions: permissions:
issues: write issues: write
steps: steps:
+2 -2
View File
@@ -4,7 +4,7 @@ on:
types: [opened] types: [opened]
jobs: jobs:
Anti-slop: Anti-slop:
runs-on: ubuntu-slim runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
issues: read issues: read
@@ -24,7 +24,7 @@ jobs:
ASLOP-PR-VERIFY ASLOP-PR-VERIFY
pr-bot: pr-bot:
name: Automated PR Bot name: Automated PR Bot
runs-on: ubuntu-slim runs-on: ubuntu-latest
# Runs after Anti-slop so the welcome comment can see whether the PR was closed # Runs after Anti-slop so the welcome comment can see whether the PR was closed
# instead of racing it. Still runs if that job fails, so labeling is not lost. # instead of racing it. Still runs if that job fails, so labeling is not lost.
needs: Anti-slop needs: Anti-slop
+1 -1
View File
@@ -12,7 +12,7 @@ permissions:
jobs: jobs:
pr_opened_or_reopened: pr_opened_or_reopened:
name: pr_opened_or_reopened name: pr_opened_or_reopened
runs-on: ubuntu-slim runs-on: ubuntu-24.04
permissions: permissions:
# write permission is required for autolabeler # write permission is required for autolabeler
pull-requests: write pull-requests: write
+5 -5
View File
@@ -9,7 +9,7 @@ jobs:
stale: stale:
name: 'Stale' name: 'Stale'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
@@ -34,7 +34,7 @@ jobs:
lock-threads: lock-threads:
name: 'Lock Old Threads' name: 'Lock Old Threads'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-26.04 runs-on: ubuntu-24.04
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
@@ -58,7 +58,7 @@ jobs:
close-answered-discussions: close-answered-discussions:
name: 'Close Answered Discussions' name: 'Close Answered Discussions'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-slim runs-on: ubuntu-24.04
permissions: permissions:
discussions: write discussions: write
steps: steps:
@@ -117,7 +117,7 @@ jobs:
close-outdated-discussions: close-outdated-discussions:
name: 'Close Outdated Discussions' name: 'Close Outdated Discussions'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-slim runs-on: ubuntu-24.04
permissions: permissions:
discussions: write discussions: write
steps: steps:
@@ -211,7 +211,7 @@ jobs:
close-unsupported-feature-requests: close-unsupported-feature-requests:
name: 'Close Unsupported Feature Requests' name: 'Close Unsupported Feature Requests'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-slim runs-on: ubuntu-24.04
permissions: permissions:
discussions: write discussions: write
steps: steps:
+1 -1
View File
@@ -8,7 +8,7 @@ env:
jobs: jobs:
generate-translate-strings: generate-translate-strings:
name: Generate Translation Strings name: Generate Translation Strings
runs-on: ubuntu-26.04 runs-on: ubuntu-latest
environment: translation-sync environment: translation-sync
permissions: permissions:
contents: write contents: write
+351 -351
View File
@@ -410,180 +410,179 @@ src/documents/search/_query.py:0: error: Argument 3 to "Term" has incompatible t
src/documents/search/_query.py:0: error: Argument 3 to "Term" has incompatible type "**dict[str, int | None]"; expected "bool" [arg-type] src/documents/search/_query.py:0: error: Argument 3 to "Term" has incompatible type "**dict[str, int | None]"; expected "bool" [arg-type]
src/documents/search/_query.py:0: error: Unexpected keyword argument "children" for "Node" [call-arg] src/documents/search/_query.py:0: error: Unexpected keyword argument "children" for "Node" [call-arg]
src/documents/search/_schema.py:0: error: Redundant cast to "Path" [redundant-cast] src/documents/search/_schema.py:0: error: Redundant cast to "Path" [redundant-cast]
src/documents/search/_schema.py:0: error: Unexpected keyword argument "fast" for "add_json_field" of "SchemaBuilder" [call-arg] src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "_meta" [attr-defined]
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "_meta" [attr-defined] src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined] src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined] src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined] src/documents/serialisers/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Argument "choices" to "ChoiceField" has incompatible type "type[FieldDataType]"; expected "Sequence[Any]" [arg-type] src/documents/serialisers/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Argument "default" to "MultipleChoiceField" has incompatible type "set[DocumentSource]"; expected "set[str | int] | set[str] | set[int] | Callable[[], set[str | int] | set[str] | set[int]] | _Empty | None" [arg-type] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Argument 1 of "get_value" is incompatible with supertype "rest_framework.fields.Field"; supertype defines the argument type as "Mapping[str, Any]" [override] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Argument 1 of "update" is incompatible with supertype "rest_framework.serializers.BaseSerializer"; supertype defines the argument type as "dict[str, Any]" [override] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Argument 1 to "__get__" of "BaseManager" has incompatible type "PrimaryKeyRelatedField[Any]"; expected "Options[Any] | None" [arg-type] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Argument 1 to "get_value_field_name" of "CustomFieldInstance" has incompatible type "str"; expected "FieldDataType" [arg-type] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Argument 1 to "list" has incompatible type "QuerySet[Document, TypedDict({'id': int, 'title': str, 'deleted_at': datetime | None})]"; expected "Iterable[dict[str, Any]]" [arg-type] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Expected iterable as variadic argument [misc] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Item "None" of "Any | None" has no attribute "name" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Item "None" of "Any | None" has no attribute "pk" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Item "list[str]" of "Any | list[str]" has no attribute "values_list" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Missing type arguments for generic type "Iterable" [type-arg]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Need type annotation for "permissions_dict" [var-annotated]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/base.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[Correspondent]", variable has type "type[Tag] | None") [assignment]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[DocumentType]", variable has type "type[Tag] | None") [assignment]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[StoragePath]", variable has type "type[Tag] | None") [assignment]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Argument 1 of "update" is incompatible with supertype "rest_framework.serializers.BaseSerializer"; supertype defines the argument type as "dict[str, Any]" [override]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Incompatible types in assignment (expression has type "type[SearchResultListSerializer]", base class "Meta" defined the type as "type[OwnedObjectListSerializer]") [assignment]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers/documents.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/documents.py:0: error: Skipping analyzing "auditlog.context": module is installed, but missing library stubs or py.typed marker [import-untyped]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/documents.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/documents.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/documents.py:0: error: Value of type "Any | None" is not indexable [index]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Argument "choices" to "ChoiceField" has incompatible type "type[FieldDataType]"; expected "Sequence[Any]" [arg-type]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Argument 1 of "get_value" is incompatible with supertype "rest_framework.fields.Field"; supertype defines the argument type as "Mapping[str, Any]" [override]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Argument 1 to "__get__" of "BaseManager" has incompatible type "PrimaryKeyRelatedField[Any]"; expected "Options[Any] | None" [arg-type]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Argument 1 to "get_value_field_name" of "CustomFieldInstance" has incompatible type "str"; expected "FieldDataType" [arg-type]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Expected iterable as variadic argument [misc]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Incompatible types in assignment (expression has type "PrimaryKeyRelatedField[Tag]", base class "Field" defined the type as "BaseSerializer[Any]") [assignment]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Incompatible types in assignment (expression has type "Sequence[str] | tuple[Lower]", variable has type "Sequence[str] | None") [assignment]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Item "None" of "CustomField | None" has no attribute "name" [union-attr]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Missing type arguments for generic type "Field" [type-arg]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Need type annotation for "document" [var-annotated]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any]") [index]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/metadata.py:0: error: Value of type "Any | None" is not indexable [index]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/saved_views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "PrimaryKeyRelatedField[Tag]", base class "Field" defined the type as "BaseSerializer[Any]") [assignment] src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "Sequence[str] | tuple[Lower]", variable has type "Sequence[str] | None") [assignment] src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "WorkflowActionWebhookSerializer", variable has type "WorkflowActionEmailSerializer") [assignment] src/documents/serialisers/saved_views.py:0: error: Value of type "Match[str] | None" is not indexable [index]
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[Correspondent]", variable has type "type[Tag] | None") [assignment] src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[DocumentType]", variable has type "type[Tag] | None") [assignment] src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[SearchResultListSerializer]", base class "Meta" defined the type as "type[OwnedObjectListSerializer]") [assignment] src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[StoragePath]", variable has type "type[Tag] | None") [assignment] src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr] src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr] src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr] src/documents/serialisers/sharing.py:0: error: Need type annotation for "documents" [var-annotated]
src/documents/serialisers.py:0: error: Item "None" of "Any | None" has no attribute "name" [union-attr] src/documents/serialisers/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Item "None" of "Any | None" has no attribute "pk" [union-attr] src/documents/serialisers/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Item "None" of "CustomField | None" has no attribute "name" [union-attr] src/documents/serialisers/tasks.py:0: error: Argument 1 to "list" has incompatible type "QuerySet[Document, TypedDict({'id': int, 'title': str, 'deleted_at': datetime | None})]"; expected "Iterable[dict[str, Any]]" [arg-type]
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr] src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr] src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr] src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr] src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr] src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr] src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
src/documents/serialisers.py:0: error: Item "list[str]" of "Any | list[str]" has no attribute "values_list" [union-attr] src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Missing type arguments for generic type "Field" [type-arg] src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Missing type arguments for generic type "Iterable" [type-arg] src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg] src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg] src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg] src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Need type annotation for "document" [var-annotated] src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Need type annotation for "documents" [var-annotated] src/documents/serialisers/workflows.py:0: error: Argument "default" to "MultipleChoiceField" has incompatible type "set[DocumentSource]"; expected "set[str | int] | set[str] | set[int] | Callable[[], set[str | int] | set[str] | set[int]] | _Empty | None" [arg-type]
src/documents/serialisers.py:0: error: Need type annotation for "permissions_dict" [var-annotated] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Skipping analyzing "auditlog.context": module is installed, but missing library stubs or py.typed marker [import-untyped] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any]") [index] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Value of type "Any | None" is not indexable [index] src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers.py:0: error: Value of type "Any | None" is not indexable [index] src/documents/serialisers/workflows.py:0: error: Incompatible types in assignment (expression has type "WorkflowActionWebhookSerializer", variable has type "WorkflowActionEmailSerializer") [assignment]
src/documents/serialisers.py:0: error: Value of type "Match[str] | None" is not indexable [index]
src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined] src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined]
src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined] src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined]
src/documents/signals/handlers.py:0: error: "Task[[int, int], Any]" has no attribute "delay_on_commit" [attr-defined] src/documents/signals/handlers.py:0: error: "Task[[int, int], Any]" has no attribute "delay_on_commit" [attr-defined]
@@ -1525,180 +1524,181 @@ src/documents/utils.py:0: error: Incompatible types in assignment (expression ha
src/documents/utils.py:0: error: Missing type arguments for generic type "CompletedProcess" [type-arg] src/documents/utils.py:0: error: Missing type arguments for generic type "CompletedProcess" [type-arg]
src/documents/validators.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/validators.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/versioning.py:0: error: Missing type arguments for generic type "Prefetch" [type-arg] src/documents/versioning.py:0: error: Missing type arguments for generic type "Prefetch" [type-arg]
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "filter_queryset" [attr-defined] src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "filter_queryset" [attr-defined]
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "get_queryset" [attr-defined] src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "get_queryset" [attr-defined]
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined] src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined] src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
src/documents/views.py:0: error: "Sequence[_SupportsHasPermission]" has no attribute "append" [attr-defined] src/documents/views/base.py:0: error: "get_serializer_context" undefined in superclass [misc]
src/documents/views.py:0: error: "get_serializer_context" undefined in superclass [misc] src/documents/views/base.py:0: error: "type[Model]" has no attribute "objects" [attr-defined]
src/documents/views.py:0: error: "object" has no attribute "apply_async" [attr-defined] src/documents/views/base.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
src/documents/views.py:0: error: "type[DocumentFilterSet]" has no attribute "declared_filters" [attr-defined] src/documents/views/base.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
src/documents/views.py:0: error: "type[Model]" has no attribute "objects" [attr-defined] src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument "filename" to "FileResponse" has incompatible type "str | Any | None"; expected "str" [arg-type] src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument "path" to "EmailAttachment" has incompatible type "Path | None"; expected "Path" [arg-type] src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type] src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type] src/documents/views/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type] src/documents/views/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type] src/documents/views/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/views.py:0: error: Argument 1 to "TaskSummarySerializer" has incompatible type "QuerySet[PaperlessTask@AnnotatedWith[TypedDict({'total_count': int, 'pending_count': int, 'success_count': int, 'failure_count': int, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})], TypedDict({'task_type': str, 'total_count': Any, 'pending_count': Any, 'success_count': Any, 'failure_count': Any, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})]"; expected "dict[str, Any] | None" [arg-type] src/documents/views/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type] src/documents/views/base.py:0: error: Missing type arguments for generic type "list" [type-arg]
src/documents/views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type] src/documents/views/bulk_edit.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
src/documents/views.py:0: error: Argument 1 to "paginate_queryset" of "GenericAPIView" has incompatible type "TantivyRelevanceList"; expected "QuerySet[Any, _MT_co] | Sequence[_MT_co]" [arg-type] src/documents/views/bulk_edit.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
src/documents/views.py:0: error: Argument 1 to "permitted_document_ids" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type] src/documents/views/bulk_edit.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type]
src/documents/views.py:0: error: Argument 1 to "permitted_object_ids" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type] src/documents/views/bulk_edit.py:0: error: Argument 1 to "permitted_object_ids" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type]
src/documents/views.py:0: error: Argument 2 to "match_correspondents" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument 2 to "match_document_types" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument 2 to "match_storage_paths" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument 2 to "match_tags" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Argument 3 to "autocomplete" of "TantivyBackend" has incompatible type "User | AnonymousUser | None"; expected "AbstractUser | None" [arg-type] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[ArchiveOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[OriginalsOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/bulk_edit.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/chat.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: "type[DocumentFilterSet]" has no attribute "declared_filters" [attr-defined]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Argument "path" to "EmailAttachment" has incompatible type "Path | None"; expected "Path" [arg-type]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Argument 1 to "paginate_queryset" of "GenericAPIView" has incompatible type "TantivyRelevanceList"; expected "QuerySet[Any, _MT_co] | Sequence[_MT_co]" [arg-type]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Argument 2 to "match_correspondents" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Argument 2 to "match_document_types" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Argument 2 to "match_storage_paths" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Argument 2 to "match_tags" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Incompatible types in assignment (expression has type "ClassificationSuggestions", variable has type "dict[Any, Any]") [assignment]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "page_size" [union-attr]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Item "None" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Item "None" of "BasePagination | None" has no attribute "page_size" [union-attr]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Missing positional argument "request" in call to "email_documents" [call-arg]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Need type annotation for "doc" [var-annotated]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Need type annotation for "doc" [var-annotated]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: No overload variant of "prefetch_related" of "QuerySet" matches argument type "list[object]" [call-overload]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Skipping analyzing "langdetect": module is installed, but missing library stubs or py.typed marker [import-untyped]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/documents.py:0: error: Unsupported operand types for + ("None" and "int") [operator]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/index.py:0: error: Argument "filename" to "FileResponse" has incompatible type "str | Any | None"; expected "str" [arg-type]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/index.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/index.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/index.py:0: error: Item "AnonymousUser" of "User | AnonymousUser" has no attribute "get_full_name" [union-attr]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/index.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/index.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/index.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "app_logo" [union-attr]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/logs.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/logs.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Incompatible types in assignment (expression has type "tuple[type[IsAuthenticated], type[ViewDocumentsPermissions]]", variable has type "tuple[type[IsAuthenticated], type[PaperlessObjectPermissions]]") [assignment]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/metadata.py:0: error: Need type annotation for "children_map" (hint: "children_map: dict[<type>, <type>] = ...") [var-annotated]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/search.py:0: error: Argument 3 to "autocomplete" of "TantivyBackend" has incompatible type "User | AnonymousUser | None"; expected "AbstractUser | None" [arg-type]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/views/search.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Document, Document]", variable has type "list[Any]") [assignment]
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/views/search.py:0: error: Need type annotation for "docs" (hint: "docs: list[<type>] = ...") [var-annotated]
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "ClassificationSuggestions", variable has type "dict[Any, Any]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Document, Document]", variable has type "list[Any]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "int", target has type "dict[str, str]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[User]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment] src/documents/views/search.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment] src/documents/views/sharing.py:0: error: "Sequence[_SupportsHasPermission]" has no attribute "append" [attr-defined]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment] src/documents/views/sharing.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment] src/documents/views/sharing.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment] src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "tuple[type[IsAuthenticated], type[ViewDocumentsPermissions]]", variable has type "tuple[type[IsAuthenticated], type[PaperlessObjectPermissions]]") [assignment] src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[ArchiveOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment] src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[DocumentSerializer]", variable has type "type[TrashSerializer]") [assignment] src/documents/views/sharing.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[OriginalsOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment] src/documents/views/sharing.py:0: error: Need type annotation for "authentication_classes" (hint: "authentication_classes: list[<type>] = ...") [var-annotated]
src/documents/views.py:0: error: Item "AnonymousUser" of "User | AnonymousUser" has no attribute "get_full_name" [union-attr] src/documents/views/sharing.py:0: error: Need type annotation for "permission_classes" (hint: "permission_classes: list[<type>] = ...") [var-annotated]
src/documents/views.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "get_page_size" [union-attr] src/documents/views/system.py:0: error: Argument 1 to "permitted_document_ids" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type]
src/documents/views.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "page_size" [union-attr] src/documents/views/system.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item]
src/documents/views.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr] src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr] src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "app_logo" [union-attr] src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Item "None" of "BasePagination | None" has no attribute "get_page_size" [union-attr] src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Item "None" of "BasePagination | None" has no attribute "page_size" [union-attr] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment]
src/documents/views.py:0: error: Missing positional argument "request" in call to "email_documents" [call-arg] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "int", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Need type annotation for "authentication_classes" (hint: "authentication_classes: list[<type>] = ...") [var-annotated] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Need type annotation for "children_map" (hint: "children_map: dict[<type>, <type>] = ...") [var-annotated] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Need type annotation for "doc" [var-annotated] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Need type annotation for "doc" [var-annotated] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Need type annotation for "docs" (hint: "docs: list[<type>] = ...") [var-annotated] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Need type annotation for "permission_classes" (hint: "permission_classes: list[<type>] = ...") [var-annotated] src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "type[DocumentSerializer]", variable has type "type[TrashSerializer]") [assignment]
src/documents/views.py:0: error: Need type annotation for "ui_settings" (hint: "ui_settings: dict[<type>, <type>] = ...") [var-annotated] src/documents/views/system.py:0: error: Need type annotation for "ui_settings" (hint: "ui_settings: dict[<type>, <type>] = ...") [var-annotated]
src/documents/views.py:0: error: No overload variant of "prefetch_related" of "QuerySet" matches argument type "list[object]" [call-overload] src/documents/views/tasks.py:0: error: "object" has no attribute "apply_async" [attr-defined]
src/documents/views.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped] src/documents/views/tasks.py:0: error: Argument 1 to "TaskSummarySerializer" has incompatible type "QuerySet[PaperlessTask@AnnotatedWith[TypedDict({'total_count': int, 'pending_count': int, 'success_count': int, 'failure_count': int, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})], TypedDict({'task_type': str, 'total_count': Any, 'pending_count': Any, 'success_count': Any, 'failure_count': Any, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})]"; expected "dict[str, Any] | None" [arg-type]
src/documents/views.py:0: error: Skipping analyzing "langdetect": module is installed, but missing library stubs or py.typed marker [import-untyped] src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Unsupported operand types for + ("None" and "int") [operator] src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index] src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index] src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index] src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index] src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index] src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index] src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index] src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index] src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[Group]" is not indexable [index] src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[User]" is not indexable [index] src/documents/views/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index] src/documents/views/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/workflows/actions.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/workflows/actions.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/workflows/actions.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/workflows/actions.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/workflows/actions.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] src/documents/workflows/actions.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
@@ -2145,9 +2145,9 @@ src/paperless_ai/ai_classifier.py:0: error: Argument 1 to "parse_ai_response" ha
src/paperless_ai/ai_classifier.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "str") [assignment] src/paperless_ai/ai_classifier.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "str") [assignment]
src/paperless_ai/ai_classifier.py:0: error: Missing type arguments for generic type "dict" [type-arg] src/paperless_ai/ai_classifier.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/paperless_ai/ai_classifier.py:0: error: Missing type arguments for generic type "dict" [type-arg] src/paperless_ai/ai_classifier.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("correspondents", "dates", "document_types", "storage_paths", "tags", ...) [literal-required] src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("title", "tags", "correspondents", "document_types", "storage_paths", ...) [literal-required]
src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("correspondents", "dates", "document_types", "storage_paths", "tags", ...) [literal-required] src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("title", "tags", "correspondents", "document_types", "storage_paths", ...) [literal-required]
src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("correspondents", "dates", "document_types", "storage_paths", "tags", ...) [literal-required] src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("title", "tags", "correspondents", "document_types", "storage_paths", ...) [literal-required]
src/paperless_ai/base_model.py:0: error: Invalid index type "str | None" for "dict[str, FieldInfo]"; expected type "str" [index] src/paperless_ai/base_model.py:0: error: Invalid index type "str | None" for "dict[str, FieldInfo]"; expected type "str" [index]
src/paperless_ai/chat.py:0: error: Dict entry 1 has incompatible type "str": "str | Any | None"; expected "str": "int | str" [dict-item] src/paperless_ai/chat.py:0: error: Dict entry 1 has incompatible type "str": "str | Any | None"; expected "str": "int | str" [dict-item]
src/paperless_ai/chat.py:0: error: Function is missing a return type annotation [no-untyped-def] src/paperless_ai/chat.py:0: error: Function is missing a return type annotation [no-untyped-def]
+357 -364
View File
@@ -896,240 +896,233 @@
"concise_description": "Argument `FieldRef | None` is not assignable to parameter `field` with type `FieldRef` in function `whoosh_compat.ast.Fuzzy.__init__`", "concise_description": "Argument `FieldRef | None` is not assignable to parameter `field` with type `FieldRef` in function `whoosh_compat.ast.Fuzzy.__init__`",
"severity": "error" "severity": "error"
}, },
{
"column": 17,
"path": "src/documents/search/_schema.py",
"name": "unexpected-keyword",
"concise_description": "Unexpected keyword argument `fast` in function `tantivy.tantivy.SchemaBuilder.add_json_field`",
"severity": "error"
},
{ {
"column": 9, "column": 9,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/base.py",
"name": "bad-override-param-name", "name": "bad-override-param-name",
"concise_description": "Class member `MatchingModelSerializer.validate` overrides parent class `ModelSerializer` in an inconsistent manner", "concise_description": "Class member `MatchingModelSerializer.validate` overrides parent class `ModelSerializer` in an inconsistent manner",
"severity": "error" "severity": "error"
}, },
{ {
"column": 62, "column": 62,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/base.py",
"name": "unsupported-operation", "name": "unsupported-operation",
"concise_description": "Cannot set item in `dict[str, QuerySet[User, User]]`", "concise_description": "Cannot set item in `dict[str, QuerySet[User, User]]`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 17, "column": 17,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/base.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `list` has no attribute `values_list`", "concise_description": "Object of class `list` has no attribute `values_list`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 9,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/base.py",
"name": "bad-override-param-name", "name": "bad-override-param-name",
"concise_description": "Class member `OwnedObjectListSerializer.to_representation` overrides parent class `ListSerializer` in an inconsistent manner", "concise_description": "Class member `OwnedObjectListSerializer.to_representation` overrides parent class `ListSerializer` in an inconsistent manner",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 9,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/base.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `NoneType` has no attribute `context`", "concise_description": "Object of class `NoneType` has no attribute `context`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 51, "column": 51,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/base.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of type `BaseSerializer[Unknown] | Field[Unknown, Unknown, Unknown, Unknown] | None` has no attribute `get_shared_object_pks`", "concise_description": "Object of type `BaseSerializer[Unknown] | Field[Unknown, Unknown, Unknown, Unknown] | None` has no attribute `get_shared_object_pks`",
"severity": "error" "severity": "error"
}, },
{
"column": 14,
"path": "src/documents/serialisers.py",
"name": "bad-assignment",
"concise_description": "`ManyRelatedField | RelatedField[@_, @_, Any]` is not assignable to attribute `parent` with type `BaseSerializer[Unknown]`",
"severity": "error"
},
{
"column": 17,
"path": "src/documents/serialisers.py",
"name": "unsupported-operation",
"concise_description": "Cannot set item in `Mapping[str, Any]`",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/serialisers.py",
"name": "bad-override-param-name",
"concise_description": "Class member `TagSerializer.validate` overrides parent class `MatchingModelSerializer` in an inconsistent manner",
"severity": "error"
},
{
"column": 17,
"path": "src/documents/serialisers.py",
"name": "bad-argument-type",
"concise_description": "Argument `type[CustomField.FieldDataType]` is not assignable to parameter `choices` with type `Sequence[Any]` in function `rest_framework.fields.ChoiceField.__init__`",
"severity": "error"
},
{ {
"column": 28, "column": 28,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/base.py",
"name": "bad-assignment", "name": "bad-assignment",
"concise_description": "`Unknown | None` is not assignable to attribute `method_name` with type `str`", "concise_description": "`Unknown | None` is not assignable to attribute `method_name` with type `str`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 13, "column": 7,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/bulk_edit.py",
"name": "bad-argument-type", "name": "invalid-inheritance",
"concise_description": "Argument `str` is not assignable to parameter `data_type` with type `CustomField.FieldDataType` in function `documents.models.CustomFieldInstance.get_value_field_name`", "concise_description": "Class `BulkEditSerializer` has inconsistent type arguments for base class `Field`: `Field[Any, Any, Any, dict[str, Any]]` and `Field[Any, Any, Any, dict[str, list[int]]]`",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/serialisers.py",
"name": "bad-override-param-name",
"concise_description": "Class member `CustomFieldInstanceSerializer.get_value` overrides parent class `ModelSerializer` in an inconsistent manner",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/serialisers.py",
"name": "bad-override-param-name",
"concise_description": "Class member `CustomFieldInstanceSerializer.validate` overrides parent class `ModelSerializer` in an inconsistent manner",
"severity": "error"
},
{
"column": 34,
"path": "src/documents/serialisers.py",
"name": "unsupported-operation",
"concise_description": "`None` is not subscriptable",
"severity": "error" "severity": "error"
}, },
{ {
"column": 30, "column": 30,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/documents.py",
"name": "unsupported-operation", "name": "unsupported-operation",
"concise_description": "`None` is not subscriptable", "concise_description": "`None` is not subscriptable",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 9,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/documents.py",
"name": "bad-override-param-name", "name": "bad-override-param-name",
"concise_description": "Class member `SearchResultListSerializer.to_representation` overrides parent class `ListSerializer` in an inconsistent manner", "concise_description": "Class member `SearchResultListSerializer.to_representation` overrides parent class `ListSerializer` in an inconsistent manner",
"severity": "error" "severity": "error"
}, },
{ {
"column": 21, "column": 21,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/documents.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of type `BaseSerializer[Unknown] | Field[Unknown, Unknown, Unknown, Unknown] | None` has no attribute `fetch_documents`", "concise_description": "Object of type `BaseSerializer[Unknown] | Field[Unknown, Unknown, Unknown, Unknown] | None` has no attribute `fetch_documents`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 9,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/documents.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `NoneType` has no attribute `context`", "concise_description": "Object of class `NoneType` has no attribute `context`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 9,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/documents.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `NoneType` has no attribute `context`", "concise_description": "Object of class `NoneType` has no attribute `context`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 51, "column": 51,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/documents.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of type `BaseSerializer[Unknown] | Field[Unknown, Unknown, Unknown, Unknown] | None` has no attribute `get_shared_object_pks`", "concise_description": "Object of type `BaseSerializer[Unknown] | Field[Unknown, Unknown, Unknown, Unknown] | None` has no attribute `get_shared_object_pks`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 9,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/documents.py",
"name": "bad-override-param-name", "name": "bad-override-param-name",
"concise_description": "Class member `SearchResultSerializer.to_representation` overrides parent class `DocumentSerializer` in an inconsistent manner", "concise_description": "Class member `SearchResultSerializer.to_representation` overrides parent class `DocumentSerializer` in an inconsistent manner",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 9,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/documents.py",
"name": "bad-override-mutable-attribute", "name": "bad-override-mutable-attribute",
"concise_description": "Class member `Meta.list_serializer_class` overrides parent class `Meta` in an inconsistent manner", "concise_description": "Class member `Meta.list_serializer_class` overrides parent class `Meta` in an inconsistent manner",
"severity": "error" "severity": "error"
}, },
{
"column": 14,
"path": "src/documents/serialisers/metadata.py",
"name": "bad-assignment",
"concise_description": "`ManyRelatedField | RelatedField[@_, @_, Any]` is not assignable to attribute `parent` with type `BaseSerializer[Unknown]`",
"severity": "error"
},
{
"column": 17,
"path": "src/documents/serialisers/metadata.py",
"name": "unsupported-operation",
"concise_description": "Cannot set item in `Mapping[str, Any]`",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/serialisers/metadata.py",
"name": "bad-override-param-name",
"concise_description": "Class member `TagSerializer.validate` overrides parent class `MatchingModelSerializer` in an inconsistent manner",
"severity": "error"
},
{
"column": 17,
"path": "src/documents/serialisers/metadata.py",
"name": "bad-argument-type",
"concise_description": "Argument `type[CustomField.FieldDataType]` is not assignable to parameter `choices` with type `Sequence[Any]` in function `rest_framework.fields.ChoiceField.__init__`",
"severity": "error"
},
{
"column": 13,
"path": "src/documents/serialisers/metadata.py",
"name": "bad-argument-type",
"concise_description": "Argument `str` is not assignable to parameter `data_type` with type `CustomField.FieldDataType` in function `documents.models.CustomFieldInstance.get_value_field_name`",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/serialisers/metadata.py",
"name": "bad-override-param-name",
"concise_description": "Class member `CustomFieldInstanceSerializer.get_value` overrides parent class `ModelSerializer` in an inconsistent manner",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/serialisers/metadata.py",
"name": "bad-override-param-name",
"concise_description": "Class member `CustomFieldInstanceSerializer.validate` overrides parent class `ModelSerializer` in an inconsistent manner",
"severity": "error"
},
{
"column": 34,
"path": "src/documents/serialisers/metadata.py",
"name": "unsupported-operation",
"concise_description": "`None` is not subscriptable",
"severity": "error"
},
{ {
"column": 21, "column": 21,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/saved_views.py",
"name": "unsupported-operation", "name": "unsupported-operation",
"concise_description": "`Literal[SavedView.DisplayFields.CUSTOM_FIELD]` is not subscriptable", "concise_description": "`Literal[SavedView.DisplayFields.CUSTOM_FIELD]` is not subscriptable",
"severity": "error" "severity": "error"
}, },
{ {
"column": 36, "column": 36,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/saved_views.py",
"name": "unsupported-operation", "name": "unsupported-operation",
"concise_description": "`None` is not subscriptable", "concise_description": "`None` is not subscriptable",
"severity": "error" "severity": "error"
}, },
{
"column": 7,
"path": "src/documents/serialisers.py",
"name": "invalid-inheritance",
"concise_description": "Class `BulkEditSerializer` has inconsistent type arguments for base class `Field`: `Field[Any, Any, Any, dict[str, Any]]` and `Field[Any, Any, Any, dict[str, list[int]]]`",
"severity": "error"
},
{ {
"column": 73, "column": 73,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/sharing.py",
"name": "unbound-name", "name": "unbound-name",
"concise_description": "`address` is uninitialized", "concise_description": "`address` is uninitialized",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 9,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/system.py",
"name": "bad-override", "name": "bad-override",
"concise_description": "Class member `UiSettingsViewSerializer.create` overrides parent class `ModelSerializer` in an inconsistent manner", "concise_description": "Class member `UiSettingsViewSerializer.create` overrides parent class `ModelSerializer` in an inconsistent manner",
"severity": "error" "severity": "error"
}, },
{ {
"column": 46, "column": 46,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/tasks.py",
"name": "no-matching-overload", "name": "no-matching-overload",
"concise_description": "No matching overload found for function `dict.get` called with arguments: (str, str)", "concise_description": "No matching overload found for function `dict.get` called with arguments: (str, str)",
"severity": "error" "severity": "error"
}, },
{ {
"column": 38, "column": 38,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/tasks.py",
"name": "no-matching-overload", "name": "no-matching-overload",
"concise_description": "No matching overload found for function `dict.get` called with arguments: (str, str)", "concise_description": "No matching overload found for function `dict.get` called with arguments: (str, str)",
"severity": "error" "severity": "error"
}, },
{ {
"column": 51, "column": 51,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/tasks.py",
"name": "no-matching-overload", "name": "no-matching-overload",
"concise_description": "No matching overload found for function `dict.get` called with arguments: (str, Literal['manual_task'])", "concise_description": "No matching overload found for function `dict.get` called with arguments: (str, Literal['manual_task'])",
"severity": "error" "severity": "error"
}, },
{ {
"column": 52, "column": 52,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/workflows.py",
"name": "unbound-name", "name": "unbound-name",
"concise_description": "`triggers` may be uninitialized", "concise_description": "`triggers` may be uninitialized",
"severity": "error" "severity": "error"
}, },
{ {
"column": 62, "column": 62,
"path": "src/documents/serialisers.py", "path": "src/documents/serialisers/workflows.py",
"name": "unbound-name", "name": "unbound-name",
"concise_description": "`actions` may be uninitialized", "concise_description": "`actions` may be uninitialized",
"severity": "error" "severity": "error"
@@ -3619,389 +3612,389 @@
"concise_description": "`float | int | None` is not assignable to attribute `MAX_IMAGE_PIXELS` with type `int | None`", "concise_description": "`float | int | None` is not assignable to attribute `MAX_IMAGE_PIXELS` with type `int | None`",
"severity": "error" "severity": "error"
}, },
{
"column": 31,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `AbstractBaseUser` has no attribute `username`",
"severity": "error"
},
{
"column": 32,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of type `AbstractBaseUser | AnonymousUser` has no attribute `get_full_name`",
"severity": "error"
},
{ {
"column": 17, "column": 17,
"path": "src/documents/views.py", "path": "src/documents/views/base.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `BulkPermissionMixin` has no attribute `queryset`", "concise_description": "Object of class `BulkPermissionMixin` has no attribute `queryset`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 19, "column": 19,
"path": "src/documents/views.py", "path": "src/documents/views/base.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `object` has no attribute `get_serializer_context`", "concise_description": "Object of class `object` has no attribute `get_serializer_context`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 24, "column": 24,
"path": "src/documents/views.py", "path": "src/documents/views/base.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `BulkPermissionMixin` has no attribute `filter_queryset`", "concise_description": "Object of class `BulkPermissionMixin` has no attribute `filter_queryset`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 45, "column": 45,
"path": "src/documents/views.py", "path": "src/documents/views/base.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `BulkPermissionMixin` has no attribute `get_queryset`", "concise_description": "Object of class `BulkPermissionMixin` has no attribute `get_queryset`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 22, "column": 22,
"path": "src/documents/views.py", "path": "src/documents/views/base.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `BulkPermissionMixin` has no attribute `queryset`", "concise_description": "Object of class `BulkPermissionMixin` has no attribute `queryset`",
"severity": "error" "severity": "error"
}, },
{
"column": 29,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `Self@CorrespondentViewSet` is not assignable to parameter `instance` with type `Options[Any] | None` in function `django.db.models.manager.BaseManager.__get__`",
"severity": "error"
},
{
"column": 29,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `NoneType` has no attribute `annotate`",
"severity": "error"
},
{
"column": 25,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `Self@CorrespondentViewSet` is not assignable to parameter `instance` with type `Options[Any] | None` in function `django.db.models.manager.BaseManager.__get__`",
"severity": "error"
},
{
"column": 25,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `NoneType` has no attribute `annotate`",
"severity": "error"
},
{
"column": 48,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `Correspondent` has no attribute `document_count`",
"severity": "error"
},
{
"column": 48,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `DocumentType` has no attribute `document_count`",
"severity": "error"
},
{
"column": 48,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `StoragePath` has no attribute `document_count`",
"severity": "error"
},
{
"column": 28,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Class `DocumentFilterSet` has no class attribute `declared_filters`",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/views.py",
"name": "bad-override",
"concise_description": "Class member `DocumentViewSet.destroy` overrides parent class `DestroyModelMixin` in an inconsistent manner",
"severity": "error"
},
{
"column": 37,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `list[MetadataEntry] | list[Unknown] | None` is not assignable to parameter `original_metadata` with type `list[Unknown]` in function `documents.caching.set_metadata_cache`",
"severity": "error"
},
{
"column": 57,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `DocumentClassifier | None` is not assignable to parameter `classifier` with type `DocumentClassifier` in function `documents.matching.match_correspondents`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `DocumentClassifier | None` is not assignable to parameter `classifier` with type `DocumentClassifier` in function `documents.matching.match_tags`",
"severity": "error"
},
{
"column": 59,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `DocumentClassifier | None` is not assignable to parameter `classifier` with type `DocumentClassifier` in function `documents.matching.match_document_types`",
"severity": "error"
},
{
"column": 58,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `DocumentClassifier | None` is not assignable to parameter `classifier` with type `DocumentClassifier` in function `documents.matching.match_storage_paths`",
"severity": "error"
},
{ {
"column": 17, "column": 17,
"path": "src/documents/views.py", "path": "src/documents/views/base.py",
"name": "bad-argument-type",
"concise_description": "Argument `ClassificationSuggestions` is not assignable to parameter `suggestions` with type `dict[Unknown, Unknown]` in function `documents.caching.set_llm_suggestions_cache`",
"severity": "error"
},
{
"column": 5,
"path": "src/documents/views.py",
"name": "bad-specialization",
"concise_description": "`(self: Self@DocumentViewSet, request: Unknown, pk: Unknown | None = None) -> HttpResponseForbidden | Response | None` is not assignable to upper bound `(...) -> HttpResponseBase` of type variable `_View`",
"severity": "error"
},
{
"column": 36,
"path": "src/documents/views.py",
"name": "missing-argument",
"concise_description": "Missing argument `request` in function `DocumentViewSet.email_documents`",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/views.py",
"name": "bad-override",
"concise_description": "Class member `UnifiedSearchViewSet.list` overrides parent class `DocumentViewSet` in an inconsistent manner",
"severity": "error"
},
{
"column": 17,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of type `BasePagination | None` has no attribute `get_page_size`",
"severity": "error"
},
{
"column": 58,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of type `BasePagination | None` has no attribute `page_size`",
"severity": "error"
},
{
"column": 13,
"path": "src/documents/views.py",
"name": "not-iterable",
"concise_description": "Type `None` is not iterable",
"severity": "error"
},
{
"column": 43,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `TantivyRelevanceList` is not assignable to parameter `queryset` with type `QuerySet[Any, @_] | Sequence[@_]` in function `rest_framework.generics.GenericAPIView.paginate_queryset`",
"severity": "error"
},
{
"column": 25,
"path": "src/documents/views.py",
"name": "unsupported-operation",
"concise_description": "`+` is not supported between `None` and `Literal[1]`",
"severity": "error"
},
{
"column": 17,
"path": "src/documents/views.py",
"name": "not-iterable", "name": "not-iterable",
"concise_description": "Type `None` is not iterable", "concise_description": "Type `None` is not iterable",
"severity": "error" "severity": "error"
}, },
{ {
"column": 18, "column": 18,
"path": "src/documents/views.py", "path": "src/documents/views/base.py",
"name": "bad-argument-type", "name": "bad-argument-type",
"concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User` in function `DocumentSelectionMixin._resolve_document_ids`", "concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User` in function `DocumentSelectionMixin._resolve_document_ids`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 18, "column": 18,
"path": "src/documents/views.py", "path": "src/documents/views/base.py",
"name": "bad-argument-type", "name": "bad-argument-type",
"concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User` in function `DocumentOperationPermissionMixin._has_document_permissions`", "concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User` in function `DocumentOperationPermissionMixin._has_document_permissions`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 18, "column": 18,
"path": "src/documents/views.py", "path": "src/documents/views/bulk_edit.py",
"name": "bad-argument-type", "name": "bad-argument-type",
"concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User` in function `DocumentSelectionMixin._resolve_document_ids`", "concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User` in function `documents.views.base.DocumentSelectionMixin._resolve_document_ids`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 18, "column": 18,
"path": "src/documents/views.py", "path": "src/documents/views/bulk_edit.py",
"name": "bad-argument-type", "name": "bad-argument-type",
"concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User` in function `DocumentOperationPermissionMixin._has_document_permissions`", "concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User` in function `documents.views.base.DocumentOperationPermissionMixin._has_document_permissions`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 33, "column": 33,
"path": "src/documents/views.py", "path": "src/documents/views/bulk_edit.py",
"name": "unbound-name", "name": "unbound-name",
"concise_description": "`old_values` may be uninitialized", "concise_description": "`old_values` may be uninitialized",
"severity": "error" "severity": "error"
}, },
{
"column": 52,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `Correspondent` has no attribute `document_count`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `Tag` has no attribute `document_count`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `DocumentType` has no attribute `document_count`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `StoragePath` has no attribute `document_count`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `CustomField` has no attribute `document_count`",
"severity": "error"
},
{
"column": 65,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `AbstractBaseUser | AnonymousUser | None` is not assignable to parameter `user` with type `AbstractUser | None` in function `documents.search._backend.TantivyBackend.autocomplete`",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/views.py",
"name": "bad-override",
"concise_description": "Class member `TasksViewSet.get_serializer_class` overrides parent class `ReadOnlyModelViewSet` in an inconsistent manner",
"severity": "error"
},
{
"column": 12,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `AbstractBaseUser` has no attribute `is_staff`",
"severity": "error"
},
{
"column": 44,
"path": "src/documents/views.py",
"name": "bad-argument-type",
"concise_description": "Argument `QuerySet[PaperlessTask, dict[str, Any]]` is not assignable to parameter `instance` with type `dict[str, Any] | None` in function `rest_framework.serializers.BaseSerializer.__init__`",
"severity": "error"
},
{
"column": 13,
"path": "src/documents/views.py",
"name": "missing-attribute",
"concise_description": "Object of class `Sequence` has no attribute `append`",
"severity": "error"
},
{ {
"column": 24, "column": 24,
"path": "src/documents/views.py", "path": "src/documents/views/bulk_edit.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `BaseSerializer` has no attribute `get_object_class`", "concise_description": "Object of class `BaseSerializer` has no attribute `get_object_class`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 45, "column": 45,
"path": "src/documents/views.py", "path": "src/documents/views/bulk_edit.py",
"name": "bad-argument-type", "name": "bad-argument-type",
"concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User | None` in function `documents.permissions.permitted_object_ids`", "concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User | None` in function `documents.permissions.permitted_object_ids`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 16, "column": 16,
"path": "src/documents/views.py", "path": "src/documents/views/bulk_edit.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `AbstractBaseUser` has no attribute `is_superuser`", "concise_description": "Object of class `AbstractBaseUser` has no attribute `is_superuser`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 17, "column": 17,
"path": "src/documents/views.py", "path": "src/documents/views/bulk_edit.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `AbstractBaseUser` has no attribute `has_perm`", "concise_description": "Object of class `AbstractBaseUser` has no attribute `has_perm`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 48,
"path": "src/documents/views.py", "path": "src/documents/views/documents.py",
"name": "bad-override", "name": "missing-attribute",
"concise_description": "Class member `WorkflowTriggerViewSet.partial_update` overrides parent class `ModelViewSet` in an inconsistent manner", "concise_description": "Object of class `Correspondent` has no attribute `document_count`",
"severity": "error"
},
{
"column": 48,
"path": "src/documents/views/documents.py",
"name": "missing-attribute",
"concise_description": "Object of class `DocumentType` has no attribute `document_count`",
"severity": "error"
},
{
"column": 48,
"path": "src/documents/views/documents.py",
"name": "missing-attribute",
"concise_description": "Object of class `StoragePath` has no attribute `document_count`",
"severity": "error"
},
{
"column": 28,
"path": "src/documents/views/documents.py",
"name": "missing-attribute",
"concise_description": "Class `DocumentFilterSet` has no class attribute `declared_filters`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 9, "column": 9,
"path": "src/documents/views.py", "path": "src/documents/views/documents.py",
"name": "bad-override", "name": "bad-override",
"concise_description": "Class member `WorkflowActionViewSet.partial_update` overrides parent class `ModelViewSet` in an inconsistent manner", "concise_description": "Class member `DocumentViewSet.destroy` overrides parent class `DestroyModelMixin` in an inconsistent manner",
"severity": "error"
},
{
"column": 37,
"path": "src/documents/views/documents.py",
"name": "bad-argument-type",
"concise_description": "Argument `list[MetadataEntry] | list[Unknown] | None` is not assignable to parameter `original_metadata` with type `list[Unknown]` in function `documents.caching.set_metadata_cache`",
"severity": "error"
},
{
"column": 57,
"path": "src/documents/views/documents.py",
"name": "bad-argument-type",
"concise_description": "Argument `DocumentClassifier | None` is not assignable to parameter `classifier` with type `DocumentClassifier` in function `documents.matching.match_correspondents`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views/documents.py",
"name": "bad-argument-type",
"concise_description": "Argument `DocumentClassifier | None` is not assignable to parameter `classifier` with type `DocumentClassifier` in function `documents.matching.match_tags`",
"severity": "error"
},
{
"column": 59,
"path": "src/documents/views/documents.py",
"name": "bad-argument-type",
"concise_description": "Argument `DocumentClassifier | None` is not assignable to parameter `classifier` with type `DocumentClassifier` in function `documents.matching.match_document_types`",
"severity": "error"
},
{
"column": 58,
"path": "src/documents/views/documents.py",
"name": "bad-argument-type",
"concise_description": "Argument `DocumentClassifier | None` is not assignable to parameter `classifier` with type `DocumentClassifier` in function `documents.matching.match_storage_paths`",
"severity": "error"
},
{
"column": 17,
"path": "src/documents/views/documents.py",
"name": "bad-argument-type",
"concise_description": "Argument `ClassificationSuggestions` is not assignable to parameter `suggestions` with type `dict[Unknown, Unknown]` in function `documents.caching.set_llm_suggestions_cache`",
"severity": "error"
},
{
"column": 5,
"path": "src/documents/views/documents.py",
"name": "bad-specialization",
"concise_description": "`(self: Self@DocumentViewSet, request: Unknown, pk: Unknown | None = None) -> HttpResponseForbidden | Response | None` is not assignable to upper bound `(...) -> HttpResponseBase` of type variable `_View`",
"severity": "error"
},
{
"column": 36,
"path": "src/documents/views/documents.py",
"name": "missing-argument",
"concise_description": "Missing argument `request` in function `DocumentViewSet.email_documents`",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/views/documents.py",
"name": "bad-override",
"concise_description": "Class member `UnifiedSearchViewSet.list` overrides parent class `DocumentViewSet` in an inconsistent manner",
"severity": "error"
},
{
"column": 17,
"path": "src/documents/views/documents.py",
"name": "missing-attribute",
"concise_description": "Object of type `BasePagination | None` has no attribute `get_page_size`",
"severity": "error"
},
{
"column": 58,
"path": "src/documents/views/documents.py",
"name": "missing-attribute",
"concise_description": "Object of type `BasePagination | None` has no attribute `page_size`",
"severity": "error"
},
{
"column": 13,
"path": "src/documents/views/documents.py",
"name": "not-iterable",
"concise_description": "Type `None` is not iterable",
"severity": "error"
},
{
"column": 43,
"path": "src/documents/views/documents.py",
"name": "bad-argument-type",
"concise_description": "Argument `TantivyRelevanceList` is not assignable to parameter `queryset` with type `QuerySet[Any, @_] | Sequence[@_]` in function `rest_framework.generics.GenericAPIView.paginate_queryset`",
"severity": "error"
},
{
"column": 25,
"path": "src/documents/views/documents.py",
"name": "unsupported-operation",
"concise_description": "`+` is not supported between `None` and `Literal[1]`",
"severity": "error"
},
{
"column": 31,
"path": "src/documents/views/index.py",
"name": "missing-attribute",
"concise_description": "Object of class `AbstractBaseUser` has no attribute `username`",
"severity": "error"
},
{
"column": 32,
"path": "src/documents/views/index.py",
"name": "missing-attribute",
"concise_description": "Object of type `AbstractBaseUser | AnonymousUser` has no attribute `get_full_name`",
"severity": "error"
},
{
"column": 16,
"path": "src/documents/views/index.py",
"name": "missing-attribute",
"concise_description": "Object of class `NoneType` has no attribute `app_logo`",
"severity": "error"
},
{
"column": 29,
"path": "src/documents/views/metadata.py",
"name": "bad-argument-type",
"concise_description": "Argument `Self@CorrespondentViewSet` is not assignable to parameter `instance` with type `Options[Any] | None` in function `django.db.models.manager.BaseManager.__get__`",
"severity": "error"
},
{
"column": 29,
"path": "src/documents/views/metadata.py",
"name": "missing-attribute",
"concise_description": "Object of class `NoneType` has no attribute `annotate`",
"severity": "error"
},
{
"column": 25,
"path": "src/documents/views/metadata.py",
"name": "bad-argument-type",
"concise_description": "Argument `Self@CorrespondentViewSet` is not assignable to parameter `instance` with type `Options[Any] | None` in function `django.db.models.manager.BaseManager.__get__`",
"severity": "error"
},
{
"column": 25,
"path": "src/documents/views/metadata.py",
"name": "missing-attribute",
"concise_description": "Object of class `NoneType` has no attribute `annotate`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views/search.py",
"name": "missing-attribute",
"concise_description": "Object of class `Correspondent` has no attribute `document_count`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views/search.py",
"name": "missing-attribute",
"concise_description": "Object of class `Tag` has no attribute `document_count`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views/search.py",
"name": "missing-attribute",
"concise_description": "Object of class `DocumentType` has no attribute `document_count`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views/search.py",
"name": "missing-attribute",
"concise_description": "Object of class `StoragePath` has no attribute `document_count`",
"severity": "error"
},
{
"column": 52,
"path": "src/documents/views/search.py",
"name": "missing-attribute",
"concise_description": "Object of class `CustomField` has no attribute `document_count`",
"severity": "error"
},
{
"column": 65,
"path": "src/documents/views/search.py",
"name": "bad-argument-type",
"concise_description": "Argument `AbstractBaseUser | AnonymousUser | None` is not assignable to parameter `user` with type `AbstractUser | None` in function `documents.search._backend.TantivyBackend.autocomplete`",
"severity": "error"
},
{
"column": 13,
"path": "src/documents/views/sharing.py",
"name": "missing-attribute",
"concise_description": "Object of class `Sequence` has no attribute `append`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 40, "column": 40,
"path": "src/documents/views.py", "path": "src/documents/views/system.py",
"name": "unbound-name", "name": "unbound-name",
"concise_description": "`all_migrations` may be uninitialized", "concise_description": "`all_migrations` may be uninitialized",
"severity": "error" "severity": "error"
}, },
{ {
"column": 17, "column": 17,
"path": "src/documents/views.py", "path": "src/documents/views/system.py",
"name": "bad-argument-type", "name": "bad-argument-type",
"concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User | None` in function `documents.permissions.permitted_document_ids`", "concise_description": "Argument `AbstractBaseUser | AnonymousUser` is not assignable to parameter `user` with type `User | None` in function `documents.permissions.permitted_document_ids`",
"severity": "error" "severity": "error"
}, },
{ {
"column": 16, "column": 9,
"path": "src/documents/views.py", "path": "src/documents/views/tasks.py",
"name": "bad-override",
"concise_description": "Class member `TasksViewSet.get_serializer_class` overrides parent class `ReadOnlyModelViewSet` in an inconsistent manner",
"severity": "error"
},
{
"column": 12,
"path": "src/documents/views/tasks.py",
"name": "missing-attribute", "name": "missing-attribute",
"concise_description": "Object of class `NoneType` has no attribute `app_logo`", "concise_description": "Object of class `AbstractBaseUser` has no attribute `is_staff`",
"severity": "error"
},
{
"column": 44,
"path": "src/documents/views/tasks.py",
"name": "bad-argument-type",
"concise_description": "Argument `QuerySet[PaperlessTask, dict[str, Any]]` is not assignable to parameter `instance` with type `dict[str, Any] | None` in function `rest_framework.serializers.BaseSerializer.__init__`",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/views/workflows.py",
"name": "bad-override",
"concise_description": "Class member `WorkflowTriggerViewSet.partial_update` overrides parent class `ModelViewSet` in an inconsistent manner",
"severity": "error"
},
{
"column": 9,
"path": "src/documents/views/workflows.py",
"name": "bad-override",
"concise_description": "Class member `WorkflowActionViewSet.partial_update` overrides parent class `ModelViewSet` in an inconsistent manner",
"severity": "error" "severity": "error"
}, },
{ {
-13
View File
@@ -1010,19 +1010,6 @@ documents to both separate and categorize them in a single operation.
**Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create **Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create
three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt"). three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt").
### Barcode Contents {#barcode-contents}
By default, Paperless only uses barcodes for splitting, ASNs and tags. With
[`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES`](configuration.md#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES)
enabled, it stores the content of every barcode with the document, e.g. payment codes or QR codes.
- Barcodes are listed on the **Metadata** tab with page, type and content, and can be copied.
- The API returns them in the `barcodes` field of `/api/documents/{id}/metadata/`.
- They can be [searched](usage.md#searching-barcodes), e.g. `barcodes:DE89370400440532013000`.
- Only the first [`PAPERLESS_CONSUMER_BARCODE_MAX_PAGES`](configuration.md#PAPERLESS_CONSUMER_BARCODE_MAX_PAGES)
pages are scanned. Reprocessing reads the barcodes of existing documents.
- Each version keeps its own barcodes, and the newest version's are shown and searched.
## Automatic collation of double-sided documents {#collate} ## Automatic collation of double-sided documents {#collate}
!!! note !!! note
-7
View File
@@ -1796,13 +1796,6 @@ assigns or creates tags if a properly formatted barcode is detected.
Defaults to false. Defaults to false.
#### [`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES=<bool>`](#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES) {#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES}
: Stores the content of every barcode found during consumption, see
[Barcode Contents](advanced_usage.md#barcode-contents).
Defaults to false.
## Audit Trail ## Audit Trail
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED} #### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
-14
View File
@@ -1061,20 +1061,6 @@ notes.user:alice notes.note:insurance
The bare `notes:` prefix is shorthand for `notes.note:`. The bare `notes:` prefix is shorthand for `notes.note:`.
#### Searching barcodes
If [barcode contents are stored](advanced_usage.md#barcode-contents), they can be searched by
content or type, but only with a field name:
```
barcodes.value:DE89370400440532013000
barcodes.format:qrcode
barcodes:wifi barcodes:guest
```
`barcodes:` is shorthand for `barcodes.value:`. Separators are stripped, so each part of e.g.
`WIFI:S:Guest;P:secret;;` can be searched on its own.
All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text. All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text.
!!! note !!! note
+2 -2
View File
@@ -33,7 +33,7 @@ dependencies = [
"django-compression-middleware~=0.5.0", "django-compression-middleware~=0.5.0",
"django-cors-headers~=4.9.0", "django-cors-headers~=4.9.0",
"django-extensions~=4.1", "django-extensions~=4.1",
"django-filter>=25.1,<27", "django-filter~=25.1",
"django-guardian>=3.3.3,<3.6", "django-guardian>=3.3.3,<3.6",
"django-multiselectfield~=1.0.1", "django-multiselectfield~=1.0.1",
"django-rich~=2.2.0", "django-rich~=2.2.0",
@@ -90,7 +90,7 @@ postgres = [
"psycopg[c,pool]==3.3.4", "psycopg[c,pool]==3.3.4",
# Direct dependency for proper resolution of the pre-built wheels # Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3.4", "psycopg-c==3.3.4",
"psycopg-pool==3.3.3", "psycopg-pool==3.3.2",
] ]
webserver = [ webserver = [
"granian[uvloop]>=2.7,<2.9", "granian[uvloop]>=2.7,<2.9",
+113 -150
View File
@@ -698,7 +698,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">463,464</context> <context context-type="linenumber">457,458</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
@@ -870,7 +870,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">482</context> <context context-type="linenumber">476</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -1373,7 +1373,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1907</context> <context context-type="linenumber">1901</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1577733187050997705" datatype="html"> <trans-unit id="1577733187050997705" datatype="html">
@@ -1451,7 +1451,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">408,409</context> <context context-type="linenumber">402,403</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1700,7 +1700,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">179</context> <context context-type="linenumber">177</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2691296884221415710" datatype="html"> <trans-unit id="2691296884221415710" datatype="html">
@@ -1715,7 +1715,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">184</context> <context context-type="linenumber">182</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1750,7 +1750,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">188</context> <context context-type="linenumber">186</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1785,7 +1785,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">192</context> <context context-type="linenumber">190</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -2478,7 +2478,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">713</context> <context context-type="linenumber">711</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
@@ -3392,11 +3392,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1521</context> <context context-type="linenumber">1515</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1908</context> <context context-type="linenumber">1902</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -4012,7 +4012,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1474</context> <context context-type="linenumber">1468</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -4156,7 +4156,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1961</context> <context context-type="linenumber">1955</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6661109599266152398" datatype="html"> <trans-unit id="6661109599266152398" datatype="html">
@@ -4167,7 +4167,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1962</context> <context context-type="linenumber">1956</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5162686434580248853" datatype="html"> <trans-unit id="5162686434580248853" datatype="html">
@@ -4178,7 +4178,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1963</context> <context context-type="linenumber">1957</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6665634854532231106" datatype="html"> <trans-unit id="6665634854532231106" datatype="html">
@@ -5430,7 +5430,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">374,375</context> <context context-type="linenumber">368,369</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8057014866157903311" datatype="html"> <trans-unit id="8057014866157903311" datatype="html">
@@ -6311,7 +6311,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1478</context> <context context-type="linenumber">1472</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -6344,7 +6344,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">177</context> <context context-type="linenumber">175</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -7176,10 +7176,6 @@
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
<context context-type="linenumber">56,57</context> <context context-type="linenumber">56,57</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
<context context-type="linenumber">5,6</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="1230154438678955604" datatype="html"> <trans-unit id="1230154438678955604" datatype="html">
<source>Change</source> <source>Change</source>
@@ -7752,8 +7748,8 @@
<context context-type="linenumber">120</context> <context context-type="linenumber">120</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5377184518735933155" datatype="html"> <trans-unit id="5700628356844396417" datatype="html">
<source>{VAR_PLURAL, plural, =1 {1 suggestion available below} other {<x id="INTERPOLATION"/> suggestions available below}}</source> <source>{VAR_PLURAL, plural, =1 {1 existing value suggested below} other {<x id="INTERPOLATION"/> existing values suggested below}}</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">54</context> <context context-type="linenumber">54</context>
@@ -8337,10 +8333,6 @@
</trans-unit> </trans-unit>
<trans-unit id="1407560924967345762" datatype="html"> <trans-unit id="1407560924967345762" datatype="html">
<source>Page</source> <source>Page</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
<context context-type="linenumber">4,5</context>
</context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">5,6</context> <context context-type="linenumber">5,6</context>
@@ -8354,28 +8346,6 @@
<context context-type="linenumber">34</context> <context context-type="linenumber">34</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6205355627445317276" datatype="html">
<source>Content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
<context context-type="linenumber">6,7</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">300,301</context>
</context-group>
</trans-unit>
<trans-unit id="3846359579066496296" datatype="html">
<source>Copy content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
<context context-type="linenumber">32,33</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
<context context-type="linenumber">40,41</context>
</context-group>
</trans-unit>
<trans-unit id="2266163016683537825" datatype="html"> <trans-unit id="2266163016683537825" datatype="html">
<source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source> <source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source>
<context-group purpose="location"> <context-group purpose="location">
@@ -8507,32 +8477,39 @@
<source>Details</source> <source>Details</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">174,175</context> <context context-type="linenumber">172,173</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5114742157723900905" datatype="html"> <trans-unit id="5114742157723900905" datatype="html">
<source>Date created</source> <source>Date created</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">181</context> <context context-type="linenumber">179</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5607669932062416162" datatype="html"> <trans-unit id="5607669932062416162" datatype="html">
<source>Default</source> <source>Default</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">193</context> <context context-type="linenumber">191</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context> <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
<context context-type="linenumber">71</context> <context context-type="linenumber">71</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6205355627445317276" datatype="html">
<source>Content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">298,299</context>
</context-group>
</trans-unit>
<trans-unit id="218403386307979629" datatype="html"> <trans-unit id="218403386307979629" datatype="html">
<source>Metadata</source> <source>Metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">309,310</context> <context context-type="linenumber">307,308</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
@@ -8543,235 +8520,228 @@
<source>Date modified</source> <source>Date modified</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">316,317</context> <context context-type="linenumber">314,315</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6392918669949841614" datatype="html"> <trans-unit id="6392918669949841614" datatype="html">
<source>Date added</source> <source>Date added</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">320,321</context> <context context-type="linenumber">318,319</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="146828917013192897" datatype="html"> <trans-unit id="146828917013192897" datatype="html">
<source>Media filename</source> <source>Media filename</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">324,325</context> <context context-type="linenumber">322,323</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4500855521601039868" datatype="html"> <trans-unit id="4500855521601039868" datatype="html">
<source>Original filename</source> <source>Original filename</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">328,329</context> <context context-type="linenumber">326,327</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2659735245739197634" datatype="html"> <trans-unit id="2659735245739197634" datatype="html">
<source>Original SHA256 checksum</source> <source>Original SHA256 checksum</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">332,333</context> <context context-type="linenumber">330,331</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5888243105821763422" datatype="html"> <trans-unit id="5888243105821763422" datatype="html">
<source>Original file size</source> <source>Original file size</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">336,337</context> <context context-type="linenumber">334,335</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2696647325713149563" datatype="html"> <trans-unit id="2696647325713149563" datatype="html">
<source>Original mime type</source> <source>Original mime type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">340,341</context> <context context-type="linenumber">338,339</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6714358112223607756" datatype="html"> <trans-unit id="6714358112223607756" datatype="html">
<source>Archive SHA256 checksum</source> <source>Archive SHA256 checksum</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">345,346</context> <context context-type="linenumber">343,344</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6033581412811562084" datatype="html"> <trans-unit id="6033581412811562084" datatype="html">
<source>Archive file size</source> <source>Archive file size</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">351,352</context> <context context-type="linenumber">349,350</context>
</context-group>
</trans-unit>
<trans-unit id="8459338343197260257" datatype="html">
<source>Barcodes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">360,361</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6992781481378431874" datatype="html"> <trans-unit id="6992781481378431874" datatype="html">
<source>Original document metadata</source> <source>Original document metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">364</context> <context context-type="linenumber">358</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2846565152091361585" datatype="html"> <trans-unit id="2846565152091361585" datatype="html">
<source>Archived document metadata</source> <source>Archived document metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">367</context> <context context-type="linenumber">361</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7206723502037428235" datatype="html"> <trans-unit id="7206723502037428235" datatype="html">
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document()?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{document().notes.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> <source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document()?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{document().notes.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">386,389</context> <context context-type="linenumber">380,383</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="186236568870281953" datatype="html"> <trans-unit id="186236568870281953" datatype="html">
<source>History</source> <source>History</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">397,398</context> <context context-type="linenumber">391,392</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8236092845697214347" datatype="html"> <trans-unit id="8236092845697214347" datatype="html">
<source> Duplicates <x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{ document().duplicate_documents.length }}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/></source> <source> Duplicates <x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{ document().duplicate_documents.length }}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">420,423</context> <context context-type="linenumber">414,417</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6449374629822973702" datatype="html"> <trans-unit id="6449374629822973702" datatype="html">
<source>Duplicate documents detected:</source> <source>Duplicate documents detected:</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">425,426</context> <context context-type="linenumber">419,420</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="14058600336670816" datatype="html"> <trans-unit id="14058600336670816" datatype="html">
<source>In trash</source> <source>In trash</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">436,437</context> <context context-type="linenumber">430,431</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5129524307369213584" datatype="html"> <trans-unit id="5129524307369213584" datatype="html">
<source>Save &amp; next</source> <source>Save &amp; next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">465</context> <context context-type="linenumber">459</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4910102545766233758" datatype="html"> <trans-unit id="4910102545766233758" datatype="html">
<source>Save &amp; close</source> <source>Save &amp; close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">467</context> <context context-type="linenumber">461</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3823219296477075982" datatype="html"> <trans-unit id="3823219296477075982" datatype="html">
<source>Discard</source> <source>Discard</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">469,470</context> <context context-type="linenumber">463,464</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1309556917227148591" datatype="html"> <trans-unit id="1309556917227148591" datatype="html">
<source>Document loading...</source> <source>Document loading...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">477</context> <context context-type="linenumber">471</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8191371354890763172" datatype="html"> <trans-unit id="8191371354890763172" datatype="html">
<source>Enter Password</source> <source>Enter Password</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">531</context> <context context-type="linenumber">525</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5758784066858623886" datatype="html"> <trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source> <source>Error retrieving metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">463</context> <context context-type="linenumber">461</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2218903673684131427" datatype="html"> <trans-unit id="2218903673684131427" datatype="html">
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source> <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">566,568</context> <context context-type="linenumber">564,566</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1047,1049</context> <context context-type="linenumber">1045,1047</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6357361810318120957" datatype="html"> <trans-unit id="6357361810318120957" datatype="html">
<source>Document was updated</source> <source>Document was updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">708</context> <context context-type="linenumber">706</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5154064822428631306" datatype="html"> <trans-unit id="5154064822428631306" datatype="html">
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source> <source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">709</context> <context context-type="linenumber">707</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8462497568316256794" datatype="html"> <trans-unit id="8462497568316256794" datatype="html">
<source>Reload to discard your local unsaved edits and load the latest remote version.</source> <source>Reload to discard your local unsaved edits and load the latest remote version.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">710</context> <context context-type="linenumber">708</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7967484035994732534" datatype="html"> <trans-unit id="7967484035994732534" datatype="html">
<source>Reload</source> <source>Reload</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">712</context> <context context-type="linenumber">710</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2907037627372942104" datatype="html"> <trans-unit id="2907037627372942104" datatype="html">
<source>Document reloaded with latest changes.</source> <source>Document reloaded with latest changes.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">768</context> <context context-type="linenumber">766</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6435639868943916539" datatype="html"> <trans-unit id="6435639868943916539" datatype="html">
<source>Document reloaded.</source> <source>Document reloaded.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">779</context> <context context-type="linenumber">777</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6142395741265832184" datatype="html"> <trans-unit id="6142395741265832184" datatype="html">
<source>Next document</source> <source>Next document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">881</context> <context context-type="linenumber">879</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="651985345816518480" datatype="html"> <trans-unit id="651985345816518480" datatype="html">
<source>Previous document</source> <source>Previous document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">891</context> <context context-type="linenumber">889</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2885986061416655600" datatype="html"> <trans-unit id="2885986061416655600" datatype="html">
<source>Close document</source> <source>Close document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">899</context> <context context-type="linenumber">897</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
@@ -8782,28 +8752,28 @@
<source>Save document</source> <source>Save document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">906</context> <context context-type="linenumber">904</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1784543155727940353" datatype="html"> <trans-unit id="1784543155727940353" datatype="html">
<source>Save and close / next</source> <source>Save and close / next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">915</context> <context context-type="linenumber">913</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7427704425579737895" datatype="html"> <trans-unit id="7427704425579737895" datatype="html">
<source>Error retrieving version content</source> <source>Error retrieving version content</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1029</context> <context context-type="linenumber">1027</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="159901853873315050" datatype="html"> <trans-unit id="159901853873315050" datatype="html">
<source>Unsaved Changes</source> <source>Unsaved Changes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1073</context> <context context-type="linenumber">1071</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context> <context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context>
@@ -8826,74 +8796,74 @@
<source>You have unsaved changes to the content of this version.</source> <source>You have unsaved changes to the content of this version.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1074</context> <context context-type="linenumber">1072</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="85184271222513014" datatype="html"> <trans-unit id="85184271222513014" datatype="html">
<source>Switching versions will discard them.</source> <source>Switching versions will discard them.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1075</context> <context context-type="linenumber">1073</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2565707334844767610" datatype="html"> <trans-unit id="2565707334844767610" datatype="html">
<source>Discard and switch</source> <source>Discard and switch</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1077</context> <context context-type="linenumber">1075</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2109314380040637387" datatype="html"> <trans-unit id="2109314380040637387" datatype="html">
<source>Save and switch</source> <source>Save and switch</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1079</context> <context context-type="linenumber">1077</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3456881259945295697" datatype="html"> <trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source> <source>Error retrieving suggestions.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1122</context> <context context-type="linenumber">1120</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2194092841814123758" datatype="html"> <trans-unit id="2194092841814123758" datatype="html">
<source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source> <source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1353</context> <context context-type="linenumber">1347</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1381</context> <context context-type="linenumber">1375</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6626387786259219838" datatype="html"> <trans-unit id="6626387786259219838" datatype="html">
<source>Error saving document &quot;<x id="PH" equiv-text="this.document().title"/>&quot;</source> <source>Error saving document &quot;<x id="PH" equiv-text="this.document().title"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1387</context> <context context-type="linenumber">1381</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="448882439049417053" datatype="html"> <trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source> <source>Error saving document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1442</context> <context context-type="linenumber">1436</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8410796510716511826" datatype="html"> <trans-unit id="8410796510716511826" datatype="html">
<source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document().title"/>&quot; to the trash?</source> <source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document().title"/>&quot; to the trash?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1475</context> <context context-type="linenumber">1469</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="282586936710748252" datatype="html"> <trans-unit id="282586936710748252" datatype="html">
<source>Documents can be restored prior to permanent deletion.</source> <source>Documents can be restored prior to permanent deletion.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1476</context> <context context-type="linenumber">1470</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8904,14 +8874,14 @@
<source>Error deleting document</source> <source>Error deleting document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1497</context> <context context-type="linenumber">1491</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="619486176823357521" datatype="html"> <trans-unit id="619486176823357521" datatype="html">
<source>Reprocess confirm</source> <source>Reprocess confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1517</context> <context context-type="linenumber">1511</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8922,102 +8892,102 @@
<source>This operation will permanently recreate the archive file for this document.</source> <source>This operation will permanently recreate the archive file for this document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1518</context> <context context-type="linenumber">1512</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="302054111564709516" datatype="html"> <trans-unit id="302054111564709516" datatype="html">
<source>The archive file will be re-generated with the current settings.</source> <source>The archive file will be re-generated with the current settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1519</context> <context context-type="linenumber">1513</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4700389117298802932" datatype="html"> <trans-unit id="4700389117298802932" datatype="html">
<source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source> <source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1532</context> <context context-type="linenumber">1526</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4409560272830824468" datatype="html"> <trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source> <source>Error executing operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1543</context> <context context-type="linenumber">1537</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6030453331794586802" datatype="html"> <trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source> <source>Error downloading document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1607</context> <context context-type="linenumber">1601</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4458954481601077369" datatype="html"> <trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source> <source>Page Fit</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1685</context> <context context-type="linenumber">1679</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4663705961777238777" datatype="html"> <trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source> <source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1928</context> <context context-type="linenumber">1922</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9043972994040261999" datatype="html"> <trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</source> <source>Error executing PDF edit operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1940</context> <context context-type="linenumber">1934</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6172690334763056188" datatype="html"> <trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source> <source>Please enter the current password before attempting to remove it.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1951</context> <context context-type="linenumber">1945</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="968660764814228922" datatype="html"> <trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source> <source>Password removal operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1985</context> <context context-type="linenumber">1979</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2282118435712883014" datatype="html"> <trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source> <source>Error executing password removal operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1999</context> <context context-type="linenumber">1993</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3740891324955700797" datatype="html"> <trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source> <source>Print failed.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2049</context> <context context-type="linenumber">2043</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6457245677384603573" datatype="html"> <trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source> <source>Error loading document for printing.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2058</context> <context context-type="linenumber">2052</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6085793215710522488" datatype="html"> <trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source> <source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2141</context> <context context-type="linenumber">2135</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2147</context> <context context-type="linenumber">2141</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">
@@ -12083,130 +12053,123 @@
<context context-type="linenumber">328</context> <context context-type="linenumber">328</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="906679600349814230" datatype="html">
<source>Store Barcode Contents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">335</context>
</context-group>
</trans-unit>
<trans-unit id="7011909364081812031" datatype="html"> <trans-unit id="7011909364081812031" datatype="html">
<source>AI Enabled</source> <source>AI Enabled</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">342</context> <context context-type="linenumber">335</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8028880048909383956" datatype="html"> <trans-unit id="8028880048909383956" datatype="html">
<source>Consider privacy implications when enabling AI features, especially if using a remote model.</source> <source>Consider privacy implications when enabling AI features, especially if using a remote model.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">346</context> <context context-type="linenumber">339</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8131374115579345652" datatype="html"> <trans-unit id="8131374115579345652" datatype="html">
<source>LLM Embedding Backend</source> <source>LLM Embedding Backend</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">350</context> <context context-type="linenumber">343</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6647708571891295756" datatype="html"> <trans-unit id="6647708571891295756" datatype="html">
<source>LLM Embedding Model</source> <source>LLM Embedding Model</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">358</context> <context context-type="linenumber">351</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="861068592166833023" datatype="html"> <trans-unit id="861068592166833023" datatype="html">
<source>LLM Embedding API Key</source> <source>LLM Embedding API Key</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">365</context> <context context-type="linenumber">358</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2929108042259892948" datatype="html"> <trans-unit id="2929108042259892948" datatype="html">
<source>Used for embeddings when set, otherwise LLM API key is used.</source> <source>Used for embeddings when set, otherwise LLM API key is used.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">367</context> <context context-type="linenumber">360</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3554114880473286122" datatype="html"> <trans-unit id="3554114880473286122" datatype="html">
<source>LLM Embedding Endpoint</source> <source>LLM Embedding Endpoint</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">373</context> <context context-type="linenumber">366</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1044242175651289991" datatype="html"> <trans-unit id="1044242175651289991" datatype="html">
<source>LLM Embedding Chunk Size</source> <source>LLM Embedding Chunk Size</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">380</context> <context context-type="linenumber">373</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7218245223139363113" datatype="html"> <trans-unit id="7218245223139363113" datatype="html">
<source>LLM Context Size</source> <source>LLM Context Size</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">387</context> <context context-type="linenumber">380</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4234495692726214397" datatype="html"> <trans-unit id="4234495692726214397" datatype="html">
<source>LLM Backend</source> <source>LLM Backend</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">394</context> <context context-type="linenumber">387</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7935234833834000002" datatype="html"> <trans-unit id="7935234833834000002" datatype="html">
<source>LLM Model</source> <source>LLM Model</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">402</context> <context context-type="linenumber">395</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1980550530387803165" datatype="html"> <trans-unit id="1980550530387803165" datatype="html">
<source>LLM API Key</source> <source>LLM API Key</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">409</context> <context context-type="linenumber">402</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6126617860376156501" datatype="html"> <trans-unit id="6126617860376156501" datatype="html">
<source>LLM Endpoint</source> <source>LLM Endpoint</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">416</context> <context context-type="linenumber">409</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6572826277249350975" datatype="html"> <trans-unit id="6572826277249350975" datatype="html">
<source>LLM Output Language</source> <source>LLM Output Language</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">423</context> <context context-type="linenumber">416</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3284403507172415792" datatype="html"> <trans-unit id="3284403507172415792" datatype="html">
<source>Language to use for generated AI suggestions. When unset, AI suggestions use the user&apos;s display language if explicitly set.</source> <source>Language to use for generated AI suggestions. When unset, AI suggestions use the user&apos;s display language if explicitly set.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">427</context> <context context-type="linenumber">420</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4493921125434706859" datatype="html"> <trans-unit id="4493921125434706859" datatype="html">
<source>LLM Request Timeout</source> <source>LLM Request Timeout</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">431</context> <context context-type="linenumber">424</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="483994032066441287" datatype="html"> <trans-unit id="483994032066441287" datatype="html">
<source>Timeout in seconds for LLM requests.</source> <source>Timeout in seconds for LLM requests.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">435</context> <context context-type="linenumber">428</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1055686627716339120" datatype="html"> <trans-unit id="1055686627716339120" datatype="html">
@@ -26,7 +26,7 @@
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown"> <div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0"> <div class="list-group list-group-flush small pb-0">
@if (novelSuggestions === 0 && fieldSuggestions === 0) { @if (novelSuggestions === 0 && reusableSuggestions === 0) {
<div class="list-group-item text-muted fst-italic"> <div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small> <small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div> </div>
@@ -49,9 +49,9 @@
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button> <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
} }
} }
@if (fieldSuggestions > 0) { @if (reusableSuggestions > 0) {
<div class="list-group-item text-muted fst-italic"> <div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small> <small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
</div> </div>
} }
</div> </div>
@@ -84,7 +84,7 @@ describe('SuggestionsDropdownComponent', () => {
fixture.detectChanges() fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain( expect(fixture.nativeElement.textContent).toContain(
'2 suggestions available below' '2 existing values suggested below'
) )
expect(fixture.nativeElement.textContent).not.toContain( expect(fixture.nativeElement.textContent).not.toContain(
'No novel suggestions' 'No novel suggestions'
@@ -108,7 +108,7 @@ describe('SuggestionsDropdownComponent', () => {
expect(component.totalSuggestions).toBe(4) expect(component.totalSuggestions).toBe(4)
expect(fixture.nativeElement.textContent).toContain('Arbitration') expect(fixture.nativeElement.textContent).toContain('Arbitration')
expect(fixture.nativeElement.textContent).toContain( expect(fixture.nativeElement.textContent).toContain(
'2 suggestions available below' '2 existing values suggested below'
) )
}) })
@@ -125,46 +125,10 @@ describe('SuggestionsDropdownComponent', () => {
}) })
expect(component.novelSuggestions).toBe(0) expect(component.novelSuggestions).toBe(0)
expect(component.totalSuggestions).toBe(6) expect(component.totalSuggestions).toBe(5)
fixture.componentRef.setInput('appliedStoragePath', 7) fixture.componentRef.setInput('appliedStoragePath', 7)
expect(component.totalSuggestions).toBe(5) expect(component.totalSuggestions).toBe(4)
})
it('should count title and dates as field suggestions without calling them existing values', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', {
title: 'Suggested title',
dates: ['2026-01-04', '2026-02-01', '2026-03-01'],
correspondents: [1, 2, 3, 4],
document_types: [1, 2, 3, 4],
tags: [1, 2, 3, 4, 5, 6, 7, 8],
})
fixture.detectChanges()
component.clickSuggest()
fixture.detectChanges()
expect(component.reusableSuggestions).toBe(16)
expect(component.fieldSuggestions).toBe(20)
expect(component.totalSuggestions).toBe(20)
expect(fixture.nativeElement.textContent).toContain(
'20 suggestions available below'
)
expect(fixture.nativeElement.textContent).not.toContain('existing value')
})
it('should not count title or date suggestions matching the current values', () => {
fixture.componentRef.setInput('suggestions', {
title: 'Current title',
dates: ['2026-01-04', '2026-02-01'],
})
expect(component.fieldSuggestions).toBe(3)
fixture.componentRef.setInput('appliedTitle', 'Current title')
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
expect(component.fieldSuggestions).toBe(1)
expect(component.totalSuggestions).toBe(1)
}) })
it('should show when a completed request returned no suggestions', () => { it('should show when a completed request returned no suggestions', () => {
@@ -181,28 +145,6 @@ describe('SuggestionsDropdownComponent', () => {
expect(fixture.nativeElement.textContent).toContain('No suggestions') expect(fixture.nativeElement.textContent).toContain('No suggestions')
}) })
it('should wait for all pending responses before showing the empty state', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('source', SuggestionSource.Both)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', { tags: [] })
fixture.componentRef.setInput('loading', true)
fixture.detectChanges()
expect(component.noSuggestions).toBeFalsy()
expect(fixture.nativeElement.textContent).not.toContain('No suggestions')
expect(
fixture.nativeElement.querySelector('[role="status"]')
).not.toBeNull()
fixture.componentRef.setInput('loading', false)
fixture.detectChanges()
expect(component.noSuggestions).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No suggestions')
expect(fixture.nativeElement.querySelector('[role="status"]')).toBeNull()
})
it('should not show the empty state before a request or with suggestions', () => { it('should not show the empty state before a request or with suggestions', () => {
expect(component.noSuggestions).toBeFalsy() expect(component.noSuggestions).toBeFalsy()
@@ -34,8 +34,6 @@ export class SuggestionsDropdownComponent {
readonly appliedCorrespondent = input<number>(null) readonly appliedCorrespondent = input<number>(null)
readonly appliedDocumentType = input<number>(null) readonly appliedDocumentType = input<number>(null)
readonly appliedStoragePath = input<number>(null) readonly appliedStoragePath = input<number>(null)
readonly appliedTitle = input<string>(null)
readonly appliedCreated = input<string>(null)
@Output() @Output()
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter() getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
@@ -130,28 +128,7 @@ export class SuggestionsDropdownComponent {
} }
get totalSuggestions(): number { get totalSuggestions(): number {
return this.novelSuggestions + this.fieldSuggestions return this.novelSuggestions + this.reusableSuggestions
}
get fieldSuggestions(): number {
return (
this.reusableSuggestions +
this.unappliedTitleSuggestions +
this.unappliedDateSuggestions
)
}
// hide a title or date suggestion equal to the current value
private get unappliedTitleSuggestions(): number {
const title = this.suggestions()?.title
return title && title !== this.appliedTitle() ? 1 : 0
}
private get unappliedDateSuggestions(): number {
const created = this.appliedCreated()
return (this.suggestions()?.dates ?? []).filter(
(date) => !created || date !== created
).length
} }
private countUnapplied(suggested: number[], applied: number[]): number { private countUnapplied(suggested: number[], applied: number[]): number {
@@ -162,7 +139,6 @@ export class SuggestionsDropdownComponent {
get noSuggestions(): boolean { get noSuggestions(): boolean {
const suggestions = this.suggestions() const suggestions = this.suggestions()
return ( return (
!this.loading() &&
suggestions != null && suggestions != null &&
!suggestions.title && !suggestions.title &&
!suggestions.tags?.length && !suggestions.tags?.length &&
@@ -1,46 +0,0 @@
<table class="table table-borderless align-baseline">
<thead>
<tr>
<th i18n>Page</th>
<th i18n>Type</th>
<th i18n>Content</th>
<th></th>
</tr>
</thead>
<tbody>
@for (barcode of barcodes(); track $index) {
<tr>
<td>{{ barcode.page }}</td>
<td class="text-nowrap">{{ barcode.format }}</td>
<td class="text-break">
@if (isLink(barcode.value)) {
<a
[href]="barcode.value"
target="_blank"
rel="noopener noreferrer nofollow"
>{{ barcode.value }}</a
>
} @else {
{{ barcode.value }}
}
</td>
<td class="text-end">
<button
type="button"
class="btn btn-sm btn-outline-primary"
(click)="copy($index)"
title="Copy content"
i18n-title
>
@if (copiedIndex() === $index) {
<i-bs name="clipboard-check"></i-bs>
} @else {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy content</span>
</button>
</td>
</tr>
}
</tbody>
</table>
@@ -1,73 +0,0 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentBarcodesComponent } from './document-barcodes.component'
const barcodes = [
{ page: 1, value: 'ASN00123', format: 'Code128' },
{ page: 2, value: 'https://example.com/invoice/4711', format: 'QRCode' },
{ page: 2, value: 'javascript:alert(1)', format: 'QRCode' },
]
describe('DocumentBarcodesComponent', () => {
let component: DocumentBarcodesComponent
let fixture: ComponentFixture<DocumentBarcodesComponent>
let clipboard: Clipboard
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
DocumentBarcodesComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
fixture = TestBed.createComponent(DocumentBarcodesComponent)
component = fixture.componentInstance
clipboard = TestBed.inject(Clipboard)
fixture.componentRef.setInput('barcodes', barcodes)
fixture.detectChanges()
})
it('should display all barcodes', () => {
const rows = fixture.nativeElement.querySelectorAll('tbody tr')
expect(rows).toHaveLength(3)
expect(rows[0].textContent).toContain('ASN00123')
expect(rows[0].textContent).toContain('Code128')
})
it('should only link http(s) values', () => {
const links = fixture.nativeElement.querySelectorAll('tbody a')
expect(links).toHaveLength(1)
expect(links[0].getAttribute('href')).toEqual(
'https://example.com/invoice/4711'
)
expect(links[0].getAttribute('target')).toEqual('_blank')
})
it('should copy a value and show feedback', () => {
jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
const buttons = fixture.nativeElement.querySelectorAll('tbody button')
buttons[0].click()
fixture.detectChanges()
expect(copySpy).toHaveBeenCalledWith('ASN00123')
expect(component.copiedIndex()).toEqual(0)
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
'clipboard-check'
)
jest.advanceTimersByTime(3000)
fixture.detectChanges()
expect(component.copiedIndex()).toBeNull()
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
'clipboard'
)
jest.useRealTimers()
})
it('should not show feedback if copying failed', () => {
jest.spyOn(clipboard, 'copy').mockReturnValue(false)
component.copy(1)
expect(component.copiedIndex()).toBeNull()
})
})
@@ -1,38 +0,0 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, inject, input, OnDestroy, signal } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentBarcode } from 'src/app/data/document-barcode'
@Component({
selector: 'pngx-document-barcodes',
templateUrl: './document-barcodes.component.html',
imports: [NgxBootstrapIconsModule],
})
export class DocumentBarcodesComponent implements OnDestroy {
private readonly clipboard = inject(Clipboard)
readonly barcodes = input<DocumentBarcode[]>([])
readonly copiedIndex = signal<number>(null)
private copyTimeout: ReturnType<typeof setTimeout>
public isLink(value: string): boolean {
try {
const url = new URL(value.trim())
return ['http:', 'https:'].includes(url.protocol) && !!url.host
} catch {
return false
}
}
public copy(index: number) {
if (!this.clipboard.copy(this.barcodes()[index].value)) return
this.copiedIndex.set(index)
clearTimeout(this.copyTimeout)
this.copyTimeout = setTimeout(() => this.copiedIndex.set(null), 3000)
}
ngOnDestroy(): void {
clearTimeout(this.copyTimeout)
}
}
@@ -141,8 +141,6 @@
[appliedCorrespondent]="documentForm.value.correspondent" [appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type" [appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path" [appliedStoragePath]="documentForm.value.storage_path"
[appliedTitle]="documentForm.value.title"
[appliedCreated]="documentForm.value.created"
(getSuggestions)="getSuggestions($event)" (getSuggestions)="getSuggestions($event)"
(sourceChange)="suggestionSourceOverride.set($event)" (sourceChange)="suggestionSourceOverride.set($event)"
(addTag)="createTag($event)" (addTag)="createTag($event)"
@@ -356,10 +354,6 @@
</table> </table>
} }
@if (metadata()?.barcodes?.length > 0) {
<h6 i18n>Barcodes</h6>
<pngx-document-barcodes [barcodes]="metadata().barcodes"></pngx-document-barcodes>
}
@if (metadata()?.original_metadata?.length > 0) { @if (metadata()?.original_metadata?.length > 0) {
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata()?.original_metadata"></pngx-metadata-collapse> <pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata()?.original_metadata"></pngx-metadata-collapse>
} }
@@ -662,45 +662,6 @@ describe('DocumentDetailComponent', () => {
) )
}) })
it.each([
['tag', 'createTag', 'tags', 'suggested_tags'],
[
'document type',
'createDocumentType',
'document_type',
'suggested_document_types',
],
[
'correspondent',
'createCorrespondent',
'correspondent',
'suggested_correspondents',
],
])(
'should create a %s after ML-only suggestions',
(_, method, field, suggestedField) => {
initNormally()
component.suggestions.set({ tags: [1] })
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component[method]('New value')
openModal.componentInstance.succeeded.next({
id: 12,
name: 'New value',
is_inbox_tag: false,
color: '#ff0000',
text_color: '#000000',
})
if (field === 'tags') {
expect(component.tagsInput.value).toContain(12)
} else {
expect(component.documentForm.get(field).value).toBe(12)
}
expect(component.suggestions()[suggestedField]).toEqual([])
}
)
it('should support creating storage path', () => { it('should support creating storage path', () => {
initNormally() initNormally()
let openModal: NgbModalRef let openModal: NgbModalRef
@@ -135,7 +135,6 @@ import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-li
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component' import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component' import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { DocumentBarcodesComponent } from './document-barcodes/document-barcodes.component'
import { DocumentHistoryComponent } from './document-history/document-history.component' import { DocumentHistoryComponent } from './document-history/document-history.component'
import { DocumentVersionDropdownComponent } from './document-version-dropdown/document-version-dropdown.component' import { DocumentVersionDropdownComponent } from './document-version-dropdown/document-version-dropdown.component'
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component' import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
@@ -178,7 +177,6 @@ interface IncomingDocumentUpdate {
DateComponent, DateComponent,
DocumentLinkComponent, DocumentLinkComponent,
MetadataCollapseComponent, MetadataCollapseComponent,
DocumentBarcodesComponent,
PermissionsFormComponent, PermissionsFormComponent,
SelectComponent, SelectComponent,
TagsComponent, TagsComponent,
@@ -1154,7 +1152,7 @@ export class DocumentDetailComponent
if (this.suggestions()) { if (this.suggestions()) {
this.suggestions.set({ this.suggestions.set({
...this.suggestions(), ...this.suggestions(),
suggested_tags: (this.suggestions().suggested_tags ?? []).filter( suggested_tags: this.suggestions().suggested_tags.filter(
(tag) => tag !== newTag.name (tag) => tag !== newTag.name
), ),
}) })
@@ -1193,12 +1191,10 @@ export class DocumentDetailComponent
this.documentForm.get('document_type').setValue(newDocumentType.id) this.documentForm.get('document_type').setValue(newDocumentType.id)
this.documentForm.get('document_type').markAsDirty() this.documentForm.get('document_type').markAsDirty()
if (this.suggestions()) { if (this.suggestions()) {
this.suggestions.set({ this.suggestions().suggested_document_types =
...this.suggestions(), this.suggestions().suggested_document_types.filter(
suggested_document_types: ( (dt) => dt !== newName
this.suggestions().suggested_document_types ?? [] )
).filter((dt) => dt !== newName),
})
} }
}) })
} }
@@ -1225,12 +1221,10 @@ export class DocumentDetailComponent
this.documentForm.get('correspondent').setValue(newCorrespondent.id) this.documentForm.get('correspondent').setValue(newCorrespondent.id)
this.documentForm.get('correspondent').markAsDirty() this.documentForm.get('correspondent').markAsDirty()
if (this.suggestions()) { if (this.suggestions()) {
this.suggestions.set({ this.suggestions().suggested_correspondents =
...this.suggestions(), this.suggestions().suggested_correspondents.filter(
suggested_correspondents: ( (c) => c !== newName
this.suggestions().suggested_correspondents ?? [] )
).filter((c) => c !== newName),
})
} }
}) })
} }
-7
View File
@@ -1,7 +0,0 @@
export interface DocumentBarcode {
page: number
value: string
format: string
}
-4
View File
@@ -1,5 +1,3 @@
import { DocumentBarcode } from './document-barcode'
export interface DocumentMetadata { export interface DocumentMetadata {
original_checksum?: string original_checksum?: string
@@ -14,6 +12,4 @@ export interface DocumentMetadata {
has_archive_version?: boolean has_archive_version?: boolean
lang?: string lang?: string
barcodes?: DocumentBarcode[]
} }
-8
View File
@@ -330,13 +330,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT', config_key: 'PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT',
category: ConfigCategory.Barcode, category: ConfigCategory.Barcode,
}, },
{
key: 'barcode_store_values',
title: $localize`Store Barcode Contents`,
type: ConfigOptionType.Boolean,
config_key: 'PAPERLESS_CONSUMER_STORE_BARCODE_VALUES',
category: ConfigCategory.Barcode,
},
{ {
key: 'ai_enabled', key: 'ai_enabled',
title: $localize`AI Enabled`, title: $localize`AI Enabled`,
@@ -465,7 +458,6 @@ export interface PaperlessConfig extends ObjectWithId {
barcode_enable_tag: boolean barcode_enable_tag: boolean
barcode_tag_mapping: object barcode_tag_mapping: object
barcode_tag_split: boolean barcode_tag_split: boolean
barcode_store_values: boolean
remote_ocr_engine: string remote_ocr_engine: string
remote_ocr_api_key: string remote_ocr_api_key: string
remote_ocr_endpoint: string remote_ocr_endpoint: string
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+74 -126
View File
@@ -18,7 +18,6 @@ from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.data_models import StoredBarcode
from documents.models import Document from documents.models import Document
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.models import Tag from documents.models import Tag
@@ -48,7 +47,6 @@ class Barcode:
page: int page: int
value: str value: str
settings: BarcodeConfig settings: BarcodeConfig
format: str = ""
@property @property
def is_separator(self) -> bool: def is_separator(self) -> bool:
@@ -80,12 +78,6 @@ class Barcode:
return True return True
return False return False
def stored(self) -> StoredBarcode:
"""
The barcode as it is stored with a document, page 1-indexed
"""
return {"page": self.page + 1, "value": self.value, "format": self.format}
class BarcodePlugin(ConsumeTaskPlugin): class BarcodePlugin(ConsumeTaskPlugin):
NAME: str = "BarcodePlugin" NAME: str = "BarcodePlugin"
@@ -97,12 +89,16 @@ class BarcodePlugin(ConsumeTaskPlugin):
- ASN from barcode detection is enabled or - ASN from barcode detection is enabled or
- Barcode support is enabled and the mime type is supported - Barcode support is enabled and the mime type is supported
""" """
if self.settings.barcode_enable_tiff_support:
supported_mimes: set[str] = {"application/pdf", "image/tiff"}
else:
supported_mimes = {"application/pdf"}
return ( return (
self.settings.barcode_enable_asn self.settings.barcode_enable_asn
or self.settings.barcodes_enabled or self.settings.barcodes_enabled
or self.settings.barcode_enable_tag or self.settings.barcode_enable_tag
or self.settings.barcode_store_values ) and self.input_doc.mime_type in supported_mimes
) and self.input_doc.mime_type in scannable_mime_types(self.settings)
def get_settings(self) -> BarcodeConfig: def get_settings(self) -> BarcodeConfig:
""" """
@@ -248,10 +244,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None: if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None:
self._apply_detected_asn(located_asn) self._apply_detected_asn(located_asn)
# After splitting too, so each split document keeps its own barcodes
if self.settings.barcode_store_values:
self.metadata.barcodes = [x.stored() for x in self.barcodes] or None
def cleanup(self) -> None: def cleanup(self) -> None:
self.temp_dir.cleanup() self.temp_dir.cleanup()
@@ -270,6 +262,22 @@ class BarcodePlugin(ConsumeTaskPlugin):
) )
self._tiff_conversion_done = True self._tiff_conversion_done = True
@staticmethod
def read_barcodes_zxing(image: Image.Image) -> list[str]:
barcodes = []
import zxingcpp
detected_barcodes = zxingcpp.read_barcodes(image)
for barcode in detected_barcodes:
if barcode.text:
barcodes.append(barcode.text)
logger.debug(
f"Barcode of type {barcode.format} found: {barcode.text}",
)
return barcodes
def detect(self) -> None: def detect(self) -> None:
""" """
Scan all pages of the PDF as images, updating barcodes and the pages Scan all pages of the PDF as images, updating barcodes and the pages
@@ -283,12 +291,60 @@ class BarcodePlugin(ConsumeTaskPlugin):
self.convert_from_tiff_to_pdf() self.convert_from_tiff_to_pdf()
try: try:
self.barcodes = scan_pdf( # Read number of pages from pdf
self.pdf_file, with Pdf.open(self.pdf_file) as pdf:
self.settings, num_of_pages = len(pdf.pages)
Path(self.temp_dir.name), logger.debug(f"PDF has {num_of_pages} pages")
# Get limit from configuration
barcode_max_pages: int = (
num_of_pages
if self.settings.barcode_max_pages == 0
else self.settings.barcode_max_pages
) )
if barcode_max_pages < num_of_pages: # pragma: no cover
logger.debug(
f"Barcodes detection will be limited to the first {barcode_max_pages} pages",
)
# Loop al page
for current_page_number in range(min(num_of_pages, barcode_max_pages)):
logger.debug(f"Processing page {current_page_number}")
# Convert page to image
page = convert_from_path(
self.pdf_file,
dpi=self.settings.barcode_dpi,
output_folder=self.temp_dir.name,
first_page=current_page_number + 1,
last_page=current_page_number + 1,
)[0]
# Remember filename, since it is lost by upscaling
page_filepath = Path(page.filename)
logger.debug(f"Image is at {page_filepath}")
# Upscale image if configured
factor = self.settings.barcode_upscale
if factor > 1.0:
logger.debug(
f"Upscaling image by {factor} for better barcode detection",
)
x, y = page.size
page = page.resize(
(round(x * factor), (round(y * factor))),
)
# Detect barcodes
for barcode_value in self.read_barcodes_zxing(page):
self.barcodes.append(
Barcode(current_page_number, barcode_value, self.settings),
)
# Delete temporary image file
page_filepath.unlink()
# Password protected files can't be checked # Password protected files can't be checked
# This is the exception raised for those # This is the exception raised for those
except PasswordError as e: except PasswordError as e:
@@ -478,111 +534,3 @@ class BarcodePlugin(ConsumeTaskPlugin):
document_paths.append(savepath) document_paths.append(savepath)
return document_paths return document_paths
def scannable_mime_types(settings: BarcodeConfig) -> set[str]:
"""
The file types the barcode scan supports with the current settings
"""
if settings.barcode_enable_tiff_support:
return {"application/pdf", "image/tiff"}
return {"application/pdf"}
def read_barcodes_zxing(image: Image.Image) -> list[tuple[str, str]]:
"""
Returns the text and format (zxing enum name) of each barcode found in
the image
"""
barcodes = []
import zxingcpp
detected_barcodes = zxingcpp.read_barcodes(image)
for barcode in detected_barcodes:
if barcode.text:
barcodes.append((barcode.text, barcode.format.name))
logger.debug(
f"Barcode of type {barcode.format} found: {barcode.text}",
)
return barcodes
def scan_pdf(pdf_path: Path, settings: BarcodeConfig, work_dir: Path) -> list[Barcode]:
"""
Scans the pages of a PDF as images for barcodes. Errors are not caught,
so callers can tell a failed scan from one that found nothing.
"""
barcodes: list[Barcode] = []
with Pdf.open(pdf_path) as pdf:
num_of_pages = len(pdf.pages)
logger.debug(f"PDF has {num_of_pages} pages")
# Get limit from configuration
barcode_max_pages: int = (
num_of_pages if settings.barcode_max_pages == 0 else settings.barcode_max_pages
)
if barcode_max_pages < num_of_pages: # pragma: no cover
logger.debug(
f"Barcodes detection will be limited to the first {barcode_max_pages} pages",
)
for current_page_number in range(min(num_of_pages, barcode_max_pages)):
logger.debug(f"Processing page {current_page_number}")
# Convert page to image
page = convert_from_path(
pdf_path,
dpi=settings.barcode_dpi,
output_folder=work_dir,
first_page=current_page_number + 1,
last_page=current_page_number + 1,
)[0]
# Remember filename, since it is lost by upscaling
page_filepath = Path(page.filename)
logger.debug(f"Image is at {page_filepath}")
# Upscale image if configured
factor = settings.barcode_upscale
if factor > 1.0:
logger.debug(
f"Upscaling image by {factor} for better barcode detection",
)
x, y = page.size
page = page.resize(
(round(x * factor), (round(y * factor))),
)
for barcode_value, barcode_format in read_barcodes_zxing(page):
barcodes.append(
Barcode(current_page_number, barcode_value, settings, barcode_format),
)
# Delete temporary image file
page_filepath.unlink()
return barcodes
def read_barcode_values(
path: Path,
mime_type: str,
settings: BarcodeConfig,
work_dir: Path,
) -> list[StoredBarcode] | None:
"""
Reads the barcodes of a file outside of the consumption plugins: for new
versions, which skip the barcode plugin, and when reprocessing.
Returns None if the file can't be scanned with the current settings.
Errors while scanning are raised.
"""
if mime_type not in scannable_mime_types(settings):
return None
if mime_type == "image/tiff":
path = convert_from_tiff_to_pdf(path, work_dir)
return [x.stored() for x in scan_pdf(path, settings, work_dir)]
-38
View File
@@ -18,7 +18,6 @@ from django.utils import timezone
from filelock import FileLock from filelock import FileLock
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from documents.barcodes import read_barcode_values
from documents.classifier import load_classifier from documents.classifier import load_classifier
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import ConsumeFileSuccessResult from documents.data_models import ConsumeFileSuccessResult
@@ -32,7 +31,6 @@ from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentBarcode
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
@@ -55,7 +53,6 @@ from documents.utils import compute_checksum
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess from documents.utils import run_subprocess
from paperless.config import BarcodeConfig
from paperless.config import OcrConfig from paperless.config import OcrConfig
from paperless.config import RemoteOCRConfig from paperless.config import RemoteOCRConfig
from paperless.models import ArchiveFileGenerationChoices from paperless.models import ArchiveFileGenerationChoices
@@ -507,13 +504,6 @@ class ConsumerPlugin(
f"Parser: {document_parser.name} v{document_parser.version}", f"Parser: {document_parser.name} v{document_parser.version}",
) )
# New versions skip the barcode plugin, so read their barcodes here
if (
self.input_doc.root_document_id is not None
and self.metadata.barcodes is None
):
self._read_version_barcodes(mime_type, Path(tmpdir))
# Parse the document. This may take some time. # Parse the document. This may take some time.
text = None text = None
@@ -641,8 +631,6 @@ class ConsumerPlugin(
else: else:
original_document.save() original_document.save()
self._store_barcodes(original_document)
# Adding a version changes the effective document, so update root modified # Adding a version changes the effective document, so update root modified
Document.objects.filter(pk=root_doc.pk).update( Document.objects.filter(pk=root_doc.pk).update(
modified=timezone.now(), modified=timezone.now(),
@@ -974,32 +962,6 @@ class ConsumerPlugin(
} }
CustomFieldInstance.objects.create(**args) # adds to document CustomFieldInstance.objects.create(**args) # adds to document
self._store_barcodes(document)
def _read_version_barcodes(self, mime_type: str, work_dir: Path) -> None:
barcode_settings = BarcodeConfig()
if not barcode_settings.barcode_store_values:
return
try:
self.metadata.barcodes = (
read_barcode_values(
self.working_copy,
mime_type,
barcode_settings,
work_dir,
)
or None
)
except Exception as e:
self.log.warning(f"Could not read barcodes of {self.filename}: {e}")
def _store_barcodes(self, document: Document) -> None:
if self.metadata.barcodes:
DocumentBarcode.objects.bulk_create(
DocumentBarcode(document=document, **barcode)
for barcode in self.metadata.barcodes
)
def _write(self, source, target) -> None: def _write(self, source, target) -> None:
with ( with (
Path(source).open("rb") as read_file, Path(source).open("rb") as read_file,
-11
View File
@@ -9,16 +9,6 @@ from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
class StoredBarcode(TypedDict):
"""
A detected barcode as it is stored with a document
"""
page: int # 1-indexed
value: str
format: str # a DocumentBarcode.Format value
@dataclasses.dataclass @dataclasses.dataclass
class DocumentMetadataOverrides: class DocumentMetadataOverrides:
""" """
@@ -45,7 +35,6 @@ class DocumentMetadataOverrides:
version_label: str | None = None version_label: str | None = None
actor_id: int | None = None actor_id: int | None = None
remote_ocr: bool = False remote_ocr: bool = False
barcodes: list[StoredBarcode] | None = None
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides": def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
""" """
@@ -45,7 +45,6 @@ from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentBarcode
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import Note from documents.models import Note
from documents.models import SavedView from documents.models import SavedView
@@ -354,7 +353,6 @@ class Command(CryptMixin, PaperlessCommand):
"workflows": Workflow.objects.all(), "workflows": Workflow.objects.all(),
"custom_fields": CustomField.objects.all(), "custom_fields": CustomField.objects.all(),
"custom_field_instances": CustomFieldInstance.global_objects.all(), "custom_field_instances": CustomFieldInstance.global_objects.all(),
"document_barcodes": DocumentBarcode.objects.all(),
"app_configs": ApplicationConfiguration.objects.all(), "app_configs": ApplicationConfiguration.objects.all(),
"notes": Note.global_objects.all(), "notes": Note.global_objects.all(),
"documents": Document.global_objects.order_by("id").all(), "documents": Document.global_objects.order_by("id").all(),
@@ -413,7 +411,6 @@ class Command(CryptMixin, PaperlessCommand):
elif self.split_manifest and key in ( elif self.split_manifest and key in (
"notes", "notes",
"custom_field_instances", "custom_field_instances",
"document_barcodes",
): ):
# Written per-document in _write_split_manifest # Written per-document in _write_split_manifest
pass pass
@@ -654,12 +651,6 @@ class Command(CryptMixin, PaperlessCommand):
CustomFieldInstance.global_objects.filter(document=document), CustomFieldInstance.global_objects.filter(document=document),
), ),
) )
content.extend(
serializers.serialize(
"python",
DocumentBarcode.objects.filter(document=document),
),
)
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json") manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
if self.use_folder_prefix: if self.use_folder_prefix:
manifest_name = Path("json") / manifest_name manifest_name = Path("json") / manifest_name
@@ -77,8 +77,6 @@ class Command(PaperlessCommand):
"notes__user", "notes__user",
"custom_fields__field", "custom_fields__field",
"versions", "versions",
"barcodes",
"versions__barcodes",
) )
total = documents.count() total = documents.count()
rebuild_kwargs = {} rebuild_kwargs = {}
@@ -1,102 +0,0 @@
# Generated by Django 5.2.16 on 2026-09-30 23:01
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0026_alter_document_archive_checksum_and_more"),
]
operations = [
migrations.CreateModel(
name="DocumentBarcode",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"page",
models.PositiveIntegerField(
help_text="Page of the original file, starting at 1",
verbose_name="page",
),
),
("value", models.TextField(verbose_name="value")),
(
"format",
models.CharField(
choices=[
("Codabar", "Codabar"),
("Code39", "Code 39"),
("Code39Std", "Code 39 Standard"),
("Code39Ext", "Code 39 Extended"),
("Code32", "Code 32"),
("PZN", "Pharmazentralnummer"),
("Code93", "Code 93"),
("Code128", "Code 128"),
("ITF", "ITF"),
("ITF14", "ITF-14"),
("DataBar", "DataBar"),
("DataBarOmni", "DataBar Omni"),
("DataBarStk", "DataBar Stacked"),
("DataBarStkOmni", "DataBar Stacked Omni"),
("DataBarLtd", "DataBar Limited"),
("DataBarExp", "DataBar Expanded"),
("DataBarExpStk", "DataBar Expanded Stacked"),
("EANUPC", "EAN/UPC"),
("EAN13", "EAN-13"),
("EAN8", "EAN-8"),
("EAN5", "EAN-5"),
("EAN2", "EAN-2"),
("ISBN", "ISBN"),
("UPCA", "UPC-A"),
("UPCE", "UPC-E"),
("Telepen", "Telepen"),
("TelepenAlpha", "Telepen Alpha"),
("TelepenNumeric", "Telepen Numeric"),
("OtherBarcode", "Other barcode"),
("DXFilmEdge", "DX Film Edge"),
("PDF417", "PDF417"),
("CompactPDF417", "Compact PDF417"),
("MicroPDF417", "MicroPDF417"),
("Aztec", "Aztec"),
("AztecCode", "Aztec Code"),
("AztecRune", "Aztec Rune"),
("QRCode", "QR Code"),
("QRCodeModel1", "QR Code Model 1"),
("QRCodeModel2", "QR Code Model 2"),
("MicroQRCode", "Micro QR Code"),
("RMQRCode", "rMQR Code"),
("DataMatrix", "Data Matrix"),
("MaxiCode", "MaxiCode"),
],
max_length=32,
verbose_name="format",
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="barcodes",
to="documents.document",
verbose_name="document",
),
),
],
options={
"verbose_name": "document barcode",
"verbose_name_plural": "document barcodes",
"ordering": ("page", "id"),
},
),
]
-91
View File
@@ -366,16 +366,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
res += f" {self.title}" res += f" {self.title}"
return res return res
def get_effective_barcodes(self) -> list["DocumentBarcode"]:
"""
Returns the stored barcodes for the document, like
get_effective_content(): for root documents those of the latest
version when there is one, as that is the file users see.
"""
from documents.versioning import latest_version
return list(latest_version(self).barcodes.all())
def get_effective_content(self) -> str | None: def get_effective_content(self) -> str | None:
""" """
Returns the effective content for the document. Returns the effective content for the document.
@@ -979,87 +969,6 @@ class Note(SoftDeleteModel):
return self.note return self.note
class DocumentBarcode(models.Model):
"""
A barcode found in a document during consumption, kept so its content
can be shown and copied
"""
document = models.ForeignKey(
Document,
related_name="barcodes",
on_delete=models.CASCADE,
verbose_name=_("document"),
)
page = models.PositiveIntegerField(
_("page"),
help_text=_("Page of the original file, starting at 1"),
)
value = models.TextField(_("value"))
class Format(models.TextChoices):
"""
The concrete barcode formats of zxing-cpp, keyed on the enum name.
The labels are symbology names and aren't translated.
"""
CODABAR = "Codabar", "Codabar"
CODE39 = "Code39", "Code 39"
CODE39_STD = "Code39Std", "Code 39 Standard"
CODE39_EXT = "Code39Ext", "Code 39 Extended"
CODE32 = "Code32", "Code 32"
PZN = "PZN", "Pharmazentralnummer"
CODE93 = "Code93", "Code 93"
CODE128 = "Code128", "Code 128"
ITF = "ITF", "ITF"
ITF14 = "ITF14", "ITF-14"
DATA_BAR = "DataBar", "DataBar"
DATA_BAR_OMNI = "DataBarOmni", "DataBar Omni"
DATA_BAR_STK = "DataBarStk", "DataBar Stacked"
DATA_BAR_STK_OMNI = "DataBarStkOmni", "DataBar Stacked Omni"
DATA_BAR_LTD = "DataBarLtd", "DataBar Limited"
DATA_BAR_EXP = "DataBarExp", "DataBar Expanded"
DATA_BAR_EXP_STK = "DataBarExpStk", "DataBar Expanded Stacked"
EANUPC = "EANUPC", "EAN/UPC"
EAN13 = "EAN13", "EAN-13"
EAN8 = "EAN8", "EAN-8"
EAN5 = "EAN5", "EAN-5"
EAN2 = "EAN2", "EAN-2"
ISBN = "ISBN", "ISBN"
UPCA = "UPCA", "UPC-A"
UPCE = "UPCE", "UPC-E"
TELEPEN = "Telepen", "Telepen"
TELEPEN_ALPHA = "TelepenAlpha", "Telepen Alpha"
TELEPEN_NUMERIC = "TelepenNumeric", "Telepen Numeric"
OTHER_BARCODE = "OtherBarcode", "Other barcode"
DX_FILM_EDGE = "DXFilmEdge", "DX Film Edge"
PDF417 = "PDF417", "PDF417"
COMPACT_PDF417 = "CompactPDF417", "Compact PDF417"
MICRO_PDF417 = "MicroPDF417", "MicroPDF417"
AZTEC = "Aztec", "Aztec"
AZTEC_CODE = "AztecCode", "Aztec Code"
AZTEC_RUNE = "AztecRune", "Aztec Rune"
QR_CODE = "QRCode", "QR Code"
QR_CODE_MODEL1 = "QRCodeModel1", "QR Code Model 1"
QR_CODE_MODEL2 = "QRCodeModel2", "QR Code Model 2"
MICRO_QR_CODE = "MicroQRCode", "Micro QR Code"
RMQR_CODE = "RMQRCode", "rMQR Code"
DATA_MATRIX = "DataMatrix", "Data Matrix"
MAXI_CODE = "MaxiCode", "MaxiCode"
format = models.CharField(_("format"), max_length=32, choices=Format.choices)
class Meta:
ordering = ("page", "id")
verbose_name = _("document barcode")
verbose_name_plural = _("document barcodes")
def __str__(self) -> str: # pragma: no cover
return self.value
class ShareLink(SoftDeleteModel): class ShareLink(SoftDeleteModel):
class FileVersion(models.TextChoices): class FileVersion(models.TextChoices):
ARCHIVE = ("archive", _("Archive")) ARCHIVE = ("archive", _("Archive"))
-24
View File
@@ -14,7 +14,6 @@ from django.db.models import QuerySet
from django.db.models import Value from django.db.models import Value
from django.db.models import When from django.db.models import When
from django.db.models.functions import Cast from django.db.models.functions import Cast
from django.db.models.functions import Coalesce
from guardian.core import ObjectPermissionChecker from guardian.core import ObjectPermissionChecker
from guardian.models import GroupObjectPermission from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
@@ -475,29 +474,6 @@ def permitted_document_ids(
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted) return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
def documents_without_permitted_root(
documents: QuerySet[Document],
user: User | None,
*,
perm: str = "view_document",
include_deleted: bool = False,
) -> QuerySet[Document]:
"""
The documents the user lacks ``perm`` on. Versions are authorized by their
root document, so a version's own owner is ignored. A single query, without
loading the documents or joining the root.
"""
return documents.annotate(
root_id=Coalesce("root_document_id", "id"),
).exclude(
root_id__in=permitted_document_ids(
user,
perm=perm,
include_deleted=include_deleted,
),
)
def get_document_count_filter_for_user(user, related_name: str = "documents"): def get_document_count_filter_for_user(user, related_name: str = "documents"):
""" """
Return the Q object used to filter document counts for the given user. Return the Q object used to filter document counts for the given user.
+5 -27
View File
@@ -31,7 +31,6 @@ from documents.search._query import parse_user_query
from documents.search._schema import _write_sentinels from documents.search._schema import _write_sentinels
from documents.search._schema import build_schema from documents.search._schema import build_schema
from documents.search._schema import open_or_rebuild_index from documents.search._schema import open_or_rebuild_index
from documents.search._schema import rebuild_in_progress
from documents.search._schema import wipe_index from documents.search._schema import wipe_index
from documents.search._tokenizer import ascii_fold from documents.search._tokenizer import ascii_fold
from documents.search._tokenizer import autocomplete_tokens from documents.search._tokenizer import autocomplete_tokens
@@ -312,13 +311,7 @@ class WriteBatch:
queryset = annotate_effective_content( queryset = annotate_effective_content(
Document.objects.filter(pk__in=ids) Document.objects.filter(pk__in=ids)
.select_related("correspondent", "document_type", "storage_path", "owner") .select_related("correspondent", "document_type", "storage_path", "owner")
.prefetch_related( .prefetch_related("tags", "notes__user", "custom_fields__field"),
"tags",
"notes__user",
"custom_fields__field",
"barcodes",
"versions__barcodes",
),
) )
for document, grant in _DocumentViewerStream(queryset, chunk_size=1000): for document, grant in _DocumentViewerStream(queryset, chunk_size=1000):
self.remove(document.pk) self.remove(document.pk)
@@ -611,16 +604,6 @@ class TantivyBackend:
}, },
) )
# Barcodes: JSON field like custom_fields, only filled when stored
for barcode in document.get_effective_barcodes():
doc.add_json(
"barcodes",
{
"value": normalize_search_text(barcode.value),
"format": normalize_search_text(barcode.format),
},
)
# Dates # Dates
created_date = datetime( created_date = datetime(
document.created.year, document.created.year,
@@ -1111,9 +1094,6 @@ class TantivyBackend:
flushing a segment, deferring merge work; they do not avoid it. flushing a segment, deferring merge work; they do not avoid it.
""" """
wipe_index(self._path) wipe_index(self._path)
# The marker covers the window where the empty index is already stamped
# as current but not yet populated, so an interrupted rebuild is retried.
with rebuild_in_progress(self._path):
new_index = tantivy.Index(build_schema(), path=str(self._path)) new_index = tantivy.Index(build_schema(), path=str(self._path))
_write_sentinels(self._path) _write_sentinels(self._path)
register_tokenizers(new_index, settings.SEARCH_LANGUAGE) register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
@@ -1123,9 +1103,8 @@ class TantivyBackend:
self._raw_index = new_index self._raw_index = new_index
self._raw_schema = new_index.schema self._raw_schema = new_index.schema
# Stream documents one-by-one (so the progress bar advances per # Stream documents one-by-one (so the progress bar advances per
# document) while fetching viewer permissions one SQL query per # document) while fetching viewer permissions one SQL query per chunk.
# chunk. The stream is Sized, so iter_wrapper can still discover # The stream is Sized, so iter_wrapper can still discover the total.
# the total.
documents_stream = _DocumentViewerStream(documents, chunk_size=1000) documents_stream = _DocumentViewerStream(documents, chunk_size=1000)
try: try:
writer = new_index.writer(heap_size=writer_heap_bytes) writer = new_index.writer(heap_size=writer_heap_bytes)
@@ -1139,9 +1118,8 @@ class TantivyBackend:
) )
writer.add_document(doc) writer.add_document(doc)
writer.commit() writer.commit()
# Wait for background merge threads to finish so all segments # Wait for background merge threads to finish so all segments are
# are fully merged and persisted before the index is considered # fully merged and persisted before the index is considered rebuilt.
# rebuilt.
writer.wait_merging_threads() writer.wait_merging_threads()
new_index.reload() new_index.reload()
except BaseException: # pragma: no cover except BaseException: # pragma: no cover
-5
View File
@@ -39,9 +39,4 @@ PUBLIC_FIELDS: tuple[FieldSpec, ...] = (
FieldKind.JSON, FieldKind.JSON,
subpaths={"name": SubpathSpec(), "value": SubpathSpec(default=True)}, subpaths={"name": SubpathSpec(), "value": SubpathSpec(default=True)},
), ),
FieldSpec(
"barcodes",
FieldKind.JSON,
subpaths={"value": SubpathSpec(default=True), "format": SubpathSpec()},
),
) )
+5 -47
View File
@@ -4,7 +4,6 @@ import hashlib
import json import json
import logging import logging
import shutil import shutil
from contextlib import contextmanager
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Final from typing import Final
from typing import NamedTuple from typing import NamedTuple
@@ -17,7 +16,6 @@ from whoosh_compat import FieldKind
from documents.search._fields import PUBLIC_FIELDS from documents.search._fields import PUBLIC_FIELDS
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
logger = logging.getLogger("paperless.search") logger = logging.getLogger("paperless.search")
@@ -27,13 +25,7 @@ logger = logging.getLogger("paperless.search")
# order, and the write-only correspondent/document_type/storage_path/tag id # order, and the write-only correspondent/document_type/storage_path/tag id
# columns dropped. tantivy compares schemas by ordered field list, so an # columns dropped. tantivy compares schemas by ordered field list, so an
# index built by v1 rejects every write against the v2 schema. # index built by v1 rejects every write against the v2 schema.
# v3 - barcodes JSON field for stored barcode contents SCHEMA_VERSION: Final[int] = 2
SCHEMA_VERSION: Final[int] = 3
# Present in the index directory from the moment a full rebuild starts until it
# finishes. If a rebuild is interrupted it is left behind, so the half-built
# index is not mistaken for a complete one.
REBUILD_MARKER: Final[str] = ".rebuilding"
class FieldDescriptor(NamedTuple): class FieldDescriptor(NamedTuple):
@@ -262,9 +254,9 @@ def needs_rebuild(index_dir: Path) -> bool:
""" """
Check if the search index needs rebuilding. Check if the search index needs rebuilding.
True if a previous full rebuild never finished (the rebuild marker is still Reads .index_settings.json to compare the stored schema version, search
present), or if the index's stamped settings no longer match the current language and schema fingerprint against the current configuration. Returns
configuration. See _settings_mismatch(). True if the file is missing, unparsable, or any value mismatches.
Args: Args:
index_dir: Path to the search index directory index_dir: Path to the search index directory
@@ -272,40 +264,6 @@ def needs_rebuild(index_dir: Path) -> bool:
Returns: Returns:
True if the index needs rebuilding, False if it's up to date True if the index needs rebuilding, False if it's up to date
""" """
if (index_dir / REBUILD_MARKER).exists():
logger.warning("Previous search index rebuild did not finish - rebuilding.")
return True
return _settings_mismatch(index_dir)
@contextmanager
def rebuild_in_progress(index_dir: Path) -> Iterator[None]:
"""
Flag the index as incomplete for the duration of a full rebuild.
The marker is cleared only if the block exits cleanly. There is deliberately
no try/finally: an exception must leave the marker behind so the next
needs_rebuild() check retries the rebuild.
"""
marker = index_dir / REBUILD_MARKER
marker.touch()
yield
marker.unlink(missing_ok=True)
def _settings_mismatch(index_dir: Path) -> bool:
"""
Check the stamped settings against the current configuration.
Reads .index_settings.json to compare the stored schema version, search
language and schema fingerprint. Returns True if the file is missing,
unparsable, or any value mismatches.
This deliberately ignores the rebuild marker: open_or_rebuild_index() uses it
so that a process opening the index while another process is mid-rebuild
(or after one died) does not wipe the partial index out from under it.
Repopulating is the job of ``document_index reindex``.
"""
settings_file = index_dir / ".index_settings.json" settings_file = index_dir / ".index_settings.json"
if not settings_file.exists(): if not settings_file.exists():
return True return True
@@ -374,7 +332,7 @@ def open_or_rebuild_index(index_dir: Path | None = None) -> tantivy.Index:
index_dir = cast("Path", settings.INDEX_DIR) index_dir = cast("Path", settings.INDEX_DIR)
if not index_dir.exists(): if not index_dir.exists():
return tantivy.Index(build_schema()) return tantivy.Index(build_schema())
if _settings_mismatch(index_dir): if needs_rebuild(index_dir):
wipe_index(index_dir) wipe_index(index_dir)
idx = tantivy.Index(build_schema(), path=str(index_dir)) idx = tantivy.Index(build_schema(), path=str(index_dir))
_write_sentinels(index_dir) _write_sentinels(index_dir)
File diff suppressed because it is too large. Load diff
Whitespace-only changes.
+568
View File
@@ -0,0 +1,568 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from typing import Any
from typing import Literal
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from guardian.core import ObjectPermissionChecker
from guardian.shortcuts import get_users_with_perms
from guardian.utils import get_group_obj_perms_model
from guardian.utils import get_user_obj_perms_model
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.fields import SerializerMethodField
from rest_framework.utils import model_meta
from documents import bulk_edit
from documents.models import Document
from documents.models import MatchingModel
from documents.models import Note
from documents.permissions import get_groups_with_only_permission
from documents.permissions import set_permissions_for_object
from documents.regex import validate_regex_pattern
if TYPE_CHECKING:
from collections.abc import Iterable
logger = logging.getLogger("paperless.serializers")
# https://www.django-rest-framework.org/api-guide/serializers/#example
class DynamicFieldsModelSerializer(serializers.ModelSerializer[Any]):
"""
A ModelSerializer that takes an additional `fields` argument that
controls which fields should be displayed.
"""
def __init__(self, *args, **kwargs) -> None:
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop("fields", None)
# Instantiate the superclass normally
super().__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
class DocumentUpdateFieldsModelSerializer(DynamicFieldsModelSerializer):
stale_update_excluded_fields = frozenset({"filename", "archive_filename"})
def _get_update_fields(self, validated_data) -> list[str]:
model_fields = {
field.name
for field in self.Meta.model._meta.concrete_fields
if field.name not in self.stale_update_excluded_fields
}
update_fields = [
field_name for field_name in validated_data if field_name in model_fields
]
if "modified" in model_fields and "modified" not in update_fields:
update_fields.append("modified")
return update_fields
def update(self, instance, validated_data):
serializers.raise_errors_on_nested_writes("update", self, validated_data)
info = model_meta.get_field_info(instance)
m2m_fields = []
for attr, value in validated_data.items():
if attr in info.relations and info.relations[attr].to_many:
m2m_fields.append((attr, value))
else:
setattr(instance, attr, value)
# File names are managed by post-save file handling. Saving only the
# serializer-updated fields prevents stale in-memory path values from
# overwriting a concurrent move.
instance.save(update_fields=self._get_update_fields(validated_data))
for attr, value in m2m_fields:
field = getattr(instance, attr)
field.set(value)
return instance
class MatchingModelSerializer(serializers.ModelSerializer[Any]):
document_count = serializers.IntegerField(read_only=True)
def get_slug(self, obj) -> str:
return slugify(obj.name)
slug = SerializerMethodField()
def validate(self, data):
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
name = data.get(
"name",
self.instance.name if hasattr(self.instance, "name") else None,
)
owner = (
data["owner"]
if "owner" in data
else self.user
if hasattr(self, "user")
else None
)
pk = self.instance.pk if hasattr(self.instance, "pk") else None
if ("name" in data or "owner" in data) and self.Meta.model.objects.filter(
name=name,
owner=owner,
).exclude(pk=pk).exists():
raise serializers.ValidationError(
{"error": "Object violates owner / name unique constraint"},
)
return data
def validate_match(self, match):
if (
"matching_algorithm" in self.initial_data
and self.initial_data["matching_algorithm"] == MatchingModel.MATCH_REGEX
):
try:
validate_regex_pattern(match)
except ValueError as e:
logger.debug(f"Invalid regular expression: {e!s}")
raise serializers.ValidationError(
"Invalid regular expression, see log for details.",
)
return match
PERMISSION_ACTIONS = ("view", "change")
class SetPermissionsMixin:
def _validate_user_ids(self, user_ids):
users = User.objects.none()
if user_ids is not None:
users = User.objects.filter(id__in=user_ids)
if not users.count() == len(user_ids):
raise serializers.ValidationError(
"Some users in don't exist or were specified twice.",
)
return users
def _validate_group_ids(self, group_ids):
groups = Group.objects.none()
if group_ids is not None:
groups = Group.objects.filter(id__in=group_ids)
if not groups.count() == len(group_ids):
raise serializers.ValidationError(
"Some groups in don't exist or were specified twice.",
)
return groups
def validate_set_permissions(self, set_permissions=None):
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
if set_permissions is not None:
for action in PERMISSION_ACTIONS:
if action in set_permissions:
if "users" in set_permissions[action]:
users = set_permissions[action]["users"]
permissions_dict[action]["users"] = self._validate_user_ids(
users,
)
if "groups" in set_permissions[action]:
groups = set_permissions[action]["groups"]
permissions_dict[action]["groups"] = self._validate_group_ids(
groups,
)
else:
del permissions_dict[action]
return permissions_dict
def _set_permissions(self, permissions, object) -> None:
set_permissions_for_object(permissions, object)
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
def __init__(self, *args, **kwargs) -> None:
self.user = kwargs.pop("user", None)
self.full_perms = kwargs.pop("full_perms", False)
self.all_fields = kwargs.pop("all_fields", False)
super().__init__(*args, **kwargs)
class PermissionSetSerializer(serializers.Serializer[dict[str, Any]]):
users = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
)
groups = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
)
class SetPermissionsSerializer(serializers.Serializer[dict[str, Any]]):
view = PermissionSetSerializer(required=False)
change = PermissionSetSerializer(required=False)
def to_internal_value(self, data):
if isinstance(data, dict):
unknown_keys = set(data) - set(PERMISSION_ACTIONS)
if unknown_keys:
raise serializers.ValidationError(
{key: "Unknown permission action." for key in sorted(unknown_keys)},
)
return super().to_internal_value(data)
class OwnedObjectSerializer(
SerializerWithPerms,
serializers.ModelSerializer[Any],
SetPermissionsMixin,
):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if not self.all_fields:
try:
if self.full_perms:
self.fields.pop("user_can_change")
self.fields.pop("is_shared_by_requester")
else:
self.fields.pop("permissions")
except KeyError:
pass
def _get_perms(self, obj, codename: str, target: Literal["users", "groups"]):
"""
Get the given permissions from context or from django-guardian.
:param codename: The permission codename, e.g. 'view' or 'change'
:param target: 'users' or 'groups'
"""
key = f"{target}_{codename}_perms"
cached = self.context.get(key, {}).get(obj.pk)
if cached is not None:
return list(cached)
# Permission not found in the context, get it from guardian
if target == "users":
return list(
get_users_with_perms(
obj,
only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"],
with_group_users=False,
).values_list("id", flat=True),
)
else: # groups
return list(
get_groups_with_only_permission(
obj,
codename=f"{codename}_{obj.__class__.__name__.lower()}",
).values_list("id", flat=True),
)
@extend_schema_field(
field={
"type": "object",
"properties": {
"view": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
"change": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
},
},
)
def get_permissions(self, obj) -> dict:
return {
"view": {
"users": self._get_perms(obj, "view", "users"),
"groups": self._get_perms(obj, "view", "groups"),
},
"change": {
"users": self._get_perms(obj, "change", "users"),
"groups": self._get_perms(obj, "change", "groups"),
},
}
def get_user_can_change(self, obj) -> bool:
if obj.owner is None or obj.owner == self.user:
return True
if self.user is None:
return False
if self.user.is_active and self.user.is_superuser:
# Mirrors guardian's own ObjectPermissionChecker.has_perm() shortcut --
# superusers aren't necessarily granted explicit object permissions,
# so the batched context below would otherwise incorrectly say no.
return True
# Prefer the page-level batch computed by BulkPermissionMixin
# (get_serializer_context) over a fresh per-object guardian check,
# which would otherwise query the permission tables once per row.
users_change_perms = self.context.get("users_change_perms")
groups_change_perms = self.context.get("groups_change_perms")
if users_change_perms is not None and groups_change_perms is not None:
if self.user.pk in users_change_perms.get(obj.pk, []):
return True
user_group_ids = getattr(self, "_user_group_ids", None)
if user_group_ids is None:
user_group_ids = set(self.user.groups.values_list("id", flat=True))
self._user_group_ids = user_group_ids
return bool(
user_group_ids.intersection(groups_change_perms.get(obj.pk, [])),
)
checker = ObjectPermissionChecker(self.user)
return checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
@staticmethod
def get_shared_object_pks(objects: Iterable):
"""
Return the primary keys of the subset of objects that are shared.
"""
try:
first_obj = next(iter(objects))
except StopIteration:
return set()
ctype = ContentType.objects.get_for_model(first_obj)
object_pks = list(obj.pk for obj in objects)
pk_type = type(first_obj.pk)
def get_pks_for_permission_type(model):
return map(
pk_type, # coerce the pk to be the same type of the provided objects
model.objects.filter(
content_type=ctype,
object_pk__in=object_pks,
)
.values_list("object_pk", flat=True)
.distinct(),
)
UserObjectPermission = get_user_obj_perms_model()
GroupObjectPermission = get_group_obj_perms_model()
user_permission_pks = get_pks_for_permission_type(UserObjectPermission)
group_permission_pks = get_pks_for_permission_type(GroupObjectPermission)
return set(user_permission_pks) | set(group_permission_pks)
def get_is_shared_by_requester(self, obj: Document) -> bool:
# First check the context to see if `shared_object_pks` is set by the parent.
shared_object_pks = self.context.get("shared_object_pks")
# If not just check if the current object is shared.
if shared_object_pks is None:
shared_object_pks = self.get_shared_object_pks([obj])
return obj.owner == self.user and obj.id in shared_object_pks
permissions = SerializerMethodField(read_only=True, required=False)
user_can_change = SerializerMethodField(read_only=True, required=False)
is_shared_by_requester = SerializerMethodField(read_only=True, required=False)
set_permissions = SetPermissionsSerializer(
label="Set permissions",
required=False,
write_only=True,
)
# other methods in mixin
def validate_unique_together(self, validated_data, instance=None) -> None:
# workaround for https://github.com/encode/django-rest-framework/issues/9358
if "owner" in validated_data and "name" in self.Meta.fields:
name = validated_data.get("name", instance.name if instance else None)
objects = (
self.Meta.model.objects.exclude(pk=instance.pk)
if instance
else self.Meta.model.objects.all()
)
not_unique = objects.filter(
owner=validated_data["owner"],
name=name,
).exists()
if not_unique:
raise serializers.ValidationError(
{"error": "Object violates owner / name unique constraint"},
)
def create(self, validated_data):
# default to current user if not set
request = self.context.get("request")
if (
"owner" not in validated_data
or (request is not None and "owner" not in request.data)
) and self.user:
validated_data["owner"] = self.user
permissions = None
if "set_permissions" in validated_data:
permissions = validated_data.pop("set_permissions")
self.validate_unique_together(validated_data)
instance = super().create(validated_data)
if permissions is not None:
self._set_permissions(permissions, instance)
return instance
def update(self, instance, validated_data):
user = getattr(self, "user", None)
is_superuser = user.is_superuser if user is not None else False
is_owner = instance.owner == user if user is not None else False
is_unowned = instance.owner is None
if (
("owner" in validated_data and validated_data["owner"] != instance.owner)
or "set_permissions" in validated_data
) and not (is_superuser or is_owner or is_unowned):
raise PermissionDenied(
_("Insufficient permissions."),
)
if "set_permissions" in validated_data:
self._set_permissions(validated_data["set_permissions"], instance)
self.validate_unique_together(validated_data, instance)
return super().update(instance, validated_data)
class OwnedObjectListSerializer(serializers.ListSerializer[Any]):
def to_representation(self, documents):
self.child.context["shared_object_pks"] = self.child.get_shared_object_pks(
documents,
)
return super().to_representation(documents)
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
"""
Based on https://stackoverflow.com/a/62579804
"""
def __init__(self, method_name=None, *args, **kwargs) -> None:
self.method_name = method_name
kwargs["source"] = "*"
super(serializers.SerializerMethodField, self).__init__(*args, **kwargs)
def to_internal_value(self, data):
return {self.field_name: data}
class DocumentListSerializer(serializers.Serializer[dict[str, list[int]]]):
documents = serializers.ListField(
required=True,
label="Documents",
write_only=True,
child=serializers.IntegerField(),
)
def _validate_document_id_list(self, documents, name="documents") -> None:
if not isinstance(documents, list):
raise serializers.ValidationError(f"{name} must be a list")
if not all(isinstance(i, int) for i in documents):
raise serializers.ValidationError(f"{name} must be a list of integers")
count = Document.objects.filter(id__in=documents).count()
if not count == len(documents):
raise serializers.ValidationError(
f"Some documents in {name} don't exist or were specified twice.",
)
def validate_documents(self, documents):
self._validate_document_id_list(documents)
return documents
class DocumentSelectionSerializer(DocumentListSerializer):
documents = serializers.ListField(
required=False,
label="Documents",
write_only=True,
child=serializers.IntegerField(),
)
all = serializers.BooleanField(
default=False,
required=False,
write_only=True,
)
filters = serializers.DictField(
required=False,
allow_empty=True,
write_only=True,
)
excluded_documents = serializers.ListField(
required=False,
default=list,
write_only=True,
child=serializers.IntegerField(),
)
def validate(self, attrs):
if attrs.get("all", False):
attrs.setdefault("documents", [])
return attrs
if attrs["excluded_documents"]:
raise serializers.ValidationError(
"excluded_documents is only supported when all is true.",
)
if "documents" not in attrs:
raise serializers.ValidationError(
"documents is required unless all is true.",
)
documents = attrs["documents"]
self._validate_document_id_list(documents)
return attrs
class SourceModeValidationMixin:
def validate_source_mode(self, source_mode: str) -> str:
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
raise serializers.ValidationError("Invalid source_mode")
return source_mode
class BasicUserSerializer(serializers.ModelSerializer[User]):
# Different than paperless.serializers.UserSerializer
class Meta:
model = User
fields = ["id", "username", "first_name", "last_name"]
class NotesSerializer(serializers.ModelSerializer[Note]):
user = BasicUserSerializer(read_only=True)
class Meta:
model = Note
fields = ["id", "note", "created", "user"]
ordering = ["-created"]
+750
View File
@@ -0,0 +1,750 @@
from __future__ import annotations
import logging
from django.contrib.auth.models import User
from rest_framework import serializers
from documents import bulk_edit
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from .base import DocumentListSerializer
from .base import DocumentSelectionSerializer
from .base import SerializerWithPerms
from .base import SetPermissionsMixin
from .base import SetPermissionsSerializer
from .base import SourceModeValidationMixin
from .metadata import CustomFieldInstanceSerializer
logger = logging.getLogger("paperless.serializers")
def _validate_rotation_degrees(degrees: int, field: str = "degrees") -> int:
# QPDF refuses any other angle, which would otherwise fail inside the task
if degrees % 90 != 0:
raise serializers.ValidationError(f"{field} must be a multiple of 90")
return degrees
class RotateDocumentsSerializer(DocumentSelectionSerializer, SourceModeValidationMixin):
degrees = serializers.IntegerField(required=True)
source_mode = serializers.CharField(
required=False,
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
)
from_webui = serializers.BooleanField(required=False, default=False)
def validate_degrees(self, value: int) -> int:
return _validate_rotation_degrees(value)
class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
metadata_document_id = serializers.IntegerField(
required=False,
allow_null=True,
)
delete_originals = serializers.BooleanField(required=False, default=False)
archive_fallback = serializers.BooleanField(required=False, default=False)
source_mode = serializers.CharField(
required=False,
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
)
from_webui = serializers.BooleanField(required=False, default=False)
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
root_document_id = serializers.IntegerField(required=True)
version_label = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
max_length=64,
)
def validate_version_label(self, value):
if value is None:
return None
normalized = value.strip()
return normalized or None
def validate(self, attrs):
documents = attrs["documents"]
if len(documents) < 2:
raise serializers.ValidationError(
"At least two documents are required.",
)
if attrs.get("version_label") is not None and len(documents) != 2:
raise serializers.ValidationError(
"version_label can only be used when merging one source document.",
)
if attrs["root_document_id"] not in documents:
raise serializers.ValidationError(
"root_document_id must be one of the selected documents.",
)
selected_documents = Document.objects.filter(id__in=documents)
if selected_documents.filter(root_document__isnull=False).exists():
raise serializers.ValidationError(
"Only top-level documents can be merged as versions.",
)
source_document_ids = set(documents) - {attrs["root_document_id"]}
if Document.global_objects.filter(
root_document_id__in=source_document_ids,
).exists():
raise serializers.ValidationError(
"Documents with existing versions cannot be merged into another document.",
)
return attrs
class PdfEditOperationSerializer(serializers.Serializer[dict[str, int]]):
page = serializers.IntegerField(min_value=1)
rotate = serializers.IntegerField(required=False)
doc = serializers.IntegerField(required=False, min_value=0)
def validate_rotate(self, value: int) -> int:
return _validate_rotation_degrees(value, field="rotate")
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
operations = serializers.ListField(
child=PdfEditOperationSerializer(),
required=True,
allow_empty=False,
)
delete_original = serializers.BooleanField(required=False, default=False)
update_document = serializers.BooleanField(required=False, default=False)
include_metadata = serializers.BooleanField(required=False, default=True)
source_mode = serializers.CharField(
required=False,
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
)
from_webui = serializers.BooleanField(required=False, default=False)
def validate(self, attrs):
documents = attrs["documents"]
if len(documents) > 1:
raise serializers.ValidationError(
"Edit PDF method only supports one document",
)
operations = attrs["operations"]
if any(op.get("doc", 0) >= len(operations) for op in operations):
raise serializers.ValidationError("doc index is out of bounds")
if attrs["update_document"]:
max_idx = max(op.get("doc", 0) for op in operations)
if max_idx > 0:
raise serializers.ValidationError(
"update_document only allowed with a single output document",
)
doc = Document.objects.get(id=documents[0])
if doc.page_count:
for op in operations:
if op["page"] > doc.page_count:
raise serializers.ValidationError(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
)
return attrs
class RemovePasswordDocumentsSerializer(
DocumentListSerializer,
SourceModeValidationMixin,
):
password = serializers.CharField(required=True)
update_document = serializers.BooleanField(required=False, default=False)
delete_original = serializers.BooleanField(required=False, default=False)
include_metadata = serializers.BooleanField(required=False, default=True)
source_mode = serializers.CharField(
required=False,
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
)
from_webui = serializers.BooleanField(required=False, default=False)
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
pass
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
remote_ocr = serializers.BooleanField(required=False, default=False)
class BulkEditSerializer(
SerializerWithPerms,
DocumentSelectionSerializer,
SetPermissionsMixin,
SourceModeValidationMixin,
):
# TODO: remove this and related backwards compatibility code when API v9 is dropped
# split, delete_pages can be removed entirely
MOVED_DOCUMENT_ACTION_ENDPOINTS = {
"delete": "/api/documents/delete/",
"reprocess": "/api/documents/reprocess/",
"rotate": "/api/documents/rotate/",
"merge": "/api/documents/merge/",
"edit_pdf": "/api/documents/edit_pdf/",
"remove_password": "/api/documents/remove_password/",
"split": "/api/documents/edit_pdf/",
"delete_pages": "/api/documents/edit_pdf/",
}
LEGACY_DOCUMENT_ACTION_METHODS = tuple(MOVED_DOCUMENT_ACTION_ENDPOINTS.keys())
method = serializers.ChoiceField(
choices=[
"set_correspondent",
"set_document_type",
"set_storage_path",
"add_tag",
"remove_tag",
"modify_tags",
"modify_custom_fields",
"set_permissions",
*LEGACY_DOCUMENT_ACTION_METHODS,
],
label="Method",
write_only=True,
)
parameters = serializers.DictField(allow_empty=True, default={}, write_only=True)
from_webui = serializers.BooleanField(required=False, default=False)
def _validate_tag_id_list(self, tags, name="tags") -> None:
if not isinstance(tags, list):
raise serializers.ValidationError(f"{name} must be a list")
if not all(isinstance(i, int) for i in tags):
raise serializers.ValidationError(f"{name} must be a list of integers")
count = Tag.objects.filter(id__in=tags).count()
if not count == len(tags):
raise serializers.ValidationError(
f"Some tags in {name} don't exist or were specified twice.",
)
def _validate_custom_field_id_list_or_dict(
self,
custom_fields,
name="custom_fields",
) -> None:
ids = custom_fields
if isinstance(custom_fields, dict):
try:
ids = [int(i[0]) for i in custom_fields.items()]
except Exception as e:
logger.exception(f"Error validating custom fields: {e}")
raise serializers.ValidationError(
f"{name} must be a list of integers or a dict of id:value pairs, see the log for details",
)
elif not isinstance(custom_fields, list) or not all(
isinstance(i, int) for i in ids
):
raise serializers.ValidationError(
f"{name} must be a list of integers or a dict of id:value pairs",
)
count = CustomField.objects.filter(id__in=ids).count()
if not count == len(ids):
raise serializers.ValidationError(
f"Some custom fields in {name} don't exist or were specified twice.",
)
def _validate_custom_field_values(self, custom_fields, name):
if not isinstance(custom_fields, dict):
return custom_fields
validated = {}
errors = {}
for raw_field_id, value in custom_fields.items():
field_id = int(raw_field_id)
validator = CustomFieldInstanceSerializer(
data={"field": field_id, "value": value},
context=self.context,
)
if validator.is_valid():
validated[field_id] = validator.validated_data["value"]
else:
errors[str(field_id)] = validator.errors
if errors:
raise serializers.ValidationError({name: errors})
return validated
def validate_method(self, method):
if method == "set_correspondent":
return bulk_edit.set_correspondent
elif method == "set_document_type":
return bulk_edit.set_document_type
elif method == "set_storage_path":
return bulk_edit.set_storage_path
elif method == "add_tag":
return bulk_edit.add_tag
elif method == "remove_tag":
return bulk_edit.remove_tag
elif method == "modify_tags":
return bulk_edit.modify_tags
elif method == "modify_custom_fields":
return bulk_edit.modify_custom_fields
elif method == "delete":
return bulk_edit.delete
elif method == "redo_ocr" or method == "reprocess":
return bulk_edit.reprocess
elif method == "set_permissions":
return bulk_edit.set_permissions
elif method == "rotate":
return bulk_edit.rotate
elif method == "merge":
return bulk_edit.merge
elif method == "split":
return bulk_edit.split
elif method == "delete_pages":
return bulk_edit.delete_pages
elif method == "edit_pdf":
return bulk_edit.edit_pdf
elif method == "remove_password":
return bulk_edit.remove_password
else:
raise serializers.ValidationError("Unsupported method.")
def _validate_parameters_tags(self, parameters) -> None:
if "tag" in parameters:
tag_id = parameters["tag"]
try:
Tag.objects.get(id=tag_id)
except Tag.DoesNotExist:
raise serializers.ValidationError("Tag does not exist")
else:
raise serializers.ValidationError("tag not specified")
def _validate_parameters_document_type(self, parameters) -> None:
if "document_type" in parameters:
document_type_id = parameters["document_type"]
if document_type_id is None:
# None is ok
return
try:
DocumentType.objects.get(id=document_type_id)
except DocumentType.DoesNotExist:
raise serializers.ValidationError("Document type does not exist")
else:
raise serializers.ValidationError("document_type not specified")
def _validate_parameters_correspondent(self, parameters) -> None:
if "correspondent" in parameters:
correspondent_id = parameters["correspondent"]
if correspondent_id is None:
return
try:
Correspondent.objects.get(id=correspondent_id)
except Correspondent.DoesNotExist:
raise serializers.ValidationError("Correspondent does not exist")
else:
raise serializers.ValidationError("correspondent not specified")
def _validate_storage_path(self, parameters) -> None:
if "storage_path" in parameters:
storage_path_id = parameters["storage_path"]
if storage_path_id is None:
return
try:
StoragePath.objects.get(id=storage_path_id)
except StoragePath.DoesNotExist:
raise serializers.ValidationError(
"Storage path does not exist",
)
else:
raise serializers.ValidationError("storage path not specified")
def _validate_parameters_modify_tags(self, parameters) -> None:
if "add_tags" in parameters:
self._validate_tag_id_list(parameters["add_tags"], "add_tags")
else:
raise serializers.ValidationError("add_tags not specified")
if "remove_tags" in parameters:
self._validate_tag_id_list(parameters["remove_tags"], "remove_tags")
else:
raise serializers.ValidationError("remove_tags not specified")
def _validate_parameters_modify_custom_fields(self, parameters) -> None:
if "add_custom_fields" in parameters:
self._validate_custom_field_id_list_or_dict(
parameters["add_custom_fields"],
"add_custom_fields",
)
parameters["add_custom_fields"] = self._validate_custom_field_values(
parameters["add_custom_fields"],
"add_custom_fields",
)
else:
raise serializers.ValidationError("add_custom_fields not specified")
if "remove_custom_fields" in parameters:
self._validate_custom_field_id_list_or_dict(
parameters["remove_custom_fields"],
"remove_custom_fields",
)
else:
raise serializers.ValidationError("remove_custom_fields not specified")
def _validate_owner(self, owner) -> User:
owner_field = serializers.PrimaryKeyRelatedField(queryset=User.objects.all())
try:
return owner_field.run_validation(owner)
except serializers.ValidationError as e:
raise serializers.ValidationError(
"Specified owner cannot be found",
) from e
def _validate_parameters_set_permissions(self, parameters) -> None:
if "set_permissions" not in parameters:
raise serializers.ValidationError("set_permissions not specified")
set_permissions = parameters["set_permissions"]
if set_permissions is not None:
set_permissions = SetPermissionsSerializer().run_validation(
set_permissions,
)
parameters["set_permissions"] = self.validate_set_permissions(
set_permissions,
)
if "owner" in parameters and parameters["owner"] is not None:
parameters["owner"] = self._validate_owner(parameters["owner"]).pk
if "merge" not in parameters:
parameters["merge"] = False
def _validate_parameters_rotate(self, parameters) -> None:
if "degrees" not in parameters:
raise serializers.ValidationError("invalid rotation degrees")
try:
degrees = serializers.IntegerField().run_validation(parameters["degrees"])
except serializers.ValidationError as e:
raise serializers.ValidationError("invalid rotation degrees") from e
parameters["degrees"] = _validate_rotation_degrees(degrees)
def _validate_source_mode(self, parameters) -> None:
source_mode = parameters.get(
"source_mode",
bulk_edit.SourceModeChoices.LATEST_VERSION,
)
parameters["source_mode"] = self.validate_source_mode(source_mode)
def _validate_parameters_split(self, parameters, document_id) -> None:
if "pages" not in parameters:
raise serializers.ValidationError("pages not specified")
if not isinstance(parameters["pages"], str):
raise serializers.ValidationError("invalid pages specified")
page_count = Document.objects.get(id=document_id).page_count
if not page_count:
raise serializers.ValidationError("document page count is unknown")
pages = []
for group in parameters["pages"].split(","):
start, is_range, end = group.partition("-")
try:
first = int(start)
last = int(end) if is_range else first
except ValueError as e:
raise serializers.ValidationError("invalid pages specified") from e
# Bound the range before building it, a huge one would exhaust memory
if not 1 <= first <= last <= page_count:
raise serializers.ValidationError("invalid pages specified")
pages.append(list(range(first, last + 1)))
parameters["pages"] = pages
if "delete_originals" in parameters:
if not isinstance(parameters["delete_originals"], bool):
raise serializers.ValidationError("delete_originals must be a boolean")
else:
parameters["delete_originals"] = False
def _validate_parameters_delete_pages(self, parameters) -> None:
if "pages" not in parameters:
raise serializers.ValidationError("pages not specified")
if not isinstance(parameters["pages"], list):
raise serializers.ValidationError("pages must be a list")
if not all(isinstance(i, int) for i in parameters["pages"]):
raise serializers.ValidationError("pages must be a list of integers")
def _validate_parameters_merge(self, parameters) -> None:
if "delete_originals" in parameters:
if not isinstance(parameters["delete_originals"], bool):
raise serializers.ValidationError("delete_originals must be a boolean")
else:
parameters["delete_originals"] = False
if "archive_fallback" in parameters:
if not isinstance(parameters["archive_fallback"], bool):
raise serializers.ValidationError("archive_fallback must be a boolean")
else:
parameters["archive_fallback"] = False
def _validate_parameters_edit_pdf(self, parameters, document_id) -> None:
if "operations" not in parameters:
raise serializers.ValidationError("operations not specified")
operations_field = serializers.ListField(
child=PdfEditOperationSerializer(),
allow_empty=False,
)
try:
operations = operations_field.run_validation(parameters["operations"])
except serializers.ValidationError as e:
# Key the errors under "operations" so they match what the
# dedicated edit_pdf endpoint returns
raise serializers.ValidationError({"operations": e.detail}) from e
parameters["operations"] = operations
if "update_document" in parameters:
if not isinstance(parameters["update_document"], bool):
raise serializers.ValidationError("update_document must be a boolean")
else:
parameters["update_document"] = False
if "include_metadata" in parameters:
if not isinstance(parameters["include_metadata"], bool):
raise serializers.ValidationError("include_metadata must be a boolean")
else:
parameters["include_metadata"] = True
if any(op.get("doc", 0) >= len(operations) for op in operations):
raise serializers.ValidationError("doc index is out of bounds")
if parameters["update_document"]:
max_idx = max(op.get("doc", 0) for op in operations)
if max_idx > 0:
raise serializers.ValidationError(
"update_document only allowed with a single output document",
)
doc = Document.objects.get(id=document_id)
# doc existence is already validated
if doc.page_count:
for op in operations:
if op["page"] > doc.page_count:
raise serializers.ValidationError(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
)
def _validate_parameters_reprocess(self, parameters) -> None:
if "remote_ocr" in parameters:
if not isinstance(parameters["remote_ocr"], bool):
raise serializers.ValidationError("remote_ocr must be a boolean")
else:
parameters["remote_ocr"] = False
def validate_parameters_remove_password(self, parameters):
if "password" not in parameters:
raise serializers.ValidationError("password not specified")
if not isinstance(parameters["password"], str):
raise serializers.ValidationError("password must be a string")
def validate(self, attrs):
attrs = super().validate(attrs)
if attrs.get("all", False) and attrs["method"] in [
bulk_edit.merge,
bulk_edit.split,
bulk_edit.delete_pages,
bulk_edit.edit_pdf,
bulk_edit.remove_password,
]:
raise serializers.ValidationError(
"This method does not support all=true.",
)
method = attrs["method"]
parameters = attrs["parameters"]
if "source_mode" in parameters:
self._validate_source_mode(parameters)
if method == bulk_edit.set_correspondent:
self._validate_parameters_correspondent(parameters)
elif method == bulk_edit.set_document_type:
self._validate_parameters_document_type(parameters)
elif method == bulk_edit.add_tag or method == bulk_edit.remove_tag:
self._validate_parameters_tags(parameters)
elif method == bulk_edit.modify_tags:
self._validate_parameters_modify_tags(parameters)
elif method == bulk_edit.set_storage_path:
self._validate_storage_path(parameters)
elif method == bulk_edit.modify_custom_fields:
self._validate_parameters_modify_custom_fields(parameters)
elif method == bulk_edit.set_permissions:
self._validate_parameters_set_permissions(parameters)
elif method == bulk_edit.rotate:
self._validate_parameters_rotate(parameters)
elif method == bulk_edit.split:
if len(attrs["documents"]) > 1:
raise serializers.ValidationError(
"Split method only supports one document",
)
self._validate_parameters_split(parameters, attrs["documents"][0])
elif method == bulk_edit.delete_pages:
if len(attrs["documents"]) > 1:
raise serializers.ValidationError(
"Delete pages method only supports one document",
)
self._validate_parameters_delete_pages(parameters)
elif method == bulk_edit.merge:
self._validate_parameters_merge(parameters)
elif method == bulk_edit.edit_pdf:
if len(attrs["documents"]) > 1:
raise serializers.ValidationError(
"Edit PDF method only supports one document",
)
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
elif method == bulk_edit.remove_password:
self.validate_parameters_remove_password(parameters)
elif method == bulk_edit.reprocess:
self._validate_parameters_reprocess(parameters)
return attrs
class BulkDownloadSerializer(DocumentSelectionSerializer):
content = serializers.ChoiceField(
choices=["archive", "originals", "both"],
default="archive",
)
compression = serializers.ChoiceField(
choices=["none", "deflated", "bzip2", "lzma"],
default="none",
)
follow_formatting = serializers.BooleanField(
default=False,
)
def validate_compression(self, compression):
import zipfile
return {
"none": zipfile.ZIP_STORED,
"deflated": zipfile.ZIP_DEFLATED,
"bzip2": zipfile.ZIP_BZIP2,
"lzma": zipfile.ZIP_LZMA,
}[compression]
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
objects = serializers.ListField(
required=False,
allow_empty=True,
label="Objects",
write_only=True,
child=serializers.IntegerField(),
)
all = serializers.BooleanField(
default=False,
required=False,
write_only=True,
)
filters = serializers.DictField(
required=False,
allow_empty=True,
write_only=True,
)
object_type = serializers.ChoiceField(
choices=[
"tags",
"correspondents",
"document_types",
"storage_paths",
],
label="Object Type",
write_only=True,
)
operation = serializers.ChoiceField(
choices=[
"set_permissions",
"delete",
],
label="Operation",
required=True,
write_only=True,
)
owner = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=False,
allow_null=True,
)
permissions = SetPermissionsSerializer(
label="Set permissions",
required=False,
write_only=True,
)
merge = serializers.BooleanField(
default=False,
write_only=True,
required=False,
)
def get_object_class(self, object_type):
object_class = None
if object_type == "tags":
object_class = Tag
elif object_type == "correspondents":
object_class = Correspondent
elif object_type == "document_types":
object_class = DocumentType
elif object_type == "storage_paths":
object_class = StoragePath
return object_class
def _validate_objects(self, objects, object_type):
if not isinstance(objects, list):
raise serializers.ValidationError("objects must be a list")
if not all(isinstance(i, int) for i in objects):
raise serializers.ValidationError("objects must be a list of integers")
object_class = self.get_object_class(object_type)
count = object_class.objects.filter(id__in=objects).count()
if not count == len(objects):
raise serializers.ValidationError(
"Some ids in objects don't exist or were specified twice.",
)
return objects
def _validate_permissions(self, permissions) -> dict:
return self.validate_set_permissions(
permissions,
)
def validate(self, attrs):
object_type = attrs["object_type"]
objects = attrs.get("objects")
apply_to_all = attrs.get("all", False)
operation = attrs.get("operation")
if apply_to_all:
attrs.setdefault("objects", [])
else:
if objects is None:
raise serializers.ValidationError(
"objects is required unless all is true.",
)
if len(objects) == 0:
raise serializers.ValidationError("objects must not be empty")
self._validate_objects(objects, object_type)
if operation == "set_permissions":
permissions = attrs.get("permissions")
if permissions is not None:
if not permissions:
raise serializers.ValidationError(
"permissions must not be empty",
)
attrs["permissions"] = self._validate_permissions(permissions)
return attrs
+484
View File
@@ -0,0 +1,484 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from typing import Any
from typing import TypedDict
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils.dateparse import parse_datetime
from django.utils.timezone import get_current_timezone
from django.utils.timezone import is_naive
from django.utils.timezone import make_aware
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.utils import extend_schema_serializer
from drf_writable_nested.serializers import NestedUpdateMixin
from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
if settings.AUDIT_LOG_ENABLED:
from auditlog.context import set_actor
from documents import bulk_edit
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import Tag
from documents.permissions import permitted_document_ids
from documents.versioning import has_prefetched_effective_content
from documents.versioning import sort_versions_newest_first
from .base import DocumentUpdateFieldsModelSerializer
from .base import NotesSerializer
from .base import OwnedObjectListSerializer
from .base import OwnedObjectSerializer
from .metadata import CorrespondentField
from .metadata import CustomFieldInstanceSerializer
from .metadata import DocumentTypeField
from .metadata import StoragePathField
from .metadata import TagsField
from .upload import PostDocumentSerializer
if TYPE_CHECKING:
from datetime import datetime
from django.db.models.query import QuerySet
from rest_framework.relations import ManyRelatedField
from rest_framework.relations import RelatedField
logger = logging.getLogger("paperless.serializers")
def _get_viewable_duplicates(
document: Document,
user: User | None,
) -> QuerySet[Document]:
checksums = {document.checksum}
if document.archive_checksum:
checksums.add(document.archive_checksum)
duplicates = Document.global_objects.filter(
Q(checksum__in=checksums) | Q(archive_checksum__in=checksums),
).exclude(pk=document.pk)
duplicates = duplicates.filter(root_document__isnull=True)
duplicates = duplicates.order_by("-created")
allowed_ids = permitted_document_ids(user, include_deleted=True)
return duplicates.filter(id__in=allowed_ids)
class DuplicateDocumentSummarySerializer(serializers.Serializer[dict[str, Any]]):
id = serializers.IntegerField()
title = serializers.CharField()
deleted_at = serializers.DateTimeField(allow_null=True)
class _DocumentVersionInfo(TypedDict):
id: int
added: datetime
version_label: str | None
checksum: str | None
is_root: bool
class DocumentVersionInfoSerializer(serializers.Serializer[_DocumentVersionInfo]):
id = serializers.IntegerField()
added = serializers.DateTimeField()
version_label = serializers.CharField(required=False, allow_null=True)
checksum = serializers.CharField(required=False, allow_null=True)
is_root = serializers.BooleanField()
@extend_schema_serializer(
deprecate_fields=["created_date"],
)
class DocumentSerializer(
OwnedObjectSerializer,
NestedUpdateMixin,
DocumentUpdateFieldsModelSerializer,
):
correspondent = CorrespondentField(allow_null=True)
tags = TagsField(many=True)
document_type = DocumentTypeField(allow_null=True)
storage_path = StoragePathField(allow_null=True)
original_file_name = SerializerMethodField()
archived_file_name = SerializerMethodField()
created_date = serializers.DateField(required=False)
page_count = SerializerMethodField()
duplicate_documents = SerializerMethodField()
notes = NotesSerializer(many=True, required=False, read_only=True)
root_document: RelatedField[Document, Document, Any] | ManyRelatedField = (
serializers.PrimaryKeyRelatedField(read_only=True)
)
versions = SerializerMethodField()
custom_fields = CustomFieldInstanceSerializer(
many=True,
allow_null=False,
required=False,
)
owner = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=False,
allow_null=True,
)
remove_inbox_tags = serializers.BooleanField(
default=False,
write_only=True,
allow_null=True,
required=False,
)
def get_page_count(self, obj) -> int | None:
# Like content versions get their own page count from the newest version,
# use the prefetched versions cache to avoid an extra query
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
prefetched_versions = (
prefetched_cache.get("versions")
if isinstance(prefetched_cache, dict)
else None
)
if obj.root_document_id is None and prefetched_versions:
return sort_versions_newest_first(prefetched_versions)[0].page_count
return obj.page_count
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
def get_duplicate_documents(self, obj):
view = self.context.get("view")
if view and getattr(view, "action", None) != "retrieve":
return []
request = self.context.get("request")
user = request.user if request else None
duplicates = _get_viewable_duplicates(obj, user)
return list(duplicates.values("id", "title", "deleted_at"))
@extend_schema_field(DocumentVersionInfoSerializer(many=True))
def get_versions(self, obj):
root_doc = obj if obj.root_document_id is None else obj.root_document
if root_doc is None:
return []
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
prefetched_versions = (
prefetched_cache.get("versions")
if isinstance(prefetched_cache, dict)
else None
)
versions: list[Document]
if prefetched_versions is not None:
versions = [*prefetched_versions, root_doc]
else:
versions_qs = Document.objects.filter(root_document=root_doc).only(
"id",
"added",
"checksum",
"version_label",
"root_document_id",
"version_index",
)
versions = [*versions_qs, root_doc]
versions = sort_versions_newest_first(versions)
def build_info(doc: Document) -> _DocumentVersionInfo:
return {
"id": doc.id,
"added": doc.added,
"version_label": doc.version_label,
"checksum": doc.checksum,
"is_root": doc.id == root_doc.id,
}
return [build_info(doc) for doc in versions]
def get_original_file_name(self, obj) -> str | None:
return obj.original_filename
def get_archived_file_name(self, obj) -> str | None:
if obj.has_archive_version:
return obj.get_public_filename(archive=True)
else:
return None
def to_representation(self, instance):
doc = super().to_representation(instance)
if "content" in self.fields and has_prefetched_effective_content(instance):
# Only resolve version-aware content when it's cheap: an SQL
# annotation or a versions prefetch is already on the instance.
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
# which build their own querysets) gets the document's own,
# unresolved content instead of paying for an extra per-instance
# query -- same as before effective_content resolution existed.
doc["content"] = instance.get_effective_content() or ""
if self.truncate_content and "content" in self.fields:
doc["content"] = doc.get("content")[0:550]
return doc
def to_internal_value(self, data):
if (
"created" in data
and isinstance(data["created"], str)
and ":" in data["created"]
):
# Handle old format of isoformat datetime string
parsed = parse_datetime(data["created"])
if parsed:
if is_naive(parsed):
parsed = make_aware(parsed, get_current_timezone())
data["created"] = parsed.astimezone().date()
return super().to_internal_value(data)
def validate(self, attrs):
if (
"archive_serial_number" in attrs
and attrs["archive_serial_number"] is not None
and len(str(attrs["archive_serial_number"])) > 0
and Document.deleted_objects.filter(
archive_serial_number=attrs["archive_serial_number"],
).exists()
):
raise serializers.ValidationError(
{
"archive_serial_number": [
"Document with this Archive Serial Number already exists in the trash.",
],
},
)
return super().validate(attrs)
def update(self, instance: Document, validated_data):
if "created_date" in validated_data:
if "created" not in validated_data:
validated_data["created"] = validated_data["created_date"]
logger.warning(
"created_date is deprecated, use created instead",
)
validated_data.pop("created_date")
if instance.custom_fields.count() > 0 and "custom_fields" in validated_data:
incoming_custom_fields = [
field["field"] for field in validated_data["custom_fields"]
]
for custom_field_instance in instance.custom_fields.filter(
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
):
if (
custom_field_instance.field not in incoming_custom_fields
and custom_field_instance.value is not None
):
# Doc link field is being removed entirely
for doc_id in custom_field_instance.value:
bulk_edit.remove_doclink(
instance,
custom_field_instance.field,
doc_id,
)
if "tags" in validated_data:
# Respect tag hierarchy on updates:
# - Adding a child adds its ancestors
# - Removing a parent removes all its descendants
prev_tags = set(instance.tags.all())
requested_tags = set(validated_data["tags"])
# Tags newly added in this update and the ancestors they require
added_tags = requested_tags - prev_tags
required_by_add_tags = set(added_tags)
for t in added_tags:
required_by_add_tags.update(t.get_ancestors())
# Tags being removed in this update and all descendants, except
# those required by a tag that is being added in this same update
removed_tags = prev_tags - requested_tags
blocked_tags = set(removed_tags)
for t in removed_tags:
blocked_tags.update(t.get_descendants())
blocked_tags.difference_update(required_by_add_tags)
# Add all parent tags
final_tags = set(requested_tags)
for t in requested_tags:
final_tags.update(t.get_ancestors())
# Drop removed parents and their descendants
final_tags.difference_update(blocked_tags)
validated_data["tags"] = list(final_tags)
if validated_data.get("remove_inbox_tags"):
current_tag_ids = {t.pk for t in instance.tags.all()}
tags = (
validated_data["tags"]
if "tags" in validated_data
else list(instance.tags.all())
)
# Tags newly added in this update, plus their ancestors, are kept
keep_ids: set[int] = set()
for tag in tags:
if tag.pk not in current_tag_ids:
keep_ids.add(tag.pk)
keep_ids.update(int(pk) for pk in tag.get_ancestors_pks())
# Remove inbox tags and their descendants, except those being kept
remove_ids: set[int] = set()
for inbox_tag in (
Tag.objects.filter(is_inbox_tag=True)
.exclude(pk__in=keep_ids)
.only("pk", "tn_descendants_pks")
):
remove_ids.add(inbox_tag.pk)
remove_ids.update(int(pk) for pk in inbox_tag.get_descendants_pks())
validated_data["tags"] = [t for t in tags if t.pk not in remove_ids]
if settings.AUDIT_LOG_ENABLED:
with set_actor(self.user):
super().update(instance, validated_data)
else:
super().update(instance, validated_data)
# hard delete custom field instances that were soft deleted
CustomFieldInstance.deleted_objects.filter(document=instance).delete()
return instance
def __init__(self, *args, **kwargs) -> None:
self.truncate_content = kwargs.pop("truncate_content", False)
# return full permissions if we're doing a PATCH or PUT
context = kwargs.get("context")
if context is not None and (
context.get("request").method == "PATCH"
or context.get("request").method == "PUT"
):
kwargs["full_perms"] = True
super().__init__(*args, **kwargs)
class Meta:
model = Document
fields = (
"id",
"correspondent",
"document_type",
"storage_path",
"title",
"content",
"tags",
"created",
"created_date",
"modified",
"added",
"deleted_at",
"archive_serial_number",
"original_file_name",
"archived_file_name",
"duplicate_documents",
"owner",
"permissions",
"user_can_change",
"is_shared_by_requester",
"set_permissions",
"notes",
"custom_fields",
"remove_inbox_tags",
"page_count",
"mime_type",
"root_document",
"versions",
)
read_only_fields = ("deleted_at",)
list_serializer_class = OwnedObjectListSerializer
class SearchResultListSerializer(serializers.ListSerializer[Document]):
def to_representation(self, hits):
document_ids = [hit["id"] for hit in hits]
# Fetch all Document objects in the list in one SQL query.
documents = self.child.fetch_documents(document_ids)
self.child.context["documents"] = documents
# Also check if they are shared with other users / groups.
self.child.context["shared_object_pks"] = self.child.get_shared_object_pks(
documents.values(),
)
return super().to_representation(hits)
class SearchResultSerializer(DocumentSerializer):
@staticmethod
def fetch_documents(ids):
"""
Return a dict that maps given document IDs to Document objects.
"""
return {
document.id: document
for document in Document.objects.select_related(
"correspondent",
"storage_path",
"document_type",
"owner",
)
.prefetch_related("tags", "custom_fields", "notes")
.filter(id__in=ids)
}
def to_representation(self, hit):
# Again we first check if the parent has already fetched the documents.
documents = self.context.get("documents")
# Otherwise we fetch this document.
if documents is None: # pragma: no cover
# In practice we only serialize **lists** of SearchHit dicts.
# Keeping this check for completeness but marking it no cover for now.
documents = self.fetch_documents([hit["id"]])
document = documents[hit["id"]]
highlights = hit.get("highlights", {})
r = super().to_representation(document)
r["__search_hit__"] = {
"score": hit["score"],
"highlights": highlights.get("content", ""),
"note_highlights": highlights.get("notes") or None,
"rank": hit["rank"],
}
return r
class Meta(DocumentSerializer.Meta):
list_serializer_class = SearchResultListSerializer
class DocumentVersionSerializer(serializers.Serializer[dict[str, Any]]):
document = serializers.FileField(
label="Document",
write_only=True,
)
version_label = serializers.CharField(
label="Version label",
required=False,
allow_blank=True,
allow_null=True,
max_length=64,
)
validate_document = PostDocumentSerializer().validate_document
class DocumentVersionLabelSerializer(serializers.Serializer[dict[str, str | None]]):
version_label = serializers.CharField(
label="Version label",
required=True,
allow_blank=True,
allow_null=True,
max_length=64,
)
def validate_version_label(self, value):
if value is None:
return None
normalized = value.strip()
return normalized or None
+557
View File
@@ -0,0 +1,557 @@
from __future__ import annotations
import logging
import math
import re
from decimal import Decimal
from typing import Any
from django.core.exceptions import ValidationError
from django.core.validators import DecimalValidator
from django.core.validators import MaxLengthValidator
from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator
from django.core.validators import RegexValidator
from django.core.validators import integer_validator
from django.db.models import Count
from django.db.models.functions import Lower
from django.utils.crypto import get_random_string
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from documents import bulk_edit
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import get_document_count_filter_for_user
from documents.permissions import permitted_document_ids
from documents.permissions import restrict_queryset_to_visible
from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format
from documents.validators import uri_validator
from .base import MatchingModelSerializer
from .base import OwnedObjectSerializer
from .base import ReadWriteSerializerMethodField
from .base import SerializerWithPerms
logger = logging.getLogger("paperless.serializers")
class CorrespondentSerializer(MatchingModelSerializer, OwnedObjectSerializer):
last_correspondence = serializers.DateField(read_only=True, required=False)
class Meta:
model = Correspondent
fields = (
"id",
"slug",
"name",
"match",
"matching_algorithm",
"is_insensitive",
"document_count",
"last_correspondence",
"owner",
"permissions",
"user_can_change",
"set_permissions",
)
class DocumentTypeSerializer(MatchingModelSerializer, OwnedObjectSerializer):
class Meta:
model = DocumentType
fields = (
"id",
"slug",
"name",
"match",
"matching_algorithm",
"is_insensitive",
"document_count",
"owner",
"permissions",
"user_can_change",
"set_permissions",
)
class DeprecatedColors:
COLOURS = (
(1, "#a6cee3"),
(2, "#1f78b4"),
(3, "#b2df8a"),
(4, "#33a02c"),
(5, "#fb9a99"),
(6, "#e31a1c"),
(7, "#fdbf6f"),
(8, "#ff7f00"),
(9, "#cab2d6"),
(10, "#6a3d9a"),
(11, "#b15928"),
(12, "#000000"),
(13, "#cccccc"),
)
@extend_schema_field(
serializers.ChoiceField(
choices=DeprecatedColors.COLOURS,
),
)
class ColorField(serializers.Field):
def to_internal_value(self, data):
for id, color in DeprecatedColors.COLOURS:
if id == data:
return color
raise serializers.ValidationError
def to_representation(self, value):
for id, color in DeprecatedColors.COLOURS:
if color == value:
return id
return 1
class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
def get_text_color(self, obj) -> str:
try:
h = obj.color.lstrip("#")
rgb = tuple(int(h[i : i + 2], 16) / 256 for i in (0, 2, 4))
luminance = math.sqrt(
0.299 * math.pow(rgb[0], 2)
+ 0.587 * math.pow(rgb[1], 2)
+ 0.114 * math.pow(rgb[2], 2),
)
return "#ffffff" if luminance < 0.53 else "#000000"
except ValueError:
return "#000000"
text_color = serializers.SerializerMethodField()
# map to treenode's tn_parent
parent = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(),
allow_null=True,
required=False,
source="tn_parent",
)
@extend_schema_field(
field=serializers.ListSerializer(
child=serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(),
),
),
)
def get_children(self, obj):
children_map = self.context.get("children_map")
if children_map is not None:
children = children_map.get(obj.pk, [])
else:
filter_q = self.context.get("document_count_filter")
request = self.context.get("request")
if filter_q is None:
user = getattr(request, "user", None) if request else None
filter_q = get_document_count_filter_for_user(user)
self.context["document_count_filter"] = filter_q
children = (
obj.get_children_queryset()
.select_related("owner")
.annotate(document_count=Count("documents", filter=filter_q))
)
user = getattr(request, "user", None) if request else self.user
children = restrict_queryset_to_visible(children, user, "view_tag")
view = self.context.get("view")
ordering = (
OrderingFilter().get_ordering(request, children, view)
if request and view
else None
)
ordering = ordering or (Lower("name"),)
children = children.order_by(*ordering)
if not children:
return []
serializer = TagSerializer(
children,
many=True,
user=self.user,
full_perms=self.full_perms,
all_fields=self.all_fields,
context=self.context,
)
return serializer.data
# children as nested Tag objects
children = serializers.SerializerMethodField()
class Meta:
model = Tag
fields = (
"id",
"slug",
"name",
"color",
"text_color",
"match",
"matching_algorithm",
"is_insensitive",
"is_inbox_tag",
"document_count",
"owner",
"permissions",
"user_can_change",
"set_permissions",
"parent",
"children",
)
def validate_color(self, color):
regex = r"#[0-9a-fA-F]{6}"
if not re.match(regex, color):
raise serializers.ValidationError(_("Invalid color."))
return color
def validate(self, attrs):
# Validate when changing parent
parent = attrs.get(
"tn_parent",
self.instance.get_parent() if self.instance else None,
)
if self.instance:
# Temporarily set parent on the instance if updating and use model clean()
original_parent = self.instance.get_parent()
try:
# Temporarily set tn_parent in-memory to validate clean()
self.instance.tn_parent = parent
self.instance.clean()
except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e)
raise e
finally:
self.instance.tn_parent = original_parent
else:
# For new instances, create a transient Tag and validate
temp = Tag(tn_parent=parent)
try:
temp.clean()
except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e)
raise e
return super().validate(attrs)
class CorrespondentField(serializers.PrimaryKeyRelatedField[Correspondent]):
def get_queryset(self):
return Correspondent.objects.all()
class TagsField(serializers.PrimaryKeyRelatedField[Tag]):
def get_queryset(self):
return Tag.objects.all()
class DocumentTypeField(serializers.PrimaryKeyRelatedField[DocumentType]):
def get_queryset(self):
return DocumentType.objects.all()
class StoragePathField(serializers.PrimaryKeyRelatedField[StoragePath]):
def get_queryset(self):
return StoragePath.objects.all()
class CustomFieldSerializer(serializers.ModelSerializer[CustomField]):
data_type = serializers.ChoiceField(
choices=CustomField.FieldDataType,
read_only=False,
)
document_count = serializers.IntegerField(read_only=True)
class Meta:
model = CustomField
fields = [
"id",
"name",
"data_type",
"extra_data",
"document_count",
]
def validate(self, attrs):
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
name = attrs.get(
"name",
self.instance.name if hasattr(self.instance, "name") else None,
)
objects = (
self.Meta.model.objects.exclude(
pk=self.instance.pk,
)
if self.instance is not None
else self.Meta.model.objects.all()
)
if ("name" in attrs) and objects.filter(
name=name,
).exists():
raise serializers.ValidationError(
{"error": "Object violates name unique constraint"},
)
if (
"data_type" in attrs
and attrs["data_type"] == CustomField.FieldDataType.SELECT
) or (
self.instance
and self.instance.data_type == CustomField.FieldDataType.SELECT
):
if (
"extra_data" not in attrs
or "select_options" not in attrs["extra_data"]
or not isinstance(attrs["extra_data"]["select_options"], list)
or len(attrs["extra_data"]["select_options"]) == 0
or not all(
len(option.get("label", "")) > 0
for option in attrs["extra_data"]["select_options"]
)
):
raise serializers.ValidationError(
{"error": "extra_data.select_options must be a valid list"},
)
# labels are valid, generate ids if not present
for option in attrs["extra_data"]["select_options"]:
if option.get("id") is None:
option["id"] = get_random_string(length=16)
elif (
"data_type" in attrs
and attrs["data_type"] == CustomField.FieldDataType.MONETARY
and "extra_data" in attrs
and "default_currency" in attrs["extra_data"]
and attrs["extra_data"]["default_currency"] is not None
and (
not isinstance(attrs["extra_data"]["default_currency"], str)
or (
len(attrs["extra_data"]["default_currency"]) > 0
and len(attrs["extra_data"]["default_currency"]) != 3
)
)
):
raise serializers.ValidationError(
{"error": "extra_data.default_currency must be a 3-character string"},
)
return super().validate(attrs)
def validate_documentlink_targets(user, doc_ids):
if Document.objects.filter(id__in=doc_ids).count() != len(doc_ids):
raise serializers.ValidationError(
"Some documents in value don't exist or were specified twice.",
)
if user is None:
return
if (
Document.objects.filter(id__in=doc_ids)
.exclude(id__in=permitted_document_ids(user, perm="change_document"))
.exists()
):
raise PermissionDenied(
_("Insufficient permissions."),
)
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
value = ReadWriteSerializerMethodField(allow_null=True)
def create(self, validated_data):
# An instance is attached to a document
document: Document = validated_data["document"]
# And to a CustomField
custom_field: CustomField = validated_data["field"]
# This key must exist, as it is validated
data_store_name = CustomFieldInstance.get_value_field_name(
custom_field.data_type,
)
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
# prior to update so we can look for any docs that are going to be removed
bulk_edit.reflect_doclinks(document, custom_field, validated_data["value"])
# Actually update or create the instance, providing the value
# to fill in the correct attribute based on the type
instance, _ = CustomFieldInstance.objects.update_or_create(
document=document,
field=custom_field,
defaults={data_store_name: validated_data["value"]},
)
return instance
def get_value(self, obj: CustomFieldInstance) -> str | int | float | dict | None:
return obj.value
def validate(self, data):
"""
Probably because we're kind of doing it odd, validation from the model
doesn't run against the field "value", so we have to re-create it here.
Don't like it, but it is better than returning an HTTP 500 when the database
hates the value
"""
data = super().validate(data)
field: CustomField = data["field"]
if "value" in data and data["value"] is not None:
if (
field.data_type == CustomField.FieldDataType.URL
and len(data["value"]) > 0
):
uri_validator(data["value"])
elif field.data_type == CustomField.FieldDataType.INT:
integer_validator(data["value"])
try:
value_int = int(data["value"])
except (TypeError, ValueError):
raise serializers.ValidationError("Enter a valid integer.")
# Keep values within the PostgreSQL integer range
MinValueValidator(-2147483648)(value_int)
MaxValueValidator(2147483647)(value_int)
elif (
field.data_type == CustomField.FieldDataType.MONETARY
and data["value"] != ""
):
try:
# First try to validate as a number from legacy format
DecimalValidator(max_digits=12, decimal_places=2)(
Decimal(str(data["value"])),
)
except Exception:
# If that fails, try to validate as a monetary string
RegexValidator(
regex=r"^[A-Z]{3}-?\d+(\.\d{1,2})$",
message="Must be a two-decimal number with optional currency code e.g. GBP123.45",
)(data["value"])
elif field.data_type == CustomField.FieldDataType.STRING:
MaxLengthValidator(limit_value=128)(data["value"])
elif field.data_type == CustomField.FieldDataType.SELECT:
select_options = field.extra_data["select_options"]
try:
next(
option
for option in select_options
if option["id"] == data["value"]
)
except Exception:
raise serializers.ValidationError(
f"Value must be an id of an element in {select_options}",
)
elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
if not (isinstance(data["value"], list) or data["value"] is None):
raise serializers.ValidationError(
"Value must be a list",
)
doc_ids = data["value"]
request = self.context.get("request")
validate_documentlink_targets(
getattr(request, "user", None) if request is not None else None,
doc_ids,
)
elif field.data_type == CustomField.FieldDataType.DATE:
data["value"] = serializers.DateField().to_internal_value(data["value"])
return data
class Meta:
model = CustomFieldInstance
fields = [
"value",
"field",
]
class StoragePathSerializer(MatchingModelSerializer, OwnedObjectSerializer):
class Meta:
model = StoragePath
fields = (
"id",
"slug",
"name",
"path",
"match",
"matching_algorithm",
"is_insensitive",
"document_count",
"owner",
"permissions",
"user_can_change",
"set_permissions",
)
def validate_path(self, path: str):
converted_path = convert_format_str_to_template_format(path)
if converted_path != path:
logger.warning(
f"Storage path {path} is not using the new style format, consider updating",
)
result = validate_filepath_template_and_render(converted_path)
if result is None:
raise serializers.ValidationError(_("Invalid variable detected."))
return converted_path
def update(self, instance, validated_data):
"""
When a storage path is updated, see if documents
using it require a rename/move
"""
doc_ids = [doc.id for doc in instance.documents.all()]
if doc_ids:
bulk_edit.bulk_update_documents.apply_async(
kwargs={"document_ids": doc_ids},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return super().update(instance, validated_data)
class StoragePathTestSerializer(SerializerWithPerms):
path = serializers.CharField(
required=True,
label="Path",
write_only=True,
)
document = serializers.PrimaryKeyRelatedField(
queryset=Document.objects.none(),
required=True,
label="Document",
write_only=True,
)
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
request = self.context.get("request")
user = getattr(request, "user", None) if request else None
if user is not None and user.is_authenticated:
document_field = self.fields.get("document")
if not isinstance(document_field, serializers.PrimaryKeyRelatedField):
return
document_field.queryset = Document.objects.filter(
id__in=permitted_document_ids(user),
)
+234
View File
@@ -0,0 +1,234 @@
from __future__ import annotations
import logging
import re
from django.conf import settings
from rest_framework import serializers
from documents.models import CustomField
from documents.models import SavedView
from documents.models import SavedViewFilterRule
from documents.models import UiSettings
from .base import OwnedObjectSerializer
logger = logging.getLogger("paperless.serializers")
class SavedViewFilterRuleSerializer(serializers.ModelSerializer[SavedViewFilterRule]):
class Meta:
model = SavedViewFilterRule
fields = ["rule_type", "value"]
class SavedViewSerializer(OwnedObjectSerializer):
filter_rules = SavedViewFilterRuleSerializer(many=True)
class Meta:
model = SavedView
fields = [
"id",
"name",
"icon",
"sort_field",
"sort_reverse",
"filter_rules",
"page_size",
"display_mode",
"display_fields",
"owner",
"permissions",
"user_can_change",
"set_permissions",
]
def _get_api_version(self) -> int:
request = self.context.get("request")
return int(
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
)
def _update_legacy_visibility_preferences(
self,
saved_view_id: int,
*,
show_on_dashboard: bool | None,
show_in_sidebar: bool | None,
) -> UiSettings | None:
if show_on_dashboard is None and show_in_sidebar is None:
return None
request = self.context.get("request")
user = request.user if request else self.user
if user is None:
return None
ui_settings, _ = UiSettings.objects.get_or_create(
user=user,
defaults={"settings": {}},
)
current_settings = (
ui_settings.settings if isinstance(ui_settings.settings, dict) else {}
)
current_settings = dict(current_settings)
saved_views_settings = current_settings.get("saved_views")
if isinstance(saved_views_settings, dict):
saved_views_settings = dict(saved_views_settings)
else:
saved_views_settings = {}
dashboard_ids = {
int(raw_id)
for raw_id in saved_views_settings.get("dashboard_views_visible_ids", [])
if str(raw_id).isdigit()
}
sidebar_ids = {
int(raw_id)
for raw_id in saved_views_settings.get("sidebar_views_visible_ids", [])
if str(raw_id).isdigit()
}
if show_on_dashboard is not None:
if show_on_dashboard:
dashboard_ids.add(saved_view_id)
else:
dashboard_ids.discard(saved_view_id)
if show_in_sidebar is not None:
if show_in_sidebar:
sidebar_ids.add(saved_view_id)
else:
sidebar_ids.discard(saved_view_id)
saved_views_settings["dashboard_views_visible_ids"] = sorted(dashboard_ids)
saved_views_settings["sidebar_views_visible_ids"] = sorted(sidebar_ids)
current_settings["saved_views"] = saved_views_settings
ui_settings.settings = current_settings
ui_settings.save(update_fields=["settings"])
return ui_settings
def to_representation(self, instance):
# TODO: remove this and related backwards compatibility code when API v9 is dropped
ret = super().to_representation(instance)
request = self.context.get("request")
api_version = self._get_api_version()
if api_version < 10:
dashboard_ids = set()
sidebar_ids = set()
user = request.user if request else None
if user is not None and hasattr(user, "ui_settings"):
ui_settings = user.ui_settings.settings or None
saved_views = None
if isinstance(ui_settings, dict):
saved_views = ui_settings.get("saved_views", {})
if isinstance(saved_views, dict):
dashboard_ids = set(
saved_views.get("dashboard_views_visible_ids", []),
)
sidebar_ids = set(
saved_views.get("sidebar_views_visible_ids", []),
)
ret["show_on_dashboard"] = instance.id in dashboard_ids
ret["show_in_sidebar"] = instance.id in sidebar_ids
return ret
def to_internal_value(self, data):
# TODO: remove this and related backwards compatibility code when API v9 is dropped
api_version = self._get_api_version()
if api_version >= 10:
return super().to_internal_value(data)
normalized_data = data.copy()
legacy_visibility_fields = {}
boolean_field = serializers.BooleanField()
for field_name in ("show_on_dashboard", "show_in_sidebar"):
if field_name in normalized_data:
try:
legacy_visibility_fields[field_name] = (
boolean_field.to_internal_value(
normalized_data.get(field_name),
)
)
except serializers.ValidationError as exc:
raise serializers.ValidationError({field_name: exc.detail})
del normalized_data[field_name]
ret = super().to_internal_value(normalized_data)
ret.update(legacy_visibility_fields)
return ret
def validate(self, attrs):
attrs = super().validate(attrs)
if "display_fields" in attrs and attrs["display_fields"] is not None:
for field in attrs["display_fields"]:
if (
SavedView.DisplayFields.CUSTOM_FIELD[:-2] in field
): # i.e. check for 'custom_field_' prefix
field_id = int(re.search(r"\d+", field)[0])
if not CustomField.objects.filter(id=field_id).exists():
raise serializers.ValidationError(
f"Invalid field: {field}",
)
elif field not in SavedView.DisplayFields.values:
raise serializers.ValidationError(
f"Invalid field: {field}",
)
return attrs
def update(self, instance, validated_data):
request = self.context.get("request")
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
if "filter_rules" in validated_data:
rules_data = validated_data.pop("filter_rules")
else:
rules_data = None
if "user" in validated_data:
# backwards compatibility
validated_data["owner"] = validated_data.pop("user")
if (
"display_fields" in validated_data
and isinstance(
validated_data["display_fields"],
list,
)
and len(validated_data["display_fields"]) == 0
):
validated_data["display_fields"] = None
instance = super().update(instance, validated_data)
if rules_data is not None:
SavedViewFilterRule.objects.filter(saved_view=instance).delete()
for rule_data in rules_data:
SavedViewFilterRule.objects.create(saved_view=instance, **rule_data)
ui_settings = self._update_legacy_visibility_preferences(
instance.id,
show_on_dashboard=show_on_dashboard,
show_in_sidebar=show_in_sidebar,
)
if request is not None and ui_settings is not None:
request.user.ui_settings = ui_settings
return instance
def create(self, validated_data):
request = self.context.get("request")
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
rules_data = validated_data.pop("filter_rules")
if "user" in validated_data:
# backwards compatibility
validated_data["owner"] = validated_data.pop("user")
saved_view = super().create(validated_data)
for rule_data in rules_data:
SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data)
ui_settings = self._update_legacy_visibility_preferences(
saved_view.id,
show_on_dashboard=show_on_dashboard,
show_in_sidebar=show_in_sidebar,
)
if request is not None and ui_settings is not None:
request.user.ui_settings = ui_settings
return saved_view
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
import logging
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.translation import gettext as _
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.fields import SerializerMethodField
from documents.models import Document
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.permissions import has_perms_owner_aware
from .base import DocumentListSerializer
from .base import OwnedObjectSerializer
logger = logging.getLogger("paperless.serializers")
class EmailSerializer(DocumentListSerializer):
addresses = serializers.CharField(
required=True,
label="Email addresses",
help_text="Comma-separated email addresses",
)
subject = serializers.CharField(
required=True,
label="Email subject",
)
message = serializers.CharField(
required=True,
label="Email message",
)
use_archive_version = serializers.BooleanField(
default=True,
label="Use archive version",
help_text="Use archive version of documents if available",
)
def validate_addresses(self, addresses):
address_list = [addr.strip() for addr in addresses.split(",")]
if not address_list:
raise serializers.ValidationError("At least one email address is required")
email_validator = EmailValidator()
try:
for address in address_list:
email_validator(address)
except ValidationError:
raise serializers.ValidationError(f"Invalid email address: {address}")
return ",".join(address_list)
def validate_documents(self, documents):
super().validate_documents(documents)
if not documents:
raise serializers.ValidationError("At least one document is required")
return documents
class ShareLinkSerializer(OwnedObjectSerializer):
document_title = serializers.CharField(
source="document.title",
read_only=True,
)
class Meta:
model = ShareLink
fields = (
"id",
"created",
"expiration",
"slug",
"document",
"document_title",
"file_version",
)
def create(self, validated_data):
validated_data["slug"] = get_random_string(50)
return super().create(validated_data)
def validate_document(self, document):
if (
self.user is not None
and self.user.has_perm("documents.view_document")
and has_perms_owner_aware(
self.user,
"view_document",
document,
)
):
return document
raise PermissionDenied(
_("Insufficient permissions."),
)
class ShareLinkBundleSerializer(OwnedObjectSerializer):
document_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=False,
write_only=True,
)
expiration_days = serializers.IntegerField(
required=False,
allow_null=True,
min_value=1,
write_only=True,
)
documents = serializers.PrimaryKeyRelatedField(
many=True,
read_only=True,
)
document_count = SerializerMethodField()
class Meta:
model = ShareLinkBundle
fields = (
"id",
"created",
"expiration",
"expiration_days",
"slug",
"file_version",
"status",
"size_bytes",
"last_error",
"built_at",
"documents",
"document_ids",
"document_count",
)
read_only_fields = (
"id",
"created",
"expiration",
"slug",
"status",
"size_bytes",
"last_error",
"built_at",
"documents",
"document_count",
)
def validate_document_ids(self, value):
unique_ids = set(value)
if len(unique_ids) != len(value):
raise serializers.ValidationError(
_("Duplicate document identifiers are not allowed."),
)
return value
def create(self, validated_data):
document_ids = validated_data.pop("document_ids")
expiration_days = validated_data.pop("expiration_days", None)
validated_data["slug"] = get_random_string(50)
if expiration_days:
validated_data["expiration"] = timezone.now() + timedelta(
days=expiration_days,
)
else:
validated_data["expiration"] = None
share_link_bundle = super().create(validated_data)
documents = list(
Document.objects.filter(pk__in=document_ids).only(
"pk",
),
)
documents_by_id = {doc.pk: doc for doc in documents}
missing = [
str(doc_id) for doc_id in document_ids if doc_id not in documents_by_id
]
if missing:
raise serializers.ValidationError(
{
"document_ids": _(
"Documents not found: %(ids)s",
)
% {"ids": ", ".join(missing)},
},
)
ordered_documents = [documents_by_id[doc_id] for doc_id in document_ids]
share_link_bundle.documents.set(ordered_documents)
share_link_bundle.document_total = len(ordered_documents)
return share_link_bundle
def get_document_count(self, obj: ShareLinkBundle) -> int:
return getattr(obj, "document_total") or obj.documents.count()
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import logging
from rest_framework import serializers
from documents.models import Document
from documents.models import UiSettings
from .base import SerializerWithPerms
logger = logging.getLogger("paperless.serializers")
class UiSettingsViewSerializer(serializers.ModelSerializer[UiSettings]):
settings = serializers.DictField(required=False, allow_null=True)
class Meta:
model = UiSettings
depth = 1
fields = [
"id",
"settings",
]
def validate_settings(self, settings):
# we never save update checking backend setting
if "update_checking" in settings:
try:
settings["update_checking"].pop("backend_setting")
except KeyError:
pass
return settings
def create(self, validated_data):
ui_settings = UiSettings.objects.update_or_create(
user=validated_data.get("user"),
defaults={"settings": validated_data.get("settings", None)},
)
return ui_settings
class TrashSerializer(SerializerWithPerms):
documents = serializers.ListField(
required=False,
label="Documents",
write_only=True,
child=serializers.IntegerField(),
)
action = serializers.ChoiceField(
choices=["restore", "empty"],
label="Action",
write_only=True,
)
def validate_documents(self, documents: list[int]) -> list[int]:
count = Document.deleted_objects.filter(id__in=documents).count()
if not count == len(documents):
raise serializers.ValidationError(
"Some documents in the list have not yet been deleted.",
)
return documents
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff. Show more