mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-25 19:00:33 +00:00
Compare commits
39
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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-3
@@ -76,9 +76,7 @@ is not supported by any of the available parsers.
|
||||
|
||||
**A:** Not by default. As of v3, a file whose contents match an existing document is still
|
||||
consumed, and the duplicate is flagged in the UI — open the document and check the
|
||||
**Duplicates** tab to review documents that share the same content, or filter the document
|
||||
list by **Duplicates** to find all of them (see
|
||||
[Duplicate documents](usage.md#duplicate-documents)). If you prefer the old
|
||||
**Duplicates** tab to review documents that share the same content. If you prefer the old
|
||||
behavior of rejecting duplicates during consumption, set
|
||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
||||
to `true`.
|
||||
|
||||
+8
-7
@@ -299,18 +299,19 @@ for details.
|
||||
### Duplicate documents
|
||||
|
||||
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
||||
contents match an existing document (same original or archive checksum), the new copy is
|
||||
still consumed and a warning is logged.
|
||||
contents exactly match an existing document (same checksum), the new copy is still
|
||||
consumed and a warning is logged. The task entry for the upload also flags that a
|
||||
duplicate was detected and links to the existing document(s).
|
||||
|
||||
When a document has duplicates, a **Duplicates** tab appears on its detail page, listing
|
||||
the other documents you can view that share the same content (including any in the trash).
|
||||
To find all documents with duplicates, choose **Duplicates** in the document list's text
|
||||
filter dropdown, or use `has_duplicates=true` in the REST API.
|
||||
To review duplicates, open a document and switch to the **Duplicates** tab on the
|
||||
document detail page. It lists other documents that share the same content, including any
|
||||
that are in the trash (shown with a badge), and links to each so you can decide which to
|
||||
keep.
|
||||
|
||||
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
||||
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
|
||||
a "Document already exists" message linking to the existing document.
|
||||
a "document already exists" message.
|
||||
|
||||
## Document Suggestions
|
||||
|
||||
|
||||
+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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user