mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-25 19:00:33 +00:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2104fd5078 | ||
|
|
10dbb77d47 | ||
|
|
2a44d8b5ba | ||
|
|
abf5050ea7 | ||
|
|
091ddf7c45 | ||
|
|
c9f7f2cfbe | ||
|
|
b457610ffb | ||
|
|
969c2ea0e2 | ||
|
|
31b806a285 | ||
|
|
99851b418c | ||
|
|
e34eda07bb | ||
|
|
793459b416 | ||
|
|
04297fd02c | ||
|
|
1b277dd8e1 | ||
|
|
3c20abeb4c | ||
|
|
b11f1f8459 | ||
|
|
7424e7ce0b | ||
|
|
a53a3d3769 | ||
|
|
15b73b890c | ||
|
|
02d355061f | ||
|
|
d8b5b4d447 | ||
|
|
03ac4aed7e | ||
|
|
90d23bad9c | ||
|
|
e4367b5648 | ||
|
|
cb85441c2f | ||
|
|
cceaa559d4 | ||
|
|
a748d4c64f | ||
|
|
452ed005bd | ||
|
|
40058ff7d5 | ||
|
|
f502cd5e34 | ||
|
|
99ce6b5db3 | ||
|
|
2d955e9697 | ||
|
|
f440e8d33c | ||
|
|
4c264651e8 | ||
|
|
48d97b78bb | ||
|
|
659a0cb2ef | ||
|
|
d02d1e1711 | ||
|
|
3b41810e7b | ||
|
|
12314fcaa8 | ||
|
|
8705bd510a |
@@ -15,6 +15,8 @@
|
||||
# Test related
|
||||
**/.pytest_cache
|
||||
**/tests
|
||||
src/paperless_testing
|
||||
src/conftest.py
|
||||
**/*.spec.ts
|
||||
**/htmlcov
|
||||
# Local folders
|
||||
|
||||
+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]
|
||||
|
||||
@@ -50,12 +50,12 @@ repos:
|
||||
- 'prettier-plugin-organize-imports@4.3.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.16.5
|
||||
rev: v0.16.7
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.28.1"
|
||||
rev: "v2.29.4"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
additional_dependencies: [tomli]
|
||||
|
||||
+1184
-1124
File diff suppressed because it is too large
Load Diff
@@ -153,8 +153,11 @@ in similar existing documents, and the document chat can retrieve relevant conte
|
||||
|
||||
Enable it by setting
|
||||
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
||||
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only
|
||||
built when AI is enabled **and** an embedding backend is set.
|
||||
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main
|
||||
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY)
|
||||
and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured.
|
||||
|
||||
The index is only built when AI is enabled **and** an embedding backend is set.
|
||||
|
||||
The index is updated automatically on a schedule controlled by
|
||||
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.2.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: only pass --conf to flower when flowerconfig.py exists [@bitfoo1](https://github.com/bitfoo1) ([#14182](https://github.com/paperless-ngx/paperless-ngx/pull/14182))
|
||||
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>4 changes</summary>
|
||||
|
||||
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.2.0
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
@@ -2133,6 +2133,13 @@ for language and resource considerations.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_API_KEY=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) {#PAPERLESS_AI_LLM_EMBEDDING_API_KEY}
|
||||
|
||||
: The API key to use for the embedding backend. If not supplied, embeddings use
|
||||
`PAPERLESS_AI_LLM_API_KEY`.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
||||
|
||||
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
||||
@@ -2217,6 +2224,19 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
||||
|
||||
Defaults to true, which allows internal endpoints.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=<json>`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS}
|
||||
|
||||
: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not
|
||||
set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the
|
||||
backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning
|
||||
models refuse tool calls on the chat completions API unless reasoning is off:
|
||||
|
||||
```
|
||||
PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"}
|
||||
```
|
||||
|
||||
Defaults to empty, which adds nothing to requests.
|
||||
|
||||
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
||||
|
||||
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
||||
|
||||
@@ -150,6 +150,7 @@ pnpm ng build --configuration production
|
||||
is loaded as well. However, the tests rely on the default
|
||||
configuration. This is not ideal. But for now, make sure no settings
|
||||
except for DEBUG are overridden when testing.
|
||||
- Tests run in a random order each session, so that one test cannot quietly depend on another having run first. The seed is printed at the top of the run; pass `--randomly-seed=<seed>` to replay that exact order, or `--randomly-seed=last` to repeat the previous run.
|
||||
|
||||
!!! note
|
||||
|
||||
|
||||
+16
-5
@@ -1,7 +1,9 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.2.1"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
description = """\
|
||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||
"""
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -10,6 +12,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Programming Language :: Python :: 3.15",
|
||||
]
|
||||
# TODO: Move certain things to groups and then utilize that further
|
||||
# This will allow testing to not install a webserver, mysql, etc
|
||||
@@ -117,7 +120,7 @@ testing = [
|
||||
"pytest-env~=1.7.0",
|
||||
"pytest-httpx",
|
||||
"pytest-mock~=3.15.1",
|
||||
# "pytest-randomly~=4.0.1",
|
||||
"pytest-randomly~=5.0.0",
|
||||
"pytest-rerunfailures~=16.4",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist~=3.8.0",
|
||||
@@ -244,6 +247,10 @@ per-file-ignores."docker/wait-for-redis.py" = [
|
||||
per-file-ignores."src/documents/models.py" = [
|
||||
"SIM115",
|
||||
]
|
||||
per-file-ignores."src/documents/tests/*.py" = [
|
||||
"TID251",
|
||||
]
|
||||
flake8-tidy-imports.banned-api."documents.tests".msg = "Shared test infrastructure lives in src/paperless_testing/."
|
||||
isort.force-single-line = true
|
||||
|
||||
[tool.codespell]
|
||||
@@ -271,9 +278,9 @@ plugins = [
|
||||
]
|
||||
|
||||
[tool.pyrefly]
|
||||
baseline = ".pyrefly-baseline.json"
|
||||
python-platform = "linux"
|
||||
search-path = [ "src" ]
|
||||
baseline = ".pyrefly-baseline.json"
|
||||
|
||||
[tool.django-stubs]
|
||||
django_settings_module = "paperless.settings"
|
||||
@@ -326,6 +333,8 @@ PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
||||
# I don't think anything hits this, but just in case, basically infinite
|
||||
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
|
||||
# The 0.1s production default trips on a stalled CI runner, the date parsing tests then find no dates
|
||||
PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS = "5"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = [
|
||||
@@ -334,13 +343,15 @@ source = [
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"manage.py",
|
||||
"paperless/wsgi.py",
|
||||
"paperless/auth.py",
|
||||
"paperless/wsgi.py",
|
||||
"src/conftest.py",
|
||||
"src/paperless_testing/*",
|
||||
]
|
||||
[tool.coverage.report]
|
||||
exclude_also = [
|
||||
"if settings.AUDIT_LOG_ENABLED:",
|
||||
"if AUDIT_LOG_ENABLED:",
|
||||
"if settings.AUDIT_LOG_ENABLED:",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
|
||||
+35
-21
@@ -9745,7 +9745,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">348</context>
|
||||
<context context-type="linenumber">351</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
||||
@@ -9760,7 +9760,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">341</context>
|
||||
<context context-type="linenumber">344</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
||||
@@ -10016,56 +10016,56 @@
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
<context context-type="linenumber">332</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4135055128446167640" datatype="html">
|
||||
<source>Open first [selected] document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">357</context>
|
||||
<context context-type="linenumber">360</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3629960544875360046" datatype="html">
|
||||
<source>Previous page</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">373</context>
|
||||
<context context-type="linenumber">376</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3337301694210287595" datatype="html">
|
||||
<source>Next page</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">385</context>
|
||||
<context context-type="linenumber">388</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2155249406916744630" datatype="html">
|
||||
<source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">419</context>
|
||||
<context context-type="linenumber">422</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4646273665293421938" datatype="html">
|
||||
<source>Failed to save view "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>".</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">425</context>
|
||||
<context context-type="linenumber">428</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6837554170707123455" datatype="html">
|
||||
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">494</context>
|
||||
<context context-type="linenumber">497</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6028096992841030074" datatype="html">
|
||||
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully, but could not update visibility settings.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||
<context context-type="linenumber">500</context>
|
||||
<context context-type="linenumber">503</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="739880801667335279" datatype="html">
|
||||
@@ -12018,81 +12018,95 @@
|
||||
<context context-type="linenumber">351</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="861068592166833023" datatype="html">
|
||||
<source>LLM Embedding API Key</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">358</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2929108042259892948" datatype="html">
|
||||
<source>Used for embeddings when set, otherwise LLM API key is used.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">360</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3554114880473286122" datatype="html">
|
||||
<source>LLM Embedding Endpoint</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">358</context>
|
||||
<context context-type="linenumber">366</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1044242175651289991" datatype="html">
|
||||
<source>LLM Embedding Chunk Size</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">365</context>
|
||||
<context context-type="linenumber">373</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7218245223139363113" datatype="html">
|
||||
<source>LLM Context Size</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">372</context>
|
||||
<context context-type="linenumber">380</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4234495692726214397" datatype="html">
|
||||
<source>LLM Backend</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">379</context>
|
||||
<context context-type="linenumber">387</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7935234833834000002" datatype="html">
|
||||
<source>LLM Model</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">387</context>
|
||||
<context context-type="linenumber">395</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1980550530387803165" datatype="html">
|
||||
<source>LLM API Key</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">394</context>
|
||||
<context context-type="linenumber">402</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6126617860376156501" datatype="html">
|
||||
<source>LLM Endpoint</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">401</context>
|
||||
<context context-type="linenumber">409</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6572826277249350975" datatype="html">
|
||||
<source>LLM Output Language</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">408</context>
|
||||
<context context-type="linenumber">416</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3284403507172415792" datatype="html">
|
||||
<source>Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">412</context>
|
||||
<context context-type="linenumber">420</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4493921125434706859" datatype="html">
|
||||
<source>LLM Request Timeout</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">416</context>
|
||||
<context context-type="linenumber">424</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="483994032066441287" datatype="html">
|
||||
<source>Timeout in seconds for LLM requests.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">420</context>
|
||||
<context context-type="linenumber">428</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1055686627716339120" datatype="html">
|
||||
|
||||
@@ -146,6 +146,19 @@ describe('DocumentListComponent', () => {
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop reloading on document deleted after destroy', () => {
|
||||
const reloadSpy = jest.spyOn(documentListService, 'reload')
|
||||
const documentDeletedSubject = new Subject<boolean>()
|
||||
jest
|
||||
.spyOn(websocketStatusService, 'onDocumentDeleted')
|
||||
.mockReturnValue(documentDeletedSubject)
|
||||
fixture.detectChanges()
|
||||
fixture.destroy()
|
||||
reloadSpy.mockClear()
|
||||
documentDeletedSubject.next(true)
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show score sort fields on fulltext queries', () => {
|
||||
documentListService.setFilterRules([
|
||||
{
|
||||
|
||||
@@ -270,9 +270,12 @@ export class DocumentListComponent
|
||||
this.list.reload()
|
||||
})
|
||||
|
||||
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
|
||||
this.list.reload()
|
||||
})
|
||||
this.websocketStatusService
|
||||
.onDocumentDeleted()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.list.reload()
|
||||
})
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
|
||||
@@ -353,6 +353,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
{
|
||||
key: 'llm_embedding_api_key',
|
||||
title: $localize`LLM Embedding API Key`,
|
||||
type: ConfigOptionType.Password,
|
||||
note: $localize`Used for embeddings when set, otherwise LLM API key is used.`,
|
||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_API_KEY',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
{
|
||||
key: 'llm_embedding_endpoint',
|
||||
title: $localize`LLM Embedding Endpoint`,
|
||||
@@ -457,6 +465,7 @@ export interface PaperlessConfig extends ObjectWithId {
|
||||
ai_enabled: boolean
|
||||
llm_embedding_backend: string
|
||||
llm_embedding_model: string
|
||||
llm_embedding_api_key: string
|
||||
llm_embedding_endpoint: string
|
||||
llm_embedding_chunk_size: number
|
||||
llm_context_size: number
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
"""Fixtures available to every Paperless-ngx app.
|
||||
|
||||
Loaded automatically for every test path. Keep module-scope imports minimal:
|
||||
this file is imported for every session, so anything heavy belongs inside
|
||||
the fixture body that needs it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from pytest_django.fixtures import Settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def faker_session_locale() -> str:
|
||||
"""Pin Faker's locale so generated data does not follow the host locale.
|
||||
|
||||
The seed itself is left to pytest-randomly, which derives one per run.
|
||||
"""
|
||||
return "en_US"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_password_hasher(settings: Settings) -> None:
|
||||
"""Hash test passwords with MD5 instead of Django's default PBKDF2.
|
||||
|
||||
PBKDF2 is deliberately slow, and every ``admin_user`` or
|
||||
``create_superuser`` call pays for it: about 600 ms each. No test depends
|
||||
on the hash format, only on ``check_password`` and on the stored value
|
||||
changing when the password does.
|
||||
"""
|
||||
settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_content_type_caches() -> None:
|
||||
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
|
||||
|
||||
Tests that delete and reinsert ContentType/Permission rows (e.g. the
|
||||
importer) corrupt both caches. Without this fixture a subsequent test on
|
||||
the same xdist worker sees stale ContentType objects and guardian raises
|
||||
MixedContentTypeError.
|
||||
"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from guardian.shortcuts import clear_ct_cache
|
||||
|
||||
ContentType.objects.clear_cache()
|
||||
clear_ct_cache()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_django_caches() -> None:
|
||||
"""Clear every configured cache before each test.
|
||||
|
||||
Cached values outlive the test that wrote them: the classifier keys its
|
||||
vectorized content on a hash of the content itself, so a second test
|
||||
generating the same fixture data takes the cache-hit path and never calls
|
||||
the code it is asserting against.
|
||||
"""
|
||||
from django.core.cache import caches
|
||||
|
||||
for cache in caches.all(initialized_only=False):
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def paperless_dirs(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
) -> Generator[PaperlessDirs, None, None]:
|
||||
"""The standard temp directory layout, applied to Django settings."""
|
||||
from documents.search import reset_backend
|
||||
from paperless_testing.dirs import build_paperless_dirs
|
||||
from paperless_testing.dirs import dirs_settings
|
||||
|
||||
dirs = build_paperless_dirs(tmp_path)
|
||||
for name, value in dirs_settings(dirs).items():
|
||||
setattr(settings, name, value)
|
||||
|
||||
# Not directory settings, but they are needed alongside the layout by the
|
||||
# sanity checker tests.
|
||||
settings.IGNORABLE_FILES = {".DS_Store", "Thumbs.db", "desktop.ini"}
|
||||
settings.APP_LOGO = ""
|
||||
|
||||
reset_backend()
|
||||
yield dirs
|
||||
reset_backend()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rest_api_client() -> APIClient:
|
||||
"""The basic DRF APIClient, unauthenticated."""
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regular_user(db: None) -> User:
|
||||
"""Unprivileged user for permission boundary tests."""
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
return UserFactory(username="regular")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(rest_api_client: APIClient, admin_user: User) -> APIClient:
|
||||
"""Admin client pre-authenticated and sending the v10 Accept header."""
|
||||
rest_api_client.force_authenticate(user=admin_user)
|
||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
||||
return rest_api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def v9_client(rest_api_client: APIClient, admin_user: User) -> APIClient:
|
||||
"""Admin client pre-authenticated and sending the v9 Accept header."""
|
||||
rest_api_client.force_authenticate(user=admin_user)
|
||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=9")
|
||||
return rest_api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_client(rest_api_client: APIClient, regular_user: User) -> APIClient:
|
||||
"""Regular-user client pre-authenticated and sending the v10 Accept header."""
|
||||
rest_api_client.force_authenticate(user=regular_user)
|
||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
||||
return rest_api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_progress_manager(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> type[FakeProgressManager]:
|
||||
"""Replace documents.tasks.ProgressManager with the fake, so consuming a file
|
||||
in a test never tries to reach a broker."""
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
|
||||
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
|
||||
return FakeProgressManager
|
||||
@@ -26,7 +26,6 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(set_document_type)
|
||||
document_consumption_finished.connect(set_tags)
|
||||
document_consumption_finished.connect(set_storage_path)
|
||||
document_consumption_finished.connect(add_to_index)
|
||||
document_consumption_finished.connect(run_workflows_added)
|
||||
document_consumption_finished.connect(add_to_index)
|
||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||
|
||||
@@ -857,8 +857,9 @@ class ConsumerPlugin(
|
||||
self.log.debug(f"Creation date from parse_date: {create_date}")
|
||||
else:
|
||||
stats = Path(self.input_doc.original_file).stat()
|
||||
create_date = timezone.make_aware(
|
||||
datetime.datetime.fromtimestamp(stats.st_mtime),
|
||||
create_date = datetime.datetime.fromtimestamp(
|
||||
stats.st_mtime,
|
||||
tz=timezone.get_current_timezone(),
|
||||
)
|
||||
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
||||
|
||||
|
||||
@@ -196,52 +196,49 @@ class WriteBatch:
|
||||
return self._raw_writer
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
if self._backend._path is not None:
|
||||
lock_path = self._backend._path / ".tantivy.lock"
|
||||
self._lock = filelock.FileLock(str(lock_path))
|
||||
for attempt in range(_LOCK_RETRY_ATTEMPTS):
|
||||
try:
|
||||
self._lock.acquire(timeout=self._lock_timeout)
|
||||
break
|
||||
except filelock.Timeout:
|
||||
if attempt == _LOCK_RETRY_ATTEMPTS - 1:
|
||||
raise SearchIndexLockError(
|
||||
f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} "
|
||||
f"attempts (timeout={self._lock_timeout}s each)",
|
||||
)
|
||||
sleep_s = random.uniform(
|
||||
0,
|
||||
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)),
|
||||
lock_path = self._backend._path / ".tantivy.lock"
|
||||
self._lock = filelock.FileLock(str(lock_path))
|
||||
for attempt in range(_LOCK_RETRY_ATTEMPTS):
|
||||
try:
|
||||
self._lock.acquire(timeout=self._lock_timeout)
|
||||
break
|
||||
except filelock.Timeout:
|
||||
if attempt == _LOCK_RETRY_ATTEMPTS - 1:
|
||||
raise SearchIndexLockError(
|
||||
f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} "
|
||||
f"attempts (timeout={self._lock_timeout}s each)",
|
||||
)
|
||||
logger.debug(
|
||||
"Index lock contention; retrying in %.2fs (attempt %d/%d)",
|
||||
sleep_s,
|
||||
attempt + 1,
|
||||
_LOCK_RETRY_ATTEMPTS,
|
||||
)
|
||||
time.sleep(sleep_s)
|
||||
sleep_s = random.uniform(
|
||||
0,
|
||||
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)),
|
||||
)
|
||||
logger.debug(
|
||||
"Index lock contention; retrying in %.2fs (attempt %d/%d)",
|
||||
sleep_s,
|
||||
attempt + 1,
|
||||
_LOCK_RETRY_ATTEMPTS,
|
||||
)
|
||||
time.sleep(sleep_s)
|
||||
|
||||
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory)
|
||||
# for the write, rather than reusing the process-local cached
|
||||
# index. ManagedDirectory loads its GC bookkeeping (.managed.json)
|
||||
# once, at construction, and never re-reads it; paperless runs
|
||||
# several long-lived processes (Granian workers, Celery workers)
|
||||
# that take turns writing under the file lock above. A cached,
|
||||
# long-lived writer index would carry a stale managed-files view
|
||||
# and, on commit, overwrite .managed.json with that stale view -
|
||||
# permanently losing track of segment files other processes
|
||||
# registered in the meantime, so they can never be garbage
|
||||
# collected. Reopening fresh here always picks up the current
|
||||
# on-disk state. The long-lived self._backend._index is used for
|
||||
# reads only and is reloaded (not reopened) after commit below.
|
||||
write_index = tantivy.Index(
|
||||
build_schema(),
|
||||
path=str(self._backend._path),
|
||||
)
|
||||
register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
|
||||
self._raw_writer = write_index.writer()
|
||||
else:
|
||||
self._raw_writer = self._backend._index.writer()
|
||||
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory)
|
||||
# for the write, rather than reusing the process-local cached
|
||||
# index. ManagedDirectory loads its GC bookkeeping (.managed.json)
|
||||
# once, at construction, and never re-reads it; paperless runs
|
||||
# several long-lived processes (Granian workers, Celery workers)
|
||||
# that take turns writing under the file lock above. A cached,
|
||||
# long-lived writer index would carry a stale managed-files view
|
||||
# and, on commit, overwrite .managed.json with that stale view -
|
||||
# permanently losing track of segment files other processes
|
||||
# registered in the meantime, so they can never be garbage
|
||||
# collected. Reopening fresh here always picks up the current
|
||||
# on-disk state. The long-lived self._backend._index is used for
|
||||
# reads only and is reloaded (not reopened) after commit below.
|
||||
write_index = tantivy.Index(
|
||||
build_schema(),
|
||||
path=str(self._backend._path),
|
||||
)
|
||||
register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
|
||||
self._raw_writer = write_index.writer()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
@@ -372,9 +369,8 @@ class TantivyBackend:
|
||||
Tantivy search backend with explicit lifecycle management.
|
||||
|
||||
Provides full-text search capabilities using the Tantivy search engine.
|
||||
Supports in-memory indexes (for testing) and persistent on-disk indexes
|
||||
(for production use). Handles document indexing, search queries, autocompletion,
|
||||
and "more like this" functionality.
|
||||
Keeps a persistent on-disk index. Handles document indexing, search queries,
|
||||
autocompletion, and "more like this" functionality.
|
||||
|
||||
The backend manages its own connection lifecycle and can be reset when
|
||||
the underlying index directory changes (e.g., during test isolation).
|
||||
@@ -408,9 +404,7 @@ class TantivyBackend:
|
||||
},
|
||||
)
|
||||
|
||||
def __init__(self, path: Path | None = None):
|
||||
# path=None → in-memory index (for tests)
|
||||
# path=some_dir → on-disk index (for production)
|
||||
def __init__(self, path: Path):
|
||||
self._path = path
|
||||
self._raw_index: tantivy.Index | None = None
|
||||
self._raw_schema: tantivy.Schema | None = None
|
||||
@@ -429,16 +423,13 @@ class TantivyBackend:
|
||||
"""
|
||||
Open or rebuild the index as needed.
|
||||
|
||||
For disk-based indexes, checks if rebuilding is needed due to schema
|
||||
version or language changes. Registers custom tokenizers after opening.
|
||||
Checks if rebuilding is needed due to schema version or language
|
||||
changes. Registers custom tokenizers after opening.
|
||||
Safe to call multiple times - subsequent calls are no-ops.
|
||||
"""
|
||||
if self._raw_index is not None:
|
||||
return # pragma: no cover
|
||||
if self._path is not None:
|
||||
self._raw_index = open_or_rebuild_index(self._path)
|
||||
else:
|
||||
self._raw_index = tantivy.Index(build_schema())
|
||||
self._raw_index = open_or_rebuild_index(self._path)
|
||||
register_tokenizers(self._raw_index, settings.SEARCH_LANGUAGE)
|
||||
self._raw_schema = self._raw_index.schema
|
||||
|
||||
@@ -1102,13 +1093,9 @@ class TantivyBackend:
|
||||
writer's threads). Larger values buffer more docs in RAM before
|
||||
flushing a segment, deferring merge work; they do not avoid it.
|
||||
"""
|
||||
# Create new index (on-disk or in-memory)
|
||||
if self._path is not None:
|
||||
wipe_index(self._path)
|
||||
new_index = tantivy.Index(build_schema(), path=str(self._path))
|
||||
_write_sentinels(self._path)
|
||||
else:
|
||||
new_index = tantivy.Index(build_schema())
|
||||
wipe_index(self._path)
|
||||
new_index = tantivy.Index(build_schema(), path=str(self._path))
|
||||
_write_sentinels(self._path)
|
||||
register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
|
||||
|
||||
# Point instance at the new index so _build_tantivy_doc uses it
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,568 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from guardian.utils import get_group_obj_perms_model
|
||||
from guardian.utils import get_user_obj_perms_model
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents.models import Document
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import Note
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.regex import validate_regex_pattern
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
# https://www.django-rest-framework.org/api-guide/serializers/#example
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer[Any]):
|
||||
"""
|
||||
A ModelSerializer that takes an additional `fields` argument that
|
||||
controls which fields should be displayed.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
# Don't pass the 'fields' arg up to the superclass
|
||||
fields = kwargs.pop("fields", None)
|
||||
|
||||
# Instantiate the superclass normally
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if fields is not None:
|
||||
# Drop any fields that are not specified in the `fields` argument.
|
||||
allowed = set(fields)
|
||||
existing = set(self.fields)
|
||||
for field_name in existing - allowed:
|
||||
self.fields.pop(field_name)
|
||||
|
||||
|
||||
class DocumentUpdateFieldsModelSerializer(DynamicFieldsModelSerializer):
|
||||
stale_update_excluded_fields = frozenset({"filename", "archive_filename"})
|
||||
|
||||
def _get_update_fields(self, validated_data) -> list[str]:
|
||||
model_fields = {
|
||||
field.name
|
||||
for field in self.Meta.model._meta.concrete_fields
|
||||
if field.name not in self.stale_update_excluded_fields
|
||||
}
|
||||
update_fields = [
|
||||
field_name for field_name in validated_data if field_name in model_fields
|
||||
]
|
||||
if "modified" in model_fields and "modified" not in update_fields:
|
||||
update_fields.append("modified")
|
||||
return update_fields
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
serializers.raise_errors_on_nested_writes("update", self, validated_data)
|
||||
info = model_meta.get_field_info(instance)
|
||||
|
||||
m2m_fields = []
|
||||
for attr, value in validated_data.items():
|
||||
if attr in info.relations and info.relations[attr].to_many:
|
||||
m2m_fields.append((attr, value))
|
||||
else:
|
||||
setattr(instance, attr, value)
|
||||
|
||||
# File names are managed by post-save file handling. Saving only the
|
||||
# serializer-updated fields prevents stale in-memory path values from
|
||||
# overwriting a concurrent move.
|
||||
instance.save(update_fields=self._get_update_fields(validated_data))
|
||||
|
||||
for attr, value in m2m_fields:
|
||||
field = getattr(instance, attr)
|
||||
field.set(value)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class MatchingModelSerializer(serializers.ModelSerializer[Any]):
|
||||
document_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
def get_slug(self, obj) -> str:
|
||||
return slugify(obj.name)
|
||||
|
||||
slug = SerializerMethodField()
|
||||
|
||||
def validate(self, data):
|
||||
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
|
||||
name = data.get(
|
||||
"name",
|
||||
self.instance.name if hasattr(self.instance, "name") else None,
|
||||
)
|
||||
owner = (
|
||||
data["owner"]
|
||||
if "owner" in data
|
||||
else self.user
|
||||
if hasattr(self, "user")
|
||||
else None
|
||||
)
|
||||
pk = self.instance.pk if hasattr(self.instance, "pk") else None
|
||||
if ("name" in data or "owner" in data) and self.Meta.model.objects.filter(
|
||||
name=name,
|
||||
owner=owner,
|
||||
).exclude(pk=pk).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Object violates owner / name unique constraint"},
|
||||
)
|
||||
return data
|
||||
|
||||
def validate_match(self, match):
|
||||
if (
|
||||
"matching_algorithm" in self.initial_data
|
||||
and self.initial_data["matching_algorithm"] == MatchingModel.MATCH_REGEX
|
||||
):
|
||||
try:
|
||||
validate_regex_pattern(match)
|
||||
except ValueError as e:
|
||||
logger.debug(f"Invalid regular expression: {e!s}")
|
||||
raise serializers.ValidationError(
|
||||
"Invalid regular expression, see log for details.",
|
||||
)
|
||||
return match
|
||||
|
||||
|
||||
PERMISSION_ACTIONS = ("view", "change")
|
||||
|
||||
|
||||
class SetPermissionsMixin:
|
||||
def _validate_user_ids(self, user_ids):
|
||||
users = User.objects.none()
|
||||
if user_ids is not None:
|
||||
users = User.objects.filter(id__in=user_ids)
|
||||
if not users.count() == len(user_ids):
|
||||
raise serializers.ValidationError(
|
||||
"Some users in don't exist or were specified twice.",
|
||||
)
|
||||
return users
|
||||
|
||||
def _validate_group_ids(self, group_ids):
|
||||
groups = Group.objects.none()
|
||||
if group_ids is not None:
|
||||
groups = Group.objects.filter(id__in=group_ids)
|
||||
if not groups.count() == len(group_ids):
|
||||
raise serializers.ValidationError(
|
||||
"Some groups in don't exist or were specified twice.",
|
||||
)
|
||||
return groups
|
||||
|
||||
def validate_set_permissions(self, set_permissions=None):
|
||||
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
|
||||
if set_permissions is not None:
|
||||
for action in PERMISSION_ACTIONS:
|
||||
if action in set_permissions:
|
||||
if "users" in set_permissions[action]:
|
||||
users = set_permissions[action]["users"]
|
||||
permissions_dict[action]["users"] = self._validate_user_ids(
|
||||
users,
|
||||
)
|
||||
if "groups" in set_permissions[action]:
|
||||
groups = set_permissions[action]["groups"]
|
||||
permissions_dict[action]["groups"] = self._validate_group_ids(
|
||||
groups,
|
||||
)
|
||||
else:
|
||||
del permissions_dict[action]
|
||||
return permissions_dict
|
||||
|
||||
def _set_permissions(self, permissions, object) -> None:
|
||||
set_permissions_for_object(permissions, object)
|
||||
|
||||
|
||||
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.user = kwargs.pop("user", None)
|
||||
self.full_perms = kwargs.pop("full_perms", False)
|
||||
self.all_fields = kwargs.pop("all_fields", False)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class PermissionSetSerializer(serializers.Serializer[dict[str, Any]]):
|
||||
users = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
groups = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
|
||||
class SetPermissionsSerializer(serializers.Serializer[dict[str, Any]]):
|
||||
view = PermissionSetSerializer(required=False)
|
||||
change = PermissionSetSerializer(required=False)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, dict):
|
||||
unknown_keys = set(data) - set(PERMISSION_ACTIONS)
|
||||
if unknown_keys:
|
||||
raise serializers.ValidationError(
|
||||
{key: "Unknown permission action." for key in sorted(unknown_keys)},
|
||||
)
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
class OwnedObjectSerializer(
|
||||
SerializerWithPerms,
|
||||
serializers.ModelSerializer[Any],
|
||||
SetPermissionsMixin,
|
||||
):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not self.all_fields:
|
||||
try:
|
||||
if self.full_perms:
|
||||
self.fields.pop("user_can_change")
|
||||
self.fields.pop("is_shared_by_requester")
|
||||
else:
|
||||
self.fields.pop("permissions")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _get_perms(self, obj, codename: str, target: Literal["users", "groups"]):
|
||||
"""
|
||||
Get the given permissions from context or from django-guardian.
|
||||
|
||||
:param codename: The permission codename, e.g. 'view' or 'change'
|
||||
:param target: 'users' or 'groups'
|
||||
"""
|
||||
key = f"{target}_{codename}_perms"
|
||||
cached = self.context.get(key, {}).get(obj.pk)
|
||||
if cached is not None:
|
||||
return list(cached)
|
||||
|
||||
# Permission not found in the context, get it from guardian
|
||||
if target == "users":
|
||||
return list(
|
||||
get_users_with_perms(
|
||||
obj,
|
||||
only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"],
|
||||
with_group_users=False,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
else: # groups
|
||||
return list(
|
||||
get_groups_with_only_permission(
|
||||
obj,
|
||||
codename=f"{codename}_{obj.__class__.__name__.lower()}",
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
|
||||
@extend_schema_field(
|
||||
field={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"view": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"change": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_permissions(self, obj) -> dict:
|
||||
return {
|
||||
"view": {
|
||||
"users": self._get_perms(obj, "view", "users"),
|
||||
"groups": self._get_perms(obj, "view", "groups"),
|
||||
},
|
||||
"change": {
|
||||
"users": self._get_perms(obj, "change", "users"),
|
||||
"groups": self._get_perms(obj, "change", "groups"),
|
||||
},
|
||||
}
|
||||
|
||||
def get_user_can_change(self, obj) -> bool:
|
||||
if obj.owner is None or obj.owner == self.user:
|
||||
return True
|
||||
if self.user is None:
|
||||
return False
|
||||
if self.user.is_active and self.user.is_superuser:
|
||||
# Mirrors guardian's own ObjectPermissionChecker.has_perm() shortcut --
|
||||
# superusers aren't necessarily granted explicit object permissions,
|
||||
# so the batched context below would otherwise incorrectly say no.
|
||||
return True
|
||||
|
||||
# Prefer the page-level batch computed by BulkPermissionMixin
|
||||
# (get_serializer_context) over a fresh per-object guardian check,
|
||||
# which would otherwise query the permission tables once per row.
|
||||
users_change_perms = self.context.get("users_change_perms")
|
||||
groups_change_perms = self.context.get("groups_change_perms")
|
||||
if users_change_perms is not None and groups_change_perms is not None:
|
||||
if self.user.pk in users_change_perms.get(obj.pk, []):
|
||||
return True
|
||||
user_group_ids = getattr(self, "_user_group_ids", None)
|
||||
if user_group_ids is None:
|
||||
user_group_ids = set(self.user.groups.values_list("id", flat=True))
|
||||
self._user_group_ids = user_group_ids
|
||||
return bool(
|
||||
user_group_ids.intersection(groups_change_perms.get(obj.pk, [])),
|
||||
)
|
||||
|
||||
checker = ObjectPermissionChecker(self.user)
|
||||
return checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
|
||||
|
||||
@staticmethod
|
||||
def get_shared_object_pks(objects: Iterable):
|
||||
"""
|
||||
Return the primary keys of the subset of objects that are shared.
|
||||
"""
|
||||
try:
|
||||
first_obj = next(iter(objects))
|
||||
except StopIteration:
|
||||
return set()
|
||||
|
||||
ctype = ContentType.objects.get_for_model(first_obj)
|
||||
object_pks = list(obj.pk for obj in objects)
|
||||
pk_type = type(first_obj.pk)
|
||||
|
||||
def get_pks_for_permission_type(model):
|
||||
return map(
|
||||
pk_type, # coerce the pk to be the same type of the provided objects
|
||||
model.objects.filter(
|
||||
content_type=ctype,
|
||||
object_pk__in=object_pks,
|
||||
)
|
||||
.values_list("object_pk", flat=True)
|
||||
.distinct(),
|
||||
)
|
||||
|
||||
UserObjectPermission = get_user_obj_perms_model()
|
||||
GroupObjectPermission = get_group_obj_perms_model()
|
||||
user_permission_pks = get_pks_for_permission_type(UserObjectPermission)
|
||||
group_permission_pks = get_pks_for_permission_type(GroupObjectPermission)
|
||||
|
||||
return set(user_permission_pks) | set(group_permission_pks)
|
||||
|
||||
def get_is_shared_by_requester(self, obj: Document) -> bool:
|
||||
# First check the context to see if `shared_object_pks` is set by the parent.
|
||||
shared_object_pks = self.context.get("shared_object_pks")
|
||||
# If not just check if the current object is shared.
|
||||
if shared_object_pks is None:
|
||||
shared_object_pks = self.get_shared_object_pks([obj])
|
||||
return obj.owner == self.user and obj.id in shared_object_pks
|
||||
|
||||
permissions = SerializerMethodField(read_only=True, required=False)
|
||||
user_can_change = SerializerMethodField(read_only=True, required=False)
|
||||
is_shared_by_requester = SerializerMethodField(read_only=True, required=False)
|
||||
|
||||
set_permissions = SetPermissionsSerializer(
|
||||
label="Set permissions",
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
# other methods in mixin
|
||||
|
||||
def validate_unique_together(self, validated_data, instance=None) -> None:
|
||||
# workaround for https://github.com/encode/django-rest-framework/issues/9358
|
||||
if "owner" in validated_data and "name" in self.Meta.fields:
|
||||
name = validated_data.get("name", instance.name if instance else None)
|
||||
objects = (
|
||||
self.Meta.model.objects.exclude(pk=instance.pk)
|
||||
if instance
|
||||
else self.Meta.model.objects.all()
|
||||
)
|
||||
not_unique = objects.filter(
|
||||
owner=validated_data["owner"],
|
||||
name=name,
|
||||
).exists()
|
||||
if not_unique:
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Object violates owner / name unique constraint"},
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
# default to current user if not set
|
||||
request = self.context.get("request")
|
||||
if (
|
||||
"owner" not in validated_data
|
||||
or (request is not None and "owner" not in request.data)
|
||||
) and self.user:
|
||||
validated_data["owner"] = self.user
|
||||
permissions = None
|
||||
if "set_permissions" in validated_data:
|
||||
permissions = validated_data.pop("set_permissions")
|
||||
self.validate_unique_together(validated_data)
|
||||
instance = super().create(validated_data)
|
||||
if permissions is not None:
|
||||
self._set_permissions(permissions, instance)
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
user = getattr(self, "user", None)
|
||||
is_superuser = user.is_superuser if user is not None else False
|
||||
is_owner = instance.owner == user if user is not None else False
|
||||
is_unowned = instance.owner is None
|
||||
|
||||
if (
|
||||
("owner" in validated_data and validated_data["owner"] != instance.owner)
|
||||
or "set_permissions" in validated_data
|
||||
) and not (is_superuser or is_owner or is_unowned):
|
||||
raise PermissionDenied(
|
||||
_("Insufficient permissions."),
|
||||
)
|
||||
|
||||
if "set_permissions" in validated_data:
|
||||
self._set_permissions(validated_data["set_permissions"], instance)
|
||||
self.validate_unique_together(validated_data, instance)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class OwnedObjectListSerializer(serializers.ListSerializer[Any]):
|
||||
def to_representation(self, documents):
|
||||
self.child.context["shared_object_pks"] = self.child.get_shared_object_pks(
|
||||
documents,
|
||||
)
|
||||
return super().to_representation(documents)
|
||||
|
||||
|
||||
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
||||
"""
|
||||
Based on https://stackoverflow.com/a/62579804
|
||||
"""
|
||||
|
||||
def __init__(self, method_name=None, *args, **kwargs) -> None:
|
||||
self.method_name = method_name
|
||||
kwargs["source"] = "*"
|
||||
super(serializers.SerializerMethodField, self).__init__(*args, **kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return {self.field_name: data}
|
||||
|
||||
|
||||
class DocumentListSerializer(serializers.Serializer[dict[str, list[int]]]):
|
||||
documents = serializers.ListField(
|
||||
required=True,
|
||||
label="Documents",
|
||||
write_only=True,
|
||||
child=serializers.IntegerField(),
|
||||
)
|
||||
|
||||
def _validate_document_id_list(self, documents, name="documents") -> None:
|
||||
if not isinstance(documents, list):
|
||||
raise serializers.ValidationError(f"{name} must be a list")
|
||||
if not all(isinstance(i, int) for i in documents):
|
||||
raise serializers.ValidationError(f"{name} must be a list of integers")
|
||||
count = Document.objects.filter(id__in=documents).count()
|
||||
if not count == len(documents):
|
||||
raise serializers.ValidationError(
|
||||
f"Some documents in {name} don't exist or were specified twice.",
|
||||
)
|
||||
|
||||
def validate_documents(self, documents):
|
||||
self._validate_document_id_list(documents)
|
||||
return documents
|
||||
|
||||
|
||||
class DocumentSelectionSerializer(DocumentListSerializer):
|
||||
documents = serializers.ListField(
|
||||
required=False,
|
||||
label="Documents",
|
||||
write_only=True,
|
||||
child=serializers.IntegerField(),
|
||||
)
|
||||
|
||||
all = serializers.BooleanField(
|
||||
default=False,
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
filters = serializers.DictField(
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
excluded_documents = serializers.ListField(
|
||||
required=False,
|
||||
default=list,
|
||||
write_only=True,
|
||||
child=serializers.IntegerField(),
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get("all", False):
|
||||
attrs.setdefault("documents", [])
|
||||
return attrs
|
||||
|
||||
if attrs["excluded_documents"]:
|
||||
raise serializers.ValidationError(
|
||||
"excluded_documents is only supported when all is true.",
|
||||
)
|
||||
|
||||
if "documents" not in attrs:
|
||||
raise serializers.ValidationError(
|
||||
"documents is required unless all is true.",
|
||||
)
|
||||
|
||||
documents = attrs["documents"]
|
||||
self._validate_document_id_list(documents)
|
||||
return attrs
|
||||
|
||||
|
||||
class SourceModeValidationMixin:
|
||||
def validate_source_mode(self, source_mode: str) -> str:
|
||||
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
|
||||
raise serializers.ValidationError("Invalid source_mode")
|
||||
return source_mode
|
||||
|
||||
|
||||
class BasicUserSerializer(serializers.ModelSerializer[User]):
|
||||
# Different than paperless.serializers.UserSerializer
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "first_name", "last_name"]
|
||||
|
||||
|
||||
class NotesSerializer(serializers.ModelSerializer[Note]):
|
||||
user = BasicUserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = ["id", "note", "created", "user"]
|
||||
ordering = ["-created"]
|
||||
@@ -0,0 +1,750 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
|
||||
from .base import DocumentListSerializer
|
||||
from .base import DocumentSelectionSerializer
|
||||
from .base import SerializerWithPerms
|
||||
from .base import SetPermissionsMixin
|
||||
from .base import SetPermissionsSerializer
|
||||
from .base import SourceModeValidationMixin
|
||||
from .metadata import CustomFieldInstanceSerializer
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
def _validate_rotation_degrees(degrees: int, field: str = "degrees") -> int:
|
||||
# QPDF refuses any other angle, which would otherwise fail inside the task
|
||||
if degrees % 90 != 0:
|
||||
raise serializers.ValidationError(f"{field} must be a multiple of 90")
|
||||
return degrees
|
||||
|
||||
|
||||
class RotateDocumentsSerializer(DocumentSelectionSerializer, SourceModeValidationMixin):
|
||||
degrees = serializers.IntegerField(required=True)
|
||||
source_mode = serializers.CharField(
|
||||
required=False,
|
||||
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
|
||||
)
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
def validate_degrees(self, value: int) -> int:
|
||||
return _validate_rotation_degrees(value)
|
||||
|
||||
|
||||
class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
||||
metadata_document_id = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
delete_originals = serializers.BooleanField(required=False, default=False)
|
||||
archive_fallback = serializers.BooleanField(required=False, default=False)
|
||||
source_mode = serializers.CharField(
|
||||
required=False,
|
||||
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
|
||||
)
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
||||
root_document_id = serializers.IntegerField(required=True)
|
||||
version_label = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
def validate_version_label(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
def validate(self, attrs):
|
||||
documents = attrs["documents"]
|
||||
if len(documents) < 2:
|
||||
raise serializers.ValidationError(
|
||||
"At least two documents are required.",
|
||||
)
|
||||
if attrs.get("version_label") is not None and len(documents) != 2:
|
||||
raise serializers.ValidationError(
|
||||
"version_label can only be used when merging one source document.",
|
||||
)
|
||||
if attrs["root_document_id"] not in documents:
|
||||
raise serializers.ValidationError(
|
||||
"root_document_id must be one of the selected documents.",
|
||||
)
|
||||
|
||||
selected_documents = Document.objects.filter(id__in=documents)
|
||||
if selected_documents.filter(root_document__isnull=False).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Only top-level documents can be merged as versions.",
|
||||
)
|
||||
|
||||
source_document_ids = set(documents) - {attrs["root_document_id"]}
|
||||
if Document.global_objects.filter(
|
||||
root_document_id__in=source_document_ids,
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Documents with existing versions cannot be merged into another document.",
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class PdfEditOperationSerializer(serializers.Serializer[dict[str, int]]):
|
||||
page = serializers.IntegerField(min_value=1)
|
||||
rotate = serializers.IntegerField(required=False)
|
||||
doc = serializers.IntegerField(required=False, min_value=0)
|
||||
|
||||
def validate_rotate(self, value: int) -> int:
|
||||
return _validate_rotation_degrees(value, field="rotate")
|
||||
|
||||
|
||||
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
||||
operations = serializers.ListField(
|
||||
child=PdfEditOperationSerializer(),
|
||||
required=True,
|
||||
allow_empty=False,
|
||||
)
|
||||
delete_original = serializers.BooleanField(required=False, default=False)
|
||||
update_document = serializers.BooleanField(required=False, default=False)
|
||||
include_metadata = serializers.BooleanField(required=False, default=True)
|
||||
source_mode = serializers.CharField(
|
||||
required=False,
|
||||
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
|
||||
)
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
documents = attrs["documents"]
|
||||
if len(documents) > 1:
|
||||
raise serializers.ValidationError(
|
||||
"Edit PDF method only supports one document",
|
||||
)
|
||||
|
||||
operations = attrs["operations"]
|
||||
|
||||
if any(op.get("doc", 0) >= len(operations) for op in operations):
|
||||
raise serializers.ValidationError("doc index is out of bounds")
|
||||
|
||||
if attrs["update_document"]:
|
||||
max_idx = max(op.get("doc", 0) for op in operations)
|
||||
if max_idx > 0:
|
||||
raise serializers.ValidationError(
|
||||
"update_document only allowed with a single output document",
|
||||
)
|
||||
|
||||
doc = Document.objects.get(id=documents[0])
|
||||
if doc.page_count:
|
||||
for op in operations:
|
||||
if op["page"] > doc.page_count:
|
||||
raise serializers.ValidationError(
|
||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class RemovePasswordDocumentsSerializer(
|
||||
DocumentListSerializer,
|
||||
SourceModeValidationMixin,
|
||||
):
|
||||
password = serializers.CharField(required=True)
|
||||
update_document = serializers.BooleanField(required=False, default=False)
|
||||
delete_original = serializers.BooleanField(required=False, default=False)
|
||||
include_metadata = serializers.BooleanField(required=False, default=True)
|
||||
source_mode = serializers.CharField(
|
||||
required=False,
|
||||
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
|
||||
)
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
||||
remote_ocr = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class BulkEditSerializer(
|
||||
SerializerWithPerms,
|
||||
DocumentSelectionSerializer,
|
||||
SetPermissionsMixin,
|
||||
SourceModeValidationMixin,
|
||||
):
|
||||
# TODO: remove this and related backwards compatibility code when API v9 is dropped
|
||||
# split, delete_pages can be removed entirely
|
||||
MOVED_DOCUMENT_ACTION_ENDPOINTS = {
|
||||
"delete": "/api/documents/delete/",
|
||||
"reprocess": "/api/documents/reprocess/",
|
||||
"rotate": "/api/documents/rotate/",
|
||||
"merge": "/api/documents/merge/",
|
||||
"edit_pdf": "/api/documents/edit_pdf/",
|
||||
"remove_password": "/api/documents/remove_password/",
|
||||
"split": "/api/documents/edit_pdf/",
|
||||
"delete_pages": "/api/documents/edit_pdf/",
|
||||
}
|
||||
LEGACY_DOCUMENT_ACTION_METHODS = tuple(MOVED_DOCUMENT_ACTION_ENDPOINTS.keys())
|
||||
|
||||
method = serializers.ChoiceField(
|
||||
choices=[
|
||||
"set_correspondent",
|
||||
"set_document_type",
|
||||
"set_storage_path",
|
||||
"add_tag",
|
||||
"remove_tag",
|
||||
"modify_tags",
|
||||
"modify_custom_fields",
|
||||
"set_permissions",
|
||||
*LEGACY_DOCUMENT_ACTION_METHODS,
|
||||
],
|
||||
label="Method",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
parameters = serializers.DictField(allow_empty=True, default={}, write_only=True)
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
def _validate_tag_id_list(self, tags, name="tags") -> None:
|
||||
if not isinstance(tags, list):
|
||||
raise serializers.ValidationError(f"{name} must be a list")
|
||||
if not all(isinstance(i, int) for i in tags):
|
||||
raise serializers.ValidationError(f"{name} must be a list of integers")
|
||||
count = Tag.objects.filter(id__in=tags).count()
|
||||
if not count == len(tags):
|
||||
raise serializers.ValidationError(
|
||||
f"Some tags in {name} don't exist or were specified twice.",
|
||||
)
|
||||
|
||||
def _validate_custom_field_id_list_or_dict(
|
||||
self,
|
||||
custom_fields,
|
||||
name="custom_fields",
|
||||
) -> None:
|
||||
ids = custom_fields
|
||||
if isinstance(custom_fields, dict):
|
||||
try:
|
||||
ids = [int(i[0]) for i in custom_fields.items()]
|
||||
except Exception as e:
|
||||
logger.exception(f"Error validating custom fields: {e}")
|
||||
raise serializers.ValidationError(
|
||||
f"{name} must be a list of integers or a dict of id:value pairs, see the log for details",
|
||||
)
|
||||
elif not isinstance(custom_fields, list) or not all(
|
||||
isinstance(i, int) for i in ids
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
f"{name} must be a list of integers or a dict of id:value pairs",
|
||||
)
|
||||
count = CustomField.objects.filter(id__in=ids).count()
|
||||
if not count == len(ids):
|
||||
raise serializers.ValidationError(
|
||||
f"Some custom fields in {name} don't exist or were specified twice.",
|
||||
)
|
||||
|
||||
def _validate_custom_field_values(self, custom_fields, name):
|
||||
if not isinstance(custom_fields, dict):
|
||||
return custom_fields
|
||||
|
||||
validated = {}
|
||||
errors = {}
|
||||
for raw_field_id, value in custom_fields.items():
|
||||
field_id = int(raw_field_id)
|
||||
validator = CustomFieldInstanceSerializer(
|
||||
data={"field": field_id, "value": value},
|
||||
context=self.context,
|
||||
)
|
||||
if validator.is_valid():
|
||||
validated[field_id] = validator.validated_data["value"]
|
||||
else:
|
||||
errors[str(field_id)] = validator.errors
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError({name: errors})
|
||||
|
||||
return validated
|
||||
|
||||
def validate_method(self, method):
|
||||
if method == "set_correspondent":
|
||||
return bulk_edit.set_correspondent
|
||||
elif method == "set_document_type":
|
||||
return bulk_edit.set_document_type
|
||||
elif method == "set_storage_path":
|
||||
return bulk_edit.set_storage_path
|
||||
elif method == "add_tag":
|
||||
return bulk_edit.add_tag
|
||||
elif method == "remove_tag":
|
||||
return bulk_edit.remove_tag
|
||||
elif method == "modify_tags":
|
||||
return bulk_edit.modify_tags
|
||||
elif method == "modify_custom_fields":
|
||||
return bulk_edit.modify_custom_fields
|
||||
elif method == "delete":
|
||||
return bulk_edit.delete
|
||||
elif method == "redo_ocr" or method == "reprocess":
|
||||
return bulk_edit.reprocess
|
||||
elif method == "set_permissions":
|
||||
return bulk_edit.set_permissions
|
||||
elif method == "rotate":
|
||||
return bulk_edit.rotate
|
||||
elif method == "merge":
|
||||
return bulk_edit.merge
|
||||
elif method == "split":
|
||||
return bulk_edit.split
|
||||
elif method == "delete_pages":
|
||||
return bulk_edit.delete_pages
|
||||
elif method == "edit_pdf":
|
||||
return bulk_edit.edit_pdf
|
||||
elif method == "remove_password":
|
||||
return bulk_edit.remove_password
|
||||
else:
|
||||
raise serializers.ValidationError("Unsupported method.")
|
||||
|
||||
def _validate_parameters_tags(self, parameters) -> None:
|
||||
if "tag" in parameters:
|
||||
tag_id = parameters["tag"]
|
||||
try:
|
||||
Tag.objects.get(id=tag_id)
|
||||
except Tag.DoesNotExist:
|
||||
raise serializers.ValidationError("Tag does not exist")
|
||||
else:
|
||||
raise serializers.ValidationError("tag not specified")
|
||||
|
||||
def _validate_parameters_document_type(self, parameters) -> None:
|
||||
if "document_type" in parameters:
|
||||
document_type_id = parameters["document_type"]
|
||||
if document_type_id is None:
|
||||
# None is ok
|
||||
return
|
||||
try:
|
||||
DocumentType.objects.get(id=document_type_id)
|
||||
except DocumentType.DoesNotExist:
|
||||
raise serializers.ValidationError("Document type does not exist")
|
||||
else:
|
||||
raise serializers.ValidationError("document_type not specified")
|
||||
|
||||
def _validate_parameters_correspondent(self, parameters) -> None:
|
||||
if "correspondent" in parameters:
|
||||
correspondent_id = parameters["correspondent"]
|
||||
if correspondent_id is None:
|
||||
return
|
||||
try:
|
||||
Correspondent.objects.get(id=correspondent_id)
|
||||
except Correspondent.DoesNotExist:
|
||||
raise serializers.ValidationError("Correspondent does not exist")
|
||||
else:
|
||||
raise serializers.ValidationError("correspondent not specified")
|
||||
|
||||
def _validate_storage_path(self, parameters) -> None:
|
||||
if "storage_path" in parameters:
|
||||
storage_path_id = parameters["storage_path"]
|
||||
if storage_path_id is None:
|
||||
return
|
||||
try:
|
||||
StoragePath.objects.get(id=storage_path_id)
|
||||
except StoragePath.DoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
"Storage path does not exist",
|
||||
)
|
||||
else:
|
||||
raise serializers.ValidationError("storage path not specified")
|
||||
|
||||
def _validate_parameters_modify_tags(self, parameters) -> None:
|
||||
if "add_tags" in parameters:
|
||||
self._validate_tag_id_list(parameters["add_tags"], "add_tags")
|
||||
else:
|
||||
raise serializers.ValidationError("add_tags not specified")
|
||||
|
||||
if "remove_tags" in parameters:
|
||||
self._validate_tag_id_list(parameters["remove_tags"], "remove_tags")
|
||||
else:
|
||||
raise serializers.ValidationError("remove_tags not specified")
|
||||
|
||||
def _validate_parameters_modify_custom_fields(self, parameters) -> None:
|
||||
if "add_custom_fields" in parameters:
|
||||
self._validate_custom_field_id_list_or_dict(
|
||||
parameters["add_custom_fields"],
|
||||
"add_custom_fields",
|
||||
)
|
||||
parameters["add_custom_fields"] = self._validate_custom_field_values(
|
||||
parameters["add_custom_fields"],
|
||||
"add_custom_fields",
|
||||
)
|
||||
else:
|
||||
raise serializers.ValidationError("add_custom_fields not specified")
|
||||
|
||||
if "remove_custom_fields" in parameters:
|
||||
self._validate_custom_field_id_list_or_dict(
|
||||
parameters["remove_custom_fields"],
|
||||
"remove_custom_fields",
|
||||
)
|
||||
else:
|
||||
raise serializers.ValidationError("remove_custom_fields not specified")
|
||||
|
||||
def _validate_owner(self, owner) -> User:
|
||||
owner_field = serializers.PrimaryKeyRelatedField(queryset=User.objects.all())
|
||||
try:
|
||||
return owner_field.run_validation(owner)
|
||||
except serializers.ValidationError as e:
|
||||
raise serializers.ValidationError(
|
||||
"Specified owner cannot be found",
|
||||
) from e
|
||||
|
||||
def _validate_parameters_set_permissions(self, parameters) -> None:
|
||||
if "set_permissions" not in parameters:
|
||||
raise serializers.ValidationError("set_permissions not specified")
|
||||
set_permissions = parameters["set_permissions"]
|
||||
if set_permissions is not None:
|
||||
set_permissions = SetPermissionsSerializer().run_validation(
|
||||
set_permissions,
|
||||
)
|
||||
parameters["set_permissions"] = self.validate_set_permissions(
|
||||
set_permissions,
|
||||
)
|
||||
if "owner" in parameters and parameters["owner"] is not None:
|
||||
parameters["owner"] = self._validate_owner(parameters["owner"]).pk
|
||||
if "merge" not in parameters:
|
||||
parameters["merge"] = False
|
||||
|
||||
def _validate_parameters_rotate(self, parameters) -> None:
|
||||
if "degrees" not in parameters:
|
||||
raise serializers.ValidationError("invalid rotation degrees")
|
||||
try:
|
||||
degrees = serializers.IntegerField().run_validation(parameters["degrees"])
|
||||
except serializers.ValidationError as e:
|
||||
raise serializers.ValidationError("invalid rotation degrees") from e
|
||||
parameters["degrees"] = _validate_rotation_degrees(degrees)
|
||||
|
||||
def _validate_source_mode(self, parameters) -> None:
|
||||
source_mode = parameters.get(
|
||||
"source_mode",
|
||||
bulk_edit.SourceModeChoices.LATEST_VERSION,
|
||||
)
|
||||
parameters["source_mode"] = self.validate_source_mode(source_mode)
|
||||
|
||||
def _validate_parameters_split(self, parameters, document_id) -> None:
|
||||
if "pages" not in parameters:
|
||||
raise serializers.ValidationError("pages not specified")
|
||||
if not isinstance(parameters["pages"], str):
|
||||
raise serializers.ValidationError("invalid pages specified")
|
||||
page_count = Document.objects.get(id=document_id).page_count
|
||||
if not page_count:
|
||||
raise serializers.ValidationError("document page count is unknown")
|
||||
pages = []
|
||||
for group in parameters["pages"].split(","):
|
||||
start, is_range, end = group.partition("-")
|
||||
try:
|
||||
first = int(start)
|
||||
last = int(end) if is_range else first
|
||||
except ValueError as e:
|
||||
raise serializers.ValidationError("invalid pages specified") from e
|
||||
# Bound the range before building it, a huge one would exhaust memory
|
||||
if not 1 <= first <= last <= page_count:
|
||||
raise serializers.ValidationError("invalid pages specified")
|
||||
pages.append(list(range(first, last + 1)))
|
||||
parameters["pages"] = pages
|
||||
|
||||
if "delete_originals" in parameters:
|
||||
if not isinstance(parameters["delete_originals"], bool):
|
||||
raise serializers.ValidationError("delete_originals must be a boolean")
|
||||
else:
|
||||
parameters["delete_originals"] = False
|
||||
|
||||
def _validate_parameters_delete_pages(self, parameters) -> None:
|
||||
if "pages" not in parameters:
|
||||
raise serializers.ValidationError("pages not specified")
|
||||
if not isinstance(parameters["pages"], list):
|
||||
raise serializers.ValidationError("pages must be a list")
|
||||
if not all(isinstance(i, int) for i in parameters["pages"]):
|
||||
raise serializers.ValidationError("pages must be a list of integers")
|
||||
|
||||
def _validate_parameters_merge(self, parameters) -> None:
|
||||
if "delete_originals" in parameters:
|
||||
if not isinstance(parameters["delete_originals"], bool):
|
||||
raise serializers.ValidationError("delete_originals must be a boolean")
|
||||
else:
|
||||
parameters["delete_originals"] = False
|
||||
if "archive_fallback" in parameters:
|
||||
if not isinstance(parameters["archive_fallback"], bool):
|
||||
raise serializers.ValidationError("archive_fallback must be a boolean")
|
||||
else:
|
||||
parameters["archive_fallback"] = False
|
||||
|
||||
def _validate_parameters_edit_pdf(self, parameters, document_id) -> None:
|
||||
if "operations" not in parameters:
|
||||
raise serializers.ValidationError("operations not specified")
|
||||
operations_field = serializers.ListField(
|
||||
child=PdfEditOperationSerializer(),
|
||||
allow_empty=False,
|
||||
)
|
||||
try:
|
||||
operations = operations_field.run_validation(parameters["operations"])
|
||||
except serializers.ValidationError as e:
|
||||
# Key the errors under "operations" so they match what the
|
||||
# dedicated edit_pdf endpoint returns
|
||||
raise serializers.ValidationError({"operations": e.detail}) from e
|
||||
parameters["operations"] = operations
|
||||
|
||||
if "update_document" in parameters:
|
||||
if not isinstance(parameters["update_document"], bool):
|
||||
raise serializers.ValidationError("update_document must be a boolean")
|
||||
else:
|
||||
parameters["update_document"] = False
|
||||
if "include_metadata" in parameters:
|
||||
if not isinstance(parameters["include_metadata"], bool):
|
||||
raise serializers.ValidationError("include_metadata must be a boolean")
|
||||
else:
|
||||
parameters["include_metadata"] = True
|
||||
|
||||
if any(op.get("doc", 0) >= len(operations) for op in operations):
|
||||
raise serializers.ValidationError("doc index is out of bounds")
|
||||
|
||||
if parameters["update_document"]:
|
||||
max_idx = max(op.get("doc", 0) for op in operations)
|
||||
if max_idx > 0:
|
||||
raise serializers.ValidationError(
|
||||
"update_document only allowed with a single output document",
|
||||
)
|
||||
|
||||
doc = Document.objects.get(id=document_id)
|
||||
# doc existence is already validated
|
||||
if doc.page_count:
|
||||
for op in operations:
|
||||
if op["page"] > doc.page_count:
|
||||
raise serializers.ValidationError(
|
||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||
)
|
||||
|
||||
def _validate_parameters_reprocess(self, parameters) -> None:
|
||||
if "remote_ocr" in parameters:
|
||||
if not isinstance(parameters["remote_ocr"], bool):
|
||||
raise serializers.ValidationError("remote_ocr must be a boolean")
|
||||
else:
|
||||
parameters["remote_ocr"] = False
|
||||
|
||||
def validate_parameters_remove_password(self, parameters):
|
||||
if "password" not in parameters:
|
||||
raise serializers.ValidationError("password not specified")
|
||||
if not isinstance(parameters["password"], str):
|
||||
raise serializers.ValidationError("password must be a string")
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
|
||||
if attrs.get("all", False) and attrs["method"] in [
|
||||
bulk_edit.merge,
|
||||
bulk_edit.split,
|
||||
bulk_edit.delete_pages,
|
||||
bulk_edit.edit_pdf,
|
||||
bulk_edit.remove_password,
|
||||
]:
|
||||
raise serializers.ValidationError(
|
||||
"This method does not support all=true.",
|
||||
)
|
||||
|
||||
method = attrs["method"]
|
||||
parameters = attrs["parameters"]
|
||||
|
||||
if "source_mode" in parameters:
|
||||
self._validate_source_mode(parameters)
|
||||
|
||||
if method == bulk_edit.set_correspondent:
|
||||
self._validate_parameters_correspondent(parameters)
|
||||
elif method == bulk_edit.set_document_type:
|
||||
self._validate_parameters_document_type(parameters)
|
||||
elif method == bulk_edit.add_tag or method == bulk_edit.remove_tag:
|
||||
self._validate_parameters_tags(parameters)
|
||||
elif method == bulk_edit.modify_tags:
|
||||
self._validate_parameters_modify_tags(parameters)
|
||||
elif method == bulk_edit.set_storage_path:
|
||||
self._validate_storage_path(parameters)
|
||||
elif method == bulk_edit.modify_custom_fields:
|
||||
self._validate_parameters_modify_custom_fields(parameters)
|
||||
elif method == bulk_edit.set_permissions:
|
||||
self._validate_parameters_set_permissions(parameters)
|
||||
elif method == bulk_edit.rotate:
|
||||
self._validate_parameters_rotate(parameters)
|
||||
elif method == bulk_edit.split:
|
||||
if len(attrs["documents"]) > 1:
|
||||
raise serializers.ValidationError(
|
||||
"Split method only supports one document",
|
||||
)
|
||||
self._validate_parameters_split(parameters, attrs["documents"][0])
|
||||
elif method == bulk_edit.delete_pages:
|
||||
if len(attrs["documents"]) > 1:
|
||||
raise serializers.ValidationError(
|
||||
"Delete pages method only supports one document",
|
||||
)
|
||||
self._validate_parameters_delete_pages(parameters)
|
||||
elif method == bulk_edit.merge:
|
||||
self._validate_parameters_merge(parameters)
|
||||
elif method == bulk_edit.edit_pdf:
|
||||
if len(attrs["documents"]) > 1:
|
||||
raise serializers.ValidationError(
|
||||
"Edit PDF method only supports one document",
|
||||
)
|
||||
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
||||
elif method == bulk_edit.remove_password:
|
||||
self.validate_parameters_remove_password(parameters)
|
||||
elif method == bulk_edit.reprocess:
|
||||
self._validate_parameters_reprocess(parameters)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class BulkDownloadSerializer(DocumentSelectionSerializer):
|
||||
content = serializers.ChoiceField(
|
||||
choices=["archive", "originals", "both"],
|
||||
default="archive",
|
||||
)
|
||||
|
||||
compression = serializers.ChoiceField(
|
||||
choices=["none", "deflated", "bzip2", "lzma"],
|
||||
default="none",
|
||||
)
|
||||
|
||||
follow_formatting = serializers.BooleanField(
|
||||
default=False,
|
||||
)
|
||||
|
||||
def validate_compression(self, compression):
|
||||
import zipfile
|
||||
|
||||
return {
|
||||
"none": zipfile.ZIP_STORED,
|
||||
"deflated": zipfile.ZIP_DEFLATED,
|
||||
"bzip2": zipfile.ZIP_BZIP2,
|
||||
"lzma": zipfile.ZIP_LZMA,
|
||||
}[compression]
|
||||
|
||||
|
||||
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||
objects = serializers.ListField(
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
label="Objects",
|
||||
write_only=True,
|
||||
child=serializers.IntegerField(),
|
||||
)
|
||||
|
||||
all = serializers.BooleanField(
|
||||
default=False,
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
filters = serializers.DictField(
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
object_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
"tags",
|
||||
"correspondents",
|
||||
"document_types",
|
||||
"storage_paths",
|
||||
],
|
||||
label="Object Type",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
operation = serializers.ChoiceField(
|
||||
choices=[
|
||||
"set_permissions",
|
||||
"delete",
|
||||
],
|
||||
label="Operation",
|
||||
required=True,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
owner = serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
permissions = SetPermissionsSerializer(
|
||||
label="Set permissions",
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
merge = serializers.BooleanField(
|
||||
default=False,
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def get_object_class(self, object_type):
|
||||
object_class = None
|
||||
if object_type == "tags":
|
||||
object_class = Tag
|
||||
elif object_type == "correspondents":
|
||||
object_class = Correspondent
|
||||
elif object_type == "document_types":
|
||||
object_class = DocumentType
|
||||
elif object_type == "storage_paths":
|
||||
object_class = StoragePath
|
||||
return object_class
|
||||
|
||||
def _validate_objects(self, objects, object_type):
|
||||
if not isinstance(objects, list):
|
||||
raise serializers.ValidationError("objects must be a list")
|
||||
if not all(isinstance(i, int) for i in objects):
|
||||
raise serializers.ValidationError("objects must be a list of integers")
|
||||
object_class = self.get_object_class(object_type)
|
||||
count = object_class.objects.filter(id__in=objects).count()
|
||||
if not count == len(objects):
|
||||
raise serializers.ValidationError(
|
||||
"Some ids in objects don't exist or were specified twice.",
|
||||
)
|
||||
return objects
|
||||
|
||||
def _validate_permissions(self, permissions) -> dict:
|
||||
return self.validate_set_permissions(
|
||||
permissions,
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
object_type = attrs["object_type"]
|
||||
objects = attrs.get("objects")
|
||||
apply_to_all = attrs.get("all", False)
|
||||
operation = attrs.get("operation")
|
||||
|
||||
if apply_to_all:
|
||||
attrs.setdefault("objects", [])
|
||||
else:
|
||||
if objects is None:
|
||||
raise serializers.ValidationError(
|
||||
"objects is required unless all is true.",
|
||||
)
|
||||
if len(objects) == 0:
|
||||
raise serializers.ValidationError("objects must not be empty")
|
||||
self._validate_objects(objects, object_type)
|
||||
|
||||
if operation == "set_permissions":
|
||||
permissions = attrs.get("permissions")
|
||||
if permissions is not None:
|
||||
if not permissions:
|
||||
raise serializers.ValidationError(
|
||||
"permissions must not be empty",
|
||||
)
|
||||
attrs["permissions"] = self._validate_permissions(permissions)
|
||||
|
||||
return attrs
|
||||
@@ -0,0 +1,474 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import TypedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from django.utils.timezone import is_naive
|
||||
from django.utils.timezone import make_aware
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
from drf_writable_nested.serializers import NestedUpdateMixin
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
from auditlog.context import set_actor
|
||||
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import Tag
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.versioning import has_prefetched_effective_content
|
||||
from documents.versioning import sort_versions_newest_first
|
||||
|
||||
from .base import DocumentUpdateFieldsModelSerializer
|
||||
from .base import NotesSerializer
|
||||
from .base import OwnedObjectListSerializer
|
||||
from .base import OwnedObjectSerializer
|
||||
from .metadata import CorrespondentField
|
||||
from .metadata import CustomFieldInstanceSerializer
|
||||
from .metadata import DocumentTypeField
|
||||
from .metadata import StoragePathField
|
||||
from .metadata import TagsField
|
||||
from .upload import PostDocumentSerializer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from django.db.models.query import QuerySet
|
||||
from rest_framework.relations import ManyRelatedField
|
||||
from rest_framework.relations import RelatedField
|
||||
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
def _get_viewable_duplicates(
|
||||
document: Document,
|
||||
user: User | None,
|
||||
) -> QuerySet[Document]:
|
||||
checksums = {document.checksum}
|
||||
if document.archive_checksum:
|
||||
checksums.add(document.archive_checksum)
|
||||
duplicates = Document.global_objects.filter(
|
||||
Q(checksum__in=checksums) | Q(archive_checksum__in=checksums),
|
||||
).exclude(pk=document.pk)
|
||||
duplicates = duplicates.filter(root_document__isnull=True)
|
||||
duplicates = duplicates.order_by("-created")
|
||||
allowed_ids = permitted_document_ids(user, include_deleted=True)
|
||||
return duplicates.filter(id__in=allowed_ids)
|
||||
|
||||
|
||||
class DuplicateDocumentSummarySerializer(serializers.Serializer[dict[str, Any]]):
|
||||
id = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
deleted_at = serializers.DateTimeField(allow_null=True)
|
||||
|
||||
|
||||
class _DocumentVersionInfo(TypedDict):
|
||||
id: int
|
||||
added: datetime
|
||||
version_label: str | None
|
||||
checksum: str | None
|
||||
is_root: bool
|
||||
|
||||
|
||||
class DocumentVersionInfoSerializer(serializers.Serializer[_DocumentVersionInfo]):
|
||||
id = serializers.IntegerField()
|
||||
added = serializers.DateTimeField()
|
||||
version_label = serializers.CharField(required=False, allow_null=True)
|
||||
checksum = serializers.CharField(required=False, allow_null=True)
|
||||
is_root = serializers.BooleanField()
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
deprecate_fields=["created_date"],
|
||||
)
|
||||
class DocumentSerializer(
|
||||
OwnedObjectSerializer,
|
||||
NestedUpdateMixin,
|
||||
DocumentUpdateFieldsModelSerializer,
|
||||
):
|
||||
correspondent = CorrespondentField(allow_null=True)
|
||||
tags = TagsField(many=True)
|
||||
document_type = DocumentTypeField(allow_null=True)
|
||||
storage_path = StoragePathField(allow_null=True)
|
||||
|
||||
original_file_name = SerializerMethodField()
|
||||
archived_file_name = SerializerMethodField()
|
||||
created_date = serializers.DateField(required=False)
|
||||
page_count = SerializerMethodField()
|
||||
duplicate_documents = SerializerMethodField()
|
||||
|
||||
notes = NotesSerializer(many=True, required=False, read_only=True)
|
||||
root_document: RelatedField[Document, Document, Any] | ManyRelatedField = (
|
||||
serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
)
|
||||
versions = SerializerMethodField()
|
||||
|
||||
custom_fields = CustomFieldInstanceSerializer(
|
||||
many=True,
|
||||
allow_null=False,
|
||||
required=False,
|
||||
)
|
||||
|
||||
owner = serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
remove_inbox_tags = serializers.BooleanField(
|
||||
default=False,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def get_page_count(self, obj) -> int | None:
|
||||
return obj.page_count
|
||||
|
||||
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
|
||||
def get_duplicate_documents(self, obj):
|
||||
view = self.context.get("view")
|
||||
if view and getattr(view, "action", None) != "retrieve":
|
||||
return []
|
||||
request = self.context.get("request")
|
||||
user = request.user if request else None
|
||||
duplicates = _get_viewable_duplicates(obj, user)
|
||||
return list(duplicates.values("id", "title", "deleted_at"))
|
||||
|
||||
@extend_schema_field(DocumentVersionInfoSerializer(many=True))
|
||||
def get_versions(self, obj):
|
||||
root_doc = obj if obj.root_document_id is None else obj.root_document
|
||||
if root_doc is None:
|
||||
return []
|
||||
|
||||
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
|
||||
prefetched_versions = (
|
||||
prefetched_cache.get("versions")
|
||||
if isinstance(prefetched_cache, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
versions: list[Document]
|
||||
if prefetched_versions is not None:
|
||||
versions = [*prefetched_versions, root_doc]
|
||||
else:
|
||||
versions_qs = Document.objects.filter(root_document=root_doc).only(
|
||||
"id",
|
||||
"added",
|
||||
"checksum",
|
||||
"version_label",
|
||||
"root_document_id",
|
||||
"version_index",
|
||||
)
|
||||
versions = [*versions_qs, root_doc]
|
||||
|
||||
versions = sort_versions_newest_first(versions)
|
||||
|
||||
def build_info(doc: Document) -> _DocumentVersionInfo:
|
||||
return {
|
||||
"id": doc.id,
|
||||
"added": doc.added,
|
||||
"version_label": doc.version_label,
|
||||
"checksum": doc.checksum,
|
||||
"is_root": doc.id == root_doc.id,
|
||||
}
|
||||
|
||||
return [build_info(doc) for doc in versions]
|
||||
|
||||
def get_original_file_name(self, obj) -> str | None:
|
||||
return obj.original_filename
|
||||
|
||||
def get_archived_file_name(self, obj) -> str | None:
|
||||
if obj.has_archive_version:
|
||||
return obj.get_public_filename(archive=True)
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_representation(self, instance):
|
||||
doc = super().to_representation(instance)
|
||||
if "content" in self.fields and has_prefetched_effective_content(instance):
|
||||
# Only resolve version-aware content when it's cheap: an SQL
|
||||
# annotation or a versions prefetch is already on the instance.
|
||||
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
|
||||
# which build their own querysets) gets the document's own,
|
||||
# unresolved content instead of paying for an extra per-instance
|
||||
# query -- same as before effective_content resolution existed.
|
||||
doc["content"] = instance.get_effective_content() or ""
|
||||
if self.truncate_content and "content" in self.fields:
|
||||
doc["content"] = doc.get("content")[0:550]
|
||||
return doc
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if (
|
||||
"created" in data
|
||||
and isinstance(data["created"], str)
|
||||
and ":" in data["created"]
|
||||
):
|
||||
# Handle old format of isoformat datetime string
|
||||
parsed = parse_datetime(data["created"])
|
||||
if parsed:
|
||||
if is_naive(parsed):
|
||||
parsed = make_aware(parsed, get_current_timezone())
|
||||
data["created"] = parsed.astimezone().date()
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate(self, attrs):
|
||||
if (
|
||||
"archive_serial_number" in attrs
|
||||
and attrs["archive_serial_number"] is not None
|
||||
and len(str(attrs["archive_serial_number"])) > 0
|
||||
and Document.deleted_objects.filter(
|
||||
archive_serial_number=attrs["archive_serial_number"],
|
||||
).exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"archive_serial_number": [
|
||||
"Document with this Archive Serial Number already exists in the trash.",
|
||||
],
|
||||
},
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
def update(self, instance: Document, validated_data):
|
||||
if "created_date" in validated_data:
|
||||
if "created" not in validated_data:
|
||||
validated_data["created"] = validated_data["created_date"]
|
||||
logger.warning(
|
||||
"created_date is deprecated, use created instead",
|
||||
)
|
||||
validated_data.pop("created_date")
|
||||
if instance.custom_fields.count() > 0 and "custom_fields" in validated_data:
|
||||
incoming_custom_fields = [
|
||||
field["field"] for field in validated_data["custom_fields"]
|
||||
]
|
||||
for custom_field_instance in instance.custom_fields.filter(
|
||||
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||
):
|
||||
if (
|
||||
custom_field_instance.field not in incoming_custom_fields
|
||||
and custom_field_instance.value is not None
|
||||
):
|
||||
# Doc link field is being removed entirely
|
||||
for doc_id in custom_field_instance.value:
|
||||
bulk_edit.remove_doclink(
|
||||
instance,
|
||||
custom_field_instance.field,
|
||||
doc_id,
|
||||
)
|
||||
if "tags" in validated_data:
|
||||
# Respect tag hierarchy on updates:
|
||||
# - Adding a child adds its ancestors
|
||||
# - Removing a parent removes all its descendants
|
||||
prev_tags = set(instance.tags.all())
|
||||
requested_tags = set(validated_data["tags"])
|
||||
|
||||
# Tags newly added in this update and the ancestors they require
|
||||
added_tags = requested_tags - prev_tags
|
||||
required_by_add_tags = set(added_tags)
|
||||
for t in added_tags:
|
||||
required_by_add_tags.update(t.get_ancestors())
|
||||
|
||||
# Tags being removed in this update and all descendants, except
|
||||
# those required by a tag that is being added in this same update
|
||||
removed_tags = prev_tags - requested_tags
|
||||
blocked_tags = set(removed_tags)
|
||||
for t in removed_tags:
|
||||
blocked_tags.update(t.get_descendants())
|
||||
blocked_tags.difference_update(required_by_add_tags)
|
||||
|
||||
# Add all parent tags
|
||||
final_tags = set(requested_tags)
|
||||
for t in requested_tags:
|
||||
final_tags.update(t.get_ancestors())
|
||||
|
||||
# Drop removed parents and their descendants
|
||||
final_tags.difference_update(blocked_tags)
|
||||
|
||||
validated_data["tags"] = list(final_tags)
|
||||
if validated_data.get("remove_inbox_tags"):
|
||||
current_tag_ids = {t.pk for t in instance.tags.all()}
|
||||
tags = (
|
||||
validated_data["tags"]
|
||||
if "tags" in validated_data
|
||||
else list(instance.tags.all())
|
||||
)
|
||||
|
||||
# Tags newly added in this update, plus their ancestors, are kept
|
||||
keep_ids: set[int] = set()
|
||||
for tag in tags:
|
||||
if tag.pk not in current_tag_ids:
|
||||
keep_ids.add(tag.pk)
|
||||
keep_ids.update(int(pk) for pk in tag.get_ancestors_pks())
|
||||
|
||||
# Remove inbox tags and their descendants, except those being kept
|
||||
remove_ids: set[int] = set()
|
||||
for inbox_tag in (
|
||||
Tag.objects.filter(is_inbox_tag=True)
|
||||
.exclude(pk__in=keep_ids)
|
||||
.only("pk", "tn_descendants_pks")
|
||||
):
|
||||
remove_ids.add(inbox_tag.pk)
|
||||
remove_ids.update(int(pk) for pk in inbox_tag.get_descendants_pks())
|
||||
|
||||
validated_data["tags"] = [t for t in tags if t.pk not in remove_ids]
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
with set_actor(self.user):
|
||||
super().update(instance, validated_data)
|
||||
else:
|
||||
super().update(instance, validated_data)
|
||||
|
||||
# hard delete custom field instances that were soft deleted
|
||||
CustomFieldInstance.deleted_objects.filter(document=instance).delete()
|
||||
return instance
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.truncate_content = kwargs.pop("truncate_content", False)
|
||||
|
||||
# return full permissions if we're doing a PATCH or PUT
|
||||
context = kwargs.get("context")
|
||||
if context is not None and (
|
||||
context.get("request").method == "PATCH"
|
||||
or context.get("request").method == "PUT"
|
||||
):
|
||||
kwargs["full_perms"] = True
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = (
|
||||
"id",
|
||||
"correspondent",
|
||||
"document_type",
|
||||
"storage_path",
|
||||
"title",
|
||||
"content",
|
||||
"tags",
|
||||
"created",
|
||||
"created_date",
|
||||
"modified",
|
||||
"added",
|
||||
"deleted_at",
|
||||
"archive_serial_number",
|
||||
"original_file_name",
|
||||
"archived_file_name",
|
||||
"duplicate_documents",
|
||||
"owner",
|
||||
"permissions",
|
||||
"user_can_change",
|
||||
"is_shared_by_requester",
|
||||
"set_permissions",
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"remove_inbox_tags",
|
||||
"page_count",
|
||||
"mime_type",
|
||||
"root_document",
|
||||
"versions",
|
||||
)
|
||||
read_only_fields = ("deleted_at",)
|
||||
list_serializer_class = OwnedObjectListSerializer
|
||||
|
||||
|
||||
class SearchResultListSerializer(serializers.ListSerializer[Document]):
|
||||
def to_representation(self, hits):
|
||||
document_ids = [hit["id"] for hit in hits]
|
||||
# Fetch all Document objects in the list in one SQL query.
|
||||
documents = self.child.fetch_documents(document_ids)
|
||||
self.child.context["documents"] = documents
|
||||
# Also check if they are shared with other users / groups.
|
||||
self.child.context["shared_object_pks"] = self.child.get_shared_object_pks(
|
||||
documents.values(),
|
||||
)
|
||||
|
||||
return super().to_representation(hits)
|
||||
|
||||
|
||||
class SearchResultSerializer(DocumentSerializer):
|
||||
@staticmethod
|
||||
def fetch_documents(ids):
|
||||
"""
|
||||
Return a dict that maps given document IDs to Document objects.
|
||||
"""
|
||||
return {
|
||||
document.id: document
|
||||
for document in Document.objects.select_related(
|
||||
"correspondent",
|
||||
"storage_path",
|
||||
"document_type",
|
||||
"owner",
|
||||
)
|
||||
.prefetch_related("tags", "custom_fields", "notes")
|
||||
.filter(id__in=ids)
|
||||
}
|
||||
|
||||
def to_representation(self, hit):
|
||||
# Again we first check if the parent has already fetched the documents.
|
||||
documents = self.context.get("documents")
|
||||
# Otherwise we fetch this document.
|
||||
if documents is None: # pragma: no cover
|
||||
# In practice we only serialize **lists** of SearchHit dicts.
|
||||
# Keeping this check for completeness but marking it no cover for now.
|
||||
documents = self.fetch_documents([hit["id"]])
|
||||
document = documents[hit["id"]]
|
||||
|
||||
highlights = hit.get("highlights", {})
|
||||
r = super().to_representation(document)
|
||||
r["__search_hit__"] = {
|
||||
"score": hit["score"],
|
||||
"highlights": highlights.get("content", ""),
|
||||
"note_highlights": highlights.get("notes") or None,
|
||||
"rank": hit["rank"],
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
class Meta(DocumentSerializer.Meta):
|
||||
list_serializer_class = SearchResultListSerializer
|
||||
|
||||
|
||||
class DocumentVersionSerializer(serializers.Serializer[dict[str, Any]]):
|
||||
document = serializers.FileField(
|
||||
label="Document",
|
||||
write_only=True,
|
||||
)
|
||||
version_label = serializers.CharField(
|
||||
label="Version label",
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
validate_document = PostDocumentSerializer().validate_document
|
||||
|
||||
|
||||
class DocumentVersionLabelSerializer(serializers.Serializer[dict[str, str | None]]):
|
||||
version_label = serializers.CharField(
|
||||
label="Version label",
|
||||
required=True,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
def validate_version_label(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
@@ -0,0 +1,557 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import DecimalValidator
|
||||
from django.core.validators import MaxLengthValidator
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import integer_validator
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.filters import OrderingFilter
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.validators import uri_validator
|
||||
|
||||
from .base import MatchingModelSerializer
|
||||
from .base import OwnedObjectSerializer
|
||||
from .base import ReadWriteSerializerMethodField
|
||||
from .base import SerializerWithPerms
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
class CorrespondentSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
last_correspondence = serializers.DateField(read_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Correspondent
|
||||
fields = (
|
||||
"id",
|
||||
"slug",
|
||||
"name",
|
||||
"match",
|
||||
"matching_algorithm",
|
||||
"is_insensitive",
|
||||
"document_count",
|
||||
"last_correspondence",
|
||||
"owner",
|
||||
"permissions",
|
||||
"user_can_change",
|
||||
"set_permissions",
|
||||
)
|
||||
|
||||
|
||||
class DocumentTypeSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
class Meta:
|
||||
model = DocumentType
|
||||
fields = (
|
||||
"id",
|
||||
"slug",
|
||||
"name",
|
||||
"match",
|
||||
"matching_algorithm",
|
||||
"is_insensitive",
|
||||
"document_count",
|
||||
"owner",
|
||||
"permissions",
|
||||
"user_can_change",
|
||||
"set_permissions",
|
||||
)
|
||||
|
||||
|
||||
class DeprecatedColors:
|
||||
COLOURS = (
|
||||
(1, "#a6cee3"),
|
||||
(2, "#1f78b4"),
|
||||
(3, "#b2df8a"),
|
||||
(4, "#33a02c"),
|
||||
(5, "#fb9a99"),
|
||||
(6, "#e31a1c"),
|
||||
(7, "#fdbf6f"),
|
||||
(8, "#ff7f00"),
|
||||
(9, "#cab2d6"),
|
||||
(10, "#6a3d9a"),
|
||||
(11, "#b15928"),
|
||||
(12, "#000000"),
|
||||
(13, "#cccccc"),
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.ChoiceField(
|
||||
choices=DeprecatedColors.COLOURS,
|
||||
),
|
||||
)
|
||||
class ColorField(serializers.Field):
|
||||
def to_internal_value(self, data):
|
||||
for id, color in DeprecatedColors.COLOURS:
|
||||
if id == data:
|
||||
return color
|
||||
raise serializers.ValidationError
|
||||
|
||||
def to_representation(self, value):
|
||||
for id, color in DeprecatedColors.COLOURS:
|
||||
if color == value:
|
||||
return id
|
||||
return 1
|
||||
|
||||
|
||||
class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
def get_text_color(self, obj) -> str:
|
||||
try:
|
||||
h = obj.color.lstrip("#")
|
||||
rgb = tuple(int(h[i : i + 2], 16) / 256 for i in (0, 2, 4))
|
||||
luminance = math.sqrt(
|
||||
0.299 * math.pow(rgb[0], 2)
|
||||
+ 0.587 * math.pow(rgb[1], 2)
|
||||
+ 0.114 * math.pow(rgb[2], 2),
|
||||
)
|
||||
return "#ffffff" if luminance < 0.53 else "#000000"
|
||||
except ValueError:
|
||||
return "#000000"
|
||||
|
||||
text_color = serializers.SerializerMethodField()
|
||||
|
||||
# map to treenode's tn_parent
|
||||
parent = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Tag.objects.all(),
|
||||
allow_null=True,
|
||||
required=False,
|
||||
source="tn_parent",
|
||||
)
|
||||
|
||||
@extend_schema_field(
|
||||
field=serializers.ListSerializer(
|
||||
child=serializers.PrimaryKeyRelatedField(
|
||||
queryset=Tag.objects.all(),
|
||||
),
|
||||
),
|
||||
)
|
||||
def get_children(self, obj):
|
||||
children_map = self.context.get("children_map")
|
||||
if children_map is not None:
|
||||
children = children_map.get(obj.pk, [])
|
||||
else:
|
||||
filter_q = self.context.get("document_count_filter")
|
||||
request = self.context.get("request")
|
||||
if filter_q is None:
|
||||
user = getattr(request, "user", None) if request else None
|
||||
filter_q = get_document_count_filter_for_user(user)
|
||||
self.context["document_count_filter"] = filter_q
|
||||
|
||||
children = (
|
||||
obj.get_children_queryset()
|
||||
.select_related("owner")
|
||||
.annotate(document_count=Count("documents", filter=filter_q))
|
||||
)
|
||||
user = getattr(request, "user", None) if request else self.user
|
||||
children = restrict_queryset_to_visible(children, user, "view_tag")
|
||||
|
||||
view = self.context.get("view")
|
||||
ordering = (
|
||||
OrderingFilter().get_ordering(request, children, view)
|
||||
if request and view
|
||||
else None
|
||||
)
|
||||
ordering = ordering or (Lower("name"),)
|
||||
children = children.order_by(*ordering)
|
||||
|
||||
if not children:
|
||||
return []
|
||||
|
||||
serializer = TagSerializer(
|
||||
children,
|
||||
many=True,
|
||||
user=self.user,
|
||||
full_perms=self.full_perms,
|
||||
all_fields=self.all_fields,
|
||||
context=self.context,
|
||||
)
|
||||
return serializer.data
|
||||
|
||||
# children as nested Tag objects
|
||||
children = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = (
|
||||
"id",
|
||||
"slug",
|
||||
"name",
|
||||
"color",
|
||||
"text_color",
|
||||
"match",
|
||||
"matching_algorithm",
|
||||
"is_insensitive",
|
||||
"is_inbox_tag",
|
||||
"document_count",
|
||||
"owner",
|
||||
"permissions",
|
||||
"user_can_change",
|
||||
"set_permissions",
|
||||
"parent",
|
||||
"children",
|
||||
)
|
||||
|
||||
def validate_color(self, color):
|
||||
regex = r"#[0-9a-fA-F]{6}"
|
||||
if not re.match(regex, color):
|
||||
raise serializers.ValidationError(_("Invalid color."))
|
||||
return color
|
||||
|
||||
def validate(self, attrs):
|
||||
# Validate when changing parent
|
||||
parent = attrs.get(
|
||||
"tn_parent",
|
||||
self.instance.get_parent() if self.instance else None,
|
||||
)
|
||||
|
||||
if self.instance:
|
||||
# Temporarily set parent on the instance if updating and use model clean()
|
||||
original_parent = self.instance.get_parent()
|
||||
try:
|
||||
# Temporarily set tn_parent in-memory to validate clean()
|
||||
self.instance.tn_parent = parent
|
||||
self.instance.clean()
|
||||
except ValidationError as e:
|
||||
logger.debug("Tag parent validation failed: %s", e)
|
||||
raise e
|
||||
finally:
|
||||
self.instance.tn_parent = original_parent
|
||||
else:
|
||||
# For new instances, create a transient Tag and validate
|
||||
temp = Tag(tn_parent=parent)
|
||||
try:
|
||||
temp.clean()
|
||||
except ValidationError as e:
|
||||
logger.debug("Tag parent validation failed: %s", e)
|
||||
raise e
|
||||
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
class CorrespondentField(serializers.PrimaryKeyRelatedField[Correspondent]):
|
||||
def get_queryset(self):
|
||||
return Correspondent.objects.all()
|
||||
|
||||
|
||||
class TagsField(serializers.PrimaryKeyRelatedField[Tag]):
|
||||
def get_queryset(self):
|
||||
return Tag.objects.all()
|
||||
|
||||
|
||||
class DocumentTypeField(serializers.PrimaryKeyRelatedField[DocumentType]):
|
||||
def get_queryset(self):
|
||||
return DocumentType.objects.all()
|
||||
|
||||
|
||||
class StoragePathField(serializers.PrimaryKeyRelatedField[StoragePath]):
|
||||
def get_queryset(self):
|
||||
return StoragePath.objects.all()
|
||||
|
||||
|
||||
class CustomFieldSerializer(serializers.ModelSerializer[CustomField]):
|
||||
data_type = serializers.ChoiceField(
|
||||
choices=CustomField.FieldDataType,
|
||||
read_only=False,
|
||||
)
|
||||
|
||||
document_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"data_type",
|
||||
"extra_data",
|
||||
"document_count",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
|
||||
name = attrs.get(
|
||||
"name",
|
||||
self.instance.name if hasattr(self.instance, "name") else None,
|
||||
)
|
||||
objects = (
|
||||
self.Meta.model.objects.exclude(
|
||||
pk=self.instance.pk,
|
||||
)
|
||||
if self.instance is not None
|
||||
else self.Meta.model.objects.all()
|
||||
)
|
||||
if ("name" in attrs) and objects.filter(
|
||||
name=name,
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Object violates name unique constraint"},
|
||||
)
|
||||
if (
|
||||
"data_type" in attrs
|
||||
and attrs["data_type"] == CustomField.FieldDataType.SELECT
|
||||
) or (
|
||||
self.instance
|
||||
and self.instance.data_type == CustomField.FieldDataType.SELECT
|
||||
):
|
||||
if (
|
||||
"extra_data" not in attrs
|
||||
or "select_options" not in attrs["extra_data"]
|
||||
or not isinstance(attrs["extra_data"]["select_options"], list)
|
||||
or len(attrs["extra_data"]["select_options"]) == 0
|
||||
or not all(
|
||||
len(option.get("label", "")) > 0
|
||||
for option in attrs["extra_data"]["select_options"]
|
||||
)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "extra_data.select_options must be a valid list"},
|
||||
)
|
||||
# labels are valid, generate ids if not present
|
||||
for option in attrs["extra_data"]["select_options"]:
|
||||
if option.get("id") is None:
|
||||
option["id"] = get_random_string(length=16)
|
||||
elif (
|
||||
"data_type" in attrs
|
||||
and attrs["data_type"] == CustomField.FieldDataType.MONETARY
|
||||
and "extra_data" in attrs
|
||||
and "default_currency" in attrs["extra_data"]
|
||||
and attrs["extra_data"]["default_currency"] is not None
|
||||
and (
|
||||
not isinstance(attrs["extra_data"]["default_currency"], str)
|
||||
or (
|
||||
len(attrs["extra_data"]["default_currency"]) > 0
|
||||
and len(attrs["extra_data"]["default_currency"]) != 3
|
||||
)
|
||||
)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "extra_data.default_currency must be a 3-character string"},
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
def validate_documentlink_targets(user, doc_ids):
|
||||
if Document.objects.filter(id__in=doc_ids).count() != len(doc_ids):
|
||||
raise serializers.ValidationError(
|
||||
"Some documents in value don't exist or were specified twice.",
|
||||
)
|
||||
|
||||
if user is None:
|
||||
return
|
||||
|
||||
if (
|
||||
Document.objects.filter(id__in=doc_ids)
|
||||
.exclude(id__in=permitted_document_ids(user, perm="change_document"))
|
||||
.exists()
|
||||
):
|
||||
raise PermissionDenied(
|
||||
_("Insufficient permissions."),
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
|
||||
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
||||
value = ReadWriteSerializerMethodField(allow_null=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
# An instance is attached to a document
|
||||
document: Document = validated_data["document"]
|
||||
# And to a CustomField
|
||||
custom_field: CustomField = validated_data["field"]
|
||||
# This key must exist, as it is validated
|
||||
data_store_name = CustomFieldInstance.get_value_field_name(
|
||||
custom_field.data_type,
|
||||
)
|
||||
|
||||
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||
# prior to update so we can look for any docs that are going to be removed
|
||||
bulk_edit.reflect_doclinks(document, custom_field, validated_data["value"])
|
||||
|
||||
# Actually update or create the instance, providing the value
|
||||
# to fill in the correct attribute based on the type
|
||||
instance, _ = CustomFieldInstance.objects.update_or_create(
|
||||
document=document,
|
||||
field=custom_field,
|
||||
defaults={data_store_name: validated_data["value"]},
|
||||
)
|
||||
return instance
|
||||
|
||||
def get_value(self, obj: CustomFieldInstance) -> str | int | float | dict | None:
|
||||
return obj.value
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
Probably because we're kind of doing it odd, validation from the model
|
||||
doesn't run against the field "value", so we have to re-create it here.
|
||||
|
||||
Don't like it, but it is better than returning an HTTP 500 when the database
|
||||
hates the value
|
||||
"""
|
||||
data = super().validate(data)
|
||||
field: CustomField = data["field"]
|
||||
if "value" in data and data["value"] is not None:
|
||||
if (
|
||||
field.data_type == CustomField.FieldDataType.URL
|
||||
and len(data["value"]) > 0
|
||||
):
|
||||
uri_validator(data["value"])
|
||||
elif field.data_type == CustomField.FieldDataType.INT:
|
||||
integer_validator(data["value"])
|
||||
try:
|
||||
value_int = int(data["value"])
|
||||
except (TypeError, ValueError):
|
||||
raise serializers.ValidationError("Enter a valid integer.")
|
||||
# Keep values within the PostgreSQL integer range
|
||||
MinValueValidator(-2147483648)(value_int)
|
||||
MaxValueValidator(2147483647)(value_int)
|
||||
elif (
|
||||
field.data_type == CustomField.FieldDataType.MONETARY
|
||||
and data["value"] != ""
|
||||
):
|
||||
try:
|
||||
# First try to validate as a number from legacy format
|
||||
DecimalValidator(max_digits=12, decimal_places=2)(
|
||||
Decimal(str(data["value"])),
|
||||
)
|
||||
except Exception:
|
||||
# If that fails, try to validate as a monetary string
|
||||
RegexValidator(
|
||||
regex=r"^[A-Z]{3}-?\d+(\.\d{1,2})$",
|
||||
message="Must be a two-decimal number with optional currency code e.g. GBP123.45",
|
||||
)(data["value"])
|
||||
elif field.data_type == CustomField.FieldDataType.STRING:
|
||||
MaxLengthValidator(limit_value=128)(data["value"])
|
||||
elif field.data_type == CustomField.FieldDataType.SELECT:
|
||||
select_options = field.extra_data["select_options"]
|
||||
try:
|
||||
next(
|
||||
option
|
||||
for option in select_options
|
||||
if option["id"] == data["value"]
|
||||
)
|
||||
except Exception:
|
||||
raise serializers.ValidationError(
|
||||
f"Value must be an id of an element in {select_options}",
|
||||
)
|
||||
elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||
if not (isinstance(data["value"], list) or data["value"] is None):
|
||||
raise serializers.ValidationError(
|
||||
"Value must be a list",
|
||||
)
|
||||
doc_ids = data["value"]
|
||||
request = self.context.get("request")
|
||||
validate_documentlink_targets(
|
||||
getattr(request, "user", None) if request is not None else None,
|
||||
doc_ids,
|
||||
)
|
||||
elif field.data_type == CustomField.FieldDataType.DATE:
|
||||
data["value"] = serializers.DateField().to_internal_value(data["value"])
|
||||
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldInstance
|
||||
fields = [
|
||||
"value",
|
||||
"field",
|
||||
]
|
||||
|
||||
|
||||
class StoragePathSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
class Meta:
|
||||
model = StoragePath
|
||||
fields = (
|
||||
"id",
|
||||
"slug",
|
||||
"name",
|
||||
"path",
|
||||
"match",
|
||||
"matching_algorithm",
|
||||
"is_insensitive",
|
||||
"document_count",
|
||||
"owner",
|
||||
"permissions",
|
||||
"user_can_change",
|
||||
"set_permissions",
|
||||
)
|
||||
|
||||
def validate_path(self, path: str):
|
||||
converted_path = convert_format_str_to_template_format(path)
|
||||
if converted_path != path:
|
||||
logger.warning(
|
||||
f"Storage path {path} is not using the new style format, consider updating",
|
||||
)
|
||||
result = validate_filepath_template_and_render(converted_path)
|
||||
|
||||
if result is None:
|
||||
raise serializers.ValidationError(_("Invalid variable detected."))
|
||||
|
||||
return converted_path
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
When a storage path is updated, see if documents
|
||||
using it require a rename/move
|
||||
"""
|
||||
doc_ids = [doc.id for doc in instance.documents.all()]
|
||||
if doc_ids:
|
||||
bulk_edit.bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": doc_ids},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class StoragePathTestSerializer(SerializerWithPerms):
|
||||
path = serializers.CharField(
|
||||
required=True,
|
||||
label="Path",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
document = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Document.objects.none(),
|
||||
required=True,
|
||||
label="Document",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None) if request else None
|
||||
if user is not None and user.is_authenticated:
|
||||
document_field = self.fields.get("document")
|
||||
if not isinstance(document_field, serializers.PrimaryKeyRelatedField):
|
||||
return
|
||||
document_field.queryset = Document.objects.filter(
|
||||
id__in=permitted_document_ids(user),
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents.models import CustomField
|
||||
from documents.models import SavedView
|
||||
from documents.models import SavedViewFilterRule
|
||||
from documents.models import UiSettings
|
||||
|
||||
from .base import OwnedObjectSerializer
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
class SavedViewFilterRuleSerializer(serializers.ModelSerializer[SavedViewFilterRule]):
|
||||
class Meta:
|
||||
model = SavedViewFilterRule
|
||||
fields = ["rule_type", "value"]
|
||||
|
||||
|
||||
class SavedViewSerializer(OwnedObjectSerializer):
|
||||
filter_rules = SavedViewFilterRuleSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = SavedView
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"icon",
|
||||
"sort_field",
|
||||
"sort_reverse",
|
||||
"filter_rules",
|
||||
"page_size",
|
||||
"display_mode",
|
||||
"display_fields",
|
||||
"owner",
|
||||
"permissions",
|
||||
"user_can_change",
|
||||
"set_permissions",
|
||||
]
|
||||
|
||||
def _get_api_version(self) -> int:
|
||||
request = self.context.get("request")
|
||||
return int(
|
||||
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||
)
|
||||
|
||||
def _update_legacy_visibility_preferences(
|
||||
self,
|
||||
saved_view_id: int,
|
||||
*,
|
||||
show_on_dashboard: bool | None,
|
||||
show_in_sidebar: bool | None,
|
||||
) -> UiSettings | None:
|
||||
if show_on_dashboard is None and show_in_sidebar is None:
|
||||
return None
|
||||
|
||||
request = self.context.get("request")
|
||||
user = request.user if request else self.user
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
ui_settings, _ = UiSettings.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={"settings": {}},
|
||||
)
|
||||
current_settings = (
|
||||
ui_settings.settings if isinstance(ui_settings.settings, dict) else {}
|
||||
)
|
||||
current_settings = dict(current_settings)
|
||||
|
||||
saved_views_settings = current_settings.get("saved_views")
|
||||
if isinstance(saved_views_settings, dict):
|
||||
saved_views_settings = dict(saved_views_settings)
|
||||
else:
|
||||
saved_views_settings = {}
|
||||
|
||||
dashboard_ids = {
|
||||
int(raw_id)
|
||||
for raw_id in saved_views_settings.get("dashboard_views_visible_ids", [])
|
||||
if str(raw_id).isdigit()
|
||||
}
|
||||
sidebar_ids = {
|
||||
int(raw_id)
|
||||
for raw_id in saved_views_settings.get("sidebar_views_visible_ids", [])
|
||||
if str(raw_id).isdigit()
|
||||
}
|
||||
|
||||
if show_on_dashboard is not None:
|
||||
if show_on_dashboard:
|
||||
dashboard_ids.add(saved_view_id)
|
||||
else:
|
||||
dashboard_ids.discard(saved_view_id)
|
||||
if show_in_sidebar is not None:
|
||||
if show_in_sidebar:
|
||||
sidebar_ids.add(saved_view_id)
|
||||
else:
|
||||
sidebar_ids.discard(saved_view_id)
|
||||
|
||||
saved_views_settings["dashboard_views_visible_ids"] = sorted(dashboard_ids)
|
||||
saved_views_settings["sidebar_views_visible_ids"] = sorted(sidebar_ids)
|
||||
current_settings["saved_views"] = saved_views_settings
|
||||
ui_settings.settings = current_settings
|
||||
ui_settings.save(update_fields=["settings"])
|
||||
return ui_settings
|
||||
|
||||
def to_representation(self, instance):
|
||||
# TODO: remove this and related backwards compatibility code when API v9 is dropped
|
||||
ret = super().to_representation(instance)
|
||||
request = self.context.get("request")
|
||||
api_version = self._get_api_version()
|
||||
|
||||
if api_version < 10:
|
||||
dashboard_ids = set()
|
||||
sidebar_ids = set()
|
||||
user = request.user if request else None
|
||||
if user is not None and hasattr(user, "ui_settings"):
|
||||
ui_settings = user.ui_settings.settings or None
|
||||
saved_views = None
|
||||
if isinstance(ui_settings, dict):
|
||||
saved_views = ui_settings.get("saved_views", {})
|
||||
if isinstance(saved_views, dict):
|
||||
dashboard_ids = set(
|
||||
saved_views.get("dashboard_views_visible_ids", []),
|
||||
)
|
||||
sidebar_ids = set(
|
||||
saved_views.get("sidebar_views_visible_ids", []),
|
||||
)
|
||||
ret["show_on_dashboard"] = instance.id in dashboard_ids
|
||||
ret["show_in_sidebar"] = instance.id in sidebar_ids
|
||||
|
||||
return ret
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# TODO: remove this and related backwards compatibility code when API v9 is dropped
|
||||
api_version = self._get_api_version()
|
||||
if api_version >= 10:
|
||||
return super().to_internal_value(data)
|
||||
|
||||
normalized_data = data.copy()
|
||||
legacy_visibility_fields = {}
|
||||
boolean_field = serializers.BooleanField()
|
||||
|
||||
for field_name in ("show_on_dashboard", "show_in_sidebar"):
|
||||
if field_name in normalized_data:
|
||||
try:
|
||||
legacy_visibility_fields[field_name] = (
|
||||
boolean_field.to_internal_value(
|
||||
normalized_data.get(field_name),
|
||||
)
|
||||
)
|
||||
except serializers.ValidationError as exc:
|
||||
raise serializers.ValidationError({field_name: exc.detail})
|
||||
del normalized_data[field_name]
|
||||
|
||||
ret = super().to_internal_value(normalized_data)
|
||||
ret.update(legacy_visibility_fields)
|
||||
return ret
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
if "display_fields" in attrs and attrs["display_fields"] is not None:
|
||||
for field in attrs["display_fields"]:
|
||||
if (
|
||||
SavedView.DisplayFields.CUSTOM_FIELD[:-2] in field
|
||||
): # i.e. check for 'custom_field_' prefix
|
||||
field_id = int(re.search(r"\d+", field)[0])
|
||||
if not CustomField.objects.filter(id=field_id).exists():
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid field: {field}",
|
||||
)
|
||||
elif field not in SavedView.DisplayFields.values:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid field: {field}",
|
||||
)
|
||||
return attrs
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
request = self.context.get("request")
|
||||
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
|
||||
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
|
||||
if "filter_rules" in validated_data:
|
||||
rules_data = validated_data.pop("filter_rules")
|
||||
else:
|
||||
rules_data = None
|
||||
if "user" in validated_data:
|
||||
# backwards compatibility
|
||||
validated_data["owner"] = validated_data.pop("user")
|
||||
if (
|
||||
"display_fields" in validated_data
|
||||
and isinstance(
|
||||
validated_data["display_fields"],
|
||||
list,
|
||||
)
|
||||
and len(validated_data["display_fields"]) == 0
|
||||
):
|
||||
validated_data["display_fields"] = None
|
||||
instance = super().update(instance, validated_data)
|
||||
if rules_data is not None:
|
||||
SavedViewFilterRule.objects.filter(saved_view=instance).delete()
|
||||
for rule_data in rules_data:
|
||||
SavedViewFilterRule.objects.create(saved_view=instance, **rule_data)
|
||||
ui_settings = self._update_legacy_visibility_preferences(
|
||||
instance.id,
|
||||
show_on_dashboard=show_on_dashboard,
|
||||
show_in_sidebar=show_in_sidebar,
|
||||
)
|
||||
if request is not None and ui_settings is not None:
|
||||
request.user.ui_settings = ui_settings
|
||||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
request = self.context.get("request")
|
||||
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
|
||||
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
|
||||
rules_data = validated_data.pop("filter_rules")
|
||||
if "user" in validated_data:
|
||||
# backwards compatibility
|
||||
validated_data["owner"] = validated_data.pop("user")
|
||||
saved_view = super().create(validated_data)
|
||||
for rule_data in rules_data:
|
||||
SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data)
|
||||
ui_settings = self._update_legacy_visibility_preferences(
|
||||
saved_view.id,
|
||||
show_on_dashboard=show_on_dashboard,
|
||||
show_in_sidebar=show_in_sidebar,
|
||||
)
|
||||
if request is not None and ui_settings is not None:
|
||||
request.user.ui_settings = ui_settings
|
||||
return saved_view
|
||||
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import EmailValidator
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
|
||||
from documents.models import Document
|
||||
from documents.models import ShareLink
|
||||
from documents.models import ShareLinkBundle
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
|
||||
from .base import DocumentListSerializer
|
||||
from .base import OwnedObjectSerializer
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
class EmailSerializer(DocumentListSerializer):
|
||||
addresses = serializers.CharField(
|
||||
required=True,
|
||||
label="Email addresses",
|
||||
help_text="Comma-separated email addresses",
|
||||
)
|
||||
|
||||
subject = serializers.CharField(
|
||||
required=True,
|
||||
label="Email subject",
|
||||
)
|
||||
|
||||
message = serializers.CharField(
|
||||
required=True,
|
||||
label="Email message",
|
||||
)
|
||||
|
||||
use_archive_version = serializers.BooleanField(
|
||||
default=True,
|
||||
label="Use archive version",
|
||||
help_text="Use archive version of documents if available",
|
||||
)
|
||||
|
||||
def validate_addresses(self, addresses):
|
||||
address_list = [addr.strip() for addr in addresses.split(",")]
|
||||
if not address_list:
|
||||
raise serializers.ValidationError("At least one email address is required")
|
||||
|
||||
email_validator = EmailValidator()
|
||||
try:
|
||||
for address in address_list:
|
||||
email_validator(address)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError(f"Invalid email address: {address}")
|
||||
|
||||
return ",".join(address_list)
|
||||
|
||||
def validate_documents(self, documents):
|
||||
super().validate_documents(documents)
|
||||
if not documents:
|
||||
raise serializers.ValidationError("At least one document is required")
|
||||
|
||||
return documents
|
||||
|
||||
|
||||
class ShareLinkSerializer(OwnedObjectSerializer):
|
||||
document_title = serializers.CharField(
|
||||
source="document.title",
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ShareLink
|
||||
fields = (
|
||||
"id",
|
||||
"created",
|
||||
"expiration",
|
||||
"slug",
|
||||
"document",
|
||||
"document_title",
|
||||
"file_version",
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["slug"] = get_random_string(50)
|
||||
return super().create(validated_data)
|
||||
|
||||
def validate_document(self, document):
|
||||
if (
|
||||
self.user is not None
|
||||
and self.user.has_perm("documents.view_document")
|
||||
and has_perms_owner_aware(
|
||||
self.user,
|
||||
"view_document",
|
||||
document,
|
||||
)
|
||||
):
|
||||
return document
|
||||
raise PermissionDenied(
|
||||
_("Insufficient permissions."),
|
||||
)
|
||||
|
||||
|
||||
class ShareLinkBundleSerializer(OwnedObjectSerializer):
|
||||
document_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
allow_empty=False,
|
||||
write_only=True,
|
||||
)
|
||||
expiration_days = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=1,
|
||||
write_only=True,
|
||||
)
|
||||
documents = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
read_only=True,
|
||||
)
|
||||
document_count = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ShareLinkBundle
|
||||
fields = (
|
||||
"id",
|
||||
"created",
|
||||
"expiration",
|
||||
"expiration_days",
|
||||
"slug",
|
||||
"file_version",
|
||||
"status",
|
||||
"size_bytes",
|
||||
"last_error",
|
||||
"built_at",
|
||||
"documents",
|
||||
"document_ids",
|
||||
"document_count",
|
||||
)
|
||||
read_only_fields = (
|
||||
"id",
|
||||
"created",
|
||||
"expiration",
|
||||
"slug",
|
||||
"status",
|
||||
"size_bytes",
|
||||
"last_error",
|
||||
"built_at",
|
||||
"documents",
|
||||
"document_count",
|
||||
)
|
||||
|
||||
def validate_document_ids(self, value):
|
||||
unique_ids = set(value)
|
||||
if len(unique_ids) != len(value):
|
||||
raise serializers.ValidationError(
|
||||
_("Duplicate document identifiers are not allowed."),
|
||||
)
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
document_ids = validated_data.pop("document_ids")
|
||||
expiration_days = validated_data.pop("expiration_days", None)
|
||||
validated_data["slug"] = get_random_string(50)
|
||||
if expiration_days:
|
||||
validated_data["expiration"] = timezone.now() + timedelta(
|
||||
days=expiration_days,
|
||||
)
|
||||
else:
|
||||
validated_data["expiration"] = None
|
||||
|
||||
share_link_bundle = super().create(validated_data)
|
||||
|
||||
documents = list(
|
||||
Document.objects.filter(pk__in=document_ids).only(
|
||||
"pk",
|
||||
),
|
||||
)
|
||||
documents_by_id = {doc.pk: doc for doc in documents}
|
||||
missing = [
|
||||
str(doc_id) for doc_id in document_ids if doc_id not in documents_by_id
|
||||
]
|
||||
if missing:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"document_ids": _(
|
||||
"Documents not found: %(ids)s",
|
||||
)
|
||||
% {"ids": ", ".join(missing)},
|
||||
},
|
||||
)
|
||||
|
||||
ordered_documents = [documents_by_id[doc_id] for doc_id in document_ids]
|
||||
share_link_bundle.documents.set(ordered_documents)
|
||||
share_link_bundle.document_total = len(ordered_documents)
|
||||
|
||||
return share_link_bundle
|
||||
|
||||
def get_document_count(self, obj: ShareLinkBundle) -> int:
|
||||
return getattr(obj, "document_total") or obj.documents.count()
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents.models import Document
|
||||
from documents.models import UiSettings
|
||||
|
||||
from .base import SerializerWithPerms
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
class UiSettingsViewSerializer(serializers.ModelSerializer[UiSettings]):
|
||||
settings = serializers.DictField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = UiSettings
|
||||
depth = 1
|
||||
fields = [
|
||||
"id",
|
||||
"settings",
|
||||
]
|
||||
|
||||
def validate_settings(self, settings):
|
||||
# we never save update checking backend setting
|
||||
if "update_checking" in settings:
|
||||
try:
|
||||
settings["update_checking"].pop("backend_setting")
|
||||
except KeyError:
|
||||
pass
|
||||
return settings
|
||||
|
||||
def create(self, validated_data):
|
||||
ui_settings = UiSettings.objects.update_or_create(
|
||||
user=validated_data.get("user"),
|
||||
defaults={"settings": validated_data.get("settings", None)},
|
||||
)
|
||||
return ui_settings
|
||||
|
||||
|
||||
class TrashSerializer(SerializerWithPerms):
|
||||
documents = serializers.ListField(
|
||||
required=False,
|
||||
label="Documents",
|
||||
write_only=True,
|
||||
child=serializers.IntegerField(),
|
||||
)
|
||||
|
||||
action = serializers.ChoiceField(
|
||||
choices=["restore", "empty"],
|
||||
label="Action",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
def validate_documents(self, documents: list[int]) -> list[int]:
|
||||
count = Document.deleted_objects.filter(id__in=documents).count()
|
||||
if not count == len(documents):
|
||||
raise serializers.ValidationError(
|
||||
"Some documents in the list have not yet been deleted.",
|
||||
)
|
||||
return documents
|
||||
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents.models import Document
|
||||
from documents.models import PaperlessTask
|
||||
from documents.permissions import permitted_document_ids
|
||||
|
||||
from .base import OwnedObjectSerializer
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
class TaskSerializerV10(OwnedObjectSerializer):
|
||||
"""Task serializer for API v10+ using new field names."""
|
||||
|
||||
related_document_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
read_only=True,
|
||||
)
|
||||
task_type_display = serializers.CharField(
|
||||
source="get_task_type_display",
|
||||
read_only=True,
|
||||
)
|
||||
trigger_source_display = serializers.CharField(
|
||||
source="get_trigger_source_display",
|
||||
read_only=True,
|
||||
)
|
||||
status_display = serializers.CharField(
|
||||
source="get_status_display",
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PaperlessTask
|
||||
fields = (
|
||||
"id",
|
||||
"task_id",
|
||||
"task_type",
|
||||
"task_type_display",
|
||||
"trigger_source",
|
||||
"trigger_source_display",
|
||||
"status",
|
||||
"status_display",
|
||||
"date_created",
|
||||
"date_started",
|
||||
"date_done",
|
||||
"duration_seconds",
|
||||
"wait_time_seconds",
|
||||
"input_data",
|
||||
"result_data",
|
||||
"related_document_ids",
|
||||
"acknowledged",
|
||||
"owner",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class TaskSerializerV9(serializers.ModelSerializer[PaperlessTask]):
|
||||
"""Task serializer for API v9 backwards compatibility.
|
||||
|
||||
Maps old field names to the new model fields so existing clients continue
|
||||
to work unchanged.
|
||||
"""
|
||||
|
||||
# v9 field: task_name -> task_type (with value remapping for renamed tasks)
|
||||
task_name = serializers.SerializerMethodField()
|
||||
|
||||
# v9 field: task_file_name -> input_data.filename
|
||||
task_file_name = serializers.SerializerMethodField()
|
||||
|
||||
# v9 field: type -> trigger_source (mapped to old enum labels)
|
||||
type = serializers.SerializerMethodField()
|
||||
|
||||
# v9 field: status -> uppercase Celery state strings
|
||||
status = serializers.SerializerMethodField()
|
||||
|
||||
# v9 field: result -> derived from result_data
|
||||
result = serializers.SerializerMethodField()
|
||||
|
||||
# v9 field: related_document -> first document ID from result_data
|
||||
related_document = serializers.SerializerMethodField()
|
||||
|
||||
# v9 field: duplicate_documents -> list of duplicate IDs from result_data
|
||||
duplicate_documents = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PaperlessTask
|
||||
fields = (
|
||||
"id",
|
||||
"task_id",
|
||||
"task_name",
|
||||
"task_file_name",
|
||||
"type",
|
||||
"status",
|
||||
"date_created",
|
||||
"date_done",
|
||||
"result",
|
||||
"acknowledged",
|
||||
"related_document",
|
||||
"duplicate_documents",
|
||||
"owner",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
_TASK_TYPE_TO_V9_NAME = {
|
||||
PaperlessTask.TaskType.SANITY_CHECK: "check_sanity",
|
||||
PaperlessTask.TaskType.LLM_INDEX: "llmindex_update",
|
||||
}
|
||||
|
||||
def get_result(self, obj: PaperlessTask) -> str | None:
|
||||
"""Reconstruct a human-readable result string from result_data for v9 clients."""
|
||||
if not obj.result_data:
|
||||
return None
|
||||
if doc_id := obj.result_data.get("document_id"):
|
||||
return f"Success. New document id {doc_id} created"
|
||||
if reason := obj.result_data.get("reason"):
|
||||
return reason
|
||||
if dup_id := obj.result_data.get("duplicate_of"):
|
||||
return f"Not consuming: It is a duplicate of document #{dup_id}"
|
||||
if error := obj.result_data.get("error_message"):
|
||||
return error
|
||||
return None
|
||||
|
||||
def get_task_name(self, obj: PaperlessTask) -> str:
|
||||
return self._TASK_TYPE_TO_V9_NAME.get(obj.task_type, obj.task_type)
|
||||
|
||||
def get_task_file_name(self, obj: PaperlessTask) -> str | None:
|
||||
if not obj.input_data:
|
||||
return None
|
||||
return obj.input_data.get("filename")
|
||||
|
||||
_STATUS_TO_V9 = {
|
||||
PaperlessTask.Status.PENDING: "PENDING",
|
||||
PaperlessTask.Status.STARTED: "STARTED",
|
||||
PaperlessTask.Status.SUCCESS: "SUCCESS",
|
||||
PaperlessTask.Status.FAILURE: "FAILURE",
|
||||
PaperlessTask.Status.REVOKED: "REVOKED",
|
||||
}
|
||||
|
||||
def get_status(self, obj: PaperlessTask) -> str:
|
||||
return self._STATUS_TO_V9.get(obj.status, obj.status.upper())
|
||||
|
||||
_TRIGGER_SOURCE_TO_V9_TYPE = {
|
||||
PaperlessTask.TriggerSource.SCHEDULED: "scheduled_task",
|
||||
PaperlessTask.TriggerSource.SYSTEM: "auto_task",
|
||||
# Email and folder-consumer documents are system-initiated, not manually triggered
|
||||
PaperlessTask.TriggerSource.EMAIL_CONSUME: "auto_task",
|
||||
PaperlessTask.TriggerSource.FOLDER_CONSUME: "auto_task",
|
||||
}
|
||||
|
||||
def get_type(self, obj: PaperlessTask) -> str:
|
||||
return self._TRIGGER_SOURCE_TO_V9_TYPE.get(obj.trigger_source, "manual_task")
|
||||
|
||||
def get_related_document(self, obj: PaperlessTask) -> int | None:
|
||||
ids = obj.related_document_ids
|
||||
return ids[0] if ids else None
|
||||
|
||||
def get_duplicate_documents(
|
||||
self,
|
||||
obj: PaperlessTask,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not obj.result_data:
|
||||
return []
|
||||
dup_of = obj.result_data.get("duplicate_of")
|
||||
if dup_of is None:
|
||||
return []
|
||||
request = self.context.get("request")
|
||||
if request is None:
|
||||
return []
|
||||
user = request.user
|
||||
qs = Document.global_objects.filter(pk=dup_of)
|
||||
if not user.is_staff:
|
||||
allowed_ids = permitted_document_ids(user, include_deleted=True)
|
||||
qs = qs.filter(pk__in=allowed_ids)
|
||||
return list(qs.values("id", "title", "deleted_at"))
|
||||
|
||||
|
||||
class TaskSummarySerializer(serializers.Serializer[dict[str, Any]]):
|
||||
task_type = serializers.CharField()
|
||||
total_count = serializers.IntegerField()
|
||||
pending_count = serializers.IntegerField()
|
||||
success_count = serializers.IntegerField()
|
||||
failure_count = serializers.IntegerField()
|
||||
avg_duration_seconds = serializers.FloatField(allow_null=True)
|
||||
avg_wait_time_seconds = serializers.FloatField(allow_null=True)
|
||||
last_run = serializers.DateTimeField(allow_null=True)
|
||||
last_success = serializers.DateTimeField(allow_null=True)
|
||||
last_failure = serializers.DateTimeField(allow_null=True)
|
||||
|
||||
|
||||
class RunTaskSerializer(serializers.Serializer[dict[str, str]]):
|
||||
task_type = serializers.ChoiceField(
|
||||
choices=PaperlessTask.TaskType.choices,
|
||||
label="Task Type",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
|
||||
class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]):
|
||||
tasks = serializers.ListField(
|
||||
required=False,
|
||||
label="Tasks",
|
||||
write_only=True,
|
||||
child=serializers.IntegerField(),
|
||||
)
|
||||
all = serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
label="All",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
def _validate_task_id_list(self, tasks, name="tasks") -> None:
|
||||
if not isinstance(tasks, list):
|
||||
raise serializers.ValidationError(f"{name} must be a list")
|
||||
if not all(isinstance(i, int) for i in tasks):
|
||||
raise serializers.ValidationError(f"{name} must be a list of integers")
|
||||
queryset = self.context.get("queryset", PaperlessTask.objects.all())
|
||||
count = queryset.filter(id__in=tasks).count()
|
||||
if not count == len(tasks):
|
||||
raise serializers.ValidationError(
|
||||
f"Some tasks in {name} don't exist or were specified twice.",
|
||||
)
|
||||
|
||||
def validate_tasks(self, tasks):
|
||||
self._validate_task_id_list(tasks)
|
||||
return tasks
|
||||
|
||||
def validate(self, attrs):
|
||||
acknowledge_all = attrs.get("all", False)
|
||||
task_ids = attrs.get("tasks")
|
||||
|
||||
if acknowledge_all and task_ids is not None:
|
||||
raise serializers.ValidationError(
|
||||
"Set either all or tasks, not both.",
|
||||
)
|
||||
if not acknowledge_all and task_ids is None:
|
||||
raise serializers.ValidationError(
|
||||
"Either all must be true or tasks must be provided.",
|
||||
)
|
||||
|
||||
return attrs
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import magic
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.parsers import is_mime_type_supported
|
||||
|
||||
from .metadata import CustomFieldInstanceSerializer
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
class PostDocumentSerializer(serializers.Serializer[dict[str, Any]]):
|
||||
created = serializers.DateTimeField(
|
||||
label="Created",
|
||||
allow_null=True,
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
document = serializers.FileField(
|
||||
label="Document",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
title = serializers.CharField(
|
||||
label="Title",
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
correspondent = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Correspondent.objects.all(),
|
||||
label="Correspondent",
|
||||
allow_null=True,
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
document_type = serializers.PrimaryKeyRelatedField(
|
||||
queryset=DocumentType.objects.all(),
|
||||
label="Document type",
|
||||
allow_null=True,
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
storage_path = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StoragePath.objects.all(),
|
||||
label="Storage path",
|
||||
allow_null=True,
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
tags = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
queryset=Tag.objects.all(),
|
||||
label="Tags",
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
archive_serial_number = serializers.IntegerField(
|
||||
label="ASN",
|
||||
write_only=True,
|
||||
required=False,
|
||||
min_value=Document.ARCHIVE_SERIAL_NUMBER_MIN,
|
||||
max_value=Document.ARCHIVE_SERIAL_NUMBER_MAX,
|
||||
)
|
||||
|
||||
# Accept either a list of custom field ids or a dict mapping id -> value
|
||||
custom_fields = serializers.JSONField(
|
||||
label="Custom fields",
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
from_webui = serializers.BooleanField(
|
||||
label="Documents are from Paperless-ngx WebUI",
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def validate_document(self, document):
|
||||
document_data = document.file.read()
|
||||
mime_type = magic.from_buffer(document_data, mime=True)
|
||||
|
||||
if not is_mime_type_supported(mime_type):
|
||||
if (
|
||||
mime_type in settings.CONSUMER_PDF_RECOVERABLE_MIME_TYPES
|
||||
and document.name.endswith(
|
||||
".pdf",
|
||||
)
|
||||
):
|
||||
# If the file is an invalid PDF, we can try to recover it later in the consumer
|
||||
mime_type = "application/pdf"
|
||||
else:
|
||||
raise serializers.ValidationError(
|
||||
_("File type %(type)s not supported") % {"type": mime_type},
|
||||
)
|
||||
|
||||
return document.name, document_data
|
||||
|
||||
def validate_correspondent(self, correspondent):
|
||||
if correspondent:
|
||||
return correspondent.id
|
||||
else:
|
||||
return None
|
||||
|
||||
def validate_document_type(self, document_type):
|
||||
if document_type:
|
||||
return document_type.id
|
||||
else:
|
||||
return None
|
||||
|
||||
def validate_storage_path(self, storage_path):
|
||||
if storage_path:
|
||||
return storage_path.id
|
||||
else:
|
||||
return None
|
||||
|
||||
def validate_tags(self, tags):
|
||||
if tags:
|
||||
return [tag.id for tag in tags]
|
||||
else:
|
||||
return None
|
||||
|
||||
def validate_custom_fields(self, custom_fields):
|
||||
if not custom_fields:
|
||||
return None
|
||||
|
||||
# Normalize single values to a list
|
||||
if isinstance(custom_fields, int):
|
||||
custom_fields = [custom_fields]
|
||||
if isinstance(custom_fields, dict):
|
||||
custom_field_serializer = CustomFieldInstanceSerializer()
|
||||
normalized = {}
|
||||
for field_id, value in custom_fields.items():
|
||||
try:
|
||||
field_id_int = int(field_id)
|
||||
except (TypeError, ValueError):
|
||||
raise serializers.ValidationError(
|
||||
_("Custom field id must be an integer: %(id)s")
|
||||
% {"id": field_id},
|
||||
)
|
||||
try:
|
||||
field = CustomField.objects.get(id=field_id_int)
|
||||
except CustomField.DoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
_("Custom field with id %(id)s does not exist")
|
||||
% {"id": field_id_int},
|
||||
)
|
||||
custom_field_serializer.validate(
|
||||
{
|
||||
"field": field,
|
||||
"value": value,
|
||||
},
|
||||
)
|
||||
normalized[field_id_int] = value
|
||||
return normalized
|
||||
elif isinstance(custom_fields, list):
|
||||
try:
|
||||
ids = [int(i) for i in custom_fields]
|
||||
except (TypeError, ValueError):
|
||||
raise serializers.ValidationError(
|
||||
_(
|
||||
"Custom fields must be a list of integers or an object mapping ids to values.",
|
||||
),
|
||||
)
|
||||
if CustomField.objects.filter(id__in=ids).count() != len(set(ids)):
|
||||
raise serializers.ValidationError(
|
||||
_("Some custom fields don't exist or were specified twice."),
|
||||
)
|
||||
return ids
|
||||
raise serializers.ValidationError(
|
||||
_(
|
||||
"Custom fields must be a list of integers or an object mapping ids to values.",
|
||||
),
|
||||
)
|
||||
|
||||
# custom_fields_w_values handled via validate_custom_fields
|
||||
|
||||
def validate_created(self, created):
|
||||
# support datetime format for created for backwards compatibility
|
||||
if isinstance(created, datetime):
|
||||
return created.date()
|
||||
@@ -0,0 +1,584 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.db.models import Count
|
||||
from rest_framework import fields
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.filters import CustomFieldQueryParser
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowActionEmail
|
||||
from documents.models import WorkflowActionWebhook
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.templating.workflows import validate_workflow_template
|
||||
from documents.validators import url_validator
|
||||
|
||||
from .metadata import CorrespondentField
|
||||
from .metadata import DocumentTypeField
|
||||
from .metadata import StoragePathField
|
||||
from .metadata import TagsField
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
class WorkflowTriggerSerializer(serializers.ModelSerializer[WorkflowTrigger]):
|
||||
id = serializers.IntegerField(required=False, allow_null=True)
|
||||
sources = fields.MultipleChoiceField(
|
||||
choices=WorkflowTrigger.DocumentSourceChoices.choices,
|
||||
allow_empty=True,
|
||||
default={
|
||||
DocumentSource.ConsumeFolder,
|
||||
DocumentSource.ApiUpload,
|
||||
DocumentSource.MailFetch,
|
||||
},
|
||||
)
|
||||
|
||||
type = serializers.ChoiceField(
|
||||
choices=WorkflowTrigger.WorkflowTriggerType.choices,
|
||||
label="Trigger Type",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = WorkflowTrigger
|
||||
fields = [
|
||||
"id",
|
||||
"sources",
|
||||
"type",
|
||||
"filter_path",
|
||||
"filter_filename",
|
||||
"filter_mailrule",
|
||||
"matching_algorithm",
|
||||
"match",
|
||||
"is_insensitive",
|
||||
"filter_has_tags",
|
||||
"filter_has_all_tags",
|
||||
"filter_has_not_tags",
|
||||
"filter_custom_field_query",
|
||||
"filter_has_any_correspondents",
|
||||
"filter_has_not_correspondents",
|
||||
"filter_has_any_document_types",
|
||||
"filter_has_not_document_types",
|
||||
"filter_has_any_storage_paths",
|
||||
"filter_has_not_storage_paths",
|
||||
"filter_has_correspondent",
|
||||
"filter_has_document_type",
|
||||
"filter_has_storage_path",
|
||||
"schedule_offset_days",
|
||||
"schedule_is_recurring",
|
||||
"schedule_recurring_interval_days",
|
||||
"schedule_date_field",
|
||||
"schedule_date_custom_field",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
# Empty strings treated as None to avoid unexpected behavior
|
||||
if (
|
||||
"filter_filename" in attrs
|
||||
and attrs["filter_filename"] is not None
|
||||
and len(attrs["filter_filename"]) == 0
|
||||
):
|
||||
attrs["filter_filename"] = None
|
||||
if (
|
||||
"filter_path" in attrs
|
||||
and attrs["filter_path"] is not None
|
||||
and len(attrs["filter_path"]) == 0
|
||||
):
|
||||
attrs["filter_path"] = None
|
||||
|
||||
if (
|
||||
"filter_custom_field_query" in attrs
|
||||
and attrs["filter_custom_field_query"] is not None
|
||||
and len(attrs["filter_custom_field_query"]) == 0
|
||||
):
|
||||
attrs["filter_custom_field_query"] = None
|
||||
|
||||
if (
|
||||
"filter_custom_field_query" in attrs
|
||||
and attrs["filter_custom_field_query"] is not None
|
||||
):
|
||||
parser = CustomFieldQueryParser("filter_custom_field_query")
|
||||
parser.parse(attrs["filter_custom_field_query"])
|
||||
|
||||
trigger_type = attrs.get("type", getattr(self.instance, "type", None))
|
||||
if (
|
||||
trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||
and "filter_mailrule" not in attrs
|
||||
and ("filter_filename" not in attrs or attrs["filter_filename"] is None)
|
||||
and ("filter_path" not in attrs or attrs["filter_path"] is None)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"File name, path or mail rule filter are required",
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
@staticmethod
|
||||
def normalize_workflow_trigger_sources(trigger) -> None:
|
||||
"""
|
||||
Convert sources to strings to handle django-multiselectfield v1.0 changes
|
||||
"""
|
||||
if trigger and "sources" in trigger:
|
||||
trigger["sources"] = [
|
||||
str(s.value if hasattr(s, "value") else s) for s in trigger["sources"]
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(validated_data)
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(validated_data)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WorkflowActionEmailSerializer(serializers.ModelSerializer[WorkflowActionEmail]):
|
||||
id = serializers.IntegerField(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = WorkflowActionEmail
|
||||
fields = [
|
||||
"id",
|
||||
"subject",
|
||||
"body",
|
||||
"to",
|
||||
"include_document",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowActionWebhookSerializer(
|
||||
serializers.ModelSerializer[WorkflowActionWebhook],
|
||||
):
|
||||
id = serializers.IntegerField(allow_null=True, required=False)
|
||||
|
||||
def validate_url(self, url):
|
||||
url_validator(url)
|
||||
return url
|
||||
|
||||
class Meta:
|
||||
model = WorkflowActionWebhook
|
||||
fields = [
|
||||
"id",
|
||||
"url",
|
||||
"use_params",
|
||||
"as_json",
|
||||
"params",
|
||||
"body",
|
||||
"headers",
|
||||
"include_document",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
||||
id = serializers.IntegerField(required=False, allow_null=True)
|
||||
assign_correspondent = CorrespondentField(allow_null=True, required=False)
|
||||
assign_tags = TagsField(many=True, allow_null=True, required=False)
|
||||
assign_document_type = DocumentTypeField(allow_null=True, required=False)
|
||||
assign_storage_path = StoragePathField(allow_null=True, required=False)
|
||||
email = WorkflowActionEmailSerializer(allow_null=True, required=False)
|
||||
webhook = WorkflowActionWebhookSerializer(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = WorkflowAction
|
||||
fields = [
|
||||
"id",
|
||||
"type",
|
||||
"assign_title",
|
||||
"assign_tags",
|
||||
"assign_correspondent",
|
||||
"assign_document_type",
|
||||
"assign_storage_path",
|
||||
"assign_owner",
|
||||
"assign_view_users",
|
||||
"assign_view_groups",
|
||||
"assign_change_users",
|
||||
"assign_change_groups",
|
||||
"assign_custom_fields",
|
||||
"assign_custom_fields_values",
|
||||
"remove_all_tags",
|
||||
"remove_tags",
|
||||
"remove_all_correspondents",
|
||||
"remove_correspondents",
|
||||
"remove_all_document_types",
|
||||
"remove_document_types",
|
||||
"remove_all_storage_paths",
|
||||
"remove_storage_paths",
|
||||
"remove_custom_fields",
|
||||
"remove_all_custom_fields",
|
||||
"remove_all_owners",
|
||||
"remove_owners",
|
||||
"remove_all_permissions",
|
||||
"remove_view_users",
|
||||
"remove_view_groups",
|
||||
"remove_change_users",
|
||||
"remove_change_groups",
|
||||
"email",
|
||||
"webhook",
|
||||
"passwords",
|
||||
"ai_suggestion_fields",
|
||||
"ai_create_missing",
|
||||
"ai_overwrite_existing",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
if "assign_title" in attrs and attrs["assign_title"] is not None:
|
||||
if len(attrs["assign_title"]) == 0:
|
||||
# Empty strings treated as None to avoid unexpected behavior
|
||||
attrs["assign_title"] = None
|
||||
else:
|
||||
try:
|
||||
validate_workflow_template(attrs["assign_title"])
|
||||
except (ValueError, KeyError) as e:
|
||||
raise serializers.ValidationError(
|
||||
{"assign_title": f"{e.args[0]}"},
|
||||
)
|
||||
|
||||
if attrs.get("assign_custom_fields_values"):
|
||||
# Empty strings treated as None to avoid unexpected behavior
|
||||
attrs["assign_custom_fields_values"] = {
|
||||
field_id: (None if value == "" else value)
|
||||
for field_id, value in attrs["assign_custom_fields_values"].items()
|
||||
}
|
||||
|
||||
if (
|
||||
"type" in attrs
|
||||
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
|
||||
and "email" not in attrs
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Email data is required for email actions",
|
||||
)
|
||||
|
||||
if (
|
||||
"type" in attrs
|
||||
and attrs["type"] == WorkflowAction.WorkflowActionType.WEBHOOK
|
||||
and "webhook" not in attrs
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Webhook data is required for webhook actions",
|
||||
)
|
||||
|
||||
if (
|
||||
"type" in attrs
|
||||
and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
|
||||
):
|
||||
passwords = attrs.get("passwords")
|
||||
# ensure passwords is a non-empty list of non-empty strings
|
||||
if (
|
||||
passwords is None
|
||||
or not isinstance(passwords, list)
|
||||
or len(passwords) == 0
|
||||
or any(not isinstance(pw, str) for pw in passwords)
|
||||
or any(len(pw.strip()) == 0 for pw in passwords)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Passwords are required for password removal actions",
|
||||
)
|
||||
|
||||
if (
|
||||
"type" in attrs
|
||||
and attrs["type"] == WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
|
||||
):
|
||||
fields = attrs.get("ai_suggestion_fields")
|
||||
valid_fields = set(WorkflowAction.AISuggestionField.values)
|
||||
if (
|
||||
fields is None
|
||||
or not isinstance(fields, list)
|
||||
or len(fields) == 0
|
||||
or any(field not in valid_fields for field in fields)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"At least one valid field is required for apply AI "
|
||||
f"suggestions actions, options are: {sorted(valid_fields)}",
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
||||
order = serializers.IntegerField(required=False)
|
||||
|
||||
triggers = WorkflowTriggerSerializer(many=True)
|
||||
actions = WorkflowActionSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Workflow
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"order",
|
||||
"enabled",
|
||||
"triggers",
|
||||
"actions",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
|
||||
if "actions" in attrs:
|
||||
has_remote_ocr_action = any(
|
||||
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
|
||||
for action in attrs["actions"]
|
||||
)
|
||||
has_ai_suggestions_action = any(
|
||||
action.get("type")
|
||||
== WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
|
||||
for action in attrs["actions"]
|
||||
)
|
||||
else:
|
||||
has_remote_ocr_action = self.instance is not None and (
|
||||
self.instance.actions.filter(
|
||||
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||
).exists()
|
||||
)
|
||||
has_ai_suggestions_action = self.instance is not None and (
|
||||
self.instance.actions.filter(
|
||||
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
||||
).exists()
|
||||
)
|
||||
|
||||
if "triggers" in attrs:
|
||||
has_consumption_trigger = any(
|
||||
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||
for trigger in attrs["triggers"]
|
||||
)
|
||||
has_non_consumption_trigger = any(
|
||||
trigger.get("type") != WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||
for trigger in attrs["triggers"]
|
||||
)
|
||||
else:
|
||||
has_consumption_trigger = self.instance is not None and (
|
||||
self.instance.triggers.filter(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
).exists()
|
||||
)
|
||||
has_non_consumption_trigger = self.instance is not None and (
|
||||
self.instance.triggers.exclude(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
).exists()
|
||||
)
|
||||
|
||||
# Remote OCR can only work with consumption triggers
|
||||
if has_remote_ocr_action and not has_consumption_trigger:
|
||||
raise serializers.ValidationError(
|
||||
"Remote OCR actions require a consumption started trigger",
|
||||
)
|
||||
|
||||
# Suggestions are made from the document content, which does not exist
|
||||
# until after consumption has finished
|
||||
if has_ai_suggestions_action and not has_non_consumption_trigger:
|
||||
raise serializers.ValidationError(
|
||||
"Apply AI suggestions actions require a trigger other than "
|
||||
"consumption started",
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def update_triggers_and_actions(
|
||||
self,
|
||||
instance: Workflow,
|
||||
triggers,
|
||||
actions,
|
||||
) -> None:
|
||||
set_triggers = []
|
||||
set_actions = []
|
||||
|
||||
if triggers is not None and triggers is not serializers.empty:
|
||||
for trigger in triggers:
|
||||
filter_has_tags = trigger.pop("filter_has_tags", None)
|
||||
filter_has_all_tags = trigger.pop("filter_has_all_tags", None)
|
||||
filter_has_not_tags = trigger.pop("filter_has_not_tags", None)
|
||||
filter_has_any_correspondents = trigger.pop(
|
||||
"filter_has_any_correspondents",
|
||||
None,
|
||||
)
|
||||
filter_has_not_correspondents = trigger.pop(
|
||||
"filter_has_not_correspondents",
|
||||
None,
|
||||
)
|
||||
filter_has_any_document_types = trigger.pop(
|
||||
"filter_has_any_document_types",
|
||||
None,
|
||||
)
|
||||
filter_has_not_document_types = trigger.pop(
|
||||
"filter_has_not_document_types",
|
||||
None,
|
||||
)
|
||||
filter_has_any_storage_paths = trigger.pop(
|
||||
"filter_has_any_storage_paths",
|
||||
None,
|
||||
)
|
||||
filter_has_not_storage_paths = trigger.pop(
|
||||
"filter_has_not_storage_paths",
|
||||
None,
|
||||
)
|
||||
# Convert sources to strings to handle django-multiselectfield v1.0 changes
|
||||
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger)
|
||||
trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
|
||||
id=trigger.get("id"),
|
||||
defaults=trigger,
|
||||
)
|
||||
if filter_has_tags is not None:
|
||||
trigger_instance.filter_has_tags.set(filter_has_tags)
|
||||
if filter_has_all_tags is not None:
|
||||
trigger_instance.filter_has_all_tags.set(filter_has_all_tags)
|
||||
if filter_has_not_tags is not None:
|
||||
trigger_instance.filter_has_not_tags.set(filter_has_not_tags)
|
||||
if filter_has_any_correspondents is not None:
|
||||
trigger_instance.filter_has_any_correspondents.set(
|
||||
filter_has_any_correspondents,
|
||||
)
|
||||
if filter_has_not_correspondents is not None:
|
||||
trigger_instance.filter_has_not_correspondents.set(
|
||||
filter_has_not_correspondents,
|
||||
)
|
||||
if filter_has_any_document_types is not None:
|
||||
trigger_instance.filter_has_any_document_types.set(
|
||||
filter_has_any_document_types,
|
||||
)
|
||||
if filter_has_not_document_types is not None:
|
||||
trigger_instance.filter_has_not_document_types.set(
|
||||
filter_has_not_document_types,
|
||||
)
|
||||
if filter_has_any_storage_paths is not None:
|
||||
trigger_instance.filter_has_any_storage_paths.set(
|
||||
filter_has_any_storage_paths,
|
||||
)
|
||||
if filter_has_not_storage_paths is not None:
|
||||
trigger_instance.filter_has_not_storage_paths.set(
|
||||
filter_has_not_storage_paths,
|
||||
)
|
||||
set_triggers.append(trigger_instance)
|
||||
|
||||
if actions is not None and actions is not serializers.empty:
|
||||
for index, action in enumerate(actions):
|
||||
action["order"] = index
|
||||
assign_tags = action.pop("assign_tags", None)
|
||||
assign_view_users = action.pop("assign_view_users", None)
|
||||
assign_view_groups = action.pop("assign_view_groups", None)
|
||||
assign_change_users = action.pop("assign_change_users", None)
|
||||
assign_change_groups = action.pop("assign_change_groups", None)
|
||||
assign_custom_fields = action.pop("assign_custom_fields", None)
|
||||
remove_tags = action.pop("remove_tags", None)
|
||||
remove_correspondents = action.pop("remove_correspondents", None)
|
||||
remove_document_types = action.pop("remove_document_types", None)
|
||||
remove_storage_paths = action.pop("remove_storage_paths", None)
|
||||
remove_custom_fields = action.pop("remove_custom_fields", None)
|
||||
remove_owners = action.pop("remove_owners", None)
|
||||
remove_view_users = action.pop("remove_view_users", None)
|
||||
remove_view_groups = action.pop("remove_view_groups", None)
|
||||
remove_change_users = action.pop("remove_change_users", None)
|
||||
remove_change_groups = action.pop("remove_change_groups", None)
|
||||
|
||||
email_data = action.pop("email", None)
|
||||
webhook_data = action.pop("webhook", None)
|
||||
|
||||
action_instance, _ = WorkflowAction.objects.update_or_create(
|
||||
id=action.get("id"),
|
||||
defaults=action,
|
||||
)
|
||||
|
||||
if email_data is not None:
|
||||
serializer = WorkflowActionEmailSerializer(data=email_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
email, _ = WorkflowActionEmail.objects.update_or_create(
|
||||
id=email_data.get("id"),
|
||||
defaults=serializer.validated_data,
|
||||
)
|
||||
action_instance.email = email
|
||||
action_instance.save()
|
||||
|
||||
if webhook_data is not None:
|
||||
serializer = WorkflowActionWebhookSerializer(data=webhook_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
webhook, _ = WorkflowActionWebhook.objects.update_or_create(
|
||||
id=webhook_data.get("id"),
|
||||
defaults=serializer.validated_data,
|
||||
)
|
||||
action_instance.webhook = webhook
|
||||
action_instance.save()
|
||||
|
||||
if assign_tags is not None:
|
||||
action_instance.assign_tags.set(assign_tags)
|
||||
if assign_view_users is not None:
|
||||
action_instance.assign_view_users.set(assign_view_users)
|
||||
if assign_view_groups is not None:
|
||||
action_instance.assign_view_groups.set(assign_view_groups)
|
||||
if assign_change_users is not None:
|
||||
action_instance.assign_change_users.set(assign_change_users)
|
||||
if assign_change_groups is not None:
|
||||
action_instance.assign_change_groups.set(assign_change_groups)
|
||||
if assign_custom_fields is not None:
|
||||
action_instance.assign_custom_fields.set(assign_custom_fields)
|
||||
if remove_tags is not None:
|
||||
action_instance.remove_tags.set(remove_tags)
|
||||
if remove_correspondents is not None:
|
||||
action_instance.remove_correspondents.set(remove_correspondents)
|
||||
if remove_document_types is not None:
|
||||
action_instance.remove_document_types.set(remove_document_types)
|
||||
if remove_storage_paths is not None:
|
||||
action_instance.remove_storage_paths.set(remove_storage_paths)
|
||||
if remove_custom_fields is not None:
|
||||
action_instance.remove_custom_fields.set(remove_custom_fields)
|
||||
if remove_owners is not None:
|
||||
action_instance.remove_owners.set(remove_owners)
|
||||
if remove_view_users is not None:
|
||||
action_instance.remove_view_users.set(remove_view_users)
|
||||
if remove_view_groups is not None:
|
||||
action_instance.remove_view_groups.set(remove_view_groups)
|
||||
if remove_change_users is not None:
|
||||
action_instance.remove_change_users.set(remove_change_users)
|
||||
if remove_change_groups is not None:
|
||||
action_instance.remove_change_groups.set(remove_change_groups)
|
||||
|
||||
set_actions.append(action_instance)
|
||||
|
||||
if triggers is not serializers.empty:
|
||||
instance.triggers.set(set_triggers)
|
||||
if actions is not serializers.empty:
|
||||
instance.actions.set(set_actions)
|
||||
instance.save()
|
||||
|
||||
def prune_triggers_and_actions(self) -> None:
|
||||
"""
|
||||
ManyToMany fields dont support e.g. on_delete so we need to discard unattached
|
||||
triggers and actions manually
|
||||
"""
|
||||
WorkflowTrigger.objects.annotate(
|
||||
workflow_count=Count("workflows"),
|
||||
).filter(workflow_count=0).delete()
|
||||
|
||||
WorkflowAction.objects.annotate(
|
||||
workflow_count=Count("workflows"),
|
||||
).filter(workflow_count=0).delete()
|
||||
|
||||
WorkflowActionEmail.objects.filter(action=None).delete()
|
||||
WorkflowActionWebhook.objects.filter(action=None).delete()
|
||||
|
||||
def create(self, validated_data) -> Workflow:
|
||||
if "triggers" in validated_data:
|
||||
triggers = validated_data.pop("triggers")
|
||||
|
||||
if "actions" in validated_data:
|
||||
actions = validated_data.pop("actions")
|
||||
for action in actions:
|
||||
action.pop("id", None)
|
||||
|
||||
instance = super().create(validated_data)
|
||||
|
||||
self.update_triggers_and_actions(instance, triggers, actions)
|
||||
|
||||
return instance
|
||||
|
||||
def update(self, instance: Workflow, validated_data) -> Workflow:
|
||||
triggers = validated_data.pop("triggers", serializers.empty)
|
||||
actions = validated_data.pop("actions", serializers.empty)
|
||||
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
self.update_triggers_and_actions(instance, triggers, actions)
|
||||
self.prune_triggers_and_actions()
|
||||
|
||||
return instance
|
||||
@@ -56,6 +56,7 @@ from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.utils import compute_checksum
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
from documents.workflows.actions import build_workflow_action_context
|
||||
from documents.workflows.actions import execute_email_action
|
||||
from documents.workflows.actions import execute_move_to_trash_action
|
||||
@@ -363,7 +364,11 @@ def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
||||
|
||||
logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}")
|
||||
try:
|
||||
shutil.move(instance.source_path, new_file_path)
|
||||
shutil.move(
|
||||
instance.source_path,
|
||||
new_file_path,
|
||||
copy_function=copy_file_with_basic_stats,
|
||||
)
|
||||
except OSError as e:
|
||||
logger.error(
|
||||
f"Failed to move {instance.source_path} to trash at "
|
||||
|
||||
+18
-143
@@ -1,88 +1,41 @@
|
||||
import shutil
|
||||
import zoneinfo
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import filelock
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from guardian.shortcuts import clear_ct_cache
|
||||
from pytest_django.fixtures import Settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.tests.factories import DocumentFactory
|
||||
|
||||
UserModelT = get_user_model()
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.models import Document
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PaperlessDirs:
|
||||
"""Standard Paperless-ngx directory layout for tests."""
|
||||
|
||||
media: Path
|
||||
originals: Path
|
||||
archive: Path
|
||||
thumbnails: Path
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def samples_dir() -> Path:
|
||||
def document_samples_dir() -> Path:
|
||||
"""Path to the shared test sample documents."""
|
||||
return Path(__file__).parent / "samples" / "documents"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def paperless_dirs(tmp_path: Path) -> PaperlessDirs:
|
||||
"""Create and return the directory structure for testing."""
|
||||
media = tmp_path / "media"
|
||||
dirs = PaperlessDirs(
|
||||
media=media,
|
||||
originals=media / "documents" / "originals",
|
||||
archive=media / "documents" / "archive",
|
||||
thumbnails=media / "documents" / "thumbnails",
|
||||
)
|
||||
for d in (dirs.originals, dirs.archive, dirs.thumbnails):
|
||||
d.mkdir(parents=True)
|
||||
return dirs
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _media_settings(paperless_dirs: PaperlessDirs, settings) -> None:
|
||||
"""Configure Django settings to point at temp directories."""
|
||||
settings.MEDIA_ROOT = paperless_dirs.media
|
||||
settings.ORIGINALS_DIR = paperless_dirs.originals
|
||||
settings.ARCHIVE_DIR = paperless_dirs.archive
|
||||
settings.THUMBNAIL_DIR = paperless_dirs.thumbnails
|
||||
settings.MEDIA_LOCK = paperless_dirs.media / "media.lock"
|
||||
settings.IGNORABLE_FILES = {".DS_Store", "Thumbs.db", "desktop.ini"}
|
||||
settings.APP_LOGO = ""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_doc(
|
||||
paperless_dirs: PaperlessDirs,
|
||||
_media_settings: None,
|
||||
samples_dir: Path,
|
||||
paperless_dirs: "PaperlessDirs",
|
||||
document_samples_dir: Path,
|
||||
) -> "Document":
|
||||
"""Create a document with valid files and matching checksums."""
|
||||
with filelock.FileLock(paperless_dirs.media / "media.lock"):
|
||||
with filelock.FileLock(paperless_dirs.media_lock):
|
||||
shutil.copy(
|
||||
samples_dir / "originals" / "0000001.pdf",
|
||||
paperless_dirs.originals / "0000001.pdf",
|
||||
document_samples_dir / "originals" / "0000001.pdf",
|
||||
paperless_dirs.originals_dir / "0000001.pdf",
|
||||
)
|
||||
shutil.copy(
|
||||
samples_dir / "archive" / "0000001.pdf",
|
||||
paperless_dirs.archive / "0000001.pdf",
|
||||
document_samples_dir / "archive" / "0000001.pdf",
|
||||
paperless_dirs.archive_dir / "0000001.pdf",
|
||||
)
|
||||
shutil.copy(
|
||||
samples_dir / "thumbnails" / "0000001.webp",
|
||||
paperless_dirs.thumbnails / "0000001.webp",
|
||||
document_samples_dir / "thumbnails" / "0000001.webp",
|
||||
paperless_dirs.thumbnail_dir / "0000001.webp",
|
||||
)
|
||||
|
||||
return DocumentFactory(
|
||||
@@ -97,95 +50,17 @@ def sample_doc(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _search_index(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
) -> Generator[None, None, None]:
|
||||
"""Create a temp index directory and point INDEX_DIR at it.
|
||||
@pytest.fixture
|
||||
def _search_index(paperless_dirs: "PaperlessDirs") -> None:
|
||||
"""Point the search backend at a fresh, empty index directory.
|
||||
|
||||
Resets the backend singleton before and after so each test gets a clean
|
||||
index rather than reusing a stale singleton from another test.
|
||||
paperless_dirs owns INDEX_DIR and resets the backend singleton on both
|
||||
sides of the test, so requesting it is all that is needed.
|
||||
"""
|
||||
from documents.search import reset_backend
|
||||
|
||||
index_dir = tmp_path / "index"
|
||||
index_dir.mkdir()
|
||||
settings.INDEX_DIR = index_dir
|
||||
reset_backend()
|
||||
yield
|
||||
reset_backend()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings_timezone(settings: Settings) -> zoneinfo.ZoneInfo:
|
||||
return zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rest_api_client():
|
||||
"""
|
||||
The basic DRF ApiClient
|
||||
"""
|
||||
yield APIClient()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def regular_user(django_user_model: type[UserModelT]) -> UserModelT:
|
||||
"""Unprivileged authenticated user for permission boundary tests."""
|
||||
return django_user_model.objects.create_user(username="regular", password="regular")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_client(rest_api_client: APIClient, admin_user: UserModelT) -> APIClient:
|
||||
"""Admin client pre-authenticated and sending the v10 Accept header."""
|
||||
rest_api_client.force_authenticate(user=admin_user)
|
||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
||||
return rest_api_client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def v9_client(rest_api_client: APIClient, admin_user: UserModelT) -> APIClient:
|
||||
"""Admin client pre-authenticated and sending the v9 Accept header."""
|
||||
rest_api_client.force_authenticate(user=admin_user)
|
||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=9")
|
||||
return rest_api_client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user_client(rest_api_client: APIClient, regular_user: UserModelT) -> APIClient:
|
||||
"""Regular-user client pre-authenticated and sending the v10 Accept header."""
|
||||
rest_api_client.force_authenticate(user=regular_user)
|
||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
||||
return rest_api_client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_content_type_caches() -> None:
|
||||
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
|
||||
|
||||
Tests that delete and reinsert ContentType/Permission rows (e.g. the
|
||||
importer) corrupt both caches. Without this fixture a subsequent test on
|
||||
the same xdist worker sees stale ContentType objects and guardian raises
|
||||
MixedContentTypeError.
|
||||
"""
|
||||
ContentType.objects.clear_cache()
|
||||
clear_ct_cache()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def faker_session_locale():
|
||||
"""Set Faker locale for reproducibility."""
|
||||
return "en_US"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def faker_seed():
|
||||
return 12345
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def indexed_document(_search_index: None) -> "Document":
|
||||
def searchable_document(_search_index: None) -> "Document":
|
||||
"""One searchable document, for tests about what the search endpoint
|
||||
returns rather than about what it finds.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import re
|
||||
|
||||
|
||||
def dummy_preprocess(content: str) -> str:
|
||||
"""
|
||||
Simpler, faster pre-processing for testing purposes
|
||||
"""
|
||||
content = content.lower().strip()
|
||||
content = re.sub(r"\s+", " ", content)
|
||||
return content
|
||||
@@ -15,11 +15,11 @@ from rich.console import Console
|
||||
|
||||
from documents.management.commands.document_sanity_checker import Command
|
||||
from documents.sanity_checker import SanityCheckMessages
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.models import Document
|
||||
from documents.tests.conftest import PaperlessDirs
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
def _render_to_string(messages: SanityCheckMessages) -> str:
|
||||
@@ -71,7 +71,7 @@ class TestRenderResultsWithIssues:
|
||||
assert "INFO" in output
|
||||
assert "No OCR data" in output
|
||||
|
||||
@pytest.mark.usefixtures("_media_settings")
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
def test_global_message(self) -> None:
|
||||
msgs = SanityCheckMessages()
|
||||
msgs.warning(None, "Orphaned file: /tmp/stray.pdf")
|
||||
@@ -87,7 +87,7 @@ class TestRenderResultsWithIssues:
|
||||
assert "Thumbnail missing" in output
|
||||
assert "Checksum mismatch" in output
|
||||
|
||||
@pytest.mark.usefixtures("_media_settings")
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
def test_unknown_doc_pk(self) -> None:
|
||||
msgs = SanityCheckMessages()
|
||||
msgs.error(99999, "Ghost document")
|
||||
@@ -184,7 +184,6 @@ class TestDocumentSanityCheckerCommand:
|
||||
assert "ERROR" in output
|
||||
assert "Original of document does not exist" in output
|
||||
|
||||
@pytest.mark.usefixtures("_media_settings")
|
||||
def test_checksum_mismatch(self, paperless_dirs: PaperlessDirs) -> None:
|
||||
"""Lightweight document with zero-byte files triggers checksum mismatch."""
|
||||
doc = DocumentFactory(
|
||||
|
||||
@@ -9,29 +9,21 @@ from documents.search._backend import TantivyBackend
|
||||
from documents.search._backend import reset_backend
|
||||
from documents.search._schema import build_schema
|
||||
from documents.search._tokenizer import register_tokenizers
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
from documents.models import Document
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index_dir(tmp_path: Path, settings: Settings) -> Path:
|
||||
path = tmp_path / "index"
|
||||
path.mkdir()
|
||||
settings.INDEX_DIR = path
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> Generator[TantivyBackend, None, None]:
|
||||
b = TantivyBackend() # path=None → in-memory index
|
||||
def backend(paperless_dirs: PaperlessDirs) -> Generator[TantivyBackend, None, None]:
|
||||
b = TantivyBackend(path=paperless_dirs.index_dir)
|
||||
b.open()
|
||||
try:
|
||||
yield b
|
||||
|
||||
@@ -14,7 +14,6 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -22,7 +21,8 @@ from documents.models import DocumentType
|
||||
from documents.models import Note
|
||||
from documents.models import StoragePath
|
||||
from documents.search._query import parse_user_query
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -148,7 +148,7 @@ class TestJsonSubpaths:
|
||||
THEN:
|
||||
- Only the document with alice's note matches
|
||||
"""
|
||||
alice = User.objects.create_user(username="alice")
|
||||
alice = UserFactory(username="alice")
|
||||
doc_with_note = DocumentFactory(
|
||||
title="Has note",
|
||||
content="x",
|
||||
|
||||
@@ -3,10 +3,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from documents.models import CustomField
|
||||
@@ -19,11 +17,12 @@ from documents.search._backend import WriteBatch
|
||||
from documents.search._backend import get_backend
|
||||
from documents.search._backend import reset_backend
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.tests.factories import CorrespondentFactory
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import DocumentTypeFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.factories import UserFactory
|
||||
from paperless_testing.factories import CorrespondentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentTypeFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||
|
||||
@@ -189,7 +188,7 @@ class TestAddOrUpdateIds:
|
||||
pk=1,
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", user, doc)
|
||||
grant_object(user, doc, "view_document")
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
@@ -209,7 +208,7 @@ class TestAddOrUpdateIds:
|
||||
pk=1,
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", group, doc)
|
||||
grant_object(group, doc, "view_document")
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
@@ -763,8 +762,8 @@ class TestSearchIds:
|
||||
|
||||
def test_respects_permission_filter(self, backend: TantivyBackend) -> None:
|
||||
"""search_ids must respect user permission filtering."""
|
||||
owner = User.objects.create_user("ids_owner")
|
||||
other = User.objects.create_user("ids_other")
|
||||
owner = UserFactory(username="ids_owner")
|
||||
other = UserFactory(username="ids_other")
|
||||
doc = Document.objects.create(
|
||||
title="private doc",
|
||||
content="secret keyword",
|
||||
@@ -843,7 +842,7 @@ class TestRebuild:
|
||||
content="group secret keyword",
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", group, doc)
|
||||
grant_object(group, doc, "view_document")
|
||||
|
||||
backend.rebuild(Document.objects.all())
|
||||
|
||||
@@ -948,7 +947,8 @@ class TestSingleton:
|
||||
yield
|
||||
reset_backend()
|
||||
|
||||
def test_returns_same_instance_on_repeated_calls(self, index_dir) -> None:
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
def test_returns_same_instance_on_repeated_calls(self) -> None:
|
||||
"""Singleton pattern: repeated calls to get_backend() must return the same instance."""
|
||||
assert get_backend() is get_backend()
|
||||
|
||||
@@ -965,7 +965,8 @@ class TestSingleton:
|
||||
assert b1 is not b2
|
||||
assert b2._path == tmp_path / "b"
|
||||
|
||||
def test_reset_forces_new_instance(self, index_dir) -> None:
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
def test_reset_forces_new_instance(self) -> None:
|
||||
"""reset_backend() must force creation of a new backend instance on next get_backend() call."""
|
||||
b1 = get_backend()
|
||||
reset_backend()
|
||||
@@ -1071,7 +1072,7 @@ class TestFieldHandling:
|
||||
|
||||
def test_notes_include_user_information(self, backend: TantivyBackend) -> None:
|
||||
"""Notes must be indexed with user information when available for structured queries."""
|
||||
user = User.objects.create_user("notewriter")
|
||||
user = UserFactory(username="notewriter")
|
||||
doc = Document.objects.create(
|
||||
title="Doc with notes",
|
||||
content="test",
|
||||
@@ -1173,7 +1174,7 @@ class TestHighlightHits:
|
||||
notes.note: prefix so the query targets notes content directly, but
|
||||
the snippet is generated from notes_text which stores the same text.
|
||||
"""
|
||||
user = User.objects.create_user("hl_noteuser")
|
||||
user = UserFactory(username="hl_noteuser")
|
||||
doc = Document.objects.create(
|
||||
title="Doc with matching note",
|
||||
content="unrelated content",
|
||||
|
||||
@@ -27,7 +27,7 @@ import time_machine
|
||||
from documents.models import Note
|
||||
from documents.models import Tag
|
||||
from documents.search._errors import InvalidDateQuery
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -269,7 +269,7 @@ class TestDocumentedDateForms:
|
||||
yield
|
||||
|
||||
@pytest.fixture
|
||||
def dated(self, index_document: Callable[..., Document]) -> dict[str, int]:
|
||||
def dated(self, backend: TantivyBackend) -> dict[str, int]:
|
||||
stamps = {
|
||||
"today": datetime(2026, 6, 15, 9, 0, tzinfo=UTC),
|
||||
"yesterday": datetime(2026, 6, 14, 9, 0, tzinfo=UTC),
|
||||
@@ -279,14 +279,14 @@ class TestDocumentedDateForms:
|
||||
"january": datetime(2026, 1, 10, 10, 0, tzinfo=UTC),
|
||||
"old": datetime(2005, 3, 4, 15, 30, tzinfo=UTC),
|
||||
}
|
||||
return {
|
||||
label: index_document(
|
||||
title=label,
|
||||
content="dated body",
|
||||
added=stamp,
|
||||
).pk
|
||||
docs = {
|
||||
label: DocumentFactory(title=label, content="dated body", added=stamp)
|
||||
for label, stamp in stamps.items()
|
||||
}
|
||||
with backend.batch_update() as batch:
|
||||
for doc in docs.values():
|
||||
batch.add_or_update(doc)
|
||||
return {label: doc.pk for label, doc in docs.items()}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "label"),
|
||||
|
||||
@@ -29,7 +29,7 @@ from rest_framework import status
|
||||
|
||||
from documents.search._backend import SearchMode
|
||||
from documents.search._query import parse_simple_text_highlight_query
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -17,12 +17,12 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Note
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -53,7 +53,7 @@ class TestBareJsonFieldPrefixes:
|
||||
decoy's content match does not resurface through a demoted
|
||||
text search
|
||||
"""
|
||||
alice = User.objects.create_user(username="alice")
|
||||
alice = UserFactory(username="alice")
|
||||
with_note = DocumentFactory(title="Has note", content="x")
|
||||
Note.objects.create(document=with_note, user=alice, note="crocodile")
|
||||
backend.add_or_update(with_note)
|
||||
@@ -116,7 +116,7 @@ class TestBareJsonFieldPrefixes:
|
||||
document; the default-subpath resolution for the bare
|
||||
prefix does not interfere with explicit subpath addressing
|
||||
"""
|
||||
bob = User.objects.create_user(username="bob")
|
||||
bob = UserFactory(username="bob")
|
||||
doc = DocumentFactory(title="Bob note", content="x")
|
||||
Note.objects.create(document=doc, user=bob, note="remark")
|
||||
backend.add_or_update(doc)
|
||||
|
||||
@@ -20,7 +20,6 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import tantivy
|
||||
from django.contrib.auth.models import User
|
||||
from whoosh_compat import FieldKind
|
||||
|
||||
from documents.models import CustomField
|
||||
@@ -28,6 +27,7 @@ from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import Note
|
||||
from documents.search._fields import PUBLIC_FIELDS
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.search._backend import TantivyBackend
|
||||
@@ -49,7 +49,7 @@ class TestJsonSubpathsAreWrittenAtIndexTime:
|
||||
- Every subpath PUBLIC_FIELDS declares for notes/custom_fields
|
||||
is present as a key in the document's stored JSON payload
|
||||
"""
|
||||
user = User.objects.create_user(username="completeness-user")
|
||||
user = UserFactory(username="completeness-user")
|
||||
field = CustomField.objects.create(
|
||||
name="Completeness Field",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
|
||||
@@ -15,7 +15,7 @@ from documents.search._backend import SearchIndexLockError
|
||||
from documents.search._backend import TantivyBackend
|
||||
from documents.tasks import index_document
|
||||
from documents.tasks import remove_document_from_index
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from documents.tests.utils import TestMigrations
|
||||
from paperless_testing.migrations import TestMigrations
|
||||
|
||||
pytestmark = pytest.mark.search
|
||||
|
||||
|
||||
@@ -18,13 +18,14 @@ from typing import TYPE_CHECKING
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from guardian.shortcuts import assign_perm
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.search._backend import TantivyBackend
|
||||
@@ -34,22 +35,22 @@ pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||
|
||||
@pytest.fixture
|
||||
def owner() -> User:
|
||||
return User.objects.create_user(username="owner")
|
||||
return UserFactory(username="owner")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stranger() -> User:
|
||||
return User.objects.create_user(username="stranger")
|
||||
return UserFactory(username="stranger")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewer() -> User:
|
||||
return User.objects.create_user(username="viewer")
|
||||
return UserFactory(username="viewer")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def group_member() -> User:
|
||||
user = User.objects.create_user(username="group_member")
|
||||
user = UserFactory(username="group_member")
|
||||
user.groups.add(Group.objects.create(name="accounting"))
|
||||
return user
|
||||
|
||||
@@ -127,7 +128,7 @@ class TestPermissionFilteringOnIndexedDocuments:
|
||||
checksum="perm-shared-user",
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", viewer, doc)
|
||||
grant_object(viewer, doc, "view_document")
|
||||
backend.add_or_update(doc)
|
||||
|
||||
assert backend.search_ids("invoice", user=viewer) == [doc.pk]
|
||||
@@ -157,7 +158,7 @@ class TestPermissionFilteringOnIndexedDocuments:
|
||||
checksum="perm-shared-group",
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", group_member.groups.first(), doc)
|
||||
grant_object(group_member.groups.first(), doc, "view_document")
|
||||
backend.add_or_update(doc)
|
||||
|
||||
assert backend.search_ids("invoice", user=group_member) == [doc.pk]
|
||||
|
||||
@@ -13,11 +13,11 @@ from documents.search._schema import needs_rebuild
|
||||
from documents.search._schema import schema_fingerprint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import tantivy
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
pytestmark = pytest.mark.search
|
||||
|
||||
@@ -25,16 +25,19 @@ pytestmark = pytest.mark.search
|
||||
class TestNeedsRebuild:
|
||||
"""needs_rebuild covers all sentinel-file states that require a full reindex."""
|
||||
|
||||
def test_returns_true_when_settings_file_missing(self, index_dir: Path) -> None:
|
||||
assert needs_rebuild(index_dir) is True
|
||||
def test_returns_true_when_settings_file_missing(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||
|
||||
def test_returns_false_when_version_and_language_match(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
@@ -43,51 +46,51 @@ class TestNeedsRebuild:
|
||||
},
|
||||
),
|
||||
)
|
||||
assert needs_rebuild(index_dir) is False
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is False
|
||||
|
||||
def test_returns_true_on_schema_version_mismatch(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": SCHEMA_VERSION - 1, "language": None}),
|
||||
)
|
||||
assert needs_rebuild(index_dir) is True
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||
|
||||
def test_returns_true_when_version_is_not_an_integer(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": "not-a-number", "language": None}),
|
||||
)
|
||||
assert needs_rebuild(index_dir) is True
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||
|
||||
def test_returns_true_when_language_key_missing(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": SCHEMA_VERSION}),
|
||||
)
|
||||
assert needs_rebuild(index_dir) is True
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||
|
||||
def test_returns_true_when_language_differs(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "de"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
||||
)
|
||||
assert needs_rebuild(index_dir) is True
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||
|
||||
|
||||
def _schema_fields(schema: tantivy.Schema) -> dict[str, dict]:
|
||||
|
||||
@@ -35,6 +35,8 @@ if TYPE_CHECKING:
|
||||
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
pytestmark = pytest.mark.search
|
||||
|
||||
# The on-disk field layout of a v2 index, pinned as data. Any edit here is an
|
||||
@@ -469,7 +471,7 @@ def _fingerprint_of(descriptors: list[FieldDescriptor]) -> str:
|
||||
class TestNeedsRebuildOnFingerprint:
|
||||
def test_matching_fingerprint_does_not_rebuild(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -482,13 +484,13 @@ class TestNeedsRebuildOnFingerprint:
|
||||
- It returns False
|
||||
"""
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
_sentinels(index_dir)
|
||||
_sentinels(paperless_dirs.index_dir)
|
||||
|
||||
assert needs_rebuild(index_dir) is False
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is False
|
||||
|
||||
def test_stale_fingerprint_rebuilds_despite_a_matching_version(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: SettingsWrapper,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -505,7 +507,7 @@ class TestNeedsRebuildOnFingerprint:
|
||||
every subsequent write would raise
|
||||
"""
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
_sentinels(index_dir)
|
||||
_sentinels(paperless_dirs.index_dir)
|
||||
extended = [
|
||||
*field_descriptors(),
|
||||
FieldDescriptor(
|
||||
@@ -519,11 +521,11 @@ class TestNeedsRebuildOnFingerprint:
|
||||
]
|
||||
monkeypatch.setattr(_schema, "field_descriptors", lambda: extended)
|
||||
|
||||
assert needs_rebuild(index_dir) is True
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||
|
||||
def test_reordered_schema_rebuilds(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: SettingsWrapper,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -538,16 +540,16 @@ class TestNeedsRebuildOnFingerprint:
|
||||
- It returns True
|
||||
"""
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
_sentinels(index_dir)
|
||||
_sentinels(paperless_dirs.index_dir)
|
||||
reordered = field_descriptors()
|
||||
reordered[1], reordered[2] = reordered[2], reordered[1]
|
||||
monkeypatch.setattr(_schema, "field_descriptors", lambda: reordered)
|
||||
|
||||
assert needs_rebuild(index_dir) is True
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||
|
||||
def test_missing_fingerprint_rebuilds(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -561,15 +563,15 @@ class TestNeedsRebuildOnFingerprint:
|
||||
is rebuilt rather than trusted
|
||||
"""
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
(paperless_dirs.index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": None}),
|
||||
)
|
||||
|
||||
assert needs_rebuild(index_dir) is True
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||
|
||||
def test_written_sentinels_satisfy_the_check(
|
||||
self,
|
||||
index_dir: Path,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -582,6 +584,6 @@ class TestNeedsRebuildOnFingerprint:
|
||||
- It returns False
|
||||
"""
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
_write_sentinels(index_dir)
|
||||
_write_sentinels(paperless_dirs.index_dir)
|
||||
|
||||
assert needs_rebuild(index_dir) is False
|
||||
assert needs_rebuild(paperless_dirs.index_dir) is False
|
||||
|
||||
@@ -16,11 +16,11 @@ from documents.models import Document
|
||||
from documents.models import Tag
|
||||
from documents.search import get_backend
|
||||
from documents.search import reset_backend
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.factories import UserFactory
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless.admin import PaperlessUserAdmin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -3,7 +3,6 @@ from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import override_settings
|
||||
from PIL import Image
|
||||
@@ -11,10 +10,11 @@ from PIL.PngImagePlugin import PngInfo
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless.models import ColorConvertChoices
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.http import read_streaming_response
|
||||
|
||||
|
||||
class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
@@ -23,7 +23,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
def test_api_get_config(self) -> None:
|
||||
@@ -81,6 +81,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
"ai_enabled": None,
|
||||
"llm_embedding_backend": None,
|
||||
"llm_embedding_model": None,
|
||||
"llm_embedding_api_key": None,
|
||||
"llm_embedding_endpoint": None,
|
||||
"llm_embedding_chunk_size": None,
|
||||
"llm_context_size": None,
|
||||
@@ -267,7 +268,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- old app_logo file is deleted
|
||||
"""
|
||||
admin = User.objects.create_superuser(username="admin")
|
||||
admin = UserFactory(username="admin", superuser=True)
|
||||
self.client.force_login(user=admin)
|
||||
response = self.client.get("/logo/")
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
@@ -922,6 +923,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
self.assertEqual(ApplicationConfiguration.objects.count(), 1)
|
||||
|
||||
def test_update_llm_embedding_api_key(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing config with llm_embedding_api_key specified
|
||||
WHEN:
|
||||
- API to update llm_embedding_api_key is called with all *s
|
||||
- API to update llm_embedding_api_key is called with empty string
|
||||
THEN:
|
||||
- llm_embedding_api_key is unchanged
|
||||
- llm_embedding_api_key is set to None
|
||||
"""
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
assert config is not None
|
||||
config.llm_embedding_api_key = "1234567890"
|
||||
config.save()
|
||||
|
||||
# Test with all *
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
json.dumps(
|
||||
{
|
||||
"llm_embedding_api_key": "*" * 32,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
config.refresh_from_db()
|
||||
self.assertEqual(config.llm_embedding_api_key, "1234567890")
|
||||
# Test with empty string
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
json.dumps(
|
||||
{
|
||||
"llm_embedding_api_key": "",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
config.refresh_from_db()
|
||||
self.assertEqual(config.llm_embedding_api_key, None)
|
||||
|
||||
def test_update_llm_api_key(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -4,8 +4,6 @@ import json
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
@@ -14,9 +12,11 @@ from rest_framework.test import APITestCase
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.http import read_streaming_response
|
||||
from paperless_testing.permissions import grant_global
|
||||
|
||||
|
||||
class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
@@ -25,7 +25,7 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.doc1 = Document.objects.create(title="unrelated", checksum="A")
|
||||
@@ -166,7 +166,15 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
response.close()
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response["Content-Type"], "application/zip")
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(read_streaming_response(response))) as zipf:
|
||||
self.assertEqual(zipf.infolist()[0].compress_type, zipfile.ZIP_LZMA)
|
||||
|
||||
with self.doc2.source_file as f:
|
||||
self.assertEqual(f.read(), zipf.read("2021-01-01 document A.pdf"))
|
||||
|
||||
@override_settings(FILENAME_FORMAT="{correspondent}/{title}")
|
||||
def test_formatted_download_originals(self) -> None:
|
||||
@@ -326,10 +334,8 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
)
|
||||
|
||||
def test_download_insufficient_permissions(self) -> None:
|
||||
user = User.objects.create_user(username="temp_user")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
user = UserFactory(username="temp_user")
|
||||
grant_global(user, "view_document")
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
self.doc2.owner = self.user
|
||||
|
||||
@@ -2,27 +2,30 @@ import json
|
||||
from unittest import mock
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_all_global
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.user = user
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
@@ -200,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(
|
||||
@@ -224,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:
|
||||
@@ -252,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(
|
||||
@@ -278,15 +281,15 @@ 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,
|
||||
) -> None:
|
||||
self.setup_mock(m, "modify_custom_fields")
|
||||
user = User.objects.create_user(username="doc-owner")
|
||||
user.user_permissions.add(Permission.objects.get(codename="change_document"))
|
||||
other_user = User.objects.create_user(username="other-user")
|
||||
user = UserFactory(username="doc-owner")
|
||||
grant_global(user, "change_document")
|
||||
other_user = UserFactory(username="other-user")
|
||||
source_doc = Document.objects.create(
|
||||
checksum="source",
|
||||
title="Source",
|
||||
@@ -322,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(
|
||||
@@ -346,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")
|
||||
|
||||
@@ -370,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:
|
||||
@@ -490,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(
|
||||
@@ -506,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(
|
||||
@@ -520,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(
|
||||
@@ -544,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(
|
||||
@@ -558,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:
|
||||
@@ -580,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:
|
||||
@@ -610,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:
|
||||
@@ -735,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,
|
||||
@@ -761,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")
|
||||
|
||||
@@ -784,13 +787,11 @@ 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 = User.objects.create_user(username="duplicate-owner")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
)
|
||||
user = UserFactory(username="duplicate-owner")
|
||||
grant_global(user, "change_document")
|
||||
first_duplicate = Document.objects.create(
|
||||
checksum="owned-duplicate",
|
||||
title="First duplicate",
|
||||
@@ -823,7 +824,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@mock.patch("documents.search.get_backend")
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
|
||||
def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters(
|
||||
self,
|
||||
m,
|
||||
@@ -1178,7 +1179,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
user1 = User.objects.create(username="user1")
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
assign_perm("view_document", user1, self.doc2)
|
||||
grant_object(user1, self.doc2, "view_document")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/selection_data/",
|
||||
@@ -1188,9 +1189,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
user1.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_global(user1, "view_document")
|
||||
user1 = User.objects.get(pk=user1.pk)
|
||||
self.client.force_authenticate(user=user1)
|
||||
response = self.client.post(
|
||||
@@ -1200,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")
|
||||
@@ -1235,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")
|
||||
|
||||
@@ -1259,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:
|
||||
@@ -1294,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:
|
||||
@@ -1336,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:
|
||||
@@ -1372,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:
|
||||
@@ -1403,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")
|
||||
@@ -1455,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:
|
||||
@@ -1519,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:
|
||||
@@ -1533,7 +1532,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||
self.doc1.save()
|
||||
user1 = User.objects.create(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
@@ -1573,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:
|
||||
@@ -1587,8 +1586,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||
self.doc1.save()
|
||||
user1 = User.objects.create(username="user1")
|
||||
assign_perm("view_document", user1, self.doc1)
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
grant_object(user1, self.doc1, "view_document")
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
@@ -1609,7 +1608,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
m.assert_not_called()
|
||||
self.assertEqual(response.content, b"Insufficient permissions")
|
||||
|
||||
assign_perm("change_document", user1, self.doc1)
|
||||
grant_object(user1, self.doc1, "change_document")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
@@ -1627,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(
|
||||
@@ -1649,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/",
|
||||
@@ -1689,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:
|
||||
@@ -1726,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:
|
||||
@@ -1754,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:
|
||||
@@ -1786,7 +1785,37 @@ 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:
|
||||
- A legacy split bulk edit of a document without a page count
|
||||
WHEN:
|
||||
- API to bulk edit is called
|
||||
THEN:
|
||||
- API returns HTTP 400
|
||||
- split is not called
|
||||
"""
|
||||
self.setup_mock(m, "split")
|
||||
|
||||
for pages in ("1", "1-5000000"):
|
||||
with self.subTest(pages=pages):
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.id],
|
||||
"method": "split",
|
||||
"parameters": {"pages": pages},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"document page count is unknown", response.content)
|
||||
m.assert_not_called()
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.split")
|
||||
def test_bulk_edit_split_parses_pages(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -1814,12 +1843,12 @@ 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()
|
||||
user1 = User.objects.create(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
@@ -1853,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(
|
||||
@@ -1875,12 +1904,12 @@ 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()
|
||||
user1 = User.objects.create(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
@@ -1916,14 +1945,11 @@ 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 = User.objects.create_user(username="no-change")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="add_document"),
|
||||
Permission.objects.get(codename="delete_document"),
|
||||
)
|
||||
user = UserFactory(username="no-change")
|
||||
grant_global(user, "add_document", "delete_document")
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.post(
|
||||
@@ -1940,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(
|
||||
@@ -1973,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:
|
||||
@@ -2062,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(
|
||||
@@ -2222,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:
|
||||
@@ -2288,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(
|
||||
@@ -2305,12 +2331,12 @@ 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()
|
||||
user1 = User.objects.create(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
@@ -2342,10 +2368,10 @@ 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 = User.objects.create_user(username="no-change")
|
||||
user = UserFactory(username="no-change")
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.post(
|
||||
@@ -2363,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,
|
||||
@@ -2372,11 +2398,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
) -> None:
|
||||
self.setup_mock(edit_pdf_mock, "edit_pdf")
|
||||
self.setup_mock(remove_password_mock, "remove_password")
|
||||
user = User.objects.create_user(username="no-delete")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="add_document"),
|
||||
Permission.objects.get(codename="change_document"),
|
||||
)
|
||||
user = UserFactory(username="no-delete")
|
||||
grant_global(user, "add_document", "change_document")
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
cases = [
|
||||
@@ -2410,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(
|
||||
@@ -2458,12 +2481,12 @@ 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()
|
||||
user1 = User.objects.create(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
@@ -2503,7 +2526,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
WHEN:
|
||||
- API to bulk edit documents is called
|
||||
THEN:
|
||||
- Audit log is created
|
||||
- Audit log is created with the old and new correspondent
|
||||
"""
|
||||
LogEntry.objects.all().delete()
|
||||
response = self.client.post(
|
||||
@@ -2519,7 +2542,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
|
||||
entry = LogEntry.objects.get_for_object(self.doc1).get()
|
||||
self.assertEqual(entry.changes, {"correspondent": [None, self.c2.id]})
|
||||
|
||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||
def test_bulk_edit_audit_log_enabled_tags(self) -> None:
|
||||
@@ -2527,16 +2551,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
GIVEN:
|
||||
- Audit log is enabled
|
||||
WHEN:
|
||||
- API to bulk edit tags is called
|
||||
- API to bulk edit tags is called on an untagged document and a
|
||||
document with several tags
|
||||
THEN:
|
||||
- Audit log is created
|
||||
- Audit log is created for each document with its full tag list
|
||||
before and after the edit
|
||||
"""
|
||||
LogEntry.objects.all().delete()
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.id],
|
||||
"documents": [self.doc1.id, self.doc4.id],
|
||||
"method": "modify_tags",
|
||||
"parameters": {
|
||||
"add_tags": [self.t1.id],
|
||||
@@ -2548,18 +2574,32 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
|
||||
entry = LogEntry.objects.get_for_object(self.doc1).get()
|
||||
self.assertEqual(entry.changes, {"tags": [[], [self.t1.id]]})
|
||||
entry = LogEntry.objects.get_for_object(self.doc4).get()
|
||||
self.assertEqual(
|
||||
entry.changes,
|
||||
{"tags": [[self.t1.id, self.t2.id], [self.t1.id]]},
|
||||
)
|
||||
|
||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||
def test_bulk_edit_audit_log_enabled_custom_fields(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Audit log is enabled
|
||||
- A document with two custom fields
|
||||
WHEN:
|
||||
- API to bulk edit custom fields is called
|
||||
- API to bulk edit custom fields is called to add a third
|
||||
THEN:
|
||||
- Audit log is created
|
||||
- Audit log is created with every custom field instance before and
|
||||
after the edit
|
||||
- Audit log is created for the new custom field instance
|
||||
"""
|
||||
cf3 = CustomField.objects.create(name="cf3", data_type="string")
|
||||
existing = [
|
||||
CustomFieldInstance.objects.create(document=self.doc1, field=field)
|
||||
for field in (self.cf2, cf3)
|
||||
]
|
||||
LogEntry.objects.all().delete()
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
@@ -2577,7 +2617,14 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
|
||||
added = CustomFieldInstance.objects.get(document=self.doc1, field=self.cf1)
|
||||
existing_ids = [instance.id for instance in existing]
|
||||
entry = LogEntry.objects.get_for_object(self.doc1).get()
|
||||
self.assertEqual(
|
||||
entry.changes,
|
||||
{"custom_fields": [existing_ids, [*existing_ids, added.id]]},
|
||||
)
|
||||
self.assertEqual(LogEntry.objects.get_for_object(added).count(), 1)
|
||||
|
||||
def test_api_bulk_edit_with_bad_search_query_returns_400(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -4,20 +4,22 @@ from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
class TestChatStreamingViewInputValidation(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def _mock_ai_enabled(self) -> mock.MagicMock:
|
||||
@@ -28,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(
|
||||
@@ -55,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),
|
||||
),
|
||||
):
|
||||
@@ -76,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(
|
||||
@@ -100,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(()),
|
||||
)
|
||||
|
||||
@@ -113,9 +117,7 @@ class TestChatStreamingViewUnrestrictedFlag:
|
||||
needs to reach the view at all. Model-level only: says nothing
|
||||
about which documents (if any) this user can actually see.
|
||||
"""
|
||||
regular_user.user_permissions.add(
|
||||
*Permission.objects.filter(codename="view_document"),
|
||||
)
|
||||
grant_global(regular_user, "view_document")
|
||||
return user_client
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -4,23 +4,24 @@ from unittest import mock
|
||||
from unittest.mock import ANY
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
||||
ENDPOINT = "/api/custom_fields/"
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
return super().setUp()
|
||||
|
||||
@@ -1174,11 +1175,9 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
||||
def test_documentlink_patch_requires_change_permission_on_target_documents(
|
||||
self,
|
||||
) -> None:
|
||||
source_owner = User.objects.create_user(username="source-owner")
|
||||
source_owner.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
)
|
||||
other_user = User.objects.create_user(username="other-user")
|
||||
source_owner = UserFactory(username="source-owner")
|
||||
grant_global(source_owner, "change_document")
|
||||
other_user = UserFactory(username="other-user")
|
||||
|
||||
source_doc = Document.objects.create(
|
||||
title="Source",
|
||||
@@ -1221,11 +1220,9 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
||||
def test_documentlink_patch_allowed_with_change_permission_on_target_documents(
|
||||
self,
|
||||
) -> None:
|
||||
source_owner = User.objects.create_user(username="source-owner")
|
||||
source_owner.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
)
|
||||
other_user = User.objects.create_user(username="other-user")
|
||||
source_owner = UserFactory(username="source-owner")
|
||||
grant_global(source_owner, "change_document")
|
||||
other_user = UserFactory(username="other-user")
|
||||
|
||||
source_doc = Document.objects.create(
|
||||
title="Source",
|
||||
@@ -1244,7 +1241,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
||||
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||
)
|
||||
|
||||
assign_perm("change_document", source_owner, target_doc)
|
||||
grant_object(source_owner, target_doc, "change_document")
|
||||
self.client.force_authenticate(user=source_owner)
|
||||
|
||||
resp = self.client.patch(
|
||||
@@ -1337,7 +1334,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(results[0]["document_count"], 1)
|
||||
|
||||
# Test as user without access to the document
|
||||
non_superuser = User.objects.create_user(username="non_superuser")
|
||||
non_superuser = UserFactory(username="non_superuser")
|
||||
non_superuser.user_permissions.add(
|
||||
*Permission.objects.exclude(codename="view_global_statistics"),
|
||||
)
|
||||
|
||||
@@ -5,8 +5,6 @@ from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
|
||||
from auditlog.models import LogEntry # type: ignore[import-untyped]
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase as DjangoTestCase
|
||||
@@ -18,10 +16,12 @@ from documents.data_models import DocumentSource
|
||||
from documents.filters import EffectiveContentFilter
|
||||
from documents.filters import TitleContentFilter
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
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
|
||||
from paperless_testing.permissions import grant_global
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -31,7 +31,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def _make_pdf_upload(self, name: str = "version.pdf") -> SimpleUploadedFile:
|
||||
@@ -89,11 +89,9 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_root_endpoint_returns_403_when_user_lacks_permission(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
viewer = User.objects.create_user(username="viewer")
|
||||
viewer.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
viewer = UserFactory(username="viewer")
|
||||
grant_global(viewer, "view_document")
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
@@ -283,11 +281,9 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(mock_backend.add_or_update.call_args[0][0].id, root.id)
|
||||
|
||||
def test_delete_version_returns_403_without_permission(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
other = User.objects.create_user(username="other")
|
||||
other.user_permissions.add(
|
||||
Permission.objects.get(codename="delete_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
other = UserFactory(username="other")
|
||||
grant_global(other, "delete_document")
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
@@ -371,11 +367,9 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
self.assertTrue(resp.data["is_root"])
|
||||
|
||||
def test_update_version_label_returns_403_without_permission(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
other = User.objects.create_user(username="other")
|
||||
other.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
other = UserFactory(username="other")
|
||||
grant_global(other, "change_document")
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
@@ -521,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}",
|
||||
@@ -553,11 +549,9 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_metadata_returns_403_when_user_lacks_permission(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
other = User.objects.create_user(username="other")
|
||||
other.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
other = UserFactory(username="other")
|
||||
grant_global(other, "view_document")
|
||||
doc = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
@@ -581,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/",
|
||||
@@ -617,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/",
|
||||
@@ -642,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/",
|
||||
@@ -653,8 +647,8 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def test_update_version_returns_403_without_permission(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
other = User.objects.create_user(username="other")
|
||||
owner = UserFactory(username="owner")
|
||||
other = UserFactory(username="other")
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
@@ -672,8 +666,8 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_update_version_requires_global_change_permission(self) -> None:
|
||||
user = User.objects.create_user(username="add-only")
|
||||
user.user_permissions.add(Permission.objects.get(codename="add_document"))
|
||||
user = UserFactory(username="add-only")
|
||||
grant_global(user, "add_document")
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
@@ -681,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()},
|
||||
@@ -978,7 +972,7 @@ class TestVersionAwareFilters(DjangoTestCase):
|
||||
superseded content -- selecting documents the list view, filtered by
|
||||
the same term, does not show.
|
||||
"""
|
||||
user = User.objects.create_superuser(username="bulk_selection")
|
||||
user = UserFactory(username="bulk_selection", superuser=True)
|
||||
|
||||
selected = DocumentSelectionMixin()._resolve_document_ids(
|
||||
user=user,
|
||||
@@ -1005,7 +999,7 @@ class TestBulkSelectionExcludesVersions(DjangoTestCase):
|
||||
"Select all matching" reconstructs the document list, which never
|
||||
contains version documents as rows of their own.
|
||||
"""
|
||||
user = User.objects.create_superuser(username="bulk_versions")
|
||||
user = UserFactory(username="bulk_versions", superuser=True)
|
||||
root = Document.objects.create(
|
||||
title="shared-title root",
|
||||
checksum="bulk-root",
|
||||
|
||||
@@ -23,7 +23,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db import DataError
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -48,18 +47,22 @@ from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.signals.handlers import run_workflows
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.utils import ConsumeTaskMixin
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.http import read_streaming_response
|
||||
from paperless_testing.permissions import grant_all_global
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
cache.clear()
|
||||
|
||||
@@ -357,10 +360,10 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
with Path(filename).open("wb") as f:
|
||||
f.write(content)
|
||||
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
user2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
user1 = UserFactory(username="test1")
|
||||
user2 = UserFactory(username="test2")
|
||||
grant_global(user1, "view_document")
|
||||
grant_global(user2, "view_document")
|
||||
|
||||
self.client.force_authenticate(user2)
|
||||
|
||||
@@ -383,7 +386,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
response = self.client.get(f"/api/documents/{doc.pk}/thumb/")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
assign_perm("view_document", user2, doc)
|
||||
grant_object(user2, doc, "view_document")
|
||||
|
||||
response = self.client.get(f"/api/documents/{doc.pk}/download/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -760,8 +763,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
- History is returned
|
||||
"""
|
||||
# No auditlog permissions
|
||||
user = User.objects.create_user(username="test")
|
||||
user.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
user = UserFactory(username="test")
|
||||
grant_global(user, "view_document")
|
||||
self.client.force_authenticate(user=user)
|
||||
doc = Document.objects.create(
|
||||
title="First title",
|
||||
@@ -776,7 +779,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
# superuser
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2 = UserFactory(username="test2")
|
||||
doc2 = Document.objects.create(
|
||||
title="Second title",
|
||||
checksum="456",
|
||||
@@ -1073,11 +1076,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
self.assertEqual(response.data["duplicate_documents"], [])
|
||||
|
||||
def test_has_duplicates_filter_respects_document_permissions(self) -> None:
|
||||
owner = User.objects.create_user(username="duplicate-owner")
|
||||
requester = User.objects.create_user(username="duplicate-requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="duplicate-owner")
|
||||
requester = UserFactory(username="duplicate-requester")
|
||||
grant_global(requester, "view_document")
|
||||
visible_document = Document.objects.create(
|
||||
title="visible document",
|
||||
checksum="permission-match",
|
||||
@@ -1096,7 +1097,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
[document["id"] for document in response.data["results"]],
|
||||
)
|
||||
|
||||
assign_perm("view_document", requester, hidden_duplicate)
|
||||
grant_object(requester, hidden_duplicate, "view_document")
|
||||
response = self.client.get("/api/documents/?has_duplicates=true")
|
||||
self.assertIn(
|
||||
visible_document.id,
|
||||
@@ -1317,10 +1318,10 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- Owner filters work correctly but still respect permissions
|
||||
"""
|
||||
u1 = User.objects.create_user("user1")
|
||||
u2 = User.objects.create_user("user2")
|
||||
u1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
u2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
u1 = UserFactory(username="user1")
|
||||
u2 = UserFactory(username="user2")
|
||||
grant_global(u1, "view_document")
|
||||
grant_global(u2, "view_document")
|
||||
|
||||
u1_doc1 = Document.objects.create(
|
||||
title="none1",
|
||||
@@ -1353,7 +1354,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=u1)
|
||||
assign_perm("view_document", u1, u2_doc2)
|
||||
grant_object(u1, u2_doc2, "view_document")
|
||||
|
||||
# Will not show any u1 docs or u2_doc1 which isn't shared
|
||||
response = self.client.get(f"/api/documents/?owner__id__none={u1.id}")
|
||||
@@ -1400,7 +1401,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
[u1_doc1.id, u1_doc2.id, u2_doc2.id],
|
||||
)
|
||||
|
||||
assign_perm("view_document", u2, u1_doc1)
|
||||
grant_object(u2, u1_doc1, "view_document")
|
||||
|
||||
# Will show only documents shared by user
|
||||
response = self.client.get(f"/api/documents/?shared_by__id={u1.id}")
|
||||
@@ -1424,8 +1425,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
- The document is returned exactly once, not once per permission path
|
||||
(regression test for https://github.com/paperless-ngx/paperless-ngx/issues/13331)
|
||||
"""
|
||||
user = User.objects.create_user("user1")
|
||||
user.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
user = UserFactory(username="user1")
|
||||
grant_global(user, "view_document")
|
||||
group = Group.objects.create(name="group1")
|
||||
user.groups.add(group)
|
||||
|
||||
@@ -1433,7 +1434,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
tag2 = TagFactory()
|
||||
doc = DocumentFactory(title="shared", owner=user)
|
||||
doc.tags.add(tag1, tag2)
|
||||
assign_perm("view_document", group, doc)
|
||||
grant_object(group, doc, "view_document")
|
||||
|
||||
self.client.force_authenticate(user=user)
|
||||
response = self.client.get(
|
||||
@@ -1452,11 +1453,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- The document does not appear in their results
|
||||
"""
|
||||
owner = User.objects.create_user("owner1")
|
||||
stranger = User.objects.create_user("stranger1")
|
||||
stranger.user_permissions.add(
|
||||
*Permission.objects.filter(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner1")
|
||||
stranger = UserFactory(username="stranger1")
|
||||
grant_global(stranger, "view_document")
|
||||
|
||||
DocumentFactory(title="private", owner=owner)
|
||||
|
||||
@@ -1474,17 +1473,17 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- Only the group member sees the document
|
||||
"""
|
||||
owner = User.objects.create_user("owner2")
|
||||
member = User.objects.create_user("member1")
|
||||
non_member = User.objects.create_user("nonmember1")
|
||||
owner = UserFactory(username="owner2")
|
||||
member = UserFactory(username="member1")
|
||||
non_member = UserFactory(username="nonmember1")
|
||||
for u in (member, non_member):
|
||||
u.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
grant_global(u, "view_document")
|
||||
|
||||
group = Group.objects.create(name="group2")
|
||||
member.groups.add(group)
|
||||
|
||||
doc = DocumentFactory(title="shared2", owner=owner)
|
||||
assign_perm("view_document", group, doc)
|
||||
grant_object(group, doc, "view_document")
|
||||
|
||||
self.client.force_authenticate(user=member)
|
||||
response = self.client.get("/api/documents/")
|
||||
@@ -1785,8 +1784,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- Statistics only include inbox counts for tags accessible by the user
|
||||
"""
|
||||
u1 = User.objects.create_user("user1")
|
||||
u2 = User.objects.create_user("user2")
|
||||
u1 = UserFactory(username="user1")
|
||||
u2 = UserFactory(username="user2")
|
||||
inbox_tag_u1 = Tag.objects.create(name="inbox_u1", is_inbox_tag=True, owner=u1)
|
||||
Tag.objects.create(name="inbox_u2", is_inbox_tag=True, owner=u2)
|
||||
doc_u1 = Document.objects.create(
|
||||
@@ -1816,11 +1815,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
self.assertEqual(response.data["documents_inbox"], 0)
|
||||
|
||||
def test_statistics_with_statistics_permission(self) -> None:
|
||||
owner = User.objects.create_user("owner")
|
||||
stats_user = User.objects.create_user("stats-user")
|
||||
stats_user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_global_statistics"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
stats_user = UserFactory(username="stats-user")
|
||||
grant_global(stats_user, "view_global_statistics")
|
||||
|
||||
inbox_tag = Tag.objects.create(
|
||||
name="stats_inbox",
|
||||
@@ -1986,7 +1983,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
|
||||
def test_upload_insufficient_permissions(self) -> None:
|
||||
self.client.force_authenticate(user=User.objects.create_user("testuser2"))
|
||||
self.client.force_authenticate(user=UserFactory(username="testuser2"))
|
||||
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
@@ -2608,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,
|
||||
@@ -2631,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,
|
||||
@@ -2666,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,
|
||||
@@ -2757,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,
|
||||
@@ -2782,9 +2779,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
mock_get_date_parser.assert_not_called()
|
||||
|
||||
def test_saved_views(self) -> None:
|
||||
u1 = User.objects.create_user("user1")
|
||||
u2 = User.objects.create_user("user2")
|
||||
u3 = User.objects.create_user("user3")
|
||||
u1 = UserFactory(username="user1")
|
||||
u2 = UserFactory(username="user2")
|
||||
u3 = UserFactory(username="user3")
|
||||
|
||||
view_perm = Permission.objects.get(codename="view_savedview")
|
||||
change_perm = Permission.objects.get(codename="change_savedview")
|
||||
@@ -2807,9 +2804,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
sort_field="",
|
||||
)
|
||||
|
||||
assign_perm("view_savedview", u1, v2)
|
||||
assign_perm("change_savedview", u1, v2)
|
||||
assign_perm("view_savedview", u1, v3)
|
||||
grant_object(u1, v2, "view_savedview")
|
||||
grant_object(u1, v2, "change_savedview")
|
||||
grant_object(u1, v3, "view_savedview")
|
||||
|
||||
self.client.force_authenticate(user=u1)
|
||||
|
||||
@@ -3064,7 +3061,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
self.assertListEqual(saved_view_settings["sidebar_views_visible_ids"], [v2.id])
|
||||
|
||||
def test_saved_view_create_update_patch(self) -> None:
|
||||
User.objects.create_user("user1")
|
||||
UserFactory(username="user1")
|
||||
|
||||
view = {
|
||||
"name": "test",
|
||||
@@ -3127,7 +3124,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
- Display options are updated
|
||||
- Display fields are validated
|
||||
"""
|
||||
User.objects.create_user("user1")
|
||||
UserFactory(username="user1")
|
||||
|
||||
view = {
|
||||
"name": "test",
|
||||
@@ -3568,11 +3565,11 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- Notes are neither created nor deleted
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2 = UserFactory(username="test2")
|
||||
user2.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
@@ -3592,7 +3589,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
self.assertEqual(resp.content, b"Insufficient permissions to view notes")
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
assign_perm("view_document", user1, doc)
|
||||
grant_object(user1, doc, "view_document")
|
||||
|
||||
resp = self.client.post(
|
||||
f"/api/documents/{doc.pk}/notes/",
|
||||
@@ -3616,12 +3613,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_notes_require_global_document_permissions(self) -> None:
|
||||
user = User.objects.create_user(username="note_editor")
|
||||
user.user_permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_note", "add_note", "delete_note"],
|
||||
),
|
||||
)
|
||||
user = UserFactory(username="note_editor")
|
||||
grant_global(user, "view_note", "add_note", "delete_note")
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
@@ -3634,9 +3627,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_global(user, "view_document")
|
||||
user = User.objects.get(pk=user.pk)
|
||||
self.client.force_authenticate(user)
|
||||
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||
@@ -3648,9 +3639,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
)
|
||||
grant_global(user, "change_document")
|
||||
user = User.objects.get(pk=user.pk)
|
||||
self.client.force_authenticate(user)
|
||||
response = self.client.post(
|
||||
@@ -3797,12 +3786,12 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
- Unique items are created
|
||||
- Non-unique items are not allowed
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="add_tag"))
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_global(user1, "add_tag")
|
||||
user1.save()
|
||||
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2.user_permissions.add(*Permission.objects.filter(codename="add_tag"))
|
||||
user2 = UserFactory(username="test2")
|
||||
grant_global(user2, "add_tag")
|
||||
user2.save()
|
||||
|
||||
# User 1 creates tag 1 owned by user 1 by default
|
||||
@@ -3857,12 +3846,12 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
- Unique items are created
|
||||
- Non-unique items are not allowed on update
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="change_tag"))
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_global(user1, "change_tag")
|
||||
user1.save()
|
||||
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2.user_permissions.add(*Permission.objects.filter(codename="change_tag"))
|
||||
user2 = UserFactory(username="test2")
|
||||
grant_global(user2, "change_tag")
|
||||
user2.save()
|
||||
|
||||
# Create name tag 1 owned by user 1
|
||||
@@ -3993,11 +3982,11 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- Links only shown if user has permissions
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2 = UserFactory(username="test2")
|
||||
user2.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
@@ -4017,7 +4006,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
self.assertEqual(resp.content, b"Insufficient permissions to add share link")
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
assign_perm("change_document", user1, doc)
|
||||
grant_object(user1, doc, "change_document")
|
||||
|
||||
resp = self.client.get(
|
||||
f"/api/documents/{doc.pk}/share_links/",
|
||||
@@ -4034,11 +4023,11 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- Share link creation is denied until view permission is granted
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="add_sharelink"))
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_global(user1, "add_sharelink")
|
||||
user1.save()
|
||||
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2 = UserFactory(username="test2")
|
||||
user2.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
@@ -4060,7 +4049,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
assign_perm("view_document", user1, doc)
|
||||
grant_object(user1, doc, "view_document")
|
||||
|
||||
create_resp = self.client.post(
|
||||
"/api/share_links/",
|
||||
@@ -4072,9 +4061,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
user1.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_global(user1, "view_document")
|
||||
user1 = User.objects.get(pk=user1.pk)
|
||||
self.client.force_authenticate(user1)
|
||||
create_resp = self.client.post(
|
||||
@@ -4097,11 +4084,11 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- ASN +1 from user2's doc is returned for user1
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2 = UserFactory(username="test2")
|
||||
user2.save()
|
||||
|
||||
doc1 = Document.objects.create(
|
||||
@@ -4141,8 +4128,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- ASN 1 is returned
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
|
||||
doc1 = Document.objects.create(
|
||||
@@ -4170,7 +4157,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- Explicit error is returned
|
||||
"""
|
||||
user1 = User.objects.create_superuser(username="test1")
|
||||
user1 = UserFactory(username="test1", superuser=True)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
@@ -4348,8 +4335,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
THEN:
|
||||
- Error response is returned
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_all_global(user1)
|
||||
user1.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
@@ -4454,7 +4441,7 @@ class TestDocumentApiTagColors(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
@@ -4534,7 +4521,7 @@ class TestDocumentApiCustomFieldsSorting(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.doc1 = Document.objects.create(
|
||||
|
||||
@@ -2,16 +2,16 @@ import json
|
||||
import shutil
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.test import override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
|
||||
|
||||
class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
@@ -20,7 +20,7 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.doc1 = Document.objects.create(
|
||||
@@ -303,8 +303,8 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
THEN:
|
||||
- Forbidden response is returned
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_global(user1, "view_document")
|
||||
|
||||
doc_owned = Document.objects.create(
|
||||
title="owned_doc",
|
||||
@@ -338,8 +338,8 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
THEN:
|
||||
- Request succeeds
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
user1 = UserFactory(username="test1")
|
||||
grant_global(user1, "view_document")
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from collections.abc import Callable
|
||||
from datetime import date
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import CustomField
|
||||
@@ -12,8 +11,9 @@ 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.tests.utils import DirectoriesMixin
|
||||
from documents.serialisers.documents import DocumentSerializer
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
|
||||
class DocumentWrapper:
|
||||
@@ -35,7 +35,7 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Create one custom field per type. The fields are called f"{type}_field".
|
||||
|
||||
@@ -3,12 +3,10 @@ import json
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test import override_settings
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_groups_with_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from rest_framework import status
|
||||
@@ -21,14 +19,17 @@ from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class TestApiObjects(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
self.tag1 = Tag.objects.create(name="t1", is_inbox_tag=True)
|
||||
@@ -153,7 +154,7 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
||||
|
||||
# A newer document owned by another user must not leak through the
|
||||
# aggregate for a non-superuser who cannot view it
|
||||
other = User.objects.create_user(username="other")
|
||||
other = UserFactory(username="other")
|
||||
Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
correspondent=self.c1,
|
||||
@@ -162,10 +163,8 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
||||
owner=other,
|
||||
)
|
||||
|
||||
user = User.objects.create_user(username="regular")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_correspondent"),
|
||||
)
|
||||
user = UserFactory(username="regular")
|
||||
grant_global(user, "view_correspondent")
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.get("/api/correspondents/?last_correspondence=true")
|
||||
@@ -200,7 +199,7 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
|
||||
@@ -455,11 +454,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.data, "folder/Something.pdf")
|
||||
|
||||
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
unprivileged = User.objects.create_user(username="unprivileged")
|
||||
unprivileged.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
unprivileged = UserFactory(username="unprivileged")
|
||||
grant_global(unprivileged, "view_document")
|
||||
document = Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
owner=owner,
|
||||
@@ -481,15 +478,15 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
self.assertIn("document", response.data)
|
||||
|
||||
def test_test_storage_path_allows_shared_document_view_permission(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
viewer = User.objects.create_user(username="viewer")
|
||||
owner = UserFactory(username="owner")
|
||||
viewer = UserFactory(username="viewer")
|
||||
document = Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
owner=owner,
|
||||
title="Shared",
|
||||
checksum="123",
|
||||
)
|
||||
assign_perm("view_document", viewer, document)
|
||||
grant_object(viewer, document, "view_document")
|
||||
|
||||
self.client.force_authenticate(user=viewer)
|
||||
response = self.client.post(
|
||||
@@ -504,9 +501,7 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
viewer.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_global(viewer, "view_document")
|
||||
viewer = User.objects.get(pk=viewer.pk)
|
||||
self.client.force_authenticate(user=viewer)
|
||||
response = self.client.post(
|
||||
@@ -545,14 +540,12 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
def test_test_storage_path_exposes_basic_document_context_but_not_sensitive_owner_data(
|
||||
self,
|
||||
) -> None:
|
||||
owner = User.objects.create_user(
|
||||
owner = UserFactory(
|
||||
username="owner",
|
||||
password="password",
|
||||
email="owner@example.com",
|
||||
)
|
||||
owner.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_global(owner, "view_document")
|
||||
document = Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
owner=owner,
|
||||
@@ -614,8 +607,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
def test_test_storage_path_includes_related_objects_for_visible_document(
|
||||
self,
|
||||
) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
viewer = User.objects.create_user(username="viewer")
|
||||
owner = UserFactory(username="owner")
|
||||
viewer = UserFactory(username="viewer")
|
||||
private_correspondent = Correspondent.objects.create(
|
||||
name="Private Correspondent",
|
||||
owner=owner,
|
||||
@@ -627,10 +620,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
title="Document",
|
||||
checksum="123",
|
||||
)
|
||||
assign_perm("view_document", viewer, document)
|
||||
viewer.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_object(viewer, document, "view_document")
|
||||
grant_global(viewer, "view_document")
|
||||
|
||||
self.client.force_authenticate(user=viewer)
|
||||
response = self.client.post(
|
||||
@@ -662,7 +653,7 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.data, "Private Correspondent.pdf")
|
||||
|
||||
def test_test_storage_path_superuser_can_view_private_related_objects(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
owner = UserFactory(username="owner")
|
||||
private_correspondent = Correspondent.objects.create(
|
||||
name="Private Correspondent",
|
||||
owner=owner,
|
||||
@@ -693,8 +684,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
def test_test_storage_path_includes_doc_type_storage_path_and_tags(
|
||||
self,
|
||||
) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
viewer = User.objects.create_user(username="viewer")
|
||||
owner = UserFactory(username="owner")
|
||||
viewer = UserFactory(username="viewer")
|
||||
private_document_type = DocumentType.objects.create(
|
||||
name="Private Type",
|
||||
owner=owner,
|
||||
@@ -717,10 +708,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
checksum="123",
|
||||
)
|
||||
document.tags.add(private_tag)
|
||||
assign_perm("view_document", viewer, document)
|
||||
viewer.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_object(viewer, document, "view_document")
|
||||
grant_global(viewer, "view_document")
|
||||
|
||||
self.client.force_authenticate(user=viewer)
|
||||
response = self.client.post(
|
||||
@@ -756,8 +745,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
def test_test_storage_path_includes_custom_fields_for_visible_document(
|
||||
self,
|
||||
) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
viewer = User.objects.create_user(username="viewer")
|
||||
owner = UserFactory(username="owner")
|
||||
viewer = UserFactory(username="viewer")
|
||||
document = Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
owner=owner,
|
||||
@@ -773,10 +762,8 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
field=custom_field,
|
||||
value_int=42,
|
||||
)
|
||||
assign_perm("view_document", viewer, document)
|
||||
viewer.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_object(viewer, document, "view_document")
|
||||
grant_global(viewer, "view_document")
|
||||
|
||||
self.client.force_authenticate(user=viewer)
|
||||
response = self.client.post(
|
||||
@@ -798,7 +785,7 @@ class TestBulkEditObjects(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.temp_admin = User.objects.create_superuser(username="temp_admin")
|
||||
self.temp_admin = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.temp_admin)
|
||||
|
||||
self.t1 = Tag.objects.create(name="t1")
|
||||
@@ -1030,9 +1017,7 @@ class TestBulkEditObjects(APITestCase):
|
||||
THEN:
|
||||
- User is able to delete objects
|
||||
"""
|
||||
self.user1.user_permissions.add(
|
||||
*Permission.objects.filter(codename="delete_tag"),
|
||||
)
|
||||
grant_global(self.user1, "delete_tag")
|
||||
self.user1.save()
|
||||
self.client.force_authenticate(user=self.user1)
|
||||
|
||||
@@ -1062,9 +1047,7 @@ class TestBulkEditObjects(APITestCase):
|
||||
self.t2.owner = User.objects.get(username="temp_admin")
|
||||
self.t2.save()
|
||||
|
||||
self.user1.user_permissions.add(
|
||||
*Permission.objects.filter(codename="delete_tag"),
|
||||
)
|
||||
grant_global(self.user1, "delete_tag")
|
||||
self.user1.save()
|
||||
self.client.force_authenticate(user=self.user1)
|
||||
|
||||
@@ -1097,9 +1080,7 @@ class TestBulkEditObjects(APITestCase):
|
||||
self.t2.owner = User.objects.get(username="temp_admin")
|
||||
self.t2.save()
|
||||
|
||||
self.user1.user_permissions.add(
|
||||
*Permission.objects.filter(codename="delete_tag"),
|
||||
)
|
||||
grant_global(self.user1, "delete_tag")
|
||||
self.user1.save()
|
||||
self.client.force_authenticate(user=self.user1)
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ from unittest import mock
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.totp.internal import auth as totp_auth
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from rest_framework import status
|
||||
@@ -19,7 +17,11 @@ from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_all_global
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
@@ -93,14 +95,14 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
self.assertNotIn("X-Version", response)
|
||||
|
||||
def test_api_version_with_auth(self) -> None:
|
||||
user = User.objects.create_superuser(username="test")
|
||||
user = UserFactory(username="test", superuser=True)
|
||||
self.client.force_authenticate(user)
|
||||
response = self.client.get("/api/documents/")
|
||||
self.assertIn("X-Api-Version", response)
|
||||
self.assertIn("X-Version", response)
|
||||
|
||||
def test_api_insufficient_permissions(self) -> None:
|
||||
user = User.objects.create_user(username="test")
|
||||
user = UserFactory(username="test")
|
||||
self.client.force_authenticate(user)
|
||||
|
||||
Document.objects.create(title="Test")
|
||||
@@ -137,8 +139,8 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
def test_api_sufficient_permissions(self) -> None:
|
||||
user = User.objects.create_user(username="test")
|
||||
user.user_permissions.add(*Permission.objects.all())
|
||||
user = UserFactory(username="test")
|
||||
grant_all_global(user)
|
||||
user.is_staff = True
|
||||
self.client.force_authenticate(user)
|
||||
|
||||
@@ -166,9 +168,9 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
def test_api_get_object_permissions(self) -> None:
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
user1 = UserFactory(username="test1")
|
||||
user2 = UserFactory(username="test2")
|
||||
grant_global(user1, "view_document")
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
self.assertEqual(
|
||||
@@ -205,7 +207,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Object created with current user as owner
|
||||
"""
|
||||
user1 = User.objects.create_superuser(username="user1")
|
||||
user1 = UserFactory(username="user1", superuser=True)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
@@ -234,7 +236,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Object created with no owner
|
||||
"""
|
||||
user1 = User.objects.create_superuser(username="user1")
|
||||
user1 = UserFactory(username="user1", superuser=True)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
@@ -265,7 +267,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Object permissions are set appropriately
|
||||
"""
|
||||
user1 = User.objects.create_superuser(username="user1")
|
||||
user1 = UserFactory(username="user1", superuser=True)
|
||||
user2 = User.objects.create(username="user2")
|
||||
group1 = Group.objects.create(name="group1")
|
||||
|
||||
@@ -313,7 +315,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Object permissions are set appropriately
|
||||
"""
|
||||
user1 = User.objects.create_superuser(username="user1")
|
||||
user1 = UserFactory(username="user1", superuser=True)
|
||||
user2 = User.objects.create(username="user2")
|
||||
group1 = Group.objects.create(name="group1")
|
||||
|
||||
@@ -363,7 +365,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
mime_type="application/pdf",
|
||||
content="this is a document",
|
||||
)
|
||||
user1 = User.objects.create_superuser(username="user1")
|
||||
user1 = UserFactory(username="user1", superuser=True)
|
||||
user2 = User.objects.create(username="user2")
|
||||
group1 = Group.objects.create(name="group1")
|
||||
|
||||
@@ -413,16 +415,16 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
mime_type="application/pdf",
|
||||
content="this is a document",
|
||||
)
|
||||
user1 = User.objects.create_superuser(username="user1")
|
||||
user1 = UserFactory(username="user1", superuser=True)
|
||||
user2 = User.objects.create(username="user2")
|
||||
group1 = Group.objects.create(name="group1")
|
||||
doc.owner = user1
|
||||
doc.save()
|
||||
|
||||
assign_perm("view_document", user2, doc)
|
||||
assign_perm("change_document", user2, doc)
|
||||
assign_perm("view_document", group1, doc)
|
||||
assign_perm("change_document", group1, doc)
|
||||
grant_object(user2, doc, "view_document")
|
||||
grant_object(user2, doc, "change_document")
|
||||
grant_object(group1, doc, "view_document")
|
||||
grant_object(group1, doc, "change_document")
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
@@ -446,11 +448,9 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
self.assertIn("change_document", get_perms(group1, doc))
|
||||
|
||||
def test_document_permissions_change_requires_owner(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
editor = User.objects.create_user(username="editor")
|
||||
editor.user_permissions.add(
|
||||
*Permission.objects.all(),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
editor = UserFactory(username="editor")
|
||||
grant_all_global(editor)
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="Ownered doc",
|
||||
@@ -460,8 +460,8 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
assign_perm("view_document", editor, doc)
|
||||
assign_perm("change_document", editor, doc)
|
||||
grant_object(editor, doc, "view_document")
|
||||
grant_object(editor, doc, "change_document")
|
||||
|
||||
self.client.force_authenticate(editor)
|
||||
response = self.client.patch(
|
||||
@@ -499,9 +499,9 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_dynamic_permissions_fields(self) -> None:
|
||||
user1 = User.objects.create_user(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
user2 = User.objects.create_user(username="user2")
|
||||
user1 = UserFactory(username="user1")
|
||||
grant_global(user1, "view_document")
|
||||
user2 = UserFactory(username="user2")
|
||||
|
||||
Document.objects.create(title="Test", content="content 1", checksum="1")
|
||||
doc2 = Document.objects.create(
|
||||
@@ -523,10 +523,10 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
owner=user1,
|
||||
)
|
||||
|
||||
assign_perm("view_document", user1, doc2)
|
||||
assign_perm("view_document", user1, doc3)
|
||||
assign_perm("change_document", user1, doc3)
|
||||
assign_perm("view_document", user2, doc4)
|
||||
grant_object(user1, doc2, "view_document")
|
||||
grant_object(user1, doc3, "view_document")
|
||||
grant_object(user1, doc3, "change_document")
|
||||
grant_object(user2, doc4, "view_document")
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
@@ -574,8 +574,8 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
owned by someone else with no explicit guardian grant -- mirrors
|
||||
guardian's own ObjectPermissionChecker.has_perm() superuser shortcut.
|
||||
"""
|
||||
superuser = User.objects.create_superuser(username="admin")
|
||||
other_user = User.objects.create_user(username="user2")
|
||||
superuser = UserFactory(username="admin", superuser=True)
|
||||
other_user = UserFactory(username="user2")
|
||||
Document.objects.create(
|
||||
title="Test",
|
||||
content="content",
|
||||
@@ -602,7 +602,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- MFA required error is returned
|
||||
"""
|
||||
user1 = User.objects.create_user(username="user1")
|
||||
user1 = UserFactory(username="user1")
|
||||
user1.set_password("password")
|
||||
user1.save()
|
||||
|
||||
@@ -626,7 +626,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- MFA code is required
|
||||
"""
|
||||
user1 = User.objects.create_user(username="user1")
|
||||
user1 = UserFactory(username="user1")
|
||||
user1.set_password("password")
|
||||
user1.save()
|
||||
|
||||
@@ -688,7 +688,7 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_get_users(self) -> None:
|
||||
@@ -858,10 +858,8 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
regular_user = User.objects.create_user(username="regular_user")
|
||||
regular_user.user_permissions.add(
|
||||
*Permission.objects.all(),
|
||||
)
|
||||
regular_user = UserFactory(username="regular_user")
|
||||
grant_all_global(regular_user)
|
||||
self.client.force_authenticate(regular_user)
|
||||
Authenticator.objects.create(
|
||||
user=user1,
|
||||
@@ -885,9 +883,9 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
||||
- Only superusers can change superuser status
|
||||
"""
|
||||
|
||||
user1 = User.objects.create_user(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
user2 = User.objects.create_superuser(username="user2")
|
||||
user1 = UserFactory(username="user1")
|
||||
grant_all_global(user1)
|
||||
user2 = UserFactory(username="user2", superuser=True)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
@@ -972,9 +970,9 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
||||
- Only superusers can change staff status
|
||||
"""
|
||||
|
||||
user1 = User.objects.create_user(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
user2 = User.objects.create_superuser(username="user2")
|
||||
user1 = UserFactory(username="user1")
|
||||
grant_all_global(user1)
|
||||
user2 = UserFactory(username="user2", superuser=True)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
@@ -1027,7 +1025,7 @@ class TestApiGroup(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_get_groups(self) -> None:
|
||||
@@ -1128,7 +1126,7 @@ class TestBulkEditObjectPermissions(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.temp_admin = User.objects.create_superuser(username="temp_admin")
|
||||
self.temp_admin = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.temp_admin)
|
||||
|
||||
self.t1 = Tag.objects.create(name="t1")
|
||||
@@ -1276,7 +1274,7 @@ class TestBulkEditObjectPermissions(APITestCase):
|
||||
},
|
||||
}
|
||||
|
||||
assign_perm("view_tag", self.user3, self.t1)
|
||||
grant_object(self.user3, self.t1, "view_tag")
|
||||
self.t1.owner = self.user3
|
||||
self.t1.save()
|
||||
|
||||
@@ -1373,13 +1371,9 @@ class TestBulkEditObjectPermissions(APITestCase):
|
||||
"""
|
||||
self.t1.owner = self.user2
|
||||
self.t1.save()
|
||||
assign_perm("view_tag", self.user1, self.t1)
|
||||
assign_perm("change_tag", self.user1, self.t1)
|
||||
self.user1.user_permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_tag", "change_tag"],
|
||||
),
|
||||
)
|
||||
grant_object(self.user1, self.t1, "view_tag")
|
||||
grant_object(self.user1, self.t1, "change_tag")
|
||||
grant_global(self.user1, "view_tag", "change_tag")
|
||||
user1 = User.objects.get(pk=self.user1.pk)
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
@@ -1426,13 +1420,9 @@ class TestBulkEditObjectPermissions(APITestCase):
|
||||
"""
|
||||
owned = Tag.objects.create(name="owned", owner=self.user1)
|
||||
shared = Tag.objects.create(name="shared", owner=self.user2)
|
||||
assign_perm("view_tag", self.user1, shared)
|
||||
assign_perm("change_tag", self.user1, shared)
|
||||
self.user1.user_permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_tag", "change_tag"],
|
||||
),
|
||||
)
|
||||
grant_object(self.user1, shared, "view_tag")
|
||||
grant_object(self.user1, shared, "change_tag")
|
||||
grant_global(self.user1, "view_tag", "change_tag")
|
||||
user1 = User.objects.get(pk=self.user1.pk)
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
@@ -1473,14 +1463,10 @@ class TestBulkEditObjectPermissions(APITestCase):
|
||||
"""
|
||||
self.t1.owner = self.user2
|
||||
self.t1.save()
|
||||
assign_perm("view_tag", self.user1, self.t1)
|
||||
assign_perm("change_tag", self.user1, self.t1)
|
||||
assign_perm("delete_tag", self.user1, self.t1)
|
||||
self.user1.user_permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_tag", "change_tag", "delete_tag"],
|
||||
),
|
||||
)
|
||||
grant_object(self.user1, self.t1, "view_tag")
|
||||
grant_object(self.user1, self.t1, "change_tag")
|
||||
grant_object(self.user1, self.t1, "delete_tag")
|
||||
grant_global(self.user1, "view_tag", "change_tag", "delete_tag")
|
||||
user1 = User.objects.get(pk=self.user1.pk)
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
@@ -1585,7 +1571,7 @@ class TestBulkEditObjectPermissions(APITestCase):
|
||||
- Request succeeds and null is treated as an empty user list,
|
||||
so the existing view permission is removed
|
||||
"""
|
||||
assign_perm("view_tag", self.user1, self.t1)
|
||||
grant_object(self.user1, self.t1, "view_tag")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
@@ -1680,7 +1666,7 @@ class TestFullPermissionsFlag(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.admin = User.objects.create_superuser(username="admin")
|
||||
self.admin = UserFactory(username="admin", superuser=True)
|
||||
|
||||
def test_full_perms_flag(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
@@ -7,8 +9,11 @@ import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -18,22 +23,14 @@ def consume_file_mock():
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def directories(tmp_path, settings, _media_settings):
|
||||
scratch = tmp_path / "scratch"
|
||||
scratch.mkdir()
|
||||
settings.SCRATCH_DIR = scratch
|
||||
return scratch
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPostDocumentNFCNormalization:
|
||||
def test_nfd_filename_normalized_to_nfc(
|
||||
self,
|
||||
admin_client,
|
||||
admin_client: APIClient,
|
||||
consume_file_mock: mock.MagicMock,
|
||||
directories,
|
||||
):
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""Uploaded file with NFD filename must have its name stored as NFC."""
|
||||
nfd = unicodedata.normalize("NFD", "Rechnung März.pdf")
|
||||
nfc = unicodedata.normalize("NFC", "Rechnung März.pdf")
|
||||
@@ -69,10 +66,10 @@ class TestPostDocumentNFCNormalization:
|
||||
|
||||
def test_already_nfc_filename_unchanged(
|
||||
self,
|
||||
admin_client,
|
||||
admin_client: APIClient,
|
||||
consume_file_mock: mock.MagicMock,
|
||||
directories,
|
||||
):
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""Uploaded file with already-NFC filename must pass through unchanged."""
|
||||
nfc = unicodedata.normalize("NFC", "Invoice_2024.pdf")
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
|
||||
# see allauth.socialaccount.providers.openid.provider.OpenIDProvider
|
||||
@@ -55,23 +56,23 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(
|
||||
self.user = UserFactory(
|
||||
username="temp_admin",
|
||||
first_name="firstname",
|
||||
last_name="surname",
|
||||
superuser=True,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def setupSocialAccount(self) -> None:
|
||||
def setupSocialAccount(self) -> SocialAccount:
|
||||
SocialApp.objects.create(
|
||||
name="Keycloak",
|
||||
provider="openid_connect",
|
||||
provider_id="keycloak-test",
|
||||
)
|
||||
self.user.socialaccount_set.add(
|
||||
SocialAccount(uid="123456789", provider="keycloak-test"),
|
||||
bulk=False,
|
||||
)
|
||||
social_account = SocialAccount(uid="123456789", provider="keycloak-test")
|
||||
self.user.socialaccount_set.add(social_account, bulk=False)
|
||||
return social_account
|
||||
|
||||
def test_get_profile(self) -> None:
|
||||
"""
|
||||
@@ -109,19 +110,17 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Profile is returned with social accounts
|
||||
"""
|
||||
self.setupSocialAccount()
|
||||
social_account = self.setupSocialAccount()
|
||||
|
||||
openid_provider = (
|
||||
MockOpenIDConnectProvider(
|
||||
app=SocialApp.objects.get(provider_id="keycloak-test"),
|
||||
),
|
||||
openid_provider = MockOpenIDConnectProvider(
|
||||
app=SocialApp.objects.get(provider_id="keycloak-test"),
|
||||
)
|
||||
mock_list_providers.return_value = [
|
||||
openid_provider,
|
||||
]
|
||||
mock_get_provider_account.return_value = MockOpenIDConnectProviderAccount(
|
||||
mock_social_account_dict={
|
||||
"name": openid_provider[0].name,
|
||||
"name": openid_provider.name,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -133,7 +132,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
||||
response.data["social_accounts"],
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"id": social_account.pk,
|
||||
"provider": "keycloak-test",
|
||||
"name": "Keycloak",
|
||||
},
|
||||
@@ -150,7 +149,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Profile is returned with "Unknown App" as name
|
||||
"""
|
||||
self.setupSocialAccount()
|
||||
social_account = self.setupSocialAccount()
|
||||
|
||||
# Remove the social app
|
||||
SocialApp.objects.get(provider_id="keycloak-test").delete()
|
||||
@@ -163,7 +162,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
||||
response.data["social_accounts"],
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"id": social_account.pk,
|
||||
"provider": "keycloak-test",
|
||||
"name": "Unknown App",
|
||||
},
|
||||
@@ -401,7 +400,7 @@ class TestApiTOTPViews(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_get_totp(self) -> None:
|
||||
|
||||
@@ -7,10 +7,8 @@ import time_machine
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -27,10 +25,13 @@ from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.search import get_backend
|
||||
from documents.search import reset_backend
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
pytestmark = pytest.mark.search
|
||||
|
||||
@@ -40,7 +41,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
super().setUp()
|
||||
reset_backend()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
@@ -949,9 +950,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Terms only within docs user has access to are returned
|
||||
"""
|
||||
u1 = User.objects.create_user("user1")
|
||||
u2 = User.objects.create_user("user2")
|
||||
u1.user_permissions.add(Permission.objects.get(codename="view_document"))
|
||||
u1 = UserFactory(username="user1")
|
||||
u2 = UserFactory(username="user2")
|
||||
grant_global(u1, "view_document")
|
||||
|
||||
self.client.force_authenticate(user=u1)
|
||||
|
||||
@@ -991,7 +992,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, ["applebaum", "apples"])
|
||||
|
||||
assign_perm("view_document", u1, d3)
|
||||
grant_object(u1, d3, "view_document")
|
||||
backend.add_or_update(d3)
|
||||
|
||||
response = self.client.get("/api/search/autocomplete/?term=app")
|
||||
@@ -999,10 +1000,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.data, ["applebaum", "apples", "appletini"])
|
||||
|
||||
def test_search_autocomplete_group_revocation_is_immediate(self) -> None:
|
||||
user = User.objects.create_user("group-user")
|
||||
owner = User.objects.create_user("document-owner")
|
||||
user = UserFactory(username="group-user")
|
||||
owner = UserFactory(username="document-owner")
|
||||
group = Group.objects.create(name="temporary-viewers")
|
||||
user.user_permissions.add(Permission.objects.get(codename="view_document"))
|
||||
grant_global(user, "view_document")
|
||||
user.groups.add(group)
|
||||
|
||||
document = Document.objects.create(
|
||||
@@ -1011,7 +1012,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
checksum="group-revocation",
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", group, document)
|
||||
grant_object(group, document, "view_document")
|
||||
get_backend().add_or_update(document)
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
@@ -1091,11 +1092,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertIsNone(response.data["corrected_query"])
|
||||
|
||||
def test_search_spelling_suggestion_suppressed_for_private_terms(self) -> None:
|
||||
owner = User.objects.create_user("owner")
|
||||
attacker = User.objects.create_user("attacker")
|
||||
attacker.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
attacker = UserFactory(username="attacker")
|
||||
grant_global(attacker, "view_document")
|
||||
|
||||
backend = get_backend()
|
||||
for i in range(5):
|
||||
@@ -1222,11 +1221,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- The request is rejected
|
||||
"""
|
||||
owner = User.objects.create_user("owner")
|
||||
attacker = User.objects.create_user("attacker")
|
||||
attacker.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
attacker = UserFactory(username="attacker")
|
||||
grant_global(attacker, "view_document")
|
||||
|
||||
private_seed = Document.objects.create(
|
||||
title="private bank statement",
|
||||
@@ -1534,11 +1531,11 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
- Only owned docs are returned for regular users
|
||||
- All docs are returned for superuser
|
||||
"""
|
||||
superuser = User.objects.create_superuser("superuser")
|
||||
u1 = User.objects.create_user("user1")
|
||||
u2 = User.objects.create_user("user2")
|
||||
u1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
u2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
superuser = UserFactory(username="superuser", superuser=True)
|
||||
u1 = UserFactory(username="user1")
|
||||
u2 = UserFactory(username="user2")
|
||||
grant_global(u1, "view_document")
|
||||
grant_global(u2, "view_document")
|
||||
|
||||
Document.objects.create(checksum="1", content="test 1", owner=u1)
|
||||
Document.objects.create(checksum="2", content="test 2", owner=u2)
|
||||
@@ -1588,10 +1585,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Only docs with granted view permissions are returned
|
||||
"""
|
||||
u1 = User.objects.create_user("user1")
|
||||
u2 = User.objects.create_user("user2")
|
||||
u1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
u2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
u1 = UserFactory(username="user1")
|
||||
u2 = UserFactory(username="user2")
|
||||
grant_global(u1, "view_document")
|
||||
grant_global(u2, "view_document")
|
||||
|
||||
d1 = Document.objects.create(checksum="1", content="test 1", owner=u1)
|
||||
d2 = Document.objects.create(checksum="2", content="test 2", owner=u2)
|
||||
@@ -1616,9 +1613,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
r = self.client.get("/api/documents/?query=test&owner__isnull=true")
|
||||
self.assertEqual(r.data["count"], 1)
|
||||
|
||||
assign_perm("view_document", u1, d2)
|
||||
assign_perm("view_document", u1, d3)
|
||||
assign_perm("view_document", u2, d1)
|
||||
grant_object(u1, d2, "view_document")
|
||||
grant_object(u1, d3, "view_document")
|
||||
grant_object(u2, d1, "view_document")
|
||||
|
||||
backend.add_or_update(d1)
|
||||
backend.add_or_update(d2)
|
||||
@@ -1641,8 +1638,8 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(r.data["count"], 1)
|
||||
|
||||
def test_search_sorting(self) -> None:
|
||||
u1 = User.objects.create_user("user1")
|
||||
u2 = User.objects.create_user("user2")
|
||||
u1 = UserFactory(username="user1")
|
||||
u2 = UserFactory(username="user2")
|
||||
c1 = Correspondent.objects.create(name="corres Ax")
|
||||
c2 = Correspondent.objects.create(name="corres Cx")
|
||||
c3 = Correspondent.objects.create(name="corres Bx")
|
||||
@@ -1892,8 +1889,8 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
pk=5,
|
||||
)
|
||||
|
||||
user1 = User.objects.create_user("bank user1")
|
||||
user2 = User.objects.create_superuser("user2")
|
||||
user1 = UserFactory(username="bank user1")
|
||||
user2 = UserFactory(username="user2", superuser=True)
|
||||
group1 = Group.objects.create(name="bank group1")
|
||||
Group.objects.create(name="group2")
|
||||
|
||||
@@ -1925,7 +1922,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
sort_field="",
|
||||
owner=user2,
|
||||
)
|
||||
assign_perm("view_savedview", user1, shared_view)
|
||||
grant_object(user1, shared_view, "view_savedview")
|
||||
mail_account1 = MailAccount.objects.create(name="bank mail account 1")
|
||||
mail_account2 = MailAccount.objects.create(name="mail account 2")
|
||||
mail_rule1 = MailRule.objects.create(
|
||||
@@ -2018,12 +2015,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(returned.get(root.id), "latest content")
|
||||
|
||||
def test_global_search_filters_owned_mail_objects(self) -> None:
|
||||
user1 = User.objects.create_user("mail-search-user")
|
||||
user2 = User.objects.create_user("other-mail-search-user")
|
||||
user1.user_permissions.add(
|
||||
Permission.objects.get(codename="view_mailaccount"),
|
||||
Permission.objects.get(codename="view_mailrule"),
|
||||
)
|
||||
user1 = UserFactory(username="mail-search-user")
|
||||
user2 = UserFactory(username="other-mail-search-user")
|
||||
grant_global(user1, "view_mailaccount", "view_mailrule")
|
||||
|
||||
own_account = MailAccount.objects.create(
|
||||
name="bank owned account",
|
||||
|
||||
@@ -33,7 +33,7 @@ class TestSearchQueryErrorStillBecomesA400:
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -68,7 +68,7 @@ class TestLibraryDefectsPropagate:
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -98,7 +98,7 @@ class TestLibraryDefectsPropagate:
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -141,7 +141,7 @@ class TestSelectionPathsAgreeWithSearch:
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -181,7 +181,7 @@ class TestSelectionPathsAgreeWithSearch:
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -221,7 +221,7 @@ class TestSelectionPathsAgreeWithSearch:
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -259,7 +259,7 @@ class TestSelectionPathsAgreeWithSearch:
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -287,7 +287,7 @@ class TestSelectionPathsAgreeWithSearch:
|
||||
{
|
||||
"documents": [],
|
||||
"all": True,
|
||||
"filters": {"more_like_id": indexed_document.pk},
|
||||
"filters": {"more_like_id": searchable_document.pk},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -298,7 +298,7 @@ class TestSelectionPathsAgreeWithSearch:
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -328,7 +328,7 @@ class TestSelectionPathsAgreeWithSearch:
|
||||
{
|
||||
"documents": [],
|
||||
"all": True,
|
||||
"filters": {"more_like_id": indexed_document.pk},
|
||||
"filters": {"more_like_id": searchable_document.pk},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -36,7 +36,7 @@ class TestGetSearchEndpointEnforcesTheCap:
|
||||
def test_query_one_over_the_cap_is_a_400(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -66,7 +66,7 @@ class TestGetSearchEndpointEnforcesTheCap:
|
||||
def test_query_at_exactly_the_cap_is_accepted(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -86,7 +86,7 @@ class TestGetSearchEndpointEnforcesTheCap:
|
||||
def test_an_ordinary_query_is_unaffected(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -112,7 +112,7 @@ class TestPostSelectionPathsEnforceTheCap:
|
||||
def test_bulk_edit_query_one_over_the_cap_is_a_400(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -154,7 +154,7 @@ class TestPostSelectionPathsEnforceTheCap:
|
||||
self,
|
||||
bulk_update_task_mock: mock.MagicMock,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -187,7 +187,7 @@ class TestPostSelectionPathsEnforceTheCap:
|
||||
def test_bulk_download_query_one_over_the_cap_is_a_400(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -236,7 +236,7 @@ class TestGlobalSearchEnforcesTheCapToo:
|
||||
def test_query_one_over_the_cap_is_a_400(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -260,7 +260,7 @@ class TestGlobalSearchEnforcesTheCapToo:
|
||||
def test_query_at_exactly_the_cap_is_accepted(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -38,7 +38,7 @@ class TestUnterminatedBracketReturnsA400:
|
||||
def test_unterminated_bracket_is_a_400(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
query: str,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -59,7 +59,7 @@ class TestUnterminatedBracketReturnsA400:
|
||||
def test_properly_closed_bracket_still_searches_cleanly(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
indexed_document: Document,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -5,8 +5,6 @@ from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
@@ -14,8 +12,10 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import PaperlessTask
|
||||
from documents.permissions import has_system_status_permission
|
||||
from documents.tests.factories import PaperlessTaskFactory
|
||||
from paperless import version
|
||||
from paperless_testing.factories import PaperlessTaskFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
|
||||
|
||||
class TestSystemStatus(APITestCase):
|
||||
@@ -23,9 +23,7 @@ class TestSystemStatus(APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.create_superuser(
|
||||
username="temp_admin",
|
||||
)
|
||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.tmp_dir = Path(tempfile.mkdtemp())
|
||||
self.override = override_settings(MEDIA_ROOT=self.tmp_dir)
|
||||
self.override.enable()
|
||||
@@ -96,7 +94,7 @@ class TestSystemStatus(APITestCase):
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assertEqual(response["WWW-Authenticate"], "Token")
|
||||
normal_user = User.objects.create_user(username="normal_user")
|
||||
normal_user = UserFactory(username="normal_user")
|
||||
self.client.force_login(normal_user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
@@ -107,10 +105,8 @@ class TestSystemStatus(APITestCase):
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
user = User.objects.create_user(username="status_user")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_system_monitoring"),
|
||||
)
|
||||
user = UserFactory(username="status_user")
|
||||
grant_global(user, "view_system_monitoring")
|
||||
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
@@ -263,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,
|
||||
|
||||
@@ -11,22 +11,21 @@ from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.filters import PaperlessTaskFilterSet
|
||||
from documents.models import PaperlessTask
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import PaperlessTaskFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import PaperlessTaskFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
pytestmark = pytest.mark.api
|
||||
|
||||
ENDPOINT = "/api/tasks/"
|
||||
ACCEPT_V10 = "application/json; version=10"
|
||||
ACCEPT_V9 = "application/json; version=9"
|
||||
|
||||
|
||||
@@ -346,21 +345,16 @@ class TestGetTasksV10:
|
||||
self,
|
||||
admin_user: User,
|
||||
regular_user: User,
|
||||
user_client: APIClient,
|
||||
) -> None:
|
||||
"""Regular users see their own tasks and unowned (system) tasks; other users' tasks are hidden."""
|
||||
regular_user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_paperlesstask"),
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=regular_user)
|
||||
client.credentials(HTTP_ACCEPT=ACCEPT_V10)
|
||||
grant_global(regular_user, "view_paperlesstask")
|
||||
|
||||
PaperlessTaskFactory(owner=admin_user) # other user — not visible
|
||||
unowned_task = PaperlessTaskFactory() # unowned (system task) — visible
|
||||
own_task = PaperlessTaskFactory(owner=regular_user)
|
||||
|
||||
response = client.get(ENDPOINT)
|
||||
response = user_client.get(ENDPOINT)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["count"] == 2
|
||||
@@ -590,9 +584,7 @@ class TestGetTasksV9:
|
||||
regular_user: User,
|
||||
) -> None:
|
||||
"""Non-staff users see their own tasks plus unowned tasks via v9 API."""
|
||||
regular_user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_paperlesstask"),
|
||||
)
|
||||
grant_global(regular_user, "view_paperlesstask")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=regular_user)
|
||||
@@ -732,19 +724,17 @@ class TestAcknowledge:
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_succeeds_with_change_permission(self, regular_user: User) -> None:
|
||||
def test_succeeds_with_change_permission(
|
||||
self,
|
||||
regular_user: User,
|
||||
user_client: APIClient,
|
||||
) -> None:
|
||||
"""Users granted change_paperlesstask permission can acknowledge tasks."""
|
||||
regular_user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_paperlesstask"),
|
||||
)
|
||||
grant_global(regular_user, "change_paperlesstask")
|
||||
regular_user.save()
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=regular_user)
|
||||
client.credentials(HTTP_ACCEPT=ACCEPT_V10)
|
||||
|
||||
task = PaperlessTaskFactory()
|
||||
response = client.post(
|
||||
response = user_client.post(
|
||||
ENDPOINT + "acknowledge/",
|
||||
{"tasks": [task.id]},
|
||||
format="json",
|
||||
@@ -807,9 +797,7 @@ class TestSummaryPermissions:
|
||||
regular_user,
|
||||
) -> None:
|
||||
"""A user with view_system_monitoring but no document permissions can access summary/."""
|
||||
regular_user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_system_monitoring"),
|
||||
)
|
||||
grant_global(regular_user, "view_system_monitoring")
|
||||
|
||||
response = user_client.get(ENDPOINT + "summary/")
|
||||
|
||||
@@ -822,9 +810,7 @@ class TestSummaryPermissions:
|
||||
admin_user,
|
||||
) -> None:
|
||||
"""Monitoring user sees aggregate data for all tasks, not just unowned ones."""
|
||||
regular_user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_system_monitoring"),
|
||||
)
|
||||
grant_global(regular_user, "view_system_monitoring")
|
||||
PaperlessTaskFactory(
|
||||
owner=admin_user,
|
||||
task_type=PaperlessTask.TaskType.CONSUME_FILE,
|
||||
@@ -845,9 +831,7 @@ class TestSummaryPermissions:
|
||||
) -> None:
|
||||
"""A regular user with view_paperlesstask but not view_system_monitoring sees only
|
||||
their own tasks and unowned tasks in the summary, not other users' tasks."""
|
||||
regular_user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_paperlesstask"),
|
||||
)
|
||||
grant_global(regular_user, "view_paperlesstask")
|
||||
|
||||
PaperlessTaskFactory(
|
||||
owner=regular_user,
|
||||
@@ -934,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(
|
||||
@@ -989,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(
|
||||
@@ -1012,9 +996,7 @@ class TestDuplicateDocumentsPermissions:
|
||||
|
||||
@pytest.fixture()
|
||||
def user_v9_client(self, regular_user: User) -> APIClient:
|
||||
regular_user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_paperlesstask"),
|
||||
)
|
||||
grant_global(regular_user, "view_paperlesstask")
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=regular_user)
|
||||
client.credentials(HTTP_ACCEPT=ACCEPT_V9)
|
||||
@@ -1085,7 +1067,7 @@ class TestDuplicateDocumentsPermissions:
|
||||
) -> None:
|
||||
"""A user with explicit guardian view_document permission sees the duplicate_of document."""
|
||||
doc = DocumentFactory(owner=admin_user, title="Granted Doc")
|
||||
assign_perm("view_document", regular_user, doc)
|
||||
grant_object(regular_user, doc, "view_document")
|
||||
PaperlessTaskFactory(
|
||||
owner=regular_user,
|
||||
status=PaperlessTask.Status.SUCCESS,
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.cache import cache
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_all_global
|
||||
|
||||
|
||||
class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_user(username="temp_admin")
|
||||
self.user.user_permissions.add(*Permission.objects.all())
|
||||
self.user = UserFactory(username="temp_admin")
|
||||
grant_all_global(self.user)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
cache.clear()
|
||||
|
||||
@@ -70,7 +70,7 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(Document.global_objects.count(), 0)
|
||||
|
||||
def test_trash_list_requires_global_document_view_permission(self) -> None:
|
||||
user = User.objects.create_user(username="trash_owner")
|
||||
user = UserFactory(username="trash_owner")
|
||||
document = Document.objects.create(title="Owned", owner=user)
|
||||
document.delete()
|
||||
self.client.force_authenticate(user)
|
||||
@@ -140,7 +140,7 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
created=date(2023, 1, 2),
|
||||
)
|
||||
document_not_owned.delete()
|
||||
user2 = User.objects.create_user(username="user2")
|
||||
user2 = UserFactory(username="user2")
|
||||
document_u2 = Document.objects.create(
|
||||
title="Title3",
|
||||
content="content3",
|
||||
@@ -158,7 +158,7 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(resp.data["results"][1]["id"], document_u1.pk)
|
||||
|
||||
# superuser sees all documents
|
||||
superuser = User.objects.create_superuser(username="superuser")
|
||||
superuser = UserFactory(username="superuser", superuser=True)
|
||||
self.client.force_authenticate(user=superuser)
|
||||
resp = self.client.get("/api/trash/")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
@@ -174,7 +174,7 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
- 403 Forbidden
|
||||
"""
|
||||
|
||||
user2 = User.objects.create_user(username="user2")
|
||||
user2 = UserFactory(username="user2")
|
||||
document = Document.objects.create(
|
||||
title="Title",
|
||||
content="content",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless.version import __full_version_str__
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
|
||||
class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
||||
@@ -15,7 +15,7 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.test_user = User.objects.create_superuser(username="test")
|
||||
self.test_user = UserFactory(username="test", superuser=True)
|
||||
self.test_user.first_name = "Test"
|
||||
self.test_user.last_name = "User"
|
||||
self.test_user.save()
|
||||
@@ -91,7 +91,7 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
def test_api_set_ui_settings_insufficient_global_permissions(self) -> None:
|
||||
not_superuser = User.objects.create_user(username="test_not_superuser")
|
||||
not_superuser = UserFactory(username="test_not_superuser")
|
||||
self.client.force_authenticate(user=not_superuser)
|
||||
|
||||
settings = {
|
||||
@@ -111,7 +111,7 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_api_set_ui_settings_sufficient_global_permissions(self) -> None:
|
||||
not_superuser = User.objects.create_user(username="test_not_superuser")
|
||||
not_superuser = UserFactory(username="test_not_superuser")
|
||||
not_superuser.user_permissions.add(
|
||||
*Permission.objects.filter(codename__contains="uisettings"),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,8 @@ from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
|
||||
class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
@@ -25,7 +26,7 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
user = UserFactory(username="temp_admin", superuser=True)
|
||||
self.client.force_authenticate(user=user)
|
||||
self.user2 = User.objects.create(username="user2")
|
||||
self.user3 = User.objects.create(username="user3")
|
||||
|
||||
@@ -2,8 +2,8 @@ import shutil
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
@@ -18,11 +18,11 @@ from documents.models import Document
|
||||
from documents.models import Tag
|
||||
from documents.plugins.base import StopConsumeTaskError
|
||||
from documents.tests.utils import ConsumeTaskMixin
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import DummyProgressManager
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
|
||||
|
||||
class GetReaderPluginMixin:
|
||||
@@ -31,7 +31,7 @@ class GetReaderPluginMixin:
|
||||
reader = BarcodePlugin(
|
||||
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
|
||||
DocumentMetadataOverrides(),
|
||||
DummyProgressManager(filepath.name, None),
|
||||
FakeProgressManager(filepath.name, None),
|
||||
self.dirs.scratch_dir,
|
||||
"task-id",
|
||||
)
|
||||
@@ -86,6 +86,7 @@ class TestBarcode(
|
||||
self.assertDictEqual(separator_page_numbers, {1: False})
|
||||
|
||||
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
|
||||
@pytest.mark.usefixtures("fake_progress_manager")
|
||||
def test_asn_barcode_duplicate_in_trash_fails(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -110,15 +111,14 @@ class TestBarcode(
|
||||
dupe_asn = settings.SCRATCH_DIR / "barcode-39-asn-123-second.pdf"
|
||||
shutil.copy(test_file, dupe_asn)
|
||||
|
||||
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
|
||||
with self.assertRaisesRegex(ConsumerError, r"ASN 123.*trash"):
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=dupe_asn,
|
||||
),
|
||||
None,
|
||||
)
|
||||
with self.assertRaisesRegex(ConsumerError, r"ASN 123.*trash"):
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=dupe_asn,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
CONSUMER_BARCODE_TIFF_SUPPORT=True,
|
||||
@@ -606,6 +606,7 @@ class TestBarcodeNewConsume(
|
||||
TestCase,
|
||||
):
|
||||
@override_settings(CONSUMER_ENABLE_BARCODES=True)
|
||||
@pytest.mark.usefixtures("fake_progress_manager")
|
||||
def test_consume_barcode_file(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -624,34 +625,33 @@ class TestBarcodeNewConsume(
|
||||
|
||||
overrides = DocumentMetadataOverrides(tag_ids=[1, 2, 9])
|
||||
|
||||
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
|
||||
self.assertEqual(
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=temp_copy,
|
||||
),
|
||||
overrides,
|
||||
self.assertEqual(
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=temp_copy,
|
||||
),
|
||||
{"reason": "Barcode splitting complete!"},
|
||||
)
|
||||
# 2 new document consume tasks created
|
||||
self.assertEqual(self.consume_file_mock.call_count, 2)
|
||||
overrides,
|
||||
),
|
||||
{"reason": "Barcode splitting complete!"},
|
||||
)
|
||||
# 2 new document consume tasks created
|
||||
self.assertEqual(self.consume_file_mock.call_count, 2)
|
||||
|
||||
self.assertIsNotFile(temp_copy)
|
||||
self.assertIsNotFile(temp_copy)
|
||||
|
||||
# Check the split files exist
|
||||
# Check the original_path is set
|
||||
# Check the source is unchanged
|
||||
# Check the overrides are unchanged
|
||||
for (
|
||||
new_input_doc,
|
||||
new_doc_overrides,
|
||||
) in self.get_all_consume_task_call_args():
|
||||
self.assertIsFile(new_input_doc.original_file)
|
||||
self.assertEqual(new_input_doc.original_path, temp_copy)
|
||||
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
|
||||
self.assertEqual(overrides, new_doc_overrides)
|
||||
# Check the split files exist
|
||||
# Check the original_path is set
|
||||
# Check the source is unchanged
|
||||
# Check the overrides are unchanged
|
||||
for (
|
||||
new_input_doc,
|
||||
new_doc_overrides,
|
||||
) in self.get_all_consume_task_call_args():
|
||||
self.assertIsFile(new_input_doc.original_file)
|
||||
self.assertEqual(new_input_doc.original_path, temp_copy)
|
||||
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
|
||||
self.assertEqual(overrides, new_doc_overrides)
|
||||
|
||||
|
||||
class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, TestCase):
|
||||
@@ -660,7 +660,7 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
||||
reader = BarcodePlugin(
|
||||
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
|
||||
DocumentMetadataOverrides(),
|
||||
DummyProgressManager(filepath.name, None),
|
||||
FakeProgressManager(filepath.name, None),
|
||||
self.dirs.scratch_dir,
|
||||
"task-id",
|
||||
)
|
||||
@@ -745,6 +745,7 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
||||
self.assertEqual(asn, None)
|
||||
|
||||
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
|
||||
@pytest.mark.usefixtures("fake_progress_manager")
|
||||
def test_consume_barcode_file_asn_assignment(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -762,19 +763,18 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
||||
dst = settings.SCRATCH_DIR / "barcode-39-asn-123.pdf"
|
||||
shutil.copy(test_file, dst)
|
||||
|
||||
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=dst,
|
||||
),
|
||||
None,
|
||||
)
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=dst,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
|
||||
self.assertEqual(document.archive_serial_number, 123)
|
||||
self.assertEqual(document.archive_serial_number, 123)
|
||||
|
||||
def test_scan_file_for_qrcode_without_upscale(self) -> None:
|
||||
"""
|
||||
@@ -819,7 +819,7 @@ class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
||||
reader = BarcodePlugin(
|
||||
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
|
||||
DocumentMetadataOverrides(),
|
||||
DummyProgressManager(filepath.name, None),
|
||||
FakeProgressManager(filepath.name, None),
|
||||
self.dirs.scratch_dir,
|
||||
"task-id",
|
||||
)
|
||||
@@ -1024,6 +1024,7 @@ class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
OCR_MODE="auto",
|
||||
)
|
||||
@pytest.mark.usefixtures("fake_progress_manager")
|
||||
def test_consume_barcode_file_tag_split_and_assignment(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -1042,34 +1043,33 @@ class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
|
||||
dst = settings.SCRATCH_DIR / "split-by-tag-basic.pdf"
|
||||
shutil.copy(test_file, dst)
|
||||
|
||||
with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
|
||||
result = tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=dst,
|
||||
),
|
||||
None,
|
||||
)
|
||||
result = tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=dst,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"reason": "Barcode splitting complete!"})
|
||||
self.assertEqual(result, {"reason": "Barcode splitting complete!"})
|
||||
|
||||
documents = Document.objects.all().order_by("id")
|
||||
self.assertEqual(documents.count(), 3)
|
||||
documents = Document.objects.all().order_by("id")
|
||||
self.assertEqual(documents.count(), 3)
|
||||
|
||||
doc1 = documents[0]
|
||||
self.assertEqual(doc1.tags.count(), 0)
|
||||
doc1 = documents[0]
|
||||
self.assertEqual(doc1.tags.count(), 0)
|
||||
|
||||
doc2 = documents[1]
|
||||
self.assertEqual(doc2.tags.count(), 1)
|
||||
_tag_1 = doc2.tags.first()
|
||||
assert _tag_1 is not None
|
||||
self.assertEqual(_tag_1.name, "invoice")
|
||||
doc2 = documents[1]
|
||||
self.assertEqual(doc2.tags.count(), 1)
|
||||
_tag_1 = doc2.tags.first()
|
||||
assert _tag_1 is not None
|
||||
self.assertEqual(_tag_1.name, "invoice")
|
||||
|
||||
doc3 = documents[2]
|
||||
self.assertEqual(doc3.tags.count(), 1)
|
||||
_tag_2 = doc3.tags.first()
|
||||
assert _tag_2 is not None
|
||||
self.assertEqual(_tag_2.name, "receipt")
|
||||
doc3 = documents[2]
|
||||
self.assertEqual(doc3.tags.count(), 1)
|
||||
_tag_2 = doc3.tags.first()
|
||||
assert _tag_2 is not None
|
||||
self.assertEqual(_tag_2.name, "receipt")
|
||||
|
||||
@override_settings(
|
||||
CONSUMER_ENABLE_TAG_BARCODE=True,
|
||||
|
||||
@@ -10,7 +10,6 @@ from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_groups_with_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
|
||||
@@ -23,7 +22,8 @@ from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
@@ -440,7 +440,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
def test_set_permissions(self, m) -> None:
|
||||
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
||||
|
||||
assign_perm("view_document", self.group1, self.doc1)
|
||||
grant_object(self.group1, self.doc1, "view_document")
|
||||
|
||||
permissions = {
|
||||
"view": {
|
||||
@@ -482,8 +482,8 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
self.doc1.owner = self.user1
|
||||
self.doc1.save()
|
||||
|
||||
assign_perm("view_document", self.user1, self.doc1)
|
||||
assign_perm("view_document", self.group1, self.doc1)
|
||||
grant_object(self.user1, self.doc1, "view_document")
|
||||
grant_object(self.group1, self.doc1, "view_document")
|
||||
|
||||
permissions = {
|
||||
"view": {
|
||||
@@ -609,7 +609,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
self.doc1.owner = self.user1
|
||||
self.doc1.save()
|
||||
self.user1.groups.add(self.group1)
|
||||
assign_perm("view_document", self.group1, self.doc1)
|
||||
grant_object(self.group1, self.doc1, "view_document")
|
||||
|
||||
bulk_edit.set_permissions(
|
||||
[self.doc1.id],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import pickle
|
||||
import re
|
||||
import warnings
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
@@ -28,21 +27,13 @@ from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.helpers import dummy_preprocess
|
||||
from paperless.settings import CLASSIFIER_LANGUAGES
|
||||
from paperless.signed_pickle import HMAC_SIZE
|
||||
from paperless.signed_pickle import signed_pickle_dumps
|
||||
|
||||
|
||||
def dummy_preprocess(content: str) -> str:
|
||||
"""
|
||||
Simpler, faster pre-processing for testing purposes
|
||||
"""
|
||||
content = content.lower().strip()
|
||||
content = re.sub(r"\s+", " ", content)
|
||||
return content
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
|
||||
|
||||
class TestClassifier(DirectoriesMixin, TestCase):
|
||||
|
||||
@@ -30,11 +30,12 @@ from documents.models import Tag
|
||||
from documents.parsers import ParseError
|
||||
from documents.plugins.helpers import ProgressStatusOptions
|
||||
from documents.tasks import sanity_check
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import DummyProgressManager
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from documents.tests.utils import GetConsumerMixin
|
||||
from paperless_mail.models import MailRule
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
|
||||
|
||||
class _BaseNewStyleParser:
|
||||
@@ -769,14 +770,14 @@ class TestConsumer(
|
||||
|
||||
original_modified = timezone.now() - datetime.timedelta(days=1)
|
||||
Document.objects.filter(pk=root_doc.pk).update(modified=original_modified)
|
||||
actor = User.objects.create_user(
|
||||
actor = UserFactory(
|
||||
username="actor",
|
||||
email="actor@example.com",
|
||||
password="password",
|
||||
)
|
||||
|
||||
version_file = self.get_test_file2()
|
||||
status = DummyProgressManager(version_file.name, None)
|
||||
status = FakeProgressManager(version_file.name, None)
|
||||
overrides = DocumentMetadataOverrides(
|
||||
version_label="v2",
|
||||
actor_id=actor.pk,
|
||||
@@ -839,7 +840,7 @@ class TestConsumer(
|
||||
assert root_doc is not None
|
||||
|
||||
version_file = self.get_test_file2()
|
||||
status = DummyProgressManager(version_file.name, None)
|
||||
status = FakeProgressManager(version_file.name, None)
|
||||
overrides = DocumentMetadataOverrides(
|
||||
filename="valid_pdf_version-upload",
|
||||
actor_id=999999,
|
||||
@@ -896,7 +897,7 @@ class TestConsumer(
|
||||
assert root_doc is not None
|
||||
|
||||
def consume_version(version_file: Path) -> Document:
|
||||
status = DummyProgressManager(version_file.name, None)
|
||||
status = FakeProgressManager(version_file.name, None)
|
||||
overrides = DocumentMetadataOverrides()
|
||||
doc = ConsumableDocument(
|
||||
DocumentSource.ApiUpload,
|
||||
|
||||
@@ -9,11 +9,11 @@ from django.test.utils import CaptureQueriesContext
|
||||
from rest_framework import status
|
||||
|
||||
from documents.models import Document
|
||||
from documents.tests.factories import DocumentFactory
|
||||
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:
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -2,8 +2,8 @@ import datetime as dt
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from pdfminer.high_level import extract_text
|
||||
@@ -15,22 +15,26 @@ from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.double_sided import STAGING_FILE_NAME
|
||||
from documents.double_sided import TIMEOUT_MINUTES
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import DummyProgressManager
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("fake_progress_manager")
|
||||
@override_settings(
|
||||
CONSUMER_RECURSIVE=True,
|
||||
CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=True,
|
||||
)
|
||||
class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
SAMPLE_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
class TestDoubleSided(
|
||||
DirectoriesMixin,
|
||||
FileSystemAssertsMixin,
|
||||
SampleDirMixin,
|
||||
TestCase,
|
||||
):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.dirs.double_sided_dir = self.dirs.consumption_dir / "double-sided"
|
||||
self.dirs.double_sided_dir.mkdir()
|
||||
self.double_sided_dir = self.dirs.consumption_dir / "double-sided"
|
||||
self.double_sided_dir.mkdir()
|
||||
self.staging_file = self.dirs.scratch_dir / STAGING_FILE_NAME
|
||||
|
||||
def consume_file(self, srcname, dstname: str | Path = "foo.pdf"):
|
||||
@@ -39,20 +43,16 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
destination file does not exist afterwards
|
||||
"""
|
||||
src = self.SAMPLE_DIR / srcname
|
||||
dst = self.dirs.double_sided_dir / dstname
|
||||
dst = self.double_sided_dir / dstname
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(src, dst)
|
||||
with mock.patch(
|
||||
"documents.tasks.ProgressManager",
|
||||
DummyProgressManager,
|
||||
):
|
||||
msg = tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=dst,
|
||||
),
|
||||
None,
|
||||
)
|
||||
msg = tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=dst,
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotFile(dst)
|
||||
return msg
|
||||
|
||||
@@ -214,31 +214,41 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertIsNotFile(self.staging_file)
|
||||
self.assertIsInstance(msg.get("document_id"), int)
|
||||
|
||||
def test_subdirectory_upload(self) -> None:
|
||||
def test_consume_double_sided_in_nested_dir(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A staging file exists
|
||||
WHEN:
|
||||
- A file gets uploaded into foo/bar/double-sided
|
||||
or double-sided/foo/bar
|
||||
- A file is uploaded into foo/bar/double-sided
|
||||
THEN:
|
||||
- The collated file gets put into foo/bar
|
||||
- The collated file is placed into foo/bar
|
||||
"""
|
||||
# TODO: parameterize this instead
|
||||
for path in [
|
||||
Path("foo") / "bar" / "double-sided",
|
||||
Path("double-sided") / "foo" / "bar",
|
||||
]:
|
||||
with self.subTest(path=str(path)):
|
||||
# Ensure we get fresh directories for each run
|
||||
self.tearDown()
|
||||
self.setUp()
|
||||
self.create_staging_file()
|
||||
self.consume_file(
|
||||
"double-sided-odd.pdf",
|
||||
Path("foo") / "bar" / "double-sided" / "foo.pdf",
|
||||
)
|
||||
self.assertIsFile(
|
||||
self.dirs.consumption_dir / "foo" / "bar" / "foo-collated.pdf",
|
||||
)
|
||||
|
||||
self.create_staging_file()
|
||||
self.consume_file("double-sided-odd.pdf", Path(path) / "foo.pdf")
|
||||
self.assertIsFile(
|
||||
self.dirs.consumption_dir / "foo" / "bar" / "foo-collated.pdf",
|
||||
)
|
||||
def test_consume_double_sided_with_nested_subdir(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A staging file exists
|
||||
WHEN:
|
||||
- A file is uploaded into double-sided/foo/bar
|
||||
THEN:
|
||||
- The collated file is placed into foo/bar
|
||||
"""
|
||||
self.create_staging_file()
|
||||
self.consume_file(
|
||||
"double-sided-odd.pdf",
|
||||
Path("double-sided") / "foo" / "bar" / "foo.pdf",
|
||||
)
|
||||
self.assertIsFile(
|
||||
self.dirs.consumption_dir / "foo" / "bar" / "foo-collated.pdf",
|
||||
)
|
||||
|
||||
@override_settings(CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=False)
|
||||
def test_disabled_double_sided_dir_upload(self) -> None:
|
||||
|
||||
@@ -8,7 +8,6 @@ from unittest import mock
|
||||
import pytest
|
||||
from auditlog.context import disable_auditlog
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import DatabaseError
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
@@ -28,11 +27,12 @@ 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 documents.tests.factories import DocumentFactory
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
|
||||
class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
@@ -1323,7 +1323,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
|
||||
- Document without owner returns "none"
|
||||
"""
|
||||
|
||||
u1 = User.objects.create_user("user1")
|
||||
u1 = UserFactory(username="user1")
|
||||
|
||||
owned_doc = Document.objects.create(
|
||||
title="The Title",
|
||||
|
||||
@@ -13,10 +13,10 @@ import pytest
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.tests.factories import CorrespondentFactory
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import StoragePathFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from paperless_testing.factories import CorrespondentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import StoragePathFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -20,8 +20,8 @@ if TYPE_CHECKING:
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.models import Document
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
|
||||
sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
|
||||
from documents.management.commands import document_exporter
|
||||
from documents.models import Correspondent
|
||||
@@ -46,11 +45,12 @@ from documents.models import WorkflowTrigger
|
||||
from documents.sanity_checker import check_sanity
|
||||
from documents.settings import EXPORTER_FILE_NAME
|
||||
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
from documents.tests.utils import paperless_environment
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.dirs import paperless_environment
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@@ -105,8 +105,8 @@ class TestExportImport(
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
assign_perm("view_document", self.user2, self.d2)
|
||||
assign_perm("view_document", self.group1, self.d3)
|
||||
grant_object(self.user2, self.d2, "view_document")
|
||||
grant_object(self.group1, self.d3, "view_document")
|
||||
|
||||
self.t1 = Tag.objects.create(name="t")
|
||||
self.dt1 = DocumentType.objects.create(name="dt")
|
||||
@@ -677,12 +677,13 @@ class TestExportImport(
|
||||
THEN:
|
||||
- Error is raised
|
||||
"""
|
||||
args = ["document_exporter", "/tmp/foo/bar"]
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
args = ["document_exporter", str(Path(tmp_dir) / "does-not-exist")]
|
||||
|
||||
with self.assertRaises(CommandError) as e:
|
||||
call_command(*args, skip_checks=True)
|
||||
with self.assertRaises(CommandError) as e:
|
||||
call_command(*args, skip_checks=True)
|
||||
|
||||
self.assertEqual("That path doesn't exist", str(e.exception))
|
||||
self.assertEqual("That path doesn't exist", str(e.exception))
|
||||
|
||||
def test_export_target_exists_but_is_file(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from documents.models import Document
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@@ -123,14 +123,14 @@ class TestFuzzyMatchCommand(TestCase):
|
||||
- Output contains clickable links to the documents instead of titles
|
||||
"""
|
||||
# Content similarity is 86.667
|
||||
Document.objects.create(
|
||||
doc1 = Document.objects.create(
|
||||
checksum="BEEFCAFE",
|
||||
title="A",
|
||||
content="first document scanned by bob",
|
||||
mime_type="application/pdf",
|
||||
filename="test.pdf",
|
||||
)
|
||||
Document.objects.create(
|
||||
doc2 = Document.objects.create(
|
||||
checksum="DEADBEAF",
|
||||
title="A",
|
||||
content="first document scanned by alice",
|
||||
@@ -145,8 +145,8 @@ class TestFuzzyMatchCommand(TestCase):
|
||||
"http://localhost:8000",
|
||||
)
|
||||
self.assertIn("Found 1 matching pair(s)", stdout)
|
||||
self.assertIn("http://localhost:8000/documents/1/details", stdout)
|
||||
self.assertIn("http://localhost:8000/documents/2/details", stdout)
|
||||
self.assertIn(f"http://localhost:8000/documents/{doc1.pk}/details", stdout)
|
||||
self.assertIn(f"http://localhost:8000/documents/{doc2.pk}/details", stdout)
|
||||
|
||||
def test_with_3_matches(self) -> None:
|
||||
"""
|
||||
@@ -198,14 +198,14 @@ class TestFuzzyMatchCommand(TestCase):
|
||||
- Documents 1 and 2 remain
|
||||
"""
|
||||
# Content similarity is 86.667
|
||||
Document.objects.create(
|
||||
doc1 = Document.objects.create(
|
||||
checksum="BEEFCAFE",
|
||||
title="A",
|
||||
content="first document scanned by bob",
|
||||
mime_type="application/pdf",
|
||||
filename="test.pdf",
|
||||
)
|
||||
Document.objects.create(
|
||||
doc2 = Document.objects.create(
|
||||
checksum="DEADBEAF",
|
||||
title="A",
|
||||
content="second document scanned by alice",
|
||||
@@ -235,8 +235,8 @@ class TestFuzzyMatchCommand(TestCase):
|
||||
self.assertIn("Deleting 1 document(s)", stdout)
|
||||
|
||||
self.assertEqual(Document.objects.count(), 2)
|
||||
self.assertIsNotNone(Document.objects.get(pk=1))
|
||||
self.assertIsNotNone(Document.objects.get(pk=2))
|
||||
self.assertIsNotNone(Document.objects.get(pk=doc1.pk))
|
||||
self.assertIsNotNone(Document.objects.get(pk=doc2.pk))
|
||||
|
||||
def test_document_deletion_cancelled(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -15,9 +15,9 @@ from documents.management.commands.document_importer import _deserialize_record
|
||||
from documents.models import Document
|
||||
from documents.settings import EXPORTER_ARCHIVE_NAME
|
||||
from documents.settings import EXPORTER_FILE_NAME
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
|
||||
@@ -14,12 +14,12 @@ from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.factories import CorrespondentFactory
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import DocumentTypeFactory
|
||||
from documents.tests.factories import StoragePathFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import CorrespondentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentTypeFactory
|
||||
from paperless_testing.factories import StoragePathFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level type aliases
|
||||
|
||||
@@ -7,14 +7,14 @@ from django.contrib.auth.models import User
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
class TestManageSuperUser(DirectoriesMixin, TestCase):
|
||||
def call_command(self, environ):
|
||||
out = StringIO()
|
||||
with mock.patch.dict(os.environ, environ):
|
||||
with mock.patch.dict(os.environ, environ, clear=True):
|
||||
call_command(
|
||||
"manage_superuser",
|
||||
"--no-color",
|
||||
|
||||
@@ -9,8 +9,8 @@ from django.test import TestCase
|
||||
from documents.management.commands.document_thumbnails import _process_document
|
||||
from documents.models import Document
|
||||
from documents.parsers import get_default_thumbnail
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
|
||||
@@ -7,10 +7,10 @@ from documents import matching
|
||||
from documents.models import Document
|
||||
from documents.models import MatchingModel
|
||||
from documents.signals import document_consumption_finished
|
||||
from documents.tests.factories import CorrespondentFactory
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import DocumentTypeFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from paperless_testing.factories import CorrespondentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import DocumentTypeFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
|
||||
@@ -2,8 +2,6 @@ import json
|
||||
from unittest import mock
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
from rest_framework import status
|
||||
@@ -11,7 +9,9 @@ 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
|
||||
|
||||
|
||||
class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
||||
@@ -294,7 +294,7 @@ class TestMergeDocumentsAsVersions(TestCase):
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.search.get_backend")
|
||||
def test_writes_audit_log_entry(self, *_mocks) -> None:
|
||||
user = User.objects.create_user(username="merger")
|
||||
user = UserFactory(username="merger")
|
||||
root = Document.objects.create(checksum="A", title="Root")
|
||||
source = Document.objects.create(checksum="B", title="Source")
|
||||
LogEntry.objects.all().delete()
|
||||
@@ -335,12 +335,8 @@ class TestMergeDocumentsAsVersions(TestCase):
|
||||
|
||||
class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user(username="user")
|
||||
self.user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
Permission.objects.get(codename="view_document"),
|
||||
Permission.objects.get(codename="delete_document"),
|
||||
)
|
||||
self.user = UserFactory(username="user")
|
||||
grant_global(self.user, "change_document", "view_document", "delete_document")
|
||||
self.doc1 = Document.objects.create(
|
||||
checksum="A",
|
||||
title="A",
|
||||
@@ -353,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"
|
||||
@@ -379,10 +375,10 @@ 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 = User.objects.create_user(username="no-change")
|
||||
user = UserFactory(username="no-change")
|
||||
self.doc1.owner = user
|
||||
self.doc1.save()
|
||||
self.doc2.owner = user
|
||||
@@ -401,15 +397,12 @@ 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
|
||||
user = User.objects.create_user(username="no-delete")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
user = UserFactory(username="no-delete")
|
||||
grant_global(user, "change_document", "view_document")
|
||||
for doc in (self.doc1, self.doc2):
|
||||
doc.owner = user
|
||||
doc.save()
|
||||
@@ -427,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",
|
||||
@@ -447,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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from documents.tests.utils import TestMigrations
|
||||
from paperless_testing.migrations import TestMigrations
|
||||
|
||||
SAVED_VIEWS_KEY = "saved_views"
|
||||
DASHBOARD_VIEWS_VISIBLE_IDS_KEY = "dashboard_views_visible_ids"
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.test import override_settings
|
||||
|
||||
from documents.tests.utils import TestMigrations
|
||||
from paperless_testing.migrations import TestMigrations
|
||||
|
||||
|
||||
def _sha256(data: bytes) -> str:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from documents.tests.utils import TestMigrations
|
||||
from paperless_testing.migrations import TestMigrations
|
||||
|
||||
|
||||
class TestMigrateShareLinkBundlePermissions(TestMigrations):
|
||||
|
||||
@@ -2,8 +2,8 @@ import pytest
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.tests.factories import CorrespondentFactory
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from paperless_testing.factories import CorrespondentFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.matching import match_correspondents
|
||||
@@ -23,12 +21,18 @@ 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.tests.factories import CorrespondentFactory
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import DocumentTypeFactory
|
||||
from documents.tests.factories import StoragePathFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
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
|
||||
from paperless_testing.factories import StoragePathFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden):
|
||||
@@ -47,8 +51,8 @@ def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden
|
||||
@pytest.mark.django_db
|
||||
class TestPermittedDocumentIdsSecurity:
|
||||
def test_owner_sees_own_document(self):
|
||||
user = User.objects.create_user(username="alice")
|
||||
stranger = User.objects.create_user(username="mallory")
|
||||
user = UserFactory(username="alice")
|
||||
stranger = UserFactory(username="mallory")
|
||||
owned = DocumentFactory(owner=user)
|
||||
strangers_doc = DocumentFactory(owner=stranger)
|
||||
|
||||
@@ -61,7 +65,7 @@ class TestPermittedDocumentIdsSecurity:
|
||||
)
|
||||
|
||||
def test_unowned_document_visible_to_everyone(self):
|
||||
user = User.objects.create_user(username="alice")
|
||||
user = UserFactory(username="alice")
|
||||
unowned = DocumentFactory(owner=None)
|
||||
|
||||
assert_visible_document_ids(
|
||||
@@ -71,12 +75,12 @@ class TestPermittedDocumentIdsSecurity:
|
||||
)
|
||||
|
||||
def test_explicit_user_permission_grants_visibility(self):
|
||||
grantee = User.objects.create_user(username="alice")
|
||||
stranger = User.objects.create_user(username="mallory")
|
||||
owner = User.objects.create_user(username="owner")
|
||||
grantee = UserFactory(username="alice")
|
||||
stranger = UserFactory(username="mallory")
|
||||
owner = UserFactory(username="owner")
|
||||
shared = DocumentFactory(owner=owner)
|
||||
not_shared = DocumentFactory(owner=owner)
|
||||
assign_perm("view_document", grantee, shared)
|
||||
grant_object(grantee, shared, "view_document")
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(grantee),
|
||||
@@ -90,13 +94,13 @@ class TestPermittedDocumentIdsSecurity:
|
||||
)
|
||||
|
||||
def test_explicit_group_permission_grants_visibility_to_members_only(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
member = User.objects.create_user(username="member")
|
||||
non_member = User.objects.create_user(username="non_member")
|
||||
owner = UserFactory(username="owner")
|
||||
member = UserFactory(username="member")
|
||||
non_member = UserFactory(username="non_member")
|
||||
group = Group.objects.create(name="finance")
|
||||
member.groups.add(group)
|
||||
shared = DocumentFactory(owner=owner)
|
||||
assign_perm("view_document", group, shared)
|
||||
grant_object(group, shared, "view_document")
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(member),
|
||||
@@ -110,7 +114,7 @@ class TestPermittedDocumentIdsSecurity:
|
||||
)
|
||||
|
||||
def test_soft_deleted_document_excluded_by_default(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
owner = UserFactory(username="owner")
|
||||
doc = DocumentFactory(owner=owner)
|
||||
doc.delete() # soft delete
|
||||
doc.refresh_from_db()
|
||||
@@ -126,8 +130,8 @@ class TestPermittedDocumentIdsSecurity:
|
||||
)
|
||||
|
||||
def test_superuser_sees_everything_including_no_perm_documents(self):
|
||||
superuser = User.objects.create_superuser(username="root")
|
||||
owner = User.objects.create_user(username="owner")
|
||||
superuser = UserFactory(username="root", superuser=True)
|
||||
owner = UserFactory(username="owner")
|
||||
doc = DocumentFactory(owner=owner)
|
||||
|
||||
assert_visible_document_ids(
|
||||
@@ -137,7 +141,7 @@ class TestPermittedDocumentIdsSecurity:
|
||||
)
|
||||
|
||||
def test_anonymous_user_sees_only_unowned_documents(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
owner = UserFactory(username="owner")
|
||||
owned = DocumentFactory(owner=owner)
|
||||
unowned = DocumentFactory(owner=None)
|
||||
|
||||
@@ -151,7 +155,7 @@ class TestPermittedDocumentIdsSecurity:
|
||||
@pytest.mark.django_db
|
||||
class TestPermittedDocumentIdsIncludeDeleted:
|
||||
def test_include_deleted_true_reveals_soft_deleted_owned_document(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
owner = UserFactory(username="owner")
|
||||
doc = DocumentFactory(owner=owner)
|
||||
doc.delete()
|
||||
|
||||
@@ -162,8 +166,8 @@ class TestPermittedDocumentIdsIncludeDeleted:
|
||||
)
|
||||
|
||||
def test_include_deleted_true_still_respects_permission_boundary(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
stranger = User.objects.create_user(username="mallory")
|
||||
owner = UserFactory(username="owner")
|
||||
stranger = UserFactory(username="mallory")
|
||||
doc = DocumentFactory(owner=owner)
|
||||
doc.delete()
|
||||
|
||||
@@ -187,18 +191,16 @@ 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"])
|
||||
|
||||
owner = User.objects.create_user(username="owner")
|
||||
asker = User.objects.create_user(username="asker")
|
||||
asker.user_permissions.add(
|
||||
*Permission.objects.filter(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
asker = UserFactory(username="asker")
|
||||
grant_global(asker, "view_document")
|
||||
shared = DocumentFactory(owner=owner)
|
||||
not_shared = DocumentFactory(owner=owner)
|
||||
assign_perm("view_document", asker, shared)
|
||||
grant_object(asker, shared, "view_document")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=asker)
|
||||
@@ -219,13 +221,13 @@ class TestAiChatAllDocumentsPermissionBoundary:
|
||||
@pytest.mark.django_db
|
||||
class TestDuplicateDocumentsPermissionBoundary:
|
||||
def test_get_viewable_duplicates_includes_soft_deleted_but_respects_perms(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
stranger = User.objects.create_user(username="mallory")
|
||||
owner = UserFactory(username="owner")
|
||||
stranger = UserFactory(username="mallory")
|
||||
original = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
||||
dup_visible = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
||||
dup_hidden = DocumentFactory(owner=owner, checksum="dupe-checksum")
|
||||
dup_hidden.delete() # soft delete, should still be found (include_deleted=True)
|
||||
assign_perm("view_document", stranger, dup_visible)
|
||||
grant_object(stranger, dup_visible, "view_document")
|
||||
|
||||
result_owner = _get_viewable_duplicates(original, owner)
|
||||
assert {d.pk for d in result_owner} == {dup_visible.pk, dup_hidden.pk}
|
||||
@@ -237,13 +239,13 @@ class TestDuplicateDocumentsPermissionBoundary:
|
||||
@pytest.mark.django_db
|
||||
class TestPermittedDocumentIdsArbitraryPermission:
|
||||
def test_change_document_permission_is_distinct_from_view(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
viewer_only = User.objects.create_user(username="viewer")
|
||||
editor = User.objects.create_user(username="editor")
|
||||
owner = UserFactory(username="owner")
|
||||
viewer_only = UserFactory(username="viewer")
|
||||
editor = UserFactory(username="editor")
|
||||
doc = DocumentFactory(owner=owner)
|
||||
assign_perm("view_document", viewer_only, doc)
|
||||
assign_perm("change_document", editor, doc)
|
||||
assign_perm("view_document", editor, doc)
|
||||
grant_object(viewer_only, doc, "view_document")
|
||||
grant_object(editor, doc, "change_document")
|
||||
grant_object(editor, doc, "view_document")
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(editor, perm="change_document"),
|
||||
@@ -257,10 +259,10 @@ class TestPermittedDocumentIdsArbitraryPermission:
|
||||
)
|
||||
|
||||
def test_qualified_permission_string_is_normalized_to_codename(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
editor = User.objects.create_user(username="editor")
|
||||
owner = UserFactory(username="owner")
|
||||
editor = UserFactory(username="editor")
|
||||
doc = DocumentFactory(owner=owner)
|
||||
assign_perm("change_document", editor, doc)
|
||||
grant_object(editor, doc, "change_document")
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(editor, perm="documents.change_document"),
|
||||
@@ -269,11 +271,11 @@ class TestPermittedDocumentIdsArbitraryPermission:
|
||||
)
|
||||
|
||||
def test_delete_permission_with_include_deleted_for_trash_restore(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
stranger = User.objects.create_user(username="mallory")
|
||||
view_only = User.objects.create_user(username="viewer")
|
||||
owner = UserFactory(username="owner")
|
||||
stranger = UserFactory(username="mallory")
|
||||
view_only = UserFactory(username="viewer")
|
||||
doc = DocumentFactory(owner=owner)
|
||||
assign_perm("view_document", view_only, doc)
|
||||
grant_object(view_only, doc, "view_document")
|
||||
doc.delete()
|
||||
|
||||
assert_visible_document_ids(
|
||||
@@ -307,11 +309,9 @@ class TestEmailDocumentPermissionBoundary:
|
||||
self,
|
||||
rest_api_client,
|
||||
):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
requester = UserFactory(username="requester")
|
||||
grant_global(requester, "view_document")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
hidden = DocumentFactory(owner=owner)
|
||||
|
||||
@@ -339,19 +339,17 @@ class TestBulkEditChangePermissionBoundary:
|
||||
# permitted document must not be partially applied just because it
|
||||
# was bundled with a forbidden one, proving the endpoint checks
|
||||
# every document in the batch rather than only the first/last.
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
owner = UserFactory(username="owner")
|
||||
requester = UserFactory(username="requester")
|
||||
# grant the global change_document permission so the object-level
|
||||
# check (not the global has_perm check) is what's under test
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
)
|
||||
grant_global(requester, "change_document")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
changeable = DocumentFactory(owner=owner)
|
||||
assign_perm("view_document", requester, changeable)
|
||||
assign_perm("change_document", requester, changeable) # fully permitted
|
||||
grant_object(requester, changeable, "view_document")
|
||||
grant_object(requester, changeable, "change_document") # fully permitted
|
||||
target = DocumentFactory(owner=owner)
|
||||
assign_perm("view_document", requester, target) # view only, NOT change
|
||||
grant_object(requester, target, "view_document") # view only, NOT change
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
@@ -369,15 +367,14 @@ class TestBulkEditChangePermissionBoundary:
|
||||
class TestBulkDownloadPermissionChecksRootDocument:
|
||||
def test_download_requires_global_view_permission(
|
||||
self,
|
||||
rest_api_client,
|
||||
paperless_dirs,
|
||||
_media_settings,
|
||||
):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
rest_api_client: APIClient,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
owner = UserFactory(username="owner")
|
||||
requester = UserFactory(username="requester")
|
||||
root = DocumentFactory(owner=owner)
|
||||
root.source_path.write_bytes(b"%PDF-1.4 test")
|
||||
assign_perm("view_document", requester, root)
|
||||
grant_object(requester, root, "view_document")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
|
||||
response = rest_api_client.post(
|
||||
@@ -390,21 +387,18 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
||||
|
||||
def test_permission_checked_on_root_not_on_version(
|
||||
self,
|
||||
rest_api_client,
|
||||
paperless_dirs,
|
||||
_media_settings,
|
||||
):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
rest_api_client: APIClient,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
owner = UserFactory(username="owner")
|
||||
requester = UserFactory(username="requester")
|
||||
grant_global(requester, "view_document")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
root = DocumentFactory(owner=owner)
|
||||
# a version of root that the requester has NOT been individually granted
|
||||
version = DocumentFactory(owner=owner, root_document=root, version_index=1)
|
||||
version.source_path.write_bytes(b"%PDF-1.4 test")
|
||||
assign_perm("view_document", requester, root) # granted on ROOT only
|
||||
grant_object(requester, root, "view_document") # granted on ROOT only
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/documents/bulk_download/",
|
||||
@@ -422,11 +416,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
||||
# root-or-version bug; a user with no grant at all (the old
|
||||
# `stranger` case) can't tell the two apart, since they're denied
|
||||
# either way.
|
||||
version_only_grantee = User.objects.create_user(username="version_only_grantee")
|
||||
version_only_grantee.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
assign_perm("view_document", version_only_grantee, version)
|
||||
version_only_grantee = UserFactory(username="version_only_grantee")
|
||||
grant_global(version_only_grantee, "view_document")
|
||||
grant_object(version_only_grantee, version, "view_document")
|
||||
rest_api_client.force_authenticate(user=version_only_grantee)
|
||||
response = rest_api_client.post(
|
||||
"/api/documents/bulk_download/",
|
||||
@@ -438,6 +430,53 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
||||
) # version-only grant must not substitute for root permission
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDocumentOperationPermissionChecksRootDocument:
|
||||
@pytest.mark.parametrize(
|
||||
("endpoint", "payload"),
|
||||
[
|
||||
pytest.param("/api/documents/merge/", {}, id="merge"),
|
||||
pytest.param("/api/documents/rotate/", {"degrees": 90}, id="rotate"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("version_owner", ["none", "requester"])
|
||||
def test_version_operation_acts_on_root(
|
||||
self,
|
||||
rest_api_client: APIClient,
|
||||
endpoint: str,
|
||||
payload: dict,
|
||||
version_owner: str,
|
||||
) -> None:
|
||||
owner = UserFactory(username="owner")
|
||||
requester = UserFactory(username="requester")
|
||||
grant_global(requester, "change_document")
|
||||
grant_global(requester, "add_document")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
root = DocumentFactory(owner=owner)
|
||||
# A version whose owner went stale, e.g. created before the root changed hands
|
||||
version = DocumentFactory(
|
||||
owner=requester if version_owner == "requester" else None,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("documents.views.bulk_edit.bulk_edit.merge") as mock_merge,
|
||||
patch("documents.views.bulk_edit.bulk_edit.rotate") as mock_rotate,
|
||||
):
|
||||
mock_merge.__name__ = "merge"
|
||||
mock_rotate.__name__ = "rotate"
|
||||
response = rest_api_client.post(
|
||||
endpoint,
|
||||
{"documents": [version.pk], **payload},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
mock_merge.assert_not_called()
|
||||
mock_rotate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("_search_index")
|
||||
class TestTrashRestorePermissionBoundary:
|
||||
@@ -445,14 +484,12 @@ class TestTrashRestorePermissionBoundary:
|
||||
self,
|
||||
rest_api_client,
|
||||
):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="delete_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
requester = UserFactory(username="requester")
|
||||
grant_global(requester, "delete_document")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
doc = DocumentFactory(owner=owner)
|
||||
assign_perm("view_document", requester, doc) # view only, NOT delete
|
||||
grant_object(requester, doc, "view_document") # view only, NOT delete
|
||||
doc.delete()
|
||||
|
||||
response = rest_api_client.post(
|
||||
@@ -466,14 +503,12 @@ class TestTrashRestorePermissionBoundary:
|
||||
self,
|
||||
rest_api_client,
|
||||
):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="delete_document"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
requester = UserFactory(username="requester")
|
||||
grant_global(requester, "delete_document")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
doc = DocumentFactory(owner=owner)
|
||||
assign_perm("delete_document", requester, doc)
|
||||
grant_object(requester, doc, "delete_document")
|
||||
doc.delete()
|
||||
|
||||
response = rest_api_client.post(
|
||||
@@ -484,11 +519,11 @@ class TestTrashRestorePermissionBoundary:
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
def test_restore_requires_global_delete_permission(self, rest_api_client):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
owner = UserFactory(username="owner")
|
||||
requester = UserFactory(username="requester")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
doc = DocumentFactory(owner=owner)
|
||||
assign_perm("delete_document", requester, doc)
|
||||
grant_object(requester, doc, "delete_document")
|
||||
doc.delete()
|
||||
|
||||
response = rest_api_client.post(
|
||||
@@ -513,14 +548,12 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
||||
"""
|
||||
|
||||
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
||||
owner = User.objects.create_user(username="trash_owner")
|
||||
grantee = User.objects.create_user(username="trash_grantee")
|
||||
grantee.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
owner = UserFactory(username="trash_owner")
|
||||
grantee = UserFactory(username="trash_grantee")
|
||||
grant_global(grantee, "view_document")
|
||||
doc = DocumentFactory(owner=owner)
|
||||
doc.delete() # soft delete
|
||||
assign_perm("view_document", grantee, doc)
|
||||
grant_object(grantee, doc, "view_document")
|
||||
|
||||
rest_api_client.force_authenticate(user=grantee)
|
||||
response = rest_api_client.get("/api/trash/")
|
||||
@@ -542,8 +575,8 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
||||
)
|
||||
class TestPermittedObjectIdsGenericModels:
|
||||
def test_owner_sees_own_object(self, model, factory, perm):
|
||||
owner = User.objects.create_user(username=f"owner_{model.__name__}")
|
||||
stranger = User.objects.create_user(username=f"stranger_{model.__name__}")
|
||||
owner = UserFactory(username=f"owner_{model.__name__}")
|
||||
stranger = UserFactory(username=f"stranger_{model.__name__}")
|
||||
owned = factory(owner=owner)
|
||||
strangers = factory(owner=stranger)
|
||||
|
||||
@@ -556,14 +589,14 @@ class TestPermittedObjectIdsGenericModels:
|
||||
@pytest.mark.parametrize("is_superuser", [False, True])
|
||||
def test_inactive_user_sees_nothing(self, model, factory, perm, is_superuser):
|
||||
suffix = f"{model.__name__}_{is_superuser}"
|
||||
user = User.objects.create_user(
|
||||
user = UserFactory(
|
||||
username=f"inactive_{suffix}",
|
||||
is_active=False,
|
||||
is_superuser=is_superuser,
|
||||
)
|
||||
other = User.objects.create_user(username=f"other_{suffix}")
|
||||
other = UserFactory(username=f"other_{suffix}")
|
||||
granted = factory(owner=other)
|
||||
assign_perm(perm, user, granted)
|
||||
grant_object(user, granted, perm)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(user, model, perm),
|
||||
@@ -576,7 +609,7 @@ class TestPermittedObjectIdsGenericModels:
|
||||
)
|
||||
|
||||
def test_unowned_object_visible_to_everyone(self, model, factory, perm):
|
||||
user = User.objects.create_user(username=f"user_{model.__name__}")
|
||||
user = UserFactory(username=f"user_{model.__name__}")
|
||||
unowned = factory(owner=None)
|
||||
|
||||
assert_visible_document_ids(
|
||||
@@ -586,12 +619,12 @@ class TestPermittedObjectIdsGenericModels:
|
||||
)
|
||||
|
||||
def test_explicit_permission_grants_visibility(self, model, factory, perm):
|
||||
owner = User.objects.create_user(username=f"owner2_{model.__name__}")
|
||||
grantee = User.objects.create_user(username=f"grantee_{model.__name__}")
|
||||
stranger = User.objects.create_user(username=f"stranger2_{model.__name__}")
|
||||
owner = UserFactory(username=f"owner2_{model.__name__}")
|
||||
grantee = UserFactory(username=f"grantee_{model.__name__}")
|
||||
stranger = UserFactory(username=f"stranger2_{model.__name__}")
|
||||
shared = factory(owner=owner)
|
||||
not_shared = factory(owner=owner)
|
||||
assign_perm(perm, grantee, shared)
|
||||
grant_object(grantee, shared, perm)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(grantee, model, perm),
|
||||
@@ -610,13 +643,13 @@ class TestPermittedObjectIdsGenericModels:
|
||||
factory,
|
||||
perm,
|
||||
):
|
||||
owner = User.objects.create_user(username=f"owner3_{model.__name__}")
|
||||
member = User.objects.create_user(username=f"member_{model.__name__}")
|
||||
non_member = User.objects.create_user(username=f"nonmember_{model.__name__}")
|
||||
owner = UserFactory(username=f"owner3_{model.__name__}")
|
||||
member = UserFactory(username=f"member_{model.__name__}")
|
||||
non_member = UserFactory(username=f"nonmember_{model.__name__}")
|
||||
group = Group.objects.create(name=f"group_{model.__name__}")
|
||||
member.groups.add(group)
|
||||
shared = factory(owner=owner)
|
||||
assign_perm(perm, group, shared)
|
||||
grant_object(group, shared, perm)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(member, model, perm),
|
||||
@@ -630,8 +663,8 @@ class TestPermittedObjectIdsGenericModels:
|
||||
)
|
||||
|
||||
def test_superuser_sees_everything(self, model, factory, perm):
|
||||
superuser = User.objects.create_superuser(username=f"root_{model.__name__}")
|
||||
owner = User.objects.create_user(username=f"owner4_{model.__name__}")
|
||||
superuser = UserFactory(username=f"root_{model.__name__}", superuser=True)
|
||||
owner = UserFactory(username=f"owner4_{model.__name__}")
|
||||
obj = factory(owner=owner)
|
||||
|
||||
assert_visible_document_ids(
|
||||
@@ -644,8 +677,8 @@ class TestPermittedObjectIdsGenericModels:
|
||||
@pytest.mark.django_db
|
||||
class TestMatchingRespectsObjectPermissions:
|
||||
def test_match_tags_only_considers_tags_visible_to_user(self):
|
||||
owner = User.objects.create_user(username="tag_owner")
|
||||
classifying_user = User.objects.create_user(username="classifier_user")
|
||||
owner = UserFactory(username="tag_owner")
|
||||
classifying_user = UserFactory(username="classifier_user")
|
||||
visible_tag = TagFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
@@ -656,7 +689,7 @@ class TestMatchingRespectsObjectPermissions:
|
||||
match="invoice",
|
||||
matching_algorithm=Tag.MATCH_LITERAL,
|
||||
)
|
||||
assign_perm("view_tag", classifying_user, visible_tag)
|
||||
grant_object(classifying_user, visible_tag, "view_tag")
|
||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||
|
||||
matched = match_tags(doc, classifier=None, user=classifying_user)
|
||||
@@ -665,8 +698,8 @@ class TestMatchingRespectsObjectPermissions:
|
||||
assert hidden_tag.pk not in matched_ids
|
||||
|
||||
def test_match_correspondents_only_considers_correspondents_visible_to_user(self):
|
||||
owner = User.objects.create_user(username="correspondent_owner")
|
||||
classifying_user = User.objects.create_user(username="classifier_user2")
|
||||
owner = UserFactory(username="correspondent_owner")
|
||||
classifying_user = UserFactory(username="classifier_user2")
|
||||
visible_correspondent = CorrespondentFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
@@ -677,7 +710,7 @@ class TestMatchingRespectsObjectPermissions:
|
||||
match="invoice",
|
||||
matching_algorithm=Correspondent.MATCH_LITERAL,
|
||||
)
|
||||
assign_perm("view_correspondent", classifying_user, visible_correspondent)
|
||||
grant_object(classifying_user, visible_correspondent, "view_correspondent")
|
||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||
|
||||
matched = match_correspondents(doc, classifier=None, user=classifying_user)
|
||||
@@ -686,8 +719,8 @@ class TestMatchingRespectsObjectPermissions:
|
||||
assert hidden_correspondent.pk not in matched_ids
|
||||
|
||||
def test_match_document_types_only_considers_document_types_visible_to_user(self):
|
||||
owner = User.objects.create_user(username="document_type_owner")
|
||||
classifying_user = User.objects.create_user(username="classifier_user3")
|
||||
owner = UserFactory(username="document_type_owner")
|
||||
classifying_user = UserFactory(username="classifier_user3")
|
||||
visible_document_type = DocumentTypeFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
@@ -698,7 +731,7 @@ class TestMatchingRespectsObjectPermissions:
|
||||
match="invoice",
|
||||
matching_algorithm=DocumentType.MATCH_LITERAL,
|
||||
)
|
||||
assign_perm("view_documenttype", classifying_user, visible_document_type)
|
||||
grant_object(classifying_user, visible_document_type, "view_documenttype")
|
||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||
|
||||
matched = match_document_types(doc, classifier=None, user=classifying_user)
|
||||
@@ -707,8 +740,8 @@ class TestMatchingRespectsObjectPermissions:
|
||||
assert hidden_document_type.pk not in matched_ids
|
||||
|
||||
def test_match_storage_paths_only_considers_storage_paths_visible_to_user(self):
|
||||
owner = User.objects.create_user(username="storage_path_owner")
|
||||
classifying_user = User.objects.create_user(username="classifier_user4")
|
||||
owner = UserFactory(username="storage_path_owner")
|
||||
classifying_user = UserFactory(username="classifier_user4")
|
||||
visible_storage_path = StoragePathFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
@@ -719,7 +752,7 @@ class TestMatchingRespectsObjectPermissions:
|
||||
match="invoice",
|
||||
matching_algorithm=StoragePath.MATCH_LITERAL,
|
||||
)
|
||||
assign_perm("view_storagepath", classifying_user, visible_storage_path)
|
||||
grant_object(classifying_user, visible_storage_path, "view_storagepath")
|
||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||
|
||||
matched = match_storage_paths(doc, classifier=None, user=classifying_user)
|
||||
@@ -731,14 +764,12 @@ class TestMatchingRespectsObjectPermissions:
|
||||
@pytest.mark.django_db
|
||||
class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
||||
def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client):
|
||||
owner = User.objects.create_user(username="tags_owner")
|
||||
requester = User.objects.create_user(username="tags_requester")
|
||||
new_owner = User.objects.create_user(username="tags_new_owner")
|
||||
owner = UserFactory(username="tags_owner")
|
||||
requester = UserFactory(username="tags_requester")
|
||||
new_owner = UserFactory(username="tags_new_owner")
|
||||
# grant the global change_tag permission so the object-level
|
||||
# filtering (not the global has_perm check) is what's under test
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_tag"),
|
||||
)
|
||||
grant_global(requester, "change_tag")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
visible = TagFactory(owner=requester)
|
||||
hidden = TagFactory(owner=owner)
|
||||
@@ -771,16 +802,14 @@ class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
||||
request rather than being silently skipped. Editing permissions is
|
||||
limited to the owner, same as documents.
|
||||
"""
|
||||
owner = User.objects.create_user(username="shared_tags_owner")
|
||||
requester = User.objects.create_user(username="shared_tags_requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_tag"),
|
||||
)
|
||||
owner = UserFactory(username="shared_tags_owner")
|
||||
requester = UserFactory(username="shared_tags_requester")
|
||||
grant_global(requester, "change_tag")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
owned = TagFactory(owner=requester)
|
||||
shared = TagFactory(owner=owner)
|
||||
assign_perm("view_tag", requester, shared)
|
||||
assign_perm("change_tag", requester, shared)
|
||||
grant_object(requester, shared, "view_tag")
|
||||
grant_object(requester, shared, "change_tag")
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
@@ -831,14 +860,12 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||
would pass/fail based on FK cascade behavior, not on whether the
|
||||
descendant-expansion logic itself respected per-object permissions.
|
||||
"""
|
||||
owner = User.objects.create_user(username="tag_hierarchy_owner")
|
||||
requester = User.objects.create_user(username="tag_hierarchy_requester")
|
||||
new_owner = User.objects.create_user(username="tag_hierarchy_new_owner")
|
||||
owner = UserFactory(username="tag_hierarchy_owner")
|
||||
requester = UserFactory(username="tag_hierarchy_requester")
|
||||
new_owner = UserFactory(username="tag_hierarchy_new_owner")
|
||||
# global change_tag permission so the has_perm() gate passes and the
|
||||
# object-level permitted_object_ids filtering is what's under test
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_tag"),
|
||||
)
|
||||
grant_global(requester, "change_tag")
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
|
||||
parent = TagFactory(owner=requester, name="parent-tag")
|
||||
@@ -890,7 +917,7 @@ class TestRestrictQuerysetToVisible:
|
||||
- The queryset is returned unfiltered, rather than
|
||||
permitted_object_ids(None, ...)'s narrower "unowned rows only"
|
||||
"""
|
||||
owner = User.objects.create_user(username="vis_none_owner")
|
||||
owner = UserFactory(username="vis_none_owner")
|
||||
tag = TagFactory(owner=owner)
|
||||
|
||||
visible = restrict_queryset_to_visible(Tag.objects.all(), None, "view_tag")
|
||||
@@ -907,8 +934,8 @@ class TestRestrictQuerysetToVisible:
|
||||
- The queryset is returned unfiltered, skipping the permission
|
||||
lookup entirely
|
||||
"""
|
||||
superuser = User.objects.create_superuser(username="vis_active_super")
|
||||
owner = User.objects.create_user(username="vis_active_super_owner")
|
||||
superuser = UserFactory(username="vis_active_super", superuser=True)
|
||||
owner = UserFactory(username="vis_active_super_owner")
|
||||
tag = TagFactory(owner=owner)
|
||||
|
||||
visible = restrict_queryset_to_visible(
|
||||
@@ -930,7 +957,7 @@ class TestRestrictQuerysetToVisible:
|
||||
deactivation has to win over the superuser shortcut, matching
|
||||
permitted_object_ids's own ordering
|
||||
"""
|
||||
user = User.objects.create_user(
|
||||
user = UserFactory(
|
||||
username="vis_inactive_super",
|
||||
is_active=False,
|
||||
is_superuser=True,
|
||||
@@ -951,8 +978,8 @@ class TestRestrictQuerysetToVisible:
|
||||
THEN:
|
||||
- Only the rows permitted_object_ids() reports are visible
|
||||
"""
|
||||
user = User.objects.create_user(username="vis_regular")
|
||||
other = User.objects.create_user(username="vis_regular_other")
|
||||
user = UserFactory(username="vis_regular")
|
||||
other = UserFactory(username="vis_regular_other")
|
||||
own = TagFactory(owner=user)
|
||||
hidden = TagFactory(owner=other)
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import User
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from documents.filters import PermittedObjectsFilter
|
||||
from documents.models import Tag
|
||||
from documents.tests.factories import TagFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class _DummyView:
|
||||
@@ -15,8 +15,8 @@ class _DummyView:
|
||||
@pytest.mark.django_db
|
||||
class TestPermittedObjectsFilter:
|
||||
def test_superuser_bypasses_filtering_entirely(self):
|
||||
superuser = User.objects.create_superuser(username="root")
|
||||
owner = User.objects.create_user(username="owner")
|
||||
superuser = UserFactory(username="root", superuser=True)
|
||||
owner = UserFactory(username="owner")
|
||||
TagFactory(owner=owner)
|
||||
request = APIRequestFactory().get("/")
|
||||
request.user = superuser
|
||||
@@ -29,13 +29,13 @@ class TestPermittedObjectsFilter:
|
||||
assert result.count() == Tag.objects.count()
|
||||
|
||||
def test_non_superuser_sees_only_owned_unowned_and_granted(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
grantee = User.objects.create_user(username="grantee")
|
||||
owner = UserFactory(username="owner")
|
||||
grantee = UserFactory(username="grantee")
|
||||
owned = TagFactory(owner=grantee)
|
||||
unowned = TagFactory(owner=None)
|
||||
granted = TagFactory(owner=owner)
|
||||
hidden = TagFactory(owner=owner)
|
||||
assign_perm("view_tag", grantee, granted)
|
||||
grant_object(grantee, granted, "view_tag")
|
||||
request = APIRequestFactory().get("/")
|
||||
request.user = grantee
|
||||
|
||||
@@ -49,11 +49,11 @@ class TestPermittedObjectsFilter:
|
||||
assert hidden.pk not in visible_ids
|
||||
|
||||
def test_include_granted_false_excludes_explicitly_shared_objects(self):
|
||||
owner = User.objects.create_user(username="owner2")
|
||||
grantee = User.objects.create_user(username="grantee2")
|
||||
owner = UserFactory(username="owner2")
|
||||
grantee = UserFactory(username="grantee2")
|
||||
owned = TagFactory(owner=grantee)
|
||||
granted = TagFactory(owner=owner)
|
||||
assign_perm("view_tag", grantee, granted)
|
||||
grant_object(grantee, granted, "view_tag")
|
||||
request = APIRequestFactory().get("/")
|
||||
request.user = grantee
|
||||
|
||||
@@ -74,15 +74,15 @@ class TestPermittedObjectsFilter:
|
||||
[("inactive", False), ("inactive_super", True)],
|
||||
)
|
||||
def test_inactive_user_sees_nothing(self, username: str, *, is_superuser: bool):
|
||||
user = User.objects.create_user(
|
||||
user = UserFactory(
|
||||
username=username,
|
||||
is_active=False,
|
||||
is_superuser=is_superuser,
|
||||
)
|
||||
TagFactory(owner=None)
|
||||
TagFactory(owner=user)
|
||||
granted = TagFactory(owner=User.objects.create_user(username=f"o_{username}"))
|
||||
assign_perm("view_tag", user, granted)
|
||||
granted = TagFactory(owner=UserFactory(username=f"o_{username}"))
|
||||
grant_object(user, granted, "view_tag")
|
||||
request = APIRequestFactory().get("/")
|
||||
request.user = user
|
||||
|
||||
@@ -94,7 +94,7 @@ class TestPermittedObjectsFilter:
|
||||
assert result.count() == 0
|
||||
|
||||
def test_inactive_user_sees_nothing_with_include_granted_false(self):
|
||||
user = User.objects.create_user(username="inactive_owner", is_active=False)
|
||||
user = UserFactory(username="inactive_owner", is_active=False)
|
||||
TagFactory(owner=user)
|
||||
TagFactory(owner=None)
|
||||
request = APIRequestFactory().get("/")
|
||||
|
||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from documents.models import Document
|
||||
from documents.tests.conftest import PaperlessDirs
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
class TestSanityCheckMessages:
|
||||
@@ -46,14 +46,14 @@ class TestSanityCheckMessages:
|
||||
class TestCheckSanityNoDocuments:
|
||||
"""Sanity checks against an empty archive."""
|
||||
|
||||
@pytest.mark.usefixtures("_media_settings")
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
def test_no_documents(self) -> None:
|
||||
messages = check_sanity()
|
||||
assert not messages.has_error
|
||||
assert not messages.has_warning
|
||||
assert messages.total_issue_count == 0
|
||||
|
||||
@pytest.mark.usefixtures("_media_settings")
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
def test_no_issues_logs_clean(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
messages = check_sanity()
|
||||
with caplog.at_level(logging.INFO, logger="paperless.sanity_checker"):
|
||||
@@ -214,18 +214,17 @@ class TestCheckSanityOrphans:
|
||||
sample_doc: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
(paperless_dirs.originals / "orphan.pdf").touch()
|
||||
(paperless_dirs.originals_dir / "orphan.pdf").touch()
|
||||
messages = check_sanity()
|
||||
assert messages.has_warning
|
||||
assert any("Orphaned file" in m["message"] for m in messages[None])
|
||||
|
||||
@pytest.mark.usefixtures("_media_settings")
|
||||
def test_ignorable_files_not_flagged(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
(paperless_dirs.media / ".DS_Store").touch()
|
||||
(paperless_dirs.media / "desktop.ini").touch()
|
||||
(paperless_dirs.media_dir / ".DS_Store").touch()
|
||||
(paperless_dirs.media_dir / "desktop.ini").touch()
|
||||
messages = check_sanity()
|
||||
assert not messages.has_warning
|
||||
|
||||
@@ -269,13 +268,13 @@ class TestCheckSanityLogMessages:
|
||||
paperless_dirs: PaperlessDirs,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
(paperless_dirs.originals / "orphan.pdf").touch()
|
||||
(paperless_dirs.originals_dir / "orphan.pdf").touch()
|
||||
messages = check_sanity()
|
||||
with caplog.at_level(logging.WARNING, logger="paperless.sanity_checker"):
|
||||
messages.log_messages()
|
||||
assert "Orphaned file" in caplog.text
|
||||
|
||||
@pytest.mark.usefixtures("_media_settings")
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
def test_logs_unknown_doc_pk(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A doc PK not in the DB logs 'Unknown' as the title."""
|
||||
messages = check_sanity()
|
||||
|
||||
@@ -6,10 +6,8 @@ from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import serializers
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -17,11 +15,14 @@ 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 documents.tests.factories import DocumentFactory
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
@@ -29,11 +30,11 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.create_superuser(username="bundle_admin")
|
||||
self.user = UserFactory(username="bundle_admin", superuser=True)
|
||||
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],
|
||||
@@ -50,18 +51,16 @@ 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,
|
||||
) -> None:
|
||||
owner = User.objects.create_user(username="document_owner")
|
||||
requester = User.objects.create_user(username="bundle_creator")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="add_sharelinkbundle"),
|
||||
)
|
||||
owner = UserFactory(username="document_owner")
|
||||
requester = UserFactory(username="bundle_creator")
|
||||
grant_global(requester, "add_sharelinkbundle")
|
||||
document = DocumentFactory.create(owner=owner)
|
||||
assign_perm("view_document", requester, document)
|
||||
grant_object(requester, document, "view_document")
|
||||
self.client.force_authenticate(requester)
|
||||
payload = {
|
||||
"document_ids": [document.pk],
|
||||
@@ -72,9 +71,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_global(requester, "view_document")
|
||||
requester = User.objects.get(pk=requester.pk)
|
||||
self.client.force_authenticate(requester)
|
||||
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||
@@ -93,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],
|
||||
@@ -107,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",
|
||||
@@ -342,15 +339,6 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.document.archive_checksum = ""
|
||||
self.document.save()
|
||||
self.addCleanup(
|
||||
setattr,
|
||||
settings,
|
||||
"SHARE_LINK_BUNDLE_DIR",
|
||||
settings.SHARE_LINK_BUNDLE_DIR,
|
||||
)
|
||||
settings.SHARE_LINK_BUNDLE_DIR = (
|
||||
Path(settings.MEDIA_ROOT) / "documents" / "share_link_bundles"
|
||||
)
|
||||
|
||||
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
|
||||
if archive:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -11,18 +9,18 @@ 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 documents.tests.utils import DirectoriesMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
|
||||
|
||||
class TestTagHierarchyPermissions(APITestCase):
|
||||
def test_children_only_include_visible_tags(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="view_tag"),
|
||||
)
|
||||
owner = UserFactory(username="owner")
|
||||
requester = UserFactory(username="requester")
|
||||
grant_global(requester, "view_tag")
|
||||
parent = Tag.objects.create(name="Visible parent", owner=requester)
|
||||
hidden_child = Tag.objects.create(
|
||||
name="Hidden child",
|
||||
@@ -49,7 +47,7 @@ class TestTagHierarchyPermissions(APITestCase):
|
||||
class TestTagHierarchy(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.create_superuser(username="admin")
|
||||
self.user = UserFactory(username="admin", superuser=True)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.parent = Tag.objects.create(name="Parent")
|
||||
|
||||
@@ -17,7 +17,8 @@ from documents.signals.handlers import task_failure_handler
|
||||
from documents.signals.handlers import task_postrun_handler
|
||||
from documents.signals.handlers import task_prerun_handler
|
||||
from documents.signals.handlers import task_revoked_handler
|
||||
from documents.tests.factories import PaperlessTaskFactory
|
||||
from paperless_testing.factories import PaperlessTaskFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -34,8 +35,8 @@ def consume_input_doc():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def consume_overrides(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="testuser")
|
||||
def consume_overrides():
|
||||
user = UserFactory(username="testuser")
|
||||
overrides = mock.MagicMock(spec=DocumentMetadataOverrides)
|
||||
overrides.owner_id = user.id
|
||||
return overrides
|
||||
|
||||
@@ -17,9 +17,9 @@ from documents.models import Tag
|
||||
from documents.models import WorkflowAction
|
||||
from documents.sanity_checker import SanityCheckFailedException
|
||||
from documents.sanity_checker import SanityCheckMessages
|
||||
from documents.tests.test_classifier import dummy_preprocess
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from documents.tests.helpers import dummy_preprocess
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -14,8 +14,8 @@ from documents.conditionals import preview_etag
|
||||
from documents.conditionals import thumbnail_etag
|
||||
from documents.conditionals import thumbnail_last_modified
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.versioning import resolve_effective_document_by_pk
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.request import Request
|
||||
|
||||
@@ -15,7 +15,6 @@ from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import status
|
||||
|
||||
from documents.caching import get_llm_suggestion_cache
|
||||
@@ -29,11 +28,14 @@ from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import UiSettings
|
||||
from documents.signals.handlers import update_llm_suggestions_cache
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless_ai.exceptions import LLMProviderError
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.http import read_streaming_response
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class TestViews(DirectoriesMixin, TestCase):
|
||||
@@ -43,7 +45,7 @@ class TestViews(DirectoriesMixin, TestCase):
|
||||
ApplicationConfiguration.objects.get_or_create()
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user("testuser")
|
||||
self.user = UserFactory(username="testuser")
|
||||
super().setUp()
|
||||
|
||||
def test_login_redirect(self) -> None:
|
||||
@@ -141,9 +143,7 @@ class TestViews(DirectoriesMixin, TestCase):
|
||||
codename__contains="sharelink",
|
||||
)
|
||||
self.user.user_permissions.add(*sharelink_permissions)
|
||||
self.user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_global(self.user, "view_document")
|
||||
self.user.save()
|
||||
|
||||
self.client.force_login(self.user)
|
||||
@@ -205,9 +205,7 @@ class TestViews(DirectoriesMixin, TestCase):
|
||||
codename__contains="sharelink",
|
||||
)
|
||||
self.user.user_permissions.add(*sharelink_permissions)
|
||||
self.user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
grant_global(self.user, "view_document")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
create_response = self.client.post(
|
||||
@@ -240,16 +238,16 @@ class TestViews(DirectoriesMixin, TestCase):
|
||||
group2 = Group.objects.create(name="group2")
|
||||
group3 = Group.objects.create(name="group3")
|
||||
t1 = Tag.objects.create(name="invoice", pk=1)
|
||||
assign_perm("view_tag", self.user, t1)
|
||||
assign_perm("view_tag", user2, t1)
|
||||
assign_perm("view_tag", user3, t1)
|
||||
assign_perm("view_tag", group1, t1)
|
||||
assign_perm("view_tag", group2, t1)
|
||||
assign_perm("view_tag", group3, t1)
|
||||
assign_perm("change_tag", self.user, t1)
|
||||
assign_perm("change_tag", user2, t1)
|
||||
assign_perm("change_tag", group1, t1)
|
||||
assign_perm("change_tag", group2, t1)
|
||||
grant_object(self.user, t1, "view_tag")
|
||||
grant_object(user2, t1, "view_tag")
|
||||
grant_object(user3, t1, "view_tag")
|
||||
grant_object(group1, t1, "view_tag")
|
||||
grant_object(group2, t1, "view_tag")
|
||||
grant_object(group3, t1, "view_tag")
|
||||
grant_object(self.user, t1, "change_tag")
|
||||
grant_object(user2, t1, "change_tag")
|
||||
grant_object(group1, t1, "change_tag")
|
||||
grant_object(group2, t1, "change_tag")
|
||||
|
||||
Tag.objects.create(name="bank statement", pk=2)
|
||||
d1 = Document.objects.create(
|
||||
@@ -338,7 +336,7 @@ class TestViews(DirectoriesMixin, TestCase):
|
||||
|
||||
class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_superuser(username="testuser")
|
||||
self.user = UserFactory(username="testuser", superuser=True)
|
||||
self.document = Document.objects.create(
|
||||
title="Test Document",
|
||||
filename="test.pdf",
|
||||
@@ -350,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",
|
||||
@@ -399,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",
|
||||
@@ -425,14 +423,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
requester; the invisible tag id does not leak into either the
|
||||
matched or suggested tags
|
||||
"""
|
||||
tag_owner = User.objects.create_user(username="cache_tag_owner")
|
||||
tag_owner = UserFactory(username="cache_tag_owner")
|
||||
invisible_tag = Tag.objects.create(name="cache_restricted", owner=tag_owner)
|
||||
requester = User.objects.create_user(username="cache_requester")
|
||||
requester.user_permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_document", "change_document", "view_tag"],
|
||||
),
|
||||
)
|
||||
requester = UserFactory(username="cache_requester")
|
||||
grant_global(requester, "view_document", "change_document", "view_tag")
|
||||
mock_get_cache.return_value = MagicMock(
|
||||
suggestions={
|
||||
"title": "Untitled",
|
||||
@@ -453,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",
|
||||
@@ -503,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",
|
||||
@@ -541,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",
|
||||
@@ -580,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",
|
||||
@@ -616,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",
|
||||
@@ -637,7 +631,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
- The classification runs with the second user's visibility
|
||||
context without evicting the first user's result
|
||||
"""
|
||||
second_user = User.objects.create_superuser(username="second_user")
|
||||
second_user = UserFactory(username="second_user", superuser=True)
|
||||
empty_choices = {
|
||||
"tags": {"existing_ids": [], "new_names": []},
|
||||
"correspondents": {"existing_ids": [], "new_names": []},
|
||||
@@ -688,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",
|
||||
@@ -717,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",
|
||||
@@ -744,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",
|
||||
@@ -776,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",
|
||||
@@ -814,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",
|
||||
@@ -853,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",
|
||||
@@ -875,14 +869,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
permission filtering survives the full request path
|
||||
- it does not appear in either the matched or suggested tags
|
||||
"""
|
||||
tag_owner = User.objects.create_user(username="tagowner")
|
||||
tag_owner = UserFactory(username="tagowner")
|
||||
invisible_tag = Tag.objects.create(name="restricted", owner=tag_owner)
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_document", "change_document", "view_tag"],
|
||||
),
|
||||
)
|
||||
requester = UserFactory(username="requester")
|
||||
grant_global(requester, "view_document", "change_document", "view_tag")
|
||||
|
||||
mock_get_ai_classification.return_value = {
|
||||
"title": "Untitled",
|
||||
@@ -956,7 +946,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
||||
ENDPOINT = "/api/documents/chat/"
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user(username="testuser", password="pass")
|
||||
self.user = UserFactory(username="testuser", password="pass")
|
||||
self.client.force_login(user=self.user)
|
||||
self.document = Document.objects.create(
|
||||
title="Test Document",
|
||||
@@ -966,9 +956,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
||||
super().setUp()
|
||||
|
||||
def grant_view_document_permission(self) -> None:
|
||||
self.user.user_permissions.add(
|
||||
*Permission.objects.filter(codename="view_document"),
|
||||
)
|
||||
grant_global(self.user, "view_document")
|
||||
|
||||
@override_settings(AI_ENABLED=False)
|
||||
def test_post_ai_disabled(self) -> None:
|
||||
@@ -981,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()
|
||||
@@ -1001,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,
|
||||
@@ -1027,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()
|
||||
@@ -1051,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()
|
||||
@@ -1064,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,
|
||||
@@ -1078,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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,242 +1,16 @@
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import warnings
|
||||
from collections import namedtuple
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.test import TransactionTestCase
|
||||
from django.test import override_settings
|
||||
|
||||
from documents.consumer import AsnCheckPlugin
|
||||
from documents.consumer import ConsumerPlugin
|
||||
from documents.consumer import ConsumerPreflightPlugin
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.parsers import ParseError
|
||||
from documents.plugins.helpers import ProgressStatusOptions
|
||||
|
||||
|
||||
def setup_directories():
|
||||
dirs = namedtuple("Dirs", ())
|
||||
|
||||
dirs.data_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.scratch_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.media_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.consumption_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.static_dir = Path(tempfile.mkdtemp()).resolve()
|
||||
dirs.index_dir = dirs.data_dir / "index"
|
||||
dirs.originals_dir = dirs.media_dir / "documents" / "originals"
|
||||
dirs.thumbnail_dir = dirs.media_dir / "documents" / "thumbnails"
|
||||
dirs.archive_dir = dirs.media_dir / "documents" / "archive"
|
||||
dirs.logging_dir = dirs.data_dir / "log"
|
||||
|
||||
dirs.index_dir.mkdir(parents=True, exist_ok=True)
|
||||
dirs.originals_dir.mkdir(parents=True, exist_ok=True)
|
||||
dirs.thumbnail_dir.mkdir(parents=True, exist_ok=True)
|
||||
dirs.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dirs.logging_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dirs.settings_override = override_settings(
|
||||
DATA_DIR=dirs.data_dir,
|
||||
SCRATCH_DIR=dirs.scratch_dir,
|
||||
MEDIA_ROOT=dirs.media_dir,
|
||||
ORIGINALS_DIR=dirs.originals_dir,
|
||||
THUMBNAIL_DIR=dirs.thumbnail_dir,
|
||||
ARCHIVE_DIR=dirs.archive_dir,
|
||||
CONSUMPTION_DIR=dirs.consumption_dir,
|
||||
LOGGING_DIR=dirs.logging_dir,
|
||||
INDEX_DIR=dirs.index_dir,
|
||||
STATIC_ROOT=dirs.static_dir,
|
||||
MODEL_FILE=dirs.data_dir / "classification_model.pickle",
|
||||
MEDIA_LOCK=dirs.media_dir / "media.lock",
|
||||
)
|
||||
dirs.settings_override.enable()
|
||||
|
||||
return dirs
|
||||
|
||||
|
||||
def remove_dirs(dirs) -> None:
|
||||
shutil.rmtree(dirs.media_dir, ignore_errors=True)
|
||||
shutil.rmtree(dirs.data_dir, ignore_errors=True)
|
||||
shutil.rmtree(dirs.scratch_dir, ignore_errors=True)
|
||||
shutil.rmtree(dirs.consumption_dir, ignore_errors=True)
|
||||
shutil.rmtree(dirs.static_dir, ignore_errors=True)
|
||||
dirs.settings_override.disable()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def paperless_environment():
|
||||
dirs = None
|
||||
try:
|
||||
dirs = setup_directories()
|
||||
yield dirs
|
||||
finally:
|
||||
if dirs:
|
||||
remove_dirs(dirs)
|
||||
|
||||
|
||||
def util_call_with_backoff(
|
||||
method_or_callable: Callable,
|
||||
args: list | tuple,
|
||||
*,
|
||||
skip_on_50x_err=True,
|
||||
) -> tuple[bool, Any]:
|
||||
"""
|
||||
For whatever reason, the images started during the test pipeline like to
|
||||
segfault sometimes, crash and otherwise fail randomly, when run with the
|
||||
exact files that usually pass.
|
||||
|
||||
So, this function will retry the given method/function up to 3 times, with larger backoff
|
||||
periods between each attempt, in hopes the issue resolves itself during
|
||||
one attempt to parse.
|
||||
|
||||
This will wait the following:
|
||||
- Attempt 1 - 20s following failure
|
||||
- Attempt 2 - 40s following failure
|
||||
- Attempt 3 - 80s following failure
|
||||
|
||||
"""
|
||||
result = None
|
||||
succeeded = False
|
||||
retry_time = 20.0
|
||||
retry_count = 0
|
||||
status_codes = []
|
||||
max_retry_count = 3
|
||||
|
||||
while retry_count < max_retry_count and not succeeded:
|
||||
try:
|
||||
result = method_or_callable(*args)
|
||||
|
||||
succeeded = True
|
||||
except ParseError as e: # pragma: no cover
|
||||
cause_exec = e.__cause__
|
||||
if cause_exec is not None and isinstance(cause_exec, httpx.HTTPStatusError):
|
||||
status_codes.append(cause_exec.response.status_code)
|
||||
warnings.warn(
|
||||
f"HTTP Exception for {cause_exec.request.url} - {cause_exec}",
|
||||
)
|
||||
else:
|
||||
warnings.warn(f"Unexpected error: {e}")
|
||||
except Exception as e: # pragma: no cover
|
||||
warnings.warn(f"Unexpected error: {e}")
|
||||
|
||||
retry_count = retry_count + 1
|
||||
|
||||
time.sleep(retry_time)
|
||||
retry_time = retry_time * 2.0
|
||||
|
||||
if (
|
||||
not succeeded
|
||||
and status_codes
|
||||
and skip_on_50x_err
|
||||
and all(httpx.codes.is_server_error(code) for code in status_codes)
|
||||
):
|
||||
pytest.skip("Repeated HTTP 50x for service") # pragma: no cover
|
||||
|
||||
return succeeded, result
|
||||
|
||||
|
||||
def read_streaming_response(response: StreamingHttpResponse) -> bytes:
|
||||
"""Consume a StreamingHttpResponse/FileResponse and close it."""
|
||||
content = b"".join(response.streaming_content)
|
||||
response.close()
|
||||
return content
|
||||
|
||||
|
||||
class DirectoriesMixin:
|
||||
"""
|
||||
Creates and overrides settings for all folders and paths, then ensures
|
||||
they are cleaned up on exit
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
from documents.search import reset_backend
|
||||
|
||||
reset_backend()
|
||||
self.dirs = setup_directories()
|
||||
super().setUp()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
from documents.search import reset_backend
|
||||
|
||||
super().tearDown()
|
||||
reset_backend()
|
||||
remove_dirs(self.dirs)
|
||||
|
||||
|
||||
class FileSystemAssertsMixin:
|
||||
"""
|
||||
Utilities for checks various state information of the file system
|
||||
"""
|
||||
|
||||
def assertIsFile(self, path: PathLike[str] | str) -> None:
|
||||
self.assertTrue(Path(path).resolve().is_file(), f"File does not exist: {path}")
|
||||
|
||||
def assertIsNotFile(self, path: PathLike[str] | str) -> None:
|
||||
self.assertFalse(Path(path).resolve().is_file(), f"File does exist: {path}")
|
||||
|
||||
def assertIsDir(self, path: PathLike[str] | str) -> None:
|
||||
self.assertTrue(Path(path).resolve().is_dir(), f"Dir does not exist: {path}")
|
||||
|
||||
def assertIsNotDir(self, path: PathLike[str] | str) -> None:
|
||||
self.assertFalse(Path(path).resolve().is_dir(), f"Dir does exist: {path}")
|
||||
|
||||
def assertFilesEqual(
|
||||
self,
|
||||
path1: PathLike[str] | str,
|
||||
path2: PathLike[str] | str,
|
||||
) -> None:
|
||||
path1 = Path(path1)
|
||||
path2 = Path(path2)
|
||||
import hashlib
|
||||
|
||||
hash1 = hashlib.sha256(path1.read_bytes()).hexdigest()
|
||||
hash2 = hashlib.sha256(path2.read_bytes()).hexdigest()
|
||||
|
||||
self.assertEqual(hash1, hash2, "File SHA256 mismatch")
|
||||
|
||||
def assertFileCountInDir(self, path: PathLike[str] | str, count: int) -> None:
|
||||
path = Path(path).resolve()
|
||||
self.assertTrue(path.is_dir(), f"Path {path} is not a directory")
|
||||
files = [x for x in path.iterdir() if x.is_file()]
|
||||
self.assertEqual(
|
||||
len(files),
|
||||
count,
|
||||
f"Path {path} contains {len(files)} files instead of {count} files",
|
||||
)
|
||||
|
||||
|
||||
class ConsumerProgressMixin:
|
||||
"""
|
||||
Mocks the Consumer _send_progress, preventing attempts to connect to Redis
|
||||
and allowing access to its calls for verification
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.send_progress_patcher = mock.patch(
|
||||
"documents.consumer.Consumer._send_progress",
|
||||
)
|
||||
self.send_progress_mock = self.send_progress_patcher.start()
|
||||
super().setUp()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
self.send_progress_patcher.stop()
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
|
||||
|
||||
class ConsumeTaskMixin:
|
||||
@@ -274,64 +48,6 @@ class ConsumeTaskMixin:
|
||||
yield (task_kwargs["input_doc"], task_kwargs["overrides"])
|
||||
|
||||
|
||||
class TestMigrations(TransactionTestCase):
|
||||
@property
|
||||
def app(self):
|
||||
return apps.get_containing_app_config(type(self).__module__).name
|
||||
|
||||
migrate_from = None
|
||||
dependencies = None
|
||||
migrate_to = None
|
||||
auto_migrate = True
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
assert self.migrate_from and self.migrate_to, (
|
||||
f"TestCase '{type(self).__name__}' must define migrate_from and migrate_to properties"
|
||||
)
|
||||
self.migrate_from = [(self.app, self.migrate_from)]
|
||||
if self.dependencies is not None:
|
||||
self.migrate_from.extend(self.dependencies)
|
||||
self.migrate_to = [(self.app, self.migrate_to)]
|
||||
executor = MigrationExecutor(connection)
|
||||
old_apps = executor.loader.project_state(self.migrate_from).apps
|
||||
|
||||
# Reverse to the original migration
|
||||
executor.migrate(self.migrate_from)
|
||||
|
||||
self.setUpBeforeMigration(old_apps)
|
||||
|
||||
self.apps = old_apps
|
||||
|
||||
if self.auto_migrate:
|
||||
self.performMigration()
|
||||
|
||||
def performMigration(self) -> None:
|
||||
# Run the migration to test
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph() # reload.
|
||||
executor.migrate(self.migrate_to)
|
||||
|
||||
self.apps = executor.loader.project_state(self.migrate_to).apps
|
||||
|
||||
def setUpBeforeMigration(self, apps) -> None:
|
||||
pass
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""
|
||||
Ensure the database schema is restored to the latest migration after
|
||||
each migration test, so subsequent tests run against HEAD.
|
||||
"""
|
||||
try:
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph()
|
||||
targets = executor.loader.graph.leaf_nodes()
|
||||
executor.migrate(targets)
|
||||
finally:
|
||||
super().tearDown()
|
||||
|
||||
|
||||
class SampleDirMixin:
|
||||
SAMPLE_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
@@ -348,7 +64,7 @@ class GetConsumerMixin:
|
||||
mailrule_id: int | None = None,
|
||||
) -> Generator[ConsumerPlugin, None, None]:
|
||||
# Store this for verification
|
||||
self.status = DummyProgressManager(filepath.name, None)
|
||||
self.status = FakeProgressManager(filepath.name, None)
|
||||
doc = ConsumableDocument(
|
||||
source,
|
||||
original_file=filepath,
|
||||
@@ -384,63 +100,3 @@ class GetConsumerMixin:
|
||||
yield reader
|
||||
finally:
|
||||
reader.cleanup()
|
||||
|
||||
|
||||
class DummyProgressManager:
|
||||
"""
|
||||
A dummy handler for progress management that doesn't actually try to
|
||||
connect to Redis. Payloads are stored for test assertions if needed.
|
||||
|
||||
Use it with
|
||||
mock.patch("documents.tasks.ProgressManager", DummyProgressManager)
|
||||
"""
|
||||
|
||||
def __init__(self, filename: str, task_id: str | None = None) -> None:
|
||||
self.filename = filename
|
||||
self.task_id = task_id
|
||||
self.payloads = []
|
||||
|
||||
def __enter__(self):
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.close()
|
||||
|
||||
def open(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def send_progress(
|
||||
self,
|
||||
status: ProgressStatusOptions,
|
||||
message: str,
|
||||
current_progress: int,
|
||||
max_progress: int,
|
||||
*,
|
||||
document_id: int | None = None,
|
||||
owner_id: int | None = None,
|
||||
users_can_view: list[int] | None = None,
|
||||
groups_can_view: list[int] | None = None,
|
||||
) -> None:
|
||||
# Ensure the layer is open
|
||||
self.open()
|
||||
|
||||
payload = {
|
||||
"type": "status_update",
|
||||
"data": {
|
||||
"filename": self.filename,
|
||||
"task_id": self.task_id,
|
||||
"current_progress": current_progress,
|
||||
"max_progress": max_progress,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"document_id": document_id,
|
||||
"owner_id": owner_id,
|
||||
"users_can_view": users_can_view or [],
|
||||
"groups_can_view": groups_can_view or [],
|
||||
},
|
||||
}
|
||||
self.payloads.append(payload)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user