Compare commits

...

20 Commits

Author SHA1 Message Date
Trenton H
2f910441f7 Updates some of the workflows and jobs to use the -slim image for faster startup on easy & quick tasks 2026-01-26 11:00:12 -08:00
GitHub Actions
df1aa13551 Auto translate strings 2026-01-26 18:32:50 +00:00
Gabgobie
e9e138e62c Enhancement: configurable SSO groups claim (#11841)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-01-26 18:31:01 +00:00
GitHub Actions
cafb0f2022 Auto translate strings 2026-01-26 17:51:20 +00:00
shamoon
1d2e3393ac Enhancement: support select all for management lists (#11889) 2026-01-26 09:49:16 -08:00
shamoon
857aaca493 Merge branch 'release/v2.20.x' into dev 2026-01-26 09:25:58 -08:00
shamoon
891f4a2faf Fix: correctly extract all ids for nested tags (#11888) 2026-01-26 09:12:03 -08:00
GitHub Actions
ae816a01b2 Auto translate strings 2026-01-26 16:32:52 +00:00
shamoon
b6531aed2f Tweakhancement: display document id, with copy (#11896) 2026-01-26 08:30:43 -08:00
GitHub Actions
991d3cef88 Auto translate strings 2026-01-26 08:31:35 +00:00
Paul Gessinger
f2bb6c9725 Enhancement: Add support for app oidc (#11756)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-01-26 00:29:36 -08:00
shamoon
2312314aa7 Performance: improve treenode inefficiencies (#11606) 2026-01-25 21:47:08 -08:00
shamoon
72e8b73108 Fix test 2026-01-25 17:08:15 -08:00
shamoon
444ff6951e Merge branch 'release/v2.20.x' into dev 2026-01-25 16:58:04 -08:00
shamoon
5c9ff367e3 Fixhancement: change date calculation for 'this year' to include future documents (#11884) 2026-01-25 16:56:51 -08:00
GitHub Actions
aecf42d1ab Auto translate strings 2026-01-25 21:47:42 +00:00
shamoon
45f5025f78 Enhancement: Add 'any of' workflow trigger filters (#11683) 2026-01-25 13:45:50 -08:00
Trenton H
94f6b8d36d Fixes the management scripts under a non-root install where the user ID is something besides 1000 (#11870) 2026-01-23 16:08:28 -08:00
shamoon
32d04e1fd3 Fix: use correct field id for overrides (#11869) 2026-01-23 15:49:22 -08:00
Trenton H
56c744fd56 Fixes the spelling of the commitish argument to the action 2026-01-23 15:49:00 -08:00
59 changed files with 1238 additions and 432 deletions

View File

@@ -23,7 +23,7 @@ env:
jobs:
build:
name: Build Documentation
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -58,7 +58,7 @@ jobs:
name: Deploy Documentation
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -12,7 +12,7 @@ concurrency:
jobs:
pre-commit:
name: Pre-commit Checks
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -10,7 +10,7 @@ jobs:
synchronize-with-crowdin:
name: Crowdin Sync
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -8,7 +8,7 @@ permissions:
jobs:
pr-bot:
name: Automated PR Bot
runs-on: ubuntu-latest
runs-on: ubuntu-slim
steps:
- name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config

View File

@@ -12,7 +12,7 @@ permissions:
jobs:
pr_opened_or_reopened:
name: pr_opened_or_reopened
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
permissions:
# write permission is required for autolabeler
pull-requests: write

View File

@@ -13,7 +13,7 @@ jobs:
stale:
name: 'Stale'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: actions/stale@v10
with:
@@ -35,7 +35,7 @@ jobs:
lock-threads:
name: 'Lock Old Threads'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: dessant/lock-threads@v6
with:
@@ -55,7 +55,7 @@ jobs:
close-answered-discussions:
name: 'Close Answered Discussions'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: actions/github-script@v8
with:
@@ -112,7 +112,7 @@ jobs:
close-outdated-discussions:
name: 'Close Outdated Discussions'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: actions/github-script@v8
with:
@@ -204,7 +204,7 @@ jobs:
close-unsupported-feature-requests:
name: 'Close Unsupported Feature Requests'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: actions/github-script@v8
with:

View File

@@ -6,7 +6,7 @@ on:
jobs:
generate-translate-strings:
name: Generate Translation Strings
runs-on: ubuntu-latest
runs-on: ubuntu-slim
permissions:
contents: write
steps:

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py management_command "$@"
elif [[ $(id -un) == "paperless" ]]; then
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py management_command "$@"
else
echo "Unknown user."
elif [[ $(id -un) == "paperless" ]]; then
s6-setuidgid paperless python3 manage.py management_command "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py convert_mariadb_uuid "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py convert_mariadb_uuid "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py createsuperuser "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py createsuperuser "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py createsuperuser "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py createsuperuser "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_archiver "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_archiver "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_archiver "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_archiver "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_create_classifier "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_create_classifier "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_create_classifier "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_create_classifier "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_exporter "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_exporter "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_exporter "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_exporter "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_fuzzy_match "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_fuzzy_match "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_importer "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_importer "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_importer "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_importer "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_index "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_index "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_index "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_index "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_renamer "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_renamer "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_renamer "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_renamer "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_retagger "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_retagger "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_retagger "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_retagger "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_sanity_checker "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_sanity_checker "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_sanity_checker "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_sanity_checker "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_thumbnails "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_thumbnails "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_thumbnails "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py document_thumbnails "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py mail_fetcher "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py mail_fetcher "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py mail_fetcher "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py mail_fetcher "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py manage_superuser "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py manage_superuser "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py manage_superuser "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py manage_superuser "$@"
fi

View File

@@ -5,10 +5,8 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py prune_audit_logs "$@"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py prune_audit_logs "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py prune_audit_logs "$@"
else
echo "Unknown user."
s6-setuidgid paperless python3 manage.py prune_audit_logs "$@"
fi

View File

@@ -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

View File

@@ -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=<str>`](#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=<comma-separated-list>`](#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.

View File

@@ -257,7 +257,7 @@ lint.isort.force-single-line = true
[tool.codespell]
write-changes = true
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober"
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
[tool.pytest.ini_options]

File diff suppressed because it is too large Load Diff

View File

@@ -412,6 +412,9 @@ describe('WorkflowEditDialogComponent', () => {
return newFilter
}
const correspondentAny = addFilterOfType(TriggerFilterType.CorrespondentAny)
correspondentAny.get('values').setValue([11])
const correspondentIs = addFilterOfType(TriggerFilterType.CorrespondentIs)
correspondentIs.get('values').setValue(1)
@@ -421,12 +424,18 @@ describe('WorkflowEditDialogComponent', () => {
const documentTypeIs = addFilterOfType(TriggerFilterType.DocumentTypeIs)
documentTypeIs.get('values').setValue(1)
const documentTypeAny = addFilterOfType(TriggerFilterType.DocumentTypeAny)
documentTypeAny.get('values').setValue([12])
const documentTypeNot = addFilterOfType(TriggerFilterType.DocumentTypeNot)
documentTypeNot.get('values').setValue([1])
const storagePathIs = addFilterOfType(TriggerFilterType.StoragePathIs)
storagePathIs.get('values').setValue(1)
const storagePathAny = addFilterOfType(TriggerFilterType.StoragePathAny)
storagePathAny.get('values').setValue([13])
const storagePathNot = addFilterOfType(TriggerFilterType.StoragePathNot)
storagePathNot.get('values').setValue([1])
@@ -441,10 +450,13 @@ describe('WorkflowEditDialogComponent', () => {
expect(formValues.triggers[0].filter_has_tags).toEqual([1])
expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3])
expect(formValues.triggers[0].filter_has_not_tags).toEqual([4])
expect(formValues.triggers[0].filter_has_any_correspondents).toEqual([11])
expect(formValues.triggers[0].filter_has_correspondent).toEqual(1)
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1])
expect(formValues.triggers[0].filter_has_any_document_types).toEqual([12])
expect(formValues.triggers[0].filter_has_document_type).toEqual(1)
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1])
expect(formValues.triggers[0].filter_has_any_storage_paths).toEqual([13])
expect(formValues.triggers[0].filter_has_storage_path).toEqual(1)
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1])
expect(formValues.triggers[0].filter_custom_field_query).toEqual(
@@ -507,16 +519,22 @@ describe('WorkflowEditDialogComponent', () => {
setFilter(TriggerFilterType.TagsAll, 11)
setFilter(TriggerFilterType.TagsNone, 12)
setFilter(TriggerFilterType.CorrespondentAny, 16)
setFilter(TriggerFilterType.CorrespondentNot, 13)
setFilter(TriggerFilterType.DocumentTypeAny, 17)
setFilter(TriggerFilterType.DocumentTypeNot, 14)
setFilter(TriggerFilterType.StoragePathAny, 18)
setFilter(TriggerFilterType.StoragePathNot, 15)
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_all_tags).toEqual([11])
expect(formValues.triggers[0].filter_has_not_tags).toEqual([12])
expect(formValues.triggers[0].filter_has_any_correspondents).toEqual([16])
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([13])
expect(formValues.triggers[0].filter_has_any_document_types).toEqual([17])
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([14])
expect(formValues.triggers[0].filter_has_any_storage_paths).toEqual([18])
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([15])
})
@@ -640,8 +658,11 @@ describe('WorkflowEditDialogComponent', () => {
filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_any_correspondents: [],
filter_has_not_correspondents: [],
filter_has_any_document_types: [],
filter_has_not_document_types: [],
filter_has_any_storage_paths: [],
filter_has_not_storage_paths: [],
filter_has_correspondent: null,
filter_has_document_type: null,
@@ -699,11 +720,14 @@ describe('WorkflowEditDialogComponent', () => {
trigger.filter_has_tags = [1]
trigger.filter_has_all_tags = [2, 3]
trigger.filter_has_not_tags = [4]
trigger.filter_has_any_correspondents = [10] as any
trigger.filter_has_correspondent = 5 as any
trigger.filter_has_not_correspondents = [6] as any
trigger.filter_has_document_type = 7 as any
trigger.filter_has_any_document_types = [11] as any
trigger.filter_has_not_document_types = [8] as any
trigger.filter_has_storage_path = 9 as any
trigger.filter_has_any_storage_paths = [12] as any
trigger.filter_has_not_storage_paths = [10] as any
trigger.filter_custom_field_query = JSON.stringify([
'AND',
@@ -714,8 +738,8 @@ describe('WorkflowEditDialogComponent', () => {
component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const filters = component.getFiltersFormArray(triggerGroup)
expect(filters.length).toBe(10)
const customFieldFilter = filters.at(9) as FormGroup
expect(filters.length).toBe(13)
const customFieldFilter = filters.at(12) as FormGroup
expect(customFieldFilter.get('type').value).toBe(
TriggerFilterType.CustomFieldQuery
)
@@ -724,12 +748,27 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should expose select metadata helpers', () => {
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentAny)).toBe(
true
)
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentNot)).toBe(
true
)
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentIs)).toBe(
false
)
expect(component.isSelectMultiple(TriggerFilterType.DocumentTypeAny)).toBe(
true
)
expect(component.isSelectMultiple(TriggerFilterType.DocumentTypeIs)).toBe(
false
)
expect(component.isSelectMultiple(TriggerFilterType.StoragePathAny)).toBe(
true
)
expect(component.isSelectMultiple(TriggerFilterType.StoragePathIs)).toBe(
false
)
component.correspondents = [{ id: 1, name: 'C1' } as any]
component.documentTypes = [{ id: 2, name: 'DT' } as any]
@@ -741,9 +780,15 @@ describe('WorkflowEditDialogComponent', () => {
expect(
component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
).toEqual(component.documentTypes)
expect(
component.getFilterSelectItems(TriggerFilterType.DocumentTypeAny)
).toEqual(component.documentTypes)
expect(
component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
).toEqual(component.storagePaths)
expect(
component.getFilterSelectItems(TriggerFilterType.StoragePathAny)
).toEqual(component.storagePaths)
expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
[]
)

View File

@@ -145,10 +145,13 @@ export enum TriggerFilterType {
TagsAny = 'tags_any',
TagsAll = 'tags_all',
TagsNone = 'tags_none',
CorrespondentAny = 'correspondent_any',
CorrespondentIs = 'correspondent_is',
CorrespondentNot = 'correspondent_not',
DocumentTypeAny = 'document_type_any',
DocumentTypeIs = 'document_type_is',
DocumentTypeNot = 'document_type_not',
StoragePathAny = 'storage_path_any',
StoragePathIs = 'storage_path_is',
StoragePathNot = 'storage_path_not',
CustomFieldQuery = 'custom_field_query',
@@ -172,8 +175,11 @@ type TriggerFilterAggregate = {
filter_has_tags: number[]
filter_has_all_tags: number[]
filter_has_not_tags: number[]
filter_has_any_correspondents: number[]
filter_has_not_correspondents: number[]
filter_has_any_document_types: number[]
filter_has_not_document_types: number[]
filter_has_any_storage_paths: number[]
filter_has_not_storage_paths: number[]
filter_has_correspondent: number | null
filter_has_document_type: number | null
@@ -219,6 +225,14 @@ const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
allowMultipleEntries: false,
allowMultipleValues: true,
},
{
id: TriggerFilterType.CorrespondentAny,
name: $localize`Has any of these correspondents`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: true,
selectItems: 'correspondents',
},
{
id: TriggerFilterType.CorrespondentIs,
name: $localize`Has correspondent`,
@@ -243,6 +257,14 @@ const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
allowMultipleValues: false,
selectItems: 'documentTypes',
},
{
id: TriggerFilterType.DocumentTypeAny,
name: $localize`Has any of these document types`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: true,
selectItems: 'documentTypes',
},
{
id: TriggerFilterType.DocumentTypeNot,
name: $localize`Does not have document types`,
@@ -259,6 +281,14 @@ const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
allowMultipleValues: false,
selectItems: 'storagePaths',
},
{
id: TriggerFilterType.StoragePathAny,
name: $localize`Has any of these storage paths`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: true,
selectItems: 'storagePaths',
},
{
id: TriggerFilterType.StoragePathNot,
name: $localize`Does not have storage paths`,
@@ -306,6 +336,15 @@ const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
extract: (trigger) => trigger.filter_has_not_tags,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.CorrespondentAny]: {
apply: (aggregate, values) => {
aggregate.filter_has_any_correspondents = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_any_correspondents,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.CorrespondentIs]: {
apply: (aggregate, values) => {
aggregate.filter_has_correspondent = Array.isArray(values)
@@ -333,6 +372,15 @@ const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
extract: (trigger) => trigger.filter_has_document_type,
hasValue: (value) => value !== null && value !== undefined,
},
[TriggerFilterType.DocumentTypeAny]: {
apply: (aggregate, values) => {
aggregate.filter_has_any_document_types = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_any_document_types,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.DocumentTypeNot]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_document_types = Array.isArray(values)
@@ -351,6 +399,15 @@ const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
extract: (trigger) => trigger.filter_has_storage_path,
hasValue: (value) => value !== null && value !== undefined,
},
[TriggerFilterType.StoragePathAny]: {
apply: (aggregate, values) => {
aggregate.filter_has_any_storage_paths = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_any_storage_paths,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.StoragePathNot]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_storage_paths = Array.isArray(values)
@@ -642,8 +699,11 @@ export class WorkflowEditDialogComponent
filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_any_correspondents: [],
filter_has_not_correspondents: [],
filter_has_any_document_types: [],
filter_has_not_document_types: [],
filter_has_any_storage_paths: [],
filter_has_not_storage_paths: [],
filter_has_correspondent: null,
filter_has_document_type: null,
@@ -670,10 +730,16 @@ export class WorkflowEditDialogComponent
trigger.filter_has_tags = aggregate.filter_has_tags
trigger.filter_has_all_tags = aggregate.filter_has_all_tags
trigger.filter_has_not_tags = aggregate.filter_has_not_tags
trigger.filter_has_any_correspondents =
aggregate.filter_has_any_correspondents
trigger.filter_has_not_correspondents =
aggregate.filter_has_not_correspondents
trigger.filter_has_any_document_types =
aggregate.filter_has_any_document_types
trigger.filter_has_not_document_types =
aggregate.filter_has_not_document_types
trigger.filter_has_any_storage_paths =
aggregate.filter_has_any_storage_paths
trigger.filter_has_not_storage_paths =
aggregate.filter_has_not_storage_paths
trigger.filter_has_correspondent =
@@ -856,8 +922,11 @@ export class WorkflowEditDialogComponent
case TriggerFilterType.TagsAny:
case TriggerFilterType.TagsAll:
case TriggerFilterType.TagsNone:
case TriggerFilterType.CorrespondentAny:
case TriggerFilterType.CorrespondentNot:
case TriggerFilterType.DocumentTypeAny:
case TriggerFilterType.DocumentTypeNot:
case TriggerFilterType.StoragePathAny:
case TriggerFilterType.StoragePathNot:
return true
default:
@@ -1179,8 +1248,11 @@ export class WorkflowEditDialogComponent
filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_any_correspondents: [],
filter_has_not_correspondents: [],
filter_has_any_document_types: [],
filter_has_not_document_types: [],
filter_has_any_storage_paths: [],
filter_has_not_storage_paths: [],
filter_custom_field_query: null,
filter_has_correspondent: null,

View File

@@ -1,9 +1,18 @@
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
<div class="col-md text-truncate">
<h3 class="text-truncate" style="line-height: 1.4">
{{title}}
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
<span class="text-truncate">{{title}}</span>
@if (id) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@if (copied) {
<i-bs width="1em" height="1em" name="clipboard-check"></i-bs>&nbsp;<ng-container i18n>Copied!</ng-container>
} @else {
ID: {{id}}
}
</span>
}
@if (subTitle) {
<span class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
}
@if (info) {
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">

View File

@@ -1,5 +1,10 @@
h3 {
min-height: calc(1.325rem + 0.9vw);
.badge {
font-size: 0.65rem;
line-height: 1;
}
}
@media (min-width: 1200px) {

View File

@@ -1,3 +1,4 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Title } from '@angular/platform-browser'
import { environment } from 'src/environments/environment'
@@ -7,6 +8,7 @@ describe('PageHeaderComponent', () => {
let component: PageHeaderComponent
let fixture: ComponentFixture<PageHeaderComponent>
let titleService: Title
let clipboard: Clipboard
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -15,6 +17,7 @@ describe('PageHeaderComponent', () => {
}).compileComponents()
titleService = TestBed.inject(Title)
clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(PageHeaderComponent)
component = fixture.componentInstance
fixture.detectChanges()
@@ -24,7 +27,8 @@ describe('PageHeaderComponent', () => {
component.title = 'Foo'
component.subTitle = 'Bar'
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Foo Bar')
expect(fixture.nativeElement.textContent).toContain('Foo')
expect(fixture.nativeElement.textContent).toContain('Bar')
})
it('should set html title', () => {
@@ -32,4 +36,16 @@ describe('PageHeaderComponent', () => {
component.title = 'Foo Bar'
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
})
it('should copy id to clipboard, reset after 3 seconds', () => {
jest.useFakeTimers()
component.id = 42 as any
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
component.copyID()
expect(clipboard.copy).toHaveBeenCalledWith('42')
expect(component.copied).toBe(true)
jest.advanceTimersByTime(3000)
expect(component.copied).toBe(false)
})
})

View File

@@ -1,3 +1,4 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, Input, inject } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
@@ -13,8 +14,11 @@ import { environment } from 'src/environments/environment'
})
export class PageHeaderComponent {
private titleService = inject(Title)
private clipboard = inject(Clipboard)
_title = ''
private _title = ''
public copied: boolean = false
private copyTimeout: any
@Input()
set title(title: string) {
@@ -26,6 +30,9 @@ export class PageHeaderComponent {
return this._title
}
@Input()
id: number
@Input()
subTitle: string = ''
@@ -34,4 +41,12 @@ export class PageHeaderComponent {
@Input()
infoLink: string
public copyID() {
this.copied = this.clipboard.copy(this.id.toString())
clearTimeout(this.copyTimeout)
this.copyTimeout = setTimeout(() => {
this.copied = false
}, 3000)
}
}

View File

@@ -1,4 +1,4 @@
<pngx-page-header [(title)]="title">
<pngx-page-header [(title)]="title" [id]="documentId">
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
@if (previewNumPages) {
<div class="input-group input-group-sm d-none d-md-flex">

View File

@@ -14,6 +14,7 @@ import { SortableDirective } from 'src/app/directives/sortable.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { PermissionType } from 'src/app/services/permissions.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@@ -36,6 +37,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
ClearableBadgeComponent,
],
})
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {

View File

@@ -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 { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-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 DocumentTypeListComponent extends ManagementListComponent<DocumentType> {

View File

@@ -1,17 +1,48 @@
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions">
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
<i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container>
<div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
@if (selectedObjects.size > 0) {
<pngx-clearable-badge [selected]="selectedObjects.size > 0" [number]="selectedObjects.size" (cleared)="selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
<button ngbDropdownItem (click)="selectNone()" i18n>Select none</button>
<button ngbDropdownItem (click)="selectPage(true)" i18n>Select page</button>
<button ngbDropdownItem (click)="selectAll()" i18n>Select all</button>
</div>
</div>
<div class="d-none d-sm-flex flex-fill me-3">
<div class="input-group input-group-sm">
<span class="input-group-text border-0" i18n>Select:</span>
</div>
<div class="btn-group btn-group-sm flex-nowrap">
@if (selectedObjects.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="selectNone()">
<i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>None</ng-container>
</button>
}
<button class="btn btn-sm btn-outline-primary" (click)="selectPage(true)">
<i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary" (click)="selectAll()">
<i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
</button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
<i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container>
</button>
</pngx-page-header>
<div class="row mb-3">
@@ -31,7 +62,7 @@
<tr>
<th scope="col">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="selectPage($event.target.checked); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>

View File

@@ -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<Tag>
@@ -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<Tag>
@@ -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])

View File

@@ -84,6 +84,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public data: T[] = []
private unfilteredData: T[] = []
private allIDs: number[] = []
public page = 1
@@ -171,7 +172,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
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<T extends MatchingModel>
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<T extends MatchingModel>
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() {

View File

@@ -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<StoragePath> {

View File

@@ -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)
})
})

View File

@@ -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<Tag> {

View File

@@ -44,10 +44,16 @@ export interface WorkflowTrigger extends ObjectWithId {
filter_has_not_tags?: number[] // Tag.id[]
filter_has_any_correspondents?: number[] // Correspondent.id[]
filter_has_not_correspondents?: number[] // Correspondent.id[]
filter_has_any_document_types?: number[] // DocumentType.id[]
filter_has_not_document_types?: number[] // DocumentType.id[]
filter_has_any_storage_paths?: number[] // StoragePath.id[]
filter_has_not_storage_paths?: number[] // StoragePath.id[]
filter_custom_field_query?: string

View File

@@ -118,7 +118,7 @@ class DocumentMetadataOverrides:
).values_list("id", flat=True),
)
overrides.custom_fields = {
custom_field.id: custom_field.value
custom_field.field.id: custom_field.value
for custom_field in doc.custom_fields.all()
}

View File

@@ -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()

View File

@@ -403,6 +403,18 @@ def existing_document_matches_workflow(
f"Document tags {list(document.tags.all())} include excluded tags {list(trigger_has_not_tags_qs)}",
)
allowed_correspondent_ids = set(
trigger.filter_has_any_correspondents.values_list("id", flat=True),
)
if (
allowed_correspondent_ids
and document.correspondent_id not in allowed_correspondent_ids
):
return (
False,
f"Document correspondent {document.correspondent} is not one of {list(trigger.filter_has_any_correspondents.all())}",
)
# Document correspondent vs trigger has_correspondent
if (
trigger.filter_has_correspondent_id is not None
@@ -424,6 +436,17 @@ def existing_document_matches_workflow(
f"Document correspondent {document.correspondent} is excluded by {list(trigger.filter_has_not_correspondents.all())}",
)
allowed_document_type_ids = set(
trigger.filter_has_any_document_types.values_list("id", flat=True),
)
if allowed_document_type_ids and (
document.document_type_id not in allowed_document_type_ids
):
return (
False,
f"Document doc type {document.document_type} is not one of {list(trigger.filter_has_any_document_types.all())}",
)
# Document document_type vs trigger has_document_type
if (
trigger.filter_has_document_type_id is not None
@@ -445,6 +468,17 @@ def existing_document_matches_workflow(
f"Document doc type {document.document_type} is excluded by {list(trigger.filter_has_not_document_types.all())}",
)
allowed_storage_path_ids = set(
trigger.filter_has_any_storage_paths.values_list("id", flat=True),
)
if allowed_storage_path_ids and (
document.storage_path_id not in allowed_storage_path_ids
):
return (
False,
f"Document storage path {document.storage_path} is not one of {list(trigger.filter_has_any_storage_paths.all())}",
)
# Document storage_path vs trigger has_storage_path
if (
trigger.filter_has_storage_path_id is not None
@@ -532,6 +566,10 @@ def prefilter_documents_by_workflowtrigger(
# Correspondent, DocumentType, etc. filtering
if trigger.filter_has_any_correspondents.exists():
documents = documents.filter(
correspondent__in=trigger.filter_has_any_correspondents.all(),
)
if trigger.filter_has_correspondent is not None:
documents = documents.filter(
correspondent=trigger.filter_has_correspondent,
@@ -541,6 +579,10 @@ def prefilter_documents_by_workflowtrigger(
correspondent__in=trigger.filter_has_not_correspondents.all(),
)
if trigger.filter_has_any_document_types.exists():
documents = documents.filter(
document_type__in=trigger.filter_has_any_document_types.all(),
)
if trigger.filter_has_document_type is not None:
documents = documents.filter(
document_type=trigger.filter_has_document_type,
@@ -550,6 +592,10 @@ def prefilter_documents_by_workflowtrigger(
document_type__in=trigger.filter_has_not_document_types.all(),
)
if trigger.filter_has_any_storage_paths.exists():
documents = documents.filter(
storage_path__in=trigger.filter_has_any_storage_paths.all(),
)
if trigger.filter_has_storage_path is not None:
documents = documents.filter(
storage_path=trigger.filter_has_storage_path,
@@ -604,8 +650,11 @@ def document_matches_workflow(
"filter_has_tags",
"filter_has_all_tags",
"filter_has_not_tags",
"filter_has_any_document_types",
"filter_has_not_document_types",
"filter_has_any_correspondents",
"filter_has_not_correspondents",
"filter_has_any_storage_paths",
"filter_has_not_storage_paths",
)
)

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.7 on 2025-12-17 22:25
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0004_remove_document_storage_type"),
]
operations = [
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_any_correspondents",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_any_correspondent",
to="documents.correspondent",
verbose_name="has one of these correspondents",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_any_document_types",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_any_document_type",
to="documents.documenttype",
verbose_name="has one of these document types",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_any_storage_paths",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_any_storage_path",
to="documents.storagepath",
verbose_name="has one of these storage paths",
),
),
]

View File

@@ -1066,6 +1066,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this document type"),
)
filter_has_any_document_types = models.ManyToManyField(
DocumentType,
blank=True,
related_name="workflowtriggers_has_any_document_type",
verbose_name=_("has one of these document types"),
)
filter_has_not_document_types = models.ManyToManyField(
DocumentType,
blank=True,
@@ -1088,6 +1095,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("does not have these correspondent(s)"),
)
filter_has_any_correspondents = models.ManyToManyField(
Correspondent,
blank=True,
related_name="workflowtriggers_has_any_correspondent",
verbose_name=_("has one of these correspondents"),
)
filter_has_storage_path = models.ForeignKey(
StoragePath,
null=True,
@@ -1096,6 +1110,13 @@ class WorkflowTrigger(models.Model):
verbose_name=_("has this storage path"),
)
filter_has_any_storage_paths = models.ManyToManyField(
StoragePath,
blank=True,
related_name="workflowtriggers_has_any_storage_path",
verbose_name=_("has one of these storage paths"),
)
filter_has_not_storage_paths = models.ManyToManyField(
StoragePath,
blank=True,

View File

@@ -2299,8 +2299,11 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
"filter_has_all_tags",
"filter_has_not_tags",
"filter_custom_field_query",
"filter_has_any_correspondents",
"filter_has_not_correspondents",
"filter_has_any_document_types",
"filter_has_not_document_types",
"filter_has_any_storage_paths",
"filter_has_not_storage_paths",
"filter_has_correspondent",
"filter_has_document_type",
@@ -2538,14 +2541,26 @@ class WorkflowSerializer(serializers.ModelSerializer):
filter_has_tags = trigger.pop("filter_has_tags", None)
filter_has_all_tags = trigger.pop("filter_has_all_tags", None)
filter_has_not_tags = trigger.pop("filter_has_not_tags", None)
filter_has_any_correspondents = trigger.pop(
"filter_has_any_correspondents",
None,
)
filter_has_not_correspondents = trigger.pop(
"filter_has_not_correspondents",
None,
)
filter_has_any_document_types = trigger.pop(
"filter_has_any_document_types",
None,
)
filter_has_not_document_types = trigger.pop(
"filter_has_not_document_types",
None,
)
filter_has_any_storage_paths = trigger.pop(
"filter_has_any_storage_paths",
None,
)
filter_has_not_storage_paths = trigger.pop(
"filter_has_not_storage_paths",
None,
@@ -2562,14 +2577,26 @@ class WorkflowSerializer(serializers.ModelSerializer):
trigger_instance.filter_has_all_tags.set(filter_has_all_tags)
if filter_has_not_tags is not None:
trigger_instance.filter_has_not_tags.set(filter_has_not_tags)
if filter_has_any_correspondents is not None:
trigger_instance.filter_has_any_correspondents.set(
filter_has_any_correspondents,
)
if filter_has_not_correspondents is not None:
trigger_instance.filter_has_not_correspondents.set(
filter_has_not_correspondents,
)
if filter_has_any_document_types is not None:
trigger_instance.filter_has_any_document_types.set(
filter_has_any_document_types,
)
if filter_has_not_document_types is not None:
trigger_instance.filter_has_not_document_types.set(
filter_has_not_document_types,
)
if filter_has_any_storage_paths is not None:
trigger_instance.filter_has_any_storage_paths.set(
filter_has_any_storage_paths,
)
if filter_has_not_storage_paths is not None:
trigger_instance.filter_has_not_storage_paths.set(
filter_has_not_storage_paths,

View File

@@ -186,8 +186,11 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"filter_has_tags": [self.t1.id],
"filter_has_all_tags": [self.t2.id],
"filter_has_not_tags": [self.t3.id],
"filter_has_any_correspondents": [self.c.id],
"filter_has_not_correspondents": [self.c2.id],
"filter_has_any_document_types": [self.dt.id],
"filter_has_not_document_types": [self.dt2.id],
"filter_has_any_storage_paths": [self.sp.id],
"filter_has_not_storage_paths": [self.sp2.id],
"filter_custom_field_query": json.dumps(
[
@@ -248,14 +251,26 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
set(trigger.filter_has_not_tags.values_list("id", flat=True)),
{self.t3.id},
)
self.assertSetEqual(
set(trigger.filter_has_any_correspondents.values_list("id", flat=True)),
{self.c.id},
)
self.assertSetEqual(
set(trigger.filter_has_not_correspondents.values_list("id", flat=True)),
{self.c2.id},
)
self.assertSetEqual(
set(trigger.filter_has_any_document_types.values_list("id", flat=True)),
{self.dt.id},
)
self.assertSetEqual(
set(trigger.filter_has_not_document_types.values_list("id", flat=True)),
{self.dt2.id},
)
self.assertSetEqual(
set(trigger.filter_has_any_storage_paths.values_list("id", flat=True)),
{self.sp.id},
)
self.assertSetEqual(
set(trigger.filter_has_not_storage_paths.values_list("id", flat=True)),
{self.sp2.id},
@@ -419,8 +434,11 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"filter_has_tags": [self.t1.id],
"filter_has_all_tags": [self.t2.id],
"filter_has_not_tags": [self.t3.id],
"filter_has_any_correspondents": [self.c.id],
"filter_has_not_correspondents": [self.c2.id],
"filter_has_any_document_types": [self.dt.id],
"filter_has_not_document_types": [self.dt2.id],
"filter_has_any_storage_paths": [self.sp.id],
"filter_has_not_storage_paths": [self.sp2.id],
"filter_custom_field_query": json.dumps(
["AND", [[self.cf1.id, "exact", "value"]]],
@@ -450,14 +468,26 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
workflow.triggers.first().filter_has_not_tags.first(),
self.t3,
)
self.assertEqual(
workflow.triggers.first().filter_has_any_correspondents.first(),
self.c,
)
self.assertEqual(
workflow.triggers.first().filter_has_not_correspondents.first(),
self.c2,
)
self.assertEqual(
workflow.triggers.first().filter_has_any_document_types.first(),
self.dt,
)
self.assertEqual(
workflow.triggers.first().filter_has_not_document_types.first(),
self.dt2,
)
self.assertEqual(
workflow.triggers.first().filter_has_any_storage_paths.first(),
self.sp,
)
self.assertEqual(
workflow.triggers.first().filter_has_not_storage_paths.first(),
self.sp2,

View File

@@ -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",

View File

@@ -1276,6 +1276,76 @@ class TestWorkflows(
)
self.assertIn(expected_str, cm.output[1])
def test_document_added_any_filters(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
)
trigger.filter_has_any_correspondents.set([self.c])
trigger.filter_has_any_document_types.set([self.dt])
trigger.filter_has_any_storage_paths.set([self.sp])
matching_doc = Document.objects.create(
title="sample test",
correspondent=self.c,
document_type=self.dt,
storage_path=self.sp,
original_filename="sample.pdf",
checksum="checksum-any-match",
)
matched, reason = existing_document_matches_workflow(matching_doc, trigger)
self.assertTrue(matched)
self.assertIsNone(reason)
wrong_correspondent = Document.objects.create(
title="wrong correspondent",
correspondent=self.c2,
document_type=self.dt,
storage_path=self.sp,
original_filename="sample2.pdf",
)
matched, reason = existing_document_matches_workflow(
wrong_correspondent,
trigger,
)
self.assertFalse(matched)
self.assertIn("correspondent", reason)
other_document_type = DocumentType.objects.create(name="Other")
wrong_document_type = Document.objects.create(
title="wrong doc type",
correspondent=self.c,
document_type=other_document_type,
storage_path=self.sp,
original_filename="sample3.pdf",
checksum="checksum-wrong-doc-type",
)
matched, reason = existing_document_matches_workflow(
wrong_document_type,
trigger,
)
self.assertFalse(matched)
self.assertIn("doc type", reason)
other_storage_path = StoragePath.objects.create(
name="Other path",
path="/other/",
)
wrong_storage_path = Document.objects.create(
title="wrong storage",
correspondent=self.c,
document_type=self.dt,
storage_path=other_storage_path,
original_filename="sample4.pdf",
checksum="checksum-wrong-storage-path",
)
matched, reason = existing_document_matches_workflow(
wrong_storage_path,
trigger,
)
self.assertFalse(matched)
self.assertIn("storage path", reason)
def test_document_added_custom_field_query_no_match(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
@@ -1384,6 +1454,39 @@ class TestWorkflows(
self.assertIn(doc1, filtered)
self.assertNotIn(doc2, filtered)
def test_prefilter_documents_any_filters(self):
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
)
trigger.filter_has_any_correspondents.set([self.c])
trigger.filter_has_any_document_types.set([self.dt])
trigger.filter_has_any_storage_paths.set([self.sp])
allowed_document = Document.objects.create(
title="allowed",
correspondent=self.c,
document_type=self.dt,
storage_path=self.sp,
original_filename="doc-allowed.pdf",
checksum="checksum-any-allowed",
)
blocked_document = Document.objects.create(
title="blocked",
correspondent=self.c2,
document_type=self.dt,
storage_path=self.sp,
original_filename="doc-blocked.pdf",
checksum="checksum-any-blocked",
)
filtered = prefilter_documents_by_workflowtrigger(
Document.objects.all(),
trigger,
)
self.assertIn(allowed_document, filtered)
self.assertNotIn(blocked_document, filtered)
def test_consumption_trigger_requires_filter_configuration(self):
serializer = WorkflowTriggerSerializer(
data={

View File

@@ -479,11 +479,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 +495,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()

View File

@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-25 03:30+0000\n"
"POT-Creation-Date: 2026-01-26 18:31+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -89,7 +89,7 @@ msgstr ""
msgid "Automatic"
msgstr ""
#: documents/models.py:64 documents/models.py:434 documents/models.py:1507
#: documents/models.py:64 documents/models.py:434 documents/models.py:1528
#: paperless_mail/models.py:23 paperless_mail/models.py:143
msgid "name"
msgstr ""
@@ -252,7 +252,7 @@ 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:1550
#: documents/models.py:1571
msgid "document"
msgstr ""
@@ -869,346 +869,358 @@ msgid "has this document type"
msgstr ""
#: documents/models.py:1073
msgid "has one of these document types"
msgstr ""
#: documents/models.py:1080
msgid "does not have these document type(s)"
msgstr ""
#: documents/models.py:1081
#: documents/models.py:1088
msgid "has this correspondent"
msgstr ""
#: documents/models.py:1088
#: documents/models.py:1095
msgid "does not have these correspondent(s)"
msgstr ""
#: documents/models.py:1096
msgid "has this storage path"
msgstr ""
#: documents/models.py:1103
msgid "does not have these storage path(s)"
msgstr ""
#: documents/models.py:1107
msgid "filter custom field query"
#: documents/models.py:1102
msgid "has one of these correspondents"
msgstr ""
#: documents/models.py:1110
msgid "JSON-encoded custom field query expression."
msgstr ""
#: documents/models.py:1114
msgid "schedule offset days"
msgid "has this storage path"
msgstr ""
#: documents/models.py:1117
msgid "has one of these storage paths"
msgstr ""
#: documents/models.py:1124
msgid "does not have these storage path(s)"
msgstr ""
#: documents/models.py:1128
msgid "filter custom field query"
msgstr ""
#: documents/models.py:1131
msgid "JSON-encoded custom field query expression."
msgstr ""
#: documents/models.py:1135
msgid "schedule offset days"
msgstr ""
#: documents/models.py:1138
msgid "The number of days to offset the schedule trigger by."
msgstr ""
#: documents/models.py:1122
#: documents/models.py:1143
msgid "schedule is recurring"
msgstr ""
#: documents/models.py:1125
#: documents/models.py:1146
msgid "If the schedule should be recurring."
msgstr ""
#: documents/models.py:1130
#: documents/models.py:1151
msgid "schedule recurring delay in days"
msgstr ""
#: documents/models.py:1134
#: documents/models.py:1155
msgid "The number of days between recurring schedule triggers."
msgstr ""
#: documents/models.py:1139
#: documents/models.py:1160
msgid "schedule date field"
msgstr ""
#: documents/models.py:1144
#: documents/models.py:1165
msgid "The field to check for a schedule trigger."
msgstr ""
#: documents/models.py:1153
#: documents/models.py:1174
msgid "schedule date custom field"
msgstr ""
#: documents/models.py:1157
#: documents/models.py:1178
msgid "workflow trigger"
msgstr ""
#: documents/models.py:1158
#: documents/models.py:1179
msgid "workflow triggers"
msgstr ""
#: documents/models.py:1166
#: documents/models.py:1187
msgid "email subject"
msgstr ""
#: documents/models.py:1170
#: documents/models.py:1191
msgid ""
"The subject of the email, can include some placeholders, see documentation."
msgstr ""
#: documents/models.py:1176
#: documents/models.py:1197
msgid "email body"
msgstr ""
#: documents/models.py:1179
#: documents/models.py:1200
msgid ""
"The body (message) of the email, can include some placeholders, see "
"documentation."
msgstr ""
#: documents/models.py:1185
#: documents/models.py:1206
msgid "emails to"
msgstr ""
#: documents/models.py:1188
#: documents/models.py:1209
msgid "The destination email addresses, comma separated."
msgstr ""
#: documents/models.py:1194
#: documents/models.py:1215
msgid "include document in email"
msgstr ""
#: documents/models.py:1205
#: documents/models.py:1226
msgid "webhook url"
msgstr ""
#: documents/models.py:1208
#: documents/models.py:1229
msgid "The destination URL for the notification."
msgstr ""
#: documents/models.py:1213
#: documents/models.py:1234
msgid "use parameters"
msgstr ""
#: documents/models.py:1218
#: documents/models.py:1239
msgid "send as JSON"
msgstr ""
#: documents/models.py:1222
#: documents/models.py:1243
msgid "webhook parameters"
msgstr ""
#: documents/models.py:1225
#: documents/models.py:1246
msgid "The parameters to send with the webhook URL if body not used."
msgstr ""
#: documents/models.py:1229
#: documents/models.py:1250
msgid "webhook body"
msgstr ""
#: documents/models.py:1232
#: documents/models.py:1253
msgid "The body to send with the webhook URL if parameters not used."
msgstr ""
#: documents/models.py:1236
#: documents/models.py:1257
msgid "webhook headers"
msgstr ""
#: documents/models.py:1239
#: documents/models.py:1260
msgid "The headers to send with the webhook URL."
msgstr ""
#: documents/models.py:1244
#: documents/models.py:1265
msgid "include document in webhook"
msgstr ""
#: documents/models.py:1255
#: documents/models.py:1276
msgid "Assignment"
msgstr ""
#: documents/models.py:1259
#: documents/models.py:1280
msgid "Removal"
msgstr ""
#: documents/models.py:1263 documents/templates/account/password_reset.html:15
#: documents/models.py:1284 documents/templates/account/password_reset.html:15
msgid "Email"
msgstr ""
#: documents/models.py:1267
#: documents/models.py:1288
msgid "Webhook"
msgstr ""
#: documents/models.py:1271
#: documents/models.py:1292
msgid "Workflow Action Type"
msgstr ""
#: documents/models.py:1276 documents/models.py:1509
#: documents/models.py:1297 documents/models.py:1530
#: paperless_mail/models.py:145
msgid "order"
msgstr ""
#: documents/models.py:1279
#: documents/models.py:1300
msgid "assign title"
msgstr ""
#: documents/models.py:1283
#: documents/models.py:1304
msgid "Assign a document title, must be a Jinja2 template, see documentation."
msgstr ""
#: documents/models.py:1291 paperless_mail/models.py:274
#: documents/models.py:1312 paperless_mail/models.py:274
msgid "assign this tag"
msgstr ""
#: documents/models.py:1300 paperless_mail/models.py:282
#: documents/models.py:1321 paperless_mail/models.py:282
msgid "assign this document type"
msgstr ""
#: documents/models.py:1309 paperless_mail/models.py:296
#: documents/models.py:1330 paperless_mail/models.py:296
msgid "assign this correspondent"
msgstr ""
#: documents/models.py:1318
#: documents/models.py:1339
msgid "assign this storage path"
msgstr ""
#: documents/models.py:1327
#: documents/models.py:1348
msgid "assign this owner"
msgstr ""
#: documents/models.py:1334
#: documents/models.py:1355
msgid "grant view permissions to these users"
msgstr ""
#: documents/models.py:1341
#: documents/models.py:1362
msgid "grant view permissions to these groups"
msgstr ""
#: documents/models.py:1348
#: documents/models.py:1369
msgid "grant change permissions to these users"
msgstr ""
#: documents/models.py:1355
#: documents/models.py:1376
msgid "grant change permissions to these groups"
msgstr ""
#: documents/models.py:1362
#: documents/models.py:1383
msgid "assign these custom fields"
msgstr ""
#: documents/models.py:1366
#: documents/models.py:1387
msgid "custom field values"
msgstr ""
#: documents/models.py:1370
#: documents/models.py:1391
msgid "Optional values to assign to the custom fields."
msgstr ""
#: documents/models.py:1379
#: documents/models.py:1400
msgid "remove these tag(s)"
msgstr ""
#: documents/models.py:1384
#: documents/models.py:1405
msgid "remove all tags"
msgstr ""
#: documents/models.py:1391
#: documents/models.py:1412
msgid "remove these document type(s)"
msgstr ""
#: documents/models.py:1396
#: documents/models.py:1417
msgid "remove all document types"
msgstr ""
#: documents/models.py:1403
#: documents/models.py:1424
msgid "remove these correspondent(s)"
msgstr ""
#: documents/models.py:1408
#: documents/models.py:1429
msgid "remove all correspondents"
msgstr ""
#: documents/models.py:1415
#: documents/models.py:1436
msgid "remove these storage path(s)"
msgstr ""
#: documents/models.py:1420
#: documents/models.py:1441
msgid "remove all storage paths"
msgstr ""
#: documents/models.py:1427
#: documents/models.py:1448
msgid "remove these owner(s)"
msgstr ""
#: documents/models.py:1432
#: documents/models.py:1453
msgid "remove all owners"
msgstr ""
#: documents/models.py:1439
#: documents/models.py:1460
msgid "remove view permissions for these users"
msgstr ""
#: documents/models.py:1446
#: documents/models.py:1467
msgid "remove view permissions for these groups"
msgstr ""
#: documents/models.py:1453
#: documents/models.py:1474
msgid "remove change permissions for these users"
msgstr ""
#: documents/models.py:1460
#: documents/models.py:1481
msgid "remove change permissions for these groups"
msgstr ""
#: documents/models.py:1465
#: documents/models.py:1486
msgid "remove all permissions"
msgstr ""
#: documents/models.py:1472
#: documents/models.py:1493
msgid "remove these custom fields"
msgstr ""
#: documents/models.py:1477
#: documents/models.py:1498
msgid "remove all custom fields"
msgstr ""
#: documents/models.py:1486
#: documents/models.py:1507
msgid "email"
msgstr ""
#: documents/models.py:1495
#: documents/models.py:1516
msgid "webhook"
msgstr ""
#: documents/models.py:1499
#: documents/models.py:1520
msgid "workflow action"
msgstr ""
#: documents/models.py:1500
#: documents/models.py:1521
msgid "workflow actions"
msgstr ""
#: documents/models.py:1515
#: documents/models.py:1536
msgid "triggers"
msgstr ""
#: documents/models.py:1522
#: documents/models.py:1543
msgid "actions"
msgstr ""
#: documents/models.py:1525 paperless_mail/models.py:154
#: documents/models.py:1546 paperless_mail/models.py:154
msgid "enabled"
msgstr ""
#: documents/models.py:1536
#: documents/models.py:1557
msgid "workflow"
msgstr ""
#: documents/models.py:1540
#: documents/models.py:1561
msgid "workflow trigger type"
msgstr ""
#: documents/models.py:1554
#: documents/models.py:1575
msgid "date run"
msgstr ""
#: documents/models.py:1560
#: documents/models.py:1581
msgid "workflow run"
msgstr ""
#: documents/models.py:1561
#: documents/models.py:1582
msgid "workflow runs"
msgstr ""
@@ -1735,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 ""

View File

@@ -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

View File

@@ -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"

View File

@@ -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:

View File

@@ -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)

View File

@@ -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/"),