mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-07 16:47:13 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f7ed1d4a7 | ||
|
|
0428bf6955 | ||
|
|
662a5237a3 | ||
|
|
562c2d0985 | ||
|
|
6cdb93cd59 | ||
|
|
85fcf66691 | ||
|
|
73b2321e87 | ||
|
|
5eecd6971e | ||
|
|
bc92e4ff98 | ||
|
|
dacc213917 | ||
|
|
c780586c72 | ||
|
|
62cc31fbdb | ||
|
|
312684aef3 | ||
|
|
1d42d372a2 | ||
|
|
08c4f3d3aa | ||
|
|
c63afb47b2 |
No files matched your search
+351
-351
@@ -410,179 +410,180 @@ src/documents/search/_query.py:0: error: Argument 3 to "Term" has incompatible t
|
|||||||
src/documents/search/_query.py:0: error: Argument 3 to "Term" has incompatible type "**dict[str, int | None]"; expected "bool" [arg-type]
|
src/documents/search/_query.py:0: error: Argument 3 to "Term" has incompatible type "**dict[str, int | None]"; expected "bool" [arg-type]
|
||||||
src/documents/search/_query.py:0: error: Unexpected keyword argument "children" for "Node" [call-arg]
|
src/documents/search/_query.py:0: error: Unexpected keyword argument "children" for "Node" [call-arg]
|
||||||
src/documents/search/_schema.py:0: error: Redundant cast to "Path" [redundant-cast]
|
src/documents/search/_schema.py:0: error: Redundant cast to "Path" [redundant-cast]
|
||||||
src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "_meta" [attr-defined]
|
src/documents/search/_schema.py:0: error: Unexpected keyword argument "fast" for "add_json_field" of "SchemaBuilder" [call-arg]
|
||||||
src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "_meta" [attr-defined]
|
||||||
src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
||||||
src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Argument "choices" to "ChoiceField" has incompatible type "type[FieldDataType]"; expected "Sequence[Any]" [arg-type]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Argument "default" to "MultipleChoiceField" has incompatible type "set[DocumentSource]"; expected "set[str | int] | set[str] | set[int] | Callable[[], set[str | int] | set[str] | set[int]] | _Empty | None" [arg-type]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Argument 1 of "get_value" is incompatible with supertype "rest_framework.fields.Field"; supertype defines the argument type as "Mapping[str, Any]" [override]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Argument 1 of "update" is incompatible with supertype "rest_framework.serializers.BaseSerializer"; supertype defines the argument type as "dict[str, Any]" [override]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Argument 1 to "__get__" of "BaseManager" has incompatible type "PrimaryKeyRelatedField[Any]"; expected "Options[Any] | None" [arg-type]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Argument 1 to "get_value_field_name" of "CustomFieldInstance" has incompatible type "str"; expected "FieldDataType" [arg-type]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Argument 1 to "list" has incompatible type "QuerySet[Document, TypedDict({'id': int, 'title': str, 'deleted_at': datetime | None})]"; expected "Iterable[dict[str, Any]]" [arg-type]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Expected iterable as variadic argument [misc]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Item "None" of "Any | None" has no attribute "name" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Item "None" of "Any | None" has no attribute "pk" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Item "list[str]" of "Any | list[str]" has no attribute "values_list" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Missing type arguments for generic type "Iterable" [type-arg]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Need type annotation for "permissions_dict" [var-annotated]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/base.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[Correspondent]", variable has type "type[Tag] | None") [assignment]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[DocumentType]", variable has type "type[Tag] | None") [assignment]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[StoragePath]", variable has type "type[Tag] | None") [assignment]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Argument 1 of "update" is incompatible with supertype "rest_framework.serializers.BaseSerializer"; supertype defines the argument type as "dict[str, Any]" [override]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Incompatible types in assignment (expression has type "type[SearchResultListSerializer]", base class "Meta" defined the type as "type[OwnedObjectListSerializer]") [assignment]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Skipping analyzing "auditlog.context": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/documents.py:0: error: Value of type "Any | None" is not indexable [index]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Argument "choices" to "ChoiceField" has incompatible type "type[FieldDataType]"; expected "Sequence[Any]" [arg-type]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Argument 1 of "get_value" is incompatible with supertype "rest_framework.fields.Field"; supertype defines the argument type as "Mapping[str, Any]" [override]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Argument 1 to "__get__" of "BaseManager" has incompatible type "PrimaryKeyRelatedField[Any]"; expected "Options[Any] | None" [arg-type]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Argument 1 to "get_value_field_name" of "CustomFieldInstance" has incompatible type "str"; expected "FieldDataType" [arg-type]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Expected iterable as variadic argument [misc]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Incompatible types in assignment (expression has type "PrimaryKeyRelatedField[Tag]", base class "Field" defined the type as "BaseSerializer[Any]") [assignment]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Incompatible types in assignment (expression has type "Sequence[str] | tuple[Lower]", variable has type "Sequence[str] | None") [assignment]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Item "None" of "CustomField | None" has no attribute "name" [union-attr]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Missing type arguments for generic type "Field" [type-arg]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Need type annotation for "document" [var-annotated]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any]") [index]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/metadata.py:0: error: Value of type "Any | None" is not indexable [index]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/saved_views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "PrimaryKeyRelatedField[Tag]", base class "Field" defined the type as "BaseSerializer[Any]") [assignment]
|
||||||
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "Sequence[str] | tuple[Lower]", variable has type "Sequence[str] | None") [assignment]
|
||||||
src/documents/serialisers/saved_views.py:0: error: Value of type "Match[str] | None" is not indexable [index]
|
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "WorkflowActionWebhookSerializer", variable has type "WorkflowActionEmailSerializer") [assignment]
|
||||||
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[Correspondent]", variable has type "type[Tag] | None") [assignment]
|
||||||
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[DocumentType]", variable has type "type[Tag] | None") [assignment]
|
||||||
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[SearchResultListSerializer]", base class "Meta" defined the type as "type[OwnedObjectListSerializer]") [assignment]
|
||||||
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[StoragePath]", variable has type "type[Tag] | None") [assignment]
|
||||||
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
|
||||||
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
||||||
src/documents/serialisers/sharing.py:0: error: Need type annotation for "documents" [var-annotated]
|
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/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Item "None" of "Any | None" has no attribute "name" [union-attr]
|
||||||
src/documents/serialisers/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Item "None" of "Any | None" has no attribute "pk" [union-attr]
|
||||||
src/documents/serialisers/tasks.py:0: error: Argument 1 to "list" has incompatible type "QuerySet[Document, TypedDict({'id': int, 'title': str, 'deleted_at': datetime | None})]"; expected "Iterable[dict[str, Any]]" [arg-type]
|
src/documents/serialisers.py:0: error: Item "None" of "CustomField | None" has no attribute "name" [union-attr]
|
||||||
src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
||||||
src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
||||||
src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
||||||
src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
|
||||||
src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
||||||
src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
||||||
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Item "list[str]" of "Any | list[str]" has no attribute "values_list" [union-attr]
|
||||||
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Missing type arguments for generic type "Field" [type-arg]
|
||||||
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Missing type arguments for generic type "Iterable" [type-arg]
|
||||||
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Need type annotation for "document" [var-annotated]
|
||||||
src/documents/serialisers/workflows.py:0: error: Argument "default" to "MultipleChoiceField" has incompatible type "set[DocumentSource]"; expected "set[str | int] | set[str] | set[int] | Callable[[], set[str | int] | set[str] | set[int]] | _Empty | None" [arg-type]
|
src/documents/serialisers.py:0: error: Need type annotation for "documents" [var-annotated]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Need type annotation for "permissions_dict" [var-annotated]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Skipping analyzing "auditlog.context": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any] | Any") [index]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Unsupported target for indexed assignment ("Mapping[str, Any]") [index]
|
||||||
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers.py:0: error: Value of type "Any | None" is not indexable [index]
|
||||||
src/documents/serialisers/workflows.py:0: error: Incompatible types in assignment (expression has type "WorkflowActionWebhookSerializer", variable has type "WorkflowActionEmailSerializer") [assignment]
|
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/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined]
|
src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined]
|
||||||
src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined]
|
src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined]
|
||||||
src/documents/signals/handlers.py:0: error: "Task[[int, int], Any]" has no attribute "delay_on_commit" [attr-defined]
|
src/documents/signals/handlers.py:0: error: "Task[[int, int], Any]" has no attribute "delay_on_commit" [attr-defined]
|
||||||
@@ -1524,181 +1525,180 @@ src/documents/utils.py:0: error: Incompatible types in assignment (expression ha
|
|||||||
src/documents/utils.py:0: error: Missing type arguments for generic type "CompletedProcess" [type-arg]
|
src/documents/utils.py:0: error: Missing type arguments for generic type "CompletedProcess" [type-arg]
|
||||||
src/documents/validators.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/validators.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/versioning.py:0: error: Missing type arguments for generic type "Prefetch" [type-arg]
|
src/documents/versioning.py:0: error: Missing type arguments for generic type "Prefetch" [type-arg]
|
||||||
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "filter_queryset" [attr-defined]
|
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "filter_queryset" [attr-defined]
|
||||||
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "get_queryset" [attr-defined]
|
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "get_queryset" [attr-defined]
|
||||||
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
|
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
|
||||||
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
|
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
|
||||||
src/documents/views/base.py:0: error: "get_serializer_context" undefined in superclass [misc]
|
src/documents/views.py:0: error: "Sequence[_SupportsHasPermission]" has no attribute "append" [attr-defined]
|
||||||
src/documents/views/base.py:0: error: "type[Model]" has no attribute "objects" [attr-defined]
|
src/documents/views.py:0: error: "get_serializer_context" undefined in superclass [misc]
|
||||||
src/documents/views/base.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
src/documents/views.py:0: error: "object" has no attribute "apply_async" [attr-defined]
|
||||||
src/documents/views/base.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
src/documents/views.py:0: error: "type[DocumentFilterSet]" has no attribute "declared_filters" [attr-defined]
|
||||||
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: "type[Model]" has no attribute "objects" [attr-defined]
|
||||||
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Argument "filename" to "FileResponse" has incompatible type "str | Any | None"; expected "str" [arg-type]
|
||||||
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Argument "path" to "EmailAttachment" has incompatible type "Path | None"; expected "Path" [arg-type]
|
||||||
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
||||||
src/documents/views/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
||||||
src/documents/views/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
||||||
src/documents/views/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
||||||
src/documents/views/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views.py:0: error: Argument 1 to "TaskSummarySerializer" has incompatible type "QuerySet[PaperlessTask@AnnotatedWith[TypedDict({'total_count': int, 'pending_count': int, 'success_count': int, 'failure_count': int, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})], TypedDict({'task_type': str, 'total_count': Any, 'pending_count': Any, 'success_count': Any, 'failure_count': Any, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})]"; expected "dict[str, Any] | None" [arg-type]
|
||||||
src/documents/views/base.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
src/documents/views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type]
|
||||||
src/documents/views/bulk_edit.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
src/documents/views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type]
|
||||||
src/documents/views/bulk_edit.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
src/documents/views.py:0: error: Argument 1 to "paginate_queryset" of "GenericAPIView" has incompatible type "TantivyRelevanceList"; expected "QuerySet[Any, _MT_co] | Sequence[_MT_co]" [arg-type]
|
||||||
src/documents/views/bulk_edit.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type]
|
src/documents/views.py:0: error: Argument 1 to "permitted_document_ids" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type]
|
||||||
src/documents/views/bulk_edit.py:0: error: Argument 1 to "permitted_object_ids" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type]
|
src/documents/views.py:0: error: Argument 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.py:0: error: Argument 2 to "match_correspondents" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
||||||
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Argument 2 to "match_document_types" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
||||||
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Argument 2 to "match_storage_paths" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
||||||
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Argument 2 to "match_tags" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
||||||
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Argument 3 to "autocomplete" of "TantivyBackend" has incompatible type "User | AnonymousUser | None"; expected "AbstractUser | None" [arg-type]
|
||||||
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item]
|
||||||
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[ArchiveOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[OriginalsOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/bulk_edit.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/chat.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: "type[DocumentFilterSet]" has no attribute "declared_filters" [attr-defined]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Argument "path" to "EmailAttachment" has incompatible type "Path | None"; expected "Path" [arg-type]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Argument 1 to "paginate_queryset" of "GenericAPIView" has incompatible type "TantivyRelevanceList"; expected "QuerySet[Any, _MT_co] | Sequence[_MT_co]" [arg-type]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Argument 2 to "match_correspondents" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Argument 2 to "match_document_types" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Argument 2 to "match_storage_paths" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Argument 2 to "match_tags" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Incompatible types in assignment (expression has type "ClassificationSuggestions", variable has type "dict[Any, Any]") [assignment]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "page_size" [union-attr]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Item "None" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Item "None" of "BasePagination | None" has no attribute "page_size" [union-attr]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Missing positional argument "request" in call to "email_documents" [call-arg]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Need type annotation for "doc" [var-annotated]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Need type annotation for "doc" [var-annotated]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: No overload variant of "prefetch_related" of "QuerySet" matches argument type "list[object]" [call-overload]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Skipping analyzing "langdetect": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/documents.py:0: error: Unsupported operand types for + ("None" and "int") [operator]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/index.py:0: error: Argument "filename" to "FileResponse" has incompatible type "str | Any | None"; expected "str" [arg-type]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/index.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/index.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/index.py:0: error: Item "AnonymousUser" of "User | AnonymousUser" has no attribute "get_full_name" [union-attr]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/index.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/index.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/index.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "app_logo" [union-attr]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/logs.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/logs.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex" [arg-type]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Incompatible types in assignment (expression has type "tuple[type[IsAuthenticated], type[ViewDocumentsPermissions]]", variable has type "tuple[type[IsAuthenticated], type[PaperlessObjectPermissions]]") [assignment]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/metadata.py:0: error: Need type annotation for "children_map" (hint: "children_map: dict[<type>, <type>] = ...") [var-annotated]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/search.py:0: error: Argument 3 to "autocomplete" of "TantivyBackend" has incompatible type "User | AnonymousUser | None"; expected "AbstractUser | None" [arg-type]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views/search.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Document, Document]", variable has type "list[Any]") [assignment]
|
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views/search.py:0: error: Need type annotation for "docs" (hint: "docs: list[<type>] = ...") [var-annotated]
|
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "ClassificationSuggestions", variable has type "dict[Any, Any]") [assignment]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Document, Document]", variable has type "list[Any]") [assignment]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "int", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[User]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/search.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/sharing.py:0: error: "Sequence[_SupportsHasPermission]" has no attribute "append" [attr-defined]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/sharing.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/sharing.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "tuple[type[IsAuthenticated], type[ViewDocumentsPermissions]]", variable has type "tuple[type[IsAuthenticated], type[PaperlessObjectPermissions]]") [assignment]
|
||||||
src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[ArchiveOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
|
||||||
src/documents/views/sharing.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[DocumentSerializer]", variable has type "type[TrashSerializer]") [assignment]
|
||||||
src/documents/views/sharing.py:0: error: Need type annotation for "authentication_classes" (hint: "authentication_classes: list[<type>] = ...") [var-annotated]
|
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[OriginalsOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
|
||||||
src/documents/views/sharing.py:0: error: Need type annotation for "permission_classes" (hint: "permission_classes: list[<type>] = ...") [var-annotated]
|
src/documents/views.py:0: error: Item "AnonymousUser" of "User | AnonymousUser" has no attribute "get_full_name" [union-attr]
|
||||||
src/documents/views/system.py:0: error: Argument 1 to "permitted_document_ids" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type]
|
src/documents/views.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
|
||||||
src/documents/views/system.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item]
|
src/documents/views.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "page_size" [union-attr]
|
||||||
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
||||||
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
||||||
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "app_logo" [union-attr]
|
||||||
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Item "None" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment]
|
src/documents/views.py:0: error: Item "None" of "BasePagination | None" has no attribute "page_size" [union-attr]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Missing positional argument "request" in call to "email_documents" [call-arg]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "int", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Need type annotation for "authentication_classes" (hint: "authentication_classes: list[<type>] = ...") [var-annotated]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Need type annotation for "children_map" (hint: "children_map: dict[<type>, <type>] = ...") [var-annotated]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Need type annotation for "doc" [var-annotated]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Need type annotation for "doc" [var-annotated]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views.py:0: error: Need type annotation for "docs" (hint: "docs: list[<type>] = ...") [var-annotated]
|
||||||
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "type[DocumentSerializer]", variable has type "type[TrashSerializer]") [assignment]
|
src/documents/views.py:0: error: Need type annotation for "permission_classes" (hint: "permission_classes: list[<type>] = ...") [var-annotated]
|
||||||
src/documents/views/system.py:0: error: Need type annotation for "ui_settings" (hint: "ui_settings: dict[<type>, <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/tasks.py:0: error: "object" has no attribute "apply_async" [attr-defined]
|
src/documents/views.py:0: error: No overload variant of "prefetch_related" of "QuerySet" matches argument type "list[object]" [call-overload]
|
||||||
src/documents/views/tasks.py:0: error: Argument 1 to "TaskSummarySerializer" has incompatible type "QuerySet[PaperlessTask@AnnotatedWith[TypedDict({'total_count': int, 'pending_count': int, 'success_count': int, 'failure_count': int, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})], TypedDict({'task_type': str, 'total_count': Any, 'pending_count': Any, 'success_count': Any, 'failure_count': Any, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})]"; expected "dict[str, Any] | None" [arg-type]
|
src/documents/views.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Skipping analyzing "langdetect": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Unsupported operand types for + ("None" and "int") [operator]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index]
|
||||||
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
|
||||||
src/documents/views/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[User]" is not indexable [index]
|
||||||
src/documents/views/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
|
||||||
src/documents/views/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
|
||||||
src/documents/workflows/actions.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/workflows/actions.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/workflows/actions.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/workflows/actions.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/workflows/actions.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/workflows/actions.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
@@ -2145,9 +2145,9 @@ src/paperless_ai/ai_classifier.py:0: error: Argument 1 to "parse_ai_response" ha
|
|||||||
src/paperless_ai/ai_classifier.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "str") [assignment]
|
src/paperless_ai/ai_classifier.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "str") [assignment]
|
||||||
src/paperless_ai/ai_classifier.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/paperless_ai/ai_classifier.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/paperless_ai/ai_classifier.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/paperless_ai/ai_classifier.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("title", "tags", "correspondents", "document_types", "storage_paths", ...) [literal-required]
|
src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("correspondents", "dates", "document_types", "storage_paths", "tags", ...) [literal-required]
|
||||||
src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("title", "tags", "correspondents", "document_types", "storage_paths", ...) [literal-required]
|
src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("correspondents", "dates", "document_types", "storage_paths", "tags", ...) [literal-required]
|
||||||
src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("title", "tags", "correspondents", "document_types", "storage_paths", ...) [literal-required]
|
src/paperless_ai/ai_classifier.py:0: error: TypedDict key must be a string literal; expected one of ("correspondents", "dates", "document_types", "storage_paths", "tags", ...) [literal-required]
|
||||||
src/paperless_ai/base_model.py:0: error: Invalid index type "str | None" for "dict[str, FieldInfo]"; expected type "str" [index]
|
src/paperless_ai/base_model.py:0: error: Invalid index type "str | None" for "dict[str, FieldInfo]"; expected type "str" [index]
|
||||||
src/paperless_ai/chat.py:0: error: Dict entry 1 has incompatible type "str": "str | Any | None"; expected "str": "int | str" [dict-item]
|
src/paperless_ai/chat.py:0: error: Dict entry 1 has incompatible type "str": "str | Any | None"; expected "str": "int | str" [dict-item]
|
||||||
src/paperless_ai/chat.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/paperless_ai/chat.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
|
|||||||
+379
-372
File diff suppressed because it is too large.
Load diff
@@ -1010,6 +1010,19 @@ documents to both separate and categorize them in a single operation.
|
|||||||
**Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create
|
**Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create
|
||||||
three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt").
|
three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt").
|
||||||
|
|
||||||
|
### Barcode Contents {#barcode-contents}
|
||||||
|
|
||||||
|
By default, Paperless only uses barcodes for splitting, ASNs and tags. With
|
||||||
|
[`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES`](configuration.md#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES)
|
||||||
|
enabled, it stores the content of every barcode with the document, e.g. payment codes or QR codes.
|
||||||
|
|
||||||
|
- Barcodes are listed on the **Metadata** tab with page, type and content, and can be copied.
|
||||||
|
- The API returns them in the `barcodes` field of `/api/documents/{id}/metadata/`.
|
||||||
|
- They can be [searched](usage.md#searching-barcodes), e.g. `barcodes:DE89370400440532013000`.
|
||||||
|
- Only the first [`PAPERLESS_CONSUMER_BARCODE_MAX_PAGES`](configuration.md#PAPERLESS_CONSUMER_BARCODE_MAX_PAGES)
|
||||||
|
pages are scanned. Reprocessing reads the barcodes of existing documents.
|
||||||
|
- Each version keeps its own barcodes, and the newest version's are shown and searched.
|
||||||
|
|
||||||
## Automatic collation of double-sided documents {#collate}
|
## Automatic collation of double-sided documents {#collate}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|||||||
@@ -1796,6 +1796,13 @@ assigns or creates tags if a properly formatted barcode is detected.
|
|||||||
|
|
||||||
Defaults to false.
|
Defaults to false.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES=<bool>`](#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES) {#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES}
|
||||||
|
|
||||||
|
: Stores the content of every barcode found during consumption, see
|
||||||
|
[Barcode Contents](advanced_usage.md#barcode-contents).
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
## Audit Trail
|
## Audit Trail
|
||||||
|
|
||||||
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
||||||
|
|||||||
+3
-1
@@ -76,7 +76,9 @@ 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
|
**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
|
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. If you prefer the old
|
**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
|
||||||
behavior of rejecting duplicates during consumption, set
|
behavior of rejecting duplicates during consumption, set
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
||||||
to `true`.
|
to `true`.
|
||||||
|
|||||||
+21
-8
@@ -299,19 +299,18 @@ for details.
|
|||||||
### Duplicate documents
|
### Duplicate documents
|
||||||
|
|
||||||
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
||||||
contents exactly match an existing document (same checksum), the new copy is still
|
contents match an existing document (same original or archive checksum), the new copy is
|
||||||
consumed and a warning is logged. The task entry for the upload also flags that a
|
still consumed and a warning is logged.
|
||||||
duplicate was detected and links to the existing document(s).
|
|
||||||
|
|
||||||
To review duplicates, open a document and switch to the **Duplicates** tab on the
|
When a document has duplicates, a **Duplicates** tab appears on its detail page, listing
|
||||||
document detail page. It lists other documents that share the same content, including any
|
the other documents you can view that share the same content (including any in the trash).
|
||||||
that are in the trash (shown with a badge), and links to each so you can decide which to
|
To find all documents with duplicates, choose **Duplicates** in the document list's text
|
||||||
keep.
|
filter dropdown, or use `has_duplicates=true` in the REST API.
|
||||||
|
|
||||||
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
[`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
|
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
|
||||||
a "document already exists" message.
|
a "Document already exists" message linking to the existing document.
|
||||||
|
|
||||||
## Document Suggestions
|
## Document Suggestions
|
||||||
|
|
||||||
@@ -1061,6 +1060,20 @@ notes.user:alice notes.note:insurance
|
|||||||
|
|
||||||
The bare `notes:` prefix is shorthand for `notes.note:`.
|
The bare `notes:` prefix is shorthand for `notes.note:`.
|
||||||
|
|
||||||
|
#### Searching barcodes
|
||||||
|
|
||||||
|
If [barcode contents are stored](advanced_usage.md#barcode-contents), they can be searched by
|
||||||
|
content or type, but only with a field name:
|
||||||
|
|
||||||
|
```
|
||||||
|
barcodes.value:DE89370400440532013000
|
||||||
|
barcodes.format:qrcode
|
||||||
|
barcodes:wifi barcodes:guest
|
||||||
|
```
|
||||||
|
|
||||||
|
`barcodes:` is shorthand for `barcodes.value:`. Separators are stripped, so each part of e.g.
|
||||||
|
`WIFI:S:Guest;P:secret;;` can be searched on its own.
|
||||||
|
|
||||||
All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text.
|
All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.2.1"
|
version = "3.3.0"
|
||||||
description = """\
|
description = """\
|
||||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||||
"""
|
"""
|
||||||
@@ -33,7 +33,7 @@ dependencies = [
|
|||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.9.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter>=25.1,<27",
|
||||||
"django-guardian>=3.3.3,<3.6",
|
"django-guardian>=3.3.3,<3.6",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-rich~=2.2.0",
|
"django-rich~=2.2.0",
|
||||||
@@ -90,7 +90,7 @@ postgres = [
|
|||||||
"psycopg[c,pool]==3.3.4",
|
"psycopg[c,pool]==3.3.4",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.3.4",
|
"psycopg-c==3.3.4",
|
||||||
"psycopg-pool==3.3.2",
|
"psycopg-pool==3.3.3",
|
||||||
]
|
]
|
||||||
webserver = [
|
webserver = [
|
||||||
"granian[uvloop]>=2.7,<2.9",
|
"granian[uvloop]>=2.7,<2.9",
|
||||||
|
|||||||
+150
-113
@@ -698,7 +698,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">457,458</context>
|
<context context-type="linenumber">463,464</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
|
||||||
@@ -870,7 +870,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">476</context>
|
<context context-type="linenumber">482</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||||
@@ -1373,7 +1373,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1901</context>
|
<context context-type="linenumber">1907</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1577733187050997705" datatype="html">
|
<trans-unit id="1577733187050997705" datatype="html">
|
||||||
@@ -1451,7 +1451,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">402,403</context>
|
<context context-type="linenumber">408,409</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||||
@@ -1700,7 +1700,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">177</context>
|
<context context-type="linenumber">179</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2691296884221415710" datatype="html">
|
<trans-unit id="2691296884221415710" datatype="html">
|
||||||
@@ -1715,7 +1715,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">182</context>
|
<context context-type="linenumber">184</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||||
@@ -1750,7 +1750,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">186</context>
|
<context context-type="linenumber">188</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||||
@@ -1785,7 +1785,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">190</context>
|
<context context-type="linenumber">192</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||||
@@ -2478,7 +2478,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">711</context>
|
<context context-type="linenumber">713</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||||
@@ -3392,11 +3392,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1515</context>
|
<context context-type="linenumber">1521</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1902</context>
|
<context context-type="linenumber">1908</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -4012,7 +4012,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1468</context>
|
<context context-type="linenumber">1474</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -4156,7 +4156,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1955</context>
|
<context context-type="linenumber">1961</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6661109599266152398" datatype="html">
|
<trans-unit id="6661109599266152398" datatype="html">
|
||||||
@@ -4167,7 +4167,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1956</context>
|
<context context-type="linenumber">1962</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5162686434580248853" datatype="html">
|
<trans-unit id="5162686434580248853" datatype="html">
|
||||||
@@ -4178,7 +4178,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1957</context>
|
<context context-type="linenumber">1963</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6665634854532231106" datatype="html">
|
<trans-unit id="6665634854532231106" datatype="html">
|
||||||
@@ -5430,7 +5430,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">368,369</context>
|
<context context-type="linenumber">374,375</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8057014866157903311" datatype="html">
|
<trans-unit id="8057014866157903311" datatype="html">
|
||||||
@@ -6311,7 +6311,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1472</context>
|
<context context-type="linenumber">1478</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -6344,7 +6344,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">175</context>
|
<context context-type="linenumber">177</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||||
@@ -7176,6 +7176,10 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||||
<context context-type="linenumber">56,57</context>
|
<context context-type="linenumber">56,57</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
|
||||||
|
<context context-type="linenumber">5,6</context>
|
||||||
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1230154438678955604" datatype="html">
|
<trans-unit id="1230154438678955604" datatype="html">
|
||||||
<source>Change</source>
|
<source>Change</source>
|
||||||
@@ -7748,8 +7752,8 @@
|
|||||||
<context context-type="linenumber">120</context>
|
<context context-type="linenumber">120</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5700628356844396417" datatype="html">
|
<trans-unit id="5377184518735933155" datatype="html">
|
||||||
<source>{VAR_PLURAL, plural, =1 {1 existing value suggested below} other {<x id="INTERPOLATION"/> existing values suggested below}}</source>
|
<source>{VAR_PLURAL, plural, =1 {1 suggestion available below} other {<x id="INTERPOLATION"/> suggestions available below}}</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">54</context>
|
<context context-type="linenumber">54</context>
|
||||||
@@ -8333,6 +8337,10 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1407560924967345762" datatype="html">
|
<trans-unit id="1407560924967345762" datatype="html">
|
||||||
<source>Page</source>
|
<source>Page</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
|
||||||
|
<context context-type="linenumber">4,5</context>
|
||||||
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">5,6</context>
|
<context context-type="linenumber">5,6</context>
|
||||||
@@ -8346,6 +8354,28 @@
|
|||||||
<context context-type="linenumber">34</context>
|
<context context-type="linenumber">34</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="6205355627445317276" datatype="html">
|
||||||
|
<source>Content</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
|
||||||
|
<context context-type="linenumber">6,7</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
|
<context context-type="linenumber">300,301</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="3846359579066496296" datatype="html">
|
||||||
|
<source>Copy content</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
|
||||||
|
<context context-type="linenumber">32,33</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
|
||||||
|
<context context-type="linenumber">40,41</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="2266163016683537825" datatype="html">
|
<trans-unit id="2266163016683537825" datatype="html">
|
||||||
<source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source>
|
<source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@@ -8477,39 +8507,32 @@
|
|||||||
<source>Details</source>
|
<source>Details</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">172,173</context>
|
<context context-type="linenumber">174,175</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5114742157723900905" datatype="html">
|
<trans-unit id="5114742157723900905" datatype="html">
|
||||||
<source>Date created</source>
|
<source>Date created</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">179</context>
|
<context context-type="linenumber">181</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5607669932062416162" datatype="html">
|
<trans-unit id="5607669932062416162" datatype="html">
|
||||||
<source>Default</source>
|
<source>Default</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">191</context>
|
<context context-type="linenumber">193</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
||||||
<context context-type="linenumber">71</context>
|
<context context-type="linenumber">71</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6205355627445317276" datatype="html">
|
|
||||||
<source>Content</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
|
||||||
<context context-type="linenumber">298,299</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="218403386307979629" datatype="html">
|
<trans-unit id="218403386307979629" datatype="html">
|
||||||
<source>Metadata</source>
|
<source>Metadata</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">307,308</context>
|
<context context-type="linenumber">309,310</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
|
||||||
@@ -8520,228 +8543,235 @@
|
|||||||
<source>Date modified</source>
|
<source>Date modified</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">314,315</context>
|
<context context-type="linenumber">316,317</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6392918669949841614" datatype="html">
|
<trans-unit id="6392918669949841614" datatype="html">
|
||||||
<source>Date added</source>
|
<source>Date added</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">318,319</context>
|
<context context-type="linenumber">320,321</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="146828917013192897" datatype="html">
|
<trans-unit id="146828917013192897" datatype="html">
|
||||||
<source>Media filename</source>
|
<source>Media filename</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">322,323</context>
|
<context context-type="linenumber">324,325</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4500855521601039868" datatype="html">
|
<trans-unit id="4500855521601039868" datatype="html">
|
||||||
<source>Original filename</source>
|
<source>Original filename</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">326,327</context>
|
<context context-type="linenumber">328,329</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2659735245739197634" datatype="html">
|
<trans-unit id="2659735245739197634" datatype="html">
|
||||||
<source>Original SHA256 checksum</source>
|
<source>Original SHA256 checksum</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">330,331</context>
|
<context context-type="linenumber">332,333</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5888243105821763422" datatype="html">
|
<trans-unit id="5888243105821763422" datatype="html">
|
||||||
<source>Original file size</source>
|
<source>Original file size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">334,335</context>
|
<context context-type="linenumber">336,337</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2696647325713149563" datatype="html">
|
<trans-unit id="2696647325713149563" datatype="html">
|
||||||
<source>Original mime type</source>
|
<source>Original mime type</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">338,339</context>
|
<context context-type="linenumber">340,341</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6714358112223607756" datatype="html">
|
<trans-unit id="6714358112223607756" datatype="html">
|
||||||
<source>Archive SHA256 checksum</source>
|
<source>Archive SHA256 checksum</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">343,344</context>
|
<context context-type="linenumber">345,346</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6033581412811562084" datatype="html">
|
<trans-unit id="6033581412811562084" datatype="html">
|
||||||
<source>Archive file size</source>
|
<source>Archive file size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">349,350</context>
|
<context context-type="linenumber">351,352</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="8459338343197260257" datatype="html">
|
||||||
|
<source>Barcodes</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
|
<context context-type="linenumber">360,361</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6992781481378431874" datatype="html">
|
<trans-unit id="6992781481378431874" datatype="html">
|
||||||
<source>Original document metadata</source>
|
<source>Original document metadata</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">358</context>
|
<context context-type="linenumber">364</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2846565152091361585" datatype="html">
|
<trans-unit id="2846565152091361585" datatype="html">
|
||||||
<source>Archived document metadata</source>
|
<source>Archived document metadata</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">361</context>
|
<context context-type="linenumber">367</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7206723502037428235" datatype="html">
|
<trans-unit id="7206723502037428235" datatype="html">
|
||||||
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document()?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="{{document().notes.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document()?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="{{document().notes.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">380,383</context>
|
<context context-type="linenumber">386,389</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="186236568870281953" datatype="html">
|
<trans-unit id="186236568870281953" datatype="html">
|
||||||
<source>History</source>
|
<source>History</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">391,392</context>
|
<context context-type="linenumber">397,398</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8236092845697214347" datatype="html">
|
<trans-unit id="8236092845697214347" datatype="html">
|
||||||
<source> Duplicates <x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="{{ document().duplicate_documents.length }}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source>
|
<source> Duplicates <x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="{{ document().duplicate_documents.length }}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">414,417</context>
|
<context context-type="linenumber">420,423</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6449374629822973702" datatype="html">
|
<trans-unit id="6449374629822973702" datatype="html">
|
||||||
<source>Duplicate documents detected:</source>
|
<source>Duplicate documents detected:</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">419,420</context>
|
<context context-type="linenumber">425,426</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="14058600336670816" datatype="html">
|
<trans-unit id="14058600336670816" datatype="html">
|
||||||
<source>In trash</source>
|
<source>In trash</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">430,431</context>
|
<context context-type="linenumber">436,437</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5129524307369213584" datatype="html">
|
<trans-unit id="5129524307369213584" datatype="html">
|
||||||
<source>Save & next</source>
|
<source>Save & next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">459</context>
|
<context context-type="linenumber">465</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4910102545766233758" datatype="html">
|
<trans-unit id="4910102545766233758" datatype="html">
|
||||||
<source>Save & close</source>
|
<source>Save & close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">461</context>
|
<context context-type="linenumber">467</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3823219296477075982" datatype="html">
|
<trans-unit id="3823219296477075982" datatype="html">
|
||||||
<source>Discard</source>
|
<source>Discard</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">463,464</context>
|
<context context-type="linenumber">469,470</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1309556917227148591" datatype="html">
|
<trans-unit id="1309556917227148591" datatype="html">
|
||||||
<source>Document loading...</source>
|
<source>Document loading...</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">471</context>
|
<context context-type="linenumber">477</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8191371354890763172" datatype="html">
|
<trans-unit id="8191371354890763172" datatype="html">
|
||||||
<source>Enter Password</source>
|
<source>Enter Password</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">525</context>
|
<context context-type="linenumber">531</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5758784066858623886" datatype="html">
|
<trans-unit id="5758784066858623886" datatype="html">
|
||||||
<source>Error retrieving metadata</source>
|
<source>Error retrieving metadata</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">461</context>
|
<context context-type="linenumber">463</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2218903673684131427" datatype="html">
|
<trans-unit id="2218903673684131427" datatype="html">
|
||||||
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
|
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">564,566</context>
|
<context context-type="linenumber">566,568</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1045,1047</context>
|
<context context-type="linenumber">1047,1049</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6357361810318120957" datatype="html">
|
<trans-unit id="6357361810318120957" datatype="html">
|
||||||
<source>Document was updated</source>
|
<source>Document was updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">706</context>
|
<context context-type="linenumber">708</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5154064822428631306" datatype="html">
|
<trans-unit id="5154064822428631306" datatype="html">
|
||||||
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
|
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">707</context>
|
<context context-type="linenumber">709</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8462497568316256794" datatype="html">
|
<trans-unit id="8462497568316256794" datatype="html">
|
||||||
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
|
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">708</context>
|
<context context-type="linenumber">710</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7967484035994732534" datatype="html">
|
<trans-unit id="7967484035994732534" datatype="html">
|
||||||
<source>Reload</source>
|
<source>Reload</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">710</context>
|
<context context-type="linenumber">712</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2907037627372942104" datatype="html">
|
<trans-unit id="2907037627372942104" datatype="html">
|
||||||
<source>Document reloaded with latest changes.</source>
|
<source>Document reloaded with latest changes.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">766</context>
|
<context context-type="linenumber">768</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6435639868943916539" datatype="html">
|
<trans-unit id="6435639868943916539" datatype="html">
|
||||||
<source>Document reloaded.</source>
|
<source>Document reloaded.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">777</context>
|
<context context-type="linenumber">779</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6142395741265832184" datatype="html">
|
<trans-unit id="6142395741265832184" datatype="html">
|
||||||
<source>Next document</source>
|
<source>Next document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">879</context>
|
<context context-type="linenumber">881</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="651985345816518480" datatype="html">
|
<trans-unit id="651985345816518480" datatype="html">
|
||||||
<source>Previous document</source>
|
<source>Previous document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">889</context>
|
<context context-type="linenumber">891</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2885986061416655600" datatype="html">
|
<trans-unit id="2885986061416655600" datatype="html">
|
||||||
<source>Close document</source>
|
<source>Close document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">897</context>
|
<context context-type="linenumber">899</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
@@ -8752,28 +8782,28 @@
|
|||||||
<source>Save document</source>
|
<source>Save document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">904</context>
|
<context context-type="linenumber">906</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1784543155727940353" datatype="html">
|
<trans-unit id="1784543155727940353" datatype="html">
|
||||||
<source>Save and close / next</source>
|
<source>Save and close / next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">913</context>
|
<context context-type="linenumber">915</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7427704425579737895" datatype="html">
|
<trans-unit id="7427704425579737895" datatype="html">
|
||||||
<source>Error retrieving version content</source>
|
<source>Error retrieving version content</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1027</context>
|
<context context-type="linenumber">1029</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="159901853873315050" datatype="html">
|
<trans-unit id="159901853873315050" datatype="html">
|
||||||
<source>Unsaved Changes</source>
|
<source>Unsaved Changes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1071</context>
|
<context context-type="linenumber">1073</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context>
|
<context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context>
|
||||||
@@ -8796,74 +8826,74 @@
|
|||||||
<source>You have unsaved changes to the content of this version.</source>
|
<source>You have unsaved changes to the content of this version.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1072</context>
|
<context context-type="linenumber">1074</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="85184271222513014" datatype="html">
|
<trans-unit id="85184271222513014" datatype="html">
|
||||||
<source>Switching versions will discard them.</source>
|
<source>Switching versions will discard them.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1073</context>
|
<context context-type="linenumber">1075</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2565707334844767610" datatype="html">
|
<trans-unit id="2565707334844767610" datatype="html">
|
||||||
<source>Discard and switch</source>
|
<source>Discard and switch</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1075</context>
|
<context context-type="linenumber">1077</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2109314380040637387" datatype="html">
|
<trans-unit id="2109314380040637387" datatype="html">
|
||||||
<source>Save and switch</source>
|
<source>Save and switch</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1077</context>
|
<context context-type="linenumber">1079</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3456881259945295697" datatype="html">
|
<trans-unit id="3456881259945295697" datatype="html">
|
||||||
<source>Error retrieving suggestions.</source>
|
<source>Error retrieving suggestions.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1120</context>
|
<context context-type="linenumber">1122</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2194092841814123758" datatype="html">
|
<trans-unit id="2194092841814123758" datatype="html">
|
||||||
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1347</context>
|
<context context-type="linenumber">1353</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1375</context>
|
<context context-type="linenumber">1381</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6626387786259219838" datatype="html">
|
<trans-unit id="6626387786259219838" datatype="html">
|
||||||
<source>Error saving document "<x id="PH" equiv-text="this.document().title"/>"</source>
|
<source>Error saving document "<x id="PH" equiv-text="this.document().title"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1381</context>
|
<context context-type="linenumber">1387</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="448882439049417053" datatype="html">
|
<trans-unit id="448882439049417053" datatype="html">
|
||||||
<source>Error saving document</source>
|
<source>Error saving document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1436</context>
|
<context context-type="linenumber">1442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8410796510716511826" datatype="html">
|
<trans-unit id="8410796510716511826" datatype="html">
|
||||||
<source>Do you really want to move the document "<x id="PH" equiv-text="this.document().title"/>" to the trash?</source>
|
<source>Do you really want to move the document "<x id="PH" equiv-text="this.document().title"/>" to the trash?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1469</context>
|
<context context-type="linenumber">1475</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="282586936710748252" datatype="html">
|
<trans-unit id="282586936710748252" datatype="html">
|
||||||
<source>Documents can be restored prior to permanent deletion.</source>
|
<source>Documents can be restored prior to permanent deletion.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1470</context>
|
<context context-type="linenumber">1476</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -8874,14 +8904,14 @@
|
|||||||
<source>Error deleting document</source>
|
<source>Error deleting document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1491</context>
|
<context context-type="linenumber">1497</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="619486176823357521" datatype="html">
|
<trans-unit id="619486176823357521" datatype="html">
|
||||||
<source>Reprocess confirm</source>
|
<source>Reprocess confirm</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1511</context>
|
<context context-type="linenumber">1517</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -8892,102 +8922,102 @@
|
|||||||
<source>This operation will permanently recreate the archive file for this document.</source>
|
<source>This operation will permanently recreate the archive file for this document.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1512</context>
|
<context context-type="linenumber">1518</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="302054111564709516" datatype="html">
|
<trans-unit id="302054111564709516" datatype="html">
|
||||||
<source>The archive file will be re-generated with the current settings.</source>
|
<source>The archive file will be re-generated with the current settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1513</context>
|
<context context-type="linenumber">1519</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4700389117298802932" datatype="html">
|
<trans-unit id="4700389117298802932" datatype="html">
|
||||||
<source>Reprocess operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
<source>Reprocess operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1526</context>
|
<context context-type="linenumber">1532</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4409560272830824468" datatype="html">
|
<trans-unit id="4409560272830824468" datatype="html">
|
||||||
<source>Error executing operation</source>
|
<source>Error executing operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1537</context>
|
<context context-type="linenumber">1543</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6030453331794586802" datatype="html">
|
<trans-unit id="6030453331794586802" datatype="html">
|
||||||
<source>Error downloading document</source>
|
<source>Error downloading document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1601</context>
|
<context context-type="linenumber">1607</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4458954481601077369" datatype="html">
|
<trans-unit id="4458954481601077369" datatype="html">
|
||||||
<source>Page Fit</source>
|
<source>Page Fit</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1679</context>
|
<context context-type="linenumber">1685</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4663705961777238777" datatype="html">
|
<trans-unit id="4663705961777238777" datatype="html">
|
||||||
<source>PDF edit operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
<source>PDF edit operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1922</context>
|
<context context-type="linenumber">1928</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9043972994040261999" datatype="html">
|
<trans-unit id="9043972994040261999" datatype="html">
|
||||||
<source>Error executing PDF edit operation</source>
|
<source>Error executing PDF edit operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1934</context>
|
<context context-type="linenumber">1940</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6172690334763056188" datatype="html">
|
<trans-unit id="6172690334763056188" datatype="html">
|
||||||
<source>Please enter the current password before attempting to remove it.</source>
|
<source>Please enter the current password before attempting to remove it.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1945</context>
|
<context context-type="linenumber">1951</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="968660764814228922" datatype="html">
|
<trans-unit id="968660764814228922" datatype="html">
|
||||||
<source>Password removal operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
<source>Password removal operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1979</context>
|
<context context-type="linenumber">1985</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2282118435712883014" datatype="html">
|
<trans-unit id="2282118435712883014" datatype="html">
|
||||||
<source>Error executing password removal operation</source>
|
<source>Error executing password removal operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1993</context>
|
<context context-type="linenumber">1999</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3740891324955700797" datatype="html">
|
<trans-unit id="3740891324955700797" datatype="html">
|
||||||
<source>Print failed.</source>
|
<source>Print failed.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">2043</context>
|
<context context-type="linenumber">2049</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6457245677384603573" datatype="html">
|
<trans-unit id="6457245677384603573" datatype="html">
|
||||||
<source>Error loading document for printing.</source>
|
<source>Error loading document for printing.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">2052</context>
|
<context context-type="linenumber">2058</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6085793215710522488" datatype="html">
|
<trans-unit id="6085793215710522488" datatype="html">
|
||||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">2135</context>
|
<context context-type="linenumber">2141</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">2141</context>
|
<context context-type="linenumber">2147</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4958946940233632319" datatype="html">
|
<trans-unit id="4958946940233632319" datatype="html">
|
||||||
@@ -12053,123 +12083,130 @@
|
|||||||
<context context-type="linenumber">328</context>
|
<context context-type="linenumber">328</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="906679600349814230" datatype="html">
|
||||||
|
<source>Store Barcode Contents</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
|
<context context-type="linenumber">335</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="7011909364081812031" datatype="html">
|
<trans-unit id="7011909364081812031" datatype="html">
|
||||||
<source>AI Enabled</source>
|
<source>AI Enabled</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">335</context>
|
<context context-type="linenumber">342</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8028880048909383956" datatype="html">
|
<trans-unit id="8028880048909383956" datatype="html">
|
||||||
<source>Consider privacy implications when enabling AI features, especially if using a remote model.</source>
|
<source>Consider privacy implications when enabling AI features, especially if using a remote model.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">339</context>
|
<context context-type="linenumber">346</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8131374115579345652" datatype="html">
|
<trans-unit id="8131374115579345652" datatype="html">
|
||||||
<source>LLM Embedding Backend</source>
|
<source>LLM Embedding Backend</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">343</context>
|
<context context-type="linenumber">350</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6647708571891295756" datatype="html">
|
<trans-unit id="6647708571891295756" datatype="html">
|
||||||
<source>LLM Embedding Model</source>
|
<source>LLM Embedding Model</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">351</context>
|
<context context-type="linenumber">358</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="861068592166833023" datatype="html">
|
<trans-unit id="861068592166833023" datatype="html">
|
||||||
<source>LLM Embedding API Key</source>
|
<source>LLM Embedding API Key</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">358</context>
|
<context context-type="linenumber">365</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2929108042259892948" datatype="html">
|
<trans-unit id="2929108042259892948" datatype="html">
|
||||||
<source>Used for embeddings when set, otherwise LLM API key is used.</source>
|
<source>Used for embeddings when set, otherwise LLM API key is used.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">360</context>
|
<context context-type="linenumber">367</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3554114880473286122" datatype="html">
|
<trans-unit id="3554114880473286122" datatype="html">
|
||||||
<source>LLM Embedding Endpoint</source>
|
<source>LLM Embedding Endpoint</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">366</context>
|
<context context-type="linenumber">373</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1044242175651289991" datatype="html">
|
<trans-unit id="1044242175651289991" datatype="html">
|
||||||
<source>LLM Embedding Chunk Size</source>
|
<source>LLM Embedding Chunk Size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">373</context>
|
<context context-type="linenumber">380</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7218245223139363113" datatype="html">
|
<trans-unit id="7218245223139363113" datatype="html">
|
||||||
<source>LLM Context Size</source>
|
<source>LLM Context Size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">380</context>
|
<context context-type="linenumber">387</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4234495692726214397" datatype="html">
|
<trans-unit id="4234495692726214397" datatype="html">
|
||||||
<source>LLM Backend</source>
|
<source>LLM Backend</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">387</context>
|
<context context-type="linenumber">394</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7935234833834000002" datatype="html">
|
<trans-unit id="7935234833834000002" datatype="html">
|
||||||
<source>LLM Model</source>
|
<source>LLM Model</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">395</context>
|
<context context-type="linenumber">402</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1980550530387803165" datatype="html">
|
<trans-unit id="1980550530387803165" datatype="html">
|
||||||
<source>LLM API Key</source>
|
<source>LLM API Key</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">402</context>
|
<context context-type="linenumber">409</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6126617860376156501" datatype="html">
|
<trans-unit id="6126617860376156501" datatype="html">
|
||||||
<source>LLM Endpoint</source>
|
<source>LLM Endpoint</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">409</context>
|
<context context-type="linenumber">416</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6572826277249350975" datatype="html">
|
<trans-unit id="6572826277249350975" datatype="html">
|
||||||
<source>LLM Output Language</source>
|
<source>LLM Output Language</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">416</context>
|
<context context-type="linenumber">423</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3284403507172415792" datatype="html">
|
<trans-unit id="3284403507172415792" datatype="html">
|
||||||
<source>Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.</source>
|
<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-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">420</context>
|
<context context-type="linenumber">427</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4493921125434706859" datatype="html">
|
<trans-unit id="4493921125434706859" datatype="html">
|
||||||
<source>LLM Request Timeout</source>
|
<source>LLM Request Timeout</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">424</context>
|
<context context-type="linenumber">431</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="483994032066441287" datatype="html">
|
<trans-unit id="483994032066441287" datatype="html">
|
||||||
<source>Timeout in seconds for LLM requests.</source>
|
<source>Timeout in seconds for LLM requests.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">428</context>
|
<context context-type="linenumber">435</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1055686627716339120" datatype="html">
|
<trans-unit id="1055686627716339120" datatype="html">
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.2.1",
|
"version": "3.3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
+3
-3
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<div class="list-group list-group-flush small pb-0">
|
||||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,9 +49,9 @@
|
|||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (reusableSuggestions > 0) {
|
@if (fieldSuggestions > 0) {
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+62
-4
@@ -84,7 +84,7 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
'2 existing values suggested below'
|
'2 suggestions available below'
|
||||||
)
|
)
|
||||||
expect(fixture.nativeElement.textContent).not.toContain(
|
expect(fixture.nativeElement.textContent).not.toContain(
|
||||||
'No novel suggestions'
|
'No novel suggestions'
|
||||||
@@ -108,7 +108,7 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
expect(component.totalSuggestions).toBe(4)
|
expect(component.totalSuggestions).toBe(4)
|
||||||
expect(fixture.nativeElement.textContent).toContain('Arbitration')
|
expect(fixture.nativeElement.textContent).toContain('Arbitration')
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
'2 existing values suggested below'
|
'2 suggestions available below'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,10 +125,46 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(component.novelSuggestions).toBe(0)
|
expect(component.novelSuggestions).toBe(0)
|
||||||
expect(component.totalSuggestions).toBe(5)
|
expect(component.totalSuggestions).toBe(6)
|
||||||
|
|
||||||
fixture.componentRef.setInput('appliedStoragePath', 7)
|
fixture.componentRef.setInput('appliedStoragePath', 7)
|
||||||
expect(component.totalSuggestions).toBe(4)
|
expect(component.totalSuggestions).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should count title and dates as field suggestions without calling them existing values', () => {
|
||||||
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
title: 'Suggested title',
|
||||||
|
dates: ['2026-01-04', '2026-02-01', '2026-03-01'],
|
||||||
|
correspondents: [1, 2, 3, 4],
|
||||||
|
document_types: [1, 2, 3, 4],
|
||||||
|
tags: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
})
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.clickSuggest()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.reusableSuggestions).toBe(16)
|
||||||
|
expect(component.fieldSuggestions).toBe(20)
|
||||||
|
expect(component.totalSuggestions).toBe(20)
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'20 suggestions available below'
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('existing value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not count title or date suggestions matching the current values', () => {
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
title: 'Current title',
|
||||||
|
dates: ['2026-01-04', '2026-02-01'],
|
||||||
|
})
|
||||||
|
expect(component.fieldSuggestions).toBe(3)
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('appliedTitle', 'Current title')
|
||||||
|
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
|
||||||
|
expect(component.fieldSuggestions).toBe(1)
|
||||||
|
expect(component.totalSuggestions).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show when a completed request returned no suggestions', () => {
|
it('should show when a completed request returned no suggestions', () => {
|
||||||
@@ -145,6 +181,28 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('No suggestions')
|
expect(fixture.nativeElement.textContent).toContain('No suggestions')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should wait for all pending responses before showing the empty state', () => {
|
||||||
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
fixture.componentRef.setInput('source', SuggestionSource.Both)
|
||||||
|
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||||
|
fixture.componentRef.setInput('suggestions', { tags: [] })
|
||||||
|
fixture.componentRef.setInput('loading', true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.noSuggestions).toBeFalsy()
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('No suggestions')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[role="status"]')
|
||||||
|
).not.toBeNull()
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('loading', false)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.noSuggestions).toBeTruthy()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('No suggestions')
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="status"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('should not show the empty state before a request or with suggestions', () => {
|
it('should not show the empty state before a request or with suggestions', () => {
|
||||||
expect(component.noSuggestions).toBeFalsy()
|
expect(component.noSuggestions).toBeFalsy()
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -34,6 +34,8 @@ export class SuggestionsDropdownComponent {
|
|||||||
readonly appliedCorrespondent = input<number>(null)
|
readonly appliedCorrespondent = input<number>(null)
|
||||||
readonly appliedDocumentType = input<number>(null)
|
readonly appliedDocumentType = input<number>(null)
|
||||||
readonly appliedStoragePath = input<number>(null)
|
readonly appliedStoragePath = input<number>(null)
|
||||||
|
readonly appliedTitle = input<string>(null)
|
||||||
|
readonly appliedCreated = input<string>(null)
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
|
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
|
||||||
@@ -128,7 +130,28 @@ export class SuggestionsDropdownComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get totalSuggestions(): number {
|
get totalSuggestions(): number {
|
||||||
return this.novelSuggestions + this.reusableSuggestions
|
return this.novelSuggestions + this.fieldSuggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
get fieldSuggestions(): number {
|
||||||
|
return (
|
||||||
|
this.reusableSuggestions +
|
||||||
|
this.unappliedTitleSuggestions +
|
||||||
|
this.unappliedDateSuggestions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hide a title or date suggestion equal to the current value
|
||||||
|
private get unappliedTitleSuggestions(): number {
|
||||||
|
const title = this.suggestions()?.title
|
||||||
|
return title && title !== this.appliedTitle() ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private get unappliedDateSuggestions(): number {
|
||||||
|
const created = this.appliedCreated()
|
||||||
|
return (this.suggestions()?.dates ?? []).filter(
|
||||||
|
(date) => !created || date !== created
|
||||||
|
).length
|
||||||
}
|
}
|
||||||
|
|
||||||
private countUnapplied(suggested: number[], applied: number[]): number {
|
private countUnapplied(suggested: number[], applied: number[]): number {
|
||||||
@@ -139,6 +162,7 @@ export class SuggestionsDropdownComponent {
|
|||||||
get noSuggestions(): boolean {
|
get noSuggestions(): boolean {
|
||||||
const suggestions = this.suggestions()
|
const suggestions = this.suggestions()
|
||||||
return (
|
return (
|
||||||
|
!this.loading() &&
|
||||||
suggestions != null &&
|
suggestions != null &&
|
||||||
!suggestions.title &&
|
!suggestions.title &&
|
||||||
!suggestions.tags?.length &&
|
!suggestions.tags?.length &&
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
<table class="table table-borderless align-baseline">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th i18n>Page</th>
|
||||||
|
<th i18n>Type</th>
|
||||||
|
<th i18n>Content</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (barcode of barcodes(); track $index) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ barcode.page }}</td>
|
||||||
|
<td class="text-nowrap">{{ barcode.format }}</td>
|
||||||
|
<td class="text-break">
|
||||||
|
@if (isLink(barcode.value)) {
|
||||||
|
<a
|
||||||
|
[href]="barcode.value"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
>{{ barcode.value }}</a
|
||||||
|
>
|
||||||
|
} @else {
|
||||||
|
{{ barcode.value }}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
(click)="copy($index)"
|
||||||
|
title="Copy content"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
@if (copiedIndex() === $index) {
|
||||||
|
<i-bs name="clipboard-check"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="clipboard"></i-bs>
|
||||||
|
}
|
||||||
|
<span class="visually-hidden" i18n>Copy content</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { DocumentBarcodesComponent } from './document-barcodes.component'
|
||||||
|
|
||||||
|
const barcodes = [
|
||||||
|
{ page: 1, value: 'ASN00123', format: 'Code128' },
|
||||||
|
{ page: 2, value: 'https://example.com/invoice/4711', format: 'QRCode' },
|
||||||
|
{ page: 2, value: 'javascript:alert(1)', format: 'QRCode' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('DocumentBarcodesComponent', () => {
|
||||||
|
let component: DocumentBarcodesComponent
|
||||||
|
let fixture: ComponentFixture<DocumentBarcodesComponent>
|
||||||
|
let clipboard: Clipboard
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
DocumentBarcodesComponent,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DocumentBarcodesComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
clipboard = TestBed.inject(Clipboard)
|
||||||
|
fixture.componentRef.setInput('barcodes', barcodes)
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display all barcodes', () => {
|
||||||
|
const rows = fixture.nativeElement.querySelectorAll('tbody tr')
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows[0].textContent).toContain('ASN00123')
|
||||||
|
expect(rows[0].textContent).toContain('Code128')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only link http(s) values', () => {
|
||||||
|
const links = fixture.nativeElement.querySelectorAll('tbody a')
|
||||||
|
expect(links).toHaveLength(1)
|
||||||
|
expect(links[0].getAttribute('href')).toEqual(
|
||||||
|
'https://example.com/invoice/4711'
|
||||||
|
)
|
||||||
|
expect(links[0].getAttribute('target')).toEqual('_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy a value and show feedback', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||||
|
const buttons = fixture.nativeElement.querySelectorAll('tbody button')
|
||||||
|
buttons[0].click()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(copySpy).toHaveBeenCalledWith('ASN00123')
|
||||||
|
expect(component.copiedIndex()).toEqual(0)
|
||||||
|
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
|
||||||
|
'clipboard-check'
|
||||||
|
)
|
||||||
|
jest.advanceTimersByTime(3000)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.copiedIndex()).toBeNull()
|
||||||
|
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
|
||||||
|
'clipboard'
|
||||||
|
)
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show feedback if copying failed', () => {
|
||||||
|
jest.spyOn(clipboard, 'copy').mockReturnValue(false)
|
||||||
|
component.copy(1)
|
||||||
|
expect(component.copiedIndex()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { Component, inject, input, OnDestroy, signal } from '@angular/core'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { DocumentBarcode } from 'src/app/data/document-barcode'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-document-barcodes',
|
||||||
|
templateUrl: './document-barcodes.component.html',
|
||||||
|
imports: [NgxBootstrapIconsModule],
|
||||||
|
})
|
||||||
|
export class DocumentBarcodesComponent implements OnDestroy {
|
||||||
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
|
||||||
|
readonly barcodes = input<DocumentBarcode[]>([])
|
||||||
|
|
||||||
|
readonly copiedIndex = signal<number>(null)
|
||||||
|
private copyTimeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
public isLink(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value.trim())
|
||||||
|
return ['http:', 'https:'].includes(url.protocol) && !!url.host
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public copy(index: number) {
|
||||||
|
if (!this.clipboard.copy(this.barcodes()[index].value)) return
|
||||||
|
this.copiedIndex.set(index)
|
||||||
|
clearTimeout(this.copyTimeout)
|
||||||
|
this.copyTimeout = setTimeout(() => this.copiedIndex.set(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
clearTimeout(this.copyTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,8 @@
|
|||||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||||
[appliedDocumentType]="documentForm.value.document_type"
|
[appliedDocumentType]="documentForm.value.document_type"
|
||||||
[appliedStoragePath]="documentForm.value.storage_path"
|
[appliedStoragePath]="documentForm.value.storage_path"
|
||||||
|
[appliedTitle]="documentForm.value.title"
|
||||||
|
[appliedCreated]="documentForm.value.created"
|
||||||
(getSuggestions)="getSuggestions($event)"
|
(getSuggestions)="getSuggestions($event)"
|
||||||
(sourceChange)="suggestionSourceOverride.set($event)"
|
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||||
(addTag)="createTag($event)"
|
(addTag)="createTag($event)"
|
||||||
@@ -354,6 +356,10 @@
|
|||||||
</table>
|
</table>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (metadata()?.barcodes?.length > 0) {
|
||||||
|
<h6 i18n>Barcodes</h6>
|
||||||
|
<pngx-document-barcodes [barcodes]="metadata().barcodes"></pngx-document-barcodes>
|
||||||
|
}
|
||||||
@if (metadata()?.original_metadata?.length > 0) {
|
@if (metadata()?.original_metadata?.length > 0) {
|
||||||
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata()?.original_metadata"></pngx-metadata-collapse>
|
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata()?.original_metadata"></pngx-metadata-collapse>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -662,6 +662,45 @@ describe('DocumentDetailComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['tag', 'createTag', 'tags', 'suggested_tags'],
|
||||||
|
[
|
||||||
|
'document type',
|
||||||
|
'createDocumentType',
|
||||||
|
'document_type',
|
||||||
|
'suggested_document_types',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'correspondent',
|
||||||
|
'createCorrespondent',
|
||||||
|
'correspondent',
|
||||||
|
'suggested_correspondents',
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'should create a %s after ML-only suggestions',
|
||||||
|
(_, method, field, suggestedField) => {
|
||||||
|
initNormally()
|
||||||
|
component.suggestions.set({ tags: [1] })
|
||||||
|
let openModal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
|
component[method]('New value')
|
||||||
|
openModal.componentInstance.succeeded.next({
|
||||||
|
id: 12,
|
||||||
|
name: 'New value',
|
||||||
|
is_inbox_tag: false,
|
||||||
|
color: '#ff0000',
|
||||||
|
text_color: '#000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (field === 'tags') {
|
||||||
|
expect(component.tagsInput.value).toContain(12)
|
||||||
|
} else {
|
||||||
|
expect(component.documentForm.get(field).value).toBe(12)
|
||||||
|
}
|
||||||
|
expect(component.suggestions()[suggestedField]).toEqual([])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
it('should support creating storage path', () => {
|
it('should support creating storage path', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-li
|
|||||||
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
||||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
|
import { DocumentBarcodesComponent } from './document-barcodes/document-barcodes.component'
|
||||||
import { DocumentHistoryComponent } from './document-history/document-history.component'
|
import { DocumentHistoryComponent } from './document-history/document-history.component'
|
||||||
import { DocumentVersionDropdownComponent } from './document-version-dropdown/document-version-dropdown.component'
|
import { DocumentVersionDropdownComponent } from './document-version-dropdown/document-version-dropdown.component'
|
||||||
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
||||||
@@ -177,6 +178,7 @@ interface IncomingDocumentUpdate {
|
|||||||
DateComponent,
|
DateComponent,
|
||||||
DocumentLinkComponent,
|
DocumentLinkComponent,
|
||||||
MetadataCollapseComponent,
|
MetadataCollapseComponent,
|
||||||
|
DocumentBarcodesComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
TagsComponent,
|
TagsComponent,
|
||||||
@@ -1152,7 +1154,7 @@ export class DocumentDetailComponent
|
|||||||
if (this.suggestions()) {
|
if (this.suggestions()) {
|
||||||
this.suggestions.set({
|
this.suggestions.set({
|
||||||
...this.suggestions(),
|
...this.suggestions(),
|
||||||
suggested_tags: this.suggestions().suggested_tags.filter(
|
suggested_tags: (this.suggestions().suggested_tags ?? []).filter(
|
||||||
(tag) => tag !== newTag.name
|
(tag) => tag !== newTag.name
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -1191,10 +1193,12 @@ export class DocumentDetailComponent
|
|||||||
this.documentForm.get('document_type').setValue(newDocumentType.id)
|
this.documentForm.get('document_type').setValue(newDocumentType.id)
|
||||||
this.documentForm.get('document_type').markAsDirty()
|
this.documentForm.get('document_type').markAsDirty()
|
||||||
if (this.suggestions()) {
|
if (this.suggestions()) {
|
||||||
this.suggestions().suggested_document_types =
|
this.suggestions.set({
|
||||||
this.suggestions().suggested_document_types.filter(
|
...this.suggestions(),
|
||||||
(dt) => dt !== newName
|
suggested_document_types: (
|
||||||
)
|
this.suggestions().suggested_document_types ?? []
|
||||||
|
).filter((dt) => dt !== newName),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1221,10 +1225,12 @@ export class DocumentDetailComponent
|
|||||||
this.documentForm.get('correspondent').setValue(newCorrespondent.id)
|
this.documentForm.get('correspondent').setValue(newCorrespondent.id)
|
||||||
this.documentForm.get('correspondent').markAsDirty()
|
this.documentForm.get('correspondent').markAsDirty()
|
||||||
if (this.suggestions()) {
|
if (this.suggestions()) {
|
||||||
this.suggestions().suggested_correspondents =
|
this.suggestions.set({
|
||||||
this.suggestions().suggested_correspondents.filter(
|
...this.suggestions(),
|
||||||
(c) => c !== newName
|
suggested_correspondents: (
|
||||||
)
|
this.suggestions().suggested_correspondents ?? []
|
||||||
|
).filter((c) => c !== newName),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface DocumentBarcode {
|
||||||
|
page: number
|
||||||
|
|
||||||
|
value: string
|
||||||
|
|
||||||
|
format: string
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DocumentBarcode } from './document-barcode'
|
||||||
|
|
||||||
export interface DocumentMetadata {
|
export interface DocumentMetadata {
|
||||||
original_checksum?: string
|
original_checksum?: string
|
||||||
|
|
||||||
@@ -12,4 +14,6 @@ export interface DocumentMetadata {
|
|||||||
has_archive_version?: boolean
|
has_archive_version?: boolean
|
||||||
|
|
||||||
lang?: string
|
lang?: string
|
||||||
|
|
||||||
|
barcodes?: DocumentBarcode[]
|
||||||
}
|
}
|
||||||
@@ -330,6 +330,13 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT',
|
config_key: 'PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT',
|
||||||
category: ConfigCategory.Barcode,
|
category: ConfigCategory.Barcode,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'barcode_store_values',
|
||||||
|
title: $localize`Store Barcode Contents`,
|
||||||
|
type: ConfigOptionType.Boolean,
|
||||||
|
config_key: 'PAPERLESS_CONSUMER_STORE_BARCODE_VALUES',
|
||||||
|
category: ConfigCategory.Barcode,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'ai_enabled',
|
key: 'ai_enabled',
|
||||||
title: $localize`AI Enabled`,
|
title: $localize`AI Enabled`,
|
||||||
@@ -458,6 +465,7 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
barcode_enable_tag: boolean
|
barcode_enable_tag: boolean
|
||||||
barcode_tag_mapping: object
|
barcode_tag_mapping: object
|
||||||
barcode_tag_split: boolean
|
barcode_tag_split: boolean
|
||||||
|
barcode_store_values: boolean
|
||||||
remote_ocr_engine: string
|
remote_ocr_engine: string
|
||||||
remote_ocr_api_key: string
|
remote_ocr_api_key: string
|
||||||
remote_ocr_endpoint: string
|
remote_ocr_endpoint: string
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const environment = {
|
|||||||
apiVersion: '10', // match src/paperless/settings.py
|
apiVersion: '10', // match src/paperless/settings.py
|
||||||
appTitle: DEFAULT_APP_TITLE,
|
appTitle: DEFAULT_APP_TITLE,
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '3.2.1',
|
version: '3.3.0',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+126
-74
@@ -18,6 +18,7 @@ from documents.converters import convert_from_tiff_to_pdf
|
|||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
|
from documents.data_models import StoredBarcode
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
@@ -47,6 +48,7 @@ class Barcode:
|
|||||||
page: int
|
page: int
|
||||||
value: str
|
value: str
|
||||||
settings: BarcodeConfig
|
settings: BarcodeConfig
|
||||||
|
format: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_separator(self) -> bool:
|
def is_separator(self) -> bool:
|
||||||
@@ -78,6 +80,12 @@ class Barcode:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def stored(self) -> StoredBarcode:
|
||||||
|
"""
|
||||||
|
The barcode as it is stored with a document, page 1-indexed
|
||||||
|
"""
|
||||||
|
return {"page": self.page + 1, "value": self.value, "format": self.format}
|
||||||
|
|
||||||
|
|
||||||
class BarcodePlugin(ConsumeTaskPlugin):
|
class BarcodePlugin(ConsumeTaskPlugin):
|
||||||
NAME: str = "BarcodePlugin"
|
NAME: str = "BarcodePlugin"
|
||||||
@@ -89,16 +97,12 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
- ASN from barcode detection is enabled or
|
- ASN from barcode detection is enabled or
|
||||||
- Barcode support is enabled and the mime type is supported
|
- Barcode support is enabled and the mime type is supported
|
||||||
"""
|
"""
|
||||||
if self.settings.barcode_enable_tiff_support:
|
|
||||||
supported_mimes: set[str] = {"application/pdf", "image/tiff"}
|
|
||||||
else:
|
|
||||||
supported_mimes = {"application/pdf"}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
self.settings.barcode_enable_asn
|
self.settings.barcode_enable_asn
|
||||||
or self.settings.barcodes_enabled
|
or self.settings.barcodes_enabled
|
||||||
or self.settings.barcode_enable_tag
|
or self.settings.barcode_enable_tag
|
||||||
) and self.input_doc.mime_type in supported_mimes
|
or self.settings.barcode_store_values
|
||||||
|
) and self.input_doc.mime_type in scannable_mime_types(self.settings)
|
||||||
|
|
||||||
def get_settings(self) -> BarcodeConfig:
|
def get_settings(self) -> BarcodeConfig:
|
||||||
"""
|
"""
|
||||||
@@ -244,6 +248,10 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None:
|
if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None:
|
||||||
self._apply_detected_asn(located_asn)
|
self._apply_detected_asn(located_asn)
|
||||||
|
|
||||||
|
# After splitting too, so each split document keeps its own barcodes
|
||||||
|
if self.settings.barcode_store_values:
|
||||||
|
self.metadata.barcodes = [x.stored() for x in self.barcodes] or None
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
self.temp_dir.cleanup()
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
@@ -262,22 +270,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
)
|
)
|
||||||
self._tiff_conversion_done = True
|
self._tiff_conversion_done = True
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def read_barcodes_zxing(image: Image.Image) -> list[str]:
|
|
||||||
barcodes = []
|
|
||||||
|
|
||||||
import zxingcpp
|
|
||||||
|
|
||||||
detected_barcodes = zxingcpp.read_barcodes(image)
|
|
||||||
for barcode in detected_barcodes:
|
|
||||||
if barcode.text:
|
|
||||||
barcodes.append(barcode.text)
|
|
||||||
logger.debug(
|
|
||||||
f"Barcode of type {barcode.format} found: {barcode.text}",
|
|
||||||
)
|
|
||||||
|
|
||||||
return barcodes
|
|
||||||
|
|
||||||
def detect(self) -> None:
|
def detect(self) -> None:
|
||||||
"""
|
"""
|
||||||
Scan all pages of the PDF as images, updating barcodes and the pages
|
Scan all pages of the PDF as images, updating barcodes and the pages
|
||||||
@@ -291,60 +283,12 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
self.convert_from_tiff_to_pdf()
|
self.convert_from_tiff_to_pdf()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Read number of pages from pdf
|
self.barcodes = scan_pdf(
|
||||||
with Pdf.open(self.pdf_file) as pdf:
|
self.pdf_file,
|
||||||
num_of_pages = len(pdf.pages)
|
self.settings,
|
||||||
logger.debug(f"PDF has {num_of_pages} pages")
|
Path(self.temp_dir.name),
|
||||||
|
|
||||||
# Get limit from configuration
|
|
||||||
barcode_max_pages: int = (
|
|
||||||
num_of_pages
|
|
||||||
if self.settings.barcode_max_pages == 0
|
|
||||||
else self.settings.barcode_max_pages
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if barcode_max_pages < num_of_pages: # pragma: no cover
|
|
||||||
logger.debug(
|
|
||||||
f"Barcodes detection will be limited to the first {barcode_max_pages} pages",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Loop al page
|
|
||||||
for current_page_number in range(min(num_of_pages, barcode_max_pages)):
|
|
||||||
logger.debug(f"Processing page {current_page_number}")
|
|
||||||
|
|
||||||
# Convert page to image
|
|
||||||
page = convert_from_path(
|
|
||||||
self.pdf_file,
|
|
||||||
dpi=self.settings.barcode_dpi,
|
|
||||||
output_folder=self.temp_dir.name,
|
|
||||||
first_page=current_page_number + 1,
|
|
||||||
last_page=current_page_number + 1,
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
# Remember filename, since it is lost by upscaling
|
|
||||||
page_filepath = Path(page.filename)
|
|
||||||
logger.debug(f"Image is at {page_filepath}")
|
|
||||||
|
|
||||||
# Upscale image if configured
|
|
||||||
factor = self.settings.barcode_upscale
|
|
||||||
if factor > 1.0:
|
|
||||||
logger.debug(
|
|
||||||
f"Upscaling image by {factor} for better barcode detection",
|
|
||||||
)
|
|
||||||
x, y = page.size
|
|
||||||
page = page.resize(
|
|
||||||
(round(x * factor), (round(y * factor))),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Detect barcodes
|
|
||||||
for barcode_value in self.read_barcodes_zxing(page):
|
|
||||||
self.barcodes.append(
|
|
||||||
Barcode(current_page_number, barcode_value, self.settings),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete temporary image file
|
|
||||||
page_filepath.unlink()
|
|
||||||
|
|
||||||
# Password protected files can't be checked
|
# Password protected files can't be checked
|
||||||
# This is the exception raised for those
|
# This is the exception raised for those
|
||||||
except PasswordError as e:
|
except PasswordError as e:
|
||||||
@@ -534,3 +478,111 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
document_paths.append(savepath)
|
document_paths.append(savepath)
|
||||||
|
|
||||||
return document_paths
|
return document_paths
|
||||||
|
|
||||||
|
|
||||||
|
def scannable_mime_types(settings: BarcodeConfig) -> set[str]:
|
||||||
|
"""
|
||||||
|
The file types the barcode scan supports with the current settings
|
||||||
|
"""
|
||||||
|
if settings.barcode_enable_tiff_support:
|
||||||
|
return {"application/pdf", "image/tiff"}
|
||||||
|
return {"application/pdf"}
|
||||||
|
|
||||||
|
|
||||||
|
def read_barcodes_zxing(image: Image.Image) -> list[tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
Returns the text and format (zxing enum name) of each barcode found in
|
||||||
|
the image
|
||||||
|
"""
|
||||||
|
barcodes = []
|
||||||
|
|
||||||
|
import zxingcpp
|
||||||
|
|
||||||
|
detected_barcodes = zxingcpp.read_barcodes(image)
|
||||||
|
for barcode in detected_barcodes:
|
||||||
|
if barcode.text:
|
||||||
|
barcodes.append((barcode.text, barcode.format.name))
|
||||||
|
logger.debug(
|
||||||
|
f"Barcode of type {barcode.format} found: {barcode.text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return barcodes
|
||||||
|
|
||||||
|
|
||||||
|
def scan_pdf(pdf_path: Path, settings: BarcodeConfig, work_dir: Path) -> list[Barcode]:
|
||||||
|
"""
|
||||||
|
Scans the pages of a PDF as images for barcodes. Errors are not caught,
|
||||||
|
so callers can tell a failed scan from one that found nothing.
|
||||||
|
"""
|
||||||
|
barcodes: list[Barcode] = []
|
||||||
|
|
||||||
|
with Pdf.open(pdf_path) as pdf:
|
||||||
|
num_of_pages = len(pdf.pages)
|
||||||
|
logger.debug(f"PDF has {num_of_pages} pages")
|
||||||
|
|
||||||
|
# Get limit from configuration
|
||||||
|
barcode_max_pages: int = (
|
||||||
|
num_of_pages if settings.barcode_max_pages == 0 else settings.barcode_max_pages
|
||||||
|
)
|
||||||
|
|
||||||
|
if barcode_max_pages < num_of_pages: # pragma: no cover
|
||||||
|
logger.debug(
|
||||||
|
f"Barcodes detection will be limited to the first {barcode_max_pages} pages",
|
||||||
|
)
|
||||||
|
|
||||||
|
for current_page_number in range(min(num_of_pages, barcode_max_pages)):
|
||||||
|
logger.debug(f"Processing page {current_page_number}")
|
||||||
|
|
||||||
|
# Convert page to image
|
||||||
|
page = convert_from_path(
|
||||||
|
pdf_path,
|
||||||
|
dpi=settings.barcode_dpi,
|
||||||
|
output_folder=work_dir,
|
||||||
|
first_page=current_page_number + 1,
|
||||||
|
last_page=current_page_number + 1,
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# Remember filename, since it is lost by upscaling
|
||||||
|
page_filepath = Path(page.filename)
|
||||||
|
logger.debug(f"Image is at {page_filepath}")
|
||||||
|
|
||||||
|
# Upscale image if configured
|
||||||
|
factor = settings.barcode_upscale
|
||||||
|
if factor > 1.0:
|
||||||
|
logger.debug(
|
||||||
|
f"Upscaling image by {factor} for better barcode detection",
|
||||||
|
)
|
||||||
|
x, y = page.size
|
||||||
|
page = page.resize(
|
||||||
|
(round(x * factor), (round(y * factor))),
|
||||||
|
)
|
||||||
|
|
||||||
|
for barcode_value, barcode_format in read_barcodes_zxing(page):
|
||||||
|
barcodes.append(
|
||||||
|
Barcode(current_page_number, barcode_value, settings, barcode_format),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete temporary image file
|
||||||
|
page_filepath.unlink()
|
||||||
|
|
||||||
|
return barcodes
|
||||||
|
|
||||||
|
|
||||||
|
def read_barcode_values(
|
||||||
|
path: Path,
|
||||||
|
mime_type: str,
|
||||||
|
settings: BarcodeConfig,
|
||||||
|
work_dir: Path,
|
||||||
|
) -> list[StoredBarcode] | None:
|
||||||
|
"""
|
||||||
|
Reads the barcodes of a file outside of the consumption plugins: for new
|
||||||
|
versions, which skip the barcode plugin, and when reprocessing.
|
||||||
|
|
||||||
|
Returns None if the file can't be scanned with the current settings.
|
||||||
|
Errors while scanning are raised.
|
||||||
|
"""
|
||||||
|
if mime_type not in scannable_mime_types(settings):
|
||||||
|
return None
|
||||||
|
if mime_type == "image/tiff":
|
||||||
|
path = convert_from_tiff_to_pdf(path, work_dir)
|
||||||
|
return [x.stored() for x in scan_pdf(path, settings, work_dir)]
|
||||||
@@ -18,6 +18,7 @@ from django.utils import timezone
|
|||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
from rest_framework.reverse import reverse
|
from rest_framework.reverse import reverse
|
||||||
|
|
||||||
|
from documents.barcodes import read_barcode_values
|
||||||
from documents.classifier import load_classifier
|
from documents.classifier import load_classifier
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import ConsumeFileSuccessResult
|
from documents.data_models import ConsumeFileSuccessResult
|
||||||
@@ -31,6 +32,7 @@ from documents.models import Correspondent
|
|||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentBarcode
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
@@ -53,6 +55,7 @@ from documents.utils import compute_checksum
|
|||||||
from documents.utils import copy_basic_file_stats
|
from documents.utils import copy_basic_file_stats
|
||||||
from documents.utils import copy_file_with_basic_stats
|
from documents.utils import copy_file_with_basic_stats
|
||||||
from documents.utils import run_subprocess
|
from documents.utils import run_subprocess
|
||||||
|
from paperless.config import BarcodeConfig
|
||||||
from paperless.config import OcrConfig
|
from paperless.config import OcrConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
from paperless.config import RemoteOCRConfig
|
||||||
from paperless.models import ArchiveFileGenerationChoices
|
from paperless.models import ArchiveFileGenerationChoices
|
||||||
@@ -504,6 +507,13 @@ class ConsumerPlugin(
|
|||||||
f"Parser: {document_parser.name} v{document_parser.version}",
|
f"Parser: {document_parser.name} v{document_parser.version}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# New versions skip the barcode plugin, so read their barcodes here
|
||||||
|
if (
|
||||||
|
self.input_doc.root_document_id is not None
|
||||||
|
and self.metadata.barcodes is None
|
||||||
|
):
|
||||||
|
self._read_version_barcodes(mime_type, Path(tmpdir))
|
||||||
|
|
||||||
# Parse the document. This may take some time.
|
# Parse the document. This may take some time.
|
||||||
|
|
||||||
text = None
|
text = None
|
||||||
@@ -631,6 +641,8 @@ class ConsumerPlugin(
|
|||||||
else:
|
else:
|
||||||
original_document.save()
|
original_document.save()
|
||||||
|
|
||||||
|
self._store_barcodes(original_document)
|
||||||
|
|
||||||
# Adding a version changes the effective document, so update root modified
|
# Adding a version changes the effective document, so update root modified
|
||||||
Document.objects.filter(pk=root_doc.pk).update(
|
Document.objects.filter(pk=root_doc.pk).update(
|
||||||
modified=timezone.now(),
|
modified=timezone.now(),
|
||||||
@@ -962,6 +974,32 @@ class ConsumerPlugin(
|
|||||||
}
|
}
|
||||||
CustomFieldInstance.objects.create(**args) # adds to document
|
CustomFieldInstance.objects.create(**args) # adds to document
|
||||||
|
|
||||||
|
self._store_barcodes(document)
|
||||||
|
|
||||||
|
def _read_version_barcodes(self, mime_type: str, work_dir: Path) -> None:
|
||||||
|
barcode_settings = BarcodeConfig()
|
||||||
|
if not barcode_settings.barcode_store_values:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.metadata.barcodes = (
|
||||||
|
read_barcode_values(
|
||||||
|
self.working_copy,
|
||||||
|
mime_type,
|
||||||
|
barcode_settings,
|
||||||
|
work_dir,
|
||||||
|
)
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f"Could not read barcodes of {self.filename}: {e}")
|
||||||
|
|
||||||
|
def _store_barcodes(self, document: Document) -> None:
|
||||||
|
if self.metadata.barcodes:
|
||||||
|
DocumentBarcode.objects.bulk_create(
|
||||||
|
DocumentBarcode(document=document, **barcode)
|
||||||
|
for barcode in self.metadata.barcodes
|
||||||
|
)
|
||||||
|
|
||||||
def _write(self, source, target) -> None:
|
def _write(self, source, target) -> None:
|
||||||
with (
|
with (
|
||||||
Path(source).open("rb") as read_file,
|
Path(source).open("rb") as read_file,
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ from guardian.shortcuts import get_groups_with_perms
|
|||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
|
|
||||||
|
|
||||||
|
class StoredBarcode(TypedDict):
|
||||||
|
"""
|
||||||
|
A detected barcode as it is stored with a document
|
||||||
|
"""
|
||||||
|
|
||||||
|
page: int # 1-indexed
|
||||||
|
value: str
|
||||||
|
format: str # a DocumentBarcode.Format value
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class DocumentMetadataOverrides:
|
class DocumentMetadataOverrides:
|
||||||
"""
|
"""
|
||||||
@@ -35,6 +45,7 @@ class DocumentMetadataOverrides:
|
|||||||
version_label: str | None = None
|
version_label: str | None = None
|
||||||
actor_id: int | None = None
|
actor_id: int | None = None
|
||||||
remote_ocr: bool = False
|
remote_ocr: bool = False
|
||||||
|
barcodes: list[StoredBarcode] | None = None
|
||||||
|
|
||||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from documents.models import Correspondent
|
|||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentBarcode
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import Note
|
from documents.models import Note
|
||||||
from documents.models import SavedView
|
from documents.models import SavedView
|
||||||
@@ -353,6 +354,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
"workflows": Workflow.objects.all(),
|
"workflows": Workflow.objects.all(),
|
||||||
"custom_fields": CustomField.objects.all(),
|
"custom_fields": CustomField.objects.all(),
|
||||||
"custom_field_instances": CustomFieldInstance.global_objects.all(),
|
"custom_field_instances": CustomFieldInstance.global_objects.all(),
|
||||||
|
"document_barcodes": DocumentBarcode.objects.all(),
|
||||||
"app_configs": ApplicationConfiguration.objects.all(),
|
"app_configs": ApplicationConfiguration.objects.all(),
|
||||||
"notes": Note.global_objects.all(),
|
"notes": Note.global_objects.all(),
|
||||||
"documents": Document.global_objects.order_by("id").all(),
|
"documents": Document.global_objects.order_by("id").all(),
|
||||||
@@ -411,6 +413,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
elif self.split_manifest and key in (
|
elif self.split_manifest and key in (
|
||||||
"notes",
|
"notes",
|
||||||
"custom_field_instances",
|
"custom_field_instances",
|
||||||
|
"document_barcodes",
|
||||||
):
|
):
|
||||||
# Written per-document in _write_split_manifest
|
# Written per-document in _write_split_manifest
|
||||||
pass
|
pass
|
||||||
@@ -651,6 +654,12 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
CustomFieldInstance.global_objects.filter(document=document),
|
CustomFieldInstance.global_objects.filter(document=document),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
content.extend(
|
||||||
|
serializers.serialize(
|
||||||
|
"python",
|
||||||
|
DocumentBarcode.objects.filter(document=document),
|
||||||
|
),
|
||||||
|
)
|
||||||
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
manifest_name = Path("json") / manifest_name
|
manifest_name = Path("json") / manifest_name
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class Command(PaperlessCommand):
|
|||||||
"notes__user",
|
"notes__user",
|
||||||
"custom_fields__field",
|
"custom_fields__field",
|
||||||
"versions",
|
"versions",
|
||||||
|
"barcodes",
|
||||||
|
"versions__barcodes",
|
||||||
)
|
)
|
||||||
total = documents.count()
|
total = documents.count()
|
||||||
rebuild_kwargs = {}
|
rebuild_kwargs = {}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-09-30 23:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0026_alter_document_archive_checksum_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DocumentBarcode",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"page",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
help_text="Page of the original file, starting at 1",
|
||||||
|
verbose_name="page",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("value", models.TextField(verbose_name="value")),
|
||||||
|
(
|
||||||
|
"format",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Codabar", "Codabar"),
|
||||||
|
("Code39", "Code 39"),
|
||||||
|
("Code39Std", "Code 39 Standard"),
|
||||||
|
("Code39Ext", "Code 39 Extended"),
|
||||||
|
("Code32", "Code 32"),
|
||||||
|
("PZN", "Pharmazentralnummer"),
|
||||||
|
("Code93", "Code 93"),
|
||||||
|
("Code128", "Code 128"),
|
||||||
|
("ITF", "ITF"),
|
||||||
|
("ITF14", "ITF-14"),
|
||||||
|
("DataBar", "DataBar"),
|
||||||
|
("DataBarOmni", "DataBar Omni"),
|
||||||
|
("DataBarStk", "DataBar Stacked"),
|
||||||
|
("DataBarStkOmni", "DataBar Stacked Omni"),
|
||||||
|
("DataBarLtd", "DataBar Limited"),
|
||||||
|
("DataBarExp", "DataBar Expanded"),
|
||||||
|
("DataBarExpStk", "DataBar Expanded Stacked"),
|
||||||
|
("EANUPC", "EAN/UPC"),
|
||||||
|
("EAN13", "EAN-13"),
|
||||||
|
("EAN8", "EAN-8"),
|
||||||
|
("EAN5", "EAN-5"),
|
||||||
|
("EAN2", "EAN-2"),
|
||||||
|
("ISBN", "ISBN"),
|
||||||
|
("UPCA", "UPC-A"),
|
||||||
|
("UPCE", "UPC-E"),
|
||||||
|
("Telepen", "Telepen"),
|
||||||
|
("TelepenAlpha", "Telepen Alpha"),
|
||||||
|
("TelepenNumeric", "Telepen Numeric"),
|
||||||
|
("OtherBarcode", "Other barcode"),
|
||||||
|
("DXFilmEdge", "DX Film Edge"),
|
||||||
|
("PDF417", "PDF417"),
|
||||||
|
("CompactPDF417", "Compact PDF417"),
|
||||||
|
("MicroPDF417", "MicroPDF417"),
|
||||||
|
("Aztec", "Aztec"),
|
||||||
|
("AztecCode", "Aztec Code"),
|
||||||
|
("AztecRune", "Aztec Rune"),
|
||||||
|
("QRCode", "QR Code"),
|
||||||
|
("QRCodeModel1", "QR Code Model 1"),
|
||||||
|
("QRCodeModel2", "QR Code Model 2"),
|
||||||
|
("MicroQRCode", "Micro QR Code"),
|
||||||
|
("RMQRCode", "rMQR Code"),
|
||||||
|
("DataMatrix", "Data Matrix"),
|
||||||
|
("MaxiCode", "MaxiCode"),
|
||||||
|
],
|
||||||
|
max_length=32,
|
||||||
|
verbose_name="format",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"document",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="barcodes",
|
||||||
|
to="documents.document",
|
||||||
|
verbose_name="document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "document barcode",
|
||||||
|
"verbose_name_plural": "document barcodes",
|
||||||
|
"ordering": ("page", "id"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -366,6 +366,16 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
res += f" {self.title}"
|
res += f" {self.title}"
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def get_effective_barcodes(self) -> list["DocumentBarcode"]:
|
||||||
|
"""
|
||||||
|
Returns the stored barcodes for the document, like
|
||||||
|
get_effective_content(): for root documents those of the latest
|
||||||
|
version when there is one, as that is the file users see.
|
||||||
|
"""
|
||||||
|
from documents.versioning import latest_version
|
||||||
|
|
||||||
|
return list(latest_version(self).barcodes.all())
|
||||||
|
|
||||||
def get_effective_content(self) -> str | None:
|
def get_effective_content(self) -> str | None:
|
||||||
"""
|
"""
|
||||||
Returns the effective content for the document.
|
Returns the effective content for the document.
|
||||||
@@ -969,6 +979,87 @@ class Note(SoftDeleteModel):
|
|||||||
return self.note
|
return self.note
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentBarcode(models.Model):
|
||||||
|
"""
|
||||||
|
A barcode found in a document during consumption, kept so its content
|
||||||
|
can be shown and copied
|
||||||
|
"""
|
||||||
|
|
||||||
|
document = models.ForeignKey(
|
||||||
|
Document,
|
||||||
|
related_name="barcodes",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
verbose_name=_("document"),
|
||||||
|
)
|
||||||
|
|
||||||
|
page = models.PositiveIntegerField(
|
||||||
|
_("page"),
|
||||||
|
help_text=_("Page of the original file, starting at 1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
value = models.TextField(_("value"))
|
||||||
|
|
||||||
|
class Format(models.TextChoices):
|
||||||
|
"""
|
||||||
|
The concrete barcode formats of zxing-cpp, keyed on the enum name.
|
||||||
|
The labels are symbology names and aren't translated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CODABAR = "Codabar", "Codabar"
|
||||||
|
CODE39 = "Code39", "Code 39"
|
||||||
|
CODE39_STD = "Code39Std", "Code 39 Standard"
|
||||||
|
CODE39_EXT = "Code39Ext", "Code 39 Extended"
|
||||||
|
CODE32 = "Code32", "Code 32"
|
||||||
|
PZN = "PZN", "Pharmazentralnummer"
|
||||||
|
CODE93 = "Code93", "Code 93"
|
||||||
|
CODE128 = "Code128", "Code 128"
|
||||||
|
ITF = "ITF", "ITF"
|
||||||
|
ITF14 = "ITF14", "ITF-14"
|
||||||
|
DATA_BAR = "DataBar", "DataBar"
|
||||||
|
DATA_BAR_OMNI = "DataBarOmni", "DataBar Omni"
|
||||||
|
DATA_BAR_STK = "DataBarStk", "DataBar Stacked"
|
||||||
|
DATA_BAR_STK_OMNI = "DataBarStkOmni", "DataBar Stacked Omni"
|
||||||
|
DATA_BAR_LTD = "DataBarLtd", "DataBar Limited"
|
||||||
|
DATA_BAR_EXP = "DataBarExp", "DataBar Expanded"
|
||||||
|
DATA_BAR_EXP_STK = "DataBarExpStk", "DataBar Expanded Stacked"
|
||||||
|
EANUPC = "EANUPC", "EAN/UPC"
|
||||||
|
EAN13 = "EAN13", "EAN-13"
|
||||||
|
EAN8 = "EAN8", "EAN-8"
|
||||||
|
EAN5 = "EAN5", "EAN-5"
|
||||||
|
EAN2 = "EAN2", "EAN-2"
|
||||||
|
ISBN = "ISBN", "ISBN"
|
||||||
|
UPCA = "UPCA", "UPC-A"
|
||||||
|
UPCE = "UPCE", "UPC-E"
|
||||||
|
TELEPEN = "Telepen", "Telepen"
|
||||||
|
TELEPEN_ALPHA = "TelepenAlpha", "Telepen Alpha"
|
||||||
|
TELEPEN_NUMERIC = "TelepenNumeric", "Telepen Numeric"
|
||||||
|
OTHER_BARCODE = "OtherBarcode", "Other barcode"
|
||||||
|
DX_FILM_EDGE = "DXFilmEdge", "DX Film Edge"
|
||||||
|
PDF417 = "PDF417", "PDF417"
|
||||||
|
COMPACT_PDF417 = "CompactPDF417", "Compact PDF417"
|
||||||
|
MICRO_PDF417 = "MicroPDF417", "MicroPDF417"
|
||||||
|
AZTEC = "Aztec", "Aztec"
|
||||||
|
AZTEC_CODE = "AztecCode", "Aztec Code"
|
||||||
|
AZTEC_RUNE = "AztecRune", "Aztec Rune"
|
||||||
|
QR_CODE = "QRCode", "QR Code"
|
||||||
|
QR_CODE_MODEL1 = "QRCodeModel1", "QR Code Model 1"
|
||||||
|
QR_CODE_MODEL2 = "QRCodeModel2", "QR Code Model 2"
|
||||||
|
MICRO_QR_CODE = "MicroQRCode", "Micro QR Code"
|
||||||
|
RMQR_CODE = "RMQRCode", "rMQR Code"
|
||||||
|
DATA_MATRIX = "DataMatrix", "Data Matrix"
|
||||||
|
MAXI_CODE = "MaxiCode", "MaxiCode"
|
||||||
|
|
||||||
|
format = models.CharField(_("format"), max_length=32, choices=Format.choices)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("page", "id")
|
||||||
|
verbose_name = _("document barcode")
|
||||||
|
verbose_name_plural = _("document barcodes")
|
||||||
|
|
||||||
|
def __str__(self) -> str: # pragma: no cover
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
class ShareLink(SoftDeleteModel):
|
class ShareLink(SoftDeleteModel):
|
||||||
class FileVersion(models.TextChoices):
|
class FileVersion(models.TextChoices):
|
||||||
ARCHIVE = ("archive", _("Archive"))
|
ARCHIVE = ("archive", _("Archive"))
|
||||||
|
|||||||
@@ -311,7 +311,13 @@ class WriteBatch:
|
|||||||
queryset = annotate_effective_content(
|
queryset = annotate_effective_content(
|
||||||
Document.objects.filter(pk__in=ids)
|
Document.objects.filter(pk__in=ids)
|
||||||
.select_related("correspondent", "document_type", "storage_path", "owner")
|
.select_related("correspondent", "document_type", "storage_path", "owner")
|
||||||
.prefetch_related("tags", "notes__user", "custom_fields__field"),
|
.prefetch_related(
|
||||||
|
"tags",
|
||||||
|
"notes__user",
|
||||||
|
"custom_fields__field",
|
||||||
|
"barcodes",
|
||||||
|
"versions__barcodes",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for document, grant in _DocumentViewerStream(queryset, chunk_size=1000):
|
for document, grant in _DocumentViewerStream(queryset, chunk_size=1000):
|
||||||
self.remove(document.pk)
|
self.remove(document.pk)
|
||||||
@@ -604,6 +610,16 @@ class TantivyBackend:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Barcodes: JSON field like custom_fields, only filled when stored
|
||||||
|
for barcode in document.get_effective_barcodes():
|
||||||
|
doc.add_json(
|
||||||
|
"barcodes",
|
||||||
|
{
|
||||||
|
"value": normalize_search_text(barcode.value),
|
||||||
|
"format": normalize_search_text(barcode.format),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Dates
|
# Dates
|
||||||
created_date = datetime(
|
created_date = datetime(
|
||||||
document.created.year,
|
document.created.year,
|
||||||
|
|||||||
@@ -39,4 +39,9 @@ PUBLIC_FIELDS: tuple[FieldSpec, ...] = (
|
|||||||
FieldKind.JSON,
|
FieldKind.JSON,
|
||||||
subpaths={"name": SubpathSpec(), "value": SubpathSpec(default=True)},
|
subpaths={"name": SubpathSpec(), "value": SubpathSpec(default=True)},
|
||||||
),
|
),
|
||||||
|
FieldSpec(
|
||||||
|
"barcodes",
|
||||||
|
FieldKind.JSON,
|
||||||
|
subpaths={"value": SubpathSpec(default=True), "format": SubpathSpec()},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -25,7 +25,8 @@ logger = logging.getLogger("paperless.search")
|
|||||||
# order, and the write-only correspondent/document_type/storage_path/tag id
|
# order, and the write-only correspondent/document_type/storage_path/tag id
|
||||||
# columns dropped. tantivy compares schemas by ordered field list, so an
|
# columns dropped. tantivy compares schemas by ordered field list, so an
|
||||||
# index built by v1 rejects every write against the v2 schema.
|
# index built by v1 rejects every write against the v2 schema.
|
||||||
SCHEMA_VERSION: Final[int] = 2
|
# v3 - barcodes JSON field for stored barcode contents
|
||||||
|
SCHEMA_VERSION: Final[int] = 3
|
||||||
|
|
||||||
|
|
||||||
class FieldDescriptor(NamedTuple):
|
class FieldDescriptor(NamedTuple):
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
@@ -1,568 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,750 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Any
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils.dateparse import parse_datetime
|
|
||||||
from django.utils.timezone import get_current_timezone
|
|
||||||
from django.utils.timezone import is_naive
|
|
||||||
from django.utils.timezone import make_aware
|
|
||||||
from drf_spectacular.utils import extend_schema_field
|
|
||||||
from drf_spectacular.utils import extend_schema_serializer
|
|
||||||
from drf_writable_nested.serializers import NestedUpdateMixin
|
|
||||||
from rest_framework import serializers
|
|
||||||
from rest_framework.fields import SerializerMethodField
|
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
|
||||||
from auditlog.context import set_actor
|
|
||||||
|
|
||||||
|
|
||||||
from documents import bulk_edit
|
|
||||||
from documents.models import CustomField
|
|
||||||
from documents.models import CustomFieldInstance
|
|
||||||
from documents.models import Document
|
|
||||||
from documents.models import Tag
|
|
||||||
from documents.permissions import permitted_document_ids
|
|
||||||
from documents.versioning import has_prefetched_effective_content
|
|
||||||
from documents.versioning import sort_versions_newest_first
|
|
||||||
|
|
||||||
from .base import DocumentUpdateFieldsModelSerializer
|
|
||||||
from .base import NotesSerializer
|
|
||||||
from .base import OwnedObjectListSerializer
|
|
||||||
from .base import OwnedObjectSerializer
|
|
||||||
from .metadata import CorrespondentField
|
|
||||||
from .metadata import CustomFieldInstanceSerializer
|
|
||||||
from .metadata import DocumentTypeField
|
|
||||||
from .metadata import StoragePathField
|
|
||||||
from .metadata import TagsField
|
|
||||||
from .upload import PostDocumentSerializer
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from django.db.models.query import QuerySet
|
|
||||||
from rest_framework.relations import ManyRelatedField
|
|
||||||
from rest_framework.relations import RelatedField
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.serializers")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_viewable_duplicates(
|
|
||||||
document: Document,
|
|
||||||
user: User | None,
|
|
||||||
) -> QuerySet[Document]:
|
|
||||||
checksums = {document.checksum}
|
|
||||||
if document.archive_checksum:
|
|
||||||
checksums.add(document.archive_checksum)
|
|
||||||
duplicates = Document.global_objects.filter(
|
|
||||||
Q(checksum__in=checksums) | Q(archive_checksum__in=checksums),
|
|
||||||
).exclude(pk=document.pk)
|
|
||||||
duplicates = duplicates.filter(root_document__isnull=True)
|
|
||||||
duplicates = duplicates.order_by("-created")
|
|
||||||
allowed_ids = permitted_document_ids(user, include_deleted=True)
|
|
||||||
return duplicates.filter(id__in=allowed_ids)
|
|
||||||
|
|
||||||
|
|
||||||
class DuplicateDocumentSummarySerializer(serializers.Serializer[dict[str, Any]]):
|
|
||||||
id = serializers.IntegerField()
|
|
||||||
title = serializers.CharField()
|
|
||||||
deleted_at = serializers.DateTimeField(allow_null=True)
|
|
||||||
|
|
||||||
|
|
||||||
class _DocumentVersionInfo(TypedDict):
|
|
||||||
id: int
|
|
||||||
added: datetime
|
|
||||||
version_label: str | None
|
|
||||||
checksum: str | None
|
|
||||||
is_root: bool
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentVersionInfoSerializer(serializers.Serializer[_DocumentVersionInfo]):
|
|
||||||
id = serializers.IntegerField()
|
|
||||||
added = serializers.DateTimeField()
|
|
||||||
version_label = serializers.CharField(required=False, allow_null=True)
|
|
||||||
checksum = serializers.CharField(required=False, allow_null=True)
|
|
||||||
is_root = serializers.BooleanField()
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_serializer(
|
|
||||||
deprecate_fields=["created_date"],
|
|
||||||
)
|
|
||||||
class DocumentSerializer(
|
|
||||||
OwnedObjectSerializer,
|
|
||||||
NestedUpdateMixin,
|
|
||||||
DocumentUpdateFieldsModelSerializer,
|
|
||||||
):
|
|
||||||
correspondent = CorrespondentField(allow_null=True)
|
|
||||||
tags = TagsField(many=True)
|
|
||||||
document_type = DocumentTypeField(allow_null=True)
|
|
||||||
storage_path = StoragePathField(allow_null=True)
|
|
||||||
|
|
||||||
original_file_name = SerializerMethodField()
|
|
||||||
archived_file_name = SerializerMethodField()
|
|
||||||
created_date = serializers.DateField(required=False)
|
|
||||||
page_count = SerializerMethodField()
|
|
||||||
duplicate_documents = SerializerMethodField()
|
|
||||||
|
|
||||||
notes = NotesSerializer(many=True, required=False, read_only=True)
|
|
||||||
root_document: RelatedField[Document, Document, Any] | ManyRelatedField = (
|
|
||||||
serializers.PrimaryKeyRelatedField(read_only=True)
|
|
||||||
)
|
|
||||||
versions = SerializerMethodField()
|
|
||||||
|
|
||||||
custom_fields = CustomFieldInstanceSerializer(
|
|
||||||
many=True,
|
|
||||||
allow_null=False,
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
owner = serializers.PrimaryKeyRelatedField(
|
|
||||||
queryset=User.objects.all(),
|
|
||||||
required=False,
|
|
||||||
allow_null=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
remove_inbox_tags = serializers.BooleanField(
|
|
||||||
default=False,
|
|
||||||
write_only=True,
|
|
||||||
allow_null=True,
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_page_count(self, obj) -> int | None:
|
|
||||||
# Like content versions get their own page count from the newest version,
|
|
||||||
# use the prefetched versions cache to avoid an extra query
|
|
||||||
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
|
|
||||||
prefetched_versions = (
|
|
||||||
prefetched_cache.get("versions")
|
|
||||||
if isinstance(prefetched_cache, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if obj.root_document_id is None and prefetched_versions:
|
|
||||||
return sort_versions_newest_first(prefetched_versions)[0].page_count
|
|
||||||
return obj.page_count
|
|
||||||
|
|
||||||
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
|
|
||||||
def get_duplicate_documents(self, obj):
|
|
||||||
view = self.context.get("view")
|
|
||||||
if view and getattr(view, "action", None) != "retrieve":
|
|
||||||
return []
|
|
||||||
request = self.context.get("request")
|
|
||||||
user = request.user if request else None
|
|
||||||
duplicates = _get_viewable_duplicates(obj, user)
|
|
||||||
return list(duplicates.values("id", "title", "deleted_at"))
|
|
||||||
|
|
||||||
@extend_schema_field(DocumentVersionInfoSerializer(many=True))
|
|
||||||
def get_versions(self, obj):
|
|
||||||
root_doc = obj if obj.root_document_id is None else obj.root_document
|
|
||||||
if root_doc is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
|
|
||||||
prefetched_versions = (
|
|
||||||
prefetched_cache.get("versions")
|
|
||||||
if isinstance(prefetched_cache, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
versions: list[Document]
|
|
||||||
if prefetched_versions is not None:
|
|
||||||
versions = [*prefetched_versions, root_doc]
|
|
||||||
else:
|
|
||||||
versions_qs = Document.objects.filter(root_document=root_doc).only(
|
|
||||||
"id",
|
|
||||||
"added",
|
|
||||||
"checksum",
|
|
||||||
"version_label",
|
|
||||||
"root_document_id",
|
|
||||||
"version_index",
|
|
||||||
)
|
|
||||||
versions = [*versions_qs, root_doc]
|
|
||||||
|
|
||||||
versions = sort_versions_newest_first(versions)
|
|
||||||
|
|
||||||
def build_info(doc: Document) -> _DocumentVersionInfo:
|
|
||||||
return {
|
|
||||||
"id": doc.id,
|
|
||||||
"added": doc.added,
|
|
||||||
"version_label": doc.version_label,
|
|
||||||
"checksum": doc.checksum,
|
|
||||||
"is_root": doc.id == root_doc.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
return [build_info(doc) for doc in versions]
|
|
||||||
|
|
||||||
def get_original_file_name(self, obj) -> str | None:
|
|
||||||
return obj.original_filename
|
|
||||||
|
|
||||||
def get_archived_file_name(self, obj) -> str | None:
|
|
||||||
if obj.has_archive_version:
|
|
||||||
return obj.get_public_filename(archive=True)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def to_representation(self, instance):
|
|
||||||
doc = super().to_representation(instance)
|
|
||||||
if "content" in self.fields and has_prefetched_effective_content(instance):
|
|
||||||
# Only resolve version-aware content when it's cheap: an SQL
|
|
||||||
# annotation or a versions prefetch is already on the instance.
|
|
||||||
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
|
|
||||||
# which build their own querysets) gets the document's own,
|
|
||||||
# unresolved content instead of paying for an extra per-instance
|
|
||||||
# query -- same as before effective_content resolution existed.
|
|
||||||
doc["content"] = instance.get_effective_content() or ""
|
|
||||||
if self.truncate_content and "content" in self.fields:
|
|
||||||
doc["content"] = doc.get("content")[0:550]
|
|
||||||
return doc
|
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
|
||||||
if (
|
|
||||||
"created" in data
|
|
||||||
and isinstance(data["created"], str)
|
|
||||||
and ":" in data["created"]
|
|
||||||
):
|
|
||||||
# Handle old format of isoformat datetime string
|
|
||||||
parsed = parse_datetime(data["created"])
|
|
||||||
if parsed:
|
|
||||||
if is_naive(parsed):
|
|
||||||
parsed = make_aware(parsed, get_current_timezone())
|
|
||||||
data["created"] = parsed.astimezone().date()
|
|
||||||
return super().to_internal_value(data)
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
if (
|
|
||||||
"archive_serial_number" in attrs
|
|
||||||
and attrs["archive_serial_number"] is not None
|
|
||||||
and len(str(attrs["archive_serial_number"])) > 0
|
|
||||||
and Document.deleted_objects.filter(
|
|
||||||
archive_serial_number=attrs["archive_serial_number"],
|
|
||||||
).exists()
|
|
||||||
):
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
{
|
|
||||||
"archive_serial_number": [
|
|
||||||
"Document with this Archive Serial Number already exists in the trash.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return super().validate(attrs)
|
|
||||||
|
|
||||||
def update(self, instance: Document, validated_data):
|
|
||||||
if "created_date" in validated_data:
|
|
||||||
if "created" not in validated_data:
|
|
||||||
validated_data["created"] = validated_data["created_date"]
|
|
||||||
logger.warning(
|
|
||||||
"created_date is deprecated, use created instead",
|
|
||||||
)
|
|
||||||
validated_data.pop("created_date")
|
|
||||||
if instance.custom_fields.count() > 0 and "custom_fields" in validated_data:
|
|
||||||
incoming_custom_fields = [
|
|
||||||
field["field"] for field in validated_data["custom_fields"]
|
|
||||||
]
|
|
||||||
for custom_field_instance in instance.custom_fields.filter(
|
|
||||||
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
|
||||||
):
|
|
||||||
if (
|
|
||||||
custom_field_instance.field not in incoming_custom_fields
|
|
||||||
and custom_field_instance.value is not None
|
|
||||||
):
|
|
||||||
# Doc link field is being removed entirely
|
|
||||||
for doc_id in custom_field_instance.value:
|
|
||||||
bulk_edit.remove_doclink(
|
|
||||||
instance,
|
|
||||||
custom_field_instance.field,
|
|
||||||
doc_id,
|
|
||||||
)
|
|
||||||
if "tags" in validated_data:
|
|
||||||
# Respect tag hierarchy on updates:
|
|
||||||
# - Adding a child adds its ancestors
|
|
||||||
# - Removing a parent removes all its descendants
|
|
||||||
prev_tags = set(instance.tags.all())
|
|
||||||
requested_tags = set(validated_data["tags"])
|
|
||||||
|
|
||||||
# Tags newly added in this update and the ancestors they require
|
|
||||||
added_tags = requested_tags - prev_tags
|
|
||||||
required_by_add_tags = set(added_tags)
|
|
||||||
for t in added_tags:
|
|
||||||
required_by_add_tags.update(t.get_ancestors())
|
|
||||||
|
|
||||||
# Tags being removed in this update and all descendants, except
|
|
||||||
# those required by a tag that is being added in this same update
|
|
||||||
removed_tags = prev_tags - requested_tags
|
|
||||||
blocked_tags = set(removed_tags)
|
|
||||||
for t in removed_tags:
|
|
||||||
blocked_tags.update(t.get_descendants())
|
|
||||||
blocked_tags.difference_update(required_by_add_tags)
|
|
||||||
|
|
||||||
# Add all parent tags
|
|
||||||
final_tags = set(requested_tags)
|
|
||||||
for t in requested_tags:
|
|
||||||
final_tags.update(t.get_ancestors())
|
|
||||||
|
|
||||||
# Drop removed parents and their descendants
|
|
||||||
final_tags.difference_update(blocked_tags)
|
|
||||||
|
|
||||||
validated_data["tags"] = list(final_tags)
|
|
||||||
if validated_data.get("remove_inbox_tags"):
|
|
||||||
current_tag_ids = {t.pk for t in instance.tags.all()}
|
|
||||||
tags = (
|
|
||||||
validated_data["tags"]
|
|
||||||
if "tags" in validated_data
|
|
||||||
else list(instance.tags.all())
|
|
||||||
)
|
|
||||||
|
|
||||||
# Tags newly added in this update, plus their ancestors, are kept
|
|
||||||
keep_ids: set[int] = set()
|
|
||||||
for tag in tags:
|
|
||||||
if tag.pk not in current_tag_ids:
|
|
||||||
keep_ids.add(tag.pk)
|
|
||||||
keep_ids.update(int(pk) for pk in tag.get_ancestors_pks())
|
|
||||||
|
|
||||||
# Remove inbox tags and their descendants, except those being kept
|
|
||||||
remove_ids: set[int] = set()
|
|
||||||
for inbox_tag in (
|
|
||||||
Tag.objects.filter(is_inbox_tag=True)
|
|
||||||
.exclude(pk__in=keep_ids)
|
|
||||||
.only("pk", "tn_descendants_pks")
|
|
||||||
):
|
|
||||||
remove_ids.add(inbox_tag.pk)
|
|
||||||
remove_ids.update(int(pk) for pk in inbox_tag.get_descendants_pks())
|
|
||||||
|
|
||||||
validated_data["tags"] = [t for t in tags if t.pk not in remove_ids]
|
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
|
||||||
with set_actor(self.user):
|
|
||||||
super().update(instance, validated_data)
|
|
||||||
else:
|
|
||||||
super().update(instance, validated_data)
|
|
||||||
|
|
||||||
# hard delete custom field instances that were soft deleted
|
|
||||||
CustomFieldInstance.deleted_objects.filter(document=instance).delete()
|
|
||||||
return instance
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
|
||||||
self.truncate_content = kwargs.pop("truncate_content", False)
|
|
||||||
|
|
||||||
# return full permissions if we're doing a PATCH or PUT
|
|
||||||
context = kwargs.get("context")
|
|
||||||
if context is not None and (
|
|
||||||
context.get("request").method == "PATCH"
|
|
||||||
or context.get("request").method == "PUT"
|
|
||||||
):
|
|
||||||
kwargs["full_perms"] = True
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Document
|
|
||||||
fields = (
|
|
||||||
"id",
|
|
||||||
"correspondent",
|
|
||||||
"document_type",
|
|
||||||
"storage_path",
|
|
||||||
"title",
|
|
||||||
"content",
|
|
||||||
"tags",
|
|
||||||
"created",
|
|
||||||
"created_date",
|
|
||||||
"modified",
|
|
||||||
"added",
|
|
||||||
"deleted_at",
|
|
||||||
"archive_serial_number",
|
|
||||||
"original_file_name",
|
|
||||||
"archived_file_name",
|
|
||||||
"duplicate_documents",
|
|
||||||
"owner",
|
|
||||||
"permissions",
|
|
||||||
"user_can_change",
|
|
||||||
"is_shared_by_requester",
|
|
||||||
"set_permissions",
|
|
||||||
"notes",
|
|
||||||
"custom_fields",
|
|
||||||
"remove_inbox_tags",
|
|
||||||
"page_count",
|
|
||||||
"mime_type",
|
|
||||||
"root_document",
|
|
||||||
"versions",
|
|
||||||
)
|
|
||||||
read_only_fields = ("deleted_at",)
|
|
||||||
list_serializer_class = OwnedObjectListSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class SearchResultListSerializer(serializers.ListSerializer[Document]):
|
|
||||||
def to_representation(self, hits):
|
|
||||||
document_ids = [hit["id"] for hit in hits]
|
|
||||||
# Fetch all Document objects in the list in one SQL query.
|
|
||||||
documents = self.child.fetch_documents(document_ids)
|
|
||||||
self.child.context["documents"] = documents
|
|
||||||
# Also check if they are shared with other users / groups.
|
|
||||||
self.child.context["shared_object_pks"] = self.child.get_shared_object_pks(
|
|
||||||
documents.values(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return super().to_representation(hits)
|
|
||||||
|
|
||||||
|
|
||||||
class SearchResultSerializer(DocumentSerializer):
|
|
||||||
@staticmethod
|
|
||||||
def fetch_documents(ids):
|
|
||||||
"""
|
|
||||||
Return a dict that maps given document IDs to Document objects.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
document.id: document
|
|
||||||
for document in Document.objects.select_related(
|
|
||||||
"correspondent",
|
|
||||||
"storage_path",
|
|
||||||
"document_type",
|
|
||||||
"owner",
|
|
||||||
)
|
|
||||||
.prefetch_related("tags", "custom_fields", "notes")
|
|
||||||
.filter(id__in=ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
def to_representation(self, hit):
|
|
||||||
# Again we first check if the parent has already fetched the documents.
|
|
||||||
documents = self.context.get("documents")
|
|
||||||
# Otherwise we fetch this document.
|
|
||||||
if documents is None: # pragma: no cover
|
|
||||||
# In practice we only serialize **lists** of SearchHit dicts.
|
|
||||||
# Keeping this check for completeness but marking it no cover for now.
|
|
||||||
documents = self.fetch_documents([hit["id"]])
|
|
||||||
document = documents[hit["id"]]
|
|
||||||
|
|
||||||
highlights = hit.get("highlights", {})
|
|
||||||
r = super().to_representation(document)
|
|
||||||
r["__search_hit__"] = {
|
|
||||||
"score": hit["score"],
|
|
||||||
"highlights": highlights.get("content", ""),
|
|
||||||
"note_highlights": highlights.get("notes") or None,
|
|
||||||
"rank": hit["rank"],
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
|
|
||||||
class Meta(DocumentSerializer.Meta):
|
|
||||||
list_serializer_class = SearchResultListSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentVersionSerializer(serializers.Serializer[dict[str, Any]]):
|
|
||||||
document = serializers.FileField(
|
|
||||||
label="Document",
|
|
||||||
write_only=True,
|
|
||||||
)
|
|
||||||
version_label = serializers.CharField(
|
|
||||||
label="Version label",
|
|
||||||
required=False,
|
|
||||||
allow_blank=True,
|
|
||||||
allow_null=True,
|
|
||||||
max_length=64,
|
|
||||||
)
|
|
||||||
|
|
||||||
validate_document = PostDocumentSerializer().validate_document
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentVersionLabelSerializer(serializers.Serializer[dict[str, str | None]]):
|
|
||||||
version_label = serializers.CharField(
|
|
||||||
label="Version label",
|
|
||||||
required=True,
|
|
||||||
allow_blank=True,
|
|
||||||
allow_null=True,
|
|
||||||
max_length=64,
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_version_label(self, value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
normalized = value.strip()
|
|
||||||
return normalized or None
|
|
||||||
@@ -1,557 +0,0 @@
|
|||||||
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),
|
|
||||||
)
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,584 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -20,6 +20,7 @@ from filelock import FileLock
|
|||||||
|
|
||||||
from documents import sanity_checker
|
from documents import sanity_checker
|
||||||
from documents.barcodes import BarcodePlugin
|
from documents.barcodes import BarcodePlugin
|
||||||
|
from documents.barcodes import read_barcode_values
|
||||||
from documents.bulk_download import ArchiveOnlyStrategy
|
from documents.bulk_download import ArchiveOnlyStrategy
|
||||||
from documents.bulk_download import OriginalsOnlyStrategy
|
from documents.bulk_download import OriginalsOnlyStrategy
|
||||||
from documents.caching import clear_document_caches
|
from documents.caching import clear_document_caches
|
||||||
@@ -36,6 +37,7 @@ from documents.data_models import ConsumeFileDuplicateResult
|
|||||||
from documents.data_models import ConsumeFileStoppedResult
|
from documents.data_models import ConsumeFileStoppedResult
|
||||||
from documents.data_models import ConsumeFileSuccessResult
|
from documents.data_models import ConsumeFileSuccessResult
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
|
from documents.data_models import StoredBarcode
|
||||||
from documents.double_sided import CollatePlugin
|
from documents.double_sided import CollatePlugin
|
||||||
from documents.file_handling import create_source_path_directory
|
from documents.file_handling import create_source_path_directory
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
@@ -43,6 +45,7 @@ from documents.matching import prefilter_documents_by_workflowtrigger
|
|||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentBarcode
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import ShareLink
|
from documents.models import ShareLink
|
||||||
@@ -67,6 +70,7 @@ from documents.utils import identity
|
|||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.workflows.utils import get_workflows_for_trigger
|
from documents.workflows.utils import get_workflows_for_trigger
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
|
from paperless.config import BarcodeConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
from paperless.config import RemoteOCRConfig
|
||||||
from paperless.logging import consume_task_id
|
from paperless.logging import consume_task_id
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
@@ -341,6 +345,29 @@ def bulk_update_documents(document_ids) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_barcodes_for_reprocess(document: Document) -> list[StoredBarcode] | None:
|
||||||
|
"""
|
||||||
|
Reads the barcodes of the original again, e.g. for documents consumed
|
||||||
|
before storing them was enabled. Returns None if they should be left as
|
||||||
|
they are: storing is off, the file can't be scanned with the current
|
||||||
|
settings, or the scan failed.
|
||||||
|
"""
|
||||||
|
barcode_settings = BarcodeConfig()
|
||||||
|
if not barcode_settings.barcode_store_values:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmpdir:
|
||||||
|
return read_barcode_values(
|
||||||
|
document.source_path,
|
||||||
|
document.mime_type,
|
||||||
|
barcode_settings,
|
||||||
|
Path(tmpdir),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not read barcodes of document {document}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def update_document_content_maybe_archive_file(
|
def update_document_content_maybe_archive_file(
|
||||||
document_id,
|
document_id,
|
||||||
@@ -387,6 +414,8 @@ def update_document_content_maybe_archive_file(
|
|||||||
produce_archive=produce_archive,
|
produce_archive=produce_archive,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
barcodes = _read_barcodes_for_reprocess(document)
|
||||||
|
|
||||||
thumbnail = parser.get_thumbnail(document.source_path, mime_type)
|
thumbnail = parser.get_thumbnail(document.source_path, mime_type)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -443,6 +472,17 @@ def update_document_content_maybe_archive_file(
|
|||||||
action=LogEntry.Action.UPDATE,
|
action=LogEntry.Action.UPDATE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if barcodes is not None:
|
||||||
|
document.barcodes.all().delete()
|
||||||
|
DocumentBarcode.objects.bulk_create(
|
||||||
|
DocumentBarcode(document=document, **barcode)
|
||||||
|
for barcode in barcodes
|
||||||
|
)
|
||||||
|
# metadata_etag includes modified
|
||||||
|
Document.objects.filter(pk=document.pk).update(
|
||||||
|
modified=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
with FileLock(settings.MEDIA_LOCK):
|
||||||
if parser.get_archive_path():
|
if parser.get_archive_path():
|
||||||
create_source_path_directory(document.archive_path)
|
create_source_path_directory(document.archive_path)
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,95 @@
|
|||||||
|
"""Stored barcode contents in the search index.
|
||||||
|
|
||||||
|
Barcodes are a JSON field like notes and custom fields: barcodes: resolves to
|
||||||
|
barcodes.value:, and a plain query without the prefix does not look at them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import DocumentBarcode
|
||||||
|
from paperless_testing.factories import DocumentBarcodeFactory
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBarcodeSearch:
|
||||||
|
@pytest.fixture
|
||||||
|
def with_barcodes(self, backend: TantivyBackend) -> Document:
|
||||||
|
document = DocumentFactory(title="Letter", content="x")
|
||||||
|
DocumentBarcodeFactory(
|
||||||
|
document=document,
|
||||||
|
value="WIFI:T:WPA;S:Guest-WLAN;P:crocodile123;;",
|
||||||
|
)
|
||||||
|
DocumentBarcodeFactory(
|
||||||
|
document=document,
|
||||||
|
page=2,
|
||||||
|
value="DE89370400440532013000",
|
||||||
|
format=DocumentBarcode.Format.CODE128,
|
||||||
|
)
|
||||||
|
backend.add_or_update(document)
|
||||||
|
return document
|
||||||
|
|
||||||
|
def test_bare_barcodes_prefix_searches_values(
|
||||||
|
self,
|
||||||
|
with_barcodes: Document,
|
||||||
|
index_document: Callable[..., Document],
|
||||||
|
matched_ids: Callable[[str], set[int]],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document with stored barcodes, and a decoy document whose
|
||||||
|
content (not a barcode) contains the same word
|
||||||
|
WHEN:
|
||||||
|
- A bare "barcodes:" prefix query is run
|
||||||
|
THEN:
|
||||||
|
- Only the document whose barcode matches is returned, also for a
|
||||||
|
part of a barcode between separators
|
||||||
|
"""
|
||||||
|
index_document(title="Decoy", content="crocodile123 in the text")
|
||||||
|
|
||||||
|
assert matched_ids("barcodes:crocodile123") == {with_barcodes.pk}
|
||||||
|
assert matched_ids("barcodes.value:DE89370400440532013000") == {
|
||||||
|
with_barcodes.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_barcodes_format_subpath(
|
||||||
|
self,
|
||||||
|
with_barcodes: Document,
|
||||||
|
matched_ids: Callable[[str], set[int]],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document with a QR code and a Code 128 barcode
|
||||||
|
WHEN:
|
||||||
|
- The format subpath is queried
|
||||||
|
THEN:
|
||||||
|
- The document is found by its barcode formats
|
||||||
|
"""
|
||||||
|
assert matched_ids("barcodes.format:qrcode") == {with_barcodes.pk}
|
||||||
|
assert matched_ids("barcodes.format:aztec") == set()
|
||||||
|
|
||||||
|
def test_plain_query_ignores_barcodes(
|
||||||
|
self,
|
||||||
|
with_barcodes: Document,
|
||||||
|
matched_ids: Callable[[str], set[int]],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document with a barcode value not found in its text
|
||||||
|
WHEN:
|
||||||
|
- The value is searched without a field prefix
|
||||||
|
THEN:
|
||||||
|
- Nothing is found, as with notes and custom fields
|
||||||
|
"""
|
||||||
|
assert matched_ids("crocodile123") == set()
|
||||||
@@ -8,7 +8,7 @@ queryable-but-always-empty -- syntactically valid, silently matching
|
|||||||
nothing -- with no test failure anywhere.
|
nothing -- with no test failure anywhere.
|
||||||
|
|
||||||
This indexes one real document carrying values for every JSON field
|
This indexes one real document carrying values for every JSON field
|
||||||
(a Note, a CustomFieldInstance) and inspects the document's own stored
|
(a Note, a CustomFieldInstance, a DocumentBarcode) and inspects the document's own stored
|
||||||
JSON payload, rather than running field-specific queries: that way a
|
JSON payload, rather than running field-specific queries: that way a
|
||||||
future JSON field's subpaths are covered automatically, without a new
|
future JSON field's subpaths are covered automatically, without a new
|
||||||
per-subpath query having to be added by hand each time.
|
per-subpath query having to be added by hand each time.
|
||||||
@@ -27,6 +27,7 @@ from documents.models import CustomFieldInstance
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import Note
|
from documents.models import Note
|
||||||
from documents.search._fields import PUBLIC_FIELDS
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
from paperless_testing.factories import DocumentBarcodeFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -42,11 +43,12 @@ class TestJsonSubpathsAreWrittenAtIndexTime:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A document with a Note and a CustomFieldInstance attached
|
- A document with a Note, a CustomFieldInstance and a
|
||||||
|
DocumentBarcode attached
|
||||||
WHEN:
|
WHEN:
|
||||||
- The document is indexed via TantivyBackend.add_or_update
|
- The document is indexed via TantivyBackend.add_or_update
|
||||||
THEN:
|
THEN:
|
||||||
- Every subpath PUBLIC_FIELDS declares for notes/custom_fields
|
- Every subpath PUBLIC_FIELDS declares for notes/custom_fields/barcodes
|
||||||
is present as a key in the document's stored JSON payload
|
is present as a key in the document's stored JSON payload
|
||||||
"""
|
"""
|
||||||
user = UserFactory(username="completeness-user")
|
user = UserFactory(username="completeness-user")
|
||||||
@@ -65,6 +67,7 @@ class TestJsonSubpathsAreWrittenAtIndexTime:
|
|||||||
field=field,
|
field=field,
|
||||||
value_text="a value",
|
value_text="a value",
|
||||||
)
|
)
|
||||||
|
DocumentBarcodeFactory(document=doc, value="a barcode")
|
||||||
backend.add_or_update(doc)
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
index = backend._index
|
index = backend._index
|
||||||
|
|||||||
@@ -175,6 +175,14 @@ PINNED_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
|
|||||||
fast=False,
|
fast=False,
|
||||||
tokenizer="paperless_text",
|
tokenizer="paperless_text",
|
||||||
),
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"barcodes",
|
||||||
|
"json",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
FieldDescriptor(
|
FieldDescriptor(
|
||||||
"title_sort",
|
"title_sort",
|
||||||
"text",
|
"text",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
"barcode_enable_tag": None,
|
"barcode_enable_tag": None,
|
||||||
"barcode_tag_mapping": None,
|
"barcode_tag_mapping": None,
|
||||||
"barcode_tag_split": None,
|
"barcode_tag_split": None,
|
||||||
|
"barcode_store_values": None,
|
||||||
"remote_ocr_engine": None,
|
"remote_ocr_engine": None,
|
||||||
"remote_ocr_api_key": None,
|
"remote_ocr_api_key": None,
|
||||||
"remote_ocr_endpoint": None,
|
"remote_ocr_endpoint": None,
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.doc1.refresh_from_db()
|
self.doc1.refresh_from_db()
|
||||||
self.assertFalse(self.doc1.tags.filter(pk=self.t1.pk).exists())
|
self.assertFalse(self.doc1.tags.filter(pk=self.t1.pk).exists())
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_tags")
|
@mock.patch("documents.serialisers.bulk_edit.modify_tags")
|
||||||
def test_api_modify_tags(self, m) -> None:
|
def test_api_modify_tags(self, m) -> None:
|
||||||
self.setup_mock(m, "modify_tags")
|
self.setup_mock(m, "modify_tags")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -227,7 +227,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["add_tags"], [self.t1.id])
|
self.assertEqual(kwargs["add_tags"], [self.t1.id])
|
||||||
self.assertEqual(kwargs["remove_tags"], [self.t2.id])
|
self.assertEqual(kwargs["remove_tags"], [self.t2.id])
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_tags")
|
@mock.patch("documents.serialisers.bulk_edit.modify_tags")
|
||||||
def test_api_modify_tags_not_provided(self, m) -> None:
|
def test_api_modify_tags_not_provided(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -255,7 +255,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
def test_api_modify_custom_fields(self, m) -> None:
|
def test_api_modify_custom_fields(self, m) -> None:
|
||||||
self.setup_mock(m, "modify_custom_fields")
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -281,7 +281,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
|
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
|
||||||
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
def test_api_modify_custom_fields_documentlink_forbidden_for_unpermitted_target(
|
def test_api_modify_custom_fields_documentlink_forbidden_for_unpermitted_target(
|
||||||
self,
|
self,
|
||||||
m,
|
m,
|
||||||
@@ -325,7 +325,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
def test_api_modify_custom_fields_with_values(self, m) -> None:
|
def test_api_modify_custom_fields_with_values(self, m) -> None:
|
||||||
self.setup_mock(m, "modify_custom_fields")
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -349,7 +349,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["add_custom_fields"], {self.cf1.id: "foo"})
|
self.assertEqual(kwargs["add_custom_fields"], {self.cf1.id: "foo"})
|
||||||
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
def test_api_modify_custom_fields_rejects_invalid_value(self, m) -> None:
|
def test_api_modify_custom_fields_rejects_invalid_value(self, m) -> None:
|
||||||
self.setup_mock(m, "modify_custom_fields")
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
|
|
||||||
@@ -373,7 +373,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(str(self.cf1.id), response.data["add_custom_fields"])
|
self.assertIn(str(self.cf1.id), response.data["add_custom_fields"])
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.modify_custom_fields")
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
def test_api_modify_custom_fields_invalid_params(self, m) -> None:
|
def test_api_modify_custom_fields_invalid_params(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -493,7 +493,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.delete")
|
@mock.patch("documents.serialisers.bulk_edit.delete")
|
||||||
def test_api_delete(self, m) -> None:
|
def test_api_delete(self, m) -> None:
|
||||||
self.setup_mock(m, "delete")
|
self.setup_mock(m, "delete")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -509,7 +509,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(args[0], [self.doc1.id])
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(len(kwargs), 0)
|
self.assertEqual(len(kwargs), 0)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.delete")
|
@mock.patch("documents.views.bulk_edit.delete")
|
||||||
def test_delete_documents_endpoint(self, m) -> None:
|
def test_delete_documents_endpoint(self, m) -> None:
|
||||||
self.setup_mock(m, "delete")
|
self.setup_mock(m, "delete")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -523,7 +523,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(args[0], [self.doc1.id])
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(len(kwargs), 0)
|
self.assertEqual(len(kwargs), 0)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.delete")
|
@mock.patch("documents.views.bulk_edit.delete")
|
||||||
def test_delete_documents_endpoint_with_excluded_documents(self, m) -> None:
|
def test_delete_documents_endpoint_with_excluded_documents(self, m) -> None:
|
||||||
self.setup_mock(m, "delete")
|
self.setup_mock(m, "delete")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -547,7 +547,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(args[0], [self.doc1.id])
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(len(kwargs), 0)
|
self.assertEqual(len(kwargs), 0)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.reprocess")
|
@mock.patch("documents.views.bulk_edit.reprocess")
|
||||||
def test_reprocess_documents_endpoint(self, m) -> None:
|
def test_reprocess_documents_endpoint(self, m) -> None:
|
||||||
self.setup_mock(m, "reprocess")
|
self.setup_mock(m, "reprocess")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -561,7 +561,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(args[0], [self.doc1.id])
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(kwargs, {"remote_ocr": False})
|
self.assertEqual(kwargs, {"remote_ocr": False})
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.reprocess")
|
@mock.patch("documents.views.bulk_edit.reprocess")
|
||||||
def test_reprocess_documents_endpoint_remote_ocr(self, m) -> None:
|
def test_reprocess_documents_endpoint_remote_ocr(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -583,7 +583,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(args[0], [self.doc1.id])
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(kwargs, {"remote_ocr": True})
|
self.assertEqual(kwargs, {"remote_ocr": True})
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_set_storage_path(self, m) -> None:
|
def test_api_set_storage_path(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -613,7 +613,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertListEqual(args[0], [self.doc1.id])
|
self.assertListEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_unset_storage_path(self, m) -> None:
|
def test_api_unset_storage_path(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -738,7 +738,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
response.content,
|
response.content,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_bulk_edit_with_all_true_resolves_documents_from_filters(
|
def test_api_bulk_edit_with_all_true_resolves_documents_from_filters(
|
||||||
self,
|
self,
|
||||||
m,
|
m,
|
||||||
@@ -764,7 +764,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(args[0], [self.doc2.id])
|
self.assertEqual(args[0], [self.doc2.id])
|
||||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_bulk_edit_with_all_true_excludes_documents(self, m) -> None:
|
def test_api_bulk_edit_with_all_true_excludes_documents(self, m) -> None:
|
||||||
self.setup_mock(m, "set_storage_path")
|
self.setup_mock(m, "set_storage_path")
|
||||||
|
|
||||||
@@ -787,7 +787,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertCountEqual(args[0], [self.doc1.id, self.doc3.id, self.doc5.id])
|
self.assertCountEqual(args[0], [self.doc1.id, self.doc3.id, self.doc5.id])
|
||||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None:
|
def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None:
|
||||||
self.setup_mock(m, "set_storage_path")
|
self.setup_mock(m, "set_storage_path")
|
||||||
user = UserFactory(username="duplicate-owner")
|
user = UserFactory(username="duplicate-owner")
|
||||||
@@ -824,7 +824,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||||
|
|
||||||
@mock.patch("documents.search.get_backend")
|
@mock.patch("documents.search.get_backend")
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters(
|
def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters(
|
||||||
self,
|
self,
|
||||||
m,
|
m,
|
||||||
@@ -1199,7 +1199,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions(self, m) -> None:
|
def test_set_permissions(self, m) -> None:
|
||||||
self.setup_mock(m, "set_permissions")
|
self.setup_mock(m, "set_permissions")
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
@@ -1234,7 +1234,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
||||||
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
|
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_requires_set_permissions_parameter(self, m) -> None:
|
def test_set_permissions_requires_set_permissions_parameter(self, m) -> None:
|
||||||
self.setup_mock(m, "set_permissions")
|
self.setup_mock(m, "set_permissions")
|
||||||
|
|
||||||
@@ -1258,7 +1258,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(b"set_permissions not specified", response.content)
|
self.assertIn(b"set_permissions not specified", response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_rejects_malformed_set_permissions(self, m) -> None:
|
def test_set_permissions_rejects_malformed_set_permissions(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1293,7 +1293,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(expected_message, response.content)
|
self.assertIn(expected_message, response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_rejects_invalid_owner(self, m) -> None:
|
def test_set_permissions_rejects_invalid_owner(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1335,7 +1335,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(b"Specified owner cannot be found", response.content)
|
self.assertIn(b"Specified owner cannot be found", response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_null_is_a_noop(self, m) -> None:
|
def test_set_permissions_null_is_a_noop(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1371,7 +1371,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
{"view": {}, "change": {}},
|
{"view": {}, "change": {}},
|
||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_passes_validated_owner_id(self, m) -> None:
|
def test_set_permissions_passes_validated_owner_id(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1402,7 +1402,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
self.assertEqual(m.call_args.kwargs["owner"], self.user.id)
|
self.assertEqual(m.call_args.kwargs["owner"], self.user.id)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_merge(self, m) -> None:
|
def test_set_permissions_merge(self, m) -> None:
|
||||||
self.setup_mock(m, "set_permissions")
|
self.setup_mock(m, "set_permissions")
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
@@ -1454,8 +1454,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
_, kwargs = m.call_args
|
_, kwargs = m.call_args
|
||||||
self.assertEqual(kwargs["merge"], True)
|
self.assertEqual(kwargs["merge"], True)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
|
@mock.patch("documents.views.bulk_edit.merge")
|
||||||
def test_insufficient_global_perms(self, mock_merge, mock_set_storage) -> None:
|
def test_insufficient_global_perms(self, mock_merge, mock_set_storage) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1518,7 +1518,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
mock_merge.assert_not_called()
|
mock_merge.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_insufficient_permissions_ownership(self, m) -> None:
|
def test_insufficient_permissions_ownership(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1572,7 +1572,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_insufficient_permissions_edit(self, m) -> None:
|
def test_insufficient_permissions_edit(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1626,7 +1626,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.rotate")
|
@mock.patch("documents.views.bulk_edit.rotate")
|
||||||
def test_rotate(self, m) -> None:
|
def test_rotate(self, m) -> None:
|
||||||
self.setup_mock(m, "rotate")
|
self.setup_mock(m, "rotate")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -1648,7 +1648,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["source_mode"], "latest_version")
|
self.assertEqual(kwargs["source_mode"], "latest_version")
|
||||||
self.assertEqual(kwargs["user"], self.user)
|
self.assertEqual(kwargs["user"], self.user)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.rotate")
|
@mock.patch("documents.views.bulk_edit.rotate")
|
||||||
def test_rotate_invalid_params(self, m) -> None:
|
def test_rotate_invalid_params(self, m) -> None:
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/rotate/",
|
"/api/documents/rotate/",
|
||||||
@@ -1688,7 +1688,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(b"degrees must be a multiple of 90", response.content)
|
self.assertIn(b"degrees must be a multiple of 90", response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.rotate")
|
@mock.patch("documents.serialisers.bulk_edit.rotate")
|
||||||
def test_bulk_edit_rotate_rejects_invalid_degrees(self, m) -> None:
|
def test_bulk_edit_rotate_rejects_invalid_degrees(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1725,7 +1725,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(expected_message, response.content)
|
self.assertIn(expected_message, response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.rotate")
|
@mock.patch("documents.serialisers.bulk_edit.rotate")
|
||||||
def test_bulk_edit_rotate_passes_integer_degrees(self, m) -> None:
|
def test_bulk_edit_rotate_passes_integer_degrees(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1753,7 +1753,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
self.assertEqual(m.call_args.kwargs["degrees"], -90)
|
self.assertEqual(m.call_args.kwargs["degrees"], -90)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.split")
|
@mock.patch("documents.serialisers.bulk_edit.split")
|
||||||
def test_bulk_edit_split_rejects_invalid_pages(self, m) -> None:
|
def test_bulk_edit_split_rejects_invalid_pages(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1785,7 +1785,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(b"invalid pages specified", response.content)
|
self.assertIn(b"invalid pages specified", response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.split")
|
@mock.patch("documents.serialisers.bulk_edit.split")
|
||||||
def test_bulk_edit_split_rejects_unknown_page_count(self, m) -> None:
|
def test_bulk_edit_split_rejects_unknown_page_count(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1815,7 +1815,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(b"document page count is unknown", response.content)
|
self.assertIn(b"document page count is unknown", response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.bulk_edit.split")
|
@mock.patch("documents.serialisers.bulk_edit.split")
|
||||||
def test_bulk_edit_split_parses_pages(self, m) -> None:
|
def test_bulk_edit_split_parses_pages(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1843,7 +1843,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
|
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.rotate")
|
@mock.patch("documents.views.bulk_edit.rotate")
|
||||||
def test_rotate_insufficient_permissions(self, m) -> None:
|
def test_rotate_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
@@ -1882,7 +1882,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
|
@mock.patch("documents.views.bulk_edit.merge")
|
||||||
def test_merge(self, m) -> None:
|
def test_merge(self, m) -> None:
|
||||||
self.setup_mock(m, "merge")
|
self.setup_mock(m, "merge")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -1904,7 +1904,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(kwargs["source_mode"], "latest_version")
|
self.assertEqual(kwargs["source_mode"], "latest_version")
|
||||||
self.assertEqual(kwargs["user"], self.user)
|
self.assertEqual(kwargs["user"], self.user)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
|
@mock.patch("documents.views.bulk_edit.merge")
|
||||||
def test_merge_and_delete_insufficient_permissions(self, m) -> None:
|
def test_merge_and_delete_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
@@ -1945,7 +1945,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
|
@mock.patch("documents.views.bulk_edit.merge")
|
||||||
def test_merge_and_delete_requires_change_permission(self, m) -> None:
|
def test_merge_and_delete_requires_change_permission(self, m) -> None:
|
||||||
self.setup_mock(m, "merge")
|
self.setup_mock(m, "merge")
|
||||||
user = UserFactory(username="no-change")
|
user = UserFactory(username="no-change")
|
||||||
@@ -1966,7 +1966,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.merge")
|
@mock.patch("documents.views.bulk_edit.merge")
|
||||||
def test_merge_invalid_parameters(self, m) -> None:
|
def test_merge_invalid_parameters(self, m) -> None:
|
||||||
self.setup_mock(m, "merge")
|
self.setup_mock(m, "merge")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -1999,7 +1999,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
for method, parameters in method_payloads.items():
|
for method, parameters in method_payloads.items():
|
||||||
with self.subTest(method=method, version=version):
|
with self.subTest(method=method, version=version):
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
f"documents.views.bulk_edit.bulk_edit.{method}",
|
f"documents.views.bulk_edit.{method}",
|
||||||
) as mocked_method:
|
) as mocked_method:
|
||||||
self.setup_mock(mocked_method, method)
|
self.setup_mock(mocked_method, method)
|
||||||
with self.assertLogs("paperless.api", level="WARNING") as logs:
|
with self.assertLogs("paperless.api", level="WARNING") as logs:
|
||||||
@@ -2088,7 +2088,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
{"operations": ["This list may not be empty."]},
|
{"operations": ["This list may not be empty."]},
|
||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf(self, m) -> None:
|
def test_edit_pdf(self, m) -> None:
|
||||||
self.setup_mock(m, "edit_pdf")
|
self.setup_mock(m, "edit_pdf")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -2248,7 +2248,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"Invalid source_mode", response.content)
|
self.assertIn(b"Invalid source_mode", response.content)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf_rejects_invalid_operation_values(self, m) -> None:
|
def test_edit_pdf_rejects_invalid_operation_values(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -2314,7 +2314,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
{"operations": {"0": {"rotate": ["rotate must be a multiple of 90"]}}},
|
{"operations": {"0": {"rotate": ["rotate must be a multiple of 90"]}}},
|
||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf_page_out_of_bounds(self, m) -> None:
|
def test_edit_pdf_page_out_of_bounds(self, m) -> None:
|
||||||
self.setup_mock(m, "edit_pdf")
|
self.setup_mock(m, "edit_pdf")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -2331,7 +2331,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(b"out of bounds", response.content)
|
self.assertIn(b"out of bounds", response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf_insufficient_permissions(self, m) -> None:
|
def test_edit_pdf_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
@@ -2368,7 +2368,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf_update_requires_change_permission(self, m) -> None:
|
def test_edit_pdf_update_requires_change_permission(self, m) -> None:
|
||||||
self.setup_mock(m, "edit_pdf")
|
self.setup_mock(m, "edit_pdf")
|
||||||
user = UserFactory(username="no-change")
|
user = UserFactory(username="no-change")
|
||||||
@@ -2389,8 +2389,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.remove_password")
|
@mock.patch("documents.views.bulk_edit.remove_password")
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_delete_original_requires_delete_permission(
|
def test_delete_original_requires_delete_permission(
|
||||||
self,
|
self,
|
||||||
edit_pdf_mock,
|
edit_pdf_mock,
|
||||||
@@ -2433,7 +2433,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
operation_mock.assert_not_called()
|
operation_mock.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.remove_password")
|
@mock.patch("documents.views.bulk_edit.remove_password")
|
||||||
def test_remove_password(self, m) -> None:
|
def test_remove_password(self, m) -> None:
|
||||||
self.setup_mock(m, "remove_password")
|
self.setup_mock(m, "remove_password")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -2481,7 +2481,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.bulk_edit.remove_password")
|
@mock.patch("documents.views.bulk_edit.remove_password")
|
||||||
def test_remove_password_insufficient_permissions(self, m) -> None:
|
def test_remove_password_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
|||||||
|
|
||||||
def test_oversized_question_is_rejected(self) -> None:
|
def test_oversized_question_is_rejected(self) -> None:
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"documents.views.chat.AIConfig",
|
"documents.views.AIConfig",
|
||||||
return_value=self._mock_ai_enabled(),
|
return_value=self._mock_ai_enabled(),
|
||||||
):
|
):
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
@@ -57,11 +57,11 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
|||||||
chunks = [f"token{i} " for i in range(40)]
|
chunks = [f"token{i} " for i in range(40)]
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"documents.views.chat.AIConfig",
|
"documents.views.AIConfig",
|
||||||
return_value=self._mock_ai_enabled(),
|
return_value=self._mock_ai_enabled(),
|
||||||
),
|
),
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"documents.views.chat.stream_chat_with_documents",
|
"documents.views.stream_chat_with_documents",
|
||||||
return_value=iter(chunks),
|
return_value=iter(chunks),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -78,7 +78,7 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
|||||||
|
|
||||||
def test_missing_question_is_rejected(self) -> None:
|
def test_missing_question_is_rejected(self) -> None:
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"documents.views.chat.AIConfig",
|
"documents.views.AIConfig",
|
||||||
return_value=self._mock_ai_enabled(),
|
return_value=self._mock_ai_enabled(),
|
||||||
):
|
):
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
@@ -102,11 +102,9 @@ class TestChatStreamingViewUnrestrictedFlag:
|
|||||||
never touches the real vector store; returns the patched callable so
|
never touches the real vector store; returns the patched callable so
|
||||||
tests can inspect how it was called.
|
tests can inspect how it was called.
|
||||||
"""
|
"""
|
||||||
mocker.patch(
|
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
|
||||||
"documents.views.chat.AIConfig",
|
|
||||||
).return_value.ai_enabled = True
|
|
||||||
return mocker.patch(
|
return mocker.patch(
|
||||||
"documents.views.chat.stream_chat_with_documents",
|
"documents.views.stream_chat_with_documents",
|
||||||
return_value=iter(()),
|
return_value=iter(()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from documents.filters import EffectiveContentFilter
|
|||||||
from documents.filters import TitleContentFilter
|
from documents.filters import TitleContentFilter
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.views.base import DocumentSelectionMixin
|
from documents.views import DocumentSelectionMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
@@ -516,9 +516,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
root_document=root,
|
root_document=root,
|
||||||
)
|
)
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch("documents.views.DocumentViewSet.get_metadata") as metadata:
|
||||||
"documents.views.documents.DocumentViewSet.get_metadata",
|
|
||||||
) as metadata:
|
|
||||||
metadata.return_value = []
|
metadata.return_value = []
|
||||||
resp = self.client.get(
|
resp = self.client.get(
|
||||||
f"/api/documents/{root.id}/metadata/?version={version.id}",
|
f"/api/documents/{root.id}/metadata/?version={version.id}",
|
||||||
@@ -576,7 +574,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
async_task = mock.Mock()
|
async_task = mock.Mock()
|
||||||
async_task.id = "task-123"
|
async_task.id = "task-123"
|
||||||
|
|
||||||
with mock.patch("documents.views.documents.consume_file") as consume_mock:
|
with mock.patch("documents.views.consume_file") as consume_mock:
|
||||||
consume_mock.apply_async.return_value = async_task
|
consume_mock.apply_async.return_value = async_task
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
f"/api/documents/{root.id}/update_version/",
|
f"/api/documents/{root.id}/update_version/",
|
||||||
@@ -612,7 +610,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
async_task = mock.Mock()
|
async_task = mock.Mock()
|
||||||
async_task.id = "task-123"
|
async_task.id = "task-123"
|
||||||
|
|
||||||
with mock.patch("documents.views.documents.consume_file") as consume_mock:
|
with mock.patch("documents.views.consume_file") as consume_mock:
|
||||||
consume_mock.apply_async.return_value = async_task
|
consume_mock.apply_async.return_value = async_task
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
f"/api/documents/{version.id}/update_version/",
|
f"/api/documents/{version.id}/update_version/",
|
||||||
@@ -637,7 +635,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
upload = self._make_pdf_upload()
|
upload = self._make_pdf_upload()
|
||||||
|
|
||||||
with mock.patch("documents.views.documents.consume_file") as consume_mock:
|
with mock.patch("documents.views.consume_file") as consume_mock:
|
||||||
consume_mock.apply_async.side_effect = Exception("boom")
|
consume_mock.apply_async.side_effect = Exception("boom")
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
f"/api/documents/{root.id}/update_version/",
|
f"/api/documents/{root.id}/update_version/",
|
||||||
@@ -676,7 +674,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
with mock.patch("documents.views.documents.consume_file") as consume_mock:
|
with mock.patch("documents.views.consume_file") as consume_mock:
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
f"/api/documents/{root.id}/update_version/",
|
f"/api/documents/{root.id}/update_version/",
|
||||||
{"document": self._make_pdf_upload()},
|
{"document": self._make_pdf_upload()},
|
||||||
|
|||||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user