diff --git a/docs/api.md b/docs/api.md index 1ac634162..ced8eb5b3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,7 +8,7 @@ Further documentation is provided here for some endpoints and features. ## Authorization -The REST api provides four different forms of authentication. +The REST api provides five different forms of authentication. 1. Basic authentication @@ -52,6 +52,14 @@ The REST api provides four different forms of authentication. [configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)), you can authenticate against the API using Remote User auth. +5. Headless OIDC via [`django-allauth`](https://codeberg.org/allauth/django-allauth) + + `django-allauth` exposes API endpoints under `api/auth/` which enable tools + like third-party apps to authenticate with social accounts that are + configured. See + [here](advanced_usage.md#openid-connect-and-social-authentication) for more + information on social accounts. + ## Searching for documents Full text searching is available on the `/api/documents/` endpoint. Two diff --git a/docs/configuration.md b/docs/configuration.md index cc829342d..41d43d424 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -659,7 +659,7 @@ system. See the corresponding : Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html). -: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.: +: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", or the custom groups claim configured in [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM) e.g.: ```json {"openid_connect":{"SCOPE": ["openid","profile","email","groups"]... @@ -667,6 +667,12 @@ system. See the corresponding Defaults to False +#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM=`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM} + +: Allows you to define a custom groups claim. See [PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) which is required for this setting to take effect. + + Defaults to "groups" + #### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS} : A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist. @@ -1146,8 +1152,9 @@ via the consumption directory, you can disable the consumer to save resources. #### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES} -: When the consumer detects a duplicate document, it will not touch -the original document. This default behavior can be changed here. +: As of version 3.0 Paperless-ngx allows duplicate documents to be consumed by default, _except_ when +this setting is enabled. When enabled, Paperless will check if a document with the same hash already +exists in the system and delete the duplicate file from the consumption directory without consuming it. Defaults to false. diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 2af2fcbf3..ea44b87bf 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -332,19 +332,19 @@ src/app/components/manage/management-list/management-list.component.html - 151 + 182 src/app/components/manage/management-list/management-list.component.html - 151 + 182 src/app/components/manage/management-list/management-list.component.html - 151 + 182 src/app/components/manage/management-list/management-list.component.html - 151 + 182 @@ -534,7 +534,7 @@ src/app/components/document-detail/document-detail.component.html - 396 + 427 @@ -593,7 +593,7 @@ src/app/components/document-detail/document-detail.component.html - 389 + 420 src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html @@ -761,7 +761,7 @@ src/app/components/document-detail/document-detail.component.html - 409 + 440 src/app/components/document-list/document-list.component.html @@ -789,19 +789,19 @@ src/app/components/manage/management-list/management-list.component.html - 52 + 83 src/app/components/manage/management-list/management-list.component.html - 52 + 83 src/app/components/manage/management-list/management-list.component.html - 52 + 83 src/app/components/manage/management-list/management-list.component.html - 52 + 83 src/app/components/manage/saved-views/saved-views.component.html @@ -999,7 +999,7 @@ src/app/components/common/page-header/page-header.component.html - 9 + 18 src/app/components/common/permissions-select/permissions-select.component.html @@ -1262,19 +1262,19 @@ src/app/components/manage/management-list/management-list.component.html - 7 + 38 src/app/components/manage/management-list/management-list.component.html - 7 + 38 src/app/components/manage/management-list/management-list.component.html - 7 + 38 src/app/components/manage/management-list/management-list.component.html - 7 + 38 @@ -1633,22 +1633,6 @@ src/app/components/document-list/document-list.component.html 153 - - src/app/components/manage/management-list/management-list.component.html - 4 - - - src/app/components/manage/management-list/management-list.component.html - 4 - - - src/app/components/manage/management-list/management-list.component.html - 4 - - - src/app/components/manage/management-list/management-list.component.html - 4 - Filter by @@ -1733,35 +1717,35 @@ src/app/components/manage/management-list/management-list.component.html - 21 + 52 src/app/components/manage/management-list/management-list.component.html - 21 + 52 src/app/components/manage/management-list/management-list.component.html - 21 + 52 src/app/components/manage/management-list/management-list.component.html - 21 + 52 src/app/components/manage/management-list/management-list.component.html - 38 + 69 src/app/components/manage/management-list/management-list.component.html - 38 + 69 src/app/components/manage/management-list/management-list.component.html - 38 + 69 src/app/components/manage/management-list/management-list.component.html - 38 + 69 src/app/components/manage/workflows/workflows.component.html @@ -1853,19 +1837,19 @@ src/app/components/manage/management-list/management-list.component.html - 44 + 75 src/app/components/manage/management-list/management-list.component.html - 44 + 75 src/app/components/manage/management-list/management-list.component.html - 44 + 75 src/app/components/manage/management-list/management-list.component.html - 44 + 75 src/app/components/manage/saved-views/saved-views.component.html @@ -1883,11 +1867,18 @@ 97 + + Duplicate(s) detected + + src/app/components/admin/tasks/tasks.component.html + 103 + + Dismiss src/app/components/admin/tasks/tasks.component.html - 110 + 116 src/app/components/admin/tasks/tasks.component.ts @@ -1898,49 +1889,49 @@ Open Document src/app/components/admin/tasks/tasks.component.html - 115 + 121 {VAR_PLURAL, plural, =1 {One task} other { total tasks}} src/app/components/admin/tasks/tasks.component.html - 134 + 140  ( selected) src/app/components/admin/tasks/tasks.component.html - 136 + 142 Failed src/app/components/admin/tasks/tasks.component.html - 148,150 + 154,156 Complete src/app/components/admin/tasks/tasks.component.html - 156,158 + 162,164 Started src/app/components/admin/tasks/tasks.component.html - 164,166 + 170,172 Queued src/app/components/admin/tasks/tasks.component.html - 172,174 + 178,180 @@ -2184,55 +2175,55 @@ src/app/components/manage/management-list/management-list.component.html - 10 + 41 src/app/components/manage/management-list/management-list.component.html - 10 + 41 src/app/components/manage/management-list/management-list.component.html - 10 + 41 src/app/components/manage/management-list/management-list.component.html - 10 + 41 src/app/components/manage/management-list/management-list.component.html - 121 + 152 src/app/components/manage/management-list/management-list.component.html - 121 + 152 src/app/components/manage/management-list/management-list.component.html - 121 + 152 src/app/components/manage/management-list/management-list.component.html - 121 + 152 src/app/components/manage/management-list/management-list.component.html - 140 + 171 src/app/components/manage/management-list/management-list.component.html - 140 + 171 src/app/components/manage/management-list/management-list.component.html - 140 + 171 src/app/components/manage/management-list/management-list.component.html - 140 + 171 src/app/components/manage/management-list/management-list.component.ts - 247 + 249 src/app/components/manage/saved-views/saved-views.component.html @@ -2266,11 +2257,11 @@ src/app/components/manage/management-list/management-list.component.ts - 243 + 245 src/app/components/manage/management-list/management-list.component.ts - 366 + 386 @@ -2312,7 +2303,7 @@ src/app/components/manage/management-list/management-list.component.ts - 368 + 388 src/app/components/manage/workflows/workflows.component.ts @@ -2503,35 +2494,35 @@ src/app/components/manage/management-list/management-list.component.html - 120 + 151 src/app/components/manage/management-list/management-list.component.html - 120 + 151 src/app/components/manage/management-list/management-list.component.html - 120 + 151 src/app/components/manage/management-list/management-list.component.html - 120 + 151 src/app/components/manage/management-list/management-list.component.html - 137 + 168 src/app/components/manage/management-list/management-list.component.html - 137 + 168 src/app/components/manage/management-list/management-list.component.html - 137 + 168 src/app/components/manage/management-list/management-list.component.html - 137 + 168 src/app/components/manage/workflows/workflows.component.html @@ -2607,11 +2598,11 @@ src/app/components/document-detail/document-detail.component.ts - 1098 + 1112 src/app/components/document-detail/document-detail.component.ts - 1463 + 1477 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -2639,7 +2630,7 @@ src/app/components/manage/management-list/management-list.component.ts - 370 + 390 src/app/components/manage/workflows/workflows.component.ts @@ -3237,7 +3228,7 @@ src/app/components/document-detail/document-detail.component.ts - 1051 + 1065 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -3342,7 +3333,7 @@ src/app/components/document-detail/document-detail.component.ts - 1514 + 1528 @@ -3353,7 +3344,7 @@ src/app/components/document-detail/document-detail.component.ts - 1515 + 1529 @@ -3364,7 +3355,7 @@ src/app/components/document-detail/document-detail.component.ts - 1516 + 1530 @@ -3453,7 +3444,7 @@ src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 111 + 113 src/app/components/common/input/date/date.component.html @@ -3573,6 +3564,22 @@ src/app/components/document-list/document-list.component.html 30 + + src/app/components/manage/management-list/management-list.component.html + 32 + + + src/app/components/manage/management-list/management-list.component.html + 32 + + + src/app/components/manage/management-list/management-list.component.html + 32 + + + src/app/components/manage/management-list/management-list.component.html + 32 + Not @@ -3697,14 +3704,14 @@ This month src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 106 + 107 Yesterday src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 116 + 118 src/app/pipes/custom-date.pipe.ts @@ -3715,28 +3722,28 @@ Previous week src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 121 + 123 Previous month src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 135 + 137 Previous quarter src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 141 + 143 Previous year src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 155 + 157 @@ -4440,7 +4447,7 @@ src/app/components/manage/storage-path-list/storage-path-list.component.ts - 51 + 53 @@ -4515,7 +4522,7 @@ src/app/components/manage/tag-list/tag-list.component.ts - 51 + 53 @@ -5458,19 +5465,19 @@ src/app/components/manage/management-list/management-list.component.html - 13 + 44 src/app/components/manage/management-list/management-list.component.html - 13 + 44 src/app/components/manage/management-list/management-list.component.html - 13 + 44 src/app/components/manage/management-list/management-list.component.html - 13 + 44 @@ -5755,11 +5762,30 @@ 20 + + Copied! + + src/app/components/common/page-header/page-header.component.html + 8 + + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 54 + + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 164 + + + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 39 + + Read more src/app/components/common/page-header/page-header.component.html - 15 + 24 src/app/components/common/permissions-select/permissions-select.component.html @@ -6058,21 +6084,6 @@ 47 - - Copied! - - src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html - 54 - - - src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html - 164 - - - src/app/components/common/share-links-dialog/share-links-dialog.component.html - 39 - - Warning: changing the token cannot be undone @@ -6898,6 +6909,22 @@ src/app/components/document-list/document-list.component.html 27 + + src/app/components/manage/management-list/management-list.component.html + 29 + + + src/app/components/manage/management-list/management-list.component.html + 29 + + + src/app/components/manage/management-list/management-list.component.html + 29 + + + src/app/components/manage/management-list/management-list.component.html + 29 + of @@ -6964,7 +6991,7 @@ src/app/components/document-detail/document-detail.component.ts - 1462 + 1476 @@ -7229,88 +7256,109 @@ 354 + + Duplicates + + src/app/components/document-detail/document-detail.component.html + 376,380 + + + + Duplicate documents detected: + + src/app/components/document-detail/document-detail.component.html + 382 + + + + In trash + + src/app/components/document-detail/document-detail.component.html + 393 + + Save & next src/app/components/document-detail/document-detail.component.html - 391 + 422 Save & close src/app/components/document-detail/document-detail.component.html - 394 + 425 Document loading... src/app/components/document-detail/document-detail.component.html - 404 + 435 Enter Password src/app/components/document-detail/document-detail.component.html - 458 + 489 An error occurred loading content: src/app/components/document-detail/document-detail.component.ts - 430,432 + 432,434 Document changes detected src/app/components/document-detail/document-detail.component.ts - 464 + 471 The version of this document in your browser session appears older than the existing version. src/app/components/document-detail/document-detail.component.ts - 465 + 472 Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document. src/app/components/document-detail/document-detail.component.ts - 466 + 473 Ok src/app/components/document-detail/document-detail.component.ts - 468 + 475 Next document src/app/components/document-detail/document-detail.component.ts - 594 + 601 Previous document src/app/components/document-detail/document-detail.component.ts - 604 + 611 Close document src/app/components/document-detail/document-detail.component.ts - 612 + 619 src/app/services/open-documents.service.ts @@ -7321,67 +7369,67 @@ Save document src/app/components/document-detail/document-detail.component.ts - 619 + 626 Save and close / next src/app/components/document-detail/document-detail.component.ts - 628 + 635 Error retrieving metadata src/app/components/document-detail/document-detail.component.ts - 683 + 690 Error retrieving suggestions. src/app/components/document-detail/document-detail.component.ts - 731 + 745 Document "" saved successfully. src/app/components/document-detail/document-detail.component.ts - 940 + 954 src/app/components/document-detail/document-detail.component.ts - 964 + 978 Error saving document "" src/app/components/document-detail/document-detail.component.ts - 970 + 984 Error saving document src/app/components/document-detail/document-detail.component.ts - 1020 + 1034 Do you really want to move the document "" to the trash? src/app/components/document-detail/document-detail.component.ts - 1052 + 1066 Documents can be restored prior to permanent deletion. src/app/components/document-detail/document-detail.component.ts - 1053 + 1067 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7392,7 +7440,7 @@ Move to trash src/app/components/document-detail/document-detail.component.ts - 1055 + 1069 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7403,14 +7451,14 @@ Error deleting document src/app/components/document-detail/document-detail.component.ts - 1074 + 1088 Reprocess confirm src/app/components/document-detail/document-detail.component.ts - 1094 + 1108 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7421,102 +7469,102 @@ This operation will permanently recreate the archive file for this document. src/app/components/document-detail/document-detail.component.ts - 1095 + 1109 The archive file will be re-generated with the current settings. src/app/components/document-detail/document-detail.component.ts - 1096 + 1110 Reprocess operation for "" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content. src/app/components/document-detail/document-detail.component.ts - 1106 + 1120 Error executing operation src/app/components/document-detail/document-detail.component.ts - 1117 + 1131 Error downloading document src/app/components/document-detail/document-detail.component.ts - 1166 + 1180 Page Fit src/app/components/document-detail/document-detail.component.ts - 1243 + 1257 PDF edit operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1481 + 1495 Error executing PDF edit operation src/app/components/document-detail/document-detail.component.ts - 1493 + 1507 Please enter the current password before attempting to remove it. src/app/components/document-detail/document-detail.component.ts - 1504 + 1518 Password removal operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1536 + 1550 Error executing password removal operation src/app/components/document-detail/document-detail.component.ts - 1550 + 1564 Print failed. src/app/components/document-detail/document-detail.component.ts - 1587 + 1601 Error loading document for printing. src/app/components/document-detail/document-detail.component.ts - 1599 + 1613 An error occurred loading tiff: src/app/components/document-detail/document-detail.component.ts - 1664 + 1678 src/app/components/document-detail/document-detail.component.ts - 1668 + 1682 @@ -8071,6 +8119,22 @@ src/app/components/document-list/document-list.component.html 5 + + src/app/components/manage/management-list/management-list.component.html + 6 + + + src/app/components/manage/management-list/management-list.component.html + 6 + + + src/app/components/manage/management-list/management-list.component.html + 6 + + + src/app/components/manage/management-list/management-list.component.html + 6 + src/app/data/custom-field.ts 51 @@ -8082,6 +8146,22 @@ src/app/components/document-list/document-list.component.html 11 + + src/app/components/manage/management-list/management-list.component.html + 12 + + + src/app/components/manage/management-list/management-list.component.html + 12 + + + src/app/components/manage/management-list/management-list.component.html + 12 + + + src/app/components/manage/management-list/management-list.component.html + 12 + Select page @@ -8093,6 +8173,22 @@ src/app/components/document-list/document-list.component.ts 315 + + src/app/components/manage/management-list/management-list.component.html + 13 + + + src/app/components/manage/management-list/management-list.component.html + 13 + + + src/app/components/manage/management-list/management-list.component.html + 13 + + + src/app/components/manage/management-list/management-list.component.html + 13 + Select all @@ -8104,6 +8200,22 @@ src/app/components/document-list/document-list.component.ts 308 + + src/app/components/manage/management-list/management-list.component.html + 14 + + + src/app/components/manage/management-list/management-list.component.html + 14 + + + src/app/components/manage/management-list/management-list.component.html + 14 + + + src/app/components/manage/management-list/management-list.component.html + 14 + Select: @@ -8111,6 +8223,22 @@ src/app/components/document-list/document-list.component.html 18 + + src/app/components/manage/management-list/management-list.component.html + 20 + + + src/app/components/manage/management-list/management-list.component.html + 20 + + + src/app/components/manage/management-list/management-list.component.html + 20 + + + src/app/components/manage/management-list/management-list.component.html + 20 + None @@ -8118,9 +8246,25 @@ src/app/components/document-list/document-list.component.html 23 + + src/app/components/manage/management-list/management-list.component.html + 25 + + + src/app/components/manage/management-list/management-list.component.html + 25 + + + src/app/components/manage/management-list/management-list.component.html + 25 + + + src/app/components/manage/management-list/management-list.component.html + 25 + src/app/components/manage/management-list/management-list.component.ts - 124 + 125 src/app/data/matching-model.ts @@ -8686,28 +8830,28 @@ correspondent src/app/components/manage/correspondent-list/correspondent-list.component.ts - 49 + 51 correspondents src/app/components/manage/correspondent-list/correspondent-list.component.ts - 50 + 52 Last used src/app/components/manage/correspondent-list/correspondent-list.component.ts - 55 + 57 Do you really want to delete the correspondent ""? src/app/components/manage/correspondent-list/correspondent-list.component.ts - 80 + 82 @@ -8739,19 +8883,19 @@ src/app/components/manage/management-list/management-list.component.html - 129 + 160 src/app/components/manage/management-list/management-list.component.html - 129 + 160 src/app/components/manage/management-list/management-list.component.html - 129 + 160 src/app/components/manage/management-list/management-list.component.html - 129 + 160 @@ -8793,21 +8937,21 @@ document type src/app/components/manage/document-type-list/document-type-list.component.ts - 45 + 47 document types src/app/components/manage/document-type-list/document-type-list.component.ts - 46 + 48 Do you really want to delete the document type ""? src/app/components/manage/document-type-list/document-type-list.component.ts - 51 + 53 @@ -9078,7 +9222,7 @@ src/app/components/manage/management-list/management-list.component.ts - 353 + 373 @@ -9120,83 +9264,83 @@ Filter by: src/app/components/manage/management-list/management-list.component.html - 20 + 51 src/app/components/manage/management-list/management-list.component.html - 20 + 51 src/app/components/manage/management-list/management-list.component.html - 20 + 51 src/app/components/manage/management-list/management-list.component.html - 20 + 51 Matching src/app/components/manage/management-list/management-list.component.html - 39 + 70 src/app/components/manage/management-list/management-list.component.html - 39 + 70 src/app/components/manage/management-list/management-list.component.html - 39 + 70 src/app/components/manage/management-list/management-list.component.html - 39 + 70 Document count src/app/components/manage/management-list/management-list.component.html - 40 + 71 src/app/components/manage/management-list/management-list.component.html - 40 + 71 src/app/components/manage/management-list/management-list.component.html - 40 + 71 src/app/components/manage/management-list/management-list.component.html - 40 + 71 {VAR_PLURAL, plural, =1 {One } other { total }} src/app/components/manage/management-list/management-list.component.html - 67 + 98 src/app/components/manage/management-list/management-list.component.html - 67 + 98 src/app/components/manage/management-list/management-list.component.html - 67 + 98 src/app/components/manage/management-list/management-list.component.html - 67 + 98 Automatic src/app/components/manage/management-list/management-list.component.ts - 122 + 123 src/app/data/matching-model.ts @@ -9207,70 +9351,70 @@ Successfully created . src/app/components/manage/management-list/management-list.component.ts - 200 + 202 Error occurred while creating . src/app/components/manage/management-list/management-list.component.ts - 205 + 207 Successfully updated "". src/app/components/manage/management-list/management-list.component.ts - 220 + 222 Error occurred while saving . src/app/components/manage/management-list/management-list.component.ts - 225 + 227 Associated documents will not be deleted. src/app/components/manage/management-list/management-list.component.ts - 245 + 247 Error while deleting element src/app/components/manage/management-list/management-list.component.ts - 261 + 263 Permissions updated successfully src/app/components/manage/management-list/management-list.component.ts - 346 + 366 This operation will permanently delete all objects. src/app/components/manage/management-list/management-list.component.ts - 367 + 387 Objects deleted successfully src/app/components/manage/management-list/management-list.component.ts - 381 + 401 Error deleting objects src/app/components/manage/management-list/management-list.component.ts - 387 + 407 @@ -9347,42 +9491,42 @@ storage path src/app/components/manage/storage-path-list/storage-path-list.component.ts - 45 + 47 storage paths src/app/components/manage/storage-path-list/storage-path-list.component.ts - 46 + 48 Do you really want to delete the storage path ""? src/app/components/manage/storage-path-list/storage-path-list.component.ts - 62 + 64 tag src/app/components/manage/tag-list/tag-list.component.ts - 45 + 47 tags src/app/components/manage/tag-list/tag-list.component.ts - 46 + 48 Do you really want to delete the tag ""? src/app/components/manage/tag-list/tag-list.component.ts - 62 + 64 diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.html b/src-ui/src/app/components/admin/tasks/tasks.component.html index 084195221..ad625789c 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -97,6 +97,12 @@
(click for full output) } + @if (task.duplicate_documents?.length > 0) { +
+ + Duplicate(s) detected +
+ } } diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html index 74b49bbdb..2057a79ff 100644 --- a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html +++ b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html @@ -164,9 +164,11 @@ {{ item.name }} @if (item.dateEnd) { - {{ item.date | customDate:'MMM d' }} – {{ item.dateEnd | customDate:'mediumDate' }} + {{ item.date | customDate:'mediumDate' }} – {{ item.dateEnd | customDate:'mediumDate' }} + } @else if (item.dateTilNow) { + {{ item.dateTilNow | customDate:'mediumDate' }} – now } @else { - {{ item.date | customDate:'mediumDate' }} – now + {{ item.date | customDate:'mediumDate' }} } diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts index e07b08959..42bd3b0e4 100644 --- a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts +++ b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts @@ -79,32 +79,34 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { { id: RelativeDate.WITHIN_1_WEEK, name: $localize`Within 1 week`, - date: new Date().setDate(new Date().getDate() - 7), + dateTilNow: new Date().setDate(new Date().getDate() - 7), }, { id: RelativeDate.WITHIN_1_MONTH, name: $localize`Within 1 month`, - date: new Date().setMonth(new Date().getMonth() - 1), + dateTilNow: new Date().setMonth(new Date().getMonth() - 1), }, { id: RelativeDate.WITHIN_3_MONTHS, name: $localize`Within 3 months`, - date: new Date().setMonth(new Date().getMonth() - 3), + dateTilNow: new Date().setMonth(new Date().getMonth() - 3), }, { id: RelativeDate.WITHIN_1_YEAR, name: $localize`Within 1 year`, - date: new Date().setFullYear(new Date().getFullYear() - 1), + dateTilNow: new Date().setFullYear(new Date().getFullYear() - 1), }, { id: RelativeDate.THIS_YEAR, name: $localize`This year`, date: new Date('1/1/' + new Date().getFullYear()), + dateEnd: new Date('12/31/' + new Date().getFullYear()), }, { id: RelativeDate.THIS_MONTH, name: $localize`This month`, date: new Date().setDate(1), + dateEnd: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0), }, { id: RelativeDate.TODAY, diff --git a/src-ui/src/app/components/common/page-header/page-header.component.html b/src-ui/src/app/components/common/page-header/page-header.component.html index 283218219..488fff59d 100644 --- a/src-ui/src/app/components/common/page-header/page-header.component.html +++ b/src-ui/src/app/components/common/page-header/page-header.component.html @@ -1,9 +1,18 @@
-

- {{title}} +

+ {{title}} + @if (id) { + + @if (copied) { +  Copied! + } @else { + ID: {{id}} + } + + } @if (subTitle) { - {{subTitle}} + {{subTitle}} } @if (info) { - - - +
+ + + +
+

+ +
+
+ Select: +
+
+ @if (selectedObjects.size > 0) { + + } + + +
+
+ + + +
@@ -31,7 +62,7 @@
- +
diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts index a9f7a0626..dca1bb2c9 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts @@ -163,8 +163,7 @@ describe('ManagementListComponent', () => { const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const reloadSpy = jest.spyOn(component, 'reloadData') - const createButton = fixture.debugElement.queryAll(By.css('button'))[4] - createButton.triggerEventHandler('click') + component.openCreateDialog() expect(modal).not.toBeUndefined() const editDialog = modal.componentInstance as EditDialogComponent @@ -187,8 +186,7 @@ describe('ManagementListComponent', () => { const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const reloadSpy = jest.spyOn(component, 'reloadData') - const editButton = fixture.debugElement.queryAll(By.css('button'))[7] - editButton.triggerEventHandler('click') + component.openEditDialog(tags[0]) expect(modal).not.toBeUndefined() const editDialog = modal.componentInstance as EditDialogComponent @@ -212,8 +210,7 @@ describe('ManagementListComponent', () => { const deleteSpy = jest.spyOn(tagService, 'delete') const reloadSpy = jest.spyOn(component, 'reloadData') - const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8] - deleteButton.triggerEventHandler('click') + component.openDeleteDialog(tags[0]) expect(modal).not.toBeUndefined() const editDialog = modal.componentInstance as ConfirmDialogComponent @@ -230,6 +227,21 @@ describe('ManagementListComponent', () => { expect(reloadSpy).toHaveBeenCalled() }) + it('should use the all list length for collection size when provided', fakeAsync(() => { + jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce( + of({ + count: 1, + all: [1, 2, 3], + results: tags.slice(0, 1), + }) + ) + + component.reloadData() + tick(100) + + expect(component.collectionSize).toBe(3) + })) + it('should support quick filter for objects', () => { const expectedUrl = documentListViewService.getQuickFilterUrl([ { rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() }, @@ -264,19 +276,84 @@ describe('ManagementListComponent', () => { expect(component.page).toEqual(1) }) - it('should support toggle all items in view', () => { + it('should support toggle select page in vew', () => { expect(component.selectedObjects.size).toEqual(0) - const toggleAllSpy = jest.spyOn(component, 'toggleAll') + const selectPageSpy = jest.spyOn(component, 'selectPage') const checkButton = fixture.debugElement.queryAll( By.css('input.form-check-input') )[0] - checkButton.nativeElement.dispatchEvent(new Event('click')) + checkButton.nativeElement.dispatchEvent(new Event('change')) checkButton.nativeElement.checked = true - checkButton.nativeElement.dispatchEvent(new Event('click')) - expect(toggleAllSpy).toHaveBeenCalled() + checkButton.nativeElement.dispatchEvent(new Event('change')) + expect(selectPageSpy).toHaveBeenCalled() expect(component.selectedObjects.size).toEqual(tags.length) }) + it('selectNone should clear selection and reset toggle flag', () => { + component.selectedObjects = new Set([tags[0].id, tags[1].id]) + component.togggleAll = true + + component.selectNone() + + expect(component.selectedObjects.size).toBe(0) + expect(component.togggleAll).toBe(false) + }) + + it('selectPage should select current page items or clear selection', () => { + component.selectPage(true) + expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id))) + expect(component.togggleAll).toBe(true) + + component.togggleAll = true + component.selectPage(false) + expect(component.selectedObjects.size).toBe(0) + expect(component.togggleAll).toBe(false) + }) + + it('selectAll should use all IDs when collection size exists', () => { + ;(component as any).allIDs = [1, 2, 3, 4] + component.collectionSize = 4 + + component.selectAll() + + expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4])) + expect(component.togggleAll).toBe(true) + }) + + it('selectAll should clear selection when collection size is zero', () => { + component.selectedObjects = new Set([1]) + component.collectionSize = 0 + component.togggleAll = true + + component.selectAll() + + expect(component.selectedObjects.size).toBe(0) + expect(component.togggleAll).toBe(false) + }) + + it('toggleSelected should toggle object selection and update toggle state', () => { + component.toggleSelected(tags[0]) + expect(component.selectedObjects.has(tags[0].id)).toBe(true) + expect(component.togggleAll).toBe(false) + + component.toggleSelected(tags[1]) + component.toggleSelected(tags[2]) + expect(component.togggleAll).toBe(true) + + component.toggleSelected(tags[1]) + expect(component.selectedObjects.has(tags[1].id)).toBe(false) + expect(component.togggleAll).toBe(false) + }) + + it('areAllPageItemsSelected should return false when page has no selectable items', () => { + component.data = [] + component.selectedObjects.clear() + + expect((component as any).areAllPageItemsSelected()).toBe(false) + + component.data = tags + }) + it('should support bulk edit permissions', () => { const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_edit_objects') component.toggleSelected(tags[0]) diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index e8e7a3bb3..daa6a0ea0 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -84,6 +84,7 @@ export abstract class ManagementListComponent public data: T[] = [] private unfilteredData: T[] = [] + private allIDs: number[] = [] public page = 1 @@ -171,7 +172,8 @@ export abstract class ManagementListComponent tap((c) => { this.unfilteredData = c.results this.data = this.filterData(c.results) - this.collectionSize = c.count + this.collectionSize = c.all?.length ?? c.count + this.allIDs = c.all }), delay(100) ) @@ -300,16 +302,6 @@ export abstract class ManagementListComponent return ownsAll } - toggleAll(event: PointerEvent) { - const checked = (event.target as HTMLInputElement).checked - this.togggleAll = checked - if (checked) { - this.selectedObjects = new Set(this.getSelectableIDs(this.data)) - } else { - this.clearSelection() - } - } - protected getSelectableIDs(objects: T[]): number[] { return objects.map((o) => o.id) } @@ -319,10 +311,38 @@ export abstract class ManagementListComponent this.selectedObjects.clear() } + selectNone() { + this.clearSelection() + } + + selectPage(select: boolean) { + if (select) { + this.selectedObjects = new Set(this.getSelectableIDs(this.data)) + this.togggleAll = this.areAllPageItemsSelected() + } else { + this.clearSelection() + } + } + + selectAll() { + if (!this.collectionSize) { + this.clearSelection() + return + } + this.selectedObjects = new Set(this.allIDs) + this.togggleAll = this.areAllPageItemsSelected() + } + toggleSelected(object) { this.selectedObjects.has(object.id) ? this.selectedObjects.delete(object.id) : this.selectedObjects.add(object.id) + this.togggleAll = this.areAllPageItemsSelected() + } + + protected areAllPageItemsSelected(): boolean { + const ids = this.getSelectableIDs(this.data) + return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id)) } setPermissions() { diff --git a/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts b/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts index cac8637d7..3ab940521 100644 --- a/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts +++ b/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts @@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct import { SortableDirective } from 'src/app/directives/sortable.directive' import { PermissionType } from 'src/app/services/permissions.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service' +import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { ManagementListComponent } from '../management-list/management-list.component' @@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp NgbDropdownModule, NgbPaginationModule, NgxBootstrapIconsModule, + ClearableBadgeComponent, ], }) export class StoragePathListComponent extends ManagementListComponent { diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts index 9b1923e43..51403379d 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts @@ -138,16 +138,12 @@ describe('TagListComponent', () => { } component.data = [parent as any] - const selectEvent = { target: { checked: true } } as unknown as PointerEvent - component.toggleAll(selectEvent) + component.selectPage(true) expect(component.selectedObjects.has(10)).toBe(true) expect(component.selectedObjects.has(11)).toBe(true) - const deselectEvent = { - target: { checked: false }, - } as unknown as PointerEvent - component.toggleAll(deselectEvent) + component.selectPage(false) expect(component.selectedObjects.size).toBe(0) }) }) diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts index 544e99b58..87045a50a 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts @@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct import { SortableDirective } from 'src/app/directives/sortable.directive' import { PermissionType } from 'src/app/services/permissions.service' import { TagService } from 'src/app/services/rest/tag.service' +import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { ManagementListComponent } from '../management-list/management-list.component' @@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp NgbDropdownModule, NgbPaginationModule, NgxBootstrapIconsModule, + ClearableBadgeComponent, ], }) export class TagListComponent extends ManagementListComponent { diff --git a/src-ui/src/app/data/document.ts b/src-ui/src/app/data/document.ts index 8aae31945..03d3bf09b 100644 --- a/src-ui/src/app/data/document.ts +++ b/src-ui/src/app/data/document.ts @@ -159,6 +159,8 @@ export interface Document extends ObjectWithPermissions { page_count?: number + duplicate_documents?: Document[] + // Frontend only __changedFields?: string[] } diff --git a/src-ui/src/app/data/paperless-task.ts b/src-ui/src/app/data/paperless-task.ts index b30af7cdd..19dd3921e 100644 --- a/src-ui/src/app/data/paperless-task.ts +++ b/src-ui/src/app/data/paperless-task.ts @@ -1,3 +1,4 @@ +import { Document } from './document' import { ObjectWithId } from './object-with-id' export enum PaperlessTaskType { @@ -42,5 +43,7 @@ export interface PaperlessTask extends ObjectWithId { related_document?: number + duplicate_documents?: Document[] + owner?: number } diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 4c8c4dd28..1ff60220b 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -779,19 +779,45 @@ class ConsumerPreflightPlugin( Q(checksum=checksum) | Q(archive_checksum=checksum), ) if existing_doc.exists(): - msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS - log_msg = f"Not consuming {self.filename}: It is a duplicate of {existing_doc.get().title} (#{existing_doc.get().pk})." + existing_doc = existing_doc.order_by("-created") + duplicates_in_trash = existing_doc.filter(deleted_at__isnull=False) + log_msg = ( + f"Consuming duplicate {self.filename}: " + f"{existing_doc.count()} existing document(s) share the same content." + ) - if existing_doc.first().deleted_at is not None: - msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS_IN_TRASH - log_msg += " Note: existing document is in the trash." + if duplicates_in_trash.exists(): + log_msg += " Note: at least one existing document is in the trash." + + self.log.warning(log_msg) if settings.CONSUMER_DELETE_DUPLICATES: + duplicate = existing_doc.first() + duplicate_label = ( + duplicate.title + or duplicate.original_filename + or (Path(duplicate.filename).name if duplicate.filename else None) + or str(duplicate.pk) + ) + Path(self.input_doc.original_file).unlink() - self._fail( - msg, - log_msg, - ) + + failure_msg = ( + f"Not consuming {self.filename}: " + f"It is a duplicate of {duplicate_label} (#{duplicate.pk})" + ) + status_msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS + + if duplicates_in_trash.exists(): + status_msg = ( + ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS_IN_TRASH + ) + failure_msg += " Note: existing document is in the trash." + + self._fail( + status_msg, + failure_msg, + ) def pre_check_directories(self): """ diff --git a/src/documents/index.py b/src/documents/index.py index ea26ea926..8afc31fe9 100644 --- a/src/documents/index.py +++ b/src/documents/index.py @@ -602,7 +602,7 @@ def rewrite_natural_date_keywords(query_string: str) -> str: case "this year": start = datetime(local_now.year, 1, 1, 0, 0, 0, tzinfo=tz) - end = datetime.combine(today, time.max, tzinfo=tz) + end = datetime(local_now.year, 12, 31, 23, 59, 59, tzinfo=tz) case "previous week": days_since_monday = local_now.weekday() diff --git a/src/documents/migrations/0006_alter_document_checksum_unique.py b/src/documents/migrations/0006_alter_document_checksum_unique.py new file mode 100644 index 000000000..f86799494 --- /dev/null +++ b/src/documents/migrations/0006_alter_document_checksum_unique.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-14 17:45 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0005_workflowtrigger_filter_has_any_correspondents_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="checksum", + field=models.CharField( + editable=False, + max_length=32, + verbose_name="checksum", + help_text="The checksum of the original document.", + ), + ), + ] diff --git a/src/documents/migrations/0007_document_content_length.py b/src/documents/migrations/0007_document_content_length.py new file mode 100644 index 000000000..c294afca5 --- /dev/null +++ b/src/documents/migrations/0007_document_content_length.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.6 on 2026-01-24 07:33 + +import django.db.models.functions.text +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0006_alter_document_checksum_unique"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="content_length", + field=models.GeneratedField( + db_persist=True, + expression=django.db.models.functions.text.Length("content"), + null=False, + help_text="Length of the content field in characters. Automatically maintained by the database for faster statistics computation.", + output_field=models.PositiveIntegerField(default=0), + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index c4a969e83..818042403 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -20,7 +20,9 @@ if settings.AUDIT_LOG_ENABLED: from auditlog.registry import auditlog from django.db.models import Case +from django.db.models import PositiveIntegerField from django.db.models.functions import Cast +from django.db.models.functions import Length from django.db.models.functions import Substr from django_softdelete.models import SoftDeleteModel @@ -192,6 +194,15 @@ class Document(SoftDeleteModel, ModelWithOwner): ), ) + content_length = models.GeneratedField( + expression=Length("content"), + output_field=PositiveIntegerField(default=0), + db_persist=True, + null=False, + serialize=False, + help_text="Length of the content field in characters. Automatically maintained by the database for faster statistics computation.", + ) + mime_type = models.CharField(_("mime type"), max_length=256, editable=False) tags = models.ManyToManyField( @@ -205,7 +216,6 @@ class Document(SoftDeleteModel, ModelWithOwner): _("checksum"), max_length=32, editable=False, - unique=True, help_text=_("The checksum of the original document."), ) @@ -946,7 +956,7 @@ if settings.AUDIT_LOG_ENABLED: auditlog.register( Document, m2m_fields={"tags"}, - exclude_fields=["modified"], + exclude_fields=["content_length", "modified"], ) auditlog.register(Correspondent) auditlog.register(Tag) diff --git a/src/documents/permissions.py b/src/documents/permissions.py index ac6d3f9ca..9d5c9eb68 100644 --- a/src/documents/permissions.py +++ b/src/documents/permissions.py @@ -148,13 +148,29 @@ def get_document_count_filter_for_user(user): ) -def get_objects_for_user_owner_aware(user, perms, Model) -> QuerySet: - objects_owned = Model.objects.filter(owner=user) - objects_unowned = Model.objects.filter(owner__isnull=True) +def get_objects_for_user_owner_aware( + user, + perms, + Model, + *, + include_deleted=False, +) -> QuerySet: + """ + Returns objects the user owns, are unowned, or has explicit perms. + When include_deleted is True, soft-deleted items are also included. + """ + manager = ( + Model.global_objects + if include_deleted and hasattr(Model, "global_objects") + else Model.objects + ) + + objects_owned = manager.filter(owner=user) + objects_unowned = manager.filter(owner__isnull=True) objects_with_perms = get_objects_for_user( user=user, perms=perms, - klass=Model, + klass=manager.all(), accept_global_perms=False, ) return objects_owned | objects_unowned | objects_with_perms diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 560a1a506..e479eb318 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -23,6 +23,7 @@ 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 import Q from django.db.models.functions import Lower from django.utils.crypto import get_random_string from django.utils.dateparse import parse_datetime @@ -72,6 +73,7 @@ from documents.models import WorkflowTrigger from documents.parsers import is_mime_type_supported from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_groups_with_only_permission +from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import set_permissions_for_object from documents.regex import validate_regex_pattern from documents.templating.filepath import validate_filepath_template_and_render @@ -82,6 +84,9 @@ from documents.validators import url_validator if TYPE_CHECKING: from collections.abc import Iterable + from django.db.models.query import QuerySet + + logger = logging.getLogger("paperless.serializers") @@ -1014,6 +1019,32 @@ class NotesSerializer(serializers.ModelSerializer): return ret +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.order_by("-created") + allowed = get_objects_for_user_owner_aware( + user, + "documents.view_document", + Document, + include_deleted=True, + ) + return duplicates.filter(id__in=allowed) + + +class DuplicateDocumentSummarySerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField() + deleted_at = serializers.DateTimeField(allow_null=True) + + @extend_schema_serializer( deprecate_fields=["created_date"], ) @@ -1031,6 +1062,7 @@ class DocumentSerializer( 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) @@ -1056,6 +1088,16 @@ class DocumentSerializer( def get_page_count(self, obj) -> int | None: return obj.page_count + @extend_schema_field(DuplicateDocumentSummarySerializer(many=True)) + def get_duplicate_documents(self, obj): + view = self.context.get("view") + if view and getattr(view, "action", None) != "retrieve": + return [] + request = self.context.get("request") + user = request.user if request else None + duplicates = _get_viewable_duplicates(obj, user) + return list(duplicates.values("id", "title", "deleted_at")) + def get_original_file_name(self, obj) -> str | None: return obj.original_filename @@ -1233,6 +1275,7 @@ class DocumentSerializer( "archive_serial_number", "original_file_name", "archived_file_name", + "duplicate_documents", "owner", "permissions", "user_can_change", @@ -2094,10 +2137,12 @@ class TasksViewSerializer(OwnedObjectSerializer): "result", "acknowledged", "related_document", + "duplicate_documents", "owner", ) related_document = serializers.SerializerMethodField() + duplicate_documents = serializers.SerializerMethodField() created_doc_re = re.compile(r"New document id (\d+) created") duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)") @@ -2122,6 +2167,17 @@ class TasksViewSerializer(OwnedObjectSerializer): return result + @extend_schema_field(DuplicateDocumentSummarySerializer(many=True)) + def get_duplicate_documents(self, obj): + related_document = self.get_related_document(obj) + request = self.context.get("request") + user = request.user if request else None + document = Document.global_objects.filter(pk=related_document).first() + if not related_document or not user or not document: + return [] + duplicates = _get_viewable_duplicates(document, user) + return list(duplicates.values("id", "title", "deleted_at")) + class RunTaskViewSerializer(serializers.Serializer): task_name = serializers.ChoiceField( diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index f40ef157f..96d22dc2c 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -131,6 +131,10 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): self.assertIn("content", results_full[0]) self.assertIn("id", results_full[0]) + # Content length is used internally for performance reasons. + # No need to expose this field. + self.assertNotIn("content_length", results_full[0]) + response = self.client.get("/api/documents/?fields=id", format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) results = response.data["results"] diff --git a/src/documents/tests/test_api_tasks.py b/src/documents/tests/test_api_tasks.py index aa42577c4..6429ef44f 100644 --- a/src/documents/tests/test_api_tasks.py +++ b/src/documents/tests/test_api_tasks.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User from rest_framework import status from rest_framework.test import APITestCase +from documents.models import Document from documents.models import PaperlessTask from documents.tests.utils import DirectoriesMixin from documents.views import TasksViewSet @@ -258,7 +259,7 @@ class TestTasks(DirectoriesMixin, APITestCase): task_id=str(uuid.uuid4()), task_file_name="task_one.pdf", status=celery.states.FAILURE, - result="test.pdf: Not consuming test.pdf: It is a duplicate.", + result="test.pdf: Unexpected error during ingestion.", ) response = self.client.get(self.ENDPOINT) @@ -270,7 +271,7 @@ class TestTasks(DirectoriesMixin, APITestCase): self.assertEqual( returned_data["result"], - "test.pdf: Not consuming test.pdf: It is a duplicate.", + "test.pdf: Unexpected error during ingestion.", ) def test_task_name_webui(self): @@ -325,20 +326,34 @@ class TestTasks(DirectoriesMixin, APITestCase): self.assertEqual(returned_data["task_file_name"], "anothertest.pdf") - def test_task_result_failed_duplicate_includes_related_doc(self): + def test_task_result_duplicate_warning_includes_count(self): """ GIVEN: - - A celery task failed with a duplicate error + - A celery task succeeds, but a duplicate exists WHEN: - API call is made to get tasks THEN: - - The returned data includes a related document link + - The returned data includes duplicate warning metadata """ + checksum = "duplicate-checksum" + Document.objects.create( + title="Existing", + content="", + mime_type="application/pdf", + checksum=checksum, + ) + created_doc = Document.objects.create( + title="Created", + content="", + mime_type="application/pdf", + checksum=checksum, + archive_checksum="another-checksum", + ) PaperlessTask.objects.create( task_id=str(uuid.uuid4()), task_file_name="task_one.pdf", - status=celery.states.FAILURE, - result="Not consuming task_one.pdf: It is a duplicate of task_one_existing.pdf (#1234).", + status=celery.states.SUCCESS, + result=f"Success. New document id {created_doc.pk} created", ) response = self.client.get(self.ENDPOINT) @@ -348,7 +363,7 @@ class TestTasks(DirectoriesMixin, APITestCase): returned_data = response.data[0] - self.assertEqual(returned_data["related_document"], "1234") + self.assertEqual(returned_data["related_document"], str(created_doc.pk)) def test_run_train_classifier_task(self): """ diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index 63d6f8f5b..16fa2bf70 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -485,21 +485,21 @@ class TestConsumer( with self.get_consumer(self.get_test_file()) as consumer: consumer.run() - with self.assertRaisesMessage(ConsumerError, "It is a duplicate"): - with self.get_consumer(self.get_test_file()) as consumer: - consumer.run() + with self.get_consumer(self.get_test_file()) as consumer: + consumer.run() - self._assert_first_last_send_progress(last_status="FAILED") + self.assertEqual(Document.objects.count(), 2) + self._assert_first_last_send_progress() def testDuplicates2(self): with self.get_consumer(self.get_test_file()) as consumer: consumer.run() - with self.assertRaisesMessage(ConsumerError, "It is a duplicate"): - with self.get_consumer(self.get_test_archive_file()) as consumer: - consumer.run() + with self.get_consumer(self.get_test_archive_file()) as consumer: + consumer.run() - self._assert_first_last_send_progress(last_status="FAILED") + self.assertEqual(Document.objects.count(), 2) + self._assert_first_last_send_progress() def testDuplicates3(self): with self.get_consumer(self.get_test_archive_file()) as consumer: @@ -513,9 +513,10 @@ class TestConsumer( Document.objects.all().delete() - with self.assertRaisesMessage(ConsumerError, "document is in the trash"): - with self.get_consumer(self.get_test_file()) as consumer: - consumer.run() + with self.get_consumer(self.get_test_file()) as consumer: + consumer.run() + + self.assertEqual(Document.objects.count(), 1) def testAsnExists(self): with self.get_consumer( @@ -718,12 +719,45 @@ class TestConsumer( dst = self.get_test_file() self.assertIsFile(dst) - with self.assertRaises(ConsumerError): + expected_message = ( + f"{dst.name}: Not consuming {dst.name}: " + f"It is a duplicate of {document.title} (#{document.pk})" + ) + + with self.assertRaisesMessage(ConsumerError, expected_message): with self.get_consumer(dst) as consumer: consumer.run() self.assertIsNotFile(dst) - self._assert_first_last_send_progress(last_status="FAILED") + self.assertEqual(Document.objects.count(), 1) + self._assert_first_last_send_progress(last_status=ProgressStatusOptions.FAILED) + + @override_settings(CONSUMER_DELETE_DUPLICATES=True) + def test_delete_duplicate_in_trash(self): + dst = self.get_test_file() + with self.get_consumer(dst) as consumer: + consumer.run() + + # Move the existing document to trash + document = Document.objects.first() + document.delete() + + dst = self.get_test_file() + self.assertIsFile(dst) + + expected_message = ( + f"{dst.name}: Not consuming {dst.name}: " + f"It is a duplicate of {document.title} (#{document.pk})" + f" Note: existing document is in the trash." + ) + + with self.assertRaisesMessage(ConsumerError, expected_message): + with self.get_consumer(dst) as consumer: + consumer.run() + + self.assertIsNotFile(dst) + self.assertEqual(Document.global_objects.count(), 1) + self.assertEqual(Document.objects.count(), 0) @override_settings(CONSUMER_DELETE_DUPLICATES=False) def test_no_delete_duplicate(self): @@ -743,15 +777,12 @@ class TestConsumer( dst = self.get_test_file() self.assertIsFile(dst) - with self.assertRaisesRegex( - ConsumerError, - r"sample\.pdf: Not consuming sample\.pdf: It is a duplicate of sample \(#\d+\)", - ): - with self.get_consumer(dst) as consumer: - consumer.run() + with self.get_consumer(dst) as consumer: + consumer.run() - self.assertIsFile(dst) - self._assert_first_last_send_progress(last_status="FAILED") + self.assertIsNotFile(dst) + self.assertEqual(Document.objects.count(), 2) + self._assert_first_last_send_progress() @override_settings(FILENAME_FORMAT="{title}") @mock.patch("documents.parsers.document_consumer_declaration.send") diff --git a/src/documents/tests/test_index.py b/src/documents/tests/test_index.py index 3167bb762..ef6b535f7 100644 --- a/src/documents/tests/test_index.py +++ b/src/documents/tests/test_index.py @@ -180,7 +180,7 @@ class TestRewriteNaturalDateKeywords(SimpleTestCase): ( "added:this year", datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc), - ("added:[20250101", "TO 20250715"), + ("added:[20250101", "TO 20251231"), ), ( "added:previous year", diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index 81262779a..c2a1360ca 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -241,6 +241,10 @@ class TestExportImport( checksum = hashlib.md5(f.read()).hexdigest() self.assertEqual(checksum, element["fields"]["checksum"]) + # Generated field "content_length" should not be exported, + # it is automatically computed during import. + self.assertNotIn("content_length", element["fields"]) + if document_exporter.EXPORTER_ARCHIVE_NAME in element: fname = ( self.target / element[document_exporter.EXPORTER_ARCHIVE_NAME] diff --git a/src/documents/views.py b/src/documents/views.py index 96b1f50b0..88c9c5cf7 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -35,7 +35,6 @@ from django.db.models import Model from django.db.models import Q from django.db.models import Sum from django.db.models import When -from django.db.models.functions import Length from django.db.models.functions import Lower from django.db.models.manager import Manager from django.http import FileResponse @@ -479,11 +478,11 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): if descendant_pks: filter_q = self.get_document_count_filter() - children_source = ( + children_source = list( Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags}) .select_related("owner") .annotate(document_count=Count("documents", filter=filter_q)) - .order_by(*ordering) + .order_by(*ordering), ) else: children_source = all_tags @@ -495,7 +494,11 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): page = self.paginate_queryset(queryset) serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) + response = self.get_paginated_response(serializer.data) + if descendant_pks: + # Include children in the "all" field, if needed + response.data["all"] = [tag.pk for tag in children_source] + return response def perform_update(self, serializer): old_parent = self.get_object().get_parent() @@ -2322,23 +2325,19 @@ class StatisticsView(GenericAPIView): user = request.user if request.user is not None else None documents = ( - ( - Document.objects.all() - if user is None - else get_objects_for_user_owner_aware( - user, - "documents.view_document", - Document, - ) + Document.objects.all() + if user is None + else get_objects_for_user_owner_aware( + user, + "documents.view_document", + Document, ) - .only("mime_type", "content") - .prefetch_related("tags") ) tags = ( Tag.objects.all() if user is None else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag) - ) + ).only("id", "is_inbox_tag") correspondent_count = ( Correspondent.objects.count() if user is None @@ -2367,31 +2366,33 @@ class StatisticsView(GenericAPIView): ).count() ) - documents_total = documents.count() - - inbox_tags = tags.filter(is_inbox_tag=True) + inbox_tag_pks = list( + tags.filter(is_inbox_tag=True).values_list("pk", flat=True), + ) documents_inbox = ( - documents.filter(tags__id__in=inbox_tags).distinct().count() - if inbox_tags.exists() + documents.filter(tags__id__in=inbox_tag_pks).values("id").distinct().count() + if inbox_tag_pks else None ) - document_file_type_counts = ( + # Single SQL request for document stats and mime type counts + mime_type_stats = list( documents.values("mime_type") - .annotate(mime_type_count=Count("mime_type")) - .order_by("-mime_type_count") - if documents_total > 0 - else [] + .annotate( + mime_type_count=Count("id"), + mime_type_chars=Sum("content_length"), + ) + .order_by("-mime_type_count"), ) - character_count = ( - documents.annotate( - characters=Length("content"), - ) - .aggregate(Sum("characters")) - .get("characters__sum") - ) + # Calculate totals from grouped results + documents_total = sum(row["mime_type_count"] for row in mime_type_stats) + character_count = sum(row["mime_type_chars"] or 0 for row in mime_type_stats) + document_file_type_counts = [ + {"mime_type": row["mime_type"], "mime_type_count": row["mime_type_count"]} + for row in mime_type_stats + ] current_asn = Document.objects.aggregate( Max("archive_serial_number", default=0), @@ -2404,11 +2405,9 @@ class StatisticsView(GenericAPIView): "documents_total": documents_total, "documents_inbox": documents_inbox, "inbox_tag": ( - inbox_tags.first().pk if inbox_tags.exists() else None + inbox_tag_pks[0] if inbox_tag_pks else None ), # backwards compatibility - "inbox_tags": ( - [tag.pk for tag in inbox_tags] if inbox_tags.exists() else None - ), + "inbox_tags": (inbox_tag_pks if inbox_tag_pks else None), "document_file_type_counts": document_file_type_counts, "character_count": character_count, "tag_count": len(tags), diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 7bc9a9801..5bdd1ccf9 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-25 21:46+0000\n" +"POT-Creation-Date: 2026-01-26 20:11+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -49,7 +49,7 @@ msgstr "" msgid "{data_type} does not support query expr {expr!r}." msgstr "" -#: documents/filters.py:669 documents/models.py:135 +#: documents/filters.py:669 documents/models.py:137 msgid "Maximum nesting depth exceeded." msgstr "" @@ -57,1202 +57,1202 @@ msgstr "" msgid "Custom field not found" msgstr "" -#: documents/models.py:38 documents/models.py:747 +#: documents/models.py:40 documents/models.py:757 msgid "owner" msgstr "" -#: documents/models.py:55 documents/models.py:962 +#: documents/models.py:57 documents/models.py:972 msgid "None" msgstr "" -#: documents/models.py:56 documents/models.py:963 +#: documents/models.py:58 documents/models.py:973 msgid "Any word" msgstr "" -#: documents/models.py:57 documents/models.py:964 +#: documents/models.py:59 documents/models.py:974 msgid "All words" msgstr "" -#: documents/models.py:58 documents/models.py:965 +#: documents/models.py:60 documents/models.py:975 msgid "Exact match" msgstr "" -#: documents/models.py:59 documents/models.py:966 +#: documents/models.py:61 documents/models.py:976 msgid "Regular expression" msgstr "" -#: documents/models.py:60 documents/models.py:967 +#: documents/models.py:62 documents/models.py:977 msgid "Fuzzy word" msgstr "" -#: documents/models.py:61 +#: documents/models.py:63 msgid "Automatic" msgstr "" -#: documents/models.py:64 documents/models.py:434 documents/models.py:1528 +#: documents/models.py:66 documents/models.py:444 documents/models.py:1538 #: paperless_mail/models.py:23 paperless_mail/models.py:143 msgid "name" msgstr "" -#: documents/models.py:66 documents/models.py:1031 +#: documents/models.py:68 documents/models.py:1041 msgid "match" msgstr "" -#: documents/models.py:69 documents/models.py:1034 +#: documents/models.py:71 documents/models.py:1044 msgid "matching algorithm" msgstr "" -#: documents/models.py:74 documents/models.py:1039 +#: documents/models.py:76 documents/models.py:1049 msgid "is insensitive" msgstr "" -#: documents/models.py:97 documents/models.py:163 +#: documents/models.py:99 documents/models.py:165 msgid "correspondent" msgstr "" -#: documents/models.py:98 +#: documents/models.py:100 msgid "correspondents" msgstr "" -#: documents/models.py:102 +#: documents/models.py:104 msgid "color" msgstr "" -#: documents/models.py:107 +#: documents/models.py:109 msgid "is inbox tag" msgstr "" -#: documents/models.py:110 +#: documents/models.py:112 msgid "" "Marks this tag as an inbox tag: All newly consumed documents will be tagged " "with inbox tags." msgstr "" -#: documents/models.py:116 +#: documents/models.py:118 msgid "tag" msgstr "" -#: documents/models.py:117 documents/models.py:201 +#: documents/models.py:119 documents/models.py:212 msgid "tags" msgstr "" -#: documents/models.py:123 +#: documents/models.py:125 msgid "Cannot set itself as parent." msgstr "" -#: documents/models.py:125 +#: documents/models.py:127 msgid "Cannot set parent to a descendant." msgstr "" -#: documents/models.py:142 documents/models.py:183 +#: documents/models.py:144 documents/models.py:185 msgid "document type" msgstr "" -#: documents/models.py:143 +#: documents/models.py:145 msgid "document types" msgstr "" -#: documents/models.py:148 +#: documents/models.py:150 msgid "path" msgstr "" -#: documents/models.py:152 documents/models.py:172 +#: documents/models.py:154 documents/models.py:174 msgid "storage path" msgstr "" -#: documents/models.py:153 +#: documents/models.py:155 msgid "storage paths" msgstr "" -#: documents/models.py:175 +#: documents/models.py:177 msgid "title" msgstr "" -#: documents/models.py:187 documents/models.py:661 +#: documents/models.py:189 documents/models.py:671 msgid "content" msgstr "" -#: documents/models.py:190 +#: documents/models.py:192 msgid "" "The raw, text-only data of the document. This field is primarily used for " "searching." msgstr "" -#: documents/models.py:195 +#: documents/models.py:206 msgid "mime type" msgstr "" -#: documents/models.py:205 +#: documents/models.py:216 msgid "checksum" msgstr "" -#: documents/models.py:209 +#: documents/models.py:219 msgid "The checksum of the original document." msgstr "" -#: documents/models.py:213 +#: documents/models.py:223 msgid "archive checksum" msgstr "" -#: documents/models.py:218 +#: documents/models.py:228 msgid "The checksum of the archived document." msgstr "" -#: documents/models.py:222 +#: documents/models.py:232 msgid "page count" msgstr "" -#: documents/models.py:229 +#: documents/models.py:239 msgid "The number of pages of the document." msgstr "" -#: documents/models.py:234 documents/models.py:667 documents/models.py:705 -#: documents/models.py:777 documents/models.py:836 +#: documents/models.py:244 documents/models.py:677 documents/models.py:715 +#: documents/models.py:787 documents/models.py:846 msgid "created" msgstr "" -#: documents/models.py:240 +#: documents/models.py:250 msgid "modified" msgstr "" -#: documents/models.py:247 +#: documents/models.py:257 msgid "added" msgstr "" -#: documents/models.py:254 +#: documents/models.py:264 msgid "filename" msgstr "" -#: documents/models.py:260 +#: documents/models.py:270 msgid "Current filename in storage" msgstr "" -#: documents/models.py:264 +#: documents/models.py:274 msgid "archive filename" msgstr "" -#: documents/models.py:270 +#: documents/models.py:280 msgid "Current archive filename in storage" msgstr "" -#: documents/models.py:274 +#: documents/models.py:284 msgid "original filename" msgstr "" -#: documents/models.py:280 +#: documents/models.py:290 msgid "The original name of the file when it was uploaded" msgstr "" -#: documents/models.py:287 +#: documents/models.py:297 msgid "archive serial number" msgstr "" -#: documents/models.py:297 +#: documents/models.py:307 msgid "The position of this document in your physical document archive." msgstr "" -#: documents/models.py:303 documents/models.py:678 documents/models.py:732 -#: documents/models.py:1571 +#: documents/models.py:313 documents/models.py:688 documents/models.py:742 +#: documents/models.py:1581 msgid "document" msgstr "" -#: documents/models.py:304 +#: documents/models.py:314 msgid "documents" msgstr "" -#: documents/models.py:415 +#: documents/models.py:425 msgid "Table" msgstr "" -#: documents/models.py:416 +#: documents/models.py:426 msgid "Small Cards" msgstr "" -#: documents/models.py:417 +#: documents/models.py:427 msgid "Large Cards" msgstr "" -#: documents/models.py:420 +#: documents/models.py:430 msgid "Title" msgstr "" -#: documents/models.py:421 documents/models.py:983 +#: documents/models.py:431 documents/models.py:993 msgid "Created" msgstr "" -#: documents/models.py:422 documents/models.py:982 +#: documents/models.py:432 documents/models.py:992 msgid "Added" msgstr "" -#: documents/models.py:423 +#: documents/models.py:433 msgid "Tags" msgstr "" -#: documents/models.py:424 +#: documents/models.py:434 msgid "Correspondent" msgstr "" -#: documents/models.py:425 +#: documents/models.py:435 msgid "Document Type" msgstr "" -#: documents/models.py:426 +#: documents/models.py:436 msgid "Storage Path" msgstr "" -#: documents/models.py:427 +#: documents/models.py:437 msgid "Note" msgstr "" -#: documents/models.py:428 +#: documents/models.py:438 msgid "Owner" msgstr "" -#: documents/models.py:429 +#: documents/models.py:439 msgid "Shared" msgstr "" -#: documents/models.py:430 +#: documents/models.py:440 msgid "ASN" msgstr "" -#: documents/models.py:431 +#: documents/models.py:441 msgid "Pages" msgstr "" -#: documents/models.py:437 +#: documents/models.py:447 msgid "show on dashboard" msgstr "" -#: documents/models.py:440 +#: documents/models.py:450 msgid "show in sidebar" msgstr "" -#: documents/models.py:444 +#: documents/models.py:454 msgid "sort field" msgstr "" -#: documents/models.py:449 +#: documents/models.py:459 msgid "sort reverse" msgstr "" -#: documents/models.py:452 +#: documents/models.py:462 msgid "View page size" msgstr "" -#: documents/models.py:460 +#: documents/models.py:470 msgid "View display mode" msgstr "" -#: documents/models.py:467 +#: documents/models.py:477 msgid "Document display fields" msgstr "" -#: documents/models.py:474 documents/models.py:537 +#: documents/models.py:484 documents/models.py:547 msgid "saved view" msgstr "" -#: documents/models.py:475 +#: documents/models.py:485 msgid "saved views" msgstr "" -#: documents/models.py:483 +#: documents/models.py:493 msgid "title contains" msgstr "" -#: documents/models.py:484 +#: documents/models.py:494 msgid "content contains" msgstr "" -#: documents/models.py:485 +#: documents/models.py:495 msgid "ASN is" msgstr "" -#: documents/models.py:486 +#: documents/models.py:496 msgid "correspondent is" msgstr "" -#: documents/models.py:487 +#: documents/models.py:497 msgid "document type is" msgstr "" -#: documents/models.py:488 +#: documents/models.py:498 msgid "is in inbox" msgstr "" -#: documents/models.py:489 +#: documents/models.py:499 msgid "has tag" msgstr "" -#: documents/models.py:490 +#: documents/models.py:500 msgid "has any tag" msgstr "" -#: documents/models.py:491 +#: documents/models.py:501 msgid "created before" msgstr "" -#: documents/models.py:492 +#: documents/models.py:502 msgid "created after" msgstr "" -#: documents/models.py:493 +#: documents/models.py:503 msgid "created year is" msgstr "" -#: documents/models.py:494 +#: documents/models.py:504 msgid "created month is" msgstr "" -#: documents/models.py:495 +#: documents/models.py:505 msgid "created day is" msgstr "" -#: documents/models.py:496 +#: documents/models.py:506 msgid "added before" msgstr "" -#: documents/models.py:497 +#: documents/models.py:507 msgid "added after" msgstr "" -#: documents/models.py:498 +#: documents/models.py:508 msgid "modified before" msgstr "" -#: documents/models.py:499 +#: documents/models.py:509 msgid "modified after" msgstr "" -#: documents/models.py:500 +#: documents/models.py:510 msgid "does not have tag" msgstr "" -#: documents/models.py:501 +#: documents/models.py:511 msgid "does not have ASN" msgstr "" -#: documents/models.py:502 +#: documents/models.py:512 msgid "title or content contains" msgstr "" -#: documents/models.py:503 +#: documents/models.py:513 msgid "fulltext query" msgstr "" -#: documents/models.py:504 +#: documents/models.py:514 msgid "more like this" msgstr "" -#: documents/models.py:505 +#: documents/models.py:515 msgid "has tags in" msgstr "" -#: documents/models.py:506 +#: documents/models.py:516 msgid "ASN greater than" msgstr "" -#: documents/models.py:507 +#: documents/models.py:517 msgid "ASN less than" msgstr "" -#: documents/models.py:508 +#: documents/models.py:518 msgid "storage path is" msgstr "" -#: documents/models.py:509 +#: documents/models.py:519 msgid "has correspondent in" msgstr "" -#: documents/models.py:510 +#: documents/models.py:520 msgid "does not have correspondent in" msgstr "" -#: documents/models.py:511 +#: documents/models.py:521 msgid "has document type in" msgstr "" -#: documents/models.py:512 +#: documents/models.py:522 msgid "does not have document type in" msgstr "" -#: documents/models.py:513 +#: documents/models.py:523 msgid "has storage path in" msgstr "" -#: documents/models.py:514 +#: documents/models.py:524 msgid "does not have storage path in" msgstr "" -#: documents/models.py:515 +#: documents/models.py:525 msgid "owner is" msgstr "" -#: documents/models.py:516 +#: documents/models.py:526 msgid "has owner in" msgstr "" -#: documents/models.py:517 +#: documents/models.py:527 msgid "does not have owner" msgstr "" -#: documents/models.py:518 +#: documents/models.py:528 msgid "does not have owner in" msgstr "" -#: documents/models.py:519 +#: documents/models.py:529 msgid "has custom field value" msgstr "" -#: documents/models.py:520 +#: documents/models.py:530 msgid "is shared by me" msgstr "" -#: documents/models.py:521 +#: documents/models.py:531 msgid "has custom fields" msgstr "" -#: documents/models.py:522 +#: documents/models.py:532 msgid "has custom field in" msgstr "" -#: documents/models.py:523 +#: documents/models.py:533 msgid "does not have custom field in" msgstr "" -#: documents/models.py:524 +#: documents/models.py:534 msgid "does not have custom field" msgstr "" -#: documents/models.py:525 +#: documents/models.py:535 msgid "custom fields query" msgstr "" -#: documents/models.py:526 +#: documents/models.py:536 msgid "created to" msgstr "" -#: documents/models.py:527 +#: documents/models.py:537 msgid "created from" msgstr "" -#: documents/models.py:528 +#: documents/models.py:538 msgid "added to" msgstr "" -#: documents/models.py:529 +#: documents/models.py:539 msgid "added from" msgstr "" -#: documents/models.py:530 +#: documents/models.py:540 msgid "mime type is" msgstr "" -#: documents/models.py:540 +#: documents/models.py:550 msgid "rule type" msgstr "" -#: documents/models.py:542 +#: documents/models.py:552 msgid "value" msgstr "" -#: documents/models.py:545 +#: documents/models.py:555 msgid "filter rule" msgstr "" -#: documents/models.py:546 +#: documents/models.py:556 msgid "filter rules" msgstr "" -#: documents/models.py:570 +#: documents/models.py:580 msgid "Auto Task" msgstr "" -#: documents/models.py:571 +#: documents/models.py:581 msgid "Scheduled Task" msgstr "" -#: documents/models.py:572 +#: documents/models.py:582 msgid "Manual Task" msgstr "" -#: documents/models.py:575 +#: documents/models.py:585 msgid "Consume File" msgstr "" -#: documents/models.py:576 +#: documents/models.py:586 msgid "Train Classifier" msgstr "" -#: documents/models.py:577 +#: documents/models.py:587 msgid "Check Sanity" msgstr "" -#: documents/models.py:578 +#: documents/models.py:588 msgid "Index Optimize" msgstr "" -#: documents/models.py:579 +#: documents/models.py:589 msgid "LLM Index Update" msgstr "" -#: documents/models.py:584 +#: documents/models.py:594 msgid "Task ID" msgstr "" -#: documents/models.py:585 +#: documents/models.py:595 msgid "Celery ID for the Task that was run" msgstr "" -#: documents/models.py:590 +#: documents/models.py:600 msgid "Acknowledged" msgstr "" -#: documents/models.py:591 +#: documents/models.py:601 msgid "If the task is acknowledged via the frontend or API" msgstr "" -#: documents/models.py:597 +#: documents/models.py:607 msgid "Task Filename" msgstr "" -#: documents/models.py:598 +#: documents/models.py:608 msgid "Name of the file which the Task was run for" msgstr "" -#: documents/models.py:605 +#: documents/models.py:615 msgid "Task Name" msgstr "" -#: documents/models.py:606 +#: documents/models.py:616 msgid "Name of the task that was run" msgstr "" -#: documents/models.py:613 +#: documents/models.py:623 msgid "Task State" msgstr "" -#: documents/models.py:614 +#: documents/models.py:624 msgid "Current state of the task being run" msgstr "" -#: documents/models.py:620 +#: documents/models.py:630 msgid "Created DateTime" msgstr "" -#: documents/models.py:621 +#: documents/models.py:631 msgid "Datetime field when the task result was created in UTC" msgstr "" -#: documents/models.py:627 +#: documents/models.py:637 msgid "Started DateTime" msgstr "" -#: documents/models.py:628 +#: documents/models.py:638 msgid "Datetime field when the task was started in UTC" msgstr "" -#: documents/models.py:634 +#: documents/models.py:644 msgid "Completed DateTime" msgstr "" -#: documents/models.py:635 +#: documents/models.py:645 msgid "Datetime field when the task was completed in UTC" msgstr "" -#: documents/models.py:641 +#: documents/models.py:651 msgid "Result Data" msgstr "" -#: documents/models.py:643 +#: documents/models.py:653 msgid "The data returned by the task" msgstr "" -#: documents/models.py:651 +#: documents/models.py:661 msgid "Task Type" msgstr "" -#: documents/models.py:652 +#: documents/models.py:662 msgid "The type of task that was run" msgstr "" -#: documents/models.py:663 +#: documents/models.py:673 msgid "Note for the document" msgstr "" -#: documents/models.py:687 +#: documents/models.py:697 msgid "user" msgstr "" -#: documents/models.py:692 +#: documents/models.py:702 msgid "note" msgstr "" -#: documents/models.py:693 +#: documents/models.py:703 msgid "notes" msgstr "" -#: documents/models.py:701 +#: documents/models.py:711 msgid "Archive" msgstr "" -#: documents/models.py:702 +#: documents/models.py:712 msgid "Original" msgstr "" -#: documents/models.py:713 paperless_mail/models.py:75 +#: documents/models.py:723 paperless_mail/models.py:75 msgid "expiration" msgstr "" -#: documents/models.py:720 +#: documents/models.py:730 msgid "slug" msgstr "" -#: documents/models.py:752 +#: documents/models.py:762 msgid "share link" msgstr "" -#: documents/models.py:753 +#: documents/models.py:763 msgid "share links" msgstr "" -#: documents/models.py:765 +#: documents/models.py:775 msgid "String" msgstr "" -#: documents/models.py:766 +#: documents/models.py:776 msgid "URL" msgstr "" -#: documents/models.py:767 +#: documents/models.py:777 msgid "Date" msgstr "" -#: documents/models.py:768 +#: documents/models.py:778 msgid "Boolean" msgstr "" -#: documents/models.py:769 +#: documents/models.py:779 msgid "Integer" msgstr "" -#: documents/models.py:770 +#: documents/models.py:780 msgid "Float" msgstr "" -#: documents/models.py:771 +#: documents/models.py:781 msgid "Monetary" msgstr "" -#: documents/models.py:772 +#: documents/models.py:782 msgid "Document Link" msgstr "" -#: documents/models.py:773 +#: documents/models.py:783 msgid "Select" msgstr "" -#: documents/models.py:774 +#: documents/models.py:784 msgid "Long Text" msgstr "" -#: documents/models.py:786 +#: documents/models.py:796 msgid "data type" msgstr "" -#: documents/models.py:793 +#: documents/models.py:803 msgid "extra data" msgstr "" -#: documents/models.py:797 +#: documents/models.py:807 msgid "Extra data for the custom field, such as select options" msgstr "" -#: documents/models.py:803 +#: documents/models.py:813 msgid "custom field" msgstr "" -#: documents/models.py:804 +#: documents/models.py:814 msgid "custom fields" msgstr "" -#: documents/models.py:904 +#: documents/models.py:914 msgid "custom field instance" msgstr "" -#: documents/models.py:905 +#: documents/models.py:915 msgid "custom field instances" msgstr "" -#: documents/models.py:970 +#: documents/models.py:980 msgid "Consumption Started" msgstr "" -#: documents/models.py:971 +#: documents/models.py:981 msgid "Document Added" msgstr "" -#: documents/models.py:972 +#: documents/models.py:982 msgid "Document Updated" msgstr "" -#: documents/models.py:973 +#: documents/models.py:983 msgid "Scheduled" msgstr "" -#: documents/models.py:976 +#: documents/models.py:986 msgid "Consume Folder" msgstr "" -#: documents/models.py:977 +#: documents/models.py:987 msgid "Api Upload" msgstr "" -#: documents/models.py:978 +#: documents/models.py:988 msgid "Mail Fetch" msgstr "" -#: documents/models.py:979 +#: documents/models.py:989 msgid "Web UI" msgstr "" -#: documents/models.py:984 +#: documents/models.py:994 msgid "Modified" msgstr "" -#: documents/models.py:985 +#: documents/models.py:995 msgid "Custom Field" msgstr "" -#: documents/models.py:988 +#: documents/models.py:998 msgid "Workflow Trigger Type" msgstr "" -#: documents/models.py:1000 +#: documents/models.py:1010 msgid "filter path" msgstr "" -#: documents/models.py:1005 +#: documents/models.py:1015 msgid "" "Only consume documents with a path that matches this if specified. Wildcards " "specified as * are allowed. Case insensitive." msgstr "" -#: documents/models.py:1012 +#: documents/models.py:1022 msgid "filter filename" msgstr "" -#: documents/models.py:1017 paperless_mail/models.py:200 +#: documents/models.py:1027 paperless_mail/models.py:200 msgid "" "Only consume documents which entirely match this filename if specified. " "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." msgstr "" -#: documents/models.py:1028 +#: documents/models.py:1038 msgid "filter documents from this mail rule" msgstr "" -#: documents/models.py:1044 +#: documents/models.py:1054 msgid "has these tag(s)" msgstr "" -#: documents/models.py:1051 +#: documents/models.py:1061 msgid "has all of these tag(s)" msgstr "" -#: documents/models.py:1058 +#: documents/models.py:1068 msgid "does not have these tag(s)" msgstr "" -#: documents/models.py:1066 +#: documents/models.py:1076 msgid "has this document type" msgstr "" -#: documents/models.py:1073 +#: documents/models.py:1083 msgid "has one of these document types" msgstr "" -#: documents/models.py:1080 +#: documents/models.py:1090 msgid "does not have these document type(s)" msgstr "" -#: documents/models.py:1088 +#: documents/models.py:1098 msgid "has this correspondent" msgstr "" -#: documents/models.py:1095 +#: documents/models.py:1105 msgid "does not have these correspondent(s)" msgstr "" -#: documents/models.py:1102 +#: documents/models.py:1112 msgid "has one of these correspondents" msgstr "" -#: documents/models.py:1110 +#: documents/models.py:1120 msgid "has this storage path" msgstr "" -#: documents/models.py:1117 +#: documents/models.py:1127 msgid "has one of these storage paths" msgstr "" -#: documents/models.py:1124 +#: documents/models.py:1134 msgid "does not have these storage path(s)" msgstr "" -#: documents/models.py:1128 +#: documents/models.py:1138 msgid "filter custom field query" msgstr "" -#: documents/models.py:1131 +#: documents/models.py:1141 msgid "JSON-encoded custom field query expression." msgstr "" -#: documents/models.py:1135 +#: documents/models.py:1145 msgid "schedule offset days" msgstr "" -#: documents/models.py:1138 +#: documents/models.py:1148 msgid "The number of days to offset the schedule trigger by." msgstr "" -#: documents/models.py:1143 +#: documents/models.py:1153 msgid "schedule is recurring" msgstr "" -#: documents/models.py:1146 +#: documents/models.py:1156 msgid "If the schedule should be recurring." msgstr "" -#: documents/models.py:1151 +#: documents/models.py:1161 msgid "schedule recurring delay in days" msgstr "" -#: documents/models.py:1155 +#: documents/models.py:1165 msgid "The number of days between recurring schedule triggers." msgstr "" -#: documents/models.py:1160 +#: documents/models.py:1170 msgid "schedule date field" msgstr "" -#: documents/models.py:1165 +#: documents/models.py:1175 msgid "The field to check for a schedule trigger." msgstr "" -#: documents/models.py:1174 +#: documents/models.py:1184 msgid "schedule date custom field" msgstr "" -#: documents/models.py:1178 +#: documents/models.py:1188 msgid "workflow trigger" msgstr "" -#: documents/models.py:1179 +#: documents/models.py:1189 msgid "workflow triggers" msgstr "" -#: documents/models.py:1187 +#: documents/models.py:1197 msgid "email subject" msgstr "" -#: documents/models.py:1191 +#: documents/models.py:1201 msgid "" "The subject of the email, can include some placeholders, see documentation." msgstr "" -#: documents/models.py:1197 +#: documents/models.py:1207 msgid "email body" msgstr "" -#: documents/models.py:1200 +#: documents/models.py:1210 msgid "" "The body (message) of the email, can include some placeholders, see " "documentation." msgstr "" -#: documents/models.py:1206 +#: documents/models.py:1216 msgid "emails to" msgstr "" -#: documents/models.py:1209 +#: documents/models.py:1219 msgid "The destination email addresses, comma separated." msgstr "" -#: documents/models.py:1215 +#: documents/models.py:1225 msgid "include document in email" msgstr "" -#: documents/models.py:1226 +#: documents/models.py:1236 msgid "webhook url" msgstr "" -#: documents/models.py:1229 +#: documents/models.py:1239 msgid "The destination URL for the notification." msgstr "" -#: documents/models.py:1234 +#: documents/models.py:1244 msgid "use parameters" msgstr "" -#: documents/models.py:1239 +#: documents/models.py:1249 msgid "send as JSON" msgstr "" -#: documents/models.py:1243 +#: documents/models.py:1253 msgid "webhook parameters" msgstr "" -#: documents/models.py:1246 +#: documents/models.py:1256 msgid "The parameters to send with the webhook URL if body not used." msgstr "" -#: documents/models.py:1250 +#: documents/models.py:1260 msgid "webhook body" msgstr "" -#: documents/models.py:1253 +#: documents/models.py:1263 msgid "The body to send with the webhook URL if parameters not used." msgstr "" -#: documents/models.py:1257 +#: documents/models.py:1267 msgid "webhook headers" msgstr "" -#: documents/models.py:1260 +#: documents/models.py:1270 msgid "The headers to send with the webhook URL." msgstr "" -#: documents/models.py:1265 +#: documents/models.py:1275 msgid "include document in webhook" msgstr "" -#: documents/models.py:1276 +#: documents/models.py:1286 msgid "Assignment" msgstr "" -#: documents/models.py:1280 +#: documents/models.py:1290 msgid "Removal" msgstr "" -#: documents/models.py:1284 documents/templates/account/password_reset.html:15 +#: documents/models.py:1294 documents/templates/account/password_reset.html:15 msgid "Email" msgstr "" -#: documents/models.py:1288 +#: documents/models.py:1298 msgid "Webhook" msgstr "" -#: documents/models.py:1292 +#: documents/models.py:1302 msgid "Workflow Action Type" msgstr "" -#: documents/models.py:1297 documents/models.py:1530 +#: documents/models.py:1307 documents/models.py:1540 #: paperless_mail/models.py:145 msgid "order" msgstr "" -#: documents/models.py:1300 +#: documents/models.py:1310 msgid "assign title" msgstr "" -#: documents/models.py:1304 +#: documents/models.py:1314 msgid "Assign a document title, must be a Jinja2 template, see documentation." msgstr "" -#: documents/models.py:1312 paperless_mail/models.py:274 +#: documents/models.py:1322 paperless_mail/models.py:274 msgid "assign this tag" msgstr "" -#: documents/models.py:1321 paperless_mail/models.py:282 +#: documents/models.py:1331 paperless_mail/models.py:282 msgid "assign this document type" msgstr "" -#: documents/models.py:1330 paperless_mail/models.py:296 +#: documents/models.py:1340 paperless_mail/models.py:296 msgid "assign this correspondent" msgstr "" -#: documents/models.py:1339 +#: documents/models.py:1349 msgid "assign this storage path" msgstr "" -#: documents/models.py:1348 +#: documents/models.py:1358 msgid "assign this owner" msgstr "" -#: documents/models.py:1355 +#: documents/models.py:1365 msgid "grant view permissions to these users" msgstr "" -#: documents/models.py:1362 +#: documents/models.py:1372 msgid "grant view permissions to these groups" msgstr "" -#: documents/models.py:1369 +#: documents/models.py:1379 msgid "grant change permissions to these users" msgstr "" -#: documents/models.py:1376 +#: documents/models.py:1386 msgid "grant change permissions to these groups" msgstr "" -#: documents/models.py:1383 +#: documents/models.py:1393 msgid "assign these custom fields" msgstr "" -#: documents/models.py:1387 +#: documents/models.py:1397 msgid "custom field values" msgstr "" -#: documents/models.py:1391 +#: documents/models.py:1401 msgid "Optional values to assign to the custom fields." msgstr "" -#: documents/models.py:1400 +#: documents/models.py:1410 msgid "remove these tag(s)" msgstr "" -#: documents/models.py:1405 +#: documents/models.py:1415 msgid "remove all tags" msgstr "" -#: documents/models.py:1412 +#: documents/models.py:1422 msgid "remove these document type(s)" msgstr "" -#: documents/models.py:1417 +#: documents/models.py:1427 msgid "remove all document types" msgstr "" -#: documents/models.py:1424 +#: documents/models.py:1434 msgid "remove these correspondent(s)" msgstr "" -#: documents/models.py:1429 +#: documents/models.py:1439 msgid "remove all correspondents" msgstr "" -#: documents/models.py:1436 +#: documents/models.py:1446 msgid "remove these storage path(s)" msgstr "" -#: documents/models.py:1441 +#: documents/models.py:1451 msgid "remove all storage paths" msgstr "" -#: documents/models.py:1448 +#: documents/models.py:1458 msgid "remove these owner(s)" msgstr "" -#: documents/models.py:1453 +#: documents/models.py:1463 msgid "remove all owners" msgstr "" -#: documents/models.py:1460 +#: documents/models.py:1470 msgid "remove view permissions for these users" msgstr "" -#: documents/models.py:1467 +#: documents/models.py:1477 msgid "remove view permissions for these groups" msgstr "" -#: documents/models.py:1474 +#: documents/models.py:1484 msgid "remove change permissions for these users" msgstr "" -#: documents/models.py:1481 +#: documents/models.py:1491 msgid "remove change permissions for these groups" msgstr "" -#: documents/models.py:1486 +#: documents/models.py:1496 msgid "remove all permissions" msgstr "" -#: documents/models.py:1493 +#: documents/models.py:1503 msgid "remove these custom fields" msgstr "" -#: documents/models.py:1498 +#: documents/models.py:1508 msgid "remove all custom fields" msgstr "" -#: documents/models.py:1507 +#: documents/models.py:1517 msgid "email" msgstr "" -#: documents/models.py:1516 +#: documents/models.py:1526 msgid "webhook" msgstr "" -#: documents/models.py:1520 +#: documents/models.py:1530 msgid "workflow action" msgstr "" -#: documents/models.py:1521 +#: documents/models.py:1531 msgid "workflow actions" msgstr "" -#: documents/models.py:1536 +#: documents/models.py:1546 msgid "triggers" msgstr "" -#: documents/models.py:1543 +#: documents/models.py:1553 msgid "actions" msgstr "" -#: documents/models.py:1546 paperless_mail/models.py:154 +#: documents/models.py:1556 paperless_mail/models.py:154 msgid "enabled" msgstr "" -#: documents/models.py:1557 +#: documents/models.py:1567 msgid "workflow" msgstr "" -#: documents/models.py:1561 +#: documents/models.py:1571 msgid "workflow trigger type" msgstr "" -#: documents/models.py:1575 +#: documents/models.py:1585 msgid "date run" msgstr "" -#: documents/models.py:1581 +#: documents/models.py:1591 msgid "workflow run" msgstr "" -#: documents/models.py:1582 +#: documents/models.py:1592 msgid "workflow runs" msgstr "" -#: documents/serialisers.py:646 +#: documents/serialisers.py:651 msgid "Invalid color." msgstr "" -#: documents/serialisers.py:1850 +#: documents/serialisers.py:1893 #, python-format msgid "File type %(type)s not supported" msgstr "" -#: documents/serialisers.py:1894 +#: documents/serialisers.py:1937 #, python-format msgid "Custom field id must be an integer: %(id)s" msgstr "" -#: documents/serialisers.py:1901 +#: documents/serialisers.py:1944 #, python-format msgid "Custom field with id %(id)s does not exist" msgstr "" -#: documents/serialisers.py:1918 documents/serialisers.py:1928 +#: documents/serialisers.py:1961 documents/serialisers.py:1971 msgid "" "Custom fields must be a list of integers or an object mapping ids to values." msgstr "" -#: documents/serialisers.py:1923 +#: documents/serialisers.py:1966 msgid "Some custom fields don't exist or were specified twice." msgstr "" -#: documents/serialisers.py:2038 +#: documents/serialisers.py:2081 msgid "Invalid variable detected." msgstr "" @@ -1747,155 +1747,155 @@ msgstr "" msgid "paperless application settings" msgstr "" -#: paperless/settings.py:800 +#: paperless/settings.py:807 msgid "English (US)" msgstr "" -#: paperless/settings.py:801 +#: paperless/settings.py:808 msgid "Arabic" msgstr "" -#: paperless/settings.py:802 +#: paperless/settings.py:809 msgid "Afrikaans" msgstr "" -#: paperless/settings.py:803 +#: paperless/settings.py:810 msgid "Belarusian" msgstr "" -#: paperless/settings.py:804 +#: paperless/settings.py:811 msgid "Bulgarian" msgstr "" -#: paperless/settings.py:805 +#: paperless/settings.py:812 msgid "Catalan" msgstr "" -#: paperless/settings.py:806 +#: paperless/settings.py:813 msgid "Czech" msgstr "" -#: paperless/settings.py:807 +#: paperless/settings.py:814 msgid "Danish" msgstr "" -#: paperless/settings.py:808 +#: paperless/settings.py:815 msgid "German" msgstr "" -#: paperless/settings.py:809 +#: paperless/settings.py:816 msgid "Greek" msgstr "" -#: paperless/settings.py:810 +#: paperless/settings.py:817 msgid "English (GB)" msgstr "" -#: paperless/settings.py:811 +#: paperless/settings.py:818 msgid "Spanish" msgstr "" -#: paperless/settings.py:812 +#: paperless/settings.py:819 msgid "Persian" msgstr "" -#: paperless/settings.py:813 +#: paperless/settings.py:820 msgid "Finnish" msgstr "" -#: paperless/settings.py:814 +#: paperless/settings.py:821 msgid "French" msgstr "" -#: paperless/settings.py:815 +#: paperless/settings.py:822 msgid "Hungarian" msgstr "" -#: paperless/settings.py:816 +#: paperless/settings.py:823 msgid "Indonesian" msgstr "" -#: paperless/settings.py:817 +#: paperless/settings.py:824 msgid "Italian" msgstr "" -#: paperless/settings.py:818 +#: paperless/settings.py:825 msgid "Japanese" msgstr "" -#: paperless/settings.py:819 +#: paperless/settings.py:826 msgid "Korean" msgstr "" -#: paperless/settings.py:820 +#: paperless/settings.py:827 msgid "Luxembourgish" msgstr "" -#: paperless/settings.py:821 +#: paperless/settings.py:828 msgid "Norwegian" msgstr "" -#: paperless/settings.py:822 +#: paperless/settings.py:829 msgid "Dutch" msgstr "" -#: paperless/settings.py:823 +#: paperless/settings.py:830 msgid "Polish" msgstr "" -#: paperless/settings.py:824 +#: paperless/settings.py:831 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings.py:825 +#: paperless/settings.py:832 msgid "Portuguese" msgstr "" -#: paperless/settings.py:826 +#: paperless/settings.py:833 msgid "Romanian" msgstr "" -#: paperless/settings.py:827 +#: paperless/settings.py:834 msgid "Russian" msgstr "" -#: paperless/settings.py:828 +#: paperless/settings.py:835 msgid "Slovak" msgstr "" -#: paperless/settings.py:829 +#: paperless/settings.py:836 msgid "Slovenian" msgstr "" -#: paperless/settings.py:830 +#: paperless/settings.py:837 msgid "Serbian" msgstr "" -#: paperless/settings.py:831 +#: paperless/settings.py:838 msgid "Swedish" msgstr "" -#: paperless/settings.py:832 +#: paperless/settings.py:839 msgid "Turkish" msgstr "" -#: paperless/settings.py:833 +#: paperless/settings.py:840 msgid "Ukrainian" msgstr "" -#: paperless/settings.py:834 +#: paperless/settings.py:841 msgid "Vietnamese" msgstr "" -#: paperless/settings.py:835 +#: paperless/settings.py:842 msgid "Chinese Simplified" msgstr "" -#: paperless/settings.py:836 +#: paperless/settings.py:843 msgid "Chinese Traditional" msgstr "" -#: paperless/urls.py:376 +#: paperless/urls.py:377 msgid "Paperless-ngx administration" msgstr "" diff --git a/src/paperless/adapter.py b/src/paperless/adapter.py index a4506275e..f1f478141 100644 --- a/src/paperless/adapter.py +++ b/src/paperless/adapter.py @@ -3,12 +3,15 @@ from urllib.parse import quote from allauth.account.adapter import DefaultAccountAdapter from allauth.core import context +from allauth.headless.tokens.sessions import SessionTokenStrategy from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from django.conf import settings from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.forms import ValidationError +from django.http import HttpRequest from django.urls import reverse +from rest_framework.authtoken.models import Token from documents.models import Document from paperless.signals import handle_social_account_updated @@ -159,3 +162,11 @@ class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): exception, extra_context, ) + + +class DrfTokenStrategy(SessionTokenStrategy): + def create_access_token(self, request: HttpRequest) -> str | None: + if not request.user.is_authenticated: + return None + token, _ = Token.objects.get_or_create(user=request.user) + return token.key diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 30ee213d1..9ad0fea4d 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -345,6 +345,7 @@ INSTALLED_APPS = [ "allauth.account", "allauth.socialaccount", "allauth.mfa", + "allauth.headless", "drf_spectacular", "drf_spectacular_sidecar", "treenode", @@ -539,6 +540,12 @@ SOCIALACCOUNT_PROVIDERS = json.loads( ) SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS") SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS") +SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM: Final[str] = os.getenv( + "PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM", + "groups", +) + +HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy" MFA_TOTP_ISSUER = "Paperless-ngx" diff --git a/src/paperless/signals.py b/src/paperless/signals.py index cfad29dbd..1ed88c051 100644 --- a/src/paperless/signals.py +++ b/src/paperless/signals.py @@ -40,15 +40,19 @@ def handle_social_account_updated(sender, request, sociallogin, **kwargs): extra_data = sociallogin.account.extra_data or {} social_account_groups = extra_data.get( - "groups", + settings.SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM, [], ) # pre-allauth 65.11.0 structure if not social_account_groups: # allauth 65.11.0+ nests claims under `userinfo`/`id_token` social_account_groups = ( - extra_data.get("userinfo", {}).get("groups") - or extra_data.get("id_token", {}).get("groups") + extra_data.get("userinfo", {}).get( + settings.SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM, + ) + or extra_data.get("id_token", {}).get( + settings.SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM, + ) or [] ) if settings.SOCIAL_ACCOUNT_SYNC_GROUPS and social_account_groups is not None: diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py index 37b8aaa3b..dbef3fde7 100644 --- a/src/paperless/tests/test_adapter.py +++ b/src/paperless/tests/test_adapter.py @@ -4,6 +4,7 @@ from allauth.account.adapter import get_adapter from allauth.core import context from allauth.socialaccount.adapter import get_adapter as get_social_adapter from django.conf import settings +from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.forms import ValidationError @@ -11,6 +12,9 @@ from django.http import HttpRequest from django.test import TestCase from django.test import override_settings from django.urls import reverse +from rest_framework.authtoken.models import Token + +from paperless.adapter import DrfTokenStrategy class TestCustomAccountAdapter(TestCase): @@ -181,3 +185,74 @@ class TestCustomSocialAccountAdapter(TestCase): self.assertTrue( any("Test authentication error" in message for message in log_cm.output), ) + + +class TestDrfTokenStrategy(TestCase): + def test_create_access_token_creates_new_token(self): + """ + GIVEN: + - A user with no existing DRF token + WHEN: + - create_access_token is called + THEN: + - A new token is created and its key is returned + """ + + user = User.objects.create_user("testuser") + request = HttpRequest() + request.user = user + + strategy = DrfTokenStrategy() + token_key = strategy.create_access_token(request) + + # Verify a token was created + self.assertIsNotNone(token_key) + self.assertTrue(Token.objects.filter(user=user).exists()) + + # Verify the returned key matches the created token + token = Token.objects.get(user=user) + self.assertEqual(token_key, token.key) + + def test_create_access_token_returns_existing_token(self): + """ + GIVEN: + - A user with an existing DRF token + WHEN: + - create_access_token is called again + THEN: + - The same token key is returned (no new token created) + """ + + user = User.objects.create_user("testuser") + existing_token = Token.objects.create(user=user) + + request = HttpRequest() + request.user = user + + strategy = DrfTokenStrategy() + token_key = strategy.create_access_token(request) + + # Verify the existing token key is returned + self.assertEqual(token_key, existing_token.key) + + # Verify only one token exists (no duplicate created) + self.assertEqual(Token.objects.filter(user=user).count(), 1) + + def test_create_access_token_returns_none_for_unauthenticated_user(self): + """ + GIVEN: + - An unauthenticated request + WHEN: + - create_access_token is called + THEN: + - None is returned and no token is created + """ + + request = HttpRequest() + request.user = AnonymousUser() + + strategy = DrfTokenStrategy() + token_key = strategy.create_access_token(request) + + self.assertIsNone(token_key) + self.assertEqual(Token.objects.count(), 0) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 179af14e0..ce5c68494 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -228,6 +228,7 @@ urlpatterns = [ ], ), ), + re_path("^auth/headless/", include("allauth.headless.urls")), re_path( "^$", # Redirect to the API swagger view RedirectView.as_view(url="schema/view/"),