Compare commits

..
Author SHA1 Message Date
stumpylog 2104fd5078 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-09-24 12:23:06 -07:00
stumpylog 10dbb77d47 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-09-24 12:22:43 -07:00
Trenton H 2a44d8b5ba Fix: During a move to the trash directory, attempt to copy metadata, but don't let it fail the move (#14250) 2026-09-23 21:51:22 -07:00
Trenton H abf5050ea7 Fix: convert file mtime to the configured time zone directly (#14249)
The `created` date fallback derived a naive datetime from a file's
mtime using the OS-local zone, then labeled it as the configured
TIME_ZONE without converting. When the OS-local zone and TIME_ZONE
disagree, or when the C library can't resolve zoneinfo at all (as in
some sandboxed environments, where it silently falls back to UTC),
the resulting date can land on the wrong calendar day.

Convert the timestamp directly into the target zone with `tz=` on
fromtimestamp() instead of a naive conversion plus make_aware().
2026-09-23 15:00:48 -07:00
GitHub Actions 091ddf7c45 Auto translate strings 2026-09-23 19:13:55 +00:00
shamoon c9f7f2cfbe Fix: ensure documentDeleted subscription is discarded (#14247) 2026-09-23 12:12:26 -07:00
Trenton H b457610ffb Chore: Fix bugs in the test suite (#14244)
* Fix: redirect SHARE_LINK_BUNDLE_DIR to the test temp layout instead of the real media directory

* Fix: include f_to in test_filters subTest labels so each of the 8 cases reports distinctly

* Fix: run the post_consume error-log assertion after the raising call and match the actual paperless_mail logger name

* Fix: rename the blank-password workflow test to match its behavior and add a real wrong-password-fails test

* Fix: use the created social account's actual pk and remove an accidental tuple wrapping the mock provider

* Fix: assert against the created documents' actual pks instead of hardcoded 1 and 2

* Fix: assert test_compression actually produces a valid LZMA-compressed zip

* Fix: clear os.environ when patching PAPERLESS_ADMIN_* vars so a host-set value can't leak into the no-user test

* Fix: restore MIDDLEWARE, AUTHENTICATION_BACKENDS and REST_FRAMEWORK auth classes after each remote-user settings test instead of leaking the mutation into later tests

* Fix: use a guaranteed-nonexistent temp path instead of hardcoded /tmp/foo/bar in test_export_target_not_exists
2026-09-23 19:11:58 +00:00
GitHub Actions 969c2ea0e2 Auto translate strings 2026-09-23 19:00:47 +00:00
shamoon 31b806a285 Fix: ensure bulk operations are checked against version root (#14246) 2026-09-23 18:59:27 +00:00
GitHub Actions 99851b418c Auto translate strings 2026-09-23 18:46:43 +00:00
e34eda07bb Enhancement: support separate embedding API key (#14067)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-09-23 11:44:37 -07:00
GitHub Actions 793459b416 Auto translate strings 2026-09-23 18:22:05 +00:00
shamoon 04297fd02c Enhancement: support passthrough extra params for LLMs (#14202) 2026-09-23 18:20:43 +00:00
dependabot[bot] 1b277dd8e1 Chore(deps): Bump autobahn in the uv group across 1 directory (#14231)
Bumps the uv group with 1 update in the / directory: [autobahn](https://github.com/crossbario/autobahn-python).


Updates `autobahn` from 25.12.2 to 26.7.1
- [Release notes](https://github.com/crossbario/autobahn-python/releases)
- [Changelog](https://github.com/crossbario/autobahn-python/blob/master/docs/changelog.rst)
- [Commits](https://github.com/crossbario/autobahn-python/compare/v25.12.2...v26.7.1)

---
updated-dependencies:
- dependency-name: autobahn
  dependency-version: 26.7.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 15:33:01 +00:00
shamoon 3c20abeb4c Fix: indexing after document-added workflows signal (#14242) 2026-09-23 14:50:48 +00:00
GitHub Actions b11f1f8459 Auto translate strings 2026-09-23 03:19:06 +00:00
Trenton H 7424e7ce0b Fix: Record full tag and custom field lists in bulk edit audit log (#14236)
Snapshot only the edited field, before and after the operation, gathering tags
and custom field instances into sorted id lists per document (empty when there
are none).
2026-09-23 03:17:40 +00:00
shamoon a53a3d3769 Chore: update pikepdf for ocrmypdf requirement (#14235) 2026-09-22 19:01:21 -07:00
92 changed files with 12613 additions and 14362 deletions
+343 -320
View File
@@ -451,166 +451,174 @@ src/documents/search/_backend.py:0: error: Tuple index out of range [misc]
src/documents/search/_backend.py:0: error: Tuple index out of range [misc]
src/documents/search/_query.py:0: error: Argument 3 to "regex_phrase_query" of "Query" has incompatible type "list[str]"; expected "list[str | tuple[int, str]]" [arg-type]
src/documents/search/_query.py:0: error: Library stubs not installed for "regex" [import-untyped]
src/documents/serialisers.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.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
src/documents/serialisers.py:0: error: Argument "choices" to "ChoiceField" has incompatible type "type[FieldDataType]"; expected "Sequence[Any]" [arg-type]
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.py:0: error: Argument 1 of "get_value" is incompatible with supertype "rest_framework.fields.Field"; supertype defines the argument type as "Mapping[Any, Any]" [override]
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.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: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
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.py:0: error: Expected iterable as variadic argument [misc]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.py:0: error: Function is missing a type annotation for one or more parameters [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.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "UnknownQuerySet[Document, Document]") [assignment]
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.py:0: error: Incompatible types in assignment (expression has type "WorkflowActionWebhookSerializer", variable has type "WorkflowActionEmailSerializer") [assignment]
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.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: 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: Incompatible types in assignment (expression has type "type[StoragePath]", variable has type "type[Tag] | None") [assignment]
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.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: 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: Item "None" of "Any | None" has no attribute "name" [union-attr]
src/documents/serialisers.py:0: error: Item "None" of "Any | None" has no attribute "pk" [union-attr]
src/documents/serialisers.py:0: error: Item "None" of "CustomField | None" has no attribute "name" [union-attr]
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
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.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: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
src/documents/serialisers.py:0: error: Item "None" of "ObjectPermissionChecker | None" has no attribute "has_perm" [union-attr]
src/documents/serialisers.py:0: error: Item "list[str]" of "Any | list[str]" has no attribute "values_list" [union-attr]
src/documents/serialisers.py:0: error: Missing type arguments for generic type "Field" [type-arg]
src/documents/serialisers.py:0: error: Missing type arguments for generic type "Iterable" [type-arg]
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/documents/serialisers.py:0: error: Need type annotation for "document" [var-annotated]
src/documents/serialisers.py:0: error: Need type annotation for "documents" [var-annotated]
src/documents/serialisers.py:0: error: Need type annotation for "permissions_dict" [var-annotated]
src/documents/serialisers.py:0: error: Need type annotation for "with_perms" [var-annotated]
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
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.py:0: error: Value of type "Any | None" is not indexable [index]
src/documents/serialisers.py:0: error: Value of type "Any | None" is not indexable [index]
src/documents/serialisers.py:0: error: Value of type "Match[str] | None" is not indexable [index]
src/documents/serialisers/base.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/base.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/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers/base.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/base.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 [no-untyped-def]
src/documents/serialisers/base.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 [no-untyped-def]
src/documents/serialisers/base.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 [no-untyped-def]
src/documents/serialisers/base.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 [no-untyped-def]
src/documents/serialisers/base.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 [no-untyped-def]
src/documents/serialisers/base.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 [no-untyped-def]
src/documents/serialisers/base.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/base.py:0: error: Function is missing a type annotation for one or more parameters [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/base.py:0: error: Function is missing a type annotation for one or more parameters [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/base.py:0: error: Function is missing a type annotation for one or more parameters [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/base.py:0: error: Function is missing a type annotation for one or more parameters [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/base.py:0: error: Function is missing a type annotation for one or more parameters [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/base.py:0: error: Function is missing a type annotation for one or more parameters [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/base.py:0: error: Item "None" of "Any | None" has no attribute "name" [union-attr]
src/documents/serialisers/base.py:0: error: Item "None" of "Any | None" has no attribute "pk" [union-attr]
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/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/base.py:0: error: Item "list[str]" of "Any | list[str]" has no attribute "values_list" [union-attr]
src/documents/serialisers/base.py:0: error: Missing type arguments for generic type "Iterable" [type-arg]
src/documents/serialisers/base.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/documents/serialisers/base.py:0: error: Need type annotation for "permissions_dict" [var-annotated]
src/documents/serialisers/bulk_edit.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/bulk_edit.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/bulk_edit.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/bulk_edit.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/bulk_edit.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/bulk_edit.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/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [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/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [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/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [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/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [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/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [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/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [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/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [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/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [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/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [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/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[DocumentType]", variable has type "type[Tag] | None") [assignment]
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/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
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/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers/documents.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/documents.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/documents.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/documents.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/documents.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/documents.py:0: error: Function is missing a type annotation for one or more parameters [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/documents.py:0: error: Function is missing a type annotation for one or more parameters [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/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/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/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/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
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/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
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/documents.py:0: error: Skipping analyzing "auditlog.context": module is installed, but missing library stubs or py.typed marker [import-untyped]
src/documents/serialisers/documents.py:0: error: Value of type "Any | None" is not indexable [index]
src/documents/serialisers/metadata.py:0: error: Argument "choices" to "ChoiceField" has incompatible type "type[FieldDataType]"; expected "Sequence[Any]" [arg-type]
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[Any, Any]" [override]
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/metadata.py:0: error: Expected iterable as variadic argument [misc]
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [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/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/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/metadata.py:0: error: Item "None" of "CustomField | None" has no attribute "name" [union-attr]
src/documents/serialisers/metadata.py:0: error: Missing type arguments for generic type "Field" [type-arg]
src/documents/serialisers/metadata.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/documents/serialisers/metadata.py:0: error: Need type annotation for "document" [var-annotated]
src/documents/serialisers/metadata.py:0: error: Value of type "Any | None" is not indexable [index]
src/documents/serialisers/saved_views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/saved_views.py:0: error: Value of type "Match[str] | None" is not indexable [index]
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/sharing.py:0: error: Need type annotation for "documents" [var-annotated]
src/documents/serialisers/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/system.py:0: error: Function is missing a type annotation [no-untyped-def]
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/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
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/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/serialisers/workflows.py:0: error: Incompatible types in assignment (expression has type "WorkflowActionWebhookSerializer", variable has type "WorkflowActionEmailSerializer") [assignment]
src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined]
src/documents/signals/handlers.py:0: error: Argument 1 to "Path" has incompatible type "Any | None"; expected "str | PathLike[str]" [arg-type]
src/documents/signals/handlers.py:0: error: Argument 1 to "Path" has incompatible type "Any | None"; expected "str | PathLike[str]" [arg-type]
@@ -1413,166 +1421,181 @@ src/documents/utils.py:0: error: Incompatible types in assignment (expression ha
src/documents/utils.py:0: error: Incompatible types in assignment (expression has type "float | int", variable has type "int | None") [assignment]
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/views.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.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.py:0: error: "BulkPermissionMixin" has no attribute "request" [attr-defined]
src/documents/views.py:0: error: "get_serializer_context" undefined in superclass [misc]
src/documents/views.py:0: error: "object" has no attribute "apply_async" [attr-defined]
src/documents/views.py:0: error: "type[Model]" has no attribute "objects" [attr-defined]
src/documents/views.py:0: error: Argument "filename" to "FileResponse" has incompatible type "str | Any | None"; expected "str" [arg-type]
src/documents/views.py:0: error: Argument "path" to "EmailAttachment" has incompatible type "Path | None"; expected "Path" [arg-type]
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.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 "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
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.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})], dict[str, Any]]"; expected "dict[str, Any] | None" [arg-type]
src/documents/views.py:0: error: Argument 1 to "get_objects_for_user_owner_aware" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type]
src/documents/views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
src/documents/views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
src/documents/views.py:0: error: Argument 1 to "paginate_queryset" of "GenericAPIView" has incompatible type "TantivyRelevanceList"; expected "QuerySet[Any, Any]" [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.py:0: error: Argument 2 to "match_document_types" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
src/documents/views.py:0: error: Argument 2 to "match_storage_paths" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
src/documents/views.py:0: error: Argument 2 to "match_tags" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
src/documents/views.py:0: error: Argument 3 to "autocomplete" of "TantivyBackend" has incompatible type "User | AnonymousUser | None"; expected "AbstractBaseUser | None" [arg-type]
src/documents/views.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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 for one or more parameters [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.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 for one or more parameters [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.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: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Any]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Document]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "int", target has type "dict[str, str]") [assignment]
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.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: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "tuple[type[IsAuthenticated]]", variable has type "tuple[type[IsAuthenticated], type[PaperlessObjectPermissions]]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[ArchiveOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[DocumentSerializer]", variable has type "type[TrashSerializer]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[OriginalsOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
src/documents/views.py:0: error: Item "AnonymousUser" of "User | AnonymousUser" has no attribute "get_full_name" [union-attr]
src/documents/views.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
src/documents/views.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "page_size" [union-attr]
src/documents/views.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
src/documents/views.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
src/documents/views.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "app_logo" [union-attr]
src/documents/views.py:0: error: Item "None" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
src/documents/views.py:0: error: Item "None" of "BasePagination | None" has no attribute "page_size" [union-attr]
src/documents/views.py:0: error: Item "None" of "dict[str, _PingReply] | None" has no attribute "keys" [union-attr]
src/documents/views.py:0: error: Missing positional argument "request" in call to "email_documents" [call-arg]
src/documents/views.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg]
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg]
src/documents/views.py:0: error: Need type annotation for "authentication_classes" (hint: "authentication_classes: list[<type>] = ...") [var-annotated]
src/documents/views.py:0: error: Need type annotation for "children_map" (hint: "children_map: dict[<type>, <type>] = ...") [var-annotated]
src/documents/views.py:0: error: Need type annotation for "doc" [var-annotated]
src/documents/views.py:0: error: Need type annotation for "docs" (hint: "docs: list[<type>] = ...") [var-annotated]
src/documents/views.py:0: error: Need type annotation for "permission_classes" (hint: "permission_classes: list[<type>] = ...") [var-annotated]
src/documents/views.py:0: error: Need type annotation for "ui_settings" (hint: "ui_settings: dict[<type>, <type>] = ...") [var-annotated]
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.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: TypedDict key must be a string literal; expected one of ("pk", "correspondent", "document_type", "storage_path", "tags", ...) [literal-required]
src/documents/views.py:0: error: Unsupported operand types for + ("None" and "int") [operator]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[User]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
src/documents/views.py:0: error: Value of type "dict[str, _PingReply] | None" is not indexable [index]
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "filter_queryset" [attr-defined]
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "get_queryset" [attr-defined]
src/documents/views/base.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/base.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/base.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: 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 return type annotation [no-untyped-def]
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/base.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/views/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
src/documents/views/base.py:0: error: Missing type arguments for generic type "list" [type-arg]
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/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/bulk_edit.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [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/bulk_edit.py:0: error: Function is missing a 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/bulk_edit.py:0: error: Function is missing a 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/bulk_edit.py:0: error: Function is missing a 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/bulk_edit.py:0: error: Function is missing a 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/bulk_edit.py:0: error: Function is missing a 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/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[ArchiveOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
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/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/chat.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/documents.py:0: error: "type[DocumentFilterSet]" has no attribute "declared_filters" [attr-defined]
src/documents/views/documents.py:0: error: Argument "path" to "EmailAttachment" has incompatible type "Path | None"; expected "Path" [arg-type]
src/documents/views/documents.py:0: error: Argument 1 to "paginate_queryset" of "GenericAPIView" has incompatible type "TantivyRelevanceList"; expected "QuerySet[Any, Any]" [arg-type]
src/documents/views/documents.py:0: error: Argument 2 to "match_correspondents" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
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/documents.py:0: error: Argument 2 to "match_storage_paths" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
src/documents/views/documents.py:0: error: Argument 2 to "match_tags" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
src/documents/views/documents.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/documents.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/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/documents.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/documents.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/documents.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/documents.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/documents.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/documents.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/documents.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/documents.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/documents.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/documents.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/documents.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/documents.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/documents.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/documents.py:0: error: Function is missing a type annotation for one or more parameters [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/documents.py:0: error: Function is missing a type annotation for one or more parameters [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/documents.py:0: error: Incompatible types in assignment (expression has type "ClassificationSuggestions", variable has type "dict[Any, Any]") [assignment]
src/documents/views/documents.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
src/documents/views/documents.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "page_size" [union-attr]
src/documents/views/documents.py:0: error: Item "None" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
src/documents/views/documents.py:0: error: Item "None" of "BasePagination | None" has no attribute "page_size" [union-attr]
src/documents/views/documents.py:0: error: Missing positional argument "request" in call to "email_documents" [call-arg]
src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
src/documents/views/documents.py:0: error: Need type annotation for "doc" [var-annotated]
src/documents/views/documents.py:0: error: Need type annotation for "doc" [var-annotated]
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/documents.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
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/documents.py:0: error: Unsupported operand types for + ("None" and "int") [operator]
src/documents/views/index.py:0: error: Argument "filename" to "FileResponse" has incompatible type "str | Any | None"; expected "str" [arg-type]
src/documents/views/index.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/index.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/index.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
src/documents/views/index.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
src/documents/views/index.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "app_logo" [union-attr]
src/documents/views/logs.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/metadata.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
src/documents/views/metadata.py:0: error: Function is missing a return 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/metadata.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/metadata.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/metadata.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/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/metadata.py:0: error: Need type annotation for "children_map" (hint: "children_map: dict[<type>, <type>] = ...") [var-annotated]
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/search.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/search.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/search.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: Need type annotation for "docs" (hint: "docs: list[<type>] = ...") [var-annotated]
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[User]" is not indexable [index]
src/documents/views/search.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
src/documents/views/sharing.py:0: error: "Sequence[_SupportsHasPermission]" has no attribute "append" [attr-defined]
src/documents/views/sharing.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/sharing.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/sharing.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg]
src/documents/views/sharing.py:0: error: Need type annotation for "authentication_classes" (hint: "authentication_classes: list[<type>] = ...") [var-annotated]
src/documents/views/sharing.py:0: error: Need type annotation for "permission_classes" (hint: "permission_classes: list[<type>] = ...") [var-annotated]
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/system.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item]
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
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/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
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/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
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/system.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
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/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
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/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
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/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
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/system.py:0: error: Need type annotation for "ui_settings" (hint: "ui_settings: dict[<type>, <type>] = ...") [var-annotated]
src/documents/views/tasks.py:0: error: "object" has no attribute "apply_async" [attr-defined]
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})], dict[str, Any]]"; expected "dict[str, Any] | None" [arg-type]
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views/upload.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/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 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]
+1184 -1124
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -153,8 +153,11 @@ in similar existing documents, and the document chat can retrieve relevant conte
Enable it by setting
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only
built when AI is enabled **and** an embedding backend is set.
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY)
and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured.
The index is only built when AI is enabled **and** an embedding backend is set.
The index is updated automatically on a schedule controlled by
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
+21 -6
View File
@@ -1576,9 +1576,6 @@ ports.
#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
A hostname is blocked if any of the addresses it resolves to is non-public.
Webhook requests connect directly, without using the `HTTP_PROXY` or
`HTTPS_PROXY` environment variables, and never follow redirects.
Defaults to true, which allows internal requests.
@@ -1587,7 +1584,7 @@ Webhook requests connect directly, without using the `HTTP_PROXY` or
#### [`PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS=<bool>`](#PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS) {#PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS}
: If set to false, incoming mail account connections are blocked when the
configured IMAP hostname resolves to any non-public address (for example,
configured IMAP hostname resolves to a non-public address (for example,
localhost, link-local, or RFC1918 private ranges).
Defaults to true, which allows internal hosts.
@@ -2136,6 +2133,13 @@ for language and resource considerations.
Defaults to None.
#### [`PAPERLESS_AI_LLM_EMBEDDING_API_KEY=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) {#PAPERLESS_AI_LLM_EMBEDDING_API_KEY}
: The API key to use for the embedding backend. If not supplied, embeddings use
`PAPERLESS_AI_LLM_API_KEY`.
Defaults to None.
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
@@ -2217,11 +2221,22 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
#### [`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS}
: If set to false, Paperless blocks AI endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
A hostname is blocked if any of the addresses it resolves to is non-public, and redirects are checked the same way.
Requests to a configured AI endpoint connect directly, without using the `HTTP_PROXY` or `HTTPS_PROXY` environment variables.
Defaults to true, which allows internal endpoints.
#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=<json>`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS}
: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not
set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the
backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning
models refuse tool calls on the chat completions API unless reasoning is off:
```
PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"}
```
Defaults to empty, which adds nothing to requests.
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
+1 -1
View File
@@ -613,7 +613,7 @@ The following workflow action types are available:
- The request headers as key-value pairs
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. Webhook requests connect directly (proxy environment variables are not used) and do not follow redirects. If you are allowing non-admins to create workflows,
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
you may want to adjust these settings to prevent abuse.
##### Move to Trash {#workflow-action-move-to-trash}
-3
View File
@@ -17,7 +17,6 @@ classifiers = [
# TODO: Move certain things to groups and then utilize that further
# This will allow testing to not install a webserver, mysql, etc
dependencies = [
"anyio>=4.12",
"azure-ai-documentintelligence>=1.0.2",
"babel>=2.17",
"bleach~=6.4.0",
@@ -48,8 +47,6 @@ dependencies = [
"filelock~=3.32.0",
"flower>=2.0.1,<2.2",
"gotenberg-client[httpx]~=1.0",
"httpcore~=1.0.9",
"httpx~=0.28.1",
"httpx-oauth~=0.17",
"ijson>=3.5.1",
"imap-tools>=1.14,<1.16",
+35 -21
View File
@@ -9745,7 +9745,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">348</context>
<context context-type="linenumber">351</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
@@ -9760,7 +9760,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">341</context>
<context context-type="linenumber">344</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
@@ -10016,56 +10016,56 @@
<source>Reset filters / selection</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">329</context>
<context context-type="linenumber">332</context>
</context-group>
</trans-unit>
<trans-unit id="4135055128446167640" datatype="html">
<source>Open first [selected] document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">357</context>
<context context-type="linenumber">360</context>
</context-group>
</trans-unit>
<trans-unit id="3629960544875360046" datatype="html">
<source>Previous page</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">373</context>
<context context-type="linenumber">376</context>
</context-group>
</trans-unit>
<trans-unit id="3337301694210287595" datatype="html">
<source>Next page</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">385</context>
<context context-type="linenumber">388</context>
</context-group>
</trans-unit>
<trans-unit id="2155249406916744630" datatype="html">
<source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">419</context>
<context context-type="linenumber">422</context>
</context-group>
</trans-unit>
<trans-unit id="4646273665293421938" datatype="html">
<source>Failed to save view &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot;.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">425</context>
<context context-type="linenumber">428</context>
</context-group>
</trans-unit>
<trans-unit id="6837554170707123455" datatype="html">
<source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">494</context>
<context context-type="linenumber">497</context>
</context-group>
</trans-unit>
<trans-unit id="6028096992841030074" datatype="html">
<source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully, but could not update visibility settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">500</context>
<context context-type="linenumber">503</context>
</context-group>
</trans-unit>
<trans-unit id="739880801667335279" datatype="html">
@@ -12018,81 +12018,95 @@
<context context-type="linenumber">351</context>
</context-group>
</trans-unit>
<trans-unit id="861068592166833023" datatype="html">
<source>LLM Embedding API Key</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">358</context>
</context-group>
</trans-unit>
<trans-unit id="2929108042259892948" datatype="html">
<source>Used for embeddings when set, otherwise LLM API key is used.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">360</context>
</context-group>
</trans-unit>
<trans-unit id="3554114880473286122" datatype="html">
<source>LLM Embedding Endpoint</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">358</context>
<context context-type="linenumber">366</context>
</context-group>
</trans-unit>
<trans-unit id="1044242175651289991" datatype="html">
<source>LLM Embedding Chunk Size</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">365</context>
<context context-type="linenumber">373</context>
</context-group>
</trans-unit>
<trans-unit id="7218245223139363113" datatype="html">
<source>LLM Context Size</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">372</context>
<context context-type="linenumber">380</context>
</context-group>
</trans-unit>
<trans-unit id="4234495692726214397" datatype="html">
<source>LLM Backend</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">379</context>
<context context-type="linenumber">387</context>
</context-group>
</trans-unit>
<trans-unit id="7935234833834000002" datatype="html">
<source>LLM Model</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">387</context>
<context context-type="linenumber">395</context>
</context-group>
</trans-unit>
<trans-unit id="1980550530387803165" datatype="html">
<source>LLM API Key</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">394</context>
<context context-type="linenumber">402</context>
</context-group>
</trans-unit>
<trans-unit id="6126617860376156501" datatype="html">
<source>LLM Endpoint</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">401</context>
<context context-type="linenumber">409</context>
</context-group>
</trans-unit>
<trans-unit id="6572826277249350975" datatype="html">
<source>LLM Output Language</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">408</context>
<context context-type="linenumber">416</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">412</context>
<context context-type="linenumber">420</context>
</context-group>
</trans-unit>
<trans-unit id="4493921125434706859" datatype="html">
<source>LLM Request Timeout</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">416</context>
<context context-type="linenumber">424</context>
</context-group>
</trans-unit>
<trans-unit id="483994032066441287" datatype="html">
<source>Timeout in seconds for LLM requests.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">420</context>
<context context-type="linenumber">428</context>
</context-group>
</trans-unit>
<trans-unit id="1055686627716339120" datatype="html">
@@ -146,6 +146,19 @@ describe('DocumentListComponent', () => {
expect(reloadSpy).toHaveBeenCalled()
})
it('should stop reloading on document deleted after destroy', () => {
const reloadSpy = jest.spyOn(documentListService, 'reload')
const documentDeletedSubject = new Subject<boolean>()
jest
.spyOn(websocketStatusService, 'onDocumentDeleted')
.mockReturnValue(documentDeletedSubject)
fixture.detectChanges()
fixture.destroy()
reloadSpy.mockClear()
documentDeletedSubject.next(true)
expect(reloadSpy).not.toHaveBeenCalled()
})
it('should show score sort fields on fulltext queries', () => {
documentListService.setFilterRules([
{
@@ -270,9 +270,12 @@ export class DocumentListComponent
this.list.reload()
})
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
this.list.reload()
})
this.websocketStatusService
.onDocumentDeleted()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.list.reload()
})
this.route.paramMap
.pipe(
+9
View File
@@ -353,6 +353,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
category: ConfigCategory.AI,
},
{
key: 'llm_embedding_api_key',
title: $localize`LLM Embedding API Key`,
type: ConfigOptionType.Password,
note: $localize`Used for embeddings when set, otherwise LLM API key is used.`,
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_API_KEY',
category: ConfigCategory.AI,
},
{
key: 'llm_embedding_endpoint',
title: $localize`LLM Embedding Endpoint`,
@@ -457,6 +465,7 @@ export interface PaperlessConfig extends ObjectWithId {
ai_enabled: boolean
llm_embedding_backend: string
llm_embedding_model: string
llm_embedding_api_key: string
llm_embedding_endpoint: string
llm_embedding_chunk_size: number
llm_context_size: number
-42
View File
@@ -17,14 +17,10 @@ if TYPE_CHECKING:
from django.contrib.auth.models import User
from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture
from rest_framework.test import APIClient
from paperless_testing.dirs import PaperlessDirs
from paperless_testing.fakes.progress import FakeProgressManager
from paperless_testing.outbound import DialRecorder
from paperless_testing.outbound import FakeDNS
from paperless_testing.outbound import LocalHTTPServer
@pytest.fixture(scope="session", autouse=True)
@@ -153,41 +149,3 @@ def fake_progress_manager(
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
return FakeProgressManager
@pytest.fixture
def local_http_server() -> Generator[LocalHTTPServer, None, None]:
"""A recording HTTP server on 127.0.0.1, for outbound connection tests."""
from paperless_testing.outbound import running_http_server
with running_http_server() as server:
yield server
@pytest.fixture
def fake_dns(mocker: MockerFixture) -> FakeDNS:
"""Per-hostname answers for the outbound guard's resolver hooks."""
from paperless_testing.outbound import install_fake_dns
return install_fake_dns(mocker)
@pytest.fixture
def dial_recorder(mocker: MockerFixture) -> DialRecorder:
"""Records which addresses the outbound guard actually dialled."""
from paperless_testing.outbound import install_dial_recorder
return install_dial_recorder(mocker)
@pytest.fixture
def every_address_is_public(mocker: MockerFixture) -> None:
"""Disable the outbound guard's address policy: every address passes.
For tests that are not themselves exercising which addresses the guard
accepts, so loopback and other private addresses dial just like a
public one.
"""
from paperless_testing.outbound import allow_all_addresses
allow_all_addresses(mocker)
-1
View File
@@ -26,7 +26,6 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(set_document_type)
document_consumption_finished.connect(set_tags)
document_consumption_finished.connect(set_storage_path)
document_consumption_finished.connect(add_to_index)
document_consumption_finished.connect(run_workflows_added)
document_consumption_finished.connect(add_to_index)
document_consumption_finished.connect(add_or_update_document_in_llm_index)
+3 -2
View File
@@ -857,8 +857,9 @@ class ConsumerPlugin(
self.log.debug(f"Creation date from parse_date: {create_date}")
else:
stats = Path(self.input_doc.original_file).stat()
create_date = timezone.make_aware(
datetime.datetime.fromtimestamp(stats.st_mtime),
create_date = datetime.datetime.fromtimestamp(
stats.st_mtime,
tz=timezone.get_current_timezone(),
)
self.log.debug(f"Creation date from st_mtime: {create_date}")
File diff suppressed because it is too large Load Diff
+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
+474
View File
@@ -0,0 +1,474 @@
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:
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
+246
View File
@@ -0,0 +1,246 @@
from __future__ import annotations
import logging
from typing import Any
from rest_framework import serializers
from documents.models import Document
from documents.models import PaperlessTask
from documents.permissions import permitted_document_ids
from .base import OwnedObjectSerializer
logger = logging.getLogger("paperless.serializers")
class TaskSerializerV10(OwnedObjectSerializer):
"""Task serializer for API v10+ using new field names."""
related_document_ids = serializers.ListField(
child=serializers.IntegerField(),
read_only=True,
)
task_type_display = serializers.CharField(
source="get_task_type_display",
read_only=True,
)
trigger_source_display = serializers.CharField(
source="get_trigger_source_display",
read_only=True,
)
status_display = serializers.CharField(
source="get_status_display",
read_only=True,
)
class Meta:
model = PaperlessTask
fields = (
"id",
"task_id",
"task_type",
"task_type_display",
"trigger_source",
"trigger_source_display",
"status",
"status_display",
"date_created",
"date_started",
"date_done",
"duration_seconds",
"wait_time_seconds",
"input_data",
"result_data",
"related_document_ids",
"acknowledged",
"owner",
)
read_only_fields = fields
class TaskSerializerV9(serializers.ModelSerializer[PaperlessTask]):
"""Task serializer for API v9 backwards compatibility.
Maps old field names to the new model fields so existing clients continue
to work unchanged.
"""
# v9 field: task_name -> task_type (with value remapping for renamed tasks)
task_name = serializers.SerializerMethodField()
# v9 field: task_file_name -> input_data.filename
task_file_name = serializers.SerializerMethodField()
# v9 field: type -> trigger_source (mapped to old enum labels)
type = serializers.SerializerMethodField()
# v9 field: status -> uppercase Celery state strings
status = serializers.SerializerMethodField()
# v9 field: result -> derived from result_data
result = serializers.SerializerMethodField()
# v9 field: related_document -> first document ID from result_data
related_document = serializers.SerializerMethodField()
# v9 field: duplicate_documents -> list of duplicate IDs from result_data
duplicate_documents = serializers.SerializerMethodField()
class Meta:
model = PaperlessTask
fields = (
"id",
"task_id",
"task_name",
"task_file_name",
"type",
"status",
"date_created",
"date_done",
"result",
"acknowledged",
"related_document",
"duplicate_documents",
"owner",
)
read_only_fields = fields
_TASK_TYPE_TO_V9_NAME = {
PaperlessTask.TaskType.SANITY_CHECK: "check_sanity",
PaperlessTask.TaskType.LLM_INDEX: "llmindex_update",
}
def get_result(self, obj: PaperlessTask) -> str | None:
"""Reconstruct a human-readable result string from result_data for v9 clients."""
if not obj.result_data:
return None
if doc_id := obj.result_data.get("document_id"):
return f"Success. New document id {doc_id} created"
if reason := obj.result_data.get("reason"):
return reason
if dup_id := obj.result_data.get("duplicate_of"):
return f"Not consuming: It is a duplicate of document #{dup_id}"
if error := obj.result_data.get("error_message"):
return error
return None
def get_task_name(self, obj: PaperlessTask) -> str:
return self._TASK_TYPE_TO_V9_NAME.get(obj.task_type, obj.task_type)
def get_task_file_name(self, obj: PaperlessTask) -> str | None:
if not obj.input_data:
return None
return obj.input_data.get("filename")
_STATUS_TO_V9 = {
PaperlessTask.Status.PENDING: "PENDING",
PaperlessTask.Status.STARTED: "STARTED",
PaperlessTask.Status.SUCCESS: "SUCCESS",
PaperlessTask.Status.FAILURE: "FAILURE",
PaperlessTask.Status.REVOKED: "REVOKED",
}
def get_status(self, obj: PaperlessTask) -> str:
return self._STATUS_TO_V9.get(obj.status, obj.status.upper())
_TRIGGER_SOURCE_TO_V9_TYPE = {
PaperlessTask.TriggerSource.SCHEDULED: "scheduled_task",
PaperlessTask.TriggerSource.SYSTEM: "auto_task",
# Email and folder-consumer documents are system-initiated, not manually triggered
PaperlessTask.TriggerSource.EMAIL_CONSUME: "auto_task",
PaperlessTask.TriggerSource.FOLDER_CONSUME: "auto_task",
}
def get_type(self, obj: PaperlessTask) -> str:
return self._TRIGGER_SOURCE_TO_V9_TYPE.get(obj.trigger_source, "manual_task")
def get_related_document(self, obj: PaperlessTask) -> int | None:
ids = obj.related_document_ids
return ids[0] if ids else None
def get_duplicate_documents(
self,
obj: PaperlessTask,
) -> list[dict[str, Any]]:
if not obj.result_data:
return []
dup_of = obj.result_data.get("duplicate_of")
if dup_of is None:
return []
request = self.context.get("request")
if request is None:
return []
user = request.user
qs = Document.global_objects.filter(pk=dup_of)
if not user.is_staff:
allowed_ids = permitted_document_ids(user, include_deleted=True)
qs = qs.filter(pk__in=allowed_ids)
return list(qs.values("id", "title", "deleted_at"))
class TaskSummarySerializer(serializers.Serializer[dict[str, Any]]):
task_type = serializers.CharField()
total_count = serializers.IntegerField()
pending_count = serializers.IntegerField()
success_count = serializers.IntegerField()
failure_count = serializers.IntegerField()
avg_duration_seconds = serializers.FloatField(allow_null=True)
avg_wait_time_seconds = serializers.FloatField(allow_null=True)
last_run = serializers.DateTimeField(allow_null=True)
last_success = serializers.DateTimeField(allow_null=True)
last_failure = serializers.DateTimeField(allow_null=True)
class RunTaskSerializer(serializers.Serializer[dict[str, str]]):
task_type = serializers.ChoiceField(
choices=PaperlessTask.TaskType.choices,
label="Task Type",
write_only=True,
)
class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]):
tasks = serializers.ListField(
required=False,
label="Tasks",
write_only=True,
child=serializers.IntegerField(),
)
all = serializers.BooleanField(
required=False,
default=False,
label="All",
write_only=True,
)
def _validate_task_id_list(self, tasks, name="tasks") -> None:
if not isinstance(tasks, list):
raise serializers.ValidationError(f"{name} must be a list")
if not all(isinstance(i, int) for i in tasks):
raise serializers.ValidationError(f"{name} must be a list of integers")
queryset = self.context.get("queryset", PaperlessTask.objects.all())
count = queryset.filter(id__in=tasks).count()
if not count == len(tasks):
raise serializers.ValidationError(
f"Some tasks in {name} don't exist or were specified twice.",
)
def validate_tasks(self, tasks):
self._validate_task_id_list(tasks)
return tasks
def validate(self, attrs):
acknowledge_all = attrs.get("all", False)
task_ids = attrs.get("tasks")
if acknowledge_all and task_ids is not None:
raise serializers.ValidationError(
"Set either all or tasks, not both.",
)
if not acknowledge_all and task_ids is None:
raise serializers.ValidationError(
"Either all must be true or tasks must be provided.",
)
return attrs
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
import magic
from django.conf import settings
from django.utils.translation import gettext as _
from rest_framework import serializers
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 documents.parsers import is_mime_type_supported
from .metadata import CustomFieldInstanceSerializer
logger = logging.getLogger("paperless.serializers")
class PostDocumentSerializer(serializers.Serializer[dict[str, Any]]):
created = serializers.DateTimeField(
label="Created",
allow_null=True,
write_only=True,
required=False,
)
document = serializers.FileField(
label="Document",
write_only=True,
)
title = serializers.CharField(
label="Title",
write_only=True,
required=False,
)
correspondent = serializers.PrimaryKeyRelatedField(
queryset=Correspondent.objects.all(),
label="Correspondent",
allow_null=True,
write_only=True,
required=False,
)
document_type = serializers.PrimaryKeyRelatedField(
queryset=DocumentType.objects.all(),
label="Document type",
allow_null=True,
write_only=True,
required=False,
)
storage_path = serializers.PrimaryKeyRelatedField(
queryset=StoragePath.objects.all(),
label="Storage path",
allow_null=True,
write_only=True,
required=False,
)
tags = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Tag.objects.all(),
label="Tags",
write_only=True,
required=False,
)
archive_serial_number = serializers.IntegerField(
label="ASN",
write_only=True,
required=False,
min_value=Document.ARCHIVE_SERIAL_NUMBER_MIN,
max_value=Document.ARCHIVE_SERIAL_NUMBER_MAX,
)
# Accept either a list of custom field ids or a dict mapping id -> value
custom_fields = serializers.JSONField(
label="Custom fields",
write_only=True,
required=False,
)
from_webui = serializers.BooleanField(
label="Documents are from Paperless-ngx WebUI",
write_only=True,
required=False,
)
def validate_document(self, document):
document_data = document.file.read()
mime_type = magic.from_buffer(document_data, mime=True)
if not is_mime_type_supported(mime_type):
if (
mime_type in settings.CONSUMER_PDF_RECOVERABLE_MIME_TYPES
and document.name.endswith(
".pdf",
)
):
# If the file is an invalid PDF, we can try to recover it later in the consumer
mime_type = "application/pdf"
else:
raise serializers.ValidationError(
_("File type %(type)s not supported") % {"type": mime_type},
)
return document.name, document_data
def validate_correspondent(self, correspondent):
if correspondent:
return correspondent.id
else:
return None
def validate_document_type(self, document_type):
if document_type:
return document_type.id
else:
return None
def validate_storage_path(self, storage_path):
if storage_path:
return storage_path.id
else:
return None
def validate_tags(self, tags):
if tags:
return [tag.id for tag in tags]
else:
return None
def validate_custom_fields(self, custom_fields):
if not custom_fields:
return None
# Normalize single values to a list
if isinstance(custom_fields, int):
custom_fields = [custom_fields]
if isinstance(custom_fields, dict):
custom_field_serializer = CustomFieldInstanceSerializer()
normalized = {}
for field_id, value in custom_fields.items():
try:
field_id_int = int(field_id)
except (TypeError, ValueError):
raise serializers.ValidationError(
_("Custom field id must be an integer: %(id)s")
% {"id": field_id},
)
try:
field = CustomField.objects.get(id=field_id_int)
except CustomField.DoesNotExist:
raise serializers.ValidationError(
_("Custom field with id %(id)s does not exist")
% {"id": field_id_int},
)
custom_field_serializer.validate(
{
"field": field,
"value": value,
},
)
normalized[field_id_int] = value
return normalized
elif isinstance(custom_fields, list):
try:
ids = [int(i) for i in custom_fields]
except (TypeError, ValueError):
raise serializers.ValidationError(
_(
"Custom fields must be a list of integers or an object mapping ids to values.",
),
)
if CustomField.objects.filter(id__in=ids).count() != len(set(ids)):
raise serializers.ValidationError(
_("Some custom fields don't exist or were specified twice."),
)
return ids
raise serializers.ValidationError(
_(
"Custom fields must be a list of integers or an object mapping ids to values.",
),
)
# custom_fields_w_values handled via validate_custom_fields
def validate_created(self, created):
# support datetime format for created for backwards compatibility
if isinstance(created, datetime):
return created.date()
+584
View File
@@ -0,0 +1,584 @@
from __future__ import annotations
import logging
from django.db.models import Count
from rest_framework import fields
from rest_framework import serializers
from documents.data_models import DocumentSource
from documents.filters import CustomFieldQueryParser
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowActionEmail
from documents.models import WorkflowActionWebhook
from documents.models import WorkflowTrigger
from documents.templating.workflows import validate_workflow_template
from documents.validators import url_validator
from .metadata import CorrespondentField
from .metadata import DocumentTypeField
from .metadata import StoragePathField
from .metadata import TagsField
logger = logging.getLogger("paperless.serializers")
class WorkflowTriggerSerializer(serializers.ModelSerializer[WorkflowTrigger]):
id = serializers.IntegerField(required=False, allow_null=True)
sources = fields.MultipleChoiceField(
choices=WorkflowTrigger.DocumentSourceChoices.choices,
allow_empty=True,
default={
DocumentSource.ConsumeFolder,
DocumentSource.ApiUpload,
DocumentSource.MailFetch,
},
)
type = serializers.ChoiceField(
choices=WorkflowTrigger.WorkflowTriggerType.choices,
label="Trigger Type",
)
class Meta:
model = WorkflowTrigger
fields = [
"id",
"sources",
"type",
"filter_path",
"filter_filename",
"filter_mailrule",
"matching_algorithm",
"match",
"is_insensitive",
"filter_has_tags",
"filter_has_all_tags",
"filter_has_not_tags",
"filter_custom_field_query",
"filter_has_any_correspondents",
"filter_has_not_correspondents",
"filter_has_any_document_types",
"filter_has_not_document_types",
"filter_has_any_storage_paths",
"filter_has_not_storage_paths",
"filter_has_correspondent",
"filter_has_document_type",
"filter_has_storage_path",
"schedule_offset_days",
"schedule_is_recurring",
"schedule_recurring_interval_days",
"schedule_date_field",
"schedule_date_custom_field",
]
def validate(self, attrs):
# Empty strings treated as None to avoid unexpected behavior
if (
"filter_filename" in attrs
and attrs["filter_filename"] is not None
and len(attrs["filter_filename"]) == 0
):
attrs["filter_filename"] = None
if (
"filter_path" in attrs
and attrs["filter_path"] is not None
and len(attrs["filter_path"]) == 0
):
attrs["filter_path"] = None
if (
"filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None
and len(attrs["filter_custom_field_query"]) == 0
):
attrs["filter_custom_field_query"] = None
if (
"filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None
):
parser = CustomFieldQueryParser("filter_custom_field_query")
parser.parse(attrs["filter_custom_field_query"])
trigger_type = attrs.get("type", getattr(self.instance, "type", None))
if (
trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
and "filter_mailrule" not in attrs
and ("filter_filename" not in attrs or attrs["filter_filename"] is None)
and ("filter_path" not in attrs or attrs["filter_path"] is None)
):
raise serializers.ValidationError(
"File name, path or mail rule filter are required",
)
return attrs
@staticmethod
def normalize_workflow_trigger_sources(trigger) -> None:
"""
Convert sources to strings to handle django-multiselectfield v1.0 changes
"""
if trigger and "sources" in trigger:
trigger["sources"] = [
str(s.value if hasattr(s, "value") else s) for s in trigger["sources"]
]
def create(self, validated_data):
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(validated_data)
return super().create(validated_data)
def update(self, instance, validated_data):
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(validated_data)
return super().update(instance, validated_data)
class WorkflowActionEmailSerializer(serializers.ModelSerializer[WorkflowActionEmail]):
id = serializers.IntegerField(allow_null=True, required=False)
class Meta:
model = WorkflowActionEmail
fields = [
"id",
"subject",
"body",
"to",
"include_document",
]
class WorkflowActionWebhookSerializer(
serializers.ModelSerializer[WorkflowActionWebhook],
):
id = serializers.IntegerField(allow_null=True, required=False)
def validate_url(self, url):
url_validator(url)
return url
class Meta:
model = WorkflowActionWebhook
fields = [
"id",
"url",
"use_params",
"as_json",
"params",
"body",
"headers",
"include_document",
]
class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
id = serializers.IntegerField(required=False, allow_null=True)
assign_correspondent = CorrespondentField(allow_null=True, required=False)
assign_tags = TagsField(many=True, allow_null=True, required=False)
assign_document_type = DocumentTypeField(allow_null=True, required=False)
assign_storage_path = StoragePathField(allow_null=True, required=False)
email = WorkflowActionEmailSerializer(allow_null=True, required=False)
webhook = WorkflowActionWebhookSerializer(allow_null=True, required=False)
class Meta:
model = WorkflowAction
fields = [
"id",
"type",
"assign_title",
"assign_tags",
"assign_correspondent",
"assign_document_type",
"assign_storage_path",
"assign_owner",
"assign_view_users",
"assign_view_groups",
"assign_change_users",
"assign_change_groups",
"assign_custom_fields",
"assign_custom_fields_values",
"remove_all_tags",
"remove_tags",
"remove_all_correspondents",
"remove_correspondents",
"remove_all_document_types",
"remove_document_types",
"remove_all_storage_paths",
"remove_storage_paths",
"remove_custom_fields",
"remove_all_custom_fields",
"remove_all_owners",
"remove_owners",
"remove_all_permissions",
"remove_view_users",
"remove_view_groups",
"remove_change_users",
"remove_change_groups",
"email",
"webhook",
"passwords",
"ai_suggestion_fields",
"ai_create_missing",
"ai_overwrite_existing",
]
def validate(self, attrs):
if "assign_title" in attrs and attrs["assign_title"] is not None:
if len(attrs["assign_title"]) == 0:
# Empty strings treated as None to avoid unexpected behavior
attrs["assign_title"] = None
else:
try:
validate_workflow_template(attrs["assign_title"])
except (ValueError, KeyError) as e:
raise serializers.ValidationError(
{"assign_title": f"{e.args[0]}"},
)
if attrs.get("assign_custom_fields_values"):
# Empty strings treated as None to avoid unexpected behavior
attrs["assign_custom_fields_values"] = {
field_id: (None if value == "" else value)
for field_id, value in attrs["assign_custom_fields_values"].items()
}
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
and "email" not in attrs
):
raise serializers.ValidationError(
"Email data is required for email actions",
)
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.WEBHOOK
and "webhook" not in attrs
):
raise serializers.ValidationError(
"Webhook data is required for webhook actions",
)
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
):
passwords = attrs.get("passwords")
# ensure passwords is a non-empty list of non-empty strings
if (
passwords is None
or not isinstance(passwords, list)
or len(passwords) == 0
or any(not isinstance(pw, str) for pw in passwords)
or any(len(pw.strip()) == 0 for pw in passwords)
):
raise serializers.ValidationError(
"Passwords are required for password removal actions",
)
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
):
fields = attrs.get("ai_suggestion_fields")
valid_fields = set(WorkflowAction.AISuggestionField.values)
if (
fields is None
or not isinstance(fields, list)
or len(fields) == 0
or any(field not in valid_fields for field in fields)
):
raise serializers.ValidationError(
"At least one valid field is required for apply AI "
f"suggestions actions, options are: {sorted(valid_fields)}",
)
return attrs
class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
order = serializers.IntegerField(required=False)
triggers = WorkflowTriggerSerializer(many=True)
actions = WorkflowActionSerializer(many=True)
class Meta:
model = Workflow
fields = [
"id",
"name",
"order",
"enabled",
"triggers",
"actions",
]
def validate(self, attrs):
attrs = super().validate(attrs)
if "actions" in attrs:
has_remote_ocr_action = any(
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
for action in attrs["actions"]
)
has_ai_suggestions_action = any(
action.get("type")
== WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
for action in attrs["actions"]
)
else:
has_remote_ocr_action = self.instance is not None and (
self.instance.actions.filter(
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
).exists()
)
has_ai_suggestions_action = self.instance is not None and (
self.instance.actions.filter(
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
).exists()
)
if "triggers" in attrs:
has_consumption_trigger = any(
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
for trigger in attrs["triggers"]
)
has_non_consumption_trigger = any(
trigger.get("type") != WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
for trigger in attrs["triggers"]
)
else:
has_consumption_trigger = self.instance is not None and (
self.instance.triggers.filter(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
).exists()
)
has_non_consumption_trigger = self.instance is not None and (
self.instance.triggers.exclude(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
).exists()
)
# Remote OCR can only work with consumption triggers
if has_remote_ocr_action and not has_consumption_trigger:
raise serializers.ValidationError(
"Remote OCR actions require a consumption started trigger",
)
# Suggestions are made from the document content, which does not exist
# until after consumption has finished
if has_ai_suggestions_action and not has_non_consumption_trigger:
raise serializers.ValidationError(
"Apply AI suggestions actions require a trigger other than "
"consumption started",
)
return attrs
def update_triggers_and_actions(
self,
instance: Workflow,
triggers,
actions,
) -> None:
set_triggers = []
set_actions = []
if triggers is not None and triggers is not serializers.empty:
for trigger in triggers:
filter_has_tags = trigger.pop("filter_has_tags", None)
filter_has_all_tags = trigger.pop("filter_has_all_tags", None)
filter_has_not_tags = trigger.pop("filter_has_not_tags", None)
filter_has_any_correspondents = trigger.pop(
"filter_has_any_correspondents",
None,
)
filter_has_not_correspondents = trigger.pop(
"filter_has_not_correspondents",
None,
)
filter_has_any_document_types = trigger.pop(
"filter_has_any_document_types",
None,
)
filter_has_not_document_types = trigger.pop(
"filter_has_not_document_types",
None,
)
filter_has_any_storage_paths = trigger.pop(
"filter_has_any_storage_paths",
None,
)
filter_has_not_storage_paths = trigger.pop(
"filter_has_not_storage_paths",
None,
)
# Convert sources to strings to handle django-multiselectfield v1.0 changes
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger)
trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
id=trigger.get("id"),
defaults=trigger,
)
if filter_has_tags is not None:
trigger_instance.filter_has_tags.set(filter_has_tags)
if filter_has_all_tags is not None:
trigger_instance.filter_has_all_tags.set(filter_has_all_tags)
if filter_has_not_tags is not None:
trigger_instance.filter_has_not_tags.set(filter_has_not_tags)
if filter_has_any_correspondents is not None:
trigger_instance.filter_has_any_correspondents.set(
filter_has_any_correspondents,
)
if filter_has_not_correspondents is not None:
trigger_instance.filter_has_not_correspondents.set(
filter_has_not_correspondents,
)
if filter_has_any_document_types is not None:
trigger_instance.filter_has_any_document_types.set(
filter_has_any_document_types,
)
if filter_has_not_document_types is not None:
trigger_instance.filter_has_not_document_types.set(
filter_has_not_document_types,
)
if filter_has_any_storage_paths is not None:
trigger_instance.filter_has_any_storage_paths.set(
filter_has_any_storage_paths,
)
if filter_has_not_storage_paths is not None:
trigger_instance.filter_has_not_storage_paths.set(
filter_has_not_storage_paths,
)
set_triggers.append(trigger_instance)
if actions is not None and actions is not serializers.empty:
for index, action in enumerate(actions):
action["order"] = index
assign_tags = action.pop("assign_tags", None)
assign_view_users = action.pop("assign_view_users", None)
assign_view_groups = action.pop("assign_view_groups", None)
assign_change_users = action.pop("assign_change_users", None)
assign_change_groups = action.pop("assign_change_groups", None)
assign_custom_fields = action.pop("assign_custom_fields", None)
remove_tags = action.pop("remove_tags", None)
remove_correspondents = action.pop("remove_correspondents", None)
remove_document_types = action.pop("remove_document_types", None)
remove_storage_paths = action.pop("remove_storage_paths", None)
remove_custom_fields = action.pop("remove_custom_fields", None)
remove_owners = action.pop("remove_owners", None)
remove_view_users = action.pop("remove_view_users", None)
remove_view_groups = action.pop("remove_view_groups", None)
remove_change_users = action.pop("remove_change_users", None)
remove_change_groups = action.pop("remove_change_groups", None)
email_data = action.pop("email", None)
webhook_data = action.pop("webhook", None)
action_instance, _ = WorkflowAction.objects.update_or_create(
id=action.get("id"),
defaults=action,
)
if email_data is not None:
serializer = WorkflowActionEmailSerializer(data=email_data)
serializer.is_valid(raise_exception=True)
email, _ = WorkflowActionEmail.objects.update_or_create(
id=email_data.get("id"),
defaults=serializer.validated_data,
)
action_instance.email = email
action_instance.save()
if webhook_data is not None:
serializer = WorkflowActionWebhookSerializer(data=webhook_data)
serializer.is_valid(raise_exception=True)
webhook, _ = WorkflowActionWebhook.objects.update_or_create(
id=webhook_data.get("id"),
defaults=serializer.validated_data,
)
action_instance.webhook = webhook
action_instance.save()
if assign_tags is not None:
action_instance.assign_tags.set(assign_tags)
if assign_view_users is not None:
action_instance.assign_view_users.set(assign_view_users)
if assign_view_groups is not None:
action_instance.assign_view_groups.set(assign_view_groups)
if assign_change_users is not None:
action_instance.assign_change_users.set(assign_change_users)
if assign_change_groups is not None:
action_instance.assign_change_groups.set(assign_change_groups)
if assign_custom_fields is not None:
action_instance.assign_custom_fields.set(assign_custom_fields)
if remove_tags is not None:
action_instance.remove_tags.set(remove_tags)
if remove_correspondents is not None:
action_instance.remove_correspondents.set(remove_correspondents)
if remove_document_types is not None:
action_instance.remove_document_types.set(remove_document_types)
if remove_storage_paths is not None:
action_instance.remove_storage_paths.set(remove_storage_paths)
if remove_custom_fields is not None:
action_instance.remove_custom_fields.set(remove_custom_fields)
if remove_owners is not None:
action_instance.remove_owners.set(remove_owners)
if remove_view_users is not None:
action_instance.remove_view_users.set(remove_view_users)
if remove_view_groups is not None:
action_instance.remove_view_groups.set(remove_view_groups)
if remove_change_users is not None:
action_instance.remove_change_users.set(remove_change_users)
if remove_change_groups is not None:
action_instance.remove_change_groups.set(remove_change_groups)
set_actions.append(action_instance)
if triggers is not serializers.empty:
instance.triggers.set(set_triggers)
if actions is not serializers.empty:
instance.actions.set(set_actions)
instance.save()
def prune_triggers_and_actions(self) -> None:
"""
ManyToMany fields dont support e.g. on_delete so we need to discard unattached
triggers and actions manually
"""
WorkflowTrigger.objects.annotate(
workflow_count=Count("workflows"),
).filter(workflow_count=0).delete()
WorkflowAction.objects.annotate(
workflow_count=Count("workflows"),
).filter(workflow_count=0).delete()
WorkflowActionEmail.objects.filter(action=None).delete()
WorkflowActionWebhook.objects.filter(action=None).delete()
def create(self, validated_data) -> Workflow:
if "triggers" in validated_data:
triggers = validated_data.pop("triggers")
if "actions" in validated_data:
actions = validated_data.pop("actions")
for action in actions:
action.pop("id", None)
instance = super().create(validated_data)
self.update_triggers_and_actions(instance, triggers, actions)
return instance
def update(self, instance: Workflow, validated_data) -> Workflow:
triggers = validated_data.pop("triggers", serializers.empty)
actions = validated_data.pop("actions", serializers.empty)
instance = super().update(instance, validated_data)
self.update_triggers_and_actions(instance, triggers, actions)
self.prune_triggers_and_actions()
return instance
+6 -1
View File
@@ -56,6 +56,7 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.plugins.helpers import DocumentsStatusManager
from documents.templating.utils import convert_format_str_to_template_format
from documents.utils import compute_checksum
from documents.utils import copy_file_with_basic_stats
from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action
from documents.workflows.actions import execute_move_to_trash_action
@@ -363,7 +364,11 @@ def cleanup_document_deletion(sender, instance, **kwargs) -> None:
logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}")
try:
shutil.move(instance.source_path, new_file_path)
shutil.move(
instance.source_path,
new_file_path,
copy_function=copy_file_with_basic_stats,
)
except OSError as e:
logger.error(
f"Failed to move {instance.source_path} to trash at "
@@ -81,6 +81,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"ai_enabled": None,
"llm_embedding_backend": None,
"llm_embedding_model": None,
"llm_embedding_api_key": None,
"llm_embedding_endpoint": None,
"llm_embedding_chunk_size": None,
"llm_context_size": None,
@@ -922,6 +923,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
self.assertEqual(ApplicationConfiguration.objects.count(), 1)
def test_update_llm_embedding_api_key(self) -> None:
"""
GIVEN:
- Existing config with llm_embedding_api_key specified
WHEN:
- API to update llm_embedding_api_key is called with all *s
- API to update llm_embedding_api_key is called with empty string
THEN:
- llm_embedding_api_key is unchanged
- llm_embedding_api_key is set to None
"""
config = ApplicationConfiguration.objects.first()
assert config is not None
config.llm_embedding_api_key = "1234567890"
config.save()
# Test with all *
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"llm_embedding_api_key": "*" * 32,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config.refresh_from_db()
self.assertEqual(config.llm_embedding_api_key, "1234567890")
# Test with empty string
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"llm_embedding_api_key": "",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config.refresh_from_db()
self.assertEqual(config.llm_embedding_api_key, None)
def test_update_llm_api_key(self) -> None:
"""
GIVEN:
@@ -166,7 +166,15 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
),
content_type="application/json",
)
response.close()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response["Content-Type"], "application/zip")
with zipfile.ZipFile(io.BytesIO(read_streaming_response(response))) as zipf:
self.assertEqual(zipf.infolist()[0].compress_type, zipfile.ZIP_LZMA)
with self.doc2.source_file as f:
self.assertEqual(f.read(), zipf.read("2021-01-01 document A.pdf"))
@override_settings(FILENAME_FORMAT="{correspondent}/{title}")
def test_formatted_download_originals(self) -> None:
+85 -60
View File
@@ -9,6 +9,7 @@ from rest_framework.test import APITestCase
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 StoragePath
@@ -202,7 +203,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.doc1.refresh_from_db()
self.assertFalse(self.doc1.tags.filter(pk=self.t1.pk).exists())
@mock.patch("documents.serialisers.bulk_edit.modify_tags")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_tags")
def test_api_modify_tags(self, m) -> None:
self.setup_mock(m, "modify_tags")
response = self.client.post(
@@ -226,7 +227,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(kwargs["add_tags"], [self.t1.id])
self.assertEqual(kwargs["remove_tags"], [self.t2.id])
@mock.patch("documents.serialisers.bulk_edit.modify_tags")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_tags")
def test_api_modify_tags_not_provided(self, m) -> None:
"""
GIVEN:
@@ -254,7 +255,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields(self, m) -> None:
self.setup_mock(m, "modify_custom_fields")
response = self.client.post(
@@ -280,7 +281,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields_documentlink_forbidden_for_unpermitted_target(
self,
m,
@@ -324,7 +325,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields_with_values(self, m) -> None:
self.setup_mock(m, "modify_custom_fields")
response = self.client.post(
@@ -348,7 +349,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(kwargs["add_custom_fields"], {self.cf1.id: "foo"})
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields_rejects_invalid_value(self, m) -> None:
self.setup_mock(m, "modify_custom_fields")
@@ -372,7 +373,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(str(self.cf1.id), response.data["add_custom_fields"])
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields_invalid_params(self, m) -> None:
"""
GIVEN:
@@ -492,7 +493,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.delete")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.delete")
def test_api_delete(self, m) -> None:
self.setup_mock(m, "delete")
response = self.client.post(
@@ -508,7 +509,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(len(kwargs), 0)
@mock.patch("documents.views.bulk_edit.delete")
@mock.patch("documents.views.bulk_edit.bulk_edit.delete")
def test_delete_documents_endpoint(self, m) -> None:
self.setup_mock(m, "delete")
response = self.client.post(
@@ -522,7 +523,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(len(kwargs), 0)
@mock.patch("documents.views.bulk_edit.delete")
@mock.patch("documents.views.bulk_edit.bulk_edit.delete")
def test_delete_documents_endpoint_with_excluded_documents(self, m) -> None:
self.setup_mock(m, "delete")
response = self.client.post(
@@ -546,7 +547,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(len(kwargs), 0)
@mock.patch("documents.views.bulk_edit.reprocess")
@mock.patch("documents.views.bulk_edit.bulk_edit.reprocess")
def test_reprocess_documents_endpoint(self, m) -> None:
self.setup_mock(m, "reprocess")
response = self.client.post(
@@ -560,7 +561,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs, {"remote_ocr": False})
@mock.patch("documents.views.bulk_edit.reprocess")
@mock.patch("documents.views.bulk_edit.bulk_edit.reprocess")
def test_reprocess_documents_endpoint_remote_ocr(self, m) -> None:
"""
GIVEN:
@@ -582,7 +583,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs, {"remote_ocr": True})
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
def test_api_set_storage_path(self, m) -> None:
"""
GIVEN:
@@ -612,7 +613,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertListEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
def test_api_unset_storage_path(self, m) -> None:
"""
GIVEN:
@@ -737,7 +738,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
response.content,
)
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
def test_api_bulk_edit_with_all_true_resolves_documents_from_filters(
self,
m,
@@ -763,7 +764,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(args[0], [self.doc2.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
def test_api_bulk_edit_with_all_true_excludes_documents(self, m) -> None:
self.setup_mock(m, "set_storage_path")
@@ -786,7 +787,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertCountEqual(args[0], [self.doc1.id, self.doc3.id, self.doc5.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None:
self.setup_mock(m, "set_storage_path")
user = UserFactory(username="duplicate-owner")
@@ -823,7 +824,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@mock.patch("documents.search.get_backend")
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters(
self,
m,
@@ -1198,7 +1199,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
def test_set_permissions(self, m) -> None:
self.setup_mock(m, "set_permissions")
user1 = User.objects.create(username="user1")
@@ -1233,7 +1234,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
def test_set_permissions_requires_set_permissions_parameter(self, m) -> None:
self.setup_mock(m, "set_permissions")
@@ -1257,7 +1258,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"set_permissions not specified", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
def test_set_permissions_rejects_malformed_set_permissions(self, m) -> None:
"""
GIVEN:
@@ -1292,7 +1293,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(expected_message, response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
def test_set_permissions_rejects_invalid_owner(self, m) -> None:
"""
GIVEN:
@@ -1334,7 +1335,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"Specified owner cannot be found", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
def test_set_permissions_null_is_a_noop(self, m) -> None:
"""
GIVEN:
@@ -1370,7 +1371,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
{"view": {}, "change": {}},
)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
def test_set_permissions_passes_validated_owner_id(self, m) -> None:
"""
GIVEN:
@@ -1401,7 +1402,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
m.assert_called_once()
self.assertEqual(m.call_args.kwargs["owner"], self.user.id)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m) -> None:
self.setup_mock(m, "set_permissions")
user1 = User.objects.create(username="user1")
@@ -1453,8 +1454,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
_, kwargs = m.call_args
self.assertEqual(kwargs["merge"], True)
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
@mock.patch("documents.views.bulk_edit.merge")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
def test_insufficient_global_perms(self, mock_merge, mock_set_storage) -> None:
"""
GIVEN:
@@ -1517,7 +1518,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
mock_merge.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
def test_insufficient_permissions_ownership(self, m) -> None:
"""
GIVEN:
@@ -1571,7 +1572,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
def test_insufficient_permissions_edit(self, m) -> None:
"""
GIVEN:
@@ -1625,7 +1626,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
m.assert_called_once()
@mock.patch("documents.views.bulk_edit.rotate")
@mock.patch("documents.views.bulk_edit.bulk_edit.rotate")
def test_rotate(self, m) -> None:
self.setup_mock(m, "rotate")
response = self.client.post(
@@ -1647,7 +1648,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(kwargs["source_mode"], "latest_version")
self.assertEqual(kwargs["user"], self.user)
@mock.patch("documents.views.bulk_edit.rotate")
@mock.patch("documents.views.bulk_edit.bulk_edit.rotate")
def test_rotate_invalid_params(self, m) -> None:
response = self.client.post(
"/api/documents/rotate/",
@@ -1687,7 +1688,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"degrees must be a multiple of 90", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.rotate")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.rotate")
def test_bulk_edit_rotate_rejects_invalid_degrees(self, m) -> None:
"""
GIVEN:
@@ -1724,7 +1725,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(expected_message, response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.rotate")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.rotate")
def test_bulk_edit_rotate_passes_integer_degrees(self, m) -> None:
"""
GIVEN:
@@ -1752,7 +1753,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
m.assert_called_once()
self.assertEqual(m.call_args.kwargs["degrees"], -90)
@mock.patch("documents.serialisers.bulk_edit.split")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.split")
def test_bulk_edit_split_rejects_invalid_pages(self, m) -> None:
"""
GIVEN:
@@ -1784,7 +1785,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"invalid pages specified", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.split")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.split")
def test_bulk_edit_split_rejects_unknown_page_count(self, m) -> None:
"""
GIVEN:
@@ -1814,7 +1815,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"document page count is unknown", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.split")
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.split")
def test_bulk_edit_split_parses_pages(self, m) -> None:
"""
GIVEN:
@@ -1842,7 +1843,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
m.assert_called_once()
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
@mock.patch("documents.views.bulk_edit.rotate")
@mock.patch("documents.views.bulk_edit.bulk_edit.rotate")
def test_rotate_insufficient_permissions(self, m) -> None:
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
@@ -1881,7 +1882,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.views.bulk_edit.merge")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
def test_merge(self, m) -> None:
self.setup_mock(m, "merge")
response = self.client.post(
@@ -1903,7 +1904,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(kwargs["source_mode"], "latest_version")
self.assertEqual(kwargs["user"], self.user)
@mock.patch("documents.views.bulk_edit.merge")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
def test_merge_and_delete_insufficient_permissions(self, m) -> None:
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
@@ -1944,7 +1945,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.views.bulk_edit.merge")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
def test_merge_and_delete_requires_change_permission(self, m) -> None:
self.setup_mock(m, "merge")
user = UserFactory(username="no-change")
@@ -1965,7 +1966,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
def test_merge_invalid_parameters(self, m) -> None:
self.setup_mock(m, "merge")
response = self.client.post(
@@ -1998,7 +1999,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
for method, parameters in method_payloads.items():
with self.subTest(method=method, version=version):
with mock.patch(
f"documents.views.bulk_edit.{method}",
f"documents.views.bulk_edit.bulk_edit.{method}",
) as mocked_method:
self.setup_mock(mocked_method, method)
with self.assertLogs("paperless.api", level="WARNING") as logs:
@@ -2087,7 +2088,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
{"operations": ["This list may not be empty."]},
)
@mock.patch("documents.views.bulk_edit.edit_pdf")
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
def test_edit_pdf(self, m) -> None:
self.setup_mock(m, "edit_pdf")
response = self.client.post(
@@ -2247,7 +2248,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Invalid source_mode", response.content)
@mock.patch("documents.views.bulk_edit.edit_pdf")
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
def test_edit_pdf_rejects_invalid_operation_values(self, m) -> None:
"""
GIVEN:
@@ -2313,7 +2314,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
{"operations": {"0": {"rotate": ["rotate must be a multiple of 90"]}}},
)
@mock.patch("documents.views.bulk_edit.edit_pdf")
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
def test_edit_pdf_page_out_of_bounds(self, m) -> None:
self.setup_mock(m, "edit_pdf")
response = self.client.post(
@@ -2330,7 +2331,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"out of bounds", response.content)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf")
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
def test_edit_pdf_insufficient_permissions(self, m) -> None:
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
@@ -2367,7 +2368,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.views.bulk_edit.edit_pdf")
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
def test_edit_pdf_update_requires_change_permission(self, m) -> None:
self.setup_mock(m, "edit_pdf")
user = UserFactory(username="no-change")
@@ -2388,8 +2389,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.remove_password")
@mock.patch("documents.views.bulk_edit.edit_pdf")
@mock.patch("documents.views.bulk_edit.bulk_edit.remove_password")
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
def test_delete_original_requires_delete_permission(
self,
edit_pdf_mock,
@@ -2432,7 +2433,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
operation_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.remove_password")
@mock.patch("documents.views.bulk_edit.bulk_edit.remove_password")
def test_remove_password(self, m) -> None:
self.setup_mock(m, "remove_password")
response = self.client.post(
@@ -2480,7 +2481,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@mock.patch("documents.views.bulk_edit.remove_password")
@mock.patch("documents.views.bulk_edit.bulk_edit.remove_password")
def test_remove_password_insufficient_permissions(self, m) -> None:
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
@@ -2525,7 +2526,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
WHEN:
- API to bulk edit documents is called
THEN:
- Audit log is created
- Audit log is created with the old and new correspondent
"""
LogEntry.objects.all().delete()
response = self.client.post(
@@ -2541,7 +2542,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
entry = LogEntry.objects.get_for_object(self.doc1).get()
self.assertEqual(entry.changes, {"correspondent": [None, self.c2.id]})
@override_settings(AUDIT_LOG_ENABLED=True)
def test_bulk_edit_audit_log_enabled_tags(self) -> None:
@@ -2549,16 +2551,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
GIVEN:
- Audit log is enabled
WHEN:
- API to bulk edit tags is called
- API to bulk edit tags is called on an untagged document and a
document with several tags
THEN:
- Audit log is created
- Audit log is created for each document with its full tag list
before and after the edit
"""
LogEntry.objects.all().delete()
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id],
"documents": [self.doc1.id, self.doc4.id],
"method": "modify_tags",
"parameters": {
"add_tags": [self.t1.id],
@@ -2570,18 +2574,32 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
entry = LogEntry.objects.get_for_object(self.doc1).get()
self.assertEqual(entry.changes, {"tags": [[], [self.t1.id]]})
entry = LogEntry.objects.get_for_object(self.doc4).get()
self.assertEqual(
entry.changes,
{"tags": [[self.t1.id, self.t2.id], [self.t1.id]]},
)
@override_settings(AUDIT_LOG_ENABLED=True)
def test_bulk_edit_audit_log_enabled_custom_fields(self) -> None:
"""
GIVEN:
- Audit log is enabled
- A document with two custom fields
WHEN:
- API to bulk edit custom fields is called
- API to bulk edit custom fields is called to add a third
THEN:
- Audit log is created
- Audit log is created with every custom field instance before and
after the edit
- Audit log is created for the new custom field instance
"""
cf3 = CustomField.objects.create(name="cf3", data_type="string")
existing = [
CustomFieldInstance.objects.create(document=self.doc1, field=field)
for field in (self.cf2, cf3)
]
LogEntry.objects.all().delete()
response = self.client.post(
"/api/documents/bulk_edit/",
@@ -2599,7 +2617,14 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
added = CustomFieldInstance.objects.get(document=self.doc1, field=self.cf1)
existing_ids = [instance.id for instance in existing]
entry = LogEntry.objects.get_for_object(self.doc1).get()
self.assertEqual(
entry.changes,
{"custom_fields": [existing_ids, [*existing_ids, added.id]]},
)
self.assertEqual(LogEntry.objects.get_for_object(added).count(), 1)
def test_api_bulk_edit_with_bad_search_query_returns_400(self) -> None:
"""
+8 -6
View File
@@ -30,7 +30,7 @@ class TestChatStreamingViewInputValidation(APITestCase):
def test_oversized_question_is_rejected(self) -> None:
with mock.patch(
"documents.views.AIConfig",
"documents.views.chat.AIConfig",
return_value=self._mock_ai_enabled(),
):
resp = self.client.post(
@@ -57,11 +57,11 @@ class TestChatStreamingViewInputValidation(APITestCase):
chunks = [f"token{i} " for i in range(40)]
with (
mock.patch(
"documents.views.AIConfig",
"documents.views.chat.AIConfig",
return_value=self._mock_ai_enabled(),
),
mock.patch(
"documents.views.stream_chat_with_documents",
"documents.views.chat.stream_chat_with_documents",
return_value=iter(chunks),
),
):
@@ -78,7 +78,7 @@ class TestChatStreamingViewInputValidation(APITestCase):
def test_missing_question_is_rejected(self) -> None:
with mock.patch(
"documents.views.AIConfig",
"documents.views.chat.AIConfig",
return_value=self._mock_ai_enabled(),
):
resp = self.client.post(
@@ -102,9 +102,11 @@ class TestChatStreamingViewUnrestrictedFlag:
never touches the real vector store; returns the patched callable so
tests can inspect how it was called.
"""
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
mocker.patch(
"documents.views.chat.AIConfig",
).return_value.ai_enabled = True
return mocker.patch(
"documents.views.stream_chat_with_documents",
"documents.views.chat.stream_chat_with_documents",
return_value=iter(()),
)
@@ -17,7 +17,7 @@ from documents.filters import EffectiveContentFilter
from documents.filters import TitleContentFilter
from documents.models import Document
from documents.versioning import annotate_effective_content
from documents.views import DocumentSelectionMixin
from documents.views.base import DocumentSelectionMixin
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import UserFactory
from paperless_testing.http import read_streaming_response
@@ -515,7 +515,9 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
root_document=root,
)
with mock.patch("documents.views.DocumentViewSet.get_metadata") as metadata:
with mock.patch(
"documents.views.documents.DocumentViewSet.get_metadata",
) as metadata:
metadata.return_value = []
resp = self.client.get(
f"/api/documents/{root.id}/metadata/?version={version.id}",
@@ -573,7 +575,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
async_task = mock.Mock()
async_task.id = "task-123"
with mock.patch("documents.views.consume_file") as consume_mock:
with mock.patch("documents.views.documents.consume_file") as consume_mock:
consume_mock.apply_async.return_value = async_task
resp = self.client.post(
f"/api/documents/{root.id}/update_version/",
@@ -609,7 +611,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
async_task = mock.Mock()
async_task.id = "task-123"
with mock.patch("documents.views.consume_file") as consume_mock:
with mock.patch("documents.views.documents.consume_file") as consume_mock:
consume_mock.apply_async.return_value = async_task
resp = self.client.post(
f"/api/documents/{version.id}/update_version/",
@@ -634,7 +636,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
)
upload = self._make_pdf_upload()
with mock.patch("documents.views.consume_file") as consume_mock:
with mock.patch("documents.views.documents.consume_file") as consume_mock:
consume_mock.apply_async.side_effect = Exception("boom")
resp = self.client.post(
f"/api/documents/{root.id}/update_version/",
@@ -673,7 +675,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
)
self.client.force_authenticate(user=user)
with mock.patch("documents.views.consume_file") as consume_mock:
with mock.patch("documents.views.documents.consume_file") as consume_mock:
resp = self.client.post(
f"/api/documents/{root.id}/update_version/",
{"document": self._make_pdf_upload()},
+11 -11
View File
@@ -2605,7 +2605,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
response = self.client.get("/api/documents/34676/suggestions/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@mock.patch("documents.views.get_ai_document_classification")
@mock.patch("documents.views.documents.get_ai_document_classification")
@override_settings(AI_ENABLED=True)
def test_suggestions_still_uses_classifier_when_ai_enabled(
self,
@@ -2628,10 +2628,10 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
)
mock_get_ai_classification.assert_not_called()
@mock.patch("documents.views.match_storage_paths")
@mock.patch("documents.views.match_document_types")
@mock.patch("documents.views.match_tags")
@mock.patch("documents.views.match_correspondents")
@mock.patch("documents.views.documents.match_storage_paths")
@mock.patch("documents.views.documents.match_document_types")
@mock.patch("documents.views.documents.match_tags")
@mock.patch("documents.views.documents.match_correspondents")
@override_settings(NUMBER_OF_SUGGESTED_DATES=10)
def test_get_suggestions(
self,
@@ -2663,11 +2663,11 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
},
)
@mock.patch("documents.views.load_classifier")
@mock.patch("documents.views.match_storage_paths")
@mock.patch("documents.views.match_document_types")
@mock.patch("documents.views.match_tags")
@mock.patch("documents.views.match_correspondents")
@mock.patch("documents.views.documents.load_classifier")
@mock.patch("documents.views.documents.match_storage_paths")
@mock.patch("documents.views.documents.match_document_types")
@mock.patch("documents.views.documents.match_tags")
@mock.patch("documents.views.documents.match_correspondents")
@override_settings(NUMBER_OF_SUGGESTED_DATES=10)
def test_get_suggestions_cached(
self,
@@ -2754,7 +2754,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
response = self.client.get(f"/api/documents/{doc.pk}/suggestions/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@mock.patch("documents.views.get_date_parser")
@mock.patch("documents.views.documents.get_date_parser")
@override_settings(NUMBER_OF_SUGGESTED_DATES=0)
def test_get_suggestions_dates_disabled(
self,
@@ -11,7 +11,7 @@ from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import SavedView
from documents.models import SavedViewFilterRule
from documents.serialisers import DocumentSerializer
from documents.serialisers.documents import DocumentSerializer
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import UserFactory
+11 -14
View File
@@ -64,16 +64,15 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
)
self.client.force_authenticate(user=self.user)
def setupSocialAccount(self) -> None:
def setupSocialAccount(self) -> SocialAccount:
SocialApp.objects.create(
name="Keycloak",
provider="openid_connect",
provider_id="keycloak-test",
)
self.user.socialaccount_set.add(
SocialAccount(uid="123456789", provider="keycloak-test"),
bulk=False,
)
social_account = SocialAccount(uid="123456789", provider="keycloak-test")
self.user.socialaccount_set.add(social_account, bulk=False)
return social_account
def test_get_profile(self) -> None:
"""
@@ -111,19 +110,17 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
THEN:
- Profile is returned with social accounts
"""
self.setupSocialAccount()
social_account = self.setupSocialAccount()
openid_provider = (
MockOpenIDConnectProvider(
app=SocialApp.objects.get(provider_id="keycloak-test"),
),
openid_provider = MockOpenIDConnectProvider(
app=SocialApp.objects.get(provider_id="keycloak-test"),
)
mock_list_providers.return_value = [
openid_provider,
]
mock_get_provider_account.return_value = MockOpenIDConnectProviderAccount(
mock_social_account_dict={
"name": openid_provider[0].name,
"name": openid_provider.name,
},
)
@@ -135,7 +132,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
response.data["social_accounts"],
[
{
"id": 1,
"id": social_account.pk,
"provider": "keycloak-test",
"name": "Keycloak",
},
@@ -152,7 +149,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
THEN:
- Profile is returned with "Unknown App" as name
"""
self.setupSocialAccount()
social_account = self.setupSocialAccount()
# Remove the social app
SocialApp.objects.get(provider_id="keycloak-test").delete()
@@ -165,7 +162,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
response.data["social_accounts"],
[
{
"id": 1,
"id": social_account.pk,
"provider": "keycloak-test",
"name": "Unknown App",
},
@@ -22,7 +22,7 @@ import pytest
from rest_framework import status
import documents.search._backend
from documents.views import _MAX_QUERY_LENGTH
from documents.views.base import _MAX_QUERY_LENGTH
if TYPE_CHECKING:
from rest_framework.test import APIClient
+1 -1
View File
@@ -259,7 +259,7 @@ class TestSystemStatus(APITestCase):
"Celery worker responded unexpectedly.",
)
@mock.patch("documents.views.sleep")
@mock.patch("documents.views.system.sleep")
@mock.patch("celery.app.control.Inspect.ping")
def test_system_status_celery_ping_retry_success(
self,
+2 -2
View File
@@ -918,7 +918,7 @@ class TestRun:
mock_apply_async = mock.Mock(return_value=mock_async_result)
with mock.patch(
"documents.views.train_classifier.apply_async",
"documents.views.tasks.train_classifier.apply_async",
mock_apply_async,
):
response = admin_client.post(
@@ -973,7 +973,7 @@ class TestRun:
mock_apply_async = mock.Mock(return_value=mock_async_result)
with mock.patch(
"documents.views.sanity_check.apply_async",
"documents.views.tasks.sanity_check.apply_async",
mock_apply_async,
):
response = admin_client.post(
@@ -12,7 +12,7 @@ from documents.models import Document
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
from documents.versioning import has_prefetched_effective_content
from documents.versioning import latest_version_content_prefetch
from documents.views import DocumentViewSet
from documents.views.documents import DocumentViewSet
from paperless_testing.factories import DocumentFactory
if TYPE_CHECKING:
+1 -1
View File
@@ -27,7 +27,7 @@ from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.serialisers import DocumentSerializer
from documents.serialisers.documents import DocumentSerializer
from documents.tasks import empty_trash
from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin
@@ -677,12 +677,13 @@ class TestExportImport(
THEN:
- Error is raised
"""
args = ["document_exporter", "/tmp/foo/bar"]
with tempfile.TemporaryDirectory() as tmp_dir:
args = ["document_exporter", str(Path(tmp_dir) / "does-not-exist")]
with self.assertRaises(CommandError) as e:
call_command(*args, skip_checks=True)
with self.assertRaises(CommandError) as e:
call_command(*args, skip_checks=True)
self.assertEqual("That path doesn't exist", str(e.exception))
self.assertEqual("That path doesn't exist", str(e.exception))
def test_export_target_exists_but_is_file(self) -> None:
"""
+8 -8
View File
@@ -123,14 +123,14 @@ class TestFuzzyMatchCommand(TestCase):
- Output contains clickable links to the documents instead of titles
"""
# Content similarity is 86.667
Document.objects.create(
doc1 = Document.objects.create(
checksum="BEEFCAFE",
title="A",
content="first document scanned by bob",
mime_type="application/pdf",
filename="test.pdf",
)
Document.objects.create(
doc2 = Document.objects.create(
checksum="DEADBEAF",
title="A",
content="first document scanned by alice",
@@ -145,8 +145,8 @@ class TestFuzzyMatchCommand(TestCase):
"http://localhost:8000",
)
self.assertIn("Found 1 matching pair(s)", stdout)
self.assertIn("http://localhost:8000/documents/1/details", stdout)
self.assertIn("http://localhost:8000/documents/2/details", stdout)
self.assertIn(f"http://localhost:8000/documents/{doc1.pk}/details", stdout)
self.assertIn(f"http://localhost:8000/documents/{doc2.pk}/details", stdout)
def test_with_3_matches(self) -> None:
"""
@@ -198,14 +198,14 @@ class TestFuzzyMatchCommand(TestCase):
- Documents 1 and 2 remain
"""
# Content similarity is 86.667
Document.objects.create(
doc1 = Document.objects.create(
checksum="BEEFCAFE",
title="A",
content="first document scanned by bob",
mime_type="application/pdf",
filename="test.pdf",
)
Document.objects.create(
doc2 = Document.objects.create(
checksum="DEADBEAF",
title="A",
content="second document scanned by alice",
@@ -235,8 +235,8 @@ class TestFuzzyMatchCommand(TestCase):
self.assertIn("Deleting 1 document(s)", stdout)
self.assertEqual(Document.objects.count(), 2)
self.assertIsNotNone(Document.objects.get(pk=1))
self.assertIsNotNone(Document.objects.get(pk=2))
self.assertIsNotNone(Document.objects.get(pk=doc1.pk))
self.assertIsNotNone(Document.objects.get(pk=doc2.pk))
def test_document_deletion_cancelled(self) -> None:
"""
@@ -14,7 +14,7 @@ from paperless_testing.dirs import DirectoriesMixin
class TestManageSuperUser(DirectoriesMixin, TestCase):
def call_command(self, environ):
out = StringIO()
with mock.patch.dict(os.environ, environ):
with mock.patch.dict(os.environ, environ, clear=True):
call_command(
"manage_superuser",
"--no-color",
@@ -9,7 +9,7 @@ from rest_framework.test import APITestCase
from documents.bulk_edit import merge_as_versions
from documents.models import Document
from documents.serialisers import MergeDocumentsAsVersionsSerializer
from documents.serialisers.bulk_edit import MergeDocumentsAsVersionsSerializer
from paperless_testing.factories import UserFactory
from paperless_testing.permissions import grant_global
@@ -349,7 +349,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
)
self.client.force_authenticate(user=self.user)
@mock.patch("documents.views.bulk_edit.merge_as_versions")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge_as_versions")
def test_merges_documents_as_versions(self, merge_mock) -> None:
merge_mock.return_value = "OK"
merge_mock.__name__ = "merge_as_versions"
@@ -375,7 +375,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
user=self.user,
)
@mock.patch("documents.views.bulk_edit.merge_as_versions")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge_as_versions")
def test_requires_change_permission(self, merge_mock) -> None:
merge_mock.__name__ = "merge_as_versions"
user = UserFactory(username="no-change")
@@ -397,7 +397,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
merge_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge_as_versions")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge_as_versions")
def test_requires_delete_permission(self, merge_mock) -> None:
merge_mock.__name__ = "merge_as_versions"
# Owns them and may change them, but may not make them stop being documents
@@ -420,7 +420,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
merge_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge_as_versions")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge_as_versions")
def test_rejects_unselected_root(self, merge_mock) -> None:
doc3 = Document.objects.create(
checksum="C",
@@ -440,7 +440,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
merge_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge_as_versions")
@mock.patch("documents.views.bulk_edit.bulk_edit.merge_as_versions")
def test_rejects_source_document_with_versions(self, merge_mock) -> None:
Document.objects.create(
checksum="C",
@@ -21,7 +21,7 @@ from documents.models import Tag
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids
from documents.permissions import restrict_queryset_to_visible
from documents.serialisers import _get_viewable_duplicates
from documents.serialisers.documents import _get_viewable_duplicates
from paperless_testing.factories import CorrespondentFactory
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import DocumentTypeFactory
@@ -191,7 +191,7 @@ class TestAiChatAllDocumentsPermissionBoundary:
ENDPOINT = "/api/documents/chat/"
@override_settings(AI_ENABLED=True)
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.chat.stream_chat_with_documents")
def test_chat_all_documents_excludes_unshared_document(self, mock_stream_chat):
mock_stream_chat.return_value = iter([b"data"])
@@ -430,6 +430,53 @@ class TestBulkDownloadPermissionChecksRootDocument:
) # version-only grant must not substitute for root permission
@pytest.mark.django_db
class TestDocumentOperationPermissionChecksRootDocument:
@pytest.mark.parametrize(
("endpoint", "payload"),
[
pytest.param("/api/documents/merge/", {}, id="merge"),
pytest.param("/api/documents/rotate/", {"degrees": 90}, id="rotate"),
],
)
@pytest.mark.parametrize("version_owner", ["none", "requester"])
def test_version_operation_acts_on_root(
self,
rest_api_client: APIClient,
endpoint: str,
payload: dict,
version_owner: str,
) -> None:
owner = UserFactory(username="owner")
requester = UserFactory(username="requester")
grant_global(requester, "change_document")
grant_global(requester, "add_document")
rest_api_client.force_authenticate(user=requester)
root = DocumentFactory(owner=owner)
# A version whose owner went stale, e.g. created before the root changed hands
version = DocumentFactory(
owner=requester if version_owner == "requester" else None,
root_document=root,
version_index=1,
)
with (
patch("documents.views.bulk_edit.bulk_edit.merge") as mock_merge,
patch("documents.views.bulk_edit.bulk_edit.rotate") as mock_rotate,
):
mock_merge.__name__ = "merge"
mock_rotate.__name__ = "rotate"
response = rest_api_client.post(
endpoint,
{"documents": [version.pk], **payload},
format="json",
)
assert response.status_code == HTTPStatus.FORBIDDEN
mock_merge.assert_not_called()
mock_rotate.assert_not_called()
@pytest.mark.django_db
@pytest.mark.usefixtures("_search_index")
class TestTrashRestorePermissionBoundary:
+5 -14
View File
@@ -15,7 +15,7 @@ from rest_framework.test import APITestCase
from documents.filters import ShareLinkBundleFilterSet
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.serialisers import ShareLinkBundleSerializer
from documents.serialisers.sharing import ShareLinkBundleSerializer
from documents.tasks import build_share_link_bundle
from documents.tasks import cleanup_expired_share_link_bundles
from paperless_testing.dirs import DirectoriesMixin
@@ -34,7 +34,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
self.client.force_authenticate(self.user)
self.document = DocumentFactory.create()
@mock.patch("documents.views.build_share_link_bundle.apply_async")
@mock.patch("documents.views.sharing.build_share_link_bundle.apply_async")
def test_create_bundle_triggers_build_job(self, delay_mock) -> None:
payload = {
"document_ids": [self.document.pk],
@@ -51,7 +51,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
delay_mock.assert_called_once()
self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk)
@mock.patch("documents.views.build_share_link_bundle.apply_async")
@mock.patch("documents.views.sharing.build_share_link_bundle.apply_async")
def test_create_bundle_requires_global_document_view_permission(
self,
delay_mock,
@@ -90,7 +90,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("document_ids", response.data)
@mock.patch("documents.views.permitted_document_ids", return_value=set())
@mock.patch("documents.views.sharing.permitted_document_ids", return_value=set())
def test_create_bundle_rejects_insufficient_permissions(self, perms_mock) -> None:
payload = {
"document_ids": [self.document.pk],
@@ -104,7 +104,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
self.assertIn("document_ids", response.data)
perms_mock.assert_called()
@mock.patch("documents.views.build_share_link_bundle.apply_async")
@mock.patch("documents.views.sharing.build_share_link_bundle.apply_async")
def test_rebuild_bundle_resets_state(self, delay_mock) -> None:
bundle = ShareLinkBundle.objects.create(
slug="rebuild-slug",
@@ -339,15 +339,6 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
)
self.document.archive_checksum = ""
self.document.save()
self.addCleanup(
setattr,
settings,
"SHARE_LINK_BUNDLE_DIR",
settings.SHARE_LINK_BUNDLE_DIR,
)
settings.SHARE_LINK_BUNDLE_DIR = (
Path(settings.MEDIA_ROOT) / "documents" / "share_link_bundles"
)
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
if archive:
+1 -1
View File
@@ -9,7 +9,7 @@ from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.serialisers import TagSerializer
from documents.serialisers.metadata import TagSerializer
from documents.signals.handlers import run_workflows
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import UserFactory
-35
View File
@@ -18,7 +18,6 @@ from documents.models import WorkflowAction
from documents.sanity_checker import SanityCheckFailedException
from documents.sanity_checker import SanityCheckMessages
from documents.tests.helpers import dummy_preprocess
from paperless_ai.exceptions import LLMBlockedError
from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin
@@ -556,37 +555,3 @@ class TestApplyAISuggestionsTask(DirectoriesMixin, TestCase):
apply_suggestions.assert_not_called()
self.assertIn("no longer exists", "".join(cm.output))
@override_settings(AI_ENABLED=True)
def test_blocked_request_fails_without_retry(self) -> None:
"""
GIVEN:
- AI enabled and a document with content
- The AI classification call blocked by the outbound request policy
WHEN:
- The task runs through Celery
THEN:
- The workflow code does not swallow the block
- The task fails with LLMBlockedError and is never retried
"""
with (
mock.patch(
"documents.workflows.ai.get_ai_document_classification",
side_effect=LLMBlockedError(
"AI backend request was blocked by the outbound request "
"policy: detail",
),
),
mock.patch.object(
tasks.apply_ai_suggestions,
"retry",
wraps=tasks.apply_ai_suggestions.retry,
) as retry,
):
result = tasks.apply_ai_suggestions.apply(
args=(self.action.pk, self.doc.pk),
)
self.assertTrue(result.failed())
self.assertIsInstance(result.result, LLMBlockedError)
retry.assert_not_called()
+23 -66
View File
@@ -29,7 +29,6 @@ from documents.models import Tag
from documents.models import UiSettings
from documents.signals.handlers import update_llm_suggestions_cache
from paperless.models import ApplicationConfiguration
from paperless_ai.exceptions import LLMBlockedError
from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError
from paperless_testing.dirs import DirectoriesMixin
@@ -349,8 +348,8 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.path1 = StoragePath.objects.create(name="path1")
super().setUp()
@patch("documents.views.get_llm_suggestion_cache")
@patch("documents.views.refresh_llm_suggestions_cache")
@patch("documents.views.documents.get_llm_suggestion_cache")
@patch("documents.views.documents.refresh_llm_suggestions_cache")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -398,8 +397,8 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
backend=f"mock_backend:user={self.user.pk}",
)
@patch("documents.views.get_llm_suggestion_cache")
@patch("documents.views.refresh_llm_suggestions_cache")
@patch("documents.views.documents.get_llm_suggestion_cache")
@patch("documents.views.documents.refresh_llm_suggestions_cache")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -448,7 +447,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.assertEqual(response.json()["tags"], [])
self.assertEqual(response.json()["suggested_tags"], [])
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -498,7 +497,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
None,
)
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -536,7 +535,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
"KI Title",
)
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -575,7 +574,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
"Titre IA",
)
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -611,7 +610,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
),
)
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -683,7 +682,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
),
)
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="openai-like",
@@ -712,7 +711,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="openai-like",
@@ -739,7 +738,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="openai-like",
@@ -771,49 +770,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="openai-like",
)
def test_ai_suggestions_with_blocked_llm_request(
self,
mock_get_ai_classification,
) -> None:
"""
GIVEN:
- An AI backend request blocked by the outbound request policy
WHEN:
- AI suggestions are requested
THEN:
- 502 is returned with a generic message and nothing is cached
"""
mock_get_ai_classification.side_effect = LLMBlockedError(
"AI backend request was blocked by the outbound request policy: detail",
)
self.client.force_login(user=self.user)
response = self.client.get(
f"/api/documents/{self.document.pk}/ai_suggestions/",
)
self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY)
self.assertEqual(
response.json(),
{
"ai": [
(
"AI backend request was blocked by the outbound request "
"policy. Check logs for details."
),
],
},
)
self.assertIsNone(
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -851,7 +808,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.assertEqual(response.json()["tags"], [self.tag1.pk])
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -890,7 +847,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.assertEqual(response.json()["tags"], [self.tag1.pk])
self.assertEqual(response.json()["suggested_tags"], [])
@patch("documents.views.get_ai_document_classification")
@patch("documents.views.documents.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
@@ -1012,8 +969,8 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn(b"AI is required for this feature", response.content)
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.permitted_document_ids")
@patch("documents.views.chat.stream_chat_with_documents")
@patch("documents.views.chat.permitted_document_ids")
@override_settings(AI_ENABLED=True)
def test_post_no_document_id(self, mock_permitted_ids, mock_stream_chat) -> None:
self.grant_view_document_permission()
@@ -1032,8 +989,8 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(list(call_kwargs["documents"]), [self.document])
self.assertIsNone(call_kwargs["output_language"])
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.permitted_document_ids")
@patch("documents.views.chat.stream_chat_with_documents")
@patch("documents.views.chat.permitted_document_ids")
@override_settings(AI_ENABLED=True)
def test_post_uses_user_display_language(
self,
@@ -1058,7 +1015,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(list(call_kwargs["documents"]), [self.document])
self.assertEqual(call_kwargs["output_language"], "de-de")
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.chat.stream_chat_with_documents")
@override_settings(AI_ENABLED=True)
def test_post_with_document_id(self, mock_stream_chat) -> None:
self.grant_view_document_permission()
@@ -1082,7 +1039,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn(b"Document not found", response.content)
@patch("documents.views.has_perms_owner_aware")
@patch("documents.views.chat.has_perms_owner_aware")
@override_settings(AI_ENABLED=True)
def test_post_with_document_id_no_permission(self, mock_has_perms) -> None:
self.grant_view_document_permission()
@@ -1095,7 +1052,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 403)
self.assertIn(b"Insufficient permissions", response.content)
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.chat.stream_chat_with_documents")
@override_settings(AI_ENABLED=True)
def test_post_no_document_id_requires_view_document_permission(
self,
@@ -1109,7 +1066,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 403)
mock_stream_chat.assert_not_called()
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.chat.stream_chat_with_documents")
@override_settings(AI_ENABLED=True)
def test_post_with_document_id_requires_view_document_permission(
self,
+142 -116
View File
@@ -1,7 +1,9 @@
import datetime
import json
import shutil
import socket
import tempfile
from collections.abc import Callable
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
@@ -17,11 +19,11 @@ from django.test import override_settings
from django.utils import timezone
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
from httpx import ConnectError
from httpx import HTTPError
from httpx import HTTPStatusError
from pytest_django.fixtures import Settings
from pytest_httpx import HTTPXMock
from pytest_mock import MockerFixture
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
@@ -31,12 +33,8 @@ from documents.file_handling import generate_unique_filename
from documents.signals.handlers import run_workflows
from documents.workflows.ai import apply_ai_suggestions_to_document
from documents.workflows.webhooks import send_webhook
from paperless.network import OutboundRequestBlockedError
from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.exceptions import LLMTimeoutError
from paperless_testing.outbound import DialRecorder
from paperless_testing.outbound import FakeDNS
from paperless_testing.outbound import LocalHTTPServer
if TYPE_CHECKING:
from django.db.models import QuerySet
@@ -63,7 +61,7 @@ from documents.models import WorkflowActionWebhook
from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.plugins.base import StopConsumeTaskError
from documents.serialisers import WorkflowTriggerSerializer
from documents.serialisers.workflows import WorkflowTriggerSerializer
from documents.signals import document_consumption_finished
from documents.tests.utils import SampleDirMixin
from documents.workflows.actions import execute_password_removal_action
@@ -71,7 +69,9 @@ from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import UserFactory
from paperless_testing.permissions import grant_global
from paperless_testing.permissions import grant_object
@@ -1061,6 +1061,41 @@ class TestWorkflows(
self.assertEqual(doc.correspondent, self.c2)
self.assertEqual(doc.title, f"Doc created in {created.year}")
@pytest.mark.usefixtures("_search_index")
def test_document_added_workflow_indexes_final_title(self) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_filename="*sample*",
)
action = WorkflowAction.objects.create(
assign_title="Linked document",
assign_owner=self.user2,
)
link_field = CustomField.objects.create(
name="Related documents",
data_type=CustomField.FieldDataType.DOCUMENTLINK,
)
action.assign_custom_fields.add(link_field)
workflow = Workflow.objects.create(name="Link workflow", order=0)
workflow.triggers.add(trigger)
workflow.actions.add(action)
doc = DocumentFactory.create()
document_consumption_finished.send(sender=self.__class__, document=doc)
self.assertTrue(doc.custom_fields.filter(field=link_field).exists())
doc.refresh_from_db()
self.assertEqual(doc.title, "Linked document")
grant_global(self.user2, "view_document")
self.client.force_authenticate(user=self.user2)
response = self.client.get("/api/documents/?title_search=linked")
self.assertEqual(response.status_code, 200)
self.assertEqual(
[result["id"] for result in response.data["results"]],
[doc.pk],
)
def test_document_added_no_match_filename(self) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
@@ -4424,18 +4459,18 @@ class TestWorkflows(
)
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_fails_without_correct_password(
def test_password_removal_action_skips_blank_and_whitespace_passwords(
self,
mock_remove_password,
) -> None:
"""
GIVEN:
- Workflow password removal action
- No correct password provided
- Only blank and whitespace-only passwords configured
WHEN:
- Document updated triggering the workflow
THEN:
- Password removal is attempted for all passwords and fails
- Password removal is not attempted
"""
doc = Document.objects.create(
title="Protected",
@@ -4456,6 +4491,60 @@ class TestWorkflows(
mock_remove_password.assert_not_called()
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_fails_without_correct_password(
self,
mock_remove_password,
) -> None:
"""
GIVEN:
- Workflow password removal action
- No configured password is correct
WHEN:
- Document updated triggering the workflow
THEN:
- Password removal is attempted for every configured password and fails
"""
doc = Document.objects.create(
title="Protected",
checksum="pw-checksum-3",
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
passwords=["wrong", "also-wrong"],
)
workflow = Workflow.objects.create(name="Password workflow wrong passwords")
workflow.triggers.add(trigger)
workflow.actions.add(action)
mock_remove_password.side_effect = ValueError("wrong password")
with self.assertLogs("paperless.workflows.actions", level="ERROR"):
run_workflows(trigger.type, doc)
assert mock_remove_password.call_count == 2
mock_remove_password.assert_has_calls(
[
mock.call(
[doc.id],
password="wrong",
update_document=True,
user=doc.owner,
source_paths_by_id=None,
),
mock.call(
[doc.id],
password="also-wrong",
update_document=True,
user=doc.owner,
source_paths_by_id=None,
),
],
)
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_skips_without_passwords(
self,
@@ -5071,6 +5160,25 @@ class TestWebhookSend:
assert httpx_mock.get_request().headers["Content-Type"] == "application/json"
@pytest.fixture
def resolve_to(monkeypatch: pytest.MonkeyPatch) -> Callable[[str], None]:
"""
Force DNS resolution to a specific IP for any hostname.
"""
def _set(ip: str) -> None:
def fake_getaddrinfo(
host: str,
*_args: object,
**_kwargs: object,
) -> list[tuple[Any, ...]]:
return [(socket.AF_INET, None, None, "", (ip, 0))]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
return _set
class TestWebhookSecurity:
def test_blocks_invalid_scheme_or_hostname(self, httpx_mock: HTTPXMock) -> None:
"""
@@ -5120,145 +5228,60 @@ class TestWebhookSecurity:
assert httpx_mock.get_request() is None
@pytest.mark.parametrize(
"address",
[
pytest.param("127.0.0.1", id="loopback"),
pytest.param("10.0.0.1", id="private"),
pytest.param("169.254.169.254", id="link-local-metadata"),
pytest.param("::ffff:127.0.0.1", id="ipv4-mapped-loopback"),
pytest.param("64:ff9b::7f00:1", id="nat64-wrapping-loopback"),
],
)
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=False)
def test_blocks_private_loopback_linklocal(
self,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
dial_recorder: DialRecorder,
address: str,
httpx_mock: HTTPXMock,
resolve_to,
) -> None:
"""
GIVEN:
- A webhook host resolving to a non-public address
- URL with a private, loopback, or link-local IP address
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is False
WHEN:
- send_webhook is called
- send_webhook is called with such URL
THEN:
- The request is blocked before any connection is opened
- ValueError is raised
"""
fake_dns.add("webhook.test", address)
with pytest.raises(OutboundRequestBlockedError):
resolve_to("127.0.0.1")
with pytest.raises(ConnectError):
send_webhook(
f"http://webhook.test:{local_http_server.port}",
"http://paperless-ngx.com",
data="",
headers={},
files=None,
as_json=False,
)
assert local_http_server.connections == 0
assert dial_recorder.hosts() == []
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=False)
@pytest.mark.usefixtures("every_address_is_public")
def test_sends_to_validated_address(
def test_allows_public_ip_and_sends(
self,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
httpx_mock: HTTPXMock,
resolve_to,
) -> None:
"""
GIVEN:
- A webhook host resolving to an address the policy accepts
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is False
- URL with a public IP address
WHEN:
- send_webhook is called
- send_webhook is called with such URL
THEN:
- The payload arrives with the webhook hostname in the Host header
- Request is sent successfully
"""
fake_dns.add("webhook.test", "127.0.0.1")
resolve_to("52.207.186.75")
httpx_mock.add_response(content=b"ok")
send_webhook(
url=f"http://webhook.test:{local_http_server.port}",
url="http://paperless-ngx.com",
data="hi",
headers={},
files=None,
as_json=False,
)
received = local_http_server.requests[0]
assert received.body == b"hi"
assert received.headers["host"] == f"webhook.test:{local_http_server.port}"
req = httpx_mock.get_request()
assert req.url.host == "52.207.186.75"
assert req.headers["host"] == "paperless-ngx.com"
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=True)
def test_allow_internal_sends_to_internal_address(
self,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
) -> None:
"""
GIVEN:
- A webhook to localhost
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is True
WHEN:
- send_webhook is called
THEN:
- The payload arrives at the internal address
- The guard does not resolve the host, leaving it to the stock
connection path
"""
send_webhook(
url=f"http://localhost:{local_http_server.port}",
data="hi",
headers={},
files=None,
as_json=False,
)
received = local_http_server.requests[0]
assert received.body == b"hi"
assert fake_dns.lookups == []
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=False)
def test_block_is_an_expected_task_failure(
self,
mocker: MockerFixture,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
dial_recorder: DialRecorder,
) -> None:
"""
GIVEN:
- A webhook host resolving to a loopback address
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is False
WHEN:
- The webhook task runs through Celery
THEN:
- The task fails with the original block error, not a wrapper,
so it matches the task's expected errors, and is not retried
"""
fake_dns.add("webhook.test", "127.0.0.1")
retry = mocker.spy(send_webhook, "retry")
result = send_webhook.apply(
kwargs={
"url": f"http://webhook.test:{local_http_server.port}",
"data": "",
"headers": {},
"files": None,
"as_json": False,
},
)
assert result.failed()
assert isinstance(result.result, OutboundRequestBlockedError)
assert isinstance(result.result, send_webhook.throws)
retry.assert_not_called()
assert local_http_server.connections == 0
assert dial_recorder.hosts() == []
def test_follow_redirects_disabled(self, httpx_mock: HTTPXMock) -> None:
def test_follow_redirects_disabled(self, httpx_mock: HTTPXMock, resolve_to) -> None:
"""
GIVEN:
- A URL that redirects
@@ -5267,6 +5290,7 @@ class TestWebhookSecurity:
THEN:
- Request is made to the original URL and does not follow the redirect
"""
resolve_to("52.207.186.75")
# Return a redirect and ensure we don't follow it (only one request recorded)
httpx_mock.add_response(
status_code=302,
@@ -5288,6 +5312,7 @@ class TestWebhookSecurity:
def test_strips_user_supplied_host_header(
self,
httpx_mock: HTTPXMock,
resolve_to: Callable[[str], None],
) -> None:
"""
GIVEN:
@@ -5295,8 +5320,9 @@ class TestWebhookSecurity:
WHEN:
- send_webhook is called with a malicious Host header
THEN:
- The Host header is stripped and set from the URL hostname
- The Host header is stripped and replaced with the resolved hostname
"""
resolve_to("52.207.186.75")
httpx_mock.add_response(content=b"ok")
send_webhook(
File diff suppressed because it is too large Load Diff
View File
+611
View File
@@ -0,0 +1,611 @@
import logging
from collections import defaultdict
from typing import TYPE_CHECKING
from typing import Any
from typing import Final
from typing import Literal
from typing import NamedTuple
from unicodedata import normalize
from urllib.parse import quote
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.db.models import Model
from django.http import FileResponse
from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from django.utils.translation import gettext_lazy as _
from guardian.utils import get_group_obj_perms_model
from guardian.utils import get_user_obj_perms_model
from rest_framework import parsers
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import ValidationError
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from documents import bulk_edit
from documents.filters import DocumentFilterSet
from documents.models import Document
from documents.models import PaperlessTask
from documents.permissions import annotate_document_count_for_related_queryset
from documents.permissions import get_document_count_filter_for_user
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids
from documents.search import SearchHit
from documents.serialisers.base import SerializerWithPerms
from documents.utils import get_boolean
from documents.versioning import get_root_document
logger = logging.getLogger("paperless.api")
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
# whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
# measured at ~0.96s/10k chars, ~3.67s/20k, ~14.4s/40k against the real field
# registry. Django's DATA_UPLOAD_MAX_MEMORY_SIZE default (2.5 MB) does not
# bound this on the POST-body selection-filter path, so an unbounded query
# is a single-request CPU exhaustion vector. 4096 chars caps the worst case
# at roughly 0.16s (quadratic extrapolation from the measurements above),
# far beyond any plausible hand-typed advanced query, while still being fast
# enough to absorb inside a request handler. Applied to all three modes at
# this shared choke point: TEXT and TITLE route through simple_search_tokens
# instead and measure linear even at 20k chars, so the cap is hygiene for
# them, not a fix, but a single limit here is simpler than one exemption.
# Not exposed as a PAPERLESS_* setting: this is a hard security boundary,
# not a tunable, and a raisable ceiling would let a misconfiguration
# reintroduce the exact hazard this exists to close.
_MAX_QUERY_LENGTH: Final[int] = 4096
def _get_tantivy_query_and_mode(params):
from documents.search import QueryTooLongError
from documents.search import SearchMode
if "text" in params:
raw, mode = str(params["text"]), SearchMode.TEXT
elif "title_search" in params:
raw, mode = str(params["title_search"]), SearchMode.TITLE
elif "query" in params:
raw, mode = str(params["query"]), SearchMode.QUERY
else:
return None # pragma: no cover
if len(raw) > _MAX_QUERY_LENGTH:
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH)
return raw, mode
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
try:
more_like_doc_id = int(query_params["more_like_id"])
more_like_doc = Document.objects.select_related("owner").get(
pk=more_like_doc_id,
)
except (TypeError, ValueError, Document.DoesNotExist):
raise PermissionDenied(_("Invalid more_like_id"))
if user and not has_perms_owner_aware(
user,
"view_document",
more_like_doc,
):
raise PermissionDenied(_("Insufficient permissions."))
return more_like_doc_id
class SearchParams(NamedTuple):
sort_field_name: str | None
sort_reverse: bool
use_tantivy_sort: bool
page_num: int
page_size: int
class SearchResultPage(NamedTuple):
ordered_ids: list[int]
hits: list[SearchHit]
page_offset: int
class ResolvedRequestDocs(NamedTuple):
request_doc: Document
root_doc: Document
class PassUserMixin(GenericAPIView[Any]):
"""
Pass a user object to serializer
"""
def get_serializer(self, *args, **kwargs):
serializer_class = self.get_serializer_class()
if isinstance(serializer_class, type) and issubclass(
serializer_class,
SerializerWithPerms,
):
kwargs.setdefault("user", self.request.user)
try:
full_perms = get_boolean(
str(self.request.query_params.get("full_perms", "false")),
)
except ValueError:
full_perms = False
kwargs.setdefault(
"full_perms",
full_perms,
)
return super().get_serializer(*args, **kwargs)
class BulkPermissionMixin:
"""
Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.
"""
def _get_object_perms(
self,
objects: list,
perm_codenames: list[str],
actor: Literal["users", "groups"],
) -> dict[int, dict[str, list[int]]]:
"""
Collect object-level permissions for either users or groups.
"""
model = self.queryset.model
obj_perm_model = (
get_user_obj_perms_model(model)
if actor == "users"
else get_group_obj_perms_model(model)
)
id_field = "user_id" if actor == "users" else "group_id"
ctype = ContentType.objects.get_for_model(model)
object_pks = [obj.pk for obj in objects]
perms_qs = obj_perm_model.objects.filter(
content_type=ctype,
object_pk__in=object_pks,
permission__codename__in=perm_codenames,
).values_list("object_pk", id_field, "permission__codename")
perms: dict[int, dict[str, list[int]]] = defaultdict(lambda: defaultdict(list))
for object_pk, actor_id, codename in perms_qs:
perms[int(object_pk)][codename].append(actor_id)
# Ensure that all objects have all codenames, even if empty
for pk in object_pks:
for codename in perm_codenames:
perms[pk][codename]
return perms
def get_serializer_context(self):
"""
Get all permissions of the current list of objects at once and pass them to the serializer.
This avoid fetching permissions object by object in database.
"""
context = super().get_serializer_context()
if getattr(self, "action", None) != "list":
# Batching only pays off across a page of objects; for single-object
# actions (retrieve, update, ...) the per-object fallback in
# get_user_can_change()/_get_perms() is cheap and avoids scanning
# the whole queryset here.
return context
# Check which objects are being paginated
page = getattr(self, "paginator", None)
if page and hasattr(page, "page"):
queryset = page.page.object_list
elif hasattr(self, "page"):
queryset = self.page
else:
queryset = self.filter_queryset(self.get_queryset())
model_name = self.queryset.model.__name__.lower()
permission_name_view = f"view_{model_name}"
permission_name_change = f"change_{model_name}"
user_perms = self._get_object_perms(
objects=queryset,
perm_codenames=[permission_name_view, permission_name_change],
actor="users",
)
group_perms = self._get_object_perms(
objects=queryset,
perm_codenames=[permission_name_view, permission_name_change],
actor="groups",
)
context["users_view_perms"] = {
pk: user_perms[pk][permission_name_view] for pk in user_perms
}
context["users_change_perms"] = {
pk: user_perms[pk][permission_name_change] for pk in user_perms
}
context["groups_view_perms"] = {
pk: group_perms[pk][permission_name_view] for pk in group_perms
}
context["groups_change_perms"] = {
pk: group_perms[pk][permission_name_change] for pk in group_perms
}
return context
class PermissionsAwareDocumentCountMixin(BulkPermissionMixin, PassUserMixin):
"""Mixin to add document count to queryset, permissions-aware if needed"""
# Direct FK/M2M relation name from this model to Document, used for the
# cheap Count(filter=...) path (Correspondent, DocumentType, StoragePath).
document_count_related_name: str = "documents"
# Set both of these instead, for models that only reach Document through
# an M2M/through-model table (Tag, CustomField). A plain Count(filter=...)
# over such a relation is fine for a direct FK, but forces a much more
# expensive plan once an M2M bridge table is involved -- see
# annotate_document_count_for_related_queryset() for why.
document_count_through: type[Model] | None = None
document_count_source_field: str | None = None
def _get_document_count_source_field(self) -> str:
if self.document_count_source_field is None:
msg = (
"document_count_source_field must be set when "
"document_count_through is configured"
)
raise ValueError(msg)
return self.document_count_source_field
def get_document_count_filter(self):
request = getattr(self, "request", None)
user = getattr(request, "user", None) if request else None
return get_document_count_filter_for_user(
user,
related_name=self.document_count_related_name,
)
def get_queryset(self):
base_qs = super().get_queryset()
if self.document_count_through:
user = getattr(getattr(self, "request", None), "user", None)
return annotate_document_count_for_related_queryset(
base_qs,
through_model=self.document_count_through,
related_object_field=self._get_document_count_source_field(),
user=user,
)
filter = self.get_document_count_filter()
return base_qs.annotate(
document_count=Count(
self.document_count_related_name,
filter=filter,
distinct=True,
),
)
class DocumentSelectionMixin:
def _get_search_document_ids(
self,
*,
user: User,
filters: dict[str, Any],
) -> list[int] | None:
search_filters = [
filter_name
for filter_name in _TANTIVY_SEARCH_PARAM_NAMES
if filter_name in filters
]
if not search_filters:
return None
if len(search_filters) > 1:
raise ValidationError(
{
"detail": _(
"Specify only one of text, title_search, query, or more_like_id.",
),
},
)
from documents.search import SearchQueryError
from documents.search import get_backend
from documents.search import search_query_error_messages
filter_name = search_filters[0]
backend = get_backend()
search_user = None if user.is_superuser else user
try:
if filter_name == "more_like_id":
more_like_doc_id = _get_more_like_id(filters, user)
search_ids = backend.more_like_this_ids(
more_like_doc_id,
user=search_user,
)
else:
query_str, search_mode = _get_tantivy_query_and_mode(filters)
search_ids = backend.search_ids(
query_str,
user=search_user,
search_mode=search_mode,
)
except SearchQueryError as e:
# Same user-fixable-query mapping as the search list endpoint:
# a bad date/number in a bulk selection filter is a 400 naming
# the value, never a 500.
raise ValidationError({"query": search_query_error_messages(e)}) from e
return search_ids
def _resolve_document_ids(
self,
*,
user: User,
validated_data: dict[str, Any],
) -> list[int]:
if not validated_data.get("all", False):
# if all is not true, just pass through the provided document ids
return validated_data["documents"]
# otherwise, reconstruct the document list based on the provided filters
filters = validated_data.get("filters") or {}
orm_filters = {
key: value
for key, value in filters.items()
if key not in _TANTIVY_SEARCH_PARAM_NAMES
}
# Operations are addressed to roots, a caller that wants
# to act on a specific version passes its id explicitly instead
permitted_documents = Document.objects.filter(
id__in=permitted_document_ids(user),
root_document__isnull=True,
)
# orm-filtered docs
filtered_documents = DocumentFilterSet(
data=orm_filters,
queryset=permitted_documents,
user=user,
).qs.distinct()
# tantivy-filtered docs (if search params provided)
search_filtered_ids = self._get_search_document_ids(
user=user,
filters=filters,
)
if search_filtered_ids is not None:
filtered_documents = filtered_documents.filter(pk__in=search_filtered_ids)
if validated_data.get("excluded_documents"):
filtered_documents = filtered_documents.exclude(
pk__in=validated_data["excluded_documents"],
)
return list(filtered_documents.values_list("pk", flat=True))
class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
permission_classes = (IsAuthenticated,)
parser_classes = (parsers.JSONParser,)
METHOD_NAMES_REQUIRING_USER = {
"split",
"merge",
"rotate",
"delete_pages",
"edit_pdf",
"remove_password",
"merge_as_versions",
}
# merge_as_versions doesn't queue any consume tasks
METHOD_NAMES_REQUIRING_TRIGGER_SOURCE = METHOD_NAMES_REQUIRING_USER - {
"merge_as_versions",
}
def _has_document_permissions(
self,
*,
user: User,
documents: list[int],
method,
parameters: dict[str, Any],
) -> bool:
if user.is_superuser:
return True
root_docs = {
get_root_document(doc)
for doc in Document.objects.select_related(
"owner",
"root_document__owner",
).filter(pk__in=documents)
}
user_is_owner_of_all_documents = all(
(doc.owner == user or doc.owner is None) for doc in root_docs
)
# check global and object permissions for all documents
has_perms = (
user.has_perm(
"documents.change_document",
)
and not Document.global_objects.filter(
pk__in=[doc.pk for doc in root_docs],
)
.exclude(
pk__in=permitted_document_ids(user, perm="change_document"),
)
.exists()
)
# check ownership for methods that change original document
if (
(
has_perms
and method
in [
bulk_edit.set_permissions,
bulk_edit.delete,
bulk_edit.rotate,
bulk_edit.delete_pages,
bulk_edit.edit_pdf,
bulk_edit.merge_as_versions,
bulk_edit.remove_password,
]
)
or (
method in [bulk_edit.merge, bulk_edit.split]
and parameters.get("delete_originals")
)
or (method == bulk_edit.edit_pdf and parameters.get("update_document"))
):
has_perms = has_perms and user_is_owner_of_all_documents
# check global add permissions for methods that create documents
if (
has_perms
and (
method in [bulk_edit.split, bulk_edit.merge]
or (
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
and not parameters.get("update_document")
)
)
and not user.has_perm("documents.add_document")
):
has_perms = False
# check global delete permissions for methods that delete documents
if (
has_perms
and (
method == bulk_edit.delete
# Sources stop being documents of their own, and removing one
# again afterwards needs delete_document
or method == bulk_edit.merge_as_versions
or (
method in [bulk_edit.merge, bulk_edit.split]
and parameters.get("delete_originals")
)
or (
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
and parameters.get("delete_original")
and not parameters.get("update_document")
)
)
and not user.has_perm("documents.delete_document")
):
has_perms = False
return has_perms
def _execute_document_action(
self,
*,
method,
validated_data: dict[str, Any],
operation_label: str,
):
documents = self._resolve_document_ids(
user=self.request.user,
validated_data=validated_data,
)
parameters = {
k: v
for k, v in validated_data.items()
if k
not in {
"documents",
"all",
"filters",
"excluded_documents",
"from_webui",
}
}
user = self.request.user
from_webui = validated_data.get("from_webui", False)
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
parameters["user"] = user
if method.__name__ in self.METHOD_NAMES_REQUIRING_TRIGGER_SOURCE:
parameters["trigger_source"] = (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
)
if not self._has_document_permissions(
user=user,
documents=documents,
method=method,
parameters=parameters,
):
return HttpResponseForbidden("Insufficient permissions")
try:
result = method(documents, **parameters)
return Response({"result": result})
except Exception as e:
logger.warning(f"An error occurred performing {operation_label}: {e!s}")
return HttpResponseBadRequest(
f"Error performing {operation_label}, check logs for more detail.",
)
def serve_file(
*,
doc: Document,
use_archive: bool,
disposition: str,
follow_formatting: bool = False,
) -> FileResponse:
if use_archive:
if TYPE_CHECKING:
assert doc.archive_filename
file_handle = doc.archive_file
filename = (
doc.archive_filename
if follow_formatting
else doc.get_public_filename(archive=True)
)
mime_type = "application/pdf"
else:
if TYPE_CHECKING:
assert doc.filename
file_handle = doc.source_file
filename = doc.filename if follow_formatting else doc.get_public_filename()
mime_type = doc.mime_type
# Support browser previewing csv files by using text mime type
if mime_type in {"application/csv", "text/csv"} and disposition == "inline":
mime_type = "text/plain"
# Tell browsers to use UTF-8 for the text files we parse as UTF-8
if mime_type in {"text/plain", "text/csv", "application/csv"}:
mime_type = f"{mime_type}; charset=utf-8"
response = FileResponse(file_handle, content_type=mime_type)
# Firefox is not able to handle unicode characters in filename field
# RFC 5987 addresses this issue
# see https://datatracker.ietf.org/doc/html/rfc5987#section-4.2
# Chromium cannot handle commas in the filename
filename_normalized = (
normalize("NFKD", filename.replace(",", "_"))
.encode(
"ascii",
"ignore",
)
.decode("ascii")
.replace("\\", "_")
.replace('"', "_")
)
filename_encoded = quote(filename)
content_disposition = (
f"{disposition}; "
f'filename="{filename_normalized}"; '
f"filename*=utf-8''{filename_encoded}"
)
response["Content-Disposition"] = content_disposition
return response
+601
View File
@@ -0,0 +1,601 @@
import logging
import os
import tempfile
import zipfile
from http import HTTPStatus
from pathlib import Path
from typing import Any
from django.conf import settings
from django.db.models import Q
from django.http import FileResponse
from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from drf_spectacular.utils import inline_serializer
from rest_framework import parsers
from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from documents import bulk_edit
from documents.bulk_download import ArchiveOnlyStrategy
from documents.bulk_download import OriginalAndArchiveStrategy
from documents.bulk_download import OriginalsOnlyStrategy
from documents.filters import CorrespondentFilterSet
from documents.filters import DocumentTypeFilterSet
from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import PaperlessTask
from documents.permissions import ViewDocumentsPermissions
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids
from documents.permissions import set_permissions_for_objects
from documents.serialisers.bulk_edit import BulkDownloadSerializer
from documents.serialisers.bulk_edit import BulkEditObjectsSerializer
from documents.serialisers.bulk_edit import BulkEditSerializer
from documents.serialisers.bulk_edit import DeleteDocumentsSerializer
from documents.serialisers.bulk_edit import EditPdfDocumentsSerializer
from documents.serialisers.bulk_edit import MergeDocumentsAsVersionsSerializer
from documents.serialisers.bulk_edit import MergeDocumentsSerializer
from documents.serialisers.bulk_edit import RemovePasswordDocumentsSerializer
from documents.serialisers.bulk_edit import ReprocessDocumentsSerializer
from documents.serialisers.bulk_edit import RotateDocumentsSerializer
from documents.versioning import get_latest_version_for_root
from documents.versioning import get_root_document
from .base import DocumentOperationPermissionMixin
from .base import DocumentSelectionMixin
from .base import PassUserMixin
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
logger = logging.getLogger("paperless.api")
@extend_schema_view(
post=extend_schema(
operation_id="bulk_edit",
description="Perform a bulk edit operation on a list of documents",
external_docs={
"description": "Further documentation",
"url": "https://docs.paperless-ngx.com/api/#bulk-editing",
},
responses={
200: inline_serializer(
name="BulkEditDocumentsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class BulkEditView(DocumentOperationPermissionMixin):
MODIFIED_FIELD_BY_METHOD = {
"set_correspondent": "correspondent",
"set_document_type": "document_type",
"set_storage_path": "storage_path",
"add_tag": "tags",
"remove_tag": "tags",
"modify_tags": "tags",
"modify_custom_fields": "custom_fields",
"set_permissions": None,
"delete": "deleted_at",
# These operations create new documents/versions no longer altering
# fields on the selected document in place
"rotate": None,
"delete_pages": None,
"split": None,
"merge": None,
"edit_pdf": None,
"reprocess": "checksum",
"remove_password": None,
}
serializer_class = BulkEditSerializer
@staticmethod
def _snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
"""
Returns each document's current value of field, for the audit log.
Tags and custom fields are one row per value, so they are gathered
into a sorted list of pks per document (empty when there are none).
Reading them through Document.values() instead would join those rows
and return one arbitrary value per document.
"""
if field == "tags":
rows = (
Document.tags.through.objects.filter(document_id__in=doc_ids)
.order_by("tag_id")
.values_list("document_id", "tag_id")
)
elif field == "custom_fields":
rows = (
CustomFieldInstance.objects.filter(document_id__in=doc_ids)
.order_by("pk")
.values_list("document_id", "pk")
)
else:
return dict(
Document.objects.filter(pk__in=doc_ids).values_list("pk", field),
)
values: dict[int, list[int]] = {doc_id: [] for doc_id in doc_ids}
for doc_id, pk in rows:
values[doc_id].append(pk)
return values
def post(self, request, *args, **kwargs):
request_method = request.data.get("method")
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
# TODO: remove this and related backwards compatibility code when API v9 is dropped
if request_method in BulkEditSerializer.LEGACY_DOCUMENT_ACTION_METHODS:
endpoint = BulkEditSerializer.MOVED_DOCUMENT_ACTION_ENDPOINTS[
request_method
]
logger.warning(
"Deprecated bulk_edit method '%s' requested on API version %s. "
"Use '%s' instead.",
request_method,
api_version,
endpoint,
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = self.request.user
method = serializer.validated_data.get("method")
parameters = serializer.validated_data.get("parameters")
from_webui = serializer.validated_data.get("from_webui", False)
documents = self._resolve_document_ids(
user=user,
validated_data=serializer.validated_data,
)
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
parameters["user"] = user
if method.__name__ in self.METHOD_NAMES_REQUIRING_TRIGGER_SOURCE:
parameters["trigger_source"] = (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
)
if not self._has_document_permissions(
user=user,
documents=documents,
method=method,
parameters=parameters,
):
return HttpResponseForbidden("Insufficient permissions")
try:
modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None)
if settings.AUDIT_LOG_ENABLED and modified_field:
old_values = self._snapshot_field(documents, modified_field)
result = method(documents, **parameters)
if settings.AUDIT_LOG_ENABLED and modified_field:
new_values = self._snapshot_field(documents, modified_field)
for doc in Document.objects.filter(pk__in=documents):
LogEntry.objects.log_create(
instance=doc,
changes={
modified_field: [
old_values[doc.pk],
new_values[doc.pk],
],
},
action=LogEntry.Action.UPDATE,
actor=user,
additional_data={
"reason": f"Bulk edit: {method.__name__}",
},
)
return Response({"result": result})
except Exception as e:
logger.warning(f"An error occurred performing bulk edit: {e!s}")
return HttpResponseBadRequest(
"Error performing bulk edit, check logs for more detail.",
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_rotate",
description="Rotate one or more documents",
responses={
200: inline_serializer(
name="RotateDocumentsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class RotateDocumentsView(DocumentOperationPermissionMixin):
serializer_class = RotateDocumentsSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
return self._execute_document_action(
method=bulk_edit.rotate,
validated_data=serializer.validated_data,
operation_label="document rotate",
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_merge",
description="Merge selected documents into a new document",
responses={
200: inline_serializer(
name="MergeDocumentsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class MergeDocumentsView(DocumentOperationPermissionMixin):
serializer_class = MergeDocumentsSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
return self._execute_document_action(
method=bulk_edit.merge,
validated_data=serializer.validated_data,
operation_label="document merge",
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_merge_as_versions",
description="Merge selected documents as versions of a chosen root document",
responses={
200: inline_serializer(
name="MergeDocumentsAsVersionsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
serializer_class = MergeDocumentsAsVersionsSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
return self._execute_document_action(
method=bulk_edit.merge_as_versions,
validated_data=serializer.validated_data,
operation_label="document merge as versions",
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_delete",
description="Move selected documents to trash",
responses={
200: inline_serializer(
name="DeleteDocumentsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class DeleteDocumentsView(DocumentOperationPermissionMixin):
serializer_class = DeleteDocumentsSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
return self._execute_document_action(
method=bulk_edit.delete,
validated_data=serializer.validated_data,
operation_label="document delete",
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_reprocess",
description="Reprocess selected documents",
responses={
200: inline_serializer(
name="ReprocessDocumentsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class ReprocessDocumentsView(DocumentOperationPermissionMixin):
serializer_class = ReprocessDocumentsSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
return self._execute_document_action(
method=bulk_edit.reprocess,
validated_data=serializer.validated_data,
operation_label="document reprocess",
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_edit_pdf",
description="Perform PDF edit operations on a selected document",
responses={
200: inline_serializer(
name="EditPdfDocumentsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class EditPdfDocumentsView(DocumentOperationPermissionMixin):
serializer_class = EditPdfDocumentsSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
return self._execute_document_action(
method=bulk_edit.edit_pdf,
validated_data=serializer.validated_data,
operation_label="PDF edit",
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_remove_password",
description="Remove password protection from selected PDFs",
responses={
200: inline_serializer(
name="RemovePasswordDocumentsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class RemovePasswordDocumentsView(DocumentOperationPermissionMixin):
serializer_class = RemovePasswordDocumentsSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
return self._execute_document_action(
method=bulk_edit.remove_password,
validated_data=serializer.validated_data,
operation_label="password removal",
)
@extend_schema_view(
post=extend_schema(
operation_id="bulk_download",
description="Download multiple documents as a ZIP archive.",
responses={
(HTTPStatus.OK, "application/zip"): OpenApiTypes.BINARY,
HTTPStatus.FORBIDDEN: None,
},
),
)
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
serializer_class = BulkDownloadSerializer
parser_classes = (parsers.JSONParser,)
def post(self, request, format=None):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
ids = self._resolve_document_ids(
user=request.user,
validated_data=serializer.validated_data,
)
documents = Document.objects.filter(pk__in=ids)
versioned_documents = []
compression = serializer.validated_data.get("compression")
content = serializer.validated_data.get("content")
follow_filename_format = serializer.validated_data.get("follow_formatting")
permitted_ids = set(permitted_document_ids(request.user))
for document in documents:
root_doc = get_root_document(document)
if root_doc.pk not in permitted_ids:
return HttpResponseForbidden("Insufficient permissions")
versioned_documents.append(
get_latest_version_for_root(
root_doc,
),
)
if content == "both":
strategy_class = OriginalAndArchiveStrategy
elif content == "originals":
strategy_class = OriginalsOnlyStrategy
else:
strategy_class = ArchiveOnlyStrategy
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(
dir=settings.SCRATCH_DIR,
suffix="-compressed-archive",
)
os.close(fd)
temp_path = Path(temp_name)
try:
with zipfile.ZipFile(temp_path, "w", compression) as zipf:
strategy = strategy_class(
zipf,
follow_formatting=follow_filename_format,
)
for document in versioned_documents:
strategy.add_document(document)
f = temp_path.open("rb")
temp_path.unlink()
except Exception:
temp_path.unlink(missing_ok=True)
raise
return FileResponse(
f,
as_attachment=True,
filename="documents.zip",
content_type="application/zip",
)
@extend_schema_view(
post=extend_schema(
operation_id="bulk_edit_objects",
description="Perform a bulk edit operation on a list of objects",
external_docs={
"description": "Further documentation",
"url": "https://docs.paperless-ngx.com/api/#objects",
},
responses={
200: inline_serializer(
name="BulkEditResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class BulkEditObjectsView(PassUserMixin):
permission_classes = (IsAuthenticated,)
serializer_class = BulkEditObjectsSerializer
parser_classes = (parsers.JSONParser,)
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = self.request.user
object_type = serializer.validated_data.get("object_type")
object_ids = serializer.validated_data.get("objects")
apply_to_all = serializer.validated_data.get("all")
object_class = serializer.get_object_class(object_type)
operation = serializer.validated_data.get("operation")
model_name = object_class._meta.model_name
perm_codename = (
f"change_{model_name}"
if operation == "set_permissions"
else f"delete_{model_name}"
)
if apply_to_all:
# Support all to avoid sending large lists of ids for bulk operations, with optional filters
filters = serializer.validated_data.get("filters") or {}
filterset_class = {
"tags": TagFilterSet,
"correspondents": CorrespondentFilterSet,
"document_types": DocumentTypeFilterSet,
"storage_paths": StoragePathFilterSet,
}[object_type]
user_permitted_objects = object_class.objects.filter(
id__in=permitted_object_ids(user, object_class, perm_codename),
)
objs = filterset_class(
data=filters,
queryset=user_permitted_objects,
).qs
if object_type == "tags":
editable_ids = set(user_permitted_objects.values_list("pk", flat=True))
all_ids = set(objs.values_list("pk", flat=True))
for tag in objs:
all_ids.update(
descendant.pk
for descendant in tag.get_descendants()
if descendant.pk in editable_ids
)
objs = object_class.objects.filter(pk__in=all_ids)
objs = objs.select_related("owner")
object_ids = list(objs.values_list("pk", flat=True))
else:
objs = object_class.objects.select_related("owner").filter(
pk__in=object_ids,
)
if not user.is_superuser:
perm = f"documents.{perm_codename}"
# Limited to the owner (or unowned), same as documents, see BulkEditView
has_perms = (
user.has_perm(perm)
and not objs.exclude(
Q(owner=user) | Q(owner__isnull=True),
).exists()
)
if not has_perms:
return HttpResponseForbidden("Insufficient permissions")
if operation == "set_permissions":
permissions = serializer.validated_data.get("permissions")
owner = serializer.validated_data.get("owner")
merge = serializer.validated_data.get("merge")
try:
qs = object_class.objects.filter(id__in=object_ids)
# if merge is true, we dont want to remove the owner
if "owner" in serializer.validated_data and (
not merge or (merge and owner is not None)
):
# if merge is true, we dont want to overwrite the owner
qs_owner_update = qs.filter(owner__isnull=True) if merge else qs
qs_owner_update.update(owner=owner)
if "permissions" in serializer.validated_data:
set_permissions_for_objects(
permissions=permissions,
model=object_class,
pks=qs.values_list("pk", flat=True),
merge=merge,
)
except Exception as e:
logger.warning(
f"An error occurred performing bulk permissions edit: {e!s}",
)
return HttpResponseBadRequest(
"Error performing bulk permissions edit, check logs for more detail.",
)
elif operation == "delete":
objs.delete()
return Response({"result": "OK"})
+81
View File
@@ -0,0 +1,81 @@
from typing import Any
from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from django.http import StreamingHttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import ensure_csrf_cookie
from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from documents.models import Document
from documents.permissions import ViewDocumentsPermissions
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids
from documents.permissions import user_is_unrestricted
from paperless.config import AIConfig
from paperless_ai.ai_classifier import get_llm_output_language
from paperless_ai.chat import stream_chat_with_documents
class ChatStreamingSerializer(serializers.Serializer[dict[str, Any]]):
q = serializers.CharField(required=True, max_length=4000)
document_id = serializers.IntegerField(required=False, allow_null=True)
@method_decorator(
[
ensure_csrf_cookie,
cache_control(no_cache=True),
],
name="dispatch",
)
class ChatStreamingView(GenericAPIView[Any]):
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
serializer_class = ChatStreamingSerializer
def post(self, request, *args, **kwargs):
ai_config = AIConfig()
if not ai_config.ai_enabled:
return HttpResponseBadRequest("AI is required for this feature")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
question = serializer.validated_data["q"]
doc_id = serializer.validated_data.get("document_id")
if doc_id:
try:
document = Document.objects.get(id=doc_id)
except Document.DoesNotExist:
return HttpResponseBadRequest("Document not found")
if not has_perms_owner_aware(request.user, "view_document", document):
return HttpResponseForbidden("Insufficient permissions")
documents = Document.objects.filter(pk=document.pk)
unrestricted = False
else:
documents = Document.objects.filter(
id__in=permitted_document_ids(request.user),
)
unrestricted = user_is_unrestricted(request.user)
output_language = get_llm_output_language(
ai_config=ai_config,
user=request.user,
)
response = StreamingHttpResponse(
stream_chat_with_documents(
query_str=question,
documents=documents,
unrestricted=unrestricted,
output_language=output_language,
),
content_type="text/event-stream",
)
return response
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
from pathlib import Path
import magic
from django.conf import settings
from django.http import FileResponse
from django.http import Http404
from django.http import HttpRequest
from django.utils.translation import get_language
from django.views.generic import TemplateView
from paperless.models import ApplicationConfiguration
class IndexView(TemplateView):
template_name = "index.html"
def get_frontend_language(self):
if hasattr(
self.request.user,
"ui_settings",
) and self.request.user.ui_settings.settings.get("language"):
lang = self.request.user.ui_settings.settings.get("language")
else:
lang = get_language()
# This is here for the following reason:
# Django identifies languages in the form "en-us"
# However, angular generates locales as "en-US".
# this translates between these two forms.
if "-" in lang:
first = lang[: lang.index("-")]
second = lang[lang.index("-") + 1 :]
return f"{first}-{second.upper()}"
return lang
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["cookie_prefix"] = settings.COOKIE_PREFIX
context["username"] = self.request.user.username
context["full_name"] = self.request.user.get_full_name()
context["styles_css"] = f"frontend/{self.get_frontend_language()}/styles.css"
context["polyfills_js"] = (
f"frontend/{self.get_frontend_language()}/polyfills.js"
)
context["main_js"] = f"frontend/{self.get_frontend_language()}/main.js"
context["webmanifest"] = (
f"frontend/{self.get_frontend_language()}/manifest.webmanifest"
)
context["apple_touch_icon"] = (
f"frontend/{self.get_frontend_language()}/apple-touch-icon.png"
)
return context
def serve_logo(request: HttpRequest, filename: str | None = None) -> FileResponse:
"""
Serves the configured logo file with Content-Disposition: attachment.
Prevents inline execution of SVGs. See GHSA-6p53-hqqw-8j62
"""
config = ApplicationConfiguration.objects.first()
app_logo = config.app_logo
if app_logo:
path = Path(app_logo.path)
logo_name = app_logo.name
else:
if not settings.APP_LOGO:
raise Http404("No logo configured")
logo_root = (Path(settings.MEDIA_ROOT) / "logo").resolve()
path = (Path(settings.MEDIA_ROOT) / settings.APP_LOGO.lstrip("/")).resolve()
if not path.is_relative_to(logo_root) or not path.is_file():
raise Http404("Configured logo not found")
logo_name = path.name
content_type = magic.from_file(path, mime=True) or "application/octet-stream"
logo_file = app_logo.open("rb") if app_logo else path.open("rb")
return FileResponse(
logo_file,
content_type=content_type,
filename=logo_name,
as_attachment=True,
)
+100
View File
@@ -0,0 +1,100 @@
from collections import deque
from pathlib import Path
from django.conf import settings
from django.http import Http404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from documents.permissions import PaperlessAdminPermissions
@extend_schema_view(
list=extend_schema(
description="Logs view",
responses={
(200, "application/json"): serializers.ListSerializer(
child=serializers.CharField(),
),
},
),
retrieve=extend_schema(
description="Single log view",
operation_id="retrieve_log",
parameters=[
OpenApiParameter(
name="id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
),
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Return only the last N entries from the log file",
required=False,
),
],
responses={
(200, "application/json"): serializers.ListSerializer(
child=serializers.CharField(),
),
(404, "application/json"): None,
},
),
)
class LogViewSet(ViewSet):
permission_classes = (IsAuthenticated, PaperlessAdminPermissions)
ALLOWED_LOG_FILES = {
"paperless": "paperless.log",
"mail": "mail.log",
"celery": "celery.log",
}
def get_log_file(self, log_key: str) -> Path:
return Path(settings.LOGGING_DIR) / self.ALLOWED_LOG_FILES[log_key]
def retrieve(self, request, *args, **kwargs):
log_key = kwargs.get("pk")
if log_key not in self.ALLOWED_LOG_FILES:
raise Http404
log_file = self.get_log_file(log_key)
if not log_file.is_file():
raise Http404
limit_param = request.query_params.get("limit")
if limit_param is not None:
try:
limit = int(limit_param)
except (TypeError, ValueError):
raise ValidationError({"limit": "Must be a positive integer"})
if limit < 1:
raise ValidationError({"limit": "Must be a positive integer"})
else:
limit = None
with log_file.open() as f:
if limit is None:
lines = [line.rstrip() for line in f.readlines()]
else:
lines = [line.rstrip() for line in deque(f, maxlen=limit)]
return Response(lines)
def list(self, request, *args, **kwargs):
existing_logs = [
log_key
for log_key in self.ALLOWED_LOG_FILES
if self.get_log_file(log_key).is_file()
]
return Response(existing_logs)
+291
View File
@@ -0,0 +1,291 @@
from http import HTTPStatus
from pathlib import Path
from django.conf import settings
from django.db.models import Max
from django.db.models.functions import Lower
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from documents import bulk_edit
from documents.file_handling import format_filename
from documents.filters import CorrespondentFilterSet
from documents.filters import CustomFieldFilterSet
from documents.filters import DocumentTypeFilterSet
from documents.filters import PermittedObjectsFilter
from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet
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 PaperlessObjectPermissions
from documents.permissions import ViewDocumentsPermissions
from documents.permissions import annotate_document_count_for_related_queryset
from documents.permissions import permitted_object_ids
from documents.schema import generate_object_with_permissions_schema
from documents.serialisers.metadata import CorrespondentSerializer
from documents.serialisers.metadata import CustomFieldSerializer
from documents.serialisers.metadata import DocumentTypeSerializer
from documents.serialisers.metadata import StoragePathSerializer
from documents.serialisers.metadata import StoragePathTestSerializer
from documents.serialisers.metadata import TagSerializer
from documents.tasks import update_document_parent_tags
from paperless.views import StandardPagination
from .base import PermissionsAwareDocumentCountMixin
@extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer))
class CorrespondentViewSet(
PermissionsAwareDocumentCountMixin,
ModelViewSet[Correspondent],
):
model = Correspondent
queryset = Correspondent.objects.select_related("owner").order_by(Lower("name"))
serializer_class = CorrespondentSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
)
filterset_class = CorrespondentFilterSet
ordering_fields = (
"name",
"matching_algorithm",
"match",
"document_count",
"last_correspondence",
)
def list(self, request, *args, **kwargs):
if request.query_params.get("last_correspondence", None):
self.queryset = self.queryset.annotate(
last_correspondence=Max(
"documents__created",
filter=self.get_document_count_filter(),
),
)
return super().list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
self.queryset = self.queryset.annotate(
last_correspondence=Max(
"documents__created",
filter=self.get_document_count_filter(),
),
)
return super().retrieve(request, *args, **kwargs)
@extend_schema_view(**generate_object_with_permissions_schema(TagSerializer))
class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
model = Tag
serializer_class = TagSerializer
document_count_through = Document.tags.through
document_count_source_field = "tag_id"
queryset = Tag.objects.select_related("owner").order_by(
Lower("name"),
)
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
)
filterset_class = TagFilterSet
ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
def get_serializer_context(self):
context = super().get_serializer_context()
context["document_count_filter"] = self.get_document_count_filter()
if hasattr(self, "_children_map"):
context["children_map"] = self._children_map
return context
def list(self, request, *args, **kwargs):
"""
Build a children map once to avoid per-parent queries in the serializer.
"""
queryset = self.filter_queryset(self.get_queryset())
ordering = OrderingFilter().get_ordering(request, queryset, self) or (
Lower("name"),
)
queryset = queryset.order_by(*ordering)
all_tags = list(queryset)
descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()}
if descendant_pks:
user = getattr(getattr(self, "request", None), "user", None)
children_source = list(
annotate_document_count_for_related_queryset(
Tag.objects.filter(
pk__in=descendant_pks | {t.pk for t in all_tags},
)
.filter(pk__in=permitted_object_ids(user, Tag, "view_tag"))
.select_related("owner"),
through_model=self.document_count_through,
related_object_field=self._get_document_count_source_field(),
user=user,
).order_by(*ordering),
)
else:
children_source = all_tags
children_map = {}
for tag in children_source:
children_map.setdefault(tag.tn_parent_id, []).append(tag)
self._children_map = children_map
page = self.paginate_queryset(queryset)
serializer = self.get_serializer(page, many=True)
response = self.get_paginated_response(serializer.data)
response.data["display_count"] = len(children_source)
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
if descendant_pks and api_version < 10:
# Include children in the "all" field, if needed
response.data["all"] = [tag.pk for tag in children_source]
return response
def perform_update(self, serializer):
old_parent = self.get_object().get_parent()
tag = serializer.save()
new_parent = tag.get_parent()
if new_parent and old_parent != new_parent:
update_document_parent_tags(tag, new_parent)
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
class DocumentTypeViewSet(
PermissionsAwareDocumentCountMixin,
ModelViewSet[DocumentType],
):
model = DocumentType
queryset = DocumentType.objects.select_related("owner").order_by(Lower("name"))
serializer_class = DocumentTypeSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
)
filterset_class = DocumentTypeFilterSet
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
@extend_schema_view(
**generate_object_with_permissions_schema(StoragePathSerializer),
test=extend_schema(
operation_id="storage_paths_test",
description="Test a storage path template against a document.",
request=StoragePathTestSerializer,
responses={
(HTTPStatus.OK, "application/json"): OpenApiTypes.STR,
},
),
)
class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[StoragePath]):
model = StoragePath
queryset = StoragePath.objects.select_related("owner").order_by(
Lower("name"),
)
serializer_class = StoragePathSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
)
filterset_class = StoragePathFilterSet
ordering_fields = ("name", "path", "matching_algorithm", "match", "document_count")
def get_permissions(self):
if self.action == "test":
# Test action does not require object level permissions
self.permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
return super().get_permissions()
def destroy(self, request, *args, **kwargs):
"""
When a storage path is deleted, see if documents
using it require a rename/move
"""
instance = self.get_object()
doc_ids = [doc.id for doc in instance.documents.all()]
# perform the deletion so renaming/moving can happen
response = super().destroy(request, *args, **kwargs)
if doc_ids:
bulk_edit.bulk_update_documents.apply_async(
kwargs={"document_ids": doc_ids},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return response
@action(methods=["post"], detail=False)
def test(self, request):
"""
Test storage path against a document
"""
serializer = StoragePathTestSerializer(
data=request.data,
context={"request": request},
)
serializer.is_valid(raise_exception=True)
document = serializer.validated_data.get("document")
path = serializer.validated_data.get("path")
result = format_filename(document, path)
if result:
extension = (
Path(str(document.filename)).suffix if document.filename else ""
) or document.file_type
result_path = Path(result)
result = str(result_path.with_name(f"{result_path.name}{extension}"))
return Response(result)
class CustomFieldViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[CustomField]):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = CustomFieldSerializer
pagination_class = StandardPagination
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
)
filterset_class = CustomFieldFilterSet
model = CustomField
document_count_through = CustomFieldInstance
document_count_source_field = "field_id"
queryset = CustomField.objects.all().order_by("name")
+31
View File
@@ -0,0 +1,31 @@
from drf_spectacular.utils import extend_schema_view
from rest_framework.filters import OrderingFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet
from documents.filters import PermittedObjectsFilter
from documents.models import SavedView
from documents.permissions import PaperlessObjectPermissions
from documents.schema import generate_object_with_permissions_schema
from documents.serialisers.saved_views import SavedViewSerializer
from paperless.views import StandardPagination
from .base import BulkPermissionMixin
from .base import PassUserMixin
@extend_schema_view(**generate_object_with_permissions_schema(SavedViewSerializer))
class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet[SavedView]):
model = SavedView
queryset = SavedView.objects.select_related("owner").prefetch_related(
"filter_rules",
)
serializer_class = SavedViewSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
OrderingFilter,
PermittedObjectsFilter,
)
ordering_fields = ("name",)
+601
View File
@@ -0,0 +1,601 @@
from typing import Any
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.db.models import Case
from django.db.models import Count
from django.db.models import IntegerField
from django.db.models import Max
from django.db.models import Sum
from django.db.models import When
from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from drf_spectacular.utils import inline_serializer
from rest_framework import parsers
from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import Document
from documents.models import DocumentType
from documents.models import SavedView
from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
from documents.permissions import ViewDocumentsPermissions
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import has_global_statistics_permission
from documents.permissions import permitted_document_ids
from documents.serialisers.base import DocumentSelectionSerializer
from documents.serialisers.documents import DocumentSerializer
from documents.serialisers.documents import SearchResultSerializer
from documents.serialisers.metadata import CorrespondentSerializer
from documents.serialisers.metadata import CustomFieldSerializer
from documents.serialisers.metadata import DocumentTypeSerializer
from documents.serialisers.metadata import StoragePathSerializer
from documents.serialisers.metadata import TagSerializer
from documents.serialisers.saved_views import SavedViewSerializer
from documents.serialisers.workflows import WorkflowSerializer
from documents.versioning import annotate_effective_content
from paperless.serialisers import GroupSerializer
from paperless.serialisers import UserSerializer
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
from paperless_mail.serialisers import MailAccountSerializer
from paperless_mail.serialisers import MailRuleSerializer
from .base import _MAX_QUERY_LENGTH
from .base import DocumentSelectionMixin
from .base import PassUserMixin
@extend_schema_view(
post=extend_schema(
description="Get selection data for the selected documents",
responses={
(200, "application/json"): inline_serializer(
name="SelectionData",
fields={
"selected_correspondents": serializers.ListSerializer(
child=inline_serializer(
name="CorrespondentCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
"selected_tags": serializers.ListSerializer(
child=inline_serializer(
name="TagCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
"selected_document_types": serializers.ListSerializer(
child=inline_serializer(
name="DocumentTypeCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
"selected_storage_paths": serializers.ListSerializer(
child=inline_serializer(
name="StoragePathCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
"selected_custom_fields": serializers.ListSerializer(
child=inline_serializer(
name="CustomFieldCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
},
),
},
),
)
class SelectionDataView(DocumentSelectionMixin, GenericAPIView[Any]):
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
serializer_class = DocumentSelectionSerializer
parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
def post(self, request, format=None):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
ids = self._resolve_document_ids(
user=request.user,
validated_data=serializer.validated_data,
)
permitted_documents = Document.objects.filter(
id__in=permitted_document_ids(request.user),
)
if permitted_documents.filter(pk__in=ids).count() != len(ids):
return HttpResponseForbidden("Insufficient permissions")
correspondents = Correspondent.objects.annotate(
document_count=Count(
Case(When(documents__id__in=ids, then=1), output_field=IntegerField()),
),
)
tags = Tag.objects.annotate(
document_count=Count(
Case(When(documents__id__in=ids, then=1), output_field=IntegerField()),
),
)
types = DocumentType.objects.annotate(
document_count=Count(
Case(When(documents__id__in=ids, then=1), output_field=IntegerField()),
),
)
storage_paths = StoragePath.objects.annotate(
document_count=Count(
Case(When(documents__id__in=ids, then=1), output_field=IntegerField()),
),
)
custom_fields = CustomField.objects.annotate(
document_count=Count(
Case(
When(
fields__document__id__in=ids,
then=1,
),
output_field=IntegerField(),
),
),
)
r = Response(
{
"selected_correspondents": [
{"id": t.id, "document_count": t.document_count}
for t in correspondents
],
"selected_tags": [
{"id": t.id, "document_count": t.document_count} for t in tags
],
"selected_document_types": [
{"id": t.id, "document_count": t.document_count} for t in types
],
"selected_storage_paths": [
{"id": t.id, "document_count": t.document_count}
for t in storage_paths
],
"selected_custom_fields": [
{"id": t.id, "document_count": t.document_count}
for t in custom_fields
],
},
)
return r
@extend_schema_view(
get=extend_schema(
description="Get a list of all available tags",
parameters=[
OpenApiParameter(
name="term",
required=False,
type=str,
description="Term to search for",
),
OpenApiParameter(
name="limit",
required=False,
type=int,
description="Number of completions to return",
),
],
responses={
(200, "application/json"): serializers.ListSerializer(
child=serializers.CharField(),
),
},
),
)
class SearchAutoCompleteView(GenericAPIView[Any]):
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
def get(self, request, format=None):
user = self.request.user if hasattr(self.request, "user") else None
if "term" in request.query_params:
term = request.query_params["term"].strip()
else:
return HttpResponseBadRequest("Term required")
if "limit" in request.query_params:
limit = int(request.query_params["limit"])
if limit <= 0:
return HttpResponseBadRequest("Invalid limit")
else:
limit = 10
from documents.search import get_backend
return Response(get_backend().autocomplete(term, limit, user))
@extend_schema_view(
get=extend_schema(
description="Global search",
parameters=[
OpenApiParameter(
name="query",
required=True,
type=str,
description="Query to search for",
),
OpenApiParameter(
name="db_only",
required=False,
type=bool,
description="Search only the database",
),
],
responses={
(200, "application/json"): inline_serializer(
name="SearchResult",
fields={
"total": serializers.IntegerField(),
"documents": DocumentSerializer(many=True),
"saved_views": SavedViewSerializer(many=True),
"tags": TagSerializer(many=True),
"correspondents": CorrespondentSerializer(many=True),
"document_types": DocumentTypeSerializer(many=True),
"storage_paths": StoragePathSerializer(many=True),
"users": UserSerializer(many=True),
"groups": GroupSerializer(many=True),
"mail_rules": MailRuleSerializer(many=True),
"mail_accounts": MailAccountSerializer(many=True),
"workflows": WorkflowSerializer(many=True),
"custom_fields": CustomFieldSerializer(many=True),
},
),
},
),
)
class GlobalSearchView(PassUserMixin):
permission_classes = (IsAuthenticated,)
serializer_class = SearchResultSerializer
def get(self, request, *args, **kwargs):
from documents.search import SearchMode
from documents.search import get_backend
query = request.query_params.get("query", None)
if query is None:
return HttpResponseBadRequest("Query required")
if len(query) < 3:
return HttpResponseBadRequest("Query must be at least 3 characters")
if len(query) > _MAX_QUERY_LENGTH:
return HttpResponseBadRequest(
f"Query must be at most {_MAX_QUERY_LENGTH} characters",
)
db_only = request.query_params.get("db_only", False)
OBJECT_LIMIT = 3
docs = []
if request.user.has_perm("documents.view_document"):
# Never more than OBJECT_LIMIT rows come back here, so annotating
# is cheap -- and without it these results show the root
# document's superseded content.
all_docs = annotate_effective_content(
Document.objects.filter(
id__in=permitted_document_ids(request.user),
),
)
if db_only:
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
else:
user = None if request.user.is_superuser else request.user
matching_ids = get_backend().search_ids(
query,
user=user,
search_mode=SearchMode.TEXT,
limit=OBJECT_LIMIT * 3,
)
docs_by_id = all_docs.in_bulk(matching_ids)
docs = [
docs_by_id[doc_id]
for doc_id in matching_ids
if doc_id in docs_by_id
][:OBJECT_LIMIT]
saved_views = (
get_objects_for_user_owner_aware(
request.user,
"view_savedview",
SavedView,
).filter(name__icontains=query)
if request.user.has_perm("documents.view_savedview")
else []
)
saved_views = saved_views[:OBJECT_LIMIT]
tags = (
get_objects_for_user_owner_aware(request.user, "view_tag", Tag).filter(
name__icontains=query,
)
if request.user.has_perm("documents.view_tag")
else []
)
tags = tags[:OBJECT_LIMIT]
correspondents = (
get_objects_for_user_owner_aware(
request.user,
"view_correspondent",
Correspondent,
).filter(name__icontains=query)
if request.user.has_perm("documents.view_correspondent")
else []
)
correspondents = correspondents[:OBJECT_LIMIT]
document_types = (
get_objects_for_user_owner_aware(
request.user,
"view_documenttype",
DocumentType,
).filter(name__icontains=query)
if request.user.has_perm("documents.view_documenttype")
else []
)
document_types = document_types[:OBJECT_LIMIT]
storage_paths = (
get_objects_for_user_owner_aware(
request.user,
"view_storagepath",
StoragePath,
).filter(name__icontains=query)
if request.user.has_perm("documents.view_storagepath")
else []
)
storage_paths = storage_paths[:OBJECT_LIMIT]
users = (
User.objects.filter(username__icontains=query)
if request.user.has_perm("auth.view_user")
else []
)
users = users[:OBJECT_LIMIT]
groups = (
Group.objects.filter(name__icontains=query)
if request.user.has_perm("auth.view_group")
else []
)
groups = groups[:OBJECT_LIMIT]
mail_rules = (
get_objects_for_user_owner_aware(
request.user,
"view_mailrule",
MailRule,
).filter(name__icontains=query)
if request.user.has_perm("paperless_mail.view_mailrule")
else []
)
mail_rules = mail_rules[:OBJECT_LIMIT]
mail_accounts = (
get_objects_for_user_owner_aware(
request.user,
"view_mailaccount",
MailAccount,
).filter(name__icontains=query)
if request.user.has_perm("paperless_mail.view_mailaccount")
else []
)
mail_accounts = mail_accounts[:OBJECT_LIMIT]
workflows = (
Workflow.objects.filter(name__icontains=query)
if request.user.has_perm("documents.view_workflow")
else []
)
workflows = workflows[:OBJECT_LIMIT]
custom_fields = (
CustomField.objects.filter(name__icontains=query)
if request.user.has_perm("documents.view_customfield")
else []
)
custom_fields = custom_fields[:OBJECT_LIMIT]
context = {
"request": request,
}
docs_serializer = DocumentSerializer(docs, many=True, context=context)
saved_views_serializer = SavedViewSerializer(
saved_views,
many=True,
context=context,
)
tags_serializer = TagSerializer(tags, many=True, context=context)
correspondents_serializer = CorrespondentSerializer(
correspondents,
many=True,
context=context,
)
document_types_serializer = DocumentTypeSerializer(
document_types,
many=True,
context=context,
)
storage_paths_serializer = StoragePathSerializer(
storage_paths,
many=True,
context=context,
)
users_serializer = UserSerializer(users, many=True, context=context)
groups_serializer = GroupSerializer(groups, many=True, context=context)
mail_rules_serializer = MailRuleSerializer(
mail_rules,
many=True,
context=context,
)
mail_accounts_serializer = MailAccountSerializer(
mail_accounts,
many=True,
context=context,
)
workflows_serializer = WorkflowSerializer(workflows, many=True, context=context)
custom_fields_serializer = CustomFieldSerializer(
custom_fields,
many=True,
context=context,
)
return Response(
{
"total": len(docs)
+ len(saved_views)
+ len(tags)
+ len(correspondents)
+ len(document_types)
+ len(storage_paths)
+ len(users)
+ len(groups)
+ len(mail_rules)
+ len(mail_accounts)
+ len(workflows)
+ len(custom_fields),
"documents": docs_serializer.data,
"saved_views": saved_views_serializer.data,
"tags": tags_serializer.data,
"correspondents": correspondents_serializer.data,
"document_types": document_types_serializer.data,
"storage_paths": storage_paths_serializer.data,
"users": users_serializer.data,
"groups": groups_serializer.data,
"mail_rules": mail_rules_serializer.data,
"mail_accounts": mail_accounts_serializer.data,
"workflows": workflows_serializer.data,
"custom_fields": custom_fields_serializer.data,
},
)
@extend_schema_view(
get=extend_schema(
description="Get statistics for the current user",
responses={
(200, "application/json"): OpenApiTypes.OBJECT,
},
),
)
class StatisticsView(GenericAPIView[Any]):
permission_classes = (IsAuthenticated,)
def get(self, request, format=None):
user = request.user if request.user is not None else None
can_view_global_stats = has_global_statistics_permission(user) or user is None
documents = (
Document.objects.all()
if can_view_global_stats
else Document.objects.filter(id__in=permitted_document_ids(user))
).filter(root_document__isnull=True)
tags = (
Tag.objects.all()
if can_view_global_stats
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
).only("id", "is_inbox_tag")
correspondent_count = (
Correspondent.objects.count()
if can_view_global_stats
else get_objects_for_user_owner_aware(
user,
"documents.view_correspondent",
Correspondent,
).count()
)
document_type_count = (
DocumentType.objects.count()
if can_view_global_stats
else get_objects_for_user_owner_aware(
user,
"documents.view_documenttype",
DocumentType,
).count()
)
storage_path_count = (
StoragePath.objects.count()
if can_view_global_stats
else get_objects_for_user_owner_aware(
user,
"documents.view_storagepath",
StoragePath,
).count()
)
inbox_tag_pks = list(
tags.filter(is_inbox_tag=True).values_list("pk", flat=True),
)
documents_inbox = (
documents.filter(tags__id__in=inbox_tag_pks).values("id").distinct().count()
if inbox_tag_pks
else None
)
# Single SQL request for document stats and mime type counts
mime_type_stats = list(
documents.values("mime_type")
.annotate(
mime_type_count=Count("id"),
mime_type_chars=Sum("content_length"),
)
.order_by("-mime_type_count"),
)
# Calculate totals from grouped results
documents_total = sum(row["mime_type_count"] for row in mime_type_stats)
character_count = sum(row["mime_type_chars"] or 0 for row in mime_type_stats)
document_file_type_counts = [
{"mime_type": row["mime_type"], "mime_type_count": row["mime_type_count"]}
for row in mime_type_stats
]
current_asn = Document.objects.aggregate(
Max("archive_serial_number", default=0),
).get(
"archive_serial_number__max",
)
return Response(
{
"documents_total": documents_total,
"documents_inbox": documents_inbox,
"inbox_tag": (
inbox_tag_pks[0] if inbox_tag_pks else None
), # backwards compatibility
"inbox_tags": (inbox_tag_pks or None),
"document_file_type_counts": document_file_type_counts,
"character_count": character_count,
"tag_count": len(tags),
"correspondent_count": correspondent_count,
"document_type_count": document_type_count,
"storage_path_count": storage_path_count,
"current_asn": current_asn,
},
)
+291
View File
@@ -0,0 +1,291 @@
from http import HTTPStatus
from unicodedata import normalize
from urllib.parse import quote
from django.db.models import Count
from django.http import FileResponse
from django.http import HttpResponse
from django.http import HttpResponseRedirect
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views import View
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from drf_spectacular.utils import inline_serializer
from rest_framework import serializers
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin
from rest_framework.mixins import DestroyModelMixin
from rest_framework.mixins import ListModelMixin
from rest_framework.mixins import RetrieveModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from rest_framework.viewsets import ModelViewSet
from documents.filters import PermittedObjectsFilter
from documents.filters import ShareLinkBundleFilterSet
from documents.filters import ShareLinkFilterSet
from documents.models import Document
from documents.models import PaperlessTask
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.permissions import PaperlessObjectPermissions
from documents.permissions import ViewDocumentsPermissions
from documents.permissions import permitted_document_ids
from documents.serialisers.sharing import ShareLinkBundleSerializer
from documents.serialisers.sharing import ShareLinkSerializer
from documents.tasks import build_share_link_bundle
from paperless.views import StandardPagination
from .base import PassUserMixin
from .base import serve_file
class ShareLinkViewSet(
PassUserMixin,
CreateModelMixin,
RetrieveModelMixin,
DestroyModelMixin,
ListModelMixin,
GenericViewSet,
):
model = ShareLink
queryset = ShareLink.objects.select_related("document")
serializer_class = ShareLinkSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
)
filterset_class = ShareLinkFilterSet
ordering_fields = ("created", "expiration", "document__title")
@extend_schema_view(
rebuild=extend_schema(
operation_id="share_link_bundles_rebuild",
description="Reset and re-queue a share link bundle for processing.",
responses={
HTTPStatus.OK: ShareLinkBundleSerializer,
(HTTPStatus.BAD_REQUEST, "application/json"): inline_serializer(
name="RebuildBundleError",
fields={"detail": serializers.CharField()},
),
},
),
)
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
model = ShareLinkBundle
# Bundles are immutable once created; rebuild via the dedicated action
# rather than PUT/PATCH.
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = ShareLinkBundle.objects.all()
serializer_class = ShareLinkBundleSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
PermittedObjectsFilter,
)
filterset_class = ShareLinkBundleFilterSet
ordering_fields = ("created", "expiration", "status")
def get_permissions(self):
permissions = super().get_permissions()
if self.action == "create":
permissions.append(ViewDocumentsPermissions())
return permissions
def get_queryset(self):
return (
super()
.get_queryset()
.prefetch_related("documents")
.annotate(document_total=Count("documents", distinct=True))
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
document_ids = serializer.validated_data["document_ids"]
documents_qs = Document.objects.filter(pk__in=document_ids).select_related(
"owner",
)
found_ids = set(documents_qs.values_list("pk", flat=True))
missing = sorted(set(document_ids) - found_ids)
if missing:
raise ValidationError(
{
"document_ids": _(
"Documents not found: %(ids)s",
)
% {"ids": ", ".join(str(item) for item in missing)},
},
)
documents = list(documents_qs)
permitted_ids = set(permitted_document_ids(request.user))
for document in documents:
if document.pk not in permitted_ids:
raise ValidationError(
{
"document_ids": _(
"Insufficient permissions to share document %(id)s.",
)
% {"id": document.pk},
},
)
document_map = {document.pk: document for document in documents}
ordered_documents = [document_map[doc_id] for doc_id in document_ids]
bundle = serializer.save(
owner=request.user,
documents=ordered_documents,
)
bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = None
bundle.size_bytes = None
bundle.built_at = None
bundle.file_path = ""
bundle.save(
update_fields=[
"status",
"last_error",
"size_bytes",
"built_at",
"file_path",
],
)
build_share_link_bundle.apply_async(
kwargs={"bundle_id": bundle.pk},
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
)
bundle.document_total = len(ordered_documents)
response_serializer = self.get_serializer(bundle)
headers = self.get_success_headers(response_serializer.data)
return Response(
response_serializer.data,
status=status.HTTP_201_CREATED,
headers=headers,
)
@action(detail=True, methods=["post"])
def rebuild(self, request, pk=None):
bundle = self.get_object()
if bundle.status == ShareLinkBundle.Status.PROCESSING:
return Response(
{"detail": _("Bundle is already being processed.")},
status=status.HTTP_400_BAD_REQUEST,
)
bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = None
bundle.size_bytes = None
bundle.built_at = None
bundle.file_path = ""
bundle.save(
update_fields=[
"status",
"last_error",
"size_bytes",
"built_at",
"file_path",
],
)
build_share_link_bundle.apply_async(
kwargs={"bundle_id": bundle.pk},
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
)
bundle.document_total = (
getattr(bundle, "document_total", None) or bundle.documents.count()
)
serializer = self.get_serializer(bundle)
return Response(serializer.data)
class SharedLinkView(View):
authentication_classes = []
permission_classes = []
def get(self, request, slug):
share_link = ShareLink.objects.filter(slug=slug).first()
if share_link is not None:
if (
share_link.expiration is not None
and share_link.expiration < timezone.now()
):
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
try:
return serve_file(
doc=share_link.document,
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
and share_link.document.has_archive_version,
disposition="inline",
)
except FileNotFoundError:
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
if bundle is None:
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
if bundle.expiration is not None and bundle.expiration < timezone.now():
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
if bundle.status in {
ShareLinkBundle.Status.PENDING,
ShareLinkBundle.Status.PROCESSING,
}:
return HttpResponse(
_(
"The share link bundle is still being prepared. Please try again later.",
),
status=status.HTTP_202_ACCEPTED,
)
file_path = bundle.absolute_file_path
if (
bundle.status == ShareLinkBundle.Status.FAILED
or file_path is None
or not file_path.exists()
):
return HttpResponse(
_(
"The share link bundle is unavailable.",
),
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
response = FileResponse(file_path.open("rb"), content_type="application/zip")
short_slug = bundle.slug[:12]
download_name = f"paperless-share-{short_slug}.zip"
filename_normalized = (
normalize("NFKD", download_name)
.encode(
"ascii",
"ignore",
)
.decode("ascii")
)
filename_encoded = quote(download_name)
response["Content-Disposition"] = (
f"attachment; filename='{filename_normalized}'; "
f"filename*=utf-8''{filename_encoded}"
)
return response
+611
View File
@@ -0,0 +1,611 @@
import logging
import os
import platform
import re
from datetime import datetime
from datetime import timedelta
from time import sleep
from typing import Any
from urllib.parse import urlparse
import httpx
from django.conf import settings
from django.contrib.auth.models import User
from django.core.cache import cache
from django.db import connections
from django.db.migrations.loader import MigrationLoader
from django.db.migrations.recorder import MigrationRecorder
from django.db.models import Count
from django.db.models import Q
from django.http import HttpResponse
from django.http import HttpResponseForbidden
from django.utils import timezone
from django.utils.timezone import make_aware
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from drf_spectacular.utils import inline_serializer
from packaging import version as packaging_version
from redis import Redis
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.generics import GenericAPIView
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from documents.filters import PermittedObjectsFilter
from documents.models import Document
from documents.models import PaperlessTask
from documents.models import UiSettings
from documents.permissions import PaperlessObjectPermissions
from documents.permissions import TrashPermissions
from documents.permissions import has_system_status_permission
from documents.permissions import permitted_document_ids
from documents.serialisers.documents import DocumentSerializer
from documents.serialisers.system import TrashSerializer
from documents.serialisers.system import UiSettingsViewSerializer
from documents.tasks import empty_trash
from paperless import version
from paperless.celery import app as celery_app
from paperless.config import AIConfig
from paperless.config import GeneralConfig
from paperless.config import RemoteOCRConfig
from paperless.parsers.remote import RemoteEngineConfig
from paperless.views import StandardPagination
from paperless_mail.oauth import PaperlessMailOAuth2Manager
from .base import PassUserMixin
logger = logging.getLogger("paperless.api")
class UiSettingsView(GenericAPIView[Any]):
queryset = UiSettings.objects.all()
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = UiSettingsViewSerializer
def get(self, request, format=None):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = User.objects.select_related("ui_settings").get(pk=request.user.id)
ui_settings = {}
if hasattr(user, "ui_settings"):
ui_settings = user.ui_settings.settings
if "update_checking" in ui_settings:
ui_settings["update_checking"]["backend_setting"] = (
settings.ENABLE_UPDATE_CHECK
)
else:
ui_settings["update_checking"] = {
"backend_setting": settings.ENABLE_UPDATE_CHECK,
}
ui_settings["trash_delay"] = settings.EMPTY_TRASH_DELAY
general_config = GeneralConfig()
ui_settings["version"] = version.__full_version_str__
ui_settings["app_title"] = settings.APP_TITLE
if general_config.app_title is not None and len(general_config.app_title) > 0:
ui_settings["app_title"] = general_config.app_title
ui_settings["app_logo"] = settings.APP_LOGO
if general_config.app_logo is not None and len(general_config.app_logo) > 0:
ui_settings["app_logo"] = general_config.app_logo
ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED
ui_settings["remote_ocr"] = {
"configured": RemoteEngineConfig.from_app_config().engine_is_valid(),
"mode": RemoteOCRConfig().remote_ocr_mode,
}
if settings.GMAIL_OAUTH_ENABLED or settings.OUTLOOK_OAUTH_ENABLED:
manager = PaperlessMailOAuth2Manager()
if settings.GMAIL_OAUTH_ENABLED:
ui_settings["gmail_oauth_url"] = manager.get_gmail_authorization_url()
request.session["oauth_state"] = manager.state
if settings.OUTLOOK_OAUTH_ENABLED:
ui_settings["outlook_oauth_url"] = (
manager.get_outlook_authorization_url()
)
request.session["oauth_state"] = manager.state
ui_settings["email_enabled"] = settings.EMAIL_ENABLED
ai_config = AIConfig()
ui_settings["ai_enabled"] = ai_config.ai_enabled
user_resp = {
"id": user.id,
"username": user.username,
"is_staff": user.is_staff,
"is_superuser": user.is_superuser,
"groups": list(user.groups.values_list("id", flat=True)),
}
if len(user.first_name) > 0:
user_resp["first_name"] = user.first_name
if len(user.last_name) > 0:
user_resp["last_name"] = user.last_name
# strip <app_label>.
roles = map(lambda perm: re.sub(r"^\w+.", "", perm), user.get_all_permissions())
return Response(
{
"user": user_resp,
"settings": ui_settings,
"permissions": roles,
},
)
def post(self, request, format=None):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(user=self.request.user)
return Response(
{
"success": True,
},
)
@extend_schema_view(
get=extend_schema(
description="Get the current version of the Paperless-NGX server",
responses={
(200, "application/json"): OpenApiTypes.OBJECT,
},
),
)
class RemoteVersionView(GenericAPIView[Any]):
cache_key = "remote_version_view_latest_release"
def get(self, request, format=None):
current_version = packaging_version.parse(version.__full_version_str__)
remote_version = cache.get(self.cache_key)
if remote_version is None:
try:
resp = httpx.get(
"https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest",
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
remote_version = data["tag_name"]
# Some early tags used ngx-x.y.z
remote_version = remote_version.removeprefix("ngx-")
except ValueError as e:
logger.debug(f"An error occurred parsing remote version json: {e}")
except httpx.HTTPError as e:
logger.debug(f"An error occurred checking for available updates: {e}")
if remote_version:
cache.set(self.cache_key, remote_version, 60 * 15)
else:
remote_version = "0.0.0"
is_greater_than_current = (
packaging_version.parse(remote_version) > current_version
)
return Response(
{
"version": remote_version,
"update_available": is_greater_than_current,
},
)
@extend_schema_view(
get=extend_schema(
description="Get the current system status of the Paperless-NGX server",
responses={
(200, "application/json"): inline_serializer(
name="SystemStatus",
fields={
"pngx_version": serializers.CharField(),
"server_os": serializers.CharField(),
"install_type": serializers.CharField(),
"storage": inline_serializer(
name="Storage",
fields={
"total": serializers.IntegerField(),
"available": serializers.IntegerField(),
},
),
"database": inline_serializer(
name="Database",
fields={
"type": serializers.CharField(),
"url": serializers.CharField(),
"status": serializers.CharField(),
"error": serializers.CharField(),
"migration_status": inline_serializer(
name="MigrationStatus",
fields={
"latest_migration": serializers.CharField(),
"unapplied_migrations": serializers.ListSerializer(
child=serializers.CharField(),
),
},
),
},
),
"tasks": inline_serializer(
name="Tasks",
fields={
"redis_url": serializers.CharField(),
"redis_status": serializers.CharField(),
"redis_error": serializers.CharField(),
"celery_status": serializers.CharField(),
"summary": inline_serializer(
name="TasksSummaryOverview",
fields={
"days": serializers.IntegerField(),
"total_count": serializers.IntegerField(),
"pending_count": serializers.IntegerField(),
"success_count": serializers.IntegerField(),
"failure_count": serializers.IntegerField(),
},
),
},
),
"index": inline_serializer(
name="Index",
fields={
"status": serializers.CharField(),
"error": serializers.CharField(),
"last_modified": serializers.DateTimeField(),
},
),
"classifier": inline_serializer(
name="Classifier",
fields={
"status": serializers.CharField(),
"error": serializers.CharField(),
"last_trained": serializers.DateTimeField(),
},
),
"sanity_check": inline_serializer(
name="SanityCheck",
fields={
"status": serializers.CharField(),
"error": serializers.CharField(),
"last_run": serializers.DateTimeField(),
},
),
},
),
},
),
)
class SystemStatusView(PassUserMixin):
permission_classes = (IsAuthenticated,)
TASK_SUMMARY_DAYS = 30
def get(self, request, format=None):
if not has_system_status_permission(request.user):
return HttpResponseForbidden("Insufficient permissions")
current_version = version.__full_version_str__
install_type = "bare-metal"
if os.environ.get("KUBERNETES_SERVICE_HOST") is not None:
install_type = "kubernetes"
elif os.environ.get("PNGX_CONTAINERIZED") == "1":
install_type = "docker"
db_conn = connections["default"]
db_url = str(db_conn.settings_dict["NAME"])
db_error = None
try:
db_conn.ensure_connection()
db_status = "OK"
loader = MigrationLoader(connection=db_conn)
all_migrations = [f"{app}.{name}" for app, name in loader.graph.nodes]
applied_migrations = [
f"{m.app}.{m.name}"
for m in MigrationRecorder.Migration.objects.all().order_by("id")
]
except Exception as e: # pragma: no cover
applied_migrations = []
db_status = "ERROR"
logger.exception(
f"System status detected a possible problem while connecting to the database: {e}",
)
db_error = "Error connecting to database, check logs for more detail."
media_stats = os.statvfs(settings.MEDIA_ROOT)
redis_url = settings._CHANNELS_REDIS_URL
redis_url_parsed = urlparse(redis_url)
redis_constructed_url = f"{redis_url_parsed.scheme}://{redis_url_parsed.path or redis_url_parsed.hostname}"
if redis_url_parsed.hostname is not None:
redis_constructed_url += f":{redis_url_parsed.port}"
redis_error = None
with Redis.from_url(url=redis_url) as client:
try:
client.ping()
redis_status = "OK"
except Exception as e:
redis_status = "ERROR"
logger.exception(
f"System status detected a possible problem while connecting to redis: {e}",
)
redis_error = "Error connecting to redis, check logs for more detail."
celery_error = None
celery_url = None
try:
celery_ping = None
for ping_attempt in range(3):
celery_ping = celery_app.control.inspect().ping()
if celery_ping:
break
if ping_attempt < 2:
sleep(0.25)
if not celery_ping:
celery_active = "WARNING"
celery_error = (
"No celery workers responded to ping. This may be temporary."
)
else:
celery_url, first_worker_ping = next(iter(celery_ping.items()))
if (
isinstance(first_worker_ping, dict)
and first_worker_ping.get("ok") == "pong"
):
celery_active = "OK"
else:
celery_active = "WARNING"
celery_error = "Celery worker responded unexpectedly."
except Exception as e:
celery_active = "ERROR"
logger.exception(
f"System status detected a possible problem while connecting to celery: {e}",
)
celery_error = "Error connecting to celery, check logs for more detail."
index_error = None
try:
from documents.search import get_backend
get_backend() # triggers open/rebuild; raises on error
index_status = "OK"
# Use the most-recently modified file in the index directory as a proxy
# for last index write time (Tantivy has no single last_modified() call).
index_dir = settings.INDEX_DIR
mtimes = [p.stat().st_mtime for p in index_dir.iterdir() if p.is_file()]
index_last_modified = (
make_aware(datetime.fromtimestamp(max(mtimes))) if mtimes else None
)
except Exception as e:
index_status = "ERROR"
index_error = "Error opening index, check logs for more detail."
logger.exception(
f"System status detected a possible problem while opening the index: {e}",
)
index_last_modified = None
last_trained_task = (
PaperlessTask.objects.filter(
task_type=PaperlessTask.TaskType.TRAIN_CLASSIFIER,
status__in=PaperlessTask.COMPLETE_STATUSES, # ignore running tasks
)
.order_by("-date_done")
.first()
)
classifier_status = "OK"
classifier_error = None
if last_trained_task is None:
classifier_status = "WARNING"
classifier_error = "No classifier training tasks found"
elif last_trained_task.status != PaperlessTask.Status.SUCCESS:
classifier_status = "ERROR"
classifier_error = (
last_trained_task.result_data.get("error_message")
if last_trained_task.result_data
else None
)
classifier_last_trained = (
last_trained_task.date_done if last_trained_task else None
)
last_sanity_check = (
PaperlessTask.objects.filter(
task_type=PaperlessTask.TaskType.SANITY_CHECK,
status__in=PaperlessTask.COMPLETE_STATUSES, # ignore running tasks
)
.order_by("-date_done")
.first()
)
sanity_check_status = "OK"
sanity_check_error = None
if last_sanity_check is None:
sanity_check_status = "WARNING"
sanity_check_error = "No sanity check tasks found"
elif last_sanity_check.status != PaperlessTask.Status.SUCCESS:
sanity_check_status = "ERROR"
sanity_check_error = (
last_sanity_check.result_data.get("error_message")
if last_sanity_check.result_data
else None
)
sanity_check_last_run = (
last_sanity_check.date_done if last_sanity_check else None
)
ai_config = AIConfig()
if not ai_config.llm_index_enabled:
llmindex_status = "DISABLED"
llmindex_error = None
llmindex_last_modified = None
else:
last_llmindex_update = (
PaperlessTask.objects.filter(
task_type=PaperlessTask.TaskType.LLM_INDEX,
)
.order_by("-date_done")
.first()
)
llmindex_status = "OK"
llmindex_error = None
if last_llmindex_update is None:
llmindex_status = "WARNING"
llmindex_error = "No LLM index update tasks found"
elif last_llmindex_update.status == PaperlessTask.Status.FAILURE:
llmindex_status = "ERROR"
llmindex_error = (
last_llmindex_update.result_data.get("error_message")
if last_llmindex_update.result_data
else None
)
llmindex_last_modified = (
last_llmindex_update.date_done if last_llmindex_update else None
)
summary_cutoff = timezone.now() - timedelta(days=self.TASK_SUMMARY_DAYS)
task_summary_agg = PaperlessTask.objects.filter(
date_created__gte=summary_cutoff,
).aggregate(
total_count=Count("id"),
pending_count=Count(
"id",
filter=Q(status=PaperlessTask.Status.PENDING),
),
success_count=Count(
"id",
filter=Q(status=PaperlessTask.Status.SUCCESS),
),
failure_count=Count(
"id",
filter=Q(status=PaperlessTask.Status.FAILURE),
),
)
task_summary = {
"days": self.TASK_SUMMARY_DAYS,
**task_summary_agg,
}
return Response(
{
"pngx_version": current_version,
"server_os": platform.platform(),
"install_type": install_type,
"storage": {
"total": media_stats.f_frsize * media_stats.f_blocks,
"available": media_stats.f_frsize * media_stats.f_bavail,
},
"database": {
"type": db_conn.vendor,
"url": db_url,
"status": db_status,
"error": db_error,
"migration_status": {
"latest_migration": applied_migrations[-1],
"unapplied_migrations": [
m for m in all_migrations if m not in applied_migrations
],
},
},
"tasks": {
"redis_url": redis_constructed_url,
"redis_status": redis_status,
"redis_error": redis_error,
"celery_status": celery_active,
"celery_url": celery_url,
"celery_error": celery_error,
"index_status": index_status,
"index_last_modified": index_last_modified,
"index_error": index_error,
"classifier_status": classifier_status,
"classifier_last_trained": classifier_last_trained,
"classifier_error": classifier_error,
"sanity_check_status": sanity_check_status,
"sanity_check_last_run": sanity_check_last_run,
"sanity_check_error": sanity_check_error,
"llmindex_status": llmindex_status,
"llmindex_last_modified": llmindex_last_modified,
"llmindex_error": llmindex_error,
"summary": task_summary,
},
},
)
class TrashView(ListModelMixin, PassUserMixin):
permission_classes = (IsAuthenticated, TrashPermissions)
serializer_class = TrashSerializer
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
include_granted = False
filter_backends = (_TrashPermittedObjectsFilter,)
pagination_class = StandardPagination
model = Document
# A version is listed separately only when its root is not in the trash.
queryset = Document.deleted_objects.exclude(
root_document_id__in=Document.deleted_objects.values("id"),
)
def get(self, request: Request, format: str | None = None) -> Response:
self.serializer_class = DocumentSerializer
return self.list(request, format)
def post(
self,
request: Request,
*args: Any,
**kwargs: Any,
) -> Response | HttpResponse:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
doc_ids = serializer.validated_data.get("documents")
docs = (
Document.global_objects.filter(id__in=doc_ids)
if doc_ids is not None
else self.filter_queryset(self.get_queryset()).all()
)
if docs.exclude(
pk__in=permitted_document_ids(
request.user,
perm="delete_document",
include_deleted=True,
),
).exists():
return HttpResponseForbidden("Insufficient permissions")
action = serializer.validated_data.get("action")
if action == "restore":
restored = list(self.get_queryset().filter(id__in=doc_ids))
if len(restored) != len(doc_ids):
raise ValidationError(
{
"documents": [
"Restore the root document instead of one of its versions.",
],
},
)
for doc in restored:
doc.restore(strict=False)
if restored:
from documents.search import get_backend
with get_backend().batch_update() as batch:
batch.add_or_update_ids([doc.pk for doc in restored])
elif action == "empty":
if doc_ids is None:
doc_ids = [doc.id for doc in docs]
empty_trash(doc_ids=doc_ids)
return Response({"result": "OK", "doc_ids": doc_ids})
+363
View File
@@ -0,0 +1,363 @@
import logging
from datetime import timedelta
from django.db.models import Avg
from django.db.models import Count
from django.db.models import Max
from django.db.models import Q
from django.http import HttpResponseForbidden
from django.http import HttpResponseServerError
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.utils import OpenApiParameter
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from drf_spectacular.utils import inline_serializer
from rest_framework import serializers
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet
from documents.filters import PaperlessTaskFilterSet
from documents.models import PaperlessTask
from documents.permissions import AcknowledgeTasksPermissions
from documents.permissions import PaperlessObjectPermissions
from documents.permissions import has_system_status_permission
from documents.serialisers.tasks import AcknowledgeTasksViewSerializer
from documents.serialisers.tasks import RunTaskSerializer
from documents.serialisers.tasks import TaskSerializerV9
from documents.serialisers.tasks import TaskSerializerV10
from documents.serialisers.tasks import TaskSummarySerializer
from documents.tasks import llmindex_index
from documents.tasks import sanity_check
from documents.tasks import train_classifier
from paperless.views import StandardPagination
logger = logging.getLogger("paperless.api")
class _TasksViewSetSchema(AutoSchema):
_UNPAGINATED_ACTIONS = frozenset({"summary", "active", "status_counts"})
def _get_paginator(self):
if getattr(self.view, "action", None) in self._UNPAGINATED_ACTIONS:
return None
return super()._get_paginator()
@extend_schema_view(
list=extend_schema(
parameters=[
OpenApiParameter(
name="task_id",
type=str,
location=OpenApiParameter.QUERY,
required=False,
description="Filter tasks by Celery UUID",
),
],
),
acknowledge=extend_schema(
operation_id="acknowledge_tasks",
description="Acknowledge a list of tasks, or all visible unacknowledged tasks",
request=AcknowledgeTasksViewSerializer,
responses={
(200, "application/json"): inline_serializer(
name="AcknowledgeTasks",
fields={
"result": serializers.IntegerField(),
},
),
},
),
run=extend_schema(
operation_id="run_task",
description="Manually dispatch a background task. Superuser only.",
request=RunTaskSerializer,
responses={
(200, "application/json"): inline_serializer(
name="RunTask",
fields={"task_id": serializers.CharField()},
),
(400, "application/json"): inline_serializer(
name="RunTaskError",
fields={"error": serializers.CharField()},
),
},
),
summary=extend_schema(
responses={200: TaskSummarySerializer(many=True)},
parameters=[
OpenApiParameter(
name="days",
type={"type": "integer", "minimum": 1, "maximum": 365, "default": 30},
location=OpenApiParameter.QUERY,
required=False,
description="Number of days to include in aggregation (default 30, min 1, max 365)",
),
],
),
status_counts=extend_schema(
responses={
200: inline_serializer(
name="TaskStatusCounts",
fields={
"all": serializers.IntegerField(),
"needs_attention": serializers.IntegerField(),
"in_progress": serializers.IntegerField(),
"completed": serializers.IntegerField(),
},
),
},
),
active=extend_schema(
description="Currently pending and running tasks (capped at 50).",
responses={200: TaskSerializerV10(many=True)},
),
)
class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]):
schema = _TasksViewSetSchema()
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
pagination_class = StandardPagination
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
)
filterset_class = PaperlessTaskFilterSet
ordering_fields = [
"date_created",
"date_done",
"status",
"task_type",
"duration_seconds",
"wait_time_seconds",
]
ordering = ["-date_created"]
# Needed for drf-spectacular schema generation (get_queryset touches request.user)
queryset = PaperlessTask.objects.none()
# v9 backwards compat: maps old task_name values to new task_type values
_V9_TASK_NAME_TO_TYPE = {
"check_sanity": PaperlessTask.TaskType.SANITY_CHECK,
"llmindex_update": PaperlessTask.TaskType.LLM_INDEX,
}
# v9 backwards compat: maps old "type" query param values to new TriggerSource.
# Must match the reverse of TaskSerializerV9._TRIGGER_SOURCE_TO_V9_TYPE.
_V9_TYPE_TO_TRIGGER_SOURCES = {
"auto_task": [
PaperlessTask.TriggerSource.SYSTEM,
PaperlessTask.TriggerSource.EMAIL_CONSUME,
PaperlessTask.TriggerSource.FOLDER_CONSUME,
],
"scheduled_task": [PaperlessTask.TriggerSource.SCHEDULED],
"manual_task": [
PaperlessTask.TriggerSource.MANUAL,
PaperlessTask.TriggerSource.WEB_UI,
PaperlessTask.TriggerSource.API_UPLOAD,
],
}
_RUNNABLE_TASKS = {
PaperlessTask.TaskType.TRAIN_CLASSIFIER: (train_classifier, {}),
PaperlessTask.TaskType.SANITY_CHECK: (sanity_check, {"raise_on_error": False}),
PaperlessTask.TaskType.LLM_INDEX: (llmindex_index, {"rebuild": False}),
}
_STATUS_COUNT_EXCLUDED_FILTERS = frozenset({"status", "is_complete"})
def get_serializer_class(self):
# v9: use backwards-compatible serializer with old field names
if self.request.version and int(self.request.version) < 10:
return TaskSerializerV9
return TaskSerializerV10
def paginate_queryset(self, queryset):
# v9: tasks endpoint was not paginated; preserve plain-list response
if self.request.version and int(self.request.version) < 10:
return None
return super().paginate_queryset(queryset)
def get_queryset(self):
is_v9 = self.request.version and int(self.request.version) < 10
if self.request.user.is_staff:
queryset = PaperlessTask.objects.all()
else:
# Own tasks + unowned (system/scheduled) tasks. Tasks owned by other
# users are never visible to non-staff regardless of API version.
queryset = PaperlessTask.objects.filter(
Q(owner=self.request.user) | Q(owner__isnull=True),
)
# v9 backwards compat: map old query params to new field names
if is_v9:
task_name = self.request.query_params.get("task_name")
if task_name is not None:
mapped = self._V9_TASK_NAME_TO_TYPE.get(task_name, task_name)
queryset = queryset.filter(task_type=mapped)
task_type_old = self.request.query_params.get("type")
if task_type_old is not None:
sources = self._V9_TYPE_TO_TRIGGER_SOURCES.get(task_type_old)
if sources:
queryset = queryset.filter(trigger_source__in=sources)
# v10+: direct task_id param for backwards compat
task_id = self.request.query_params.get("task_id")
if task_id is not None:
queryset = queryset.filter(task_id=task_id)
return queryset
def get_status_count_queryset(self):
"""Apply task filters except the status dimensions represented by the counts."""
query_params = self.request.query_params.copy()
for param in self._STATUS_COUNT_EXCLUDED_FILTERS:
query_params.pop(param, None)
filterset = self.filterset_class(
data=query_params,
queryset=self.get_queryset(),
request=self.request,
)
if not filterset.is_valid():
raise ValidationError(filterset.errors)
return filterset.qs
@action(
methods=["post"],
detail=False,
permission_classes=[IsAuthenticated, AcknowledgeTasksPermissions],
)
def acknowledge(self, request):
queryset = self.get_queryset()
serializer = AcknowledgeTasksViewSerializer(
data=request.data,
context={"queryset": queryset},
)
serializer.is_valid(raise_exception=True)
if serializer.validated_data.get("all", False):
tasks = queryset.filter(acknowledged=False)
else:
task_ids = serializer.validated_data.get("tasks")
tasks = queryset.filter(id__in=task_ids)
count = tasks.update(acknowledged=True)
return Response({"result": count})
def get_permissions(self):
if self.action == "summary" and has_system_status_permission(
getattr(self.request, "user", None),
):
return [IsAuthenticated()]
return super().get_permissions()
@action(methods=["get"], detail=False)
def summary(self, request):
"""Aggregated task statistics per task_type over the last N days (default 30)."""
try:
days = min(365, max(1, int(request.query_params.get("days", 30))))
except (TypeError, ValueError):
return Response(
{"days": "Must be a positive integer."},
status=status.HTTP_400_BAD_REQUEST,
)
cutoff = timezone.now() - timedelta(days=days)
if has_system_status_permission(request.user):
queryset = PaperlessTask.objects.filter(date_created__gte=cutoff)
else:
queryset = self.get_queryset().filter(date_created__gte=cutoff)
data = queryset.values("task_type").annotate(
total_count=Count("id"),
pending_count=Count("id", filter=Q(status=PaperlessTask.Status.PENDING)),
success_count=Count("id", filter=Q(status=PaperlessTask.Status.SUCCESS)),
failure_count=Count("id", filter=Q(status=PaperlessTask.Status.FAILURE)),
avg_duration_seconds=Avg(
"duration_seconds",
filter=Q(duration_seconds__isnull=False),
),
avg_wait_time_seconds=Avg(
"wait_time_seconds",
filter=Q(wait_time_seconds__isnull=False),
),
last_run=Max("date_created"),
last_success=Max(
"date_done",
filter=Q(status=PaperlessTask.Status.SUCCESS),
),
last_failure=Max(
"date_done",
filter=Q(status=PaperlessTask.Status.FAILURE),
),
)
serializer = TaskSummarySerializer(data, many=True)
return Response(serializer.data)
@action(methods=["get"], detail=False)
def status_counts(self, request):
"""Aggregated task counts for task UI sections."""
queryset = self.get_status_count_queryset()
counts = queryset.aggregate(
all=Count("id"),
needs_attention=Count(
"id",
filter=Q(
status__in=[
PaperlessTask.Status.FAILURE,
PaperlessTask.Status.REVOKED,
],
),
),
in_progress=Count(
"id",
filter=Q(
status__in=[
PaperlessTask.Status.PENDING,
PaperlessTask.Status.STARTED,
],
),
),
completed=Count("id", filter=Q(status=PaperlessTask.Status.SUCCESS)),
)
return Response(counts)
@action(methods=["get"], detail=False)
def active(self, request):
"""Currently pending and running tasks (capped at 50)."""
queryset = (
self.get_queryset()
.filter(
status__in=[PaperlessTask.Status.PENDING, PaperlessTask.Status.STARTED],
)
.order_by("-date_created")[:50]
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(methods=["post"], detail=False)
def run(self, request):
"""Manually dispatch a background task. Superuser only."""
if not request.user.is_superuser:
return HttpResponseForbidden("Insufficient permissions")
serializer = RunTaskSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
task_type = serializer.validated_data.get("task_type")
if task_type not in self._RUNNABLE_TASKS:
return Response(
{"error": f"Task type '{task_type}' cannot be manually triggered"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
task_func, task_kwargs = self._RUNNABLE_TASKS[task_type]
async_result = task_func.apply_async(
kwargs=task_kwargs,
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
)
return Response({"task_id": async_result.id})
except Exception as e:
logger.warning(f"Error running task: {e!s}")
return HttpResponseServerError(
"Error running task, check logs for more detail.",
)
+108
View File
@@ -0,0 +1,108 @@
import os
import tempfile
from datetime import datetime
from pathlib import Path
from time import mktime
from typing import Any
from unicodedata import normalize
import pathvalidate
from django.conf import settings
from django.http import HttpResponseForbidden
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from rest_framework import parsers
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.models import PaperlessTask
from documents.serialisers.upload import PostDocumentSerializer
from documents.tasks import consume_file
@extend_schema_view(
post=extend_schema(
description="Upload a document via the API",
external_docs={
"description": "Further documentation",
"url": "https://docs.paperless-ngx.com/api/#file-uploads",
},
responses={
(200, "application/json"): OpenApiTypes.STR,
},
),
)
class PostDocumentView(GenericAPIView[Any]):
permission_classes = (IsAuthenticated,)
serializer_class = PostDocumentSerializer
parser_classes = (parsers.MultiPartParser,)
def post(self, request, *args, **kwargs):
if not request.user.has_perm("documents.add_document"):
return HttpResponseForbidden("Insufficient permissions")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
doc_name, doc_data = serializer.validated_data.get("document")
doc_name = normalize("NFC", doc_name)
correspondent_id = serializer.validated_data.get("correspondent")
document_type_id = serializer.validated_data.get("document_type")
storage_path_id = serializer.validated_data.get("storage_path")
tag_ids = serializer.validated_data.get("tags")
title = serializer.validated_data.get("title")
created = serializer.validated_data.get("created")
archive_serial_number = serializer.validated_data.get("archive_serial_number")
cf = serializer.validated_data.get("custom_fields")
from_webui = serializer.validated_data.get("from_webui")
t = int(mktime(datetime.now().timetuple()))
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
temp_file_path = Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / Path(
pathvalidate.sanitize_filename(doc_name),
)
temp_file_path.write_bytes(doc_data)
os.utime(temp_file_path, times=(t, t))
input_doc = ConsumableDocument(
source=DocumentSource.WebUI if from_webui else DocumentSource.ApiUpload,
original_file=temp_file_path,
)
custom_fields = None
if isinstance(cf, dict) and cf:
custom_fields = cf
elif isinstance(cf, list) and cf:
custom_fields = dict.fromkeys(cf, None)
input_doc_overrides = DocumentMetadataOverrides(
filename=doc_name,
title=title,
correspondent_id=correspondent_id,
document_type_id=document_type_id,
storage_path_id=storage_path_id,
tag_ids=tag_ids,
created=created,
asn=archive_serial_number,
owner_id=request.user.id,
custom_fields=custom_fields,
)
async_task = consume_file.apply_async(
kwargs={"input_doc": input_doc, "overrides": input_doc_overrides},
headers={
"trigger_source": (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
),
},
)
return Response(async_task.id)
+110
View File
@@ -0,0 +1,110 @@
from django.db.models import Prefetch
from django.http import HttpResponseBadRequest
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.permissions import PaperlessObjectPermissions
from documents.serialisers.workflows import WorkflowActionSerializer
from documents.serialisers.workflows import WorkflowSerializer
from documents.serialisers.workflows import WorkflowTriggerSerializer
from paperless.views import StandardPagination
class WorkflowTriggerViewSet(ModelViewSet[WorkflowTrigger]):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = WorkflowTriggerSerializer
pagination_class = StandardPagination
model = WorkflowTrigger
queryset = WorkflowTrigger.objects.all()
def partial_update(self, request, *args, **kwargs):
if "id" in request.data and str(request.data["id"]) != str(kwargs["pk"]):
return HttpResponseBadRequest(
"ID in body does not match URL",
)
return super().partial_update(request, *args, **kwargs)
class WorkflowActionViewSet(ModelViewSet[WorkflowAction]):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = WorkflowActionSerializer
pagination_class = StandardPagination
model = WorkflowAction
queryset = WorkflowAction.objects.all().prefetch_related(
"assign_tags",
"assign_view_users",
"assign_view_groups",
"assign_change_users",
"assign_change_groups",
"assign_custom_fields",
)
def partial_update(self, request, *args, **kwargs):
if "id" in request.data and str(request.data["id"]) != str(kwargs["pk"]):
return HttpResponseBadRequest(
"ID in body does not match URL",
)
return super().partial_update(request, *args, **kwargs)
class WorkflowViewSet(ModelViewSet[Workflow]):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = WorkflowSerializer
pagination_class = StandardPagination
model = Workflow
queryset = (
Workflow.objects.all()
.order_by("order")
.prefetch_related(
Prefetch(
"triggers",
queryset=WorkflowTrigger.objects.prefetch_related(
"filter_has_tags",
"filter_has_all_tags",
"filter_has_not_tags",
"filter_has_any_correspondents",
"filter_has_not_correspondents",
"filter_has_any_document_types",
"filter_has_not_document_types",
"filter_has_any_storage_paths",
"filter_has_not_storage_paths",
),
),
Prefetch(
"actions",
queryset=WorkflowAction.objects.order_by(
"order",
"pk",
).prefetch_related(
"assign_tags",
"assign_view_users",
"assign_view_groups",
"assign_change_users",
"assign_change_groups",
"assign_custom_fields",
"remove_tags",
"remove_correspondents",
"remove_document_types",
"remove_storage_paths",
"remove_custom_fields",
"remove_owners",
"remove_view_users",
"remove_view_groups",
"remove_change_users",
"remove_change_groups",
),
),
)
)
+4 -6
View File
@@ -4,8 +4,7 @@ import httpx
from celery import shared_task
from django.conf import settings
from paperless.network import GuardedHTTPTransport
from paperless.network import OutboundRequestBlockedError
from paperless.network import PinnedHostHTTPTransport
from paperless.network import validate_outbound_http_url
logger = logging.getLogger("paperless.workflows.webhooks")
@@ -15,7 +14,7 @@ logger = logging.getLogger("paperless.workflows.webhooks")
retry_backoff=True,
autoretry_for=(httpx.HTTPStatusError,),
max_retries=3,
throws=(httpx.HTTPError, OutboundRequestBlockedError),
throws=(httpx.HTTPError,),
)
def send_webhook(
url: str,
@@ -30,15 +29,14 @@ def send_webhook(
url,
allowed_schemes=settings.WEBHOOKS_ALLOWED_SCHEMES,
allowed_ports=settings.WEBHOOKS_ALLOWED_PORTS,
# Scheme and port only; the transport enforces the internal-address
# policy at connect time, on the address actually dialled.
# Internal-address checks happen in transport to preserve ConnectError behavior.
allow_internal=True,
)
except ValueError as e:
logger.warning("Webhook blocked: %s", e)
raise
transport = GuardedHTTPTransport(
transport = PinnedHostHTTPTransport(
allow_internal=settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS,
)
+62 -58
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-21 19:00+0000\n"
"POT-Creation-Date: 2026-09-23 19:00+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1632,7 +1632,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:514 documents/serialisers.py:871
#: documents/serialisers.py:2885 documents/views.py:343 documents/views.py:2726
#: documents/serialisers.py:2885 documents/views.py:342 documents/views.py:2725
#: paperless_mail/serialisers.py:156
msgid "Insufficient permissions."
msgstr ""
@@ -1673,7 +1673,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2971 documents/views.py:4763
#: documents/serialisers.py:2971 documents/views.py:4780
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1941,40 +1941,40 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:336 documents/views.py:2723
#: documents/views.py:335 documents/views.py:2722
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1670
#: documents/views.py:1669
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1681
#: documents/views.py:1680
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:1693
#: documents/views.py:1692
msgid "AI backend rejected the request. Check logs for details."
msgstr ""
#: documents/views.py:2548 documents/views.py:2864
#: documents/views.py:2547 documents/views.py:2863
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4776
#: documents/views.py:4793
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4822
#: documents/views.py:4839
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4886
#: documents/views.py:4903
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4900
#: documents/views.py:4917
msgid "The share link bundle is unavailable."
msgstr ""
@@ -2219,190 +2219,194 @@ msgid "Sets the LLM embedding model"
msgstr ""
#: paperless/models.py:369
msgid "Sets the LLM embedding endpoint, optional"
msgid "Sets the LLM embedding API key"
msgstr ""
#: paperless/models.py:376
msgid "Sets the LLM embedding endpoint, optional"
msgstr ""
#: paperless/models.py:383
msgid "Sets the LLM embedding chunk size"
msgstr ""
#: paperless/models.py:382
#: paperless/models.py:389
msgid "Sets the LLM context size"
msgstr ""
#: paperless/models.py:388
#: paperless/models.py:395
msgid "Sets the LLM backend"
msgstr ""
#: paperless/models.py:396
#: paperless/models.py:403
msgid "Sets the LLM model"
msgstr ""
#: paperless/models.py:403
#: paperless/models.py:410
msgid "Sets the LLM API key"
msgstr ""
#: paperless/models.py:410
#: paperless/models.py:417
msgid "Sets the LLM endpoint, optional"
msgstr ""
#: paperless/models.py:417
#: paperless/models.py:424
msgid "Sets the LLM output language"
msgstr ""
#: paperless/models.py:424
#: paperless/models.py:431
msgid "Sets the LLM timeout in seconds"
msgstr ""
#: paperless/models.py:430
#: paperless/models.py:437
msgid "paperless application settings"
msgstr ""
#: paperless/settings/__init__.py:558
#: paperless/settings/__init__.py:559
msgid "English (US)"
msgstr ""
#: paperless/settings/__init__.py:559
#: paperless/settings/__init__.py:560
msgid "Arabic"
msgstr ""
#: paperless/settings/__init__.py:560
#: paperless/settings/__init__.py:561
msgid "Afrikaans"
msgstr ""
#: paperless/settings/__init__.py:561
#: paperless/settings/__init__.py:562
msgid "Belarusian"
msgstr ""
#: paperless/settings/__init__.py:562
#: paperless/settings/__init__.py:563
msgid "Bulgarian"
msgstr ""
#: paperless/settings/__init__.py:563
#: paperless/settings/__init__.py:564
msgid "Catalan"
msgstr ""
#: paperless/settings/__init__.py:564
#: paperless/settings/__init__.py:565
msgid "Czech"
msgstr ""
#: paperless/settings/__init__.py:565
#: paperless/settings/__init__.py:566
msgid "Danish"
msgstr ""
#: paperless/settings/__init__.py:566
#: paperless/settings/__init__.py:567
msgid "German"
msgstr ""
#: paperless/settings/__init__.py:567
#: paperless/settings/__init__.py:568
msgid "Greek"
msgstr ""
#: paperless/settings/__init__.py:568
#: paperless/settings/__init__.py:569
msgid "English (GB)"
msgstr ""
#: paperless/settings/__init__.py:569
#: paperless/settings/__init__.py:570
msgid "Spanish"
msgstr ""
#: paperless/settings/__init__.py:570
#: paperless/settings/__init__.py:571
msgid "Persian"
msgstr ""
#: paperless/settings/__init__.py:571
#: paperless/settings/__init__.py:572
msgid "Finnish"
msgstr ""
#: paperless/settings/__init__.py:572
#: paperless/settings/__init__.py:573
msgid "French"
msgstr ""
#: paperless/settings/__init__.py:573
#: paperless/settings/__init__.py:574
msgid "Hungarian"
msgstr ""
#: paperless/settings/__init__.py:574
#: paperless/settings/__init__.py:575
msgid "Indonesian"
msgstr ""
#: paperless/settings/__init__.py:575
#: paperless/settings/__init__.py:576
msgid "Italian"
msgstr ""
#: paperless/settings/__init__.py:576
#: paperless/settings/__init__.py:577
msgid "Japanese"
msgstr ""
#: paperless/settings/__init__.py:577
#: paperless/settings/__init__.py:578
msgid "Korean"
msgstr ""
#: paperless/settings/__init__.py:578
#: paperless/settings/__init__.py:579
msgid "Luxembourgish"
msgstr ""
#: paperless/settings/__init__.py:579
#: paperless/settings/__init__.py:580
msgid "Norwegian"
msgstr ""
#: paperless/settings/__init__.py:580
#: paperless/settings/__init__.py:581
msgid "Dutch"
msgstr ""
#: paperless/settings/__init__.py:581
#: paperless/settings/__init__.py:582
msgid "Polish"
msgstr ""
#: paperless/settings/__init__.py:582
#: paperless/settings/__init__.py:583
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings/__init__.py:583
#: paperless/settings/__init__.py:584
msgid "Portuguese"
msgstr ""
#: paperless/settings/__init__.py:584
#: paperless/settings/__init__.py:585
msgid "Romanian"
msgstr ""
#: paperless/settings/__init__.py:585
#: paperless/settings/__init__.py:586
msgid "Russian"
msgstr ""
#: paperless/settings/__init__.py:586
#: paperless/settings/__init__.py:587
msgid "Slovak"
msgstr ""
#: paperless/settings/__init__.py:587
#: paperless/settings/__init__.py:588
msgid "Slovenian"
msgstr ""
#: paperless/settings/__init__.py:588
#: paperless/settings/__init__.py:589
msgid "Serbian"
msgstr ""
#: paperless/settings/__init__.py:589
#: paperless/settings/__init__.py:590
msgid "Swedish"
msgstr ""
#: paperless/settings/__init__.py:590
#: paperless/settings/__init__.py:591
msgid "Turkish"
msgstr ""
#: paperless/settings/__init__.py:591
#: paperless/settings/__init__.py:592
msgid "Ukrainian"
msgstr ""
#: paperless/settings/__init__.py:592
#: paperless/settings/__init__.py:593
msgid "Vietnamese"
msgstr ""
#: paperless/settings/__init__.py:593
#: paperless/settings/__init__.py:594
msgid "Chinese Simplified"
msgstr ""
#: paperless/settings/__init__.py:594
#: paperless/settings/__init__.py:595
msgid "Chinese Traditional"
msgstr ""
+7
View File
@@ -1,5 +1,6 @@
import dataclasses
import json
from typing import Any
from django.conf import settings
@@ -244,6 +245,7 @@ class AIConfig(BaseConfig):
ai_enabled: bool = dataclasses.field(init=False)
llm_embedding_backend: str = dataclasses.field(init=False)
llm_embedding_model: str = dataclasses.field(init=False)
llm_embedding_api_key: str = dataclasses.field(init=False)
llm_embedding_endpoint: str = dataclasses.field(init=False)
llm_embedding_chunk_size: int = dataclasses.field(init=False)
llm_context_size: int = dataclasses.field(init=False)
@@ -254,6 +256,7 @@ class AIConfig(BaseConfig):
llm_endpoint: str = dataclasses.field(init=False)
llm_output_language: str = dataclasses.field(init=False)
llm_allow_internal_endpoints: bool = dataclasses.field(init=False)
llm_extra_params: dict[str, Any] = dataclasses.field(init=False)
def __post_init__(self) -> None:
app_config = self._get_config_instance()
@@ -269,6 +272,9 @@ class AIConfig(BaseConfig):
self.llm_embedding_model = (
app_config.llm_embedding_model or settings.LLM_EMBEDDING_MODEL
)
self.llm_embedding_api_key = (
app_config.llm_embedding_api_key or settings.LLM_EMBEDDING_API_KEY
)
self.llm_embedding_endpoint = (
app_config.llm_embedding_endpoint or settings.LLM_EMBEDDING_ENDPOINT
)
@@ -287,6 +293,7 @@ class AIConfig(BaseConfig):
app_config.llm_output_language or settings.LLM_OUTPUT_LANGUAGE
)
self.llm_allow_internal_endpoints = settings.LLM_ALLOW_INTERNAL_ENDPOINTS
self.llm_extra_params = settings.LLM_EXTRA_PARAMS
@property
def llm_index_enabled(self) -> bool:
@@ -0,0 +1,23 @@
# Generated by Django 5.2.16 on 2026-09-11 09:32
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("paperless", "0016_alter_applicationconfiguration_ai_enabled"),
]
operations = [
migrations.AddField(
model_name="applicationconfiguration",
name="llm_embedding_api_key",
field=models.CharField(
blank=True,
max_length=1024,
null=True,
verbose_name="Sets the LLM embedding API key",
),
),
]
+7
View File
@@ -365,6 +365,13 @@ class ApplicationConfiguration(AbstractSingletonModel):
max_length=128,
)
llm_embedding_api_key = models.CharField(
verbose_name=_("Sets the LLM embedding API key"),
blank=True,
null=True,
max_length=1024,
)
llm_embedding_endpoint = models.CharField(
verbose_name=_("Sets the LLM embedding endpoint, optional"),
blank=True,
+158 -519
View File
@@ -1,533 +1,61 @@
import functools
import ipaddress
import logging
import math
import re
import socket
import time
from collections.abc import Callable
from collections.abc import Collection
from collections.abc import Iterable
from enum import StrEnum
from typing import Any
from typing import Final
from typing import Self
from typing import TypeAlias
from urllib.parse import ParseResult
from urllib.parse import urlparse
import anyio
import httpcore
import httpx
# Not exported by httpcore; the guard asserts it is still the async default.
from httpcore._backends.auto import AutoBackend
logger = logging.getLogger("paperless.network")
# requires-python is >=3.11, so no PEP 695 `type` statement.
IPAddress: TypeAlias = ipaddress.IPv4Address | ipaddress.IPv6Address
# Ranges that ipaddress reports as global but which still reach internal hosts.
# Ranges ipaddress does not report as private, but which routinely front
# internal infrastructure.
_NON_PUBLIC_NETWORKS = (
# RFC 6598 shared address space: ISP CGNAT, and the default pod/service
# CIDR on several managed Kubernetes offerings.
ipaddress.ip_network("100.64.0.0/10"),
# RFC 6052 NAT64 well-known prefix: 64:ff9b::7f00:1 is 127.0.0.1 wherever
# a NAT64 gateway exists, yet ipaddress classifies the prefix as global.
# a NAT64 gateway exists.
ipaddress.ip_network("64:ff9b::/96"),
)
class BlockReason(StrEnum):
NON_PUBLIC_ADDRESS = "non_public_address"
UNIX_SOCKET = "unix_socket"
class OutboundRequestBlockedError(Exception):
"""
An outbound connection was refused by policy before any socket was opened.
For NON_PUBLIC_ADDRESS, ``host`` is the name or literal being connected to
and ``address`` the first offending address. For UNIX_SOCKET, ``host`` is
the socket path and ``port`` and ``address`` are None.
``address`` is deliberately left out of the message: the message is logged
and stored on failed tasks, and must not disclose internal addresses.
"""
def __init__(
self,
*,
host: str,
port: int | None,
reason: BlockReason,
address: IPAddress | None = None,
) -> None:
self.host = host
self.port = port
self.reason = reason
self.address = address
target = host if port is None else f"{host}:{port}"
super().__init__(f"Outbound connection to {target} blocked ({reason})")
def __reduce__(self) -> tuple[Callable[..., Self], tuple[object, ...]]:
# Celery rebuilds failed-task exceptions by pickling; keyword-only
# fields cannot be recovered from ``args`` alone.
return (
functools.partial(
type(self),
host=self.host,
port=self.port,
reason=self.reason,
address=self.address,
),
(),
def is_public_ip(ip: str | int) -> bool:
try:
obj = ipaddress.ip_address(ip)
return not (
obj.is_private
or obj.is_loopback
or obj.is_link_local
or obj.is_multicast
or obj.is_unspecified
or any(obj in network for network in _NON_PUBLIC_NETWORKS)
)
except ValueError: # pragma: no cover
return False
class HostResolutionError(Exception):
"""The resolver returned no usable addresses for a host."""
def resolve_hostname_ips(hostname: str) -> list[str]:
try:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror as e:
raise ValueError(f"Could not resolve hostname: {hostname}") from e
def __init__(self, *, host: str, detail: str) -> None:
self.host = host
self.detail = detail
super().__init__(f"Could not resolve {host}: {detail}")
def __reduce__(self) -> tuple[Callable[..., Self], tuple[object, ...]]:
return (
functools.partial(type(self), host=self.host, detail=self.detail),
(),
)
ips = [info[4][0] for info in addr_info if info and info[4]]
if not ips:
raise ValueError(f"Could not resolve hostname: {hostname}")
return ips
def blocked_message(exc: OutboundRequestBlockedError | HostResolutionError) -> str:
"""User-facing text for validation errors, kept stable for existing callers."""
if isinstance(exc, HostResolutionError):
return f"Could not resolve hostname: {exc.host}"
if exc.reason is BlockReason.UNIX_SOCKET:
return "Connection blocked: unix sockets are not permitted"
return f"Connection blocked: {exc.host} resolves to a non-public address"
def is_public_ip(ip: IPAddress) -> bool:
def format_host_for_url(host: str) -> str:
"""
True when ``ip`` is globally routable unicast and not in a range that
ipaddress reports as global but which still reaches internal hosts.
"""
return (
ip.is_global
and not ip.is_multicast
and not any(ip in network for network in _NON_PUBLIC_NETWORKS)
)
# Resolver and clock indirection so tests can fake DNS and time for this module
# without changing how the stock httpcore backends resolve the literals the
# guard dials.
_getaddrinfo = socket.getaddrinfo
_agetaddrinfo = anyio.getaddrinfo
# The clock is a seam because time-machine does not mock monotonic clocks, and
# patching time.monotonic globally would also replace the asyncio event loop's
# own clock, hanging or misfiring its timers for the rest of the test.
_monotonic = time.monotonic
def _collect_addresses(
host: str,
infos: Iterable[tuple[Any, ...]],
) -> tuple[IPAddress, ...]:
# Resolver output is always an address, but a scoped IPv6 answer carries a
# zone id ("fe80::1%1"), which is dropped before classification.
# dict keys keep the first occurrence and resolver order
addresses: dict[IPAddress, None] = {}
for info in infos:
address = ipaddress.ip_address(str(info[4][0]).split("%", 1)[0])
addresses.setdefault(address, None)
if not addresses:
raise HostResolutionError(host=host, detail="no addresses returned")
return tuple(addresses)
def _require_public(
host: str,
port: int | None,
addresses: tuple[IPAddress, ...],
) -> tuple[IPAddress, ...]:
for address in addresses:
if not is_public_ip(address):
raise OutboundRequestBlockedError(
host=host,
port=port,
reason=BlockReason.NON_PUBLIC_ADDRESS,
address=address,
)
return addresses
def resolve_public_addresses(host: str, port: int | None) -> tuple[IPAddress, ...]:
"""
Resolve ``host`` and return its addresses in resolver order, or raise if
any of them is non-public. A name is rejected as a whole; offending
addresses are never filtered out.
IP literals go through the resolver too: getaddrinfo answers them without
a lookup, and validating only its answer means no second parser can read
the host differently from the one that connects.
Format IP address for URL use (wrap IPv6 in brackets).
"""
try:
infos = _getaddrinfo(host, port, type=socket.SOCK_STREAM)
except (OSError, UnicodeError) as e:
raise HostResolutionError(host=host, detail=str(e)) from e
return _require_public(host, port, _collect_addresses(host, infos))
async def aresolve_public_addresses(
host: str,
port: int | None,
) -> tuple[IPAddress, ...]:
"""Async variant of resolve_public_addresses."""
try:
infos = await _agetaddrinfo(host, port, type=socket.SOCK_STREAM)
except (OSError, UnicodeError) as e:
raise HostResolutionError(host=host, detail=str(e)) from e
return _require_public(host, port, _collect_addresses(host, infos))
MAX_ADDRESSES_TRIED: Final = 8
MIN_ATTEMPT_TIMEOUT: Final = 2.0
MAX_ATTEMPT_TIMEOUT: Final = 10.0
def _require_positive_timeout(host: str, timeout: float | None) -> None:
# A zero timeout makes the socket non-blocking and a negative one is
# rejected by settimeout; neither can produce a useful connection attempt.
if timeout is not None and timeout <= 0:
raise httpcore.ConnectTimeout(
f"Connect timeout for {host} must be positive, got {timeout}",
)
def _deadline(timeout: float | None) -> float:
return math.inf if timeout is None else _monotonic() + timeout
def _attempt_order(addresses: tuple[IPAddress, ...]) -> list[IPAddress]:
# Alternate address families, starting with the resolver's first family
# (RFC 8305 section 4), so one unreachable family cannot delay the other.
first_version = addresses[0].version
primary = [a for a in addresses if a.version == first_version]
secondary = [a for a in addresses if a.version != first_version]
ordered: list[IPAddress] = []
for index in range(max(len(primary), len(secondary))):
ordered.extend(primary[index : index + 1])
ordered.extend(secondary[index : index + 1])
return ordered[:MAX_ADDRESSES_TRIED]
def _attempt_timeout(remaining: float, attempts_left: int) -> float:
"""
Budget for the next attempt. Once the budget is too small to split, or on
the last address, the attempt gets everything left. Otherwise it gets an
equal share clamped to [MIN, MAX], always leaving MIN for a later attempt.
The floor survives one lost SYN; the ceiling bounds how long a black-holed
address delays the next one.
"""
if attempts_left == 1 or remaining < 2 * MIN_ATTEMPT_TIMEOUT:
return remaining
share = remaining / attempts_left
return min(
MAX_ATTEMPT_TIMEOUT,
max(MIN_ATTEMPT_TIMEOUT, share),
remaining - MIN_ATTEMPT_TIMEOUT,
)
def _as_httpcore_timeout(seconds: float) -> float | None:
return None if math.isinf(seconds) else seconds
def _log_block(error: OutboundRequestBlockedError) -> None:
logger.warning("Blocked outbound connection: %s", error)
def _budget_exhausted(host: str, tried: int, total: int) -> httpcore.ConnectTimeout:
return httpcore.ConnectTimeout(
f"Timed out connecting to {host} after trying {tried} of {total} addresses",
)
def _next_attempt_budget(
host: str,
deadline: float,
candidates: list[IPAddress],
index: int,
) -> float:
"""Budget for the attempt at index, or a timeout if none is left."""
remaining = deadline - _monotonic()
if remaining <= 0:
raise _budget_exhausted(host, index, len(candidates))
return _attempt_timeout(remaining, len(candidates) - index)
def _resolve_for_connect(host: str, port: int) -> tuple[IPAddress, ...]:
try:
return resolve_public_addresses(host, port)
except OutboundRequestBlockedError as e:
_log_block(e)
raise
except HostResolutionError as e:
raise httpcore.ConnectError(str(e)) from e
async def _aresolve_for_connect(
host: str,
port: int,
timeout: float | None,
) -> tuple[IPAddress, ...]:
# The scope closes before dialling; attempts are not nested inside it.
try:
with anyio.fail_after(timeout):
return await aresolve_public_addresses(host, port)
except TimeoutError as e:
raise httpcore.ConnectTimeout(f"Timed out resolving {host}") from e
except OutboundRequestBlockedError as e:
_log_block(e)
raise
except HostResolutionError as e:
raise httpcore.ConnectError(str(e)) from e
class _GuardedSyncBackend(httpcore.NetworkBackend):
"""
Wraps httpcore's sync backend. With internal addresses disallowed, it
resolves the origin host itself, rejects the name if any address is
non-public, and dials the validated literals so the checked address is
the connected one. TLS still verifies against the origin hostname.
"""
def __init__(self, inner: httpcore.NetworkBackend, *, allow_internal: bool) -> None:
self._inner = inner
self._allow_internal = allow_internal
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.NetworkStream:
if self._allow_internal:
return self._inner.connect_tcp(
host,
port,
timeout=timeout,
local_address=local_address,
socket_options=socket_options,
)
_require_positive_timeout(host, timeout)
# Resolution is not charged to the budget, matching the stock backend.
candidates = _attempt_order(_resolve_for_connect(host, port))
deadline = _deadline(timeout)
last_error: httpcore.ConnectError | httpcore.ConnectTimeout | None = None
for index, address in enumerate(candidates):
budget = _next_attempt_budget(host, deadline, candidates, index)
try:
return self._inner.connect_tcp(
str(address),
port,
timeout=_as_httpcore_timeout(budget),
local_address=local_address,
socket_options=socket_options,
)
except (httpcore.ConnectError, httpcore.ConnectTimeout) as e:
logger.debug("Connecting to %s via %s failed: %s", host, address, e)
last_error = e
# candidates is never empty, so every address was tried and failed
raise last_error or _budget_exhausted(host, len(candidates), len(candidates))
def connect_unix_socket(
self,
path: str,
timeout: float | None = None,
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.NetworkStream:
error = OutboundRequestBlockedError(
host=path,
port=None,
reason=BlockReason.UNIX_SOCKET,
)
_log_block(error)
raise error
def sleep(self, seconds: float) -> None:
self._inner.sleep(seconds)
class _GuardedAsyncBackend(httpcore.AsyncNetworkBackend):
"""Async twin of _GuardedSyncBackend."""
def __init__(
self,
inner: httpcore.AsyncNetworkBackend,
*,
allow_internal: bool,
) -> None:
self._inner = inner
self._allow_internal = allow_internal
async def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.AsyncNetworkStream:
if self._allow_internal:
return await self._inner.connect_tcp(
host,
port,
timeout=timeout,
local_address=local_address,
socket_options=socket_options,
)
_require_positive_timeout(host, timeout)
# Resolution counts against the budget, matching the stock backend.
deadline = _deadline(timeout)
candidates = _attempt_order(await _aresolve_for_connect(host, port, timeout))
last_error: httpcore.ConnectError | httpcore.ConnectTimeout | None = None
for index, address in enumerate(candidates):
budget = _next_attempt_budget(host, deadline, candidates, index)
try:
return await self._inner.connect_tcp(
str(address),
port,
timeout=_as_httpcore_timeout(budget),
local_address=local_address,
socket_options=socket_options,
)
except (httpcore.ConnectError, httpcore.ConnectTimeout) as e:
logger.debug("Connecting to %s via %s failed: %s", host, address, e)
last_error = e
raise last_error or _budget_exhausted(host, len(candidates), len(candidates))
async def connect_unix_socket(
self,
path: str,
timeout: float | None = None,
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.AsyncNetworkStream:
error = OutboundRequestBlockedError(
host=path,
port=None,
reason=BlockReason.UNIX_SOCKET,
)
_log_block(error)
raise error
async def sleep(self, seconds: float) -> None:
await self._inner.sleep(seconds)
_LAYOUT_ERROR = (
"Unexpected httpx transport layout; refusing to create a transport "
"without the outbound connection guard"
)
class GuardedHTTPTransport(httpx.HTTPTransport):
"""
httpx transport whose connections pass through the outbound guard.
Deliberately accepts no proxy, uds or retries options: a proxy would be
dialled instead of the destination, and a unix socket bypasses TCP
entirely. Adding an option here is a reviewed change, not a pass-through.
"""
def __init__(self, *, allow_internal: bool) -> None:
super().__init__()
# httpx has no public hook for the network backend. Check the exact
# layout before swapping so an httpx or httpcore change fails loudly.
pool = self._pool
if (
type(pool) is not httpcore.ConnectionPool
or type(pool._network_backend) is not httpcore.SyncBackend
):
raise RuntimeError(_LAYOUT_ERROR)
pool._network_backend = _GuardedSyncBackend(
pool._network_backend,
allow_internal=allow_internal,
)
class GuardedAsyncHTTPTransport(httpx.AsyncHTTPTransport):
"""Async twin of GuardedHTTPTransport."""
def __init__(self, *, allow_internal: bool) -> None:
super().__init__()
pool = self._pool
if (
type(pool) is not httpcore.AsyncConnectionPool
or type(pool._network_backend) is not AutoBackend
):
raise RuntimeError(_LAYOUT_ERROR)
pool._network_backend = _GuardedAsyncBackend(
pool._network_backend,
allow_internal=allow_internal,
)
def create_guarded_httpx_client(
url: str,
*,
allow_internal: bool,
timeout: float,
) -> httpx.Client:
"""
Validate ``url`` up front, then build a client that re-checks at connect
time. The up-front check turns static misconfiguration into a ValueError
before any retry layer sees it.
"""
validate_outbound_http_url(url, allow_internal=allow_internal)
return httpx.Client(
transport=GuardedHTTPTransport(allow_internal=allow_internal),
timeout=timeout,
)
def create_guarded_async_httpx_client(
url: str,
*,
allow_internal: bool,
timeout: float,
) -> httpx.AsyncClient:
"""Async twin of create_guarded_httpx_client."""
validate_outbound_http_url(url, allow_internal=allow_internal)
return httpx.AsyncClient(
transport=GuardedAsyncHTTPTransport(allow_internal=allow_internal),
timeout=timeout,
)
# urllib3 treats a backslash as ending the authority while urlparse and httpx do
# not, so the host checked here could differ from the one that is dialled.
# Control and whitespace characters are refused for the same reason.
_UNSAFE_URL_CHARS = re.compile(r"[\\\x00-\x1f\x7f\s]")
def _dns_name(url: str) -> str:
"""
The ASCII hostname that httpx and urllib3 look up for ``url``.
urlparse keeps a non-ASCII hostname as typed, and getaddrinfo would then
encode it with the stdlib IDNA 2003 codec. That maps some characters
differently from the IDNA 2008 encoding the HTTP clients use ("faß"
becomes "fass" instead of "xn--fa-hia"), so the check would resolve a
different name from the one that is connected to.
"""
try:
return httpx.URL(url).raw_host.decode("ascii")
except (httpx.InvalidURL, UnicodeError) as e:
raise ValueError("Invalid URL scheme or hostname.") from e
ip_obj = ipaddress.ip_address(host)
if ip_obj.version == 6:
return f"[{host}]"
return host
except ValueError:
return host
def validate_outbound_http_url(
@@ -553,17 +81,128 @@ def validate_outbound_http_url(
raise ValueError("Destination port not permitted.")
if not allow_internal:
if _UNSAFE_URL_CHARS.search(url):
raise ValueError("Invalid URL scheme or hostname.")
host = _dns_name(url)
# HTTP clients may percent-decode the host before resolving it, so the
# checked name could differ from the dialled one. An IPv6 zone id is the
# only legitimate use, and link-local addresses are non-public anyway.
if "%" in host:
raise ValueError("Invalid URL scheme or hostname.")
try:
resolve_public_addresses(host, port)
except (OutboundRequestBlockedError, HostResolutionError) as e:
raise ValueError(blocked_message(e)) from e
for ip_str in resolve_hostname_ips(parsed.hostname):
if not is_public_ip(ip_str):
raise ValueError(
f"Connection blocked: {parsed.hostname} resolves to a non-public address",
)
return parsed
def _rewrite_request_to_pinned_ip(
request: httpx.Request,
*,
allow_internal: bool,
) -> httpx.Request:
hostname = request.url.host
if not hostname:
raise httpx.ConnectError("No hostname in request URL")
try:
ips = resolve_hostname_ips(hostname)
except ValueError as e:
raise httpx.ConnectError(str(e)) from e
if not allow_internal:
for ip_str in ips:
if not is_public_ip(ip_str):
raise httpx.ConnectError(
f"Connection blocked: {hostname} resolves to a non-public address",
)
ip_str = ips[0]
formatted_ip = format_host_for_url(ip_str)
new_headers = httpx.Headers(request.headers)
if "host" in new_headers:
del new_headers["host"]
host_header = format_host_for_url(hostname)
default_port = 443 if request.url.scheme == "https" else 80
if request.url.port and request.url.port != default_port:
host_header = f"{host_header}:{request.url.port}"
new_headers["Host"] = host_header
new_url = request.url.copy_with(host=formatted_ip)
rewritten_request = httpx.Request(
method=request.method,
url=new_url,
headers=new_headers,
stream=request.stream,
extensions=request.extensions,
)
rewritten_request.extensions["sni_hostname"] = hostname
return rewritten_request
class PinnedHostHTTPTransport(httpx.HTTPTransport):
"""
HTTP transport that resolves/validates hostnames per request and connects to
a vetted IP while preserving the original Host header and TLS SNI hostname.
"""
def __init__(
self,
*args,
allow_internal: bool = False,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.allow_internal = allow_internal
def handle_request(self, request: httpx.Request) -> httpx.Response:
request = _rewrite_request_to_pinned_ip(
request,
allow_internal=self.allow_internal,
)
return super().handle_request(request)
class PinnedHostAsyncHTTPTransport(httpx.AsyncHTTPTransport):
"""
Async variant of PinnedHostHTTPTransport.
"""
def __init__(
self,
*args,
allow_internal: bool = False,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.allow_internal = allow_internal
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
request = _rewrite_request_to_pinned_ip(
request,
allow_internal=self.allow_internal,
)
return await super().handle_async_request(request)
def create_pinned_httpx_client(
url: str,
*,
allow_internal: bool = False,
**kwargs,
) -> httpx.Client:
validate_outbound_http_url(url, allow_internal=allow_internal)
return httpx.Client(
transport=PinnedHostHTTPTransport(allow_internal=allow_internal),
**kwargs,
)
def create_pinned_async_httpx_client(
url: str,
*,
allow_internal: bool = False,
**kwargs,
) -> httpx.AsyncClient:
validate_outbound_http_url(url, allow_internal=allow_internal)
return httpx.AsyncClient(
transport=PinnedHostAsyncHTTPTransport(allow_internal=allow_internal),
**kwargs,
)
+10 -1
View File
@@ -216,6 +216,11 @@ class ApplicationConfigurationSerializer(
externally_configured_variables = serializers.SerializerMethodField()
user_args = serializers.JSONField(binary=True, allow_null=True)
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
llm_embedding_api_key = ObfuscatedPasswordField(
required=False,
allow_null=True,
max_length=1024,
)
llm_api_key = ObfuscatedPasswordField(
required=False,
allow_null=True,
@@ -227,7 +232,11 @@ class ApplicationConfigurationSerializer(
max_length=1024,
)
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
OBFUSCATED_FIELDS = (
"llm_embedding_api_key",
"llm_api_key",
"remote_ocr_api_key",
)
def get_externally_configured_variables(
self,
+22
View File
@@ -7,6 +7,7 @@ import multiprocessing
import os
import tempfile
from pathlib import Path
from typing import Any
from typing import Final
from urllib.parse import urlparse
@@ -1081,6 +1082,25 @@ CLASSIFIER_LANGUAGES: Final[dict[str, str]] = {
}
def _get_llm_extra_params() -> dict[str, Any]:
"""
Parse PAPERLESS_AI_LLM_EXTRA_PARAMS, a JSON object passed straight through
to the LLM backend's request body.
"""
raw = os.getenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", "{}")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise ImproperlyConfigured(
"PAPERLESS_AI_LLM_EXTRA_PARAMS must be valid JSON",
) from e
if not isinstance(parsed, dict):
raise ImproperlyConfigured(
"PAPERLESS_AI_LLM_EXTRA_PARAMS must be a JSON object",
)
return parsed
def _get_classifier_language_setting(ocr_lang: str) -> str | None:
"""
Maps the primary Tesseract language to the classifier's stemming
@@ -1216,6 +1236,7 @@ LLM_EMBEDDING_BACKEND = get_choice_from_env(
{"huggingface", "openai-like", "ollama"},
)
LLM_EMBEDDING_MODEL = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_MODEL")
LLM_EMBEDDING_API_KEY = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_API_KEY")
LLM_EMBEDDING_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT")
LLM_EMBEDDING_CHUNK_SIZE = get_int_from_env(
"PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE",
@@ -1241,3 +1262,4 @@ LLM_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
"PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS",
"true",
)
LLM_EXTRA_PARAMS = _get_llm_extra_params()
@@ -17,6 +17,24 @@ class TestRemoteUser(DirectoriesMixin, APITestCase):
self.user = UserFactory(username="temp_admin", superuser=True)
# _parse_remote_user_settings() mutates these shared lists in place,
# so undo that after the test instead of leaking remote-user auth
# into every test that runs afterward.
original_middleware = list(settings.MIDDLEWARE)
original_auth_backends = list(settings.AUTHENTICATION_BACKENDS)
original_auth_classes = list(
settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"],
)
def _restore_remote_user_settings() -> None:
settings.MIDDLEWARE[:] = original_middleware
settings.AUTHENTICATION_BACKENDS[:] = original_auth_backends
settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"][:] = (
original_auth_classes
)
self.addCleanup(_restore_remote_user_settings)
def test_remote_user(self) -> None:
"""
GIVEN:
@@ -7,6 +7,7 @@ from django.core.exceptions import ImproperlyConfigured
from paperless.settings import _get_allauth_trusted_proxy_count
from paperless.settings import _get_classifier_language_setting
from paperless.settings import _get_llm_extra_params
from paperless.settings import _get_search_language_setting
from paperless.settings import _parse_paperless_url
from paperless.settings import default_threads_per_worker
@@ -166,3 +167,45 @@ class TestPaperlessURLSettings(TestCase):
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
class TestLlmExtraParams:
@pytest.mark.parametrize(
("env_value", "expected"),
[
pytest.param(None, {}, id="unset"),
pytest.param(
'{"reasoning_effort": "none"}',
{"reasoning_effort": "none"},
id="json-object",
),
],
)
def test_parses(
self,
monkeypatch,
env_value,
expected,
):
if env_value is None:
monkeypatch.delenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", raising=False)
else:
monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value)
assert _get_llm_extra_params() == expected
@pytest.mark.parametrize(
("env_value", "match"),
[
pytest.param("reasoning_effort=none", "valid JSON", id="invalid-json"),
pytest.param('["none"]', "JSON object", id="not-an-object"),
],
)
def test_invalid_raises(
self,
monkeypatch,
env_value,
match,
):
monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value)
with pytest.raises(ImproperlyConfigured, match=match):
_get_llm_extra_params()
@@ -30,3 +30,27 @@ class TestBooleanConfigPrecedence(TestCase):
config.save()
self.assertTrue(AIConfig().ai_enabled)
class TestAIConfigPrecedence(TestCase):
@override_settings(LLM_EMBEDDING_API_KEY="environment-embedding-key")
def test_database_embedding_api_key_overrides_environment_setting(self) -> None:
config, _ = ApplicationConfiguration.objects.get_or_create()
config.llm_embedding_api_key = "database-embedding-key"
config.save()
self.assertEqual(
AIConfig().llm_embedding_api_key,
"database-embedding-key",
)
@override_settings(LLM_EMBEDDING_API_KEY="environment-embedding-key")
def test_null_embedding_api_key_uses_environment_setting(self) -> None:
config, _ = ApplicationConfiguration.objects.get_or_create()
config.llm_embedding_api_key = None
config.save()
self.assertEqual(
AIConfig().llm_embedding_api_key,
"environment-embedding-key",
)
File diff suppressed because it is too large Load Diff
@@ -1,375 +0,0 @@
import ipaddress
import os
import httpcore
import httpx
import pytest
from pytest_mock import MockerFixture
from paperless.network import GuardedAsyncHTTPTransport
from paperless.network import GuardedHTTPTransport
from paperless.network import OutboundRequestBlockedError
from paperless.network import create_guarded_httpx_client
from paperless_testing.outbound import DialRecorder
from paperless_testing.outbound import FakeDNS
from paperless_testing.outbound import LocalHTTPServer
from paperless_testing.outbound import running_http_server
class TestGuardedTransportSync:
@pytest.mark.usefixtures("every_address_is_public")
def test_pinned_connection_falls_back_to_next_address(
self,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
dial_recorder: DialRecorder,
) -> None:
"""
GIVEN:
- A hostname resolving to ::1 then 127.0.0.1
- A server listening on 127.0.0.1 only
- Internal addresses disallowed, with loopback treated as public
WHEN:
- A request is made
THEN:
- ::1 fails, 127.0.0.1 is dialled next and the request succeeds
"""
fake_dns.add("dual-stack.test", "::1", "127.0.0.1")
with httpx.Client(
transport=GuardedHTTPTransport(allow_internal=False),
timeout=5.0,
) as client:
response = client.get(f"http://dual-stack.test:{local_http_server.port}/")
assert response.status_code == 200
assert dial_recorder.hosts() == ["::1", "127.0.0.1"]
def test_allow_internal_uses_stock_resolution(
self,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
) -> None:
"""
GIVEN:
- Internal addresses allowed
WHEN:
- A request is made to localhost
THEN:
- It succeeds without the guard resolving anything
"""
with httpx.Client(
transport=GuardedHTTPTransport(allow_internal=True),
timeout=5.0,
) as client:
response = client.get(f"http://localhost:{local_http_server.port}/")
assert response.status_code == 200
assert fake_dns.lookups == []
@pytest.mark.usefixtures("every_address_is_public")
def test_host_header_is_the_hostname(
self,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
) -> None:
"""
GIVEN:
- A pinned connection to a named host
WHEN:
- A request is made
THEN:
- The server receives the hostname in Host, not the dialled IP
"""
fake_dns.add("pinned.test", "127.0.0.1")
with httpx.Client(
transport=GuardedHTTPTransport(allow_internal=False),
timeout=5.0,
) as client:
client.get(f"http://pinned.test:{local_http_server.port}/")
assert local_http_server.requests[0].headers["host"] == (
f"pinned.test:{local_http_server.port}"
)
def test_redirect_to_internal_host_is_blocked(
self,
mocker: MockerFixture,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
dial_recorder: DialRecorder,
) -> None:
"""
GIVEN:
- An allowed origin that redirects to a host resolving to a blocked
address, and a client that follows redirects
WHEN:
- The origin is requested
THEN:
- The redirect hop is blocked without dialling the blocked address
"""
allowed = ipaddress.ip_address("127.0.0.1")
mocker.patch(
"paperless.network.is_public_ip",
side_effect=lambda address: address == allowed,
)
fake_dns.add("origin.test", "127.0.0.1")
fake_dns.add("internal.test", "127.0.0.2")
local_http_server.redirect_to = (
f"http://internal.test:{local_http_server.port}/"
)
with (
httpx.Client(
transport=GuardedHTTPTransport(allow_internal=False),
timeout=5.0,
follow_redirects=True,
) as client,
pytest.raises(OutboundRequestBlockedError) as exc_info,
):
client.get(f"http://origin.test:{local_http_server.port}/")
assert exc_info.value.address == ipaddress.ip_address("127.0.0.2")
assert dial_recorder.hosts() == ["127.0.0.1"]
assert len(local_http_server.requests) == 1
@pytest.mark.usefixtures("every_address_is_public")
def test_connections_are_not_shared_between_hosts_on_one_address(
self,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
dial_recorder: DialRecorder,
) -> None:
"""
GIVEN:
- Two hostnames resolving to the same address
- Internal addresses disallowed, with loopback treated as public
WHEN:
- One client requests the first host twice, then the second host
THEN:
- The first host's connection is reused for its second request
- The second host gets its own connection, so its certificate would
be checked rather than inheriting the first host's session
"""
fake_dns.add("first.test", "127.0.0.1")
fake_dns.add("second.test", "127.0.0.1")
with httpx.Client(
transport=GuardedHTTPTransport(allow_internal=False),
timeout=5.0,
) as client:
client.get(f"http://first.test:{local_http_server.port}/")
client.get(f"http://first.test:{local_http_server.port}/")
client.get(f"http://second.test:{local_http_server.port}/")
assert dial_recorder.hosts() == ["127.0.0.1", "127.0.0.1"]
assert local_http_server.connections == 2
assert [request.headers["host"] for request in local_http_server.requests] == [
f"first.test:{local_http_server.port}",
f"first.test:{local_http_server.port}",
f"second.test:{local_http_server.port}",
]
@pytest.mark.usefixtures("every_address_is_public")
def test_tls_uses_the_hostname_not_the_dialled_address(
self,
mocker: MockerFixture,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
dial_recorder: DialRecorder,
) -> None:
"""
GIVEN:
- A pinned HTTPS connection to a named host
- A plain HTTP server, so the handshake itself fails
WHEN:
- A request is made
THEN:
- The validated address is dialled
- TLS is started with the hostname for SNI and certificate checks
"""
fake_dns.add("pinned.test", "127.0.0.1")
start_tls = mocker.spy(httpcore._backends.sync.SyncStream, "start_tls")
with (
httpx.Client(
transport=GuardedHTTPTransport(allow_internal=False),
timeout=5.0,
) as client,
pytest.raises(httpx.ConnectError),
):
client.get(f"https://pinned.test:{local_http_server.port}/")
assert dial_recorder.hosts() == ["127.0.0.1"]
start_tls.assert_called_once()
assert start_tls.call_args.kwargs["server_hostname"] == "pinned.test"
@pytest.mark.parametrize(
"host",
[
pytest.param("localhost", id="name"),
pytest.param("2130706433", id="decimal"),
pytest.param("0x7f.1", id="hex-short"),
pytest.param("127.1", id="short-dotted"),
],
)
def test_blocks_internal_host_without_connecting(
self,
local_http_server: LocalHTTPServer,
dial_recorder: DialRecorder,
host: str,
) -> None:
"""
GIVEN:
- Internal addresses disallowed
- A URL whose host reaches loopback, by name or by a
non-canonical spelling of 127.0.0.1
WHEN:
- A request is made through the transport
THEN:
- The resolved address is checked, the request is blocked and the
server never sees a connection
"""
with (
httpx.Client(
transport=GuardedHTTPTransport(allow_internal=False),
timeout=5.0,
) as client,
pytest.raises(OutboundRequestBlockedError),
):
client.get(f"http://{host}:{local_http_server.port}/")
assert local_http_server.connections == 0
assert dial_recorder.hosts() == []
@pytest.mark.usefixtures("every_address_is_public")
def test_environment_proxy_is_not_used(
self,
mocker: MockerFixture,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
dial_recorder: DialRecorder,
) -> None:
"""
GIVEN:
- Proxy variables in the environment pointing at a second local server
- Internal addresses disallowed
WHEN:
- A request is made through the production client factory to an
allowed origin
THEN:
- The origin server receives the request directly and the proxy
server never sees a connection
"""
with running_http_server() as proxy_server:
mocker.patch.dict(
os.environ,
{
"HTTP_PROXY": f"http://127.0.0.1:{proxy_server.port}",
"HTTPS_PROXY": f"http://127.0.0.1:{proxy_server.port}",
"ALL_PROXY": f"http://127.0.0.1:{proxy_server.port}",
},
)
fake_dns.add("origin.test", "127.0.0.1")
url = f"http://origin.test:{local_http_server.port}/"
with create_guarded_httpx_client(
url,
allow_internal=False,
timeout=5.0,
) as client:
response = client.get(url)
assert response.status_code == 200
assert len(local_http_server.requests) == 1
assert local_http_server.requests[0].headers["host"] == (
f"origin.test:{local_http_server.port}"
)
assert proxy_server.connections == 0
assert proxy_server.requests == []
assert dial_recorder.hosts() == ["127.0.0.1"]
class TestGuardedTransportAsync:
@pytest.fixture(autouse=True)
def anyio_backend(self) -> str:
return "asyncio"
@pytest.mark.anyio
@pytest.mark.usefixtures("every_address_is_public")
async def test_pinned_connection_falls_back_to_next_address(
self,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
dial_recorder: DialRecorder,
) -> None:
"""
GIVEN:
- A hostname resolving to ::1 then 127.0.0.1
- A server listening on 127.0.0.1 only
- Internal addresses disallowed, with loopback treated as public
WHEN:
- An async request is made
THEN:
- ::1 fails, 127.0.0.1 is dialled next and the request succeeds
"""
fake_dns.add("dual-stack.test", "::1", "127.0.0.1")
async with httpx.AsyncClient(
transport=GuardedAsyncHTTPTransport(allow_internal=False),
timeout=5.0,
) as client:
response = await client.get(
f"http://dual-stack.test:{local_http_server.port}/",
)
assert response.status_code == 200
assert dial_recorder.hosts() == ["::1", "127.0.0.1"]
@pytest.mark.anyio
async def test_allow_internal_uses_stock_resolution(
self,
local_http_server: LocalHTTPServer,
fake_dns: FakeDNS,
) -> None:
"""
GIVEN:
- Internal addresses allowed
WHEN:
- An async request is made to localhost
THEN:
- It succeeds without the guard resolving anything
"""
async with httpx.AsyncClient(
transport=GuardedAsyncHTTPTransport(allow_internal=True),
timeout=5.0,
) as client:
response = await client.get(f"http://localhost:{local_http_server.port}/")
assert response.status_code == 200
assert fake_dns.lookups == []
@pytest.mark.anyio
async def test_blocks_internal_host_without_connecting(
self,
local_http_server: LocalHTTPServer,
dial_recorder: DialRecorder,
) -> None:
"""
GIVEN:
- Internal addresses disallowed
WHEN:
- An async request is made to localhost through the transport
THEN:
- It is blocked and the server never sees a connection
"""
async with httpx.AsyncClient(
transport=GuardedAsyncHTTPTransport(allow_internal=False),
timeout=5.0,
) as client:
with pytest.raises(OutboundRequestBlockedError):
await client.get(f"http://localhost:{local_http_server.port}/")
assert local_http_server.connections == 0
assert dial_recorder.hosts() == []
+37 -37
View File
@@ -15,43 +15,43 @@ from drf_spectacular.views import SpectacularAPIView
from drf_spectacular.views import SpectacularSwaggerView
from rest_framework.routers import DefaultRouter
from documents.views import BulkDownloadView
from documents.views import BulkEditObjectsView
from documents.views import BulkEditView
from documents.views import ChatStreamingView
from documents.views import CorrespondentViewSet
from documents.views import CustomFieldViewSet
from documents.views import DeleteDocumentsView
from documents.views import DocumentTypeViewSet
from documents.views import EditPdfDocumentsView
from documents.views import GlobalSearchView
from documents.views import IndexView
from documents.views import LogViewSet
from documents.views import MergeDocumentsAsVersionsView
from documents.views import MergeDocumentsView
from documents.views import PostDocumentView
from documents.views import RemoteVersionView
from documents.views import RemovePasswordDocumentsView
from documents.views import ReprocessDocumentsView
from documents.views import RotateDocumentsView
from documents.views import SavedViewViewSet
from documents.views import SearchAutoCompleteView
from documents.views import SelectionDataView
from documents.views import SharedLinkView
from documents.views import ShareLinkBundleViewSet
from documents.views import ShareLinkViewSet
from documents.views import StatisticsView
from documents.views import StoragePathViewSet
from documents.views import SystemStatusView
from documents.views import TagViewSet
from documents.views import TasksViewSet
from documents.views import TrashView
from documents.views import UiSettingsView
from documents.views import UnifiedSearchViewSet
from documents.views import WorkflowActionViewSet
from documents.views import WorkflowTriggerViewSet
from documents.views import WorkflowViewSet
from documents.views import serve_logo
from documents.views.bulk_edit import BulkDownloadView
from documents.views.bulk_edit import BulkEditObjectsView
from documents.views.bulk_edit import BulkEditView
from documents.views.bulk_edit import DeleteDocumentsView
from documents.views.bulk_edit import EditPdfDocumentsView
from documents.views.bulk_edit import MergeDocumentsAsVersionsView
from documents.views.bulk_edit import MergeDocumentsView
from documents.views.bulk_edit import RemovePasswordDocumentsView
from documents.views.bulk_edit import ReprocessDocumentsView
from documents.views.bulk_edit import RotateDocumentsView
from documents.views.chat import ChatStreamingView
from documents.views.documents import UnifiedSearchViewSet
from documents.views.index import IndexView
from documents.views.index import serve_logo
from documents.views.logs import LogViewSet
from documents.views.metadata import CorrespondentViewSet
from documents.views.metadata import CustomFieldViewSet
from documents.views.metadata import DocumentTypeViewSet
from documents.views.metadata import StoragePathViewSet
from documents.views.metadata import TagViewSet
from documents.views.saved_views import SavedViewViewSet
from documents.views.search import GlobalSearchView
from documents.views.search import SearchAutoCompleteView
from documents.views.search import SelectionDataView
from documents.views.search import StatisticsView
from documents.views.sharing import SharedLinkView
from documents.views.sharing import ShareLinkBundleViewSet
from documents.views.sharing import ShareLinkViewSet
from documents.views.system import RemoteVersionView
from documents.views.system import SystemStatusView
from documents.views.system import TrashView
from documents.views.system import UiSettingsView
from documents.views.tasks import TasksViewSet
from documents.views.upload import PostDocumentView
from documents.views.workflows import WorkflowActionViewSet
from documents.views.workflows import WorkflowTriggerViewSet
from documents.views.workflows import WorkflowViewSet
from paperless.consumers import StatusConsumer
from paperless.views import ApplicationConfigurationViewSet
from paperless.views import DisconnectSocialAccountView
+10 -29
View File
@@ -14,16 +14,14 @@ if TYPE_CHECKING:
from llama_index.llms.openai_like import OpenAILike
from paperless.config import AIConfig
from paperless.network import GuardedAsyncHTTPTransport
from paperless.network import GuardedHTTPTransport
from paperless.network import OutboundRequestBlockedError
from paperless.network import create_guarded_async_httpx_client
from paperless.network import create_guarded_httpx_client
from paperless.network import PinnedHostAsyncHTTPTransport
from paperless.network import PinnedHostHTTPTransport
from paperless.network import create_pinned_async_httpx_client
from paperless.network import create_pinned_httpx_client
from paperless.network import validate_outbound_http_url
from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import DocumentClassifierSchema
from paperless_ai.base_model import model_to_classification_suggestions
from paperless_ai.exceptions import LLMBlockedError
from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError
@@ -45,19 +43,6 @@ LLM_SYSTEM_PROMPT = (
PLACEHOLDER_API_KEY: Final = "fake"
def _find_blocked_cause(exc: BaseException) -> OutboundRequestBlockedError | None:
# The openai SDK wraps transport errors in APIConnectionError, so the
# block can sit anywhere in the __cause__ chain.
current: BaseException | None = exc
seen: set[int] = set()
while current is not None and id(current) not in seen:
if isinstance(current, OutboundRequestBlockedError):
return current
seen.add(id(current))
current = current.__cause__
return None
class AIClient:
"""
A client for interacting with an LLM backend.
@@ -78,10 +63,10 @@ class AIClient:
endpoint,
allow_internal=self.settings.llm_allow_internal_endpoints,
)
transport = GuardedHTTPTransport(
transport = PinnedHostHTTPTransport(
allow_internal=self.settings.llm_allow_internal_endpoints,
)
async_transport = GuardedAsyncHTTPTransport(
async_transport = PinnedHostAsyncHTTPTransport(
allow_internal=self.settings.llm_allow_internal_endpoints,
)
return Ollama(
@@ -90,6 +75,7 @@ class AIClient:
context_window=self.settings.llm_context_size,
request_timeout=self.settings.llm_request_timeout,
system_prompt=LLM_SYSTEM_PROMPT,
additional_kwargs=self.settings.llm_extra_params,
client=Client(
host=endpoint,
timeout=self.settings.llm_request_timeout,
@@ -108,12 +94,12 @@ class AIClient:
http_client = None
async_http_client = None
if endpoint:
http_client = create_guarded_httpx_client(
http_client = create_pinned_httpx_client(
endpoint,
allow_internal=self.settings.llm_allow_internal_endpoints,
timeout=self.settings.llm_request_timeout,
)
async_http_client = create_guarded_async_httpx_client(
async_http_client = create_pinned_async_httpx_client(
endpoint,
allow_internal=self.settings.llm_allow_internal_endpoints,
timeout=self.settings.llm_request_timeout,
@@ -126,6 +112,7 @@ class AIClient:
is_chat_model=True,
is_function_calling_model=True,
system_prompt=LLM_SYSTEM_PROMPT,
additional_kwargs=self.settings.llm_extra_params,
http_client=http_client,
async_http_client=async_http_client,
)
@@ -194,12 +181,6 @@ class AIClient:
except httpx.TimeoutException as exc:
raise LLMTimeoutError from exc
except Exception as exc:
blocked = _find_blocked_cause(exc)
if blocked is not None:
raise LLMBlockedError(
"AI backend request was blocked by the outbound request "
f"policy: {blocked}",
) from exc
if self._is_openai_timeout(exc):
raise LLMTimeoutError from exc
if self._is_provider_error(exc):
+11 -9
View File
@@ -9,10 +9,10 @@ if TYPE_CHECKING:
from documents.models import Document
from paperless.config import AIConfig
from paperless.models import LLMEmbeddingBackend
from paperless.network import GuardedAsyncHTTPTransport
from paperless.network import GuardedHTTPTransport
from paperless.network import create_guarded_async_httpx_client
from paperless.network import create_guarded_httpx_client
from paperless.network import PinnedHostAsyncHTTPTransport
from paperless.network import PinnedHostHTTPTransport
from paperless.network import create_pinned_async_httpx_client
from paperless.network import create_pinned_httpx_client
from paperless.network import validate_outbound_http_url
from paperless_ai.client import PLACEHOLDER_API_KEY
@@ -29,19 +29,21 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
http_client = None
async_http_client = None
if endpoint:
http_client = create_guarded_httpx_client(
http_client = create_pinned_httpx_client(
endpoint,
allow_internal=config.llm_allow_internal_endpoints,
timeout=config.llm_request_timeout,
)
async_http_client = create_guarded_async_httpx_client(
async_http_client = create_pinned_async_httpx_client(
endpoint,
allow_internal=config.llm_allow_internal_endpoints,
timeout=config.llm_request_timeout,
)
return OpenAILikeEmbedding(
model_name=config.llm_embedding_model or "text-embedding-3-small",
api_key=config.llm_api_key or PLACEHOLDER_API_KEY,
api_key=config.llm_embedding_api_key
or config.llm_api_key
or PLACEHOLDER_API_KEY,
api_base=endpoint,
timeout=config.llm_request_timeout,
http_client=http_client,
@@ -77,14 +79,14 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
embedding._client = Client(
host=endpoint,
timeout=config.llm_request_timeout,
transport=GuardedHTTPTransport(
transport=PinnedHostHTTPTransport(
allow_internal=config.llm_allow_internal_endpoints,
),
)
embedding._async_client = AsyncClient(
host=endpoint,
timeout=config.llm_request_timeout,
transport=GuardedAsyncHTTPTransport(
transport=PinnedHostAsyncHTTPTransport(
allow_internal=config.llm_allow_internal_endpoints,
),
)
-4
View File
@@ -4,7 +4,3 @@ class LLMTimeoutError(Exception):
class LLMProviderError(Exception):
"""The LLM backend rejected the request."""
class LLMBlockedError(Exception):
"""The outbound request policy refused the connection to the LLM backend."""
+1 -1
View File
@@ -1,7 +1,7 @@
{# NOTE: {context_str}/{query_str} below are llama_index PromptTemplate
placeholders, filled in at query time. They are not Jinja variables. Do
not change them to {{ }}. output_language may come from user-controlled
ui_settings (see documents/views.py's _get_llm_output_language) and is
ui_settings (see paperless_ai.ai_classifier.get_llm_output_language) and is
not guaranteed brace-free, so it goes through the replace filter below
to escape '{'/'}' into '{{'/'}}'. This rendered template still goes
through llama_index's .format() later, and unescaped braces there would
+33 -144
View File
@@ -1,4 +1,3 @@
import ipaddress
import json
from unittest.mock import ANY
from unittest.mock import MagicMock
@@ -10,15 +9,11 @@ import openai
import pytest
from llama_index.core.llms.llm import ToolSelection
from paperless.network import BlockReason
from paperless.network import OutboundRequestBlockedError
from paperless_ai.client import LLM_SYSTEM_PROMPT
from paperless_ai.client import PLACEHOLDER_API_KEY
from paperless_ai.client import AIClient
from paperless_ai.exceptions import LLMBlockedError
from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError
from paperless_testing.outbound import guard_of
@pytest.fixture
@@ -28,6 +23,7 @@ def mock_ai_config():
mock_config.llm_allow_internal_endpoints = True
mock_config.llm_context_size = 8192
mock_config.llm_request_timeout = 120
mock_config.llm_extra_params = {}
MockAIConfig.return_value = mock_config
yield mock_config
@@ -57,6 +53,7 @@ def test_get_llm_ollama(mock_ai_config, mock_ollama_llm):
context_window=8192,
request_timeout=120,
system_prompt=LLM_SYSTEM_PROMPT,
additional_kwargs={},
client=ANY,
async_client=ANY,
)
@@ -79,6 +76,7 @@ def test_get_llm_openai(mock_ai_config, mock_openai_llm):
is_chat_model=True,
is_function_calling_model=True,
system_prompt=LLM_SYSTEM_PROMPT,
additional_kwargs={},
http_client=ANY,
async_http_client=ANY,
)
@@ -201,6 +199,36 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
)
@pytest.mark.parametrize(
("backend", "llm_fixture"),
[
pytest.param("openai-like", "mock_openai_llm", id="openai-like"),
pytest.param("ollama", "mock_ollama_llm", id="ollama"),
],
)
def test_get_llm_passes_extra_params(request, mock_ai_config, backend, llm_fixture):
"""
GIVEN:
- Extra LLM params configured, e.g. for a provider that needs a
parameter we do not set ourselves
WHEN:
- The client builds the LLM
THEN:
- They are handed to the backend as additional_kwargs
"""
llm_mock = request.getfixturevalue(llm_fixture)
mock_ai_config.llm_backend = backend
mock_ai_config.llm_model = "gpt-5.6-luna"
mock_ai_config.llm_endpoint = "http://test-url"
mock_ai_config.llm_extra_params = {"reasoning_effort": "none"}
AIClient()
assert llm_mock.call_args.kwargs["additional_kwargs"] == {
"reasoning_effort": "none",
}
def test_run_llm_query_openai_timeout_raises_local_error(
mock_ai_config,
mock_openai_llm,
@@ -282,142 +310,3 @@ def test_run_llm_query_httpx_timeout_raises_local_error(
with pytest.raises(LLMTimeoutError):
client.run_llm_query("test_prompt")
class TestGuardedLLMClients:
@pytest.mark.parametrize(
("endpoint", "allow_internal"),
[
pytest.param("http://test-url", True, id="internal-allowed"),
pytest.param("http://93.184.216.34:11434", False, id="internal-blocked"),
],
)
def test_ollama_clients_are_guarded(
self,
mock_ai_config: MagicMock,
mock_ollama_llm: MagicMock,
endpoint: str,
*,
allow_internal: bool,
) -> None:
"""
GIVEN:
- The Ollama backend
WHEN:
- The LLM is built
THEN:
- Its sync and async clients use guarded transports with the setting
"""
mock_ai_config.llm_backend = "ollama"
mock_ai_config.llm_model = "test_model"
mock_ai_config.llm_endpoint = endpoint
mock_ai_config.llm_allow_internal_endpoints = allow_internal
AIClient()
kwargs = mock_ollama_llm.call_args.kwargs
assert guard_of(kwargs["client"]._client)._allow_internal is allow_internal
assert (
guard_of(kwargs["async_client"]._client)._allow_internal is allow_internal
)
@pytest.mark.parametrize(
("endpoint", "allow_internal"),
[
pytest.param("http://test-url", True, id="internal-allowed"),
pytest.param("http://93.184.216.34:8080", False, id="internal-blocked"),
],
)
def test_openai_like_clients_are_guarded(
self,
mock_ai_config: MagicMock,
mock_openai_llm: MagicMock,
endpoint: str,
*,
allow_internal: bool,
) -> None:
"""
GIVEN:
- The OpenAI-like backend with an endpoint
WHEN:
- The LLM is built
THEN:
- Its sync and async http clients use guarded transports
"""
mock_ai_config.llm_backend = "openai-like"
mock_ai_config.llm_model = "test_model"
mock_ai_config.llm_api_key = "key"
mock_ai_config.llm_endpoint = endpoint
mock_ai_config.llm_allow_internal_endpoints = allow_internal
AIClient()
kwargs = mock_openai_llm.call_args.kwargs
assert guard_of(kwargs["http_client"])._allow_internal is allow_internal
assert guard_of(kwargs["async_http_client"])._allow_internal is allow_internal
def _block() -> OutboundRequestBlockedError:
return OutboundRequestBlockedError(
host="llm.example",
port=443,
reason=BlockReason.NON_PUBLIC_ADDRESS,
address=ipaddress.ip_address("10.0.0.1"),
)
class TestBlockedLLMRequests:
def test_ollama_block_becomes_llm_blocked_error(
self,
mock_ai_config: MagicMock,
mock_ollama_llm: MagicMock,
) -> None:
"""
GIVEN:
- The Ollama backend and a connection blocked by policy
WHEN:
- An LLM query runs
THEN:
- LLMBlockedError is raised with a message, chained to the block
- The message, which tracked tasks store, names the destination but
not the resolved internal address
"""
mock_ai_config.llm_backend = "ollama"
mock_ai_config.llm_model = "test_model"
mock_ai_config.llm_endpoint = "http://test-url"
block = _block()
mock_ollama_llm.return_value.chat.side_effect = block
with pytest.raises(LLMBlockedError) as exc_info:
AIClient().run_llm_query("test_prompt")
assert exc_info.value.__cause__ is block
assert "llm.example:443" in str(exc_info.value)
assert "10.0.0.1" not in str(exc_info.value)
def test_openai_wrapped_block_becomes_llm_blocked_error(
self,
mock_ai_config: MagicMock,
mock_openai_llm: MagicMock,
) -> None:
"""
GIVEN:
- The OpenAI-like backend, whose SDK wraps the block in
APIConnectionError
WHEN:
- An LLM query runs
THEN:
- LLMBlockedError is raised
"""
mock_ai_config.llm_backend = "openai-like"
mock_ai_config.llm_model = "test_model"
mock_ai_config.llm_api_key = "key"
mock_ai_config.llm_endpoint = "http://test-url"
wrapped = openai.APIConnectionError(
request=httpx.Request("POST", "http://test-url/v1/chat/completions"),
)
wrapped.__cause__ = _block()
mock_openai_llm.return_value.chat_with_tools.side_effect = wrapped
with pytest.raises(LLMBlockedError):
AIClient().run_llm_query("test_prompt")
+18 -66
View File
@@ -1,12 +1,9 @@
from typing import TYPE_CHECKING
from typing import cast
from unittest.mock import ANY
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from django.conf import settings
from pytest_mock import MockerFixture
from documents.models import Document
from paperless.models import LLMEmbeddingBackend
@@ -15,15 +12,12 @@ from paperless_ai.embedding import _normalize_llm_index_text
from paperless_ai.embedding import build_llm_index_text
from paperless_ai.embedding import get_configured_model_name
from paperless_ai.embedding import get_embedding_model
from paperless_testing.outbound import guard_of
if TYPE_CHECKING:
from llama_index.embeddings.ollama import OllamaEmbedding
@pytest.fixture
def mock_ai_config():
with patch("paperless_ai.embedding.AIConfig") as MockAIConfig:
MockAIConfig.return_value.llm_embedding_api_key = None
MockAIConfig.return_value.llm_embedding_endpoint = None
MockAIConfig.return_value.llm_allow_internal_endpoints = True
MockAIConfig.return_value.llm_context_size = 8192
@@ -70,6 +64,7 @@ def mock_document():
def test_get_embedding_model_openai(mock_ai_config):
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
mock_ai_config.return_value.llm_embedding_api_key = "test_embedding_api_key"
mock_ai_config.return_value.llm_api_key = "test_api_key"
mock_ai_config.return_value.llm_endpoint = "http://test-url"
@@ -79,7 +74,7 @@ def test_get_embedding_model_openai(mock_ai_config):
model = get_embedding_model(mock_ai_config.return_value)
MockOpenAIEmbedding.assert_called_once_with(
model_name="text-embedding-3-small",
api_key="test_api_key",
api_key="test_embedding_api_key",
api_base="http://test-url",
timeout=120,
http_client=ANY,
@@ -88,6 +83,20 @@ def test_get_embedding_model_openai(mock_ai_config):
assert model == MockOpenAIEmbedding.return_value
def test_get_embedding_model_openai_falls_back_to_llm_api_key(mock_ai_config):
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
mock_ai_config.return_value.llm_api_key = "test_api_key"
mock_ai_config.return_value.llm_endpoint = "http://test-url"
with patch(
"llama_index.embeddings.openai_like.OpenAILikeEmbedding",
) as MockOpenAIEmbedding:
get_embedding_model(mock_ai_config.return_value)
assert MockOpenAIEmbedding.call_args.kwargs["api_key"] == "test_api_key"
@pytest.mark.parametrize("configured_key", [None, ""])
def test_get_embedding_model_openai_without_api_key_sends_placeholder(
mock_ai_config,
@@ -96,6 +105,7 @@ def test_get_embedding_model_openai_without_api_key_sends_placeholder(
"""Same required key handling as the LLM client, see #13831."""
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
mock_ai_config.return_value.llm_embedding_api_key = configured_key
mock_ai_config.return_value.llm_api_key = configured_key
mock_ai_config.return_value.llm_endpoint = "http://test-url"
@@ -290,61 +300,3 @@ def test_normalize_llm_index_text_collapses_ocr_leaders_without_joining_lines():
def test_normalize_llm_index_text_collapses_non_breaking_spaces():
assert _normalize_llm_index_text("A\u00a0........\u00a0B") == "A B"
class TestGuardedEmbeddingClients:
def test_ollama_embedding_clients_are_guarded(
self,
mocker: MockerFixture,
mock_ai_config: MagicMock,
) -> None:
"""
GIVEN:
- The Ollama embedding backend
WHEN:
- The embedding model is built
THEN:
- The clients swapped onto it use guarded transports
"""
config = mock_ai_config.return_value
config.llm_embedding_backend = LLMEmbeddingBackend.OLLAMA
config.llm_embedding_model = "embeddinggemma"
config.llm_endpoint = "http://93.184.216.34:11434"
config.llm_allow_internal_endpoints = False
mocker.patch("llama_index.embeddings.ollama.OllamaEmbedding")
model = cast("OllamaEmbedding", get_embedding_model(config))
assert guard_of(model._client._client)._allow_internal is False
assert guard_of(model._async_client._client)._allow_internal is False
def test_openai_like_embedding_clients_are_guarded(
self,
mocker: MockerFixture,
mock_ai_config: MagicMock,
) -> None:
"""
GIVEN:
- The OpenAI-like embedding backend with an endpoint
WHEN:
- The embedding model is built
THEN:
- Its http clients use guarded transports
"""
config = mock_ai_config.return_value
config.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
config.llm_embedding_model = "text-embedding-3-small"
config.llm_api_key = "key"
config.llm_endpoint = "http://93.184.216.34:8080"
config.llm_allow_internal_endpoints = False
embedding_class = mocker.patch(
"llama_index.embeddings.openai_like.OpenAILikeEmbedding",
)
get_embedding_model(config)
kwargs = embedding_class.call_args.kwargs
assert guard_of(kwargs["http_client"])._allow_internal is False
assert guard_of(kwargs["async_http_client"])._allow_internal is False
+21 -43
View File
@@ -45,11 +45,8 @@ from documents.models import Correspondent
from documents.models import PaperlessTask
from documents.parsers import is_mime_type_supported
from documents.tasks import consume_file
from paperless.network import HostResolutionError
from paperless.network import IPAddress
from paperless.network import OutboundRequestBlockedError
from paperless.network import blocked_message
from paperless.network import resolve_public_addresses
from paperless.network import is_public_ip
from paperless.network import resolve_hostname_ips
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail
@@ -448,34 +445,18 @@ class PinnedIMAP4(imaplib.IMAP4):
Without pinned addresses, and with the ssl_context of the matching imaplib
class, this behaves exactly like imaplib.IMAP4 / imaplib.IMAP4_SSL.
``pinned_ips`` of ``None`` means no pinning was requested and the stock
imaplib connection path is used. An empty tuple means pinning was requested
and yielded nothing, and the connection fails without opening a socket
rather than falling back to a hostname lookup.
"""
def __init__(
self,
host: str,
port: int | None,
pinned_ips: tuple[IPAddress, ...] | None,
ssl_context: ssl.SSLContext | None = None,
timeout: float | None = None,
) -> None:
def __init__(self, host, port, pinned_ips, ssl_context=None, timeout=None) -> None:
self._pinned_ips = pinned_ips
self.ssl_context = ssl_context
super().__init__(host, port, timeout=timeout)
def _connect_pinned(
self,
pinned_ips: tuple[IPAddress, ...],
timeout: float | None,
) -> socket.socket:
def _connect_pinned(self, timeout):
last_error: OSError | None = None
for ip in pinned_ips:
for ip_str in self._pinned_ips:
try:
address = (str(ip), self.port)
address = (ip_str, self.port)
if timeout is not None:
return socket.create_connection(address, timeout)
return socket.create_connection(address)
@@ -483,9 +464,9 @@ class PinnedIMAP4(imaplib.IMAP4):
last_error = e
raise last_error or OSError(f"Could not connect to {self.host}")
def _create_socket(self, timeout: float | None) -> socket.socket:
if self._pinned_ips is not None:
sock = self._connect_pinned(self._pinned_ips, timeout)
def _create_socket(self, timeout):
if self._pinned_ips:
sock = self._connect_pinned(timeout)
else:
sock = super()._create_socket(timeout)
if self.ssl_context is None:
@@ -496,12 +477,7 @@ class PinnedIMAP4(imaplib.IMAP4):
class PinnedClientMixin:
"""Builds the imaplib client against the pre-resolved addresses, if any."""
def __init__(
self,
*args,
pinned_ips: tuple[IPAddress, ...] | None,
**kwargs,
) -> None:
def __init__(self, *args, pinned_ips: list[str] | None, **kwargs) -> None:
self._pinned_ips = pinned_ips
super().__init__(*args, **kwargs)
@@ -539,20 +515,22 @@ class PinnedMailBoxStartTls(PinnedClientMixin, MailBoxStartTls):
return client
def get_mailbox(
server: str,
port: int | None,
security: int,
) -> MailBox:
def get_mailbox(server, port, security) -> MailBox:
"""
Returns the correct MailBox instance for the given configuration.
"""
pinned_ips: tuple[IPAddress, ...] | None = None
pinned_ips: list[str] | None = None
if not settings.EMAIL_ALLOW_INTERNAL_HOSTS:
try:
pinned_ips = resolve_public_addresses(server, port)
except (OutboundRequestBlockedError, HostResolutionError) as e:
raise MailError(blocked_message(e)) from e
pinned_ips = resolve_hostname_ips(server)
except ValueError as e:
raise MailError(str(e)) from e
for ip_str in pinned_ips:
if not is_public_ip(ip_str):
raise MailError(
f"Connection blocked: {server} resolves to a non-public address",
)
ssl_context = ssl.create_default_context()
if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: no cover
+4 -4
View File
@@ -4,10 +4,10 @@ from rest_framework.exceptions import PermissionDenied
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import has_perms_owner_aware
from documents.serialisers import CorrespondentField
from documents.serialisers import DocumentTypeField
from documents.serialisers import OwnedObjectSerializer
from documents.serialisers import TagsField
from documents.serialisers.base import OwnedObjectSerializer
from documents.serialisers.metadata import CorrespondentField
from documents.serialisers.metadata import DocumentTypeField
from documents.serialisers.metadata import TagsField
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail
+23 -65
View File
@@ -1,12 +1,9 @@
import dataclasses
import ipaddress
import socket
import time
import uuid
from collections import namedtuple
from datetime import timedelta
from unittest import mock
from unittest.mock import MagicMock
import pytest
from django.contrib.auth.models import Permission
@@ -28,7 +25,6 @@ from documents.models import MatchingModel
from paperless_mail import tasks
from paperless_mail.mail import MailAccountHandler
from paperless_mail.mail import MailError
from paperless_mail.mail import PinnedIMAP4
from paperless_mail.mail import TagMailAction
from paperless_mail.mail import apply_mail_action
from paperless_mail.mail import error_callback
@@ -1569,7 +1565,12 @@ class TestMail(
("electronic", None, "invoices@mycompany.com", None, 1),
(None, "amazon", "me@myselfandi.com", None, 1),
]:
with self.subTest(f_body=f_body, f_from=f_from, f_subject=f_subject):
with self.subTest(
f_body=f_body,
f_from=f_from,
f_to=f_to,
f_subject=f_subject,
):
MailRule.objects.all().delete()
_ = MailRule.objects.create(
name="testrule3",
@@ -1810,7 +1811,7 @@ class TestPostConsumeAction(TestCase):
with (
self.assertRaises(errors.ImapToolsError),
self.assertLogs("paperless.mail", level="ERROR") as cm,
self.assertLogs("paperless_mail", level="ERROR") as cm,
):
apply_mail_action(
result=[],
@@ -1819,9 +1820,10 @@ class TestPostConsumeAction(TestCase):
message_subject=self.message_subject,
message_date=self.message_date,
)
error_str = cm.output[0]
expected_str = "Error while processing mail action during post_consume"
self.assertIn(expected_str, error_str)
error_str = cm.output[0]
expected_str = "Error while processing mail action during post_consume"
self.assertIn(expected_str, error_str)
processed_mail = ProcessedMail.objects.get(uid=self.message_uid)
self.assertEqual(processed_mail.status, "FAILED")
@@ -2049,13 +2051,10 @@ class TestMailAccountTestView(APITestCase):
self.assertEqual(response.content.decode(), "Unable to connect to server")
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
@mock.patch(
"paperless.network._getaddrinfo",
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993))],
)
@mock.patch("paperless_mail.mail.resolve_hostname_ips", return_value=["127.0.0.1"])
def test_mail_account_test_view_blocks_internal_host_when_disabled(
self,
_mock_getaddrinfo: MagicMock,
_mock_resolve_hostname_ips,
) -> None:
data = {
"imap_server": "internal.example",
@@ -2212,10 +2211,10 @@ class TestGetMailboxHostPinning(TestCase):
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
@mock.patch(
"paperless_mail.mail.resolve_public_addresses",
return_value=(ipaddress.ip_address("93.184.216.34"),),
"paperless_mail.mail.resolve_hostname_ips",
return_value=["93.184.216.34"],
)
def test_connects_to_validated_ip(self, _mock_resolve: MagicMock) -> None:
def test_connects_to_validated_ip(self, _mock_resolve) -> None:
with mock.patch(
"paperless_mail.mail.socket.create_connection",
side_effect=OSError("no connection in tests"),
@@ -2232,13 +2231,10 @@ class TestGetMailboxHostPinning(TestCase):
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
@mock.patch(
"paperless_mail.mail.resolve_public_addresses",
return_value=(ipaddress.ip_address("93.184.216.34"),),
"paperless_mail.mail.resolve_hostname_ips",
return_value=["93.184.216.34"],
)
def test_ssl_pins_ip_but_keeps_hostname_for_sni(
self,
_mock_resolve: MagicMock,
) -> None:
def test_ssl_pins_ip_but_keeps_hostname_for_sni(self, _mock_resolve) -> None:
ssl_context = mock.MagicMock()
ssl_context.wrap_socket.return_value.makefile.side_effect = OSError(
"no connection in tests",
@@ -2269,51 +2265,13 @@ class TestGetMailboxHostPinning(TestCase):
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
@mock.patch(
"paperless.network._getaddrinfo",
return_value=[
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993)),
],
"paperless_mail.mail.resolve_hostname_ips",
return_value=["93.184.216.34", "127.0.0.1"],
)
def test_blocks_when_any_resolved_address_is_internal(
self,
_mock_resolve: MagicMock,
) -> None:
"""
GIVEN:
- A mail host resolving to one public and one loopback address
- EMAIL_ALLOW_INTERNAL_HOSTS is False
WHEN:
- A mailbox is requested
THEN:
- The whole host is blocked with the existing message
"""
with self.assertRaisesMessage(
MailError,
"Connection blocked: mail.example.com resolves to a non-public address",
):
def test_blocks_when_any_resolved_address_is_internal(self, _mock_resolve) -> None:
with self.assertRaises(MailError):
get_mailbox("mail.example.com", 993, MailAccount.ImapSecurity.SSL)
def test_empty_pin_list_never_falls_back_to_hostname_lookup(self) -> None:
"""
GIVEN:
- A pinned IMAP client given an empty tuple of addresses
WHEN:
- It connects
THEN:
- It fails without opening any socket, rather than resolving the
hostname itself
"""
with (
mock.patch("paperless_mail.mail.socket.create_connection") as pinned,
mock.patch("imaplib.IMAP4._create_socket") as unpinned,
self.assertRaises(OSError),
):
PinnedIMAP4("mail.example.com", 143, ())
pinned.assert_not_called()
unpinned.assert_not_called()
class TestMailAccountProcess(APITestCase):
def setUp(self) -> None:
+1 -1
View File
@@ -29,7 +29,7 @@ from documents.models import PaperlessTask
from documents.permissions import PaperlessObjectPermissions
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_object_ids
from documents.views import PassUserMixin
from documents.views.base import PassUserMixin
from paperless.views import StandardPagination
from paperless_mail.filters import ProcessedMailFilterSet
from paperless_mail.mail import MailError
+4
View File
@@ -37,6 +37,7 @@ class PaperlessDirs:
logging_dir: Path
model_file: Path
media_lock: Path
share_link_bundle_dir: Path
class DirSettings(TypedDict):
@@ -54,6 +55,7 @@ class DirSettings(TypedDict):
STATIC_ROOT: Path
MODEL_FILE: Path
MEDIA_LOCK: Path
SHARE_LINK_BUNDLE_DIR: Path
def build_paperless_dirs(root: Path) -> PaperlessDirs:
@@ -75,6 +77,7 @@ def build_paperless_dirs(root: Path) -> PaperlessDirs:
logging_dir=data_dir / "log",
model_file=data_dir / "classification_model.pickle",
media_lock=media_dir / "media.lock",
share_link_bundle_dir=documents_dir / "share_link_bundles",
)
for directory in (
@@ -109,6 +112,7 @@ def dirs_settings(dirs: PaperlessDirs) -> DirSettings:
STATIC_ROOT=dirs.static_dir,
MODEL_FILE=dirs.model_file,
MEDIA_LOCK=dirs.media_lock,
SHARE_LINK_BUNDLE_DIR=dirs.share_link_bundle_dir,
)
-218
View File
@@ -1,218 +0,0 @@
"""
Real-socket helpers for tests of the outbound connection guard in
paperless.network: a local HTTP server, a per-hostname resolver fake and
spies recording which addresses were actually dialled.
The fixtures wrapping these live in the root conftest.
"""
from __future__ import annotations
import http.server
import socket
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from dataclasses import field
from typing import TYPE_CHECKING
from typing import Any
from typing import cast
import anyio
import httpcore
from paperless.network import GuardedAsyncHTTPTransport
from paperless.network import GuardedHTTPTransport
from paperless.network import _GuardedAsyncBackend
from paperless.network import _GuardedSyncBackend
if TYPE_CHECKING:
from collections.abc import Iterator
from unittest.mock import MagicMock
from unittest.mock import _Call
import httpx
from pytest_mock import MockerFixture
_REAL_GETADDRINFO = socket.getaddrinfo
_REAL_AGETADDRINFO = anyio.getaddrinfo
@dataclass
class ReceivedRequest:
method: str
path: str
headers: dict[str, str]
body: bytes
@dataclass
class LocalHTTPServer:
"""State of a threaded HTTP server bound to 127.0.0.1 on an ephemeral port."""
port: int
requests: list[ReceivedRequest] = field(default_factory=list)
connections: int = 0
redirect_to: str | None = None
class _Handler(http.server.BaseHTTPRequestHandler):
# HTTP/1.1 keeps connections open, so tests can observe connection reuse.
# Every response sets Content-Length, which keep-alive requires.
protocol_version = "HTTP/1.1"
def _handle(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
# BaseHTTPRequestHandler types server as the base socketserver.BaseServer;
# narrowing the attribute's declared type is a variance error, so the
# subclass is recovered here instead of on the class body.
server = cast("_RecordingHTTPServer", self.server)
state = server.state
state.requests.append(
ReceivedRequest(
method=self.command,
path=self.path,
headers={key.lower(): value for key, value in self.headers.items()},
body=body,
),
)
if state.redirect_to is not None:
self.send_response(302)
self.send_header("Location", state.redirect_to)
self.send_header("Content-Length", "0")
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"ok")
do_GET = _handle
do_POST = _handle
def log_message(self, format: str, *args: Any) -> None:
return None
class _RecordingHTTPServer(http.server.ThreadingHTTPServer):
daemon_threads = True
def __init__(self) -> None:
super().__init__(("127.0.0.1", 0), _Handler)
self.state = LocalHTTPServer(port=self.socket.getsockname()[1])
def verify_request(self, request: Any, client_address: Any) -> bool:
self.state.connections += 1
return True
@contextmanager
def running_http_server() -> Iterator[LocalHTTPServer]:
"""Serve on 127.0.0.1 in a background thread until the block exits."""
server = _RecordingHTTPServer()
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server.state
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def _addrinfo(address: str, port: int | None) -> tuple[Any, ...]:
if ":" in address:
return (socket.AF_INET6, socket.SOCK_STREAM, 6, "", (address, port or 0, 0, 0))
return (socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, port or 0))
class FakeDNS:
"""
Answers the guard's resolver hooks for registered names and delegates
every other name to the real resolver. The stock httpcore backends keep
using the unpatched socket.getaddrinfo.
"""
def __init__(self) -> None:
self._answers: dict[str, list[str]] = {}
self.lookups: list[str] = []
def add(self, hostname: str, *addresses: str) -> None:
self._answers[hostname] = list(addresses)
def getaddrinfo(
self,
host: str,
port: int | None,
*args: Any,
**kwargs: Any,
) -> list[tuple[Any, ...]]:
self.lookups.append(host)
if host in self._answers:
return [_addrinfo(address, port) for address in self._answers[host]]
return list(_REAL_GETADDRINFO(host, port, *args, **kwargs))
async def agetaddrinfo(
self,
host: str,
port: int | None,
**kwargs: Any,
) -> list[tuple[Any, ...]]:
self.lookups.append(host)
if host in self._answers:
return [_addrinfo(address, port) for address in self._answers[host]]
return list(await _REAL_AGETADDRINFO(host, port, **kwargs))
def install_fake_dns(mocker: MockerFixture) -> FakeDNS:
"""Patch the guard's resolver hooks with a FakeDNS for the current test."""
dns = FakeDNS()
mocker.patch("paperless.network._getaddrinfo", new=dns.getaddrinfo)
mocker.patch("paperless.network._agetaddrinfo", new=dns.agetaddrinfo)
return dns
def _dialled_host(call: _Call) -> str:
# The spy sits on the class, so args[0] is the backend instance.
if "host" in call.kwargs:
return str(call.kwargs["host"])
return str(call.args[1])
@dataclass
class DialRecorder:
sync_spy: MagicMock
async_spy: MagicMock
def hosts(self) -> list[str]:
calls = [*self.sync_spy.call_args_list, *self.async_spy.call_args_list]
return [_dialled_host(call) for call in calls]
def install_dial_recorder(mocker: MockerFixture) -> DialRecorder:
"""Spy on the stock backends' connect_tcp for the current test."""
return DialRecorder(
sync_spy=mocker.spy(httpcore.SyncBackend, "connect_tcp"),
async_spy=mocker.spy(httpcore.AnyIOBackend, "connect_tcp"),
)
def allow_all_addresses(mocker: MockerFixture) -> None:
"""Patch the guard's public-address check to accept every address.
Loopback and other private addresses pass just like a public one, for
tests that exercise something other than the address policy itself.
"""
mocker.patch("paperless.network.is_public_ip", return_value=True)
def guard_of(
client: httpx.Client | httpx.AsyncClient,
) -> _GuardedSyncBackend | _GuardedAsyncBackend:
"""Return the guard installed on a client's transport."""
transport = client._transport
assert isinstance(transport, GuardedHTTPTransport | GuardedAsyncHTTPTransport)
backend = transport._pool._network_backend
assert isinstance(backend, _GuardedSyncBackend | _GuardedAsyncBackend)
return backend
Generated
+57 -53
View File
@@ -220,7 +220,7 @@ wheels = [
[[package]]
name = "autobahn"
version = "25.12.2"
version = "26.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cbor2" },
@@ -228,24 +228,35 @@ dependencies = [
{ name = "cryptography" },
{ name = "hyperlink" },
{ name = "msgpack", marker = "platform_python_implementation == 'CPython'" },
{ name = "py-ubjson" },
{ name = "txaio" },
{ name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" },
{ name = "ujson" },
]
sdist = { url = "https://files.pythonhosted.org/packages/54/d5/9adf0f5b9eb244e58e898e9f3db4b00c09835ef4b6c37d491886e0376b4f/autobahn-25.12.2.tar.gz", hash = "sha256:754c06a54753aeb7e8d10c5cbf03249ad9e2a1a32bca8be02865c6f00628a98c", size = 13893652, upload-time = "2025-12-15T11:13:19.086Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/73/f109f563c27e048e45d135d81af19e6ca391e24905550b06bd1c9d674c57/autobahn-26.7.1.tar.gz", hash = "sha256:c6949a2c6eb95fb1c218837dbda0a59abbbebafb8b11098551c01a7061dfd245", size = 14056542, upload-time = "2026-07-15T19:14:01.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/23/923e4f11dc9d12b9f5a014f36d591c479d623d54dda3bdcbd688cd12f052/autobahn-25.12.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16df879672c60f1f3fe452138c80f0fd221b3cb2ee5a14390c80f33b994104c1", size = 2053413, upload-time = "2025-12-15T11:12:58.167Z" },
{ url = "https://files.pythonhosted.org/packages/b3/0d/3d39637a1e32f555ce5fabec4a723a035556ef918b14140faea05e7de902/autobahn-25.12.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffe28048ef96eb0f925f24c2569bd72332e120f4cb31cd6c40dd66718a5f85e", size = 2224850, upload-time = "2025-12-15T11:13:00.089Z" },
{ url = "https://files.pythonhosted.org/packages/64/8d/36452c06cbcad6d04587aeb87dfa987ef94be4a427b9f2155783d166bd97/autobahn-25.12.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:220748f21e91bd4a538d2d3de640cc17ee30b79f1c04a6c3dcdef321d531ee1c", size = 2225453, upload-time = "2025-12-15T11:13:02.865Z" },
{ url = "https://files.pythonhosted.org/packages/83/30/ef9c47038e4e9257319d6e1b87668b3df360a0c488d66ccff9d11aaff6ba/autobahn-25.12.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:bc17f6cab9438156d2701c293c76fd02a144f9be0a992c065dfee1935ce4845b", size = 1960447, upload-time = "2025-12-15T11:13:05.007Z" },
{ url = "https://files.pythonhosted.org/packages/e2/e4/f3d5cb70bc0b9b5523d940734b2e0a251510d051a50d2e723f321e890859/autobahn-25.12.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5297a782fc7d0a26842438ef1342549ceee29496cda52672ac44635c79eeb94", size = 2053955, upload-time = "2025-12-15T11:13:06.052Z" },
{ url = "https://files.pythonhosted.org/packages/ea/49/4e592a19ae58fd9c796821a882b22598fac295ede50f899cc9d14a0282b6/autobahn-25.12.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0c3f1d5dafda52f8dc962ab583b6f3473b7b7186cab082d05372ed43a8261a5", size = 2225441, upload-time = "2025-12-15T11:13:07.527Z" },
{ url = "https://files.pythonhosted.org/packages/54/b7/0a0e3ecb2af7e452f5f359d19bdc647cbc8658f3f498bfa3bf8545cf4768/autobahn-25.12.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c840ee136bfaf6560467160129b0b25a0e33c9a51e2b251e98c5474f27583915", size = 1960463, upload-time = "2025-12-15T11:13:10.183Z" },
{ url = "https://files.pythonhosted.org/packages/19/8b/4215ac49d6b793b592fb08698f3a0e21a59eb3520be7f7ed288fcb52d919/autobahn-25.12.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9abda5cf817c0f8a19a55a67a031adf2fc70ed351719b5bd9e6fa0f5f4bc8f89", size = 2225590, upload-time = "2025-12-15T11:13:11.367Z" },
{ url = "https://files.pythonhosted.org/packages/d6/99/b4a3da42471d3ec36e2dca0c1a5368a079fed9f73b159ce3f049c4a4983b/autobahn-25.12.2-pp311-pypy311_pp73-macosx_15_0_arm64.whl", hash = "sha256:0c226329ddec154c6f3b491ea3e4713035f0326c96ebfd6b305bf90f27a2fba1", size = 1955357, upload-time = "2025-12-15T11:13:13.581Z" },
{ url = "https://files.pythonhosted.org/packages/89/81/67f19dd7395a9f1123a1f071314f8d1c4879c1869adeb8d99a236e756ac0/autobahn-25.12.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f079393a7626eb448c8accf21151f5f206d02f8e9cee4313d62a5ca30a3aaed", size = 623173, upload-time = "2025-12-15T11:13:14.945Z" },
{ url = "https://files.pythonhosted.org/packages/71/eb/857eab3d25e3b9cc9e7e741d6193808ad91de0befb38cf10658bd339c205/autobahn-25.12.2-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b3a6c7d54a9f0434a435d88b86555510e5d0a84aa87042e292f29f707cab237", size = 2178008, upload-time = "2025-12-15T11:13:15.881Z" },
{ url = "https://files.pythonhosted.org/packages/44/8c/381cdcab8016df2177adc93d25f84ca3a5fb8f8be4f9d784336416c7bee8/autobahn-26.7.1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3fe80550707f0affb5cb10f3e0f66ec7e6e52abb29edc66dd76734c2d7d51bf4", size = 1997747, upload-time = "2026-07-15T19:13:21.998Z" },
{ url = "https://files.pythonhosted.org/packages/79/a9/9293c6c6bc8970f42c9675942de78f306e18eafa47edba52fd27f9dc71bd/autobahn-26.7.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00fb9acd8775eaa0e272f36b76db903f10de56478f6a72f0bd07ee882ae1f2b8", size = 2082284, upload-time = "2026-07-15T19:13:23.582Z" },
{ url = "https://files.pythonhosted.org/packages/a9/ba/7396cb42a9c59df20c350ea05f75e6f25f582b474dee82b8e32823b2711e/autobahn-26.7.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c9674eddd55ad3ebd733824789175e5fb90c88afd523de507569ba0fcd6853", size = 2254260, upload-time = "2026-07-15T19:13:24.894Z" },
{ url = "https://files.pythonhosted.org/packages/b7/64/19753442770662ff45c4fe48db6345ac6fa3100fbbd989241c074e38ea6f/autobahn-26.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:30fa714de5c9903ef64084d3a938d8a3bac0bb42f1532d5de22f34b04a1c4819", size = 3173653, upload-time = "2026-07-15T19:13:26.261Z" },
{ url = "https://files.pythonhosted.org/packages/04/a4/b690f272427acf1e8ea03b146e559dc67ade10dd4e0cccacc1d4c011b141/autobahn-26.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c3362197f3b9d5b0df7f3365bd00dedba7ee8b649d652941377abe690d1c8b14", size = 3402880, upload-time = "2026-07-15T19:13:27.683Z" },
{ url = "https://files.pythonhosted.org/packages/6e/23/0769ef39e1cfb0bec15bacdd7f407aaedfda14c0ca3f7e818b856f2ed1a1/autobahn-26.7.1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6c9013e9aa9ea8a561c89d7be2709546b51fc7ac8fdf6cd71bc12a634672d9a8", size = 2000605, upload-time = "2026-07-15T19:13:30.391Z" },
{ url = "https://files.pythonhosted.org/packages/f9/ef/26833f38ecf3aef3ff0aa09feb12f5d472f7370104148a6b78e3c7afc286/autobahn-26.7.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd9ebe577dd1030f9a0c41d20dc00eca90d5cf338531abcb510d978825feeea", size = 2082844, upload-time = "2026-07-15T19:13:31.559Z" },
{ url = "https://files.pythonhosted.org/packages/b2/4c/00553ee9d57ee11df47bc9867d120cfe721a73bec0b58fb3a3b91cd7c797/autobahn-26.7.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de491baa4cf52fb6d7f542e445c72d94ce52dc7aabb47b0b4d5191e452dd2c74", size = 2254852, upload-time = "2026-07-15T19:13:32.8Z" },
{ url = "https://files.pythonhosted.org/packages/c3/ca/7884f6ffb8410882df98cb939dea24225dd79e4f091ceb59f4b826e54f2f/autobahn-26.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9723561c820ed032fe5a8f1530cb6d5f91dc595eea6009f3b2de7044a3df892a", size = 3174235, upload-time = "2026-07-15T19:13:34.134Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e7/c6704e8f6bef3aa552a851a34908a06d91317d35ca55ec04b5db14385c33/autobahn-26.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e81c86cf41adca8a56ca5621ecd9ba40037f6d90d338c334bd529c8ac94bd7b6", size = 3403687, upload-time = "2026-07-15T19:13:35.633Z" },
{ url = "https://files.pythonhosted.org/packages/36/92/2f6e57d9f9e6b86b9db362f58aaa6cfeadc2f3a6901ec95aab27ef232b5c/autobahn-26.7.1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2ce48214b28f73338fabe0c7fd13d222cfab9e1dd2ef11660293522f64e76727", size = 1987052, upload-time = "2026-07-15T19:13:38.543Z" },
{ url = "https://files.pythonhosted.org/packages/38/6d/f170134468e276fa9ea57eb1ae41f9cc0dcd0228e9d501f370fa50c0ee31/autobahn-26.7.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5f285dce9b3dff3eb2ef6c818ac8ede24d96bd1edac340855170fc9825c38a9", size = 2082813, upload-time = "2026-07-15T19:13:39.686Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ce/b735fa933e9ba4fa8c3f9aa9ae68b4e2d4aadb0a92b38ade30e37a7d4795/autobahn-26.7.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66ab6e034e54f8c473df1a6b8031a3db46c16deebaf0c9b66db3e9137d2fab5e", size = 2254818, upload-time = "2026-07-15T19:13:40.951Z" },
{ url = "https://files.pythonhosted.org/packages/df/3e/57855f4f52aa0ee64c6d8210637d0d9847de4c1082e03ddd2ffafd853d2d/autobahn-26.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20b3eab7d483e93278f9d7345592eb6b465883a6e88c2823aad13c3f943452db", size = 682494, upload-time = "2026-07-15T19:13:42.193Z" },
{ url = "https://files.pythonhosted.org/packages/3b/3c/3944f17dd2a06aee7d0d9f1c37b5a94518434d13ba2c38e738d33ca10daf/autobahn-26.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cd840524ff190aee695a58e8acd8740ee66b4a0e6a58ccb41416ed2cbe48d43f", size = 3403666, upload-time = "2026-07-15T19:13:43.508Z" },
{ url = "https://files.pythonhosted.org/packages/51/3e/200471878093a502f8e8078c1ca19fd82acc68ae9ac363e395170da6dbe2/autobahn-26.7.1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f3d1be925e3fb33fff5280c1bd02027047519812c400d3efa5477d3968686c94", size = 1987070, upload-time = "2026-07-15T19:13:47.112Z" },
{ url = "https://files.pythonhosted.org/packages/61/d1/704f881fd2c52b056dc0f14e6d0d640b1f3ff43f3b84cf85d3631e3243f4/autobahn-26.7.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f8094b0c0fa29a4963ce12130b7932469f89afa0242ff858d2b26541a81005", size = 2082939, upload-time = "2026-07-15T19:13:48.569Z" },
{ url = "https://files.pythonhosted.org/packages/a8/27/84e76aec7abbcb502d4cd34ef5c859eaa19a3b707cda68ab7ce68478dd92/autobahn-26.7.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0fcf9c3ff6b9b2bc85d6e1814a94d1d941d62f7df36d350b523d77df85d66ea", size = 2254983, upload-time = "2026-07-15T19:13:49.802Z" },
{ url = "https://files.pythonhosted.org/packages/e6/81/a810732a10342c5d6b90d19f83fa2bc9b6126e7c0cda7c4df867e311aa2e/autobahn-26.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f82e5a113f6c1ff14cec99aa411f7da8fceec3dcd4647d7ebdfc0278811e14d", size = 3174295, upload-time = "2026-07-15T19:13:51.227Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c6/4886fdaecfeda013e085288a9d83bad6f1ded9995b8088ca933d9ec37201/autobahn-26.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:919309cbe41b28b0a3028e7c6fed52aca9fd21639619f372d3270c44341b6bdc", size = 3403713, upload-time = "2026-07-15T19:13:52.744Z" },
{ url = "https://files.pythonhosted.org/packages/94/14/6485c29ad06a6bd7b3017558f99f6f89dcaa6ef6641930b25d9247adba4c/autobahn-26.7.1-pp311-pypy311_pp73-macosx_15_0_arm64.whl", hash = "sha256:9088acf790caf8cfd86590cb2b749279256ee210198f41d9858a38d1346e56c9", size = 1981962, upload-time = "2026-07-15T19:13:55.79Z" },
{ url = "https://files.pythonhosted.org/packages/8d/46/cb6d09604417beacdf485b414a05efa18511b0e78ac5451b3655bee711fa/autobahn-26.7.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea4548ee15c6bdf8aa0a1e81bf47b42db350f2bef69f83bace27a95ed0d21276", size = 655967, upload-time = "2026-07-15T19:13:57.19Z" },
{ url = "https://files.pythonhosted.org/packages/00/d9/b846bc5a37f25ac147879d6451c466968a54efbf9d0467f0732f37c6f3f3/autobahn-26.7.1-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4ee0fe13a5218831d60863becd8c3cf6558e6c5ccc5d9d0e722012bdb1459bf", size = 2207401, upload-time = "2026-07-15T19:13:58.392Z" },
]
[[package]]
@@ -2874,7 +2885,6 @@ name = "paperless-ngx"
version = "3.2.1"
source = { virtual = "." }
dependencies = [
{ name = "anyio" },
{ name = "azure-ai-documentintelligence" },
{ name = "babel" },
{ name = "bleach" },
@@ -2903,8 +2913,6 @@ dependencies = [
{ name = "filelock" },
{ name = "flower" },
{ name = "gotenberg-client", extra = ["httpx"] },
{ name = "httpcore" },
{ name = "httpx" },
{ name = "httpx-oauth" },
{ name = "ijson" },
{ name = "imap-tools" },
@@ -3029,7 +3037,6 @@ typing = [
[package.metadata]
requires-dist = [
{ name = "anyio", specifier = ">=4.12" },
{ name = "azure-ai-documentintelligence", specifier = ">=1.0.2" },
{ name = "babel", specifier = ">=2.17" },
{ name = "bleach", specifier = "~=6.4.0" },
@@ -3059,8 +3066,6 @@ requires-dist = [
{ name = "flower", specifier = ">=2.0.1,<2.2" },
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
{ name = "httpcore", specifier = "~=1.0.9" },
{ name = "httpx", specifier = "~=0.28.1" },
{ name = "httpx-oauth", specifier = "~=0.17" },
{ name = "ijson", specifier = ">=3.5.1" },
{ name = "imap-tools", specifier = ">=1.14,<1.16" },
@@ -3217,40 +3222,45 @@ wheels = [
[[package]]
name = "pikepdf"
version = "10.2.0"
version = "10.13.0.post1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "lxml" },
{ name = "packaging" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/e9/a1462d6160805ca80c8f4aafc941aaf410a92d0fcc683706e94f499c2fac/pikepdf-10.2.0.tar.gz", hash = "sha256:0f398b0daeb2ffd2358f75c06f1dd47b9ba76f1a77dfe938cccf7080c58227d7", size = 4568506, upload-time = "2026-01-09T22:54:25.847Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/0e/6e74dd213537b71c945743a4b3112dbb430896ad68b8a6ad22e4468455d4/pikepdf-10.13.0.post1.tar.gz", hash = "sha256:4b73f926ebae81f04bf14527af330bd00bb268be767e0f189f7c4c3e4ad7ae0a", size = 4973186, upload-time = "2026-09-05T06:49:20.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/dc/aa7293763b603a9080ffab7ab87c7b571d637a389e9fb2ba839b864ca283/pikepdf-10.2.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:fb93732127d5183a91300af39e1cda5ded309e8439daec93536331a472b5e190", size = 4727891, upload-time = "2026-01-09T22:53:26.262Z" },
{ url = "https://files.pythonhosted.org/packages/d2/dc/700c31f2c14f94d92483b10e1918390948ed20f6f572d82beb78ac5f94d0/pikepdf-10.2.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:ab7bd4629539cf2136a799dc3eaa2dfda59937035a97b0c5e22a7a3a4033cc49", size = 5030510, upload-time = "2026-01-09T22:53:28.023Z" },
{ url = "https://files.pythonhosted.org/packages/23/46/dc63364b05aa1913f2d7480cad62676bfb473065ba4b02d314dfd482f7dd/pikepdf-10.2.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f5623a5ba456d69dfeb86dc3bb3ec31ec1d120382d8c24804d1b430fce715ea", size = 2439498, upload-time = "2026-01-09T22:53:30.122Z" },
{ url = "https://files.pythonhosted.org/packages/59/b6/1f9b8ca588fd34d9e3df49a80c62016e0b42ce6e580146c46d9728fdb6e8/pikepdf-10.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec4d12f294df378d122ae441c27c1e76fb0d15b1e9d7374ae70c26604559bab", size = 2666945, upload-time = "2026-01-09T22:53:32.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/31/b1e61fac59f0b807edde655a821ff83bb041ae1500234c52ed1a2403c44a/pikepdf-10.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c0cebe3235232f1bd3c5f7956218ce92241c94223cb80eba837d372a40c61765", size = 3638109, upload-time = "2026-01-09T22:53:34.144Z" },
{ url = "https://files.pythonhosted.org/packages/02/e9/a99bbf503c9d55e54553edff84ec67cac49d335fc33f3d5516c4746b6340/pikepdf-10.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0908e845c9140e245ad89a19fdfc6e5a6d82fcb505b8cc2c0ce81439ac4f064", size = 3829538, upload-time = "2026-01-09T22:53:36.341Z" },
{ url = "https://files.pythonhosted.org/packages/73/18/598383493a0f0f0c4eecd09b8fe06dddb9d326a89e2623a134d43e051485/pikepdf-10.2.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:18c35d00baff72bfae82d67028bedb02ea2b208e1af5545c23cd681f2487a279", size = 4737716, upload-time = "2026-01-09T22:53:39.973Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/bec04784ba07d44f03b52ea524bcb7409bf7185ee8abec7ae29e3ac9e9ae/pikepdf-10.2.0-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:dd849d033b95de15965c095ebc4d78983099a11bb7b7897801dfaf3cb4083a35", size = 5042152, upload-time = "2026-01-09T22:53:41.808Z" },
{ url = "https://files.pythonhosted.org/packages/38/3e/148b3c8e101c8ac3a33f41e86c5739413575495e471bce45ee228aafcbd6/pikepdf-10.2.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9910efdc7907af3da9e7b2a125a1f67d512165ffa623f62825deeb642669a7a", size = 2445796, upload-time = "2026-01-09T22:53:44.027Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ef/b06f8fd68c34fed631cb8e3520dd955e59987de0eee6960dbc94bed11711/pikepdf-10.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0ec947e6429d7a3306153d32a0142462fdd8f905c5fe08c8a8e8c53b9c28a5c", size = 2693908, upload-time = "2026-01-09T22:53:46.039Z" },
{ url = "https://files.pythonhosted.org/packages/3e/bf/e5c40e9210e2ae8da7cad2cf6ae7d1db3b63a2916e6040645958e9ab4054/pikepdf-10.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6383219a1cd31400403a69737a4e2a0c5d2a2c4cb9f380bcf45e33e8de802ea", size = 3643423, upload-time = "2026-01-09T22:53:48.333Z" },
{ url = "https://files.pythonhosted.org/packages/d4/1b/969dfb29dc9fd7b82fa7bc065df498e8a3e7ddb81e982140634ee539a8db/pikepdf-10.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d2f9ef5a84949bfc11152a323558f94cf85d9d97e9c510c061c7f803028f3f", size = 3854816, upload-time = "2026-01-09T22:53:50.158Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c5/e6f9e3407dd73ec570000a64747ff84e2f57b06b0477d1da6eaca5038162/pikepdf-10.2.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:09ff28d1de7fc7711a7ef8dfc40396d9243b64ee24c37cd1ab2a9f9827895caa", size = 4737680, upload-time = "2026-01-09T22:53:54.905Z" },
{ url = "https://files.pythonhosted.org/packages/eb/de/dffb785235ac2d930db86b215c1848d7258e625fa1949dd0633f8b72ab0a/pikepdf-10.2.0-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:62348b66e1401a4db0c64976b72dd74bb1a9eb3a33007a661500f4f8a64436bd", size = 5042150, upload-time = "2026-01-09T22:53:57.722Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f0/4d883f57304d98650ade30a8c73fe593582d9afd9a7dada1f5f3f4cce362/pikepdf-10.2.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9e91780cb9ea3c6a350ffbcf03d5d95c30084d238afbd1d4b927cdb9e3649d", size = 2445446, upload-time = "2026-01-09T22:53:59.993Z" },
{ url = "https://files.pythonhosted.org/packages/62/65/ffe2555812a152d616accacea7c1c617c27a75590379ea7d9cc3a26bd92d/pikepdf-10.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52360a49a22e9353ec9a08ff5713cec8aacaf3ef960c704bc0a89ca8f050bdad", size = 2696242, upload-time = "2026-01-09T22:54:01.687Z" },
{ url = "https://files.pythonhosted.org/packages/ed/8c/2f937b0e2867cd48b523122e08753571fc9847978e239d7b5db9bd46879c/pikepdf-10.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4c1046939eb22c24c396deb37f8e0500caaa66b73114be55377d5554b4167", size = 3643730, upload-time = "2026-01-09T22:54:04.177Z" },
{ url = "https://files.pythonhosted.org/packages/2e/17/f2919e4085c399e938bb945ea712dea70b3849e17cae6403f0cc1100e9ef/pikepdf-10.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3dcd8957a08e0a47f7a138904dca8cf73962fa17a096a47cd8bc33eb83a4f0a7", size = 3856645, upload-time = "2026-01-09T22:54:07.887Z" },
{ url = "https://files.pythonhosted.org/packages/1d/6e/846902abe8286d3b4ab70893e9ffbeec99aadd93ba1536cf471b222bb910/pikepdf-10.2.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:194c9a81ecb49e425a5cd5162621270b5e42cf05709d87eac018bd6f9ce98f80", size = 4733930, upload-time = "2026-01-09T22:54:11.931Z" },
{ url = "https://files.pythonhosted.org/packages/8b/6d/abdbb794d2a512d4e828ef2014cc47ca263ad3fbd1b65f25f791b9c0bb1e/pikepdf-10.2.0-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:5adcf87dbfff4e1cd0a850db487274f474c94a6bf6347f3842c53da8d0eaa8df", size = 5042477, upload-time = "2026-01-09T22:54:13.708Z" },
{ url = "https://files.pythonhosted.org/packages/52/6c/6c42694fe1574a37aa2a40b4ba29a6713b4226436155ef7aa0bef649c117/pikepdf-10.2.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5de3cecbb35c4bc651e9326932974217be1d450d4a9840d77a592062eb507e27", size = 2448419, upload-time = "2026-01-09T22:54:15.6Z" },
{ url = "https://files.pythonhosted.org/packages/45/f4/aca3286aa37ace581afc8e3e0644a0cc55b9f9ceb31f28219d12ca11536c/pikepdf-10.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77868fd25182a45a4f3dec3c461aea8c696ef9565894c5cde4394bc8c32fb069", size = 2697600, upload-time = "2026-01-09T22:54:18.123Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a6/9135f9f0189634de61410573a0712d849e0157e3902e6b867339cc7dbf1b/pikepdf-10.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9a10e15e2f4d0bba36a2b4328342d00eff1a5a31399e1d1a93483c70d3c2b0e", size = 3647720, upload-time = "2026-01-09T22:54:20.158Z" },
{ url = "https://files.pythonhosted.org/packages/83/60/f282077773a3321fad4cbfb16fe73ee3f8dd93b408df65c24779f12227c5/pikepdf-10.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8f80ecf00fb15a760f218432a1046e7797cd14eaa6ccb52c8814ae8852745d8", size = 3859133, upload-time = "2026-01-09T22:54:22.358Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c1/48c9c0ed2ed88ca5d9cdd7f16075385a05a812d781b61bfa7f2d5182b247/pikepdf-10.13.0.post1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:98a7305e330f797da02b543d3ad57a134c4a14c6ec6f8d86d91aa9dd130c425b", size = 1846300, upload-time = "2026-09-05T06:48:11.563Z" },
{ url = "https://files.pythonhosted.org/packages/e9/20/484a3a61664132dc8c4bd97e0b8291fa79f9f4a3b1e1ffd5b67ac41ed98a/pikepdf-10.13.0.post1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:f3dedd02795626f17ee42d5c02ec4ec94e28aa47046ef430d4478454a8fbd07f", size = 1944179, upload-time = "2026-09-05T06:48:14.01Z" },
{ url = "https://files.pythonhosted.org/packages/66/38/797df7d60352fc5ec3943c425acaa15d032cd2e673b516861f449536db57/pikepdf-10.13.0.post1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ba09ef5a5f26e38ee558d2a08223fee08a5ef1868962ae2d8590d4de3c8c92f", size = 2105054, upload-time = "2026-09-05T06:48:15.547Z" },
{ url = "https://files.pythonhosted.org/packages/a8/8a/1f003558c5c05cecf182af775839ad674fe23e06b314d0219b9d8422680a/pikepdf-10.13.0.post1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365b94f2be7e2857c6cb5445b56dc52dc7417ba9f06c8a4282d9f521cb2d0fb8", size = 2308617, upload-time = "2026-09-05T06:48:17.216Z" },
{ url = "https://files.pythonhosted.org/packages/55/5b/0e7193ee8c7ca5b15f478918033a0fa78917b01249e1d4a4a65754644cd3/pikepdf-10.13.0.post1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f515a31c76cce043bbb7b781e77a343a4e26fa8f520ba337d30ddea0f7a0ce50", size = 3742337, upload-time = "2026-09-05T06:48:18.971Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f5/519e8728c04d05dcbd44d03b266a3f6acf8de3a0a6ed5ec0fa39ececddd7/pikepdf-10.13.0.post1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b63577c44fedf7ed6b971076f7c7ed0ff95a8bac627ac93b79e8b542214861a7", size = 3952988, upload-time = "2026-09-05T06:48:20.984Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a5/598e72c72ed46046e297f15763dffda88424870724a4a22f599b815cb774/pikepdf-10.13.0.post1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2c6e83f8a1828ec79cdec4df8cc07209eaf10ed7e4f5a90a7356b254bacc07d5", size = 1845515, upload-time = "2026-09-05T06:48:24.477Z" },
{ url = "https://files.pythonhosted.org/packages/f9/b3/29691a5e9ee915357c081730d8cc02f35f19b4155857556fbe562a4d83ba/pikepdf-10.13.0.post1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:f2463f650efab46905b9e279f5c776faf96c65bb45c1acf9f2de0e8a6eec5fb7", size = 1944755, upload-time = "2026-09-05T06:48:26.332Z" },
{ url = "https://files.pythonhosted.org/packages/04/4e/201f553b9405424d7aefa3be997f0a1c787ac3a089a844f1fc42f412fe0e/pikepdf-10.13.0.post1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9613505f5b22203465d4224a3fd8cf69876ce8278442c6478ac6849f54724a30", size = 2102202, upload-time = "2026-09-05T06:48:28.816Z" },
{ url = "https://files.pythonhosted.org/packages/3e/a3/bd7e7b321e8bfe4b8530da57d12c557a259bbd4b40e739960a1f2ea507cb/pikepdf-10.13.0.post1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f80ca046d984752cf6f08093debc193884bee91c01c104aa0236767711a20f", size = 2307194, upload-time = "2026-09-05T06:48:31.558Z" },
{ url = "https://files.pythonhosted.org/packages/23/b0/ca630f56015dfc6c4c8c81fdeb5f5099e7fca354f54a41dfe7f1382a5316/pikepdf-10.13.0.post1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f76fcbe5d86f2ae6f231ba542cd04793d4c89e75bd5f62526b7412926ff2100", size = 3740157, upload-time = "2026-09-05T06:48:33.285Z" },
{ url = "https://files.pythonhosted.org/packages/be/48/7a84adc2fd14ec35e4b3d007575914de1ce0518e8c69b35ebee62fa2b142/pikepdf-10.13.0.post1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:092a9bf15739e931ecab15ec3baee5d9629dad90ea4e42b779f6b439d2d1e462", size = 3951601, upload-time = "2026-09-05T06:48:35.468Z" },
{ url = "https://files.pythonhosted.org/packages/10/f4/3636368760840cbc3ee512330024dd6f518d583c1bbbb1b551ca8e18f5e8/pikepdf-10.13.0.post1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:87141ada970386ff6640db54f0bda734d3bde7960d3ba04a76768b48e25028ca", size = 1845524, upload-time = "2026-09-05T06:48:39.309Z" },
{ url = "https://files.pythonhosted.org/packages/ce/dc/7bbfba253a0394a237b81be371a64f904f99636d579ec78ef5d92025fd2d/pikepdf-10.13.0.post1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:55e53b4d8a4b1700f686f76e3a68411e421e962a4c8b1b90d00aab3f3e494a55", size = 1944829, upload-time = "2026-09-05T06:48:41.601Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a3/10367bfb93501a151cbb96b6973f7c049fef04040aea35cf6cedf579c3e4/pikepdf-10.13.0.post1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fb8f82dc43056a4b4f891e78ee1db4e3ced75ba3e87b836f8e28c8771228928", size = 2102269, upload-time = "2026-09-05T06:48:43.222Z" },
{ url = "https://files.pythonhosted.org/packages/2d/bd/a68b5d9b4aef4d4b9c374cfdfd941623f52303d31adea13554568df42abe/pikepdf-10.13.0.post1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3b58ccb30b93ba400e6a6a83315b4830eea49f6f19e18d78241fe6b1c49fec2", size = 2307160, upload-time = "2026-09-05T06:48:45.192Z" },
{ url = "https://files.pythonhosted.org/packages/9a/59/47bd86d9e338d301c28416d52d0e154a0d6322cf6b957c9df5f3d7828aa2/pikepdf-10.13.0.post1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6b038cd5bcbb6c1952bcc271695eaf24c4199d72e45606ee5e467f837760820e", size = 3739722, upload-time = "2026-09-05T06:48:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/49/40/87fb6dddc9dce110429c72449e942174fd500f6fc4c9ff518a6b73057aa7/pikepdf-10.13.0.post1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b7b0cbb135de32ec3f41651a08ab294e3c18ae9fec32516a48d27e64a53a47f0", size = 3951549, upload-time = "2026-09-05T06:48:49.263Z" },
{ url = "https://files.pythonhosted.org/packages/14/0e/86897bf5325824c1f2d9d8be89839baf11011b5392d92620cb0273fb9af5/pikepdf-10.13.0.post1-cp314-abi3-macosx_14_0_arm64.whl", hash = "sha256:51fae4a4a3c6549aa4c405896ff7010f3e43e0c4f407c0bcee071ef13d271202", size = 1845245, upload-time = "2026-09-05T06:48:53.575Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ed/923846b7511627f8564d09345e083f14674dfb193de92d27f1cc4650602e/pikepdf-10.13.0.post1-cp314-abi3-macosx_15_0_x86_64.whl", hash = "sha256:8cb976331cb8b03ec3465e06d9e7a3eadbadb7e622be888e70f918ac732a105e", size = 1943659, upload-time = "2026-09-05T06:48:55.823Z" },
{ url = "https://files.pythonhosted.org/packages/58/bb/fcb09ad4bd227bbb37a7e5b24de86f9ce9d462aa0c7ee18781899bfa378a/pikepdf-10.13.0.post1-cp314-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e18d5a009bbe5f3ab18f916fb9e28f0c5b0d920736e3a85cc10c627ec633596", size = 2099423, upload-time = "2026-09-05T06:48:57.671Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c9/707f9ba96727fa366a650237e46f0e20a73b244592eb6b97a49e401e2b43/pikepdf-10.13.0.post1-cp314-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:414f42c83e5e6029870de1a988625dafc95781ebff10e15d82caeb5e69a83c9c", size = 2303539, upload-time = "2026-09-05T06:48:59.506Z" },
{ url = "https://files.pythonhosted.org/packages/22/15/79a2ccc354514a1321a161867a011fc917be158fc01a88da8c78ad18399d/pikepdf-10.13.0.post1-cp314-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f0eb89f06cad9231b9db54d81a22592b03b63924824a6b850febd2b75daa6546", size = 3737772, upload-time = "2026-09-05T06:49:01.245Z" },
{ url = "https://files.pythonhosted.org/packages/6e/85/a17440c2de64da71dc012b42e644b30ee4d98eb540c14d4e9f3538b73a9e/pikepdf-10.13.0.post1-cp314-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:544f1be1b1e5630a79cd182a8663c439504099eb9eae0d342a50173d9825bdf3", size = 3948342, upload-time = "2026-09-05T06:49:03.27Z" },
{ url = "https://files.pythonhosted.org/packages/e9/65/15a796a3cf9fb17d41acc1ab6719e7d3dcdf1260e76909d0dd32ecc97ba7/pikepdf-10.13.0.post1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:ef4ed47d40aa44deb063feb4a88e8bcf1c8fa0183ce526dc4295f7cbdb1292f8", size = 1853638, upload-time = "2026-09-05T06:49:07.184Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f3/5d49a511fd13b59b94c5ad673695d331fce5d2846ab1501646c2a3b35b5f/pikepdf-10.13.0.post1-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:571efcd1d54e0dd817973c76c253feb6fb758c93bb0c16a893cc68f2a178d404", size = 1951768, upload-time = "2026-09-05T06:49:09.048Z" },
{ url = "https://files.pythonhosted.org/packages/06/de/f6bbd9653695f6e2ed3494a439f11f3a89450c004fe9bf8ee583201ea759/pikepdf-10.13.0.post1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db9a18074ba112e7c517e8c21dfd8894cc13b8a37ccf49a5841192170fb68eb", size = 2107137, upload-time = "2026-09-05T06:49:10.788Z" },
{ url = "https://files.pythonhosted.org/packages/c0/aa/43b355681f05ea0b5808a26cba9e8e764686fa8dcebe0875db612664de40/pikepdf-10.13.0.post1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7962a75cf22d0d683b49ab19b8966e94dc7014d9aa6806f3c0d2b37fb9ae607", size = 2310991, upload-time = "2026-09-05T06:49:12.455Z" },
{ url = "https://files.pythonhosted.org/packages/a9/46/77574e9c4bded01afd7a3fe538f5432e396c3772c9bc1eab5d287aed00df/pikepdf-10.13.0.post1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b948e11f7dd3710f939194f00b4d75b0df87a30d53b31414128768ac25da77d8", size = 3744358, upload-time = "2026-09-05T06:49:14.428Z" },
{ url = "https://files.pythonhosted.org/packages/89/a8/4857df72cf4773553c2e6a82f93ee5e98c02f1c4e4877379e84d27384982/pikepdf-10.13.0.post1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99f6afccd6119233e7133bd4c2ade48461de3ddd4269cc28a4f90cdf7c1372f5", size = 3955964, upload-time = "2026-09-05T06:49:16.656Z" },
]
[[package]]
@@ -3610,12 +3620,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
]
[[package]]
name = "py-ubjson"
version = "0.16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/28220d37e041fe1df03e857fe48f768dcd30cd151480bf6f00da8713214a/py-ubjson-0.16.1.tar.gz", hash = "sha256:b9bfb8695a1c7e3632e800fb83c943bf67ed45ddd87cd0344851610c69a5a482", size = 50316, upload-time = "2020-04-18T15:05:57.698Z" }
[[package]]
name = "pyasn1"
version = "0.6.4"