mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-25 02:40:32 +00:00
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.
This commit is contained in:
+343
-320
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -203,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(
|
||||
@@ -227,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:
|
||||
@@ -255,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(
|
||||
@@ -281,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,
|
||||
@@ -325,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(
|
||||
@@ -349,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")
|
||||
|
||||
@@ -373,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:
|
||||
@@ -493,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(
|
||||
@@ -509,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(
|
||||
@@ -523,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(
|
||||
@@ -547,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(
|
||||
@@ -561,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:
|
||||
@@ -583,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:
|
||||
@@ -613,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:
|
||||
@@ -738,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,
|
||||
@@ -764,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")
|
||||
|
||||
@@ -787,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")
|
||||
@@ -824,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,
|
||||
@@ -1199,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")
|
||||
@@ -1234,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")
|
||||
|
||||
@@ -1258,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:
|
||||
@@ -1293,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:
|
||||
@@ -1335,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:
|
||||
@@ -1371,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:
|
||||
@@ -1402,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")
|
||||
@@ -1454,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:
|
||||
@@ -1518,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:
|
||||
@@ -1572,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:
|
||||
@@ -1626,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(
|
||||
@@ -1648,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/",
|
||||
@@ -1688,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:
|
||||
@@ -1725,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:
|
||||
@@ -1753,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:
|
||||
@@ -1785,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:
|
||||
@@ -1815,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:
|
||||
@@ -1843,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()
|
||||
@@ -1882,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(
|
||||
@@ -1904,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()
|
||||
@@ -1945,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")
|
||||
@@ -1966,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(
|
||||
@@ -1999,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:
|
||||
@@ -2088,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(
|
||||
@@ -2248,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:
|
||||
@@ -2314,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(
|
||||
@@ -2331,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()
|
||||
@@ -2368,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")
|
||||
@@ -2389,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,
|
||||
@@ -2433,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(
|
||||
@@ -2481,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()
|
||||
|
||||
@@ -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()},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -461,8 +461,8 @@ class TestDocumentOperationPermissionChecksRootDocument:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("documents.views.bulk_edit.merge") as mock_merge,
|
||||
patch("documents.views.bulk_edit.rotate") as mock_rotate,
|
||||
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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -348,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",
|
||||
@@ -397,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",
|
||||
@@ -447,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",
|
||||
@@ -497,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",
|
||||
@@ -535,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",
|
||||
@@ -574,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",
|
||||
@@ -610,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",
|
||||
@@ -682,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",
|
||||
@@ -711,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",
|
||||
@@ -738,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",
|
||||
@@ -770,7 +770,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="mock_backend",
|
||||
@@ -808,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",
|
||||
@@ -847,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",
|
||||
@@ -969,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()
|
||||
@@ -989,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,
|
||||
@@ -1015,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()
|
||||
@@ -1039,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()
|
||||
@@ -1052,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,
|
||||
@@ -1066,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,
|
||||
|
||||
@@ -61,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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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"})
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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",)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
+37
-37
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user